Non puoi selezionare più di 25 argomenti Gli argomenti devono iniziare con una lettera o un numero, possono includere trattini ('-') e possono essere lunghi fino a 35 caratteri.
 
 
 

464 righe
25 KiB

  1. 'use client';
  2. import { useState, useEffect } from 'react';
  3. import { Card, Portal } from '@/types';
  4. export default function AdminDashboard() {
  5. console.log('[ADMIN] Component rendering');
  6. const [activeTab, setActiveTab] = useState<'cards' | 'settings'>('cards');
  7. // Card State
  8. const [cards, setCards] = useState<Card[]>([]);
  9. const [isEditing, setIsEditing] = useState<Partial<Card> | null>(null);
  10. // Portal State
  11. const [portal, setPortal] = useState<Partial<Portal>>({});
  12. const [savingPortal, setSavingPortal] = useState(false);
  13. const [uploading, setUploading] = useState<{ [key: string]: boolean }>({});
  14. // NEW UI STATES: Toast and Confirm Dialog
  15. const [toast, setToast] = useState<string | null>(null);
  16. const [confirmDialog, setConfirmDialog] = useState<{ message: string, onConfirm: () => void } | null>(null);
  17. // Helper to show auto-dismissing toast
  18. const showToast = (message: string) => {
  19. setToast(message);
  20. setTimeout(() => setToast(null), 3000);
  21. };
  22. useEffect(() => {
  23. console.log('[ADMIN] useEffect fired - fetching data');
  24. fetch('/api/cards')
  25. .then(res => { console.log('[ADMIN] /api/cards status:', res.status); return res.json(); })
  26. .then(data => { console.log('[ADMIN] cards received:', data); setCards(data); })
  27. .catch(err => console.error('[ADMIN] cards fetch error:', err));
  28. fetch('/api/portals')
  29. .then(res => { console.log('[ADMIN] /api/portals status:', res.status); return res.json(); })
  30. .then(data => { console.log('[ADMIN] portal received:', data); if (data) setPortal(data); })
  31. .catch(err => console.error('[ADMIN] portal fetch error:', err));
  32. }, []);
  33. const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>, field: string, isPortal = false) => {
  34. if (!e.target.files?.[0]) return;
  35. setUploading(prev => ({ ...prev, [field]: true }));
  36. const formData = new FormData();
  37. formData.append('file', e.target.files[0]);
  38. const res = await fetch('/api/upload', { method: 'POST', body: formData });
  39. const data = await res.json();
  40. if (data.url) {
  41. if (isPortal) {
  42. setPortal(prev => ({ ...prev, [field]: data.url }));
  43. } else {
  44. setIsEditing(prev => ({ ...prev, [field]: data.url }));
  45. }
  46. }
  47. setUploading(prev => ({ ...prev, [field]: false }));
  48. };
  49. const handleUploadExtraImage = async (e: React.ChangeEvent<HTMLInputElement>) => {
  50. const files = e.target.files;
  51. if (!files || files.length === 0) return;
  52. setUploading(prev => ({ ...prev, extraImages: true }));
  53. const uploaded: string[] = [];
  54. for (const file of Array.from(files)) {
  55. const formData = new FormData();
  56. formData.append('file', file);
  57. const res = await fetch('/api/upload', { method: 'POST', body: formData });
  58. const data = await res.json();
  59. if (data.url) uploaded.push(data.url);
  60. }
  61. setIsEditing(prev => ({
  62. ...prev,
  63. extraImages: [...(prev?.extraImages || []), ...uploaded],
  64. }));
  65. setUploading(prev => ({ ...prev, extraImages: false }));
  66. // Reset input so the same file can be re-selected if needed
  67. e.target.value = '';
  68. };
  69. const removeExtraImage = (index: number) => {
  70. setIsEditing(prev => ({
  71. ...prev,
  72. extraImages: (prev?.extraImages || []).filter((_, i) => i !== index),
  73. }));
  74. };
  75. const handleSaveCard = async () => {
  76. if (!isEditing) return;
  77. const generateSafeId = () => 'card-' + Date.now().toString(36) + Math.random().toString(36).substring(2);
  78. const newCard = { ...isEditing, id: isEditing.id || generateSafeId() } as Card;
  79. await fetch('/api/cards', {
  80. method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(newCard)
  81. });
  82. setCards(prev => {
  83. const exists = prev.find(c => c.id === newCard.id);
  84. return exists ? prev.map(c => c.id === newCard.id ? newCard : c) : [...prev, newCard];
  85. });
  86. setIsEditing(null);
  87. };
  88. const handleDeleteCard = (id: string) => {
  89. // Replace window.confirm with our custom dialog
  90. setConfirmDialog({
  91. message: 'Are you sure you want to delete this card? This action cannot be undone.',
  92. onConfirm: async () => {
  93. await fetch(`/api/cards?id=${id}`, { method: 'DELETE' });
  94. setCards(prev => prev.filter(c => c.id !== id));
  95. setConfirmDialog(null);
  96. showToast('Card successfully deleted.');
  97. }
  98. });
  99. };
  100. const moveCard = async (index: number, direction: 'up' | 'down') => {
  101. const newCards = [...cards];
  102. if (direction === 'up' && index > 0) {
  103. [newCards[index - 1], newCards[index]] = [newCards[index], newCards[index - 1]];
  104. } else if (direction === 'down' && index < newCards.length - 1) {
  105. [newCards[index + 1], newCards[index]] = [newCards[index], newCards[index + 1]];
  106. } else {
  107. return; // Do nothing if trying to move out of bounds
  108. }
  109. // Recalculate displayOrder for the whole array
  110. const updatedCards = newCards.map((c, i) => ({ ...c, displayOrder: i }));
  111. // Optimistically update the UI
  112. setCards(updatedCards);
  113. // Persist the new order to the backend
  114. await fetch('/api/cards', {
  115. method: 'PUT',
  116. headers: { 'Content-Type': 'application/json' },
  117. body: JSON.stringify(updatedCards)
  118. });
  119. };
  120. const handleSavePortal = async () => {
  121. setSavingPortal(true);
  122. await fetch('/api/portals', {
  123. method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(portal)
  124. });
  125. setSavingPortal(false);
  126. showToast('Portal settings saved successfully!'); // Replaced window.alert
  127. };
  128. // Shared Input Classes for high contrast
  129. const inputClasses = "w-full border border-gray-300 p-2.5 rounded-lg outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900 placeholder-gray-400";
  130. return (
  131. <div className="min-h-screen bg-gray-50 font-sans pb-12">
  132. {/* Top Header */}
  133. <div className="bg-blue-900 text-white shadow-md py-6 px-4">
  134. <div className="max-w-5xl mx-auto flex justify-between items-center">
  135. <div>
  136. <h1 className="text-2xl font-bold">Captive Portal CMS</h1>
  137. <p className="text-sm text-blue-200">Local Administration</p>
  138. </div>
  139. <a href="/" target="_blank" className="bg-blue-800 hover:bg-blue-700 text-white px-4 py-2 rounded text-sm transition-colors">
  140. View Live Portal ↗
  141. </a>
  142. </div>
  143. </div>
  144. <div className="max-w-5xl mx-auto mt-8 px-4">
  145. {/* Tab Navigation */}
  146. <div className="flex space-x-2 mb-6">
  147. <button onClick={() => { console.log('[ADMIN] click: cards tab'); setActiveTab('cards'); }} className={`px-6 py-3 rounded-t-lg font-bold transition-colors ${activeTab === 'cards' ? 'bg-white text-blue-700 border-t-4 border-blue-600 shadow-sm' : 'bg-gray-200 text-gray-600 hover:bg-gray-300'}`}>
  148. Manage Cards
  149. </button>
  150. <button onClick={() => { console.log('[ADMIN] click: settings tab'); setActiveTab('settings'); }} className={`px-6 py-3 rounded-t-lg font-bold transition-colors ${activeTab === 'settings' ? 'bg-white text-blue-700 border-t-4 border-blue-600 shadow-sm' : 'bg-gray-200 text-gray-600 hover:bg-gray-300'}`}>
  151. Portal Settings
  152. </button>
  153. </div>
  154. <div className="bg-white rounded-b-xl rounded-tr-xl shadow-sm border border-gray-200 overflow-hidden min-h-[500px]">
  155. {/* TAB: CARDS */}
  156. {activeTab === 'cards' && (
  157. <div className="p-6 md:p-8">
  158. <div className="flex justify-between items-center mb-8 border-b pb-4">
  159. <h2 className="text-xl font-bold text-gray-800">Card Grid</h2>
  160. <button onClick={() => setIsEditing({ title: '', cardType: 'INFO_PAGE', displayOrder: cards.length })} className="bg-blue-600 text-white px-5 py-2.5 rounded-lg shadow-sm hover:bg-blue-700 font-medium">
  161. + Add New Card
  162. </button>
  163. </div>
  164. <div className="space-y-3 mb-8">
  165. {cards.length === 0 && <p className="text-gray-500 italic text-center py-8">No cards available. Create one to get started.</p>}
  166. {cards.map((card, idx) => (
  167. // CHANGED: flex-col on mobile, flex-row on sm+, added gap-4 for mobile spacing
  168. <div key={card.id} className="flex flex-col sm:flex-row sm:items-center justify-between p-4 border rounded-lg bg-gray-50 hover:bg-gray-100 transition-colors gap-4">
  169. <div className="flex items-center gap-4">
  170. {card.imageUrl ? <img src={card.imageUrl} className="w-16 h-16 object-cover rounded-md shadow-sm shrink-0" alt="" /> : <div className="w-16 h-16 bg-gray-200 rounded-md shadow-sm flex items-center justify-center text-gray-400 text-xs shrink-0">No Image</div>}
  171. <div>
  172. <span className="font-semibold text-gray-800 block">{card.title}</span>
  173. <span className="text-xs text-gray-500 uppercase tracking-wider">{card.cardType}</span>
  174. </div>
  175. </div>
  176. {/* CHANGED: flex-wrap to ensure buttons don't overflow on small screens, w-full on mobile */}
  177. <div className="flex flex-wrap items-center gap-2 w-full sm:w-auto justify-end">
  178. <button onClick={() => moveCard(idx, 'up')} className="p-2 text-gray-500 hover:text-gray-800 hover:bg-gray-200 rounded" title="Move Up">↑</button>
  179. <button onClick={() => moveCard(idx, 'down')} className="p-2 text-gray-500 hover:text-gray-800 hover:bg-gray-200 rounded" title="Move Down">↓</button>
  180. <div className="w-px h-6 bg-gray-300 mx-1 hidden sm:block"></div>
  181. <button onClick={() => setIsEditing(card)} className="px-4 py-2 text-blue-600 hover:bg-blue-50 rounded font-medium">Edit</button>
  182. <button onClick={() => handleDeleteCard(card.id)} className="px-4 py-2 text-red-600 hover:bg-red-50 rounded font-medium">Delete</button>
  183. </div>
  184. </div>
  185. ))}
  186. </div>
  187. </div>
  188. )}
  189. {/* TAB: SETTINGS */}
  190. {activeTab === 'settings' && (
  191. <div className="p-6 md:p-8">
  192. <h2 className="text-xl font-bold text-gray-800 mb-8 border-b pb-4">Global Portal Settings</h2>
  193. <div className="grid grid-cols-1 md:grid-cols-2 gap-10">
  194. <div className="space-y-6">
  195. <div>
  196. <label className="block text-sm font-semibold text-gray-700 mb-1">Portal Title</label>
  197. <input type="text" value={portal.title || ''} onChange={e => setPortal({...portal, title: e.target.value})} className={inputClasses} />
  198. </div>
  199. <div>
  200. <label className="block text-sm font-semibold text-gray-700 mb-1">Welcome Text</label>
  201. <textarea value={portal.welcomeText || ''} onChange={e => setPortal({...portal, welcomeText: e.target.value})} className={`${inputClasses} h-32 resize-none`} />
  202. </div>
  203. <div className="flex gap-8">
  204. <div>
  205. <label className="block text-sm font-semibold text-gray-700 mb-1">Theme Color</label>
  206. <div className="flex items-center gap-4">
  207. <input type="color" value={portal.themeColor || '#1e3a8a'} onChange={e => setPortal({...portal, themeColor: e.target.value})} className="h-12 w-12 rounded cursor-pointer border-0 p-0" />
  208. <span className="text-gray-900 font-mono font-medium">{portal.themeColor || '#1e3a8a'}</span>
  209. </div>
  210. </div>
  211. {/* NEW: Max Columns Setting updated for 3 */}
  212. <div className="flex-1">
  213. <label className="block text-sm font-semibold text-gray-700 mb-1">Grid Max Columns: {portal.maxGridColumns || 5}</label>
  214. <input
  215. type="range"
  216. min="3"
  217. max="8"
  218. value={portal.maxGridColumns || 5}
  219. onChange={e => setPortal({...portal, maxGridColumns: parseInt(e.target.value)})}
  220. className="w-full mt-3 accent-blue-600"
  221. />
  222. <div className="flex justify-between text-xs text-gray-400 mt-1">
  223. <span>3</span><span>4</span><span>5</span><span>6</span><span>7</span><span>8</span>
  224. </div>
  225. </div>
  226. </div>
  227. </div>
  228. <div className="space-y-6">
  229. {/* Logo Upload with Remove Button */}
  230. <div>
  231. <label className="block text-sm font-semibold text-gray-700 mb-1">Logo Image</label>
  232. <input type="file" accept="image/*" onChange={e => handleUpload(e, 'logoUrl', true)} className="block w-full text-sm text-gray-900 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:bg-gray-100 cursor-pointer" />
  233. {uploading['logoUrl'] && <span className="text-xs text-blue-500">Uploading...</span>}
  234. {portal.logoUrl && (
  235. <div className="mt-2 bg-gray-100 p-4 rounded inline-block relative border">
  236. <img src={portal.logoUrl} className="h-16 object-contain" alt="Logo Preview" />
  237. <button onClick={() => setPortal({...portal, logoUrl: ''})} className="absolute -top-2 -right-2 bg-red-500 text-white w-6 h-6 rounded-full text-xs font-bold hover:bg-red-600 shadow">✕</button>
  238. </div>
  239. )}
  240. </div>
  241. {/* Hero Upload with Remove Button */}
  242. <div>
  243. <label className="block text-sm font-semibold text-gray-700 mb-1">Hero Background Image</label>
  244. <input type="file" accept="image/*" onChange={e => handleUpload(e, 'heroImageUrl', true)} className="block w-full text-sm text-gray-900 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:bg-gray-100 cursor-pointer" />
  245. {uploading['heroImageUrl'] && <span className="text-xs text-blue-500">Uploading...</span>}
  246. {portal.heroImageUrl && (
  247. <div className="mt-2 relative rounded shadow border inline-block w-full">
  248. <img src={portal.heroImageUrl} className="h-32 w-full object-cover rounded" alt="Hero Preview" />
  249. <button onClick={() => setPortal({...portal, heroImageUrl: ''})} className="absolute top-2 right-2 bg-red-500 text-white w-8 h-8 flex items-center justify-center rounded-full text-sm font-bold hover:bg-red-600 shadow-lg">✕</button>
  250. </div>
  251. )}
  252. </div>
  253. <div className="bg-gray-50 p-4 rounded-lg border border-gray-200">
  254. <label className="flex items-center gap-3 cursor-pointer">
  255. <input type="checkbox" checked={!!portal.fadeHeroImage} onChange={e => setPortal({...portal, fadeHeroImage: e.target.checked})} className="w-5 h-5 text-blue-600 rounded" />
  256. <div>
  257. <span className="block text-sm font-semibold text-gray-900">Fade Image into Background Color</span>
  258. <span className="block text-xs text-gray-600">Creates a smooth gradient from the top of the image into the solid theme color at the bottom.</span>
  259. </div>
  260. </label>
  261. </div>
  262. </div>
  263. </div>
  264. <div className="mt-10 pt-6 border-t border-gray-200 flex justify-end">
  265. <button onClick={handleSavePortal} disabled={savingPortal} className="bg-blue-600 text-white px-10 py-3 rounded-lg hover:bg-blue-700 font-bold shadow disabled:opacity-50 transition-colors">
  266. {savingPortal ? 'Saving...' : 'Save Portal Settings'}
  267. </button>
  268. </div>
  269. </div>
  270. )}
  271. </div>
  272. </div>
  273. {/* MODAL FOR EDITING/CREATING CARDS */}
  274. {isEditing && (
  275. <div
  276. className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4 transition-opacity"
  277. onClick={() => setIsEditing(null)} // Click outside to close
  278. >
  279. <div
  280. className="bg-white rounded-2xl w-full max-w-3xl max-h-[90vh] overflow-y-auto shadow-2xl p-6 md:p-8 relative animate-in fade-in zoom-in-95 duration-200"
  281. onClick={(e) => e.stopPropagation()} // Prevent inside clicks from closing
  282. >
  283. <button
  284. onClick={() => setIsEditing(null)}
  285. className="absolute top-6 right-6 text-gray-400 hover:text-gray-900 bg-gray-100 hover:bg-gray-200 rounded-full w-8 h-8 flex items-center justify-center transition-colors"
  286. >
  287. </button>
  288. <h3 className="text-2xl font-bold mb-6 text-gray-900 border-b pb-4">
  289. {isEditing.id ? 'Edit Card' : 'Create New Card'}
  290. </h3>
  291. <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
  292. <div className="space-y-5">
  293. <div>
  294. <label className="block text-sm font-semibold text-gray-800 mb-1">Title</label>
  295. <input type="text" value={isEditing.title || ''} onChange={e => setIsEditing({...isEditing, title: e.target.value})} className={inputClasses} placeholder="e.g., Local History" />
  296. </div>
  297. <div>
  298. <label className="block text-sm font-semibold text-gray-800 mb-1">Card Type</label>
  299. <select value={isEditing.cardType || 'INFO_PAGE'} onChange={e => setIsEditing({...isEditing, cardType: e.target.value as any})} className={inputClasses}>
  300. <option value="INFO_PAGE">Info Page</option>
  301. <option value="IMAGE_GALLERY">Image Gallery</option>
  302. <option value="EXTERNAL_LINK">External Link</option>
  303. </select>
  304. </div>
  305. <div>
  306. <label className="block text-sm font-semibold text-gray-800 mb-1">Short Description</label>
  307. <textarea value={isEditing.shortDescription || ''} onChange={e => setIsEditing({...isEditing, shortDescription: e.target.value})} className={`${inputClasses} h-24 resize-none`} placeholder="Brief summary..." />
  308. </div>
  309. </div>
  310. <div className="space-y-5">
  311. {/* Cover Image */}
  312. <div>
  313. <label className="block text-sm font-semibold text-gray-800 mb-1">
  314. Cover Image <span className="text-gray-400 font-normal text-xs">(shown in grid)</span>
  315. </label>
  316. <div className="border-2 border-dashed border-gray-300 rounded-lg p-3 hover:bg-gray-50 transition-colors">
  317. <input type="file" accept="image/*" onChange={e => handleUpload(e, 'imageUrl')} className="block w-full text-sm text-gray-700 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100 cursor-pointer" />
  318. {uploading['imageUrl'] && <p className="mt-2 text-sm text-blue-600 font-medium">Uploading...</p>}
  319. </div>
  320. {isEditing.imageUrl && (
  321. <div className="mt-3 relative rounded-lg overflow-hidden border border-gray-200 group">
  322. <img src={isEditing.imageUrl} className="w-full h-32 object-cover" alt="Cover preview" />
  323. <button
  324. onClick={() => setIsEditing({...isEditing, imageUrl: ''})}
  325. className="absolute top-2 right-2 bg-red-500 text-white w-8 h-8 rounded-full text-sm font-bold shadow opacity-0 group-hover:opacity-100 transition-opacity hover:bg-red-600"
  326. title="Remove cover image"
  327. >✕</button>
  328. </div>
  329. )}
  330. </div>
  331. {/* Gallery Images */}
  332. <div>
  333. <label className="block text-sm font-semibold text-gray-800 mb-1">
  334. Gallery Images <span className="text-gray-400 font-normal text-xs">(optional, shown in detail modal)</span>
  335. </label>
  336. <div className="border-2 border-dashed border-gray-300 rounded-lg p-3 hover:bg-gray-50 transition-colors">
  337. <input
  338. type="file"
  339. accept="image/*"
  340. multiple
  341. onChange={handleUploadExtraImage}
  342. className="block w-full text-sm text-gray-700 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-purple-50 file:text-purple-700 hover:file:bg-purple-100 cursor-pointer"
  343. />
  344. {uploading['extraImages'] && <p className="mt-2 text-sm text-purple-600 font-medium">Uploading...</p>}
  345. </div>
  346. {/* Thumbnails strip */}
  347. {(isEditing.extraImages || []).length > 0 && (
  348. <div className="mt-3 flex flex-wrap gap-2">
  349. {(isEditing.extraImages || []).map((url, i) => (
  350. <div key={url + i} className="relative group w-20 h-20 rounded-lg overflow-hidden border border-gray-200 shrink-0">
  351. <img src={url} className="w-full h-full object-cover" alt={`Gallery ${i + 1}`} />
  352. <div className="absolute inset-0 bg-black/30 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
  353. <button
  354. onClick={() => removeExtraImage(i)}
  355. className="bg-red-500 text-white w-7 h-7 rounded-full text-xs font-bold hover:bg-red-600"
  356. title="Remove"
  357. >✕</button>
  358. </div>
  359. <span className="absolute bottom-0 left-0 right-0 text-center text-white text-[10px] bg-black/50 pb-0.5">{i + 1}</span>
  360. </div>
  361. ))}
  362. </div>
  363. )}
  364. </div>
  365. </div>
  366. </div>
  367. <div className="flex gap-3 pt-8 mt-6 border-t border-gray-200 justify-end">
  368. <button onClick={() => setIsEditing(null)} className="px-5 py-2.5 text-gray-700 hover:bg-gray-100 rounded-lg font-medium transition-colors">
  369. Cancel
  370. </button>
  371. <button onClick={handleSaveCard} className="bg-green-600 text-white px-8 py-2.5 rounded-lg hover:bg-green-700 font-medium shadow-sm transition-colors">
  372. Save Card
  373. </button>
  374. </div>
  375. </div>
  376. </div>
  377. )}
  378. {/* CUSTOM CONFIRM DIALOG */}
  379. {confirmDialog && (
  380. <div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 animate-in fade-in duration-200">
  381. <div className="bg-white rounded-xl shadow-2xl p-6 max-w-sm w-full animate-in zoom-in-95">
  382. <h3 className="text-xl font-bold text-gray-900 mb-2">Confirm Action</h3>
  383. <p className="text-gray-600 mb-6 leading-relaxed">{confirmDialog.message}</p>
  384. <div className="flex justify-end gap-3">
  385. <button
  386. onClick={() => setConfirmDialog(null)}
  387. className="px-4 py-2.5 text-gray-700 hover:bg-gray-100 rounded-lg font-medium transition-colors"
  388. >
  389. Cancel
  390. </button>
  391. <button
  392. onClick={confirmDialog.onConfirm}
  393. className="px-6 py-2.5 bg-red-600 text-white rounded-lg hover:bg-red-700 font-medium transition-colors shadow-sm"
  394. >
  395. Delete
  396. </button>
  397. </div>
  398. </div>
  399. </div>
  400. )}
  401. {/* CUSTOM TOAST NOTIFICATION */}
  402. {toast && (
  403. <div className="fixed bottom-6 right-6 z-[70] bg-gray-900 text-white px-6 py-4 rounded-lg shadow-2xl flex items-center gap-3 animate-in slide-in-from-bottom-5 fade-in duration-300">
  404. <div className="w-6 h-6 bg-green-500 rounded-full flex items-center justify-center text-gray-900 font-bold text-sm">
  405. </div>
  406. <span className="font-medium">{toast}</span>
  407. </div>
  408. )}
  409. </div>
  410. );
  411. }