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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user