Initial commit

This commit is contained in:
2026-03-27 19:35:14 +01:00
commit 38581b88a4
68 changed files with 12137 additions and 0 deletions

23
app/(auth)/login/page.tsx Normal file
View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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>
);
}