← back to Stayclaim
src/app/dashboard/page.tsx
152 lines
import Link from 'next/link';
import type { Metadata } from 'next';
import { getOwnerByEmail, getListingsForOwner, getPendingClaimsForEmail } from '@/lib/db';
import { BreadcrumbArchive } from '@/components/BreadcrumbArchive';
import { NodeCard } from '@/components/NodeCard';
export const metadata: Metadata = {
robots: { index: false, follow: false },
};
/**
* Owner dashboard. Authentication is stubbed for v0.1: ?email=... query
* acts as the session. Real auth lands when the email-verification claim
* flow ships (token in claim_request.token).
*/
export default async function DashboardPage({
searchParams,
}: {
// Next 14: searchParams values are `string | string[] | undefined`. A
// duplicate key (`?email=a&email=b`) hands us an array and `.trim()` would
// throw. Normalize to first value.
searchParams: { email?: string | string[] };
}) {
const rawEmail = searchParams.email;
const email = (Array.isArray(rawEmail) ? rawEmail[0] : rawEmail)?.trim();
if (!email) {
return (
<>
<div className="max-w-2xl mx-auto px-6 pt-6">
<BreadcrumbArchive items={[{ label: 'Archive', href: '/' }, { label: 'Dashboard' }]} />
</div>
<section className="max-w-2xl mx-auto px-6 py-12">
<p className="text-xs uppercase tracking-[0.15em] text-ink/50 mb-2">Owner dashboard</p>
<h1 className="font-display text-5xl text-ink tracking-[-0.02em] leading-[1.02]">
Sign in to your archive.
</h1>
<p className="mt-4 font-display italic text-xl text-ink/70">
v0.1 stub: enter the email you used when you claimed an address. Magic-link auth lands soon.
</p>
<form className="mt-8 grid gap-4 max-w-md">
<label className="flex flex-col gap-1 text-[10px] uppercase tracking-[0.15em] text-ink/55">
Email
<input
type="email"
name="email"
required
className="border border-ink/20 px-3 py-2 text-sm font-sans focus:outline-none focus:border-moss bg-sand"
/>
</label>
<button
type="submit"
className="bg-moss text-sand px-5 py-2 text-xs uppercase tracking-wider hover:bg-ink transition self-start"
>
Open dashboard
</button>
</form>
</section>
</>
);
}
const [owner, claims] = await Promise.all([
getOwnerByEmail(email),
getPendingClaimsForEmail(email),
]);
const listings = owner ? await getListingsForOwner(owner.id) : [];
return (
<>
<div className="max-w-5xl mx-auto px-6 pt-6">
<BreadcrumbArchive items={[{ label: 'Archive', href: '/' }, { label: 'Dashboard' }]} />
</div>
<header className="max-w-5xl mx-auto px-6 py-12 border-b border-ink/10">
<p className="text-xs uppercase tracking-[0.15em] text-ink/50 mb-2">
{owner?.name ?? 'Owner'} · {email}
</p>
<h1 className="font-display text-5xl md:text-6xl text-ink tracking-[-0.02em] leading-[1.02]">
Your addresses.
</h1>
<p className="mt-3 font-display italic text-xl text-ink/70">
Edit photos, copy, and contact info. The historical record stays independent.
</p>
</header>
<section className="max-w-5xl mx-auto px-6 py-10">
<div className="flex items-baseline justify-between mb-6">
<h2 className="font-display text-3xl text-ink tracking-[-0.01em]">Claimed</h2>
<Link
href="/browse"
className="text-xs uppercase tracking-wider text-ink/60 hover:text-coral transition"
>
Find another address →
</Link>
</div>
{listings.length === 0 ? (
<p className="text-sm text-ink/60 italic">
No claimed addresses yet. Find one in the{' '}
<Link href="/browse" className="underline-offset-2 hover:underline text-coral">
archive
</Link>{' '}
and claim it for free.
</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{listings.map(l => (
<NodeCard
key={l.id}
kind="address"
href={`/address/${l.slug}`}
title={l.title}
subtitle={l.address_line1 ?? l.city ?? undefined}
imageUrl={l.hero_image ?? undefined}
eyebrow={`Claimed · ${l.tier}`}
/>
))}
</div>
)}
</section>
<section className="max-w-5xl mx-auto px-6 py-10">
<h2 className="font-display text-3xl text-ink tracking-[-0.01em] mb-6">Pending claims</h2>
{claims.length === 0 ? (
<p className="text-sm text-ink/60 italic">No pending claims.</p>
) : (
<ul className="divide-y divide-ink/10">
{claims.map(c => (
<li key={c.id} className="py-4 flex items-baseline justify-between gap-3 flex-wrap">
<Link
href={`/address/${c.listing_slug}`}
className="font-display text-2xl text-ink hover:text-coral transition"
>
{c.listing_title}
</Link>
<span className="font-mono text-xs uppercase tracking-wider text-ink/50">
{c.status} · {(() => {
// Guard: a null/garbage created_at would throw RangeError on
// toISOString() and crash the entire page render.
const d = c.created_at ? new Date(c.created_at) : null;
return d && Number.isFinite(d.getTime()) ? d.toISOString().slice(0, 10) : '—';
})()}
</span>
</li>
))}
</ul>
)}
</section>
</>
);
}