Initial commit
This commit is contained in:
23
app/(auth)/login/page.tsx
Normal file
23
app/(auth)/login/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import Link from "next/link";
|
||||
import { LoginForm } from "@/components/auth/login-form";
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen w-full max-w-md flex-col justify-center px-6 py-16">
|
||||
<div className="rounded-xl border border-border/80 bg-panel/50 p-6 md:p-7">
|
||||
<div className="mb-8 space-y-3">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-muted">PayMe</p>
|
||||
<h1 className="text-2xl font-semibold">Sign in</h1>
|
||||
<p className="text-sm text-muted">Use your email and password to manage your public payment profile.</p>
|
||||
</div>
|
||||
<LoginForm />
|
||||
<p className="mt-6 text-sm text-muted">
|
||||
No account yet?{" "}
|
||||
<Link href="/register" className="text-accent underline underline-offset-4 hover:text-text">
|
||||
Create one
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
23
app/(auth)/register/page.tsx
Normal file
23
app/(auth)/register/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import Link from "next/link";
|
||||
import { RegisterForm } from "@/components/auth/register-form";
|
||||
|
||||
export default function RegisterPage() {
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen w-full max-w-md flex-col justify-center px-6 py-16">
|
||||
<div className="rounded-xl border border-border/80 bg-panel/50 p-6 md:p-7">
|
||||
<div className="mb-8 space-y-3">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-muted">PayMe</p>
|
||||
<h1 className="text-2xl font-semibold">Create account</h1>
|
||||
<p className="text-sm text-muted">Start publishing your payment details in a self-hosted profile.</p>
|
||||
</div>
|
||||
<RegisterForm />
|
||||
<p className="mt-6 text-sm text-muted">
|
||||
Already registered?{" "}
|
||||
<Link href="/login" className="text-accent underline underline-offset-4 hover:text-text">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
33
app/(dashboard)/dashboard/layout.tsx
Normal file
33
app/(dashboard)/dashboard/layout.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
import { SignOutButton } from "@/components/auth/signout-button";
|
||||
import { Sidebar } from "@/components/dashboard/sidebar";
|
||||
import { requireCurrentUser } from "@/lib/session";
|
||||
|
||||
export default async function DashboardLayout({
|
||||
children
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const user = await requireCurrentUser();
|
||||
|
||||
return (
|
||||
<div className="dashboard-shell flex min-h-screen flex-col">
|
||||
<header className="mb-7 flex flex-wrap items-center justify-between gap-4 border-b border-border/80 pb-5">
|
||||
<div className="space-y-1">
|
||||
<Link href="/" className="terminal-heading">
|
||||
PayMe
|
||||
</Link>
|
||||
<p className="text-sm text-muted">{user.email}</p>
|
||||
</div>
|
||||
|
||||
<SignOutButton />
|
||||
</header>
|
||||
|
||||
<div className="flex flex-col gap-7 md:flex-row">
|
||||
<Sidebar username={user.profile?.username} />
|
||||
<main className="min-w-0 flex-1">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
app/(dashboard)/dashboard/page.tsx
Normal file
43
app/(dashboard)/dashboard/page.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { db } from "@/lib/db";
|
||||
import { requireCurrentUser } from "@/lib/session";
|
||||
|
||||
export default async function DashboardOverviewPage() {
|
||||
const user = await requireCurrentUser();
|
||||
|
||||
const profile = await db.profile.findUnique({
|
||||
where: { userId: user.id },
|
||||
include: {
|
||||
paymentMethods: true,
|
||||
socialLinks: true
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="space-y-8">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-semibold">Overview</h1>
|
||||
<p className="text-sm text-muted">
|
||||
Manage your public payment profile. PayMe only displays payment details and links.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<article className="terminal-card py-5">
|
||||
<p className="terminal-heading">Payment Methods</p>
|
||||
<p className="mt-4 text-3xl font-semibold">{profile?.paymentMethods.length ?? 0}</p>
|
||||
</article>
|
||||
|
||||
<article className="terminal-card py-5">
|
||||
<p className="terminal-heading">Social Links</p>
|
||||
<p className="mt-4 text-3xl font-semibold">{profile?.socialLinks.length ?? 0}</p>
|
||||
</article>
|
||||
|
||||
<article className="terminal-card py-5">
|
||||
<p className="terminal-heading">Public Visibility</p>
|
||||
<p className="mt-4 text-3xl font-semibold">{profile?.isPublic ? "On" : "Off"}</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
);
|
||||
}
|
||||
45
app/(dashboard)/dashboard/payment-methods/page.tsx
Normal file
45
app/(dashboard)/dashboard/payment-methods/page.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { PaymentMethodsForm } from "@/components/dashboard/payment-methods-form";
|
||||
import { PaymentMethodsList } from "@/components/dashboard/payment-methods-list";
|
||||
import { db } from "@/lib/db";
|
||||
import { requireCurrentUser } from "@/lib/session";
|
||||
|
||||
export default async function DashboardPaymentMethodsPage() {
|
||||
const user = await requireCurrentUser();
|
||||
|
||||
const profile = await db.profile.findUnique({
|
||||
where: { userId: user.id },
|
||||
include: {
|
||||
paymentMethods: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
label: true,
|
||||
value: true,
|
||||
network: true,
|
||||
description: true,
|
||||
sortOrder: true,
|
||||
isVisible: true
|
||||
},
|
||||
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!profile) {
|
||||
return <p className="text-sm text-red-300">Profile not found.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<header className="space-y-2">
|
||||
<h1 className="text-2xl font-semibold">Payment Methods</h1>
|
||||
<p className="text-sm text-muted">
|
||||
Add, edit, reorder, and toggle payment methods. Validation is format-based only and not authoritative.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<PaymentMethodsForm />
|
||||
<PaymentMethodsList methods={profile.paymentMethods} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
37
app/(dashboard)/dashboard/profile/page.tsx
Normal file
37
app/(dashboard)/dashboard/profile/page.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { ProfileForm } from "@/components/dashboard/profile-form";
|
||||
import { db } from "@/lib/db";
|
||||
import { DEFAULT_THEME_ID } from "@/lib/constants";
|
||||
import { requireCurrentUser } from "@/lib/session";
|
||||
|
||||
export default async function DashboardProfilePage() {
|
||||
const user = await requireCurrentUser();
|
||||
const [profile, themes] = await Promise.all([
|
||||
db.profile.findUnique({
|
||||
where: { userId: user.id }
|
||||
}),
|
||||
db.theme.findMany({ orderBy: { name: "asc" } })
|
||||
]);
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<header className="space-y-2">
|
||||
<h1 className="text-2xl font-semibold">Profile</h1>
|
||||
<p className="text-sm text-muted">
|
||||
Update how your public page appears. Username changes update your public URL.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ProfileForm
|
||||
initialValues={{
|
||||
username: profile?.username ?? "",
|
||||
displayName: profile?.displayName ?? "",
|
||||
bio: profile?.bio ?? "",
|
||||
avatarUrl: profile?.avatarUrl ?? "",
|
||||
themeId: profile?.themeId ?? DEFAULT_THEME_ID,
|
||||
isPublic: profile?.isPublic ?? true
|
||||
}}
|
||||
themes={themes}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
40
app/(dashboard)/dashboard/social-links/page.tsx
Normal file
40
app/(dashboard)/dashboard/social-links/page.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { SocialLinksForm } from "@/components/dashboard/social-links-form";
|
||||
import { db } from "@/lib/db";
|
||||
import { requireCurrentUser } from "@/lib/session";
|
||||
|
||||
export default async function DashboardSocialLinksPage() {
|
||||
const user = await requireCurrentUser();
|
||||
|
||||
const profile = await db.profile.findUnique({
|
||||
where: { userId: user.id },
|
||||
include: {
|
||||
socialLinks: {
|
||||
select: {
|
||||
id: true,
|
||||
label: true,
|
||||
url: true,
|
||||
sortOrder: true,
|
||||
isVisible: true
|
||||
},
|
||||
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!profile) {
|
||||
return <p className="text-sm text-red-300">Profile not found.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<header className="space-y-2">
|
||||
<h1 className="text-2xl font-semibold">Social Links</h1>
|
||||
<p className="text-sm text-muted">
|
||||
Optional links to contact points and social profiles. Keep this short and relevant.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<SocialLinksForm links={profile.socialLinks} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
6
app/api/auth/[...nextauth]/route.ts
Normal file
6
app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import NextAuth from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
|
||||
const handler = NextAuth(authOptions);
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
87
app/api/register/route.ts
Normal file
87
app/api/register/route.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { hash } from "bcryptjs";
|
||||
import { NextResponse } from "next/server";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { DEFAULT_THEME_ID } from "@/lib/constants";
|
||||
import { db } from "@/lib/db";
|
||||
import { normalizeUsername, sanitizePlainText } from "@/lib/sanitize";
|
||||
import { registerSchema } from "@/lib/validators/auth";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
// Rate limiting should be applied here (IP/email key) at proxy or middleware level in production.
|
||||
const body = await request.json().catch(() => null);
|
||||
if (!body) {
|
||||
return NextResponse.json({ message: "Invalid JSON payload" }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = registerSchema.safeParse({
|
||||
email: sanitizePlainText(String(body.email ?? "")).toLowerCase(),
|
||||
password: String(body.password ?? ""),
|
||||
username: normalizeUsername(String(body.username ?? "")),
|
||||
displayName: sanitizePlainText(String(body.displayName ?? ""))
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ message: parsed.error.issues[0]?.message ?? "Invalid input" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const existingEmail = await db.user.findUnique({
|
||||
where: { email: parsed.data.email },
|
||||
select: { id: true }
|
||||
});
|
||||
|
||||
if (existingEmail) {
|
||||
return NextResponse.json({ message: "Email is already registered" }, { status: 409 });
|
||||
}
|
||||
|
||||
const existingUsername = await db.profile.findUnique({
|
||||
where: { username: parsed.data.username },
|
||||
select: { id: true }
|
||||
});
|
||||
|
||||
if (existingUsername) {
|
||||
return NextResponse.json({ message: "Username is already taken" }, { status: 409 });
|
||||
}
|
||||
|
||||
const hashedPassword = await hash(parsed.data.password, 12);
|
||||
|
||||
await db.$transaction(async (tx) => {
|
||||
const theme = await tx.theme.findUnique({ where: { id: DEFAULT_THEME_ID } });
|
||||
if (!theme) {
|
||||
throw new Error(`Missing default theme: ${DEFAULT_THEME_ID}`);
|
||||
}
|
||||
|
||||
const user = await tx.user.create({
|
||||
data: {
|
||||
email: parsed.data.email,
|
||||
hashedPassword
|
||||
}
|
||||
});
|
||||
|
||||
await tx.profile.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
username: parsed.data.username,
|
||||
displayName: parsed.data.displayName,
|
||||
themeId: DEFAULT_THEME_ID,
|
||||
isPublic: true
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientInitializationError) {
|
||||
return NextResponse.json(
|
||||
{ message: "Database is not reachable. Start PostgreSQL and run migrations first." },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
|
||||
return NextResponse.json({ message: "Account already exists" }, { status: 409 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: "Could not create account" }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: "Account created" }, { status: 201 });
|
||||
}
|
||||
262
app/globals.css
Normal file
262
app/globals.css
Normal file
@@ -0,0 +1,262 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--color-bg: 11 12 14;
|
||||
--color-panel: 18 20 23;
|
||||
--color-text: 236 231 220;
|
||||
--color-muted: 159 151 137;
|
||||
--color-border: 56 60 65;
|
||||
--color-accent: 122 154 92;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
border-color: rgb(var(--color-border));
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background: rgb(var(--color-bg));
|
||||
color: rgb(var(--color-text));
|
||||
font-family: var(--font-mono), ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
line-height: 1.62;
|
||||
letter-spacing: 0.01em;
|
||||
text-rendering: geometricPrecision;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgb(var(--color-accent) / 0.35);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid rgb(var(--color-accent) / 0.8);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.terminal-shell {
|
||||
width: 100%;
|
||||
max-width: 56rem;
|
||||
margin: 0 auto;
|
||||
padding: 3rem 1.75rem;
|
||||
}
|
||||
|
||||
.dashboard-shell {
|
||||
width: 100%;
|
||||
max-width: 70rem;
|
||||
margin: 0 auto;
|
||||
padding: 2.5rem 1.75rem;
|
||||
}
|
||||
|
||||
.profile-shell {
|
||||
width: 100%;
|
||||
max-width: 56rem;
|
||||
margin: 0 auto;
|
||||
padding: 2.5rem 1.75rem 3rem;
|
||||
}
|
||||
|
||||
.terminal-section {
|
||||
border-top: 1px solid rgb(var(--color-border) / 0.8);
|
||||
padding-top: 2.5rem;
|
||||
}
|
||||
|
||||
.terminal-heading {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: rgb(var(--color-muted));
|
||||
}
|
||||
|
||||
.terminal-card {
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgb(var(--color-border) / 0.8);
|
||||
background: rgb(var(--color-panel) / 0.55);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.terminal-subtle {
|
||||
font-size: 0.875rem;
|
||||
color: rgb(var(--color-muted));
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 13px;
|
||||
color: rgb(var(--color-muted));
|
||||
}
|
||||
|
||||
.ui-control {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
border: 1px solid rgb(var(--color-border));
|
||||
border-radius: 6px;
|
||||
background: rgb(var(--color-panel) / 0.72);
|
||||
color: rgb(var(--color-text));
|
||||
padding: 0.65rem 0.8rem;
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.ui-control::placeholder {
|
||||
color: rgb(var(--color-muted));
|
||||
}
|
||||
|
||||
textarea.ui-control {
|
||||
min-height: 110px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
select.ui-control {
|
||||
appearance: none;
|
||||
background-image:
|
||||
linear-gradient(45deg, transparent 50%, rgb(var(--color-muted)) 50%),
|
||||
linear-gradient(135deg, rgb(var(--color-muted)) 50%, transparent 50%);
|
||||
background-position:
|
||||
calc(100% - 18px) calc(50% + 1px),
|
||||
calc(100% - 12px) calc(50% + 1px);
|
||||
background-size: 6px 6px, 6px 6px;
|
||||
background-repeat: no-repeat;
|
||||
padding-right: 2.2rem;
|
||||
}
|
||||
|
||||
.ui-control:focus-visible {
|
||||
outline: 2px solid rgb(var(--color-accent) / 0.75);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.terminal-shell {
|
||||
padding: 4rem 2.5rem;
|
||||
}
|
||||
|
||||
.dashboard-shell {
|
||||
padding: 3.25rem 2.5rem;
|
||||
}
|
||||
|
||||
.profile-shell {
|
||||
padding: 3rem 2.25rem 3.5rem;
|
||||
}
|
||||
|
||||
.terminal-section {
|
||||
padding-top: 3rem;
|
||||
}
|
||||
|
||||
.terminal-card {
|
||||
padding: 2.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.method-card {
|
||||
border: 1px solid rgb(var(--color-border) / 0.78);
|
||||
border-radius: 6px;
|
||||
background: rgb(var(--color-panel) / 0.5);
|
||||
padding: 28px 30px;
|
||||
}
|
||||
|
||||
.method-card-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.method-card-content {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.method-card-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
line-height: 1.18;
|
||||
}
|
||||
|
||||
.method-card-meta {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: rgb(var(--color-muted));
|
||||
}
|
||||
|
||||
.method-card-value {
|
||||
margin-top: 16px;
|
||||
font-size: 17px;
|
||||
line-height: 1.55;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.method-card-description {
|
||||
margin-top: 14px;
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
color: rgb(var(--color-muted));
|
||||
}
|
||||
|
||||
.method-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.method-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 36px;
|
||||
padding: 6px 11px;
|
||||
border: 1px solid rgb(var(--color-border) / 0.92);
|
||||
border-radius: 6px;
|
||||
background: rgb(var(--color-panel) / 0.82);
|
||||
color: rgb(var(--color-text));
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.18s ease, border-color 0.18s ease;
|
||||
}
|
||||
|
||||
.method-action:hover {
|
||||
border-color: rgb(var(--color-accent) / 0.55);
|
||||
background: rgb(var(--color-panel) / 1);
|
||||
}
|
||||
|
||||
.dashboard-action {
|
||||
min-height: 38px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
letter-spacing: 0.04em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dashboard-action-small {
|
||||
min-height: 34px;
|
||||
padding: 6px 9px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.04em;
|
||||
font-weight: 600;
|
||||
}
|
||||
27
app/layout.tsx
Normal file
27
app/layout.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
import { IBM_Plex_Mono } from "next/font/google";
|
||||
import "@/app/globals.css";
|
||||
|
||||
const mono = IBM_Plex_Mono({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-mono",
|
||||
weight: ["400", "500", "600", "700"]
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "PayMe",
|
||||
description: "Self-hosted payment profile platform"
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children
|
||||
}: Readonly<{
|
||||
children: ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={`${mono.variable} bg-bg text-text antialiased`}>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
16
app/not-found.tsx
Normal file
16
app/not-found.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function NotFoundPage() {
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen w-full max-w-lg flex-col items-center justify-center px-6 py-16 text-center">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-muted">404</p>
|
||||
<h1 className="mt-3 text-2xl font-semibold">Profile not found</h1>
|
||||
<p className="mt-3 text-sm text-muted">
|
||||
This profile is unavailable or currently private.
|
||||
</p>
|
||||
<Link href="/" className="mt-6 rounded-md border border-border px-3 py-2 text-sm text-muted hover:text-text">
|
||||
Back to home
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
135
app/page.tsx
Normal file
135
app/page.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import Link from "next/link";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { buttonStyles } from "@/components/ui/button";
|
||||
|
||||
const previewRows = [
|
||||
{
|
||||
title: "PayPal",
|
||||
meta: "paypal.me/alexdev",
|
||||
value: "alexdev",
|
||||
actions: "copy · open"
|
||||
},
|
||||
{
|
||||
title: "USDT",
|
||||
meta: "TRC20",
|
||||
value: "TQ6...k2S",
|
||||
actions: "copy · qr"
|
||||
},
|
||||
{
|
||||
title: "Bank Transfer",
|
||||
meta: "IBAN",
|
||||
value: "DE89 3704 0044 0532 0130 00",
|
||||
actions: "copy"
|
||||
}
|
||||
];
|
||||
|
||||
const howItWorks = [
|
||||
"Create an account and choose your public username.",
|
||||
"Add payment methods and optional social/contact links.",
|
||||
"Share your profile URL so people can copy details or scan QR."
|
||||
];
|
||||
|
||||
const whyPayMe = [
|
||||
"No custody: PayMe does not hold funds.",
|
||||
"No processing: PayMe does not execute transactions.",
|
||||
"Self-hosted: run it on your own infrastructure.",
|
||||
"Open source: inspect and modify everything.",
|
||||
"Privacy-friendly: minimal profile data, no tracking layer built in."
|
||||
];
|
||||
|
||||
export default async function HomePage() {
|
||||
const session = await auth();
|
||||
|
||||
const primaryHref = session?.user ? "/dashboard" : "/register";
|
||||
const primaryLabel = session?.user ? "Open dashboard" : "Create your profile";
|
||||
|
||||
return (
|
||||
<main className="terminal-shell md:px-8 md:py-16">
|
||||
<section className="space-y-7">
|
||||
<div className="space-y-4">
|
||||
<p className="terminal-heading">PayMe</p>
|
||||
<h1 className="max-w-3xl text-3xl font-semibold leading-tight md:text-5xl">
|
||||
One public page for every way people can pay you.
|
||||
</h1>
|
||||
<p className="max-w-3xl text-lg leading-relaxed text-muted">
|
||||
Self-hosted payment profile pages for creators, freelancers, and OSS maintainers. No custody. No
|
||||
transaction processing. Just clear payment details and links.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 md:gap-4">
|
||||
<Link
|
||||
href={primaryHref}
|
||||
className={buttonStyles({
|
||||
variant: "primary",
|
||||
className: "relative -top-1 min-h-[3.1rem] min-w-[13rem] justify-center px-6"
|
||||
})}
|
||||
>
|
||||
{primaryLabel}
|
||||
</Link>
|
||||
<a
|
||||
href="#example"
|
||||
className={buttonStyles({
|
||||
variant: "secondary",
|
||||
className: "relative -top-1 min-h-[3.1rem] min-w-[10.75rem] justify-center px-5"
|
||||
})}
|
||||
style={{ marginLeft: "14px" }}
|
||||
>
|
||||
View example
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="example" className="mt-8 space-y-5 md:mt-10">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-2xl font-bold">Profile preview</h2>
|
||||
<p className="text-sm text-muted">How a public PayMe profile is presented.</p>
|
||||
</div>
|
||||
|
||||
<div className="terminal-card space-y-5 md:p-6">
|
||||
<div className="space-y-1 border-b border-border/70 pb-4">
|
||||
<p className="terminal-heading">Public profile</p>
|
||||
<p className="pt-1 text-2xl font-bold">Alex Rivera</p>
|
||||
<p className="text-sm text-muted">@alexdev</p>
|
||||
<p className="pt-2 text-base text-muted">Open source maintainer. Donations keep maintenance sustainable.</p>
|
||||
</div>
|
||||
|
||||
<ul className="list-none space-y-3 p-0">
|
||||
{previewRows.map((row) => (
|
||||
<li key={row.title} className="method-card">
|
||||
<div className="method-card-grid">
|
||||
<div className="method-card-content">
|
||||
<p className="method-card-title">{row.title}</p>
|
||||
<p className="method-card-meta">{row.meta}</p>
|
||||
<p className="method-card-value">{row.value}</p>
|
||||
</div>
|
||||
<p className="text-xs uppercase tracking-[0.12em] text-muted">{row.actions}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="terminal-section grid gap-5 md:grid-cols-2 md:gap-6 md:pt-10">
|
||||
<div className="terminal-card space-y-4 md:p-6">
|
||||
<h2 className="text-2xl font-bold">How it works</h2>
|
||||
<ol className="list-decimal space-y-3 pl-5 text-sm leading-relaxed text-muted">
|
||||
{howItWorks.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="terminal-card space-y-4 md:p-6">
|
||||
<h2 className="text-2xl font-bold">Why PayMe</h2>
|
||||
<ul className="list-none space-y-3 p-0 text-sm leading-relaxed text-muted">
|
||||
{whyPayMe.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
104
app/u/[username]/page.tsx
Normal file
104
app/u/[username]/page.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import type { CSSProperties } from "react";
|
||||
import { db } from "@/lib/db";
|
||||
import { THEME_TOKENS } from "@/lib/constants";
|
||||
import { ProfileHeader } from "@/components/public/profile-header";
|
||||
import { PaymentMethodCard } from "@/components/public/payment-method-card";
|
||||
import { SocialLinksList } from "@/components/public/social-links-list";
|
||||
|
||||
export const revalidate = 60;
|
||||
|
||||
type PublicProfilePageProps = {
|
||||
params: Promise<{
|
||||
username: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export default async function PublicProfilePage({ params }: PublicProfilePageProps) {
|
||||
const { username } = await params;
|
||||
const normalizedUsername = username?.trim().toLowerCase();
|
||||
|
||||
if (!normalizedUsername) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const profile = await db.profile.findUnique({
|
||||
where: { username: normalizedUsername },
|
||||
include: {
|
||||
paymentMethods: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
label: true,
|
||||
value: true,
|
||||
network: true,
|
||||
description: true,
|
||||
isVisible: true
|
||||
},
|
||||
where: { isVisible: true },
|
||||
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }]
|
||||
},
|
||||
socialLinks: {
|
||||
select: {
|
||||
id: true,
|
||||
label: true,
|
||||
url: true
|
||||
},
|
||||
where: { isVisible: true },
|
||||
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!profile || !profile.isPublic) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const tokens = THEME_TOKENS[profile.themeId] ?? THEME_TOKENS["terminal-dark"];
|
||||
|
||||
return (
|
||||
<main
|
||||
className="profile-shell min-h-screen"
|
||||
style={
|
||||
{
|
||||
"--color-bg": tokens.bg,
|
||||
"--color-panel": tokens.panel,
|
||||
"--color-text": tokens.text,
|
||||
"--color-muted": tokens.muted,
|
||||
"--color-border": tokens.border,
|
||||
"--color-accent": tokens.accent
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="space-y-8">
|
||||
<ProfileHeader
|
||||
displayName={profile.displayName}
|
||||
username={profile.username}
|
||||
bio={profile.bio}
|
||||
avatarUrl={profile.avatarUrl}
|
||||
/>
|
||||
|
||||
<section className="terminal-section space-y-4">
|
||||
<h2 className="terminal-heading">Payment Methods</h2>
|
||||
{profile.paymentMethods.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed border-border p-4 text-sm text-muted">
|
||||
No payment methods published yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{profile.paymentMethods.map((method) => (
|
||||
<PaymentMethodCard key={method.id} method={method} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<SocialLinksList links={profile.socialLinks} />
|
||||
|
||||
<footer className="terminal-section text-xs text-muted">
|
||||
PayMe is a profile and presentation layer. It does not process payments or hold funds.
|
||||
</footer>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user