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

8
.env.example Normal file
View File

@@ -0,0 +1,8 @@
# App
NODE_ENV=development
PORT=3000
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=replace-with-a-long-random-secret
# Database
DATABASE_URL=postgresql://payme:payme@localhost:5432/payme?schema=public

3
.eslintrc.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": ["next/core-web-vitals", "next/typescript"]
}

11
.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
node_modules
.next
.env
.env.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
coverage
.DS_Store
prisma/dev.db

28
Dockerfile Normal file
View File

@@ -0,0 +1,28 @@
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run db:generate
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/scripts ./scripts
COPY --from=builder /app/next.config.ts ./next.config.ts
EXPOSE 3000
RUN chmod +x ./scripts/entrypoint.sh
CMD ["./scripts/entrypoint.sh"]

106
README.md Normal file
View File

@@ -0,0 +1,106 @@
# PayMe
PayMe is an open source, self-hosted payment profile platform.
A user creates a public profile page and publishes ways to receive money in one place: PayPal, crypto addresses, Revolut, bank transfer details, and optional social/contact links.
## What PayMe Is
- Self-hosted first profile/presentation layer
- Minimal dashboard to manage profile data and payment methods
- Public profile pages at `/u/:username` (and `/@username` rewrite)
- Copy-first UX with QR code support for payment details
## What PayMe Is Not
- Not a wallet
- Not a payment processor
- Does not hold funds
- Does not execute transactions
- Not a marketplace
- Not a social network
## Tech Stack
- Next.js App Router + TypeScript
- Tailwind CSS
- PostgreSQL
- Prisma ORM
- Auth.js (credentials)
- Docker + docker-compose
## Local Development
1. Copy env file:
```bash
cp .env.example .env
```
2. Install dependencies:
```bash
npm install
```
3. Start PostgreSQL (Docker):
```bash
docker compose up -d db
```
4. Run migrations and seed:
```bash
npm run db:migrate:dev
npm run db:seed
```
5. Start app:
```bash
npm run dev
```
6. Open `http://localhost:3000`
## Environment Variables
- `DATABASE_URL`: PostgreSQL connection URL
- `NEXTAUTH_SECRET`: strong random secret for session/auth signing
- `NEXTAUTH_URL`: absolute app URL (for example `http://localhost:3000`)
- `NODE_ENV`: `development` or `production`
- `PORT`: app port
## Database Setup
- Prisma schema: `prisma/schema.prisma`
- Initial migration: `prisma/migrations/20260327133000_init/migration.sql`
- Seed inserts built-in themes (`terminal-dark`, `amber-paper`)
Useful commands:
```bash
npm run db:generate
npm run db:migrate:dev
npm run db:migrate
npm run db:seed
```
## Docker Deployment
1. Copy env:
```bash
cp .env.example .env
```
2. Set `NEXTAUTH_SECRET` in `.env`.
3. Start services:
```bash
docker compose up --build -d
```
4. Open `http://localhost:3000`.
The app container runs `npm run db:migrate` before `npm run start`.
## Security Notes
- All important writes are server-side validated (Zod + method-specific checks)
- Input is normalized/sanitized to plain text presentation
- Authenticated routes are protected in middleware and server-side checks
- Public profile visibility can be disabled per profile
- Payment validation is basic format validation only (not account/wallet ownership verification)
- Add rate limiting at reverse-proxy or middleware level for production
## Product Notes
- Username is normalized to lowercase and unique
- Payment methods are normalized in a separate table (not user columns)
- Ordering is deterministic via up/down controls and persistent sort order
## Future Roadmap
- Richer social links management
- Profile verification model
- Public API
- Import/export
- Additional built-in themes
- Plugin architecture

218
actions/payment-methods.ts Normal file
View File

@@ -0,0 +1,218 @@
"use server";
import { PaymentMethodType } from "@prisma/client";
import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";
import { requireCurrentUser } from "@/lib/session";
import { sanitizeOptionalPlainText, sanitizePlainText } from "@/lib/sanitize";
import { paymentMethodSchema } from "@/lib/validators/payment-method";
export type ActionResult = {
success: boolean;
message: string;
};
function parseCheckboxValue(value: FormDataEntryValue | null) {
return value === "on" || value === "true";
}
async function getOwnedProfile(userId: string) {
return db.profile.findUnique({
where: { userId },
include: {
paymentMethods: {
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }]
}
}
});
}
async function resequencePaymentMethods(profileId: string) {
const methods = await db.paymentMethod.findMany({
where: { profileId },
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }]
});
await db.$transaction(
methods.map((method, index) =>
db.paymentMethod.update({
where: { id: method.id },
data: { sortOrder: index }
})
)
);
}
function parsePaymentInput(formData: FormData) {
return paymentMethodSchema.safeParse({
type: sanitizePlainText(String(formData.get("type") ?? "")) as PaymentMethodType,
label: sanitizePlainText(String(formData.get("label") ?? "")),
value: sanitizePlainText(String(formData.get("value") ?? "")),
network: sanitizeOptionalPlainText(String(formData.get("network") ?? "")) ?? "",
description: sanitizeOptionalPlainText(String(formData.get("description") ?? "")) ?? "",
isVisible: parseCheckboxValue(formData.get("isVisible"))
});
}
export async function createPaymentMethodAction(formData: FormData): Promise<ActionResult> {
const user = await requireCurrentUser();
const profile = await getOwnedProfile(user.id);
if (!profile) {
return { success: false, message: "Profile not found" };
}
const parsed = parsePaymentInput(formData);
if (!parsed.success) {
return { success: false, message: parsed.error.issues[0]?.message ?? "Invalid payment method" };
}
const maxSortOrder = profile.paymentMethods.length;
await db.paymentMethod.create({
data: {
profileId: profile.id,
type: parsed.data.type,
label: parsed.data.label,
value: parsed.data.value,
network: parsed.data.network || null,
description: parsed.data.description || null,
isVisible: parsed.data.isVisible,
sortOrder: maxSortOrder
}
});
revalidatePath("/dashboard/payment-methods");
revalidatePath(`/u/${profile.username}`);
return { success: true, message: "Payment method added" };
}
export async function updatePaymentMethodAction(formData: FormData): Promise<ActionResult> {
const user = await requireCurrentUser();
const profile = await getOwnedProfile(user.id);
if (!profile) {
return { success: false, message: "Profile not found" };
}
const paymentMethodId = sanitizePlainText(String(formData.get("id") ?? ""));
const owned = profile.paymentMethods.find((method) => method.id === paymentMethodId);
if (!owned) {
return { success: false, message: "Payment method not found" };
}
const parsed = parsePaymentInput(formData);
if (!parsed.success) {
return { success: false, message: parsed.error.issues[0]?.message ?? "Invalid payment method" };
}
await db.paymentMethod.update({
where: { id: paymentMethodId },
data: {
type: parsed.data.type,
label: parsed.data.label,
value: parsed.data.value,
network: parsed.data.network || null,
description: parsed.data.description || null,
isVisible: parsed.data.isVisible
}
});
revalidatePath("/dashboard/payment-methods");
revalidatePath(`/u/${profile.username}`);
return { success: true, message: "Payment method updated" };
}
export async function deletePaymentMethodAction(formData: FormData): Promise<ActionResult> {
const user = await requireCurrentUser();
const profile = await getOwnedProfile(user.id);
if (!profile) {
return { success: false, message: "Profile not found" };
}
const paymentMethodId = sanitizePlainText(String(formData.get("id") ?? ""));
const owned = profile.paymentMethods.find((method) => method.id === paymentMethodId);
if (!owned) {
return { success: false, message: "Payment method not found" };
}
await db.paymentMethod.delete({ where: { id: paymentMethodId } });
await resequencePaymentMethods(profile.id);
revalidatePath("/dashboard/payment-methods");
revalidatePath(`/u/${profile.username}`);
return { success: true, message: "Payment method deleted" };
}
export async function togglePaymentMethodVisibilityAction(formData: FormData): Promise<ActionResult> {
const user = await requireCurrentUser();
const profile = await getOwnedProfile(user.id);
if (!profile) {
return { success: false, message: "Profile not found" };
}
const paymentMethodId = sanitizePlainText(String(formData.get("id") ?? ""));
const owned = profile.paymentMethods.find((method) => method.id === paymentMethodId);
if (!owned) {
return { success: false, message: "Payment method not found" };
}
await db.paymentMethod.update({
where: { id: paymentMethodId },
data: { isVisible: !owned.isVisible }
});
revalidatePath("/dashboard/payment-methods");
revalidatePath(`/u/${profile.username}`);
return {
success: true,
message: owned.isVisible ? "Payment method hidden" : "Payment method visible"
};
}
export async function movePaymentMethodAction(formData: FormData): Promise<ActionResult> {
const user = await requireCurrentUser();
const profile = await getOwnedProfile(user.id);
if (!profile) {
return { success: false, message: "Profile not found" };
}
const paymentMethodId = sanitizePlainText(String(formData.get("id") ?? ""));
const direction = sanitizePlainText(String(formData.get("direction") ?? ""));
const index = profile.paymentMethods.findIndex((method) => method.id === paymentMethodId);
if (index === -1) {
return { success: false, message: "Payment method not found" };
}
if (direction !== "up" && direction !== "down") {
return { success: false, message: "Invalid sort direction" };
}
const swapIndex = direction === "up" ? index - 1 : index + 1;
if (swapIndex < 0 || swapIndex >= profile.paymentMethods.length) {
return { success: false, message: "Cannot move further" };
}
const current = profile.paymentMethods[index];
const swapWith = profile.paymentMethods[swapIndex];
await db.$transaction([
db.paymentMethod.update({ where: { id: current.id }, data: { sortOrder: swapWith.sortOrder } }),
db.paymentMethod.update({ where: { id: swapWith.id }, data: { sortOrder: current.sortOrder } })
]);
revalidatePath("/dashboard/payment-methods");
revalidatePath(`/u/${profile.username}`);
return { success: true, message: "Order updated" };
}

82
actions/profile.ts Normal file
View File

@@ -0,0 +1,82 @@
"use server";
import { Prisma } from "@prisma/client";
import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";
import { DEFAULT_THEME_ID } from "@/lib/constants";
import { requireCurrentUser } from "@/lib/session";
import { normalizeUsername, safeUrl, sanitizeOptionalPlainText, sanitizePlainText } from "@/lib/sanitize";
import { profileSchema } from "@/lib/validators/profile";
export type ActionResult = {
success: boolean;
message: string;
};
function parseCheckboxValue(value: FormDataEntryValue | null) {
return value === "on" || value === "true";
}
export async function updateProfileAction(formData: FormData): Promise<ActionResult> {
const user = await requireCurrentUser();
const currentUsername = user.profile?.username;
const parsed = profileSchema.safeParse({
username: normalizeUsername(String(formData.get("username") ?? "")),
displayName: sanitizePlainText(String(formData.get("displayName") ?? "")),
bio: sanitizeOptionalPlainText(String(formData.get("bio") ?? "")) ?? "",
avatarUrl: safeUrl(String(formData.get("avatarUrl") ?? "")) ?? "",
themeId: sanitizePlainText(String(formData.get("themeId") ?? DEFAULT_THEME_ID)),
isPublic: parseCheckboxValue(formData.get("isPublic"))
});
if (!parsed.success) {
return {
success: false,
message: parsed.error.issues[0]?.message ?? "Invalid profile input"
};
}
const theme = await db.theme.findUnique({ where: { id: parsed.data.themeId } });
if (!theme) {
return { success: false, message: "Invalid theme selection" };
}
try {
await db.profile.upsert({
where: { userId: user.id },
update: {
username: parsed.data.username,
displayName: parsed.data.displayName,
bio: parsed.data.bio || null,
avatarUrl: parsed.data.avatarUrl || null,
themeId: parsed.data.themeId,
isPublic: parsed.data.isPublic
},
create: {
userId: user.id,
username: parsed.data.username,
displayName: parsed.data.displayName,
bio: parsed.data.bio || null,
avatarUrl: parsed.data.avatarUrl || null,
themeId: parsed.data.themeId,
isPublic: parsed.data.isPublic
}
});
if (currentUsername) {
revalidatePath(`/u/${currentUsername}`);
}
revalidatePath(`/u/${parsed.data.username}`);
revalidatePath("/dashboard");
revalidatePath("/dashboard/profile");
return { success: true, message: "Profile saved" };
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
return { success: false, message: "Username is already taken" };
}
return { success: false, message: "Could not update profile" };
}
}

164
actions/social-links.ts Normal file
View File

@@ -0,0 +1,164 @@
"use server";
import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";
import { requireCurrentUser } from "@/lib/session";
import { sanitizePlainText } from "@/lib/sanitize";
import { socialLinkSchema } from "@/lib/validators/social-link";
export type ActionResult = {
success: boolean;
message: string;
};
function parseCheckboxValue(value: FormDataEntryValue | null) {
return value === "on" || value === "true";
}
async function getOwnedProfile(userId: string) {
return db.profile.findUnique({
where: { userId },
include: {
socialLinks: {
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }]
}
}
});
}
export async function createSocialLinkAction(formData: FormData): Promise<ActionResult> {
const user = await requireCurrentUser();
const profile = await getOwnedProfile(user.id);
if (!profile) {
return { success: false, message: "Profile not found" };
}
const parsed = socialLinkSchema.safeParse({
label: sanitizePlainText(String(formData.get("label") ?? "")),
url: sanitizePlainText(String(formData.get("url") ?? "")),
isVisible: parseCheckboxValue(formData.get("isVisible"))
});
if (!parsed.success) {
return { success: false, message: parsed.error.issues[0]?.message ?? "Invalid social link" };
}
await db.socialLink.create({
data: {
profileId: profile.id,
label: parsed.data.label,
url: parsed.data.url,
isVisible: parsed.data.isVisible,
sortOrder: profile.socialLinks.length
}
});
revalidatePath("/dashboard/social-links");
revalidatePath(`/u/${profile.username}`);
return { success: true, message: "Social link added" };
}
export async function deleteSocialLinkAction(formData: FormData): Promise<ActionResult> {
const user = await requireCurrentUser();
const profile = await getOwnedProfile(user.id);
if (!profile) {
return { success: false, message: "Profile not found" };
}
const socialLinkId = sanitizePlainText(String(formData.get("id") ?? ""));
const owned = profile.socialLinks.find((link) => link.id === socialLinkId);
if (!owned) {
return { success: false, message: "Social link not found" };
}
await db.socialLink.delete({ where: { id: socialLinkId } });
const remaining = await db.socialLink.findMany({
where: { profileId: profile.id },
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }]
});
await db.$transaction(
remaining.map((link, index) =>
db.socialLink.update({
where: { id: link.id },
data: { sortOrder: index }
})
)
);
revalidatePath("/dashboard/social-links");
revalidatePath(`/u/${profile.username}`);
return { success: true, message: "Social link deleted" };
}
export async function toggleSocialLinkAction(formData: FormData): Promise<ActionResult> {
const user = await requireCurrentUser();
const profile = await getOwnedProfile(user.id);
if (!profile) {
return { success: false, message: "Profile not found" };
}
const socialLinkId = sanitizePlainText(String(formData.get("id") ?? ""));
const owned = profile.socialLinks.find((link) => link.id === socialLinkId);
if (!owned) {
return { success: false, message: "Social link not found" };
}
await db.socialLink.update({
where: { id: socialLinkId },
data: { isVisible: !owned.isVisible }
});
revalidatePath("/dashboard/social-links");
revalidatePath(`/u/${profile.username}`);
return {
success: true,
message: owned.isVisible ? "Social link hidden" : "Social link visible"
};
}
export async function moveSocialLinkAction(formData: FormData): Promise<ActionResult> {
const user = await requireCurrentUser();
const profile = await getOwnedProfile(user.id);
if (!profile) {
return { success: false, message: "Profile not found" };
}
const socialLinkId = sanitizePlainText(String(formData.get("id") ?? ""));
const direction = sanitizePlainText(String(formData.get("direction") ?? ""));
const index = profile.socialLinks.findIndex((link) => link.id === socialLinkId);
if (index === -1) {
return { success: false, message: "Social link not found" };
}
if (direction !== "up" && direction !== "down") {
return { success: false, message: "Invalid sort direction" };
}
const swapIndex = direction === "up" ? index - 1 : index + 1;
if (swapIndex < 0 || swapIndex >= profile.socialLinks.length) {
return { success: false, message: "Cannot move further" };
}
const current = profile.socialLinks[index];
const swapWith = profile.socialLinks[swapIndex];
await db.$transaction([
db.socialLink.update({ where: { id: current.id }, data: { sortOrder: swapWith.sortOrder } }),
db.socialLink.update({ where: { id: swapWith.id }, data: { sortOrder: current.sortOrder } })
]);
revalidatePath("/dashboard/social-links");
revalidatePath(`/u/${profile.username}`);
return { success: true, message: "Order updated" };
}

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

View File

@@ -0,0 +1,71 @@
"use client";
import { FormEvent, useState } from "react";
import { signIn } from "next-auth/react";
import type { Route } from "next";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
export function LoginForm() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
setPending(true);
const result = await signIn("credentials", {
email,
password,
redirect: false
});
setPending(false);
if (!result || result.error) {
setError("Invalid email or password");
return;
}
router.push("/dashboard" as Route);
router.refresh();
}
return (
<form className="space-y-4" onSubmit={onSubmit}>
<label className="block space-y-2 text-sm">
<span className="text-muted">Email</span>
<Input
required
type="email"
autoComplete="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
/>
</label>
<label className="block space-y-2 text-sm">
<span className="text-muted">Password</span>
<Input
required
type="password"
autoComplete="current-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
</label>
{error ? <p className="text-sm text-red-300">{error}</p> : null}
<Button type="submit" variant="primary" className="w-full" disabled={pending}>
{pending ? "Signing in..." : "Sign in"}
</Button>
</form>
);
}

View File

@@ -0,0 +1,105 @@
"use client";
import { FormEvent, useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
export function RegisterForm() {
const router = useRouter();
const [email, setEmail] = useState("");
const [username, setUsername] = useState("");
const [displayName, setDisplayName] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
setPending(true);
const response = await fetch("/api/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email,
username,
displayName,
password
})
});
const payload = (await response.json().catch(() => null)) as { message?: string } | null;
setPending(false);
if (!response.ok) {
setError(payload?.message ?? "Could not create account");
return;
}
router.push("/login?registered=1");
}
return (
<form className="space-y-4" onSubmit={onSubmit}>
<label className="block space-y-2 text-sm">
<span className="text-muted">Email</span>
<Input
required
type="email"
autoComplete="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
/>
</label>
<label className="block space-y-2 text-sm">
<span className="text-muted">Username</span>
<Input
required
minLength={3}
maxLength={32}
pattern="[a-z0-9_]{3,32}"
placeholder="yourname"
value={username}
onChange={(event) => setUsername(event.target.value.toLowerCase())}
/>
</label>
<label className="block space-y-2 text-sm">
<span className="text-muted">Display Name</span>
<Input
required
minLength={2}
maxLength={60}
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
/>
</label>
<label className="block space-y-2 text-sm">
<span className="text-muted">Password</span>
<Input
required
type="password"
autoComplete="new-password"
minLength={8}
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
</label>
<p className="text-xs text-muted">
Password must be at least 8 chars and include uppercase, lowercase, and a number.
</p>
{error ? <p className="text-sm text-red-300">{error}</p> : null}
<Button type="submit" variant="primary" className="w-full" disabled={pending}>
{pending ? "Creating account..." : "Create account"}
</Button>
</form>
);
}

View File

@@ -0,0 +1,17 @@
"use client";
import { signOut } from "next-auth/react";
import { Button } from "@/components/ui/button";
export function SignOutButton() {
return (
<Button
variant="secondary"
className="dashboard-action"
type="button"
onClick={() => signOut({ callbackUrl: "/login" })}
>
Sign out
</Button>
);
}

View File

@@ -0,0 +1,145 @@
"use client";
import { FormEvent, useMemo, useState, useTransition } from "react";
import { createPaymentMethodAction } from "@/actions/payment-methods";
import { PAYMENT_METHOD_LABELS, PAYMENT_METHOD_TYPES, USDT_NETWORKS } from "@/lib/constants";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
export function PaymentMethodsForm() {
const [isPending, startTransition] = useTransition();
const [message, setMessage] = useState<string | null>(null);
const [isError, setIsError] = useState(false);
const [type, setType] = useState<(typeof PAYMENT_METHOD_TYPES)[number]>("PAYPAL");
const [label, setLabel] = useState("");
const [value, setValue] = useState("");
const [network, setNetwork] = useState("");
const [description, setDescription] = useState("");
const [isVisible, setIsVisible] = useState(true);
const isUsdt = useMemo(() => type === "USDT", [type]);
function resetForm() {
setLabel("");
setValue("");
setNetwork("");
setDescription("");
setIsVisible(true);
}
function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setMessage(null);
const formData = new FormData();
formData.set("type", type);
formData.set("label", label);
formData.set("value", value);
formData.set("network", network);
formData.set("description", description);
if (isVisible) {
formData.set("isVisible", "on");
}
startTransition(async () => {
const result = await createPaymentMethodAction(formData);
setMessage(result.message);
setIsError(!result.success);
if (result.success) {
resetForm();
}
});
}
return (
<form onSubmit={onSubmit} className="terminal-card space-y-5">
<h2 className="terminal-heading">Add Payment Method</h2>
<div className="grid gap-4 md:grid-cols-2">
<label className="space-y-2 text-sm">
<span className="form-label">Type</span>
<Select
value={type}
onChange={(event) => {
const nextType = event.target.value as (typeof PAYMENT_METHOD_TYPES)[number];
setType(nextType);
if (nextType !== "USDT") {
setNetwork("");
}
}}
>
{PAYMENT_METHOD_TYPES.map((methodType) => (
<option key={methodType} value={methodType}>
{PAYMENT_METHOD_LABELS[methodType]}
</option>
))}
</Select>
</label>
<label className="space-y-2 text-sm">
<span className="form-label">Label</span>
<Input required value={label} onChange={(event) => setLabel(event.target.value)} placeholder="Primary wallet" />
</label>
</div>
<label className="space-y-2 text-sm">
<span className="form-label">Value</span>
<Input
required
value={value}
onChange={(event) => setValue(event.target.value)}
placeholder="Address, username, IBAN, or link"
/>
</label>
<label className="space-y-2 text-sm">
<span className="form-label">Network {isUsdt ? "(required for USDT)" : "(optional)"}</span>
{isUsdt ? (
<Select value={network} onChange={(event) => setNetwork(event.target.value)}>
<option value="">Select a network</option>
{USDT_NETWORKS.map((networkOption) => (
<option key={networkOption} value={networkOption}>
{networkOption}
</option>
))}
</Select>
) : (
<Input value={network} onChange={(event) => setNetwork(event.target.value)} placeholder="ERC20, TRC20, ..." />
)}
</label>
<label className="space-y-2 text-sm">
<span className="form-label">Description (optional)</span>
<Textarea
value={description}
onChange={(event) => setDescription(event.target.value)}
maxLength={180}
rows={3}
placeholder="Use this for donation only"
/>
</label>
<label className="flex items-center gap-3 text-sm text-muted">
<input
type="checkbox"
checked={isVisible}
onChange={(event) => setIsVisible(event.target.checked)}
className="h-4 w-4 rounded border-border bg-panel text-accent focus:ring-accent"
/>
Visible on public profile
</label>
{message ? <p className={isError ? "text-sm text-red-300" : "text-sm text-accent"}>{message}</p> : null}
<div className="pt-2">
<Button type="submit" variant="primary" className="dashboard-action" disabled={isPending}>
{isPending ? "Adding..." : "Add method"}
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,273 @@
"use client";
import { useMemo, useState, useTransition } from "react";
import { PaymentMethodType } from "@prisma/client";
import {
deletePaymentMethodAction,
movePaymentMethodAction,
togglePaymentMethodVisibilityAction,
updatePaymentMethodAction
} from "@/actions/payment-methods";
import { PAYMENT_METHOD_LABELS, PAYMENT_METHOD_TYPES, USDT_NETWORKS } from "@/lib/constants";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
type PaymentMethodsListProps = {
methods: MethodItem[];
};
type MethodItem = {
id: string;
type: PaymentMethodType;
label: string;
value: string;
network: string | null;
description: string | null;
sortOrder: number;
isVisible: boolean;
};
type EditorState = {
id: string;
type: PaymentMethodType;
label: string;
value: string;
network: string;
description: string;
isVisible: boolean;
};
function toEditor(method: MethodItem): EditorState {
return {
id: method.id,
type: method.type,
label: method.label,
value: method.value,
network: method.network ?? "",
description: method.description ?? "",
isVisible: method.isVisible
};
}
export function PaymentMethodsList({ methods }: PaymentMethodsListProps) {
const [isPending, startTransition] = useTransition();
const [message, setMessage] = useState<string | null>(null);
const [isError, setIsError] = useState(false);
const [editor, setEditor] = useState<EditorState | null>(null);
const indexed = useMemo(() => methods.map((method, index) => ({ method, index })), [methods]);
function runAction(executor: () => Promise<{ success: boolean; message: string }>) {
setMessage(null);
startTransition(async () => {
const result = await executor();
setMessage(result.message);
setIsError(!result.success);
if (result.success) {
setEditor(null);
}
});
}
function handleMove(id: string, direction: "up" | "down") {
runAction(async () => {
const formData = new FormData();
formData.set("id", id);
formData.set("direction", direction);
return movePaymentMethodAction(formData);
});
}
function handleToggle(id: string) {
runAction(async () => {
const formData = new FormData();
formData.set("id", id);
return togglePaymentMethodVisibilityAction(formData);
});
}
function handleDelete(id: string) {
runAction(async () => {
const formData = new FormData();
formData.set("id", id);
return deletePaymentMethodAction(formData);
});
}
function handleUpdate() {
if (!editor) {
return;
}
runAction(async () => {
const formData = new FormData();
formData.set("id", editor.id);
formData.set("type", editor.type);
formData.set("label", editor.label);
formData.set("value", editor.value);
formData.set("network", editor.network);
formData.set("description", editor.description);
if (editor.isVisible) {
formData.set("isVisible", "on");
}
return updatePaymentMethodAction(formData);
});
}
if (methods.length === 0) {
return (
<div className="rounded-lg border border-dashed border-border p-4 text-sm text-muted">
No payment methods yet. Add one above.
</div>
);
}
return (
<section className="space-y-4">
<h2 className="terminal-heading">Current Methods</h2>
{message ? <p className={isError ? "text-sm text-red-300" : "text-sm text-accent"}>{message}</p> : null}
{indexed.map(({ method, index }) => {
const editing = editor?.id === method.id;
return (
<article key={method.id} className="terminal-card space-y-4">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<p className="text-base font-semibold text-text">
{method.label} <span className="text-muted">({PAYMENT_METHOD_LABELS[method.type]})</span>
</p>
<p className="mt-2 break-all text-sm text-muted">{method.value}</p>
{method.network ? <p className="mt-1 text-xs text-muted">Network: {method.network}</p> : null}
{method.description ? <p className="mt-1 text-xs text-muted">{method.description}</p> : null}
{!method.isVisible ? <p className="mt-1 text-xs text-amber-200">Hidden on public profile</p> : null}
</div>
<div className="flex flex-wrap gap-2">
<Button className="dashboard-action-small" disabled={isPending || index === 0} onClick={() => handleMove(method.id, "up")}></Button>
<Button className="dashboard-action-small" disabled={isPending || index === methods.length - 1} onClick={() => handleMove(method.id, "down")}></Button>
<Button className="dashboard-action-small" disabled={isPending} onClick={() => handleToggle(method.id)}>
{method.isVisible ? "Hide" : "Show"}
</Button>
<Button className="dashboard-action-small" disabled={isPending} onClick={() => setEditor(editing ? null : toEditor(method))}>
{editing ? "Cancel" : "Edit"}
</Button>
<Button className="dashboard-action-small" variant="danger" disabled={isPending} onClick={() => handleDelete(method.id)}>
Delete
</Button>
</div>
</div>
{editing && editor ? (
<div className="space-y-4 border-t border-border/70 pt-4">
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-2 text-sm">
<span className="form-label">Type</span>
<Select
value={editor.type}
onChange={(event) =>
setEditor((previous) =>
previous
? {
...previous,
type: event.target.value as PaymentMethodType,
network: event.target.value === "USDT" ? previous.network : ""
}
: previous
)
}
>
{PAYMENT_METHOD_TYPES.map((methodType) => (
<option key={methodType} value={methodType}>
{PAYMENT_METHOD_LABELS[methodType]}
</option>
))}
</Select>
</label>
<label className="space-y-2 text-sm">
<span className="form-label">Label</span>
<Input
value={editor.label}
onChange={(event) =>
setEditor((previous) => (previous ? { ...previous, label: event.target.value } : previous))
}
/>
</label>
</div>
<label className="space-y-2 text-sm">
<span className="form-label">Value</span>
<Input
value={editor.value}
onChange={(event) =>
setEditor((previous) => (previous ? { ...previous, value: event.target.value } : previous))
}
/>
</label>
<label className="space-y-2 text-sm">
<span className="form-label">Network</span>
{editor.type === "USDT" ? (
<Select
value={editor.network}
onChange={(event) =>
setEditor((previous) => (previous ? { ...previous, network: event.target.value } : previous))
}
>
<option value="">Select a network</option>
{USDT_NETWORKS.map((network) => (
<option key={network} value={network}>
{network}
</option>
))}
</Select>
) : (
<Input
value={editor.network}
onChange={(event) =>
setEditor((previous) => (previous ? { ...previous, network: event.target.value } : previous))
}
/>
)}
</label>
<label className="space-y-2 text-sm">
<span className="form-label">Description</span>
<Textarea
rows={2}
value={editor.description}
onChange={(event) =>
setEditor((previous) => (previous ? { ...previous, description: event.target.value } : previous))
}
/>
</label>
<label className="flex items-center gap-3 text-sm text-muted">
<input
type="checkbox"
checked={editor.isVisible}
onChange={(event) =>
setEditor((previous) => (previous ? { ...previous, isVisible: event.target.checked } : previous))
}
className="h-4 w-4 rounded border-border bg-panel text-accent focus:ring-accent"
/>
Visible on public profile
</label>
<Button className="dashboard-action" variant="primary" disabled={isPending} onClick={handleUpdate}>
{isPending ? "Saving..." : "Save changes"}
</Button>
</div>
) : null}
</article>
);
})}
</section>
);
}

View File

@@ -0,0 +1,136 @@
"use client";
import { FormEvent, useState, useTransition } from "react";
import { updateProfileAction } from "@/actions/profile";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
type ThemeOption = {
id: string;
name: string;
description: string | null;
};
type ProfileFormProps = {
initialValues: {
username: string;
displayName: string;
bio: string;
avatarUrl: string;
themeId: string;
isPublic: boolean;
};
themes: ThemeOption[];
};
export function ProfileForm({ initialValues, themes }: ProfileFormProps) {
const [isPending, startTransition] = useTransition();
const [message, setMessage] = useState<string | null>(null);
const [isError, setIsError] = useState(false);
const [username, setUsername] = useState(initialValues.username);
const [displayName, setDisplayName] = useState(initialValues.displayName);
const [bio, setBio] = useState(initialValues.bio);
const [avatarUrl, setAvatarUrl] = useState(initialValues.avatarUrl);
const [themeId, setThemeId] = useState(initialValues.themeId);
const [isPublic, setIsPublic] = useState(initialValues.isPublic);
function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setMessage(null);
const formData = new FormData();
formData.set("username", username);
formData.set("displayName", displayName);
formData.set("bio", bio);
formData.set("avatarUrl", avatarUrl);
formData.set("themeId", themeId);
if (isPublic) {
formData.set("isPublic", "on");
}
startTransition(async () => {
const result = await updateProfileAction(formData);
setMessage(result.message);
setIsError(!result.success);
});
}
return (
<form onSubmit={onSubmit} className="terminal-card space-y-5">
<h2 className="terminal-heading">Edit Profile</h2>
<div className="grid gap-5 md:grid-cols-2">
<label className="space-y-2 text-sm">
<span className="form-label">Username</span>
<Input
required
value={username}
onChange={(event) => setUsername(event.target.value.toLowerCase())}
minLength={3}
maxLength={32}
/>
</label>
<label className="space-y-2 text-sm">
<span className="form-label">Display Name</span>
<Input
required
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
minLength={2}
maxLength={60}
/>
</label>
</div>
<label className="space-y-2 text-sm">
<span className="form-label">Bio</span>
<Textarea value={bio} onChange={(event) => setBio(event.target.value)} maxLength={280} rows={4} />
</label>
<label className="space-y-2 text-sm">
<span className="form-label">Avatar URL</span>
<Input
type="url"
placeholder="https://..."
value={avatarUrl}
onChange={(event) => setAvatarUrl(event.target.value)}
/>
</label>
<label className="space-y-2 text-sm">
<span className="form-label">Theme</span>
<Select value={themeId} onChange={(event) => setThemeId(event.target.value)}>
{themes.map((theme) => (
<option key={theme.id} value={theme.id}>
{theme.name}
</option>
))}
</Select>
</label>
<label className="flex items-center gap-3 text-sm text-muted">
<input
type="checkbox"
checked={isPublic}
onChange={(event) => setIsPublic(event.target.checked)}
className="h-4 w-4 rounded border-border bg-panel text-accent focus:ring-accent"
/>
Public profile visible
</label>
<p className="text-xs text-muted">
Validation is basic format checking and does not guarantee account or chain-level correctness.
</p>
{message ? <p className={isError ? "text-sm text-red-300" : "text-sm text-accent"}>{message}</p> : null}
<Button type="submit" variant="primary" className="dashboard-action" disabled={isPending}>
{isPending ? "Saving..." : "Save profile"}
</Button>
</form>
);
}

View File

@@ -0,0 +1,49 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
const navItems = [
{ href: "/dashboard", label: "Overview" },
{ href: "/dashboard/profile", label: "Profile" },
{ href: "/dashboard/payment-methods", label: "Payment Methods" },
{ href: "/dashboard/social-links", label: "Social Links" }
];
type SidebarProps = {
username?: string;
};
export function Sidebar({ username }: SidebarProps) {
const pathname = usePathname();
return (
<aside className="w-full border-b border-border/80 pb-5 md:w-64 md:self-start md:border-b-0 md:border-r md:pb-0 md:pr-6">
<div className="mb-6 space-y-1">
<p className="terminal-heading">PayMe Dashboard</p>
{username ? <p className="text-sm text-muted">Public profile: /u/{username}</p> : null}
</div>
<nav aria-label="Dashboard navigation" className="space-y-2">
{navItems.map((item) => {
const active = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
className={cn(
"block rounded-md px-3 py-2.5 text-sm transition-colors",
active
? "bg-panel text-text"
: "text-muted hover:bg-panel/70 hover:text-text"
)}
>
{item.label}
</Link>
);
})}
</nav>
</aside>
);
}

View File

@@ -0,0 +1,165 @@
"use client";
import { FormEvent, useState, useTransition } from "react";
import {
createSocialLinkAction,
deleteSocialLinkAction,
moveSocialLinkAction,
toggleSocialLinkAction
} from "@/actions/social-links";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
type SocialLinksFormProps = {
links: SocialLinkItem[];
};
type SocialLinkItem = {
id: string;
label: string;
url: string;
sortOrder: number;
isVisible: boolean;
};
export function SocialLinksForm({ links }: SocialLinksFormProps) {
const [isPending, startTransition] = useTransition();
const [message, setMessage] = useState<string | null>(null);
const [isError, setIsError] = useState(false);
const [label, setLabel] = useState("");
const [url, setUrl] = useState("");
const [isVisible, setIsVisible] = useState(true);
function runAction(executor: () => Promise<{ success: boolean; message: string }>) {
setMessage(null);
startTransition(async () => {
const result = await executor();
setMessage(result.message);
setIsError(!result.success);
if (result.success) {
setLabel("");
setUrl("");
setIsVisible(true);
}
});
}
function handleCreate(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
runAction(async () => {
const formData = new FormData();
formData.set("label", label);
formData.set("url", url);
if (isVisible) {
formData.set("isVisible", "on");
}
return createSocialLinkAction(formData);
});
}
function handleDelete(id: string) {
runAction(async () => {
const formData = new FormData();
formData.set("id", id);
return deleteSocialLinkAction(formData);
});
}
function handleToggle(id: string) {
runAction(async () => {
const formData = new FormData();
formData.set("id", id);
return toggleSocialLinkAction(formData);
});
}
function handleMove(id: string, direction: "up" | "down") {
runAction(async () => {
const formData = new FormData();
formData.set("id", id);
formData.set("direction", direction);
return moveSocialLinkAction(formData);
});
}
return (
<div className="space-y-5">
<form className="terminal-card space-y-5" onSubmit={handleCreate}>
<h2 className="terminal-heading">Add Social/Contact Link</h2>
<div className="grid gap-4 md:grid-cols-2">
<label className="space-y-2 text-sm">
<span className="form-label">Label</span>
<Input required value={label} onChange={(event) => setLabel(event.target.value)} placeholder="GitHub" />
</label>
<label className="space-y-2 text-sm">
<span className="form-label">URL</span>
<Input
required
type="url"
value={url}
onChange={(event) => setUrl(event.target.value)}
placeholder="https://github.com/username"
/>
</label>
</div>
<label className="flex items-center gap-3 text-sm text-muted">
<input
type="checkbox"
checked={isVisible}
onChange={(event) => setIsVisible(event.target.checked)}
className="h-4 w-4 rounded border-border bg-panel text-accent focus:ring-accent"
/>
Visible on public profile
</label>
<div className="pt-2">
<Button type="submit" variant="primary" className="dashboard-action" disabled={isPending}>
{isPending ? "Saving..." : "Add link"}
</Button>
</div>
</form>
{message ? <p className={isError ? "text-sm text-red-300" : "text-sm text-accent"}>{message}</p> : null}
{links.length === 0 ? (
<div className="rounded-lg border border-dashed border-border p-4 text-sm text-muted">No social links yet.</div>
) : (
<section className="space-y-3">
{links.map((link, index) => (
<article key={link.id} className="terminal-card">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<p className="text-sm font-semibold">{link.label}</p>
<p className="text-xs text-muted">{link.url}</p>
{!link.isVisible ? <p className="text-xs text-amber-200">Hidden on public profile</p> : null}
</div>
<div className="flex flex-wrap gap-2">
<Button className="dashboard-action-small" disabled={isPending || index === 0} onClick={() => handleMove(link.id, "up")}>
</Button>
<Button className="dashboard-action-small" disabled={isPending || index === links.length - 1} onClick={() => handleMove(link.id, "down")}>
</Button>
<Button className="dashboard-action-small" disabled={isPending} onClick={() => handleToggle(link.id)}>
{link.isVisible ? "Hide" : "Show"}
</Button>
<Button className="dashboard-action-small" variant="danger" disabled={isPending} onClick={() => handleDelete(link.id)}>
Delete
</Button>
</div>
</div>
</article>
))}
</section>
)}
</div>
);
}

View File

@@ -0,0 +1,27 @@
"use client";
import { useState } from "react";
type CopyButtonProps = {
value: string;
};
export function CopyButton({ value }: CopyButtonProps) {
const [copied, setCopied] = useState(false);
async function handleCopy() {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 1200);
} catch {
setCopied(false);
}
}
return (
<button type="button" onClick={handleCopy} aria-label="Copy value" className="method-action">
{copied ? "Copied" : "Copy"}
</button>
);
}

View File

@@ -0,0 +1,57 @@
"use client";
import { PaymentMethod, PaymentMethodType } from "@prisma/client";
import { PAYMENT_METHOD_LABELS, SUPPORTS_QR } from "@/lib/constants";
import { buildPaymentUri } from "@/lib/payment-uri";
import { CopyButton } from "@/components/public/copy-button";
import { QrModal } from "@/components/public/qr-modal";
type PublicPaymentMethod = Pick<
PaymentMethod,
"id" | "type" | "label" | "value" | "network" | "description" | "isVisible"
>;
type PaymentMethodCardProps = {
method: PublicPaymentMethod;
};
function isHttpUrl(value: string) {
try {
const url = new URL(value);
return url.protocol === "http:" || url.protocol === "https:";
} catch {
return false;
}
}
export function PaymentMethodCard({ method }: PaymentMethodCardProps) {
const typeLabel = PAYMENT_METHOD_LABELS[method.type as PaymentMethodType];
const qrPayload = buildPaymentUri(method.type, method.value, method.network);
const linkTarget = isHttpUrl(qrPayload) ? qrPayload : null;
return (
<article className="method-card">
<div className="method-card-grid">
<div className="method-card-content">
<p className="method-card-title">{method.label}</p>
<p className="method-card-meta">
{typeLabel}
{method.network ? ` · ${method.network}` : ""}
</p>
<p className="method-card-value">{method.value}</p>
{method.description ? <p className="method-card-description">{method.description}</p> : null}
</div>
<div className="method-actions">
<CopyButton value={method.value} />
{SUPPORTS_QR.has(method.type) ? <QrModal title={`${method.label} QR`} payload={qrPayload} /> : null}
{linkTarget ? (
<a href={linkTarget} target="_blank" rel="noreferrer" className="method-action">
Open
</a>
) : null}
</div>
</div>
</article>
);
}

View File

@@ -0,0 +1,40 @@
/* eslint-disable @next/next/no-img-element */
type ProfileHeaderProps = {
displayName: string;
username: string;
bio: string | null;
avatarUrl: string | null;
};
export function ProfileHeader({ displayName, username, bio, avatarUrl }: ProfileHeaderProps) {
const words = displayName.trim().split(/\s+/).filter(Boolean);
const initials =
words.length >= 2
? `${words[0]?.[0] ?? ""}${words[1]?.[0] ?? ""}`.toUpperCase()
: (words[0]?.slice(0, 2) ?? username.slice(0, 2)).toUpperCase();
return (
<header className="terminal-card space-y-4">
<div className="flex items-center gap-4">
{avatarUrl ? (
<img
src={avatarUrl}
alt={`${displayName} avatar`}
className="h-16 w-16 rounded-full border border-border object-cover"
/>
) : (
<div className="flex h-16 w-16 items-center justify-center rounded-full border border-border bg-panel text-lg font-semibold text-muted">
{initials}
</div>
)}
<div className="space-y-1.5">
<h1 className="text-3xl font-bold leading-tight">{displayName}</h1>
<p className="text-sm text-muted">@{username}</p>
</div>
</div>
{bio ? <p className="max-w-2xl text-sm leading-relaxed text-muted">{bio}</p> : null}
</header>
);
}

View File

@@ -0,0 +1,65 @@
"use client";
/* eslint-disable @next/next/no-img-element */
import { useEffect, useState } from "react";
import QRCode from "qrcode";
import { Modal } from "@/components/ui/modal";
type QrModalProps = {
title: string;
payload: string;
};
export function QrModal({ title, payload }: QrModalProps) {
const [open, setOpen] = useState(false);
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open) {
return;
}
QRCode.toDataURL(payload, { margin: 1, width: 280 })
.then((dataUrl) => {
setQrDataUrl(dataUrl);
setError(null);
})
.catch(() => {
setQrDataUrl(null);
setError("Could not generate QR code");
});
}, [open, payload]);
return (
<>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
setOpen(true);
}}
aria-label="Show QR code"
className="method-action"
>
QR
</button>
<Modal open={open} onClose={() => setOpen(false)} title={title}>
<div className="space-y-3">
{qrDataUrl ? (
<img
src={qrDataUrl}
alt="QR code"
className="mx-auto h-56 w-56 rounded-md border border-border bg-white p-2 md:h-64 md:w-64"
/>
) : (
<div className="flex h-56 items-center justify-center rounded-md border border-border bg-bg/40 text-sm text-muted md:h-64">
{error ?? "Generating QR..."}
</div>
)}
<p className="max-h-28 overflow-y-auto break-all text-xs text-muted">{payload}</p>
</div>
</Modal>
</>
);
}

View File

@@ -0,0 +1,34 @@
import { buttonStyles } from "@/components/ui/button";
import { SocialLink } from "@prisma/client";
type PublicSocialLink = Pick<SocialLink, "id" | "label" | "url">;
type SocialLinksListProps = {
links: PublicSocialLink[];
};
export function SocialLinksList({ links }: SocialLinksListProps) {
if (links.length === 0) {
return null;
}
return (
<section className="terminal-section space-y-3">
<h2 className="terminal-heading">Social / Contact</h2>
<ul className="space-y-3">
{links.map((link) => (
<li key={link.id}>
<a
href={link.url}
target="_blank"
rel="noreferrer"
className={buttonStyles({ variant: "secondary", className: "bg-panel/45" })}
>
{link.label}
</a>
</li>
))}
</ul>
</section>
);
}

46
components/ui/button.tsx Normal file
View File

@@ -0,0 +1,46 @@
import { ButtonHTMLAttributes, forwardRef } from "react";
import { cn } from "@/lib/utils";
type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: ButtonVariant;
};
const variantClasses: Record<ButtonVariant, string> = {
primary: "border-[#86ad60] bg-[#5f7f42] text-[#f7fbef] hover:border-[#98c16f] hover:bg-[#6a8d4a]",
secondary: "border-[#7a7f87] bg-[#14171b] text-[#f0e9da] hover:border-[#97a86f] hover:bg-[#1a1f24]",
danger: "border-[#b14a4a] bg-[#5a1f1f] text-[#ffeaea] hover:bg-[#6b2525]",
ghost: "border-[#5f646d] bg-transparent text-[#ddd5c4] hover:border-[#95a86e] hover:bg-[#171b20] hover:text-[#f4ecdc]"
};
export function buttonStyles({
variant = "secondary",
className
}: {
variant?: ButtonVariant;
className?: string;
}) {
return cn(
"inline-flex min-h-10 appearance-none items-center justify-center gap-2 rounded-md border px-4 py-2.5 text-sm font-semibold no-underline transition-colors cursor-pointer",
"tracking-[0.02em]",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#a6c279] focus-visible:ring-offset-2 focus-visible:ring-offset-bg",
"disabled:cursor-not-allowed disabled:opacity-50",
variantClasses[variant],
className
);
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{ className, variant = "secondary", type = "button", ...props },
ref
) {
return (
<button
ref={ref}
type={type}
className={buttonStyles({ variant, className })}
{...props}
/>
);
});

19
components/ui/input.tsx Normal file
View File

@@ -0,0 +1,19 @@
import { forwardRef, InputHTMLAttributes } from "react";
import { cn } from "@/lib/utils";
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(function Input(
{ className, ...props },
ref
) {
return (
<input
ref={ref}
className={cn(
"ui-control",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/70 focus-visible:ring-offset-2 focus-visible:ring-offset-bg",
className
)}
{...props}
/>
);
});

62
components/ui/modal.tsx Normal file
View File

@@ -0,0 +1,62 @@
"use client";
import { ReactNode, useEffect } from "react";
import { createPortal } from "react-dom";
import { Button } from "@/components/ui/button";
type ModalProps = {
open: boolean;
title: string;
onClose: () => void;
children: ReactNode;
};
export function Modal({ open, title, onClose, children }: ModalProps) {
useEffect(() => {
if (!open) {
return;
}
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};
window.addEventListener("keydown", handleEscape);
return () => window.removeEventListener("keydown", handleEscape);
}, [open, onClose]);
if (!open) {
return null;
}
if (typeof document === "undefined") {
return null;
}
return createPortal(
<div
className="fixed inset-0 z-[120] grid place-items-center bg-black/80 p-4 md:p-6"
onClick={onClose}
role="presentation"
>
<section
aria-modal="true"
aria-label={title}
role="dialog"
className="w-full max-w-[34rem] rounded-lg border border-border bg-panel p-5 max-h-[90vh] overflow-y-auto"
onClick={(event) => event.stopPropagation()}
>
<header className="mb-4 flex items-center justify-between">
<h2 className="text-base font-semibold text-text">{title}</h2>
<Button variant="ghost" className="px-2 py-1" onClick={onClose}>
Close
</Button>
</header>
{children}
</section>
</div>,
document.body
);
}

21
components/ui/select.tsx Normal file
View File

@@ -0,0 +1,21 @@
import { forwardRef, SelectHTMLAttributes } from "react";
import { cn } from "@/lib/utils";
export const Select = forwardRef<HTMLSelectElement, SelectHTMLAttributes<HTMLSelectElement>>(function Select(
{ className, children, ...props },
ref
) {
return (
<select
ref={ref}
className={cn(
"ui-control",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/70 focus-visible:ring-offset-2 focus-visible:ring-offset-bg",
className
)}
{...props}
>
{children}
</select>
);
});

View File

@@ -0,0 +1,18 @@
import { forwardRef, TextareaHTMLAttributes } from "react";
import { cn } from "@/lib/utils";
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaHTMLAttributes<HTMLTextAreaElement>>(
function Textarea({ className, ...props }, ref) {
return (
<textarea
ref={ref}
className={cn(
"ui-control",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/70 focus-visible:ring-offset-2 focus-visible:ring-offset-bg",
className
)}
{...props}
/>
);
}
);

37
docker-compose.yml Normal file
View File

@@ -0,0 +1,37 @@
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: payme
POSTGRES_USER: payme
POSTGRES_PASSWORD: payme
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U payme -d payme"]
interval: 5s
timeout: 5s
retries: 10
app:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
depends_on:
db:
condition: service_healthy
env_file:
- .env
environment:
NODE_ENV: production
DATABASE_URL: postgresql://payme:payme@db:5432/payme?schema=public
NEXTAUTH_URL: http://localhost:3000
ports:
- "3000:3000"
volumes:
postgres_data:

6
eslint.config.mjs Normal file
View File

@@ -0,0 +1,6 @@
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
import nextTypeScript from "eslint-config-next/typescript";
const config = [...nextCoreWebVitals, ...nextTypeScript];
export default config;

72
lib/auth.ts Normal file
View File

@@ -0,0 +1,72 @@
import { PrismaAdapter } from "@auth/prisma-adapter";
import { compare } from "bcryptjs";
import { getServerSession, type NextAuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { db } from "@/lib/db";
import { loginSchema } from "@/lib/validators/auth";
export const authOptions: NextAuthOptions = {
adapter: PrismaAdapter(db),
secret:
process.env.NEXTAUTH_SECRET ??
(process.env.NODE_ENV === "development" ? "payme-dev-only-secret-change-in-production" : undefined),
session: {
// Credentials auth in NextAuth v4 requires JWT strategy.
strategy: "jwt"
},
pages: {
signIn: "/login"
},
providers: [
CredentialsProvider({
name: "Credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" }
},
authorize: async (credentials) => {
// Apply credential attempt throttling in front of this endpoint for production deployments.
const parsed = loginSchema.safeParse(credentials);
if (!parsed.success) {
return null;
}
const user = await db.user.findUnique({
where: { email: parsed.data.email }
});
if (!user) {
return null;
}
const validPassword = await compare(parsed.data.password, user.hashedPassword);
if (!validPassword) {
return null;
}
return {
id: user.id,
email: user.email
};
}
})
],
callbacks: {
jwt: ({ token, user }) => {
if (user) {
token.id = user.id;
}
return token;
},
session: ({ session, token }) => {
if (session.user) {
session.user.id = String(token.id ?? token.sub ?? "");
}
return session;
}
}
};
export function auth() {
return getServerSession(authOptions);
}

62
lib/constants.ts Normal file
View File

@@ -0,0 +1,62 @@
import { PaymentMethodType } from "@prisma/client";
export const DEFAULT_THEME_ID = "terminal-dark";
export const THEME_TOKENS: Record<
string,
{
bg: string;
panel: string;
text: string;
muted: string;
border: string;
accent: string;
}
> = {
"terminal-dark": {
bg: "11 12 14",
panel: "16 18 20",
text: "237 232 220",
muted: "163 156 140",
border: "52 55 58",
accent: "118 150 92"
},
"amber-paper": {
bg: "15 16 17",
panel: "20 22 24",
text: "238 229 212",
muted: "178 166 146",
border: "58 53 44",
accent: "166 129 74"
}
};
export const PAYMENT_METHOD_LABELS: Record<PaymentMethodType, string> = {
PAYPAL: "PayPal",
BITCOIN: "Bitcoin",
ETHEREUM: "Ethereum",
MONERO: "Monero",
LITECOIN: "Litecoin",
SOLANA: "Solana",
USDT: "USDT",
REVOLUT: "Revolut",
BANK_TRANSFER: "Bank Transfer",
CUSTOM: "Custom"
};
export const PAYMENT_METHOD_TYPES = Object.keys(PAYMENT_METHOD_LABELS) as PaymentMethodType[];
export const USDT_NETWORKS = ["ERC20", "TRC20", "BEP20", "SOL", "POLYGON"] as const;
export const SUPPORTS_QR = new Set<PaymentMethodType>([
"PAYPAL",
"BITCOIN",
"ETHEREUM",
"MONERO",
"LITECOIN",
"SOLANA",
"USDT",
"REVOLUT",
"BANK_TRANSFER",
"CUSTOM"
]);

11
lib/db.ts Normal file
View File

@@ -0,0 +1,11 @@
import { PrismaClient } from "@prisma/client";
declare global {
var prisma: PrismaClient | undefined;
}
export const db = global.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") {
global.prisma = db;
}

47
lib/payment-uri.ts Normal file
View File

@@ -0,0 +1,47 @@
import { PaymentMethodType } from "@prisma/client";
function looksLikeUrl(value: string) {
try {
const url = new URL(value);
return ["http:", "https:"].includes(url.protocol);
} catch {
return false;
}
}
export function buildPaymentUri(type: PaymentMethodType, value: string, network?: string | null) {
const cleaned = value.trim();
switch (type) {
case "PAYPAL": {
if (looksLikeUrl(cleaned)) {
return cleaned;
}
return `https://paypal.me/${cleaned}`;
}
case "BITCOIN":
return `bitcoin:${cleaned}`;
case "ETHEREUM":
return `ethereum:${cleaned}`;
case "MONERO":
return `monero:${cleaned}`;
case "LITECOIN":
return `litecoin:${cleaned}`;
case "SOLANA":
return `solana:${cleaned}`;
case "USDT":
return network ? `usdt:${network}:${cleaned}` : `usdt:${cleaned}`;
case "REVOLUT": {
if (looksLikeUrl(cleaned)) {
return cleaned;
}
return `https://revolut.me/${cleaned}`;
}
case "BANK_TRANSFER":
return cleaned;
case "CUSTOM":
return cleaned;
default:
return cleaned;
}
}

6
lib/qr.ts Normal file
View File

@@ -0,0 +1,6 @@
import { PaymentMethodType } from "@prisma/client";
import { buildPaymentUri } from "@/lib/payment-uri";
export function getQrPayload(type: PaymentMethodType, value: string, network?: string | null) {
return buildPaymentUri(type, value, network);
}

36
lib/sanitize.ts Normal file
View File

@@ -0,0 +1,36 @@
export function sanitizePlainText(value: string) {
return value
.replace(/[\u0000-\u001F\u007F]/g, "")
.replace(/\s+/g, " ")
.trim();
}
export function sanitizeOptionalPlainText(value: string | null | undefined) {
if (!value) {
return undefined;
}
const sanitized = sanitizePlainText(value);
return sanitized.length > 0 ? sanitized : undefined;
}
export function normalizeUsername(value: string) {
return sanitizePlainText(value).toLowerCase().replace(/^@+/, "");
}
export function safeUrl(value: string | null | undefined) {
if (!value) {
return undefined;
}
const sanitized = value.trim();
try {
const url = new URL(sanitized);
if (!["http:", "https:"].includes(url.protocol)) {
return undefined;
}
return sanitized;
} catch {
return undefined;
}
}

30
lib/session.ts Normal file
View File

@@ -0,0 +1,30 @@
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
export async function getCurrentSession() {
return auth();
}
export async function requireSession() {
const session = await getCurrentSession();
if (!session?.user?.id) {
redirect("/login");
}
return session;
}
export async function requireCurrentUser() {
const session = await requireSession();
const user = await db.user.findUnique({
where: { id: session.user.id },
include: { profile: true }
});
if (!user) {
redirect("/login");
}
return user;
}

6
lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

27
lib/validators/auth.ts Normal file
View File

@@ -0,0 +1,27 @@
import { z } from "zod";
export const emailSchema = z.string().trim().toLowerCase().email().max(254);
export const passwordSchema = z
.string()
.min(8, "Password must be at least 8 characters")
.max(128, "Password must be at most 128 characters")
.regex(/[A-Z]/, "Password must contain at least one uppercase letter")
.regex(/[a-z]/, "Password must contain at least one lowercase letter")
.regex(/[0-9]/, "Password must contain at least one number");
export const registerSchema = z.object({
email: emailSchema,
password: passwordSchema,
username: z
.string()
.trim()
.toLowerCase()
.regex(/^[a-z0-9_]{3,32}$/, "Username must be 3-32 chars (a-z, 0-9, _)"),
displayName: z.string().trim().min(2).max(60)
});
export const loginSchema = z.object({
email: emailSchema,
password: z.string().min(1)
});

View File

@@ -0,0 +1,134 @@
import { PaymentMethodType } from "@prisma/client";
import { z } from "zod";
import { USDT_NETWORKS } from "@/lib/constants";
const BTC_REGEX = /^(bc1[a-z0-9]{25,62}|[13][a-km-zA-HJ-NP-Z1-9]{25,34})$/;
const ETH_REGEX = /^0x[a-fA-F0-9]{40}$/;
const XMR_REGEX = /^(4|8)[1-9A-HJ-NP-Za-km-z]{94,105}$/;
const LTC_REGEX = /^(ltc1[a-z0-9]{39,59}|[LM3][a-km-zA-HJ-NP-Z1-9]{26,34})$/;
const SOL_REGEX = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/;
const PAYPAL_USERNAME_REGEX = /^[a-zA-Z0-9._-]{3,64}$/;
const REVOLUT_REGEX = /^[a-zA-Z0-9._-]{3,32}$/;
function isPayPalValue(value: string) {
if (PAYPAL_USERNAME_REGEX.test(value)) {
return true;
}
try {
const url = new URL(value);
return url.hostname.includes("paypal.com") || url.hostname.includes("paypal.me");
} catch {
return false;
}
}
function isRevolutValue(value: string) {
if (REVOLUT_REGEX.test(value)) {
return true;
}
try {
const url = new URL(value);
return url.hostname.includes("revolut.com") || url.hostname.includes("revolut.me");
} catch {
return false;
}
}
function isValidIban(value: string) {
const iban = value.replace(/\s+/g, "").toUpperCase();
if (!/^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$/.test(iban)) {
return false;
}
const rearranged = `${iban.slice(4)}${iban.slice(0, 4)}`;
let transformed = "";
for (const char of rearranged) {
const code = char.charCodeAt(0);
transformed += code >= 65 && code <= 90 ? String(code - 55) : char;
}
let remainder = 0;
for (const digit of transformed) {
remainder = (remainder * 10 + Number(digit)) % 97;
}
return remainder === 1;
}
export const paymentMethodSchema = z
.object({
id: z.string().optional(),
type: z.nativeEnum(PaymentMethodType),
label: z.string().trim().min(1).max(60),
value: z.string().trim().min(1).max(500),
network: z.string().trim().max(30).optional().or(z.literal("")),
description: z.string().trim().max(180).optional().or(z.literal("")),
isVisible: z.boolean().default(true)
})
.superRefine((input, ctx) => {
const value = input.value.trim();
const network = input.network?.trim();
if (input.type === "PAYPAL" && !isPayPalValue(value)) {
ctx.addIssue({
code: "custom",
path: ["value"],
message: "PayPal value must be a PayPal username or paypal.com/paypal.me URL"
});
}
if (input.type === "BITCOIN" && !BTC_REGEX.test(value)) {
ctx.addIssue({ code: "custom", path: ["value"], message: "Invalid Bitcoin address format" });
}
if (input.type === "ETHEREUM" && !ETH_REGEX.test(value)) {
ctx.addIssue({ code: "custom", path: ["value"], message: "Invalid Ethereum address format" });
}
if (input.type === "MONERO" && !XMR_REGEX.test(value)) {
ctx.addIssue({ code: "custom", path: ["value"], message: "Invalid Monero address format" });
}
if (input.type === "LITECOIN" && !LTC_REGEX.test(value)) {
ctx.addIssue({ code: "custom", path: ["value"], message: "Invalid Litecoin address format" });
}
if (input.type === "SOLANA" && !SOL_REGEX.test(value)) {
ctx.addIssue({ code: "custom", path: ["value"], message: "Invalid Solana address format" });
}
if (input.type === "USDT") {
if (!network || !USDT_NETWORKS.includes(network as (typeof USDT_NETWORKS)[number])) {
ctx.addIssue({
code: "custom",
path: ["network"],
message: `USDT network must be one of: ${USDT_NETWORKS.join(", ")}`
});
}
const looksLikeWallet = ETH_REGEX.test(value) || SOL_REGEX.test(value) || /^[T][1-9A-HJ-NP-Za-km-z]{33}$/.test(value);
if (!looksLikeWallet) {
ctx.addIssue({
code: "custom",
path: ["value"],
message: "Invalid USDT wallet address format for common networks"
});
}
}
if (input.type === "REVOLUT" && !isRevolutValue(value)) {
ctx.addIssue({
code: "custom",
path: ["value"],
message: "Revolut value must be a username or revolut.com/revolut.me URL"
});
}
if (input.type === "BANK_TRANSFER" && !isValidIban(value)) {
ctx.addIssue({ code: "custom", path: ["value"], message: "Invalid IBAN format" });
}
});
export type PaymentMethodInput = z.infer<typeof paymentMethodSchema>;

23
lib/validators/profile.ts Normal file
View File

@@ -0,0 +1,23 @@
import { z } from "zod";
export const profileSchema = z.object({
username: z
.string()
.trim()
.toLowerCase()
.regex(/^[a-z0-9_]{3,32}$/, "Username must be 3-32 chars (a-z, 0-9, _)")
.max(32),
displayName: z.string().trim().min(2).max(60),
bio: z.string().trim().max(280).optional().or(z.literal("")),
avatarUrl: z
.string()
.trim()
.url("Avatar URL must be a valid URL")
.max(500)
.optional()
.or(z.literal("")),
themeId: z.string().trim().min(1),
isPublic: z.boolean()
});
export type ProfileInput = z.infer<typeof profileSchema>;

View File

@@ -0,0 +1,10 @@
import { z } from "zod";
export const socialLinkSchema = z.object({
id: z.string().optional(),
label: z.string().trim().min(2).max(50),
url: z.string().trim().url("Social link must be a valid URL").max(500),
isVisible: z.boolean().default(true)
});
export type SocialLinkInput = z.infer<typeof socialLinkSchema>;

6
next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

5
next.config.ts Normal file
View File

@@ -0,0 +1,5 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {};
export default nextConfig;

8230
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

47
package.json Normal file
View File

@@ -0,0 +1,47 @@
{
"name": "payme",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint .",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate deploy",
"db:migrate:dev": "prisma migrate dev",
"db:push": "prisma db push",
"db:seed": "tsx prisma/seed.ts"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
},
"dependencies": {
"@auth/prisma-adapter": "latest",
"@prisma/client": "^6.16.2",
"bcryptjs": "latest",
"clsx": "latest",
"next": "latest",
"next-auth": "latest",
"qrcode": "latest",
"react": "latest",
"react-dom": "latest",
"tailwind-merge": "latest",
"zod": "latest"
},
"devDependencies": {
"@tailwindcss/postcss": "latest",
"@types/node": "latest",
"@types/qrcode": "latest",
"@types/react": "latest",
"@types/react-dom": "latest",
"autoprefixer": "latest",
"eslint": "latest",
"eslint-config-next": "latest",
"postcss": "latest",
"prisma": "^6.16.2",
"tailwindcss": "latest",
"tsx": "latest",
"typescript": "latest"
}
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
"@tailwindcss/postcss": {},
autoprefixer: {}
}
};

View File

@@ -0,0 +1,166 @@
-- CreateEnum
CREATE TYPE "PaymentMethodType" AS ENUM (
'PAYPAL',
'BITCOIN',
'ETHEREUM',
'MONERO',
'LITECOIN',
'SOLANA',
'USDT',
'REVOLUT',
'BANK_TRANSFER',
'CUSTOM'
);
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"hashedPassword" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Profile" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"username" TEXT NOT NULL,
"displayName" TEXT NOT NULL,
"bio" TEXT,
"avatarUrl" TEXT,
"themeId" TEXT NOT NULL,
"isPublic" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Profile_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Theme" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Theme_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PaymentMethod" (
"id" TEXT NOT NULL,
"profileId" TEXT NOT NULL,
"type" "PaymentMethodType" NOT NULL,
"label" TEXT NOT NULL,
"value" TEXT NOT NULL,
"network" TEXT,
"description" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"isVisible" BOOLEAN NOT NULL DEFAULT true,
"metadata" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PaymentMethod_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SocialLink" (
"id" TEXT NOT NULL,
"profileId" TEXT NOT NULL,
"label" TEXT NOT NULL,
"url" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"isVisible" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SocialLink_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Account" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"providerAccountId" TEXT NOT NULL,
"refresh_token" TEXT,
"access_token" TEXT,
"expires_at" INTEGER,
"token_type" TEXT,
"scope" TEXT,
"id_token" TEXT,
"session_state" TEXT,
CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"sessionToken" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "VerificationToken" (
"identifier" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Profile_userId_key" ON "Profile"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "Profile_username_key" ON "Profile"("username");
-- CreateIndex
CREATE INDEX "Profile_username_idx" ON "Profile"("username");
-- CreateIndex
CREATE INDEX "PaymentMethod_profileId_sortOrder_idx" ON "PaymentMethod"("profileId", "sortOrder");
-- CreateIndex
CREATE INDEX "SocialLink_profileId_sortOrder_idx" ON "SocialLink"("profileId", "sortOrder");
-- CreateIndex
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
-- CreateIndex
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token");
-- AddForeignKey
ALTER TABLE "Profile" ADD CONSTRAINT "Profile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Profile" ADD CONSTRAINT "Profile_themeId_fkey" FOREIGN KEY ("themeId") REFERENCES "Theme"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PaymentMethod" ADD CONSTRAINT "PaymentMethod_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "Profile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SocialLink" ADD CONSTRAINT "SocialLink_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "Profile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1 @@
provider = "postgresql"

128
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,128 @@
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum PaymentMethodType {
PAYPAL
BITCOIN
ETHEREUM
MONERO
LITECOIN
SOLANA
USDT
REVOLUT
BANK_TRANSFER
CUSTOM
}
model User {
id String @id @default(cuid())
email String @unique
hashedPassword String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
profile Profile?
accounts Account[]
sessions Session[]
}
model Profile {
id String @id @default(cuid())
userId String @unique
username String @unique
displayName String
bio String?
avatarUrl String?
themeId String
isPublic Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
theme Theme @relation(fields: [themeId], references: [id])
paymentMethods PaymentMethod[]
socialLinks SocialLink[]
@@index([username])
}
model Theme {
id String @id
name String
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
profiles Profile[]
}
model PaymentMethod {
id String @id @default(cuid())
profileId String
type PaymentMethodType
label String
value String
network String?
description String?
sortOrder Int @default(0)
isVisible Boolean @default(true)
metadata Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
profile Profile @relation(fields: [profileId], references: [id], onDelete: Cascade)
@@index([profileId, sortOrder])
}
model SocialLink {
id String @id @default(cuid())
profileId String
label String
url String
sortOrder Int @default(0)
isVisible Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
profile Profile @relation(fields: [profileId], references: [id], onDelete: Cascade)
@@index([profileId, sortOrder])
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String?
access_token String?
expires_at Int?
token_type String?
scope String?
id_token String?
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}

40
prisma/seed.ts Normal file
View File

@@ -0,0 +1,40 @@
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
async function main() {
await prisma.theme.upsert({
where: { id: "terminal-dark" },
update: {
name: "Terminal Dark",
description: "Near-black background, warm text, and muted green accent"
},
create: {
id: "terminal-dark",
name: "Terminal Dark",
description: "Near-black background, warm text, and muted green accent"
}
});
await prisma.theme.upsert({
where: { id: "amber-paper" },
update: {
name: "Amber Paper",
description: "Soft dark slate with amber accent and warm mono feel"
},
create: {
id: "amber-paper",
name: "Amber Paper",
description: "Soft dark slate with amber accent and warm mono feel"
}
});
}
main()
.catch((error) => {
console.error(error);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

36
proxy.ts Normal file
View File

@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from "next/server";
import { getToken } from "next-auth/jwt";
const AUTH_ROUTES = ["/login", "/register"];
export async function proxy(request: NextRequest) {
const pathname = request.nextUrl.pathname;
const atMatch = pathname.match(/^\/@([a-zA-Z0-9_]{3,32})$/);
if (atMatch) {
const url = request.nextUrl.clone();
url.pathname = `/u/${atMatch[1].toLowerCase()}`;
return NextResponse.rewrite(url);
}
const token = await getToken({ req: request });
if (pathname.startsWith("/dashboard") && !token) {
const url = request.nextUrl.clone();
url.pathname = "/login";
url.searchParams.set("next", pathname);
return NextResponse.redirect(url);
}
if (AUTH_ROUTES.includes(pathname) && token) {
const url = request.nextUrl.clone();
url.pathname = "/dashboard";
return NextResponse.redirect(url);
}
return NextResponse.next();
}
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml).*)"]
};

32
scripts/entrypoint.sh Normal file
View File

@@ -0,0 +1,32 @@
#!/bin/sh
set -eu
echo "[payme] running prisma generate"
npm run db:generate >/dev/null
echo "[payme] running prisma migrate deploy"
set +e
MIGRATE_OUT="$(npx prisma migrate deploy 2>&1)"
MIGRATE_CODE=$?
set -e
if [ "$MIGRATE_CODE" -ne 0 ]; then
echo "$MIGRATE_OUT"
if echo "$MIGRATE_OUT" | grep -q "Error: P3005"; then
echo "[payme] baselining existing schema with initial migration"
npx prisma migrate resolve --applied 20260327133000_init
npx prisma migrate deploy
else
echo "[payme] migrate deploy failed"
exit "$MIGRATE_CODE"
fi
fi
echo "[payme] seeding themes (idempotent)"
set +e
DATABASE_URL="${DATABASE_URL:-}" npm run db:seed >/dev/null 2>&1
set -e
echo "[payme] starting next"
exec npm run start

25
tailwind.config.ts Normal file
View File

@@ -0,0 +1,25 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: [
"./app/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
"./actions/**/*.{ts,tsx}",
"./lib/**/*.{ts,tsx}"
],
theme: {
extend: {
colors: {
bg: "rgb(var(--color-bg) / <alpha-value>)",
panel: "rgb(var(--color-panel) / <alpha-value>)",
text: "rgb(var(--color-text) / <alpha-value>)",
muted: "rgb(var(--color-muted) / <alpha-value>)",
border: "rgb(var(--color-border) / <alpha-value>)",
accent: "rgb(var(--color-accent) / <alpha-value>)"
}
}
},
plugins: []
};
export default config;

42
tsconfig.json Normal file
View File

@@ -0,0 +1,42 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"baseUrl": ".",
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}

15
types/next-auth.d.ts vendored Normal file
View File

@@ -0,0 +1,15 @@
import { DefaultSession } from "next-auth";
declare module "next-auth" {
interface Session {
user: DefaultSession["user"] & {
id: string;
};
}
}
declare module "next-auth/jwt" {
interface JWT {
id?: string;
}
}