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:
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
43
src/app/(dashboard)/[portSlug]/expenses/layout.tsx
Normal file
43
src/app/(dashboard)/[portSlug]/expenses/layout.tsx
Normal 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`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
42
src/app/(dashboard)/[portSlug]/invoices/layout.tsx
Normal file
42
src/app/(dashboard)/[portSlug]/invoices/layout.tsx
Normal 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`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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've pre-filled what we have on file. Please review, correct anything that's
|
||||
wrong, and add what's missing.
|
||||
wrong, and add what'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>
|
||||
|
||||
Reference in New Issue
Block a user