Skip to content

Commit 9448681

Browse files
committed
feat: implement bestsellers slider and admin home settings
1 parent 6aaf2e4 commit 9448681

File tree

22 files changed

+3103
-16
lines changed

22 files changed

+3103
-16
lines changed
Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
"use client";
2+
3+
import { useState, useRef, useCallback } from "react";
4+
import { Sparkles, Upload, Loader2, Check, ChevronDown, ChevronUp, Wand2, Palette, X } from "lucide-react";
5+
import { toast } from "sonner";
6+
7+
interface AIResult {
8+
description?: string;
9+
shortDescription?: string;
10+
suggestedPrice?: number;
11+
keyBenefits?: string[];
12+
ingredients?: string;
13+
howToUse?: string;
14+
}
15+
16+
interface Variant {
17+
name: string;
18+
colorCode: string;
19+
suggestedPrice: number;
20+
}
21+
22+
interface AIProductWriterProps {
23+
productTitle: string;
24+
onApplyDescription: (description: string) => void;
25+
onApplyPrice: (price: number) => void;
26+
onApplyVariants: (variants: Variant[]) => void;
27+
}
28+
29+
export default function AIProductWriter({
30+
productTitle,
31+
onApplyDescription,
32+
onApplyPrice,
33+
onApplyVariants,
34+
}: AIProductWriterProps) {
35+
const [isOpen, setIsOpen] = useState(false);
36+
const [loading, setLoading] = useState<"description" | "variants" | null>(null);
37+
const [result, setResult] = useState<AIResult | null>(null);
38+
const [variants, setVariants] = useState<Variant[]>([]);
39+
const [imageBase64, setImageBase64] = useState<string | null>(null);
40+
const [imagePreview, setImagePreview] = useState<string | null>(null);
41+
const [applied, setApplied] = useState<Record<string, boolean>>({});
42+
const fileRef = useRef<HTMLInputElement>(null);
43+
44+
const handleImageUpload = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
45+
const file = e.target.files?.[0];
46+
if (!file) return;
47+
const reader = new FileReader();
48+
reader.onload = (ev) => {
49+
const dataUrl = ev.target?.result as string;
50+
setImagePreview(dataUrl);
51+
// Strip data:image/jpeg;base64, prefix
52+
const base64 = dataUrl.split(",")[1];
53+
setImageBase64(base64);
54+
};
55+
reader.readAsDataURL(file);
56+
}, []);
57+
58+
async function generateDescription() {
59+
if (!productTitle?.trim()) {
60+
toast.error("Enter a product title first");
61+
return;
62+
}
63+
setLoading("description");
64+
setResult(null);
65+
try {
66+
const res = await fetch("/api/admin/ai-product", {
67+
method: "POST",
68+
headers: { "Content-Type": "application/json" },
69+
body: JSON.stringify({
70+
title: productTitle,
71+
imageBase64,
72+
mode: "description",
73+
}),
74+
});
75+
const data = await res.json();
76+
if (!res.ok) throw new Error(data.error || "Failed");
77+
setResult(data.result);
78+
toast.success("AI description generated");
79+
} catch (err: any) {
80+
toast.error(err.message);
81+
} finally {
82+
setLoading(null);
83+
}
84+
}
85+
86+
async function generateVariants() {
87+
if (!productTitle?.trim()) {
88+
toast.error("Enter a product title first");
89+
return;
90+
}
91+
setLoading("variants");
92+
setVariants([]);
93+
try {
94+
const res = await fetch("/api/admin/ai-product", {
95+
method: "POST",
96+
headers: { "Content-Type": "application/json" },
97+
body: JSON.stringify({ title: productTitle, mode: "variants" }),
98+
});
99+
const data = await res.json();
100+
if (!res.ok) throw new Error(data.error || "Failed");
101+
setVariants(data.result?.variants || []);
102+
toast.success("Variants generated");
103+
} catch (err: any) {
104+
toast.error(err.message);
105+
} finally {
106+
setLoading(null);
107+
}
108+
}
109+
110+
function applyField(field: string, value: any) {
111+
if (field === "description" && result?.description) {
112+
onApplyDescription(result.description);
113+
}
114+
if (field === "price" && result?.suggestedPrice) {
115+
onApplyPrice(result.suggestedPrice);
116+
}
117+
if (field === "variants") {
118+
onApplyVariants(variants);
119+
}
120+
setApplied(prev => ({ ...prev, [field]: true }));
121+
setTimeout(() => setApplied(prev => ({ ...prev, [field]: false })), 2000);
122+
}
123+
124+
return (
125+
<div className="border border-[#D4AF37]/20 rounded-xl overflow-hidden bg-gradient-to-br from-[#D4AF37]/5 to-transparent">
126+
<button
127+
type="button"
128+
onClick={() => setIsOpen(!isOpen)}
129+
className="w-full flex items-center justify-between px-5 py-4 hover:bg-[#D4AF37]/5 transition-colors"
130+
>
131+
<div className="flex items-center gap-3">
132+
<div className="w-8 h-8 bg-[#D4AF37]/10 border border-[#D4AF37]/20 rounded-lg flex items-center justify-center">
133+
<Sparkles className="w-4 h-4 text-[#D4AF37]" />
134+
</div>
135+
<div className="text-left">
136+
<p className="text-sm font-bold text-white">AI Product Assistant</p>
137+
<p className="text-[10px] text-white/40 uppercase tracking-widest">Generate descriptions · shade names · pricing</p>
138+
</div>
139+
</div>
140+
{isOpen ? <ChevronUp className="w-4 h-4 text-white/40" /> : <ChevronDown className="w-4 h-4 text-white/40" />}
141+
</button>
142+
143+
{isOpen && (
144+
<div className="border-t border-[#D4AF37]/10 px-5 pb-5 pt-4 space-y-5">
145+
{/* Image Upload */}
146+
<div className="space-y-2">
147+
<p className="text-[10px] uppercase tracking-widest text-white/40">
148+
Optional: Upload product photo for better descriptions
149+
</p>
150+
<div className="flex items-center gap-3">
151+
<button
152+
type="button"
153+
onClick={() => fileRef.current?.click()}
154+
className="flex items-center gap-2 px-4 py-2 bg-black/40 border border-white/10 rounded-lg text-[11px] uppercase tracking-widest text-white/60 hover:border-[#D4AF37]/40 hover:text-white transition-all"
155+
>
156+
<Upload className="w-3.5 h-3.5" />
157+
{imagePreview ? "Change Photo" : "Upload Photo"}
158+
</button>
159+
{imagePreview && (
160+
<div className="relative">
161+
<img src={imagePreview} alt="Product" className="w-12 h-12 object-cover rounded-lg border border-white/10" />
162+
<button
163+
type="button"
164+
onClick={() => { setImagePreview(null); setImageBase64(null); }}
165+
className="absolute -top-1.5 -right-1.5 w-4 h-4 bg-red-500 rounded-full flex items-center justify-center"
166+
>
167+
<X className="w-2.5 h-2.5 text-white" />
168+
</button>
169+
</div>
170+
)}
171+
</div>
172+
<input ref={fileRef} type="file" accept="image/*" onChange={handleImageUpload} className="hidden" />
173+
</div>
174+
175+
{/* Action buttons */}
176+
<div className="flex flex-wrap gap-3">
177+
<button
178+
type="button"
179+
onClick={generateDescription}
180+
disabled={loading !== null}
181+
className="flex items-center gap-2 px-5 py-2.5 bg-[#D4AF37] text-black rounded-lg text-[11px] font-bold uppercase tracking-widest hover:bg-white transition-all disabled:opacity-50"
182+
>
183+
{loading === "description" ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Wand2 className="w-3.5 h-3.5" />}
184+
Write Description
185+
</button>
186+
<button
187+
type="button"
188+
onClick={generateVariants}
189+
disabled={loading !== null}
190+
className="flex items-center gap-2 px-5 py-2.5 bg-white/10 border border-white/10 text-white rounded-lg text-[11px] font-bold uppercase tracking-widest hover:bg-white/20 transition-all disabled:opacity-50"
191+
>
192+
{loading === "variants" ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Palette className="w-3.5 h-3.5" />}
193+
Generate Shades
194+
</button>
195+
</div>
196+
197+
{/* Description result */}
198+
{result && (
199+
<div className="space-y-3 bg-black/30 border border-white/10 rounded-xl p-4">
200+
<p className="text-[10px] uppercase tracking-widest text-[#D4AF37] font-bold">AI Generated Content</p>
201+
202+
{result.description && (
203+
<div className="space-y-1.5">
204+
<div className="flex items-center justify-between">
205+
<p className="text-[10px] uppercase tracking-widest text-white/40">Description</p>
206+
<button
207+
type="button"
208+
onClick={() => applyField("description", result.description)}
209+
className={`flex items-center gap-1.5 px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest transition-all ${
210+
applied.description
211+
? "bg-emerald-500/20 text-emerald-400 border border-emerald-500/30"
212+
: "bg-[#D4AF37]/10 text-[#D4AF37] border border-[#D4AF37]/20 hover:bg-[#D4AF37]/20"
213+
}`}
214+
>
215+
{applied.description ? <Check className="w-3 h-3" /> : null}
216+
{applied.description ? "Applied!" : "Apply"}
217+
</button>
218+
</div>
219+
<p className="text-sm text-white/80 leading-relaxed">{result.description}</p>
220+
</div>
221+
)}
222+
223+
{result.suggestedPrice && (
224+
<div className="flex items-center justify-between pt-2 border-t border-white/5">
225+
<div>
226+
<p className="text-[10px] uppercase tracking-widest text-white/40">Suggested Price</p>
227+
<p className="text-xl font-serif text-[#D4AF37] font-bold">${result.suggestedPrice}</p>
228+
</div>
229+
<button
230+
type="button"
231+
onClick={() => applyField("price", result.suggestedPrice)}
232+
className={`flex items-center gap-1.5 px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest transition-all ${
233+
applied.price
234+
? "bg-emerald-500/20 text-emerald-400 border border-emerald-500/30"
235+
: "bg-[#D4AF37]/10 text-[#D4AF37] border border-[#D4AF37]/20 hover:bg-[#D4AF37]/20"
236+
}`}
237+
>
238+
{applied.price ? <Check className="w-3 h-3" /> : null}
239+
{applied.price ? "Applied!" : "Use This Price"}
240+
</button>
241+
</div>
242+
)}
243+
244+
{result.keyBenefits && result.keyBenefits.length > 0 && (
245+
<div className="pt-2 border-t border-white/5">
246+
<p className="text-[10px] uppercase tracking-widest text-white/40 mb-2">Key Benefits</p>
247+
<div className="flex flex-wrap gap-2">
248+
{result.keyBenefits.map((b, i) => (
249+
<span key={i} className="px-2 py-1 bg-white/5 border border-white/10 rounded-full text-[10px] text-white/70">
250+
{b}
251+
</span>
252+
))}
253+
</div>
254+
</div>
255+
)}
256+
257+
{result.howToUse && (
258+
<div className="pt-2 border-t border-white/5">
259+
<p className="text-[10px] uppercase tracking-widest text-white/40 mb-1">How To Use</p>
260+
<p className="text-xs text-white/60 italic">{result.howToUse}</p>
261+
</div>
262+
)}
263+
</div>
264+
)}
265+
266+
{/* Variants result */}
267+
{variants.length > 0 && (
268+
<div className="space-y-3 bg-black/30 border border-white/10 rounded-xl p-4">
269+
<div className="flex items-center justify-between">
270+
<p className="text-[10px] uppercase tracking-widest text-[#D4AF37] font-bold">
271+
{variants.length} Shades Generated
272+
</p>
273+
<button
274+
type="button"
275+
onClick={() => applyField("variants", variants)}
276+
className={`flex items-center gap-1.5 px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest transition-all ${
277+
applied.variants
278+
? "bg-emerald-500/20 text-emerald-400 border border-emerald-500/30"
279+
: "bg-[#D4AF37]/10 text-[#D4AF37] border border-[#D4AF37]/20 hover:bg-[#D4AF37]/20"
280+
}`}
281+
>
282+
{applied.variants ? <Check className="w-3 h-3" /> : null}
283+
{applied.variants ? "Applied!" : "Add All Variants"}
284+
</button>
285+
</div>
286+
<div className="flex flex-wrap gap-3">
287+
{variants.map((v, i) => (
288+
<div key={i} className="flex items-center gap-2 bg-black/40 border border-white/10 rounded-lg px-3 py-2">
289+
<div
290+
className="w-5 h-5 rounded-full border border-white/20 flex-shrink-0"
291+
style={{ backgroundColor: v.colorCode }}
292+
/>
293+
<div>
294+
<p className="text-xs font-medium text-white">{v.name}</p>
295+
<p className="text-[9px] text-white/40 font-mono">{v.colorCode}</p>
296+
</div>
297+
</div>
298+
))}
299+
</div>
300+
</div>
301+
)}
302+
303+
{loading && (
304+
<div className="flex items-center gap-3 py-4 text-white/40">
305+
<Loader2 className="w-4 h-4 animate-spin text-[#D4AF37]" />
306+
<p className="text-[11px] uppercase tracking-widest">
307+
{loading === "description" ? "Writing luxury copy..." : "Generating shade palette..."}
308+
</p>
309+
</div>
310+
)}
311+
</div>
312+
)}
313+
</div>
314+
);
315+
}

0 commit comments

Comments
 (0)