@actuate-media/cms-admin 0.21.1 → 0.23.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.
Files changed (83) hide show
  1. package/dist/__tests__/components/forms-pagination.render.test.d.ts +2 -0
  2. package/dist/__tests__/components/forms-pagination.render.test.d.ts.map +1 -0
  3. package/dist/__tests__/components/forms-pagination.render.test.js +33 -0
  4. package/dist/__tests__/components/forms-pagination.render.test.js.map +1 -0
  5. package/dist/__tests__/layout/sidebar-forms-badge.render.test.d.ts +2 -0
  6. package/dist/__tests__/layout/sidebar-forms-badge.render.test.d.ts.map +1 -0
  7. package/dist/__tests__/layout/sidebar-forms-badge.render.test.js +30 -0
  8. package/dist/__tests__/layout/sidebar-forms-badge.render.test.js.map +1 -0
  9. package/dist/__tests__/layout/sidebar-submenu.render.test.js +8 -2
  10. package/dist/__tests__/layout/sidebar-submenu.render.test.js.map +1 -1
  11. package/dist/__tests__/views/form-submissions.render.test.d.ts +2 -0
  12. package/dist/__tests__/views/form-submissions.render.test.d.ts.map +1 -0
  13. package/dist/__tests__/views/form-submissions.render.test.js +110 -0
  14. package/dist/__tests__/views/form-submissions.render.test.js.map +1 -0
  15. package/dist/__tests__/views/forms-list.render.test.d.ts +2 -0
  16. package/dist/__tests__/views/forms-list.render.test.d.ts.map +1 -0
  17. package/dist/__tests__/views/forms-list.render.test.js +122 -0
  18. package/dist/__tests__/views/forms-list.render.test.js.map +1 -0
  19. package/dist/actuate-admin.css +1 -1
  20. package/dist/components/forms/Drawer.d.ts +13 -0
  21. package/dist/components/forms/Drawer.d.ts.map +1 -0
  22. package/dist/components/forms/Drawer.js +13 -0
  23. package/dist/components/forms/Drawer.js.map +1 -0
  24. package/dist/components/forms/EmbedPanel.d.ts +7 -0
  25. package/dist/components/forms/EmbedPanel.d.ts.map +1 -0
  26. package/dist/components/forms/EmbedPanel.js +91 -0
  27. package/dist/components/forms/EmbedPanel.js.map +1 -0
  28. package/dist/components/forms/EntryDetailDrawer.d.ts +11 -0
  29. package/dist/components/forms/EntryDetailDrawer.d.ts.map +1 -0
  30. package/dist/components/forms/EntryDetailDrawer.js +145 -0
  31. package/dist/components/forms/EntryDetailDrawer.js.map +1 -0
  32. package/dist/components/forms/FieldsPanel.d.ts +8 -0
  33. package/dist/components/forms/FieldsPanel.d.ts.map +1 -0
  34. package/dist/components/forms/FieldsPanel.js +123 -0
  35. package/dist/components/forms/FieldsPanel.js.map +1 -0
  36. package/dist/components/forms/FormSchemaDrawer.d.ts +9 -0
  37. package/dist/components/forms/FormSchemaDrawer.d.ts.map +1 -0
  38. package/dist/components/forms/FormSchemaDrawer.js +96 -0
  39. package/dist/components/forms/FormSchemaDrawer.js.map +1 -0
  40. package/dist/components/forms/NotificationsPanel.d.ts +6 -0
  41. package/dist/components/forms/NotificationsPanel.d.ts.map +1 -0
  42. package/dist/components/forms/NotificationsPanel.js +80 -0
  43. package/dist/components/forms/NotificationsPanel.js.map +1 -0
  44. package/dist/components/forms/primitives.d.ts +57 -0
  45. package/dist/components/forms/primitives.d.ts.map +1 -0
  46. package/dist/components/forms/primitives.js +131 -0
  47. package/dist/components/forms/primitives.js.map +1 -0
  48. package/dist/layout/Sidebar.d.ts +5 -0
  49. package/dist/layout/Sidebar.d.ts.map +1 -1
  50. package/dist/layout/Sidebar.js +43 -3
  51. package/dist/layout/Sidebar.js.map +1 -1
  52. package/dist/lib/forms-events.d.ts +17 -0
  53. package/dist/lib/forms-events.d.ts.map +1 -0
  54. package/dist/lib/forms-events.js +20 -0
  55. package/dist/lib/forms-events.js.map +1 -0
  56. package/dist/lib/forms-service.d.ts +114 -0
  57. package/dist/lib/forms-service.d.ts.map +1 -0
  58. package/dist/lib/forms-service.js +210 -0
  59. package/dist/lib/forms-service.js.map +1 -0
  60. package/dist/views/FormSubmissions.d.ts.map +1 -1
  61. package/dist/views/FormSubmissions.js +224 -77
  62. package/dist/views/FormSubmissions.js.map +1 -1
  63. package/dist/views/Forms.d.ts.map +1 -1
  64. package/dist/views/Forms.js +135 -17
  65. package/dist/views/Forms.js.map +1 -1
  66. package/package.json +1 -1
  67. package/src/__tests__/components/forms-pagination.render.test.tsx +49 -0
  68. package/src/__tests__/layout/sidebar-forms-badge.render.test.tsx +50 -0
  69. package/src/__tests__/layout/sidebar-submenu.render.test.tsx +9 -2
  70. package/src/__tests__/views/form-submissions.render.test.tsx +129 -0
  71. package/src/__tests__/views/forms-list.render.test.tsx +141 -0
  72. package/src/components/forms/Drawer.tsx +70 -0
  73. package/src/components/forms/EmbedPanel.tsx +173 -0
  74. package/src/components/forms/EntryDetailDrawer.tsx +312 -0
  75. package/src/components/forms/FieldsPanel.tsx +385 -0
  76. package/src/components/forms/FormSchemaDrawer.tsx +185 -0
  77. package/src/components/forms/NotificationsPanel.tsx +240 -0
  78. package/src/components/forms/primitives.tsx +300 -0
  79. package/src/layout/Sidebar.tsx +72 -6
  80. package/src/lib/forms-events.ts +32 -0
  81. package/src/lib/forms-service.ts +343 -0
  82. package/src/views/FormSubmissions.tsx +529 -394
  83. package/src/views/Forms.tsx +381 -106
@@ -0,0 +1,141 @@
1
+ // @vitest-environment happy-dom
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
4
+
5
+ // Drive the Forms list through a mocked forms-service so the test owns the
6
+ // data and can assert filtering, search, the notify toggle, and the schema
7
+ // drawer without a real backend.
8
+ function makeForm(over: Record<string, unknown> = {}) {
9
+ return {
10
+ id: 'f1',
11
+ name: 'Contact Us',
12
+ slug: 'contact-us',
13
+ description: 'General contact and inquiries',
14
+ status: 'active',
15
+ fields: [
16
+ { id: 'c1', label: 'Full Name', key: 'name', type: 'text', required: true, sortOrder: 0 },
17
+ { id: 'c2', label: 'Email', key: 'email', type: 'email', required: true, sortOrder: 1 },
18
+ ],
19
+ fieldCount: 2,
20
+ totalEntries: 47,
21
+ unreadEntries: 8,
22
+ submissions: 47,
23
+ notifyEnabled: true,
24
+ lastEntryAt: new Date().toISOString(),
25
+ ...over,
26
+ }
27
+ }
28
+
29
+ const FORMS = [
30
+ makeForm(),
31
+ makeForm({
32
+ id: 'f2',
33
+ name: 'Event RSVP',
34
+ slug: 'event-rsvp',
35
+ description: 'Webinar registration',
36
+ status: 'draft',
37
+ totalEntries: 0,
38
+ unreadEntries: 0,
39
+ notifyEnabled: false,
40
+ lastEntryAt: null,
41
+ }),
42
+ ]
43
+
44
+ const fetchForms = vi.fn(async () => FORMS)
45
+ const fetchFormStats = vi.fn(async () => ({
46
+ totalForms: 2,
47
+ activeForms: 1,
48
+ totalEntries: 47,
49
+ unreadEntries: 8,
50
+ }))
51
+ const setNotifyEnabled = vi.fn(async (_id: string, _v: boolean) => ({
52
+ data: { notifyEnabled: false },
53
+ }))
54
+ const fetchFormSchema = vi.fn(async () => ({
55
+ fields: FORMS[0]!.fields,
56
+ activeVersionId: null,
57
+ versions: [],
58
+ }))
59
+ const fetchEmbedSettings = vi.fn(async () => ({}))
60
+ const fetchNotificationSettings = vi.fn(async () => ({ enabled: true, recipients: [] }))
61
+
62
+ vi.mock('../../lib/forms-service.js', () => ({
63
+ fetchForms: () => fetchForms(),
64
+ fetchFormStats: () => fetchFormStats(),
65
+ setNotifyEnabled: (id: string, v: boolean) => setNotifyEnabled(id, v),
66
+ fetchFormSchema: () => fetchFormSchema(),
67
+ saveFormSchema: vi.fn(async () => ({ data: {} })),
68
+ fetchEmbedSettings: () => fetchEmbedSettings(),
69
+ updateEmbedSettings: vi.fn(async () => ({ data: {} })),
70
+ fetchNotificationSettings: () => fetchNotificationSettings(),
71
+ updateNotificationSettings: vi.fn(async () => ({ data: {} })),
72
+ }))
73
+
74
+ vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn() } }))
75
+
76
+ const { Forms } = await import('../../views/Forms.js')
77
+
78
+ beforeEach(() => {
79
+ fetchForms.mockClear()
80
+ fetchFormStats.mockClear()
81
+ setNotifyEnabled.mockClear()
82
+ })
83
+
84
+ describe('Forms list', () => {
85
+ it('renders forms, summary chips, and field badges from service data', async () => {
86
+ render(<Forms onNavigate={() => {}} />)
87
+
88
+ expect(await screen.findByText('Contact Us')).toBeTruthy()
89
+ expect(screen.getByText('Event RSVP')).toBeTruthy()
90
+ // Summary chips reflect stats (not hardcoded).
91
+ expect(screen.getByText('total entries')).toBeTruthy()
92
+ // Field-type badges render per form.
93
+ expect(screen.getAllByText('EMAIL').length).toBeGreaterThan(0)
94
+ })
95
+
96
+ it('filters by status pill', async () => {
97
+ render(<Forms onNavigate={() => {}} />)
98
+ await screen.findByText('Contact Us')
99
+
100
+ fireEvent.click(screen.getByRole('button', { name: 'Draft', pressed: false }))
101
+
102
+ // Active-only form is filtered out; the draft remains.
103
+ await waitFor(() => {
104
+ expect(screen.queryByText('Contact Us')).toBeNull()
105
+ expect(screen.getByText('Event RSVP')).toBeTruthy()
106
+ })
107
+ })
108
+
109
+ it('searches across name and field labels', async () => {
110
+ render(<Forms onNavigate={() => {}} />)
111
+ await screen.findByText('Contact Us')
112
+
113
+ fireEvent.change(screen.getByLabelText('Search forms'), { target: { value: 'rsvp' } })
114
+
115
+ await waitFor(() => {
116
+ expect(screen.getByText('Event RSVP')).toBeTruthy()
117
+ expect(screen.queryByText('Contact Us')).toBeNull()
118
+ })
119
+ })
120
+
121
+ it('optimistically persists the notify toggle', async () => {
122
+ render(<Forms onNavigate={() => {}} />)
123
+ await screen.findByText('Contact Us')
124
+
125
+ const toggles = screen.getAllByLabelText(/Notifications for/)
126
+ fireEvent.click(toggles[0]!)
127
+
128
+ await waitFor(() => expect(setNotifyEnabled).toHaveBeenCalledWith('f1', false))
129
+ })
130
+
131
+ it('opens the schema drawer from the Schema action', async () => {
132
+ render(<Forms onNavigate={() => {}} />)
133
+ await screen.findByText('Contact Us')
134
+
135
+ fireEvent.click(screen.getAllByRole('button', { name: 'Schema' })[0]!)
136
+
137
+ await waitFor(() => expect(fetchFormSchema).toHaveBeenCalled())
138
+ // Drawer tabs are present.
139
+ expect(await screen.findByRole('tab', { name: 'Fields' })).toBeTruthy()
140
+ })
141
+ })
@@ -0,0 +1,70 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Right-side drawer built on Radix Dialog — focus-trapped, escape-closable,
5
+ * token-only styling. Shared by the Forms schema / entry-detail drawers.
6
+ */
7
+ import * as Dialog from '@radix-ui/react-dialog'
8
+ import { X } from 'lucide-react'
9
+ import type { ReactNode } from 'react'
10
+ import { btnGhostIcon } from './primitives.js'
11
+
12
+ export function Drawer({
13
+ open,
14
+ onOpenChange,
15
+ title,
16
+ description,
17
+ headerExtra,
18
+ footer,
19
+ children,
20
+ width = 'max-w-xl',
21
+ }: {
22
+ open: boolean
23
+ onOpenChange: (open: boolean) => void
24
+ title: ReactNode
25
+ description?: ReactNode
26
+ /** Optional content rendered between the header text and the close button. */
27
+ headerExtra?: ReactNode
28
+ footer?: ReactNode
29
+ children: ReactNode
30
+ width?: string
31
+ }) {
32
+ return (
33
+ <Dialog.Root open={open} onOpenChange={onOpenChange}>
34
+ <Dialog.Portal>
35
+ <Dialog.Overlay className="bg-foreground/40 motion-safe:animate-in motion-safe:fade-in fixed inset-0 z-50" />
36
+ <Dialog.Content
37
+ {...(description ? {} : { 'aria-describedby': undefined })}
38
+ className={`bg-card text-foreground fixed inset-y-0 right-0 z-50 flex w-full ${width} motion-safe:animate-in motion-safe:slide-in-from-right flex-col shadow-xl outline-none`}
39
+ >
40
+ <header className="border-border flex items-start justify-between gap-3 border-b px-5 py-4">
41
+ <div className="min-w-0">
42
+ <Dialog.Title className="text-foreground truncate text-lg font-medium">
43
+ {title}
44
+ </Dialog.Title>
45
+ {description && (
46
+ <Dialog.Description className="text-muted-foreground mt-0.5 truncate text-sm">
47
+ {description}
48
+ </Dialog.Description>
49
+ )}
50
+ </div>
51
+ <div className="flex shrink-0 items-center gap-1">
52
+ {headerExtra}
53
+ <Dialog.Close asChild>
54
+ <button className={btnGhostIcon} aria-label="Close" data-shortcut="close">
55
+ <X size={20} aria-hidden />
56
+ </button>
57
+ </Dialog.Close>
58
+ </div>
59
+ </header>
60
+ <div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">{children}</div>
61
+ {footer && (
62
+ <footer className="border-border bg-card flex items-center justify-end gap-2 border-t px-5 py-4">
63
+ {footer}
64
+ </footer>
65
+ )}
66
+ </Dialog.Content>
67
+ </Dialog.Portal>
68
+ </Dialog.Root>
69
+ )
70
+ }
@@ -0,0 +1,173 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Embed tab of the Form Schema drawer. Shows the public submission/schema
5
+ * endpoints and copy-paste embed snippets, and lets the editor restrict which
6
+ * origins may host the embedded form. Snippets are derived from the form slug;
7
+ * the live public endpoints land in a later phase (PR 5) but the paths shown
8
+ * here are the committed contract.
9
+ */
10
+ import { Check, Copy } from 'lucide-react'
11
+ import { useEffect, useState } from 'react'
12
+ import { toast } from 'sonner'
13
+ import {
14
+ fetchEmbedSettings,
15
+ updateEmbedSettings,
16
+ type FormEmbedSettings,
17
+ type FormStatus,
18
+ } from '../../lib/forms-service.js'
19
+ import { FormsErrorState, FormsLoading, btnSecondary } from './primitives.js'
20
+
21
+ function siteOrigin(): string {
22
+ if (typeof window !== 'undefined') return window.location.origin
23
+ return 'https://your-site.com'
24
+ }
25
+
26
+ export function EmbedPanel({
27
+ formId,
28
+ slug,
29
+ status,
30
+ }: {
31
+ formId: string
32
+ slug: string
33
+ status: FormStatus
34
+ }) {
35
+ const [settings, setSettings] = useState<FormEmbedSettings | null>(null)
36
+ const [loading, setLoading] = useState(true)
37
+ const [error, setError] = useState<string | null>(null)
38
+ const [domains, setDomains] = useState('')
39
+ const [saving, setSaving] = useState(false)
40
+
41
+ useEffect(() => {
42
+ let active = true
43
+ setLoading(true)
44
+ setError(null)
45
+ fetchEmbedSettings(formId)
46
+ .then((s) => {
47
+ if (!active) return
48
+ setSettings(s)
49
+ setDomains((s.allowedDomains ?? []).join('\n'))
50
+ })
51
+ .catch((e: unknown) => {
52
+ if (active) setError(e instanceof Error ? e.message : 'Failed to load embed settings')
53
+ })
54
+ .finally(() => {
55
+ if (active) setLoading(false)
56
+ })
57
+ return () => {
58
+ active = false
59
+ }
60
+ }, [formId])
61
+
62
+ const origin = siteOrigin()
63
+ const submitUrl = `${origin}/api/forms/${slug}/submit`
64
+ const schemaUrl = `${origin}/api/forms/${slug}/schema`
65
+ const embedUrl = `${origin}/forms/embed/${slug}`
66
+ const iframeSnippet = `<iframe src="${embedUrl}" title="${slug}" style="width:100%;border:0;" loading="lazy"></iframe>`
67
+ const scriptSnippet = `<div data-actuate-form="${slug}"></div>\n<script src="${origin}/api/forms/embed.js" async></script>`
68
+
69
+ async function saveDomains() {
70
+ setSaving(true)
71
+ const list = domains
72
+ .split('\n')
73
+ .map((d) => d.trim())
74
+ .filter(Boolean)
75
+ const res = await updateEmbedSettings(formId, { allowedDomains: list })
76
+ setSaving(false)
77
+ if (res.error) {
78
+ toast.error(res.error)
79
+ return
80
+ }
81
+ if (res.data) setSettings(res.data)
82
+ toast.success('Embed settings saved.')
83
+ }
84
+
85
+ if (loading) return <FormsLoading label="Loading embed settings…" />
86
+ if (error) return <FormsErrorState message={error} />
87
+
88
+ return (
89
+ <div className="space-y-5">
90
+ {status !== 'active' && (
91
+ <p className="border-warning/30 bg-warning/10 text-warning rounded-md border px-3 py-2 text-sm">
92
+ This form is <span className="font-medium">{status}</span> and won&apos;t accept public
93
+ submissions until it&apos;s set to Active.
94
+ </p>
95
+ )}
96
+
97
+ <CopyField label="Public submission endpoint" value={submitUrl} />
98
+ <CopyField label="Schema endpoint" value={schemaUrl} />
99
+ <CopyField label="Iframe embed" value={iframeSnippet} multiline />
100
+ <CopyField label="Script embed" value={scriptSnippet} multiline />
101
+
102
+ <div>
103
+ <label className="text-foreground mb-1 block text-sm font-medium">
104
+ Allowed domains
105
+ <span className="text-muted-foreground ml-1 font-normal">
106
+ (one per line — leave empty to allow any)
107
+ </span>
108
+ </label>
109
+ <textarea
110
+ rows={3}
111
+ value={domains}
112
+ onChange={(e) => setDomains(e.target.value)}
113
+ placeholder="example.com"
114
+ className="border-border bg-input-background text-foreground focus-visible:ring-ring w-full rounded-md border px-2.5 py-1.5 text-sm focus-visible:ring-2 focus-visible:outline-none"
115
+ />
116
+ <div className="mt-2 flex justify-end">
117
+ <button className={btnSecondary} onClick={saveDomains} disabled={saving}>
118
+ {saving ? 'Saving…' : 'Save domains'}
119
+ </button>
120
+ </div>
121
+ </div>
122
+ {settings === null && null}
123
+ </div>
124
+ )
125
+ }
126
+
127
+ function CopyField({
128
+ label,
129
+ value,
130
+ multiline = false,
131
+ }: {
132
+ label: string
133
+ value: string
134
+ multiline?: boolean
135
+ }) {
136
+ const [copied, setCopied] = useState(false)
137
+
138
+ async function copy() {
139
+ try {
140
+ await navigator.clipboard.writeText(value)
141
+ setCopied(true)
142
+ setTimeout(() => setCopied(false), 1500)
143
+ } catch {
144
+ toast.error('Copy failed — your browser blocked clipboard access.')
145
+ }
146
+ }
147
+
148
+ return (
149
+ <div>
150
+ <div className="mb-1 flex items-center justify-between">
151
+ <span className="text-foreground text-sm font-medium">{label}</span>
152
+ <button
153
+ type="button"
154
+ onClick={copy}
155
+ className="text-muted-foreground hover:text-foreground focus-visible:ring-ring inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium focus-visible:ring-2 focus-visible:outline-none"
156
+ aria-label={`Copy ${label}`}
157
+ >
158
+ {copied ? (
159
+ <Check className="h-3.5 w-3.5" aria-hidden />
160
+ ) : (
161
+ <Copy className="h-3.5 w-3.5" aria-hidden />
162
+ )}
163
+ {copied ? 'Copied' : 'Copy'}
164
+ </button>
165
+ </div>
166
+ <pre
167
+ className={`border-border bg-muted text-foreground overflow-x-auto rounded-md border px-2.5 py-2 font-mono text-xs ${multiline ? 'whitespace-pre-wrap' : 'whitespace-nowrap'}`}
168
+ >
169
+ {value}
170
+ </pre>
171
+ </div>
172
+ )
173
+ }
@@ -0,0 +1,312 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Entry detail drawer — shows a single submission rendered against the schema
5
+ * version it was captured with, plus sender, attribution, and per-entry
6
+ * actions (star, archive, mark unread, delete). Opening an unread entry marks
7
+ * it read. Token-only styling; built on the shared `Drawer`.
8
+ */
9
+ import { useCallback, useEffect, useState } from 'react'
10
+ import { Archive, ArchiveRestore, Loader2, Mail, Star, Trash2 } from 'lucide-react'
11
+ import { toast } from 'sonner'
12
+ import {
13
+ deleteEntry,
14
+ fetchEntry,
15
+ updateEntry,
16
+ type EntryDetail,
17
+ type FormField,
18
+ type FormSubmission,
19
+ } from '../../lib/forms-service.js'
20
+ import { emitFormsChanged } from '../../lib/forms-events.js'
21
+ import { ConfirmDialog } from '../ui/ConfirmDialog.js'
22
+ import { Drawer } from './Drawer.js'
23
+ import { FormsErrorState, btnGhostIcon } from './primitives.js'
24
+
25
+ function formatDateTime(iso: string | null | undefined): string {
26
+ if (!iso) return '—'
27
+ const d = new Date(iso)
28
+ if (Number.isNaN(d.getTime())) return '—'
29
+ return d.toLocaleString(undefined, {
30
+ month: 'short',
31
+ day: 'numeric',
32
+ year: 'numeric',
33
+ hour: 'numeric',
34
+ minute: '2-digit',
35
+ })
36
+ }
37
+
38
+ /** Render a stored value for display, coping with arrays/objects/booleans. */
39
+ function displayValue(value: unknown): string {
40
+ if (value == null || value === '') return '—'
41
+ if (typeof value === 'boolean') return value ? 'Yes' : 'No'
42
+ if (Array.isArray(value)) return value.map((v) => String(v)).join(', ')
43
+ if (typeof value === 'object') return JSON.stringify(value)
44
+ return String(value)
45
+ }
46
+
47
+ interface AttrRow {
48
+ label: string
49
+ value: string | null | undefined
50
+ }
51
+
52
+ function attributionRows(entry: FormSubmission): AttrRow[] {
53
+ return [
54
+ { label: 'Source', value: entry.utmSource },
55
+ { label: 'Medium', value: entry.utmMedium },
56
+ { label: 'Campaign', value: entry.utmCampaign },
57
+ { label: 'Page', value: entry.pageUrl },
58
+ { label: 'Referrer', value: entry.referrer },
59
+ ].filter((r) => r.value)
60
+ }
61
+
62
+ export interface EntryDetailDrawerProps {
63
+ entryId: string | null
64
+ open: boolean
65
+ onOpenChange: (open: boolean) => void
66
+ /** Called after a mutation (star/archive/read/delete) so the list refreshes. */
67
+ onChanged?: () => void
68
+ /** Called specifically after a delete so the caller can drop the row/selection. */
69
+ onDeleted?: (id: string) => void
70
+ }
71
+
72
+ export function EntryDetailDrawer({
73
+ entryId,
74
+ open,
75
+ onOpenChange,
76
+ onChanged,
77
+ onDeleted,
78
+ }: EntryDetailDrawerProps) {
79
+ const [detail, setDetail] = useState<EntryDetail | null>(null)
80
+ const [loading, setLoading] = useState(false)
81
+ const [error, setError] = useState<string | null>(null)
82
+ const [busy, setBusy] = useState(false)
83
+ const [confirmDelete, setConfirmDelete] = useState(false)
84
+
85
+ const notifyChanged = useCallback(() => {
86
+ emitFormsChanged()
87
+ onChanged?.()
88
+ }, [onChanged])
89
+
90
+ // Load the entry (and its schema) when the drawer opens, marking it read.
91
+ useEffect(() => {
92
+ if (!open || !entryId) return
93
+ let cancelled = false
94
+ setLoading(true)
95
+ setError(null)
96
+ setDetail(null)
97
+ void (async () => {
98
+ try {
99
+ const data = await fetchEntry(entryId)
100
+ if (cancelled) return
101
+ if (!data) {
102
+ setError('Entry not found')
103
+ return
104
+ }
105
+ setDetail(data)
106
+ // Mark read on open (fire-and-forget; refresh the list counts).
107
+ if (data.entry.unread) {
108
+ const res = await updateEntry(entryId, { unread: false })
109
+ if (!cancelled && !res.error) notifyChanged()
110
+ }
111
+ } catch (e) {
112
+ if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load entry')
113
+ } finally {
114
+ if (!cancelled) setLoading(false)
115
+ }
116
+ })()
117
+ return () => {
118
+ cancelled = true
119
+ }
120
+ }, [open, entryId, notifyChanged])
121
+
122
+ const mutate = useCallback(
123
+ async (patch: { unread?: boolean; starred?: boolean; archived?: boolean }) => {
124
+ if (!entryId) return
125
+ setBusy(true)
126
+ const res = await updateEntry(entryId, patch)
127
+ setBusy(false)
128
+ if (res.error) {
129
+ toast.error(res.error)
130
+ return
131
+ }
132
+ setDetail((cur) => (cur ? { ...cur, entry: { ...cur.entry, ...patch } } : cur))
133
+ notifyChanged()
134
+ },
135
+ [entryId, notifyChanged],
136
+ )
137
+
138
+ const handleDelete = useCallback(async () => {
139
+ if (!entryId) return
140
+ setBusy(true)
141
+ const res = await deleteEntry(entryId)
142
+ setBusy(false)
143
+ if (res.error) {
144
+ toast.error(res.error)
145
+ return
146
+ }
147
+ toast.success('Submission deleted')
148
+ notifyChanged()
149
+ onDeleted?.(entryId)
150
+ onOpenChange(false)
151
+ }, [entryId, notifyChanged, onDeleted, onOpenChange])
152
+
153
+ const entry = detail?.entry
154
+ const senderName = entry?.senderName || 'Anonymous'
155
+ const attrRows = entry ? attributionRows(entry) : []
156
+
157
+ return (
158
+ <>
159
+ <Drawer
160
+ open={open}
161
+ onOpenChange={onOpenChange}
162
+ title={entry ? `Entry${entry.entryNumber ? ` #${entry.entryNumber}` : ''}` : 'Entry'}
163
+ description={entry ? formatDateTime(entry.submittedAt) : undefined}
164
+ headerExtra={
165
+ entry ? (
166
+ <>
167
+ <button
168
+ type="button"
169
+ className={btnGhostIcon}
170
+ aria-pressed={entry.starred}
171
+ aria-label={entry.starred ? 'Unstar entry' : 'Star entry'}
172
+ disabled={busy}
173
+ onClick={() => void mutate({ starred: !entry.starred })}
174
+ >
175
+ <Star
176
+ size={18}
177
+ aria-hidden
178
+ className={entry.starred ? 'fill-warning text-warning' : ''}
179
+ />
180
+ </button>
181
+ <button
182
+ type="button"
183
+ className={btnGhostIcon}
184
+ aria-pressed={entry.archived}
185
+ aria-label={entry.archived ? 'Unarchive entry' : 'Archive entry'}
186
+ disabled={busy}
187
+ onClick={() => void mutate({ archived: !entry.archived })}
188
+ >
189
+ {entry.archived ? (
190
+ <ArchiveRestore size={18} aria-hidden />
191
+ ) : (
192
+ <Archive size={18} aria-hidden />
193
+ )}
194
+ </button>
195
+ </>
196
+ ) : undefined
197
+ }
198
+ footer={
199
+ entry ? (
200
+ <>
201
+ <button
202
+ type="button"
203
+ className="text-destructive hover:bg-destructive/10 focus-visible:ring-ring inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-60"
204
+ disabled={busy}
205
+ onClick={() => setConfirmDelete(true)}
206
+ >
207
+ <Trash2 size={16} aria-hidden />
208
+ Delete
209
+ </button>
210
+ <button
211
+ type="button"
212
+ className="border-border text-foreground hover:bg-accent focus-visible:ring-ring inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-60"
213
+ disabled={busy}
214
+ onClick={() => void mutate({ unread: !entry.unread })}
215
+ >
216
+ <Mail size={16} aria-hidden />
217
+ Mark {entry.unread ? 'read' : 'unread'}
218
+ </button>
219
+ </>
220
+ ) : undefined
221
+ }
222
+ >
223
+ {loading ? (
224
+ <div className="flex h-40 items-center justify-center" role="status" aria-live="polite">
225
+ <Loader2 className="text-muted-foreground h-5 w-5 animate-spin" aria-hidden />
226
+ <span className="sr-only">Loading entry…</span>
227
+ </div>
228
+ ) : error ? (
229
+ <FormsErrorState message={error} />
230
+ ) : entry ? (
231
+ <div className="space-y-6">
232
+ <section className="space-y-1">
233
+ <h3 className="text-foreground text-sm font-medium">{senderName}</h3>
234
+ {entry.senderEmail && (
235
+ <a
236
+ href={`mailto:${entry.senderEmail}`}
237
+ className="text-primary block text-sm hover:underline"
238
+ >
239
+ {entry.senderEmail}
240
+ </a>
241
+ )}
242
+ {entry.senderPhone && (
243
+ <span className="text-muted-foreground block text-sm">{entry.senderPhone}</span>
244
+ )}
245
+ </section>
246
+
247
+ <FieldValues entry={entry} fields={detail.fields} />
248
+
249
+ {attrRows.length > 0 && (
250
+ <section>
251
+ <h4 className="text-muted-foreground mb-2 text-xs font-medium tracking-wide uppercase">
252
+ Attribution
253
+ </h4>
254
+ <dl className="divide-border divide-y text-sm">
255
+ {attrRows.map((row) => (
256
+ <div key={row.label} className="flex justify-between gap-4 py-1.5">
257
+ <dt className="text-muted-foreground shrink-0">{row.label}</dt>
258
+ <dd className="text-foreground min-w-0 truncate text-right">{row.value}</dd>
259
+ </div>
260
+ ))}
261
+ </dl>
262
+ </section>
263
+ )}
264
+ </div>
265
+ ) : null}
266
+ </Drawer>
267
+
268
+ <ConfirmDialog
269
+ open={confirmDelete}
270
+ onClose={() => setConfirmDelete(false)}
271
+ onConfirm={() => void handleDelete()}
272
+ title="Delete submission?"
273
+ description="This permanently removes the submission and any attached files. This cannot be undone."
274
+ confirmLabel="Delete"
275
+ destructive
276
+ />
277
+ </>
278
+ )
279
+ }
280
+
281
+ /** Render the captured field values against the entry's schema. */
282
+ function FieldValues({ entry, fields }: { entry: FormSubmission; fields: FormField[] }) {
283
+ // Prefer the schema order; fall back to whatever keys exist on the data.
284
+ const inputFields = fields.filter(
285
+ (f) => f.type !== 'divider' && f.type !== 'richtext' && f.type !== 'honeypot',
286
+ )
287
+ const rows: { key: string; label: string }[] = inputFields.length
288
+ ? inputFields.map((f) => ({ key: f.key, label: f.label }))
289
+ : Object.keys(entry.data ?? {}).map((k) => ({ key: k, label: k }))
290
+
291
+ if (rows.length === 0) {
292
+ return <p className="text-muted-foreground text-sm">No field data captured.</p>
293
+ }
294
+
295
+ return (
296
+ <section>
297
+ <h4 className="text-muted-foreground mb-2 text-xs font-medium tracking-wide uppercase">
298
+ Submission
299
+ </h4>
300
+ <dl className="space-y-3">
301
+ {rows.map((row) => (
302
+ <div key={row.key}>
303
+ <dt className="text-muted-foreground text-xs">{row.label}</dt>
304
+ <dd className="text-foreground mt-0.5 text-sm wrap-break-word whitespace-pre-wrap">
305
+ {displayValue(entry.data?.[row.key])}
306
+ </dd>
307
+ </div>
308
+ ))}
309
+ </dl>
310
+ </section>
311
+ )
312
+ }