chore(autonomous-session): consolidate uncommitted work from prior session

Bundles the prior autonomous-session output that was sitting unstaged:

- Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances)
- country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that
  never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk
  after the per-subpath dynamic-import approach silently failed in webpack)
- Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index,
  redirects (ocr to ai, reports to dashboard, invitations to users),
  docs/admin-ia-proposal.md
- Per-template email tester (registry + endpoint + UI on Email admin page)
- Cancel-document mode picker (delete-from-Documenso vs keep-for-audit)
- Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers
- Customize-widgets per-region sortables at xl+ (charts/rails/feed); single
  flat sortable below xl when the layout stacks; per-viewport saved orders
- Audit doc updates capturing each shipped item
- Lint fixes: react-compiler immutability in DonutChart (reduce instead of
  let-reassign), set-state-in-effect disables in CountryFlag and
  UploadForSigning preview-bytes effect, unused 'confirm' destructures in
  interest contract + reservation tabs, unescaped apostrophe in test-template
  card copy
This commit is contained in:
2026-05-23 00:52:59 +02:00
parent 43719b49e9
commit 221ae5784e
749 changed files with 7440 additions and 3118 deletions

View File

@@ -4,8 +4,10 @@ import { useMemo, useState } from 'react';
import Link from 'next/link';
import {
Activity,
BarChart3,
AlertCircle,
Anchor,
BellRing,
BookMarked,
BookOpen,
ClipboardList,
CopyCheck,
@@ -15,6 +17,7 @@ import {
FileText,
FileUp,
GitBranch,
Home,
Inbox,
ListChecks,
Mail,
@@ -64,62 +67,60 @@ interface AdminGroup {
// Catalog lives inside the client component so React Server Components don't
// need to serialize the lucide icon factories (functions / $$typeof refs)
// across the RSC boundary that crash was the error the rep hit when the
// across the RSC boundary - that crash was the error the rep hit when the
// catalog lived in the server-component `page.tsx`. Adding a new admin
// surface? Append it to the matching group below.
// surface? Append it to the matching domain below.
//
// 7-domain IA regrouped 2026-05-22 per docs/admin-ia-proposal.md:
// 1. Brand & Communication - how outbound looks
// 2. Sales workflow - pipeline behaviour + templates
// 3. Catalog - tenant-defined data shapes
// 4. Identity & access - who can use the system
// 5. Inbox & data quality - admin queues + cleanup
// 6. Integrations - external systems + providers
// 7. System & observability - infra + diagnostics + escape hatches
//
// Pages deleted in the regroup (redirect-shimmed): /admin/ocr (dup of /ai),
// /admin/invitations (merged into /users 2026-05-21), /admin/reports
// (duplicated dashboard widgets). See docs/admin-ia-proposal.md §8.
const GROUPS: AdminGroup[] = [
{
title: 'Access',
description: 'Who can sign in and what they can do once they do.',
title: 'Brand & Communication',
description: 'How outbound looks and which channels it ships on.',
sections: [
{
href: 'users',
label: 'Users',
description: 'CRM accounts, role assignments, and per-user residential access toggles.',
icon: Users,
keywords: [
'accounts',
'staff',
'team',
'disable user',
'reset password',
'residential access',
],
href: 'branding',
label: 'Branding',
description: 'App name, logo, primary color, and email header/footer HTML.',
icon: Paintbrush,
keywords: ['logo', 'colors', 'email shell', 'preview', 'test email'],
},
// /admin/invitations merged into /admin/users (Active users +
// Invitations tabs) on 2026-05-21. The standalone tile was
// removed; reps still find the invitation flow via the Users
// tile's "Invitations" tab.
{
href: 'roles',
label: 'Roles & Permissions',
description: 'Default permission sets and per-port role overrides.',
icon: Shield,
href: 'email',
label: 'Email Settings',
description:
'SMTP credentials (noreply + sales), per-template tester, routing rules, and the connectivity probe.',
icon: Mail,
keywords: ['smtp', 'noreply', 'sales mailbox', 'test send', 'routing'],
},
{
href: 'email-templates',
label: 'Email Templates',
description:
'Subject-line + body overrides for transactional emails (portal, inquiry, invite).',
icon: FilePen,
},
],
},
{
title: 'Configuration',
description: 'Branding, integrations, and per-port settings.',
title: 'Sales workflow',
description: 'How the pipeline behaves - triggers, scoring, document + form templates.',
sections: [
{
href: 'email',
label: 'Email Settings',
description: 'From address, signatures, and per-port SMTP overrides.',
icon: Mail,
},
{
href: 'documenso',
label: 'EOI signing service',
description:
'API credentials, EOI template, and default in-app vs external signing pathway.',
icon: FileSignature,
},
{
href: 'pipeline-rules',
label: 'Pipeline auto-advance',
label: 'Pipeline rules',
description:
'Per-trigger control: which lifecycle events (EOI signed, deposit received, contract signed) auto-advance the deal stage.',
'Berth-rules engine triggers + per-event auto-advance (EOI signed, deposit received, contract signed).',
icon: GitBranch,
keywords: ['pipeline', 'auto-advance', 'stage rules', 'aggressive', 'conservative'],
},
@@ -127,7 +128,7 @@ const GROUPS: AdminGroup[] = [
href: 'pulse',
label: 'Deal Pulse',
description:
'Configure the chip on every interest header master toggle, per-signal toggles, and tier-label overrides.',
'Configure the chip on every interest header - master toggle, per-signal toggles, and tier-label overrides.',
icon: Activity,
keywords: ['pulse', 'deal-health', 'health chip', 'hot warm cold'],
},
@@ -138,15 +139,232 @@ const GROUPS: AdminGroup[] = [
icon: BellRing,
},
{
href: 'branding',
label: 'Branding',
description: 'App name, logo, primary color, and email header/footer HTML.',
icon: Paintbrush,
href: 'qualification-criteria',
label: 'Qualification criteria',
description:
'Checklist reps complete to qualify an enquiry. Enable/disable/reorder per port; affects the "ready to qualify" hint on the interest detail.',
icon: ListChecks,
keywords: ['qualification', 'criteria', 'checklist', 'qualify', 'enquiry', 'sales gate'],
},
{
href: 'residential-stages',
label: 'Residential pipeline stages',
description:
'Configure stages residential interests flow through. Removing a stage with active interests prompts for reassignment.',
icon: Home,
keywords: ['stages', 'pipeline', 'residential funnel', 'reassign'],
},
{
href: 'forms',
label: 'Forms',
description: 'Form templates used by client-facing inquiry and intake flows.',
icon: ClipboardList,
},
{
href: 'templates',
label: 'Document Templates',
description:
'PDF + email templates with merge-field placeholders - EOI, reservation, contract.',
icon: FileText,
},
],
},
{
title: 'Catalog',
description: 'Tenant-defined data shapes that attach to records.',
sections: [
{
href: 'vocabularies',
label: 'Vocabularies',
description:
'Per-port pick lists used across the CRM: interest temperatures, status reasons, tenure types, expense categories, document types.',
icon: BookOpen,
},
{
href: 'tags',
label: 'Tags',
description: 'Color-coded tags applied to clients, yachts, companies, and interests.',
icon: Tag,
},
{
href: 'custom-fields',
label: 'Custom Fields',
description: 'Tenant-defined fields for clients, yachts, and reservations.',
icon: SlidersHorizontal,
},
{
href: 'brochures',
label: 'Brochures',
description: 'Per-port versioned brochure PDFs distributed through the public site + reps.',
icon: BookMarked,
},
],
},
{
title: 'Identity & access',
description: 'Who can sign in and what they can do once they do.',
sections: [
{
href: 'users',
label: 'Users',
description:
'Active CRM accounts + pending invitations (merged tabs), role assignments, residential access toggles.',
icon: Users,
keywords: [
'accounts',
'staff',
'team',
'invitations',
'invite',
'disable user',
'reset password',
'residential access',
],
},
{
href: 'roles',
label: 'Roles & Permissions',
description: 'Default permission sets and per-port role overrides.',
icon: Shield,
},
{
href: 'ports',
label: 'Ports',
description: 'Manage the marinas/ports this installation serves (super-admin only).',
icon: Ship,
},
],
},
{
title: 'Inbox & data quality',
description: 'Admin queues for inbound submissions + cleanup tooling.',
sections: [
{
href: 'inquiries',
label: 'Inquiry Inbox',
description:
'Submissions captured from the public marketing site (berth, residence, contact).',
icon: Inbox,
},
{
href: 'sends',
label: 'Send Log',
description: 'Brochure and per-berth PDF sends, with delivery failures surfaced for retry.',
icon: Send,
},
{
href: 'duplicates',
label: 'Duplicates',
description: 'Review queue of suspected duplicate clients flagged by the dedup engine.',
icon: CopyCheck,
},
{
href: 'import',
label: 'Bulk Import',
description: 'CSV-driven imports for clients, yachts, and reservations.',
icon: FileUp,
},
{
href: 'berths',
label: 'Berths admin',
description:
'Bulk-add new berths in one wizard + reconcile berths missing required fields.',
icon: Anchor,
keywords: ['bulk add', 'reconcile', 'berth pdf', 'mooring', 'bulk berths'],
},
],
},
{
title: 'Integrations',
description: 'External systems + provider configuration.',
sections: [
{
href: 'documenso',
label: 'Signing service (Documenso)',
description:
'API credentials, signer identities, templates, and signing-behaviour for every document the CRM puts out for signature.',
icon: FileSignature,
keywords: ['documenso', 'eoi', 'signing', 'envelope', 'signer', 'embedded'],
},
{
href: 'webhooks',
label: 'Webhooks',
description: 'Outgoing webhook subscriptions, secrets, and delivery log.',
icon: Webhook,
},
{
href: 'website-analytics',
label: 'Website analytics (Umami)',
description: 'Per-port Umami URL, API token, and Website ID.',
icon: TrendingUp,
keywords: ['umami', 'analytics', 'traffic', 'visitors', 'marketing', 'pageviews'],
},
{
href: 'ai',
label: 'AI configuration',
description:
'One panel for every AI feature: master switch, provider credentials, OCR settings, interest scoring, email-drafts, recommender controls.',
icon: Sparkles,
keywords: [
'openai',
'anthropic',
'gpt',
'claude',
'llm',
'api key',
'embeddings',
'receipt',
'scan',
'tesseract',
'ocr',
'expense scanner',
'interest scoring',
'email drafts',
],
},
],
},
{
title: 'System & observability',
description: 'Infra, observability, escape hatches.',
sections: [
{
href: 'audit',
label: 'Audit Log',
description: 'Searchable log of every authenticated mutation in the system.',
icon: ScrollText,
},
{
href: 'monitoring',
label: 'Queue Monitoring',
description: 'BullMQ queue health, throughput, and retry diagnostics.',
icon: Activity,
},
{
href: 'errors',
label: 'Errors',
description:
'Recent server-side error events with request-id drill-down, plus the error-code catalog.',
icon: AlertCircle,
keywords: ['exceptions', 'request id', 'stack trace', 'error code'],
},
{
href: 'backup',
label: 'Backup & Restore',
description: 'Backup posture + retention policy (read-only).',
icon: DatabaseBackup,
},
{
href: 'storage',
label: 'Storage Backend',
description:
'Choose between S3-compatible object store or local filesystem; migrate between them.',
icon: Server,
},
{
href: 'settings',
label: 'System Settings',
description: 'Generic key/value configuration store for advanced flags.',
description: 'Generic key/value configuration store for advanced flags (escape hatch).',
icon: Settings,
// Mirrors KNOWN_SETTINGS in settings-manager.tsx so a search for any
// individual flag jumps straight to this card. Keep in sync when
@@ -188,194 +406,15 @@ const GROUPS: AdminGroup[] = [
'default currency',
],
},
{
href: 'webhooks',
label: 'Webhooks',
description: 'Outgoing webhook subscriptions, secrets, and delivery log.',
icon: Webhook,
},
],
},
{
title: 'Content',
description: 'Forms, templates, and labels that users see.',
sections: [
{
href: 'forms',
label: 'Forms',
description: 'Form templates used by client-facing inquiry and intake flows.',
icon: ClipboardList,
},
{
href: 'templates',
label: 'Document Templates',
description: 'PDF + email templates with merge-field placeholders.',
icon: FileText,
},
{
href: 'email-templates',
label: 'Email Templates',
description: 'Customize subject lines for transactional emails (portal, inquiry, invite).',
icon: FilePen,
},
{
href: 'tags',
label: 'Tags',
description: 'Color-coded tags applied to clients, yachts, companies, and interests.',
icon: Tag,
},
{
href: 'vocabularies',
label: 'Vocabularies',
description:
'Per-port pick lists used across the CRM: interest temperatures, status reasons, tenure types, expense categories, document types.',
icon: BookOpen,
},
{
href: 'custom-fields',
label: 'Custom Fields',
description: 'Tenant-defined fields for clients, yachts, and reservations.',
icon: SlidersHorizontal,
},
],
},
{
title: 'Data Quality',
description: 'Cleanup, imports, and the audit trail.',
sections: [
{
href: 'inquiries',
label: 'Inquiry Inbox',
description:
'Submissions captured from the public marketing site (berth, residence, contact).',
icon: Inbox,
},
{
href: 'sends',
label: 'Send Log',
description: 'Brochure and per-berth PDF sends, with delivery failures surfaced for retry.',
icon: Send,
},
{
href: 'duplicates',
label: 'Duplicates',
description: 'Review queue of suspected duplicate clients flagged by the dedup engine.',
icon: CopyCheck,
},
{
href: 'import',
label: 'Bulk Import',
description: 'CSV-driven imports for clients, yachts, and reservations.',
icon: FileUp,
},
{
href: 'audit',
label: 'Audit Log',
description: 'Searchable log of every authenticated mutation in the system.',
icon: ScrollText,
},
],
},
{
title: 'Operations',
description: 'Health checks and disaster recovery.',
sections: [
{
href: 'reports',
label: 'Reports',
description: 'Saved analytics views and ad-hoc query results.',
icon: BarChart3,
},
{
href: 'monitoring',
label: 'Queue Monitoring',
description: 'BullMQ queue health, throughput, and retry diagnostics.',
icon: Activity,
},
{
href: 'backup',
label: 'Backup & Restore',
description: 'Backup posture + retention policy (read-only).',
icon: DatabaseBackup,
},
{
href: 'storage',
label: 'Storage Backend',
description:
'Choose between S3-compatible object store or local filesystem; migrate between them.',
icon: Server,
},
],
},
{
title: 'Tenancy',
description: 'Multi-port and multi-install scaffolding.',
sections: [
{
href: 'ports',
label: 'Ports',
description: 'Manage the marinas/ports this installation serves.',
icon: Ship,
},
{
href: 'onboarding',
label: 'Onboarding checklist',
description:
'Step-by-step setup checklist for fresh ports auto-detects what youve configured and lets you mark manual steps complete.',
'Step-by-step setup checklist for fresh ports - auto-detects what youve configured and lets you mark manual steps complete.',
icon: ListChecks,
},
],
},
{
title: 'Integrations',
description: 'Third-party providers wired into the app.',
sections: [
{
href: 'ai',
label: 'AI configuration',
description:
'Master switch, provider credentials, and the Receipt OCR settings in one place. Per-feature pages (berth-PDF parser, recommender) link out from here.',
icon: Sparkles,
keywords: [
'openai',
'anthropic',
'gpt',
'claude',
'llm',
'api key',
'embeddings',
'receipt',
'scan',
'tesseract',
'ocr',
'expense scanner',
],
},
{
href: 'website-analytics',
label: 'Website analytics (Umami)',
description: 'Per-port Umami URL, API token, and Website ID.',
icon: TrendingUp,
keywords: ['umami', 'analytics', 'traffic', 'visitors', 'marketing', 'pageviews'],
},
{
href: 'residential-stages',
label: 'Residential pipeline stages',
description:
'Configure stages residential interests flow through. Removing a stage with active interests prompts for reassignment.',
icon: ListChecks,
keywords: ['stages', 'pipeline', 'residential funnel', 'reassign'],
},
{
href: 'qualification-criteria',
label: 'Qualification criteria',
description:
'Checklist reps complete to qualify an enquiry. Enable/disable/reorder per port; affects the soft "ready to qualify" hint on the interest detail.',
icon: ListChecks,
keywords: ['qualification', 'criteria', 'checklist', 'qualify', 'enquiry', 'sales gate'],
},
],
},
];
interface AdminSectionsBrowserProps {

View File

@@ -165,7 +165,7 @@ export function AuditLogList() {
if (source !== 'all') params.set('source', source);
if (debouncedSearch) params.set('search', debouncedSearch);
if (debouncedUserId) params.set('userId', debouncedUserId);
// Skip the date filters when From > To the inline warning below
// Skip the date filters when From > To - the inline warning below
// tells the user to fix it; we don't want to fire a request with a
// useless empty range either.
const datesValid = !(dateFrom && dateTo && dateFrom > dateTo);
@@ -213,7 +213,7 @@ export function AuditLogList() {
useEffect(() => {
// Refetch on filter change. Migrating this list to useInfiniteQuery
// would be the proper fix but is deferred the fetch-on-effect
// would be the proper fix but is deferred - the fetch-on-effect
// pattern here is functionally correct and gated by the queryString
// memo so it only fires when filters actually change.
// eslint-disable-next-line react-hooks/set-state-in-effect

View File

@@ -38,7 +38,7 @@ const STATUS_TONE: Record<string, string> = {
};
function formatBytes(n: number | null): string {
if (n === null) return '';
if (n === null) return '-';
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;

View File

@@ -1,7 +1,10 @@
'use client';
import { useState } from 'react';
import { Eye, Send } from 'lucide-react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import type { Route } from 'next';
import { ArrowRight, Eye, Send } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
@@ -21,6 +24,8 @@ interface PreviewResponse {
* without firing one of the real flows.
*/
export function EmailPreviewCard() {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const [html, setHtml] = useState<string | null>(null);
const [subject, setSubject] = useState<string | null>(null);
const [loadingPreview, setLoadingPreview] = useState(false);
@@ -116,6 +121,19 @@ export function EmailPreviewCard() {
Sends the same sample email to the address you enter. Useful for checking how it lands
in Gmail, Outlook, Apple Mail, etc.
</p>
{/* The branded shell only fires the generic sample copy. To
test the actual subject/body of a specific transactional
template (password reset, EOI invite, signing reminder
etc.), use the per-template tester on Email Settings. */}
{portSlug ? (
<Link
href={`/${portSlug}/admin/email` as Route}
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
>
Test individual templates (password reset, EOI invite, signing reminder, )
<ArrowRight className="h-3 w-3" aria-hidden />
</Link>
) : null}
</div>
</CardContent>
</Card>

View File

@@ -64,8 +64,8 @@ async function brandingHeaders(): Promise<Headers> {
const WARNING_LABELS: Record<string, string> = {
trimmed: 'Auto-trimmed whitespace borders',
resized: 'Downscaled for size',
'no-alpha': 'No transparent background will show a white box on dark headers',
'jpeg-source': 'JPEG source prefer PNG with alpha for crisp rendering',
'no-alpha': 'No transparent background - will show a white box on dark headers',
'jpeg-source': 'JPEG source - prefer PNG with alpha for crisp rendering',
'svg-rasterized': 'SVG rasterized to PNG at 300 DPI',
'heic-converted': 'HEIC/HEIF converted to PNG',
'webp-converted': 'WebP converted to PNG',

View File

@@ -11,7 +11,7 @@
* dimensions / pontoon / pricing. "Apply to all" inputs at the top
* of each column copy a value down. Validation is inline.
*
* Per PRE-DEPLOY-PLAN § 1.4.13. Drag-fill is a stretch left as a
* Per PRE-DEPLOY-PLAN § 1.4.13. Drag-fill is a stretch - left as a
* follow-up; keyboard-friendly "Apply to all" covers most of the
* speed win without the complexity.
*/
@@ -39,7 +39,7 @@ import { useVocabulary } from '@/hooks/use-vocabulary';
import { CurrencySelect } from '@/components/shared/currency-select';
// Common dock-letter shorthand. Wizard accepts any uppercase letter
// sequence matching the canonical mooring regex (`^[A-Z]+$`) these
// sequence matching the canonical mooring regex (`^[A-Z]+$`) - these
// five are the most-frequently-used; reps add new ones via the
// "Custom" input below.
const COMMON_DOCK_LETTERS = ['A', 'B', 'C', 'D', 'E'] as const;
@@ -117,7 +117,7 @@ export function BulkAddBerthsWizard() {
const [checkingDups, setCheckingDups] = useState(false);
async function handleGenerate() {
// Validate the dock letter must be one or more uppercase letters per
// Validate the dock letter - must be one or more uppercase letters per
// the canonical mooring regex. Custom-input path normalises to upper
// already, but guard against an empty input.
if (!letter || !/^[A-Z]+$/.test(letter)) {
@@ -321,7 +321,7 @@ export function BulkAddBerthsWizard() {
{/* Dimension-unit toggle. The wizard stores values as-entered;
conversion to canonical feet (1 m = 3.28084 ft) happens once
at submit. Switching mid-edit leaves existing inputs as
numeric strings the rep is responsible for re-entering if
numeric strings - the rep is responsible for re-entering if
the unit interpretation just changed under them. */}
<Button
type="button"
@@ -329,7 +329,7 @@ export function BulkAddBerthsWizard() {
variant="outline"
onClick={() => setDimUnit(dimUnit === 'ft' ? 'm' : 'ft')}
aria-label={`Switch dimension entry to ${dimUnit === 'ft' ? 'metres' : 'feet'}`}
title={`Entering dimensions in ${dimUnit === 'ft' ? 'feet' : 'metres'} click to switch`}
title={`Entering dimensions in ${dimUnit === 'ft' ? 'feet' : 'metres'} - click to switch`}
className="font-mono text-xs"
>
{dimUnit}

View File

@@ -46,7 +46,7 @@ const EMBED_FIELDS: SettingFieldDef[] = [
* - A Test connection button that probes the host's `/` and
* `/sign/success` paths to verify the marketing-site cutover is
* ready BEFORE signers get sent there from outbound emails.
* - A Help button that opens a Sheet with the setup instructions
* - A Help button that opens a Sheet with the setup instructions -
* what routes the marketing site needs, what URL parameters to
* handle, and the Documenso webhook config that pairs with it.
*/

View File

@@ -61,7 +61,7 @@ function formatRelative(iso: string): string {
}
/**
* "Sync from Documenso" admin button calls GET /template/{id} on the
* "Sync from Documenso" admin button - calls GET /template/{id} on the
* configured Documenso instance (via the per-port creds in admin settings),
* pre-fills the recipient slot IDs into the matching documenso_*_recipient_id
* settings, and caches the template's field name→ID map at

View File

@@ -13,8 +13,8 @@ import { apiFetch } from '@/lib/api/client';
/**
* SMTP connectivity test card. Distinct from the branding-page "Send a
* test" affordance:
* - This one isolates SMTP plaintext + minimal HTML, no logo, no
* branded shell so the failure mode is pure transport.
* - This one isolates SMTP - plaintext + minimal HTML, no logo, no
* branded shell - so the failure mode is pure transport.
* - The branding-preview send exercises the full rendering pipeline.
*
* Surfaces the SMTP error inline (under the input) instead of toasting,

View File

@@ -0,0 +1,177 @@
'use client';
import { useState } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { Send, CheckCircle2, AlertCircle } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Skeleton } from '@/components/ui/skeleton';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
interface TemplateMeta {
id: string;
label: string;
description: string;
}
/**
* Per-template test-send card. Lists every transactional template the
* system can emit, lets the admin pick one + a recipient address, and
* fires a realistic preview (subject line tagged `[TEST · …]`) through
* the configured SMTP transport.
*
* Sits next to `<SmtpTestSendCard>` on the Email admin page - the
* SMTP-test verifies transport-layer auth + connectivity, this card
* verifies that each template renders + deliverability of the real
* branded payload.
*/
export function TestTemplateCard() {
const [templateId, setTemplateId] = useState<string>('');
const [recipient, setRecipient] = useState('');
const [lastResult, setLastResult] = useState<
| { kind: 'ok'; label: string; recipient: string; messageId: string | null }
| { kind: 'err'; message: string }
| null
>(null);
// Registry is fetched at runtime so adding a template on the backend
// surfaces in the dropdown without a client-side build.
const { data, isLoading } = useQuery<{ data: TemplateMeta[] }>({
queryKey: ['admin', 'email', 'test-template', 'list'],
queryFn: () => apiFetch('/api/v1/admin/email/test-template'),
staleTime: 5 * 60_000,
});
const templates = data?.data ?? [];
const selected = templates.find((t) => t.id === templateId);
const mutation = useMutation({
mutationFn: () =>
apiFetch<{
data: { templateId: string; recipient: string; subject: string; messageId: string | null };
}>('/api/v1/admin/email/test-template', {
method: 'POST',
body: { templateId, recipient },
}),
onSuccess: (res) => {
const label = selected?.label ?? res.data.templateId;
setLastResult({
kind: 'ok',
label,
recipient: res.data.recipient,
messageId: res.data.messageId,
});
toast.success(`${label} sent to ${res.data.recipient}`);
},
onError: (err) => {
const message = err instanceof Error ? err.message : 'Send failed';
setLastResult({ kind: 'err', message });
toastError(err);
},
});
const canSend = !!templateId && /.+@.+\..+/.test(recipient) && !mutation.isPending;
return (
<Card>
<CardHeader>
<CardTitle>Test each transactional email</CardTitle>
<CardDescription>
Pick a template and fire a realistic preview to a designated address. The subject is
prefixed <span className="font-mono">[TEST · &lt;template&gt;]</span> so it&apos;s
unambiguous in the recipient&apos;s inbox. Uses the port&apos;s real From / SMTP
configuration - the same path the live flow takes.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="test-template-select">Template</Label>
{isLoading ? (
<Skeleton className="h-9 w-full" />
) : (
<Select value={templateId} onValueChange={setTemplateId}>
<SelectTrigger id="test-template-select" className="w-full">
<SelectValue placeholder="Select a template…" />
</SelectTrigger>
<SelectContent>
{templates.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{selected ? (
<p className="text-xs text-muted-foreground">{selected.description}</p>
) : (
<p className="text-xs text-muted-foreground">
Tip: pick a template above to see what production flow fires it.
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="test-template-recipient">Recipient</Label>
<div className="flex flex-wrap items-center gap-2">
<Input
id="test-template-recipient"
type="email"
value={recipient}
onChange={(e) => setRecipient(e.target.value)}
placeholder="you@example.com"
className="flex-1 min-w-[240px]"
/>
<Button onClick={() => mutation.mutate()} disabled={!canSend}>
<Send className="mr-1.5 h-4 w-4" aria-hidden />
{mutation.isPending ? 'Sending…' : 'Send test'}
</Button>
</div>
</div>
{lastResult?.kind === 'ok' && (
<div
role="status"
className="flex items-start gap-2 rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-900"
>
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />
<div>
<div className="font-medium">
{lastResult.label} sent to {lastResult.recipient}.
</div>
{lastResult.messageId ? (
<div className="text-xs text-emerald-800/80">
Message-ID: <span className="font-mono">{lastResult.messageId}</span>
</div>
) : null}
<div className="text-xs text-emerald-800/80">
Check the recipient&apos;s mailbox (and spam folder) to confirm delivery.
</div>
</div>
</div>
)}
{lastResult?.kind === 'err' && (
<div
role="alert"
className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive"
>
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />
<div>
<div className="font-medium">Send failed</div>
<div className="font-mono text-xs break-all">{lastResult.message}</div>
</div>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -115,7 +115,7 @@ function FormTemplateFormBody({ open, onOpenChange, template, onSaved }: Props)
function changeBinding(idx: number, raw: string) {
if (raw === BIND_TO_NONE) {
// Clear the binding but leave the rest of the field untouched
// Clear the binding but leave the rest of the field untouched -
// admins may want to keep a custom field that no longer autofills.
setFields((prev) =>
prev.map((f, i) => {
@@ -324,7 +324,7 @@ function FormTemplateFormBody({ open, onOpenChange, template, onSaved }: Props)
* Map a bindable column's natural input type onto the form-field types we
* actually render. When binding to a `number` column we still let the
* admin keep `select` if they'd already chosen it (e.g. they want to
* constrain to specific values) same for `textarea`.
* constrain to specific values) - same for `textarea`.
*/
function coerceFieldType(
bindableType: BindableType,

View File

@@ -21,7 +21,7 @@ interface OnboardingStep {
autoCheckSettingKey?: string;
/** Multi-key gate: all listed setting keys must be present (non-empty)
* for the step to auto-complete. Useful for compound checks where a
* single key would falsely mark "done" e.g. Documenso needs a URL
* single key would falsely mark "done" - e.g. Documenso needs a URL
* plus signer identity plus a template id, not just the URL. */
autoCheckSettingKeysAll?: readonly string[];
/** Override: read this many users / tags / roles from a list endpoint
@@ -133,7 +133,7 @@ export function OnboardingChecklist() {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const [autoChecks, setAutoChecks] = useState<Record<string, boolean>>({});
// Per-step source flags populated for steps whose auto-check resolved
// Per-step source flags - populated for steps whose auto-check resolved
// via the env / default fallback rather than a port / global override.
// Surfaces "Resolving from env" copy so super admins see what's
// backing each green tick without digging into the settings page.
@@ -157,7 +157,7 @@ export function OnboardingChecklist() {
if (s.autoCheckSettingKeysAll) for (const k of s.autoCheckSettingKeysAll) keys.add(k);
}
// Manual-checkbox state still lives in the raw system_settings
// row (it's a JSON blob, not a per-key registry entry) keep
// row (it's a JSON blob, not a per-key registry entry) - keep
// fetching it the old way.
const [resolved, settings] = await Promise.all([
keys.size > 0

View File

@@ -21,17 +21,17 @@ import { apiFetch } from '@/lib/api/client';
// Marina deals price in a small set; an admin who needs an exotic
// currency can add it here without a schema change.
const CURRENCY_OPTIONS: Array<{ value: string; label: string }> = [
{ value: 'USD', label: 'USD US Dollar' },
{ value: 'EUR', label: 'EUR Euro' },
{ value: 'GBP', label: 'GBP British Pound' },
{ value: 'CHF', label: 'CHF Swiss Franc' },
{ value: 'AED', label: 'AED UAE Dirham' },
{ value: 'SAR', label: 'SAR Saudi Riyal' },
{ value: 'PLN', label: 'PLN Polish Złoty' },
{ value: 'AUD', label: 'AUD Australian Dollar' },
{ value: 'CAD', label: 'CAD Canadian Dollar' },
{ value: 'NZD', label: 'NZD New Zealand Dollar' },
{ value: 'JPY', label: 'JPY Japanese Yen' },
{ value: 'USD', label: 'USD - US Dollar' },
{ value: 'EUR', label: 'EUR - Euro' },
{ value: 'GBP', label: 'GBP - British Pound' },
{ value: 'CHF', label: 'CHF - Swiss Franc' },
{ value: 'AED', label: 'AED - UAE Dirham' },
{ value: 'SAR', label: 'SAR - Saudi Riyal' },
{ value: 'PLN', label: 'PLN - Polish Złoty' },
{ value: 'AUD', label: 'AUD - Australian Dollar' },
{ value: 'CAD', label: 'CAD - Canadian Dollar' },
{ value: 'NZD', label: 'NZD - New Zealand Dollar' },
{ value: 'JPY', label: 'JPY - Japanese Yen' },
];
interface PortFormProps {

View File

@@ -54,7 +54,7 @@ interface ListResponse {
* Per-port qualification-criteria admin. Lists current criteria, add via
* the dialog, toggle enabled inline, drag-to-reorder via dnd-kit (the
* whole list ships in one PATCH so partial failure can't scramble the
* order see qualification.service.reorderCriteria).
* order - see qualification.service.reorderCriteria).
*/
export function QualificationCriteriaAdmin() {
const queryClient = useQueryClient();

View File

@@ -34,7 +34,7 @@ const STATUS_PILL: Record<string, StatusPillStatus> = {
};
function relativeAge(iso: string | null): string {
if (!iso) return '';
if (!iso) return '-';
const days = Math.floor((Date.now() - new Date(iso).getTime()) / 86_400_000);
if (days <= 0) return 'today';
if (days === 1) return 'yesterday';

View File

@@ -8,7 +8,7 @@
* that the document-sends flow uses.
*
* §14.10 enforcement: passwords are write-only. The GET endpoint never
* returns the decrypted value only a `*PassIsSet` boolean. Empty
* returns the decrypted value - only a `*PassIsSet` boolean. Empty
* password input means "leave unchanged"; explicit `null` sent over the
* wire means "clear".
*/
@@ -129,7 +129,7 @@ export function SalesEmailConfigCard() {
}
useEffect(() => {
// Initial load on mount canonical fetch-once pattern.
// Initial load on mount - canonical fetch-once pattern.
// eslint-disable-next-line react-hooks/set-state-in-effect
void refresh();
}, []);

View File

@@ -26,7 +26,7 @@ interface SendRow {
brochureId: string | null;
clientId: string | null;
interestId: string | null;
/** Phase 6 populated by the IMAP bounce poller when a delivery
/** Phase 6 - populated by the IMAP bounce poller when a delivery
* failure for this send was matched in the configured mailbox. */
bounceStatus: 'hard' | 'soft' | 'ooo' | null;
bounceReason: string | null;

View File

@@ -92,7 +92,7 @@ export function RegistryDrivenForm({ sections, title, description, extra }: Prop
),
});
// Lifted draft state every field's current input value is held here so
// Lifted draft state - every field's current input value is held here so
// a card-level "Save N changes" button can write them all in one batch.
// Sensitive fields seed as empty (we never seed cleartext from the server);
// non-sensitive fields seed from the resolved value.
@@ -313,13 +313,13 @@ function SettingField({
},
onSuccess: (r) => {
if (r.revealed && r.value != null) {
// Server reveal populate draft but do NOT mark dirty (the value
// Server reveal - populate draft but do NOT mark dirty (the value
// already matches what's stored).
setDraft(r.value, { dirty: false });
setRevealedFromServer(true);
setShowSecret(true);
} else {
toast.info(`${entry.label} isn't set nothing to reveal.`);
toast.info(`${entry.label} isn't set - nothing to reveal.`);
}
},
onError: (err) => toastError(err, `Failed to reveal ${entry.label}`),
@@ -439,7 +439,7 @@ function SettingField({
disabled={
reveal.isPending ||
// Disable when the value is resolved from env/default and the
// rep hasn't typed anything yet there's no in-app cleartext
// rep hasn't typed anything yet - there's no in-app cleartext
// path for those, and silently no-op'ing was indistinguishable
// from a broken toggle.
(!showSecret &&

View File

@@ -44,14 +44,14 @@ export interface SettingFieldDef {
options?: Array<{ value: string; label: string }>;
/** For 'image-upload' fields: aspect ratio for the cropper (default 1).
* For 'html' fields: when set, renders an "Insert default" button that
* pastes this text into the textarea used for email-template defaults
* pastes this text into the textarea - used for email-template defaults
* so admins can see the baseline before editing. */
defaultTemplate?: string;
/** For 'image-upload' fields: cropper aspect ratio. */
imageAspect?: number;
/** For 'image-upload' fields: output format. Default 'jpeg' (smaller
* files, good for photos / backgrounds). Use 'png' for logos with
* transparency JPEG has no alpha channel, so transparent pixels
* transparency - JPEG has no alpha channel, so transparent pixels
* composite against black on export. */
imageFormat?: 'jpeg' | 'png';
}
@@ -87,7 +87,7 @@ export function SettingsFormCard({ title, description, fields, extra }: Settings
// Parent components often pass `FIELDS.slice(0, 5)` directly, so the prop
// reference changes on every render. Capture it in a ref so the fetch
// callback can read the current list without being re-created and looping
// through useEffect forever. Update inside an effect writing to ref
// through useEffect forever. Update inside an effect - writing to ref
// .current during render trips the React Compiler purity rules.
const fieldsRef = useRef(fields);
useEffect(() => {
@@ -112,7 +112,7 @@ export function SettingsFormCard({ title, description, fields, extra }: Settings
}, []);
useEffect(() => {
// Initial load fetchValues internally setStates loading + values.
// Initial load - fetchValues internally setStates loading + values.
// eslint-disable-next-line react-hooks/set-state-in-effect
void fetchValues();
}, [fetchValues]);
@@ -404,13 +404,13 @@ function ImageUploadField({
async function uploadCropped(blob: Blob) {
const fd = new FormData();
// Trust the blob's own MIME the cropper auto-picks PNG when the
// Trust the blob's own MIME - the cropper auto-picks PNG when the
// source had alpha, JPEG otherwise. Hardcoding to JPEG here threw
// away the alpha channel on transparent logos.
const mime = blob.type || 'image/jpeg';
const ext = mime === 'image/png' ? 'png' : 'jpg';
fd.append('file', new File([blob], `image.${ext}`, { type: mime }));
// Raw fetch (not apiFetch FormData body) → manually attach the
// Raw fetch (not apiFetch - FormData body) → manually attach the
// X-Port-Id header that the admin settings route requires.
const headers = new Headers();
if (typeof window !== 'undefined') {

View File

@@ -41,12 +41,12 @@ function useCustomFieldTokens() {
* Renders the canonical `MERGE_FIELDS` catalog grouped by entity scope.
* Below the static catalog, lazily fetches per-port custom field
* definitions and renders any whose entityType is resolvable at
* send-time (client / interest / berth see `mergeCustomFieldValues`
* send-time (client / interest / berth - see `mergeCustomFieldValues`
* in `document-sends.service.ts`) as a "Custom" group.
*
* The validator accepts any `{{custom.<fieldName>}}` shape, but only
* the three entity types above resolve to real values, so we only show
* those surfacing client-portal-only fields would mis-set expectations.
* those - surfacing client-portal-only fields would mis-set expectations.
*/
export function TemplateTokenPicker() {
const customQuery = useCustomFieldTokens();

View File

@@ -209,7 +209,7 @@ export function StorageAdminPanel() {
// Dry run first so the dialog shows the exact rows + bytes.
dryRunMutation.mutate({ from: s.backend, to: otherBackend });
} else {
// Switch-only no dry run, just show the warning.
// Switch-only - no dry run, just show the warning.
setDryRun(null);
setConfirmOpen(true);
}

View File

@@ -111,7 +111,7 @@ export function TagForm({ open, onOpenChange, tag, onSuccess }: TagFormProps) {
))}
</div>
<div className="flex items-center gap-2 mt-2">
{/* Native colour picker clicking the swatch opens the
{/* Native colour picker - clicking the swatch opens the
* OS picker, and the chosen colour writes back as a
* hex string. Keeps a manual hex input next to it for
* pasting brand colours from spec sheets. */}

View File

@@ -46,7 +46,7 @@ interface TemplateData {
templateFormat: string;
sourceFileId: string | null;
overlayPositions: FieldMap | null;
/** Tokens marked as required for the EOI flow see
/** Tokens marked as required for the EOI flow - see
* STANDARD_EOI_MERGE_FIELDS in lib/templates/merge-fields. The editor
* surfaces a checklist of which required tokens are still unplaced. */
mergeFields?: string[] | null;
@@ -79,7 +79,7 @@ const DEFAULT_MARKER_H = 0.04;
const MIN_MARKER_DIM = 0.02;
/**
* Phase 7.1 + 7.2 PDF marker editor.
* Phase 7.1 + 7.2 - PDF marker editor.
*
* - Click anywhere to drop a marker (page-aware).
* - Drag markers to move; corner handles to resize.
@@ -158,7 +158,7 @@ function TemplateEditorBody({
const handler = (e: BeforeUnloadEvent) => {
e.preventDefault();
// Modern browsers ignore the returnValue string and show their own
// generic "you have unsaved changes" prompt setting it still
// generic "you have unsaved changes" prompt - setting it still
// triggers the prompt, just without our wording.
e.returnValue = '';
};
@@ -385,7 +385,7 @@ function TemplateEditorBody({
// Map detected type → best-guess merge token. Falls back to first
// sorted token when the detector finds a Documenso-only field
// (SIGNATURE / INITIALS) that has no direct merge-token equivalent
// in the in-app fill pipeline the user retags from the dropdown.
// in the in-app fill pipeline - the user retags from the dropdown.
const fallbackToken = TOKEN_OPTIONS[0] ?? '';
const pick = (candidates: string[]): string => {
for (const c of candidates) {

View File

@@ -100,7 +100,7 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
// Admin email change for an existing user goes through a confirmation
// dialog because it locks the original sign-in identity out the
// dialog because it locks the original sign-in identity out - the
// submit path runs after the admin acknowledges. New-user creation
// and same-email saves go straight through.
if (isEdit && email.trim().toLowerCase() !== originalEmail.toLowerCase()) {

View File

@@ -52,7 +52,7 @@ const GROUP_LABELS: Record<string, string> = {
residential_interests: 'Residential Interests',
};
// Mirrors RolePermissions in src/lib/db/schema/users.ts used as the
// Mirrors RolePermissions in src/lib/db/schema/users.ts - used as the
// canonical leaf list so the matrix shows every action even when the
// baseline JSON omits a key (older roles, partial overrides).
const PERMISSION_LEAVES: Record<string, string[]> = {