@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.
- package/README.md +40 -0
- package/app/ClientLayout.tsx +66 -0
- package/app/about/page.tsx +11 -248
- package/app/adRequest/page.tsx +101 -25
- package/app/admin-profile/page.tsx +123 -0
- package/app/analytics/page.tsx +41 -5
- package/app/api/about/route.ts +2 -18
- package/app/api/adRequest/route.ts +7 -27
- package/app/api/analytics/[reportType]/route.ts +1 -64
- package/app/api/bio/route.ts +1 -17
- package/app/api/blog/route.ts +1 -19
- package/app/api/contacts/route.ts +1 -46
- package/app/api/files/route.ts +1 -15
- package/app/api/gallery-data/route.ts +53 -61
- package/app/api/schedule/route.ts +5 -21
- package/app/api/signup/route.ts +129 -0
- package/app/api/sync-user/route.ts +268 -94
- package/app/api/verify-admin/route.ts +46 -0
- package/app/blog/[id]/page.tsx +71 -52
- package/app/blog/page.tsx +43 -10
- package/app/favicon.ico +0 -0
- package/app/gallery/page.tsx +27 -6
- package/app/layout.tsx +31 -82
- package/app/page.tsx +20 -311
- package/app/products/constants/product.ts +27 -0
- package/app/products/page.tsx +296 -0
- package/app/products/productOne/page.tsx +266 -0
- package/app/products/productTwo/page.tsx +272 -0
- package/app/schedule/page.tsx +78 -40
- package/bin/init.js +0 -12
- package/components/addOns/functional/ClassList.tsx +21 -17
- package/components/addOns/functional/ProductList.tsx +1027 -0
- package/components/addOns/functional/aboutSections/AboutSection.tsx +107 -70
- package/components/addOns/functional/aboutSections/constants/aboutSection.ts +9 -4
- package/components/addOns/functional/banner/Banner.tsx +150 -0
- package/components/addOns/functional/banner/BannerDashboard.tsx +283 -0
- package/components/addOns/functional/bioSections/BioEditor.tsx +471 -0
- package/components/addOns/functional/bioSections/constants/bioEditor.ts +36 -0
- package/components/addOns/functional/blogSections/BlogDashboard.tsx +1 -1
- package/components/addOns/functional/blogSections/BlogFormPopUp.tsx +2 -1
- package/components/addOns/functional/{ImageDescCarousel.tsx → carousels/ImageDescCarousel.tsx} +166 -57
- package/components/addOns/functional/carousels/ProductDescCarousel.tsx +1129 -0
- package/components/addOns/functional/{ScheduleCarousel.tsx → carousels/ScheduleCarousel.tsx} +110 -50
- package/components/addOns/functional/carousels/constants.ts/productDescCarousel.ts +197 -0
- package/components/addOns/functional/carousels/constants.ts/scheduleCarousel.ts +20 -0
- package/components/addOns/functional/contactsDashboard/ContactsDashboard.tsx +1 -1
- package/components/addOns/functional/fileUploaders/FileUploader.tsx +437 -0
- package/components/addOns/functional/fileUploaders/constants/fileUploader.ts +45 -0
- package/components/addOns/functional/galleries/GalleryComplex.tsx +468 -267
- package/components/addOns/functional/galleries/GallerySimple.tsx +78 -50
- package/components/addOns/functional/galleries/ThreeSetGallery.tsx +260 -0
- package/components/addOns/functional/schedules/ScheduleGridOne.tsx +22 -8
- package/components/addOns/functional/schedules/ScheduleGridTwo.tsx +12 -7
- package/components/addOns/functional/schedules/ScheduleGridTwoBasic.tsx +12 -7
- package/components/addOns/non-functional/SampleCarousel.tsx +3 -3
- package/components/addOns/non-functional/ThreeSetGallery.tsx +3 -3
- package/components/addOns/non-functional/featureSections/FeaturesSection.tsx +74 -0
- package/components/addOns/non-functional/featureSections/constants/featuresSection.ts +30 -0
- package/components/addOns/non-functional/{Heros/HeroSection.tsx → heros/HomeHero.tsx} +17 -15
- package/components/addOns/non-functional/heros/ProductHero.tsx +111 -0
- package/components/addOns/non-functional/heros/constants/hero.ts +62 -0
- package/components/addOns/non-functional/imageCarousels/ProductSlider.tsx +6 -6
- package/components/addOns/non-functional/imageCarousels/ProgramCarousel.tsx +10 -10
- package/components/footers/footer.tsx +161 -198
- package/components/other/admin-menu.tsx +1 -1
- package/lib/auth/auth-context.tsx +225 -0
- package/lib/auth/auth-utils.tsx +30 -0
- package/lib/constants/adRequest.ts +199 -56
- package/lib/constants/admin-profile.ts +12 -0
- package/lib/constants/page.ts +15 -15
- package/lib/google/google-analytics-tracking.tsx +44 -0
- package/lib/types.ts +235 -0
- package/lib/utils/compressImage.tsx +32 -0
- package/middleware.ts +9 -5
- package/next.config.js +1 -1
- package/package.json +3 -2
- package/public/images/test.png +0 -0
- package/components/addOns/functional/BioEditor.tsx +0 -447
- package/components/addOns/functional/FileUploader.tsx +0 -295
- package/components/addOns/non-functional/FeaturesSection.tsx +0 -63
- package/components/types.ts +0 -50
- package/lib/auth-context.tsx +0 -131
- package/lib/verify-user.ts +0 -118
- /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
|
+
}
|