7 Commits

Author SHA1 Message Date
Matt Ciaccio
8699f81879 chore(style): codebase em-dash sweep + minor layout polish
Some checks failed
Build & Push Docker Images / lint (push) Failing after 1m18s
Build & Push Docker Images / build-and-push (push) Has been skipped
Replaces every em-dash and en-dash with regular ASCII hyphens
across comments, JSX strings, and dev-facing logs. Mostly cosmetic
but stops the inconsistent mix that crept in over the last few
months (some files used em-dashes in comments, others didn't,
some used both).

Bundles two small dashboard-layout tweaks that touch a couple of
already-modified files:
- (dashboard)/layout.tsx main padding goes from p-6 to pt-3 px-6
  pb-6 so page content sits closer to the topbar.
- Sidebar now receives the ports list it needs for the footer
  port switcher.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:57:01 +02:00
Matt Ciaccio
d62822c284 fix(migration): NocoDB import safety + dedup helpers + lead-source backfill
migration-apply: residential client + interest inserts now wrap in
db.transaction so a partial failure can't leave an orphan client
row without its interest (or vice versa).

migration-transform: buildPlannedDocument returns null when there
are no signers so the apply pass doesn't try to send a Documenso
envelope without recipients. mapDocumentStatus gets an explicit
"Awaiting Further Details" branch that no longer auto-promotes via
stale sign-time fields. parseFlexibleDate handles ISO and DD-MM-YYYY
inputs uniformly.

backfill-legacy-lead-source: chunk UPDATE WHERE clause now
isNull(source) on top of the inArray match, so a re-run can't
overwrite a more accurate source written between batches.

Adds 235 lines of vitest coverage on migration-transform.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:56:18 +02:00
Matt Ciaccio
089f4a67a4 feat(receipts): upload guide page + scanner head-tag fix
Adds /invoices/upload-receipts as the dedicated explainer for the
mobile scanner PWA: install instructions for iOS/Android, direct
deep-link button, and a walkthrough of the scan -> verify -> save
flow. Sidebar entry replaces the old "Scan receipt" tab so the
desktop side picks up the install steps before sending users to
the mobile-only surface.

Scanner layout moves PWA manifest + apple-* meta tags from inline
JSX into Next.js's metadata/viewport exports so the App Router
doesn't try to render a second <head>, fixing a hydration error
that surfaced as two console warnings on the scan page.

Scanner shell gains a centered Port Nimara logo header so the
standalone PWA looks branded when launched from the home screen
without the dashboard chrome.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:55:42 +02:00
Matt Ciaccio
77ad10ced1 feat(dashboard): custom date range + KPI port-hydration gate
DateRangePicker grows a "Custom range" mode (From/To inputs capped
at today, mutually-bounded so From <= To). dashboard-shell threads
the range through to /api/v1/analytics, which validates calendar
dates via ISO round-trip and enforces a 365-day cap as a backstop
against the occupancy timeline N+1.

KpiCards now gates its query on currentPortId so the early
unhydrated-store fetch can't cache a zeroed/error response and
display "-" until staleTime expires.

MyRemindersRail drops xl:h-full so the rail no longer stretches
past its grid row and overlaps ActivityFeed below.

useRealtimeInvalidation switches to partial-prefix queryKeys so a
realtime mutation invalidates every cached range bucket at once
instead of just the one currently visible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:54:55 +02:00
Matt Ciaccio
e598cc0708 feat(layout): unified Inbox + UserMenu extraction
Replaces the topbar's separate AlertBell + NotificationBell with a
single Inbox popover that tabs between alerts and notifications.
NotificationBell keeps a popover-gate so it doesn't fire its list
fetch when Inbox is mounted alongside it.

Extracts the user dropdown into <UserMenu> and moves the port
switcher + role label + theme toggle into the sidebar footer so
the topbar can reclaim space for breadcrumbs and command search.

Adds dedicated Insights / Receipts nav sections in the sidebar
(scaffolds the website-analytics + upload-receipts entry points).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:54:06 +02:00
Matt Ciaccio
f5772ce318 feat(analytics): Umami integration with per-port admin settings
Adds /[portSlug]/website-analytics dashboard page (pageviews, top
pages, top referrers) and a per-port admin config UI for the
Umami URL / website-ID / API token. Settings live in system_settings
keyed per-port so a future second port has its own Umami account.
Adds a website glance tile to the main dashboard, a server-side
test-credentials endpoint, and a stable cache key for the active-
visitor poll so React Query doesn't fragment the cache per range.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:53:06 +02:00
Matt Ciaccio
49d34e00c8 feat(website-intake): dual-write endpoint + migration chain repair
Adds website_submissions table + shared-secret POST endpoint so the
marketing site can dual-write inquiries alongside its NocoDB write.
Race-safe via INSERT ... ON CONFLICT, idempotent on submission_id,
refuses every request when WEBSITE_INTAKE_SECRET is unset. Also
repairs pre-existing 0020/0021/0022 prevId collision (renumbered +
journal re-sorted) so db:generate works again. 11 unit tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:52:33 +02:00
279 changed files with 15738 additions and 1126 deletions

View File

@@ -0,0 +1,135 @@
/**
* One-shot: backfill `interests.source` for legacy NocoDB-imported rows.
*
* Why this exists: the legacy NocoDB Interests table left the `Source`
* column null for ~95 % of rows. The migration mapped null → null, so the
* Lead Source Attribution chart shows them as "Unspecified". Per the
* operator's best knowledge, almost all of those legacy rows came in
* through the website (web form / portal) — the few that didn't are the
* ones that already carry an explicit `Source` value (Form / portal /
* External). Defaulting null → 'website' is therefore the closest
* truth we can reconstruct without per-row sales notes review.
*
* Idempotent: only updates rows where `source IS NULL` AND the row has a
* `migration_source_links` entry tying it back to the legacy NocoDB import,
* so net-new manually-created interests with null source aren't touched.
*
* Usage:
* pnpm tsx scripts/backfill-legacy-lead-source.ts --port-slug port-nimara [--dry-run]
*/
import 'dotenv/config';
import { eq, and, isNull, inArray } from 'drizzle-orm';
import { db } from '@/lib/db';
import { ports } from '@/lib/db/schema/ports';
import { interests } from '@/lib/db/schema/interests';
import { migrationSourceLinks } from '@/lib/db/schema/migration';
interface CliArgs {
portSlug: string | null;
dryRun: boolean;
}
function parseArgs(argv: string[]): CliArgs {
const args: CliArgs = { portSlug: null, dryRun: false };
for (let i = 0; i < argv.length; i += 1) {
const a = argv[i]!;
if (a === '--port-slug') args.portSlug = argv[++i] ?? null;
else if (a === '--dry-run') args.dryRun = true;
else if (a === '-h' || a === '--help') {
console.log(
'Usage: pnpm tsx scripts/backfill-legacy-lead-source.ts --port-slug <slug> [--dry-run]',
);
process.exit(0);
}
}
if (!args.portSlug) {
console.error('Missing required --port-slug');
process.exit(1);
}
return args;
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const [port] = await db
.select({ id: ports.id, name: ports.name })
.from(ports)
.where(eq(ports.slug, args.portSlug!))
.limit(1);
if (!port) {
console.error(`No port found with slug "${args.portSlug}"`);
process.exit(1);
}
console.log(`[backfill] target: ${port.name} (${port.id})`);
// Pull every interest id this port owns that has a NULL source.
const candidateInterests = await db
.select({ id: interests.id })
.from(interests)
.where(and(eq(interests.portId, port.id), isNull(interests.source)));
console.log(`[backfill] interests with NULL source in this port: ${candidateInterests.length}`);
if (candidateInterests.length === 0) {
console.log('Nothing to backfill.');
return;
}
// Filter to ONLY those that came in via the legacy migration — preserves
// null on net-new rows where the operator hasn't picked a source yet.
const candidateIds = candidateInterests.map((r) => r.id);
const legacyLinks = await db
.select({ targetEntityId: migrationSourceLinks.targetEntityId })
.from(migrationSourceLinks)
.where(
and(
eq(migrationSourceLinks.sourceSystem, 'nocodb_interests'),
eq(migrationSourceLinks.targetEntityType, 'interest'),
inArray(migrationSourceLinks.targetEntityId, candidateIds),
),
);
const legacyIds = new Set(legacyLinks.map((l) => l.targetEntityId));
const toUpdate = candidateIds.filter((id) => legacyIds.has(id));
console.log(
`[backfill] of those, ${toUpdate.length} are legacy migration rows (will set source='website')`,
);
console.log(
`[backfill] ${candidateInterests.length - toUpdate.length} are net-new rows (left untouched)`,
);
if (args.dryRun) {
console.log('[backfill] --dry-run set; no writes.');
return;
}
if (toUpdate.length === 0) {
console.log('Nothing to write.');
return;
}
// Update in chunks of 500 to keep query size sane.
const CHUNK = 500;
let updated = 0;
for (let i = 0; i < toUpdate.length; i += CHUNK) {
const chunk = toUpdate.slice(i, i + CHUNK);
// Belt-and-suspenders: re-assert `source IS NULL` in the WHERE so
// a concurrent process that set source on one of these rows
// between SELECT and UPDATE doesn't get its value clobbered.
const result = await db
.update(interests)
.set({ source: 'website' })
.where(and(inArray(interests.id, chunk), isNull(interests.source)))
.returning({ id: interests.id });
updated += result.length;
}
console.log(`[backfill] updated ${updated} rows.`);
}
main().catch((err) => {
console.error('FATAL', err);
process.exit(1);
});

View File

@@ -178,6 +178,12 @@ async function main(): Promise<void> {
);
console.log(` Output: ${s.outputClients} clients, ${s.outputInterests} interests`);
console.log(` ${s.outputContacts} contacts, ${s.outputAddresses} addresses`);
console.log(
` ${s.outputDocuments} EOI documents, ${s.outputDocumentSigners} signers`,
);
console.log(
` ${s.outputResidentialClients} residential clients (with default-stage interests)`,
);
console.log(
` Dedup: ${s.autoLinkedClusters} auto-linked clusters, ${s.needsReviewPairs} pairs flagged for review`,
);
@@ -217,6 +223,14 @@ async function main(): Promise<void> {
console.log(
` Interests: ${result.interestsInserted} inserted, ${result.interestsSkipped} already linked`,
);
console.log(
` Documents: ${result.documentsInserted} inserted, ${result.documentsSkipped} already linked`,
);
console.log(` Signers: ${result.documentSignersInserted} inserted`);
console.log(
` Res-Clt: ${result.residentialClientsInserted} inserted, ${result.residentialClientsSkipped} already linked`,
);
console.log(` Res-Int: ${result.residentialInterestsInserted} inserted`);
if (result.warnings.length > 0) {
console.log('');

View File

@@ -34,7 +34,7 @@ const FIELDS: SettingFieldDef[] = [
label: 'Default signature (HTML)',
description: 'Appended to the bottom of system-generated emails.',
type: 'html',
placeholder: '<p><br>The Port Nimara team</p>',
placeholder: '<p>-<br>The Port Nimara team</p>',
defaultValue: '',
},
{
@@ -71,7 +71,7 @@ const FIELDS: SettingFieldDef[] = [
{
key: 'smtp_pass_override',
label: 'SMTP password override',
description: 'Optional. Stored in plain text only set when overriding env credentials.',
description: 'Optional. Stored in plain text - only set when overriding env credentials.',
type: 'password',
defaultValue: '',
},

View File

@@ -18,6 +18,7 @@ import {
Users,
UsersRound,
Webhook,
Globe,
} from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -209,6 +210,12 @@ const GROUPS: AdminGroup[] = [
description: 'Configure the AI provider used by the mobile receipt scanner.',
icon: ScrollText,
},
{
href: 'website-analytics',
label: 'Website analytics (Umami)',
description: 'Per-port Umami URL, API token, and Website ID.',
icon: Globe,
},
],
},
];

View File

@@ -0,0 +1,74 @@
import {
SettingsFormCard,
type SettingFieldDef,
} from '@/components/admin/shared/settings-form-card';
import { UmamiTestButton } from '@/components/admin/website-analytics/umami-test-button';
import { PageHeader } from '@/components/shared/page-header';
/**
* Per-port Umami credentials. We deliberately keep all three values
* port-scoped (per the operator decision) so different ports can point at
* different Umami instances if needed. The /website-analytics dashboard
* page reads these settings via the umami.service layer at request time.
*/
const FIELDS: SettingFieldDef[] = [
{
key: 'umami_api_url',
label: 'Umami API URL',
description:
'Base URL of the Umami instance, e.g. https://analytics.portnimara.com (no trailing slash, no /api).',
type: 'string',
placeholder: 'https://analytics.portnimara.com',
defaultValue: '',
},
{
key: 'umami_api_token',
label: 'API token',
description:
'Long-lived API token if your Umami install supports one (Umami Cloud or v2 self-hosted with API keys enabled). Leave blank if you only have username/password - the service falls back to the JWT login flow using the credentials below. Stored in plain text in system_settings.',
type: 'password',
defaultValue: '',
},
{
key: 'umami_username',
label: 'Username',
description: 'Self-hosted JWT fallback. Only used if API token is blank.',
type: 'string',
placeholder: 'admin',
defaultValue: '',
},
{
key: 'umami_password',
label: 'Password',
description: 'Self-hosted JWT fallback. Only used if API token is blank.',
type: 'password',
defaultValue: '',
},
{
key: 'umami_website_id',
label: 'Website ID',
description:
'UUID of this ports website inside Umami. Find it in Umami → Settings → Websites → Edit → Website ID.',
type: 'string',
placeholder: '00000000-0000-0000-0000-000000000000',
defaultValue: '',
},
];
export default function WebsiteAnalyticsSettingsPage() {
return (
<div className="space-y-6">
<PageHeader
title="Website analytics (Umami)"
description="Connect this port to its Umami website to display traffic, top pages, referrers, and conversion data on the Website Analytics dashboard."
/>
<SettingsFormCard
title="Umami connection"
description="Per-port credentials. Each port can point at its own Umami instance; or share one instance with different website IDs."
fields={FIELDS}
extra={<UmamiTestButton />}
/>
</div>
);
}

View File

@@ -4,13 +4,13 @@ import { CardSkeleton } from '@/components/shared/loading-skeleton';
/**
* Route-level loading UI for the client detail page. Renders while the
* server component resolves the session and the client component bootstraps
* its initial query replaces the previous empty-header flash on direct
* its initial query - replaces the previous empty-header flash on direct
* URL visits.
*/
export default function Loading() {
return (
<div className="space-y-6">
{/* Header strip title, badges, action buttons */}
{/* Header strip - title, badges, action buttons */}
<div className="rounded-xl border border-border bg-card px-5 py-4 shadow-sm space-y-3">
<div className="flex items-center gap-3">
<Skeleton className="h-7 w-56" />

View File

@@ -59,7 +59,7 @@ export default function NewInvoicePage() {
}, [setChrome]);
// When the form is launched from an interest detail with `?interestId=…&kind=deposit`,
// fetch enough of the interest to display "Deposit for {client} Berth {n}" in
// fetch enough of the interest to display "Deposit for {client} - Berth {n}" in
// the review step. Doubles as the source of truth for the billing entity prefill.
const { data: prefilledInterest } = useQuery<{
data: {
@@ -184,7 +184,7 @@ export default function NewInvoicePage() {
return (
<div className="max-w-2xl mx-auto space-y-6">
{/* Header desktop only; mobile gets the title from the topbar */}
{/* Header - desktop only; mobile gets the title from the topbar */}
<div className="hidden sm:flex items-center gap-3">
<Button variant="ghost" size="sm" onClick={() => router.push(`/${portSlug}/invoices`)}>
<ChevronLeft className="h-4 w-4" />
@@ -233,7 +233,7 @@ export default function NewInvoicePage() {
{prefilledInterest?.data
? `Linked to ${prefilledInterest.data.clientName ?? 'interest'}${
prefilledInterest.data.berthMooringNumber
? ` Berth ${prefilledInterest.data.berthMooringNumber}`
? ` - Berth ${prefilledInterest.data.berthMooringNumber}`
: ''
}. Marking this invoice as paid will advance the interest to "Deposit 10%".`
: 'Marking this invoice as paid will advance the linked interest to "Deposit 10%".'}

View File

@@ -0,0 +1,16 @@
import type { Metadata } from 'next';
import { UploadReceiptsGuide } from '@/components/invoices/upload-receipts-guide';
export const metadata: Metadata = {
title: 'How to upload receipts',
};
export default async function UploadReceiptsPage({
params,
}: {
params: Promise<{ portSlug: string }>;
}) {
const { portSlug } = await params;
return <UploadReceiptsGuide portSlug={portSlug} />;
}

View File

@@ -0,0 +1,11 @@
import type { Metadata } from 'next';
import { WebsiteAnalyticsShell } from '@/components/website-analytics/website-analytics-shell';
export const metadata: Metadata = {
title: 'Website analytics',
};
export default function WebsiteAnalyticsPage() {
return <WebsiteAnalyticsShell />;
}

View File

@@ -40,7 +40,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
<PermissionsProvider>
<SocketProvider>
<RealtimeToasts />
{/* Desktop shell hidden by CSS on mobile */}
{/* Desktop shell - hidden by CSS on mobile */}
<div data-shell="desktop" className="flex h-screen overflow-hidden bg-background">
<Sidebar
portRoles={portRoles}
@@ -49,6 +49,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
name: profile?.displayName ?? session.user.name ?? session.user.email,
email: session.user.email,
}}
ports={ports}
/>
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
<Topbar
@@ -58,11 +59,13 @@ export default async function DashboardLayout({ children }: { children: React.Re
email: session.user.email,
}}
/>
<main className="flex-1 overflow-y-auto bg-background p-6">{children}</main>
<main className="flex-1 overflow-y-auto bg-background pt-3 px-6 pb-6">
{children}
</main>
</div>
</div>
{/* Mobile shell hidden by CSS on desktop */}
{/* Mobile shell - hidden by CSS on desktop */}
<MobileLayout>{children}</MobileLayout>
</SocketProvider>
</PermissionsProvider>

View File

@@ -12,14 +12,10 @@ export const metadata: Metadata = {
},
};
export default async function PortalLayout({
children,
}: {
children: React.ReactNode;
}) {
export default async function PortalLayout({ children }: { children: React.ReactNode }) {
// This layout wraps all portal routes including login/verify
// We can't easily check pathname in a server layout, so we attempt
// to get the session and pass it down login/verify pages handle their own
// to get the session and pass it down - login/verify pages handle their own
// redirect logic independently.
const session = await getPortalSession().catch(() => null);
@@ -42,17 +38,11 @@ export default async function PortalLayout({
<div className="min-h-screen bg-gray-50">
{session && (
<>
<PortalHeader
portName={portName}
portLogoUrl={portLogoUrl}
clientName={clientName}
/>
<PortalHeader portName={portName} portLogoUrl={portLogoUrl} clientName={clientName} />
<PortalNav />
</>
)}
<main className={session ? 'max-w-5xl mx-auto px-4 sm:px-6 py-8' : ''}>
{children}
</main>
<main className={session ? 'max-w-5xl mx-auto px-4 sm:px-6 py-8' : ''}>{children}</main>
</div>
);
}

View File

@@ -14,7 +14,7 @@ export default function PortalActivatePage() {
<PasswordSetForm
endpoint="/api/portal/auth/activate"
title="Activate your account"
description="Welcome choose a password to finish setting up your client portal account."
description="Welcome - choose a password to finish setting up your client portal account."
successTitle="Account activated"
successDescription="You can now sign in with your new password."
submitLabel="Activate account"

View File

@@ -18,7 +18,7 @@ export default function PortalForgotPasswordPage() {
e.preventDefault();
setLoading(true);
try {
// Always returns 200 caller never sees whether email exists.
// Always returns 200 - caller never sees whether email exists.
await fetch('/api/portal/auth/forgot-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },

View File

@@ -57,7 +57,7 @@ export default async function PortalInterestsPage() {
<span className="font-medium text-gray-900">General Interest</span>
)}
{interest.berthArea && (
<span className="text-sm text-gray-400"> {interest.berthArea}</span>
<span className="text-sm text-gray-400">- {interest.berthArea}</span>
)}
</div>
{interest.leadCategory && (

View File

@@ -59,7 +59,7 @@ export default async function PortalMyReservationsPage() {
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-gray-900">{r.yachtName ?? 'Yacht'}</span>
{r.berthMooringNumber && (
<span className="text-sm text-gray-400"> Berth {r.berthMooringNumber}</span>
<span className="text-sm text-gray-400">- Berth {r.berthMooringNumber}</span>
)}
</div>
<p className="text-sm text-gray-500">

View File

@@ -1,20 +1,51 @@
import type { Metadata, Viewport } from 'next';
import { redirect } from 'next/navigation';
import { headers } from 'next/headers';
import { eq } from 'drizzle-orm';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { ports as portsTable } from '@/lib/db/schema/ports';
import { QueryProvider } from '@/providers/query-provider';
import { PortProvider } from '@/providers/port-provider';
import { eq } from 'drizzle-orm';
/**
* Minimal layout for the mobile receipt-scanner PWA. No sidebar, no
* topbar the scanner is its own contained surface. Adds the PWA
* manifest link + theme color so iOS/Android pick up "Add to Home
* Screen". Auth check matches the dashboard layout so unauthorized
* users still bounce to /login.
* topbar - the scanner is its own contained surface. PWA manifest +
* iOS web-app meta tags are emitted via Next.js's metadata/viewport
* exports so React doesn't try to render a second `<head>` mid-tree
* (which throws hydration errors in the App Router). Auth check
* matches the dashboard layout so unauthorized users still bounce.
*/
export async function generateMetadata({
params,
}: {
params: Promise<{ portSlug: string }>;
}): Promise<Metadata> {
const { portSlug } = await params;
return {
manifest: `/${portSlug}/scan/manifest.webmanifest`,
appleWebApp: {
capable: true,
title: 'PN Scanner',
statusBarStyle: 'default',
},
other: {
// Android/Chrome equivalent of the apple-* meta. metadata.appleWebApp
// covers iOS only; this preserves the existing PWA hint for Chrome.
'mobile-web-app-capable': 'yes',
},
};
}
export const viewport: Viewport = {
themeColor: '#3a7bc8',
width: 'device-width',
initialScale: 1,
viewportFit: 'cover',
};
export default async function ScannerLayout({
children,
params,
@@ -33,16 +64,7 @@ export default async function ScannerLayout({
return (
<QueryProvider>
<PortProvider ports={port ? [port] : []} defaultPortId={port?.id ?? null}>
<head>
<link rel="manifest" href={`/${portSlug}/scan/manifest.webmanifest`} />
<meta name="theme-color" content="#3a7bc8" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="PN Scanner" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
</head>
<PortProvider ports={[port]} defaultPortId={port.id}>
<div className="min-h-[100dvh] bg-background">{children}</div>
</PortProvider>
</QueryProvider>

View File

@@ -15,7 +15,7 @@ export async function GET(_req: Request, { params }: { params: Promise<{ portSlu
const portName = port?.name ?? 'Port Nimara';
const manifest = {
name: `${portName} Scanner`,
name: `${portName} - Scanner`,
short_name: 'Scanner',
description: `Capture and submit expense receipts for ${portName}.`,
start_url: `/${portSlug}/scan`,

View File

@@ -3,7 +3,7 @@ import type { Metadata } from 'next';
import { ScanShell } from '@/components/scan/scan-shell';
export const metadata: Metadata = {
title: 'Scan receipt Port Nimara',
title: 'Scan receipt - Port Nimara',
};
export default function ScanPage() {

View File

@@ -1,11 +1,11 @@
import { NextResponse } from 'next/server';
/**
* Liveness probe confirms the Next.js process is responding.
* Liveness probe - confirms the Next.js process is responding.
*
* Returns 200 unconditionally; if the process is wedged or has crashed
* the request never lands here at all. Do NOT include database/Redis/MinIO
* checks in this endpoint a transient downstream blip should drop the
* checks in this endpoint - a transient downstream blip should drop the
* pod from the load balancer (readiness), not restart the pod (liveness).
*
* For deep dependency checks, hit `/api/ready` instead.

View File

@@ -36,7 +36,7 @@ type PublicInterestData = z.infer<typeof publicInterestSchema>;
// Keep the helper aligned with that.
type Tx = typeof db;
// POST /api/public/interests unauthenticated public interest registration.
// POST /api/public/interests - unauthenticated public interest registration.
// Creates the trio (client + yacht + interest) plus an optional company +
// membership, all inside a single transaction.
export async function POST(req: NextRequest) {
@@ -70,7 +70,7 @@ export async function POST(req: NextRequest) {
const firstName = data.firstName ?? fullName.split(/\s+/)[0] ?? 'Valued Guest';
// Resolve berth by mooring number (if provided). Read-only lookup safe
// Resolve berth by mooring number (if provided). Read-only lookup - safe
// to do outside the transaction.
let berthId: string | null = null;
let resolvedMooringNumber: string | null = data.mooringNumber ?? null;

View File

@@ -34,7 +34,7 @@ async function gateRateLimit(ip: string): Promise<void> {
}
/**
* POST /api/public/residential-inquiries unauthenticated entry point for
* POST /api/public/residential-inquiries - unauthenticated entry point for
* the public website's residential interest form. Creates a
* `residential_clients` row and an opening `residential_interests` row in a
* single transaction.
@@ -110,7 +110,7 @@ export async function POST(req: NextRequest) {
emitToRoom(`port:${portId}`, 'residential_client:created', { id: result.clientId });
emitToRoom(`port:${portId}`, 'residential_interest:created', { id: result.interestId });
// Send notification emails (non-blocking failures shouldn't 500 the
// Send notification emails (non-blocking - failures shouldn't 500 the
// public form).
void sendResidentialNotifications({
portId,
@@ -147,7 +147,7 @@ async function sendResidentialNotifications(args: {
});
await sendEmail(data.email, confirmation.subject, confirmation.html);
// Sales-team alert pull recipients from system_settings if configured;
// Sales-team alert - pull recipients from system_settings if configured;
// fall back to the inquiry_contact_email if available.
const recipientsRow = await db.query.systemSettings.findFirst({
where: and(

View File

@@ -0,0 +1,177 @@
import { NextRequest, NextResponse } from 'next/server';
import { timingSafeEqual } from 'node:crypto';
import { z } from 'zod';
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { ports } from '@/lib/db/schema/ports';
import { websiteSubmissions } from '@/lib/db/schema/website-submissions';
import { env } from '@/lib/env';
import { logger } from '@/lib/logger';
import { checkRateLimit, rateLimiters } from '@/lib/rate-limit';
/**
* POST /api/public/website-inquiries
*
* Capture endpoint for the marketing website's dual-write. The website
* server (`/server/api/register.ts`, `/server/api/contact.ts`) calls this
* AFTER its existing NocoDB write succeeds, sending the same payload as a
* server-to-server fire-and-forget POST. The CRM stores the raw payload
* in `website_submissions` for later analysis / promotion to entities.
*
* Auth: shared-secret in `X-Webhook-Secret` header, timing-safe compared
* against `WEBSITE_INTAKE_SECRET`. If the env var is unset on this
* instance, the endpoint refuses every request with 503 - the correct
* posture for dev/staging that hasn't been wired up yet.
*
* Idempotency: payload carries a `submission_id` UUID. The unique index
* on `website_submissions.submission_id` makes redelivery a no-op; the
* handler returns 200 + the existing record's id instead of erroring.
*
* No emails / no `interests` rows are created here. The endpoint's job is
* pure data capture. A separate "promote" step (future) will turn captured
* submissions into proper `clients` + `interests` rows once we trust the
* pipeline.
*/
const SubmissionSchema = z.object({
submission_id: z.string().uuid(),
kind: z.enum(['berth_inquiry', 'residence_inquiry', 'contact_form']),
payload: z.record(z.unknown()),
legacy_nocodb_id: z.string().optional(),
/** 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'),
});
function verifySecret(header: string | null): boolean {
const expected = env.WEBSITE_INTAKE_SECRET;
if (!expected) return false;
if (!header) return false;
// Timing-safe compare requires equal-length buffers; pad to whichever is
// longer so an early-exit on length mismatch can't leak the secret length.
const a = Buffer.from(header);
const b = Buffer.from(expected);
const pad = Buffer.alloc(Math.max(a.length, b.length));
const aPad = Buffer.concat([a, pad]).subarray(0, pad.length);
const bPad = Buffer.concat([b, pad]).subarray(0, pad.length);
return timingSafeEqual(aPad, bPad) && a.length === b.length;
}
export async function POST(req: NextRequest) {
// Refuse outright if the CRM hasn't been wired up - safer than letting
// unauthenticated traffic in just because the env var was forgotten.
if (!env.WEBSITE_INTAKE_SECRET) {
return NextResponse.json(
{ error: 'Website intake is not configured on this server.' },
{ status: 503 },
);
}
// Auth gate - shared secret in header, timing-safe compare.
const secretHeader = req.headers.get('x-webhook-secret');
if (!verifySecret(secretHeader)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Rate limit. All website-side traffic shares the website's egress IP,
// so we use a dedicated bucket sized to accommodate normal traffic
// (500/hr) rather than the 5/hr publicForm bucket meant for individual
// human submissions. The shared-secret header is the real abuse
// boundary; this limiter is just a backstop if the secret ever leaks.
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';
const rl = await checkRateLimit(ip, rateLimiters.websiteIntake);
if (!rl.allowed) {
const retryAfter = Math.max(1, Math.ceil((rl.resetAt - Date.now()) / 1000));
return NextResponse.json(
{ error: 'Rate limit exceeded' },
{ status: 429, headers: { 'Retry-After': String(retryAfter) } },
);
}
// Parse + validate body. Reject anything that doesn't conform — the
// website is a known caller; a malformed payload signals tampering.
let parsed;
try {
const body = await req.json();
parsed = SubmissionSchema.parse(body);
} catch (err) {
return NextResponse.json(
{ error: 'Invalid payload', details: err instanceof Error ? err.message : 'parse error' },
{ status: 400 },
);
}
// Resolve port. We require the slug to exist; can't capture submissions
// for a port the CRM doesn't know about.
const [port] = await db
.select({ id: ports.id })
.from(ports)
.where(eq(ports.slug, parsed.port_slug))
.limit(1);
if (!port) {
// Don't echo the input slug back in the error - generic message is
// sufficient and avoids the input-reflection pattern that complicates
// log-injection / audit reviews. The slug is logged server-side
// for debugging.
logger.warn(
{ portSlug: parsed.port_slug, submissionId: parsed.submission_id },
'website-inquiry rejected: unknown port',
);
return NextResponse.json({ error: 'Unknown port' }, { status: 400 });
}
// Idempotent insert. Two parallel requests carrying the same submission_id
// could both pass any pre-check, so we don't pre-check at all - the unique
// index on submission_id is the source of truth, and `onConflictDoNothing`
// keeps the second request's INSERT from raising 23505. When the conflict
// hits, `returning()` yields zero rows and we look up the existing row to
// return its id, mirroring the first-delivery shape so the website never
// sees a difference between fresh and dup.
const insertResult = await db
.insert(websiteSubmissions)
.values({
portId: port.id,
submissionId: parsed.submission_id,
kind: parsed.kind,
payload: parsed.payload,
legacyNocodbId: parsed.legacy_nocodb_id ?? null,
sourceIp: ip,
userAgent: req.headers.get('user-agent') ?? null,
})
.onConflictDoNothing({ target: websiteSubmissions.submissionId })
.returning({ id: websiteSubmissions.id });
if (insertResult[0]) {
logger.info(
{
submissionId: parsed.submission_id,
kind: parsed.kind,
portSlug: parsed.port_slug,
legacyNocodbId: parsed.legacy_nocodb_id,
},
'website inquiry captured',
);
return NextResponse.json({ id: insertResult[0].id, deduped: false });
}
// Conflict path: row already exists. Fetch its id so the response shape
// stays identical regardless of which request "won" the race.
const existing = await db
.select({ id: websiteSubmissions.id })
.from(websiteSubmissions)
.where(eq(websiteSubmissions.submissionId, parsed.submission_id))
.limit(1);
if (existing[0]) {
return NextResponse.json({ id: existing[0].id, deduped: true });
}
// Should be unreachable - the conflict means a row exists, so the lookup
// above should always find it. If it doesn't (e.g. simultaneous DELETE),
// surface a 500 explicitly rather than silently 200ing a missing id.
logger.error(
{ submissionId: parsed.submission_id },
'website-inquiry conflict but row not found on lookup',
);
return NextResponse.json({ error: 'Insert failed' }, { status: 500 });
}

View File

@@ -21,7 +21,7 @@ interface ReadyResponse {
}
/**
* Readiness probe verifies that every backing service this process
* Readiness probe - verifies that every backing service this process
* needs to serve traffic is reachable. A 503 should drop the pod from the
* load balancer until the next probe succeeds; it should not trigger a
* pod restart (that's what `/api/health` is for).

View File

@@ -10,7 +10,7 @@ import { runAlertEngineForPorts } from '@/lib/services/alert-engine';
* exercised by the realapi socket fanout test.
*
* Requires super_admin or per-port admin permissions; the engine itself
* is idempotent duplicate runs only re-evaluate, never duplicate rows.
* is idempotent - duplicate runs only re-evaluate, never duplicate rows.
*/
export const POST = withAuth(async (_req, ctx) => {
try {

View File

@@ -5,7 +5,7 @@ import { errorResponse } from '@/lib/errors';
import { checkDocumensoHealth } from '@/lib/services/documenso-client';
/**
* Admin probe calls Documenso /api/v1/health using the port's effective
* Admin probe - calls Documenso /api/v1/health using the port's effective
* config. Used by the "Test connection" button on /admin/documenso.
*/
export const POST = withAuth(

View File

@@ -40,7 +40,7 @@ export async function listHandler(_req: Request, ctx: AuthContext): Promise<Next
.map((p) => {
const a = clientById.get(p.clientAId);
const b = clientById.get(p.clientBId);
if (!a || !b) return null; // FK orphan shouldn't happen, but be defensive
if (!a || !b) return null; // FK orphan - shouldn't happen, but be defensive
// Skip pairs where one side has already been merged or archived.
if (a.mergedIntoClientId || b.mergedIntoClientId) return null;
return {

View File

@@ -9,7 +9,7 @@ import { createCrmInvite, listCrmInvites } from '@/lib/services/crm-invite.servi
export const GET = withAuth(
withPermission('admin', 'manage_users', async (_req, ctx) => {
try {
// crm_user_invites is a global table (no per-port column) invites
// crm_user_invites is a global table (no per-port column) - invites
// mint better-auth users that may later be assigned roles in any
// port. Listing it cross-tenant would let a port-A director
// enumerate pending invitee emails, names, and isSuperAdmin flags

View File

@@ -13,7 +13,7 @@ const schema = z.object({
apiKey: z.string().min(1),
});
// `manage_settings`-gated for parity with the parent OCR settings route
// `manage_settings`-gated for parity with the parent OCR settings route -
// triggers outbound AI provider auth requests using a caller-supplied key.
export const POST = withAuth(
withPermission('admin', 'manage_settings', async (req) => {

View File

@@ -17,12 +17,12 @@ import { previewAdminTemplateSchema } from '@/lib/validators/document-templates'
* POST /api/v1/admin/templates/preview
*
* Generates a preview PDF from a TipTap JSON content block.
* Returns { data: { pdfBase64: string } } the client can render this
* Returns { data: { pdfBase64: string } } - the client can render this
* in an <iframe src="data:application/pdf;base64,..."> or open in a new tab.
*
* Body:
* content: TipTap JSON document
* sampleData?: Record<string, string> variable substitutions
* sampleData?: Record<string, string> - variable substitutions
*/
export const POST = withAuth(
withPermission('documents', 'manage', async (req, _ctx) => {
@@ -60,10 +60,7 @@ export const POST = withAuth(
/**
* Deeply substitutes {{variable}} tokens in all text nodes of a TipTap doc.
*/
function substituteInDoc(
node: TipTapNode,
data: Record<string, string>,
): TipTapNode {
function substituteInDoc(node: TipTapNode, data: Record<string, string>): TipTapNode {
if (node.type === 'text' && node.text) {
return { ...node, text: substituteVariables(node.text, data) };
}

View File

@@ -0,0 +1,24 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { testConnection } from '@/lib/services/umami.service';
/**
* POST /api/v1/admin/umami/test - admin-only Umami connection check.
*
* Returns `{ data: { ok: true, visitors } }` on success or
* `{ data: { ok: false, error } }` on failure. Mirrors the shape used by
* the Documenso health endpoint so the existing test-button UI pattern
* just works.
*/
export const POST = withAuth(
withPermission('admin', 'manage_settings', async (_req, ctx) => {
try {
const result = await testConnection(ctx.portId);
return NextResponse.json({ data: result });
} catch (err) {
const error = err instanceof Error ? err.message : 'Unknown error';
return NextResponse.json({ data: { ok: false, error } });
}
}),
);

View File

@@ -9,6 +9,7 @@ import {
getRevenueBreakdown,
type DateRange,
type MetricBase,
type PresetDateRange,
} from '@/lib/services/analytics.service';
const METRICS: Record<MetricBase, (portId: string, range: DateRange) => Promise<unknown>> = {
@@ -18,17 +19,69 @@ const METRICS: Record<MetricBase, (portId: string, range: DateRange) => Promise<
lead_source_attribution: getLeadSourceAttribution,
};
const ISO_DATE_RX = /^\d{4}-\d{2}-\d{2}$/;
export const GET = withAuth(
withPermission('reports', 'view_analytics', async (req: NextRequest, ctx) => {
const url = new URL(req.url);
const metric = url.searchParams.get('metric') as MetricBase | null;
const range = (url.searchParams.get('range') ?? '30d') as DateRange;
const rawRange = url.searchParams.get('range') ?? '30d';
const fromParam = url.searchParams.get('from');
const toParam = url.searchParams.get('to');
if (!metric || !(metric in METRICS)) {
return NextResponse.json({ error: 'Invalid or missing metric' }, { status: 400 });
}
if (!ALL_RANGES.includes(range)) {
return NextResponse.json({ error: 'Invalid range' }, { status: 400 });
let range: DateRange;
if (rawRange === 'custom') {
if (!fromParam || !toParam) {
return NextResponse.json(
{ error: 'Custom range requires `from` and `to` (YYYY-MM-DD)' },
{ status: 400 },
);
}
if (!ISO_DATE_RX.test(fromParam) || !ISO_DATE_RX.test(toParam)) {
return NextResponse.json(
{ error: '`from`/`to` must be ISO date strings (YYYY-MM-DD)' },
{ status: 400 },
);
}
if (fromParam > toParam) {
return NextResponse.json({ error: '`from` must be on or before `to`' }, { status: 400 });
}
// Round-trip date check: regex passes "9999-13-99" or "2026-02-31"
// (rolls over silently when handed to `new Date`). Re-serialize and
// confirm it matches the input to catch invalid calendar values.
for (const [label, raw] of [
['from', fromParam],
['to', toParam],
] as const) {
const d = new Date(`${raw}T00:00:00.000Z`);
if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== raw) {
return NextResponse.json(
{ error: `\`${label}\` is not a valid calendar date` },
{ status: 400 },
);
}
}
// Backstop against the occupancy-timeline N+1 query loop. Each day
// in the range issues its own DB query, so a multi-year custom
// range would saturate the connection pool. 365 days is a generous
// ceiling for analytical queries; if a longer span is needed, the
// service should be restructured to use `generate_series` instead
// of a JS loop.
const fromMs = new Date(`${fromParam}T00:00:00.000Z`).getTime();
const toMs = new Date(`${toParam}T23:59:59.999Z`).getTime();
if ((toMs - fromMs) / 86_400_000 > 365) {
return NextResponse.json({ error: 'Custom range cannot exceed 365 days' }, { status: 400 });
}
range = { kind: 'custom', from: fromParam, to: toParam };
} else {
if (!ALL_RANGES.includes(rawRange as PresetDateRange)) {
return NextResponse.json({ error: 'Invalid range' }, { status: 400 });
}
range = rawRange as PresetDateRange;
}
const data = await METRICS[metric](ctx.portId, range);

View File

@@ -3,7 +3,7 @@ import { withAuth, withPermission } from '@/lib/api/helpers';
import { getHandler, patchHandler, deleteHandler } from './handlers';
export const GET = withAuth(withPermission('reservations', 'view', getHandler));
// PATCH cannot use `withPermission` wrapper the required permission depends
// PATCH cannot use `withPermission` wrapper - the required permission depends
// on the `action` field in the body. `requirePermission` is called inside the
// handler after the body is parsed.
export const PATCH = withAuth(patchHandler);

View File

@@ -40,7 +40,7 @@ export const PUT = withAuth(
}),
);
// PATCH /api/v1/berths/[id]/waiting-list reorder a single entry
// PATCH /api/v1/berths/[id]/waiting-list - reorder a single entry
export const PATCH = withAuth(
withPermission('berths', 'manage_waiting_list', async (req, ctx, params) => {
try {

View File

@@ -4,7 +4,7 @@ import { withAuth, withPermission } from '@/lib/api/helpers';
import { getBerthOptions } from '@/lib/services/berths.service';
import { errorResponse } from '@/lib/errors';
// GET /api/v1/berths/options lightweight list for selects/comboboxes
// GET /api/v1/berths/options - lightweight list for selects/comboboxes
export const GET = withAuth(
withPermission('berths', 'view', async (req, ctx) => {
try {

View File

@@ -19,7 +19,7 @@ const inviteSchema = z.object({
*
* Admin creates a portal account for a client and triggers the activation
* email. Idempotent in spirit: if a portal user already exists for the
* email, returns 409 the admin can resend the activation via
* email, returns 409 - the admin can resend the activation via
* ?action=resend.
*/
export const POST = withAuth(

View File

@@ -44,7 +44,7 @@ export async function getMatchCandidatesHandler(
const nameResult = rawName ? normalizeName(rawName) : null;
// If the caller didn't give us anything useful to match on, return empty
// short-circuit rather than scan every client for nothing.
// - short-circuit rather than scan every client for nothing.
if (!email && !phoneResult?.e164 && !nameResult?.surnameToken) {
return NextResponse.json({ data: [] });
}
@@ -122,7 +122,7 @@ export async function getMatchCandidatesHandler(
mediumScore: 50,
});
// Only return medium+ low-confidence noise isn't useful at the
// Only return medium+ - low-confidence noise isn't useful at the
// create-form layer (background scoring queue picks those up).
const useful = matches.filter((m) => m.confidence !== 'low');
if (useful.length === 0) {

View File

@@ -7,7 +7,7 @@ import { errorResponse } from '@/lib/errors';
import { mergeDuplicate } from '@/lib/services/expense-dedup.service';
const mergeSchema = z.object({
/** Surviving expense id typically the row's existing `duplicateOf` pointer. */
/** Surviving expense id - typically the row's existing `duplicateOf` pointer. */
targetId: z.string().min(1),
});

View File

@@ -51,7 +51,7 @@ export const POST = withAuth(
});
}
// Per-port budget gate refuse the call before we spend tokens
// Per-port budget gate - refuse the call before we spend tokens
// when the port has already hit its hard cap, or when the request
// would push it past the cap. Soft-cap warnings ride along on the
// success response so the UI can show a banner without blocking.
@@ -99,7 +99,7 @@ export const POST = withAuth(
});
} catch (err) {
logger.error({ err, provider: config.provider }, 'OCR provider call failed');
// Provider hiccup degrade to manual entry rather than 500-ing.
// Provider hiccup - degrade to manual entry rather than 500-ing.
return NextResponse.json({
data: {
parsed: EMPTY,

View File

@@ -16,7 +16,7 @@ export const POST = withAuth(
try {
const body = await parseBody(req, createFolderSchema);
// Sanitize path no null bytes, no path traversal
// Sanitize path - no null bytes, no path traversal
const safePath = body.path
.replace(/\x00/g, '')
.replace(/\.\.\//g, '')

View File

@@ -20,7 +20,7 @@ export const GET = withAuth(
}),
);
// POST /api/v1/interests/[id]/recommendations add manual recommendation
// POST /api/v1/interests/[id]/recommendations - add manual recommendation
export const POST = withAuth(
withPermission('interests', 'edit', async (req, ctx, params) => {
try {

View File

@@ -12,9 +12,9 @@ import { stageLabel } from '@/lib/constants';
const OUTCOME_LABELS: Record<string, string> = {
won: 'Won',
lost_other_marina: 'Lost went to another marina',
lost_unqualified: 'Lost unqualified',
lost_no_response: 'Lost no response',
lost_other_marina: 'Lost - went to another marina',
lost_unqualified: 'Lost - unqualified',
lost_no_response: 'Lost - no response',
cancelled: 'Cancelled',
};
@@ -187,7 +187,7 @@ function buildAuditDescription(
const outcomeKey = (newValue?.outcome as string | undefined) ?? '';
const label = OUTCOME_LABELS[outcomeKey] ?? outcomeKey ?? 'Closed';
const reason = (newValue?.reason as string | undefined) ?? '';
return reason ? `Marked as ${label} ${reason}` : `Marked as ${label}`;
return reason ? `Marked as ${label} - ${reason}` : `Marked as ${label}`;
}
if (type === 'outcome_cleared') {
@@ -200,9 +200,9 @@ function buildAuditDescription(
const reason = (newValue.reason as string | undefined) ?? '';
const auto = userId === 'system';
if (auto) {
return reason ? `${stage} (auto-advanced ${reason})` : `Stage advanced to ${stage}`;
return reason ? `${stage} (auto-advanced - ${reason})` : `Stage advanced to ${stage}`;
}
return reason ? `Stage changed to ${stage} ${reason}` : `Stage changed to ${stage}`;
return reason ? `Stage changed to ${stage} - ${reason}` : `Stage changed to ${stage}`;
}
if (action === 'update' && newValue?.pipelineStage) {

View File

@@ -18,7 +18,7 @@ export const GET = withAuth(async (req: NextRequest, ctx) => {
const results = await search(ctx.portId, q);
// Fire-and-forget do not await
// Fire-and-forget - do not await
saveRecentSearch(ctx.userId, ctx.portId, q);
return NextResponse.json(results);

View File

@@ -0,0 +1,113 @@
import { NextRequest, NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { ALL_RANGES, type DateRange, type PresetDateRange } from '@/lib/analytics/range';
import {
getActiveVisitors,
getMetric,
getPageviewsSeries,
getStats,
type UmamiMetricType,
} from '@/lib/services/umami.service';
/**
* GET /api/v1/website-analytics?metric=...&range=...
*
* Single endpoint serving every Umami widget on the /website-analytics
* page. Mirrors the shape of /api/v1/analytics so the client side can
* reuse the same hook pattern.
*
* Supported metrics:
* - stats → KPI tiles (pageviews, visitors, visits, etc.)
* - pageviews → time-series for the trend chart
* - active → live "right now" count (range ignored)
* - top-{type} → top pages/referrers/countries/etc.
* where type ∈ url|referrer|country|browser|
* os|device|event
*
* Range param accepts the same presets as /api/v1/analytics, plus
* `range=custom&from=YYYY-MM-DD&to=YYYY-MM-DD`.
*/
const ISO_DATE_RX = /^\d{4}-\d{2}-\d{2}$/;
const TOP_METRIC_RX = /^top-(url|referrer|country|browser|os|device|event)$/;
function parseRange(req: NextRequest): DateRange | { error: string } {
const url = new URL(req.url);
const rawRange = url.searchParams.get('range') ?? '30d';
const fromParam = url.searchParams.get('from');
const toParam = url.searchParams.get('to');
if (rawRange === 'custom') {
if (!fromParam || !toParam) {
return { error: 'Custom range requires `from` and `to` (YYYY-MM-DD)' };
}
if (!ISO_DATE_RX.test(fromParam) || !ISO_DATE_RX.test(toParam)) {
return { error: '`from`/`to` must be ISO date strings (YYYY-MM-DD)' };
}
if (fromParam > toParam) {
return { error: '`from` must be on or before `to`' };
}
// Round-trip date check (catches "2026-02-31" type rollovers).
for (const [label, raw] of [
['from', fromParam],
['to', toParam],
] as const) {
const d = new Date(`${raw}T00:00:00.000Z`);
if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== raw) {
return { error: `\`${label}\` is not a valid calendar date` };
}
}
return { kind: 'custom', from: fromParam, to: toParam };
}
if (!ALL_RANGES.includes(rawRange as PresetDateRange)) {
return { error: 'Invalid range' };
}
return rawRange as PresetDateRange;
}
export const GET = withAuth(
withPermission('reports', 'view_analytics', async (req: NextRequest, ctx) => {
const url = new URL(req.url);
const metric = url.searchParams.get('metric');
if (!metric) {
return NextResponse.json({ error: 'Missing metric' }, { status: 400 });
}
const rangeOrError = parseRange(req);
if (typeof rangeOrError === 'object' && 'error' in rangeOrError) {
return NextResponse.json({ error: rangeOrError.error }, { status: 400 });
}
const range = rangeOrError as DateRange;
try {
let data: unknown;
if (metric === 'stats') {
data = await getStats(ctx.portId, range);
} else if (metric === 'pageviews') {
data = await getPageviewsSeries(ctx.portId, range);
} else if (metric === 'active') {
data = await getActiveVisitors(ctx.portId);
} else if (TOP_METRIC_RX.test(metric)) {
const type = metric.replace(/^top-/, '') as UmamiMetricType;
const limit = Number(url.searchParams.get('limit') ?? 10);
data = await getMetric(ctx.portId, range, type, limit);
} else {
return NextResponse.json({ error: `Unknown metric: ${metric}` }, { status: 400 });
}
// `data === null` from the service means Umami isn't configured for
// this port - surface that explicitly so the UI can render a
// "configure your credentials" empty state instead of a chart.
if (data === null) {
return NextResponse.json({ error: 'umami_not_configured', metric, range }, { status: 200 });
}
return NextResponse.json({ metric, range, data });
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
return NextResponse.json({ error: message, metric, range }, { status: 502 });
}
}),
);

View File

@@ -75,7 +75,7 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
});
if (existing) {
logger.info({ signatureHash }, 'Duplicate Documenso webhook skipping');
logger.info({ signatureHash }, 'Duplicate Documenso webhook - skipping');
return NextResponse.json({ ok: true }, { status: 200 });
}
} catch (err) {

View File

@@ -93,7 +93,7 @@
@apply bg-background text-foreground font-sans antialiased;
}
/* Wave watermark subtle background texture for auth pages */
/* Wave watermark - subtle background texture for auth pages */
.wave-watermark {
background-image: repeating-linear-gradient(
135deg,
@@ -134,7 +134,7 @@
* from User-Agent (see src/lib/form-factor.ts). The media-query fallback
* handles desktop browsers resized below lg (1024px), or stripped UAs.
*
* IMPORTANT: only `display: none` rules are emitted we never set a positive
* IMPORTANT: only `display: none` rules are emitted - we never set a positive
* display, because the desktop shell uses Tailwind's `flex` class which would
* be overridden by `display: block` (same specificity, later cascade).
*/
@@ -169,3 +169,33 @@ body[data-form-factor='mobile'] [data-shell='mobile'] {
display: none !important;
}
}
/*
* Recharts focus-ring suppression.
*
* Recharts SVG surfaces become keyboard-focusable when a user clicks into
* them (the library adds tabindex on chart sectors / paths). The global
* `*:focus-visible` rule above paints a 4px brand-blue box-shadow ring,
* which on a chart surface reads as a stray rectangle around the plot
* area. Hover/tooltip already handles chart interactivity, so suppress
* the ring entirely here.
*
* Lives OUTSIDE `@layer base` so Tailwind's PostCSS pipeline can't drop
* it during purge (an earlier copy inside `@layer base` was being
* silently removed at build time, leaving the ring intact).
*/
div.recharts-wrapper:focus,
div.recharts-wrapper:focus-visible,
svg.recharts-surface:focus,
svg.recharts-surface:focus-visible,
div.recharts-responsive-container:focus,
div.recharts-responsive-container:focus-visible,
.recharts-wrapper *:focus,
.recharts-wrapper *:focus-visible {
outline: none !important;
box-shadow: none !important;
--tw-ring-shadow: 0 0 #0000 !important;
--tw-ring-offset-shadow: 0 0 #0000 !important;
--tw-ring-color: transparent !important;
--tw-ring-offset-color: transparent !important;
}

View File

@@ -87,7 +87,7 @@ export function AuditLogList() {
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
// Filter state debounce text inputs.
// Filter state - debounce text inputs.
const [search, setSearch] = useState('');
const [entityType, setEntityType] = useState<string>('all');
const [action, setAction] = useState<string>('all');
@@ -215,7 +215,7 @@ export function AuditLogList() {
</span>
);
}
return <span className="text-xs text-muted-foreground"></span>;
return <span className="text-xs text-muted-foreground">-</span>;
},
},
{
@@ -245,7 +245,7 @@ export function AuditLogList() {
<PageHeader
title="Audit Log"
eyebrow="Admin"
description="Every state change in this port fully searchable."
description="Every state change in this port - fully searchable."
variant="gradient"
/>

View File

@@ -59,12 +59,7 @@ const FIELD_TYPE_LABELS: Record<string, string> = {
// ─── Component ────────────────────────────────────────────────────────────────
export function CustomFieldForm({
open,
onOpenChange,
field,
onSuccess,
}: CustomFieldFormProps) {
export function CustomFieldForm({ open, onOpenChange, field, onSuccess }: CustomFieldFormProps) {
const isEdit = !!field;
// Form state
@@ -72,9 +67,7 @@ export function CustomFieldForm({
const [fieldName, setFieldName] = useState(field?.fieldName ?? '');
const [fieldLabel, setFieldLabel] = useState(field?.fieldLabel ?? '');
const [fieldType, setFieldType] = useState(field?.fieldType ?? 'text');
const [selectOptions, setSelectOptions] = useState<string[]>(
field?.selectOptions ?? [],
);
const [selectOptions, setSelectOptions] = useState<string[]>(field?.selectOptions ?? []);
const [newOption, setNewOption] = useState('');
const [isRequired, setIsRequired] = useState(field?.isRequired ?? false);
const [sortOrder, setSortOrder] = useState(field?.sortOrder ?? 0);
@@ -169,13 +162,11 @@ export function CustomFieldForm({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>
{isEdit ? 'Edit Custom Field' : 'New Custom Field'}
</DialogTitle>
<DialogTitle>{isEdit ? 'Edit Custom Field' : 'New Custom Field'}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-5 py-2">
{/* Entity Type create only */}
{/* Entity Type - create only */}
<div className="space-y-1.5">
<Label htmlFor="cf-entity-type">Entity Type</Label>
{isEdit ? (
@@ -198,7 +189,7 @@ export function CustomFieldForm({
)}
</div>
{/* Field Name create only */}
{/* Field Name - create only */}
<div className="space-y-1.5">
<Label htmlFor="cf-field-name">
Field Name
@@ -232,7 +223,7 @@ export function CustomFieldForm({
/>
</div>
{/* Field Type create only */}
{/* Field Type - create only */}
<div className="space-y-1.5">
<Label htmlFor="cf-field-type">Field Type</Label>
{isEdit ? (
@@ -260,7 +251,7 @@ export function CustomFieldForm({
)}
</div>
{/* Select Options visible when fieldType = 'select' */}
{/* Select Options - visible when fieldType = 'select' */}
{fieldType === 'select' && (
<div className="space-y-2">
<Label>Options</Label>
@@ -302,11 +293,7 @@ export function CustomFieldForm({
{/* Is Required */}
<div className="flex items-center justify-between">
<Label htmlFor="cf-is-required">Required field</Label>
<Switch
id="cf-is-required"
checked={isRequired}
onCheckedChange={setIsRequired}
/>
<Switch id="cf-is-required" checked={isRequired} onCheckedChange={setIsRequired} />
</div>
{/* Sort Order */}

View File

@@ -11,13 +11,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetFooter,
} from '@/components/ui/sheet';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
import { apiFetch } from '@/lib/api/client';
import { TEMPLATE_VARIABLES } from '@/lib/pdf/tiptap-to-pdfme';
@@ -61,20 +55,13 @@ interface TemplateFormProps {
onSuccess: () => void;
}
export function TemplateForm({
open,
onOpenChange,
template,
onSuccess,
}: TemplateFormProps) {
export function TemplateForm({ open, onOpenChange, template, onSuccess }: TemplateFormProps) {
const isEdit = !!template;
const [name, setName] = useState(template?.name ?? '');
const [type, setType] = useState(template?.templateType ?? 'other');
const [contentJson, setContentJson] = useState(
template?.content
? JSON.stringify(template.content, null, 2)
: EMPTY_DOC,
template?.content ? JSON.stringify(template.content, null, 2) : EMPTY_DOC,
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -86,7 +73,7 @@ export function TemplateForm({
setJsonError(null);
return true;
} catch {
setJsonError('Invalid JSON check syntax.');
setJsonError('Invalid JSON - check syntax.');
return false;
}
}
@@ -115,8 +102,7 @@ export function TemplateForm({
onSuccess();
onOpenChange(false);
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : 'Something went wrong';
const message = err instanceof Error ? err.message : 'Something went wrong';
setError(message);
} finally {
setLoading(false);
@@ -127,9 +113,7 @@ export function TemplateForm({
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full max-w-2xl overflow-y-auto sm:max-w-2xl">
<SheetHeader>
<SheetTitle>
{isEdit ? 'Edit Template' : 'New Document Template'}
</SheetTitle>
<SheetTitle>{isEdit ? 'Edit Template' : 'New Document Template'}</SheetTitle>
</SheetHeader>
<form onSubmit={handleSubmit} className="mt-6 space-y-6">
@@ -145,7 +129,7 @@ export function TemplateForm({
/>
</div>
{/* Type only on create */}
{/* Type - only on create */}
{!isEdit && (
<div className="space-y-2">
<Label htmlFor="template-type">Document Type</Label>
@@ -166,15 +150,11 @@ export function TemplateForm({
{/* TipTap JSON Content */}
<div className="space-y-2">
<Label htmlFor="template-content">
Document Content (TipTap JSON)
</Label>
<Label htmlFor="template-content">Document Content (TipTap JSON)</Label>
<p className="text-xs text-muted-foreground">
Paste or edit TipTap JSON. Use{' '}
<code className="rounded bg-muted px-1 text-xs">
{'{{variable.key}}'}
</code>{' '}
tokens for dynamic content.
<code className="rounded bg-muted px-1 text-xs">{'{{variable.key}}'}</code> tokens for
dynamic content.
</p>
<textarea
id="template-content"
@@ -187,9 +167,7 @@ export function TemplateForm({
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-xs shadow-sm focus:outline-none focus:ring-2 focus:ring-ring"
spellCheck={false}
/>
{jsonError && (
<p className="text-xs text-destructive">{jsonError}</p>
)}
{jsonError && <p className="text-xs text-destructive">{jsonError}</p>}
</div>
{/* Available Variables Reference */}
@@ -200,19 +178,15 @@ export function TemplateForm({
<div className="mt-3 grid grid-cols-1 gap-1 sm:grid-cols-2">
{TEMPLATE_VARIABLES.map((v) => (
<div key={v.key} className="text-xs">
<code className="rounded bg-muted px-1">
{`{{${v.key}}}`}
</code>{' '}
<span className="text-muted-foreground"> {v.label}</span>
<code className="rounded bg-muted px-1">{`{{${v.key}}}`}</code>{' '}
<span className="text-muted-foreground">- {v.label}</span>
</div>
))}
</div>
</details>
{error && (
<p className="rounded bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</p>
<p className="rounded bg-destructive/10 px-3 py-2 text-sm text-destructive">{error}</p>
)}
<SheetFooter>
@@ -225,11 +199,7 @@ export function TemplateForm({
Cancel
</Button>
<Button type="submit" disabled={loading || !!jsonError}>
{loading
? 'Saving…'
: isEdit
? 'Save Changes'
: 'Create Template'}
{loading ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Template'}
</Button>
</SheetFooter>
</form>

View File

@@ -9,12 +9,7 @@ import { PageHeader } from '@/components/shared/page-header';
import { ConfirmationDialog } from '@/components/shared/confirmation-dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { apiFetch } from '@/lib/api/client';
import { TemplateForm } from './template-form';
import { TemplateVersionHistory } from './template-version-history';
@@ -57,9 +52,7 @@ export function TemplateList() {
const fetchTemplates = useCallback(async () => {
setLoading(true);
try {
const res = await apiFetch<{ data: AdminTemplate[] }>(
'/api/v1/admin/templates',
);
const res = await apiFetch<{ data: AdminTemplate[] }>('/api/v1/admin/templates');
setTemplates(res.data);
} finally {
setLoading(false);
@@ -122,9 +115,7 @@ export function TemplateList() {
accessorKey: 'version',
header: 'Version',
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">
v{row.original.version}
</span>
<span className="text-sm text-muted-foreground">v{row.original.version}</span>
),
},
{
@@ -151,10 +142,7 @@ export function TemplateList() {
header: '',
cell: ({ row }) => (
<div className="flex items-center justify-end gap-1">
<TemplatePreview
content={row.original.content}
templateName={row.original.name}
/>
<TemplatePreview content={row.original.content} templateName={row.original.name} />
<Button
variant="ghost"
size="icon"
@@ -177,9 +165,7 @@ export function TemplateList() {
title={row.original.isActive ? 'Deactivate' : 'Activate'}
onClick={() => handleToggleActive(row.original)}
>
<span className="text-xs">
{row.original.isActive ? 'Off' : 'On'}
</span>
<span className="text-xs">{row.original.isActive ? 'Off' : 'On'}</span>
</Button>
<ConfirmationDialog
trigger={
@@ -233,9 +219,7 @@ export function TemplateList() {
<Sheet open={historyOpen} onOpenChange={setHistoryOpen}>
<SheetContent className="w-full max-w-xl sm:max-w-xl overflow-y-auto">
<SheetHeader>
<SheetTitle>
Version History {historyTemplate?.name}
</SheetTitle>
<SheetTitle>Version History - {historyTemplate?.name}</SheetTitle>
</SheetHeader>
<div className="mt-6">
{historyTemplate && (

View File

@@ -3,12 +3,7 @@
import { useState } from 'react';
import { Eye, ExternalLink } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { apiFetch } from '@/lib/api/client';
import { TEMPLATE_VARIABLES } from '@/lib/pdf/tiptap-to-pdfme';
@@ -24,9 +19,7 @@ export function TemplatePreview({ content, templateName }: TemplatePreviewProps)
const [error, setError] = useState<string | null>(null);
// Build sample data from TEMPLATE_VARIABLES examples
const sampleData = Object.fromEntries(
TEMPLATE_VARIABLES.map((v) => [v.key, v.example]),
);
const sampleData = Object.fromEntries(TEMPLATE_VARIABLES.map((v) => [v.key, v.example]));
async function handlePreview() {
if (!content) {
@@ -74,14 +67,9 @@ export function TemplatePreview({ content, templateName }: TemplatePreviewProps)
<DialogContent className="max-w-4xl">
<DialogHeader>
<div className="flex items-center justify-between">
<DialogTitle>Preview {templateName}</DialogTitle>
<DialogTitle>Preview - {templateName}</DialogTitle>
{pdfBase64 && (
<Button
variant="ghost"
size="sm"
onClick={handleOpenInNewTab}
className="mr-6"
>
<Button variant="ghost" size="sm" onClick={handleOpenInNewTab} className="mr-6">
<ExternalLink className="mr-1.5 h-3.5 w-3.5" />
Open in new tab
</Button>
@@ -100,9 +88,7 @@ export function TemplatePreview({ content, templateName }: TemplatePreviewProps)
)}
{error && !loading && (
<div className="rounded bg-destructive/10 p-4 text-sm text-destructive">
{error}
</div>
<div className="rounded bg-destructive/10 p-4 text-sm text-destructive">{error}</div>
)}
{pdfBase64 && !loading && (

View File

@@ -117,7 +117,7 @@ export function InvitationsManager() {
{invites.map((i) => (
<tr key={i.id} className="border-t">
<td className="px-3 py-2 font-medium">{i.email}</td>
<td className="px-3 py-2 text-muted-foreground">{i.name ?? ''}</td>
<td className="px-3 py-2 text-muted-foreground">{i.name ?? '-'}</td>
<td className="px-3 py-2 text-muted-foreground">
{i.isSuperAdmin ? 'Super admin' : 'Standard user'}
</td>
@@ -163,7 +163,7 @@ export function InvitationsManager() {
)}
</div>
) : (
<span className="text-xs text-muted-foreground"></span>
<span className="text-xs text-muted-foreground">-</span>
)}
</td>
</tr>

View File

@@ -160,7 +160,7 @@ function SettingsBlock({ scope, title, description, showUseGlobal }: SettingsBlo
Enable AI receipt parsing for this port
</Label>
<p className="text-xs text-muted-foreground">
Off by default. Receipts are read on-device using Tesseract.js accurate enough for
Off by default. Receipts are read on-device using Tesseract.js - accurate enough for
most receipts and incurs no AI cost. Turning this on lets the configured provider
re-parse receipts server-side for higher accuracy on hard-to-read images.
</p>
@@ -214,7 +214,7 @@ function SettingsBlock({ scope, title, description, showUseGlobal }: SettingsBlo
id={`apiKey-${scope}`}
type={showKey ? 'text' : 'password'}
autoComplete="off"
placeholder={hasKey ? '•••••• (saved leave blank to keep)' : 'sk-…'}
placeholder={hasKey ? '•••••• (saved - leave blank to keep)' : 'sk-…'}
value={apiKey}
onChange={(e) => {
setApiKey(e.target.value);

View File

@@ -33,7 +33,7 @@ const statusVariant: Record<JobStatus, 'default' | 'secondary' | 'destructive' |
};
function formatDate(ts: number | undefined): string {
if (!ts) return '';
if (!ts) return '-';
return new Date(ts).toLocaleString();
}
@@ -42,7 +42,7 @@ function truncateId(id: string): string {
}
function truncateReason(reason: string | undefined): string {
if (!reason) return '';
if (!reason) return '-';
return reason.length > 80 ? `${reason.slice(0, 80)}` : reason;
}
@@ -184,7 +184,7 @@ export function QueueDetailTable({ queueName }: QueueDetailTableProps) {
{totalPages > 1 && (
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
{total} total jobs page {page} of {totalPages}
{total} total jobs - page {page} of {totalPages}
</span>
<div className="flex gap-2">
<Button

View File

@@ -95,7 +95,7 @@ export function RoleList() {
accessorKey: 'description',
header: 'Description',
cell: ({ row }) => (
<span className="text-muted-foreground text-sm">{row.original.description ?? ''}</span>
<span className="text-muted-foreground text-sm">{row.original.description ?? '-'}</span>
),
},
{

View File

@@ -363,7 +363,7 @@ export function SettingsManager() {
);
void saveSetting(setting.key, parsed);
} catch {
// invalid JSON do nothing
// invalid JSON - do nothing
}
}}
>

View File

@@ -108,7 +108,7 @@ export function UserCard({ user, onEdit, onRemove, isRemoving }: UserCardProps)
<span aria-hidden className="block h-9 w-9 shrink-0" />
</div>
{/* Email subtitle only when display name is shown as title */}
{/* Email subtitle - only when display name is shown as title */}
{user.displayName && user.displayName !== user.email ? (
<p className="mt-0.5 inline-flex items-center gap-1 truncate text-sm text-muted-foreground">
<Mail className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" aria-hidden />

View File

@@ -57,7 +57,7 @@ export function WebhookDeliveryLog({ webhookId }: Props) {
useEffect(() => {
void load(page);
// eslint-disable-next-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [webhookId, page]);
if (loading && deliveries.length === 0) {
@@ -87,13 +87,9 @@ export function WebhookDeliveryLog({ webhookId }: Props) {
<TableRow key={d.id}>
<TableCell className="font-mono text-xs">{d.eventType}</TableCell>
<TableCell>
<Badge variant={STATUS_VARIANTS[d.status] ?? 'outline'}>
{d.status}
</Badge>
</TableCell>
<TableCell className="text-sm">
{d.responseStatus ?? '—'}
<Badge variant={STATUS_VARIANTS[d.status] ?? 'outline'}>{d.status}</Badge>
</TableCell>
<TableCell className="text-sm">{d.responseStatus ?? '-'}</TableCell>
<TableCell className="text-sm">{d.attempt}</TableCell>
<TableCell className="text-xs text-muted-foreground">
{d.deliveredAt

View File

@@ -0,0 +1,67 @@
'use client';
import { useState } from 'react';
import { Loader2, CheckCircle2, XCircle } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { apiFetch } from '@/lib/api/client';
interface TestResponse {
ok: boolean;
visitors?: number;
error?: string;
}
/**
* Hits POST /api/v1/admin/umami/test which calls Umami's `/api/websites/:id/
* active` to verify auth + websiteId in one request. On success, shows the
* live visitor count as proof we got real data back.
*/
export function UmamiTestButton() {
const [pending, setPending] = useState(false);
const [result, setResult] = useState<TestResponse | null>(null);
async function runTest() {
setPending(true);
setResult(null);
try {
const res = await apiFetch<{ data: TestResponse }>('/api/v1/admin/umami/test', {
method: 'POST',
});
setResult(res.data);
if (res.data.ok) {
toast.success(`Umami reachable - ${res.data.visitors ?? 0} active visitor(s) right now`);
} else {
toast.error(res.data.error ?? 'Umami test failed');
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Test failed';
setResult({ ok: false, error: message });
toast.error(message);
} finally {
setPending(false);
}
}
return (
<div className="flex items-center gap-3">
{result &&
(result.ok ? (
<span className="flex items-center text-xs text-green-600">
<CheckCircle2 className="mr-1 h-3.5 w-3.5" />
Connected ({result.visitors ?? 0} active)
</span>
) : (
<span className="flex items-center text-xs text-destructive">
<XCircle className="mr-1 h-3.5 w-3.5" />
{result.error ?? 'Failed'}
</span>
))}
<Button type="button" size="sm" variant="outline" onClick={runTest} disabled={pending}>
{pending ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : null}
Test connection
</Button>
</div>
);
}

View File

@@ -16,8 +16,8 @@ import { useAlertCount, useAlertList, useAlertRealtime } from './use-alerts';
export function AlertBell() {
const portSlug = useUIStore((s) => s.currentPortSlug);
const [open, setOpen] = useState(false);
// Count is cheap (one aggregate query) fire on every page so the badge stays live.
// List is heavier only fetch when the popover is actually open.
// Count is cheap (one aggregate query) - fire on every page so the badge stays live.
// List is heavier - only fetch when the popover is actually open.
const { data: count } = useAlertCount();
const { data: list, isLoading } = useAlertList('open', open);
useAlertRealtime();

View File

@@ -22,11 +22,10 @@ export function AlertRail() {
<section
data-testid="alert-rail"
aria-label="Active alerts"
// `h-full` is intentional only at xl: where the parent dashboard grid
// gives this rail a sibling column whose height it should match. On
// mobile (single-column stack) there's no fixed-height context, so
// forcing 100% height makes the section overflow / look stretched.
className="flex flex-col gap-3 xl:h-full"
// Natural height - the parent aside no longer forces 100% of the
// dashboard grid row, so the rail can sit compactly under Reminders
// without bleeding down into the Recent Activity panel below.
className="flex flex-col gap-3"
>
<div className="flex items-baseline justify-between">
<h2 className="text-sm font-semibold tracking-tight">Alerts</h2>
@@ -57,7 +56,7 @@ export function AlertRail() {
href={portSlug ? (`/${portSlug}/alerts` as never) : ('/alerts' as never)}
className="block rounded-lg border border-dashed border-border px-3 py-2 text-center text-xs text-muted-foreground transition-colors hover:bg-accent"
>
+{overflow} more view all
+{overflow} more - view all
</Link>
) : null}
</div>

View File

@@ -56,7 +56,12 @@ function ActionsCell({ row }: { row: { original: BerthRow } }) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={(e) => e.stopPropagation()}>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Open menu</span>
</Button>
@@ -89,14 +94,12 @@ export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
{
accessorKey: 'mooringNumber',
header: 'Mooring #',
cell: ({ row }) => (
<span className="font-medium">{row.original.mooringNumber}</span>
),
cell: ({ row }) => <span className="font-medium">{row.original.mooringNumber}</span>,
},
{
accessorKey: 'area',
header: 'Area',
cell: ({ row }) => row.original.area ?? '',
cell: ({ row }) => row.original.area ?? '-',
},
{
accessorKey: 'status',
@@ -109,7 +112,7 @@ export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
enableSorting: false,
cell: ({ row }) => {
const { lengthM, widthM } = row.original;
if (!lengthM && !widthM) return '';
if (!lengthM && !widthM) return '-';
return `${lengthM ?? '?'}m × ${widthM ?? '?'}m`;
},
},
@@ -118,7 +121,7 @@ export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
header: 'Price',
cell: ({ row }) => {
const { price, priceCurrency } = row.original;
if (!price) return '';
if (!price) return '-';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: priceCurrency || 'USD',
@@ -129,8 +132,7 @@ export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
{
accessorKey: 'tenureType',
header: 'Tenure',
cell: ({ row }) =>
row.original.tenureType === 'permanent' ? 'Permanent' : 'Fixed Term',
cell: ({ row }) => (row.original.tenureType === 'permanent' ? 'Permanent' : 'Fixed Term'),
},
{
id: 'tags',

View File

@@ -93,7 +93,7 @@ function SelectOrEmpty({
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}></SelectItem>
<SelectItem value={NONE}>-</SelectItem>
{options.map((opt) => (
<SelectItem key={opt} value={opt}>
{opt}

View File

@@ -168,7 +168,7 @@ export function BerthInterestsTab({ berthId }: BerthInterestsTabProps) {
href={`/${portSlug}/interests/${i.id}` as never}
className="hover:text-brand"
>
{i.clientName ?? ''}
{i.clientName ?? '-'}
</Link>
</td>
<td className="px-3 py-2">
@@ -177,10 +177,10 @@ export function BerthInterestsTab({ berthId }: BerthInterestsTabProps) {
</Badge>
</td>
<td className="px-3 py-2 text-muted-foreground">
{i.leadCategory ? (CATEGORY_LABELS[i.leadCategory] ?? i.leadCategory) : ''}
{i.leadCategory ? (CATEGORY_LABELS[i.leadCategory] ?? i.leadCategory) : '-'}
</td>
<td className="px-3 py-2 text-muted-foreground">
{i.source ? (SOURCE_LABELS[i.source] ?? i.source) : ''}
{i.source ? (SOURCE_LABELS[i.source] ?? i.source) : '-'}
</td>
<td className="px-3 py-2 text-xs text-muted-foreground">
{new Date(i.createdAt).toLocaleDateString()}

View File

@@ -36,7 +36,7 @@ export function BerthList() {
title="Berths"
description="View and manage berth allocations"
variant="gradient"
// No "New" button berths are import-only
// No "New" button - berths are import-only
/>
<div className="flex items-center gap-2 flex-wrap">

View File

@@ -109,7 +109,7 @@ function OverviewTab({ berth }: { berth: BerthData }) {
return (
<div className="space-y-6">
{/* Sales pulse top-of-page so reps doing berth-level triage can see
{/* Sales pulse - top-of-page so reps doing berth-level triage can see
who's interested + how warm without clicking into the Interests tab. */}
<BerthInterestPulse berthId={berth.id} />

View File

@@ -70,7 +70,7 @@ export function getClientColumns({
enableSorting: false,
cell: ({ row }) => {
const primary = row.original.contacts?.find((c) => c.isPrimary);
if (!primary) return <span className="text-muted-foreground"></span>;
if (!primary) return <span className="text-muted-foreground">-</span>;
return (
<span className="text-sm">
<span className="text-muted-foreground capitalize">{primary.channel}: </span>
@@ -86,7 +86,7 @@ export function getClientColumns({
cell: ({ getValue }) => {
const iso = getValue() as string | null;
return (
<span className="text-muted-foreground">{iso ? getCountryName(iso, 'en') : ''}</span>
<span className="text-muted-foreground">{iso ? getCountryName(iso, 'en') : '-'}</span>
);
},
},
@@ -96,7 +96,7 @@ export function getClientColumns({
header: 'Source',
cell: ({ getValue }) => {
const source = getValue() as string | null;
if (!source) return <span className="text-muted-foreground"></span>;
if (!source) return <span className="text-muted-foreground">-</span>;
return (
<Badge variant="outline" className="capitalize text-xs">
{SOURCE_LABELS[source] ?? source}
@@ -111,7 +111,7 @@ export function getClientColumns({
cell: ({ row }) => {
const c = row.original.yachtCount ?? 0;
return c === 0 ? (
<span className="text-muted-foreground"></span>
<span className="text-muted-foreground">-</span>
) : (
<Badge variant="secondary" className="text-xs">
{c}
@@ -126,7 +126,7 @@ export function getClientColumns({
cell: ({ row }) => {
const c = row.original.companyCount ?? 0;
return c === 0 ? (
<span className="text-muted-foreground"></span>
<span className="text-muted-foreground">-</span>
) : (
<Badge variant="secondary" className="text-xs">
{c}
@@ -140,7 +140,7 @@ export function getClientColumns({
enableSorting: false,
cell: ({ row }) => {
const clientTags = row.original.tags ?? [];
if (clientTags.length === 0) return <span className="text-muted-foreground"></span>;
if (clientTags.length === 0) return <span className="text-muted-foreground">-</span>;
return (
<div className="flex flex-wrap gap-1">
{clientTags.slice(0, 3).map((tag) => (

View File

@@ -33,7 +33,7 @@ interface ClientCompaniesTabProps {
function formatSince(startDate: string | Date): string {
const d = typeof startDate === 'string' ? new Date(startDate) : startDate;
if (Number.isNaN(d.getTime())) return '';
if (Number.isNaN(d.getTime())) return '-';
return format(d, 'MMM d, yyyy');
}
@@ -87,7 +87,7 @@ export function ClientCompaniesTab({ clientId: _clientId, companies }: ClientCom
Primary
</Badge>
) : (
<span className="text-muted-foreground"></span>
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="text-muted-foreground text-sm">

View File

@@ -169,7 +169,7 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
)}
</div>
{/* Top-right: archive/restore as a small icon button destructive
{/* Top-right: archive/restore as a small icon button - destructive
action sits out of the primary action flow. */}
<button
type="button"

View File

@@ -150,7 +150,7 @@ export function ClientForm({ open, onOpenChange, client, onUseExistingClient }:
</SheetHeader>
<form onSubmit={handleSubmit((data) => mutation.mutate(data))} className="space-y-6 py-6">
{/* Dedup suggestion only on the create path. Watches the
{/* Dedup suggestion - only on the create path. Watches the
live form values for email / phone / name and surfaces
an existing client when one matches. The user can
attach the new interest to that client instead of

View File

@@ -180,7 +180,7 @@ function InterestPreviewDrawer({
}) {
// Pin the most recently selected interest so the drawer stays populated
// during the close-animation tail (Vaul keeps the content mounted ~250ms
// after `open=false`). Conditional setState is safe here the guard
// after `open=false`). Conditional setState is safe here - the guard
// ensures it only fires when the prop actually changes to a new row.
const [pinned, setPinned] = useState<ClientInterestRow | null>(interest);
if (interest && interest !== pinned) setPinned(interest);
@@ -243,7 +243,7 @@ function InterestPreviewDrawer({
</DrawerHeader>
<div className="space-y-5 overflow-y-auto px-4 pb-4">
{/* Pipeline-stepper segmented bar the same primitive used on the
{/* Pipeline-stepper segmented bar - the same primitive used on the
row card, so the at-a-glance progress hint is consistent
across surfaces. */}
{stage ? (
@@ -255,7 +255,7 @@ function InterestPreviewDrawer({
</div>
) : null}
{/* Milestones three sections matching the full interest detail
{/* Milestones - three sections matching the full interest detail
page (EOI / Deposit / Contract). Done-state is derived from
the pipeline stage so seed data without per-step dates still
renders correctly. The full milestone columns + per-step
@@ -308,7 +308,7 @@ function InterestPreviewDrawer({
</div>
</section>
{/* Compact key/value pairs lead category, source, last contact,
{/* Compact key/value pairs - lead category, source, last contact,
activity. Each row collapses cleanly when its value is
missing so the drawer scales from sparse seed data to full
records without empty placeholders. */}

View File

@@ -106,8 +106,8 @@ function lastActivityLabel(interests: ClientInterestRow[]): string | null {
interface PipelineSummaryProps {
clientId: string;
/**
* `hero` single-line pulse for the detail header (highest active stage only).
* `panel` compact list of every active interest, for the Overview tab.
* `hero` - single-line pulse for the detail header (highest active stage only).
* `panel` - compact list of every active interest, for the Overview tab.
*/
variant?: 'hero' | 'panel';
}

View File

@@ -74,9 +74,9 @@ export function ClientYachtsTab({ clientId: _clientId, yachts }: ClientYachtsTab
</Link>
</TableCell>
<TableCell>
{y.lengthFt && y.widthFt ? `${y.lengthFt} × ${y.widthFt} ft` : ''}
{y.lengthFt && y.widthFt ? `${y.lengthFt} × ${y.widthFt} ft` : '-'}
</TableCell>
<TableCell>{y.hullNumber ?? ''}</TableCell>
<TableCell>{y.hullNumber ?? '-'}</TableCell>
<TableCell className="capitalize">{y.status.replace('_', ' ')}</TableCell>
</TableRow>
))}

View File

@@ -225,10 +225,10 @@ function ContactRow({
{/* Bottom / right: tag + actions.
Two layers of hiding compose here:
(a) phoneEditing when the phone editor is open, hide the entire
(a) phoneEditing - when the phone editor is open, hide the entire
action cluster (tag + star + trash) so the user can focus on
the form without chips fighting for space.
(b) contact.value when the value is empty (stale import row,
(b) contact.value - when the value is empty (stale import row,
aborted edit), hide just the tag + Make-primary star;
neither makes sense without a value. The trash icon stays
so the user can clean up the empty entry.

View File

@@ -63,7 +63,7 @@ export function DedupSuggestionPanel({
useEffect(() => {
const t = setTimeout(() => {
setDebounced({ email: email ?? '', phone: phone ?? '', name: name ?? '' });
// Clear the dismissed flag when inputs change the user typed
// Clear the dismissed flag when inputs change - the user typed
// something new, so the prior dismissal no longer applies.
setDismissed(false);
}, 300);
@@ -83,7 +83,7 @@ export function DedupSuggestionPanel({
return apiFetch<{ data: MatchData[] }>(`/api/v1/clients/match-candidates?${params}`);
},
enabled: hasSomething && !dismissed,
// Same query is fine to cache for a minute moves are slow at this layer.
// Same query is fine to cache for a minute - moves are slow at this layer.
staleTime: 60_000,
});
@@ -120,7 +120,7 @@ export function DedupSuggestionPanel({
<p className="text-sm font-semibold leading-tight">
{isHigh
? 'This looks like an existing client'
: 'Possible match check before creating'}
: 'Possible match - check before creating'}
</p>
<div className="mt-2 rounded-md border bg-background/80 p-2.5">
<div className="flex items-center gap-2">

View File

@@ -74,7 +74,7 @@ export function GdprExportButton({ clientId }: { clientId: string }) {
},
}),
onSuccess: () => {
toast.success('Export queued refresh in ~30 seconds');
toast.success('Export queued - refresh in ~30 seconds');
qc.invalidateQueries({ queryKey });
setEmailOverride('');
},
@@ -128,7 +128,7 @@ export function GdprExportButton({ clientId }: { clientId: string }) {
Email the bundle when ready
</Label>
<p className="text-xs text-muted-foreground">
Sends a 7-day signed download link to the client&apos;s primary email or to the
Sends a 7-day signed download link to the client&apos;s primary email - or to the
override below.
</p>
{emailToClient ? (

View File

@@ -122,7 +122,7 @@ export function AddMembershipDialog({ open, onOpenChange, companyId }: AddMember
},
onError: (err: unknown) => {
let msg = err instanceof Error ? err.message : 'Failed to add membership';
// Detect 409 service returns a "membership already exists" message
// Detect 409 - service returns a "membership already exists" message
if (/already exists/i.test(msg)) {
msg = 'This membership already exists (same client + role + start date).';
}

View File

@@ -76,7 +76,7 @@ export function getCompanyColumns({
enableSorting: false,
cell: ({ getValue }) => {
const value = getValue() as string | null;
if (!value) return <span className="text-muted-foreground"></span>;
if (!value) return <span className="text-muted-foreground">-</span>;
return <span className="text-sm">{value}</span>;
},
},
@@ -87,7 +87,7 @@ export function getCompanyColumns({
enableSorting: false,
cell: ({ getValue }) => {
const value = getValue() as string | null;
if (!value) return <span className="text-muted-foreground"></span>;
if (!value) return <span className="text-muted-foreground">-</span>;
return <span className="text-sm">{value}</span>;
},
},
@@ -98,7 +98,7 @@ export function getCompanyColumns({
size: 88,
cell: ({ row }) => {
const n = row.original.memberCount ?? 0;
if (n === 0) return <span className="text-muted-foreground"></span>;
if (n === 0) return <span className="text-muted-foreground">-</span>;
return <Badge variant="secondary">{n}</Badge>;
},
},
@@ -109,7 +109,7 @@ export function getCompanyColumns({
size: 88,
cell: ({ row }) => {
const n = row.original.yachtCount ?? 0;
if (n === 0) return <span className="text-muted-foreground"></span>;
if (n === 0) return <span className="text-muted-foreground">-</span>;
return <Badge variant="secondary">{n}</Badge>;
},
},

View File

@@ -101,7 +101,7 @@ export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) {
const mutation = useMutation({
mutationFn: async (data: CreateCompanyInput) => {
if (isEdit) {
// updateCompanySchema omits tagIds strip them from PATCH body.
// updateCompanySchema omits tagIds - strip them from PATCH body.
const { tagIds: _tIds, ...rest } = data;
void _tIds;
await apiFetch(`/api/v1/companies/${company!.id}`, {
@@ -178,7 +178,7 @@ export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) {
value={watch('incorporationCountryIso')}
onChange={(iso) => {
setValue('incorporationCountryIso', iso ?? undefined);
// Wipe subdivision when country flips codes are country-scoped.
// Wipe subdivision when country flips - codes are country-scoped.
setValue('incorporationSubdivisionIso', undefined);
}}
data-testid="company-incorp-country"

View File

@@ -56,7 +56,7 @@ const ROLE_LABELS: Record<string, string> = {
};
function formatDate(value: string | null): string {
if (!value) return '';
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleDateString();
@@ -201,14 +201,14 @@ export function CompanyMembersTab({ companyId, portSlug }: CompanyMembersTabProp
</TableCell>
<TableCell>{ROLE_LABELS[m.role] ?? m.role}</TableCell>
<TableCell className="text-sm text-muted-foreground max-w-[240px] truncate">
{m.roleDetail ?? ''}
{m.roleDetail ?? '-'}
</TableCell>
<TableCell>{formatDate(m.startDate)}</TableCell>
<TableCell>
{m.endDate ? (
formatDate(m.endDate)
) : (
<span className="text-muted-foreground"></span>
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>
@@ -217,7 +217,7 @@ export function CompanyMembersTab({ companyId, portSlug }: CompanyMembersTabProp
Primary
</Badge>
) : (
<span className="text-muted-foreground"></span>
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>

View File

@@ -49,13 +49,13 @@ const STATUS_LABELS: Record<string, string> = {
function formatDimensions(y: OwnedYachtRow): string | null {
if (y.lengthFt || y.widthFt) {
const length = y.lengthFt ?? '';
const width = y.widthFt ?? '';
const length = y.lengthFt ?? '-';
const width = y.widthFt ?? '-';
return `${length} × ${width} ft`;
}
if (y.lengthM || y.widthM) {
const length = y.lengthM ?? '';
const width = y.widthM ?? '';
const length = y.lengthM ?? '-';
const width = y.widthM ?? '-';
return `${length} × ${width} m`;
}
return null;
@@ -129,14 +129,14 @@ export function CompanyOwnedYachtsTab({ companyId, portSlug }: CompanyOwnedYacht
{dims ? (
<span className="text-sm">{dims}</span>
) : (
<span className="text-muted-foreground"></span>
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>
{y.hullNumber ? (
<span className="text-sm">{y.hullNumber}</span>
) : (
<span className="text-muted-foreground"></span>
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>

View File

@@ -125,7 +125,7 @@ function OverviewTab({ companyId, company }: { companyId: string; company: Compa
<InlineCountryField
value={company.incorporationCountryIso}
onSave={async (iso) => {
// Wipe subdivision when country flips codes are country-scoped.
// Wipe subdivision when country flips - codes are country-scoped.
await mutation.mutateAsync({
incorporationCountryIso: iso,
incorporationSubdivisionIso: null,
@@ -175,7 +175,7 @@ function OverviewTab({ companyId, company }: { companyId: string; company: Compa
variant="textarea"
value={company.notes}
onSave={save('notes')}
emptyText="No notes click to add"
emptyText="No notes - click to add"
/>
</div>

View File

@@ -58,7 +58,7 @@ function ActivityFeedInner() {
<CardContent>
{items.length === 0 ? (
<p className="text-sm text-muted-foreground">
No recent activity yet your team&apos;s actions (interests created, stages changed,
No recent activity yet - your team&apos;s actions (interests created, stages changed,
invoices sent) will appear here.
</p>
) : (

View File

@@ -3,6 +3,7 @@
import { useState } from 'react';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { usePortContext } from '@/providers/port-provider';
import { PageHeader } from '@/components/shared/page-header';
import { KpiCardsWithBoundary } from './kpi-cards';
import { ActivityFeed } from './activity-feed';
@@ -12,29 +13,53 @@ import { OccupancyTimelineChart } from './occupancy-timeline-chart';
import { RevenueBreakdownChart } from './revenue-breakdown-chart';
import { LeadSourceChart } from './lead-source-chart';
import { MyRemindersRail } from './my-reminders-rail';
import { WebsiteGlanceTile } from './website-glance-tile';
import { WidgetErrorBoundary } from './widget-error-boundary';
import { AlertRail } from '@/components/alerts/alert-rail';
import type { DateRange } from '@/lib/services/analytics.service';
import { isCustomRange, type DateRange } from '@/lib/analytics/range';
const RANGE_LABELS: Record<DateRange, string> = {
const PRESET_LABELS: Record<'today' | '7d' | '30d' | '90d', string> = {
today: 'Today',
'7d': 'Last 7 days',
'30d': 'Last 30 days',
'90d': 'Last 90 days',
};
function rangeLabel(range: DateRange): string {
if (isCustomRange(range)) {
const fmt: Intl.DateTimeFormatOptions = {
month: 'short',
day: 'numeric',
year: 'numeric',
timeZone: 'UTC',
};
const from = new Date(`${range.from}T00:00:00.000Z`).toLocaleDateString('en-US', fmt);
const to = new Date(`${range.to}T00:00:00.000Z`).toLocaleDateString('en-US', fmt);
return `${from} ${to}`;
}
return PRESET_LABELS[range];
}
export function DashboardShell() {
const [range, setRange] = useState<DateRange>('30d');
const { currentPort } = usePortContext();
const portName = currentPort?.name ?? 'this port';
// Use a partial query-key prefix (no range segment) for invalidations.
// Reading: "any cached analytics result, regardless of range, please
// refetch on this event." This avoids any chance that a custom-range
// object literal hashes differently than the one stored in the cache,
// and keeps the invalidation surface broad enough to refresh whichever
// range the user is currently looking at.
useRealtimeInvalidation({
'interest:stageChanged': [
['analytics', 'pipeline_funnel', range],
['analytics', 'lead_source_attribution', range],
['analytics', 'pipeline_funnel'],
['analytics', 'lead_source_attribution'],
['dashboard', 'kpis'],
],
'client:created': [['dashboard', 'kpis']],
'berth:statusChanged': [
['analytics', 'occupancy_timeline', range],
['analytics', 'occupancy_timeline'],
['dashboard', 'kpis'],
],
});
@@ -44,8 +69,8 @@ export function DashboardShell() {
<PageHeader
title="Dashboard"
eyebrow="Overview"
description="Live snapshot of your marina activity"
kpiLine={<span>{RANGE_LABELS[range]}</span>}
description={`Live snapshot of ${portName} activity`}
kpiLine={<span>{rangeLabel(range)}</span>}
variant="gradient"
actions={<DateRangePicker value={range} onChange={setRange} />}
/>
@@ -54,7 +79,12 @@ export function DashboardShell() {
<KpiCardsWithBoundary />
</div>
<div className="grid gap-4 grid-cols-1 xl:grid-cols-[minmax(0,1fr)_320px]">
{/* `items-start` is critical: without it, the right-column aside is
stretched to match the chart column's row height, which forces
MyRemindersRail (or any other child with `h-full`) to push later
children out of the aside's box and into the rows below where
ActivityFeed renders. */}
<div className="grid gap-4 grid-cols-1 items-start xl:grid-cols-[minmax(0,1fr)_320px]">
<div className="grid gap-4 grid-cols-1 lg:grid-cols-2">
<WidgetErrorBoundary>
<PipelineFunnelChart range={range} />
@@ -70,6 +100,11 @@ export function DashboardShell() {
</WidgetErrorBoundary>
</div>
<aside className="min-w-0 space-y-4">
{/* Soft-fail tile linking to /website-analytics. Hidden if Umami
isn't configured for this port. */}
<WidgetErrorBoundary>
<WebsiteGlanceTile />
</WidgetErrorBoundary>
<WidgetErrorBoundary>
<MyRemindersRail />
</WidgetErrorBoundary>

View File

@@ -1,8 +1,12 @@
'use client';
import { useState } from 'react';
import { Calendar } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { cn } from '@/lib/utils';
import type { DateRange } from '@/lib/services/analytics.service';
import { isCustomRange, type DateRange } from '@/lib/analytics/range';
interface DateRangePickerProps {
value: DateRange;
@@ -10,14 +14,64 @@ interface DateRangePickerProps {
className?: string;
}
const OPTIONS: Array<{ value: DateRange; label: string }> = [
const PRESETS: Array<{ value: 'today' | '7d' | '30d' | '90d'; label: string }> = [
{ value: 'today', label: 'Today' },
{ value: '7d', label: '7d' },
{ value: '30d', label: '30d' },
{ value: '90d', label: '90d' },
];
/**
* Format a custom range as a compact button label, e.g. "Apr 14 May 4".
* Same year omits the year on both sides; different years includes both.
*/
function formatCustom(range: { from: string; to: string }): string {
const from = new Date(`${range.from}T00:00:00.000Z`);
const to = new Date(`${range.to}T00:00:00.000Z`);
const sameYear = from.getUTCFullYear() === to.getUTCFullYear();
const fmt: Intl.DateTimeFormatOptions = sameYear
? { month: 'short', day: 'numeric', timeZone: 'UTC' }
: { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' };
return `${from.toLocaleDateString('en-US', fmt)} ${to.toLocaleDateString('en-US', fmt)}`;
}
/**
* Today's date as a YYYY-MM-DD string in UTC. Used as the default for the
* "to" picker so users can't accidentally pick a future date by leaving the
* field empty.
*/
function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}
export function DateRangePicker({ value, onChange, className }: DateRangePickerProps) {
const [open, setOpen] = useState(false);
const isCustom = isCustomRange(value);
// Local state for the popover form. Seeded from the current value if it's
// already custom, otherwise defaults to a 14-day window ending today.
const [draftFrom, setDraftFrom] = useState<string>(() => {
if (isCustom) return value.from;
const d = new Date();
d.setUTCDate(d.getUTCDate() - 14);
return d.toISOString().slice(0, 10);
});
const [draftTo, setDraftTo] = useState<string>(() => (isCustom ? value.to : todayIso()));
const today = todayIso();
const draftValid =
draftFrom !== '' &&
draftTo !== '' &&
draftFrom <= draftTo &&
draftFrom <= today &&
draftTo <= today;
function applyCustom() {
if (!draftValid) return;
onChange({ kind: 'custom', from: draftFrom, to: draftTo });
setOpen(false);
}
return (
<div
role="tablist"
@@ -27,8 +81,8 @@ export function DateRangePicker({ value, onChange, className }: DateRangePickerP
className,
)}
>
{OPTIONS.map((opt) => {
const active = opt.value === value;
{PRESETS.map((opt) => {
const active = !isCustom && opt.value === value;
return (
<Button
key={opt.value}
@@ -50,6 +104,68 @@ export function DateRangePicker({ value, onChange, className }: DateRangePickerP
</Button>
);
})}
{/* Custom range - popover with two date inputs and an Apply button */}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
role="tab"
aria-selected={isCustom}
variant="ghost"
size="sm"
className={cn(
'h-7 px-3 text-xs font-medium transition-all duration-base ease-spring inline-flex items-center gap-1',
isCustom
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
data-testid="range-custom"
>
<Calendar className="h-3 w-3" aria-hidden />
{isCustom ? formatCustom(value) : 'Custom'}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-[260px] p-3">
<div className="space-y-3">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Custom range
</div>
<label className="block text-xs">
<span className="block text-muted-foreground mb-1">From</span>
<input
type="date"
value={draftFrom}
/* `max` capped at min(draftTo, today). Without the today
cap, users could pick a future From, end up with an
empty result, and not understand why. */
max={draftTo && draftTo < today ? draftTo : today}
onChange={(e) => setDraftFrom(e.target.value)}
className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm outline-none focus:border-brand/60 focus:ring-2 focus:ring-brand/15"
/>
</label>
<label className="block text-xs">
<span className="block text-muted-foreground mb-1">To</span>
<input
type="date"
value={draftTo}
min={draftFrom || undefined}
max={today}
onChange={(e) => setDraftTo(e.target.value)}
className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm outline-none focus:border-brand/60 focus:ring-2 focus:ring-brand/15"
/>
</label>
<div className="flex items-center justify-end gap-2 pt-1">
<Button variant="ghost" size="sm" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button size="sm" onClick={applyCustom} disabled={!draftValid}>
Apply
</Button>
</div>
</div>
</PopoverContent>
</Popover>
</div>
);
}

View File

@@ -3,6 +3,7 @@
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api/client';
import { useUIStore } from '@/stores/ui-store';
import { KPITile } from '@/components/ui/kpi-tile';
import { Skeleton } from '@/components/ui/skeleton';
import { WidgetErrorBoundary } from './widget-error-boundary';
@@ -37,11 +38,19 @@ function KpiTileSkeleton() {
}
export function KpiCards() {
// Keying on currentPortId ensures React Query treats a port-resolved fetch
// as a different query than the one that fires on first paint when the
// store hasn't yet hydrated. Without this, an early null-port fetch could
// cache an error and display "-" indefinitely until the staleTime expires.
const portId = useUIStore((s) => s.currentPortId);
const { data, isLoading, isError } = useQuery<KpiData>({
queryKey: ['dashboard', 'kpis'],
queryKey: ['dashboard', 'kpis', portId],
queryFn: () => apiFetch<KpiData>('/api/v1/dashboard/kpis'),
staleTime: 60_000,
retry: 2,
// Avoid running until we have a port id - gates against the early
// unauth/no-port window where the API would return zeroes/errors.
enabled: !!portId,
});
if (isLoading) {
@@ -62,22 +71,22 @@ export function KpiCards() {
}> = [
{
label: 'Total Clients',
value: isError ? '' : String(data?.totalClients ?? 0),
value: isError ? '-' : String(data?.totalClients ?? 0),
accent: 'brand',
},
{
label: 'Active Interests',
value: isError ? '' : String(data?.activeInterests ?? 0),
value: isError ? '-' : String(data?.activeInterests ?? 0),
accent: 'teal',
},
{
label: 'Pipeline Value',
value: isError ? '' : formatCurrency(data?.pipelineValueUsd ?? 0),
value: isError ? '-' : formatCurrency(data?.pipelineValueUsd ?? 0),
accent: 'success',
},
{
label: 'Occupancy Rate',
value: isError ? '' : formatPercent(data?.occupancyRate ?? 0),
value: isError ? '-' : formatPercent(data?.occupancyRate ?? 0),
accent: 'purple',
},
];

View File

@@ -56,7 +56,7 @@ export function LeadSourceChart({ range }: Props) {
) : !slices.length ? (
<EmptyState
title="No interests in range"
description="Lights up once new interests are created tracks where each came from (website, referral, broker)."
description="Lights up once new interests are created - tracks where each came from (website, referral, broker)."
/>
) : (
// Percentage radii + center-anchored chart so the pie scales with

View File

@@ -36,7 +36,7 @@ const PRIORITY_BADGE: Record<string, string> = {
/**
* Compact reminders rail for the dashboard sidebar. Lists reminders assigned
* to the current user (overdue first, then upcoming). Each item links to its
* subject interest preferred, then client, then the generic entity ref.
* subject - interest preferred, then client, then the generic entity ref.
*
* Limited to 6 items; "View all" routes to /reminders.
*/
@@ -67,11 +67,13 @@ export function MyRemindersRail() {
return `/${portSlug}/reminders`;
}
// `h-full` only at xl: where the dashboard grid pairs this rail with
// a sibling chart column. On mobile (stacked) it produced a weirdly
// tall empty card.
// Natural height - the parent dashboard grid uses `items-start` so the
// aside column no longer forces this rail to fill the chart column's
// height. Stretching here pushed AlertRail out of the aside's box and
// into the territory below where ActivityFeed renders, producing a
// visible overlap on tall viewports.
return (
<Card className="xl:h-full">
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0 pb-3">
<div className="space-y-0.5">
<CardTitle className="flex items-center gap-1.5 text-base">
@@ -100,7 +102,7 @@ export function MyRemindersRail() {
</div>
) : sorted.length === 0 ? (
<p className="py-3 text-center text-sm text-muted-foreground">
All caught up no reminders.
All caught up - no reminders.
</p>
) : (
<ul className="space-y-1">

View File

@@ -3,8 +3,8 @@
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api/client';
import { isCustomRange, type DateRange } from '@/lib/analytics/range';
import type {
DateRange,
LeadSourceAttributionData,
MetricBase,
OccupancyTimelineData,
@@ -18,12 +18,27 @@ interface MetricResponse<T> {
data: T;
}
/**
* Serialize a DateRange (preset or custom) into the URL query params the
* /api/v1/analytics route expects: `range=30d` for presets, or
* `range=custom&from=YYYY-MM-DD&to=YYYY-MM-DD` for custom.
*/
function rangeToQuery(range: DateRange): string {
if (isCustomRange(range)) {
return `range=custom&from=${range.from}&to=${range.to}`;
}
return `range=${range}`;
}
export function useAnalyticsMetric<T>(metric: MetricBase, range: DateRange) {
return useQuery<T>({
// Stringify custom ranges into the cache key so React Query treats
// each {from,to} pair as its own query - otherwise switching dates
// would never refetch.
queryKey: ['analytics', metric, range],
queryFn: async () => {
const res = await apiFetch<MetricResponse<T>>(
`/api/v1/analytics?metric=${metric}&range=${range}`,
`/api/v1/analytics?metric=${metric}&${rangeToQuery(range)}`,
);
return res.data;
},

View File

@@ -0,0 +1,79 @@
'use client';
/**
* Compact "Website at a glance" tile for the main sales dashboard. Shows
* pageviews today + active visitors right now + a deep-link to the full
* /website-analytics page. Soft-fails (renders nothing) when Umami isn't
* configured for this port - so the dashboard doesn't get cluttered with
* a "configure Umami" prompt that the user already saw on the dedicated
* page.
*/
import Link from 'next/link';
import { Globe, ArrowRight } from 'lucide-react';
import { useUIStore } from '@/stores/ui-store';
import { Card } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import {
useUmamiActive,
useUmamiStats,
} from '@/components/website-analytics/use-website-analytics';
export function WebsiteGlanceTile() {
const portSlug = useUIStore((s) => s.currentPortSlug);
const stats = useUmamiStats('today');
const active = useUmamiActive('today');
// Hide the tile entirely if Umami isn't configured - this dashboard is
// for sales, not for prompting the operator into integration setup.
if (
stats.data?.error === 'umami_not_configured' ||
active.data?.error === 'umami_not_configured'
) {
return null;
}
const today = stats.data?.data?.pageviews?.value ?? 0;
const activeNow = active.data?.data?.visitors ?? 0;
const loading = stats.isLoading || active.isLoading;
return (
<Link
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
href={portSlug ? (`/${portSlug}/website-analytics` as any) : ('/' as any)}
className="block group"
>
<Card className="relative overflow-hidden p-3 sm:p-5 transition-shadow hover:shadow-md">
<div className="absolute inset-x-0 top-0 h-1 bg-mint" aria-hidden />
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground sm:text-xs">
<Globe className="h-3 w-3" aria-hidden />
Website today
</div>
{loading ? (
<Skeleton className="mt-2 h-7 w-20" />
) : (
<div className="mt-1 flex items-baseline gap-2 text-lg font-semibold tabular-nums sm:mt-2 sm:text-2xl">
{today.toLocaleString()}
<span className="text-xs font-normal text-muted-foreground">pageviews</span>
</div>
)}
<div className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="relative flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500" />
</span>
{activeNow} active right now
</div>
</div>
<ArrowRight
className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5"
aria-hidden
/>
</div>
</Card>
</Link>
);
}

View File

@@ -174,7 +174,7 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) {
data: { to: string[]; subject: string; attachments: Array<{ fileId: string }> };
}>(`/api/v1/documents/${documentId}/compose-completion-email`, { method: 'POST' });
toast.info(
`Email composer prepared for ${draft.data.to.length} signer${draft.data.to.length === 1 ? '' : 's'} opens in PR8 wizard`,
`Email composer prepared for ${draft.data.to.length} signer${draft.data.to.length === 1 ? '' : 's'} - opens in PR8 wizard`,
);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to prepare email');

View File

@@ -26,7 +26,7 @@ interface DocumentListProps {
interestId?: string;
clientId?: string;
/** Override the default empty state ("No documents yet.") with a contextual
* CTA e.g. on the interest Documents tab we render a Generate EOI prompt. */
* CTA - e.g. on the interest Documents tab we render a Generate EOI prompt. */
emptyState?: React.ReactNode;
}
@@ -80,7 +80,7 @@ export function DocumentList({ interestId, clientId, emptyState }: DocumentListP
};
const getSignerProgress = (doc: DocumentRow) => {
if (!doc.signers) return '';
if (!doc.signers) return '-';
const signed = doc.signers.filter((s) => s.status === 'signed').length;
return `${signed}/${doc.signers.length} signed`;
};

View File

@@ -180,7 +180,7 @@ export function DocumentsHub({ portSlug, initialTab = 'all' }: DocumentsHubProps
{isNonSignature && doc.status === 'sent' ? 'Delivered' : doc.status.replace(/_/g, ' ')}
</StatusPill>
<span className="text-xs tabular-nums text-muted-foreground">
{totalSigners > 0 ? `${signedCount}/${totalSigners} signed` : ''}
{totalSigners > 0 ? `${signedCount}/${totalSigners} signed` : '-'}
</span>
<span className="text-xs text-muted-foreground">
{new Date(doc.createdAt).toLocaleDateString('en-GB')}

View File

@@ -22,14 +22,14 @@ import {
import { Label } from '@/components/ui/label';
import { apiFetch } from '@/lib/api/client';
/** Required for the EOI's top paragraph (Section 2) without these the
/** Required for the EOI's top paragraph (Section 2) - without these the
* document is unsignable, so generation is blocked. Yacht and berth fields
* belong to Section 3 and may be left blank. */
interface EoiPrerequisites {
hasName: boolean;
hasEmail: boolean;
hasAddress: boolean;
/** Optional info-only checks. Generation proceeds without them. */
/** Optional - info-only checks. Generation proceeds without them. */
hasYacht: boolean;
hasBerth: boolean;
}
@@ -180,7 +180,7 @@ export function EoiGenerateDialog({
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">
Optional (Section 3 left blank if absent)
Optional (Section 3 - left blank if absent)
</p>
{OPTIONAL_LABELS.map(({ key, label }) => (
<div key={key} className="flex items-center gap-3 text-sm">

View File

@@ -156,7 +156,7 @@ export function ExpenseCard({ expense, portSlug, onEdit, onArchive }: ExpenseCar
</p>
) : null}
{/* Amount prominent */}
{/* Amount - prominent */}
<p className="mt-1 text-base font-semibold tabular-nums text-foreground">
{amountFormatted}
</p>

View File

@@ -72,7 +72,7 @@ export function getExpenseColumns({
className="font-medium text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
{row.original.establishmentName ?? ''}
{row.original.establishmentName ?? '-'}
</Link>
),
},
@@ -113,7 +113,7 @@ export function getExpenseColumns({
header: 'Category',
cell: ({ getValue }) => {
const cat = getValue() as string | null;
if (!cat) return <span className="text-muted-foreground"></span>;
if (!cat) return <span className="text-muted-foreground">-</span>;
return (
<Badge variant="outline" className="capitalize text-xs">
{cat.replace(/_/g, ' ')}

View File

@@ -146,19 +146,19 @@ export function ExpenseDetail({ expenseId, onEdit, onArchived }: ExpenseDetailPr
<CardContent className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Category</span>
<p className="mt-0.5 capitalize">{expense.category?.replace(/_/g, ' ') ?? ''}</p>
<p className="mt-0.5 capitalize">{expense.category?.replace(/_/g, ' ') ?? '-'}</p>
</div>
<div>
<span className="text-muted-foreground">Payment Method</span>
<p className="mt-0.5 capitalize">{expense.paymentMethod?.replace(/_/g, ' ') ?? ''}</p>
<p className="mt-0.5 capitalize">{expense.paymentMethod?.replace(/_/g, ' ') ?? '-'}</p>
</div>
<div>
<span className="text-muted-foreground">Payer</span>
<p className="mt-0.5">{expense.payer ?? ''}</p>
<p className="mt-0.5">{expense.payer ?? '-'}</p>
</div>
<div>
<span className="text-muted-foreground">Description</span>
<p className="mt-0.5">{expense.description ?? ''}</p>
<p className="mt-0.5">{expense.description ?? '-'}</p>
</div>
</CardContent>
</Card>

View File

@@ -30,7 +30,7 @@ interface InlineStagePickerProps {
/**
* Click-to-change stage chip. Replaces the modal-based InterestStagePicker
* for inline editing user clicks the chip, picks a new stage from the
* for inline editing - user clicks the chip, picks a new stage from the
* popover (with optional reason), commits in one click. The popover stays
* compact: a small reason field above the stage list, and clicking any stage
* fires the mutation immediately.
@@ -140,7 +140,7 @@ export function InlineStagePicker({
isCurrent && 'font-medium',
)}
>
{/* Colored chip (mirrors the inline stage badge) turns
{/* Colored chip (mirrors the inline stage badge) - turns
the picker into a visual scan rather than just a list. */}
<span
className={cn('inline-flex h-5 w-3 shrink-0 rounded-sm', STAGE_DOT[s])}

View File

@@ -78,7 +78,7 @@ export function getInterestColumns({
className="truncate font-medium text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
{row.original.clientName ?? ''}
{row.original.clientName ?? '-'}
</Link>
{notesCount > 0 ? (
<span
@@ -99,7 +99,7 @@ export function getInterestColumns({
header: 'Berth',
cell: ({ row }) => {
if (!row.original.berthId || !row.original.berthMooringNumber) {
return <span className="text-muted-foreground"></span>;
return <span className="text-muted-foreground">-</span>;
}
return (
<Link
@@ -150,7 +150,7 @@ export function getInterestColumns({
header: 'Category',
cell: ({ getValue }) => {
const cat = getValue() as string | null;
if (!cat) return <span className="text-muted-foreground"></span>;
if (!cat) return <span className="text-muted-foreground">-</span>;
return (
<Badge variant="outline" className="text-xs capitalize">
{CATEGORY_LABELS[cat] ?? cat}
@@ -164,7 +164,7 @@ export function getInterestColumns({
header: 'Source',
cell: ({ getValue }) => {
const source = getValue() as string | null;
if (!source) return <span className="text-muted-foreground"></span>;
if (!source) return <span className="text-muted-foreground">-</span>;
return (
<Badge variant="outline" className="text-xs">
{SOURCE_LABELS[source] ?? source}
@@ -178,7 +178,7 @@ export function getInterestColumns({
enableSorting: false,
cell: ({ row }) => {
const rowTags = row.original.tags ?? [];
if (rowTags.length === 0) return <span className="text-muted-foreground"></span>;
if (rowTags.length === 0) return <span className="text-muted-foreground">-</span>;
return (
<div className="flex flex-wrap gap-1">
{rowTags.slice(0, 3).map((tag) => (
@@ -203,7 +203,7 @@ export function getInterestColumns({
cell: ({ row }) => {
const lastIso = row.original.dateLastContact ?? row.original.updatedAt ?? null;
if (!lastIso) {
return <span className="text-muted-foreground text-sm"></span>;
return <span className="text-muted-foreground text-sm">-</span>;
}
const d = new Date(lastIso);
return (

View File

@@ -30,9 +30,9 @@ import { cn } from '@/lib/utils';
const OUTCOME_BADGE: Record<string, { label: string; className: string }> = {
won: { label: 'Won', className: 'bg-emerald-100 text-emerald-700' },
lost_other_marina: { label: 'Lost other marina', className: 'bg-rose-100 text-rose-700' },
lost_unqualified: { label: 'Lost unqualified', className: 'bg-rose-100 text-rose-700' },
lost_no_response: { label: 'Lost no response', className: 'bg-rose-100 text-rose-700' },
lost_other_marina: { label: 'Lost - other marina', className: 'bg-rose-100 text-rose-700' },
lost_unqualified: { label: 'Lost - unqualified', className: 'bg-rose-100 text-rose-700' },
lost_no_response: { label: 'Lost - no response', className: 'bg-rose-100 text-rose-700' },
cancelled: { label: 'Cancelled', className: 'bg-slate-200 text-slate-700' },
};
@@ -69,7 +69,7 @@ interface InterestDetailHeaderProps {
clientPrimaryPhone?: string | null;
clientPrimaryPhoneE164?: string | null;
/** Pending/snoozed reminders attached to this interest. Drives the
* alarm-bell badge on the header surfaces follow-ups so the rep
* alarm-bell badge on the header - surfaces follow-ups so the rep
* doesn't have to remember to check /reminders. */
activeReminderCount?: number;
berthId: string | null;
@@ -107,7 +107,7 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
const outcomeBadge = resolveOutcomeBadge(interest.outcome);
const isClosed = !!interest.outcome;
// Contact deep-links resolved from the linked client's primary channels.
// Contact deep-links - resolved from the linked client's primary channels.
// wa.me requires the digits-only E.164 number (no leading "+"); fall back to
// stripping non-digits from the display value when the canonical form is
// missing.
@@ -258,7 +258,7 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
</div>
)}
{/* Contact deep-links let the rep email / call / WhatsApp the
{/* Contact deep-links - let the rep email / call / WhatsApp the
client without leaving the interest workspace. Resolved from
the linked client's primary contact channels (server-side
fetch in getInterestById). */}
@@ -343,7 +343,7 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
the won/lost meaning (green vs rose). Adding a "Won" /
"Lost" text label inline blew out the cluster width and
forced the Email/Call/WhatsApp action-chip row above to
stack vertically bad trade. From sm up, the full
stack vertically - bad trade. From sm up, the full
"Mark won" / "Close as lost" labels read clearly. */}
<button
type="button"

View File

@@ -36,7 +36,7 @@ export function InterestDocumentsTab({ interestId }: InterestDocumentsTabProps)
});
const prerequisites = {
// Required (EOI Section 2 top paragraph): name, address, email.
// Required (EOI Section 2 - top paragraph): name, address, email.
hasName: Boolean(interest?.clientName),
hasEmail: Boolean(interest?.clientPrimaryEmail),
hasAddress: Boolean(interest?.clientHasAddress),

Some files were not shown because too many files have changed in this diff Show More