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