Replaced 174 em-dashes (—) with " - " (space-hyphen-space) across 49 files in src/components + src/app. The em-dash reads as a tell-tale "AI-generated" marker per the user's design feedback; hyphens with spaces preserve the connector semantics without the AI tint. Touched only lines outside pure-comment context (// /* * */). Code comments, JSDoc, audit-log strings, structured logging strings, and templates outside the lint scope retain their em-dashes for now — they're not user-visible. Also captured two remaining cases that used the `—` HTML entity instead of the literal character (system-monitoring-dashboard, interest-stage-picker) — replaced with a plain hyphen. Bumped the existing `no-restricted-syntax` rule from `warn` → `error` in eslint.config.mjs scoped to src/components/**/*.tsx + src/app/**/*.tsx. New code reintroducing em-dashes in JSX text now fails the lint gate. Verified: tsc clean, vitest 1448/1448, eslint 0 em-dash warnings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
201 lines
6.7 KiB
TypeScript
201 lines
6.7 KiB
TypeScript
'use client';
|
|
|
|
/**
|
|
* Per-category send-from routing matrix.
|
|
*
|
|
* Renders one row per email category with a `noreply | sales` dropdown.
|
|
* When the port has no sales SMTP credentials configured, the `sales`
|
|
* option is disabled across the board and the card explains why.
|
|
*/
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { Loader2 } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import { WarningCallout } from '@/components/ui/warning-callout';
|
|
import { apiFetch } from '@/lib/api/client';
|
|
import { toastError } from '@/lib/api/toast-error';
|
|
|
|
type Sender = 'noreply' | 'sales';
|
|
|
|
interface RoutingResponse {
|
|
data: {
|
|
routing: Record<string, Sender>;
|
|
isSalesAvailable: boolean;
|
|
categories: readonly string[];
|
|
};
|
|
}
|
|
|
|
const LABELS: Record<string, string> = {
|
|
account_activation: 'Account activation',
|
|
password_reset: 'Password reset',
|
|
notification_digest: 'Notification digest',
|
|
eoi_signing_request: 'EOI signing request',
|
|
reservation_signing_request: 'Reservation signing request',
|
|
contract_signing_request: 'Contract signing request',
|
|
brochure_send: 'Brochure send',
|
|
berth_pdf_send: 'Berth PDF send',
|
|
signed_doc_completion: 'Signed-doc completion',
|
|
sales_sendout: 'Sales send-out',
|
|
manual_compose: 'Manual rep compose',
|
|
inquiry_confirmation: 'Inquiry confirmation (to client)',
|
|
inquiry_sales_notification: 'Inquiry notification (to sales)',
|
|
crm_invite: 'CRM user invite',
|
|
admin_email_change: 'Admin email-change notice',
|
|
gdpr_export: 'GDPR export delivery',
|
|
system_other: 'Other system-generated',
|
|
};
|
|
|
|
export function EmailRoutingCard() {
|
|
const qc = useQueryClient();
|
|
const { data, isLoading, isError } = useQuery<RoutingResponse>({
|
|
queryKey: ['admin', 'email', 'routing'],
|
|
queryFn: () => apiFetch<RoutingResponse>('/api/v1/admin/email/routing'),
|
|
staleTime: 30_000,
|
|
});
|
|
|
|
const update = useMutation({
|
|
mutationFn: (routing: Record<string, Sender>) =>
|
|
apiFetch<RoutingResponse>('/api/v1/admin/email/routing', {
|
|
method: 'PATCH',
|
|
body: { routing },
|
|
}),
|
|
onSuccess: (resp) => {
|
|
qc.setQueryData(['admin', 'email', 'routing'], resp);
|
|
toast.success('Send-from routing saved');
|
|
},
|
|
onError: (err) => toastError(err, 'Failed to save send-from routing'),
|
|
});
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Send-from routing</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Loader2 className="size-4 animate-spin" aria-hidden />
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
if (isError || !data) {
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Send-from routing</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<p className="text-sm text-muted-foreground">Failed to load routing matrix.</p>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
const { routing, isSalesAvailable, categories } = data.data;
|
|
|
|
const setCategory = (category: string, sender: Sender) => {
|
|
update.mutate({ ...routing, [category]: sender });
|
|
};
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Send-from routing</CardTitle>
|
|
<CardDescription>
|
|
Pick which account each email category sends from. Automated traffic typically goes
|
|
through <code className="rounded bg-muted px-1 text-xs">noreply</code>; human-touch
|
|
traffic (brochures, send-outs) goes through{' '}
|
|
<code className="rounded bg-muted px-1 text-xs">sales</code>.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{!isSalesAvailable ? (
|
|
<WarningCallout>
|
|
<p className="text-sm">
|
|
Sales sender is disabled - configure SMTP credentials in the "Sales send-from
|
|
account" card below to enable the <code>sales</code> option.
|
|
</p>
|
|
</WarningCallout>
|
|
) : null}
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[1fr_180px]">
|
|
{categories.map((cat) => (
|
|
<div
|
|
key={cat}
|
|
className="contents [&>*]:flex [&>*]:items-center [&>*]:border-b [&>*]:border-border [&>*]:py-2"
|
|
>
|
|
<div className="text-sm font-medium">{LABELS[cat] ?? cat}</div>
|
|
<div>
|
|
<Select
|
|
value={routing[cat] ?? 'noreply'}
|
|
onValueChange={(v) => setCategory(cat, v as Sender)}
|
|
disabled={update.isPending}
|
|
>
|
|
<SelectTrigger className="h-9">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="noreply">noreply</SelectItem>
|
|
<SelectItem value="sales" disabled={!isSalesAvailable}>
|
|
sales {!isSalesAvailable ? '(not configured)' : ''}
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
Changes save automatically when you pick a new sender. The <code>sales</code> option falls
|
|
back to <code>noreply</code> at send-time if creds are removed.
|
|
</p>
|
|
{update.isPending ? (
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<Loader2 className="size-3 animate-spin" aria-hidden /> Saving…
|
|
</div>
|
|
) : null}
|
|
</CardContent>
|
|
<CardContent className="pt-0">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() =>
|
|
update.mutate({
|
|
account_activation: 'noreply',
|
|
password_reset: 'noreply',
|
|
notification_digest: 'noreply',
|
|
eoi_signing_request: 'noreply',
|
|
reservation_signing_request: 'noreply',
|
|
contract_signing_request: 'noreply',
|
|
brochure_send: 'sales',
|
|
berth_pdf_send: 'sales',
|
|
signed_doc_completion: 'sales',
|
|
sales_sendout: 'sales',
|
|
manual_compose: 'sales',
|
|
inquiry_confirmation: 'noreply',
|
|
inquiry_sales_notification: 'noreply',
|
|
crm_invite: 'noreply',
|
|
admin_email_change: 'noreply',
|
|
gdpr_export: 'noreply',
|
|
system_other: 'noreply',
|
|
})
|
|
}
|
|
disabled={update.isPending}
|
|
>
|
|
Reset to defaults
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|