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

@@ -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>