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