@wopr-network/platform-ui-core 1.8.1 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wopr-network/platform-ui-core",
3
- "version": "1.8.1",
3
+ "version": "1.9.0",
4
4
  "description": "Brand-agnostic AI agent platform UI — deploy as any brand via env vars",
5
5
  "repository": {
6
6
  "type": "git",
@@ -3,8 +3,29 @@ import userEvent from "@testing-library/user-event";
3
3
  import { describe, expect, it, vi } from "vitest";
4
4
  import { InstanceListClient } from "../app/instances/instance-list-client";
5
5
 
6
+ vi.mock("@/lib/tenant-context", () => ({
7
+ useTenant: vi.fn().mockReturnValue({
8
+ activeTenantId: "tenant-001",
9
+ tenants: [],
10
+ isLoading: false,
11
+ switchTenant: vi.fn(),
12
+ }),
13
+ getActiveTenantId: vi.fn().mockReturnValue("tenant-001"),
14
+ }));
15
+
6
16
  vi.mock("@/lib/trpc", () => ({
7
17
  trpc: {
18
+ fleetUpdateConfig: {
19
+ getUpdateConfig: {
20
+ useQuery: vi.fn().mockReturnValue({
21
+ data: null,
22
+ isLoading: false,
23
+ isError: false,
24
+ error: null,
25
+ refetch: vi.fn(),
26
+ }),
27
+ },
28
+ },
8
29
  fleet: {
9
30
  getChangelog: {
10
31
  useQuery: vi.fn().mockReturnValue({ data: null, isLoading: false, error: null }),
@@ -0,0 +1,618 @@
1
+ "use client";
2
+
3
+ import { Eye, Mail, MailCheck, Save, Search, Sprout, X } from "lucide-react";
4
+ import { useCallback, useMemo, useState } from "react";
5
+ import { toast } from "sonner";
6
+ import { Badge } from "@/components/ui/badge";
7
+ import { Button } from "@/components/ui/button";
8
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
9
+ import { Input } from "@/components/ui/input";
10
+ import { Label } from "@/components/ui/label";
11
+ import { Separator } from "@/components/ui/separator";
12
+ import { Skeleton } from "@/components/ui/skeleton";
13
+ import { Switch } from "@/components/ui/switch";
14
+ import {
15
+ Table,
16
+ TableBody,
17
+ TableCell,
18
+ TableHead,
19
+ TableHeader,
20
+ TableRow,
21
+ } from "@/components/ui/table";
22
+ import { Textarea } from "@/components/ui/textarea";
23
+ import { trpc } from "@/lib/trpc";
24
+ import { cn } from "@/lib/utils";
25
+
26
+ /* ------------------------------------------------------------------ */
27
+ /* Types */
28
+ /* ------------------------------------------------------------------ */
29
+
30
+ interface EmailTemplate {
31
+ id: string;
32
+ name: string;
33
+ description: string | null;
34
+ subject: string;
35
+ htmlBody: string;
36
+ textBody: string;
37
+ active: boolean;
38
+ updatedAt: string;
39
+ }
40
+
41
+ /* ------------------------------------------------------------------ */
42
+ /* Variable map — derived from template name */
43
+ /* ------------------------------------------------------------------ */
44
+
45
+ const TEMPLATE_VARIABLES: Record<string, string[]> = {
46
+ welcome: ["userName", "loginUrl", "platformName"],
47
+ "password-reset": ["userName", "resetUrl", "expiresIn"],
48
+ "email-verification": ["userName", "verifyUrl", "expiresIn"],
49
+ "invite-member": ["inviterName", "orgName", "role", "inviteUrl", "expiresIn"],
50
+ "payment-receipt": ["userName", "amount", "currency", "date", "invoiceUrl"],
51
+ "payment-failed": ["userName", "amount", "currency", "retryUrl"],
52
+ "credits-low": ["userName", "balance", "topupUrl"],
53
+ "usage-alert": ["userName", "usagePercent", "limit", "settingsUrl"],
54
+ "instance-down": ["userName", "instanceName", "downSince", "dashboardUrl"],
55
+ "instance-recovered": ["userName", "instanceName", "recoveredAt", "dashboardUrl"],
56
+ };
57
+
58
+ function getVariablesForTemplate(name: string): string[] {
59
+ const key = name.toLowerCase().replace(/_/g, "-");
60
+ for (const [pattern, vars] of Object.entries(TEMPLATE_VARIABLES)) {
61
+ if (key.includes(pattern)) return vars;
62
+ }
63
+ return ["userName", "platformName"];
64
+ }
65
+
66
+ function buildSampleData(variables: string[]): Record<string, string> {
67
+ const samples: Record<string, string> = {
68
+ userName: "Jane Doe",
69
+ loginUrl: "https://app.example.com/login",
70
+ platformName: "Platform",
71
+ resetUrl: "https://app.example.com/reset?token=abc",
72
+ verifyUrl: "https://app.example.com/verify?token=abc",
73
+ expiresIn: "24 hours",
74
+ inviterName: "John Admin",
75
+ orgName: "Acme Corp",
76
+ role: "member",
77
+ inviteUrl: "https://app.example.com/invite?token=abc",
78
+ amount: "$25.00",
79
+ currency: "USD",
80
+ date: new Date().toLocaleDateString(),
81
+ invoiceUrl: "https://app.example.com/invoices/123",
82
+ retryUrl: "https://app.example.com/billing",
83
+ balance: "$2.50",
84
+ topupUrl: "https://app.example.com/billing/topup",
85
+ usagePercent: "90%",
86
+ limit: "$100.00",
87
+ settingsUrl: "https://app.example.com/settings",
88
+ instanceName: "prod-bot-1",
89
+ downSince: "10 minutes ago",
90
+ recoveredAt: new Date().toLocaleTimeString(),
91
+ dashboardUrl: "https://app.example.com/fleet",
92
+ };
93
+ const result: Record<string, string> = {};
94
+ for (const v of variables) {
95
+ result[v] = samples[v] ?? `{{${v}}}`;
96
+ }
97
+ return result;
98
+ }
99
+
100
+ /* ------------------------------------------------------------------ */
101
+ /* Main component */
102
+ /* ------------------------------------------------------------------ */
103
+
104
+ export function EmailTemplatesClient() {
105
+ const [search, setSearch] = useState("");
106
+ const [selectedId, setSelectedId] = useState<string | null>(null);
107
+
108
+ // --- Editor state ---
109
+ const [editDescription, setEditDescription] = useState("");
110
+ const [editSubject, setEditSubject] = useState("");
111
+ const [editHtmlBody, setEditHtmlBody] = useState("");
112
+ const [editTextBody, setEditTextBody] = useState("");
113
+ const [editActive, setEditActive] = useState(true);
114
+
115
+ // --- Preview state ---
116
+ const [previewHtml, setPreviewHtml] = useState<string | null>(null);
117
+ const [previewSubject, setPreviewSubject] = useState<string | null>(null);
118
+ const [previewText, setPreviewText] = useState<string | null>(null);
119
+
120
+ const utils = trpc.useUtils();
121
+
122
+ // --- Queries ---
123
+ const listQuery = trpc.notificationTemplates.listTemplates.useQuery(undefined, {
124
+ refetchOnWindowFocus: false,
125
+ });
126
+
127
+ // --- Mutations ---
128
+ const updateMutation = trpc.notificationTemplates.updateTemplate.useMutation({
129
+ onSuccess: () => {
130
+ toast.success("Template saved successfully.");
131
+ utils.notificationTemplates.listTemplates.invalidate();
132
+ },
133
+ onError: (err) => {
134
+ toast.error(`Failed to save template: ${err.message}`);
135
+ },
136
+ });
137
+
138
+ const previewMutation = trpc.notificationTemplates.previewTemplate.useMutation({
139
+ onError: (err) => {
140
+ toast.error(`Preview failed: ${err.message}`);
141
+ },
142
+ });
143
+
144
+ const seedMutation = trpc.notificationTemplates.seedDefaults.useMutation({
145
+ // NOTE: Cast needed because trpc-types.ts uses AnyTRPCMutationProcedure stubs
146
+ // that erase return types. Remove when @wopr-network/sdk is published.
147
+ onSuccess: (data: unknown) => {
148
+ const result = data as { seeded: number } | undefined;
149
+ const count = result?.seeded ?? 0;
150
+ toast.success(`Seeded ${count} default template${count !== 1 ? "s" : ""}.`);
151
+ utils.notificationTemplates.listTemplates.invalidate();
152
+ },
153
+ onError: (err) => {
154
+ toast.error(`Seed failed: ${err.message}`);
155
+ },
156
+ });
157
+
158
+ // --- Derived data ---
159
+ // NOTE: Cast needed because trpc-types.ts uses AnyTRPCMutationProcedure stubs
160
+ // that erase return types. Remove when @wopr-network/sdk is published.
161
+ const templates = (listQuery.data ?? []) as EmailTemplate[];
162
+
163
+ const filtered = useMemo(() => {
164
+ if (!search.trim()) return templates;
165
+ const q = search.toLowerCase();
166
+ return templates.filter(
167
+ (t) =>
168
+ t.name.toLowerCase().includes(q) ||
169
+ (t.description ?? "").toLowerCase().includes(q) ||
170
+ t.subject.toLowerCase().includes(q),
171
+ );
172
+ }, [templates, search]);
173
+
174
+ const selectedTemplate = useMemo(
175
+ () => templates.find((t) => t.id === selectedId) ?? null,
176
+ [templates, selectedId],
177
+ );
178
+
179
+ // --- Handlers ---
180
+
181
+ const openEditor = useCallback((template: EmailTemplate) => {
182
+ setSelectedId(template.id);
183
+ setEditDescription(template.description ?? "");
184
+ setEditSubject(template.subject);
185
+ setEditHtmlBody(template.htmlBody);
186
+ setEditTextBody(template.textBody);
187
+ setEditActive(template.active);
188
+ setPreviewHtml(null);
189
+ setPreviewSubject(null);
190
+ setPreviewText(null);
191
+ }, []);
192
+
193
+ const closeEditor = useCallback(() => {
194
+ // Dirty check — warn if editor state differs from loaded template values
195
+ if (selectedTemplate) {
196
+ const isDirty =
197
+ editDescription !== (selectedTemplate.description ?? "") ||
198
+ editSubject !== selectedTemplate.subject ||
199
+ editHtmlBody !== selectedTemplate.htmlBody ||
200
+ editTextBody !== selectedTemplate.textBody ||
201
+ editActive !== selectedTemplate.active;
202
+
203
+ if (isDirty) {
204
+ const confirmed = window.confirm(
205
+ "You have unsaved changes. Are you sure you want to close without saving?",
206
+ );
207
+ if (!confirmed) return;
208
+ }
209
+ }
210
+
211
+ setSelectedId(null);
212
+ setPreviewHtml(null);
213
+ setPreviewSubject(null);
214
+ setPreviewText(null);
215
+ }, [selectedTemplate, editDescription, editSubject, editHtmlBody, editTextBody, editActive]);
216
+
217
+ const handleSave = useCallback(() => {
218
+ if (!selectedId) return;
219
+ updateMutation.mutate({
220
+ id: selectedId,
221
+ description: editDescription,
222
+ subject: editSubject,
223
+ htmlBody: editHtmlBody,
224
+ textBody: editTextBody,
225
+ active: editActive,
226
+ });
227
+ }, [
228
+ selectedId,
229
+ editDescription,
230
+ editSubject,
231
+ editHtmlBody,
232
+ editTextBody,
233
+ editActive,
234
+ updateMutation,
235
+ ]);
236
+
237
+ const handlePreview = useCallback(() => {
238
+ if (!selectedTemplate) return;
239
+ const variables = getVariablesForTemplate(selectedTemplate.name);
240
+ const sampleData = buildSampleData(variables);
241
+
242
+ previewMutation.mutate(
243
+ {
244
+ id: selectedTemplate.id,
245
+ subject: editSubject,
246
+ htmlBody: editHtmlBody,
247
+ textBody: editTextBody,
248
+ sampleData,
249
+ },
250
+ {
251
+ // NOTE: Cast needed because trpc-types.ts uses AnyTRPCMutationProcedure stubs
252
+ // that erase return types. Remove when @wopr-network/sdk is published.
253
+ onSuccess: (data: unknown) => {
254
+ const result = data as {
255
+ html: string;
256
+ subject: string;
257
+ text: string;
258
+ } | null;
259
+ if (result) {
260
+ setPreviewHtml(result.html);
261
+ setPreviewSubject(result.subject);
262
+ setPreviewText(result.text);
263
+ }
264
+ },
265
+ },
266
+ );
267
+ }, [selectedTemplate, editSubject, editHtmlBody, editTextBody, previewMutation]);
268
+
269
+ const handleSeed = useCallback(() => {
270
+ seedMutation.mutate(undefined);
271
+ }, [seedMutation]);
272
+
273
+ // --- Loading state ---
274
+
275
+ if (listQuery.isLoading) {
276
+ return (
277
+ <div className="p-6 space-y-6">
278
+ <Skeleton className="h-8 w-72" />
279
+ <Skeleton className="h-10 w-full max-w-sm" />
280
+ <div className="space-y-2">
281
+ {Array.from({ length: 5 }, (_, i) => (
282
+ <Skeleton key={`skel-${i.toString()}`} className="h-12 w-full" />
283
+ ))}
284
+ </div>
285
+ </div>
286
+ );
287
+ }
288
+
289
+ if (listQuery.isError) {
290
+ return (
291
+ <div className="flex h-40 flex-col items-center justify-center gap-3">
292
+ <p className="text-sm text-destructive font-mono">
293
+ Failed to load email templates. Please try again.
294
+ </p>
295
+ <Button variant="outline" size="sm" onClick={() => listQuery.refetch()}>
296
+ Retry
297
+ </Button>
298
+ </div>
299
+ );
300
+ }
301
+
302
+ // --- Editor panel ---
303
+
304
+ if (selectedTemplate) {
305
+ const variables = getVariablesForTemplate(selectedTemplate.name);
306
+
307
+ return (
308
+ <div className="flex flex-col h-full">
309
+ {/* Header bar */}
310
+ <div className="flex items-center justify-between border-b border-border px-6 py-4">
311
+ <div className="flex items-center gap-3">
312
+ <Button
313
+ variant="ghost"
314
+ size="sm"
315
+ onClick={closeEditor}
316
+ className="text-muted-foreground hover:text-foreground"
317
+ >
318
+ <X className="size-4 mr-1" />
319
+ Back
320
+ </Button>
321
+ <Separator orientation="vertical" className="h-6" />
322
+ <div className="flex items-center gap-2">
323
+ <MailCheck className="size-4 text-amber-400" />
324
+ <h2 className="text-lg font-bold uppercase tracking-wider">
325
+ {selectedTemplate.name}
326
+ </h2>
327
+ </div>
328
+ </div>
329
+ <div className="flex items-center gap-2">
330
+ <Button
331
+ variant="ghost"
332
+ className="border border-border hover:bg-secondary"
333
+ onClick={handlePreview}
334
+ disabled={previewMutation.isPending}
335
+ >
336
+ <Eye className="size-4 mr-1.5" />
337
+ {previewMutation.isPending ? "Rendering..." : "Preview"}
338
+ </Button>
339
+ <Button
340
+ variant="ghost"
341
+ className="bg-amber-500/10 text-amber-400 border border-amber-500/30 hover:bg-amber-500/20"
342
+ onClick={handleSave}
343
+ disabled={updateMutation.isPending}
344
+ >
345
+ <Save className="size-4 mr-1.5" />
346
+ {updateMutation.isPending ? "Saving..." : "Save"}
347
+ </Button>
348
+ </div>
349
+ </div>
350
+
351
+ <div className="flex flex-1 min-h-0">
352
+ {/* Left: editor form */}
353
+ <div className="flex-1 overflow-auto p-6 space-y-6">
354
+ <Card className="border-border">
355
+ <CardHeader>
356
+ <CardTitle className="text-base">Template Settings</CardTitle>
357
+ <CardDescription>
358
+ Edit the template content. Use Handlebars syntax (
359
+ <code className="text-xs font-mono text-amber-400">{"{{variableName}}"}</code>)
360
+ for dynamic values.
361
+ </CardDescription>
362
+ </CardHeader>
363
+ <CardContent className="space-y-5">
364
+ {/* Description */}
365
+ <div className="space-y-2">
366
+ <Label htmlFor="tpl-description">Description</Label>
367
+ <Input
368
+ id="tpl-description"
369
+ value={editDescription}
370
+ onChange={(e) => setEditDescription(e.target.value)}
371
+ placeholder="Brief description of when this template is sent"
372
+ className="bg-black/20 border-border focus:border-amber-500/50"
373
+ />
374
+ </div>
375
+
376
+ {/* Subject */}
377
+ <div className="space-y-2">
378
+ <Label htmlFor="tpl-subject">Subject Line</Label>
379
+ <Input
380
+ id="tpl-subject"
381
+ value={editSubject}
382
+ onChange={(e) => setEditSubject(e.target.value)}
383
+ placeholder="Email subject (supports Handlebars)"
384
+ className="font-mono text-sm bg-black/20 border-border focus:border-amber-500/50"
385
+ />
386
+ </div>
387
+
388
+ {/* HTML Body */}
389
+ <div className="space-y-2">
390
+ <Label htmlFor="tpl-html">HTML Body</Label>
391
+ <Textarea
392
+ id="tpl-html"
393
+ value={editHtmlBody}
394
+ onChange={(e) => setEditHtmlBody(e.target.value)}
395
+ placeholder="HTML email body (supports Handlebars)"
396
+ className="min-h-[240px] font-mono text-sm leading-relaxed bg-black/20 border-border focus:border-amber-500/50"
397
+ />
398
+ </div>
399
+
400
+ {/* Text Body */}
401
+ <div className="space-y-2">
402
+ <Label htmlFor="tpl-text">Text Body</Label>
403
+ <Textarea
404
+ id="tpl-text"
405
+ value={editTextBody}
406
+ onChange={(e) => setEditTextBody(e.target.value)}
407
+ placeholder="Plain text email body (supports Handlebars)"
408
+ className="min-h-[160px] font-mono text-sm leading-relaxed bg-black/20 border-border focus:border-amber-500/50"
409
+ />
410
+ </div>
411
+
412
+ {/* Active toggle */}
413
+ <div className="flex items-center justify-between rounded-md border border-border p-3">
414
+ <div className="space-y-0.5">
415
+ <Label>Active</Label>
416
+ <p className="text-xs text-muted-foreground">
417
+ Inactive templates will not be sent.
418
+ </p>
419
+ </div>
420
+ <Switch
421
+ checked={editActive}
422
+ onCheckedChange={setEditActive}
423
+ className="data-[state=checked]:bg-amber-500"
424
+ />
425
+ </div>
426
+ </CardContent>
427
+ </Card>
428
+
429
+ {/* Available variables */}
430
+ <Card className="border-border">
431
+ <CardHeader>
432
+ <CardTitle className="text-base">Available Variables</CardTitle>
433
+ <CardDescription>
434
+ Use these in your template with Handlebars syntax.
435
+ </CardDescription>
436
+ </CardHeader>
437
+ <CardContent>
438
+ <div className="flex flex-wrap gap-2">
439
+ {variables.map((v) => (
440
+ <Badge
441
+ key={v}
442
+ variant="outline"
443
+ className="border-amber-500/30 text-amber-400 font-mono text-xs"
444
+ >
445
+ {`{{${v}}}`}
446
+ </Badge>
447
+ ))}
448
+ </div>
449
+ </CardContent>
450
+ </Card>
451
+ </div>
452
+
453
+ {/* Right: preview panel */}
454
+ <div className="w-[480px] shrink-0 border-l border-border overflow-auto p-6 space-y-4">
455
+ <h3 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
456
+ Preview
457
+ </h3>
458
+
459
+ {previewHtml === null && previewSubject === null ? (
460
+ <div className="flex flex-col items-center justify-center h-64 text-muted-foreground">
461
+ <Eye className="size-8 mb-3 opacity-40" />
462
+ <p className="text-sm font-mono">
463
+ Click &quot;Preview&quot; to render the template
464
+ </p>
465
+ </div>
466
+ ) : (
467
+ <div className="space-y-4">
468
+ {/* Rendered subject */}
469
+ {previewSubject !== null && (
470
+ <div className="space-y-1">
471
+ <span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
472
+ Subject
473
+ </span>
474
+ <p className="text-sm font-medium rounded-md border border-border bg-black/20 px-3 py-2">
475
+ {previewSubject}
476
+ </p>
477
+ </div>
478
+ )}
479
+
480
+ {/* Rendered HTML */}
481
+ {previewHtml !== null && (
482
+ <div className="space-y-1">
483
+ <span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
484
+ HTML Preview
485
+ </span>
486
+ <div className="rounded-md border border-border overflow-auto max-h-[400px]">
487
+ <div
488
+ className="bg-white p-4 text-black text-sm [color-scheme:light]"
489
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: Admin preview of controlled email template
490
+ dangerouslySetInnerHTML={{ __html: previewHtml }}
491
+ />
492
+ </div>
493
+ </div>
494
+ )}
495
+
496
+ {/* Rendered text */}
497
+ {previewText !== null && (
498
+ <div className="space-y-1">
499
+ <span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
500
+ Text Preview
501
+ </span>
502
+ <pre className="whitespace-pre-wrap rounded-md border border-border bg-black/20 p-3 text-xs font-mono leading-relaxed">
503
+ {previewText}
504
+ </pre>
505
+ </div>
506
+ )}
507
+ </div>
508
+ )}
509
+ </div>
510
+ </div>
511
+ </div>
512
+ );
513
+ }
514
+
515
+ // --- List view ---
516
+
517
+ return (
518
+ <div className="p-6 space-y-6">
519
+ {/* Header */}
520
+ <div className="flex items-center justify-between">
521
+ <div className="flex items-center gap-3">
522
+ <Mail className="size-5 text-amber-400" />
523
+ <h1 className="text-xl font-bold uppercase tracking-wider [text-shadow:0_0_10px_rgba(251,191,36,0.25)]">
524
+ Email Templates
525
+ </h1>
526
+ </div>
527
+ <Button
528
+ variant="ghost"
529
+ className="bg-amber-500/10 text-amber-400 border border-amber-500/30 hover:bg-amber-500/20"
530
+ onClick={handleSeed}
531
+ disabled={seedMutation.isPending}
532
+ >
533
+ <Sprout className="size-4 mr-1.5" />
534
+ {seedMutation.isPending ? "Seeding..." : "Seed Defaults"}
535
+ </Button>
536
+ </div>
537
+
538
+ {/* Search */}
539
+ <div className="relative max-w-sm">
540
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
541
+ <Input
542
+ placeholder="Search templates..."
543
+ value={search}
544
+ onChange={(e) => setSearch(e.target.value)}
545
+ className="pl-9 bg-black/20 border-border focus:border-amber-500/50"
546
+ />
547
+ </div>
548
+
549
+ {/* Table */}
550
+ <div className="border border-amber-500/10 rounded-sm">
551
+ <Table>
552
+ <TableHeader>
553
+ <TableRow className="bg-secondary hover:bg-secondary">
554
+ <TableHead className="text-xs uppercase tracking-wider">Name</TableHead>
555
+ <TableHead className="text-xs uppercase tracking-wider">Description</TableHead>
556
+ <TableHead className="text-xs uppercase tracking-wider">Subject</TableHead>
557
+ <TableHead className="text-xs uppercase tracking-wider">Status</TableHead>
558
+ <TableHead className="text-xs uppercase tracking-wider">Updated</TableHead>
559
+ </TableRow>
560
+ </TableHeader>
561
+ <TableBody>
562
+ {filtered.length === 0 ? (
563
+ <TableRow>
564
+ <TableCell colSpan={5} className="text-center py-12">
565
+ <span className="text-sm text-muted-foreground font-mono">
566
+ {templates.length === 0
567
+ ? '> No templates found. Click "Seed Defaults" to create initial templates.'
568
+ : "> No templates match your search."}
569
+ </span>
570
+ </TableCell>
571
+ </TableRow>
572
+ ) : (
573
+ filtered.map((template) => (
574
+ <TableRow
575
+ key={template.id}
576
+ className="cursor-pointer hover:bg-secondary/50"
577
+ onClick={() => openEditor(template)}
578
+ >
579
+ <TableCell>
580
+ <span className="text-sm font-medium font-mono">{template.name}</span>
581
+ </TableCell>
582
+ <TableCell>
583
+ <span className="text-sm text-muted-foreground line-clamp-1">
584
+ {template.description ?? "--"}
585
+ </span>
586
+ </TableCell>
587
+ <TableCell>
588
+ <span className="text-sm text-muted-foreground font-mono line-clamp-1 max-w-[200px]">
589
+ {template.subject}
590
+ </span>
591
+ </TableCell>
592
+ <TableCell>
593
+ <Badge
594
+ variant="outline"
595
+ className={cn(
596
+ "text-xs",
597
+ template.active
598
+ ? "border-amber-500/30 text-amber-400"
599
+ : "border-border text-muted-foreground",
600
+ )}
601
+ >
602
+ {template.active ? "Active" : "Inactive"}
603
+ </Badge>
604
+ </TableCell>
605
+ <TableCell>
606
+ <span className="text-xs text-muted-foreground">
607
+ {new Date(template.updatedAt).toLocaleDateString()}
608
+ </span>
609
+ </TableCell>
610
+ </TableRow>
611
+ ))
612
+ )}
613
+ </TableBody>
614
+ </Table>
615
+ </div>
616
+ </div>
617
+ );
618
+ }
@@ -0,0 +1,72 @@
1
+ "use client";
2
+
3
+ import { AlertTriangleIcon, HomeIcon, RefreshCwIcon } from "lucide-react";
4
+ import { useEffect, useState } from "react";
5
+ import { Button } from "@/components/ui/button";
6
+ import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
7
+ import { logger } from "@/lib/logger";
8
+
9
+ const log = logger("error-boundary:email-templates");
10
+
11
+ export default function EmailTemplatesError({
12
+ error,
13
+ reset,
14
+ }: {
15
+ error: Error & { digest?: string };
16
+ reset: () => void;
17
+ }) {
18
+ const [showDetails, setShowDetails] = useState(false);
19
+ const isDev = process.env.NODE_ENV === "development";
20
+
21
+ useEffect(() => {
22
+ log.error("Email templates error", error);
23
+ }, [error]);
24
+
25
+ return (
26
+ <div className="flex h-full items-center justify-center p-6">
27
+ <Card className="w-full max-w-lg">
28
+ <CardHeader>
29
+ <div className="flex items-center gap-3">
30
+ <AlertTriangleIcon className="size-6 text-destructive" />
31
+ <CardTitle className="text-xl">Email Templates Error</CardTitle>
32
+ </div>
33
+ </CardHeader>
34
+ <CardContent className="space-y-4">
35
+ <p className="text-muted-foreground">
36
+ Something went wrong loading the email template editor. This may be a temporary issue.
37
+ </p>
38
+ {isDev && (
39
+ <Button
40
+ type="button"
41
+ variant="link"
42
+ size="sm"
43
+ onClick={() => setShowDetails((v) => !v)}
44
+ className="h-auto p-0 text-muted-foreground"
45
+ >
46
+ {showDetails ? "Hide" : "Show"} error details
47
+ </Button>
48
+ )}
49
+ {isDev && showDetails && (
50
+ <pre className="max-h-48 overflow-auto rounded-md border bg-muted p-3 text-xs">
51
+ {error.message}
52
+ {error.stack && `\n\n${error.stack}`}
53
+ {error.digest && `\n\nDigest: ${error.digest}`}
54
+ </pre>
55
+ )}
56
+ </CardContent>
57
+ <CardFooter className="gap-3">
58
+ <Button onClick={reset}>
59
+ <RefreshCwIcon />
60
+ Try Again
61
+ </Button>
62
+ <Button variant="outline" asChild>
63
+ <a href="/admin">
64
+ <HomeIcon />
65
+ Admin Home
66
+ </a>
67
+ </Button>
68
+ </CardFooter>
69
+ </Card>
70
+ </div>
71
+ );
72
+ }
@@ -0,0 +1,38 @@
1
+ import { Skeleton } from "@/components/ui/skeleton";
2
+
3
+ export default function AdminEmailTemplatesLoading() {
4
+ return (
5
+ <div className="space-y-6 p-6">
6
+ <div className="flex items-center justify-between">
7
+ <div className="flex items-center gap-3">
8
+ <Skeleton className="h-5 w-5" />
9
+ <Skeleton className="h-8 w-40" />
10
+ </div>
11
+ <Skeleton className="h-9 w-32" />
12
+ </div>
13
+ <Skeleton className="h-10 w-full max-w-sm" />
14
+ <div className="rounded-md border">
15
+ <div className="border-b px-4 py-3">
16
+ <div className="flex items-center gap-4">
17
+ <Skeleton className="h-4 w-24" />
18
+ <Skeleton className="h-4 w-40" />
19
+ <Skeleton className="h-4 w-32" />
20
+ <Skeleton className="h-4 w-16" />
21
+ <Skeleton className="h-4 w-20" />
22
+ </div>
23
+ </div>
24
+ <div className="space-y-0">
25
+ {Array.from({ length: 6 }, (_, n) => `sk-${n}`).map((skId) => (
26
+ <div key={skId} className="flex items-center gap-4 border-b px-4 py-3 last:border-b-0">
27
+ <Skeleton className="h-4 w-32" />
28
+ <Skeleton className="h-4 w-48" />
29
+ <Skeleton className="h-4 w-40" />
30
+ <Skeleton className="h-5 w-16" />
31
+ <Skeleton className="h-4 w-20" />
32
+ </div>
33
+ ))}
34
+ </div>
35
+ </div>
36
+ </div>
37
+ );
38
+ }
@@ -0,0 +1,5 @@
1
+ import { EmailTemplatesClient } from "./email-templates-client";
2
+
3
+ export default function AdminEmailTemplatesPage() {
4
+ return <EmailTemplatesClient />;
5
+ }
@@ -0,0 +1,19 @@
1
+ import { Settings2 } from "lucide-react";
2
+ import { UpdateSettingsCard } from "@/components/fleet/update-settings-card";
3
+
4
+ export default function FleetSettingsPage() {
5
+ return (
6
+ <div className="p-6 space-y-6">
7
+ <div>
8
+ <h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
9
+ <Settings2 className="h-6 w-6 text-muted-foreground" />
10
+ Fleet Settings
11
+ </h1>
12
+ <p className="text-muted-foreground mt-1">
13
+ Configure update behavior and maintenance windows for your fleet.
14
+ </p>
15
+ </div>
16
+ <UpdateSettingsCard />
17
+ </div>
18
+ );
19
+ }
@@ -0,0 +1,173 @@
1
+ "use client";
2
+
3
+ import { Clock, Loader2, RefreshCw } from "lucide-react";
4
+ import { useState } from "react";
5
+ import { toast } from "sonner";
6
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
7
+ import { Label } from "@/components/ui/label";
8
+ import {
9
+ Select,
10
+ SelectContent,
11
+ SelectItem,
12
+ SelectTrigger,
13
+ SelectValue,
14
+ } from "@/components/ui/select";
15
+ import { Switch } from "@/components/ui/switch";
16
+ import { useTenant } from "@/lib/tenant-context";
17
+ import { trpc } from "@/lib/trpc";
18
+
19
+ const HOUR_OPTIONS = Array.from({ length: 24 }, (_, i) => {
20
+ const label =
21
+ i === 0 ? "12:00 AM" : i < 12 ? `${i}:00 AM` : i === 12 ? "12:00 PM" : `${i - 12}:00 PM`;
22
+ return { value: String(i), label };
23
+ });
24
+
25
+ export function UpdateSettingsCard() {
26
+ const { activeTenantId: tenantId } = useTenant();
27
+ const [saving, setSaving] = useState(false);
28
+
29
+ const configQuery = trpc.fleetUpdateConfig.getUpdateConfig.useQuery(
30
+ { tenantId },
31
+ { enabled: !!tenantId },
32
+ );
33
+
34
+ const setConfigMutation = trpc.fleetUpdateConfig.setUpdateConfig.useMutation();
35
+
36
+ const mode = configQuery.data?.mode ?? "auto";
37
+ const preferredHourUtc = configQuery.data?.preferredHourUtc ?? 3;
38
+
39
+ async function handleModeToggle(checked: boolean) {
40
+ const newMode = checked ? "auto" : "manual";
41
+ setSaving(true);
42
+ try {
43
+ await setConfigMutation.mutateAsync({
44
+ tenantId,
45
+ mode: newMode,
46
+ preferredHourUtc,
47
+ });
48
+ await configQuery.refetch();
49
+ toast.success(`Updates set to ${newMode}`);
50
+ } catch {
51
+ toast.error("Failed to update settings");
52
+ } finally {
53
+ setSaving(false);
54
+ }
55
+ }
56
+
57
+ async function handleHourChange(value: string) {
58
+ const hour = Number.parseInt(value, 10);
59
+ setSaving(true);
60
+ try {
61
+ await setConfigMutation.mutateAsync({
62
+ tenantId,
63
+ mode,
64
+ preferredHourUtc: hour,
65
+ });
66
+ await configQuery.refetch();
67
+ toast.success("Preferred update window saved");
68
+ } catch {
69
+ toast.error("Failed to update settings");
70
+ } finally {
71
+ setSaving(false);
72
+ }
73
+ }
74
+
75
+ if (!tenantId) return null;
76
+
77
+ return (
78
+ <Card>
79
+ <CardHeader>
80
+ <CardTitle className="flex items-center gap-2">
81
+ <RefreshCw className="h-5 w-5 text-muted-foreground" />
82
+ Auto-Update Settings
83
+ </CardTitle>
84
+ <CardDescription>
85
+ Control how your fleet receives updates. Auto mode applies updates during your preferred
86
+ maintenance window. Manual mode shows an update badge — you choose when to apply.
87
+ </CardDescription>
88
+ </CardHeader>
89
+ <CardContent className="space-y-6">
90
+ {configQuery.isLoading ? (
91
+ <div className="flex items-center gap-2 text-muted-foreground">
92
+ <Loader2 className="h-4 w-4 animate-spin" />
93
+ Loading settings...
94
+ </div>
95
+ ) : configQuery.isError ? (
96
+ <div className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
97
+ Failed to load update settings. Please try again later.
98
+ </div>
99
+ ) : (
100
+ <>
101
+ {/* Auto/Manual toggle */}
102
+ <div className="flex items-center justify-between rounded-lg border border-border p-4">
103
+ <div className="space-y-0.5">
104
+ <Label htmlFor="auto-update-toggle" className="text-base font-medium">
105
+ Automatic Updates
106
+ </Label>
107
+ <p className="text-sm text-muted-foreground">
108
+ {mode === "auto"
109
+ ? "Instances update automatically during the maintenance window"
110
+ : "You'll be notified when updates are available"}
111
+ </p>
112
+ </div>
113
+ <div className="flex items-center gap-2">
114
+ {saving && <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
115
+ <Switch
116
+ id="auto-update-toggle"
117
+ checked={mode === "auto"}
118
+ onCheckedChange={handleModeToggle}
119
+ disabled={saving}
120
+ />
121
+ </div>
122
+ </div>
123
+
124
+ {/* Preferred hour picker — only shown in auto mode */}
125
+ {mode === "auto" && (
126
+ <div className="flex items-center justify-between rounded-lg border border-border p-4">
127
+ <div className="space-y-0.5">
128
+ <Label className="flex items-center gap-2 text-base font-medium">
129
+ <Clock className="h-4 w-4 text-muted-foreground" />
130
+ Maintenance Window
131
+ </Label>
132
+ <p className="text-sm text-muted-foreground">
133
+ Preferred hour for applying updates (UTC)
134
+ </p>
135
+ </div>
136
+ <Select
137
+ value={String(preferredHourUtc)}
138
+ onValueChange={handleHourChange}
139
+ disabled={saving}
140
+ >
141
+ <SelectTrigger className="w-[140px]">
142
+ <SelectValue />
143
+ </SelectTrigger>
144
+ <SelectContent>
145
+ {HOUR_OPTIONS.map((opt) => (
146
+ <SelectItem key={opt.value} value={opt.value}>
147
+ {opt.label}
148
+ </SelectItem>
149
+ ))}
150
+ </SelectContent>
151
+ </Select>
152
+ </div>
153
+ )}
154
+
155
+ {/* Current status */}
156
+ {configQuery.data?.updatedAt && (
157
+ <p className="text-xs text-muted-foreground">
158
+ Last changed:{" "}
159
+ {new Date(configQuery.data.updatedAt).toLocaleString(undefined, {
160
+ year: "numeric",
161
+ month: "short",
162
+ day: "numeric",
163
+ hour: "2-digit",
164
+ minute: "2-digit",
165
+ })}
166
+ </p>
167
+ )}
168
+ </>
169
+ )}
170
+ </CardContent>
171
+ </Card>
172
+ );
173
+ }
@@ -170,6 +170,19 @@ type AppRouterRecord = {
170
170
  orgTopupCheckout: AnyTRPCMutationProcedure;
171
171
  orgSetupIntent: AnyTRPCMutationProcedure;
172
172
  };
173
+ /** Separate from `fleet` — backed by createFleetUpdateConfigRouter in platform-core. */
174
+ fleetUpdateConfig: {
175
+ getUpdateConfig: AnyTRPCQueryProcedure;
176
+ setUpdateConfig: AnyTRPCMutationProcedure;
177
+ };
178
+ /** Admin-only: DB-driven email template management. */
179
+ notificationTemplates: {
180
+ listTemplates: AnyTRPCQueryProcedure;
181
+ getTemplate: AnyTRPCQueryProcedure;
182
+ updateTemplate: AnyTRPCMutationProcedure;
183
+ previewTemplate: AnyTRPCMutationProcedure;
184
+ seedDefaults: AnyTRPCMutationProcedure;
185
+ };
173
186
  adminMarketplace: {
174
187
  listPlugins: AnyTRPCQueryProcedure;
175
188
  updatePlugin: AnyTRPCMutationProcedure;