sitcod3 commited on
Commit
99b80ff
·
verified ·
1 Parent(s): ffb0c52

Upload src/pages/Campaigns.tsx with huggingface_hub

Browse files
Files changed (1) hide show
  1. src/pages/Campaigns.tsx +441 -0
src/pages/Campaigns.tsx ADDED
@@ -0,0 +1,441 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
2
+ import { apiClient } from "../lib/apiClient";
3
+ import {
4
+ Plus,
5
+ Search,
6
+ Megaphone,
7
+ Play,
8
+ Pause,
9
+ Square,
10
+ Trash2,
11
+ ArrowUpRight,
12
+ Sliders,
13
+ Clock,
14
+ Globe,
15
+ CheckCircle2,
16
+ MailCheck
17
+ } from "lucide-react";
18
+ import { useState } from "react";
19
+ import { Link } from "react-router-dom";
20
+ import StatusBadge from "../components/StatusBadge";
21
+ import ConfirmModal from "../components/ConfirmModal";
22
+ import FormDrawer from "../components/FormDrawer";
23
+ import toast from "react-hot-toast";
24
+
25
+ export default function Campaigns() {
26
+ const queryClient = useQueryClient();
27
+ const [searchTerm, setSearchTerm] = useState("");
28
+ const [statusFilter, setStatusFilter] = useState("ALL");
29
+ const [isCreateOpen, setIsCreateOpen] = useState(false);
30
+ const [selectedForDelete, setSelectedForDelete] = useState<string | null>(null);
31
+
32
+ const { data: campaigns, isLoading } = useQuery({
33
+ queryKey: ["campaigns"],
34
+ queryFn: async () => {
35
+ const res = await apiClient.get("/campaigns");
36
+ return res.data;
37
+ },
38
+ });
39
+
40
+ const updateStatusMutation = useMutation({
41
+ mutationFn: async ({ id, status }: { id: string; status: "START" | "PAUSE" | "STOP" }) => {
42
+ const res = await apiClient.post(`/campaigns/${id}/status`, { status });
43
+ return res.data;
44
+ },
45
+ onSuccess: (_, variables) => {
46
+ const statusLabel = variables.status === "START" ? "started" : variables.status === "PAUSE" ? "paused" : "stopped";
47
+ toast.success(`Campaign ${statusLabel}`);
48
+ queryClient.invalidateQueries({ queryKey: ["campaigns"] });
49
+ },
50
+ onError: (err: any) => {
51
+ toast.error(err.response?.data?.error || "Failed to update campaign status");
52
+ }
53
+ });
54
+
55
+ const deleteMutation = useMutation({
56
+ mutationFn: async (id: string) => {
57
+ const res = await apiClient.delete(`/campaigns/${id}`);
58
+ return res.data;
59
+ },
60
+ onSuccess: () => {
61
+ toast.success("Campaign removed");
62
+ setSelectedForDelete(null);
63
+ queryClient.invalidateQueries({ queryKey: ["campaigns"] });
64
+ },
65
+ onError: (err: any) => {
66
+ toast.error(err.response?.data?.error || "Failed to delete campaign");
67
+ }
68
+ });
69
+
70
+ const filteredCampaigns = (campaigns || []).filter((c: any) => {
71
+ const matchesSearch = c.name?.toLowerCase().includes(searchTerm.toLowerCase());
72
+ const matchesStatus = statusFilter === "ALL" || c.status === statusFilter;
73
+ return matchesSearch && matchesStatus;
74
+ });
75
+
76
+ return (
77
+ <div className="p-6 max-w-7xl mx-auto space-y-6">
78
+ {/* Header */}
79
+ <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
80
+ <div>
81
+ <h1 className="text-2xl font-bold tracking-tight text-zinc-100 flex items-center gap-2.5">
82
+ <Megaphone className="w-6 h-6 text-indigo-400" />
83
+ Outreach Campaigns
84
+ </h1>
85
+ <p className="text-zinc-400 text-sm mt-1">
86
+ Automated multi-step cold email sequences with A/B variant testing.
87
+ </p>
88
+ </div>
89
+
90
+ <button
91
+ onClick={() => setIsCreateOpen(true)}
92
+ className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium rounded-lg transition-all shadow-md shadow-indigo-600/20 flex items-center gap-2 self-start sm:self-auto"
93
+ >
94
+ <Plus className="w-4 h-4" />
95
+ Create Campaign
96
+ </button>
97
+ </div>
98
+
99
+ {/* Filter and Search Bar */}
100
+ <div className="bg-white/5 border border-white/10 backdrop-blur-md rounded-xl p-4 flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3 shadow-[0_8px_32px_-8px_rgba(6,10,12,0.65)]">
101
+ <div className="relative flex-1 max-w-md">
102
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-400" />
103
+ <input
104
+ type="text"
105
+ placeholder="Search campaigns by name..."
106
+ value={searchTerm}
107
+ onChange={(e) => setSearchTerm(e.target.value)}
108
+ className="w-full bg-black/40 border border-white/5 rounded-lg py-2 pl-9 pr-4 text-sm text-zinc-100 placeholder:text-zinc-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 shadow-[inset_0_1px_1px_rgba(246,248,242,0.02)] transition-all"
109
+ />
110
+ </div>
111
+
112
+ <div className="flex items-center gap-2">
113
+ {["ALL", "ACTIVE", "PAUSED", "STOPPED"].map((status) => (
114
+ <button
115
+ key={status}
116
+ onClick={() => setStatusFilter(status)}
117
+ className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all duration-300 shadow-[inset_0_1px_1px_rgba(246,248,242,0.02)] ${
118
+ statusFilter === status
119
+ ? "bg-indigo-600 text-white shadow-[0_0_12px_rgba(37,125,104,0.5)] border border-indigo-500/50"
120
+ : "bg-black/40 text-zinc-400 hover:text-zinc-200 border border-white/5"
121
+ }`}
122
+ >
123
+ {status}
124
+ </button>
125
+ ))}
126
+ </div>
127
+ </div>
128
+
129
+ {/* Campaigns Table */}
130
+ <div className="bg-white/5 border border-white/10 backdrop-blur-md rounded-xl overflow-hidden shadow-[0_8px_32px_-8px_rgba(6,10,12,0.65)]">
131
+ {isLoading ? (
132
+ <div className="py-20 flex flex-col items-center justify-center gap-3">
133
+ <div className="w-8 h-8 rounded-full border-2 border-indigo-500 border-t-transparent animate-spin drop-shadow-[0_0_8px_rgba(63,156,132,0.5)]" />
134
+ <p className="text-sm text-zinc-400">Loading campaign records...</p>
135
+ </div>
136
+ ) : filteredCampaigns.length > 0 ? (
137
+ <div className="overflow-x-auto">
138
+ <table className="w-full text-sm text-left">
139
+ <thead className="bg-black/20 border-b border-white/5 text-zinc-400 text-xs uppercase font-sans tracking-wider">
140
+ <tr>
141
+ <th className="px-6 py-3.5 font-semibold">Campaign Name</th>
142
+ <th className="px-6 py-3.5 font-semibold">Status</th>
143
+ <th className="px-6 py-3.5 font-semibold">Leads</th>
144
+ <th className="px-6 py-3.5 font-semibold">Sent</th>
145
+ <th className="px-6 py-3.5 font-semibold">Opens</th>
146
+ <th className="px-6 py-3.5 font-semibold">Replies</th>
147
+ <th className="px-6 py-3.5 font-semibold">Bounces</th>
148
+ <th className="px-6 py-3.5 font-semibold text-right">Actions</th>
149
+ </tr>
150
+ </thead>
151
+ <tbody className="divide-y divide-white/5">
152
+ {filteredCampaigns.map((camp: any) => (
153
+ <tr key={camp.id} className="hover:bg-white/5 transition-colors">
154
+ <td className="px-6 py-4">
155
+ <Link
156
+ to={`/campaigns/${camp.id}`}
157
+ className="font-semibold text-zinc-100 hover:text-indigo-400 transition-colors flex items-center gap-1.5"
158
+ >
159
+ {camp.name}
160
+ <ArrowUpRight className="w-3.5 h-3.5 text-zinc-500" />
161
+ </Link>
162
+ <div className="text-[11px] text-zinc-400 mt-0.5 font-sans">
163
+ Schedule: <span className="font-mono">{camp.start_schedule || "09:00"} - {camp.end_schedule || "17:00"}</span> ({camp.timezone || "America/New_York"})
164
+ </div>
165
+ </td>
166
+ <td className="px-6 py-4">
167
+ <StatusBadge status={camp.status} />
168
+ </td>
169
+ <td className="px-6 py-4 font-mono text-zinc-300">
170
+ {camp.lead_count?.toLocaleString() || 0}
171
+ </td>
172
+ <td className="px-6 py-4 font-mono text-zinc-300">
173
+ {camp.sent_count?.toLocaleString() || 0}
174
+ </td>
175
+ <td className="px-6 py-4 font-mono text-emerald-400 drop-shadow-[0_0_8px_rgba(63,156,132,0.3)]">
176
+ {camp.open_count?.toLocaleString() || 0}
177
+ </td>
178
+ <td className="px-6 py-4 font-mono text-purple-400 font-semibold drop-shadow-[0_0_8px_rgba(178,87,49,0.3)]">
179
+ {camp.reply_count?.toLocaleString() || 0}
180
+ </td>
181
+ <td className="px-6 py-4 font-mono text-rose-400 drop-shadow-[0_0_8px_rgba(203,117,80,0.3)]">
182
+ {camp.bounce_count || 0}
183
+ </td>
184
+ <td className="px-6 py-4 text-right">
185
+ <div className="flex items-center justify-end gap-1.5">
186
+ {camp.status === "ACTIVE" ? (
187
+ <button
188
+ onClick={() => updateStatusMutation.mutate({ id: camp.id, status: "PAUSE" })}
189
+ title="Pause Campaign"
190
+ className="p-1.5 bg-black/40 hover:bg-amber-500/20 text-zinc-400 hover:text-amber-400 rounded-md transition-colors border border-white/5 hover:border-amber-500/30"
191
+ >
192
+ <Pause className="w-3.5 h-3.5" />
193
+ </button>
194
+ ) : (
195
+ <button
196
+ onClick={() => updateStatusMutation.mutate({ id: camp.id, status: "START" })}
197
+ title="Start Campaign"
198
+ className="p-1.5 bg-black/40 hover:bg-emerald-500/20 text-zinc-400 hover:text-emerald-400 rounded-md transition-colors border border-white/5 hover:border-emerald-500/30"
199
+ >
200
+ <Play className="w-3.5 h-3.5" />
201
+ </button>
202
+ )}
203
+ <button
204
+ onClick={() => updateStatusMutation.mutate({ id: camp.id, status: "STOP" })}
205
+ title="Stop Campaign"
206
+ className="p-1.5 bg-black/40 hover:bg-rose-500/20 text-zinc-400 hover:text-rose-400 rounded-md transition-colors border border-white/5 hover:border-rose-500/30"
207
+ >
208
+ <Square className="w-3.5 h-3.5" />
209
+ </button>
210
+ <button
211
+ onClick={() => setSelectedForDelete(camp.id)}
212
+ title="Delete Campaign"
213
+ className="p-1.5 bg-black/40 hover:bg-rose-500/20 text-zinc-400 hover:text-rose-400 rounded-md transition-colors border border-white/5 hover:border-rose-500/30"
214
+ >
215
+ <Trash2 className="w-3.5 h-3.5" />
216
+ </button>
217
+ </div>
218
+ </td>
219
+ </tr>
220
+ ))}
221
+ </tbody>
222
+ </table>
223
+ </div>
224
+ ) : (
225
+ <div className="py-16 text-center">
226
+ <Megaphone className="w-12 h-12 text-zinc-600 mx-auto mb-3" />
227
+ <h3 className="text-base font-medium text-zinc-300">No campaigns found</h3>
228
+ <p className="text-xs text-zinc-500 mt-1 max-w-sm mx-auto font-sans">
229
+ Create your first campaign to begin dispatching automated cold outreach sequences.
230
+ </p>
231
+ <button
232
+ onClick={() => setIsCreateOpen(true)}
233
+ className="mt-4 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-medium rounded-lg transition-colors shadow-[0_0_12px_rgba(37,125,104,0.5)] border border-indigo-500/50"
234
+ >
235
+ Create Campaign
236
+ </button>
237
+ </div>
238
+ )}
239
+ </div>
240
+
241
+ {/* Delete Campaign Confirmation */}
242
+ <ConfirmModal
243
+ isOpen={Boolean(selectedForDelete)}
244
+ onClose={() => setSelectedForDelete(null)}
245
+ onConfirm={() => selectedForDelete && deleteMutation.mutate(selectedForDelete)}
246
+ title="Delete Campaign"
247
+ description="Are you sure you want to permanently delete this campaign and all associated sequences? This action cannot be undone."
248
+ confirmText="Delete Campaign"
249
+ isDestructive={true}
250
+ isLoading={deleteMutation.isPending}
251
+ />
252
+
253
+ {/* Full Multi-Step Create Campaign Drawer */}
254
+ <FormDrawer
255
+ isOpen={isCreateOpen}
256
+ onClose={() => setIsCreateOpen(false)}
257
+ title="Launch New Campaign"
258
+ subtitle="Configure schedule parameters, tracking domain, and initial sequence step."
259
+ >
260
+ <form
261
+ onSubmit={async (e) => {
262
+ e.preventDefault();
263
+ const form = e.target as HTMLFormElement;
264
+ const data = {
265
+ name: (form.elements.namedItem("name") as HTMLInputElement).value,
266
+ timezone: (form.elements.namedItem("timezone") as HTMLSelectElement).value,
267
+ start_schedule: (form.elements.namedItem("start_schedule") as HTMLInputElement).value,
268
+ end_schedule: (form.elements.namedItem("end_schedule") as HTMLInputElement).value,
269
+ track_settings: (form.elements.namedItem("track_settings") as HTMLSelectElement).value,
270
+ send_as_plain_text: (form.elements.namedItem("send_as_plain_text") as HTMLInputElement).checked,
271
+ follow_up_percentage: Number((form.elements.namedItem("follow_up_percentage") as HTMLInputElement).value),
272
+ unsubscribe_text: (form.elements.namedItem("unsubscribe_text") as HTMLInputElement).value,
273
+ stop_lead_settings: (form.elements.namedItem("stop_lead_settings") as HTMLSelectElement).value,
274
+ initial_subject: (form.elements.namedItem("subject") as HTMLInputElement).value,
275
+ initial_body: (form.elements.namedItem("body") as HTMLTextAreaElement).value,
276
+ };
277
+
278
+ try {
279
+ await apiClient.post("/campaigns", data);
280
+ toast.success("Campaign launched successfully");
281
+ setIsCreateOpen(false);
282
+ queryClient.invalidateQueries({ queryKey: ["campaigns"] });
283
+ } catch (err: any) {
284
+ toast.error(err.response?.data?.error || "Failed to create campaign");
285
+ }
286
+ }}
287
+ className="space-y-5 text-sm"
288
+ >
289
+ <div>
290
+ <label className="block font-medium text-zinc-300 mb-1">Campaign Name *</label>
291
+ <input
292
+ name="name"
293
+ required
294
+ placeholder="e.g. Enterprise Founders Outbound"
295
+ className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2.5 text-zinc-100 focus:outline-none focus:border-indigo-500"
296
+ />
297
+ </div>
298
+
299
+ <div className="grid grid-cols-2 gap-3">
300
+ <div>
301
+ <label className="block font-medium text-zinc-300 mb-1">Start Time</label>
302
+ <input
303
+ type="time"
304
+ name="start_schedule"
305
+ defaultValue="09:00"
306
+ className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2.5 text-zinc-100 focus:outline-none focus:border-indigo-500"
307
+ />
308
+ </div>
309
+ <div>
310
+ <label className="block font-medium text-zinc-300 mb-1">End Time</label>
311
+ <input
312
+ type="time"
313
+ name="end_schedule"
314
+ defaultValue="17:00"
315
+ className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2.5 text-zinc-100 focus:outline-none focus:border-indigo-500"
316
+ />
317
+ </div>
318
+ </div>
319
+
320
+ <div>
321
+ <label className="block font-medium text-zinc-300 mb-1">Timezone</label>
322
+ <select
323
+ name="timezone"
324
+ defaultValue="America/New_York"
325
+ className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2.5 text-zinc-100 focus:outline-none focus:border-indigo-500"
326
+ >
327
+ <option value="America/New_York">America/New_York (EST)</option>
328
+ <option value="America/Chicago">America/Chicago (CST)</option>
329
+ <option value="America/Los_Angeles">America/Los_Angeles (PST)</option>
330
+ <option value="Europe/London">Europe/London (GMT)</option>
331
+ <option value="Europe/Paris">Europe/Paris (CET)</option>
332
+ <option value="Asia/Singapore">Asia/Singapore (SGT)</option>
333
+ </select>
334
+ </div>
335
+
336
+ <div>
337
+ <label className="block font-medium text-zinc-300 mb-1">Tracking Settings</label>
338
+ <select
339
+ name="track_settings"
340
+ defaultValue="TRACK_EMAIL_OPEN_AND_CLICK"
341
+ className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2.5 text-zinc-100 focus:outline-none focus:border-indigo-500"
342
+ >
343
+ <option value="TRACK_EMAIL_OPEN_AND_CLICK">Track Opens and Clicks</option>
344
+ <option value="TRACK_EMAIL_OPEN">Track Email Opens Only</option>
345
+ <option value="TRACK_EMAIL_CLICK">Track Email Clicks Only</option>
346
+ <option value="DONT_TRACK_EMAIL_OPEN">Do Not Track Opens or Clicks</option>
347
+ </select>
348
+ </div>
349
+
350
+ <div className="flex items-center justify-between p-3 bg-zinc-900 border border-zinc-800 rounded-lg">
351
+ <div>
352
+ <span className="font-medium text-zinc-200 block text-xs">Send as Plain Text</span>
353
+ <span className="text-[11px] text-zinc-500">Improves deliverability in strict enterprise inboxes</span>
354
+ </div>
355
+ <input
356
+ type="checkbox"
357
+ name="send_as_plain_text"
358
+ className="w-4 h-4 rounded text-indigo-600 focus:ring-indigo-500 bg-zinc-950 border-zinc-700"
359
+ />
360
+ </div>
361
+
362
+ <div>
363
+ <div className="flex justify-between text-xs font-medium text-zinc-300 mb-1">
364
+ <span>Follow-up Distribution %</span>
365
+ <span>85%</span>
366
+ </div>
367
+ <input
368
+ type="range"
369
+ name="follow_up_percentage"
370
+ min="10"
371
+ max="100"
372
+ defaultValue="85"
373
+ className="w-full accent-indigo-500"
374
+ />
375
+ </div>
376
+
377
+ <div>
378
+ <label className="block font-medium text-zinc-300 mb-1">Unsubscribe Message</label>
379
+ <input
380
+ name="unsubscribe_text"
381
+ defaultValue="Reply unsubscribe if you prefer not to receive updates."
382
+ className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2.5 text-zinc-100 focus:outline-none focus:border-indigo-500 text-xs"
383
+ />
384
+ </div>
385
+
386
+ <div>
387
+ <label className="block font-medium text-zinc-300 mb-1">Stop Lead Condition</label>
388
+ <select
389
+ name="stop_lead_settings"
390
+ defaultValue="REPLIED"
391
+ className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2.5 text-zinc-100 focus:outline-none focus:border-indigo-500 text-xs"
392
+ >
393
+ <option value="REPLIED">Stop sequence once lead replies</option>
394
+ <option value="OPENED">Stop sequence once lead opens email</option>
395
+ <option value="CLICKED">Stop sequence once lead clicks a link</option>
396
+ </select>
397
+ </div>
398
+
399
+ <div className="pt-2 border-t border-zinc-800 space-y-3">
400
+ <h4 className="text-xs font-semibold uppercase text-zinc-400">Step 1 Sequence Setup</h4>
401
+ <div>
402
+ <label className="block font-medium text-zinc-300 mb-1">Subject Line</label>
403
+ <input
404
+ name="subject"
405
+ required
406
+ defaultValue="Quick sync for {{first_name}}"
407
+ className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2.5 text-zinc-100 focus:outline-none focus:border-indigo-500 text-xs"
408
+ />
409
+ </div>
410
+ <div>
411
+ <label className="block font-medium text-zinc-300 mb-1">Email Body</label>
412
+ <textarea
413
+ name="body"
414
+ rows={3}
415
+ required
416
+ defaultValue="<p>Hi {{first_name}},</p><p>Wanted to see how {{company_name}} is optimizing outbound deliverability?</p>"
417
+ className="w-full bg-zinc-900 border border-zinc-800 rounded-lg p-2.5 text-zinc-100 focus:outline-none focus:border-indigo-500 font-mono text-xs"
418
+ />
419
+ </div>
420
+ </div>
421
+
422
+ <div className="pt-4 flex justify-end gap-3 border-t border-zinc-800">
423
+ <button
424
+ type="button"
425
+ onClick={() => setIsCreateOpen(false)}
426
+ className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-xs"
427
+ >
428
+ Cancel
429
+ </button>
430
+ <button
431
+ type="submit"
432
+ className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white font-medium rounded-lg text-xs shadow-md"
433
+ >
434
+ Launch Campaign
435
+ </button>
436
+ </div>
437
+ </form>
438
+ </FormDrawer>
439
+ </div>
440
+ );
441
+ }