feat(launch-readiness-batch): UAT drains, navigation refactor, launch infra, trackers

Bundles the rest of the in-flight work from this UAT round into one
checkpoint. Each sub-area is independent; see the headings below.

UAT polish (drained 11 findings from active-uat.md):
- Dialog primitive default bumped sm:max-w-xl/lg:max-w-3xl →
  sm:max-w-2xl/lg:max-w-4xl so multi-field forms + PDF previews
  aren't cramped at 1440-1920px.
- Notes tab badge aggregation: new countFor{Client,Yacht,Company}
  Aggregated helpers in notes.service mirror the listFor*Aggregated
  symmetric-reach joins. yacht-tabs + company-tabs render the
  badge; client-tabs already had badge support.
- Supplemental-info form polish bundle: BrandedAuthShell gains a
  `width: 'sm' | 'md'` prop (md uses min-h-dvh scroll instead of
  fixed inset-0 pin so long forms scroll naturally). Form picks up
  port branding (logoUrl + backgroundUrl + appName) via
  loadByToken. Address fields completed (street + city + region +
  postal + country). Port name eyebrow + success-state copy added.
- new-document-menu Upload-file landing toast: per-file completion
  emits toast.success with action link to the destination entity
  or folder.
- interest-tabs OverviewTab "from client" pill on Email + Phone
  rows via new EditableRow `inheritedFrom` prop.
- create-document-wizard subject picker → segmented button strip
  (5 types visible at once).

Launch infra:
- UTM column wiring (Init 1b step 4): migration
  0089_website_submissions_utm.sql adds utm_source/medium/campaign/
  term/content + composite index (port_id, utm_source, received_at)
  for per-campaign rollups. website-inquiries intake accepts the
  five fields. Residential intake intentionally untouched per audit
  scope.
- Invoicing module gate (Init 1c spike): new
  invoices-module.service + invoices layout guard + registry entry
  invoices_module_enabled (default false). Audit conclusion in
  launch-readiness.md: payments table is canonical money path;
  /invoices flow is parallel infrastructure now hidden by default.

Smart-back navigation refactor:
- Replaced breadcrumb component with history-aware Back button.
  New route-labels.ts + use-smart-back hook +
  navigation-history-tracker so back falls through to the parent
  route when there's no prior page in history.
- Sidebar / topbar / mobile-topbar adopt the new pattern; old
  breadcrumb-store kept for back-compat consumers but the
  breadcrumbs component is gone.
- 6 detail pages (admin/errors per-id + codes, invoices/
  upload-receipts, reports kind, tenancies detail, analytics
  metric, client detail) migrated.

Trackers + docs:
- docs/launch-readiness.md — master pre-launch tracker. Includes
  the reports gap audit (cross-cutting filter set, Marketing +
  Financial blockers, custom builder remaining entities, scheduled
  CSV/XLSX, template scope picker).
- docs/superpowers/audits/active-uat.md — 15 findings flipped
  OPEN → SHIPPED locally with fix-applied notes; 4 OPEN remaining
  (each blocked on user input or cross-repo).
- CLAUDE.md — minor session notes carried forward.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 22:42:37 +02:00
parent 3bdf59e917
commit cb8292464c
62 changed files with 2944 additions and 662 deletions

View File

@@ -4,17 +4,18 @@ import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { format } from 'date-fns';
import { ArrowLeft, Copy, Wrench } from 'lucide-react';
import { Copy, Wrench } from 'lucide-react';
import { toast } from 'sonner';
import type { Route } from 'next';
import { Badge } from '@/components/ui/badge';
import { ERROR_CODES, isErrorCode } from '@/lib/error-codes';
import { Button } from '@/components/ui/button';
import { ERROR_CODES, isErrorCode } from '@/lib/error-codes';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { apiFetch } from '@/lib/api/client';
import { useBreadcrumbHint } from '@/hooks/use-breadcrumb-hint';
import type { ErrorEvent } from '@/lib/db/schema/system';
import type { LikelyCulprit } from '@/lib/error-classifier';
@@ -36,6 +37,17 @@ export default function ErrorEventDetailPage() {
const portSlug = params?.portSlug ?? '';
const requestId = params?.requestId ?? '';
// Smart-back target: send the user back to the error list, not the
// generic Administration page that URL-derivation would land on.
useBreadcrumbHint(
portSlug
? {
parents: [{ label: 'Error inspector', href: `/${portSlug}/admin/errors` }],
current: `Error ${requestId.slice(0, 8)}`,
}
: null,
);
const query = useQuery<DetailResponse>({
queryKey: ['admin', 'error-events', requestId],
queryFn: () => apiFetch<DetailResponse>(`/api/v1/admin/error-events/${requestId}`),
@@ -71,15 +83,6 @@ export default function ErrorEventDetailPage() {
return (
<div className="space-y-4">
<div>
<Button variant="ghost" size="sm" asChild>
<Link href={`/${portSlug}/admin/errors` as Route}>
<ArrowLeft className="mr-1.5 h-4 w-4" />
Back to error list
</Link>
</Button>
</div>
<div className="flex items-center gap-2 flex-wrap">
<h1 className="text-2xl font-bold">Error {requestId.slice(0, 8)}</h1>
<Badge

View File

@@ -1,16 +1,13 @@
'use client';
import { useState, useMemo } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { ArrowLeft, BookOpen, Search } from 'lucide-react';
import type { Route } from 'next';
import { BookOpen, Search } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { useBreadcrumbHint } from '@/hooks/use-breadcrumb-hint';
import { ERROR_CODES } from '@/lib/error-codes';
/**
@@ -27,6 +24,17 @@ export default function ErrorCodeReferencePage() {
const portSlug = params?.portSlug ?? '';
const [search, setSearch] = useState('');
// Smart-back target: send the user back to the error inspector, not
// the generic Administration page URL-derivation would land on.
useBreadcrumbHint(
portSlug
? {
parents: [{ label: 'Error inspector', href: `/${portSlug}/admin/errors` }],
current: 'Error code reference',
}
: null,
);
const entries = useMemo(() => {
const all = Object.entries(ERROR_CODES) as Array<
[string, (typeof ERROR_CODES)[keyof typeof ERROR_CODES]]
@@ -53,15 +61,6 @@ export default function ErrorCodeReferencePage() {
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" asChild>
<Link href={`/${portSlug}/admin/errors` as Route}>
<ArrowLeft className="mr-1.5 h-4 w-4" />
Back to error inspector
</Link>
</Button>
</div>
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">

View File

@@ -0,0 +1,43 @@
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { ports as portsTable } from '@/lib/db/schema/ports';
import { isExpensesModuleEnabled } from '@/lib/services/expenses-module.service';
import { ModuleDisabledPage } from '@/components/shared/module-disabled-page';
interface ExpensesLayoutProps {
children: React.ReactNode;
params: Promise<{ portSlug: string }>;
}
/**
* Layout-level gate for the entire /expenses subtree (list, scan,
* detail). When the port has expenses_module_enabled = false, every
* route under /expenses renders the ModuleDisabledPage instead of the
* real content. This is the route-level half of the "hybrid hide+block"
* model (the sidebar entries are independently hidden via
* expensesModuleByPort on the SSR-resolved sidebar prop).
*
* Using a layout rather than per-page guards means: (a) one place to
* change the gate logic, (b) nested routes (scan, [id]) are covered
* automatically, (c) the children subtree never mounts when disabled,
* so its data-fetching effects don't fire.
*/
export default async function ExpensesLayout({ children, params }: ExpensesLayoutProps) {
const { portSlug } = await params;
const port = await db.query.ports.findFirst({
where: eq(portsTable.slug, portSlug),
columns: { id: true },
});
if (!port) return children;
const enabled = await isExpensesModuleEnabled(port.id);
if (enabled) return children;
return (
<ModuleDisabledPage
moduleName="Expenses"
description="Expense tracking and receipt upload are turned off for this port. Previously-recorded expense rows are preserved and will reappear when the module is re-enabled."
settingsHref={`/${portSlug}/admin/settings`}
fallbackHref={`/${portSlug}/dashboard`}
/>
);
}

View File

@@ -0,0 +1,42 @@
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { ports as portsTable } from '@/lib/db/schema/ports';
import { isInvoicesModuleEnabled } from '@/lib/services/invoices-module.service';
import { ModuleDisabledPage } from '@/components/shared/module-disabled-page';
interface InvoicesLayoutProps {
children: React.ReactNode;
params: Promise<{ portSlug: string }>;
}
/**
* Layout-level gate for the standalone `/invoices` flow (list, new,
* detail, upload-receipts). Default is OFF: the canonical "money
* received" path in this CRM is the per-interest Payments tab; the
* standalone invoicing surface only matters when an operator wants to
* generate client-facing invoices from the CRM itself (rare). Admins
* can opt in from Admin → Operations.
*
* Existing invoice rows are preserved when the module is disabled —
* the API endpoints still respond so historical PDF links / webhook
* callbacks keep resolving. Only the UI is hidden.
*/
export default async function InvoicesLayout({ children, params }: InvoicesLayoutProps) {
const { portSlug } = await params;
const port = await db.query.ports.findFirst({
where: eq(portsTable.slug, portSlug),
columns: { id: true },
});
if (!port) return children;
const enabled = await isInvoicesModuleEnabled(port.id);
if (enabled) return children;
return (
<ModuleDisabledPage
moduleName="Standalone invoicing"
description="The standalone /invoices flow is turned off for this port. The canonical money-received path here is the per-interest Payments tab — that's where to record deposits and balance payments. Existing invoice rows are preserved and will reappear when the module is re-enabled."
settingsHref={`/${portSlug}/admin/settings`}
fallbackHref={`/${portSlug}/dashboard`}
/>
);
}

View File

@@ -1,6 +1,11 @@
import type { Metadata } from 'next';
import { eq } from 'drizzle-orm';
import { UploadReceiptsGuide } from '@/components/invoices/upload-receipts-guide';
import { db } from '@/lib/db';
import { ports as portsTable } from '@/lib/db/schema/ports';
import { isExpensesModuleEnabled } from '@/lib/services/expenses-module.service';
import { ModuleDisabledPage } from '@/components/shared/module-disabled-page';
export const metadata: Metadata = {
title: 'How to upload receipts',
@@ -12,5 +17,23 @@ export default async function UploadReceiptsPage({
params: Promise<{ portSlug: string }>;
}) {
const { portSlug } = await params;
// Mirrors the /expenses layout gate: the receipt-upload explainer is
// part of the expenses surface, so the same port-scoped toggle
// blocks it. /invoices/upload-receipts isn't under /expenses, so a
// separate gate is needed here.
const port = await db.query.ports.findFirst({
where: eq(portsTable.slug, portSlug),
columns: { id: true },
});
if (port && !(await isExpensesModuleEnabled(port.id))) {
return (
<ModuleDisabledPage
moduleName="Expenses"
description="Receipt upload is part of the Expenses module, which is turned off for this port."
settingsHref={`/${portSlug}/admin/settings`}
fallbackHref={`/${portSlug}/dashboard`}
/>
);
}
return <UploadReceiptsGuide portSlug={portSlug} />;
}

View File

@@ -18,6 +18,7 @@ import { WebVitalsReporter } from '@/components/shared/web-vitals-reporter';
import { classifyFormFactor } from '@/lib/form-factor';
import { getPortBrandingConfig } from '@/lib/services/port-config';
import { isTenanciesModuleEnabled } from '@/lib/services/tenancies-module.service';
import { isExpensesModuleEnabled } from '@/lib/services/expenses-module.service';
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
const headerList = await headers();
@@ -89,6 +90,24 @@ export default async function DashboardLayout({ children }: { children: React.Re
);
const tenanciesModuleByPort: Record<string, boolean> = Object.fromEntries(tenanciesModuleEntries);
// Per-port expenses-module gate. Defaults to enabled (the registry's
// default) so existing ports keep the feature on deploy. Resolved
// server-side so the sidebar SSRs without flicker when an admin has
// turned the feature off for a tenant.
const expensesModuleEntries = await Promise.all(
ports.map(async (p) => {
try {
return [p.id, await isExpensesModuleEnabled(p.id)] as const;
} catch {
// Conservative default on lookup failure: keep the feature
// visible so a transient DB hiccup doesn't hide the module
// for a port that actually has it enabled.
return [p.id, true] as const;
}
}),
);
const expensesModuleByPort: Record<string, boolean> = Object.fromEntries(expensesModuleEntries);
return (
<QueryProvider>
<PortProvider
@@ -116,6 +135,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
ports={ports}
portLogoUrls={portLogoUrls}
tenanciesModuleByPort={tenanciesModuleByPort}
expensesModuleByPort={expensesModuleByPort}
initialFormFactor={initialFormFactor}
>
{children}

View File

@@ -28,6 +28,11 @@ export async function GET(
const submissionSchema = z.object({
fullName: z.string().min(1).max(200),
address: z.string().max(500).nullable().optional(),
city: z.string().max(120).nullable().optional(),
/** ISO-3166-2 subdivision code (e.g. 'PL-MZ'). Accept up to 8 chars
* so we cover ISO codes that include alpha-3 country prefixes. */
subdivisionIso: z.string().max(8).nullable().optional(),
postalCode: z.string().max(20).nullable().optional(),
country: z.string().length(2).nullable().optional(),
email: z.string().email().nullable().optional(),
phoneE164: z
@@ -52,6 +57,9 @@ export async function POST(
await applySubmission(token, {
fullName: body.fullName,
address: body.address ?? null,
city: body.city ?? null,
subdivisionIso: body.subdivisionIso ?? null,
postalCode: body.postalCode ?? null,
country: body.country ?? null,
email: body.email ?? null,
phoneE164: body.phoneE164 ?? null,

View File

@@ -49,6 +49,14 @@ const SubmissionSchema = z.object({
/** Defaults to port-nimara since that's currently the only port with a
* public marketing site. Future ports can override per-submission. */
port_slug: z.string().default('port-nimara'),
/** UTM attribution. Opportunistic — the website's tracker pulls
* these from the query string (or referrer) at submit time. Capped
* at 200 chars per part to defend against pathological strings. */
utm_source: z.string().max(200).nullable().optional(),
utm_medium: z.string().max(200).nullable().optional(),
utm_campaign: z.string().max(200).nullable().optional(),
utm_term: z.string().max(200).nullable().optional(),
utm_content: z.string().max(200).nullable().optional(),
});
function verifySecret(header: string | null): boolean {
@@ -142,6 +150,11 @@ export async function POST(req: NextRequest) {
legacyNocodbId: parsed.legacy_nocodb_id ?? null,
sourceIp: ip,
userAgent: req.headers.get('user-agent') ?? null,
utmSource: parsed.utm_source ?? null,
utmMedium: parsed.utm_medium ?? null,
utmCampaign: parsed.utm_campaign ?? null,
utmTerm: parsed.utm_term ?? null,
utmContent: parsed.utm_content ?? null,
})
.onConflictDoNothing({ target: websiteSubmissions.submissionId })
.returning({ id: websiteSubmissions.id });

View File

@@ -1,12 +1,17 @@
import { NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse, ValidationError } from '@/lib/errors';
import { db } from '@/lib/db';
import { ports } from '@/lib/db/schema/ports';
import { errorResponse, NotFoundError, ValidationError } from '@/lib/errors';
import { parseBody } from '@/lib/api/route-helpers';
import { getPortBrandingConfig } from '@/lib/services/port-config';
import { getBrandingShell } from '@/lib/email/branding-resolver';
import { renderShell } from '@/lib/email/shell';
import { sendEmail } from '@/lib/email';
import { findTestTemplate } from '@/lib/email/test-registry';
const SAMPLE_SUBJECT_SUFFIX = ' - branding preview';
@@ -51,13 +56,44 @@ function buildSampleEmail(branding: {
return { subject, html };
}
/**
* Render one of the registered transactional templates with realistic
* sample fixtures + the current port's branding. Falls back to the
* generic "branding preview" sample when no templateId is supplied
* (preserves the original card behaviour).
*/
async function renderTemplatePreview(
portId: string,
templateId: string | null,
): Promise<{ subject: string; html: string }> {
if (!templateId) {
const branding = await getPortBrandingConfig(portId);
return buildSampleEmail(branding);
}
const template = findTestTemplate(templateId);
if (!template) throw new ValidationError(`Unknown templateId: ${templateId}`);
const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) });
if (!port) throw new NotFoundError('Port');
const branding = await getBrandingShell(portId);
const rendered = await template.render({
recipientName: 'Sample Recipient',
recipientEmail: 'sample@example.com',
portName: port.name,
portUrl: `https://${port.slug}.example`,
branding,
});
return { subject: rendered.subject, html: rendered.html };
}
// GET - return the sample email rendered with the current port's branding.
// Optional ?templateId=<id> renders a real transactional template instead
// of the generic sample (e.g. crm_invite, portal_activation, signing_*).
export const GET = withAuth(
withPermission('admin', 'manage_settings', async (_req, ctx) => {
withPermission('admin', 'manage_settings', async (req, ctx) => {
try {
if (!ctx.portId) throw new ValidationError('No active port');
const branding = await getPortBrandingConfig(ctx.portId);
const { subject, html } = buildSampleEmail(branding);
const templateId = new URL(req.url).searchParams.get('templateId');
const { subject, html } = await renderTemplatePreview(ctx.portId, templateId);
return NextResponse.json({ data: { subject, html } });
} catch (error) {
return errorResponse(error);
@@ -67,6 +103,8 @@ export const GET = withAuth(
const sendTestSchema = z.object({
recipient: z.string().email('Enter a valid email address'),
/** Optional - same templateId surface as the GET endpoint. */
templateId: z.string().optional().nullable(),
});
// POST - actually send the sample email to a single recipient.
@@ -74,9 +112,8 @@ export const POST = withAuth(
withPermission('admin', 'manage_settings', async (req, ctx) => {
try {
if (!ctx.portId) throw new ValidationError('No active port');
const { recipient } = await parseBody(req, sendTestSchema);
const branding = await getPortBrandingConfig(ctx.portId);
const { subject, html } = buildSampleEmail(branding);
const { recipient, templateId } = await parseBody(req, sendTestSchema);
const { subject, html } = await renderTemplatePreview(ctx.portId, templateId ?? null);
await sendEmail(recipient, subject, html);
return NextResponse.json({ data: { sent: true, recipient } });
} catch (error) {

View File

@@ -7,6 +7,7 @@ import { parseBody } from '@/lib/api/route-helpers';
import { db } from '@/lib/db';
import { ports } from '@/lib/db/schema/ports';
import { sendEmail } from '@/lib/email';
import { getBrandingShell } from '@/lib/email/branding-resolver';
import { findTestTemplate, TEST_TEMPLATES } from '@/lib/email/test-registry';
import { errorResponse, NotFoundError, ValidationError } from '@/lib/errors';
@@ -63,11 +64,17 @@ export const POST = withAuth(
// No publicUrl column on `ports` yet - synthesise a plausible URL
// from the slug so the sample renders with a "real-looking" base.
const portUrl = `https://${port.slug}.example`;
// Resolve the per-port branding shell (logo, blur background, accent,
// header/footer HTML) so the preview matches the live production look
// - without this the email falls back to the neutral default shell
// and admins see a logo-less, background-less email.
const branding = await getBrandingShell(ctx.portId);
const rendered = await template.render({
recipientName: 'Sample Recipient',
recipientEmail: body.recipient,
portName: port.name,
portUrl,
branding,
});
// Subject prefix makes it visually unambiguous in the recipient's

View File

@@ -15,10 +15,16 @@ import type { CountryCode } from '@/lib/i18n/countries';
interface PrefillData {
token: { expiresAt: string; consumed: boolean };
port: {
name: string;
logoUrl: string | null;
backgroundUrl: string | null;
};
client: {
fullName: string;
streetAddress: string | null;
city: string | null;
subdivisionIso: string | null;
postalCode: string | null;
country: string | null;
primaryEmail: string | null;
@@ -48,6 +54,9 @@ export default function SupplementalInfoPage({ params }: PageProps) {
// Form fields
const [fullName, setFullName] = useState('');
const [address, setAddress] = useState('');
const [city, setCity] = useState('');
const [region, setRegion] = useState('');
const [postalCode, setPostalCode] = useState('');
const [country, setCountry] = useState<CountryCode | null>(null);
const [email, setEmail] = useState('');
const [phone, setPhone] = useState<PhoneInputValue | null>(null);
@@ -74,6 +83,9 @@ export default function SupplementalInfoPage({ params }: PageProps) {
setData(payload.data);
setFullName(payload.data.client.fullName ?? '');
setAddress(payload.data.client.streetAddress ?? '');
setCity(payload.data.client.city ?? '');
setRegion(payload.data.client.subdivisionIso ?? '');
setPostalCode(payload.data.client.postalCode ?? '');
setCountry((payload.data.client.country as CountryCode | null) ?? null);
setEmail(payload.data.client.primaryEmail ?? '');
if (payload.data.client.primaryPhone) {
@@ -114,6 +126,9 @@ export default function SupplementalInfoPage({ params }: PageProps) {
body: JSON.stringify({
fullName: fullName.trim(),
address: address.trim() || null,
city: city.trim() || null,
subdivisionIso: region.trim() || null,
postalCode: postalCode.trim() || null,
country: country ?? null,
email: email.trim() || null,
phoneE164: phone?.e164 ?? null,
@@ -137,9 +152,20 @@ export default function SupplementalInfoPage({ params }: PageProps) {
}
}
// Branding prop fed into BrandedAuthShell. Falls back to nulls until
// `data` lands; once it lands the shell re-renders with the resolved
// port logo + backdrop.
const branding = data
? {
logoUrl: data.port.logoUrl,
backgroundUrl: data.port.backgroundUrl,
appName: data.port.name,
}
: undefined;
if (loading) {
return (
<BrandedAuthShell>
<BrandedAuthShell width="md" branding={branding}>
<div role="status" aria-live="polite" className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<span className="sr-only">Loading</span>
@@ -150,7 +176,7 @@ export default function SupplementalInfoPage({ params }: PageProps) {
if (error) {
return (
<BrandedAuthShell>
<BrandedAuthShell width="md" branding={branding}>
<div role="alert" aria-live="assertive" className="text-center space-y-2 py-6">
<p className="text-sm text-muted-foreground">{error}</p>
</div>
@@ -158,19 +184,15 @@ export default function SupplementalInfoPage({ params }: PageProps) {
);
}
// Tokens are now reusable until expiry - the consumed flag is kept
// so the form can show a soft "you've submitted this before" banner
// (and prefill the entered values) without locking the recipient out
// of updating their details.
if (submitted) {
return (
<BrandedAuthShell>
<BrandedAuthShell width="md" branding={branding}>
<div className="text-center space-y-3 py-6">
<CheckCircle2 className="h-10 w-10 text-emerald-600 mx-auto" />
<h1 className="text-lg font-semibold">Thanks, got it</h1>
<p className="text-sm text-muted-foreground">
Your details have been sent to the team. Watch your inbox for your EOI document shortly.
Your details have been sent to the team at {data?.port.name ?? 'the marina'}. Watch your
inbox for your EOI document shortly.
</p>
</div>
</BrandedAuthShell>
@@ -178,13 +200,17 @@ export default function SupplementalInfoPage({ params }: PageProps) {
}
return (
<BrandedAuthShell>
<BrandedAuthShell width="md" branding={branding}>
<form onSubmit={onSubmit} className="space-y-6">
<div className="space-y-1 text-center">
<div className="space-y-2 text-center">
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{data?.port.name}
</p>
<h1 className="text-xl font-semibold">A few details before we draft your EOI</h1>
<p className="text-sm text-muted-foreground">
We&apos;ve pre-filled what we have on file. Please review, correct anything that&apos;s
wrong, and add what&apos;s missing.
wrong, and add what&apos;s missing. Submissions go straight to the team handling your
application.
</p>
{data?.token.consumed ? (
<div className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
@@ -227,19 +253,45 @@ export default function SupplementalInfoPage({ params }: PageProps) {
</div>
<div className="space-y-1.5">
<Label htmlFor="address">Address</Label>
<Label htmlFor="address">Street address</Label>
<Textarea
id="address"
rows={2}
value={address}
onChange={(e) => setAddress(e.target.value)}
placeholder="Street, city, postal code"
placeholder="Apartment, suite, building, street"
/>
</div>
<div className="space-y-1.5">
<Label>Country</Label>
<CountryCombobox value={country} onChange={(c) => setCountry(c ?? null)} clearable />
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="city">City</Label>
<Input id="city" value={city} onChange={(e) => setCity(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label htmlFor="postalCode">Postal code</Label>
<Input
id="postalCode"
value={postalCode}
onChange={(e) => setPostalCode(e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="region">Region / state</Label>
<Input
id="region"
value={region}
onChange={(e) => setRegion(e.target.value)}
placeholder="Optional"
/>
</div>
<div className="space-y-1.5">
<Label>Country</Label>
<CountryCombobox value={country} onChange={(c) => setCountry(c ?? null)} clearable />
</div>
</div>
</fieldset>
@@ -299,7 +351,7 @@ export default function SupplementalInfoPage({ params }: PageProps) {
</Button>
<p className="text-center text-xs text-muted-foreground">
This link is private to you and expires after one use.
This link is private to you. You can come back and update your details until it expires.
</p>
</form>
</BrandedAuthShell>