commit 38581b88a43f66ebdd5fde5020017421b1b0c93c Author: zvspany Date: Fri Mar 27 19:35:14 2026 +0100 Initial commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ad907b6 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..3722418 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": ["next/core-web-vitals", "next/typescript"] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..479cff1 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a468e60 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..47d6b97 --- /dev/null +++ b/README.md @@ -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 diff --git a/actions/payment-methods.ts b/actions/payment-methods.ts new file mode 100644 index 0000000..8fcc3a9 --- /dev/null +++ b/actions/payment-methods.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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" }; +} diff --git a/actions/profile.ts b/actions/profile.ts new file mode 100644 index 0000000..b16cf4f --- /dev/null +++ b/actions/profile.ts @@ -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 { + 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" }; + } +} diff --git a/actions/social-links.ts b/actions/social-links.ts new file mode 100644 index 0000000..96a4290 --- /dev/null +++ b/actions/social-links.ts @@ -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 { + 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 { + 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 { + 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 { + 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" }; +} diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx new file mode 100644 index 0000000..77ef82b --- /dev/null +++ b/app/(auth)/login/page.tsx @@ -0,0 +1,23 @@ +import Link from "next/link"; +import { LoginForm } from "@/components/auth/login-form"; + +export default function LoginPage() { + return ( +
+
+
+

PayMe

+

Sign in

+

Use your email and password to manage your public payment profile.

+
+ +

+ No account yet?{" "} + + Create one + +

+
+
+ ); +} diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx new file mode 100644 index 0000000..43feec7 --- /dev/null +++ b/app/(auth)/register/page.tsx @@ -0,0 +1,23 @@ +import Link from "next/link"; +import { RegisterForm } from "@/components/auth/register-form"; + +export default function RegisterPage() { + return ( +
+
+
+

PayMe

+

Create account

+

Start publishing your payment details in a self-hosted profile.

+
+ +

+ Already registered?{" "} + + Sign in + +

+
+
+ ); +} diff --git a/app/(dashboard)/dashboard/layout.tsx b/app/(dashboard)/dashboard/layout.tsx new file mode 100644 index 0000000..66ad9a3 --- /dev/null +++ b/app/(dashboard)/dashboard/layout.tsx @@ -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 ( +
+
+
+ + PayMe + +

{user.email}

+
+ + +
+ +
+ +
{children}
+
+
+ ); +} diff --git a/app/(dashboard)/dashboard/page.tsx b/app/(dashboard)/dashboard/page.tsx new file mode 100644 index 0000000..959b5a6 --- /dev/null +++ b/app/(dashboard)/dashboard/page.tsx @@ -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 ( +
+
+

Overview

+

+ Manage your public payment profile. PayMe only displays payment details and links. +

+
+ +
+
+

Payment Methods

+

{profile?.paymentMethods.length ?? 0}

+
+ +
+

Social Links

+

{profile?.socialLinks.length ?? 0}

+
+ +
+

Public Visibility

+

{profile?.isPublic ? "On" : "Off"}

+
+
+ +
+ ); +} diff --git a/app/(dashboard)/dashboard/payment-methods/page.tsx b/app/(dashboard)/dashboard/payment-methods/page.tsx new file mode 100644 index 0000000..e933783 --- /dev/null +++ b/app/(dashboard)/dashboard/payment-methods/page.tsx @@ -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

Profile not found.

; + } + + return ( +
+
+

Payment Methods

+

+ Add, edit, reorder, and toggle payment methods. Validation is format-based only and not authoritative. +

+
+ + + +
+ ); +} diff --git a/app/(dashboard)/dashboard/profile/page.tsx b/app/(dashboard)/dashboard/profile/page.tsx new file mode 100644 index 0000000..ffc913b --- /dev/null +++ b/app/(dashboard)/dashboard/profile/page.tsx @@ -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 ( +
+
+

Profile

+

+ Update how your public page appears. Username changes update your public URL. +

+
+ + +
+ ); +} diff --git a/app/(dashboard)/dashboard/social-links/page.tsx b/app/(dashboard)/dashboard/social-links/page.tsx new file mode 100644 index 0000000..2c1e15e --- /dev/null +++ b/app/(dashboard)/dashboard/social-links/page.tsx @@ -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

Profile not found.

; + } + + return ( +
+
+

Social Links

+

+ Optional links to contact points and social profiles. Keep this short and relevant. +

+
+ + +
+ ); +} diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..7b38c1b --- /dev/null +++ b/app/api/auth/[...nextauth]/route.ts @@ -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 }; diff --git a/app/api/register/route.ts b/app/api/register/route.ts new file mode 100644 index 0000000..5ca8f13 --- /dev/null +++ b/app/api/register/route.ts @@ -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 }); +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..2ad0345 --- /dev/null +++ b/app/globals.css @@ -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; +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..064a92b --- /dev/null +++ b/app/layout.tsx @@ -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 ( + + {children} + + ); +} diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 0000000..f91fdc8 --- /dev/null +++ b/app/not-found.tsx @@ -0,0 +1,16 @@ +import Link from "next/link"; + +export default function NotFoundPage() { + return ( +
+

404

+

Profile not found

+

+ This profile is unavailable or currently private. +

+ + Back to home + +
+ ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..db7231b --- /dev/null +++ b/app/page.tsx @@ -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 ( +
+
+
+

PayMe

+

+ One public page for every way people can pay you. +

+

+ Self-hosted payment profile pages for creators, freelancers, and OSS maintainers. No custody. No + transaction processing. Just clear payment details and links. +

+
+ +
+ + {primaryLabel} + + + View example + +
+
+ +
+
+

Profile preview

+

How a public PayMe profile is presented.

+
+ +
+
+

Public profile

+

Alex Rivera

+

@alexdev

+

Open source maintainer. Donations keep maintenance sustainable.

+
+ +
    + {previewRows.map((row) => ( +
  • +
    +
    +

    {row.title}

    +

    {row.meta}

    +

    {row.value}

    +
    +

    {row.actions}

    +
    +
  • + ))} +
+
+
+ +
+
+

How it works

+
    + {howItWorks.map((item) => ( +
  1. {item}
  2. + ))} +
+
+ +
+

Why PayMe

+
    + {whyPayMe.map((item) => ( +
  • {item}
  • + ))} +
+
+
+
+ ); +} diff --git a/app/u/[username]/page.tsx b/app/u/[username]/page.tsx new file mode 100644 index 0000000..077b3e9 --- /dev/null +++ b/app/u/[username]/page.tsx @@ -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 ( +
+
+ + +
+

Payment Methods

+ {profile.paymentMethods.length === 0 ? ( +

+ No payment methods published yet. +

+ ) : ( +
+ {profile.paymentMethods.map((method) => ( + + ))} +
+ )} +
+ + + +
+ PayMe is a profile and presentation layer. It does not process payments or hold funds. +
+
+
+ ); +} diff --git a/components/auth/login-form.tsx b/components/auth/login-form.tsx new file mode 100644 index 0000000..027ba03 --- /dev/null +++ b/components/auth/login-form.tsx @@ -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(null); + const [pending, setPending] = useState(false); + + async function onSubmit(event: FormEvent) { + 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 ( +
+ + + + + {error ?

{error}

: null} + + +
+ ); +} diff --git a/components/auth/register-form.tsx b/components/auth/register-form.tsx new file mode 100644 index 0000000..168f8b5 --- /dev/null +++ b/components/auth/register-form.tsx @@ -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(null); + const [pending, setPending] = useState(false); + + async function onSubmit(event: FormEvent) { + 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 ( +
+ + + + + + + + +

+ Password must be at least 8 chars and include uppercase, lowercase, and a number. +

+ + {error ?

{error}

: null} + + +
+ ); +} diff --git a/components/auth/signout-button.tsx b/components/auth/signout-button.tsx new file mode 100644 index 0000000..2257eae --- /dev/null +++ b/components/auth/signout-button.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { signOut } from "next-auth/react"; +import { Button } from "@/components/ui/button"; + +export function SignOutButton() { + return ( + + ); +} diff --git a/components/dashboard/payment-methods-form.tsx b/components/dashboard/payment-methods-form.tsx new file mode 100644 index 0000000..fcdd492 --- /dev/null +++ b/components/dashboard/payment-methods-form.tsx @@ -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(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) { + 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 ( +
+

Add Payment Method

+ +
+ + + +
+ + + + + +