@devvistatech/devvista-kit 0.0.12 → 0.0.13

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 (84) hide show
  1. package/README.md +40 -0
  2. package/app/ClientLayout.tsx +66 -0
  3. package/app/about/page.tsx +11 -248
  4. package/app/adRequest/page.tsx +101 -25
  5. package/app/admin-profile/page.tsx +123 -0
  6. package/app/analytics/page.tsx +41 -5
  7. package/app/api/about/route.ts +2 -18
  8. package/app/api/adRequest/route.ts +7 -27
  9. package/app/api/analytics/[reportType]/route.ts +1 -64
  10. package/app/api/bio/route.ts +1 -17
  11. package/app/api/blog/route.ts +1 -19
  12. package/app/api/contacts/route.ts +1 -46
  13. package/app/api/files/route.ts +1 -15
  14. package/app/api/gallery-data/route.ts +53 -61
  15. package/app/api/schedule/route.ts +5 -21
  16. package/app/api/signup/route.ts +129 -0
  17. package/app/api/sync-user/route.ts +268 -94
  18. package/app/api/verify-admin/route.ts +46 -0
  19. package/app/blog/[id]/page.tsx +71 -52
  20. package/app/blog/page.tsx +43 -10
  21. package/app/favicon.ico +0 -0
  22. package/app/gallery/page.tsx +27 -6
  23. package/app/layout.tsx +31 -82
  24. package/app/page.tsx +20 -311
  25. package/app/products/constants/product.ts +27 -0
  26. package/app/products/page.tsx +296 -0
  27. package/app/products/productOne/page.tsx +266 -0
  28. package/app/products/productTwo/page.tsx +272 -0
  29. package/app/schedule/page.tsx +78 -40
  30. package/bin/init.js +0 -12
  31. package/components/addOns/functional/ClassList.tsx +21 -17
  32. package/components/addOns/functional/ProductList.tsx +1027 -0
  33. package/components/addOns/functional/aboutSections/AboutSection.tsx +107 -70
  34. package/components/addOns/functional/aboutSections/constants/aboutSection.ts +9 -4
  35. package/components/addOns/functional/banner/Banner.tsx +150 -0
  36. package/components/addOns/functional/banner/BannerDashboard.tsx +283 -0
  37. package/components/addOns/functional/bioSections/BioEditor.tsx +471 -0
  38. package/components/addOns/functional/bioSections/constants/bioEditor.ts +36 -0
  39. package/components/addOns/functional/blogSections/BlogDashboard.tsx +1 -1
  40. package/components/addOns/functional/blogSections/BlogFormPopUp.tsx +2 -1
  41. package/components/addOns/functional/{ImageDescCarousel.tsx → carousels/ImageDescCarousel.tsx} +166 -57
  42. package/components/addOns/functional/carousels/ProductDescCarousel.tsx +1129 -0
  43. package/components/addOns/functional/{ScheduleCarousel.tsx → carousels/ScheduleCarousel.tsx} +110 -50
  44. package/components/addOns/functional/carousels/constants.ts/productDescCarousel.ts +197 -0
  45. package/components/addOns/functional/carousels/constants.ts/scheduleCarousel.ts +20 -0
  46. package/components/addOns/functional/contactsDashboard/ContactsDashboard.tsx +1 -1
  47. package/components/addOns/functional/fileUploaders/FileUploader.tsx +437 -0
  48. package/components/addOns/functional/fileUploaders/constants/fileUploader.ts +45 -0
  49. package/components/addOns/functional/galleries/GalleryComplex.tsx +468 -267
  50. package/components/addOns/functional/galleries/GallerySimple.tsx +78 -50
  51. package/components/addOns/functional/galleries/ThreeSetGallery.tsx +260 -0
  52. package/components/addOns/functional/schedules/ScheduleGridOne.tsx +22 -8
  53. package/components/addOns/functional/schedules/ScheduleGridTwo.tsx +12 -7
  54. package/components/addOns/functional/schedules/ScheduleGridTwoBasic.tsx +12 -7
  55. package/components/addOns/non-functional/SampleCarousel.tsx +3 -3
  56. package/components/addOns/non-functional/ThreeSetGallery.tsx +3 -3
  57. package/components/addOns/non-functional/featureSections/FeaturesSection.tsx +74 -0
  58. package/components/addOns/non-functional/featureSections/constants/featuresSection.ts +30 -0
  59. package/components/addOns/non-functional/{Heros/HeroSection.tsx → heros/HomeHero.tsx} +17 -15
  60. package/components/addOns/non-functional/heros/ProductHero.tsx +111 -0
  61. package/components/addOns/non-functional/heros/constants/hero.ts +62 -0
  62. package/components/addOns/non-functional/imageCarousels/ProductSlider.tsx +6 -6
  63. package/components/addOns/non-functional/imageCarousels/ProgramCarousel.tsx +10 -10
  64. package/components/footers/footer.tsx +161 -198
  65. package/components/other/admin-menu.tsx +1 -1
  66. package/lib/auth/auth-context.tsx +225 -0
  67. package/lib/auth/auth-utils.tsx +30 -0
  68. package/lib/constants/adRequest.ts +199 -56
  69. package/lib/constants/admin-profile.ts +12 -0
  70. package/lib/constants/page.ts +15 -15
  71. package/lib/google/google-analytics-tracking.tsx +44 -0
  72. package/lib/types.ts +235 -0
  73. package/lib/utils/compressImage.tsx +32 -0
  74. package/middleware.ts +9 -5
  75. package/next.config.js +1 -1
  76. package/package.json +3 -2
  77. package/public/images/test.png +0 -0
  78. package/components/addOns/functional/BioEditor.tsx +0 -447
  79. package/components/addOns/functional/FileUploader.tsx +0 -295
  80. package/components/addOns/non-functional/FeaturesSection.tsx +0 -63
  81. package/components/types.ts +0 -50
  82. package/lib/auth-context.tsx +0 -131
  83. package/lib/verify-user.ts +0 -118
  84. /package/lib/{google-analytics.tsx → google/google-analytics.tsx} +0 -0
@@ -0,0 +1,1129 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect, useRef } from "react";
4
+ import {
5
+ ActionButton,
6
+ EditIconButton,
7
+ TrashIconButton,
8
+ CloseButton,
9
+ SubmitButton,
10
+ CancelButton,
11
+ NextButton,
12
+ PrevButton,
13
+ FilterButton,
14
+ DeleteButton,
15
+ } from "@/components/other/button";
16
+ import { Card } from "@/components/other/card";
17
+ import Image from "next/image";
18
+ import { Upload, X } from "lucide-react";
19
+ import { motion, useScroll, useTransform, Variants } from "framer-motion";
20
+ import { useAuth, useUser } from "@clerk/nextjs";
21
+ import { isAdminUser } from "@/lib/auth/auth-utils";
22
+ import { StrapiUser, UploadedImage, EditFormState, Category, FormState } from "@/lib/types";
23
+ import { compressImage } from "@/lib/utils/compressImage";
24
+ import { PRODUCT_DESC } from "./constants.ts/productDescCarousel";
25
+
26
+ const getValidCategory = (category: string | undefined, fallback: Category = "none"): Category => {
27
+ const VALID_CATEGORIES: Category[] = [
28
+ "none",
29
+ "indoor",
30
+ "outdoor",
31
+ "commercial",
32
+ ];
33
+ return category && VALID_CATEGORIES.includes(category as Category) ? (category as Category) : fallback;
34
+ };
35
+
36
+ // Validate subcategory input
37
+ const validateSubCategory = (subCategory: string): string | null => {
38
+ if (!subCategory.trim()) return PRODUCT_DESC.ERRORS.SUBCATEGORY_EMPTY;
39
+ if (subCategory.length > 50) return PRODUCT_DESC.ERRORS.SUBCATEGORY_TOO_LONG;
40
+ if (!/^[a-zA-Z0-9\s\-]+$/.test(subCategory)) return PRODUCT_DESC.ERRORS.SUBCATEGORY_INVALID;
41
+ return null;
42
+ };
43
+
44
+ interface ProductDescCarouselProps {
45
+ user: StrapiUser | null;
46
+ uploadedImages: UploadedImage[];
47
+ setUploadedImages: (images: UploadedImage[]) => void;
48
+ error: string | null;
49
+ setError: (error: string | null) => void;
50
+ isLoading: boolean;
51
+ setIsLoading: (isLoading: boolean) => void;
52
+ handleImageUpload: (
53
+ e: React.FormEvent<HTMLFormElement>,
54
+ file: File | null,
55
+ title: string,
56
+ description: string,
57
+ category: Category,
58
+ subCategory: string,
59
+ favorite: boolean
60
+ ) => Promise<void>;
61
+ handleDeleteImage: (documentId: string) => Promise<void>;
62
+ visibleCategories?: string[];
63
+ fixedCategory?: Category;
64
+ visibleSubCategories: string[];
65
+ setVisibleSubCategories: (subCategories: string[]) => void;
66
+ }
67
+
68
+ interface SlideshowProps {
69
+ images: UploadedImage[];
70
+ altPrefix: string;
71
+ currentSlide: number;
72
+ setCurrentSlide: React.Dispatch<React.SetStateAction<number>>;
73
+ isAdmin: boolean;
74
+ handleImageClick: (image: UploadedImage) => void;
75
+ handleEditImage: (image: UploadedImage) => void;
76
+ handleDeleteImage: (documentId: string) => void;
77
+ }
78
+
79
+ interface ModalState {
80
+ upload: boolean;
81
+ edit: boolean;
82
+ delete: boolean;
83
+ view: boolean;
84
+ documentIdToDelete: string | null;
85
+ selectedImage: UploadedImage | null;
86
+ }
87
+
88
+ const modalVariants: Variants = {
89
+ hidden: { opacity: 0, y: "100vh" },
90
+ visible: { opacity: 1, y: 0, transition: { duration: 0.5, ease: [0.16, 1, 0.3, 1] } },
91
+ exit: { opacity: 0, y: "100vh", transition: { duration: 0.3, ease: "easeIn" } },
92
+ };
93
+
94
+ // Slideshow Component
95
+ const Slideshow = ({
96
+ images,
97
+ altPrefix,
98
+ currentSlide,
99
+ setCurrentSlide,
100
+ isAdmin,
101
+ handleImageClick,
102
+ handleEditImage,
103
+ handleDeleteImage,
104
+ }: SlideshowProps) => {
105
+ const slideshowRef = useRef<HTMLDivElement>(null);
106
+
107
+ const goToPrev = () => setCurrentSlide((prev) => (prev - 1 + images.length) % images.length);
108
+ const goToNext = () => setCurrentSlide((prev) => (prev + 1) % images.length);
109
+
110
+ if (!images.length || !images[currentSlide]) {
111
+ return (
112
+ <Card
113
+ className="relative h-[40vh] min-h-[400px] sm:h-[50vh] lg:h-[900px] rounded-3xl overflow-hidden bg-white/10 backdrop-blur-md shadow-2xl w-full mx-auto max-w-3xl md:max-w-none md:mx-0 flex items-center justify-center supports-[not(backdrop-filter:blur(10px))]:bg-white/20"
114
+ ref={slideshowRef}
115
+ >
116
+ <p className="text-gray-600 text-lg">{PRODUCT_DESC.UI.NO_IMAGES_MESSAGE}</p>
117
+ </Card>
118
+ );
119
+ }
120
+
121
+ return (
122
+ <div
123
+ className="relative h-[40vh] min-h-[400px] sm:h-[50vh] lg:h-[900px] rounded-3xl overflow-hidden bg-white/10 backdrop-blur-md shadow-2xl w-full mx-auto max-w-3xl md:max-w-none md:mx-0 supports-[not(backdrop-filter:blur(10px))]:bg-white/20"
124
+ ref={slideshowRef}
125
+ >
126
+ <div
127
+ className="relative w-full h-full cursor-pointer"
128
+ onClick={() => handleImageClick(images[currentSlide])}
129
+ >
130
+ <Image
131
+ src={images[currentSlide].url}
132
+ alt={images[currentSlide].title || altPrefix}
133
+ fill
134
+ className="object-cover rounded-2xl transition-transform duration-700 hover:scale-110"
135
+ sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
136
+ loading="lazy"
137
+ quality={85}
138
+ />
139
+ <div className="absolute inset-0 bg-gradient-to-t from-black/40 to-transparent opacity-0 hover:opacity-100 transition-opacity duration-500" />
140
+ <div className="absolute bottom-4 left-4 text-white opacity-90 transition-opacity duration-500">
141
+ <span className="text-lg sm:text-xl lg:text-2xl font-semibold drop-shadow-md">
142
+ {altPrefix}
143
+ </span>
144
+ </div>
145
+ {isAdmin && (
146
+ <div className="absolute top-2 right-2 flex space-x-2">
147
+ <EditIconButton
148
+ onClick={(e) => {
149
+ e.stopPropagation();
150
+ handleEditImage(images[currentSlide]);
151
+ }}
152
+ />
153
+ <TrashIconButton
154
+ onClick={(e) => {
155
+ e.stopPropagation();
156
+ handleDeleteImage(images[currentSlide].documentId);
157
+ }}
158
+ />
159
+ </div>
160
+ )}
161
+ </div>
162
+ {images.length > 1 && (
163
+ <>
164
+ <PrevButton
165
+ onClick={(e) => {
166
+ e.stopPropagation();
167
+ goToPrev();
168
+ }}
169
+ />
170
+ <NextButton
171
+ onClick={(e) => {
172
+ e.stopPropagation();
173
+ goToNext();
174
+ }}
175
+ />
176
+ </>
177
+ )}
178
+ </div>
179
+ );
180
+ };
181
+
182
+ // Upload Modal Component
183
+ const UploadModal = ({
184
+ isOpen,
185
+ onClose,
186
+ uploadForm,
187
+ setUploadForm,
188
+ isSubmitting,
189
+ setIsSubmitting,
190
+ handleImageUpload,
191
+ error,
192
+ setError,
193
+ visibleCategories = ["none"],
194
+ visibleSubCategories = [],
195
+ setVisibleSubCategories,
196
+ }: {
197
+ isOpen: boolean;
198
+ onClose: () => void;
199
+ uploadForm: FormState;
200
+ setUploadForm: React.Dispatch<React.SetStateAction<FormState>>;
201
+ isSubmitting: boolean;
202
+ setIsSubmitting: React.Dispatch<React.SetStateAction<boolean>>;
203
+ handleImageUpload: (
204
+ e: React.FormEvent<HTMLFormElement>,
205
+ file: File | null,
206
+ title: string,
207
+ description: string,
208
+ category: Category,
209
+ subCategory: string,
210
+ favorite: boolean
211
+ ) => Promise<void>;
212
+ error: string | null;
213
+ setError: (error: string | null) => void;
214
+ visibleCategories?: string[];
215
+ visibleSubCategories?: string[];
216
+ setVisibleSubCategories: (subCategories: string[]) => void;
217
+ }) => {
218
+ const [subCategoryError, setSubCategoryError] = useState<string | null>(null);
219
+
220
+ if (!isOpen) return null;
221
+
222
+ const handleSubCategoryChange = (value: string) => {
223
+ setUploadForm({ ...uploadForm, subCategory: value });
224
+ const error = validateSubCategory(value);
225
+ setSubCategoryError(error);
226
+ };
227
+
228
+ const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
229
+ e.preventDefault();
230
+ const error = validateSubCategory(uploadForm.subCategory);
231
+ if (error) {
232
+ setSubCategoryError(error);
233
+ return;
234
+ }
235
+ setIsSubmitting(true);
236
+ try {
237
+ const compressedFile = uploadForm.file ? await compressImage(uploadForm.file) : null;
238
+ await handleImageUpload(
239
+ e,
240
+ compressedFile,
241
+ uploadForm.title,
242
+ uploadForm.description,
243
+ uploadForm.category,
244
+ uploadForm.subCategory,
245
+ uploadForm.favorite || false
246
+ );
247
+ if (uploadForm.subCategory && !visibleSubCategories.includes(uploadForm.subCategory)) {
248
+ setVisibleSubCategories([...visibleSubCategories, uploadForm.subCategory]);
249
+ }
250
+ onClose();
251
+ } catch (err) {
252
+ console.error("Upload error in UploadModal:", { error: err instanceof Error ? err.message : "Unknown error", timestamp: new Date().toISOString() });
253
+ setError(err instanceof Error ? err.message : PRODUCT_DESC.ERRORS.UPLOAD_FAILED);
254
+ } finally {
255
+ setIsSubmitting(false);
256
+ }
257
+ };
258
+
259
+ return (
260
+ <motion.div
261
+ variants={modalVariants}
262
+ initial="hidden"
263
+ animate="visible"
264
+ exit="exit"
265
+ className="fixed inset-0 bg-black/90 flex items-center justify-center z-[10000] p-4 isolate"
266
+ onClick={onClose}
267
+ aria-modal="true"
268
+ role="dialog"
269
+ >
270
+ <div
271
+ className="relative max-w-5xl w-full mx-4 p-6 bg-gray-800/50 border border-gray-700/50 rounded-lg shadow-lg max-h-[90vh] overflow-y-auto"
272
+ onClick={(e) => e.stopPropagation()}
273
+ >
274
+ <CloseButton onClick={onClose}>
275
+ <X className="h-6 w-6 sm:h-8 sm:w-8" />
276
+ </CloseButton>
277
+ <h3 className="text-xl font-bold text-white mb-4">{PRODUCT_DESC.UI.UPLOAD_MODAL_HEADING}</h3>
278
+ <form onSubmit={handleSubmit} className="space-y-4">
279
+ <div>
280
+ <label htmlFor="image-upload" className="block text-sm font-medium text-gray-300 mb-1">
281
+ {PRODUCT_DESC.UI.CHOOSE_IMAGE_LABEL}
282
+ </label>
283
+ <input
284
+ type="file"
285
+ accept="image/jpeg,image/png,image/gif"
286
+ onChange={(e) => setUploadForm({ ...uploadForm, file: e.target.files?.[0] || null })}
287
+ className="w-full p-2 rounded bg-gray-700 text-white border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
288
+ id="image-upload"
289
+ required
290
+ />
291
+ {uploadForm.file && (
292
+ <p className="mt-2 text-gray-300 text-sm">{PRODUCT_DESC.UI.SELECTED_IMAGE_TEXT.replace("${formImage.name}", uploadForm.file.name)}</p>
293
+ )}
294
+ </div>
295
+ <div>
296
+ <label htmlFor="image-title" className="block text-sm font-medium text-gray-300 mb-1">
297
+ {PRODUCT_DESC.UI.TITLE_LABEL}
298
+ </label>
299
+ <input
300
+ id="image-title"
301
+ value={uploadForm.title}
302
+ onChange={(e) => setUploadForm({ ...uploadForm, title: e.target.value })}
303
+ placeholder={PRODUCT_DESC.UI.TITLE_PLACEHOLDER}
304
+ className="w-full p-2 rounded bg-gray-700 text-white border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
305
+ required
306
+ />
307
+ </div>
308
+ <div>
309
+ <label htmlFor="image-description" className="block text-sm font-medium text-gray-300 mb-1">
310
+ {PRODUCT_DESC.UI.DESCRIPTION_LABEL}
311
+ </label>
312
+ <textarea
313
+ id="image-description"
314
+ value={uploadForm.description}
315
+ onChange={(e) => setUploadForm({ ...uploadForm, description: e.target.value })}
316
+ placeholder={PRODUCT_DESC.UI.DESCRIPTION_PLACEHOLDER}
317
+ className="w-full p-2 rounded bg-gray-700 text-white border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm min-h-[80px]"
318
+ required
319
+ />
320
+ </div>
321
+ <div>
322
+ <label htmlFor="image-category" className="block text-sm font-medium text-gray-300 mb-1">
323
+ {PRODUCT_DESC.UI.CATEGORY_LABEL}
324
+ </label>
325
+ <select
326
+ id="image-category"
327
+ value={uploadForm.category}
328
+ onChange={(e) =>
329
+ setUploadForm({
330
+ ...uploadForm,
331
+ category: e.target.value as Category,
332
+ })
333
+ }
334
+ className="w-full p-2 rounded bg-gray-700 text-white border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
335
+ required
336
+ >
337
+ {visibleCategories.map((category) => (
338
+ <option key={category} value={category}>
339
+ {category === "none"
340
+ ? PRODUCT_DESC.UI.CATEGORY_NONE
341
+ : category
342
+ .split("-")
343
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
344
+ .join(" ")}
345
+ </option>
346
+ ))}
347
+ </select>
348
+ </div>
349
+ <div>
350
+ <label htmlFor="image-subcategory" className="block text-sm font-medium text-gray-300 mb-1">
351
+ {PRODUCT_DESC.UI.SUBCATEGORY_LABEL}
352
+ </label>
353
+ <input
354
+ id="image-subcategory"
355
+ type="text"
356
+ value={uploadForm.subCategory}
357
+ onChange={(e) => handleSubCategoryChange(e.target.value)}
358
+ placeholder={PRODUCT_DESC.UI.SUBCATEGORY_PLACEHOLDER}
359
+ className="w-full p-2 pr-8 rounded bg-gray-700 text-white border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
360
+ autoComplete="off"
361
+ name="product-subcategory"
362
+ />
363
+ {subCategoryError && <p className="text-red-400 text-sm font-medium mt-1 w-full text-left">{subCategoryError}</p>}
364
+ </div>
365
+ <div className="flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-3">
366
+ <SubmitButton
367
+ type="submit"
368
+ disabled={isSubmitting || !uploadForm.file || !uploadForm.title.trim() || !uploadForm.description.trim() || !uploadForm.category || !!subCategoryError}
369
+ >
370
+ {isSubmitting ? PRODUCT_DESC.BUTTONS.UPLOADING_BUTTON : PRODUCT_DESC.BUTTONS.UPLOAD_BUTTON}
371
+ </SubmitButton>
372
+ <CancelButton onClick={onClose} />
373
+ </div>
374
+ {error && <p className="text-red-400 text-sm font-medium">{error}</p>}
375
+ </form>
376
+ </div>
377
+ </motion.div>
378
+ );
379
+ };
380
+
381
+ // Edit Modal Component
382
+ const EditModal = ({
383
+ isOpen,
384
+ onClose,
385
+ editForm,
386
+ setEditForm,
387
+ isSubmitting,
388
+ setIsSubmitting,
389
+ handleEditImage,
390
+ error,
391
+ setError,
392
+ visibleCategories = ["none"],
393
+ visibleSubCategories = [],
394
+ setVisibleSubCategories,
395
+ }: {
396
+ isOpen: boolean;
397
+ onClose: () => void;
398
+ editForm: EditFormState;
399
+ setEditForm: React.Dispatch<React.SetStateAction<EditFormState>>;
400
+ isSubmitting: boolean;
401
+ setIsSubmitting: React.Dispatch<React.SetStateAction<boolean>>;
402
+ handleEditImage: (e: React.FormEvent<HTMLFormElement>, subCategory: string) => Promise<void>;
403
+ error: string | null;
404
+ setError: (error: string | null) => void;
405
+ visibleCategories?: string[];
406
+ visibleSubCategories?: string[];
407
+ setVisibleSubCategories: (subCategories: string[]) => void;
408
+ }) => {
409
+ const [subCategoryError, setSubCategoryError] = useState<string | null>(null);
410
+
411
+ if (!isOpen) return null;
412
+
413
+ const handleSubCategoryChange = (value: string) => {
414
+ setEditForm({ ...editForm, subCategory: value });
415
+ const error = validateSubCategory(value);
416
+ setSubCategoryError(error);
417
+ };
418
+
419
+ const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
420
+ e.preventDefault();
421
+ const error = validateSubCategory(editForm.subCategory);
422
+ if (error) {
423
+ setSubCategoryError(error);
424
+ return;
425
+ }
426
+ setIsSubmitting(true);
427
+ try {
428
+ await handleEditImage(e, editForm.subCategory);
429
+ if (editForm.subCategory && !visibleSubCategories.includes(editForm.subCategory)) {
430
+ setVisibleSubCategories([...visibleSubCategories, editForm.subCategory]);
431
+ }
432
+ onClose();
433
+ } catch (err) {
434
+ console.error("Edit error in EditModal:", { error: err instanceof Error ? err.message : "Unknown error", timestamp: new Date().toISOString() });
435
+ setError(err instanceof Error ? err.message : PRODUCT_DESC.ERRORS.EDIT_FAILED);
436
+ } finally {
437
+ setIsSubmitting(false);
438
+ }
439
+ };
440
+
441
+ return (
442
+ <motion.div
443
+ variants={modalVariants}
444
+ initial="hidden"
445
+ animate="visible"
446
+ exit="exit"
447
+ className="fixed inset-0 bg-black/90 flex items-center justify-center z-[10000] p-4 isolate"
448
+ onClick={onClose}
449
+ aria-modal="true"
450
+ role="dialog"
451
+ >
452
+ <div
453
+ className="relative max-w-5xl w-full mx-4 p-6 bg-gray-800/50 border border-gray-700/50 rounded-lg shadow-lg max-h-[90vh] overflow-y-auto"
454
+ onClick={(e) => e.stopPropagation()}
455
+ >
456
+ <CloseButton onClick={onClose}>
457
+ <X className="h-6 w-6 sm:h-8 sm:w-8" />
458
+ </CloseButton>
459
+ <h3 className="text-xl font-bold text-white mb-4">{PRODUCT_DESC.UI.EDIT_MODAL_HEADING}</h3>
460
+ <form onSubmit={handleSubmit} className="space-y-4">
461
+ <div>
462
+ <label htmlFor="edit-image" className="block text-sm font-medium text-gray-300 mb-1">
463
+ {PRODUCT_DESC.UI.REPLACE_IMAGE_LABEL}
464
+ </label>
465
+ <input
466
+ id="edit-image"
467
+ type="file"
468
+ accept="image/jpeg,image/png,image/gif"
469
+ onChange={(e) => setEditForm({ ...editForm, file: e.target.files?.[0] || null })}
470
+ className="w-full p-2 rounded bg-gray-700 text-white border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
471
+ />
472
+ {editForm.file && (
473
+ <p className="mt-2 text-gray-300 text-sm">{PRODUCT_DESC.UI.SELECTED_IMAGE_TEXT.replace("${formImage.name}", editForm.file.name)}</p>
474
+ )}
475
+ </div>
476
+ <div>
477
+ <label htmlFor="edit-title" className="block text-sm font-medium text-gray-300 mb-1">
478
+ {PRODUCT_DESC.UI.TITLE_LABEL}
479
+ </label>
480
+ <input
481
+ id="edit-title"
482
+ value={editForm.title}
483
+ onChange={(e) => setEditForm({ ...editForm, title: e.target.value })}
484
+ placeholder={PRODUCT_DESC.UI.TITLE_PLACEHOLDER}
485
+ className="w-full p-2 rounded bg-gray-700 text-white border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
486
+ required
487
+ />
488
+ </div>
489
+ <div>
490
+ <label htmlFor="edit-description" className="block text-sm font-medium text-gray-300 mb-1">
491
+ {PRODUCT_DESC.UI.DESCRIPTION_LABEL}
492
+ </label>
493
+ <textarea
494
+ id="edit-description"
495
+ value={editForm.description}
496
+ onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
497
+ placeholder={PRODUCT_DESC.UI.DESCRIPTION_PLACEHOLDER}
498
+ className="w-full p-2 rounded bg-gray-700 text-white border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm min-h-[80px]"
499
+ required
500
+ />
501
+ </div>
502
+ <div>
503
+ <label htmlFor="edit-category" className="block text-sm font-medium text-gray-300 mb-1">
504
+ {PRODUCT_DESC.UI.CATEGORY_LABEL}
505
+ </label>
506
+ <select
507
+ id="edit-category"
508
+ value={editForm.category}
509
+ onChange={(e) =>
510
+ setEditForm({
511
+ ...editForm,
512
+ category: e.target.value as Category,
513
+ })
514
+ }
515
+ className="w-full p-2 rounded bg-gray-700 text-white border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
516
+ required
517
+ >
518
+ {visibleCategories.map((category) => (
519
+ <option key={category} value={category}>
520
+ {category === "none"
521
+ ? PRODUCT_DESC.UI.CATEGORY_NONE
522
+ : category
523
+ .split("-")
524
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
525
+ .join(" ")}
526
+ </option>
527
+ ))}
528
+ </select>
529
+ </div>
530
+ <div>
531
+ <label htmlFor="edit-subcategory" className="block text-sm font-medium text-gray-300 mb-1">
532
+ {PRODUCT_DESC.UI.SUBCATEGORY_LABEL}
533
+ </label>
534
+ <input
535
+ id="edit-subcategory"
536
+ type="text"
537
+ value={editForm.subCategory}
538
+ onChange={(e) => handleSubCategoryChange(e.target.value)}
539
+ placeholder={PRODUCT_DESC.UI.SUBCATEGORY_PLACEHOLDER}
540
+ className="w-full p-2 pr-8 rounded bg-gray-700 text-white border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
541
+ autoComplete="off"
542
+ name="product-subcategory"
543
+ />
544
+ {subCategoryError && <p className="text-red-400 text-sm font-medium mt-1 w-full text-left">{subCategoryError}</p>}
545
+ </div>
546
+ <div className="flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-3">
547
+ <SubmitButton
548
+ type="submit"
549
+ disabled={isSubmitting || !editForm.title.trim() || !editForm.description.trim() || !editForm.category || !!subCategoryError}
550
+ >
551
+ {isSubmitting ? PRODUCT_DESC.BUTTONS.SAVING_BUTTON : PRODUCT_DESC.BUTTONS.SAVE_BUTTON}
552
+ </SubmitButton>
553
+ <CancelButton onClick={onClose} />
554
+ </div>
555
+ {error && <p className="text-red-400 text-sm font-medium">{error}</p>}
556
+ </form>
557
+ </div>
558
+ </motion.div>
559
+ );
560
+ };
561
+
562
+ // Delete Confirmation Modal Component
563
+ const DeleteModal = ({
564
+ isOpen,
565
+ onClose,
566
+ onConfirm,
567
+ isSubmitting,
568
+ }: {
569
+ isOpen: boolean;
570
+ onClose: () => void;
571
+ onConfirm: () => Promise<void>;
572
+ isSubmitting: boolean;
573
+ }) => {
574
+ if (!isOpen) return null;
575
+
576
+ return (
577
+ <motion.div
578
+ variants={modalVariants}
579
+ initial="hidden"
580
+ animate="visible"
581
+ exit="exit"
582
+ className="fixed inset-0 bg-black/90 flex items-center justify-center z-[10000] p-4 isolate"
583
+ onClick={onClose}
584
+ aria-modal="true"
585
+ role="dialog"
586
+ >
587
+ <div
588
+ className="relative max-w-sm w-full mx-4 p-6 bg-gray-800/50 border border-gray-700/50 rounded-lg shadow-lg max-h-[90vh] overflow-y-auto"
589
+ onClick={(e) => e.stopPropagation()}
590
+ >
591
+ <CloseButton onClick={onClose}>
592
+ <X className="h-6 w-6 sm:h-8 sm:w-8" />
593
+ </CloseButton>
594
+ <h3 className="text-xl font-bold text-white mb-4">{PRODUCT_DESC.UI.DELETE_MODAL_HEADING}</h3>
595
+ <p className="text-gray-300 mb-6">{PRODUCT_DESC.UI.DELETE_CONFIRMATION_MESSAGE}</p>
596
+ <div className="flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-3">
597
+ <DeleteButton onClick={onConfirm} disabled={isSubmitting}>
598
+ {isSubmitting ? PRODUCT_DESC.BUTTONS.DELETING_BUTTON : PRODUCT_DESC.BUTTONS.DELETE_BUTTON}
599
+ </DeleteButton>
600
+ <CancelButton onClick={onClose} />
601
+ </div>
602
+ </div>
603
+ </motion.div>
604
+ );
605
+ };
606
+
607
+ // View Image Modal Component
608
+ const ViewImageModal = ({
609
+ isOpen,
610
+ onClose,
611
+ selectedImage,
612
+ }: {
613
+ isOpen: boolean;
614
+ onClose: () => void;
615
+ selectedImage: UploadedImage | null;
616
+ }) => {
617
+ if (!isOpen || !selectedImage) return null;
618
+
619
+ return (
620
+ <motion.div
621
+ variants={modalVariants}
622
+ initial="hidden"
623
+ animate="visible"
624
+ exit="exit"
625
+ className="fixed inset-0 bg-black/90 flex items-center justify-center z-[10001] p-2 sm:p-4 isolate"
626
+ onClick={onClose}
627
+ role="dialog"
628
+ aria-modal="true"
629
+ aria-labelledby="modal-image"
630
+ >
631
+ <div
632
+ className="relative max-w-5xl w-full mx-2 sm:mx-4 max-h-[90vh] bg-gray-800/50 border border-gray-700/50 rounded-2xl shadow-lg overflow-hidden"
633
+ onClick={(e) => e.stopPropagation()}
634
+ >
635
+ <CloseButton variant="close-form" onClick={onClose}>
636
+ <X className="h-6 w-6 sm:h-8 sm:w-8" />
637
+ </CloseButton>
638
+ <div className="relative w-full h-[70vh] sm:h-[80vh]">
639
+ <Image
640
+ src={selectedImage.url}
641
+ alt={selectedImage.title || PRODUCT_DESC.UI.DEFAULT_IMAGE_ALT}
642
+ fill
643
+ className="object-contain rounded-2xl"
644
+ quality={85}
645
+ />
646
+ {(selectedImage.title || selectedImage.description) && (
647
+ <div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-3 sm:p-4">
648
+ {selectedImage.title && (
649
+ <h3 className="text-lg sm:text-xl md:text-2xl font-bold text-white drop-shadow-lg">
650
+ {selectedImage.title}
651
+ </h3>
652
+ )}
653
+ {selectedImage.description && (
654
+ <p className="text-xs sm:text-sm md:text-base text-gray-200 drop-shadow-md line-clamp-2">
655
+ {selectedImage.description}
656
+ </p>
657
+ )}
658
+ </div>
659
+ )}
660
+ </div>
661
+ </div>
662
+ </motion.div>
663
+ );
664
+ };
665
+
666
+ // Main Component
667
+ export function ProductDescCarousel({
668
+ user,
669
+ uploadedImages,
670
+ setUploadedImages,
671
+ error,
672
+ setError,
673
+ isLoading,
674
+ setIsLoading,
675
+ handleImageUpload,
676
+ handleDeleteImage,
677
+ visibleCategories = ["none"],
678
+ fixedCategory,
679
+ visibleSubCategories,
680
+ setVisibleSubCategories,
681
+ }: ProductDescCarouselProps) {
682
+ const { isSignedIn } = useUser();
683
+ const { getToken } = useAuth();
684
+ const [activeTab, setActiveTab] = useState<string>(visibleSubCategories[0] ?? visibleCategories[0] ?? "none");
685
+ const [currentSlide, setCurrentSlide] = useState(0);
686
+ const [modalState, setModalState] = useState<ModalState>({
687
+ upload: false,
688
+ edit: false,
689
+ delete: false,
690
+ view: false,
691
+ documentIdToDelete: null,
692
+ selectedImage: null,
693
+ });
694
+ const [uploadForm, setUploadForm] = useState<FormState>({
695
+ file: null,
696
+ title: "",
697
+ description: "",
698
+ category: fixedCategory ?? getValidCategory(visibleCategories[0], "none"),
699
+ subCategory: "",
700
+ favorite: false,
701
+ banner: false,
702
+ });
703
+ const [editForm, setEditForm] = useState<EditFormState>({
704
+ id: 0,
705
+ documentId: "",
706
+ title: "",
707
+ description: "",
708
+ category: fixedCategory ?? getValidCategory(visibleCategories[0], "none"),
709
+ subCategory: visibleSubCategories[0] ?? "",
710
+ file: null,
711
+ favorite: false,
712
+ banner: false,
713
+ });
714
+ const [isSubmitting, setIsSubmitting] = useState(false);
715
+ const [isAdmin, setIsAdmin] = useState(false);
716
+
717
+ const containerRef = useRef<HTMLDivElement>(null);
718
+ const { scrollYProgress } = useScroll({
719
+ target: containerRef,
720
+ offset: ["start end", "end start"] as ["start end", "end start"],
721
+ });
722
+ const parallaxY = useTransform(scrollYProgress, [0, 1], [0, -50]);
723
+
724
+ // Compute isAdmin with retry logic
725
+ useEffect(() => {
726
+ let isMounted = true;
727
+ const checkAdmin = async (retries = 3, delay = 1000) => {
728
+ if (!isSignedIn || !user?.authId) {
729
+ if (isMounted) setIsAdmin(false);
730
+ return;
731
+ }
732
+
733
+ for (let attempt = 1; attempt <= retries; attempt++) {
734
+ try {
735
+ const adminStatus = await isAdminUser(isSignedIn, user);
736
+ if (isMounted) {
737
+ setIsAdmin(adminStatus);
738
+ setError(null);
739
+ }
740
+ return;
741
+ } catch (error) {
742
+ console.error("ProductDescCarousel: Admin check failed:", {
743
+ error: error instanceof Error ? error.message : "Unknown error",
744
+ attempt,
745
+ timestamp: new Date().toISOString(),
746
+ });
747
+ if (attempt < retries) {
748
+ await new Promise(resolve => setTimeout(resolve, delay));
749
+ } else if (isMounted) {
750
+ setIsAdmin(false);
751
+ setError(PRODUCT_DESC.ERRORS.ADMIN_CHECK_FAILED);
752
+ }
753
+ }
754
+ }
755
+ };
756
+ checkAdmin();
757
+ return () => {
758
+ isMounted = false;
759
+ };
760
+ }, [isSignedIn, user, setError]);
761
+
762
+ useEffect(() => {
763
+ const isAnyModalOpen = modalState.upload || modalState.edit || modalState.delete || modalState.view;
764
+ document.body.style.overflow = isAnyModalOpen ? "hidden" : "";
765
+ window.dispatchEvent(
766
+ new CustomEvent("modalStateChange", { detail: { isOpen: isAnyModalOpen } })
767
+ );
768
+ return () => {
769
+ document.body.style.overflow = "";
770
+ };
771
+ }, [modalState]);
772
+
773
+ const filteredImages = uploadedImages.filter((img) => {
774
+ const matchesCategory = fixedCategory ? img.category === fixedCategory : (img.category || "none") === activeTab;
775
+ const matchesSubCategory = visibleSubCategories.length > 0 ? (img.subCategory || "") === activeTab : true;
776
+ return matchesCategory && matchesSubCategory;
777
+ });
778
+
779
+ const checkAdmin = (action: string): boolean => {
780
+ if (!isAdmin) {
781
+ console.error(`ProductDescCarousel: Unauthorized ${action} attempt`, {
782
+ isSignedIn,
783
+ authId: user?.authId,
784
+ businessAdminId: user?.businessAdminId,
785
+ userRole: user?.userRole,
786
+ businessOwner: user?.businessOwner ?? null,
787
+ timestamp: new Date().toISOString(),
788
+ });
789
+ setError(PRODUCT_DESC.ERRORS[`UNAUTHORIZED_${action.toUpperCase()}` as keyof typeof PRODUCT_DESC.ERRORS]);
790
+ return false;
791
+ }
792
+ return true;
793
+ };
794
+
795
+ const handleImageClick = (image: UploadedImage) => {
796
+ setModalState((prev) => ({ ...prev, view: true, selectedImage: image }));
797
+ };
798
+
799
+ const closeModal = () => {
800
+ setModalState((prev) => ({ ...prev, view: false, selectedImage: null }));
801
+ };
802
+
803
+ const openEditModal = (image: UploadedImage) => {
804
+ if (!checkAdmin("edit")) return;
805
+ setEditForm({
806
+ id: image.id,
807
+ documentId: image.documentId,
808
+ title: image.title?.trim() || "",
809
+ description: image.description || "",
810
+ category: fixedCategory ?? getValidCategory(image.category, getValidCategory(visibleCategories[0], "none")),
811
+ subCategory: image.subCategory || (visibleSubCategories[0] ?? ""),
812
+ file: null,
813
+ favorite: image.favorite,
814
+ banner: image.banner,
815
+ });
816
+ setModalState((prev) => ({ ...prev, edit: true }));
817
+ };
818
+
819
+ const closeEditModal = () => {
820
+ setModalState((prev) => ({ ...prev, edit: false }));
821
+ setEditForm({
822
+ id: 0,
823
+ documentId: "",
824
+ title: "",
825
+ description: "",
826
+ category: fixedCategory ?? getValidCategory(visibleCategories[0], "none"),
827
+ subCategory: visibleSubCategories[0] ?? "",
828
+ file: null,
829
+ favorite: false,
830
+ banner: false,
831
+ });
832
+ };
833
+
834
+ const closeUploadModal = () => {
835
+ setModalState((prev) => ({ ...prev, upload: false }));
836
+ setUploadForm({
837
+ file: null,
838
+ title: "",
839
+ description: "",
840
+ category: fixedCategory ?? getValidCategory(visibleCategories[0], "none"),
841
+ subCategory: "",
842
+ favorite: false,
843
+ banner: false,
844
+ });
845
+ };
846
+
847
+ const openConfirmDelete = (documentId: string) => {
848
+ if (!checkAdmin("delete")) return;
849
+ setModalState((prev) => ({ ...prev, delete: true, documentIdToDelete: documentId }));
850
+ };
851
+
852
+ const handleConfirmDelete = async () => {
853
+ if (!checkAdmin("delete")) {
854
+ setModalState((prev) => ({ ...prev, delete: false }));
855
+ return;
856
+ }
857
+ if (modalState.documentIdToDelete) {
858
+ setIsLoading(true);
859
+ try {
860
+ await handleDeleteImage(modalState.documentIdToDelete);
861
+ } finally {
862
+ setIsLoading(false);
863
+ setModalState((prev) => ({ ...prev, delete: false, documentIdToDelete: null }));
864
+ }
865
+ }
866
+ };
867
+
868
+ const handleCancelDelete = () => {
869
+ setModalState((prev) => ({ ...prev, delete: false, documentIdToDelete: null }));
870
+ };
871
+
872
+ const handleEditImage = async (e: React.FormEvent<HTMLFormElement>, subCategory: string) => {
873
+ e.preventDefault();
874
+ if (!checkAdmin("edit")) return;
875
+
876
+ try {
877
+ setIsLoading(true);
878
+ setIsSubmitting(true);
879
+ const token = await getToken();
880
+ if (!token) {
881
+ console.error("ProductDescCarousel: No authentication token available", { timestamp: new Date().toISOString() });
882
+ setError(PRODUCT_DESC.ERRORS.NO_AUTH_TOKEN);
883
+ return;
884
+ }
885
+
886
+ const formData = new FormData();
887
+ formData.append("documentId", editForm.documentId);
888
+ formData.append("title", editForm.title || `Image ${new Date().toISOString()}`);
889
+ formData.append("description", editForm.description || "");
890
+ formData.append("category", editForm.category);
891
+ formData.append("subCategory", editForm.subCategory || "");
892
+ if (editForm.file) {
893
+ const compressedFile = await compressImage(editForm.file);
894
+ formData.append("file", compressedFile);
895
+ }
896
+
897
+ const response = await fetch("/api/gallery-data", {
898
+ method: "PUT",
899
+ headers: { Authorization: `Bearer ${token}` },
900
+ credentials: "include",
901
+ body: formData,
902
+ });
903
+
904
+ if (!response.ok) {
905
+ const errorData = await response.json();
906
+ console.error("ProductDescCarousel: Edit failed", { status: response.status, errorData, timestamp: new Date().toISOString() });
907
+ if (response.status === 401) {
908
+ setError(PRODUCT_DESC.ERRORS.AUTHENTICATION_ERROR);
909
+ return;
910
+ }
911
+ throw new Error(errorData.error || PRODUCT_DESC.ERRORS.EDIT_FAILED_STATUS.replace("${response.status}", response.status.toString()));
912
+ }
913
+
914
+ const { data }: { data: UploadedImage[] } = await response.json();
915
+ setUploadedImages(data || []);
916
+
917
+ const oldSubCategory = uploadedImages.find(
918
+ (img) => img.documentId === editForm.documentId
919
+ )?.subCategory;
920
+
921
+ let updatedSubCategories = [...visibleSubCategories];
922
+ if (subCategory && !updatedSubCategories.includes(subCategory)) {
923
+ updatedSubCategories = [...updatedSubCategories, subCategory];
924
+ }
925
+
926
+ if (oldSubCategory && oldSubCategory !== subCategory) {
927
+ const hasImagesWithOldSubCategory = data.some(
928
+ (img) => img.subCategory === oldSubCategory
929
+ );
930
+ if (!hasImagesWithOldSubCategory) {
931
+ updatedSubCategories = updatedSubCategories.filter(
932
+ (cat) => cat !== oldSubCategory
933
+ );
934
+ }
935
+ }
936
+
937
+ setVisibleSubCategories(updatedSubCategories);
938
+ if (subCategory) {
939
+ setActiveTab(subCategory);
940
+ setCurrentSlide(0);
941
+ } else if (updatedSubCategories.length > 0) {
942
+ setActiveTab(updatedSubCategories[0]);
943
+ setCurrentSlide(0);
944
+ } else {
945
+ setActiveTab(visibleCategories[0] ?? "none");
946
+ setCurrentSlide(0);
947
+ }
948
+
949
+ setError(null);
950
+ setModalState((prev) => ({ ...prev, edit: false }));
951
+ setEditForm({
952
+ id: 0,
953
+ documentId: "",
954
+ title: "",
955
+ description: "",
956
+ category: fixedCategory ?? getValidCategory(visibleCategories[0], "none"),
957
+ subCategory: updatedSubCategories[0] ?? "",
958
+ file: null,
959
+ favorite: false,
960
+ banner: false,
961
+ });
962
+ } catch (err) {
963
+ console.error("ProductDescCarousel: Edit Error", { error: err instanceof Error ? err.message : "Unknown error", timestamp: new Date().toISOString() });
964
+ setError(err instanceof Error ? err.message : PRODUCT_DESC.ERRORS.EDIT_FAILED);
965
+ } finally {
966
+ setIsLoading(false);
967
+ setIsSubmitting(false);
968
+ }
969
+ };
970
+
971
+ const sectionVariants: Variants = {
972
+ hidden: { opacity: 0 },
973
+ visible: {
974
+ opacity: 1,
975
+ transition: { duration: 0.8, ease: [0.16, 1, 0.3, 1], staggerChildren: 0.1 },
976
+ },
977
+ };
978
+
979
+ const tabs = visibleSubCategories.length > 0 ? visibleSubCategories : visibleCategories;
980
+
981
+ return (
982
+ <div className="w-full">
983
+ <motion.section
984
+ variants={sectionVariants}
985
+ initial="hidden"
986
+ whileInView="visible"
987
+ viewport={{ once: true }}
988
+ className="relative py-12 sm:py-16 lg:pb-24 w-full bg-gray-50/50 backdrop-blur-sm"
989
+ ref={containerRef}
990
+ style={{ y: parallaxY }}
991
+ >
992
+ <div className="relative z-10 w-full px-4 sm:px-6 lg:px-8">
993
+ {error && <p className="text-red-600 text-lg text-center mb-8">{error}</p>}
994
+ {user && !isAdmin && (
995
+ <p className="text-yellow-600 text-lg text-center mb-8">
996
+ {PRODUCT_DESC.UI.NON_ADMIN_MESSAGE}
997
+ </p>
998
+ )}
999
+ {isAdmin && (
1000
+ <div className="flex justify-center mb-12">
1001
+ <ActionButton
1002
+ onClick={() => setModalState((prev) => ({ ...prev, upload: true }))}
1003
+ className="flex items-center"
1004
+ >
1005
+ <Upload className="mr-2 h-4 w-4" />
1006
+ {PRODUCT_DESC.BUTTONS.UPLOAD_IMAGE_BUTTON}
1007
+ </ActionButton>
1008
+ </div>
1009
+ )}
1010
+ <div className="mb-6 flex justify-center max-w-2xl mx-auto">
1011
+ <div className="md:hidden w-full max-w-xs">
1012
+ <select
1013
+ value={activeTab}
1014
+ onChange={(e) => {
1015
+ setActiveTab(e.target.value);
1016
+ setCurrentSlide(0);
1017
+ }}
1018
+ className="px-4 py-2 text-base font-medium text-gray-600 bg-white rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 w-full max-w-xs"
1019
+ >
1020
+ {tabs.map((tab) => (
1021
+ <option key={tab} value={tab}>
1022
+ {tab === "none" || !tab
1023
+ ? PRODUCT_DESC.UI.CATEGORY_NONE
1024
+ : tab
1025
+ .split("-")
1026
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
1027
+ .join(" ")}
1028
+ </option>
1029
+ ))}
1030
+ </select>
1031
+ </div>
1032
+ <div className="hidden md:flex flex-wrap justify-center gap-2 sm:gap-4">
1033
+ {tabs.map((tab) => (
1034
+ <FilterButton
1035
+ key={tab}
1036
+ isActive={activeTab === tab}
1037
+ onClick={() => {
1038
+ setActiveTab(tab);
1039
+ setCurrentSlide(0);
1040
+ }}
1041
+ >
1042
+ {tab === "none" || !tab
1043
+ ? PRODUCT_DESC.UI.CATEGORY_NONE
1044
+ : tab
1045
+ .split("-")
1046
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
1047
+ .join(" ")}
1048
+ </FilterButton>
1049
+ ))}
1050
+ </div>
1051
+ </div>
1052
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6 sm:gap-8 lg:gap-12 items-start">
1053
+ <div className="h-auto max-h-[600px] sm:min-h-[600px] lg:min-h-[900px] bg-white/10 backdrop-blur-md shadow-2xl p-6 sm:p-8 lg:p-10 rounded-3xl flex flex-col space-y-4 sm:space-y-6 order-2 max-w-3xl mx-auto md:max-w-none md:mx-0 md:order-1 supports-[not(backdrop-filter:blur(10px))]:bg-white/20">
1054
+ <h2 className="text-2xl sm:text-3xl lg:text-4xl font-extrabold text-gray-900">
1055
+ {filteredImages[currentSlide]?.title || PRODUCT_DESC.UI.FALLBACK_TITLE}
1056
+ </h2>
1057
+ <div className="text-sm sm:text-base lg:text-lg text-gray-700">
1058
+ {filteredImages.length > 0 && currentSlide >= 0 && currentSlide < filteredImages.length ? (
1059
+ (filteredImages[currentSlide].description || "")
1060
+ .split("\n")
1061
+ .filter((paragraph) => paragraph.trim() !== "")
1062
+ .map((paragraph, index) => (
1063
+ <p key={index} className="mb-4">
1064
+ {paragraph}
1065
+ </p>
1066
+ )) || <p className="mb-4">{PRODUCT_DESC.UI.NO_DESCRIPTION_MESSAGE}</p>
1067
+ ) : (
1068
+ <p className="mb-4">{PRODUCT_DESC.UI.NO_DESCRIPTION_MESSAGE}</p>
1069
+ )}
1070
+ </div>
1071
+ </div>
1072
+ <div className="order-1 md:order-2">
1073
+ <Slideshow
1074
+ images={filteredImages}
1075
+ altPrefix={filteredImages[currentSlide]?.title || PRODUCT_DESC.UI.DEFAULT_IMAGE_ALT}
1076
+ currentSlide={currentSlide}
1077
+ setCurrentSlide={setCurrentSlide}
1078
+ isAdmin={isAdmin}
1079
+ handleImageClick={handleImageClick}
1080
+ handleEditImage={openEditModal}
1081
+ handleDeleteImage={openConfirmDelete}
1082
+ />
1083
+ </div>
1084
+ </div>
1085
+ </div>
1086
+ </motion.section>
1087
+
1088
+ <UploadModal
1089
+ isOpen={modalState.upload}
1090
+ onClose={closeUploadModal}
1091
+ uploadForm={uploadForm}
1092
+ setUploadForm={setUploadForm}
1093
+ isSubmitting={isSubmitting}
1094
+ setIsSubmitting={setIsSubmitting}
1095
+ handleImageUpload={handleImageUpload}
1096
+ error={error}
1097
+ setError={setError}
1098
+ visibleCategories={visibleCategories}
1099
+ visibleSubCategories={visibleSubCategories}
1100
+ setVisibleSubCategories={setVisibleSubCategories}
1101
+ />
1102
+ <EditModal
1103
+ isOpen={modalState.edit}
1104
+ onClose={closeEditModal}
1105
+ editForm={editForm}
1106
+ setEditForm={setEditForm}
1107
+ isSubmitting={isSubmitting}
1108
+ setIsSubmitting={setIsSubmitting}
1109
+ handleEditImage={handleEditImage}
1110
+ error={error}
1111
+ setError={setError}
1112
+ visibleCategories={visibleCategories}
1113
+ visibleSubCategories={visibleSubCategories}
1114
+ setVisibleSubCategories={setVisibleSubCategories}
1115
+ />
1116
+ <DeleteModal
1117
+ isOpen={modalState.delete}
1118
+ onClose={handleCancelDelete}
1119
+ onConfirm={handleConfirmDelete}
1120
+ isSubmitting={isSubmitting}
1121
+ />
1122
+ <ViewImageModal
1123
+ isOpen={modalState.view}
1124
+ onClose={closeModal}
1125
+ selectedImage={modalState.selectedImage}
1126
+ />
1127
+ </div>
1128
+ );
1129
+ }