Initial commit

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

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

View File

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