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