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:
@@ -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 you’ve configured and lets you mark manual steps complete.',
|
||||
'Step-by-step setup checklist for fresh ports - auto-detects what you’ve 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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
177
src/components/admin/email/test-template-card.tsx
Normal file
177
src/components/admin/email/test-template-card.tsx
Normal 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 · <template>]</span> so it's
|
||||
unambiguous in the recipient's inbox. Uses the port'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'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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
}, []);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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[]> = {
|
||||
|
||||
Reference in New Issue
Block a user