@actuate-media/cms-admin 0.1.4 → 0.2.1
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/LICENSE +21 -21
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +17 -11
- package/dist/AdminRoot.js.map +1 -1
- package/dist/actuate-admin.css +2 -0
- package/dist/components/TipTapEditor.js +78 -78
- package/dist/lib/useApiData.d.ts +8 -1
- package/dist/lib/useApiData.d.ts.map +1 -1
- package/dist/lib/useApiData.js +39 -7
- package/dist/lib/useApiData.js.map +1 -1
- package/dist/views/Dashboard.d.ts +2 -1
- package/dist/views/Dashboard.d.ts.map +1 -1
- package/dist/views/Dashboard.js +52 -7
- package/dist/views/Dashboard.js.map +1 -1
- package/package.json +10 -5
- package/src/AdminRoot.tsx +312 -0
- package/src/__tests__/lib/search.test.ts +138 -0
- package/src/__tests__/lib/utils.test.ts +19 -0
- package/src/__tests__/router/match-route.test.ts +47 -0
- package/src/__tests__/router/strip-base.test.ts +30 -0
- package/src/components/Breadcrumbs.tsx +92 -0
- package/src/components/CommandPalette.tsx +384 -0
- package/src/components/ErrorBoundary.tsx +52 -0
- package/src/components/FocalPointPicker.tsx +54 -0
- package/src/components/FolderTree.tsx +427 -0
- package/src/components/LivePreview.tsx +136 -0
- package/src/components/LocaleProvider.tsx +51 -0
- package/src/components/LocaleSwitcher.tsx +51 -0
- package/src/components/MediaPickerModal.tsx +183 -0
- package/src/components/PresenceIndicator.tsx +71 -0
- package/src/components/SEOPanel.tsx +767 -0
- package/src/components/ThemeProvider.tsx +98 -0
- package/src/components/TipTapEditor.tsx +469 -0
- package/src/components/VersionHistory.tsx +167 -0
- package/src/components/ui/Avatar.tsx +42 -0
- package/src/components/ui/Badge.tsx +25 -0
- package/src/components/ui/Button.tsx +52 -0
- package/src/components/ui/CommandPalette.tsx +119 -0
- package/src/components/ui/ConfirmDialog.tsx +52 -0
- package/src/components/ui/DataTable.tsx +194 -0
- package/src/components/ui/EmptyState.tsx +29 -0
- package/src/components/ui/Modal.tsx +48 -0
- package/src/components/ui/Pagination.tsx +79 -0
- package/src/components/ui/SearchInput.tsx +44 -0
- package/src/components/ui/Skeleton.tsx +48 -0
- package/src/components/ui/Toast.tsx +66 -0
- package/src/components/ui/index.ts +24 -0
- package/src/fields/ArrayField.tsx +92 -0
- package/src/fields/BlockBuilderField.tsx +421 -0
- package/src/fields/DateField.tsx +41 -0
- package/src/fields/FieldRenderer.tsx +84 -0
- package/src/fields/GroupField.tsx +41 -0
- package/src/fields/MediaField.tsx +48 -0
- package/src/fields/NavBuilderField.tsx +78 -0
- package/src/fields/NumberField.tsx +45 -0
- package/src/fields/RelationshipField.tsx +245 -0
- package/src/fields/RichTextField.tsx +26 -0
- package/src/fields/SelectField.tsx +117 -0
- package/src/fields/SlugField.tsx +65 -0
- package/src/fields/TextField.tsx +48 -0
- package/src/fields/ToggleField.tsx +36 -0
- package/src/fields/block-types.ts +95 -0
- package/src/fields/index.ts +17 -0
- package/src/hooks/useContentLock.ts +52 -0
- package/src/hooks/useDebounce.ts +14 -0
- package/src/hooks/useKeyboardShortcuts.ts +32 -0
- package/src/index.ts +55 -0
- package/src/layout/Header.tsx +135 -0
- package/src/layout/Layout.tsx +77 -0
- package/src/layout/Sidebar.tsx +216 -0
- package/src/lib/api.ts +67 -0
- package/src/lib/search.ts +59 -0
- package/src/lib/useApiData.ts +95 -0
- package/src/lib/utils.ts +6 -0
- package/src/router/index.ts +81 -0
- package/src/styles/build-input.css +11 -0
- package/src/styles/tailwind.css +11 -6
- package/src/styles/theme.css +182 -181
- package/src/views/CollectionList.tsx +270 -0
- package/src/views/Dashboard.tsx +300 -0
- package/src/views/DocumentEdit.tsx +377 -0
- package/src/views/FormEditor.tsx +533 -0
- package/src/views/FormSubmissions.tsx +316 -0
- package/src/views/Forms.tsx +106 -0
- package/src/views/Login.tsx +322 -0
- package/src/views/MediaBrowser.tsx +774 -0
- package/src/views/PageEditor.tsx +192 -0
- package/src/views/Pages.tsx +354 -0
- package/src/views/PostEditor.tsx +251 -0
- package/src/views/Posts.tsx +243 -0
- package/src/views/Redirects.tsx +293 -0
- package/src/views/SEO.tsx +458 -0
- package/src/views/Settings.tsx +811 -0
- package/src/views/SetupWizard.tsx +207 -0
- package/src/views/Users.tsx +282 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useMemo } from 'react';
|
|
4
|
+
import {
|
|
5
|
+
ArrowLeft, Download, Trash2, Search, Globe, ChevronDown, ChevronUp,
|
|
6
|
+
MousePointerClick, MapPin, Smartphone, Monitor, Tablet, ExternalLink,
|
|
7
|
+
Loader2, AlertTriangle,
|
|
8
|
+
} from 'lucide-react';
|
|
9
|
+
import { toast } from 'sonner';
|
|
10
|
+
import { sortByRelevance } from '../lib/search.js';
|
|
11
|
+
import { useApiData } from '../lib/useApiData.js';
|
|
12
|
+
|
|
13
|
+
interface Attribution {
|
|
14
|
+
source: string;
|
|
15
|
+
medium: string;
|
|
16
|
+
campaign: string;
|
|
17
|
+
term: string;
|
|
18
|
+
content: string;
|
|
19
|
+
landingPage: string;
|
|
20
|
+
referrer: string;
|
|
21
|
+
deviceType: 'Desktop' | 'Mobile' | 'Tablet';
|
|
22
|
+
clickIds: Record<string, string | null>;
|
|
23
|
+
capturedAt: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface Submission {
|
|
27
|
+
id: number;
|
|
28
|
+
name: string;
|
|
29
|
+
email: string;
|
|
30
|
+
phone?: string;
|
|
31
|
+
message: string;
|
|
32
|
+
submittedAt: string;
|
|
33
|
+
status: 'new' | 'read' | 'replied';
|
|
34
|
+
attribution: Attribution;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function sourceColor(source: string): string {
|
|
38
|
+
const s = source.toLowerCase();
|
|
39
|
+
if (s === 'google') return 'bg-blue-100 text-blue-800';
|
|
40
|
+
if (s === 'facebook' || s === 'meta') return 'bg-indigo-100 text-indigo-800';
|
|
41
|
+
if (s === 'linkedin') return 'bg-sky-100 text-sky-800';
|
|
42
|
+
if (s === 'bing') return 'bg-teal-100 text-teal-800';
|
|
43
|
+
if (s === '(direct)') return 'bg-gray-100 text-gray-800';
|
|
44
|
+
return 'bg-purple-100 text-purple-800';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function mediumColor(medium: string): string {
|
|
48
|
+
const m = medium.toLowerCase();
|
|
49
|
+
if (m === 'cpc' || m === 'paid') return 'bg-orange-100 text-orange-800';
|
|
50
|
+
if (m === 'organic') return 'bg-green-100 text-green-800';
|
|
51
|
+
if (m === 'social') return 'bg-pink-100 text-pink-800';
|
|
52
|
+
if (m === 'email') return 'bg-yellow-100 text-yellow-800';
|
|
53
|
+
if (m === '(none)') return 'bg-gray-100 text-gray-600';
|
|
54
|
+
return 'bg-gray-100 text-gray-800';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function DeviceIcon({ type }: { type: string }) {
|
|
58
|
+
if (type === 'Mobile') return <Smartphone className="w-3.5 h-3.5" />;
|
|
59
|
+
if (type === 'Tablet') return <Tablet className="w-3.5 h-3.5" />;
|
|
60
|
+
return <Monitor className="w-3.5 h-3.5" />;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface FormSubmissionsProps {
|
|
64
|
+
formId: string;
|
|
65
|
+
onNavigate?: (path: string) => void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function FormSubmissions({ formId, onNavigate }: FormSubmissionsProps) {
|
|
69
|
+
const { data, loading, error, refetch } = useApiData<Submission[]>(`/forms/${formId}/submissions`);
|
|
70
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
71
|
+
const [filterStatus, setFilterStatus] = useState('all');
|
|
72
|
+
const [filterSource, setFilterSource] = useState('all');
|
|
73
|
+
const [expandedId, setExpandedId] = useState<number | null>(null);
|
|
74
|
+
|
|
75
|
+
const submissions = data ?? [];
|
|
76
|
+
|
|
77
|
+
const filteredSubmissions = useMemo(() => {
|
|
78
|
+
let results = submissions.filter((s) => {
|
|
79
|
+
const matchesSearch =
|
|
80
|
+
s.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
81
|
+
s.email.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
82
|
+
s.message.toLowerCase().includes(searchQuery.toLowerCase());
|
|
83
|
+
const matchesStatus = filterStatus === 'all' || s.status === filterStatus;
|
|
84
|
+
const matchesSource = filterSource === 'all' || s.attribution.source === filterSource;
|
|
85
|
+
return matchesSearch && matchesStatus && matchesSource;
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
if (searchQuery.trim()) {
|
|
89
|
+
results = sortByRelevance(results, searchQuery, (s) => [s.name, s.email, s.message]);
|
|
90
|
+
}
|
|
91
|
+
return results;
|
|
92
|
+
}, [submissions, searchQuery, filterStatus, filterSource]);
|
|
93
|
+
|
|
94
|
+
const uniqueSources = [...new Set(submissions.map(s => s.attribution.source))];
|
|
95
|
+
|
|
96
|
+
const sourceSummary = useMemo(() => {
|
|
97
|
+
const counts: Record<string, number> = {};
|
|
98
|
+
for (const s of submissions) {
|
|
99
|
+
const key = `${s.attribution.source} / ${s.attribution.medium}`;
|
|
100
|
+
counts[key] = (counts[key] ?? 0) + 1;
|
|
101
|
+
}
|
|
102
|
+
return Object.entries(counts).sort(([, a], [, b]) => b - a);
|
|
103
|
+
}, [submissions]);
|
|
104
|
+
|
|
105
|
+
const paidCount = submissions.filter(s => ['cpc', 'paid'].includes(s.attribution.medium)).length;
|
|
106
|
+
const organicCount = submissions.filter(s => s.attribution.medium === 'organic').length;
|
|
107
|
+
|
|
108
|
+
const handleExport = () => {
|
|
109
|
+
toast.success('Submissions exported to CSV (includes attribution data)');
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
if (loading) {
|
|
113
|
+
return (
|
|
114
|
+
<div className="p-3 pr-6 sm:p-4 sm:pr-8 flex items-center justify-center h-64">
|
|
115
|
+
<Loader2 className="w-6 h-6 animate-spin text-blue-600" />
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<div className="p-3 pr-6 sm:p-4 sm:pr-8">
|
|
122
|
+
{error && (
|
|
123
|
+
<div className="mb-4 flex items-center gap-3 rounded-lg border border-red-200 bg-red-50 p-3">
|
|
124
|
+
<AlertTriangle className="w-5 h-5 text-red-600 shrink-0" />
|
|
125
|
+
<span className="text-sm text-red-800 flex-1">{error}</span>
|
|
126
|
+
<button onClick={refetch} className="px-3 py-1 text-sm text-red-700 border border-red-300 rounded-lg hover:bg-red-100 transition-colors">Retry</button>
|
|
127
|
+
</div>
|
|
128
|
+
)}
|
|
129
|
+
|
|
130
|
+
<div className="mb-4">
|
|
131
|
+
<button onClick={() => onNavigate?.('/forms')} className="inline-flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 transition-colors mb-3">
|
|
132
|
+
<ArrowLeft className="w-4 h-4" />Back to Forms
|
|
133
|
+
</button>
|
|
134
|
+
<div className="flex items-center justify-between">
|
|
135
|
+
<div>
|
|
136
|
+
<h1 className="text-2xl font-semibold text-gray-900 mb-1">Contact Form - Submissions</h1>
|
|
137
|
+
<p className="text-sm text-gray-600">{filteredSubmissions.length} submission{filteredSubmissions.length !== 1 ? 's' : ''}</p>
|
|
138
|
+
</div>
|
|
139
|
+
<button onClick={handleExport} className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm">
|
|
140
|
+
<Download className="w-4 h-4" />Export CSV
|
|
141
|
+
</button>
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
|
|
145
|
+
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 mb-4">
|
|
146
|
+
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
|
147
|
+
<div className="text-xs text-gray-600 mb-1">Total</div>
|
|
148
|
+
<div className="text-2xl font-semibold text-gray-900">{submissions.length}</div>
|
|
149
|
+
</div>
|
|
150
|
+
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
|
151
|
+
<div className="text-xs text-gray-600 mb-1">New</div>
|
|
152
|
+
<div className="text-2xl font-semibold text-blue-600">{submissions.filter(s => s.status === 'new').length}</div>
|
|
153
|
+
</div>
|
|
154
|
+
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
|
155
|
+
<div className="text-xs text-gray-600 mb-1">Paid Traffic</div>
|
|
156
|
+
<div className="text-2xl font-semibold text-orange-600">{paidCount}</div>
|
|
157
|
+
</div>
|
|
158
|
+
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
|
159
|
+
<div className="text-xs text-gray-600 mb-1">Organic</div>
|
|
160
|
+
<div className="text-2xl font-semibold text-green-600">{organicCount}</div>
|
|
161
|
+
</div>
|
|
162
|
+
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
|
163
|
+
<div className="text-xs text-gray-600 mb-1">Top Source</div>
|
|
164
|
+
<div className="text-lg font-semibold text-gray-900">{sourceSummary[0]?.[0] ?? '—'}</div>
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
<div className="bg-white rounded-lg border border-gray-200 p-4 mb-4">
|
|
169
|
+
<h3 className="text-sm font-semibold text-gray-900 mb-3 flex items-center gap-2"><Globe className="w-4 h-4 text-blue-600" />Lead Source Breakdown</h3>
|
|
170
|
+
<div className="flex flex-wrap gap-2">
|
|
171
|
+
{sourceSummary.map(([label, count]) => (
|
|
172
|
+
<div key={label} className="flex items-center gap-2 px-3 py-1.5 bg-gray-50 rounded-lg">
|
|
173
|
+
<span className="text-sm font-medium text-gray-800">{label}</span>
|
|
174
|
+
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">{count}</span>
|
|
175
|
+
</div>
|
|
176
|
+
))}
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
<div className="bg-white rounded-lg border border-gray-200 mb-4">
|
|
181
|
+
<div className="p-3 flex flex-col sm:flex-row gap-3">
|
|
182
|
+
<div className="flex-1 relative">
|
|
183
|
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
|
184
|
+
<input type="text" placeholder="Search submissions..." value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} className="w-full pl-9 pr-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
|
185
|
+
</div>
|
|
186
|
+
<select value={filterStatus} onChange={(e) => setFilterStatus(e.target.value)} className="px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
|
187
|
+
<option value="all">All Status</option>
|
|
188
|
+
<option value="new">New</option>
|
|
189
|
+
<option value="read">Read</option>
|
|
190
|
+
<option value="replied">Replied</option>
|
|
191
|
+
</select>
|
|
192
|
+
<select value={filterSource} onChange={(e) => setFilterSource(e.target.value)} className="px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
|
193
|
+
<option value="all">All Sources</option>
|
|
194
|
+
{uniqueSources.map(src => <option key={src} value={src}>{src}</option>)}
|
|
195
|
+
</select>
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
|
|
199
|
+
{filteredSubmissions.length === 0 ? (
|
|
200
|
+
<div className="bg-white rounded-lg border border-gray-200 p-8 text-center">
|
|
201
|
+
<p className="text-sm text-gray-500">No submissions yet</p>
|
|
202
|
+
</div>
|
|
203
|
+
) : (
|
|
204
|
+
<div className="space-y-3">
|
|
205
|
+
{filteredSubmissions.map((submission) => {
|
|
206
|
+
const expanded = expandedId === submission.id;
|
|
207
|
+
const attr = submission.attribution;
|
|
208
|
+
const activeClickIds = Object.entries(attr.clickIds).filter(([, v]) => v);
|
|
209
|
+
|
|
210
|
+
return (
|
|
211
|
+
<div key={submission.id} className="bg-white rounded-lg border border-gray-200 hover:shadow-md transition-shadow">
|
|
212
|
+
<div className="p-4">
|
|
213
|
+
<div className="flex items-start justify-between mb-3">
|
|
214
|
+
<div className="flex items-start gap-3 flex-1">
|
|
215
|
+
<div className="w-10 h-10 bg-linear-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center text-white font-medium shrink-0 text-sm">
|
|
216
|
+
{submission.name.charAt(0)}
|
|
217
|
+
</div>
|
|
218
|
+
<div className="flex-1 min-w-0">
|
|
219
|
+
<div className="flex items-center gap-2 mb-1">
|
|
220
|
+
<h3 className="font-semibold text-gray-900 text-sm">{submission.name}</h3>
|
|
221
|
+
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${submission.status === 'new' ? 'bg-blue-100 text-blue-800' : submission.status === 'read' ? 'bg-gray-100 text-gray-800' : 'bg-green-100 text-green-800'}`}>{submission.status}</span>
|
|
222
|
+
</div>
|
|
223
|
+
<div className="text-xs text-gray-600 mb-2">{submission.email}{submission.phone ? ` · ${submission.phone}` : ''}</div>
|
|
224
|
+
<p className="text-sm text-gray-700">{submission.message}</p>
|
|
225
|
+
<div className="text-xs text-gray-500 mt-2">Submitted {submission.submittedAt}</div>
|
|
226
|
+
</div>
|
|
227
|
+
</div>
|
|
228
|
+
<button onClick={() => toast.success('Submission deleted')} className="p-1.5 hover:bg-gray-100 rounded transition-colors" title="Delete">
|
|
229
|
+
<Trash2 className="w-4 h-4 text-red-600" />
|
|
230
|
+
</button>
|
|
231
|
+
</div>
|
|
232
|
+
|
|
233
|
+
<div className="flex flex-wrap items-center gap-2 mt-3 pt-3 border-t border-gray-100">
|
|
234
|
+
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${sourceColor(attr.source)}`}>{attr.source}</span>
|
|
235
|
+
<span className="text-gray-400 text-xs">/</span>
|
|
236
|
+
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${mediumColor(attr.medium)}`}>{attr.medium}</span>
|
|
237
|
+
{attr.campaign && <span className="px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800">{attr.campaign}</span>}
|
|
238
|
+
<span className="flex items-center gap-1 text-xs text-gray-500"><DeviceIcon type={attr.deviceType} />{attr.deviceType}</span>
|
|
239
|
+
{activeClickIds.length > 0 && (
|
|
240
|
+
<span className="flex items-center gap-1 text-xs text-gray-500">
|
|
241
|
+
<MousePointerClick className="w-3.5 h-3.5" />
|
|
242
|
+
{activeClickIds.map(([k]) => k).join(', ')}
|
|
243
|
+
</span>
|
|
244
|
+
)}
|
|
245
|
+
<button onClick={() => setExpandedId(expanded ? null : submission.id)} className="ml-auto flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800 transition-colors">
|
|
246
|
+
{expanded ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
|
|
247
|
+
{expanded ? 'Hide' : 'Attribution'}
|
|
248
|
+
</button>
|
|
249
|
+
</div>
|
|
250
|
+
|
|
251
|
+
{expanded && (
|
|
252
|
+
<div className="mt-3 pt-3 border-t border-gray-100 bg-gray-50 rounded-lg p-4 text-sm space-y-4">
|
|
253
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
254
|
+
<div>
|
|
255
|
+
<h4 className="text-xs font-semibold text-gray-600 uppercase tracking-wider mb-2">Traffic Source</h4>
|
|
256
|
+
<div className="space-y-1.5">
|
|
257
|
+
<div className="flex justify-between"><span className="text-gray-500">Source</span><span className="font-medium text-gray-900">{attr.source}</span></div>
|
|
258
|
+
<div className="flex justify-between"><span className="text-gray-500">Medium</span><span className="font-medium text-gray-900">{attr.medium}</span></div>
|
|
259
|
+
{attr.campaign && <div className="flex justify-between"><span className="text-gray-500">Campaign</span><span className="font-medium text-gray-900">{attr.campaign}</span></div>}
|
|
260
|
+
{attr.term && <div className="flex justify-between"><span className="text-gray-500">Keyword</span><span className="font-medium text-gray-900">{attr.term}</span></div>}
|
|
261
|
+
{attr.content && <div className="flex justify-between"><span className="text-gray-500">Content</span><span className="font-medium text-gray-900">{attr.content}</span></div>}
|
|
262
|
+
</div>
|
|
263
|
+
</div>
|
|
264
|
+
<div>
|
|
265
|
+
<h4 className="text-xs font-semibold text-gray-600 uppercase tracking-wider mb-2">Page Context</h4>
|
|
266
|
+
<div className="space-y-1.5">
|
|
267
|
+
<div>
|
|
268
|
+
<span className="text-gray-500 text-xs">Landing Page</span>
|
|
269
|
+
<div className="flex items-center gap-1 mt-0.5">
|
|
270
|
+
<MapPin className="w-3 h-3 text-gray-400 shrink-0" />
|
|
271
|
+
<span className="font-medium text-gray-900 text-xs break-all">{attr.landingPage}</span>
|
|
272
|
+
</div>
|
|
273
|
+
</div>
|
|
274
|
+
{attr.referrer && (
|
|
275
|
+
<div>
|
|
276
|
+
<span className="text-gray-500 text-xs">Referrer</span>
|
|
277
|
+
<div className="flex items-center gap-1 mt-0.5">
|
|
278
|
+
<ExternalLink className="w-3 h-3 text-gray-400 shrink-0" />
|
|
279
|
+
<span className="font-medium text-gray-900 text-xs break-all">{attr.referrer}</span>
|
|
280
|
+
</div>
|
|
281
|
+
</div>
|
|
282
|
+
)}
|
|
283
|
+
<div className="flex justify-between"><span className="text-gray-500">Device</span><span className="flex items-center gap-1 font-medium text-gray-900"><DeviceIcon type={attr.deviceType} />{attr.deviceType}</span></div>
|
|
284
|
+
</div>
|
|
285
|
+
</div>
|
|
286
|
+
</div>
|
|
287
|
+
{activeClickIds.length > 0 && (
|
|
288
|
+
<div>
|
|
289
|
+
<h4 className="text-xs font-semibold text-gray-600 uppercase tracking-wider mb-2">Click IDs</h4>
|
|
290
|
+
<div className="flex flex-wrap gap-2">
|
|
291
|
+
{activeClickIds.map(([key, val]) => (
|
|
292
|
+
<div key={key} className="px-2.5 py-1 bg-white border border-gray-200 rounded-lg">
|
|
293
|
+
<span className="text-xs text-gray-500">{key}: </span>
|
|
294
|
+
<span className="text-xs font-mono text-gray-800">{String(val).slice(0, 20)}...</span>
|
|
295
|
+
</div>
|
|
296
|
+
))}
|
|
297
|
+
</div>
|
|
298
|
+
</div>
|
|
299
|
+
)}
|
|
300
|
+
<div className="text-xs text-gray-500">Attribution captured {attr.capturedAt}</div>
|
|
301
|
+
</div>
|
|
302
|
+
)}
|
|
303
|
+
</div>
|
|
304
|
+
|
|
305
|
+
<div className="px-4 py-3 border-t border-gray-200 flex items-center gap-2">
|
|
306
|
+
<button className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">Reply</button>
|
|
307
|
+
<button className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">Mark as Read</button>
|
|
308
|
+
</div>
|
|
309
|
+
</div>
|
|
310
|
+
);
|
|
311
|
+
})}
|
|
312
|
+
</div>
|
|
313
|
+
)}
|
|
314
|
+
</div>
|
|
315
|
+
);
|
|
316
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Plus, MoreVertical, FileText, Send, Search, Loader2, AlertTriangle } from 'lucide-react';
|
|
4
|
+
import { useState, useMemo } from 'react';
|
|
5
|
+
import { sortByRelevance } from '../lib/search.js';
|
|
6
|
+
import { useApiData } from '../lib/useApiData.js';
|
|
7
|
+
|
|
8
|
+
export interface FormsProps {
|
|
9
|
+
onNavigate?: (path: string) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function Forms({ onNavigate }: FormsProps) {
|
|
13
|
+
const { data, loading, error, refetch } = useApiData<any[]>('/forms');
|
|
14
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
15
|
+
|
|
16
|
+
const forms = data ?? [];
|
|
17
|
+
|
|
18
|
+
const filteredAndSorted = useMemo(() => {
|
|
19
|
+
let results = forms.filter((form: any) =>
|
|
20
|
+
form.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
21
|
+
(form.description ?? '').toLowerCase().includes(searchQuery.toLowerCase())
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
if (searchQuery.trim()) {
|
|
25
|
+
results = sortByRelevance(results, searchQuery, (f: any) => [f.name, f.description ?? '']);
|
|
26
|
+
}
|
|
27
|
+
return results;
|
|
28
|
+
}, [forms, searchQuery]);
|
|
29
|
+
|
|
30
|
+
if (loading) {
|
|
31
|
+
return (
|
|
32
|
+
<div className="p-3 pr-6 sm:p-4 sm:pr-8 flex items-center justify-center h-64">
|
|
33
|
+
<Loader2 className="w-6 h-6 animate-spin text-blue-600" />
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div className="p-3 pr-6 sm:p-4 sm:pr-8">
|
|
40
|
+
{error && (
|
|
41
|
+
<div className="mb-4 flex items-center gap-3 rounded-lg border border-red-200 bg-red-50 p-3">
|
|
42
|
+
<AlertTriangle className="w-5 h-5 text-red-600 shrink-0" />
|
|
43
|
+
<span className="text-sm text-red-800 flex-1">{error}</span>
|
|
44
|
+
<button onClick={refetch} className="px-3 py-1 text-sm text-red-700 border border-red-300 rounded-lg hover:bg-red-100 transition-colors">Retry</button>
|
|
45
|
+
</div>
|
|
46
|
+
)}
|
|
47
|
+
|
|
48
|
+
<div className="flex items-center justify-between mb-4">
|
|
49
|
+
<div>
|
|
50
|
+
<h1 className="text-xl sm:text-2xl font-semibold text-gray-900 mb-1">Forms</h1>
|
|
51
|
+
<p className="text-sm text-gray-600">{filteredAndSorted.length} total forms</p>
|
|
52
|
+
</div>
|
|
53
|
+
<button onClick={() => onNavigate?.('/forms/new')} className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm">
|
|
54
|
+
<Plus className="w-4 h-4" />
|
|
55
|
+
New Form
|
|
56
|
+
</button>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<div className="bg-white rounded-lg border border-gray-200 mb-4">
|
|
60
|
+
<div className="p-3">
|
|
61
|
+
<div className="relative">
|
|
62
|
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
|
63
|
+
<input
|
|
64
|
+
type="text"
|
|
65
|
+
placeholder="Search forms..."
|
|
66
|
+
value={searchQuery}
|
|
67
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
68
|
+
className="w-full pl-9 pr-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
69
|
+
/>
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
{filteredAndSorted.length === 0 ? (
|
|
75
|
+
<div className="bg-white rounded-lg border border-gray-200 p-8 text-center">
|
|
76
|
+
<p className="text-sm text-gray-500 mb-2">No forms yet</p>
|
|
77
|
+
<button className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">Create your first form</button>
|
|
78
|
+
</div>
|
|
79
|
+
) : (
|
|
80
|
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
81
|
+
{filteredAndSorted.map((form: any) => (
|
|
82
|
+
<div key={form.id} className="bg-white rounded-lg border border-gray-200 p-4 hover:shadow-md transition-shadow">
|
|
83
|
+
<div className="flex items-start justify-between mb-3">
|
|
84
|
+
<div className="flex-1">
|
|
85
|
+
<h3 className="font-semibold text-gray-900 mb-1">{form.name}</h3>
|
|
86
|
+
<p className="text-sm text-gray-600 mb-2">{form.description}</p>
|
|
87
|
+
</div>
|
|
88
|
+
<button className="p-1.5 hover:bg-gray-100 rounded transition-colors">
|
|
89
|
+
<MoreVertical className="w-4 h-4 text-gray-600" />
|
|
90
|
+
</button>
|
|
91
|
+
</div>
|
|
92
|
+
<div className="flex items-center gap-4 text-sm text-gray-600 mb-3">
|
|
93
|
+
<span className="flex items-center gap-1"><FileText className="w-4 h-4" />{form.fields} fields</span>
|
|
94
|
+
<span className="flex items-center gap-1"><Send className="w-4 h-4" />{form.submissions} submissions</span>
|
|
95
|
+
</div>
|
|
96
|
+
<div className="flex items-center gap-2">
|
|
97
|
+
<button onClick={() => onNavigate?.(`/forms/${form.id}/submissions`)} className="flex-1 px-3 py-1.5 text-sm text-center bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">View Submissions</button>
|
|
98
|
+
<button onClick={() => onNavigate?.(`/forms/${form.id}/edit`)} className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">Edit</button>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
))}
|
|
102
|
+
</div>
|
|
103
|
+
)}
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
}
|