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

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