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