CodeIssuesPull RequestsActionsSecurityInsights
✨ AI
More
Settings

feat: UI batch 2 — Documents, A/B Testing, Auto-Responder/OOO #4050

MergedXSccantynz wants to mergefeat/missing-ui-pages-batch2mainopened Jun 16, 20260/4 tasks
2 changed files+1773−0
Addedapps/web/app/(dashboard)/ab-tests/page.tsx+961−0View fileUnifiedSplit
1"use client";
2
3import { useState, useEffect, useCallback } from "react";
4import {
5 Box,
6 Text,
7 Button,
8 Input,
9 Card,
10 CardContent,
11 CardHeader,
12 PageLayout,
13} from "@alecrae/ui";
14import {
15 abTestsApi,
16 type ABTest,
17 type ABTestVariant,
18} from "../../../lib/api";
19
20// ─── Helpers ──────────────────────────────────────────────────────────────────
21
22function formatDate(iso: string): string {
23 return new Date(iso).toLocaleDateString("en-US", {
24 month: "short",
25 day: "numeric",
26 year: "numeric",
27 });
28}
29
30function formatMetricLabel(metric: string): string {
31 switch (metric) {
32 case "open_rate":
33 return "Open Rate";
34 case "click_rate":
35 return "Click Rate";
36 case "reply_rate":
37 return "Reply Rate";
38 default:
39 return metric;
40 }
41}
42
43function pct(n: number): string {
44 return `${(n * 100).toFixed(1)}%`;
45}
46
47// ─── Status badge ─────────────────────────────────────────────────────────────
48
49type TestStatus = ABTest["status"];
50
51interface StatusBadgeColors {
52 bg: string;
53 text: string;
54}
55
56function statusColors(status: TestStatus): StatusBadgeColors {
57 switch (status) {
58 case "running":
59 return { bg: "bg-blue-100", text: "text-blue-700" };
60 case "completed":
61 return { bg: "bg-status-success/10", text: "text-status-success" };
62 case "cancelled":
63 return { bg: "bg-red-100", text: "text-red-700" };
64 default:
65 return { bg: "bg-surface-secondary", text: "text-content-tertiary" };
66 }
67}
68
69function StatusBadge({ status }: { status: TestStatus }): React.ReactNode {
70 const { bg, text } = statusColors(status);
71 const label =
72 status.charAt(0).toUpperCase() + status.slice(1);
73 return (
74 <Box className={`rounded-full px-2 py-0.5 ${bg}`}>
75 <Text variant="caption" className={`font-medium ${text}`}>
76 {label}
77 </Text>
78 </Box>
79 );
80}
81StatusBadge.displayName = "StatusBadge";
82
83// ─── Error / Loading UI ───────────────────────────────────────────────────────
84
85function ErrorBanner({ message }: { message: string }): React.ReactNode {
86 return (
87 <Box className="mb-4 rounded-md border border-red-200 bg-red-50 p-3" role="alert">
88 <Text variant="body-sm" className="text-red-800">
89 {message}
90 </Text>
91 </Box>
92 );
93}
94ErrorBanner.displayName = "ErrorBanner";
95
96function LoadingSkeleton(): React.ReactNode {
97 return (
98 <Box className="space-y-4" aria-busy="true" aria-label="Loading">
99 {[1, 2, 3].map((i) => (
100 <Box key={i} className="h-24 animate-pulse rounded-lg bg-surface-secondary" />
101 ))}
102 </Box>
103 );
104}
105LoadingSkeleton.displayName = "LoadingSkeleton";
106
107// ─── Percentage bar ───────────────────────────────────────────────────────────
108
109function PercentageBar({
110 value,
111 max = 100,
112 color = "bg-accent",
113}: {
114 value: number;
115 max?: number;
116 color?: string;
117}): React.ReactNode {
118 const widthPct = max > 0 ? Math.min(100, (value / max) * 100) : 0;
119 return (
120 <Box className="h-2 w-full rounded-full bg-surface-secondary overflow-hidden">
121 <Box
122 className={`h-full rounded-full ${color} transition-all duration-300`}
123 style={{ width: `${widthPct}%` }}
124 role="progressbar"
125 aria-valuenow={value}
126 aria-valuemin={0}
127 aria-valuemax={max}
128 />
129 </Box>
130 );
131}
132PercentageBar.displayName = "PercentageBar";
133
134// ─── Variant split visualization ──────────────────────────────────────────────
135
136function VariantSplitBar({
137 variants,
138}: {
139 variants: { id: string; percentage: number }[];
140}): React.ReactNode {
141 const COLORS = [
142 "bg-blue-400",
143 "bg-emerald-400",
144 "bg-amber-400",
145 "bg-rose-400",
146 "bg-violet-400",
147 ];
148
149 return (
150 <Box className="flex h-3 w-full overflow-hidden rounded-full" aria-label="Variant split">
151 {variants.map((v, i) => (
152 <Box
153 key={v.id}
154 className={`${COLORS[i % COLORS.length] ?? "bg-surface-secondary"} first:rounded-l-full last:rounded-r-full`}
155 style={{ width: `${v.percentage}%` }}
156 title={`Variant ${String.fromCharCode(65 + i)}: ${v.percentage}%`}
157 />
158 ))}
159 </Box>
160 );
161}
162VariantSplitBar.displayName = "VariantSplitBar";
163
164// ─── Variant builder ──────────────────────────────────────────────────────────
165
166interface DraftVariant {
167 subject: string;
168 percentage: number;
169}
170
171function VariantBuilder({
172 variants,
173 onChange,
174}: {
175 variants: DraftVariant[];
176 onChange: (variants: DraftVariant[]) => void;
177}): React.ReactNode {
178 const total = variants.reduce((s, v) => s + v.percentage, 0);
179 const isBalanced = Math.abs(total - 100) <= 0.01;
180
181 const updateVariant = (index: number, patch: Partial<DraftVariant>): void => {
182 onChange(variants.map((v, i) => (i === index ? { ...v, ...patch } : v)));
183 };
184
185 const addVariant = (): void => {
186 if (variants.length >= 4) return;
187 const newPct = Math.floor(100 / (variants.length + 1));
188 const updated: DraftVariant[] = [
189 ...variants.map((v) => ({ ...v, percentage: newPct })),
190 { subject: "", percentage: 100 - newPct * variants.length },
191 ];
192 onChange(updated);
193 };
194
195 const removeVariant = (index: number): void => {
196 if (variants.length <= 2) return;
197 const removed = variants.filter((_, i) => i !== index);
198 // Redistribute removed variant's percentage to the first variant
199 const removedPct = variants[index]?.percentage ?? 0;
200 onChange(
201 removed.map((v, i) =>
202 i === 0 ? { ...v, percentage: v.percentage + removedPct } : v,
203 ),
204 );
205 };
206
207 const LABELS = ["A", "B", "C", "D"];
208
209 return (
210 <Box className="space-y-3">
211 {variants.map((v, i) => (
212 <Box
213 key={i}
214 className="rounded-lg border border-border bg-surface-secondary p-3 space-y-2"
215 >
216 <Box className="flex items-center justify-between gap-2">
217 <Box className="flex items-center gap-2 min-w-0 flex-1">
218 <Box className="flex-shrink-0 w-6 h-6 rounded-full bg-accent/20 flex items-center justify-center">
219 <Text variant="caption" className="font-bold text-accent">
220 {LABELS[i] ?? String(i + 1)}
221 </Text>
222 </Box>
223 <Text variant="body-sm" className="font-medium text-content">
224 Variant {LABELS[i] ?? String(i + 1)}
225 </Text>
226 </Box>
227 {variants.length > 2 && (
228 <Button
229 variant="ghost"
230 size="sm"
231 onClick={() => removeVariant(i)}
232 className="text-red-500 hover:bg-red-50 flex-shrink-0"
233 aria-label={`Remove variant ${LABELS[i] ?? String(i + 1)}`}
234 >
235 Remove
236 </Button>
237 )}
238 </Box>
239 <Input
240 label={`Subject line (Variant ${LABELS[i] ?? String(i + 1)})`}
241 variant="text"
242 placeholder={
243 i === 0
244 ? "e.g. You won't believe this deal"
245 : "e.g. Limited time offer inside"
246 }
247 value={v.subject}
248 onChange={(e) => updateVariant(i, { subject: e.target.value })}
249 />
250 <Box>
251 <Box className="flex items-center justify-between mb-1">
252 <Text variant="caption" className="text-content-secondary font-medium">
253 Split percentage
254 </Text>
255 <Text variant="caption" className="font-mono text-content">
256 {v.percentage}%
257 </Text>
258 </Box>
259 <input
260 type="range"
261 min={5}
262 max={95}
263 step={5}
264 value={v.percentage}
265 onChange={(e) => updateVariant(i, { percentage: Number(e.target.value) })}
266 className="w-full accent-accent"
267 aria-label={`Variant ${LABELS[i] ?? String(i + 1)} percentage`}
268 />
269 </Box>
270 </Box>
271 ))}
272
273 <Box className="space-y-2">
274 <VariantSplitBar variants={variants.map((v, i) => ({ id: String(i), percentage: v.percentage }))} />
275 <Box className="flex items-center justify-between">
276 <Text
277 variant="caption"
278 className={isBalanced ? "text-status-success" : "text-red-600"}
279 >
280 {isBalanced ? "Percentages sum to 100%" : `Total: ${total}% (must equal 100%)`}
281 </Text>
282 {variants.length < 4 && (
283 <Button variant="ghost" size="sm" onClick={addVariant}>
284 + Add variant
285 </Button>
286 )}
287 </Box>
288 </Box>
289 </Box>
290 );
291}
292VariantBuilder.displayName = "VariantBuilder";
293
294// ─── Create form ──────────────────────────────────────────────────────────────
295
296const WINNER_METRICS: {
297 value: "open_rate" | "click_rate" | "reply_rate";
298 label: string;
299 description: string;
300}[] = [
301 { value: "open_rate", label: "Open Rate", description: "Best subject line" },
302 { value: "click_rate", label: "Click Rate", description: "Best engagement" },
303 { value: "reply_rate", label: "Reply Rate", description: "Best response" },
304];
305
306function CreateTestForm({
307 onCreated,
308}: {
309 onCreated: (test: ABTest) => void;
310}): React.ReactNode {
311 const [expanded, setExpanded] = useState(false);
312 const [name, setName] = useState("");
313 const [winnerMetric, setWinnerMetric] = useState<
314 "open_rate" | "click_rate" | "reply_rate"
315 >("open_rate");
316 const [variants, setVariants] = useState<DraftVariant[]>([
317 { subject: "", percentage: 50 },
318 { subject: "", percentage: 50 },
319 ]);
320 const [saving, setSaving] = useState(false);
321 const [startAfter, setStartAfter] = useState(false);
322 const [formError, setFormError] = useState<string | null>(null);
323
324 const total = variants.reduce((s, v) => s + v.percentage, 0);
325 const isBalanced = Math.abs(total - 100) <= 0.01;
326
327 const handleSubmit = async (): Promise<void> => {
328 if (!name.trim()) {
329 setFormError("Test name is required.");
330 return;
331 }
332 if (!isBalanced) {
333 setFormError("Variant percentages must sum to 100%.");
334 return;
335 }
336 if (variants.some((v) => !v.subject.trim())) {
337 setFormError("All variants require a subject line.");
338 return;
339 }
340
341 setSaving(true);
342 setFormError(null);
343
344 try {
345 const res = await abTestsApi.create({
346 name: name.trim(),
347 winnerMetric,
348 variants: variants.map((v) => ({
349 subject: v.subject.trim(),
350 percentage: v.percentage,
351 })),
352 });
353
354 let test = res.data;
355
356 if (startAfter) {
357 await abTestsApi.start(test.id);
358 test = { ...test, status: "running" };
359 }
360
361 onCreated(test);
362 setName("");
363 setVariants([
364 { subject: "", percentage: 50 },
365 { subject: "", percentage: 50 },
366 ]);
367 setExpanded(false);
368 } catch (err) {
369 setFormError(
370 err instanceof Error ? err.message : "Failed to create test",
371 );
372 } finally {
373 setSaving(false);
374 }
375 };
376
377 if (!expanded) {
378 return (
379 <Box>
380 <Button variant="primary" size="sm" onClick={() => setExpanded(true)}>
381 New A/B Test
382 </Button>
383 </Box>
384 );
385 }
386
387 return (
388 <Card className="border-accent/30">
389 <CardHeader>
390 <Text variant="heading-sm">Create A/B Test</Text>
391 <Text variant="body-sm" muted>
392 Send subject line variants to recipient segments, then declare a
393 winner based on performance.
394 </Text>
395 </CardHeader>
396 <CardContent>
397 {formError && <ErrorBanner message={formError} />}
398 <Box className="space-y-5">
399 <Input
400 label="Test name"
401 variant="text"
402 placeholder="e.g. Summer Sale Subject Lines"
403 value={name}
404 onChange={(e) => setName(e.target.value)}
405 />
406
407 <Box>
408 <Text variant="body-sm" className="mb-2 font-medium text-content">
409 Winner metric
410 </Text>
411 <Box className="flex flex-wrap gap-2" role="radiogroup" aria-label="Winner metric">
412 {WINNER_METRICS.map((m) => (
413 <Button
414 key={m.value}
415 variant={winnerMetric === m.value ? "secondary" : "ghost"}
416 size="sm"
417 role="radio"
418 aria-checked={winnerMetric === m.value}
419 onClick={() => setWinnerMetric(m.value)}
420 title={m.description}
421 >
422 {m.label}
423 </Button>
424 ))}
425 </Box>
426 </Box>
427
428 <Box>
429 <Text variant="body-sm" className="mb-2 font-medium text-content">
430 Variants (2–4)
431 </Text>
432 <VariantBuilder variants={variants} onChange={setVariants} />
433 </Box>
434
435 <Box className="flex items-center gap-3 flex-wrap">
436 <Button
437 variant="primary"
438 size="sm"
439 onClick={() => {
440 setStartAfter(false);
441 void handleSubmit();
442 }}
443 disabled={saving || !isBalanced || !name.trim()}
444 >
445 {saving && !startAfter ? "Creating..." : "Save as Draft"}
446 </Button>
447 <Button
448 variant="secondary"
449 size="sm"
450 onClick={() => {
451 setStartAfter(true);
452 void handleSubmit();
453 }}
454 disabled={saving || !isBalanced || !name.trim()}
455 >
456 {saving && startAfter ? "Starting..." : "Create & Start"}
457 </Button>
458 <Button
459 variant="ghost"
460 size="sm"
461 onClick={() => setExpanded(false)}
462 disabled={saving}
463 >
464 Cancel
465 </Button>
466 </Box>
467 </Box>
468 </CardContent>
469 </Card>
470 );
471}
472CreateTestForm.displayName = "CreateTestForm";
473
474// ─── Test detail panel ────────────────────────────────────────────────────────
475
476function TestDetail({
477 test,
478 onBack,
479 onUpdated,
480 onDeleted,
481}: {
482 test: ABTest;
483 onBack: () => void;
484 onUpdated: (updated: ABTest) => void;
485 onDeleted: (id: string) => void;
486}): React.ReactNode {
487 const [detail, setDetail] = useState<ABTest>(test);
488 const [loading, setLoading] = useState(false);
489 const [error, setError] = useState<string | null>(null);
490 const [selectedWinner, setSelectedWinner] = useState<string>("");
491 const [completing, setCompleting] = useState(false);
492 const [deleteConfirm, setDeleteConfirm] = useState(false);
493 const [deleting, setDeleting] = useState(false);
494
495 const LABELS = ["A", "B", "C", "D"];
496 const COLORS = ["bg-blue-400", "bg-emerald-400", "bg-amber-400", "bg-rose-400"];
497 const TEXT_COLORS = ["text-blue-700", "text-emerald-700", "text-amber-700", "text-rose-700"];
498 const BG_COLORS = ["bg-blue-50", "bg-emerald-50", "bg-amber-50", "bg-rose-50"];
499
500 // Refresh full detail from API
501 const refresh = useCallback(async (): Promise<void> => {
502 setLoading(true);
503 try {
504 const res = await abTestsApi.get(detail.id);
505 setDetail(res.data);
506 onUpdated(res.data);
507 setError(null);
508 } catch (err) {
509 setError(err instanceof Error ? err.message : "Failed to refresh test");
510 } finally {
511 setLoading(false);
512 }
513 }, [detail.id, onUpdated]);
514
515 const handleStart = async (): Promise<void> => {
516 setError(null);
517 try {
518 await abTestsApi.start(detail.id);
519 await refresh();
520 } catch (err) {
521 setError(err instanceof Error ? err.message : "Failed to start test");
522 }
523 };
524
525 const handleComplete = async (): Promise<void> => {
526 setCompleting(true);
527 setError(null);
528 try {
529 await abTestsApi.complete(
530 detail.id,
531 selectedWinner || undefined,
532 );
533 await refresh();
534 } catch (err) {
535 setError(err instanceof Error ? err.message : "Failed to complete test");
536 } finally {
537 setCompleting(false);
538 }
539 };
540
541 const handleDelete = async (): Promise<void> => {
542 setDeleting(true);
543 setError(null);
544 try {
545 await abTestsApi.remove(detail.id);
546 onDeleted(detail.id);
547 } catch (err) {
548 setError(err instanceof Error ? err.message : "Failed to delete test");
549 setDeleting(false);
550 }
551 };
552
553 const winnerVariantId = detail.results?.winner;
554
555 return (
556 <Box className="space-y-5">
557 {/* Back button + title */}
558 <Box className="flex items-center gap-3">
559 <Button variant="ghost" size="sm" onClick={onBack} aria-label="Back to list">
560 ← Back
561 </Button>
562 <Box className="min-w-0 flex-1">
563 <Box className="flex items-center gap-2 flex-wrap">
564 <Text variant="heading-sm" className="font-semibold truncate">
565 {detail.name}
566 </Text>
567 <StatusBadge status={detail.status} />
568 </Box>
569 <Text variant="caption" muted className="mt-0.5">
570 Winner metric: {formatMetricLabel(detail.winnerMetric)} · Created{" "}
571 {formatDate(detail.createdAt)}
572 </Text>
573 </Box>
574 </Box>
575
576 {error && <ErrorBanner message={error} />}
577 {loading && (
578 <Box className="text-center py-4">
579 <Text variant="body-sm" muted>
580 Loading…
581 </Text>
582 </Box>
583 )}
584
585 {/* Variants */}
586 <Box className="space-y-3">
587 {detail.variants.map((variant: ABTestVariant, i: number) => {
588 const metrics = detail.results?.variants[variant.id];
589 const isWinner = winnerVariantId === variant.id;
590 const label = LABELS[i] ?? String(i + 1);
591 const color = COLORS[i % COLORS.length] ?? "bg-surface-secondary";
592 const textColor = TEXT_COLORS[i % TEXT_COLORS.length] ?? "text-content";
593 const bgColor = BG_COLORS[i % BG_COLORS.length] ?? "bg-surface-secondary";
594
595 return (
596 <Card
597 key={variant.id}
598 className={`border-border ${isWinner ? "ring-2 ring-status-success ring-offset-2" : ""}`}
599 >
600 <CardContent>
601 <Box className="space-y-3">
602 {/* Header row */}
603 <Box className="flex items-start justify-between gap-2">
604 <Box className="flex items-center gap-2 min-w-0 flex-1">
605 <Box
606 className={`flex-shrink-0 w-7 h-7 rounded-full ${bgColor} flex items-center justify-center`}
607 >
608 <Text
609 variant="caption"
610 className={`font-bold ${textColor}`}
611 >
612 {label}
613 </Text>
614 </Box>
615 <Box className="min-w-0">
616 <Box className="flex items-center gap-2 flex-wrap">
617 <Text variant="body-sm" className="font-semibold truncate">
618 {variant.subject ?? "(no subject variant)"}
619 </Text>
620 {isWinner && (
621 <Box className="rounded-full bg-status-success/10 px-2 py-0.5">
622 <Text
623 variant="caption"
624 className="text-status-success font-medium"
625 >
626 Winner
627 </Text>
628 </Box>
629 )}
630 </Box>
631 <Text variant="caption" muted>
632 {variant.percentage}% of recipients
633 </Text>
634 </Box>
635 </Box>
636 </Box>
637
638 {/* Metrics */}
639 {metrics ? (
640 <Box className="grid grid-cols-3 gap-3">
641 {[
642 {
643 label: "Open Rate",
644 value: metrics.openRate,
645 count: metrics.opened,
646 },
647 {
648 label: "Click Rate",
649 value: metrics.clickRate,
650 count: metrics.clicked,
651 },
652 {
653 label: "Sent",
654 value: null,
655 count: metrics.sent,
656 },
657 ].map((stat) => (
658 <Box key={stat.label} className="space-y-1">
659 <Text variant="caption" muted>
660 {stat.label}
661 </Text>
662 <Text variant="body-sm" className="font-semibold">
663 {stat.value !== null
664 ? pct(stat.value)
665 : stat.count.toString()}
666 </Text>
667 {stat.value !== null && (
668 <PercentageBar
669 value={stat.value * 100}
670 color={color}
671 />
672 )}
673 </Box>
674 ))}
675 </Box>
676 ) : (
677 <Box className="rounded-md bg-surface-secondary p-3">
678 <Text variant="caption" muted>
679 {detail.status === "draft"
680 ? "Start the test to begin collecting data."
681 : "No data yet — check back after emails are sent."}
682 </Text>
683 </Box>
684 )}
685 </Box>
686 </CardContent>
687 </Card>
688 );
689 })}
690 </Box>
691
692 {/* Split visualization */}
693 <Box className="space-y-1.5">
694 <Text variant="caption" muted>
695 Recipient split
696 </Text>
697 <VariantSplitBar
698 variants={detail.variants.map((v) => ({
699 id: v.id,
700 percentage: v.percentage,
701 }))}
702 />
703 <Box className="flex flex-wrap gap-3">
704 {detail.variants.map((v, i) => (
705 <Box key={v.id} className="flex items-center gap-1.5">
706 <Box
707 className={`w-2.5 h-2.5 rounded-full ${COLORS[i % COLORS.length] ?? "bg-surface-secondary"}`}
708 />
709 <Text variant="caption" muted>
710 {LABELS[i] ?? String(i + 1)}: {v.percentage}%
711 </Text>
712 </Box>
713 ))}
714 </Box>
715 </Box>
716
717 {/* Actions */}
718 <Box className="flex flex-wrap items-center gap-3 pt-1">
719 {detail.status === "draft" && (
720 <Button variant="primary" size="sm" onClick={handleStart}>
721 Start Test
722 </Button>
723 )}
724
725 {detail.status === "running" && (
726 <Box className="flex flex-wrap items-center gap-3">
727 <Box>
728 <Text variant="caption" muted className="mb-1 block">
729 Pick winner (optional — auto-selected by metric if blank)
730 </Text>
731 <select
732 className="rounded-md border border-border bg-surface p-2 text-sm text-content focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
733 value={selectedWinner}
734 onChange={(e) => setSelectedWinner(e.target.value)}
735 aria-label="Select winning variant"
736 >
737 <option value="">Auto (by {formatMetricLabel(detail.winnerMetric)})</option>
738 {detail.variants.map((v, i) => (
739 <option key={v.id} value={v.id}>
740 Variant {LABELS[i] ?? String(i + 1)}{" "}
741 {v.subject ? `— "${v.subject}"` : ""}
742 </option>
743 ))}
744 </select>
745 </Box>
746 <Button
747 variant="primary"
748 size="sm"
749 onClick={handleComplete}
750 disabled={completing}
751 >
752 {completing ? "Completing..." : "Complete & Pick Winner"}
753 </Button>
754 </Box>
755 )}
756
757 {detail.status === "draft" && (
758 <Box className="flex items-center gap-2 ml-auto">
759 {deleteConfirm ? (
760 <>
761 <Text variant="caption" className="text-red-600">
762 Delete this draft?
763 </Text>
764 <Button
765 variant="ghost"
766 size="sm"
767 onClick={handleDelete}
768 disabled={deleting}
769 className="text-red-600 hover:bg-red-50"
770 >
771 {deleting ? "Deleting..." : "Confirm"}
772 </Button>
773 <Button
774 variant="ghost"
775 size="sm"
776 onClick={() => setDeleteConfirm(false)}
777 disabled={deleting}
778 >
779 Cancel
780 </Button>
781 </>
782 ) : (
783 <Button
784 variant="ghost"
785 size="sm"
786 onClick={() => setDeleteConfirm(true)}
787 className="text-red-500 hover:bg-red-50"
788 >
789 Delete
790 </Button>
791 )}
792 </Box>
793 )}
794 </Box>
795 </Box>
796 );
797}
798TestDetail.displayName = "TestDetail";
799
800// ─── Test row in list ─────────────────────────────────────────────────────────
801
802function TestRow({
803 test,
804 onClick,
805}: {
806 test: ABTest;
807 onClick: () => void;
808}): React.ReactNode {
809 return (
810 <Card className="border-border cursor-pointer hover:border-accent/50 transition-colors" onClick={onClick}>
811 <CardContent>
812 <Box className="flex items-start justify-between gap-4">
813 <Box className="min-w-0 flex-1 space-y-1.5">
814 <Box className="flex items-center gap-2 flex-wrap">
815 <Text variant="body-md" className="font-semibold truncate">
816 {test.name}
817 </Text>
818 <StatusBadge status={test.status} />
819 {test.results?.winner && (
820 <Box className="rounded-full bg-status-success/10 px-2 py-0.5">
821 <Text variant="caption" className="text-status-success font-medium">
822 Winner declared
823 </Text>
824 </Box>
825 )}
826 </Box>
827 <Box className="flex items-center gap-4 flex-wrap">
828 <Text variant="caption" muted>
829 {test.variants.length} variants · {formatMetricLabel(test.winnerMetric)}
830 </Text>
831 {test.recipientCount !== undefined && test.recipientCount > 0 && (
832 <Text variant="caption" muted>
833 {test.recipientCount} recipients
834 </Text>
835 )}
836 <Text variant="caption" muted>
837 Created {formatDate(test.createdAt)}
838 </Text>
839 </Box>
840 <VariantSplitBar
841 variants={test.variants.map((v) => ({ id: v.id, percentage: v.percentage }))}
842 />
843 </Box>
844 <Text variant="caption" className="text-content-tertiary flex-shrink-0 pt-1">
845
846 </Text>
847 </Box>
848 </CardContent>
849 </Card>
850 );
851}
852TestRow.displayName = "TestRow";
853
854// ─── Empty state ──────────────────────────────────────────────────────────────
855
856function EmptyState(): React.ReactNode {
857 return (
858 <Card>
859 <CardContent>
860 <Box className="py-12 text-center space-y-3">
861 <Box className="text-4xl" aria-hidden="true" role="img">
862 🧪
863 </Box>
864 <Text variant="heading-sm" className="font-semibold">
865 No A/B tests yet
866 </Text>
867 <Text variant="body-sm" muted className="max-w-sm mx-auto">
868 A/B testing lets you send two or more subject line variants to
869 segments of recipients, then automatically picks the winner by open
870 rate, click rate, or reply rate.
871 </Text>
872 <Text variant="body-sm" muted>
873 Click <Text as="span" className="font-medium text-content">New A/B Test</Text> above to get started.
874 </Text>
875 </Box>
876 </CardContent>
877 </Card>
878 );
879}
880EmptyState.displayName = "EmptyState";
881
882// ─── Main page ────────────────────────────────────────────────────────────────
883
884export default function ABTestsPage(): React.ReactNode {
885 const [tests, setTests] = useState<ABTest[]>([]);
886 const [loading, setLoading] = useState(true);
887 const [error, setError] = useState<string | null>(null);
888 const [selectedTestId, setSelectedTestId] = useState<string | null>(null);
889
890 const load = useCallback(async (): Promise<void> => {
891 try {
892 setLoading(true);
893 const res = await abTestsApi.list({ limit: 50 });
894 setTests(res.data);
895 setError(null);
896 } catch (err) {
897 setError(err instanceof Error ? err.message : "Failed to load A/B tests");
898 } finally {
899 setLoading(false);
900 }
901 }, []);
902
903 useEffect(() => {
904 void load();
905 }, [load]);
906
907 const selectedTest = tests.find((t) => t.id === selectedTestId) ?? null;
908
909 const handleCreated = (test: ABTest): void => {
910 setTests((prev) => [test, ...prev]);
911 };
912
913 const handleUpdated = (updated: ABTest): void => {
914 setTests((prev) =>
915 prev.map((t) => (t.id === updated.id ? updated : t)),
916 );
917 };
918
919 const handleDeleted = (id: string): void => {
920 setTests((prev) => prev.filter((t) => t.id !== id));
921 setSelectedTestId(null);
922 };
923
924 return (
925 <PageLayout
926 title="A/B Testing"
927 description="Send subject line variants, track performance, and automatically declare a winner."
928 >
929 {selectedTest ? (
930 <TestDetail
931 test={selectedTest}
932 onBack={() => setSelectedTestId(null)}
933 onUpdated={handleUpdated}
934 onDeleted={handleDeleted}
935 />
936 ) : (
937 <Box className="space-y-5">
938 {error && <ErrorBanner message={error} />}
939
940 <CreateTestForm onCreated={handleCreated} />
941
942 {loading ? (
943 <LoadingSkeleton />
944 ) : tests.length === 0 ? (
945 <EmptyState />
946 ) : (
947 <Box className="space-y-3">
948 {tests.map((test) => (
949 <TestRow
950 key={test.id}
951 test={test}
952 onClick={() => setSelectedTestId(test.id)}
953 />
954 ))}
955 </Box>
956 )}
957 </Box>
958 )}
959 </PageLayout>
960 );
961}
Addedapps/web/app/(dashboard)/auto-responder/page.tsx+812−0View fileUnifiedSplit
1"use client";
2
3import { useState, useEffect, useCallback } from "react";
4import {
5 Box,
6 Text,
7 Button,
8 Card,
9 CardContent,
10 Input,
11 PageLayout,
12} from "@alecrae/ui";
13import {
14 autoResponderApi,
15 type AutoResponder,
16 type AutoResponderLogEntry,
17 type AutoResponderMode,
18} from "../../../lib/api";
19
20// ─── Constants ───────────────────────────────────────────────────────────────
21
22const TIMEZONES = [
23 { value: "UTC", label: "UTC" },
24 { value: "America/Los_Angeles", label: "Pacific (US)" },
25 { value: "America/Denver", label: "Mountain (US)" },
26 { value: "America/Chicago", label: "Central (US)" },
27 { value: "America/New_York", label: "Eastern (US)" },
28 { value: "Europe/London", label: "London" },
29 { value: "Europe/Paris", label: "Paris" },
30 { value: "Australia/Sydney", label: "Sydney" },
31 { value: "Pacific/Auckland", label: "Auckland" },
32] as const;
33
34const MODE_OPTIONS: { value: AutoResponderMode; label: string; description: string }[] = [
35 {
36 value: "off",
37 label: "Off",
38 description: "Auto-responder is disabled.",
39 },
40 {
41 value: "vacation",
42 label: "Vacation",
43 description: "You are on vacation and will reply when you return.",
44 },
45 {
46 value: "busy",
47 label: "Busy",
48 description: "You are available but response may be delayed.",
49 },
50 {
51 value: "custom",
52 label: "Custom",
53 description: "Send a fully custom reply message.",
54 },
55];
56
57// ─── Shared helpers ──────────────────────────────────────────────────────────
58
59function ErrorBanner({ message }: { message: string }): React.ReactNode {
60 return (
61 <Box className="mb-4 rounded-md border border-red-200 bg-red-50 p-3" role="alert">
62 <Text variant="body-sm" className="text-red-800">
63 {message}
64 </Text>
65 </Box>
66 );
67}
68
69ErrorBanner.displayName = "ErrorBanner";
70
71function SuccessBanner({ message }: { message: string }): React.ReactNode {
72 return (
73 <Box className="mb-4 rounded-md border border-green-200 bg-green-50 p-3" role="status">
74 <Text variant="body-sm" className="text-green-800">
75 {message}
76 </Text>
77 </Box>
78 );
79}
80
81SuccessBanner.displayName = "SuccessBanner";
82
83function LoadingSkeleton(): React.ReactNode {
84 return (
85 <Box className="space-y-4" aria-busy="true" aria-label="Loading">
86 {[1, 2, 3].map((i) => (
87 <Box key={i} className="h-20 animate-pulse rounded-lg bg-surface-secondary" />
88 ))}
89 </Box>
90 );
91}
92
93LoadingSkeleton.displayName = "LoadingSkeleton";
94
95function SelectField({
96 label,
97 value,
98 onChange,
99 options,
100 id,
101}: {
102 label: string;
103 value: string;
104 onChange: (v: string) => void;
105 options: readonly { value: string; label: string }[];
106 id: string;
107}): React.ReactNode {
108 return (
109 <Box>
110 <Text
111 as="label"
112 variant="body-sm"
113 className="mb-1 block font-medium text-content"
114 // biome-ignore lint/a11y/noLabelWithoutControl: label targets via htmlFor
115 htmlFor={id}
116 >
117 {label}
118 </Text>
119 <select
120 id={id}
121 className="w-full rounded-md border border-border bg-surface p-2.5 text-sm text-content focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
122 value={value}
123 onChange={(e) => onChange(e.target.value)}
124 >
125 {options.map((opt) => (
126 <option key={opt.value} value={opt.value}>
127 {opt.label}
128 </option>
129 ))}
130 </select>
131 </Box>
132 );
133}
134
135SelectField.displayName = "SelectField";
136
137function ToggleRow({
138 label,
139 description,
140 checked,
141 onChange,
142 id,
143}: {
144 label: string;
145 description?: string;
146 checked: boolean;
147 onChange: (v: boolean) => void;
148 id: string;
149}): React.ReactNode {
150 return (
151 <Box className="flex items-start justify-between gap-4 py-2">
152 <Box className="min-w-0 flex-1">
153 <Text as="label" variant="body-sm" className="font-medium text-content cursor-pointer" htmlFor={id}>
154 {label}
155 </Text>
156 {description && (
157 <Text variant="caption" muted className="mt-0.5 block">
158 {description}
159 </Text>
160 )}
161 </Box>
162 <Box
163 as="button"
164 role="switch"
165 id={id}
166 aria-checked={checked}
167 onClick={() => onChange(!checked)}
168 className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 ${
169 checked ? "bg-accent" : "bg-surface-tertiary"
170 }`}
171 >
172 <Box
173 as="span"
174 aria-hidden="true"
175 className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow ring-0 transition-transform ${
176 checked ? "translate-x-5" : "translate-x-0"
177 }`}
178 />
179 </Box>
180 </Box>
181 );
182}
183
184ToggleRow.displayName = "ToggleRow";
185
186function CollapsibleSection({
187 title,
188 children,
189 defaultOpen = false,
190}: {
191 title: string;
192 children: React.ReactNode;
193 defaultOpen?: boolean;
194}): React.ReactNode {
195 const [open, setOpen] = useState(defaultOpen);
196
197 return (
198 <Box className="rounded-md border border-border overflow-hidden">
199 <Box
200 as="button"
201 className="flex w-full items-center justify-between p-4 text-left hover:bg-surface-secondary transition-colors"
202 onClick={() => setOpen((prev) => !prev)}
203 aria-expanded={open}
204 >
205 <Text variant="body-sm" className="font-semibold text-content">
206 {title}
207 </Text>
208 <Text variant="caption" className="text-content-tertiary">
209 {open ? "▲" : "▼"}
210 </Text>
211 </Box>
212 {open && (
213 <Box className="border-t border-border p-4">
214 {children}
215 </Box>
216 )}
217 </Box>
218 );
219}
220
221CollapsibleSection.displayName = "CollapsibleSection";
222
223// ─── Status Banner ───────────────────────────────────────────────────────────
224
225function StatusBanner({
226 config,
227 onActivate,
228 onDeactivate,
229 toggling,
230}: {
231 config: AutoResponder | null;
232 onActivate: () => Promise<void>;
233 onDeactivate: () => Promise<void>;
234 toggling: boolean;
235}): React.ReactNode {
236 const isActive = config?.isActive ?? false;
237 const mode = config?.mode ?? "off";
238
239 const modeLabel = MODE_OPTIONS.find((m) => m.value === mode)?.label ?? mode;
240
241 return (
242 <Card className={isActive ? "border-green-200 bg-green-50/50" : "border-border"}>
243 <CardContent>
244 <Box className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
245 <Box className="flex items-center gap-3">
246 <Box
247 className={`h-3 w-3 rounded-full flex-shrink-0 ${
248 isActive ? "bg-status-success" : "bg-surface-tertiary"
249 }`}
250 aria-hidden="true"
251 />
252 <Box>
253 <Text variant="heading-sm" className={isActive ? "text-green-800" : "text-content"}>
254 Auto-Responder is {isActive ? "ON" : "OFF"}
255 </Text>
256 {config && (
257 <Text variant="body-sm" muted className="mt-0.5">
258 Mode:{" "}
259 <Box
260 as="span"
261 className={`rounded-full px-2 py-0.5 text-xs font-medium ${
262 isActive ? "bg-green-100 text-green-700" : "bg-surface-secondary text-content-tertiary"
263 }`}
264 >
265 {modeLabel}
266 </Box>
267 </Text>
268 )}
269 </Box>
270 </Box>
271 {config && (
272 <Button
273 variant={isActive ? "ghost" : "primary"}
274 size="sm"
275 onClick={isActive ? onDeactivate : onActivate}
276 disabled={toggling}
277 className={isActive ? "text-red-600 hover:bg-red-50" : ""}
278 >
279 {toggling ? "Working..." : isActive ? "Deactivate" : "Activate"}
280 </Button>
281 )}
282 </Box>
283 </CardContent>
284 </Card>
285 );
286}
287
288StatusBanner.displayName = "StatusBanner";
289
290// ─── Send Log ────────────────────────────────────────────────────────────────
291
292function SendLog(): React.ReactNode {
293 const [log, setLog] = useState<AutoResponderLogEntry[]>([]);
294 const [loading, setLoading] = useState(true);
295 const [error, setError] = useState<string | null>(null);
296
297 const load = useCallback(async (): Promise<void> => {
298 try {
299 setLoading(true);
300 const res = await autoResponderApi.getLog({ limit: 25 });
301 setLog(res.data);
302 setError(null);
303 } catch (err) {
304 setError(err instanceof Error ? err.message : "Failed to load send log");
305 } finally {
306 setLoading(false);
307 }
308 }, []);
309
310 useEffect(() => {
311 void load();
312 }, [load]);
313
314 const formatDate = (iso: string): string =>
315 new Date(iso).toLocaleString("en-US", {
316 month: "short",
317 day: "numeric",
318 year: "numeric",
319 hour: "numeric",
320 minute: "2-digit",
321 });
322
323 return (
324 <Card>
325 <CardContent>
326 <Box className="mb-4 flex items-center justify-between">
327 <Text variant="heading-sm">Send Log</Text>
328 <Button variant="ghost" size="sm" onClick={load} disabled={loading}>
329 {loading ? "Loading..." : "Refresh"}
330 </Button>
331 </Box>
332
333 {error && <ErrorBanner message={error} />}
334
335 {loading ? (
336 <Box className="space-y-2" aria-busy="true">
337 {[1, 2, 3].map((i) => (
338 <Box key={i} className="h-10 animate-pulse rounded bg-surface-secondary" />
339 ))}
340 </Box>
341 ) : log.length === 0 ? (
342 <Box className="py-8 text-center">
343 <Text variant="body-sm" muted>
344 No auto-responses sent yet.
345 </Text>
346 </Box>
347 ) : (
348 <Box
349 as="table"
350 className="w-full text-sm"
351 role="table"
352 aria-label="Auto-responder send log"
353 >
354 <Box as="thead">
355 <Box as="tr" className="border-b border-border">
356 <Box as="th" className="pb-2 text-left font-medium text-content-tertiary pr-4">
357 To
358 </Box>
359 <Box as="th" className="pb-2 text-left font-medium text-content-tertiary pr-4">
360 Subject
361 </Box>
362 <Box as="th" className="pb-2 text-left font-medium text-content-tertiary">
363 Sent
364 </Box>
365 </Box>
366 </Box>
367 <Box as="tbody">
368 {log.map((entry) => (
369 <Box
370 as="tr"
371 key={entry.id}
372 className="border-b border-border/50 last:border-0"
373 >
374 <Box as="td" className="py-2.5 pr-4 text-content truncate max-w-[180px]">
375 {entry.toEmail}
376 </Box>
377 <Box as="td" className="py-2.5 pr-4 text-content truncate max-w-[220px]">
378 {entry.subject}
379 </Box>
380 <Box as="td" className="py-2.5 text-content-tertiary whitespace-nowrap">
381 {formatDate(entry.sentAt)}
382 </Box>
383 </Box>
384 ))}
385 </Box>
386 </Box>
387 )}
388 </CardContent>
389 </Card>
390 );
391}
392
393SendLog.displayName = "SendLog";
394
395// ─── AI Preview ──────────────────────────────────────────────────────────────
396
397function AIPreview(): React.ReactNode {
398 const [sampleBody, setSampleBody] = useState("");
399 const [previewReply, setPreviewReply] = useState<string | null>(null);
400 const [loading, setLoading] = useState(false);
401 const [error, setError] = useState<string | null>(null);
402
403 const handlePreview = async (): Promise<void> => {
404 if (!sampleBody.trim()) return;
405 setLoading(true);
406 setError(null);
407 setPreviewReply(null);
408 try {
409 const res = await autoResponderApi.preview(sampleBody.trim());
410 setPreviewReply(res.reply);
411 } catch (err) {
412 setError(err instanceof Error ? err.message : "Preview failed");
413 } finally {
414 setLoading(false);
415 }
416 };
417
418 return (
419 <Card>
420 <CardContent>
421 <Box className="mb-3">
422 <Text variant="heading-sm">AI Reply Preview</Text>
423 <Text variant="body-sm" muted className="mt-0.5">
424 Paste a sample incoming email to see how AlecRae AI would respond.
425 </Text>
426 </Box>
427
428 {error && <ErrorBanner message={error} />}
429
430 <Box className="space-y-3">
431 <Box>
432 <Text
433 as="label"
434 variant="body-sm"
435 className="mb-1 block font-medium text-content"
436 htmlFor="preview-sample-body"
437 >
438 Sample incoming email body
439 </Text>
440 <textarea
441 id="preview-sample-body"
442 className="w-full rounded-md border border-border bg-surface p-3 text-sm text-content placeholder:text-content-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
443 rows={4}
444 placeholder="Hi, I was wondering if you had time to discuss the project proposal..."
445 value={sampleBody}
446 onChange={(e) => setSampleBody(e.target.value)}
447 aria-label="Sample email body for AI preview"
448 />
449 </Box>
450
451 <Button
452 variant="secondary"
453 size="sm"
454 onClick={handlePreview}
455 disabled={loading || !sampleBody.trim()}
456 >
457 {loading ? "Generating..." : "Preview AI Reply"}
458 </Button>
459
460 {previewReply && (
461 <Box className="rounded-md border border-accent/30 bg-accent/5 p-4">
462 <Text variant="body-sm" className="mb-1 font-semibold text-content">
463 AI-generated reply:
464 </Text>
465 <Text variant="body-sm" className="whitespace-pre-wrap text-content-secondary">
466 {previewReply}
467 </Text>
468 </Box>
469 )}
470 </Box>
471 </CardContent>
472 </Card>
473 );
474}
475
476AIPreview.displayName = "AIPreview";
477
478// ─── Main Page ───────────────────────────────────────────────────────────────
479
480export default function AutoResponderPage(): React.ReactNode {
481 // Config state
482 const [config, setConfig] = useState<AutoResponder | null>(null);
483 const [loading, setLoading] = useState(true);
484 const [error, setError] = useState<string | null>(null);
485 const [success, setSuccess] = useState<string | null>(null);
486 const [toggling, setToggling] = useState(false);
487 const [saving, setSaving] = useState(false);
488
489 // Form state
490 const [mode, setMode] = useState<AutoResponderMode>("vacation");
491 const [subject, setSubject] = useState("");
492 const [textBody, setTextBody] = useState("");
493
494 // Schedule state
495 const [startDate, setStartDate] = useState("");
496 const [endDate, setEndDate] = useState("");
497 const [timezone, setTimezone] = useState("UTC");
498
499 // Rules state
500 const [respondToContacts, setRespondToContacts] = useState(true);
501 const [respondToUnknown, setRespondToUnknown] = useState(false);
502 const [aiSmartReply, setAiSmartReply] = useState(false);
503 const [excludeDomains, setExcludeDomains] = useState("");
504 const [maxResponses, setMaxResponses] = useState("1");
505
506 const populateFormFromConfig = useCallback((cfg: AutoResponder): void => {
507 setMode(cfg.mode);
508 setSubject(cfg.subject);
509 setTextBody(cfg.textBody ?? "");
510 if (cfg.schedule) {
511 setStartDate(cfg.schedule.startDate.slice(0, 10));
512 setEndDate(cfg.schedule.endDate ? cfg.schedule.endDate.slice(0, 10) : "");
513 setTimezone(cfg.schedule.timezone);
514 }
515 if (cfg.rules) {
516 setRespondToContacts(cfg.rules.respondToContacts);
517 setRespondToUnknown(cfg.rules.respondToUnknown);
518 setAiSmartReply(cfg.rules.aiSmartReply);
519 setExcludeDomains(cfg.rules.excludeDomains?.join(", ") ?? "");
520 setMaxResponses(String(cfg.rules.maxResponsesPerSender ?? 1));
521 }
522 }, []);
523
524 const load = useCallback(async (): Promise<void> => {
525 try {
526 setLoading(true);
527 const res = await autoResponderApi.getConfig();
528 setConfig(res.data);
529 if (res.data) {
530 populateFormFromConfig(res.data);
531 }
532 setError(null);
533 } catch (err) {
534 setError(err instanceof Error ? err.message : "Failed to load auto-responder");
535 } finally {
536 setLoading(false);
537 }
538 }, [populateFormFromConfig]);
539
540 useEffect(() => {
541 void load();
542 }, [load]);
543
544 const handleSave = async (): Promise<void> => {
545 if (!subject.trim()) {
546 setError("Reply subject is required.");
547 return;
548 }
549 setSaving(true);
550 setError(null);
551 setSuccess(null);
552 try {
553 const excludeDomainsArr = excludeDomains
554 .split(",")
555 .map((d) => d.trim())
556 .filter(Boolean);
557
558 await autoResponderApi.upsert({
559 mode,
560 subject: subject.trim(),
561 ...(textBody.trim() ? { textBody: textBody.trim() } : {}),
562 ...(startDate
563 ? {
564 schedule: {
565 startDate,
566 ...(endDate ? { endDate } : {}),
567 timezone,
568 },
569 }
570 : {}),
571 rules: {
572 respondToContacts,
573 respondToUnknown,
574 aiSmartReply,
575 ...(excludeDomainsArr.length > 0 ? { excludeDomains: excludeDomainsArr } : {}),
576 maxResponsesPerSender: Number(maxResponses) || 1,
577 },
578 });
579 setSuccess("Settings saved.");
580 await load();
581 } catch (err) {
582 setError(err instanceof Error ? err.message : "Failed to save settings");
583 } finally {
584 setSaving(false);
585 }
586 };
587
588 const handleActivate = async (): Promise<void> => {
589 setToggling(true);
590 setError(null);
591 setSuccess(null);
592 try {
593 await autoResponderApi.activate();
594 setSuccess("Auto-Responder activated.");
595 await load();
596 } catch (err) {
597 setError(err instanceof Error ? err.message : "Failed to activate auto-responder");
598 } finally {
599 setToggling(false);
600 }
601 };
602
603 const handleDeactivate = async (): Promise<void> => {
604 setToggling(true);
605 setError(null);
606 setSuccess(null);
607 try {
608 await autoResponderApi.deactivate();
609 setSuccess("Auto-Responder deactivated.");
610 await load();
611 } catch (err) {
612 setError(err instanceof Error ? err.message : "Failed to deactivate auto-responder");
613 } finally {
614 setToggling(false);
615 }
616 };
617
618 const selectedMode = MODE_OPTIONS.find((m) => m.value === mode);
619 const bodyCharCount = textBody.length;
620
621 return (
622 <PageLayout
623 title="Auto-Responder"
624 description="Automatically reply to incoming email while you are away or busy."
625 >
626 <Box className="max-w-2xl space-y-6">
627 {error && <ErrorBanner message={error} />}
628 {success && <SuccessBanner message={success} />}
629
630 {/* Status Banner */}
631 <StatusBanner
632 config={config}
633 onActivate={handleActivate}
634 onDeactivate={handleDeactivate}
635 toggling={toggling}
636 />
637
638 {loading ? (
639 <LoadingSkeleton />
640 ) : (
641 <>
642 {/* Configuration Form */}
643 <Card>
644 <CardContent>
645 <Text variant="heading-sm" className="mb-4">
646 Configuration
647 </Text>
648
649 <Box className="space-y-5">
650 {/* Mode selector */}
651 <Box>
652 <Text variant="body-sm" className="mb-2 font-medium text-content">
653 Mode
654 </Text>
655 <Box
656 className="flex flex-wrap gap-2"
657 role="radiogroup"
658 aria-label="Auto-responder mode"
659 >
660 {MODE_OPTIONS.map((opt) => (
661 <Button
662 key={opt.value}
663 variant={mode === opt.value ? "secondary" : "ghost"}
664 size="sm"
665 role="radio"
666 aria-checked={mode === opt.value}
667 onClick={() => setMode(opt.value)}
668 >
669 {opt.label}
670 </Button>
671 ))}
672 </Box>
673 {selectedMode && (
674 <Text variant="caption" muted className="mt-1.5 block">
675 {selectedMode.description}
676 </Text>
677 )}
678 </Box>
679
680 {/* Subject */}
681 <Input
682 label="Reply subject"
683 variant="text"
684 placeholder="e.g. Out of office until July 1"
685 value={subject}
686 onChange={(e) => setSubject(e.target.value)}
687 />
688
689 {/* Message body */}
690 <Box>
691 <Box className="mb-1 flex items-center justify-between">
692 <Text variant="body-sm" className="font-medium text-content">
693 Message body
694 </Text>
695 <Text variant="caption" muted>
696 {bodyCharCount} chars
697 </Text>
698 </Box>
699 <textarea
700 id="auto-responder-body"
701 className="w-full rounded-md border border-border bg-surface p-3 text-sm text-content placeholder:text-content-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
702 rows={5}
703 placeholder="I'm away until July 1 and will get back to you as soon as possible."
704 value={textBody}
705 onChange={(e) => setTextBody(e.target.value)}
706 aria-label="Auto-responder message body"
707 />
708 </Box>
709
710 {/* Schedule (collapsible) */}
711 <CollapsibleSection title="Schedule (optional)">
712 <Box className="space-y-4">
713 <Box className="grid grid-cols-1 gap-4 sm:grid-cols-2">
714 <Input
715 label="Start date"
716 variant="text"
717 type="date"
718 value={startDate}
719 onChange={(e) => setStartDate(e.target.value)}
720 />
721 <Input
722 label="End date (optional)"
723 variant="text"
724 type="date"
725 value={endDate}
726 onChange={(e) => setEndDate(e.target.value)}
727 />
728 </Box>
729 <SelectField
730 id="schedule-timezone"
731 label="Timezone"
732 value={timezone}
733 onChange={setTimezone}
734 options={TIMEZONES}
735 />
736 </Box>
737 </CollapsibleSection>
738
739 {/* Rules (collapsible) */}
740 <CollapsibleSection title="Reply rules">
741 <Box className="space-y-1 divide-y divide-border/50">
742 <ToggleRow
743 id="rule-respond-contacts"
744 label="Reply to contacts"
745 description="Send auto-replies to people in your contacts."
746 checked={respondToContacts}
747 onChange={setRespondToContacts}
748 />
749 <ToggleRow
750 id="rule-respond-unknown"
751 label="Reply to unknown senders"
752 description="Also send auto-replies to people not in your contacts."
753 checked={respondToUnknown}
754 onChange={setRespondToUnknown}
755 />
756 <ToggleRow
757 id="rule-ai-smart-reply"
758 label="AI smart reply"
759 description="Auto-generate contextual responses with AI based on the incoming email."
760 checked={aiSmartReply}
761 onChange={setAiSmartReply}
762 />
763 <Box className="pt-4 space-y-4">
764 <Input
765 label="Exclude domains (comma-separated)"
766 variant="text"
767 placeholder="noreply.com, marketing.example.com"
768 value={excludeDomains}
769 onChange={(e) => setExcludeDomains(e.target.value)}
770 />
771 <Input
772 label="Max responses per sender"
773 variant="text"
774 type="number"
775 value={maxResponses}
776 onChange={(e) => setMaxResponses(e.target.value)}
777 />
778 </Box>
779 </Box>
780 </CollapsibleSection>
781 </Box>
782
783 {/* Save button */}
784 <Box className="mt-6 flex items-center gap-3">
785 <Button
786 variant="primary"
787 size="sm"
788 onClick={handleSave}
789 disabled={saving || !subject.trim()}
790 >
791 {saving ? "Saving..." : "Save Changes"}
792 </Button>
793 {!config && (
794 <Text variant="caption" muted>
795 Save first to enable activation.
796 </Text>
797 )}
798 </Box>
799 </CardContent>
800 </Card>
801
802 {/* AI Preview — only shown when AI smart reply is enabled */}
803 {aiSmartReply && <AIPreview />}
804
805 {/* Send Log */}
806 <SendLog />
807 </>
808 )}
809 </Box>
810 </PageLayout>
811 );
812}
0813
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts