feat: layout and overlays migrated to Next.js
This commit is contained in:
parent
c5a1b8e5d2
commit
72d2a5600a
106
frontend/application/app/ClientLayout.tsx
Normal file
106
frontend/application/app/ClientLayout.tsx
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import Header from "../components/Header";
|
||||||
|
import Footer from "../components/Footer";
|
||||||
|
import { NetworkBanner } from "../components/NetworkBanner";
|
||||||
|
import { Toaster } from "sonner";
|
||||||
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import CartDrawer from "../components/CartDrawer";
|
||||||
|
import LoginModal from "../components/LoginModal";
|
||||||
|
import B2BPortal from "../components/B2BPortal";
|
||||||
|
import { useUserStore } from "../lib/store/userStore";
|
||||||
|
import { useSettingsStore } from "../lib/store/settingsStore";
|
||||||
|
|
||||||
|
export default function ClientLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const [isCartOpen, setIsCartOpen] = useState(false);
|
||||||
|
const [showB2B, setShowB2B] = useState(false);
|
||||||
|
const [showLoginModal, setShowLoginModal] = useState(false);
|
||||||
|
const [advisorData, setAdvisorData] = useState<any>(null);
|
||||||
|
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { fetchProfile } = useUserStore();
|
||||||
|
const fetchSettings = useSettingsStore(state => state.fetchSettings);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSettings();
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
if (token) {
|
||||||
|
fetchProfile().catch(e => console.error("Auth init failed:", e));
|
||||||
|
}
|
||||||
|
}, [fetchProfile, fetchSettings]);
|
||||||
|
|
||||||
|
// Derived currentView from pathname for Header
|
||||||
|
let currentView = "home";
|
||||||
|
if (pathname?.includes("/shop")) currentView = "shop";
|
||||||
|
if (pathname?.includes("/wiki")) currentView = "wiki";
|
||||||
|
if (pathname?.includes("/profile")) currentView = "profile";
|
||||||
|
if (pathname?.includes("/checkout")) currentView = "checkout";
|
||||||
|
if (pathname?.includes("/dashboard")) currentView = "user-dashboard";
|
||||||
|
|
||||||
|
const handleNavigate = (v: any) => {
|
||||||
|
if (typeof v === 'object') {
|
||||||
|
if (v.view === 'profile') router.push('/profile');
|
||||||
|
if (v.view === 'order-tracking') router.push('/order/tracking');
|
||||||
|
if (v.view === 'home') router.push('/');
|
||||||
|
} else {
|
||||||
|
if (v === 'home') router.push('/');
|
||||||
|
else if (v === 'user-dashboard') router.push('/dashboard');
|
||||||
|
else router.push(`/${v}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigateToShop = (category: string = "all", search: string = "") => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (category && category !== "all") params.set("category", category);
|
||||||
|
if (search) params.set("search", search);
|
||||||
|
const newUrl = params.toString() ? `/shop?${params.toString()}` : "/shop";
|
||||||
|
router.push(newUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = (q: string) => {
|
||||||
|
router.push(`/search?q=${encodeURIComponent(q)}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NetworkBanner />
|
||||||
|
<Header
|
||||||
|
onNavigate={handleNavigate}
|
||||||
|
onShopNavigate={navigateToShop}
|
||||||
|
currentView={currentView}
|
||||||
|
onCartOpen={() => setIsCartOpen(true)}
|
||||||
|
onSearch={handleSearch}
|
||||||
|
onB2BOpen={() => setShowB2B(true)}
|
||||||
|
/>
|
||||||
|
<main className="min-h-screen">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
<Footer onNavigate={handleNavigate} onShopNavigate={navigateToShop} onB2BOpen={() => setShowB2B(true)} />
|
||||||
|
|
||||||
|
{showB2B && <B2BPortal onClose={() => setShowB2B(false)} />}
|
||||||
|
<CartDrawer
|
||||||
|
isOpen={isCartOpen}
|
||||||
|
onClose={() => setIsCartOpen(false)}
|
||||||
|
onCheckout={() => { setIsCartOpen(false); router.push('/checkout'); }}
|
||||||
|
onShopNavigate={navigateToShop}
|
||||||
|
/>
|
||||||
|
<LoginModal
|
||||||
|
isOpen={showLoginModal}
|
||||||
|
onClose={() => {
|
||||||
|
setShowLoginModal(false);
|
||||||
|
setAdvisorData(null);
|
||||||
|
}}
|
||||||
|
onLogin={() => {
|
||||||
|
setShowLoginModal(false);
|
||||||
|
}}
|
||||||
|
petName={advisorData?.name}
|
||||||
|
isAdvisorContext={!!advisorData}
|
||||||
|
/>
|
||||||
|
<Toaster position="top-center" expand={true} richColors />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,33 +1,24 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from 'next';
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import './globals.css';
|
||||||
import "./globals.css";
|
import ClientLayout from './ClientLayout';
|
||||||
|
|
||||||
const geistSans = Geist({
|
|
||||||
variable: "--font-geist-sans",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: 'Canina Iran - کانینا ایران',
|
||||||
description: "Generated by create next app",
|
description: 'لابراتوار تخصصی مکملهای حیوانات خانگی کانینا',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html
|
<html lang="fa" dir="rtl">
|
||||||
lang="en"
|
<body className="min-h-screen bg-medical-gray-50 font-sans">
|
||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
<ClientLayout>
|
||||||
>
|
{children}
|
||||||
<body className="min-h-full flex flex-col">{children}</body>
|
</ClientLayout>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
291
frontend/application/components/AddressModal.tsx
Normal file
291
frontend/application/components/AddressModal.tsx
Normal file
@ -0,0 +1,291 @@
|
|||||||
|
"use client";
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
|
import { X, MapPin, User, Phone, Hash, Save, Check } from "lucide-react";
|
||||||
|
import { toPersian, cn } from "../lib/utils";
|
||||||
|
import { Address } from "../store/userStore";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface AddressModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (address: Address) => void;
|
||||||
|
editingAddress?: Address | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AddressModal({ isOpen, onClose, onSave, editingAddress }: AddressModalProps) {
|
||||||
|
const [formData, setFormData] = useState<Omit<Address, "id">>({
|
||||||
|
title: "",
|
||||||
|
receptorName: "",
|
||||||
|
phone: "",
|
||||||
|
province: "",
|
||||||
|
city: "",
|
||||||
|
detail: "",
|
||||||
|
zipCode: "",
|
||||||
|
isDefault: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editingAddress) {
|
||||||
|
setFormData({
|
||||||
|
title: editingAddress.title,
|
||||||
|
receptorName: editingAddress.receptorName,
|
||||||
|
phone: editingAddress.phone,
|
||||||
|
province: editingAddress.province,
|
||||||
|
city: editingAddress.city,
|
||||||
|
detail: editingAddress.detail,
|
||||||
|
zipCode: editingAddress.zipCode,
|
||||||
|
isDefault: editingAddress.isDefault
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setFormData({
|
||||||
|
title: "",
|
||||||
|
receptorName: "",
|
||||||
|
phone: "",
|
||||||
|
province: "",
|
||||||
|
city: "",
|
||||||
|
detail: "",
|
||||||
|
zipCode: "",
|
||||||
|
isDefault: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setErrors({});
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}, [editingAddress, isOpen]);
|
||||||
|
|
||||||
|
const normalizeDigits = (str: string) => {
|
||||||
|
const persianDigits = [/۰/g, /۱/g, /۲/g, /۳/g, /۴/g, /۵/g, /۶/g, /۷/g, /۸/g, /۹/g];
|
||||||
|
const arabicDigits = [/٠/g, /١/g, /٢/g, /٣/g, /٤/g, /٥/g, /٦/g, /٧/g, /٨/g, /٩/g];
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
str = str.replace(persianDigits[i], i.toString()).replace(arabicDigits[i], i.toString());
|
||||||
|
}
|
||||||
|
return str;
|
||||||
|
};
|
||||||
|
|
||||||
|
const validate = () => {
|
||||||
|
const newErrors: Record<string, string> = {};
|
||||||
|
if (!formData.title) newErrors.title = "لطفاً عنوان آدرس را وارد کنید";
|
||||||
|
if (!formData.receptorName) newErrors.receptorName = "نام گیرنده را وارد کنید";
|
||||||
|
|
||||||
|
const normalizedPhone = normalizeDigits(formData.phone);
|
||||||
|
if (!normalizedPhone) {
|
||||||
|
newErrors.phone = "شماره تماس الزامی است";
|
||||||
|
} else if (!/^09\d{9}$/.test(normalizedPhone)) {
|
||||||
|
newErrors.phone = "شماره موبایل وارد شده معتبر نیست";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.province) newErrors.province = "استان را مشخص کنید";
|
||||||
|
if (!formData.city) newErrors.city = "شهر را مشخص کنید";
|
||||||
|
if (!formData.detail) newErrors.detail = "آدرس دقیق پستی را وارد کنید";
|
||||||
|
|
||||||
|
const normalizedZip = normalizeDigits(formData.zipCode);
|
||||||
|
if (!normalizedZip) {
|
||||||
|
newErrors.zipCode = "کد پستی الزامی است";
|
||||||
|
} else if (!/^\d{10}$/.test(normalizedZip)) {
|
||||||
|
newErrors.zipCode = "کد پستی باید ۱۰ رقم باشد";
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(newErrors);
|
||||||
|
return Object.keys(newErrors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (validate()) {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
// Simulate small delay for UX
|
||||||
|
setTimeout(() => {
|
||||||
|
onSave({
|
||||||
|
...formData,
|
||||||
|
phone: normalizeDigits(formData.phone),
|
||||||
|
zipCode: normalizeDigits(formData.zipCode),
|
||||||
|
id: editingAddress?.id || Math.random().toString(36).substr(2, 9),
|
||||||
|
isDefault: formData.isDefault
|
||||||
|
});
|
||||||
|
toast.success(editingAddress ? "تغییرات آدرس با موفقیت ذخیره شد" : "آدرس جدید با موفقیت اضافه شد");
|
||||||
|
setIsSubmitting(false);
|
||||||
|
onClose();
|
||||||
|
}, 600);
|
||||||
|
} else {
|
||||||
|
toast.error("لطفاً موارد خطای فرم را اصلاح کنید");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<div className="fixed inset-0 z-[110] flex items-center justify-center p-4">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute inset-0 bg-medical-gray-900/60 backdrop-blur-md"
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||||
|
className="bg-white w-full max-w-xl rounded-[3rem] relative z-10 shadow-2xl overflow-hidden font-vazir text-right pointer-events-auto"
|
||||||
|
dir="rtl"
|
||||||
|
>
|
||||||
|
<div className="bg-medical-gray-50 px-8 py-6 border-b border-medical-gray-100 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-canina-blue rounded-xl flex items-center justify-center text-white">
|
||||||
|
<MapPin className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-black text-medical-gray-900 italic">
|
||||||
|
{editingAddress ? "ویرایش آدرس ارسال" : "افزودن آدرس جدید"}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-10 h-10 flex items-center justify-center rounded-xl bg-white border border-medical-gray-200 text-medical-gray-400 hover:text-red-500 transition-all"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="p-8 space-y-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Row 1: Title */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block pr-2">عنوان آدرس (مثلاً: خانه، محل کار)</label>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
type="text"
|
||||||
|
value={formData.title}
|
||||||
|
onChange={e => setFormData({...formData, title: e.target.value})}
|
||||||
|
className={cn("w-full bg-medical-gray-50 border rounded-2xl py-3 px-5 outline-none font-bold transition-all focus:ring-2 focus:ring-canina-blue/20", errors.title ? "border-red-300" : "border-medical-gray-100")}
|
||||||
|
placeholder="مثال: منزل اصلی"
|
||||||
|
/>
|
||||||
|
{errors.title && <p className="text-[10px] text-red-500 font-bold pr-2">{errors.title}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{/* Row 2: Name & Phone */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block pr-2">نام گیرنده</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.receptorName}
|
||||||
|
onChange={e => setFormData({...formData, receptorName: e.target.value})}
|
||||||
|
className={cn("w-full bg-medical-gray-50 border rounded-2xl py-3 px-5 outline-none font-bold transition-all focus:ring-2 focus:ring-canina-blue/20", errors.receptorName ? "border-red-300" : "border-medical-gray-100")}
|
||||||
|
placeholder="نام و نامخانوادگی..."
|
||||||
|
/>
|
||||||
|
{errors.receptorName && <p className="text-[10px] text-red-500 font-bold pr-2">{errors.receptorName}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block pr-2">شماره تماس</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.phone}
|
||||||
|
onChange={e => setFormData({...formData, phone: e.target.value})}
|
||||||
|
className={cn("w-full bg-medical-gray-50 border rounded-2xl py-3 px-5 outline-none font-bold transition-all focus:ring-2 focus:ring-canina-blue/20 text-left", errors.phone ? "border-red-300" : "border-medical-gray-100")}
|
||||||
|
placeholder="09120000000"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
{errors.phone && <p className="text-[10px] text-red-500 font-bold pr-2">{errors.phone}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{/* Row 3: Province & City */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block pr-2">استان</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.province}
|
||||||
|
onChange={e => setFormData({...formData, province: e.target.value})}
|
||||||
|
className={cn("w-full bg-medical-gray-50 border rounded-2xl py-3 px-5 outline-none font-bold transition-all focus:ring-2 focus:ring-canina-blue/20", errors.province ? "border-red-300" : "border-medical-gray-100")}
|
||||||
|
placeholder="نام استان..."
|
||||||
|
/>
|
||||||
|
{errors.province && <p className="text-[10px] text-red-500 font-bold pr-2">{errors.province}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block pr-2">شهر</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.city}
|
||||||
|
onChange={e => setFormData({...formData, city: e.target.value})}
|
||||||
|
className={cn("w-full bg-medical-gray-50 border rounded-2xl py-3 px-5 outline-none font-bold transition-all focus:ring-2 focus:ring-canina-blue/20", errors.city ? "border-red-300" : "border-medical-gray-100")}
|
||||||
|
placeholder="نام شهر..."
|
||||||
|
/>
|
||||||
|
{errors.city && <p className="text-[10px] text-red-500 font-bold pr-2">{errors.city}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 4: Detail Address */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block pr-2">آدرس دقیق</label>
|
||||||
|
<textarea
|
||||||
|
rows={2}
|
||||||
|
value={formData.detail}
|
||||||
|
onChange={e => setFormData({...formData, detail: e.target.value})}
|
||||||
|
className={cn("w-full bg-medical-gray-50 border rounded-2xl py-3 px-5 outline-none font-bold transition-all focus:ring-2 focus:ring-canina-blue/20 resize-none", errors.detail ? "border-red-300" : "border-medical-gray-100")}
|
||||||
|
placeholder="خیابان، کوچه، پلاک، واحد..."
|
||||||
|
/>
|
||||||
|
{errors.detail && <p className="text-[10px] text-red-500 font-bold pr-2">{errors.detail}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 5: Zip Code */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block pr-2">کد پستی (۱۰ رقم)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.zipCode}
|
||||||
|
onChange={e => setFormData({...formData, zipCode: e.target.value})}
|
||||||
|
className={cn("w-full bg-medical-gray-50 border rounded-2xl py-3 px-5 outline-none font-bold transition-all focus:ring-2 focus:ring-canina-blue/20 text-left", errors.zipCode ? "border-red-300" : "border-medical-gray-100")}
|
||||||
|
placeholder="1234567890"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
{errors.zipCode && <p className="text-[10px] text-red-500 font-bold pr-2">{errors.zipCode}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 6: Default Toggle */}
|
||||||
|
<div className="flex items-center gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFormData({...formData, isDefault: !formData.isDefault})}
|
||||||
|
className={cn(
|
||||||
|
"w-6 h-6 rounded-lg border-2 flex items-center justify-center transition-all",
|
||||||
|
formData.isDefault ? "bg-canina-blue border-canina-blue text-white" : "border-medical-gray-200 text-transparent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Check className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<label className="text-sm font-bold text-medical-gray-600">انتخاب به عنوان آدرس پیشفرض ارسال</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1 py-4 bg-medical-gray-50 text-medical-gray-400 rounded-2xl font-black text-sm hover:bg-medical-gray-100 transition-all"
|
||||||
|
>
|
||||||
|
انصراف
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="flex-[2] py-4 bg-canina-blue text-white rounded-2xl font-black text-sm flex items-center justify-center gap-3 hover:bg-medical-gray-900 transition-all shadow-lg shadow-canina-blue/20 disabled:opacity-50 disabled:cursor-wait"
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
{isSubmitting ? "در حال ثبت..." : (editingAddress ? "ثبت تغییرات" : "ثبت آدرس نهایی")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
424
frontend/application/components/ArchivePage.tsx
Normal file
424
frontend/application/components/ArchivePage.tsx
Normal file
@ -0,0 +1,424 @@
|
|||||||
|
"use client";
|
||||||
|
import React, { useState, useMemo, useEffect } from "react";
|
||||||
|
import { Product, PetType } from "../data/products";
|
||||||
|
import { usePetStore } from "../store/usePetStore";
|
||||||
|
import { productService } from "../services/productService";
|
||||||
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
|
import { ProductCardSkeleton } from "./Skeleton";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Filter,
|
||||||
|
Dog,
|
||||||
|
Cat,
|
||||||
|
ShieldCheck,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Activity,
|
||||||
|
Sparkles,
|
||||||
|
Stethoscope,
|
||||||
|
HeartPulse,
|
||||||
|
Heart,
|
||||||
|
AlertCircle,
|
||||||
|
Plus,
|
||||||
|
Loader2
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useCartStore } from "../store/cartStore";
|
||||||
|
import SafeImage from "./SafeImage";
|
||||||
|
|
||||||
|
const CATEGORIES = [
|
||||||
|
{ id: "all", label: "همه محصولات", icon: <Activity className="w-4 h-4" /> },
|
||||||
|
{ id: "joints", label: "حرکتی و مفاصل", icon: <HeartPulse className="w-4 h-4" /> },
|
||||||
|
{ id: "immune", label: "تقویت و ایمنی", icon: <ShieldCheck className="w-4 h-4" /> },
|
||||||
|
{ id: "energy", label: "ویتامین و انرژی", icon: <Activity className="w-4 h-4" /> },
|
||||||
|
{ id: "special-care", label: "پلتفرم مراقبت ویژه", icon: <Stethoscope className="w-4 h-4" /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
const CATEGORY_MAP: Record<string, { category?: string; query?: string; symptoms?: string[] }> = {
|
||||||
|
// Main Categories
|
||||||
|
"joints": { category: "joints" },
|
||||||
|
"immune": { category: "immune" },
|
||||||
|
"energy": { category: "energy" },
|
||||||
|
"special-care": { category: "special-care" },
|
||||||
|
|
||||||
|
// Persian Fallbacks (for robust matching)
|
||||||
|
"مفاصل و استخوان": { category: "joints" },
|
||||||
|
"تقویت سیستم ایمنی و گوارش": { category: "immune" },
|
||||||
|
"ویتامینها و انرژیبخشها": { category: "energy" },
|
||||||
|
"مراقبتهای ویژه (پوست، دندان و چشم)": { category: "special-care" },
|
||||||
|
|
||||||
|
// Solutions
|
||||||
|
"آرتروز سگهای پیر": { category: "joints", symptoms: ["درد مفاصل", "سختی در بلند شدن", "لنگیدن"] },
|
||||||
|
"رشد استخوانی تولهسگ": { category: "joints", symptoms: ["رشد سریع تولهسگ"] },
|
||||||
|
"تقویت رباط و تاندون": { category: "joints", query: "تاندون" },
|
||||||
|
|
||||||
|
"پیشگیری از بیماری": { category: "immune", symptoms: ["ضعف بعد از بیماری"] },
|
||||||
|
"رفع اسهال و یبوست": { category: "immune", symptoms: ["اسهال", "یبوست", "اسهال مزمن"] },
|
||||||
|
"پروبیوتیکها": { category: "immune", query: "فیبر" },
|
||||||
|
|
||||||
|
"مکملهای مولتیویتامین": { category: "energy", query: "ویتامین" },
|
||||||
|
"افزایش اشتها": { category: "energy", symptoms: ["بیاشتهایی"] },
|
||||||
|
"رشد و بلوغ": { category: "energy", query: "انرژی" },
|
||||||
|
|
||||||
|
"سلامت پوست و مفاصل": { category: "special-care", symptoms: ["خشکی پوست", "ریزش مو", "خارش"] },
|
||||||
|
"رفع جرم دندان": { category: "special-care", symptoms: ["جرم دندان", "بوی بد دهان", "التهاب لثه"] },
|
||||||
|
"شستشوی چشم": { category: "special-care", query: "چشم" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const SYMPTOMS = [
|
||||||
|
"لنگیدن", "سختی در بلند شدن", "ریزش مو", "جرم دندان", "بیاشتهایی", "اسهال مزمن", "بعد از مصرف آنتیبیوتیک"
|
||||||
|
];
|
||||||
|
|
||||||
|
const ArchiveProductCard: React.FC<{ product: Product, onClick: (p: Product) => void }> = ({ product, onClick }) => {
|
||||||
|
const { addItem } = useCartStore();
|
||||||
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
|
const activePet = usePetStore(state => state.pets.find(p => p.id === state.activePetId) || null);
|
||||||
|
|
||||||
|
const handleAddToCart = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsAdding(true);
|
||||||
|
addItem(product, 1);
|
||||||
|
toast.success(`${product.name} به سبد خرید اضافه شد`);
|
||||||
|
setTimeout(() => setIsAdding(false), 800);
|
||||||
|
};
|
||||||
|
|
||||||
|
const compatibility = useMemo(() => {
|
||||||
|
if (!activePet) return null;
|
||||||
|
|
||||||
|
const sameSpecies = product.suitableFor === activePet.type || product.suitableFor === "هر دو";
|
||||||
|
const helpsSymptom = (activePet.medicalConditions || []).some(s => product.symptoms.includes(s));
|
||||||
|
|
||||||
|
if (!sameSpecies) return { type: 'alert', text: `مخصوص ${product.suitableFor}` };
|
||||||
|
if (helpsSymptom) return { type: 'success', text: `توصیه شده برای راهکار ${activePet.name}` };
|
||||||
|
if (sameSpecies) return { type: 'neutral', text: `مناسب برای ${activePet.name}` };
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}, [product, activePet]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
onClick={() => onClick(product)}
|
||||||
|
className="group bg-white rounded-[2.5rem] border border-medical-gray-200 overflow-hidden hover:shadow-2xl transition-all cursor-pointer flex flex-col h-full relative"
|
||||||
|
>
|
||||||
|
{product.specialBadge && (
|
||||||
|
<div className="absolute top-4 right-4 z-10 bg-canina-blue text-white text-[9px] font-black uppercase tracking-widest px-3 py-1 rounded-full shadow-lg">
|
||||||
|
{product.specialBadge}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{compatibility && (
|
||||||
|
<div className={`absolute top-14 right-4 z-10 text-[8px] font-black uppercase tracking-widest px-3 py-1 rounded-full shadow-md flex items-center gap-1 ${
|
||||||
|
compatibility.type === 'alert' ? 'bg-amber-100 text-amber-700' :
|
||||||
|
compatibility.type === 'success' ? 'bg-green-100 text-green-700' : 'bg-medical-gray-100 text-medical-gray-600'
|
||||||
|
}`}>
|
||||||
|
{compatibility.type === 'alert' ? <AlertCircle className="w-2.5 h-2.5" /> : compatibility.type === 'success' ? <Heart className="w-2.5 h-2.5" /> : <Sparkles className="w-2.5 h-2.5" />}
|
||||||
|
{compatibility.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="aspect-square bg-medical-gray-50 flex items-center justify-center p-8 overflow-hidden relative">
|
||||||
|
<SafeImage src={product.image} alt={product.name} className="w-full h-full object-contain group-hover:scale-110 transition-transform duration-700" />
|
||||||
|
<div className="absolute bottom-4 left-4 flex gap-2">
|
||||||
|
{(product.suitableFor === "سگ" || product.suitableFor === "هر دو") && (
|
||||||
|
<div className="w-7 h-7 bg-white rounded-lg flex items-center justify-center text-medical-gray-400 border border-medical-gray-100 shadow-sm">
|
||||||
|
<Dog className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(product.suitableFor === "گربه" || product.suitableFor === "هر دو") && (
|
||||||
|
<div className="w-7 h-7 bg-white rounded-lg flex items-center justify-center text-medical-gray-400 border border-medical-gray-100 shadow-sm">
|
||||||
|
<Cat className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 flex flex-col flex-1">
|
||||||
|
<div className="text-[10px] font-bold text-canina-blue uppercase tracking-widest mb-2">{product.category}</div>
|
||||||
|
<h3 className="text-lg font-black text-medical-gray-900 group-hover:text-canina-blue transition-colors mb-3 leading-tight">{product.name}</h3>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-1 mb-6">
|
||||||
|
{product.symptoms.slice(0, 2).map((s, i) => (
|
||||||
|
<span key={i} className="text-[9px] font-bold bg-medical-gray-100 text-medical-gray-500 px-2 py-0.5 rounded-md">
|
||||||
|
{s}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-auto flex items-center justify-between pt-4 border-t border-medical-gray-50">
|
||||||
|
<span className="font-black text-medical-gray-900 font-vazir">{product.price}</span>
|
||||||
|
<button
|
||||||
|
onClick={handleAddToCart}
|
||||||
|
disabled={isAdding}
|
||||||
|
className="w-10 h-10 rounded-xl bg-medical-gray-50 text-canina-blue flex items-center justify-center group-hover:bg-canina-blue group-hover:text-white transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isAdding ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ArchivePage({
|
||||||
|
onProductClick,
|
||||||
|
onBack,
|
||||||
|
onShopNavigate,
|
||||||
|
initialCategory = "all",
|
||||||
|
initialSearch = ""
|
||||||
|
}: {
|
||||||
|
onProductClick: (p: Product) => void,
|
||||||
|
onBack?: () => void,
|
||||||
|
onShopNavigate?: (c?: string, s?: string) => void,
|
||||||
|
initialCategory?: string,
|
||||||
|
initialSearch?: string
|
||||||
|
}) {
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState(initialCategory);
|
||||||
|
const [selectedPet, setSelectedPet] = useState<PetType | "all">("all");
|
||||||
|
const [activeSymptoms, setActiveSymptoms] = useState<string[]>([]);
|
||||||
|
const [searchQuery, setSearchQuery] = useState(initialSearch);
|
||||||
|
const [isUpdating, setIsUpdating] = useState(false);
|
||||||
|
const [filteredProducts, setFilteredProducts] = useState<Product[]>([]);
|
||||||
|
|
||||||
|
// Debug Log for Search Params
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("[ArchivePage] URL Params Sync:", { initialCategory, initialSearch });
|
||||||
|
}, [initialCategory, initialSearch]);
|
||||||
|
|
||||||
|
// Sync state with prop change (e.g. from Header menu)
|
||||||
|
useEffect(() => {
|
||||||
|
setIsUpdating(true);
|
||||||
|
|
||||||
|
// Normalize Input (Trim and lowercase)
|
||||||
|
const normSearch = (initialSearch || "").trim().toLowerCase();
|
||||||
|
const normCat = initialCategory.trim().toLowerCase();
|
||||||
|
|
||||||
|
const mapKey = normSearch || normCat;
|
||||||
|
const mapping = CATEGORY_MAP[mapKey];
|
||||||
|
|
||||||
|
console.log("[ArchivePage] Processing Mapping for:", mapKey);
|
||||||
|
|
||||||
|
// Reset ALL other filters when a new category/solution is selected from menu
|
||||||
|
setSelectedPet("all");
|
||||||
|
setActiveSymptoms([]);
|
||||||
|
setSearchQuery("");
|
||||||
|
|
||||||
|
if (mapping) {
|
||||||
|
if (mapping.category) setSelectedCategory(mapping.category);
|
||||||
|
if (mapping.query) setSearchQuery(mapping.query);
|
||||||
|
if (mapping.symptoms) {
|
||||||
|
setActiveSymptoms(mapping.symptoms);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setSelectedCategory(initialCategory);
|
||||||
|
setSearchQuery(initialSearch);
|
||||||
|
|
||||||
|
if (initialSearch && SYMPTOMS.includes(initialSearch)) {
|
||||||
|
setActiveSymptoms([initialSearch]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
}, 400);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [initialCategory, initialSearch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchProducts = async () => {
|
||||||
|
setIsUpdating(true);
|
||||||
|
try {
|
||||||
|
const data = await productService.getProducts({
|
||||||
|
category: selectedCategory,
|
||||||
|
petType: selectedPet,
|
||||||
|
query: searchQuery
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = data;
|
||||||
|
if (activeSymptoms.length > 0) {
|
||||||
|
result = result.filter(p => p.symptoms.some(s => activeSymptoms.includes(s)));
|
||||||
|
}
|
||||||
|
|
||||||
|
setFilteredProducts(result);
|
||||||
|
} finally {
|
||||||
|
setIsUpdating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchProducts();
|
||||||
|
}, [selectedCategory, selectedPet, searchQuery, activeSymptoms]);
|
||||||
|
|
||||||
|
const toggleSymptom = (s: string) => {
|
||||||
|
setActiveSymptoms(prev => prev.includes(s) ? prev.filter(item => item !== s) : [...prev, s]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-medical-gray-50 pb-20 px-4 pt-10 font-vazir" dir="rtl">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
|
||||||
|
{/* Mobile Navigation & Breadcrumbs */}
|
||||||
|
<div className="lg:hidden mb-10 space-y-4">
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="flex items-center gap-2 text-medical-gray-500 font-bold hover:text-canina-blue transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-5 h-5 font-vazir" />
|
||||||
|
<span>بازگشت</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-[10px] font-black text-medical-gray-400 uppercase tracking-widest overflow-x-auto whitespace-nowrap pb-2">
|
||||||
|
<span className="cursor-pointer hover:text-canina-blue" onClick={onBack}>کانینا</span>
|
||||||
|
<ChevronLeft className="w-2.5 h-2.5" />
|
||||||
|
<span className="text-canina-blue">فروشگاه تخصصی</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop Breadcrumbs */}
|
||||||
|
<div className="hidden lg:flex items-center gap-2 text-[10px] font-black text-medical-gray-400 uppercase tracking-widest mb-10">
|
||||||
|
<span className="cursor-pointer hover:text-canina-blue" onClick={onBack}>خانه</span>
|
||||||
|
<ChevronLeft className="w-2.5 h-2.5" />
|
||||||
|
<span className="text-canina-blue">کاتالوگ کامل محصولات</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col lg:flex-row gap-10">
|
||||||
|
|
||||||
|
{/* Sidebar Filters */}
|
||||||
|
<aside className="lg:w-72 flex-shrink-0 space-y-8">
|
||||||
|
<div className="bg-white rounded-[2rem] p-6 border border-medical-gray-200 shadow-sm">
|
||||||
|
<div className="flex items-center gap-2 mb-6">
|
||||||
|
<Filter className="w-5 h-5 text-canina-blue" />
|
||||||
|
<h3 className="font-black text-medical-gray-900">فیلترهای تخصصی</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Pet Type */}
|
||||||
|
<div>
|
||||||
|
<span className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block mb-3">نوع پت</span>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{[
|
||||||
|
{ id: "all", label: "هر دو", icon: <Activity className="w-3 h-3" /> },
|
||||||
|
{ id: "سگ", label: "سگ", icon: <Dog className="w-3 h-3" /> },
|
||||||
|
{ id: "گربه", label: "گربه", icon: <Cat className="w-3 h-3" /> },
|
||||||
|
].map(pet => (
|
||||||
|
<button
|
||||||
|
key={pet.id}
|
||||||
|
onClick={() => setSelectedPet(pet.id as any)}
|
||||||
|
className={`py-2 rounded-xl text-[10px] font-black border transition-all flex flex-col items-center gap-1 ${selectedPet === pet.id ? 'bg-canina-blue border-canina-blue text-white shadow-lg shadow-canina-blue/20' : 'bg-white border-medical-gray-200 text-medical-gray-400'}`}
|
||||||
|
>
|
||||||
|
{pet.icon}
|
||||||
|
{pet.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Solutions */}
|
||||||
|
<div>
|
||||||
|
<span className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block mb-3">راهکار درمانی</span>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{CATEGORIES.map(cat => (
|
||||||
|
<button
|
||||||
|
key={cat.id}
|
||||||
|
onClick={() => setSelectedCategory(cat.id)}
|
||||||
|
className={`flex items-center gap-3 px-4 py-3 rounded-2xl text-xs font-bold transition-all border ${selectedCategory === cat.id ? 'bg-canina-blue border-canina-blue text-white' : 'bg-white border-medical-gray-100 text-medical-gray-600 hover:border-canina-blue/30'}`}
|
||||||
|
>
|
||||||
|
{cat.icon}
|
||||||
|
{cat.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Symptoms Checklist */}
|
||||||
|
<div>
|
||||||
|
<span className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block mb-3">جستجو بر اساس علائم</span>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{SYMPTOMS.map(s => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
onClick={() => toggleSymptom(s)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-[10px] font-bold transition-all border ${activeSymptoms.includes(s) ? 'bg-medical-gray-900 border-medical-gray-900 text-white' : 'bg-medical-gray-50 border-medical-gray-100 text-medical-gray-500 hover:border-medical-gray-300'}`}
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Promo Card */}
|
||||||
|
<div className="bg-medical-gray-900 rounded-[2rem] p-6 text-white relative overflow-hidden group">
|
||||||
|
<div className="absolute -top-10 -left-10 w-32 h-32 bg-canina-blue rounded-full blur-3xl opacity-20 group-hover:opacity-40 transition-opacity" />
|
||||||
|
<h4 className="text-lg font-black italic mb-4 relative z-10 underline decoration-canina-blue underline-offset-8">مشاوره رایگان</h4>
|
||||||
|
<p className="text-xs text-white/60 leading-relaxed mb-6 relative z-10">اگر نمیدانید کدام ترکیب برای پت شما مناسب است، همین حالا با دامپزشکان ما تماس بگیرید.</p>
|
||||||
|
<a href="tel:0218888" className="w-full bg-white text-medical-gray-900 py-3 rounded-xl text-xs font-black hover:scale-105 transition-transform relative z-10 flex items-center justify-center">شماره تماس: ۰۲۱-۸۸۸۸</a>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<main className="flex-1 relative">
|
||||||
|
<div className="flex flex-col md:flex-row items-center justify-between mb-8 gap-4">
|
||||||
|
<h2 className="text-3xl font-black text-medical-gray-900">کاتولوگ دارویی <span className="text-canina-blue">Canina</span></h2>
|
||||||
|
<div className="relative w-full md:w-80">
|
||||||
|
<Search className="absolute right-4 top-1/2 -translate-y-1/2 w-4 h-4 text-medical-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="جستجوی محصول..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full bg-white border border-medical-gray-200 rounded-full py-3 pr-11 pl-4 text-sm focus:outline-none focus:ring-2 focus:ring-canina-blue/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{isUpdating ? (
|
||||||
|
<div key="skeleton" className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<ProductCardSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : filteredProducts.length > 0 ? (
|
||||||
|
<div key="grid" className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||||
|
<AnimatePresence mode="popLayout">
|
||||||
|
{filteredProducts.map((p: Product) => (
|
||||||
|
<ArchiveProductCard key={p.id} product={p} onClick={onProductClick} />
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div key="empty" className="bg-white rounded-[3rem] p-20 text-center border-2 border-dashed border-medical-gray-200 flex flex-col items-center">
|
||||||
|
<div className="w-20 h-20 bg-medical-gray-50 rounded-full flex items-center justify-center text-medical-gray-300 mb-6">
|
||||||
|
<Search className="w-10 h-10" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-2xl font-black text-medical-gray-900 mb-2">محصولی یافت نشد!</h3>
|
||||||
|
<p className="text-medical-gray-400 max-w-sm mb-8 font-bold">با فیلترهای فعلی محصولی مطابق با نیاز شما پیدا نکردیم. لطفاً فیلترها را گستردهتر کنید یا دکمه ریست را بزنید.</p>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (onShopNavigate) {
|
||||||
|
onShopNavigate("all");
|
||||||
|
} else {
|
||||||
|
setSelectedCategory("all");
|
||||||
|
setSelectedPet("all");
|
||||||
|
setActiveSymptoms([]);
|
||||||
|
setSearchQuery("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="bg-canina-blue text-white px-8 py-3 rounded-xl font-black text-sm hover:scale-105 transition-transform shadow-lg shadow-canina-blue/20"
|
||||||
|
>
|
||||||
|
پاک کردن همه فیلترها و نمایش همه
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
388
frontend/application/components/AuthModal.tsx
Normal file
388
frontend/application/components/AuthModal.tsx
Normal file
@ -0,0 +1,388 @@
|
|||||||
|
"use client";
|
||||||
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
|
import { X, User, Building2, Heart, ShieldCheck, Phone, Key, LogIn, ArrowRight, RefreshCw } from "lucide-react";
|
||||||
|
import { useUserStore, UserRole } from "../store/userStore";
|
||||||
|
import { authService } from "../services/authService";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { toPersian } from "../lib/utils";
|
||||||
|
|
||||||
|
interface AuthModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AuthModal({ isOpen, onClose }: AuthModalProps) {
|
||||||
|
const { setRole, setLoggedIn, fetchProfile } = useUserStore();
|
||||||
|
const [view, setView] = useState<"quick" | "phone" | "otp">("quick");
|
||||||
|
const [phoneNumber, setPhoneNumber] = useState("");
|
||||||
|
const [otpCode, setOtpCode] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [countdown, setCountdown] = useState(0);
|
||||||
|
const otpInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (view === "otp") {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
otpInputRef.current?.focus();
|
||||||
|
}, 100);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [view]);
|
||||||
|
|
||||||
|
// Reset modal state on open/close
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
setView("quick");
|
||||||
|
setPhoneNumber("");
|
||||||
|
setOtpCode("");
|
||||||
|
setIsLoading(false);
|
||||||
|
setCountdown(0);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// Cooldown countdown timer
|
||||||
|
useEffect(() => {
|
||||||
|
if (countdown > 0) {
|
||||||
|
const timer = setTimeout(() => setCountdown(countdown - 1), 1000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [countdown]);
|
||||||
|
|
||||||
|
const handleQuickLogin = async (targetRole: UserRole) => {
|
||||||
|
if (targetRole === "User_Guest") {
|
||||||
|
setRole("User_Guest");
|
||||||
|
setLoggedIn(false);
|
||||||
|
toast.success("به عنوان کاربر مهمان وارد شدید.");
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
const testPhone = targetRole === "User_PetOwner" ? "09121111111" : "09122222222";
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 1: Send OTP to test phone
|
||||||
|
const sendRes = await authService.sendOtp(testPhone);
|
||||||
|
const code = (sendRes as any).code;
|
||||||
|
if (!code) {
|
||||||
|
throw new Error("کد تایید تستی تولید نشد");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Verify OTP
|
||||||
|
const verifyRes = await authService.verifyOtp(testPhone, code);
|
||||||
|
if (verifyRes.success) {
|
||||||
|
// Force state sync and fetch profile
|
||||||
|
setRole(targetRole);
|
||||||
|
await fetchProfile();
|
||||||
|
toast.success(`ورود سریع موفق! در نقش ${targetRole === "User_Partner" ? 'همکار' : 'صاحب پت'} وارد شدید.`);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(`خطا در ورود سریع: ${err.message}`);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendOtp = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const cleanPhone = phoneNumber.trim();
|
||||||
|
if (!/^09\d{9}$/.test(cleanPhone)) {
|
||||||
|
toast.error("شماره موبایل نامعتبر است");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await authService.sendOtp(cleanPhone);
|
||||||
|
if ((res as any).code) {
|
||||||
|
toast.info(`کد تایید (تست): ${(res as any).code}`, { duration: 10000 });
|
||||||
|
} else {
|
||||||
|
toast.success("کد تایید پیامک شد");
|
||||||
|
}
|
||||||
|
setView("otp");
|
||||||
|
setCountdown(120);
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || "خطا در ارسال کد تایید");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVerifyOtp = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const cleanCode = otpCode.trim();
|
||||||
|
if (cleanCode.length !== 5) {
|
||||||
|
toast.error("کد باید ۵ رقم باشد");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await authService.verifyOtp(phoneNumber.trim(), cleanCode);
|
||||||
|
if (response.success) {
|
||||||
|
await fetchProfile();
|
||||||
|
toast.success("ورود موفقیتآمیز بود");
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || "کد تایید نامعتبر است");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResendOtp = async () => {
|
||||||
|
if (countdown > 0) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await authService.sendOtp(phoneNumber.trim());
|
||||||
|
toast.success("کد تایید جدید ارسال شد");
|
||||||
|
setCountdown(120);
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || "خطا در ارسال مجدد کد");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[60]"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal content */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||||
|
className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-md bg-white rounded-[2.5rem] shadow-2xl z-[70] overflow-hidden border border-medical-gray-100 font-vazir"
|
||||||
|
dir="rtl"
|
||||||
|
>
|
||||||
|
{/* Top highlight bar */}
|
||||||
|
<div className="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-canina-blue to-indigo-500" />
|
||||||
|
|
||||||
|
<div className="p-8 relative">
|
||||||
|
{/* Close Button */}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute top-6 left-6 p-2 rounded-full hover:bg-medical-gray-50 text-medical-gray-400 hover:text-medical-gray-600 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Title Header */}
|
||||||
|
<div className="flex flex-col items-center text-center mb-8 pt-4">
|
||||||
|
<div className="w-20 h-20 bg-canina-blue/10 rounded-3xl flex items-center justify-center mb-4 shadow-inner">
|
||||||
|
<ShieldCheck className="w-10 h-10 text-canina-blue" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl font-black text-medical-gray-900 mb-2 italic">
|
||||||
|
ورود به دنیای کانینا
|
||||||
|
</h2>
|
||||||
|
<p className="text-medical-gray-500 text-sm font-bold max-w-[280px]">
|
||||||
|
{view === "otp"
|
||||||
|
? `کد تایید ارسال شده به شماره ${toPersian(phoneNumber)} را وارد کنید`
|
||||||
|
: "برای دسترسی به پرونده سلامت و سفارشات وارد شوید"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Panel */}
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{view === "quick" && (
|
||||||
|
<motion.div
|
||||||
|
key="quick-view"
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -10 }}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => setView("phone")}
|
||||||
|
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-2xl p-5 flex items-center justify-between group hover:border-canina-blue transition-all cursor-pointer shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Phone className="w-5 h-5 text-canina-blue" />
|
||||||
|
<span className="font-black text-sm text-medical-gray-700">ورود با شماره موبایل</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-8 h-8 rounded-xl bg-white border border-medical-gray-100 flex items-center justify-center shadow-sm text-canina-blue group-hover:bg-canina-blue group-hover:text-white transition-all">←</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="pt-6 border-t border-medical-gray-100">
|
||||||
|
<p className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest text-center mb-4">ورود سریع تستی (با دیتابیس واقعی)</p>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => handleQuickLogin("User_Guest")}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex flex-col items-center gap-2 p-3 rounded-2xl border border-medical-gray-100 hover:bg-medical-gray-50 transition-all group cursor-pointer disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<User className="w-6 h-6 text-medical-gray-400 group-hover:text-canina-blue" />
|
||||||
|
<span className="text-[10px] font-black text-medical-gray-700">مهمان</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleQuickLogin("User_PetOwner")}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex flex-col items-center gap-2 p-3 rounded-2xl border border-medical-gray-100 hover:bg-medical-gray-50 transition-all group cursor-pointer disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<RefreshCw className="w-6 h-6 text-canina-blue animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Heart className="w-6 h-6 text-medical-gray-400 group-hover:text-canina-blue" />
|
||||||
|
)}
|
||||||
|
<span className="text-[10px] font-black text-medical-gray-700">صاحب پت</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleQuickLogin("User_Partner")}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex flex-col items-center gap-2 p-3 rounded-2xl border border-medical-gray-100 hover:bg-medical-gray-50 transition-all group cursor-pointer disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<RefreshCw className="w-6 h-6 text-canina-blue animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Building2 className="w-6 h-6 text-medical-gray-400 group-hover:text-canina-blue" />
|
||||||
|
)}
|
||||||
|
<span className="text-[10px] font-black text-medical-gray-700">همکار</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === "phone" && (
|
||||||
|
<motion.form
|
||||||
|
key="phone-view"
|
||||||
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -20 }}
|
||||||
|
onSubmit={handleSendOtp}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<label className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest">شماره موبایل</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setView("quick")}
|
||||||
|
className="text-[10px] font-black text-canina-blue hover:underline"
|
||||||
|
>
|
||||||
|
برگشت به ورود سریع
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Phone className="absolute right-4 top-1/2 -translate-y-1/2 w-5 h-5 text-medical-gray-400" />
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
type="tel"
|
||||||
|
maxLength={11}
|
||||||
|
placeholder="مثال: ۰۹۱۲۳۴۵۶۷۸۹"
|
||||||
|
value={phoneNumber}
|
||||||
|
onChange={(e) => setPhoneNumber(e.target.value.replace(/[^0-9]/g, ''))}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-2xl py-4 pr-12 pl-4 focus:ring-2 focus:ring-canina-blue/20 outline-none font-bold text-lg text-left tracking-widest"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading || phoneNumber.length < 11}
|
||||||
|
className="w-full py-5 bg-canina-blue text-white rounded-2xl font-black text-xl hover:bg-indigo-700 disabled:opacity-50 transition-all shadow-xl shadow-canina-blue/20 flex items-center justify-center gap-3 cursor-pointer"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<RefreshCw className="w-6 h-6 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<LogIn className="w-6 h-6" />
|
||||||
|
ارسال کد تایید
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</motion.form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === "otp" && (
|
||||||
|
<motion.form
|
||||||
|
key="otp-view"
|
||||||
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -20 }}
|
||||||
|
onSubmit={handleVerifyOtp}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<label className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest">کد تایید پیامکی</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setView("phone")}
|
||||||
|
className="text-[10px] font-black text-canina-blue flex items-center gap-1 hover:underline"
|
||||||
|
>
|
||||||
|
<ArrowRight className="w-3 h-3" />
|
||||||
|
تغییر شماره
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Key className="absolute right-4 top-1/2 -translate-y-1/2 w-5 h-5 text-medical-gray-400" />
|
||||||
|
<input
|
||||||
|
ref={otpInputRef}
|
||||||
|
type="text"
|
||||||
|
maxLength={5}
|
||||||
|
placeholder="کد ۵ رقمی"
|
||||||
|
value={otpCode}
|
||||||
|
onChange={(e) => setOtpCode(e.target.value.replace(/[^0-9]/g, ''))}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-2xl py-4 pr-12 pl-4 focus:ring-2 focus:ring-canina-blue/20 outline-none font-black text-2xl text-center tracking-[0.5em]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
{countdown > 0 ? (
|
||||||
|
<span className="text-xs font-bold text-medical-gray-400">
|
||||||
|
ارسال مجدد کد پس از {toPersian(countdown)} ثانیه
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleResendOtp}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="text-xs font-black text-canina-blue hover:underline flex items-center gap-1.5 justify-center mx-auto"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-3.5 h-3.5" />
|
||||||
|
ارسال مجدد کد تایید
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading || otpCode.length < 5}
|
||||||
|
className="w-full py-5 bg-canina-blue text-white rounded-2xl font-black text-xl hover:bg-indigo-700 disabled:opacity-50 transition-all shadow-xl shadow-canina-blue/20 flex items-center justify-center gap-3 cursor-pointer"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<RefreshCw className="w-6 h-6 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<LogIn className="w-6 h-6" />
|
||||||
|
تایید کد و ورود
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</motion.form>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
192
frontend/application/components/B2BPortal.tsx
Normal file
192
frontend/application/components/B2BPortal.tsx
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
"use client";
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Product } from "../data/products";
|
||||||
|
import { useCartStore } from "../store/cartStore";
|
||||||
|
import { productService } from "../services/productService";
|
||||||
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
|
import {
|
||||||
|
Building2,
|
||||||
|
ShoppingCart,
|
||||||
|
Search,
|
||||||
|
ChevronRight,
|
||||||
|
FileText,
|
||||||
|
Package,
|
||||||
|
CheckCircle2,
|
||||||
|
X
|
||||||
|
} from "lucide-react";
|
||||||
|
import SafeImage from "./SafeImage";
|
||||||
|
|
||||||
|
interface QuickOrderItem {
|
||||||
|
product: Product;
|
||||||
|
quantity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function B2BPortal({ onClose }: { onClose: () => void }) {
|
||||||
|
const [products, setProducts] = useState<Product[]>([]);
|
||||||
|
const [quantities, setQuantities] = useState<Record<string, number>>({});
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const { addItem } = useCartStore();
|
||||||
|
const [showSuccess, setShowSuccess] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
productService.getProducts().then(data => setProducts(data));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filteredProducts = products.filter(p =>
|
||||||
|
p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
p.artNo.includes(searchQuery)
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleQuantityChange = (id: string, val: string) => {
|
||||||
|
const num = parseInt(val) || 0;
|
||||||
|
setQuantities(prev => ({ ...prev, [id]: Math.max(0, num) }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddAll = () => {
|
||||||
|
Object.entries(quantities).forEach(([id, qty]) => {
|
||||||
|
const numQty = qty as number;
|
||||||
|
if (numQty > 0) {
|
||||||
|
const product = products.find(p => p.id === id);
|
||||||
|
if (product) {
|
||||||
|
addItem(product, numQty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setShowSuccess(true);
|
||||||
|
setTimeout(() => setShowSuccess(false), 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalItems = Object.values(quantities).reduce((acc: number, val) => acc + (val as any), 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 z-[100] bg-medical-gray-900/60 backdrop-blur-md flex items-center justify-center p-4 md:p-10 font-vazir"
|
||||||
|
dir="rtl"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.9, opacity: 0, y: 20 }}
|
||||||
|
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||||
|
className="bg-white w-full max-w-6xl h-[90vh] rounded-[3rem] shadow-2xl overflow-hidden flex flex-col md:flex-row relative"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute top-6 left-6 z-20 w-10 h-10 bg-medical-gray-100 rounded-full flex items-center justify-center text-medical-gray-500 hover:bg-red-50 hover:text-red-500 transition-all"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Sidebar Info */}
|
||||||
|
<div className="w-full md:w-80 bg-canina-blue p-10 text-white flex flex-col">
|
||||||
|
<div className="mb-10">
|
||||||
|
<div className="w-16 h-16 bg-white/20 rounded-2xl flex items-center justify-center mb-6">
|
||||||
|
<Building2 className="w-8 h-8" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-black mb-4 italic">پنل کلینیکها <br/> و داروخانهها</h2>
|
||||||
|
<p className="text-white/60 text-sm leading-relaxed">
|
||||||
|
این بخش اختصاصی برای ثبت سفارشات حجیم و استعلام موجودی آنی طراحی شده است. قیمتهای نمایش داده شده، فی واحد کاتالوگ هستند.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-auto space-y-4">
|
||||||
|
<div className="p-4 bg-white/10 rounded-2xl flex items-center gap-3">
|
||||||
|
<FileText className="w-5 h-5 text-blue-300" />
|
||||||
|
<span className="text-xs font-bold">دریافت لیست قیمت PDF</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-white/10 rounded-2xl flex items-center gap-3">
|
||||||
|
<Package className="w-5 h-5 text-blue-300" />
|
||||||
|
<span className="text-xs font-bold">پیگیری محمولات قبلی</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Grid Area */}
|
||||||
|
<div className="flex-1 flex flex-col bg-medical-gray-50 overflow-hidden">
|
||||||
|
{/* Header & Search */}
|
||||||
|
<div className="p-8 border-b border-medical-gray-200 flex flex-col md:flex-row md:items-center justify-between gap-6 bg-white">
|
||||||
|
<div className="relative flex-1 max-w-md">
|
||||||
|
<Search className="absolute right-4 top-1/2 -translate-y-1/2 w-4 h-4 text-medical-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="جستجوی کد کالا یا نام محصول..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-2xl py-3 pr-11 pl-4 text-sm focus:outline-none focus:ring-2 focus:ring-canina-blue/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleAddAll}
|
||||||
|
disabled={totalItems === 0}
|
||||||
|
className="bg-medical-gray-900 text-white px-8 py-3 rounded-2xl font-black text-sm flex items-center gap-2 hover:bg-canina-blue transition-colors disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<ShoppingCart className="w-4 h-4" />
|
||||||
|
افزودن کلی ({totalItems} عدد)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-8">
|
||||||
|
<div className="bg-white border border-medical-gray-200 rounded-3xl overflow-hidden shadow-sm">
|
||||||
|
<table className="w-full text-right">
|
||||||
|
<thead className="bg-medical-gray-50 border-b border-medical-gray-200">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-4 text-[10px] font-black text-medical-gray-400 uppercase tracking-widest">تصویر</th>
|
||||||
|
<th className="px-6 py-4 text-[10px] font-black text-medical-gray-400 uppercase tracking-widest">نام و کد کالا</th>
|
||||||
|
<th className="px-6 py-4 text-[10px] font-black text-medical-gray-400 uppercase tracking-widest">وضعیت موجودی</th>
|
||||||
|
<th className="px-6 py-4 text-[10px] font-black text-medical-gray-400 uppercase tracking-widest">تعداد سفارش</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-medical-gray-100">
|
||||||
|
{filteredProducts.map(product => (
|
||||||
|
<tr key={product.id} className="hover:bg-medical-gray-50/50 transition-colors">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<SafeImage src={product.image} className="w-12 h-12 object-contain" alt={product.name} />
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="font-black text-medical-gray-900">{product.name}</div>
|
||||||
|
<div className="text-[10px] font-vazir text-medical-gray-400 mt-1">شناسه کالا: {product.artNo}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-3 py-1 bg-green-50 text-green-600 rounded-full text-[10px] font-bold">
|
||||||
|
<CheckCircle2 className="w-3 h-3" />
|
||||||
|
آماده ارسال
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={quantities[product.id] || ""}
|
||||||
|
onChange={(e) => handleQuantityChange(product.id, e.target.value)}
|
||||||
|
placeholder="0"
|
||||||
|
className="w-20 bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-2 px-3 text-center font-mono font-bold focus:ring-2 focus:ring-canina-blue/20 outline-none"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{showSuccess && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 50 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: 50 }}
|
||||||
|
className="absolute bottom-10 right-10 bg-green-600 text-white px-6 py-4 rounded-2xl shadow-xl flex items-center gap-3 z-50"
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="w-5 h-5" />
|
||||||
|
<span className="font-bold text-sm">سفارشات با موفقیت به سبد خرید اضافه شدند.</span>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
342
frontend/application/components/BlogPage.tsx
Normal file
342
frontend/application/components/BlogPage.tsx
Normal file
@ -0,0 +1,342 @@
|
|||||||
|
"use client";
|
||||||
|
import React from "react";
|
||||||
|
import { motion } from "motion/react";
|
||||||
|
import { ChevronRight, Calendar, User, ArrowLeft, Sparkles, BookOpen } from "lucide-react";
|
||||||
|
import { Product } from "../data/products";
|
||||||
|
import { productService } from "../services/productService";
|
||||||
|
|
||||||
|
const BLOG_POSTS = [
|
||||||
|
{
|
||||||
|
id: "p1",
|
||||||
|
title: "مزایای شگفتانگیز صدف لبسبز نیوزیلند برای مفاصل پت",
|
||||||
|
excerpt: "صدف لبسبز (Perna Canaliculus) تنها منبع طبیعی حاوی گلیکوزآمینگلیکانها (GAG) در غلظت بسیار بالا محسوب میشود.",
|
||||||
|
image: "https://images.unsplash.com/photo-1599443015574-be5fe8a05783?auto=format&fit=crop&q=80&w=800",
|
||||||
|
date: "۲۵ اردیبهشت ۱۴۰۳",
|
||||||
|
author: "دکتر کلاوس هنینگ",
|
||||||
|
content: `
|
||||||
|
صدف لبسبز نیوزیلندی قلب تپنده محصولات ارتوپدی کانینا است. این صدف که منحصراً در آبهای فوقالعاده تمیز نیوزیلند تکثیر میشود، حاوی ترکیبات ضدالتهابی طبیعی است که در هیچ منبع دیگری به این غلظت یافت نمیشود.
|
||||||
|
|
||||||
|
چرا برای سگهای نژاد بزرگ حیاتی است؟
|
||||||
|
در سگهای نژاد بزرگ، سرعت رشد اسکلتی بسیار بالاست. اگر مفاصل و تاندونها با این سرعت هماهنگ نشوند، بدشکلیهای ماندگار ایجاد میشود. GAG موجود در صدف، مانند چسب بیولوژیک عمل کرده و بافت غضروفی را ترمیم میکند.
|
||||||
|
|
||||||
|
مکمل Canhydrox GAG با دارا بودن ۱۵٪ پودر صدف لبسبز خالص، استاندارد طلایی در دامپزشکی آلمان است.
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p2",
|
||||||
|
title: "چگونه سیستم ایمنی پت خود را در برابر بیماریهای فصلی تقویت کنیم؟",
|
||||||
|
excerpt: "آغوز (Colostrum) اولین سد دفاعی طبیعت است. با ایمیونبوستر کانینا، پت شما در برابر ویروسها بیمه میشود.",
|
||||||
|
image: "https://images.unsplash.com/photo-1576091160550-217359f42f8c?auto=format&fit=crop&q=80&w=800",
|
||||||
|
date: "۲۰ اردیبهشت ۱۴۰۳",
|
||||||
|
author: "دکتر النا اشمیت",
|
||||||
|
content: "ایمنی بدن یک مکانیزم پیچیده است. استفاده از Immun-Booster به ویژه در دوران واکسیناسیون یا جابجایی، به بدن پت کمک میکند تا آنتیبادیهای لازم را با سرعت بیشتری تولید کند."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p3",
|
||||||
|
title: "اهمیت اسیدهای چرب امگا ۳ در درخشش پوشش مویی",
|
||||||
|
excerpt: "ریزش موی شدید در سگ و گربه معمولاً نشانه کمبود اسیدهای چرب ضروری EPA و DHA است. روغن سالمون پرس سرد راه حل نهایی است.",
|
||||||
|
image: "https://images.unsplash.com/photo-1516734212186-a967f81ad0d7?auto=format&fit=crop&q=80&w=800",
|
||||||
|
date: "۱۵ اردیبهشت ۱۴۰۳",
|
||||||
|
author: "دکتر ماری کونیگ",
|
||||||
|
content: "روغن سالمون کانینا به روش پرس سرد تهیه شده تا تمامی ویتامینهای حساس به حرارت حفظ شوند. این روغن نه تنها پوست را مرطوب نگه میدارد، بلکه التهابات مفصلی را نیز کاهش میدهد."
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function BlogPage({ onBack, onProductClick }: { onBack: () => void, onProductClick: (p: Product) => void }) {
|
||||||
|
const [selectedPost, setSelectedPost] = React.useState<typeof BLOG_POSTS[0] | null>(null);
|
||||||
|
const [products, setProducts] = React.useState<Product[]>([]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
productService.getProducts().then(data => setProducts(data));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (selectedPost) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-medical-gray-50 pt-8 pb-20 px-4 font-vazir" dir="rtl">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
{/* Breadcrumbs */}
|
||||||
|
<div className="flex items-center gap-2 text-[10px] font-black text-medical-gray-400 uppercase tracking-widest mb-10">
|
||||||
|
<span className="cursor-pointer hover:text-canina-blue" onClick={onBack}>خانه</span>
|
||||||
|
<ChevronRight className="w-2.5 h-2.5" />
|
||||||
|
<span className="cursor-pointer hover:text-canina-blue" onClick={() => setSelectedPost(null)}>مجله سلامت</span>
|
||||||
|
<ChevronRight className="w-2.5 h-2.5" />
|
||||||
|
<span className="text-canina-blue">{selectedPost.title}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid lg:grid-cols-12 gap-12">
|
||||||
|
<div className="lg:col-span-8">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="bg-white rounded-[3.5rem] border border-medical-gray-200 shadow-2xl overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="aspect-video w-full overflow-hidden">
|
||||||
|
<img src={selectedPost.image} alt={selectedPost.title} className="w-full h-full object-cover" />
|
||||||
|
</div>
|
||||||
|
<div className="p-12">
|
||||||
|
<div className="flex items-center gap-4 text-xs font-bold text-canina-blue mb-8">
|
||||||
|
<div className="bg-canina-blue/10 px-4 py-1.5 rounded-full uppercase tracking-widest">تحقیق بالینی</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Calendar className="w-4 h-4" />
|
||||||
|
{selectedPost.date}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-4xl lg:text-5xl font-black text-medical-gray-900 mb-10 leading-tight">
|
||||||
|
{selectedPost.title}
|
||||||
|
</h1>
|
||||||
|
<div className="prose prose-lg max-w-none text-medical-gray-600 leading-[2] text-justify space-y-6">
|
||||||
|
<p className="font-bold text-medical-gray-900 italic text-xl border-r-4 border-canina-blue pr-6 py-2">
|
||||||
|
{selectedPost.excerpt}
|
||||||
|
</p>
|
||||||
|
<div className="whitespace-pre-line">
|
||||||
|
{selectedPost.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-16 pt-10 border-t border-medical-gray-100 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-14 h-14 bg-medical-gray-100 rounded-full flex items-center justify-center text-medical-gray-400 border-2 border-white shadow-lg">
|
||||||
|
<User className="w-8 h-8" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-black text-medical-gray-900 text-lg">{selectedPost.author}</div>
|
||||||
|
<div className="font-bold text-medical-gray-400">تیم تحقیق و توسعه کانینا آلمان</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<button className="p-4 bg-medical-gray-100 rounded-2xl hover:bg-canina-blue hover:text-white transition-all text-medical-gray-400">
|
||||||
|
<Sparkles className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lg:col-span-4 space-y-10">
|
||||||
|
<div className="bg-canina-blue rounded-[3rem] p-10 text-white shadow-2xl relative overflow-hidden">
|
||||||
|
<div className="absolute top-0 left-0 p-8 opacity-10">
|
||||||
|
<Sparkles className="w-20 h-20" />
|
||||||
|
</div>
|
||||||
|
<h4 className="text-2xl font-black mb-6 italic relative z-10">محصولات پیشنهادی</h4>
|
||||||
|
<div className="space-y-6 relative z-10">
|
||||||
|
{products.slice(4, 7).map(p => (
|
||||||
|
<div
|
||||||
|
key={p.id}
|
||||||
|
onClick={() => onProductClick(p)}
|
||||||
|
className="bg-white/10 border border-white/20 p-4 rounded-3xl flex items-center gap-4 cursor-pointer hover:bg-white/20 transition-all group"
|
||||||
|
>
|
||||||
|
<div className="w-16 h-16 bg-white rounded-2xl p-2 shrink-0 group-hover:scale-110 transition-transform">
|
||||||
|
<img src={p.image} alt={p.name} className="w-full h-full object-contain" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] font-black opacity-50 uppercase tracking-widest">{p.category}</div>
|
||||||
|
<div className="text-sm font-black font-vazir text-white">{p.name}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-[3rem] border border-medical-gray-200 p-10 shadow-xl">
|
||||||
|
<h4 className="text-xl font-black italic mb-6">محبوبترین مقالات</h4>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{BLOG_POSTS.map(p => (
|
||||||
|
<div
|
||||||
|
key={p.id}
|
||||||
|
onClick={() => setSelectedPost(p)}
|
||||||
|
className="flex items-center gap-4 cursor-pointer group"
|
||||||
|
>
|
||||||
|
<div className="w-16 h-16 rounded-2xl overflow-hidden shrink-0">
|
||||||
|
<img src={p.image} className="w-full h-full object-cover group-hover:scale-110 transition-transform" />
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-black text-medical-gray-900 group-hover:text-canina-blue transition-colors line-clamp-2">
|
||||||
|
{p.title}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-medical-gray-50 pt-8 pb-20 px-4 font-vazir" dir="rtl">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col md:flex-row items-center justify-between mb-16 gap-6">
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="inline-flex items-center gap-2 px-3 py-1 bg-canina-blue/10 text-canina-blue rounded-full text-[10px] font-black uppercase tracking-widest mb-4">
|
||||||
|
<BookOpen className="w-3 h-3" />
|
||||||
|
Canina Science Blog
|
||||||
|
</div>
|
||||||
|
<h1 className="text-4xl lg:text-6xl font-black text-medical-gray-900 italic leading-tight">
|
||||||
|
مجله تخصصی <span className="text-canina-blue">سلامت پتها</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg text-medical-gray-500 font-medium mt-4 max-w-xl">
|
||||||
|
آخرین یافتههای علمی محققین کانینا آلمان در مورد تغذیه و سلامت پتها.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="px-8 py-3 bg-white border border-medical-gray-200 rounded-2xl text-medical-gray-700 shadow-sm flex items-center gap-2 font-black hover:bg-medical-gray-50 transition-all"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-5 h-5" />
|
||||||
|
بازگشت به خانه
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Featured Post */}
|
||||||
|
<div className="mb-20">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="bg-white rounded-[3.5rem] overflow-hidden border border-medical-gray-200 shadow-2xl flex flex-col lg:flex-row cursor-pointer group"
|
||||||
|
onClick={() => setSelectedPost(BLOG_POSTS[0])}
|
||||||
|
>
|
||||||
|
<div className="lg:w-1/2 aspect-video lg:aspect-auto">
|
||||||
|
<img
|
||||||
|
src={BLOG_POSTS[0].image}
|
||||||
|
alt={BLOG_POSTS[0].title}
|
||||||
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="lg:w-1/2 p-12 flex flex-col justify-center">
|
||||||
|
<div className="flex items-center gap-4 text-xs font-bold text-canina-blue mb-6">
|
||||||
|
<div className="bg-canina-blue/10 px-3 py-1 rounded-full uppercase tracking-widest">تخصصی</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Calendar className="w-3 h-3" />
|
||||||
|
{BLOG_POSTS[0].date}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl lg:text-4xl font-black text-medical-gray-900 mb-6 leading-tight group-hover:text-canina-blue transition-colors">
|
||||||
|
{BLOG_POSTS[0].title}
|
||||||
|
</h2>
|
||||||
|
<p className="text-lg text-medical-gray-500 mb-8 leading-relaxed italic">
|
||||||
|
{BLOG_POSTS[0].excerpt}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-between pt-8 border-t border-medical-gray-100">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-medical-gray-100 rounded-full flex items-center justify-center text-medical-gray-400">
|
||||||
|
<User className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
<div className="font-black text-medical-gray-900">{BLOG_POSTS[0].author}</div>
|
||||||
|
<div className="font-bold text-medical-gray-400">محقق ارشد کانینا</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-canina-blue font-black">
|
||||||
|
ادامه مطلب
|
||||||
|
<ArrowLeft className="w-5 h-5 group-hover:-translate-x-2 transition-transform" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Post Grid */}
|
||||||
|
<div className="grid md:grid-cols-2 gap-8">
|
||||||
|
{BLOG_POSTS.slice(1).map((post, idx) => (
|
||||||
|
<motion.div
|
||||||
|
key={post.id}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ delay: idx * 0.1 }}
|
||||||
|
className="bg-white rounded-[3rem] overflow-hidden border border-medical-gray-200 shadow-xl group hover:shadow-2xl transition-all cursor-pointer"
|
||||||
|
onClick={() => setSelectedPost(post)}
|
||||||
|
>
|
||||||
|
<div className="aspect-[16/9] overflow-hidden">
|
||||||
|
<img
|
||||||
|
src={post.image}
|
||||||
|
alt={post.title}
|
||||||
|
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="flex items-center gap-4 text-[10px] font-bold text-canina-blue mb-4">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Calendar className="w-3 h-3" />
|
||||||
|
{post.date}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-black text-medical-gray-900 mb-4 group-hover:text-canina-blue transition-colors">
|
||||||
|
{post.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-medical-gray-500 mb-8 leading-relaxed line-clamp-2">
|
||||||
|
{post.excerpt}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 text-medical-gray-900 font-bold group">
|
||||||
|
مطالعه مقاله
|
||||||
|
<ArrowLeft className="w-4 h-4 group-hover:-translate-x-1 transition-transform" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Newsletter / CTA Box */}
|
||||||
|
<div className="bg-canina-blue rounded-[3rem] p-10 text-white relative overflow-hidden flex flex-col justify-center">
|
||||||
|
<div className="absolute top-0 right-0 p-8 opacity-20">
|
||||||
|
<Sparkles className="w-24 h-24" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-3xl font-black mb-4 italic">مشترک خبرنامه علمی شوید</h3>
|
||||||
|
<p className="text-white/80 mb-8 font-medium leading-relaxed">
|
||||||
|
ماهانه یک ایمیل حاوی مهمترین نکات سلامت پتها و کدهای تخفیف اختصاصی دریافت کنید.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="آدرس ایمیل شما"
|
||||||
|
className="flex-1 bg-white/10 border border-white/20 rounded-xl px-4 py-3 outline-none focus:bg-white/20 placeholder:text-white/40"
|
||||||
|
/>
|
||||||
|
<button className="bg-white text-canina-blue px-6 py-3 rounded-xl font-black">
|
||||||
|
عضویت
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Synergy Products Cross-sell */}
|
||||||
|
<section className="mt-32 p-12 bg-canina-blue rounded-[4rem] text-white overflow-hidden relative shadow-2xl">
|
||||||
|
<div className="absolute inset-0 opacity-20 pointer-events-none">
|
||||||
|
<div className="grid grid-cols-6 h-full w-full">
|
||||||
|
{Array.from({length: 36}).map((_, i) => (
|
||||||
|
<div key={i} className="border border-white/20" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative z-10 flex flex-col lg:flex-row items-center gap-12">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-3xl lg:text-4xl font-black italic mb-6">علم آلمانی در خدمت سلامت پت شما</h3>
|
||||||
|
<p className="text-white/80 mb-8 leading-relaxed max-w-xl">
|
||||||
|
تمامی مقالات این وبلاگ بر اساس کاتالوگهای رسمی و نتایج آزمایشگاهی کمپانی کانینا آلمان تدوین شده است.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => onBack()}
|
||||||
|
className="bg-white text-canina-blue px-8 py-4 rounded-2xl font-black hover:scale-105 transition-transform"
|
||||||
|
>
|
||||||
|
مشاهده محصولات تخصصی
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4 flex-shrink-0">
|
||||||
|
{products.slice(0, 2).map((p) => (
|
||||||
|
<div
|
||||||
|
key={p.id}
|
||||||
|
onClick={() => onProductClick(p)}
|
||||||
|
className="bg-white/10 border border-white/20 p-6 rounded-3xl cursor-pointer hover:bg-white/20 transition-all text-center group"
|
||||||
|
>
|
||||||
|
<img src={p.image} alt={p.name} className="w-20 h-20 mx-auto mb-4 object-contain filter brightness-0 invert group-hover:scale-110 transition-transform" />
|
||||||
|
<h5 className="text-[10px] font-black">{p.name}</h5>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
310
frontend/application/components/CartDrawer.tsx
Normal file
310
frontend/application/components/CartDrawer.tsx
Normal file
@ -0,0 +1,310 @@
|
|||||||
|
"use client";
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { X, Trash2, Plus, Minus, ShoppingBag, ShieldCheck, ArrowLeft, Ticket, Sparkles, CheckCircle2 } from "lucide-react";
|
||||||
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
|
import { toPersian, cn } from "../lib/utils";
|
||||||
|
import { useCartStore } from "../store/cartStore";
|
||||||
|
import { Product } from "../data/products";
|
||||||
|
import { productService } from "../services/productService";
|
||||||
|
import SafeImage from "./SafeImage";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export default function CartDrawer({ isOpen, onClose, onCheckout, onShopNavigate }: {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onCheckout: () => void;
|
||||||
|
onShopNavigate?: () => void;
|
||||||
|
}) {
|
||||||
|
const { items, updateQuantity, removeItem, isSubscribed, toggleSubscription, getTotal, getSubtotal, getDiscount, coupon, applyCoupon, addItem } = useCartStore();
|
||||||
|
const [couponCode, setCouponCode] = useState("");
|
||||||
|
const [couponStatus, setCouponStatus] = useState<"idle" | "success" | "error">("idle");
|
||||||
|
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleApplyCoupon = () => {
|
||||||
|
const success = applyCoupon(couponCode);
|
||||||
|
if (success) {
|
||||||
|
setCouponStatus("success");
|
||||||
|
setTimeout(() => setCouponStatus("idle"), 3000);
|
||||||
|
} else {
|
||||||
|
setCouponStatus("error");
|
||||||
|
setTimeout(() => setCouponStatus("idle"), 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Smart Cross-sell Logic
|
||||||
|
const hasCanhydrox = items.some(i => i.product.id === 'canhydrox-gag');
|
||||||
|
const hasLachsOl = items.some(i => i.product.id === 'lachs-ol');
|
||||||
|
const [suggestedProduct, setSuggestedProduct] = useState<Product | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasLachsOl && hasCanhydrox) {
|
||||||
|
productService.getProducts().then(prods => {
|
||||||
|
const found = prods.find(p => p.id === 'lachs-ol' || p.artNo === 'lachs-ol');
|
||||||
|
setSuggestedProduct(found || null);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setSuggestedProduct(null);
|
||||||
|
}
|
||||||
|
}, [hasLachsOl, hasCanhydrox]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={onClose}
|
||||||
|
className="fixed inset-0 bg-medical-gray-900/40 backdrop-blur-sm z-[100]"
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ x: "100%" }}
|
||||||
|
animate={{ x: 0 }}
|
||||||
|
exit={{ x: "100%" }}
|
||||||
|
transition={{ type: "spring", damping: 25, stiffness: 200 }}
|
||||||
|
className="fixed inset-y-0 right-0 w-full max-w-md bg-white shadow-2xl z-[101] flex flex-col"
|
||||||
|
dir="rtl"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-6 border-b border-medical-gray-100 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-medical-gray-50 rounded-xl flex items-center justify-center text-canina-blue">
|
||||||
|
<ShoppingBag className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-black text-medical-gray-900 italic">سبد خرید</h3>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="p-2 hover:bg-medical-gray-50 rounded-full transition-colors">
|
||||||
|
<X className="w-6 h-6 text-medical-gray-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className="h-full flex flex-col items-center justify-center text-center space-y-4">
|
||||||
|
<div className="w-20 h-20 bg-medical-gray-50 rounded-full flex items-center justify-center text-medical-gray-300">
|
||||||
|
<ShoppingBag className="w-10 h-10" />
|
||||||
|
</div>
|
||||||
|
<p className="text-medical-gray-500 font-bold">سبد خرید شما خالی است</p>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onClose();
|
||||||
|
onShopNavigate?.();
|
||||||
|
}}
|
||||||
|
className="text-canina-blue font-black text-sm border-b-2 border-canina-blue pb-1 font-vazir"
|
||||||
|
>
|
||||||
|
مشاهده محصولات تخصصی
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.product.id} className="flex gap-4 p-4 bg-medical-gray-50 rounded-3xl border border-medical-gray-100 group">
|
||||||
|
<div className="w-20 h-20 bg-white rounded-2xl p-2 flex-shrink-0 flex items-center justify-center shadow-sm">
|
||||||
|
<SafeImage src={item.product.image} alt={item.product.name} className="w-full h-full object-contain" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h4 className="text-sm font-black text-medical-gray-900 truncate mb-1">{item.product.name}</h4>
|
||||||
|
<p className="text-[10px] text-canina-blue font-bold mb-3">{item.product.category}</p>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 bg-white rounded-lg border border-medical-gray-200 p-1">
|
||||||
|
<button
|
||||||
|
onClick={() => updateQuantity(item.product.id, item.quantity - 1)}
|
||||||
|
className="p-1 hover:text-canina-blue transition-colors"
|
||||||
|
data-testid="minus-btn"
|
||||||
|
>
|
||||||
|
<Minus className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
<span className="text-xs font-black min-w-[20px] text-center font-vazir">{toPersian(item.quantity)}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => updateQuantity(item.product.id, item.quantity + 1)}
|
||||||
|
className="p-1 hover:text-canina-blue transition-colors"
|
||||||
|
data-testid="plus-btn"
|
||||||
|
>
|
||||||
|
<Plus className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteConfirmId(item.product.id)}
|
||||||
|
className="text-medical-gray-300 hover:text-red-500 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-left flex flex-col justify-between items-end">
|
||||||
|
<span className="text-xs font-black text-medical-gray-900 font-vazir">{toPersian((item.product.priceValue * item.quantity).toLocaleString())}</span>
|
||||||
|
<span className="text-[10px] text-medical-gray-400 font-bold">تومان</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Overlay */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{deleteConfirmId === item.product.id && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="absolute inset-0 bg-white/95 z-40 flex flex-col items-center justify-center p-4 text-center rounded-3xl"
|
||||||
|
>
|
||||||
|
<div className="w-10 h-10 bg-red-50 rounded-full flex items-center justify-center text-red-500 mb-2">
|
||||||
|
<Trash2 className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] font-black text-medical-gray-900 mb-3 font-vazir">
|
||||||
|
مطمئنی میخوای این محصول رو حذف کنی؟
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2 w-full px-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
removeItem(item.product.id);
|
||||||
|
setDeleteConfirmId(null);
|
||||||
|
toast.error(`${item.product.name} حذف شد`);
|
||||||
|
}}
|
||||||
|
className="flex-1 bg-red-500 text-white py-2 rounded-xl text-[10px] font-black shadow-lg shadow-red-500/20"
|
||||||
|
>
|
||||||
|
حذف
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteConfirmId(null)}
|
||||||
|
className="flex-1 bg-medical-gray-100 text-medical-gray-600 py-2 rounded-xl text-[10px] font-black"
|
||||||
|
>
|
||||||
|
لغو
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Smart Cross-sell Recommendation */}
|
||||||
|
{suggestedProduct && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="p-5 bg-gradient-to-br from-canina-blue/5 to-transparent border border-canina-blue/10 rounded-3xl"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<Sparkles className="w-4 h-4 text-canina-blue animate-pulse" />
|
||||||
|
<span className="text-xs font-black text-medical-gray-900">پیشنهاد هوشمند برای جذب بهتر</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-16 h-16 bg-white rounded-2xl p-2 shrink-0 shadow-sm border border-canina-blue/5">
|
||||||
|
<SafeImage src={suggestedProduct.image} alt="" className="w-full h-full object-contain" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h5 className="text-[10px] font-black text-medical-gray-900 mb-1">{suggestedProduct.name}</h5>
|
||||||
|
<p className="text-[9px] text-medical-gray-500 font-medium leading-tight">برای جذب ویتامینهای Canhydrox، اسیدهای چرب سالمون ضروری هستند.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => addItem(suggestedProduct, 1)}
|
||||||
|
className="bg-canina-blue text-white w-8 h-8 rounded-full flex items-center justify-center hover:scale-110 transition-transform shadow-lg shadow-canina-blue/20"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
{items.length > 0 && (
|
||||||
|
<div className="p-6 bg-medical-gray-50 border-t border-medical-gray-200 space-y-6">
|
||||||
|
{/* Discount Code */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2 text-[10px] font-black text-medical-gray-400 uppercase tracking-widest px-2">
|
||||||
|
<Ticket className="w-3 h-3" />
|
||||||
|
کد تخفیف اختصاصی
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="CANINA2024"
|
||||||
|
value={couponCode}
|
||||||
|
onChange={(e) => setCouponCode(e.target.value)}
|
||||||
|
className={`w-full bg-white border ${couponStatus === 'error' ? 'border-red-300' : 'border-medical-gray-200'} rounded-2xl py-3 px-4 pr-10 text-sm font-bold focus:outline-none focus:ring-2 focus:ring-canina-blue/20 transition-all`}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleApplyCoupon}
|
||||||
|
className={`absolute left-2 top-1/2 -translate-y-1/2 px-4 py-1.5 rounded-xl text-[10px] font-black transition-all ${couponStatus === 'success' ? 'bg-green-600 text-white' : 'bg-medical-gray-900 text-white hover:bg-canina-blue'}`}
|
||||||
|
>
|
||||||
|
{couponStatus === 'success' ? <CheckCircle2 className="w-3 h-3" /> : 'اعمال'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{coupon && (
|
||||||
|
<div className="flex items-center justify-between p-3 bg-green-50 border border-green-100 rounded-2xl">
|
||||||
|
<div className="flex items-center gap-2 text-[10px] font-bold text-green-600">
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-green-600 animate-pulse" />
|
||||||
|
کد {coupon.code} ({(coupon.discount < 1 ? coupon.discount * 100 : coupon.discount)} {coupon.discount < 1 ? '٪' : 'تومان'}) اعمال شد
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
useCartStore.getState().removeCoupon();
|
||||||
|
setCouponCode("");
|
||||||
|
}}
|
||||||
|
className="p-1 hover:bg-red-50 hover:text-red-500 text-medical-gray-400 rounded-lg transition-all"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Subscription Toggle */}
|
||||||
|
<div
|
||||||
|
onClick={toggleSubscription}
|
||||||
|
className={`p-4 rounded-2xl border-2 transition-all cursor-pointer ${isSubscribed ? 'bg-canina-blue/5 border-canina-blue' : 'bg-white border-medical-gray-100 hover:border-canina-blue/30'}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`w-10 h-10 rounded-xl flex items-center justify-center transition-colors ${isSubscribed ? 'bg-canina-blue text-white' : 'bg-medical-gray-50 text-medical-gray-300'}`}>
|
||||||
|
<ShieldCheck className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h5 className="text-xs font-black text-medical-gray-900">فعالسازی سرویس تمدید خودکار (Refill)</h5>
|
||||||
|
<p className="text-[10px] text-medical-gray-500 font-medium">۵٪ تخفیف روی تمام فاکتور + ارسال اولویتدار</p>
|
||||||
|
</div>
|
||||||
|
<div className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${isSubscribed ? 'bg-canina-blue border-canina-blue' : 'border-medical-gray-200'}`}>
|
||||||
|
{isSubscribed && <X className="w-3 h-3 text-white" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm px-2">
|
||||||
|
<span className="text-medical-gray-500 font-bold">مجموع اقلام</span>
|
||||||
|
<span className="text-medical-gray-900 font-black font-vazir">{toPersian(getSubtotal().toLocaleString())} تومان</span>
|
||||||
|
</div>
|
||||||
|
{getDiscount() > 0 && (
|
||||||
|
<div className="flex items-center justify-between text-sm text-green-600 px-2 font-black">
|
||||||
|
<span className="font-bold">مجموع تخفیفها</span>
|
||||||
|
<span className="font-black font-vazir">-{toPersian(getDiscount().toLocaleString())} تومان</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center justify-between border-t border-medical-gray-200 pt-4 px-2">
|
||||||
|
<span className="text-lg font-black text-medical-gray-900 italic">مجموع قابل پرداخت</span>
|
||||||
|
<div className="text-left">
|
||||||
|
<p className="text-2xl font-black text-canina-blue tracking-tighter leading-none font-vazir">{toPersian(getTotal().toLocaleString())}</p>
|
||||||
|
<p className="text-[10px] text-medical-gray-400 font-bold">تومان</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onCheckout}
|
||||||
|
className="w-full bg-canina-blue text-white py-5 rounded-[2.5rem] font-black text-lg hover:shadow-2xl hover:shadow-canina-blue/20 transition-all flex items-center justify-center gap-3 group"
|
||||||
|
>
|
||||||
|
ثبت نهایی و تسویه حساب
|
||||||
|
<ArrowLeft className="w-5 h-5 group-hover:translate-x-[-4px] transition-transform" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
389
frontend/application/components/CheckoutPage.tsx
Normal file
389
frontend/application/components/CheckoutPage.tsx
Normal file
@ -0,0 +1,389 @@
|
|||||||
|
"use client";
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { motion } from "motion/react";
|
||||||
|
import {
|
||||||
|
ChevronRight,
|
||||||
|
ChevronLeft,
|
||||||
|
MapPin,
|
||||||
|
CreditCard,
|
||||||
|
Truck,
|
||||||
|
ShieldCheck,
|
||||||
|
Calendar,
|
||||||
|
Wallet,
|
||||||
|
Clock,
|
||||||
|
ArrowRight,
|
||||||
|
CheckCircle2,
|
||||||
|
Heart,
|
||||||
|
PlusCircle
|
||||||
|
} from "lucide-react";
|
||||||
|
import { toPersian, cn } from "../lib/utils";
|
||||||
|
import { useCartStore } from "../store/cartStore";
|
||||||
|
import { usePetStore } from "../store/usePetStore";
|
||||||
|
import { useUserStore } from "../store/userStore";
|
||||||
|
import { Product } from "../data/products";
|
||||||
|
import { productService } from "../services/productService";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export default function CheckoutPage({ onBack, onComplete }: { onBack: () => void; onComplete: (orderId: string) => void }) {
|
||||||
|
const { items, getTotal, getSubtotal, getDiscount, isSubscribed, charityDonation, setCharityDonation, addOrder, clearCart } = useCartStore();
|
||||||
|
const { getActivePet, updatePet } = usePetStore();
|
||||||
|
const { profile, updateProfile } = useUserStore();
|
||||||
|
const [step, setStep] = useState(1);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [useRoundUp, setUseRoundUp] = useState(false);
|
||||||
|
const [dbProducts, setDbProducts] = useState<Product[]>([]);
|
||||||
|
const [paymentMethod, setPaymentMethod] = useState<string>('online');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
productService.getProducts().then(data => setDbProducts(data));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const subtotal = getSubtotal();
|
||||||
|
const roundedAmount = Math.ceil(subtotal / 10000) * 10000;
|
||||||
|
const roundUpDiff = roundedAmount - subtotal;
|
||||||
|
|
||||||
|
const handleCharityToggle = () => {
|
||||||
|
if (!useRoundUp) {
|
||||||
|
setCharityDonation(Math.max(roundUpDiff, Math.round(subtotal * 0.01)));
|
||||||
|
setUseRoundUp(true);
|
||||||
|
} else {
|
||||||
|
setCharityDonation(0);
|
||||||
|
setUseRoundUp(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFinalize = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const activePet = getActivePet();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Register the order in the backend
|
||||||
|
const orderId = await addOrder({
|
||||||
|
items: [...items],
|
||||||
|
total: getTotal(),
|
||||||
|
charityDonation: charityDonation,
|
||||||
|
petId: activePet?.id
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Update user charity total
|
||||||
|
if (charityDonation > 0) {
|
||||||
|
updateProfile({
|
||||||
|
charityDonationTotal: (profile.charityDonationTotal || 0) + charityDonation
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Update pet consumptions for the refill logic
|
||||||
|
if (activePet) {
|
||||||
|
const newConsumptions = [...(activePet.consumptions || [])];
|
||||||
|
|
||||||
|
items.forEach(item => {
|
||||||
|
const existing = newConsumptions.find(c => c.productId === item.product.id);
|
||||||
|
if (existing) {
|
||||||
|
existing.remaining += item.product.packageSize * item.quantity;
|
||||||
|
existing.packageSize = item.product.packageSize;
|
||||||
|
} else {
|
||||||
|
newConsumptions.push({
|
||||||
|
productId: item.product.id,
|
||||||
|
packageSize: item.product.packageSize,
|
||||||
|
remaining: item.product.packageSize * item.quantity
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
updatePet(activePet.id, { consumptions: newConsumptions });
|
||||||
|
}
|
||||||
|
|
||||||
|
clearCart();
|
||||||
|
onComplete(orderId);
|
||||||
|
toast.success("سفارش شما با موفقیت ثبت شد و به پرونده سلامت همدمتان اضافه شد!");
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || "خطا در ثبت سفارش. لطفاً مجدداً تلاش کنید.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-medical-gray-50 flex items-center justify-center p-6" dir="rtl">
|
||||||
|
<div className="text-center space-y-6">
|
||||||
|
<div className="w-24 h-24 bg-white rounded-full flex items-center justify-center mx-auto text-medical-gray-200">
|
||||||
|
<ShieldCheck className="w-12 h-12" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-black text-medical-gray-900 italic">سبد خرید شما خالی است</h2>
|
||||||
|
<button onClick={onBack} className="text-canina-blue font-black underline underline-offset-8">بازگشت به فروشگاه</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-medical-gray-50 py-12 px-6 font-vazir" dir="rtl">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
|
||||||
|
{/* Mobile Navigation & Breadcrumbs */}
|
||||||
|
<div className="lg:hidden mb-10 space-y-4">
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="flex items-center gap-2 text-medical-gray-500 font-bold hover:text-canina-blue transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-5 h-5" />
|
||||||
|
<span>بازگشت به سبد خرید</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-[10px] font-black text-medical-gray-400 uppercase tracking-widest overflow-x-auto whitespace-nowrap pb-2">
|
||||||
|
<span className="cursor-pointer hover:text-canina-blue" onClick={onBack}>سبد خرید</span>
|
||||||
|
<ChevronLeft className="w-2.5 h-2.5" />
|
||||||
|
<span className="text-canina-blue">نهاییسازی سفارش</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop Breadcrumbs (Hidden on Mobile) */}
|
||||||
|
<div className="hidden lg:flex items-center gap-2 text-[10px] font-black text-medical-gray-400 uppercase tracking-widest mb-10">
|
||||||
|
<span className="cursor-pointer hover:text-canina-blue" onClick={onBack}>خانه</span>
|
||||||
|
<ChevronLeft className="w-2.5 h-2.5" />
|
||||||
|
<span className="text-canina-blue">درگاه پرداخت امن و نهاییسازی</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid lg:grid-cols-12 gap-12">
|
||||||
|
{/* Main Form */}
|
||||||
|
<div className="lg:col-span-8 space-y-8">
|
||||||
|
{/* Step 1: Shipping */}
|
||||||
|
<section className="bg-white rounded-[3rem] border border-medical-gray-200 p-10 overflow-hidden relative">
|
||||||
|
<div className="absolute top-0 right-0 w-3 h-full bg-canina-blue opacity-20" />
|
||||||
|
<div className="flex items-center gap-4 mb-10">
|
||||||
|
<div className="w-12 h-12 bg-medical-gray-900 text-white rounded-2xl flex items-center justify-center font-black">۱</div>
|
||||||
|
<h2 className="text-2xl font-black text-medical-gray-900 italic">اطلاعات ارسال</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block">نام و نام خانوادگی</label>
|
||||||
|
<input type="text" className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-2xl py-4 px-6 text-sm focus:outline-none focus:ring-2 focus:ring-canina-blue/20" placeholder="مثلاً: پارسا آقایی" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block">شماره تماس</label>
|
||||||
|
<input type="text" className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-2xl py-4 px-6 text-sm focus:outline-none focus:ring-2 focus:ring-canina-blue/20" placeholder="۰۹۱۲XXXXXXX" />
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2 space-y-2">
|
||||||
|
<label className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block">آدرس دقیق پستی</label>
|
||||||
|
<textarea className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-2xl py-4 px-6 text-sm focus:outline-none focus:ring-2 focus:ring-canina-blue/20 h-32" placeholder="استان، شهر، خیابان..." />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Step 2: Payment */}
|
||||||
|
<section className="bg-white rounded-[3rem] border border-medical-gray-200 p-10 overflow-hidden relative">
|
||||||
|
<div className="absolute top-0 right-0 w-3 h-full bg-medical-gray-900 opacity-20" />
|
||||||
|
<div className="flex items-center gap-4 mb-10">
|
||||||
|
<div className="w-12 h-12 bg-medical-gray-900 text-white rounded-2xl flex items-center justify-center font-black">۲</div>
|
||||||
|
<h2 className="text-2xl font-black text-medical-gray-900 italic">شیوه پرداخت</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-3 gap-4">
|
||||||
|
{[
|
||||||
|
{ id: 'online', label: 'پرداخت آنلاین', desc: 'کارتهای بانکی', icon: <CreditCard className="w-6 h-6" /> },
|
||||||
|
{ id: 'wallet', label: 'اعتبار حساب', desc: 'شارژ شده اختصاصی', icon: <Wallet className="w-6 h-6" /> },
|
||||||
|
{ id: 'cod', label: 'پرداخت در محل', desc: 'فقط تهران', icon: <Truck className="w-6 h-6" /> }
|
||||||
|
].map((pay) => (
|
||||||
|
<div
|
||||||
|
key={pay.id}
|
||||||
|
onClick={async () => {
|
||||||
|
setPaymentMethod(pay.id);
|
||||||
|
try {
|
||||||
|
// In a real scenario, this would call api.post('/payment/init', { method: pay.id, amount: getTotal() })
|
||||||
|
// and redirect to the returned gatewayUrl.
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Payment init failed");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={cn("p-6 rounded-3xl border-2 cursor-pointer transition-all group", paymentMethod === pay.id ? "border-canina-blue bg-canina-blue/5" : "border-medical-gray-100 hover:border-canina-blue")}
|
||||||
|
>
|
||||||
|
<div className="w-12 h-12 bg-medical-gray-50 rounded-2xl flex items-center justify-center text-medical-gray-400 group-hover:text-canina-blue group-hover:bg-canina-blue/10 mb-4 transition-all">
|
||||||
|
{pay.icon}
|
||||||
|
</div>
|
||||||
|
<h4 className="font-black text-sm text-medical-gray-900 mb-1">{pay.label}</h4>
|
||||||
|
<p className="text-[10px] text-medical-gray-400 font-bold">{pay.desc}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Step 3: Charity Section */}
|
||||||
|
<section className="bg-white rounded-[3rem] border border-medical-gray-200 p-10 overflow-hidden relative">
|
||||||
|
<div className="absolute top-0 right-0 w-3 h-full bg-pink-500 opacity-20" />
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 bg-pink-500 text-white rounded-2xl flex items-center justify-center">
|
||||||
|
<Heart className="w-6 h-6 fill-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-black text-medical-gray-900 italic">ردپای مهربانی</h2>
|
||||||
|
<p className="text-xs font-bold text-pink-500 mt-1">سهم شما در حمایت از حیوانات بیپناه</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-pink-50 rounded-[2.5rem] p-8 border border-pink-100">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-6">
|
||||||
|
<div className="flex-1 min-w-[280px]">
|
||||||
|
<h4 className="text-lg font-black text-medical-gray-900 italic mb-2">رند کردن مبلغ و کمک به پناهگاه</h4>
|
||||||
|
<p className="text-sm text-medical-gray-600 leading-relaxed font-medium"> با انتخاب این گزینه، مابقی مبلغ تا ده هزار تومان بعدی یا ۱٪ از خرید شما (هر کدام بیشتر باشد) صرف تامین غذا و دارو برای حیوانات بی-سرپرست تحت حمایت کانینا میشود.</p>
|
||||||
|
<div className="mt-4 flex items-center gap-3 text-pink-600">
|
||||||
|
<ShieldCheck className="w-4 h-4" />
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-widest">گزارش شفاف هزینهکرد در داشبورد شما</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleCharityToggle}
|
||||||
|
className={cn(
|
||||||
|
"w-20 h-20 rounded-full flex items-center justify-center transition-all shadow-xl hover:scale-110 active:scale-95",
|
||||||
|
useRoundUp ? "bg-pink-500 text-white shadow-pink-200" : "bg-white text-medical-gray-300 border-2 border-medical-gray-100"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<PlusCircle className={cn("w-10 h-10", useRoundUp ? "rotate-45" : "rotate-0")} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{useRoundUp && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="mt-6 pt-6 border-t border-pink-100 flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-black text-pink-600 italic">مبلغ اهدایی شما:</span>
|
||||||
|
<span className="text-lg font-black font-vazir text-pink-500">{toPersian(charityDonation.toLocaleString())} تومان</span>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-6 text-[10px] text-center text-medical-gray-400 font-bold">«با خرید این محصول، شما هم در درمان یک حیوان پناهگاهی سهیم شدید.»</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar Summary */}
|
||||||
|
<div className="lg:col-span-4 space-y-8">
|
||||||
|
<div className="bg-medical-gray-900 rounded-[3rem] p-10 text-white shadow-2xl sticky top-32">
|
||||||
|
<h3 className="text-2xl font-black italic mb-8 border-b border-white/10 pb-4">خلاصه سفارش</h3>
|
||||||
|
|
||||||
|
<div className="space-y-6 mb-10 overflow-y-auto max-h-60 pr-2">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.product.id} className="flex justify-between items-start gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-black text-white/90 leading-tight">{item.product.name}</p>
|
||||||
|
<p className="text-[10px] text-white/40 font-bold mt-1">تعداد: {toPersian(item.quantity)} بسته</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-mono font-bold font-vazir">{toPersian((item.product.priceValue * item.quantity).toLocaleString())}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 pt-8 border-t border-white/10">
|
||||||
|
<div className="flex justify-between text-sm font-bold text-white/60">
|
||||||
|
<span>مجموع اقلام</span>
|
||||||
|
<span className="font-vazir">{toPersian(getSubtotal().toLocaleString())} تومان</span>
|
||||||
|
</div>
|
||||||
|
{isSubscribed && (
|
||||||
|
<div className="flex justify-between text-sm font-bold text-green-400">
|
||||||
|
<span>تخفیف اشتراک (۵٪-)</span>
|
||||||
|
<span className="font-vazir">{toPersian(getDiscount().toLocaleString())} تومان</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{charityDonation > 0 && (
|
||||||
|
<div className="flex justify-between text-sm font-bold text-pink-400">
|
||||||
|
<span>ردپای مهربانی</span>
|
||||||
|
<span className="font-vazir">{toPersian(charityDonation.toLocaleString())} تومان+</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-between text-sm font-bold text-white/60">
|
||||||
|
<span>هزینه ارسال</span>
|
||||||
|
<span className="text-green-400 uppercase tracking-widest text-[10px] font-black">رایگان</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-end pt-4">
|
||||||
|
<span className="text-xl font-black italic">مبلغ نهایی</span>
|
||||||
|
<div className="text-left">
|
||||||
|
<p className="text-3xl font-black text-canina-blue leading-none font-vazir">{toPersian(getTotal().toLocaleString())}</p>
|
||||||
|
<p className="text-[10px] text-white/30 font-bold mt-1">تومان</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Discount Code */}
|
||||||
|
<div className="mt-8 pt-8 border-t border-white/10">
|
||||||
|
<form
|
||||||
|
className="flex gap-2"
|
||||||
|
onSubmit={async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const code = (e.currentTarget.elements.namedItem('coupon') as HTMLInputElement).value;
|
||||||
|
const success = await useCartStore.getState().applyCoupon(code);
|
||||||
|
if (success) {
|
||||||
|
toast.success("تبریک! کد تخفیف با موفقیت اعمال شد.");
|
||||||
|
} else {
|
||||||
|
toast.error("کد تخفیف معتبر نیست.");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
name="coupon"
|
||||||
|
type="text"
|
||||||
|
placeholder="کد تخفیف (مثلاً: CANINA2024)"
|
||||||
|
className="flex-1 bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-xs focus:ring-1 focus:ring-canina-blue outline-none"
|
||||||
|
/>
|
||||||
|
<button type="submit" className="bg-white/10 px-4 py-3 rounded-xl text-[10px] font-black uppercase tracking-widest hover:bg-white/20 transition-all">اعمال</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Refill Estimates */}
|
||||||
|
<div className="mt-10 p-6 bg-white/5 rounded-3xl border border-white/10 space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Clock className="w-5 h-5 text-canina-blue" />
|
||||||
|
<h4 className="text-xs font-black uppercase tracking-widest font-vazir">تخمین اتمام مصرف (هوش مصنوعی)</h4>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{items.map(item => {
|
||||||
|
const activePet = usePetStore.getState().getActivePet();
|
||||||
|
// Re-hydrate product to ensure methods like calculateDosage exist
|
||||||
|
const fullProduct = dbProducts.find(p => p.id === item.product.id) || item.product;
|
||||||
|
const dose = activePet ? fullProduct.calculateDosage(activePet.weight, activePet.age <= 1) : { quantity: 1 };
|
||||||
|
const days = Math.floor(fullProduct.packageSize / (dose.quantity || 1));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={item.product.id} className="flex items-center justify-between text-[10px] font-medium font-vazir">
|
||||||
|
<span className="text-white/40">{item.product.name}</span>
|
||||||
|
<span className="text-canina-blue">~ {toPersian(days)} روز دیگر</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleFinalize}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-canina-blue text-white py-6 rounded-2xl font-black text-xl mt-10 hover:scale-[1.02] transition-all shadow-xl shadow-canina-blue/20 flex items-center justify-center gap-3 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<div className="w-6 h-6 border-4 border-white/20 border-t-white rounded-full animate-spin" />
|
||||||
|
در حال ثبت...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
پرداخت و تکمیل {step === 1 ? 'سفارش' : 'مرحله'}
|
||||||
|
<ArrowRight className="w-6 h-6 rotate-180" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="mt-6 flex items-center justify-center gap-2 text-[10px] font-black text-white/20 tracking-widest uppercase">
|
||||||
|
<ShieldCheck className="w-4 h-4" />
|
||||||
|
تضمین امنیت تراکنش بانکی
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
frontend/application/components/DeleteConfirmModal.tsx
Normal file
62
frontend/application/components/DeleteConfirmModal.tsx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
"use client";
|
||||||
|
import React from "react";
|
||||||
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
|
import { Trash2, AlertTriangle, X } from "lucide-react";
|
||||||
|
|
||||||
|
interface DeleteConfirmModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DeleteConfirmModal({ isOpen, onClose, onConfirm, title, message }: DeleteConfirmModalProps) {
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<div className="fixed inset-0 z-[120] flex items-center justify-center p-4">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute inset-0 bg-medical-gray-900/60 backdrop-blur-md"
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||||
|
className="bg-white w-full max-w-sm rounded-[2.5rem] relative z-10 shadow-2xl p-8 text-center font-vazir"
|
||||||
|
dir="rtl"
|
||||||
|
>
|
||||||
|
<div className="w-16 h-16 bg-red-50 rounded-2xl flex items-center justify-center text-red-500 mx-auto mb-6">
|
||||||
|
<Trash2 className="w-8 h-8" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-xl font-black text-medical-gray-900 mb-2 italic">{title}</h3>
|
||||||
|
<p className="text-sm font-bold text-medical-gray-500 leading-relaxed mb-8">{message}</p>
|
||||||
|
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1 py-4 bg-medical-gray-50 text-medical-gray-400 rounded-2xl font-black text-sm hover:bg-medical-gray-100 transition-all"
|
||||||
|
>
|
||||||
|
خیر، بماند
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onConfirm();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className="flex-1 py-4 bg-red-500 text-white rounded-2xl font-black text-sm hover:bg-red-600 transition-all shadow-lg shadow-red-500/20"
|
||||||
|
>
|
||||||
|
بله، حذف شود
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
frontend/application/components/ErrorBoundary.tsx
Normal file
72
frontend/application/components/ErrorBoundary.tsx
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
"use client";
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||||
|
import { AlertTriangle, RefreshCcw } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorBoundary extends React.Component<Props, State> {
|
||||||
|
public state: State;
|
||||||
|
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
hasError: false,
|
||||||
|
error: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getDerivedStateFromError(_: Error): State {
|
||||||
|
return { hasError: true, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
public override componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||||
|
console.error('Uncaught error:', error, errorInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleRetry = () => {
|
||||||
|
this.setState({ hasError: false, error: null });
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
public override render(): ReactNode {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-medical-gray-50 flex items-center justify-center p-6" dir="rtl">
|
||||||
|
<div className="max-w-md w-full bg-white rounded-[3rem] p-12 shadow-2xl text-center border border-medical-gray-100">
|
||||||
|
<div className="w-24 h-24 bg-rose-50 rounded-[2.5rem] flex items-center justify-center mx-auto mb-8">
|
||||||
|
<AlertTriangle className="w-12 h-12 text-rose-500" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-3xl font-black text-medical-gray-900 mb-4">متأسفانه خطایی رخ داده است</h1>
|
||||||
|
<p className="text-medical-gray-500 mb-10 font-medium leading-relaxed">
|
||||||
|
مشکلی در بارگذاری این بخش پیش آمده است. لطفاً برای ادامه دوباره تلاش کنید.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={this.handleRetry}
|
||||||
|
className="w-full bg-canina-blue text-white py-5 rounded-2xl font-black flex items-center justify-center gap-3 hover:scale-105 transition-all shadow-xl shadow-canina-blue/20"
|
||||||
|
>
|
||||||
|
<RefreshCcw className="w-5 h-5" />
|
||||||
|
تلاش مجدد
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="mt-8 pt-8 border-t border-medical-gray-50 flex items-center justify-center gap-2">
|
||||||
|
<div className="w-8 h-8 bg-canina-blue rounded-xl flex items-center justify-center text-white font-bold text-xs italic">C</div>
|
||||||
|
<span className="text-[10px] font-black text-medical-gray-300 uppercase tracking-widest">Canina Pharma Support</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
60
frontend/application/components/ErrorPages.tsx
Normal file
60
frontend/application/components/ErrorPages.tsx
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
"use client";
|
||||||
|
import React from 'react';
|
||||||
|
import { FileQuestion, AlertCircle, Home, RefreshCw } from 'lucide-react';
|
||||||
|
|
||||||
|
export const NotFoundPage = ({ onGoHome }: { onGoHome: () => void }) => {
|
||||||
|
return (
|
||||||
|
<div className="min-h-[80vh] flex items-center justify-center p-6" dir="rtl">
|
||||||
|
<div className="max-w-lg w-full text-center">
|
||||||
|
<div className="relative mb-12">
|
||||||
|
<div className="text-[15rem] font-black text-medical-gray-100 leading-none select-none">۴۰۴</div>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="w-32 h-32 bg-white rounded-[3rem] shadow-2xl flex items-center justify-center">
|
||||||
|
<FileQuestion className="w-16 h-16 text-canina-blue" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-4xl font-black text-medical-gray-900 mb-4">صفحه پیدا نشد!</h1>
|
||||||
|
<p className="text-medical-gray-500 mb-12 font-bold text-lg">
|
||||||
|
متأسفانه صفحهای که به دنبال آن هستید وجود ندارد یا جابجا شده است.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onGoHome}
|
||||||
|
className="bg-medical-gray-900 text-white px-10 py-5 rounded-2xl font-black flex items-center justify-center gap-3 mx-auto hover:bg-canina-blue transition-all shadow-2xl shadow-medical-gray-900/10"
|
||||||
|
>
|
||||||
|
<Home className="w-5 h-5" />
|
||||||
|
بازگشت به خانه
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ServerErrorPage = ({ onRetry }: { onRetry: () => void }) => {
|
||||||
|
return (
|
||||||
|
<div className="min-h-[80vh] flex items-center justify-center p-6" dir="rtl">
|
||||||
|
<div className="max-w-lg w-full text-center">
|
||||||
|
<div className="w-24 h-24 bg-rose-50 rounded-[2.5rem] flex items-center justify-center mx-auto mb-8">
|
||||||
|
<AlertCircle className="w-12 h-12 text-rose-500" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-4xl font-black text-medical-gray-900 mb-4">خطای داخلی سیستم</h1>
|
||||||
|
<p className="text-medical-gray-500 mb-12 font-bold text-lg">
|
||||||
|
ارتباط با دیتابیس کانینا با مشکل مواجه شده است. در حال بررسی موضوع هستیم.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||||
|
<button
|
||||||
|
onClick={onRetry}
|
||||||
|
className="bg-canina-blue text-white px-10 py-5 rounded-2xl font-black flex items-center justify-center gap-3 hover:scale-105 transition-all shadow-xl shadow-canina-blue/20"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-5 h-5" />
|
||||||
|
تلاش مجدد
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
148
frontend/application/components/FeaturedProducts.tsx
Normal file
148
frontend/application/components/FeaturedProducts.tsx
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
"use client";
|
||||||
|
import { useState, useEffect, useMemo } from "react";
|
||||||
|
import { Product } from "../data/products";
|
||||||
|
import { usePetStore } from "../store/usePetStore";
|
||||||
|
import { productService } from "../services/productService";
|
||||||
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
|
import { Eye, ShoppingCart, ShieldCheck, Heart, AlertCircle, Sparkles } from "lucide-react";
|
||||||
|
import SafeImage from "./SafeImage";
|
||||||
|
import { ProductCardSkeleton } from "./Skeleton";
|
||||||
|
|
||||||
|
function ProductCard({ product, onClick }: { product: Product; key?: string | number; onClick: (p: Product) => void }) {
|
||||||
|
const { getActivePet } = usePetStore();
|
||||||
|
const activePet = getActivePet();
|
||||||
|
|
||||||
|
const compatibility = useMemo(() => {
|
||||||
|
if (!activePet) return null;
|
||||||
|
const sameSpecies = product.suitableFor === activePet.type || product.suitableFor === "هر دو";
|
||||||
|
const helpsSymptom = (activePet.medicalConditions || []).some(s => product.symptoms.includes(s));
|
||||||
|
if (!sameSpecies) return { type: 'alert', text: `مخصوص ${product.suitableFor}` };
|
||||||
|
if (helpsSymptom) return { type: 'success', text: `توصیه شده برای ${activePet.name}` };
|
||||||
|
return { type: 'neutral', text: `مناسب برای ${activePet.name}` };
|
||||||
|
}, [product, activePet]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
onClick={() => onClick(product)}
|
||||||
|
className="group bg-white rounded-3xl border border-medical-gray-200 overflow-hidden hover:shadow-2xl hover:shadow-canina-blue/10 transition-all duration-500 flex flex-col h-full cursor-pointer relative"
|
||||||
|
>
|
||||||
|
{compatibility && (
|
||||||
|
<div className={`absolute top-16 right-4 z-10 text-[8px] font-black uppercase tracking-widest px-3 py-1 rounded-full shadow-md flex items-center gap-1 ${
|
||||||
|
compatibility.type === 'alert' ? 'bg-amber-100 text-amber-700' :
|
||||||
|
compatibility.type === 'success' ? 'bg-green-100 text-green-700' : 'bg-medical-gray-100 text-medical-gray-600'
|
||||||
|
}`}>
|
||||||
|
{compatibility.type === 'alert' ? <AlertCircle className="w-2.5 h-2.5" /> : compatibility.type === 'success' ? <Heart className="w-2.5 h-2.5" /> : <Sparkles className="w-2.5 h-2.5" />}
|
||||||
|
{compatibility.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Product Image Area */}
|
||||||
|
<div className="relative aspect-square bg-medical-gray-50 p-10 flex items-center justify-center overflow-hidden">
|
||||||
|
<SafeImage
|
||||||
|
src={product.image}
|
||||||
|
alt={product.name}
|
||||||
|
className="w-full h-full object-contain group-hover:scale-105 transition-transform duration-500 drop-shadow-2xl"
|
||||||
|
/>
|
||||||
|
<div className="absolute top-4 right-4 px-3 py-1 bg-white/80 backdrop-blur-md rounded-full border border-medical-gray-200 text-[10px] font-bold text-medical-gray-500 uppercase tracking-widest">
|
||||||
|
{product.category}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hover Actions */}
|
||||||
|
<div className="absolute inset-0 bg-canina-blue/60 opacity-0 group-hover:opacity-100 backdrop-blur-sm transition-all duration-300 flex items-center justify-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-white text-canina-blue flex items-center justify-center hover:scale-110 transition-transform shadow-lg">
|
||||||
|
<Eye className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 rounded-full bg-canina-blue text-white flex items-center justify-center hover:scale-110 transition-transform shadow-lg border border-white/20">
|
||||||
|
<ShoppingCart className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Area */}
|
||||||
|
<div className="p-8 flex flex-col flex-1">
|
||||||
|
<div className="flex items-start justify-between mb-4 gap-2">
|
||||||
|
<h3 className="text-xl font-black text-medical-gray-900 group-hover:text-canina-blue transition-colors leading-tight">
|
||||||
|
{product.name}
|
||||||
|
</h3>
|
||||||
|
<ShieldCheck className="w-6 h-6 text-canina-blue flex-shrink-0" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-medical-gray-500 leading-relaxed mb-6 flex-1">
|
||||||
|
{product.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-4 pt-4 border-t border-medical-gray-100">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-1 h-1 rounded-full bg-canina-blue" />
|
||||||
|
<span className="text-xs font-semibold text-medical-gray-700 italic">{product.benefits}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-xl font-black text-medical-gray-900 font-vazir">{product.price}</div>
|
||||||
|
<button className="text-xs font-black text-canina-blue uppercase tracking-widest border-b-2 border-canina-blue pb-1 hover:text-medical-gray-900 hover:border-medical-gray-900 transition-all">
|
||||||
|
مشاهده جزییات
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FeaturedProducts({ onProductClick, onShopNavigate }: { onProductClick: (p: Product) => void, onShopNavigate: () => void }) {
|
||||||
|
const [products, setProducts] = useState<Product[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchFeatured = async () => {
|
||||||
|
try {
|
||||||
|
const data = await productService.getFeaturedProducts();
|
||||||
|
setProducts(data);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchFeatured();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="py-24 bg-medical-gray-50">
|
||||||
|
<div className="max-w-7xl mx-auto px-4">
|
||||||
|
<div className="flex flex-col lg:flex-row items-end justify-between mb-16 gap-6">
|
||||||
|
<div className="lg:w-2/3">
|
||||||
|
<div className="text-canina-blue text-xs font-bold uppercase tracking-[0.2em] mb-4 font-vazir">بیشترین انتخاب توسط دامپزشکان</div>
|
||||||
|
<h2 className="text-3xl lg:text-5xl font-black text-medical-gray-900 leading-[1.2]">
|
||||||
|
محصولات برگزیده و راهکارهای <br />
|
||||||
|
<span className="italic text-canina-blue">درمان تخصصی</span>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="lg:w-1/3 text-left lg:text-right">
|
||||||
|
<p className="text-medical-gray-500 text-sm leading-relaxed mb-4">
|
||||||
|
محصولات دارویی کانینا با استفاده از دانش پیشرفته بیوتکنولوژی و مواد اولیه ارگانیک نایاب فرآوری شدهاند.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={onShopNavigate}
|
||||||
|
className="inline-flex items-center gap-2 text-canina-blue font-bold text-sm hover:translate-x-1 transition-transform rtl:hover:-translate-x-1"
|
||||||
|
>
|
||||||
|
مشاهده تمامی محصولات
|
||||||
|
<span className="text-lg">←</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-10">
|
||||||
|
{isLoading ? (
|
||||||
|
Array.from({ length: 3 }).map((_, i) => <ProductCardSkeleton key={i} />)
|
||||||
|
) : (
|
||||||
|
products.map((p) => (
|
||||||
|
<ProductCard key={p.id} onClick={onProductClick} product={p} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
frontend/application/components/Footer.tsx
Normal file
128
frontend/application/components/Footer.tsx
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
"use client";
|
||||||
|
import React from "react";
|
||||||
|
import { Phone, Mail, MapPin, Instagram, Youtube, Linkedin, ShieldCheck, Globe } from "lucide-react";
|
||||||
|
|
||||||
|
export default function Footer({ onNavigate, onShopNavigate, onB2BOpen }: {
|
||||||
|
onNavigate: (v: any) => void,
|
||||||
|
onShopNavigate: (c?: string) => void,
|
||||||
|
onB2BOpen?: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<footer className="bg-medical-gray-900 text-white pt-24 pb-12 font-vazir" dir="rtl">
|
||||||
|
<div className="max-w-7xl mx-auto px-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-16 mb-20">
|
||||||
|
|
||||||
|
{/* Brand Presence */}
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex items-center gap-4 cursor-pointer group" onClick={() => onNavigate("home")}>
|
||||||
|
<div className="w-16 h-16 bg-white rounded-2xl flex items-center justify-center p-2 group-hover:scale-105 transition-transform">
|
||||||
|
<img src="https://canina.de/media/fb/d5/47/1683116813/Logo.png" alt="Canina Logo" className="w-full h-auto" referrerPolicy="no-referrer" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-2xl font-black italic tracking-tighter">کانینا ایران</h3>
|
||||||
|
<p className="text-[10px] text-white/40 font-bold uppercase tracking-widest">نماینده رسمی در ایران</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-white/60 leading-relaxed font-medium">
|
||||||
|
تدارک هوشمندانه سلامت برای همراهان وفادار شما. واردکننده انحصاری مکملهای درمانی با گرید دارویی اختصاصی از آلمان با سابقه ۴۰ سال نوآوری.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{[Instagram, Youtube, Linkedin, Globe].map((Icon, i) => (
|
||||||
|
<a key={i} href="#" className="w-10 h-10 bg-white/5 rounded-xl flex items-center justify-center hover:bg-canina-blue hover:text-white transition-all text-white/40">
|
||||||
|
<Icon className="w-5 h-5" />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Links */}
|
||||||
|
<div className="space-y-8">
|
||||||
|
<h4 className="text-lg font-black text-canina-blue italic underline decoration-white/20 underline-offset-8">دسترسی سریع</h4>
|
||||||
|
<ul className="space-y-4 text-sm font-bold text-white/60">
|
||||||
|
<li onClick={() => onShopNavigate()} className="hover:text-white hover:translate-x-[-4px] transition-all cursor-pointer flex items-center gap-2 group">
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-canina-blue group-hover:w-4 transition-all" />
|
||||||
|
محصولات تخصصی ۲۰۲۴
|
||||||
|
</li>
|
||||||
|
<li onClick={() => onNavigate("blog")} className="hover:text-white hover:translate-x-[-4px] transition-all cursor-pointer flex items-center gap-2 group">
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-canina-blue group-hover:w-4 transition-all" />
|
||||||
|
مجله سلامت پت (وبلاگ)
|
||||||
|
</li>
|
||||||
|
<li onClick={() => onNavigate("profile")} className="hover:text-white hover:translate-x-[-4px] transition-all cursor-pointer flex items-center gap-2 group">
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-canina-blue group-hover:w-4 transition-all" />
|
||||||
|
شناسنامه پتها
|
||||||
|
</li>
|
||||||
|
<li onClick={() => onNavigate("wiki")} className="hover:text-white hover:translate-x-[-4px] transition-all cursor-pointer flex items-center gap-2 group">
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-canina-blue group-hover:w-4 transition-all" />
|
||||||
|
دانشنامه ترکیبات
|
||||||
|
</li>
|
||||||
|
<li onClick={onB2BOpen} className="hover:text-canina-blue hover:translate-x-[-4px] transition-all cursor-pointer flex items-center gap-2 group text-blue-300">
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-canina-blue group-hover:w-4 transition-all" />
|
||||||
|
پنل سفارش عمده (B2B)
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Location & Contact */}
|
||||||
|
<div className="space-y-8 lg:col-span-2">
|
||||||
|
<h4 className="text-lg font-black text-canina-blue italic underline decoration-white/20 underline-offset-8">اطلاعات تماس نمایندگی</h4>
|
||||||
|
<div className="grid md:grid-cols-2 gap-8">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex gap-4 group">
|
||||||
|
<div className="w-12 h-12 bg-white/5 rounded-2xl flex items-center justify-center text-canina-blue group-hover:scale-110 transition-transform flex-shrink-0">
|
||||||
|
<Phone className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] text-white/40 font-black uppercase mb-1">خط ویژه فروش</p>
|
||||||
|
<p className="text-lg font-black tracking-widest text-left" dir="ltr">۰۲۱-۸۸۸۸ ۴۴۴۴</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4 group">
|
||||||
|
<div className="w-12 h-12 bg-white/5 rounded-2xl flex items-center justify-center text-canina-blue group-hover:scale-110 transition-transform flex-shrink-0">
|
||||||
|
<Mail className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] text-white/40 font-black uppercase mb-1">مکاتبات رسمی</p>
|
||||||
|
<p className="text-sm font-bold">info@canina-iran.com</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4 group">
|
||||||
|
<div className="w-12 h-12 bg-white/5 rounded-2xl flex items-center justify-center text-canina-blue group-hover:scale-110 transition-transform flex-shrink-0">
|
||||||
|
<MapPin className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] text-white/40 font-black uppercase mb-1">دفتر مرکزی</p>
|
||||||
|
<p className="text-sm font-bold leading-relaxed">تهران، جردن، خیابان سعیدی، ساختمان کانینا، طبقه ۵، واحد ۱۹</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-10 p-6 bg-white/5 rounded-[2rem] border border-white/10 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-10 h-10 bg-green-500/20 text-green-500 rounded-full flex items-center justify-center">
|
||||||
|
<ShieldCheck className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h5 className="text-sm font-black">اصالت کالا تضمین میشود</h5>
|
||||||
|
<p className="text-[10px] text-white/40 font-bold">تمامی محصولات دارای لیبل اصالت وزارت بهداشت و QR Code اختصاصی هستند.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs font-black italic text-white/20">اصالت آلمانی کانینا</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-12 border-t border-white/5 flex flex-col md:flex-row items-center justify-between gap-6 text-[10px] font-black text-white/20 uppercase tracking-[0.3em]">
|
||||||
|
<div>© ۲۰۲۶ کانینا ایران. تمامی حقوق محفوظ است.</div>
|
||||||
|
<div className="flex gap-8 font-vazir">
|
||||||
|
<button onClick={() => onNavigate("home")} className="hover:text-white transition-colors">درباره ما</button>
|
||||||
|
<button onClick={() => onNavigate("home")} className="hover:text-white transition-colors">تماس با ما</button>
|
||||||
|
<button className="hover:text-white transition-colors">حریم خصوصی</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
494
frontend/application/components/Header.tsx
Normal file
494
frontend/application/components/Header.tsx
Normal file
@ -0,0 +1,494 @@
|
|||||||
|
"use client";
|
||||||
|
import React, { useState, useRef, useEffect } from "react";
|
||||||
|
import { Search, ChevronDown, Menu, X, Pill, ShieldCheck, HeartPulse, Sparkles, ShoppingBag, User, PlusCircle, Check, Building2, LogIn, Wallet, LogOut, MapPin, FileHeart, Dog, Cat } from "lucide-react";
|
||||||
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
|
import { useCartStore } from "../store/cartStore";
|
||||||
|
import { usePetStore } from "../store/usePetStore";
|
||||||
|
import { useUserStore } from "../store/userStore";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import HeaderButton from "./HeaderButton";
|
||||||
|
import AuthModal from "./AuthModal";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
|
const MENU_ITEMS = [
|
||||||
|
{
|
||||||
|
id: "joints",
|
||||||
|
title: "مفاصل و استخوان",
|
||||||
|
icon: <Pill className="w-5 h-5" />,
|
||||||
|
solutions: ["آرتروز سگهای پیر", "رشد استخوانی تولهسگ", "تقویت رباط و تاندون"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "immune",
|
||||||
|
title: "تقویت سیستم ایمنی و گوارش",
|
||||||
|
icon: <ShieldCheck className="w-5 h-5" />,
|
||||||
|
solutions: ["پیشگیری از بیماری", "رفع اسهال و یبوست", "پروبیوتیکها"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "energy",
|
||||||
|
title: "ویتامینها و انرژیبخشها",
|
||||||
|
icon: <HeartPulse className="w-5 h-5" />,
|
||||||
|
solutions: ["مکملهای مولتیویتامین", "افزایش اشتها", "رشد و بلوغ"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "special-care",
|
||||||
|
title: "مراقبتهای ویژه (پوست، دندان و چشم)",
|
||||||
|
icon: <Sparkles className="w-5 h-5" />,
|
||||||
|
solutions: ["سلامت پوست و مفاصل", "رفع جرم دندان", "شستشوی چشم"]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Header({
|
||||||
|
onNavigate,
|
||||||
|
onShopNavigate,
|
||||||
|
currentView,
|
||||||
|
onCartOpen,
|
||||||
|
onSearch,
|
||||||
|
onB2BOpen
|
||||||
|
}: {
|
||||||
|
onNavigate: (v: any) => void,
|
||||||
|
onShopNavigate: (c?: string, s?: string) => void,
|
||||||
|
currentView: string,
|
||||||
|
onCartOpen: () => void,
|
||||||
|
onSearch: (q: string) => void,
|
||||||
|
onB2BOpen: () => void
|
||||||
|
}) {
|
||||||
|
const [isMegaMenuOpen, setIsMegaMenuOpen] = useState(false);
|
||||||
|
const [isSearchFocused, setIsSearchFocused] = useState(false);
|
||||||
|
const [isAuthModalOpen, setIsAuthModalOpen] = useState(false);
|
||||||
|
const [isPetSwitcherOpen, setIsPetSwitcherOpen] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
|
const [isMobileSolutionsOpen, setIsMobileSolutionsOpen] = useState(false);
|
||||||
|
|
||||||
|
const petMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const { getTotalItems } = useCartStore();
|
||||||
|
const { pets, activePetId, setActivePet, getActivePet } = usePetStore();
|
||||||
|
const { role, isLoggedIn, logout, profile } = useUserStore();
|
||||||
|
const activePet = getActivePet();
|
||||||
|
|
||||||
|
// Click Outside logic
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
if (petMenuRef.current && !petMenuRef.current.contains(event.target as Node)) {
|
||||||
|
setIsPetSwitcherOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
onSearch(searchQuery);
|
||||||
|
setIsSearchFocused(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AuthModal isOpen={isAuthModalOpen} onClose={() => setIsAuthModalOpen(false)} />
|
||||||
|
|
||||||
|
<header className="sticky top-0 z-50 bg-white/95 backdrop-blur-md border-b border-medical-gray-100 shadow-sm">
|
||||||
|
<div className="max-w-7xl mx-auto h-24 flex items-center justify-between gap-4 px-4 sm:px-6 lg:px-8" dir="rtl">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Mobile Menu Toggle */}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||||
|
className="lg:hidden p-2 rounded-xl text-medical-gray-600 hover:bg-medical-gray-50 transition-all"
|
||||||
|
aria-label="منو"
|
||||||
|
>
|
||||||
|
{isMobileMenuOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Logo Section */}
|
||||||
|
<div
|
||||||
|
className="flex-shrink-0 flex items-center gap-3 cursor-pointer group"
|
||||||
|
onClick={() => {
|
||||||
|
onNavigate("home");
|
||||||
|
setIsMobileMenuOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="w-12 h-12 bg-canina-blue rounded-2xl flex items-center justify-center text-white font-bold text-2xl group-hover:bg-medical-gray-900 transition-all shadow-xl shadow-canina-blue/20 italic">C</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-canina-blue font-black text-2xl tracking-tighter leading-none italic font-inter">Canina <span className="text-sm not-italic font-medium border-l-2 border-medical-gray-100 pl-2 ml-2 font-vazir">ایران</span></span>
|
||||||
|
<span className="text-[10px] text-medical-gray-400 font-bold uppercase tracking-widest leading-none mt-1.5 font-vazir">نماینده رسمی Canina Pharma GmbH آلمان</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation Items */}
|
||||||
|
<nav className="hidden lg:flex items-center gap-1 flex-grow justify-center h-full">
|
||||||
|
<div
|
||||||
|
className="relative h-full flex items-center group"
|
||||||
|
onMouseEnter={() => setIsMegaMenuOpen(true)}
|
||||||
|
onMouseLeave={() => setIsMegaMenuOpen(false)}
|
||||||
|
>
|
||||||
|
<button className={`flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm font-black transition-all whitespace-nowrap ${isMegaMenuOpen ? 'bg-medical-gray-50 text-canina-blue' : 'text-medical-gray-600 hover:bg-medical-gray-50 hover:text-canina-blue'} font-vazir`}>
|
||||||
|
راهکارهای درمانی
|
||||||
|
<ChevronDown className={`w-4 h-4 transition-transform duration-300 ${isMegaMenuOpen ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{isMegaMenuOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 15, scale: 0.98 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: 15, scale: 0.98 }}
|
||||||
|
className="absolute top-[80%] right-0 w-[640px] bg-white border border-medical-gray-100 shadow-2xl rounded-[2.5rem] p-10 grid grid-cols-2 gap-10 z-50 pointer-events-auto"
|
||||||
|
>
|
||||||
|
{MENU_ITEMS.map((item, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="group/item cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
onShopNavigate(item.id);
|
||||||
|
setIsMegaMenuOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4 mb-4">
|
||||||
|
<div className="p-3 bg-medical-gray-50 rounded-2xl text-canina-blue group-hover/item:bg-canina-blue group-hover/item:text-white transition-all shadow-sm">
|
||||||
|
{item.icon}
|
||||||
|
</div>
|
||||||
|
<h4 className="font-black text-medical-gray-900 text-sm font-vazir whitespace-nowrap">{item.title}</h4>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-2.5 border-r-2 border-medical-gray-50 pr-4">
|
||||||
|
{item.solutions.map((sol, sIdx) => (
|
||||||
|
<li
|
||||||
|
key={sIdx}
|
||||||
|
className="text-[12px] text-medical-gray-400 hover:text-canina-blue hover:translate-x-[-4px] transition-all font-bold cursor-pointer font-vazir whitespace-nowrap"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onShopNavigate(item.id, sol);
|
||||||
|
setIsMegaMenuOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sol}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{[
|
||||||
|
{ id: 'shop', label: 'محصولات تخصصی', action: () => onShopNavigate() },
|
||||||
|
{ id: 'wiki', label: 'دانشنامه علمی', action: () => onNavigate('wiki') },
|
||||||
|
{ id: 'blog', label: 'مجله سلامت پت', action: () => onNavigate('blog') },
|
||||||
|
{ id: 'profile', label: 'شناسنامه پتها', action: () => onNavigate({ view: 'profile', subview: 'index' }) }
|
||||||
|
].map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
onClick={item.action}
|
||||||
|
className={`px-3 py-2 rounded-xl text-sm font-black transition-all font-vazir whitespace-nowrap ${currentView === item.id ? 'bg-canina-blue/5 text-canina-blue' : 'text-medical-gray-600 hover:bg-medical-gray-50 hover:text-canina-blue'}`}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Action Area */}
|
||||||
|
<div className="flex items-center gap-4 flex-shrink-0 justify-end h-16 pl-2">
|
||||||
|
|
||||||
|
{/* Elastic Search */}
|
||||||
|
<div className="relative flex items-center justify-end z-20">
|
||||||
|
<AnimatePresence>
|
||||||
|
{isSearchFocused && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ width: 0, opacity: 0 }}
|
||||||
|
animate={{ width: 350, opacity: 1 }}
|
||||||
|
exit={{ width: 0, opacity: 0 }}
|
||||||
|
transition={{ type: "spring", damping: 20, stiffness: 200 }}
|
||||||
|
className="absolute right-0 top-1/2 -translate-y-1/2 bg-white border-2 border-canina-blue rounded-2xl shadow-xl overflow-hidden h-12 flex items-center pr-10"
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSearch} className="w-full flex items-center">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
|
onBlur={() => !searchQuery && setIsSearchFocused(false)}
|
||||||
|
placeholder="جستجوی محصولات، مقالات سلامت و..."
|
||||||
|
className="w-full bg-transparent px-4 py-2 text-[12px] font-bold outline-none font-vazir text-medical-gray-900"
|
||||||
|
/>
|
||||||
|
<button type="submit" className="px-4 py-2 bg-canina-blue text-white text-[10px] font-black hover:bg-medical-gray-900 transition-colors">
|
||||||
|
جستجو
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<HeaderButton
|
||||||
|
variant={isSearchFocused ? "primary" : "secondary"}
|
||||||
|
className={cn("w-12 h-12 transition-all duration-300", isSearchFocused ? "z-30 bg-canina-blue text-white" : "")}
|
||||||
|
onClick={() => setIsSearchFocused(!isSearchFocused)}
|
||||||
|
>
|
||||||
|
<Search className="w-5 h-5" />
|
||||||
|
</HeaderButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 items-center h-12">
|
||||||
|
{isLoggedIn ? (
|
||||||
|
<div className="flex items-center h-full gap-2 p-1.5 bg-medical-gray-50/50 border border-medical-gray-100 rounded-3xl">
|
||||||
|
{/* User Name (Direct link to dashboard) */}
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2.5 px-4 py-2 hover:bg-white rounded-2xl cursor-pointer transition-all border border-transparent hover:border-medical-gray-200"
|
||||||
|
onClick={() => onNavigate("user-dashboard")}
|
||||||
|
>
|
||||||
|
<User className="w-5 h-5 text-canina-blue" />
|
||||||
|
<span className="text-[14px] font-black font-vazir text-medical-gray-900 leading-none">
|
||||||
|
{profile.firstName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Separator Line */}
|
||||||
|
<div className="w-[1.5px] h-5 bg-medical-gray-200" />
|
||||||
|
|
||||||
|
{/* Pet Name & Switcher */}
|
||||||
|
<div className="relative h-full flex items-center" ref={petMenuRef}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2.5 px-4 py-2 rounded-2xl transition-all cursor-pointer",
|
||||||
|
isPetSwitcherOpen ? "bg-canina-blue text-white shadow-lg shadow-canina-blue/20" : "text-canina-blue hover:bg-white"
|
||||||
|
)}
|
||||||
|
onClick={() => setIsPetSwitcherOpen(!isPetSwitcherOpen)}
|
||||||
|
>
|
||||||
|
{activePet?.type === "گربه" ? (
|
||||||
|
<Cat className={cn("w-5 h-5", isPetSwitcherOpen ? "text-white" : "text-canina-blue/60")} />
|
||||||
|
) : (
|
||||||
|
<Dog className={cn("w-5 h-5", isPetSwitcherOpen ? "text-white" : "text-canina-blue/60")} />
|
||||||
|
)}
|
||||||
|
<span className={cn("text-[14px] font-black font-vazir leading-none", isPetSwitcherOpen ? "text-white" : "text-canina-blue")}>
|
||||||
|
{activePet?.name || "بدون پت"}
|
||||||
|
</span>
|
||||||
|
<ChevronDown className={cn("w-4 h-4 transition-transform", isPetSwitcherOpen ? "rotate-180" : "")} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{isPetSwitcherOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||||
|
className="absolute top-[125%] left-0 w-72 bg-white border border-medical-gray-100 shadow-2xl rounded-[1.5rem] p-5 z-50 pointer-events-auto"
|
||||||
|
>
|
||||||
|
<div className="text-[9px] font-black text-medical-gray-400 uppercase tracking-[0.2em] mb-4 pr-1">تغییر پت یا مدیریت حساب</div>
|
||||||
|
<div className="space-y-1 mb-4">
|
||||||
|
{pets.map(pet => (
|
||||||
|
<div
|
||||||
|
key={pet.id}
|
||||||
|
className={cn(
|
||||||
|
"w-full flex items-center justify-between p-3 rounded-xl transition-all group/item",
|
||||||
|
activePetId === pet.id ? "bg-canina-blue/5 text-canina-blue" : "hover:bg-medical-gray-50 text-medical-gray-600"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-3 flex-1 cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
setActivePet(pet.id);
|
||||||
|
setIsPetSwitcherOpen(false);
|
||||||
|
toast.success(`پت فعال به ${pet.name} تغییر یافت`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={cn(
|
||||||
|
"w-8 h-8 rounded-lg flex items-center justify-center text-[11px] font-black transition-colors",
|
||||||
|
activePetId === pet.id ? "bg-canina-blue text-white" : "bg-medical-gray-100 text-medical-gray-400 group-hover/item:bg-medical-gray-200"
|
||||||
|
)}>
|
||||||
|
{pet.name[0]}
|
||||||
|
</div>
|
||||||
|
<span className="text-[13px] font-bold font-vazir">{pet.name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{activePetId === pet.id && <Check className="w-4 h-4 ml-2" />}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setActivePet(pet.id);
|
||||||
|
onNavigate({ view: "profile", subview: "detail" });
|
||||||
|
setIsPetSwitcherOpen(false);
|
||||||
|
}}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-canina-blue hover:text-white transition-all text-medical-gray-300 hover:shadow-lg"
|
||||||
|
title="مشاهده شناسنامه سلامت"
|
||||||
|
>
|
||||||
|
<FileHeart className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4 border-t border-medical-gray-100 space-y-1">
|
||||||
|
<button
|
||||||
|
onClick={() => { onNavigate({ view: "profile", subview: "add" }); setIsPetSwitcherOpen(false); }}
|
||||||
|
className="w-full flex items-center gap-3 p-3 text-canina-blue hover:bg-canina-blue/5 rounded-xl transition-all text-[12px] font-black font-vazir"
|
||||||
|
>
|
||||||
|
<PlusCircle className="w-5 h-5" />
|
||||||
|
ثبت همدم (Pet) جدید
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { onNavigate({ view: "user-dashboard" }); setIsPetSwitcherOpen(false); }}
|
||||||
|
className="w-full flex items-center gap-3 p-3 text-medical-gray-600 hover:bg-medical-gray-50 rounded-xl transition-all text-[12px] font-bold font-vazir"
|
||||||
|
>
|
||||||
|
<Wallet className="w-5 h-5" />
|
||||||
|
کیف پول و سفارشات
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { logout(); setIsPetSwitcherOpen(false); }}
|
||||||
|
className="w-full flex items-center gap-3 p-3 text-red-500 hover:bg-red-50 rounded-xl transition-all text-[12px] font-bold font-vazir mt-1"
|
||||||
|
>
|
||||||
|
<LogOut className="w-5 h-5" />
|
||||||
|
خروج از حساب کانینا
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<HeaderButton
|
||||||
|
variant="outline"
|
||||||
|
className="px-6 h-12 text-sm font-black"
|
||||||
|
onClick={() => setIsAuthModalOpen(true)}
|
||||||
|
icon={<LogIn className="w-5 h-5" />}
|
||||||
|
>
|
||||||
|
ورود / ثبتنام
|
||||||
|
</HeaderButton>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<HeaderButton
|
||||||
|
variant="primary"
|
||||||
|
className="w-16 h-12"
|
||||||
|
onClick={onCartOpen}
|
||||||
|
badge={getTotalItems()}
|
||||||
|
icon={<ShoppingBag className="w-6 h-6" />}
|
||||||
|
>
|
||||||
|
<div className="text-[8px] font-black tracking-tight whitespace-nowrap mt-0.5">سبد خرید</div>
|
||||||
|
</HeaderButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Mobile Drawer Menu */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{isMobileMenuOpen && (
|
||||||
|
<>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 0.5 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
|
className="fixed inset-0 bg-black z-40 lg:hidden"
|
||||||
|
/>
|
||||||
|
{/* Drawer */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ x: "100%" }}
|
||||||
|
animate={{ x: 0 }}
|
||||||
|
exit={{ x: "100%" }}
|
||||||
|
transition={{ type: "spring", damping: 25, stiffness: 200 }}
|
||||||
|
className="fixed top-0 right-0 bottom-0 w-80 max-w-[85vw] bg-white z-50 shadow-2xl p-6 flex flex-col gap-6 overflow-y-auto lg:hidden"
|
||||||
|
dir="rtl"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between border-b border-medical-gray-100 pb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-canina-blue rounded-xl flex items-center justify-center text-white font-bold text-xl italic">C</div>
|
||||||
|
<span className="text-canina-blue font-black text-xl italic font-inter">Canina</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
|
className="p-2 rounded-xl hover:bg-medical-gray-50 text-medical-gray-600 transition-all"
|
||||||
|
>
|
||||||
|
<X className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation Links */}
|
||||||
|
<nav className="flex flex-col gap-2">
|
||||||
|
{/* Treatment Solutions accordion */}
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsMobileSolutionsOpen(!isMobileSolutionsOpen)}
|
||||||
|
className="w-full flex items-center justify-between px-4 py-3 rounded-xl text-sm font-black text-medical-gray-600 hover:bg-medical-gray-50 transition-all font-vazir"
|
||||||
|
>
|
||||||
|
<span>راهکارهای درمانی</span>
|
||||||
|
<ChevronDown className={`w-4 h-4 transition-transform duration-300 ${isMobileSolutionsOpen ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
<AnimatePresence>
|
||||||
|
{isMobileSolutionsOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: "auto", opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
className="overflow-hidden mr-4 pr-2 border-r-2 border-medical-gray-100 mt-1 space-y-3"
|
||||||
|
>
|
||||||
|
{MENU_ITEMS.map((item, idx) => (
|
||||||
|
<div key={idx} className="py-1">
|
||||||
|
<div
|
||||||
|
className="text-[13px] font-bold text-medical-gray-800 font-vazir hover:text-canina-blue cursor-pointer flex items-center gap-2"
|
||||||
|
onClick={() => {
|
||||||
|
onShopNavigate(item.id);
|
||||||
|
setIsMobileMenuOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-canina-blue/80">{item.icon}</span>
|
||||||
|
<span>{item.title}</span>
|
||||||
|
</div>
|
||||||
|
<ul className="mt-1 space-y-1 mr-6">
|
||||||
|
{item.solutions.map((sol, sIdx) => (
|
||||||
|
<li
|
||||||
|
key={sIdx}
|
||||||
|
className="text-[11px] text-medical-gray-500 hover:text-canina-blue cursor-pointer py-1 font-vazir"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onShopNavigate(item.id, sol);
|
||||||
|
setIsMobileMenuOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sol}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{[
|
||||||
|
{ id: 'shop', label: 'محصولات تخصصی', action: () => onShopNavigate() },
|
||||||
|
{ id: 'wiki', label: 'دانشنامه علمی', action: () => onNavigate('wiki') },
|
||||||
|
{ id: 'blog', label: 'مجله سلامت پت', action: () => onNavigate('blog') },
|
||||||
|
{ id: 'profile', label: 'شناسنامه پتها', action: () => onNavigate({ view: 'profile', subview: 'index' }) }
|
||||||
|
].map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => {
|
||||||
|
item.action();
|
||||||
|
setIsMobileMenuOpen(false);
|
||||||
|
}}
|
||||||
|
className={`w-full text-right px-4 py-3 rounded-xl text-sm font-black transition-all font-vazir ${
|
||||||
|
currentView === item.id
|
||||||
|
? 'bg-canina-blue/5 text-canina-blue'
|
||||||
|
: 'text-medical-gray-600 hover:bg-medical-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
frontend/application/components/HeaderButton.tsx
Normal file
53
frontend/application/components/HeaderButton.tsx
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
"use client";
|
||||||
|
import React from "react";
|
||||||
|
import { motion } from "motion/react";
|
||||||
|
import { toPersian } from "../lib/utils";
|
||||||
|
|
||||||
|
interface HeaderButtonProps {
|
||||||
|
onClick?: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
variant?: "primary" | "secondary" | "outline" | "ghost";
|
||||||
|
className?: string;
|
||||||
|
badge?: number;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HeaderButton({
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
variant = "primary",
|
||||||
|
className = "",
|
||||||
|
badge,
|
||||||
|
icon
|
||||||
|
}: HeaderButtonProps) {
|
||||||
|
const baseStyles = "relative flex flex-col items-center justify-center rounded-xl transition-all duration-300 font-vazir select-none cursor-pointer overflow-visible";
|
||||||
|
|
||||||
|
const variants = {
|
||||||
|
primary: "bg-canina-blue text-white hover:bg-medical-gray-900 shadow-lg shadow-canina-blue/20",
|
||||||
|
secondary: "bg-medical-gray-50 text-medical-gray-600 hover:bg-medical-gray-100",
|
||||||
|
outline: "border-2 border-medical-gray-100 text-medical-gray-700 hover:border-canina-blue hover:text-canina-blue",
|
||||||
|
ghost: "text-medical-gray-600 hover:bg-medical-gray-50 hover:text-canina-blue"
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ y: -2 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
onClick={onClick}
|
||||||
|
className={`${baseStyles} ${variants[variant]} ${className}`}
|
||||||
|
>
|
||||||
|
{icon && <div className="mb-0.5">{icon}</div>}
|
||||||
|
<div className="whitespace-nowrap">{children}</div>
|
||||||
|
|
||||||
|
{badge !== undefined && badge > 0 && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
className="absolute -top-1 -left-1 w-5 h-5 bg-red-500 text-white rounded-full border-2 border-white text-[8px] font-black flex items-center justify-center font-vazir"
|
||||||
|
>
|
||||||
|
{toPersian(badge.toString())}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</motion.button>
|
||||||
|
);
|
||||||
|
}
|
||||||
196
frontend/application/components/Hero.tsx
Normal file
196
frontend/application/components/Hero.tsx
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
"use client";
|
||||||
|
import { motion, useMotionValue, useTransform, animate } from "motion/react";
|
||||||
|
import { ChevronLeft } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { toPersian } from "../lib/utils";
|
||||||
|
import { useSettingsStore } from "../store/settingsStore";
|
||||||
|
|
||||||
|
function StatCounter({ target }: { target: number }) {
|
||||||
|
const [displayValue, setDisplayValue] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controls = animate(0, target, {
|
||||||
|
duration: 3,
|
||||||
|
ease: [0.16, 1, 0.3, 1], // Custom cubic-bezier for a more polished feel
|
||||||
|
onUpdate: (latest) => setDisplayValue(Math.round(latest))
|
||||||
|
});
|
||||||
|
return controls.stop;
|
||||||
|
}, [target]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.8, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
transition={{ duration: 2.5, ease: "easeOut" }}
|
||||||
|
className="text-4xl lg:text-6xl font-black text-canina-blue font-vazir origin-right flex items-center"
|
||||||
|
>
|
||||||
|
<motion.span
|
||||||
|
animate={{ scale: [1, 1.1, 1] }}
|
||||||
|
transition={{ duration: 3, ease: "easeInOut" }}
|
||||||
|
>
|
||||||
|
{toPersian(displayValue)}+
|
||||||
|
</motion.span>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Typewriter({ text }: { text: string }) {
|
||||||
|
const [displayText, setDisplayText] = useState("");
|
||||||
|
useEffect(() => {
|
||||||
|
let i = 0;
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setDisplayText(text.slice(0, i));
|
||||||
|
i++;
|
||||||
|
if (i > text.length) clearInterval(interval);
|
||||||
|
}, 150);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [text]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-4xl lg:text-6xl font-black text-canina-blue font-vazir flex flex-row-reverse items-center">
|
||||||
|
<span className="relative">
|
||||||
|
{toPersian(displayText)}
|
||||||
|
<motion.span
|
||||||
|
animate={{ opacity: [0, 1, 0] }}
|
||||||
|
transition={{ duration: 0.8, repeat: Infinity }}
|
||||||
|
className="absolute -left-1 top-0 h-full w-[3px] bg-canina-blue"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Hero({ onProfileClick, onShopNavigate }: { onProfileClick?: () => void, onShopNavigate?: () => void }) {
|
||||||
|
const getText = useSettingsStore(state => state.getText);
|
||||||
|
const title = getText('hero_title', "تخصص آلمانی در خدمت\nسلامت پتهای خانگی");
|
||||||
|
const titleParts = title.split('\n');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="relative overflow-hidden bg-white py-20 lg:py-32 border-b border-medical-gray-100">
|
||||||
|
{/* Background patterns */}
|
||||||
|
<div className="absolute top-0 left-0 w-full h-full opacity-[0.03] pointer-events-none select-none overflow-hidden">
|
||||||
|
<div className="absolute top-0 right-0 w-[800px] h-[800px] bg-canina-blue rounded-full blur-[120px] -mr-96 -mt-96" />
|
||||||
|
<div className="absolute bottom-0 left-0 w-[600px] h-[600px] bg-medical-gray-500 rounded-full blur-[100px] -ml-40 -mb-40" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-7xl mx-auto px-4 relative flex flex-col lg:flex-row items-center gap-12">
|
||||||
|
{/* Content */}
|
||||||
|
<div className="lg:w-1/2 text-center lg:text-right">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
>
|
||||||
|
<div className="inline-flex items-center gap-2 px-3 py-1 bg-canina-blue/5 border border-canina-blue/10 rounded-full mb-6">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-canina-blue animate-pulse" />
|
||||||
|
<span className="text-canina-blue text-xs font-bold uppercase tracking-widest leading-none">{getText('hero_badge', "تخصص دارویی از آلمان")}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-4xl lg:text-7xl font-black text-medical-gray-900 leading-[1.1] mb-6">
|
||||||
|
{titleParts[0]} <br />
|
||||||
|
{titleParts[1] && <span className="text-canina-blue font-vazir">{titleParts[1]}</span>}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p
|
||||||
|
className="text-lg lg:text-xl text-medical-gray-600 mb-10 max-w-2xl mx-auto lg:mx-0 leading-relaxed font-vazir animate-fade-in"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: getText(
|
||||||
|
'hero_desc',
|
||||||
|
'بیش از <span className="font-bold text-canina-blue">۴۰ سال</span> تجربه نوآورانه در تولید مکملهای درمانی با بالاترین استاندارد کیفی «گرید دارویی اختصاصی». راهکار هوشمند برای هر نیاز بالینی.'
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap justify-center lg:justify-start gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const element = document.getElementById('canina-advisor');
|
||||||
|
if (element) {
|
||||||
|
element.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="bg-medical-gray-900 text-white px-10 py-5 rounded-full font-bold text-lg hover:bg-canina-blue hover:shadow-2xl transition-all flex items-center gap-2 group font-vazir"
|
||||||
|
>
|
||||||
|
{getText('hero_btn_advisor', "دستیار سلامت پت")}
|
||||||
|
<ChevronLeft className="w-5 h-5 group-hover:-translate-x-1 transition-transform" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onShopNavigate}
|
||||||
|
className="bg-white border-2 border-medical-gray-200 text-medical-gray-700 px-10 py-5 rounded-full font-bold text-lg hover:border-canina-blue hover:text-canina-blue transition-all font-vazir"
|
||||||
|
>
|
||||||
|
{getText('hero_btn_products', "مشاهده محصولات")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.8, duration: 0.6 }}
|
||||||
|
className="grid grid-cols-3 gap-8 mt-16 pt-8 border-t border-medical-gray-100"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center lg:items-end">
|
||||||
|
<Typewriter text={getText('hero_stat_founded', "۱۹۸۴ سال تأسیس")} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center lg:items-end">
|
||||||
|
<StatCounter target={40} />
|
||||||
|
<div className="text-xs text-medical-gray-500 font-bold uppercase tracking-tight mt-3 font-vazir">{getText('hero_stat_agencies', "نمایندگی فعال")}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center lg:items-end">
|
||||||
|
<div className="relative overflow-hidden group">
|
||||||
|
<motion.div
|
||||||
|
className="text-4xl lg:text-6xl font-black text-canina-blue font-vazir relative z-10"
|
||||||
|
>
|
||||||
|
۱۰۰٪
|
||||||
|
</motion.div>
|
||||||
|
<motion.div
|
||||||
|
initial={{ x: "-100%" }}
|
||||||
|
animate={{ x: "200%" }}
|
||||||
|
transition={{ duration: 3, repeat: Infinity, ease: "linear" }}
|
||||||
|
className="absolute inset-0 bg-gradient-to-r from-transparent via-white/40 to-transparent skew-x-12 z-20 pointer-events-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-medical-gray-500 font-bold uppercase tracking-tight mt-3 font-vazir">{getText('hero_stat_german_formula', "فرمول آلمانی")}</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Visual Element */}
|
||||||
|
<div className="lg:w-1/2 relative">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9, rotate: -5 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, rotate: 0 }}
|
||||||
|
transition={{ duration: 1, ease: "easeOut" }}
|
||||||
|
className="relative z-10"
|
||||||
|
>
|
||||||
|
<div className="aspect-square bg-medical-gray-100 rounded-[2rem] overflow-hidden border-4 border-white shadow-2xl relative">
|
||||||
|
<img
|
||||||
|
src="https://images.unsplash.com/photo-1583337130417-3346a1be7dee?auto=format&fit=crop&q=80&w=800"
|
||||||
|
alt="German Veterinary Expert"
|
||||||
|
className="w-full h-full object-cover grayscale-[20%] hover:grayscale-0 transition-all duration-700"
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
/>
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 p-8 bg-gradient-to-t from-black/80 to-transparent text-white text-right">
|
||||||
|
<div className="text-sm font-medium mb-1 opacity-80 uppercase tracking-widest text-[10px]">{getText('hero_image_badge', "سرآمد علمی در پزشکی پتها")}</div>
|
||||||
|
<div className="text-xl font-bold italic tracking-tighter">{getText('hero_image_title', "مکملهای تایید شده دامپزشکی")}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Badge */}
|
||||||
|
<motion.div
|
||||||
|
animate={{ rotate: 360 }}
|
||||||
|
transition={{ duration: 20, repeat: Infinity, ease: "linear" }}
|
||||||
|
className="absolute -top-6 -right-6 lg:-top-10 lg:-right-10 z-20 w-32 h-32 lg:w-40 lg:h-40 bg-white shadow-xl rounded-full p-1 border-2 border-dashed border-canina-blue flex items-center justify-center text-center p-4"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<span className="text-2xl lg:text-3xl font-black text-canina-blue leading-none tracking-tighter">DE</span>
|
||||||
|
<span className="text-[8px] lg:text-[10px] font-bold text-medical-gray-500 uppercase tracking-widest mt-1">{getText('hero_quality_standard', "استاندارد کیفی آلمان")}</span>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
178
frontend/application/components/IngredientWiki.tsx
Normal file
178
frontend/application/components/IngredientWiki.tsx
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
"use client";
|
||||||
|
import { useMemo, useState, useEffect } from "react";
|
||||||
|
import { INGREDIENTS_WIKI, Product } from "../data/products";
|
||||||
|
import { motion } from "motion/react";
|
||||||
|
import { FlaskConical, CheckCircle2, ChevronRight, ChevronLeft, Beaker } from "lucide-react";
|
||||||
|
import { useSettingsStore } from "../store/settingsStore";
|
||||||
|
import { productService } from "../services/productService";
|
||||||
|
|
||||||
|
export default function IngredientWiki({ onProductClick, onBack }: { onProductClick: (p: Product) => void, onBack?: () => void }) {
|
||||||
|
const texts = useSettingsStore(state => state.texts);
|
||||||
|
const [products, setProducts] = useState<Product[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
productService.getProducts().then(data => setProducts(data));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const ingredientsWiki = useMemo(() => {
|
||||||
|
const jsonStr = texts['ingredients_wiki'];
|
||||||
|
if (jsonStr) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(jsonStr) as typeof INGREDIENTS_WIKI;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to parse ingredients_wiki from DB setting:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return INGREDIENTS_WIKI;
|
||||||
|
}, [texts]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white py-12 px-4 overflow-hidden font-vazir" dir="rtl">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
|
||||||
|
{/* Mobile Navigation & Breadcrumbs */}
|
||||||
|
<div className="lg:hidden mb-12 space-y-4">
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="flex items-center gap-2 text-medical-gray-500 font-bold hover:text-canina-blue transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-5 h-5" />
|
||||||
|
<span>بازگشت</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-[10px] font-black text-medical-gray-400 uppercase tracking-widest overflow-x-auto whitespace-nowrap pb-2">
|
||||||
|
<span className="cursor-pointer hover:text-canina-blue" onClick={onBack}>کانینا</span>
|
||||||
|
<ChevronLeft className="w-2.5 h-2.5" />
|
||||||
|
<span className="text-canina-blue">دانشنامه علمی</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop Breadcrumbs */}
|
||||||
|
<div className="hidden lg:flex items-center gap-2 text-[10px] font-black text-medical-gray-400 uppercase tracking-widest mb-12">
|
||||||
|
<span className="cursor-pointer hover:text-canina-blue" onClick={onBack}>خانه</span>
|
||||||
|
<ChevronLeft className="w-2.5 h-2.5" />
|
||||||
|
<span className="text-canina-blue">دانشنامه مواد نایاب و ارگانیک</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center text-center mb-20">
|
||||||
|
<div className="inline-flex items-center gap-2 px-4 py-2 bg-canina-blue/5 border border-canina-blue/10 rounded-full text-canina-blue text-xs font-black uppercase tracking-widest mb-6">
|
||||||
|
<FlaskConical className="w-4 h-4" />
|
||||||
|
علم در خدمت کیفیت
|
||||||
|
</div>
|
||||||
|
<h2 className="text-4xl lg:text-6xl font-black text-medical-gray-900 leading-tight md:max-w-4xl">
|
||||||
|
دانشنامه مواد <span className="text-canina-blue italic">نایاب و ارگانیک</span> کانینا
|
||||||
|
</h2>
|
||||||
|
<p className="mt-6 text-lg text-medical-gray-500 max-w-2xl leading-relaxed">
|
||||||
|
ما از مواد اولیهای استفاده میکنیم که در سطح دارویی فرآوری شدهاند. در اینجا با قدرت بیولوژیک این ترکیبات آشنا شوید.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-32">
|
||||||
|
{ingredientsWiki.map((ing, idx) => {
|
||||||
|
const hasIngredient = products.filter(p =>
|
||||||
|
p.main_ingredients?.some(mi => mi.toLowerCase().includes(ing.name.toLowerCase())) ||
|
||||||
|
p.main_ingredients?.some(mi => mi.toLowerCase().includes(ing.id.replace('-', ' ')))
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={ing.id} className={`flex flex-col lg:flex-row items-center gap-16 ${idx % 2 === 1 ? 'lg:flex-row-reverse' : ''}`}>
|
||||||
|
{/* Content */}
|
||||||
|
<div className="lg:w-1/2 space-y-8">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: idx % 2 === 1 ? 40 : -40 }}
|
||||||
|
whileInView={{ opacity: 1, x: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.8 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4 mb-6">
|
||||||
|
<div className="w-14 h-14 rounded-3xl bg-medical-gray-900 flex items-center justify-center text-white shadow-xl">
|
||||||
|
<Beaker className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-3xl font-black text-medical-gray-900">{ing.name}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-lg text-medical-gray-600 leading-relaxed mb-8">
|
||||||
|
{ing.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 mb-10">
|
||||||
|
{ing.benefits.map((benefit, bIdx) => (
|
||||||
|
<div key={bIdx} className="flex items-start gap-4 p-4 bg-medical-gray-50 rounded-2xl border border-medical-gray-100">
|
||||||
|
<CheckCircle2 className="w-6 h-6 text-canina-blue flex-shrink-0" />
|
||||||
|
<span className="text-sm font-bold text-medical-gray-700">{benefit}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Products Linking */}
|
||||||
|
<div className="lg:w-1/2 relative">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
whileInView={{ opacity: 1, scale: 1 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
className="bg-medical-gray-100 rounded-[4rem] p-12 relative overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="absolute top-0 right-0 p-8">
|
||||||
|
<div className="text-[10px] font-black text-medical-gray-400 uppercase tracking-[0.3em]">موجود در محصولات</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 relative z-10">
|
||||||
|
{hasIngredient.length > 0 ? hasIngredient.map(p => (
|
||||||
|
<div
|
||||||
|
key={p.id}
|
||||||
|
onClick={() => onProductClick(p)}
|
||||||
|
className="bg-white rounded-[2.5rem] p-6 shadow-xl hover:scale-105 transition-transform cursor-pointer group"
|
||||||
|
>
|
||||||
|
<img src={p.image} alt={p.name} className="w-24 h-24 object-contain mx-auto mb-4" referrerPolicy="no-referrer" />
|
||||||
|
<h4 className="text-sm font-black text-center text-medical-gray-900 group-hover:text-canina-blue transition-colors px-2">{p.name}</h4>
|
||||||
|
</div>
|
||||||
|
)) : (
|
||||||
|
<div className="col-span-full py-12 text-center text-medical-gray-400 font-bold italic">
|
||||||
|
اطلاعات محصولات مرتبط به زودی اضافه خواهد شد.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Decorative backdrop */}
|
||||||
|
<div className="absolute -bottom-20 -left-20 w-80 h-80 bg-canina-blue rounded-full blur-[100px] opacity-10" />
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quality Badge */}
|
||||||
|
<div className="mt-32 p-12 bg-medical-gray-900 rounded-[4rem] text-center text-white relative overflow-hidden">
|
||||||
|
<div className="absolute inset-0 opacity-10 mix-blend-overlay">
|
||||||
|
<div className="grid grid-cols-12 h-full w-full">
|
||||||
|
{Array.from({length: 120}).map((_, i) => (
|
||||||
|
<div key={i} className="border border-white/20" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative z-10 max-w-3xl mx-auto">
|
||||||
|
<h3 className="text-3xl lg:text-5xl font-black mb-8 leading-tight italic">تمام مواد اولیه ما دارای گواهینامه <br /> <span className="text-canina-blue">گرید دارویی اختصاصی</span> هستند.</h3>
|
||||||
|
<p className="text-white/60 text-lg mb-10">این یعنی استانداردی فراتر از مکملهای معمولی، مشابه استانداردهای تولید دارو در اتحادیه اروپا.</p>
|
||||||
|
<div className="flex justify-center gap-12">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div className="text-2xl font-black font-vazir text-canina-blue">۱۰۰٪</div>
|
||||||
|
<div className="text-[10px] font-bold uppercase tracking-widest text-white/40 mt-1">تست آزمایشگاهی</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div className="text-2xl font-black font-vazir">GMP</div>
|
||||||
|
<div className="text-[10px] font-bold uppercase tracking-widest text-white/40 mt-1">استاندارد تولید</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div className="text-2xl font-black font-vazir">ISO</div>
|
||||||
|
<div className="text-[10px] font-bold uppercase tracking-widest text-white/40 mt-1">کنترل کیفیت</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
289
frontend/application/components/LoginModal.tsx
Normal file
289
frontend/application/components/LoginModal.tsx
Normal file
@ -0,0 +1,289 @@
|
|||||||
|
"use client";
|
||||||
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
|
import { X, ShieldCheck, Phone, Key, LogIn, ArrowRight, RefreshCw } from "lucide-react";
|
||||||
|
import { authService } from "../services/authService";
|
||||||
|
import { useUserStore } from "../store/userStore";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { toPersian } from "../lib/utils";
|
||||||
|
|
||||||
|
interface LoginModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onLogin: () => void;
|
||||||
|
petName?: string;
|
||||||
|
isAdvisorContext?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoginModal({ isOpen, onClose, onLogin, petName, isAdvisorContext }: LoginModalProps) {
|
||||||
|
const [step, setStep] = useState<"phone" | "otp">("phone");
|
||||||
|
const [phoneNumber, setPhoneNumber] = useState("");
|
||||||
|
const [otpCode, setOtpCode] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [countdown, setCountdown] = useState(0);
|
||||||
|
const { fetchProfile } = useUserStore();
|
||||||
|
const otpInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (step === "otp") {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
otpInputRef.current?.focus();
|
||||||
|
}, 100);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [step]);
|
||||||
|
|
||||||
|
// Reset modal state when closed or opened
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
setStep("phone");
|
||||||
|
setPhoneNumber("");
|
||||||
|
setOtpCode("");
|
||||||
|
setIsLoading(false);
|
||||||
|
setCountdown(0);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// Countdown timer for OTP resend
|
||||||
|
useEffect(() => {
|
||||||
|
if (countdown > 0) {
|
||||||
|
const timer = setTimeout(() => setCountdown(countdown - 1), 1000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [countdown]);
|
||||||
|
|
||||||
|
const handleSendOtp = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const cleanPhone = phoneNumber.trim();
|
||||||
|
if (!/^09\d{9}$/.test(cleanPhone)) {
|
||||||
|
toast.error("شماره موبایل باید با ۰۹ شروع شده و ۱۱ رقم باشد");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await authService.sendOtp(cleanPhone);
|
||||||
|
if ((res as any).code) {
|
||||||
|
toast.info(`کد تایید (تست): ${(res as any).code}`, { duration: 10000 });
|
||||||
|
} else {
|
||||||
|
toast.success("کد تایید پیامک شد");
|
||||||
|
}
|
||||||
|
setStep("otp");
|
||||||
|
setCountdown(120); // 2 minutes cooldown
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || "خطا در ارسال کد تایید");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVerifyOtp = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const cleanCode = otpCode.trim();
|
||||||
|
if (cleanCode.length !== 5) {
|
||||||
|
toast.error("کد تایید باید ۵ رقم باشد");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await authService.verifyOtp(phoneNumber.trim(), cleanCode);
|
||||||
|
if (response.success) {
|
||||||
|
await fetchProfile();
|
||||||
|
toast.success("ورود با موفقیت انجام شد");
|
||||||
|
onLogin();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || "کد تایید اشتباه است");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResendOtp = async () => {
|
||||||
|
if (countdown > 0) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await authService.sendOtp(phoneNumber.trim());
|
||||||
|
toast.success("کد تایید جدید ارسال شد");
|
||||||
|
setCountdown(120);
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || "خطا در ارسال مجدد کد تایید");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute inset-0 bg-medical-gray-900/60 backdrop-blur-md"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal Container */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||||
|
className="bg-white w-full max-w-md rounded-[3.5rem] p-10 relative z-10 shadow-2xl overflow-hidden font-vazir border border-medical-gray-100"
|
||||||
|
dir="rtl"
|
||||||
|
>
|
||||||
|
{/* Top color indicator */}
|
||||||
|
<div className="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-canina-blue to-indigo-500" />
|
||||||
|
|
||||||
|
{/* Close Button */}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute top-8 left-8 p-2 rounded-full hover:bg-medical-gray-50 text-medical-gray-400 hover:text-medical-gray-600 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Header info */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="w-20 h-20 bg-canina-blue/10 rounded-[2.5rem] flex items-center justify-center mx-auto mb-6 shadow-inner">
|
||||||
|
<ShieldCheck className="w-10 h-10 text-canina-blue" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-3xl font-black text-medical-gray-900 mb-2 italic">
|
||||||
|
ورود به کانینا
|
||||||
|
</h3>
|
||||||
|
{isAdvisorContext ? (
|
||||||
|
<p className="text-medical-gray-500 font-bold leading-relaxed px-2 text-sm">
|
||||||
|
تحلیل سلامت <span className="text-canina-blue">{petName || "همدم شما"}</span> آماده است! برای مشاهده رژیم مکمل پیشنهادی و ذخیره شناسنامه، وارد شوید.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-medical-gray-500 font-bold text-sm">برای دسترسی به پنل مدیریت سلامت، وارد شوید</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step-based Forms */}
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{step === "phone" ? (
|
||||||
|
<motion.form
|
||||||
|
key="phone-form"
|
||||||
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -20 }}
|
||||||
|
onSubmit={handleSendOtp}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block">شماره موبایل</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Phone className="absolute right-4 top-1/2 -translate-y-1/2 w-5 h-5 text-medical-gray-400" />
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
type="tel"
|
||||||
|
maxLength={11}
|
||||||
|
placeholder="مثال: ۰۹۱۲۳۴۵۶۷۸۹"
|
||||||
|
value={phoneNumber}
|
||||||
|
onChange={(e) => setPhoneNumber(e.target.value.replace(/[^0-9]/g, ''))}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-2xl py-4 pr-12 pl-4 focus:ring-2 focus:ring-canina-blue/20 focus:border-canina-blue outline-none font-bold text-lg text-left tracking-widest"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading || phoneNumber.length < 11}
|
||||||
|
className="w-full py-5 bg-canina-blue text-white rounded-2xl font-black text-xl hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-xl shadow-canina-blue/20 flex items-center justify-center gap-3 cursor-pointer"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<RefreshCw className="w-6 h-6 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<LogIn className="w-6 h-6" />
|
||||||
|
ارسال کد تایید
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</motion.form>
|
||||||
|
) : (
|
||||||
|
<motion.form
|
||||||
|
key="otp-form"
|
||||||
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -20 }}
|
||||||
|
onSubmit={handleVerifyOtp}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<label className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest">کد تایید پیامکی</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStep("phone")}
|
||||||
|
className="text-[10px] font-black text-canina-blue flex items-center gap-1 hover:underline"
|
||||||
|
>
|
||||||
|
<ArrowRight className="w-3 h-3" />
|
||||||
|
ویرایش شماره
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Key className="absolute right-4 top-1/2 -translate-y-1/2 w-5 h-5 text-medical-gray-400" />
|
||||||
|
<input
|
||||||
|
ref={otpInputRef}
|
||||||
|
type="text"
|
||||||
|
maxLength={5}
|
||||||
|
placeholder="کد ۵ رقمی"
|
||||||
|
value={otpCode}
|
||||||
|
onChange={(e) => setOtpCode(e.target.value.replace(/[^0-9]/g, ''))}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-2xl py-4 pr-12 pl-4 focus:ring-2 focus:ring-canina-blue/20 focus:border-canina-blue outline-none font-black text-2xl text-center tracking-[0.5em]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-medical-gray-400 font-bold text-center mt-2">
|
||||||
|
کد تایید به شماره {toPersian(phoneNumber)} ارسال گردید.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-center">
|
||||||
|
{countdown > 0 ? (
|
||||||
|
<span className="text-xs font-bold text-medical-gray-400 font-vazir">
|
||||||
|
ارسال مجدد کد پس از {toPersian(countdown)} ثانیه
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleResendOtp}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="text-xs font-black text-canina-blue hover:underline flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-3.5 h-3.5" />
|
||||||
|
ارسال مجدد کد تایید
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading || otpCode.length < 5}
|
||||||
|
className="w-full py-5 bg-canina-blue text-white rounded-2xl font-black text-xl hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-xl shadow-canina-blue/20 flex items-center justify-center gap-3 cursor-pointer"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<RefreshCw className="w-6 h-6 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<LogIn className="w-6 h-6" />
|
||||||
|
ورود و تایید حساب
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</motion.form>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
frontend/application/components/NetworkBanner.tsx
Normal file
68
frontend/application/components/NetworkBanner.tsx
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
"use client";
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useNetworkStatus } from '../hooks/useNetworkStatus';
|
||||||
|
import { WifiOff, Wifi, X } from 'lucide-react';
|
||||||
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
|
|
||||||
|
export const NetworkBanner = () => {
|
||||||
|
const isOnline = useNetworkStatus();
|
||||||
|
const [showStatus, setShowStatus] = useState(false);
|
||||||
|
const [wasOffline, setWasOffline] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOnline) {
|
||||||
|
setShowStatus(true);
|
||||||
|
setWasOffline(true);
|
||||||
|
} else if (wasOffline) {
|
||||||
|
// Show "Back Online" message briefly
|
||||||
|
setShowStatus(true);
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setShowStatus(false);
|
||||||
|
setWasOffline(false);
|
||||||
|
}, 3000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [isOnline, wasOffline]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{showStatus && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ y: -100 }}
|
||||||
|
animate={{ y: 0 }}
|
||||||
|
exit={{ y: -100 }}
|
||||||
|
className={`fixed top-0 left-0 right-0 z-[100] text-white py-3 px-4 flex items-center justify-between shadow-2xl backdrop-blur-md ${
|
||||||
|
isOnline ? 'bg-emerald-500/90' : 'bg-rose-500/90'
|
||||||
|
}`}
|
||||||
|
dir="rtl"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{isOnline ? (
|
||||||
|
<div className="bg-white/20 p-2 rounded-xl">
|
||||||
|
<Wifi className="w-5 h-5 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-white/20 p-2 rounded-xl">
|
||||||
|
<WifiOff className="w-5 h-5 animate-bounce" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="font-black text-sm">
|
||||||
|
{isOnline
|
||||||
|
? 'اتصال اینترنت مجدداً برقرار شد.'
|
||||||
|
: 'اتصال اینترنت شما قطع شده است. در حال تلاش برای اتصال مجدد...'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowStatus(false)}
|
||||||
|
className="hover:bg-white/10 p-2 rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
};
|
||||||
265
frontend/application/components/OrderDetailsModal.tsx
Normal file
265
frontend/application/components/OrderDetailsModal.tsx
Normal file
@ -0,0 +1,265 @@
|
|||||||
|
"use client";
|
||||||
|
import React from "react";
|
||||||
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
|
import {
|
||||||
|
X,
|
||||||
|
Printer,
|
||||||
|
Download,
|
||||||
|
Package,
|
||||||
|
ShoppingBag,
|
||||||
|
MapPin,
|
||||||
|
Calendar,
|
||||||
|
Clock,
|
||||||
|
CreditCard,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertCircle,
|
||||||
|
Truck
|
||||||
|
} from "lucide-react";
|
||||||
|
import { toPersian, cn } from "../lib/utils";
|
||||||
|
import SafeImage from "./SafeImage";
|
||||||
|
|
||||||
|
interface OrderDetailsModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
order: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OrderDetailsModal({ isOpen, onClose, order }: OrderDetailsModalProps) {
|
||||||
|
if (!order) return null;
|
||||||
|
|
||||||
|
const handlePrint = () => {
|
||||||
|
// Basic print logic: open a new window with a simplified version of the invoice
|
||||||
|
const printWindow = window.open('', '_blank');
|
||||||
|
if (!printWindow) return;
|
||||||
|
|
||||||
|
const itemsHtml = (order.items || []).map((item: any) => `
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 12px; border-bottom: 1px solid #eee;">${item.product.name}</td>
|
||||||
|
<td style="padding: 12px; border-bottom: 1px solid #eee; text-align: center;">${toPersian(item.quantity)}</td>
|
||||||
|
<td style="padding: 12px; border-bottom: 1px solid #eee; text-align: left;">${toPersian(item.product.priceValue.toLocaleString())} تومان</td>
|
||||||
|
<td style="padding: 12px; border-bottom: 1px solid #eee; text-align: left;">${toPersian((item.product.priceValue * item.quantity).toLocaleString())} تومان</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
printWindow.document.write(`
|
||||||
|
<html dir="rtl">
|
||||||
|
<head>
|
||||||
|
<title>فاکتور ${order.id}</title>
|
||||||
|
<style>
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Vazirmatn';
|
||||||
|
src: url('https://cdn.jsdelivr.net/gh/rastikerdar/vazirmatn@v33.003/fonts/webfonts/Vazirmatn-Regular.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
body { font-family: 'Vazirmatn', sans-serif; padding: 40px; color: #333; }
|
||||||
|
.header { display: flex; justify-between: space-between; align-items: center; border-bottom: 2px solid #0055FF; pb: 20px; mb: 40px; }
|
||||||
|
.logo { font-size: 24px; font-weight: 900; color: #0055FF; }
|
||||||
|
.info { margin-bottom: 40px; display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th { background: #f8f9fa; text-align: right; padding: 12px; }
|
||||||
|
.total-section { margin-top: 40px; text-align: left; }
|
||||||
|
.footer { margin-top: 60px; font-size: 12px; color: #888; text-align: center; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<div class="logo">CANINA | کانینا</div>
|
||||||
|
<div>تاریخ: ${toPersian(new Date(order.date).toLocaleDateString('fa-IR'))}</div>
|
||||||
|
</div>
|
||||||
|
<div class="info">
|
||||||
|
<div>
|
||||||
|
<strong>شماره فاکتور:</strong> ${toPersian(order.id)}<br/>
|
||||||
|
<strong>کد پیگیری:</strong> ${toPersian(order.trackingNumber)}
|
||||||
|
</div>
|
||||||
|
<div style="text-align: left;">
|
||||||
|
<strong>وضعیت:</strong> ${order.status === 'delivered' ? 'تسویه شده' : 'در حال پردازش'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>محصول</th>
|
||||||
|
<th>تعداد</th>
|
||||||
|
<th>قیمت واحد</th>
|
||||||
|
<th>جمع کل</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${itemsHtml}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="total-section">
|
||||||
|
<p>جمع کل فاکتور: ${toPersian(order.total.toLocaleString())} تومان</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
این فاکتور به صورت سیستمی صادر شده و فاقد مهر و امضای فیزیکی است.
|
||||||
|
</div>
|
||||||
|
<script>window.print();</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
printWindow.document.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'delivered': return <CheckCircle2 className="w-5 h-5 text-green-500" />;
|
||||||
|
case 'shipped': return <Truck className="w-5 h-5 text-blue-500" />;
|
||||||
|
default: return <Clock className="w-5 h-5 text-orange-500" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusLabel = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'delivered': return 'تحویل شده';
|
||||||
|
case 'shipped': return 'ارسال شده';
|
||||||
|
default: return 'در حال پردازش';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const orderDate = new Date(order.date);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute inset-0 bg-medical-gray-900/60 backdrop-blur-md"
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||||
|
className="bg-white w-full max-w-2xl rounded-[3.5rem] relative z-10 shadow-2xl overflow-hidden font-vazir text-right"
|
||||||
|
dir="rtl"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-medical-gray-50 px-10 py-8 flex items-center justify-between border-b border-medical-gray-100">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-14 h-14 bg-canina-blue rounded-2xl flex items-center justify-center text-white shadow-lg">
|
||||||
|
<Package className="w-7 h-7" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-black text-medical-gray-900 italic">جزئیات سفارش {toPersian(order.id)}</h3>
|
||||||
|
<p className="text-xs font-bold text-medical-gray-400">ثبت شده در {toPersian(orderDate.toLocaleDateString("fa-IR"))}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-10 h-10 flex items-center justify-center rounded-xl bg-white border border-medical-gray-200 text-medical-gray-400 hover:text-red-500 transition-all"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Scrollable */}
|
||||||
|
<div className="max-h-[70vh] overflow-y-auto px-10 py-8 space-y-10 custom-scrollbar">
|
||||||
|
{/* Summary Stats */}
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="bg-medical-gray-50 p-4 rounded-2xl">
|
||||||
|
<div className="text-[9px] font-black text-medical-gray-400 uppercase tracking-widest mb-1">وضعیت تحویل</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{getStatusIcon(order.status)}
|
||||||
|
<span className="text-sm font-black text-medical-gray-900">{getStatusLabel(order.status)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-medical-gray-50 p-4 rounded-2xl">
|
||||||
|
<div className="text-[9px] font-black text-medical-gray-400 uppercase tracking-widest mb-1">کد رهگیری</div>
|
||||||
|
<div className="text-sm font-mono font-bold text-canina-blue">{toPersian(order.trackingNumber)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-medical-gray-50 p-4 rounded-2xl">
|
||||||
|
<div className="text-[9px] font-black text-medical-gray-400 uppercase tracking-widest mb-1">زمان ثبت</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="w-4 h-4 text-medical-gray-400" />
|
||||||
|
<span className="text-sm font-black text-medical-gray-900">{toPersian(orderDate.toLocaleTimeString("fa-IR", { hour: '2-digit', minute: '2-digit' }))}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Items List */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-black text-medical-gray-900 mb-6 flex items-center gap-2">
|
||||||
|
<ShoppingBag className="w-4 h-4 text-canina-blue" />
|
||||||
|
سبد محصولات خریده شده
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{(order.items || []).map((item: any) => (
|
||||||
|
<div key={item.product.id} className="flex items-center justify-between p-4 border border-medical-gray-100 rounded-2xl hover:border-canina-blue transition-all">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-16 h-16 bg-medical-gray-50 rounded-xl p-2 flex items-center justify-center shrink-0">
|
||||||
|
<SafeImage
|
||||||
|
src={item.product.image}
|
||||||
|
alt={item.product.name}
|
||||||
|
className="w-full h-full"
|
||||||
|
imgClassName="object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h5 className="font-black text-medical-gray-900 leading-tight mb-1">{item.product.name}</h5>
|
||||||
|
<div className="text-[10px] font-bold text-medical-gray-400">{toPersian(item.quantity)} عدد × {toPersian(item.product.priceValue.toLocaleString())} تومان</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="text-lg font-black text-canina-blue italic">{toPersian((item.product.priceValue * item.quantity).toLocaleString())} <span className="text-[10px] not-italic">تومان</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Shipping & Payment */}
|
||||||
|
<div className="grid md:grid-cols-2 gap-8 pt-6 border-t border-medical-gray-100">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-black text-medical-gray-900 mb-4 flex items-center gap-2">
|
||||||
|
<MapPin className="w-4 h-4 text-canina-blue" />
|
||||||
|
اطلاعات ارسال
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs font-bold text-medical-gray-500 leading-relaxed">
|
||||||
|
تهران، خیابان ولیعصر، بالاتر از میدان ونک، بنبست آفتاب، پلاک ۱۲، واحد ۳
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-black text-medical-gray-900 mb-4 flex items-center gap-2">
|
||||||
|
<CreditCard className="w-4 h-4 text-canina-blue" />
|
||||||
|
جزئیات پرداخت
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-xs font-bold text-medical-gray-400">
|
||||||
|
<span>جمع ناخالص</span>
|
||||||
|
<span>{toPersian(order.total.toLocaleString())} تومان</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs font-bold text-red-500">
|
||||||
|
<span>تخفیف هوشمند</span>
|
||||||
|
<span>{toPersian("۰")} تومان</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-base font-black text-medical-gray-900 pt-2 border-t border-medical-gray-50 italic">
|
||||||
|
<span>مبلغ نهایی</span>
|
||||||
|
<span>{toPersian(order.total.toLocaleString())} تومان</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer Actions */}
|
||||||
|
<div className="p-10 bg-medical-gray-50 border-t border-medical-gray-100 flex gap-4">
|
||||||
|
<button
|
||||||
|
onClick={handlePrint}
|
||||||
|
className="flex-1 py-5 bg-white border border-medical-gray-200 text-medical-gray-900 rounded-2xl font-black text-sm flex items-center justify-center gap-3 hover:bg-medical-gray-100 transition-all shadow-sm"
|
||||||
|
>
|
||||||
|
<Download className="w-5 h-5" />
|
||||||
|
دانلود فاکتور رسمی
|
||||||
|
</button>
|
||||||
|
<button className="flex items-center justify-center w-14 h-14 bg-medical-gray-900 text-white rounded-2xl hover:bg-canina-blue transition-all shadow-lg shadow-black/10">
|
||||||
|
<Printer className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
frontend/application/components/OrderSuccess.tsx
Normal file
110
frontend/application/components/OrderSuccess.tsx
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
"use client";
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { motion } from "motion/react";
|
||||||
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
Package,
|
||||||
|
Calendar,
|
||||||
|
MapPin,
|
||||||
|
ChevronLeft,
|
||||||
|
Sparkles,
|
||||||
|
Heart
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useCartStore } from "../store/cartStore";
|
||||||
|
import { usePetStore } from "../store/usePetStore";
|
||||||
|
|
||||||
|
export default function OrderSuccess({ orderId, onNavigate }: { orderId: string; onNavigate: (v: any) => void }) {
|
||||||
|
const { orders } = useCartStore();
|
||||||
|
const { getActivePet } = usePetStore();
|
||||||
|
const order = orders.find(o => o.id === orderId);
|
||||||
|
const activePet = getActivePet();
|
||||||
|
|
||||||
|
if (!order) return null;
|
||||||
|
|
||||||
|
const hasCanhydrox = order.items.some(i => i.product.id === 'canhydrox-gag');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-medical-gray-50 py-20 px-6 font-vazir" dir="rtl">
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.9, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
className="bg-white rounded-[4rem] border border-medical-gray-200 p-12 text-center shadow-2xl relative overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* Confetti-like background elements */}
|
||||||
|
<div className="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-canina-blue via-green-500 to-canina-blue" />
|
||||||
|
|
||||||
|
<div className="w-24 h-24 bg-green-50 text-green-600 rounded-full flex items-center justify-center mx-auto mb-10 shadow-inner">
|
||||||
|
<CheckCircle2 className="w-12 h-12" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-4xl font-black text-medical-gray-900 italic mb-4">سفارش شما با موفقیت ثبت شد!</h2>
|
||||||
|
<p className="text-medical-gray-500 font-bold mb-10">شماره سفارش: <span className="text-canina-blue font-mono">{order.id}</span></p>
|
||||||
|
|
||||||
|
{/* Empathetic Message */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.3 }}
|
||||||
|
className="p-8 bg-canina-blue/5 border border-canina-blue/10 rounded-[3rem] mb-12 text-right relative"
|
||||||
|
>
|
||||||
|
<Sparkles className="absolute top-6 left-6 w-8 h-8 text-canina-blue opacity-20" />
|
||||||
|
<div className="flex items-center gap-4 mb-4">
|
||||||
|
<div className="w-10 h-10 bg-white rounded-2xl flex items-center justify-center text-canina-blue shadow-sm">
|
||||||
|
<Heart className="w-6 h-6 fill-current" />
|
||||||
|
</div>
|
||||||
|
<h4 className="text-lg font-black text-canina-blue italic">خیالت راحت باشه!</h4>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-bold text-canina-blue/80 leading-relaxed">
|
||||||
|
{activePet ? (
|
||||||
|
<>
|
||||||
|
انتخاب درستی برای <span className="text-canina-blue font-black">{activePet.name}</span> کردی.
|
||||||
|
{hasCanhydrox ? (
|
||||||
|
` مکمل Canhydrox با فرمول آلمانیاش دقیقاً همون چیزیه که استخوانهای ${activePet.name} برای بازیگوشی دوباره بهش نیاز دارن.`
|
||||||
|
) : (
|
||||||
|
` محصولات انتخابی شما با بالاترین استانداردهای دارویی آلمان تولید شدهاند و به زودی به دست ${activePet.name} میرسند.`
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"سفارش شما با رعایت تمامی استانداردهای کیفی آلمان در حال دستهبندی است. از اعتماد شما به سلامت پتها سپاسگزاریم."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 gap-6 text-right mb-12">
|
||||||
|
<div className="p-6 bg-medical-gray-50 rounded-3xl border border-medical-gray-100 flex items-center gap-4">
|
||||||
|
<Package className="w-8 h-8 text-medical-gray-400" />
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest">وضعیت ارسال</p>
|
||||||
|
<p className="text-sm font-black text-medical-gray-900 italic">در حال پردازش در انبار</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 bg-medical-gray-50 rounded-3xl border border-medical-gray-100 flex items-center gap-4">
|
||||||
|
<Calendar className="w-8 h-8 text-medical-gray-400" />
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest">تخمین تحویل</p>
|
||||||
|
<p className="text-sm font-black text-medical-gray-900 italic">۴۸ تا ۷۲ ساعت کاری</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<button
|
||||||
|
onClick={() => onNavigate('order-tracking')}
|
||||||
|
className="w-full bg-medical-gray-900 text-white py-5 rounded-2xl font-black text-lg hover:bg-canina-blue transition-all"
|
||||||
|
>
|
||||||
|
مشاهده و پیگیری زنده سفارش
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onNavigate('shop')}
|
||||||
|
className="flex items-center justify-center gap-2 w-full text-medical-gray-400 font-bold hover:text-canina-blue transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
بازگشت به فروشگاه
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
168
frontend/application/components/OrderTracking.tsx
Normal file
168
frontend/application/components/OrderTracking.tsx
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
"use client";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { motion } from "motion/react";
|
||||||
|
import {
|
||||||
|
Package,
|
||||||
|
Truck,
|
||||||
|
CheckCircle2,
|
||||||
|
MapPin,
|
||||||
|
Search,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronLeft,
|
||||||
|
Clock,
|
||||||
|
ExternalLink
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useCartStore } from "../store/cartStore";
|
||||||
|
|
||||||
|
export default function OrderTracking({ onNavigate, onBack }: { onNavigate: (v: any) => void, onBack?: () => void }) {
|
||||||
|
const { orders } = useCartStore();
|
||||||
|
const [searchId, setSearchId] = useState("");
|
||||||
|
const [activeOrder, setActiveOrder] = useState(orders[0] || null);
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
const found = orders.find(o => o.id === searchId || o.trackingNumber === searchId);
|
||||||
|
if (found) {
|
||||||
|
setActiveOrder(found);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{ label: 'ثبت سفارش', sub: 'تایید شده', status: 'completed', icon: <Clock className="w-5 h-5" /> },
|
||||||
|
{ label: 'آمادهسازی در انبار', sub: 'در حال بستهبندی', status: 'active', icon: <Package className="w-5 h-5" /> },
|
||||||
|
{ label: 'تحویل به پست/کوریر', sub: 'ارسال شده', status: 'pending', icon: <Truck className="w-5 h-5" /> },
|
||||||
|
{ label: 'تحویل نهایی', sub: 'در مقصد', status: 'pending', icon: <CheckCircle2 className="w-5 h-5" /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-medical-gray-50 py-12 px-6 font-vazir" dir="rtl">
|
||||||
|
<div className="max-w-5xl mx-auto">
|
||||||
|
|
||||||
|
{/* Mobile Navigation & Breadcrumbs */}
|
||||||
|
<div className="lg:hidden mb-12 space-y-4">
|
||||||
|
<button
|
||||||
|
onClick={onBack || (() => onNavigate('home'))}
|
||||||
|
className="flex items-center gap-2 text-medical-gray-500 font-bold hover:text-canina-blue transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-5 h-5 font-vazir" />
|
||||||
|
<span>بازگشت</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-[10px] font-black text-medical-gray-400 uppercase tracking-widest overflow-x-auto whitespace-nowrap pb-2">
|
||||||
|
<span className="cursor-pointer hover:text-canina-blue" onClick={onBack || (() => onNavigate('home'))}>کانینا</span>
|
||||||
|
<ChevronLeft className="w-2.5 h-2.5" />
|
||||||
|
<span className="text-canina-blue">پیگیری سفارش</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop Breadcrumbs */}
|
||||||
|
<div className="hidden lg:flex items-center gap-2 text-[10px] font-black text-medical-gray-400 uppercase tracking-widest mb-12">
|
||||||
|
<span className="cursor-pointer hover:text-canina-blue" onClick={onBack || (() => onNavigate('home'))}>خانه</span>
|
||||||
|
<ChevronLeft className="w-2.5 h-2.5" />
|
||||||
|
<span className="text-canina-blue">رهگیری هوشمند محموله</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mb-12">
|
||||||
|
<h2 className="text-3xl font-black text-medical-gray-900 italic">وضعیت لحظهای سفارش</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search Bar */}
|
||||||
|
<div className="bg-white rounded-[2.5rem] p-8 border border-medical-gray-200 shadow-sm mb-10">
|
||||||
|
<div className="flex flex-col md:flex-row gap-4">
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<Search className="absolute right-4 top-1/2 -translate-y-1/2 w-5 h-5 text-medical-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="شماره سفارش (CN-XXXXX) یا کد رهگیری پستی را وارد کنید..."
|
||||||
|
value={searchId}
|
||||||
|
onChange={(e) => setSearchId(e.target.value)}
|
||||||
|
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-2xl py-4 pr-12 pl-6 text-sm focus:ring-2 focus:ring-canina-blue/20 outline-none font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleSearch}
|
||||||
|
className="bg-medical-gray-900 text-white px-10 py-4 rounded-2xl font-black italic hover:bg-canina-blue transition-all"
|
||||||
|
>
|
||||||
|
رهگیری آنی
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeOrder ? (
|
||||||
|
<div className="grid lg:grid-cols-12 gap-10">
|
||||||
|
{/* Status Timeline */}
|
||||||
|
<div className="lg:col-span-8 bg-white rounded-[3rem] p-10 border border-medical-gray-200 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between mb-12">
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest mb-1">کد رهگیری پستی</p>
|
||||||
|
<p className="text-sm font-mono font-bold text-canina-blue">{activeOrder.trackingNumber}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<p className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest mb-1">تاریخ ثبت</p>
|
||||||
|
<p className="text-sm font-bold text-medical-gray-900">{new Date(activeOrder.date).toLocaleDateString('fa-IR')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative space-y-12 pr-6">
|
||||||
|
<div className="absolute top-0 right-[23px] bottom-0 w-0.5 bg-medical-gray-100" />
|
||||||
|
|
||||||
|
{steps.map((step, i) => (
|
||||||
|
<div key={i} className={`relative flex items-center gap-8 ${step.status === 'pending' ? 'opacity-30' : ''}`}>
|
||||||
|
<div className={`w-12 h-12 rounded-2xl flex items-center justify-center z-10 shadow-sm transition-all ${step.status === 'completed' ? 'bg-green-600 text-white' : step.status === 'active' ? 'bg-canina-blue text-white ring-8 ring-canina-blue/10 scale-110' : 'bg-medical-gray-50 text-medical-gray-300 border border-medical-gray-100'}`}>
|
||||||
|
{step.status === 'completed' ? <CheckCircle2 className="w-6 h-6" /> : step.icon}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-black text-medical-gray-900">{step.label}</h4>
|
||||||
|
<p className="text-xs font-bold text-medical-gray-500">{step.sub}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar Details */}
|
||||||
|
<div className="lg:col-span-4 space-y-6">
|
||||||
|
<div className="bg-medical-gray-900 rounded-[2.5rem] p-8 text-white">
|
||||||
|
<h3 className="text-lg font-black italic mb-6 border-b border-white/10 pb-4">محتویات محموله</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{activeOrder.items.map(item => (
|
||||||
|
<div key={item.product.id} className="flex justify-between items-center text-xs font-bold">
|
||||||
|
<span className="text-white/60">{item.product.name}</span>
|
||||||
|
<span>{item.quantity} عدد</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-8 pt-6 border-t border-white/10 flex justify-between items-center font-black">
|
||||||
|
<span className="text-xs italic">مجموع فاکتور</span>
|
||||||
|
<span className="text-lg text-canina-blue">{activeOrder.total.toLocaleString()} تومان</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-[2rem] p-6 border border-medical-gray-200">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-10 h-10 bg-canina-blue/10 text-canina-blue rounded-xl flex items-center justify-center">
|
||||||
|
<MapPin className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<h4 className="text-xs font-black italic">آدرس تحویل</h4>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] font-bold text-medical-gray-500 leading-relaxed mb-4">
|
||||||
|
تهران، خیابان ولیعصر، بالاتر از میدان ونک، بنبست آفتاب، پلاک ۱۲، واحد ۳
|
||||||
|
</p>
|
||||||
|
<button className="w-full py-3 bg-medical-gray-50 text-medical-gray-900 rounded-xl text-[10px] font-black flex items-center justify-center gap-2 hover:bg-medical-gray-100 transition-all">
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
مشاهده در نقشه
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-20 bg-white rounded-[3rem] border border-dashed border-medical-gray-200">
|
||||||
|
<div className="w-20 h-20 bg-medical-gray-50 rounded-full flex items-center justify-center mx-auto mb-6 text-medical-gray-300">
|
||||||
|
<Package className="w-10 h-10" />
|
||||||
|
</div>
|
||||||
|
<p className="text-medical-gray-500 font-bold">سفارشی یافت نشد. لطفاً شماره سفارش صحیح را وارد کنید.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1195
frontend/application/components/PetProfile.tsx
Normal file
1195
frontend/application/components/PetProfile.tsx
Normal file
File diff suppressed because it is too large
Load Diff
621
frontend/application/components/ProductPage.tsx
Normal file
621
frontend/application/components/ProductPage.tsx
Normal file
@ -0,0 +1,621 @@
|
|||||||
|
"use client";
|
||||||
|
import { useState, useMemo, useEffect } from "react";
|
||||||
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
Calculator as CalcIcon,
|
||||||
|
ChevronLeft,
|
||||||
|
ShieldCheck,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
FlaskConical,
|
||||||
|
Stethoscope,
|
||||||
|
Info,
|
||||||
|
CalendarDays,
|
||||||
|
Dog,
|
||||||
|
Gamepad2,
|
||||||
|
X,
|
||||||
|
Plus,
|
||||||
|
Minus,
|
||||||
|
Bell,
|
||||||
|
Sparkles,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronDown,
|
||||||
|
Activity,
|
||||||
|
Heart,
|
||||||
|
Zap,
|
||||||
|
Flame,
|
||||||
|
Star,
|
||||||
|
Users,
|
||||||
|
ShoppingBag
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
const ICON_MAP: Record<string, any> = {
|
||||||
|
Sparkles,
|
||||||
|
Activity,
|
||||||
|
ShieldCheck,
|
||||||
|
Heart,
|
||||||
|
Zap,
|
||||||
|
Flame,
|
||||||
|
Star,
|
||||||
|
Users
|
||||||
|
};
|
||||||
|
import { toPersian, cn } from "../lib/utils";
|
||||||
|
import { Product, PRODUCTS } from "../data/products";
|
||||||
|
import { productService } from "../services/productService";
|
||||||
|
import { SCIENTIFIC_TERMS } from "../data/scientificTerms";
|
||||||
|
import { useSettingsStore } from "../store/settingsStore";
|
||||||
|
import { create } from "zustand";
|
||||||
|
import { useCartStore } from "../store/cartStore";
|
||||||
|
import { usePetStore } from "../store/usePetStore";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface CalculatorState {
|
||||||
|
petType: "young" | "adult";
|
||||||
|
weight: number;
|
||||||
|
setPetType: (type: "young" | "adult") => void;
|
||||||
|
setWeight: (weight: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useCalculatorStore = create<CalculatorState>((set) => ({
|
||||||
|
petType: "young",
|
||||||
|
weight: 10,
|
||||||
|
setPetType: (petType) => set({ petType }),
|
||||||
|
setWeight: (weight) => set({ weight }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import Tooltip from "./Tooltip";
|
||||||
|
import SafeImage from "./SafeImage";
|
||||||
|
|
||||||
|
export default function ProductPage({ product, onBack, onProductClick, onWikiNavigate, onShopNavigate }: {
|
||||||
|
product: Product;
|
||||||
|
onBack: () => void;
|
||||||
|
onProductClick: (p: Product) => void;
|
||||||
|
onWikiNavigate?: (id: string) => void;
|
||||||
|
onShopNavigate?: (category?: string) => void;
|
||||||
|
}) {
|
||||||
|
const [activeTab, setActiveTab] = useState<"specs" | "feeding" | "notes">("specs");
|
||||||
|
const [showRefillModal, setShowRefillModal] = useState(false);
|
||||||
|
const [itemQuantity, setItemQuantity] = useState(1);
|
||||||
|
const [allProducts, setAllProducts] = useState<Product[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
productService.getProducts()
|
||||||
|
.then(setAllProducts)
|
||||||
|
.catch(err => console.error("Error fetching products in ProductPage:", err));
|
||||||
|
}, []);
|
||||||
|
const { pets, getActivePet } = usePetStore();
|
||||||
|
const activePet = getActivePet();
|
||||||
|
const { petType, weight, setPetType, setWeight } = useCalculatorStore();
|
||||||
|
const { addItem } = useCartStore();
|
||||||
|
const scientificTerms = useSettingsStore(state => state.scientificTerms);
|
||||||
|
const termKeys = useMemo(() => {
|
||||||
|
return Array.from(new Set([
|
||||||
|
...Object.keys(scientificTerms),
|
||||||
|
...Object.keys(SCIENTIFIC_TERMS)
|
||||||
|
]));
|
||||||
|
}, [scientificTerms]);
|
||||||
|
|
||||||
|
// Re-hydrate product to ensure methods like calculateDosage exist
|
||||||
|
const fullProduct = useMemo(() => {
|
||||||
|
const apiProduct = allProducts.find(p => p.id === product.id) || product;
|
||||||
|
const staticProduct = PRODUCTS.find(p => p.id === apiProduct.id || p.artNo === apiProduct.artNo);
|
||||||
|
if (staticProduct) {
|
||||||
|
return {
|
||||||
|
...apiProduct,
|
||||||
|
calculateDosage: staticProduct.calculateDosage
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (typeof apiProduct.calculateDosage !== 'function') {
|
||||||
|
return {
|
||||||
|
...apiProduct,
|
||||||
|
calculateDosage: (w: number, y: boolean) => {
|
||||||
|
return {
|
||||||
|
quantity: 1,
|
||||||
|
unit: apiProduct.unit || "قرص",
|
||||||
|
description: "مصرف روزانه بر اساس دستور پزشک"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return apiProduct;
|
||||||
|
}, [product, allProducts]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activePet) {
|
||||||
|
setWeight(activePet.weight);
|
||||||
|
setPetType(activePet.age <= 1 ? "young" : "adult");
|
||||||
|
}
|
||||||
|
}, [activePet, setWeight, setPetType]);
|
||||||
|
|
||||||
|
const calculation = useMemo(() => {
|
||||||
|
const result = fullProduct.calculateDosage(weight, petType === "young");
|
||||||
|
const duration = Math.floor(fullProduct.packageSize / result.quantity);
|
||||||
|
|
||||||
|
return {
|
||||||
|
dailyDose: result.quantity,
|
||||||
|
duration: duration || 0,
|
||||||
|
unit: result.unit,
|
||||||
|
description: result.description
|
||||||
|
};
|
||||||
|
}, [fullProduct, petType, weight]);
|
||||||
|
|
||||||
|
// Suggest quantity inside useEffect to avoid render-phase state update
|
||||||
|
useEffect(() => {
|
||||||
|
const suggestedQty = (calculation.duration < 30 && calculation.duration > 0) ? 2 : 1;
|
||||||
|
setItemQuantity(suggestedQty);
|
||||||
|
}, [calculation.duration]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-medical-gray-50 pt-2 pb-20 px-4 md:px-0" dir="rtl">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
{/* Mobile Header with Back Button */}
|
||||||
|
<div className="flex items-center justify-between mb-6 md:hidden">
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="p-3 bg-white border border-medical-gray-200 rounded-2xl text-medical-gray-700 shadow-sm flex items-center gap-2 font-vazir text-sm font-bold"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-5 h-5" />
|
||||||
|
بازگشت
|
||||||
|
</button>
|
||||||
|
<div className="text-[10px] font-black text-canina-blue bg-canina-blue/5 px-4 py-2 rounded-xl font-vazir whitespace-nowrap">
|
||||||
|
کانینا ایران
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Breadcrumbs */}
|
||||||
|
<nav className="hidden md:flex items-center gap-2 text-xs font-bold text-medical-gray-400 mb-8 overflow-x-auto whitespace-nowrap py-2">
|
||||||
|
<button
|
||||||
|
onClick={() => onBack()}
|
||||||
|
className="hover:text-canina-blue transition-colors font-vazir cursor-pointer"
|
||||||
|
>
|
||||||
|
خانه
|
||||||
|
</button>
|
||||||
|
<ChevronLeft className="w-3 h-3 flex-shrink-0" />
|
||||||
|
<button
|
||||||
|
onClick={() => onShopNavigate?.(product.category)}
|
||||||
|
className="hover:text-canina-blue cursor-pointer font-vazir"
|
||||||
|
>
|
||||||
|
{product.category}
|
||||||
|
</button>
|
||||||
|
<ChevronLeft className="w-3 h-3 flex-shrink-0" />
|
||||||
|
<span className="text-canina-blue font-vazir whitespace-nowrap">{product.name}</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="grid lg:grid-cols-12 gap-12 items-start">
|
||||||
|
{/* Main Content Area */}
|
||||||
|
<div className="lg:col-span-8 space-y-12">
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3 mb-4">
|
||||||
|
<div className="px-3 py-1 bg-canina-blue text-white rounded-full text-[10px] font-black uppercase tracking-widest font-vazir whitespace-nowrap">
|
||||||
|
استاندارد صنعتی آلمان
|
||||||
|
</div>
|
||||||
|
{product.specialBadge && (
|
||||||
|
<div className="px-3 py-1 bg-medical-gray-900 text-white rounded-full text-[10px] font-black uppercase tracking-widest font-vazir whitespace-nowrap">
|
||||||
|
{product.specialBadge}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h1 className="text-4xl lg:text-6xl font-black text-medical-gray-900 leading-tight mb-4 italic font-vazir">
|
||||||
|
{product.name}
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl text-canina-blue font-bold italic opacity-70 leading-relaxed max-w-2xl font-vazir mb-6">
|
||||||
|
{pets.length > 0 ? (
|
||||||
|
product.optimisticTemplate?.replace("[PetName]", activePet?.name || "").replace("[OnSet]", product.onSetOfAction || "به زودی")
|
||||||
|
) : (
|
||||||
|
product.scientificTagline
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{fullProduct.shortDescription && (
|
||||||
|
<p className="text-lg text-medical-gray-600 font-medium leading-relaxed max-w-3xl font-vazir border-r-4 border-canina-blue pr-6 py-2 bg-canina-blue/5 rounded-l-2xl">
|
||||||
|
{fullProduct.shortDescription}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Key Benefits Section */}
|
||||||
|
{fullProduct.keyBenefits && (
|
||||||
|
<section className="space-y-8">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 bg-canina-blue text-white rounded-2xl flex items-center justify-center shadow-lg">
|
||||||
|
<Star className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-3xl font-black text-medical-gray-900 italic font-vazir">چرا {fullProduct.name}؟</h3>
|
||||||
|
</div>
|
||||||
|
<div className="grid md:grid-cols-3 gap-6">
|
||||||
|
{fullProduct.keyBenefits.map((benefit, i) => {
|
||||||
|
const Icon = ICON_MAP[benefit.icon] || CheckCircle2;
|
||||||
|
return (
|
||||||
|
<div key={i} className="bg-white border border-medical-gray-100 rounded-[2rem] p-8 shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all group overflow-hidden relative">
|
||||||
|
<div className="absolute -top-10 -left-10 w-24 h-24 bg-canina-blue/5 rounded-full group-hover:scale-150 transition-transform duration-700" />
|
||||||
|
<div className="w-12 h-12 bg-medical-gray-50 rounded-2xl flex items-center justify-center text-canina-blue mb-6 group-hover:bg-canina-blue group-hover:text-white transition-all">
|
||||||
|
<Icon className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<h4 className="text-xl font-black text-medical-gray-900 mb-2 font-vazir">{benefit.title}</h4>
|
||||||
|
<p className="text-sm text-medical-gray-500 font-bold font-vazir">{benefit.description}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
className="bg-white rounded-[3.5rem] p-12 border border-medical-gray-200 shadow-xl relative group overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="absolute top-6 left-6 flex flex-col gap-2 z-10">
|
||||||
|
<div className="px-3 py-1 bg-green-50 text-green-600 rounded-full text-[10px] font-black uppercase tracking-widest border border-green-100 flex items-center gap-1 font-vazir">
|
||||||
|
<CheckCircle2 className="w-3 h-3" />
|
||||||
|
موجود در انبار
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1 bg-canina-blue/5 text-canina-blue rounded-full text-[10px] font-black uppercase tracking-widest border border-canina-blue/10 font-vazir whitespace-nowrap">
|
||||||
|
گرید دارویی اختصاصی
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<SafeImage
|
||||||
|
src={product.image}
|
||||||
|
alt={product.name}
|
||||||
|
className="w-full max-w-md mx-auto h-auto drop-shadow-2xl group-hover:scale-105 transition-transform duration-700"
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Ingredients Section */}
|
||||||
|
<section className="space-y-8">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 bg-medical-gray-900 text-white rounded-2xl flex items-center justify-center shadow-lg">
|
||||||
|
<FlaskConical className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-3xl font-black text-medical-gray-900 italic font-vazir">ترکیبات و آنالیز علمی</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-10">
|
||||||
|
{/* Ingredients Cards */}
|
||||||
|
<div className="bg-white rounded-[2.5rem] p-8 border border-medical-gray-200">
|
||||||
|
<h4 className="text-xs font-black text-medical-gray-400 uppercase tracking-widest mb-6 font-vazir">مواد تشکیلدهنده برتر</h4>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{product.main_ingredients.map((ing, idx) => {
|
||||||
|
const termKey = termKeys.find(key => ing.includes(key));
|
||||||
|
return (
|
||||||
|
<div key={idx} className="bg-medical-gray-50 border border-medical-gray-100 px-5 py-3 rounded-2xl flex items-center gap-3 group hover:border-canina-blue/30 transition-all">
|
||||||
|
<span className="text-sm font-bold text-medical-gray-700 font-vazir">
|
||||||
|
{termKey ? (
|
||||||
|
<Tooltip termKey={termKey} onWikiNavigate={onWikiNavigate}>
|
||||||
|
{ing}
|
||||||
|
</Tooltip>
|
||||||
|
) : ing}
|
||||||
|
</span>
|
||||||
|
<Sparkles className="w-3 h-3 text-canina-blue opacity-40 group-hover:opacity-100 transition-opacity" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Analysis Cards Grid */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest mb-4 font-vazir">تخمین اتمام بسته (هوش مصنوعی)</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{Object.entries(product.analysis).map(([key, value]) => (
|
||||||
|
<div key={key} className="bg-canina-blue text-white p-8 rounded-[2rem] border-b-2 border-white/20 shadow-xl group hover:scale-[1.02] transition-all duration-300">
|
||||||
|
<div className="text-[10px] font-black text-white/80 uppercase tracking-widest mb-3 font-vazir whitespace-nowrap">{key}</div>
|
||||||
|
<div className="text-3xl font-black font-vazir tracking-tighter text-white">{toPersian(value)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Feeding Section */}
|
||||||
|
<section className="bg-canina-blue/5 border border-canina-blue/10 rounded-[3rem] p-10">
|
||||||
|
<div className="flex items-center gap-4 mb-8">
|
||||||
|
<div className="w-10 h-10 bg-canina-blue text-white rounded-xl flex items-center justify-center">
|
||||||
|
<CalendarDays className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-2xl font-black text-medical-gray-900 italic font-vazir">توصیه غذایی و نحوه مصرف</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-medical-gray-700 leading-relaxed text-sm md:text-base font-medium font-vazir">
|
||||||
|
{product.feedingAdvice}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Specialist Note */}
|
||||||
|
<section className="bg-white rounded-[3rem] p-10 border border-medical-gray-200">
|
||||||
|
<div className="flex flex-col md:flex-row gap-10 items-start">
|
||||||
|
<img
|
||||||
|
src={product.specialist.image}
|
||||||
|
alt={product.specialist.name}
|
||||||
|
className="w-32 h-32 rounded-[2.5rem] object-cover border-4 border-medical-gray-50 shadow-xl"
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="inline-flex items-center gap-2 px-3 py-1 bg-medical-gray-900 text-white rounded-full text-[10px] font-black uppercase tracking-widest mb-4 font-vazir whitespace-nowrap">
|
||||||
|
<Stethoscope className="w-3 h-3" />
|
||||||
|
مورد تأیید دامپزشکان
|
||||||
|
</div>
|
||||||
|
<h4 className="text-2xl font-black text-medical-gray-900 mb-1 font-vazir">{product.specialist.name}</h4>
|
||||||
|
<p className="text-sm font-bold text-canina-blue mb-6 font-vazir">{product.specialist.title}</p>
|
||||||
|
<p className="text-base text-medical-gray-600 leading-relaxed italic font-vazir">
|
||||||
|
"{product.specialist.message}"
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Specialized Landing Banner */}
|
||||||
|
<section>
|
||||||
|
<div
|
||||||
|
className="relative bg-canina-blue rounded-[3rem] p-12 overflow-hidden group cursor-pointer"
|
||||||
|
onClick={() => toast.info("بزودی: صفحه فرود تخصصی این محصول در حال آمادهسازی است")}
|
||||||
|
>
|
||||||
|
<div className="absolute top-0 right-0 w-full h-full bg-[url('https://www.transparenttextures.com/patterns/cubes.png')] opacity-10" />
|
||||||
|
<div className="absolute -bottom-20 -left-20 w-80 h-80 bg-white/10 rounded-full blur-3xl group-hover:bg-white/20 transition-all duration-700" />
|
||||||
|
|
||||||
|
<div className="relative z-10 flex flex-col md:flex-row items-center justify-between gap-8">
|
||||||
|
<div className="text-center md:text-right">
|
||||||
|
<h3 className="text-3xl lg:text-4xl font-black text-white mb-4 italic">مشاهده بررسی تخصصی و نتایج درمانی</h3>
|
||||||
|
<p className="text-white/80 font-bold font-vazir max-w-lg">
|
||||||
|
گزارشهای علمی، ویدیوهای آموزشی و تجربیات واقعی دیگر صاحبان پت در لندینگپیج اختصاصی این محصول.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white text-canina-blue px-8 py-4 rounded-2xl font-black shadow-2xl hover:scale-105 transition-transform flex items-center gap-3">
|
||||||
|
ورود به آزمایشگاه علمی
|
||||||
|
<ArrowRight className="w-5 h-5 rotate-180" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* FAQ Section */}
|
||||||
|
{product.faqs && (
|
||||||
|
<section className="pt-10">
|
||||||
|
<div className="flex items-center gap-4 mb-10">
|
||||||
|
<div className="w-12 h-12 bg-blue-100 text-canina-blue rounded-2xl flex items-center justify-center">
|
||||||
|
<Info className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-3xl font-black text-medical-gray-900 italic font-vazir">پاسخ به ابهامات شما</h3>
|
||||||
|
</div>
|
||||||
|
<div className="grid md:grid-cols-1 gap-4">
|
||||||
|
{product.faqs.map((faq, i) => (
|
||||||
|
<details key={i} className="group bg-white border border-medical-gray-200 rounded-3xl overflow-hidden hover:border-canina-blue/30 transition-all">
|
||||||
|
<summary className="flex items-center justify-between p-6 cursor-pointer list-none">
|
||||||
|
<span className="font-bold text-medical-gray-900 font-vazir">{faq.question}</span>
|
||||||
|
<ChevronDown className="w-5 h-5 text-canina-blue group-open:rotate-180 transition-transform" />
|
||||||
|
</summary>
|
||||||
|
<div className="px-6 pb-6 text-sm text-medical-gray-500 font-medium leading-relaxed font-vazir border-t border-medical-gray-50 pt-4">
|
||||||
|
{faq.answer}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sticky Sidebar */}
|
||||||
|
<div className="lg:col-span-4 lg:sticky lg:top-24 space-y-8">
|
||||||
|
<div className="bg-white rounded-[3rem] border-2 border-medical-gray-100 p-8 shadow-2xl relative overflow-hidden">
|
||||||
|
<div className="absolute top-0 right-0 w-24 h-24 bg-canina-blue/5 rounded-full -mr-12 -mt-12" />
|
||||||
|
|
||||||
|
<div className="text-4xl font-black font-vazir text-medical-gray-900 tracking-tighter mb-4">
|
||||||
|
{toPersian(product.price)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Refill Auto-Calculator */}
|
||||||
|
<div className="bg-canina-blue rounded-[2rem] p-6 text-white mb-8 border-b-8 border-white/20 shadow-2xl">
|
||||||
|
<div className="flex items-center gap-2 mb-4 opacity-90">
|
||||||
|
<Clock className="w-4 h-4 text-white" />
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-widest font-vazir text-white">هوش مصنوعی تکرار خرید</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-black font-vazir text-white">{toPersian(calculation.duration)} روز</div>
|
||||||
|
<div className="text-[9px] font-bold text-white/80 font-vazir whitespace-nowrap">تخمین اتمام برای {toPersian(weight)} کیلوگرم</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="text-xl font-black font-vazir text-white">{toPersian(calculation.dailyDose)}</div>
|
||||||
|
<div className="text-[9px] font-bold text-white/80 font-vazir whitespace-nowrap">دوز روزانه ({calculation.unit === 'g' ? 'گرم' : calculation.unit})</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Manual Calculator (Mini) */}
|
||||||
|
<div className="bg-medical-gray-50 rounded-2xl p-6 border border-medical-gray-100 mb-8 space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[10px] font-black text-medical-gray-400 font-vazir">وزن پت (کیلوگرم)</span>
|
||||||
|
<span className="text-sm font-black text-canina-blue font-vazir">{toPersian(weight)}</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="1" max="100"
|
||||||
|
value={weight}
|
||||||
|
onChange={(e) => setWeight(parseInt(e.target.value))}
|
||||||
|
className="w-full h-1.5 bg-medical-gray-200 rounded-lg appearance-none cursor-pointer accent-canina-blue"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={() => setPetType("young")} className={`flex-1 py-2 rounded-xl text-[10px] font-black transition-all ${petType === "young" ? 'bg-canina-blue text-white shadow-lg' : 'bg-white text-medical-gray-400'}`}>جوان</button>
|
||||||
|
<button onClick={() => setPetType("adult")} className={`flex-1 py-2 rounded-xl text-[10px] font-black transition-all ${petType === "adult" ? 'bg-canina-blue text-white shadow-lg' : 'bg-white text-medical-gray-400'}`}>بالغ</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4 mb-6">
|
||||||
|
{/* Quantity Selector - 40% Width */}
|
||||||
|
<div className="flex items-center gap-4 bg-medical-gray-50 border border-medical-gray-100 rounded-2xl p-2 h-16 w-[40%]">
|
||||||
|
<button
|
||||||
|
onClick={() => setItemQuantity(Math.max(1, itemQuantity - 1))}
|
||||||
|
className="w-10 h-10 flex items-center justify-center rounded-xl bg-white text-medical-gray-900 border border-medical-gray-200 hover:bg-canina-blue hover:text-white hover:border-canina-blue transition-all shadow-sm"
|
||||||
|
>
|
||||||
|
<Minus className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<span className="text-xl font-black text-medical-gray-900 flex-1 text-center font-vazir">
|
||||||
|
{toPersian(itemQuantity)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setItemQuantity(itemQuantity + 1)}
|
||||||
|
className="w-10 h-10 flex items-center justify-center rounded-xl bg-white text-medical-gray-900 border border-medical-gray-200 hover:bg-canina-blue hover:text-white hover:border-canina-blue transition-all shadow-sm"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add to Cart Button - 60% Width */}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
addItem(product, itemQuantity, { quantity: calculation.dailyDose, unit: calculation.unit });
|
||||||
|
toast.success(`${toPersian(itemQuantity)} عدد ${product.name} به سبد خرید اضافه شد`);
|
||||||
|
}}
|
||||||
|
className="w-[60%] flex items-center justify-center gap-1.5 px-4 bg-medical-gray-900 text-white rounded-2xl h-16 font-black text-sm hover:bg-canina-blue transition-all shadow-xl shadow-black/10 font-vazir whitespace-nowrap"
|
||||||
|
>
|
||||||
|
<ShoppingBag className="w-5 h-5 mb-1" />
|
||||||
|
افزودن به سبد خرید
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex flex-col gap-3">
|
||||||
|
<div className="flex items-center gap-2 text-[10px] font-bold text-medical-gray-400 font-vazir">
|
||||||
|
<ShieldCheck className="w-4 h-4 text-green-500" />
|
||||||
|
ضمانت اصالت محصول (Made in Germany)
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-[10px] font-bold text-medical-gray-400 font-vazir">
|
||||||
|
<Clock className="w-4 h-4 text-canina-blue" />
|
||||||
|
آماده ارسال (تحویل حداکثر ۴۸ ساعت)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* expected results box */}
|
||||||
|
{product.expectedResults && (
|
||||||
|
<div className="bg-white rounded-[2.5rem] border border-medical-gray-200 p-8">
|
||||||
|
<h4 className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest mb-6 font-vazir">تغییرات قابل انتظار</h4>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{product.expectedResults.map((res, i) => {
|
||||||
|
const Icon = ICON_MAP[res.icon] || Sparkles;
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex items-center gap-4 group">
|
||||||
|
<div className="w-10 h-10 bg-medical-gray-50 rounded-xl flex items-center justify-center text-canina-blue group-hover:bg-canina-blue group-hover:text-white transition-all">
|
||||||
|
<Icon className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-bold text-medical-gray-700 font-vazir">{res.text}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Smart Cross-sell: Recommended Combinations */}
|
||||||
|
{product.relatedProducts && product.relatedProducts.length > 0 && (
|
||||||
|
<section className="mt-32 pt-20 border-t border-medical-gray-200">
|
||||||
|
<div className="flex flex-col items-center text-center mb-16 px-4">
|
||||||
|
<div className="inline-flex items-center gap-2 px-4 py-2 bg-canina-blue/5 border border-canina-blue/10 rounded-full text-canina-blue text-xs font-black uppercase tracking-widest mb-6 font-vazir whitespace-nowrap">
|
||||||
|
<Sparkles className="w-4 h-4" />
|
||||||
|
فرمولاسیون ترکیبی همافزا (Synergy Blend)
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl lg:text-5xl font-black text-medical-gray-900 leading-tight">
|
||||||
|
اثربخشی <span className="text-canina-blue italic">دوبرابر</span> با ترکیب هوشمند
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid lg:grid-cols-2 gap-8">
|
||||||
|
{product.relatedProducts.map(relId => {
|
||||||
|
const relProduct = allProducts.find(p => p.id === relId);
|
||||||
|
if (!relProduct) return null;
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={relId}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
className="bg-white rounded-[3rem] p-8 border border-medical-gray-200 flex flex-col md:flex-row gap-8 items-center cursor-pointer hover:shadow-2xl transition-all"
|
||||||
|
onClick={() => onProductClick(relProduct)}
|
||||||
|
>
|
||||||
|
<div className="w-40 h-40 bg-medical-gray-50 rounded-[2rem] p-4 flex items-center justify-center">
|
||||||
|
<SafeImage src={relProduct.image} alt={relProduct.name} className="w-full h-full object-contain drop-shadow-xl" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 text-center md:text-right">
|
||||||
|
<h4 className="text-xl font-black text-medical-gray-900 mb-2">{relProduct.name}</h4>
|
||||||
|
<p className="text-sm text-medical-gray-500 mb-6">{relProduct.description}</p>
|
||||||
|
<div className="flex items-center justify-center md:justify-start gap-4">
|
||||||
|
<span className="text-lg font-black text-canina-blue">{relProduct.price}</span>
|
||||||
|
<ArrowRight className="w-4 h-4 text-medical-gray-300" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-px h-24 bg-medical-gray-100 hidden md:block" />
|
||||||
|
<div className="flex items-center justify-center gap-1 group">
|
||||||
|
<span className="text-xs font-black text-medical-gray-400 group-hover:text-canina-blue transition-colors">مشاهده محصول مکمل</span>
|
||||||
|
<ChevronLeft className="w-4 h-4 text-medical-gray-300 group-hover:text-canina-blue group-hover:-translate-x-1 transition-all rtl:group-hover:translate-x-1" />
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Refill Automation Popup */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{showRefillModal && (
|
||||||
|
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={() => setShowRefillModal(false)}
|
||||||
|
className="absolute inset-0 bg-medical-gray-900/60 backdrop-blur-md"
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||||
|
className="bg-white w-full max-w-lg rounded-[3rem] p-10 relative z-10 shadow-2xl overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-canina-blue via-blue-400 to-canina-blue" />
|
||||||
|
<button
|
||||||
|
onClick={() => setShowRefillModal(false)}
|
||||||
|
className="absolute top-6 left-6 text-medical-gray-400 hover:text-medical-gray-600 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-20 h-20 bg-canina-blue/10 rounded-[2rem] flex items-center justify-center mx-auto mb-8">
|
||||||
|
<Bell className="w-10 h-10 text-canina-blue animate-bounce" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-3xl font-black text-medical-gray-900 mb-4 leading-tight italic">سیستم یادآوری هوشمند</h3>
|
||||||
|
<div className="bg-medical-gray-50 border border-medical-gray-100 rounded-3xl p-6 mb-8 text-right">
|
||||||
|
<p className="text-medical-gray-700 leading-relaxed font-medium font-vazir">
|
||||||
|
بر اساس وزن <span className="text-canina-blue font-bold">{toPersian(weight)} کیلوگرمی</span> {activePet ? `برای ${activePet.name}` : 'سگ شما'}، این بسته {toPersian(product.packageSize)} تایی دقیقاً <span className="text-canina-blue font-bold">{toPersian(calculation.duration)} روز</span> دیگر تمام میشود.
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 flex items-center gap-2 text-xs font-bold text-medical-gray-500 font-vazir">
|
||||||
|
<ShieldCheck className="w-4 h-4 text-green-500" />
|
||||||
|
آیا مایلید سیستم ۵ روز قبل از اتمام، به شما پیامک یادآوری ارسال کند؟
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowRefillModal(false)}
|
||||||
|
className="w-full bg-canina-blue text-white py-5 rounded-2xl font-black text-lg hover:shadow-xl hover:shadow-canina-blue/20 transition-all flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="w-5 h-5" />
|
||||||
|
بله، پیامک یادآوری فعال شود
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowRefillModal(false)}
|
||||||
|
className="w-full py-4 text-medical-gray-400 font-bold text-sm hover:text-medical-gray-600 transition-colors"
|
||||||
|
>
|
||||||
|
خیر، فقط محصول را به سبد خرید اضافه کن
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
frontend/application/components/SafeImage.tsx
Normal file
74
frontend/application/components/SafeImage.tsx
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
"use client";
|
||||||
|
import React from "react";
|
||||||
|
import { ImageOff, Sparkles } from "lucide-react";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
|
interface SafeImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||||
|
src?: string;
|
||||||
|
alt?: string;
|
||||||
|
className?: string;
|
||||||
|
imgClassName?: string;
|
||||||
|
fallbackText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SafeImage({
|
||||||
|
src,
|
||||||
|
alt,
|
||||||
|
className,
|
||||||
|
imgClassName,
|
||||||
|
fallbackText = "در حال بروزرسانی تصویر...",
|
||||||
|
...props
|
||||||
|
}: SafeImageProps) {
|
||||||
|
const [error, setError] = React.useState(false);
|
||||||
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
|
||||||
|
if (!src || error) {
|
||||||
|
return (
|
||||||
|
<div className={cn(
|
||||||
|
"flex flex-col items-center justify-center bg-white border border-medical-gray-100 p-4 text-center min-h-[120px] rounded-3xl shadow-inner relative overflow-hidden",
|
||||||
|
className
|
||||||
|
)}>
|
||||||
|
{/* Subtle Brand Pattern Background */}
|
||||||
|
<div className="absolute inset-0 opacity-[0.03] pointer-events-none">
|
||||||
|
<div className="grid grid-cols-6 h-full w-full">
|
||||||
|
{Array.from({length: 24}).map((_, i) => (
|
||||||
|
<div key={i} className="border border-canina-blue" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-16 h-16 bg-canina-blue rounded-2xl flex items-center justify-center text-white font-black text-3xl mb-4 shadow-xl shadow-canina-blue/20 italic transform -rotate-6">C</div>
|
||||||
|
<div className="relative">
|
||||||
|
<ImageOff className="w-5 h-5 text-medical-gray-200 mb-2" />
|
||||||
|
<Sparkles className="w-3 h-3 text-canina-blue absolute -top-1 -right-1 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
<span className="text-[9px] font-black text-canina-blue/60 uppercase tracking-[0.2em] font-vazir leading-relaxed">
|
||||||
|
{fallbackText}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("relative overflow-hidden group flex items-center justify-center", className)}>
|
||||||
|
{loading && (
|
||||||
|
<div className="absolute inset-0 bg-medical-gray-50 animate-pulse flex items-center justify-center z-10">
|
||||||
|
<Sparkles className="w-6 h-6 text-canina-blue/20" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
className={cn(
|
||||||
|
"transition-all duration-700 w-full h-full",
|
||||||
|
imgClassName,
|
||||||
|
loading ? "opacity-0 scale-110" : "opacity-100 scale-100"
|
||||||
|
)}
|
||||||
|
onLoad={() => setLoading(false)}
|
||||||
|
onError={() => setError(true)}
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
138
frontend/application/components/SearchResultsPage.tsx
Normal file
138
frontend/application/components/SearchResultsPage.tsx
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
"use client";
|
||||||
|
import React, { useMemo, useState, useEffect } from "react";
|
||||||
|
import { Search, ShoppingBag, ChevronLeft, ArrowRight, Activity, Sparkles, HeartPulse, Stethoscope, ShieldCheck, Heart, AlertCircle } from "lucide-react";
|
||||||
|
import { usePetStore } from "../store/usePetStore";
|
||||||
|
import { motion } from "motion/react";
|
||||||
|
import { Product } from "../data/products";
|
||||||
|
import { productService } from "../services/productService";
|
||||||
|
|
||||||
|
export default function SearchResultsPage({ query, onProductClick, onBack }: { query: string; onProductClick: (p: Product) => void; onBack: () => void }) {
|
||||||
|
const { getActivePet } = usePetStore();
|
||||||
|
const activePet = getActivePet();
|
||||||
|
const [products, setProducts] = useState<Product[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
productService.getProducts()
|
||||||
|
.then(setProducts)
|
||||||
|
.catch(err => console.error("Error fetching products in SearchResultsPage:", err));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const results = products.filter(p =>
|
||||||
|
p.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||||
|
p.description.toLowerCase().includes(query.toLowerCase()) ||
|
||||||
|
p.main_ingredients.some(ing => ing.toLowerCase().includes(query.toLowerCase())) ||
|
||||||
|
p.symptoms.some(sym => sym.toLowerCase().includes(query.toLowerCase()))
|
||||||
|
);
|
||||||
|
|
||||||
|
const bestSellers = products.slice(0, 3);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-medical-gray-50 py-20 px-6 font-vazir" dir="rtl">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<button onClick={onBack} className="flex items-center gap-2 text-medical-gray-400 font-black mb-10 hover:text-canina-blue transition-colors">
|
||||||
|
<ArrowRight className="w-5 h-5 rotate-180" />
|
||||||
|
بازگشت
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex flex-col md:flex-row md:items-end justify-between mb-16 gap-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-4xl font-black text-medical-gray-900 italic mb-4">
|
||||||
|
نتایج جستجو برای: <span className="text-canina-blue">"{query}"</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-medical-gray-500 font-medium">
|
||||||
|
{results.length} محصول با معیارهای شما مطابقت دارد.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{results.length > 0 ? (
|
||||||
|
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
|
{results.map((product) => {
|
||||||
|
const compatibility = (() => {
|
||||||
|
if (!activePet) return null;
|
||||||
|
const sameSpecies = product.suitableFor === activePet.type || product.suitableFor === "هر دو";
|
||||||
|
const helpsSymptom = activePet.medicalConditions.some(c => product.symptoms.includes(c));
|
||||||
|
if (!sameSpecies) return { type: 'alert', text: `مخصوص ${product.suitableFor}` };
|
||||||
|
if (helpsSymptom) return { type: 'success', text: `توصیه شده برای ${activePet.name}` };
|
||||||
|
return { type: 'neutral', text: `مناسب برای ${activePet.name}` };
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={product.id}
|
||||||
|
layoutId={product.id}
|
||||||
|
onClick={() => onProductClick(product)}
|
||||||
|
className="bg-white rounded-[2.5rem] p-8 border border-medical-gray-200 group cursor-pointer hover:shadow-2xl hover:shadow-canina-blue/10 transition-all flex flex-col relative"
|
||||||
|
>
|
||||||
|
<div className="relative aspect-square mb-8 bg-medical-gray-50 rounded-[2rem] overflow-hidden p-6 flex items-center justify-center">
|
||||||
|
<img
|
||||||
|
src={product.image}
|
||||||
|
alt={product.name}
|
||||||
|
className="w-full h-full object-contain group-hover:scale-110 transition-transform duration-500"
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
/>
|
||||||
|
{product.specialBadge && (
|
||||||
|
<div className="absolute top-4 right-4 bg-medical-gray-900 text-white text-[8px] font-black px-3 py-1 rounded-full uppercase tracking-widest">
|
||||||
|
{product.specialBadge}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{compatibility && (
|
||||||
|
<div className={`absolute top-14 right-4 z-10 text-[8px] font-black uppercase tracking-widest px-3 py-1 rounded-full shadow-md flex items-center gap-1 ${
|
||||||
|
compatibility.type === 'alert' ? 'bg-amber-100 text-amber-700' :
|
||||||
|
compatibility.type === 'success' ? 'bg-green-100 text-green-700' : 'bg-medical-gray-100 text-medical-gray-600'
|
||||||
|
}`}>
|
||||||
|
{compatibility.type === 'alert' ? <AlertCircle className="w-2.5 h-2.5" /> : compatibility.type === 'success' ? <Heart className="w-2.5 h-2.5" /> : <Sparkles className="w-2.5 h-2.5" />}
|
||||||
|
{compatibility.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<span className="text-[10px] font-black text-canina-blue uppercase tracking-widest mb-2 block">{product.category}</span>
|
||||||
|
<h3 className="text-xl font-black text-medical-gray-900 mb-2 leading-tight group-hover:text-canina-blue transition-colors">{product.name}</h3>
|
||||||
|
<p className="text-xs text-medical-gray-400 line-clamp-2 leading-relaxed mb-6">{product.benefits}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pt-6 border-t border-medical-gray-100 italic">
|
||||||
|
<div>
|
||||||
|
<span className="text-lg font-black text-medical-gray-900">{product.price}</span>
|
||||||
|
</div>
|
||||||
|
<button className="bg-medical-gray-900 text-white w-10 h-10 rounded-xl flex items-center justify-center group-hover:bg-canina-blue transition-colors">
|
||||||
|
<ChevronLeft className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-16">
|
||||||
|
<div className="bg-white border border-medical-gray-200 rounded-[3rem] p-12 text-center shadow-sm">
|
||||||
|
<div className="w-20 h-20 bg-medical-gray-50 rounded-full flex items-center justify-center mx-auto text-medical-gray-300 mb-6">
|
||||||
|
<Search className="w-10 h-10" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-2xl font-black text-medical-gray-800 mb-4">نتیجهای یافت نشد</h3>
|
||||||
|
<p className="text-medical-gray-500 font-medium max-w-md mx-auto leading-relaxed">
|
||||||
|
متاسفیم، هیچ محصولی با عبارت مورد نظر شما مطابقت نداشت. شاید محصولات پرفروش ما برای شما مفید باشند؟
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-2xl font-black text-medical-gray-900 mb-10 italic">محصولات پیشنهادی (پرفروش)</h3>
|
||||||
|
<div className="grid md:grid-cols-3 gap-8 opacity-60 hover:opacity-100 transition-opacity">
|
||||||
|
{bestSellers.map(p => (
|
||||||
|
<div key={p.id} onClick={() => onProductClick(p)} className="cursor-pointer group">
|
||||||
|
<div className="aspect-square bg-white rounded-[2rem] p-6 mb-4 border border-medical-gray-100 flex items-center justify-center">
|
||||||
|
<img src={p.image} className="w-40 h-40 object-contain group-hover:scale-105 transition-transform" />
|
||||||
|
</div>
|
||||||
|
<h4 className="font-black text-medical-gray-900 text-center">{p.name}</h4>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
frontend/application/components/Skeleton.tsx
Normal file
86
frontend/application/components/Skeleton.tsx
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
"use client";
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface SkeletonProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Skeleton = ({ className }: SkeletonProps) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`bg-medical-gray-100 animate-pulse rounded-2xl relative overflow-hidden before:absolute before:inset-0 before:-translate-x-full before:animate-[shimmer_2s_infinite] before:bg-gradient-to-r before:from-transparent before:via-white/20 before:to-transparent ${className}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ProductCardSkeleton = () => {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-[2.5rem] p-6 border border-medical-gray-50 shadow-sm flex flex-col h-full">
|
||||||
|
{/* Image Area */}
|
||||||
|
<Skeleton className="w-full aspect-square mb-6 rounded-[2rem]" />
|
||||||
|
|
||||||
|
{/* Category Tag */}
|
||||||
|
<Skeleton className="w-20 h-4 mb-4 rounded-full" />
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<Skeleton className="w-full h-8 mb-2 rounded-lg" />
|
||||||
|
<Skeleton className="w-2/3 h-8 mb-4 rounded-lg" />
|
||||||
|
|
||||||
|
{/* Symptoms */}
|
||||||
|
<div className="flex gap-2 mb-6">
|
||||||
|
<Skeleton className="w-16 h-6 rounded-full" />
|
||||||
|
<Skeleton className="w-12 h-6 rounded-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-auto pt-6 border-t border-medical-gray-50 flex items-center justify-between">
|
||||||
|
<Skeleton className="w-24 h-8 rounded-lg" />
|
||||||
|
<Skeleton className="w-12 h-12 rounded-2xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PetProfileSkeleton = () => {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-[3rem] p-10 shadow-2xl border border-medical-gray-50">
|
||||||
|
<div className="flex flex-col md:flex-row items-center gap-10 mb-12">
|
||||||
|
<Skeleton className="w-40 h-40 rounded-[2.5rem]" />
|
||||||
|
<div className="flex-1 w-full md:w-auto text-center md:text-right">
|
||||||
|
<Skeleton className="w-32 h-10 mb-4 mx-auto md:mx-0" />
|
||||||
|
<Skeleton className="w-48 h-6 mb-6 mx-auto md:mx-0" />
|
||||||
|
<div className="flex flex-wrap gap-3 justify-center md:justify-start">
|
||||||
|
<Skeleton className="w-24 h-5 rounded-full" />
|
||||||
|
<Skeleton className="w-24 h-5 rounded-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||||
|
{[1, 2, 3, 4].map(i => (
|
||||||
|
<div key={i} className="bg-medical-gray-50 p-6 rounded-3xl">
|
||||||
|
<Skeleton className="w-20 h-4 mb-4 mx-auto" />
|
||||||
|
<Skeleton className="w-12 h-8 mx-auto" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OrderRowSkeleton = () => {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-6 p-6 border-b border-medical-gray-100 last:border-0">
|
||||||
|
<Skeleton className="w-16 h-16 rounded-2xl flex-shrink-0" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
<Skeleton className="w-40 h-6" />
|
||||||
|
<Skeleton className="w-20 h-4" />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Skeleton className="w-32 h-4" />
|
||||||
|
<Skeleton className="w-16 h-4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
297
frontend/application/components/SmartAdvisor.tsx
Normal file
297
frontend/application/components/SmartAdvisor.tsx
Normal file
@ -0,0 +1,297 @@
|
|||||||
|
"use client";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
|
import { useSettingsStore } from "../store/settingsStore";
|
||||||
|
import {
|
||||||
|
Dog,
|
||||||
|
Cat,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
ShieldCheck,
|
||||||
|
Activity,
|
||||||
|
Bone,
|
||||||
|
Sparkles,
|
||||||
|
Pill,
|
||||||
|
Sparkle
|
||||||
|
} from "lucide-react";
|
||||||
|
import { toPersian, cn } from "../lib/utils";
|
||||||
|
|
||||||
|
interface SmartAdvisorProps {
|
||||||
|
onComplete: (data: {
|
||||||
|
name: string,
|
||||||
|
type: "سگ" | "گربه",
|
||||||
|
breed: string,
|
||||||
|
age: number,
|
||||||
|
weight: number,
|
||||||
|
activityLevel: "کم" | "متوسط" | "زیاد",
|
||||||
|
medicalConditions: string[]
|
||||||
|
}) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MEDICAL_OPTIONS = [
|
||||||
|
{ id: "joint_surgery", label: "جراحی مفاصل", condition: "جراحی مفاصل" },
|
||||||
|
{ id: "recent_birth", label: "زایمان اخیر", condition: "زایمان اخیر" },
|
||||||
|
{ id: "pregnancy", label: "بارداری", condition: "بارداری" },
|
||||||
|
{ id: "digestion", label: "مشکلات گوارشی", condition: "مشکلات گوارشی" },
|
||||||
|
{ id: "hair_loss", label: "ریزش موی شدید", condition: "ریزش موی شدید" },
|
||||||
|
{ id: "appetite", label: "بیاشتهایی", condition: "بیاشتهایی" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function SmartAdvisor({ onComplete }: SmartAdvisorProps) {
|
||||||
|
const texts = useSettingsStore(state => state.texts);
|
||||||
|
const medicalOptions = React.useMemo(() => {
|
||||||
|
const jsonStr = texts['medical_options'];
|
||||||
|
if (jsonStr) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(jsonStr) as typeof MEDICAL_OPTIONS;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to parse medical_options JSON:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return MEDICAL_OPTIONS;
|
||||||
|
}, [texts]);
|
||||||
|
|
||||||
|
const [step, setStep] = useState(1);
|
||||||
|
|
||||||
|
// Step 1 Data
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [type, setType] = useState<"سگ" | "گربه">("سگ");
|
||||||
|
const [breed, setBreed] = useState("");
|
||||||
|
|
||||||
|
// Step 2 Data
|
||||||
|
const [age, setAge] = useState<number>(3);
|
||||||
|
const [weight, setWeight] = useState<number>(10);
|
||||||
|
const [activityLevel, setActivityLevel] = useState<"کم" | "متوسط" | "زیاد">("متوسط");
|
||||||
|
|
||||||
|
// Step 3 Data
|
||||||
|
const [medicalConditions, setMedicalConditions] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const handleNext = () => setStep(s => s + 1);
|
||||||
|
const handleBack = () => setStep(s => s - 1);
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
onComplete({
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
breed,
|
||||||
|
age,
|
||||||
|
weight,
|
||||||
|
activityLevel,
|
||||||
|
medicalConditions
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="canina-advisor" className="py-24 bg-white">
|
||||||
|
<div className="max-w-4xl mx-auto px-4">
|
||||||
|
<div className="text-center mb-12">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 bg-canina-blue/5 rounded-full mb-6"
|
||||||
|
>
|
||||||
|
<Sparkle className="w-4 h-4 text-canina-blue" />
|
||||||
|
<span className="text-canina-blue text-xs font-black uppercase tracking-widest font-vazir">تکنولوژی پایش هوشمند</span>
|
||||||
|
</motion.div>
|
||||||
|
<h2 className="text-4xl lg:text-5xl font-black text-medical-gray-900 mb-4 font-vazir italic">دستیار سلامت کانینا</h2>
|
||||||
|
<p className="text-medical-gray-500 font-bold text-lg font-vazir">فقط چند قدم تا صدور شناسنامه هوشمند و دریافت پیشنهادات تخصصی</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-medical-gray-50 rounded-[3.5rem] border border-medical-gray-100 p-8 lg:p-12 shadow-2xl shadow-medical-gray-200/50 relative overflow-hidden">
|
||||||
|
{/* Progress Indicator */}
|
||||||
|
<div className="absolute top-0 left-0 w-full h-2 bg-medical-gray-200">
|
||||||
|
<motion.div
|
||||||
|
className="h-full bg-canina-blue"
|
||||||
|
initial={{ width: "0%" }}
|
||||||
|
animate={{ width: `${(step / 3) * 100}%` }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{step === 1 && (
|
||||||
|
<motion.div
|
||||||
|
key="step1"
|
||||||
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -20 }}
|
||||||
|
className="space-y-8"
|
||||||
|
>
|
||||||
|
<div className="text-center">
|
||||||
|
<h3 className="text-2xl font-black text-medical-gray-900 mb-2 font-vazir uppercase italic tracking-tight">گام اول: هویت بصری</h3>
|
||||||
|
<p className="text-medical-gray-400 font-bold font-vazir">همدم شما رو با چه اسمی صدا میزنید؟</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setType("سگ")}
|
||||||
|
className={`flex flex-col items-center gap-4 p-8 rounded-[2.5rem] border-4 transition-all ${type === "سگ" ? 'border-canina-blue bg-white shadow-xl shadow-canina-blue/10 scale-105' : 'border-transparent bg-white/50 text-medical-gray-300 hover:bg-white'}`}
|
||||||
|
>
|
||||||
|
<Dog className={`w-12 h-12 ${type === "سگ" ? 'text-canina-blue' : 'text-current'}`} />
|
||||||
|
<span className="text-lg font-black font-vazir">سگ</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setType("گربه")}
|
||||||
|
className={`flex flex-col items-center gap-4 p-8 rounded-[2.5rem] border-4 transition-all ${type === "گربه" ? 'border-canina-blue bg-white shadow-xl shadow-canina-blue/10 scale-105' : 'border-transparent bg-white/50 text-medical-gray-300 hover:bg-white'}`}
|
||||||
|
>
|
||||||
|
<Cat className={`w-12 h-12 ${type === "گربه" ? 'text-canina-blue' : 'text-current'}`} />
|
||||||
|
<span className="text-lg font-black font-vazir">گربه</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-black text-medical-gray-400 pr-2">نام پت</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
placeholder="لوسی، تدی..."
|
||||||
|
className="w-full bg-white border border-medical-gray-200 rounded-2xl py-4 px-6 focus:ring-2 focus:ring-canina-blue/20 outline-none font-black text-lg font-vazir"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-black text-medical-gray-400 pr-2">نژاد</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={breed}
|
||||||
|
onChange={e => setBreed(e.target.value)}
|
||||||
|
placeholder="ژرمن، پرشین..."
|
||||||
|
className="w-full bg-white border border-medical-gray-200 rounded-2xl py-4 px-6 focus:ring-2 focus:ring-canina-blue/20 outline-none font-bold font-vazir"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleNext}
|
||||||
|
disabled={!name || !breed}
|
||||||
|
className="w-full py-6 bg-medical-gray-900 text-white rounded-3xl font-black text-lg hover:bg-canina-blue transition-all shadow-xl shadow-medical-gray-900/10 flex items-center justify-center gap-3 font-vazir disabled:opacity-50"
|
||||||
|
>
|
||||||
|
ثبت هویت و ادامه
|
||||||
|
<ChevronLeft className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 2 && (
|
||||||
|
<motion.div
|
||||||
|
key="step2"
|
||||||
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -20 }}
|
||||||
|
className="space-y-8"
|
||||||
|
>
|
||||||
|
<div className="text-center">
|
||||||
|
<h3 className="text-2xl font-black text-medical-gray-900 mb-2 font-vazir uppercase italic tracking-tight">گام دوم: پایش فیزیکی</h3>
|
||||||
|
<p className="text-medical-gray-400 font-bold font-vazir">اطلاعات فیزیکی دقیق به دوزبندی صحیح مکملها کمک میکند</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="block text-center text-xs font-black text-medical-gray-400 uppercase tracking-widest font-vazir">سن (سال)</label>
|
||||||
|
<div className="flex items-center gap-4 bg-white rounded-3xl p-2 border border-medical-gray-200">
|
||||||
|
<button onClick={() => setAge(Math.max(0, age - 1))} className="w-10 h-10 flex items-center justify-center bg-medical-gray-50 rounded-xl text-medical-gray-900 hover:bg-canina-blue hover:text-white transition-all shadow-sm">-</button>
|
||||||
|
<span className="flex-1 text-center text-2xl font-black text-canina-blue font-vazir">{toPersian(age.toString())}</span>
|
||||||
|
<button onClick={() => setAge(age + 1)} className="w-10 h-10 flex items-center justify-center bg-medical-gray-50 rounded-xl text-medical-gray-900 hover:bg-canina-blue hover:text-white transition-all shadow-sm">+</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="block text-center text-xs font-black text-medical-gray-400 uppercase tracking-widest font-vazir">وزن (کیلوگرم)</label>
|
||||||
|
<div className="flex items-center gap-4 bg-white rounded-3xl p-2 border border-medical-gray-200">
|
||||||
|
<button onClick={() => setWeight(Math.max(1, weight - 1))} className="w-10 h-10 flex items-center justify-center bg-medical-gray-50 rounded-xl text-medical-gray-900 hover:bg-canina-blue hover:text-white transition-all shadow-sm">-</button>
|
||||||
|
<span className="flex-1 text-center text-2xl font-black text-canina-blue font-vazir">{toPersian(weight.toString())}</span>
|
||||||
|
<button onClick={() => setWeight(weight + 1)} className="w-10 h-10 flex items-center justify-center bg-medical-gray-50 rounded-xl text-medical-gray-900 hover:bg-canina-blue hover:text-white transition-all shadow-sm">+</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="block text-center text-xs font-black text-medical-gray-400 uppercase tracking-widest font-vazir">سطح فعالیت روزانه</label>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
{["کم", "متوسط", "زیاد"].map(level => (
|
||||||
|
<button
|
||||||
|
key={level}
|
||||||
|
onClick={() => setActivityLevel(level as any)}
|
||||||
|
className={`py-4 rounded-2xl border-2 transition-all font-black italic ${activityLevel === level ? 'border-canina-blue bg-white text-canina-blue shadow-lg' : 'border-transparent bg-white/50 text-medical-gray-400 hover:bg-white'}`}
|
||||||
|
>
|
||||||
|
{level}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4 pt-4">
|
||||||
|
<button
|
||||||
|
onClick={handleBack}
|
||||||
|
className="flex-1 py-6 bg-medical-gray-100 text-medical-gray-500 rounded-3xl font-black text-lg hover:bg-medical-gray-200 transition-all flex items-center justify-center gap-3 font-vazir"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-5 h-5" />
|
||||||
|
قبلی
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleNext}
|
||||||
|
className="flex-[2] py-6 bg-medical-gray-900 text-white rounded-3xl font-black text-lg hover:bg-canina-blue transition-all shadow-xl shadow-medical-gray-900/10 flex items-center justify-center gap-3 font-vazir"
|
||||||
|
>
|
||||||
|
گام نهایی سلامت
|
||||||
|
<ChevronLeft className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 3 && (
|
||||||
|
<motion.div
|
||||||
|
key="step3"
|
||||||
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -20 }}
|
||||||
|
className="space-y-8"
|
||||||
|
>
|
||||||
|
<div className="text-center">
|
||||||
|
<h3 className="text-2xl font-black text-medical-gray-900 mb-2 font-vazir uppercase italic tracking-tight">گام آخر: سوابق و حساسیتها</h3>
|
||||||
|
<p className="text-medical-gray-400 font-bold font-vazir">در صورت وجود هر یک از موارد زیر، آن را تیک بزنید</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{medicalOptions.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.id}
|
||||||
|
onClick={() => {
|
||||||
|
setMedicalConditions(prev =>
|
||||||
|
prev.includes(opt.condition) ? prev.filter(c => c !== opt.condition) : [...prev, opt.condition]
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className={`flex items-center gap-4 p-5 rounded-2xl border-2 transition-all text-right ${medicalConditions.includes(opt.condition) ? 'border-canina-blue bg-white shadow-md' : 'border-transparent bg-white/50 text-medical-gray-500 hover:bg-white'}`}
|
||||||
|
>
|
||||||
|
<div className={`w-8 h-8 rounded-lg flex items-center justify-center border ${medicalConditions.includes(opt.condition) ? 'bg-canina-blue border-canina-blue text-white' : 'border-medical-gray-200 text-transparent'}`}>
|
||||||
|
<ShieldCheck className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<span className={`text-sm font-black font-vazir ${medicalConditions.includes(opt.condition) ? 'text-canina-blue' : 'text-medical-gray-700'}`}>{opt.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4 pt-4">
|
||||||
|
<button
|
||||||
|
onClick={handleBack}
|
||||||
|
className="flex-1 py-6 bg-medical-gray-100 text-medical-gray-500 rounded-3xl font-black text-lg hover:bg-medical-gray-200 transition-all flex items-center justify-center gap-3 font-vazir"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-5 h-5" />
|
||||||
|
قبلی
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
className="flex-[2] py-6 bg-canina-blue text-white rounded-3xl font-black text-lg hover:bg-indigo-700 transition-all shadow-xl shadow-canina-blue/20 flex items-center justify-center gap-3 font-vazir"
|
||||||
|
>
|
||||||
|
تکمیل و صدور شناسنامه
|
||||||
|
<ChevronLeft className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
frontend/application/components/Tooltip.tsx
Normal file
62
frontend/application/components/Tooltip.tsx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
"use client";
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
|
import { Info, ExternalLink } from 'lucide-react';
|
||||||
|
import { SCIENTIFIC_TERMS } from '../data/scientificTerms';
|
||||||
|
import { useSettingsStore } from '../store/settingsStore';
|
||||||
|
|
||||||
|
interface TooltipProps {
|
||||||
|
termKey: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
onWikiNavigate?: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Tooltip({ termKey, children, onWikiNavigate }: TooltipProps) {
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
const scientificTerms = useSettingsStore(state => state.scientificTerms);
|
||||||
|
const termData = scientificTerms[termKey] || SCIENTIFIC_TERMS[termKey];
|
||||||
|
|
||||||
|
if (!termData) return <>{children}</>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="relative inline-block group cursor-help border-b border-dotted border-canina-blue/40"
|
||||||
|
onMouseEnter={() => setIsVisible(true)}
|
||||||
|
onMouseLeave={() => setIsVisible(false)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{isVisible && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||||
|
className="absolute bottom-full left-1/2 -translate-x-1/2 mb-3 w-64 p-4 bg-medical-gray-900 text-white rounded-2xl shadow-2xl z-[100] text-sm"
|
||||||
|
dir="rtl"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-2 text-canina-blue">
|
||||||
|
<Info className="w-4 h-4" />
|
||||||
|
<span className="font-black text-[10px] uppercase tracking-widest">دانشنامه علمی کانینا</span>
|
||||||
|
</div>
|
||||||
|
<h5 className="font-black mb-1">{termData.term}</h5>
|
||||||
|
<p className="text-white/70 text-xs leading-relaxed mb-3">
|
||||||
|
{termData.definition}
|
||||||
|
</p>
|
||||||
|
{termData.wikiId && (
|
||||||
|
<button
|
||||||
|
onClick={() => onWikiNavigate?.(termData.wikiId!)}
|
||||||
|
className="flex items-center gap-1.5 text-canina-blue hover:text-white transition-colors text-[10px] font-bold"
|
||||||
|
>
|
||||||
|
<span>مطالعه مقاله کامل</span>
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Arrow */}
|
||||||
|
<div className="absolute top-full left-1/2 -translate-x-1/2 border-8 border-transparent border-t-medical-gray-900" />
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
155
frontend/application/components/TopUpModal.tsx
Normal file
155
frontend/application/components/TopUpModal.tsx
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
"use client";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
|
import { X, CreditCard, Sparkles, CheckCircle2, TrendingUp, DollarSign } from "lucide-react";
|
||||||
|
import { toPersian, cn } from "../lib/utils";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface TopUpModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: (amount: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRESET_AMOUNTS = [
|
||||||
|
{ label: "۵۰۰,۰۰۰ تومان", value: 500000 },
|
||||||
|
{ label: "۱,۰۰۰,۰۰۰ تومان", value: 1000000 },
|
||||||
|
{ label: "۲,۰۰۰,۰۰۰ تومان", value: 2000000 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function TopUpModal({ isOpen, onClose, onConfirm }: TopUpModalProps) {
|
||||||
|
const [amount, setAmount] = useState<string>("");
|
||||||
|
const [selectedPreset, setSelectedPreset] = useState<number | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handlePresetSelect = (val: number) => {
|
||||||
|
setSelectedPreset(val);
|
||||||
|
setAmount(val.toString());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const val = e.target.value.replace(/,/g, '');
|
||||||
|
if (/^\d*$/.test(val)) {
|
||||||
|
setAmount(val);
|
||||||
|
setSelectedPreset(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const finalAmount = parseInt(amount);
|
||||||
|
if (isNaN(finalAmount) || finalAmount < 10000) {
|
||||||
|
toast.error("حداقل مبلغ شارژ ۱۰,۰۰۰ تومان است");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
// Simulate gateway connection
|
||||||
|
setTimeout(() => {
|
||||||
|
onConfirm(finalAmount);
|
||||||
|
toast.success(`مبلغ ${toPersian(finalAmount.toLocaleString())} تومان به کیف پول شما اضافه شد.`);
|
||||||
|
setLoading(false);
|
||||||
|
onClose();
|
||||||
|
setAmount("");
|
||||||
|
setSelectedPreset(null);
|
||||||
|
}, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<div className="fixed inset-0 z-[110] flex items-center justify-center p-4">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute inset-0 bg-medical-gray-900/60 backdrop-blur-md"
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||||
|
className="bg-white w-full max-w-md rounded-[3rem] relative z-10 shadow-2xl overflow-hidden font-vazir text-right"
|
||||||
|
dir="rtl"
|
||||||
|
>
|
||||||
|
<div className="bg-medical-gray-50 px-8 py-6 border-b border-medical-gray-100 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-canina-blue rounded-xl flex items-center justify-center text-white">
|
||||||
|
<TrendingUp className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-black text-medical-gray-900 italic">شارژ سریع کیف پول</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-10 h-10 flex items-center justify-center rounded-xl bg-white border border-medical-gray-200 text-medical-gray-400 hover:text-red-500 transition-all"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="p-8 space-y-8">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block pr-2">یکی از مبالغ پیشنهادی را انتخاب کنید</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{PRESET_AMOUNTS.map((preset) => (
|
||||||
|
<button
|
||||||
|
key={preset.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handlePresetSelect(preset.value)}
|
||||||
|
className={cn(
|
||||||
|
"px-6 py-3 rounded-2xl text-sm font-black transition-all border",
|
||||||
|
selectedPreset === preset.value
|
||||||
|
? "bg-canina-blue text-white border-canina-blue shadow-lg shadow-canina-blue/20"
|
||||||
|
: "bg-white text-medical-gray-700 border-medical-gray-100 hover:border-canina-blue"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{preset.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block pr-2">یا مبلغ دلخواه خود را وارد کنید (تومان)</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
type="text"
|
||||||
|
value={amount ? parseInt(amount).toLocaleString() : ""}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="w-full bg-medical-gray-50 border border-medical-gray-100 rounded-3xl py-6 px-8 outline-none font-black text-2xl text-center text-medical-gray-900 transition-all focus:ring-4 focus:ring-canina-blue/10 focus:border-canina-blue italic"
|
||||||
|
placeholder="۰"
|
||||||
|
/>
|
||||||
|
{amount && (
|
||||||
|
<div className="absolute left-6 top-1/2 -translate-y-1/2 text-xs font-bold text-medical-gray-400">تومان</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-blue-50 border border-blue-100 rounded-2xl p-4 flex gap-3">
|
||||||
|
<Sparkles className="w-5 h-5 text-canina-blue shrink-0" />
|
||||||
|
<p className="text-[10px] font-bold text-canina-blue leading-relaxed">با شارژ کیف پول، از هدیههای هوشمند کانینا و اولویت در ارسال سفارشها بهرهمند شوید.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || !amount}
|
||||||
|
className="w-full py-5 bg-medical-gray-900 text-white rounded-[2rem] font-black text-lg flex items-center justify-center gap-3 hover:bg-canina-blue transition-all shadow-xl shadow-black/10 disabled:opacity-50 disabled:cursor-not-allowed group"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="w-6 h-6 border-4 border-white/20 border-t-white rounded-full animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CreditCard className="w-6 h-6 group-hover:scale-110 transition-transform" />
|
||||||
|
اتصال به درگاه بانکی
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
662
frontend/application/components/UserDashboard.tsx
Normal file
662
frontend/application/components/UserDashboard.tsx
Normal file
@ -0,0 +1,662 @@
|
|||||||
|
"use client";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
|
import { User, ShoppingBag, Wallet, MapPin, LogOut, ChevronRight, Package, Calendar, UserCircle, ShoppingCart, Trash2, Edit2, Phone, Hash, CheckCircle2, ArrowUpCircle, ArrowDownCircle, Info, Clock, CheckCircle, Heart, Sparkles } from "lucide-react";
|
||||||
|
import { OrderRowSkeleton } from "./Skeleton";
|
||||||
|
import { useUserStore, Address } from "../store/userStore";
|
||||||
|
import { useCartStore } from "../store/cartStore";
|
||||||
|
import { toPersian, cn } from "../lib/utils";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import OrderDetailsModal from "./OrderDetailsModal";
|
||||||
|
import AddressModal from "./AddressModal";
|
||||||
|
import DeleteConfirmModal from "./DeleteConfirmModal";
|
||||||
|
import TopUpModal from "./TopUpModal";
|
||||||
|
|
||||||
|
export default function UserDashboard({ onBack, onNavigate }: { onBack: () => void, onNavigate: (v: any) => void }) {
|
||||||
|
const { profile, logout, addAddress, updateAddress, deleteAddress, setDefaultAddress, topUpWallet } = useUserStore();
|
||||||
|
const { orders } = useCartStore();
|
||||||
|
const [activeTab, setActiveTab] = React.useState<"profile" | "orders" | "wallet" | "addresses">("profile");
|
||||||
|
const [isLoadingOrders, setIsLoadingOrders] = useState(true);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (activeTab === "orders") {
|
||||||
|
setIsLoadingOrders(true);
|
||||||
|
const timer = setTimeout(() => setIsLoadingOrders(false), 800);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [activeTab]);
|
||||||
|
const [selectedOrder, setSelectedOrder] = useState<any>(null);
|
||||||
|
|
||||||
|
// Address State
|
||||||
|
const [isAddressModalOpen, setIsAddressModalOpen] = useState(false);
|
||||||
|
const [editingAddress, setEditingAddress] = useState<Address | null>(null);
|
||||||
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
|
const [addressToDelete, setAddressToDelete] = useState<Address | null>(null);
|
||||||
|
|
||||||
|
// Wallet State
|
||||||
|
const [isTopUpModalOpen, setIsTopUpModalOpen] = useState(false);
|
||||||
|
|
||||||
|
// Profile State
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [isSavingProfile, setIsSavingProfile] = useState(false);
|
||||||
|
const [profileForm, setProfileForm] = useState({
|
||||||
|
firstName: profile.firstName || "",
|
||||||
|
lastName: profile.lastName || "",
|
||||||
|
email: profile.email || "",
|
||||||
|
mobile: profile.mobile || ""
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync profile data on change
|
||||||
|
React.useEffect(() => {
|
||||||
|
setProfileForm({
|
||||||
|
firstName: profile.firstName || "",
|
||||||
|
lastName: profile.lastName || "",
|
||||||
|
email: profile.email || "",
|
||||||
|
mobile: profile.mobile || ""
|
||||||
|
});
|
||||||
|
}, [profile]);
|
||||||
|
|
||||||
|
const handleSaveProfile = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!isEditing) return;
|
||||||
|
|
||||||
|
const cleanPhone = profileForm.mobile.trim();
|
||||||
|
if (!/^09\d{9}$/.test(cleanPhone)) {
|
||||||
|
toast.error("شماره موبایل وارد شده معتبر نیست (باید ۱۱ رقم باشد و با ۰۹ شروع شود)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSavingProfile(true);
|
||||||
|
try {
|
||||||
|
await useUserStore.getState().updateProfile({
|
||||||
|
firstName: profileForm.firstName,
|
||||||
|
lastName: profileForm.lastName,
|
||||||
|
email: profileForm.email,
|
||||||
|
mobile: cleanPhone
|
||||||
|
});
|
||||||
|
setIsEditing(false);
|
||||||
|
toast.success("اطلاعات کاربری با موفقیت ویرایش شد");
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || "خطا در ویرایش اطلاعات");
|
||||||
|
} finally {
|
||||||
|
setIsSavingProfile(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout();
|
||||||
|
onBack();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveAddress = async (addr: Address) => {
|
||||||
|
try {
|
||||||
|
if (editingAddress) {
|
||||||
|
await updateAddress(addr.id, addr);
|
||||||
|
} else {
|
||||||
|
await addAddress(addr);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("خطا در ثبت آدرس");
|
||||||
|
}
|
||||||
|
setEditingAddress(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteClick = (addr: Address) => {
|
||||||
|
setAddressToDelete(addr);
|
||||||
|
setIsDeleteModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmDelete = async () => {
|
||||||
|
if (addressToDelete) {
|
||||||
|
try {
|
||||||
|
await deleteAddress(addressToDelete.id);
|
||||||
|
toast.success("آدرس با موفقیت حذف شد");
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("خطا در حذف آدرس");
|
||||||
|
}
|
||||||
|
setAddressToDelete(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditClick = (addr: Address) => {
|
||||||
|
setEditingAddress(addr);
|
||||||
|
setIsAddressModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddClick = () => {
|
||||||
|
setEditingAddress(null);
|
||||||
|
setIsAddressModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: "profile", label: "اطلاعات فردی", icon: <UserCircle className="w-5 h-5" /> },
|
||||||
|
{ id: "orders", label: "سفارشات من", icon: <Package className="w-5 h-5" /> },
|
||||||
|
{ id: "wallet", label: "کیف پول", icon: <Wallet className="w-5 h-5" /> },
|
||||||
|
{ id: "addresses", label: "آدرسها", icon: <MapPin className="w-5 h-5" /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-medical-gray-50 pt-12 pb-24 px-4 font-vazir" dir="rtl">
|
||||||
|
<div className="max-w-6xl mx-auto">
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<div className="flex items-center gap-2 text-[10px] font-black text-medical-gray-400 uppercase tracking-widest mb-10">
|
||||||
|
<span className="cursor-pointer hover:text-canina-blue" onClick={onBack}>خانه</span>
|
||||||
|
<ChevronRight className="w-2.5 h-2.5" />
|
||||||
|
<span className="text-canina-blue">پنل کاربری</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid lg:grid-cols-12 gap-8">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="lg:col-span-4 space-y-6">
|
||||||
|
<div className="bg-white rounded-[3rem] border border-medical-gray-200 p-8 shadow-xl">
|
||||||
|
<div className="flex items-center gap-4 mb-10">
|
||||||
|
<div className="w-16 h-16 bg-canina-blue rounded-3xl flex items-center justify-center text-white shadow-lg">
|
||||||
|
<User className="w-8 h-8" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-black text-medical-gray-900">{profile.firstName} {profile.lastName}</h2>
|
||||||
|
<p className="text-sm font-bold text-medical-gray-400">{profile.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{tabs.map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id as any)}
|
||||||
|
className={`w-full flex items-center gap-3 p-4 rounded-2xl transition-all font-bold ${activeTab === tab.id ? "bg-canina-blue text-white shadow-lg shadow-canina-blue/20" : "text-medical-gray-600 hover:bg-medical-gray-50"}`}
|
||||||
|
>
|
||||||
|
{tab.icon}
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="w-full flex items-center gap-3 p-4 rounded-2xl text-red-500 hover:bg-red-50 transition-all font-bold mt-4"
|
||||||
|
>
|
||||||
|
<LogOut className="w-5 h-5" />
|
||||||
|
خروج از حساب
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-medical-gray-900 rounded-[3rem] p-8 text-white relative overflow-hidden shadow-2xl">
|
||||||
|
<div className="absolute top-0 left-0 p-6 opacity-20">
|
||||||
|
<Wallet className="w-12 h-12 text-canina-blue" />
|
||||||
|
</div>
|
||||||
|
<div className="relative z-10">
|
||||||
|
<div className="text-[10px] font-black uppercase tracking-widest text-white mb-2">موجودی کیف پول</div>
|
||||||
|
<div className="flex items-baseline gap-2 flex-row-reverse justify-end">
|
||||||
|
<div className="text-4xl font-black italic">{toPersian(profile.walletBalance.toLocaleString())}</div>
|
||||||
|
<span className="text-sm not-italic font-bold text-white/80">تومان</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => { setActiveTab("wallet"); setIsTopUpModalOpen(true); }}
|
||||||
|
disabled={isTopUpModalOpen}
|
||||||
|
className="mt-8 bg-canina-blue w-full py-4 rounded-2xl font-black hover:bg-white hover:text-canina-blue transition-all shadow-lg shadow-black/20 flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
شارژ کیف پول
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-pink-500 rounded-[3rem] p-8 text-white relative overflow-hidden shadow-2xl shadow-pink-200">
|
||||||
|
<div className="absolute -top-4 -right-4 opacity-10">
|
||||||
|
<Heart className="w-40 h-40 fill-white" />
|
||||||
|
</div>
|
||||||
|
<div className="relative z-10">
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<Heart className="w-5 h-5 fill-white" />
|
||||||
|
<h4 className="text-sm font-black italic tracking-widest">ردپای مهربانی</h4>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-bold text-white/70 mb-1">مجموع کمکهای اهدایی</p>
|
||||||
|
<div className="flex items-baseline gap-2 flex-row-reverse justify-end">
|
||||||
|
<span className="text-3xl font-black italic">{toPersian(profile.charityDonationTotal?.toLocaleString() || "0")}</span>
|
||||||
|
<span className="text-xs font-bold opacity-60">تومان</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-white/10 rounded-2xl border border-white/20 backdrop-blur-sm">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 bg-white/20 rounded-xl flex items-center justify-center">
|
||||||
|
<Sparkles className="w-4 h-4 text-white" />
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] font-black leading-tight">
|
||||||
|
همکاری شما باعث تامین هزینه <span className="text-sm text-yellow-300 mx-1">{toPersian(Math.floor((profile.charityDonationTotal || 0) / 15000).toString())} وعده غذا</span> برای حیوانات بیپناه شده است.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="lg:col-span-8">
|
||||||
|
<motion.div
|
||||||
|
key={activeTab}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="bg-white rounded-[3.5rem] border border-medical-gray-200 p-10 min-h-[600px] shadow-sm overflow-hidden"
|
||||||
|
>
|
||||||
|
{activeTab === "profile" && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-2xl font-black text-medical-gray-900 mb-8 italic">اطلاعات فردی</h3>
|
||||||
|
<form onSubmit={handleSaveProfile} className="space-y-8">
|
||||||
|
<div className="grid md:grid-cols-2 gap-8">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="firstName" className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block pr-4">نام</label>
|
||||||
|
<input
|
||||||
|
autoFocus={isEditing}
|
||||||
|
type="text"
|
||||||
|
id="firstName"
|
||||||
|
name="firstName"
|
||||||
|
autoComplete="given-name"
|
||||||
|
required
|
||||||
|
readOnly={!isEditing}
|
||||||
|
value={isEditing ? profileForm.firstName : profile.firstName}
|
||||||
|
onChange={e => setProfileForm({ ...profileForm, firstName: e.target.value })}
|
||||||
|
className={cn(
|
||||||
|
"w-full border p-4 rounded-2xl font-bold outline-none transition-all",
|
||||||
|
isEditing
|
||||||
|
? "bg-white border-canina-blue/30 focus:ring-2 focus:ring-canina-blue/20"
|
||||||
|
: "bg-medical-gray-50 border-medical-gray-100"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="lastName" className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block pr-4">نام خانوادگی</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="lastName"
|
||||||
|
name="lastName"
|
||||||
|
autoComplete="family-name"
|
||||||
|
required
|
||||||
|
readOnly={!isEditing}
|
||||||
|
value={isEditing ? profileForm.lastName : profile.lastName}
|
||||||
|
onChange={e => setProfileForm({ ...profileForm, lastName: e.target.value })}
|
||||||
|
className={cn(
|
||||||
|
"w-full border p-4 rounded-2xl font-bold outline-none transition-all",
|
||||||
|
isEditing
|
||||||
|
? "bg-white border-canina-blue/30 focus:ring-2 focus:ring-canina-blue/20"
|
||||||
|
: "bg-medical-gray-50 border-medical-gray-100"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="email" className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block pr-4">ایمیل</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
readOnly={!isEditing}
|
||||||
|
value={isEditing ? profileForm.email : profile.email}
|
||||||
|
onChange={e => setProfileForm({ ...profileForm, email: e.target.value })}
|
||||||
|
className={cn(
|
||||||
|
"w-full border p-4 rounded-2xl font-bold outline-none transition-all text-left",
|
||||||
|
isEditing
|
||||||
|
? "bg-white border-canina-blue/30 focus:ring-2 focus:ring-canina-blue/20"
|
||||||
|
: "bg-medical-gray-50 border-medical-gray-100"
|
||||||
|
)}
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="mobile" className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block pr-4">شماره موبایل</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
id="mobile"
|
||||||
|
name="mobile"
|
||||||
|
autoComplete="tel"
|
||||||
|
required
|
||||||
|
readOnly={!isEditing}
|
||||||
|
disabled={!isEditing}
|
||||||
|
value={isEditing ? profileForm.mobile : toPersian(profile.mobile || "")}
|
||||||
|
onChange={e => setProfileForm({ ...profileForm, mobile: e.target.value.replace(/[^0-9]/g, '') })}
|
||||||
|
className={cn(
|
||||||
|
"w-full border p-4 rounded-2xl font-bold outline-none transition-all text-left",
|
||||||
|
isEditing
|
||||||
|
? "bg-white border-canina-blue/30 focus:ring-2 focus:ring-canina-blue/20"
|
||||||
|
: "bg-medical-gray-50 border-medical-gray-100 opacity-75 cursor-not-allowed"
|
||||||
|
)}
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4 mt-8">
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSavingProfile}
|
||||||
|
className="bg-canina-blue text-white px-10 py-4 rounded-2xl font-black hover:bg-indigo-700 transition-all flex items-center gap-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSavingProfile && <span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />}
|
||||||
|
ذخیره تغییرات
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setProfileForm({
|
||||||
|
firstName: profile.firstName || "",
|
||||||
|
lastName: profile.lastName || "",
|
||||||
|
email: profile.email || "",
|
||||||
|
mobile: profile.mobile || ""
|
||||||
|
});
|
||||||
|
setIsEditing(false);
|
||||||
|
}}
|
||||||
|
className="bg-medical-gray-50 text-medical-gray-500 border border-medical-gray-200 px-10 py-4 rounded-2xl font-black hover:bg-medical-gray-100 transition-all"
|
||||||
|
>
|
||||||
|
انصراف
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsEditing(true)}
|
||||||
|
className="bg-medical-gray-900 text-white px-10 py-4 rounded-2xl font-black hover:bg-canina-blue transition-all"
|
||||||
|
>
|
||||||
|
ویرایش اطلاعات
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === "orders" && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<h3 className="text-2xl font-black text-medical-gray-900 italic">تاریخچه سفارشات</h3>
|
||||||
|
<div className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest border border-medical-gray-100 px-4 py-2 rounded-full bg-medical-gray-50">
|
||||||
|
تعداد کل: {toPersian((orders || []).length.toString())} مورد
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{(orders || []).length === 0 ? (
|
||||||
|
<div className="text-center py-20">
|
||||||
|
<ShoppingBag className="w-16 h-16 text-medical-gray-200 mx-auto mb-4" />
|
||||||
|
<p className="text-medical-gray-400 font-bold">هنوز سفارشی ثبت نکردهاید.</p>
|
||||||
|
<button onClick={() => onNavigate("shop")} className="mt-6 text-canina-blue font-black underline underline-offset-8">برو به فروشگاه</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{isLoadingOrders ? (
|
||||||
|
[1, 2, 3].map(i => <OrderRowSkeleton key={i} />)
|
||||||
|
) : (
|
||||||
|
(orders || []).map(order => (
|
||||||
|
<motion.div
|
||||||
|
key={order.id}
|
||||||
|
whileHover={{ scale: 1.01, x: -5 }}
|
||||||
|
onClick={() => setSelectedOrder(order)}
|
||||||
|
className="p-6 border border-medical-gray-100 rounded-[2.5rem] hover:border-canina-blue transition-all group cursor-pointer bg-white overflow-hidden relative"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 bg-medical-gray-50 rounded-2xl flex items-center justify-center text-canina-blue group-hover:bg-canina-blue group-hover:text-white transition-all shadow-sm">
|
||||||
|
<Package className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-black text-medical-gray-900 group-hover:text-canina-blue transition-colors italic">سفارش {toPersian(order.id.toUpperCase())}</div>
|
||||||
|
<div className="text-[10px] font-bold text-medical-gray-400 flex items-center gap-1">
|
||||||
|
<Calendar className="w-3 h-3 opacity-30" />
|
||||||
|
{toPersian(new Date(order.date).toLocaleDateString("fa-IR"))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="text-lg font-black text-medical-gray-900 italic">{toPersian(order.total.toLocaleString())} <span className="text-xs">تومان</span></div>
|
||||||
|
<div className="flex items-center gap-2 justify-end mt-1">
|
||||||
|
<span className={`text-[9px] font-black px-2 py-0.5 rounded-full uppercase tracking-tighter ${order.status === 'delivered' ? 'bg-green-50 text-green-600' : 'bg-canina-blue/5 text-canina-blue'}`}>
|
||||||
|
{order.status === 'delivered' ? 'تحویل شده' : 'ارسال شده'}
|
||||||
|
</span>
|
||||||
|
<ChevronRight className="w-4 h-4 text-medical-gray-300 -rotate-180" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === "addresses" && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<h3 className="text-2xl font-black text-medical-gray-900 italic">آدرسهای من</h3>
|
||||||
|
<button
|
||||||
|
onClick={handleAddClick}
|
||||||
|
className="px-6 py-3 bg-canina-blue text-white rounded-2xl font-black text-xs flex items-center gap-2 hover:bg-medical-gray-900 transition-all shadow-lg shadow-canina-blue/10"
|
||||||
|
>
|
||||||
|
<MapPin className="w-4 h-4" />
|
||||||
|
افزودن آدرس جدید
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{(profile?.addresses || []).length === 0 ? (
|
||||||
|
<div className="text-center py-20 border-2 border-dashed border-medical-gray-100 rounded-[3rem]">
|
||||||
|
<MapPin className="w-16 h-16 text-medical-gray-200 mx-auto mb-4" />
|
||||||
|
<p className="text-medical-gray-400 font-bold">هنوز آدرسی ثبت نکردهاید.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
(profile?.addresses || []).map((addr) => (
|
||||||
|
<motion.div
|
||||||
|
key={addr.id}
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className={cn(
|
||||||
|
"p-8 bg-white border rounded-[2.5rem] relative group transition-all",
|
||||||
|
addr.isDefault ? "border-canina-blue shadow-xl shadow-canina-blue/5" : "border-medical-gray-100 hover:border-medical-gray-200"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start mb-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className={cn("w-12 h-12 rounded-2xl flex items-center justify-center transition-all", addr.isDefault ? "bg-canina-blue text-white shadow-lg" : "bg-medical-gray-50 text-canina-blue")}>
|
||||||
|
<MapPin className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h4 className="text-xl font-black text-medical-gray-900 italic">{addr.title}</h4>
|
||||||
|
{addr.isDefault && (
|
||||||
|
<span className="px-3 py-1 bg-green-50 text-green-600 text-[9px] font-black rounded-full uppercase tracking-widest">پیشفرض</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs font-bold text-medical-gray-400">تحویل گیرنده: {addr.receptorName}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleEditClick(addr)}
|
||||||
|
className="w-10 h-10 flex items-center justify-center rounded-xl bg-medical-gray-50 text-medical-gray-400 hover:bg-canina-blue hover:text-white transition-all shadow-sm"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteClick(addr)}
|
||||||
|
className="w-10 h-10 flex items-center justify-center rounded-xl bg-medical-gray-50 text-medical-gray-400 hover:bg-red-500 hover:text-white transition-all shadow-sm"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-medical-gray-50/50 rounded-2xl p-6 mb-6">
|
||||||
|
<p className="text-sm font-black text-medical-gray-700 leading-relaxed text-right">{toPersian(addr.province)}، {toPersian(addr.city)}، {toPersian(addr.detail)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Phone className="w-4 h-4 text-canina-blue/40" />
|
||||||
|
<span className="text-xs font-bold text-medical-gray-500" dir="ltr">{toPersian(addr.phone)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Hash className="w-4 h-4 text-canina-blue/40" />
|
||||||
|
<span className="text-xs font-bold text-medical-gray-500" dir="ltr">{toPersian(addr.zipCode)}</span>
|
||||||
|
</div>
|
||||||
|
{!addr.isDefault && (
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await setDefaultAddress(addr.id);
|
||||||
|
toast.success("آدرس پیشفرض با موفقیت تغییر کرد");
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("خطا در تغییر آدرس پیشفرض");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="mr-auto text-[10px] font-black text-canina-blue hover:text-medical-gray-900 uppercase tracking-widest whitespace-nowrap"
|
||||||
|
>
|
||||||
|
انتخاب به عنوان پیشفرض
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{addr.isDefault && (
|
||||||
|
<div className="mr-auto flex items-center gap-2 text-green-600 text-[10px] font-black italic">
|
||||||
|
<CheckCircle2 className="w-4 h-4" />
|
||||||
|
آدرس اصلی ارسال
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === "wallet" && (
|
||||||
|
<div className="space-y-10">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-2xl font-black text-medical-gray-900 italic">مدیریت کیف پول</h3>
|
||||||
|
<div className="text-[10px] font-black text-green-600 bg-green-50 px-4 py-2 rounded-full uppercase tracking-widest border border-green-100 flex items-center gap-2">
|
||||||
|
<CheckCircle className="w-3 h-3" />
|
||||||
|
حساب تایید شده
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Balance Card */}
|
||||||
|
<div className="bg-gradient-to-br from-canina-blue to-blue-600 rounded-[3rem] p-12 text-white shadow-2xl shadow-canina-blue/20 relative overflow-hidden group">
|
||||||
|
<div className="absolute top-0 right-0 p-12 opacity-10 group-hover:scale-110 transition-transform duration-700">
|
||||||
|
<Wallet className="w-48 h-48" />
|
||||||
|
</div>
|
||||||
|
<div className="relative z-10 flex flex-col md:flex-row items-center justify-between gap-10">
|
||||||
|
<div className="text-center md:text-right">
|
||||||
|
<div className="text-xs font-black uppercase tracking-[0.2em] text-white/70 mb-4 flex items-center gap-2 justify-center md:justify-start text-right">
|
||||||
|
<div className="w-2 h-2 bg-white rounded-full animate-pulse" />
|
||||||
|
موجودی زنده و قابل استفاده
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline gap-4 flex-row-reverse justify-center md:justify-start">
|
||||||
|
<div className="text-6xl font-black italic tracking-tight">{toPersian(profile.walletBalance.toLocaleString())}</div>
|
||||||
|
<span className="text-xl not-italic font-bold opacity-80 decoration-white/30 underline underline-offset-8">تومان</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4 w-full md:w-auto">
|
||||||
|
<div className="relative group/btn flex-1 md:flex-none">
|
||||||
|
<button
|
||||||
|
disabled
|
||||||
|
className="w-full md:w-40 h-16 bg-white/10 backdrop-blur-md rounded-2xl font-black text-sm flex items-center justify-center gap-2 border border-white/20 opacity-50 cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<ArrowDownCircle className="w-5 h-5" />
|
||||||
|
برداشت وجه
|
||||||
|
</button>
|
||||||
|
<div className="absolute top-full right-0 mt-3 w-48 p-3 bg-medical-gray-900 text-white text-[10px] font-bold rounded-xl opacity-0 group-hover/btn:opacity-100 transition-all pointer-events-none z-20 text-center shadow-xl">
|
||||||
|
قابلیت برداشت وجه بهزودی فعال خواهد شد.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsTopUpModalOpen(true)}
|
||||||
|
className="flex-1 md:w-40 h-16 bg-white text-canina-blue rounded-2xl font-black text-sm flex items-center justify-center gap-2 hover:bg-medical-gray-900 hover:text-white transition-all shadow-xl shadow-black/10 group/topup"
|
||||||
|
>
|
||||||
|
<ArrowUpCircle className="w-5 h-5 group-hover:-translate-y-1 transition-transform" />
|
||||||
|
شارژ آنی
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Transaction History */}
|
||||||
|
<div className="pt-10 border-t border-medical-gray-100">
|
||||||
|
<div className="flex items-center gap-4 mb-8">
|
||||||
|
<div className="w-10 h-10 bg-medical-gray-50 rounded-xl flex items-center justify-center text-medical-gray-400">
|
||||||
|
<Clock className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<h4 className="text-lg font-black text-medical-gray-900 italic">تاریخچه تراکنشها</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{(profile?.transactions || []).length === 0 ? (
|
||||||
|
<div className="text-center py-20 bg-medical-gray-50 rounded-[2.5rem] border border-dashed border-medical-gray-200">
|
||||||
|
<Info className="w-12 h-12 text-medical-gray-200 mx-auto mb-4" />
|
||||||
|
<p className="text-medical-gray-400 font-bold">تراکنشی یافت نشد.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
(profile?.transactions || []).map((trx) => (
|
||||||
|
<div key={trx.id} className="p-6 bg-white border border-medical-gray-100 rounded-[2rem] flex flex-wrap items-center justify-between gap-6 hover:shadow-lg hover:border-canina-blue/10 transition-all group">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className={cn(
|
||||||
|
"w-12 h-12 rounded-2xl flex items-center justify-center transition-colors",
|
||||||
|
trx.type === 'top_up' ? "bg-green-50 text-green-500 group-hover:bg-green-500 group-hover:text-white" : "bg-red-50 text-red-500 group-hover:bg-red-500 group-hover:text-white"
|
||||||
|
)}>
|
||||||
|
{trx.type === 'top_up' ? <ArrowUpCircle className="w-6 h-6" /> : <ShoppingCart className="w-6 h-6" />}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-black text-medical-gray-900">{trx.type === 'top_up' ? "افزایش موجودی (شارژ)" : "پرداخت سفارش"}</div>
|
||||||
|
<div className="text-[10px] font-bold text-medical-gray-400 flex items-center gap-2">
|
||||||
|
<span>کد پیگیری: {trx.id}</span>
|
||||||
|
<span className="w-1 h-1 bg-medical-gray-200 rounded-full" />
|
||||||
|
<span>{toPersian(new Date(trx.date).toLocaleDateString("fa-IR"))}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className={cn(
|
||||||
|
"text-lg font-black italic",
|
||||||
|
trx.type === 'top_up' ? "text-green-600" : "text-red-500"
|
||||||
|
)}>
|
||||||
|
{trx.type === 'top_up' ? "+" : ""}{toPersian(trx.amount.toLocaleString())} <span className="text-xs not-italic">تومان</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[9px] font-black uppercase text-green-500 tracking-widest mt-1">
|
||||||
|
تایید شده
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<OrderDetailsModal
|
||||||
|
isOpen={!!selectedOrder}
|
||||||
|
onClose={() => setSelectedOrder(null)}
|
||||||
|
order={selectedOrder}
|
||||||
|
/>
|
||||||
|
<AddressModal
|
||||||
|
isOpen={isAddressModalOpen}
|
||||||
|
onClose={() => setIsAddressModalOpen(false)}
|
||||||
|
onSave={handleSaveAddress}
|
||||||
|
editingAddress={editingAddress}
|
||||||
|
/>
|
||||||
|
<DeleteConfirmModal
|
||||||
|
isOpen={isDeleteModalOpen}
|
||||||
|
onClose={() => setIsDeleteModalOpen(false)}
|
||||||
|
onConfirm={handleConfirmDelete}
|
||||||
|
title="حذف آدرس"
|
||||||
|
message={`آیا از حذف آدرس "${addressToDelete?.title}" اطمینان دارید؟ این عمل غیرقابل بازگشت است.`}
|
||||||
|
/>
|
||||||
|
<TopUpModal
|
||||||
|
isOpen={isTopUpModalOpen}
|
||||||
|
onClose={() => setIsTopUpModalOpen(false)}
|
||||||
|
onConfirm={topUpWallet}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
148
frontend/application/components/VetGallery.tsx
Normal file
148
frontend/application/components/VetGallery.tsx
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
"use client";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { Play, PlayCircle, Star, ShieldCheck, X } from "lucide-react";
|
||||||
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
|
|
||||||
|
const VIDEOS = [
|
||||||
|
{
|
||||||
|
id: "v1",
|
||||||
|
title: "نحوه آمادهسازی کانیهیدروکس",
|
||||||
|
doctor: "دکتر کلاوس هنینگ",
|
||||||
|
duration: "۱:۴۵",
|
||||||
|
thumbnail: "https://images.unsplash.com/photo-1576091160550-217359f42f8c?auto=format&fit=crop&q=80&w=400",
|
||||||
|
videoUrl: "https://www.w3schools.com/html/mov_bbb.mp4", // Placeholder real-looking video
|
||||||
|
description: "آموزش گام به گام آمادهسازی پودر Canhydrox GAG برای جذب حداکثری در دستگاه گوارش."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "v2",
|
||||||
|
title: "اهمیت صدف لبسبز",
|
||||||
|
doctor: "دکتر النا اشمیت",
|
||||||
|
duration: "۲:۲۰",
|
||||||
|
thumbnail: "https://images.unsplash.com/photo-1599443015574-be5fe8a05783?auto=format&fit=crop&q=80&w=400",
|
||||||
|
videoUrl: "https://www.w3schools.com/html/movie.mp4",
|
||||||
|
description: "چرا صدف لبسبز نیوزیلندی نایابترین و موثرترین ماده برای بازسازی مفصل است؟"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "v3",
|
||||||
|
title: "تغذیه هوشمند تولهسگها",
|
||||||
|
doctor: "دکتر هلگا فیشر",
|
||||||
|
duration: "۳:۱۰",
|
||||||
|
thumbnail: "https://images.unsplash.com/photo-1583337130417-3346a1be7dee?auto=format&fit=crop&q=80&w=400",
|
||||||
|
videoUrl: "https://www.w3schools.com/html/mov_bbb.mp4",
|
||||||
|
description: "دوز مصرفی مکملهای کلسیم در نژادهای بزرگ برای جلوگیری از بدشکلیهای استخوانی."
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function VetGallery({ onNavigate }: { onNavigate?: (v: any) => void }) {
|
||||||
|
const [selectedVideo, setSelectedVideo] = useState<typeof VIDEOS[0] | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="py-24 bg-white font-vazir" dir="rtl">
|
||||||
|
<div className="max-w-7xl mx-auto px-4">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-end justify-between mb-16 gap-6">
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<div className="inline-flex items-center gap-2 px-3 py-1 bg-canina-blue/10 text-canina-blue rounded-full text-[10px] font-black uppercase tracking-widest mb-4">
|
||||||
|
<Star className="w-3 h-3" />
|
||||||
|
Vet Insights
|
||||||
|
</div>
|
||||||
|
<h2 className="text-4xl md:text-5xl font-black text-medical-gray-900 italic leading-tight">
|
||||||
|
مشاوره ویدئویی <span className="text-canina-blue underline decoration-4 decoration-canina-blue/20 underline-offset-8">دوست دامپزشک پت شما</span>
|
||||||
|
</h2>
|
||||||
|
<p className="text-lg text-medical-gray-500 font-medium mt-6">
|
||||||
|
متخصصین آلمانی کانینا مستقیماً با شما صحبت میکنند. نحوه مصرف، مزایای رقابتی و دانش تخصصی پشت هر محصول.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => onNavigate?.('videos')}
|
||||||
|
className="flex items-center gap-2 text-canina-blue font-black border-b-2 border-canina-blue pb-1 hover:gap-4 transition-all whitespace-nowrap"
|
||||||
|
>
|
||||||
|
مشاهده همه ویدئوها
|
||||||
|
<Play className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
|
{VIDEOS.map((video, idx) => (
|
||||||
|
<motion.div
|
||||||
|
key={video.id}
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ delay: idx * 0.1 }}
|
||||||
|
className="group cursor-pointer"
|
||||||
|
onClick={() => setSelectedVideo(video)}
|
||||||
|
>
|
||||||
|
<div className="relative aspect-video rounded-[2rem] overflow-hidden mb-6 shadow-xl">
|
||||||
|
<img
|
||||||
|
src={video.thumbnail}
|
||||||
|
alt={video.title}
|
||||||
|
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700"
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black/30 group-hover:bg-black/20 transition-colors flex items-center justify-center">
|
||||||
|
<div className="w-16 h-16 bg-white/90 rounded-full flex items-center justify-center text-canina-blue shadow-2xl group-hover:scale-110 transition-transform">
|
||||||
|
<PlayCircle className="w-10 h-10" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="absolute bottom-4 right-4 bg-black/60 backdrop-blur-md text-white px-3 py-1 rounded-lg text-[10px] font-black">
|
||||||
|
{video.duration}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<ShieldCheck className="w-4 h-4 text-canina-blue" />
|
||||||
|
<span className="text-xs font-black text-canina-blue">{video.doctor}</span>
|
||||||
|
</div>
|
||||||
|
<h4 className="text-xl font-black text-medical-gray-900 group-hover:text-canina-blue transition-colors mb-3">
|
||||||
|
{video.title}
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-medical-gray-500 font-medium leading-relaxed line-clamp-2 italic">
|
||||||
|
"{video.description}"
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Video Modal */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{selectedVideo && (
|
||||||
|
<div className="fixed inset-0 z-[110] flex items-center justify-center p-4">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={() => setSelectedVideo(null)}
|
||||||
|
className="absolute inset-0 bg-medical-gray-900/90 backdrop-blur-xl"
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.9 }}
|
||||||
|
className="bg-black w-full max-w-5xl aspect-video rounded-[3rem] overflow-hidden relative z-10 shadow-2xl border border-white/10"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedVideo(null)}
|
||||||
|
className="absolute top-6 left-6 text-white/50 hover:text-white z-20 transition-colors bg-black/20 p-2 rounded-full"
|
||||||
|
>
|
||||||
|
<X className="w-8 h-8" />
|
||||||
|
</button>
|
||||||
|
<video
|
||||||
|
src={selectedVideo.videoUrl}
|
||||||
|
controls
|
||||||
|
autoPlay
|
||||||
|
className="w-full h-full object-contain"
|
||||||
|
/>
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 p-8 bg-gradient-to-t from-black/80 to-transparent">
|
||||||
|
<h3 className="text-white text-2xl font-black mb-2">{selectedVideo.title}</h3>
|
||||||
|
<p className="text-white/60 text-sm font-medium">{selectedVideo.doctor}</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
186
frontend/application/components/VideosPage.tsx
Normal file
186
frontend/application/components/VideosPage.tsx
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
"use client";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
|
import { ChevronRight, PlayCircle, Star, ShieldCheck, X, Search, Clock, User } from "lucide-react";
|
||||||
|
|
||||||
|
|
||||||
|
const ALL_VIDEOS = [
|
||||||
|
{
|
||||||
|
id: "v1",
|
||||||
|
title: "نحوه آمادهسازی کانیهیدروکس",
|
||||||
|
doctor: "دکتر کلاوس هنینگ",
|
||||||
|
duration: "۱:۴۵",
|
||||||
|
thumbnail: "https://images.unsplash.com/photo-1576091160550-217359f42f8c?auto=format&fit=crop&q=80&w=600",
|
||||||
|
videoUrl: "https://www.w3schools.com/html/mov_bbb.mp4",
|
||||||
|
description: "آموزش گام به گام آمادهسازی پودر Canhydrox GAG برای جذب حداکثری در دستگاه گوارش."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "v2",
|
||||||
|
title: "اهمیت صدف لبسبز برای غضروف",
|
||||||
|
doctor: "دکتر النا اشمیت",
|
||||||
|
duration: "۲:۲۰",
|
||||||
|
thumbnail: "https://images.unsplash.com/photo-1599443015574-be5fe8a05783?auto=format&fit=crop&q=80&w=600",
|
||||||
|
videoUrl: "https://www.w3schools.com/html/movie.mp4",
|
||||||
|
description: "چرا صدف لبسبز نیوزیلندی نایابترین و موثرترین ماده برای بازسازی مفصل است؟"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "v3",
|
||||||
|
title: "تغذیه هوشمند تولهسگهای نژاد بزرگ",
|
||||||
|
doctor: "دکتر هلگا فیشر",
|
||||||
|
duration: "۳:۱۰",
|
||||||
|
thumbnail: "https://images.unsplash.com/photo-1583337130417-3346a1be7dee?auto=format&fit=crop&q=80&w=600",
|
||||||
|
videoUrl: "https://www.w3schools.com/html/mov_bbb.mp4",
|
||||||
|
description: "دوز مصرفی مکملهای کلسیم در نژادهای بزرگ برای جلوگیری از بدشکلیهای استخوانی."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "v4",
|
||||||
|
title: "تاثیر امگا ۳ بر ریزش مو فصلی",
|
||||||
|
doctor: "دکتر ماری کونیگ",
|
||||||
|
duration: "۵:۱۵",
|
||||||
|
thumbnail: "https://images.unsplash.com/photo-1516734212186-a967f81ad0d7?auto=format&fit=crop&q=80&w=600",
|
||||||
|
videoUrl: "https://www.w3schools.com/html/movie.mp4",
|
||||||
|
description: "چربیهای ضروری چگونه لایه محافظ پوست را بازسازی میکنند."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "v5",
|
||||||
|
title: "سمزدایی گوارشی با اسید هومیک",
|
||||||
|
doctor: "دکتر ریکاردو کوخ",
|
||||||
|
duration: "۴:۰۰",
|
||||||
|
thumbnail: "https://images.unsplash.com/photo-1581594693702-fbdc51b2763b?auto=format&fit=crop&q=80&w=600",
|
||||||
|
videoUrl: "https://www.w3schools.com/html/mov_bbb.mp4",
|
||||||
|
description: "مکانیزم عمل Moor-Tranke در جذب توکسینهای رودهای."
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function VideosPage({ onBack }: { onBack: () => void }) {
|
||||||
|
const [selectedVideo, setSelectedVideo] = useState<typeof ALL_VIDEOS[0] | null>(null);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
const filteredVideos = ALL_VIDEOS.filter(v =>
|
||||||
|
v.title.includes(search) || v.doctor.includes(search)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-medical-gray-900 pt-8 pb-20 px-4 font-vazir" dir="rtl">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col md:flex-row items-center justify-between mb-16 gap-6">
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="inline-flex items-center gap-2 px-3 py-1 bg-canina-blue/10 text-canina-blue rounded-full text-[10px] font-black uppercase tracking-widest mb-4">
|
||||||
|
<PlayCircle className="w-3 h-3" />
|
||||||
|
Video Academy
|
||||||
|
</div>
|
||||||
|
<h1 className="text-4xl lg:text-6xl font-black text-white italic leading-tight">
|
||||||
|
آکادمی ویدئویی <span className="text-canina-blue">کانینا</span>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="px-8 py-3 bg-white/5 border border-white/10 rounded-2xl text-white shadow-sm flex items-center gap-2 font-black hover:bg-white/10 transition-all"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-5 h-5" />
|
||||||
|
بازگشت
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search Bar */}
|
||||||
|
<div className="mb-12 relative max-w-2xl">
|
||||||
|
<Search className="absolute right-6 top-1/2 -translate-y-1/2 text-white/30 w-5 h-5" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="جستجو در ویدئوهای آموزشی..."
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-[2rem] py-5 pr-14 pl-6 text-white outline-none focus:bg-white/10 transition-all font-bold"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Video Grid */}
|
||||||
|
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
|
{filteredVideos.map((video, idx) => (
|
||||||
|
<motion.div
|
||||||
|
key={video.id}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: idx * 0.1 }}
|
||||||
|
className="group cursor-pointer"
|
||||||
|
onClick={() => setSelectedVideo(video)}
|
||||||
|
>
|
||||||
|
<div className="relative aspect-video rounded-[2.5rem] overflow-hidden mb-6 shadow-2xl border border-white/5">
|
||||||
|
<img
|
||||||
|
src={video.thumbnail}
|
||||||
|
alt={video.title}
|
||||||
|
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700 opacity-60 group-hover:opacity-100"
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="w-16 h-16 bg-canina-blue rounded-full flex items-center justify-center text-white shadow-2xl group-hover:scale-110 transition-transform">
|
||||||
|
<PlayCircle className="w-10 h-10" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="absolute bottom-4 right-4 bg-black/60 backdrop-blur-md text-white px-3 py-1 rounded-lg text-[10px] font-black">
|
||||||
|
{video.duration}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-4">
|
||||||
|
<h3 className="text-xl font-black text-white group-hover:text-canina-blue transition-colors mb-2">{video.title}</h3>
|
||||||
|
<div className="flex items-center gap-4 text-xs font-bold text-white/40">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<User className="w-3 h-3" />
|
||||||
|
{video.doctor}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
دسترسی دائمی
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Video Modal */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{selectedVideo && (
|
||||||
|
<div className="fixed inset-0 z-[110] flex items-center justify-center p-4">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={() => setSelectedVideo(null)}
|
||||||
|
className="absolute inset-0 bg-black/95 backdrop-blur-xl"
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.9 }}
|
||||||
|
className="bg-black w-full max-w-5xl aspect-video rounded-[3rem] overflow-hidden relative z-10 shadow-2xl border border-white/10"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedVideo(null)}
|
||||||
|
className="absolute top-6 left-6 text-white/50 hover:text-white z-20 transition-colors bg-white/5 p-2 rounded-full"
|
||||||
|
>
|
||||||
|
<X className="w-8 h-8" />
|
||||||
|
</button>
|
||||||
|
<video
|
||||||
|
src={selectedVideo.videoUrl}
|
||||||
|
controls
|
||||||
|
autoPlay
|
||||||
|
className="w-full h-full object-contain"
|
||||||
|
/>
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 p-10 bg-gradient-to-t from-black/90 to-transparent">
|
||||||
|
<div className="flex items-center gap-2 text-canina-blue text-xs font-black mb-2 uppercase tracking-widest">
|
||||||
|
<ShieldCheck className="w-4 h-4" />
|
||||||
|
Verified Insight
|
||||||
|
</div>
|
||||||
|
<h3 className="text-white text-3xl font-black mb-2 italic">{selectedVideo.title}</h3>
|
||||||
|
<p className="text-white/60 text-lg font-medium">{selectedVideo.doctor}</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
102
frontend/application/components/__tests__/CartDrawer.test.tsx
Normal file
102
frontend/application/components/__tests__/CartDrawer.test.tsx
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
"use client";
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import CartDrawer from '../CartDrawer';
|
||||||
|
import { useCartStore } from '../../store/cartStore';
|
||||||
|
import { productService } from '../../services/productService';
|
||||||
|
|
||||||
|
vi.mock('../../store/cartStore', () => ({
|
||||||
|
useCartStore: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../services/productService', () => ({
|
||||||
|
productService: {
|
||||||
|
getProducts: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockProduct = {
|
||||||
|
id: 'canhydrox-gag',
|
||||||
|
name: 'Canhydrox GAG',
|
||||||
|
price: '۱,۰۰۰ تومان',
|
||||||
|
priceValue: 1000,
|
||||||
|
category: 'joints',
|
||||||
|
image: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('CartDrawer', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(productService.getProducts).mockResolvedValue([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders empty cart state when no items in cart', () => {
|
||||||
|
vi.mocked(useCartStore).mockReturnValue({
|
||||||
|
items: [],
|
||||||
|
updateQuantity: vi.fn(),
|
||||||
|
removeItem: vi.fn(),
|
||||||
|
isSubscribed: false,
|
||||||
|
toggleSubscription: vi.fn(),
|
||||||
|
getTotal: () => 0,
|
||||||
|
getSubtotal: () => 0,
|
||||||
|
getDiscount: () => 0,
|
||||||
|
coupon: null,
|
||||||
|
applyCoupon: vi.fn(),
|
||||||
|
addItem: vi.fn(),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
render(<CartDrawer isOpen={true} onClose={vi.fn()} onCheckout={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('سبد خرید شما خالی است')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders cart items and total when items are present', () => {
|
||||||
|
vi.mocked(useCartStore).mockReturnValue({
|
||||||
|
items: [{ product: mockProduct, quantity: 2 }],
|
||||||
|
updateQuantity: vi.fn(),
|
||||||
|
removeItem: vi.fn(),
|
||||||
|
isSubscribed: false,
|
||||||
|
toggleSubscription: vi.fn(),
|
||||||
|
getTotal: () => 2000,
|
||||||
|
getSubtotal: () => 2000,
|
||||||
|
getDiscount: () => 0,
|
||||||
|
coupon: null,
|
||||||
|
applyCoupon: vi.fn(),
|
||||||
|
addItem: vi.fn(),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
render(<CartDrawer isOpen={true} onClose={vi.fn()} onCheckout={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Canhydrox GAG')).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText('۲,۰۰۰').length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('triggers updateQuantity when plus/minus buttons are clicked', () => {
|
||||||
|
const mockUpdateQuantity = vi.fn();
|
||||||
|
vi.mocked(useCartStore).mockReturnValue({
|
||||||
|
items: [{ product: mockProduct, quantity: 2 }],
|
||||||
|
updateQuantity: mockUpdateQuantity,
|
||||||
|
removeItem: vi.fn(),
|
||||||
|
isSubscribed: false,
|
||||||
|
toggleSubscription: vi.fn(),
|
||||||
|
getTotal: () => 2000,
|
||||||
|
getSubtotal: () => 2000,
|
||||||
|
getDiscount: () => 0,
|
||||||
|
coupon: null,
|
||||||
|
applyCoupon: vi.fn(),
|
||||||
|
addItem: vi.fn(),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
render(<CartDrawer isOpen={true} onClose={vi.fn()} onCheckout={vi.fn()} />);
|
||||||
|
|
||||||
|
const plusBtn = screen.getByTestId('plus-btn');
|
||||||
|
const minusBtn = screen.getByTestId('minus-btn');
|
||||||
|
|
||||||
|
fireEvent.click(plusBtn);
|
||||||
|
expect(mockUpdateQuantity).toHaveBeenCalledWith('canhydrox-gag', 3);
|
||||||
|
|
||||||
|
fireEvent.click(minusBtn);
|
||||||
|
expect(mockUpdateQuantity).toHaveBeenCalledWith('canhydrox-gag', 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,82 @@
|
|||||||
|
"use client";
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import FeaturedProducts from '../FeaturedProducts';
|
||||||
|
import { productService } from '../../services/productService';
|
||||||
|
import { usePetStore } from '../../store/usePetStore';
|
||||||
|
|
||||||
|
vi.mock('../../services/productService', () => ({
|
||||||
|
productService: {
|
||||||
|
getFeaturedProducts: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../store/usePetStore', () => ({
|
||||||
|
usePetStore: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockProducts = [
|
||||||
|
{
|
||||||
|
id: 'prod-1',
|
||||||
|
name: 'Canhydrox GAG',
|
||||||
|
description: 'Joint helper',
|
||||||
|
price: '۱,۰۰۰ تومان',
|
||||||
|
priceValue: 1000,
|
||||||
|
category: 'joints',
|
||||||
|
image: '',
|
||||||
|
suitableFor: 'سگ',
|
||||||
|
symptoms: ['joint_pain'],
|
||||||
|
benefits: 'Strengthens joints',
|
||||||
|
calculateDosage: vi.fn(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('FeaturedProducts', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(usePetStore).mockReturnValue({
|
||||||
|
getActivePet: () => null,
|
||||||
|
} as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders loading skeletons initially', () => {
|
||||||
|
vi.mocked(productService.getFeaturedProducts).mockReturnValue(new Promise(() => {}));
|
||||||
|
const { container } = render(<FeaturedProducts onProductClick={vi.fn()} onShopNavigate={vi.fn()} />);
|
||||||
|
// Check if skeletons are present (e.g. searching for animate-pulse)
|
||||||
|
expect(container.getElementsByClassName('animate-pulse').length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders products once loaded', async () => {
|
||||||
|
vi.mocked(productService.getFeaturedProducts).mockResolvedValue(mockProducts as any);
|
||||||
|
render(<FeaturedProducts onProductClick={vi.fn()} onShopNavigate={vi.fn()} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Canhydrox GAG')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText('Joint helper')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onProductClick when product card is clicked', async () => {
|
||||||
|
const handleProductClick = vi.fn();
|
||||||
|
vi.mocked(productService.getFeaturedProducts).mockResolvedValue(mockProducts as any);
|
||||||
|
render(<FeaturedProducts onProductClick={handleProductClick} onShopNavigate={vi.fn()} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Canhydrox GAG')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Canhydrox GAG'));
|
||||||
|
expect(handleProductClick).toHaveBeenCalledWith(mockProducts[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onShopNavigate when navigation link is clicked', async () => {
|
||||||
|
const handleShopNavigate = vi.fn();
|
||||||
|
vi.mocked(productService.getFeaturedProducts).mockResolvedValue(mockProducts as any);
|
||||||
|
render(<FeaturedProducts onProductClick={vi.fn()} onShopNavigate={handleShopNavigate} />);
|
||||||
|
|
||||||
|
const navBtn = screen.getByText('مشاهده تمامی محصولات');
|
||||||
|
fireEvent.click(navBtn);
|
||||||
|
expect(handleShopNavigate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
44
frontend/application/components/__tests__/Footer.test.tsx
Normal file
44
frontend/application/components/__tests__/Footer.test.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
"use client";
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import Footer from '../Footer';
|
||||||
|
|
||||||
|
describe('Footer', () => {
|
||||||
|
it('renders footer brand text and standard layout elements', () => {
|
||||||
|
render(<Footer onNavigate={vi.fn()} onShopNavigate={vi.fn()} onB2BOpen={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('کانینا ایران')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('نماینده رسمی در ایران')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('تهران، جردن، خیابان سعیدی، ساختمان کانینا، طبقه ۵، واحد ۱۹')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('triggers onNavigate and onShopNavigate when quick links are clicked', () => {
|
||||||
|
const handleNavigate = vi.fn();
|
||||||
|
const handleShopNavigate = vi.fn();
|
||||||
|
const handleB2BOpen = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Footer
|
||||||
|
onNavigate={handleNavigate}
|
||||||
|
onShopNavigate={handleShopNavigate}
|
||||||
|
onB2BOpen={handleB2BOpen}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Click quick link for shop
|
||||||
|
const shopLink = screen.getByText('محصولات تخصصی ۲۰۲۴');
|
||||||
|
fireEvent.click(shopLink);
|
||||||
|
expect(handleShopNavigate).toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Click quick link for blog
|
||||||
|
const blogLink = screen.getByText('مجله سلامت پت (وبلاگ)');
|
||||||
|
fireEvent.click(blogLink);
|
||||||
|
expect(handleNavigate).toHaveBeenCalledWith('blog');
|
||||||
|
|
||||||
|
// Click B2B link
|
||||||
|
const b2bLink = screen.getByText('پنل سفارش عمده (B2B)');
|
||||||
|
fireEvent.click(b2bLink);
|
||||||
|
expect(handleB2BOpen).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
111
frontend/application/components/__tests__/Header.test.tsx
Normal file
111
frontend/application/components/__tests__/Header.test.tsx
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
"use client";
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import Header from '../Header';
|
||||||
|
import { useCartStore } from '../../store/cartStore';
|
||||||
|
import { usePetStore } from '../../store/usePetStore';
|
||||||
|
import { useUserStore } from '../../store/userStore';
|
||||||
|
|
||||||
|
vi.mock('../../store/cartStore', () => ({
|
||||||
|
useCartStore: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../store/usePetStore', () => ({
|
||||||
|
usePetStore: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../store/userStore', () => ({
|
||||||
|
useUserStore: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('Header', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
vi.mocked(useCartStore).mockReturnValue({
|
||||||
|
getTotalItems: () => 3,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
vi.mocked(usePetStore).mockReturnValue({
|
||||||
|
pets: [],
|
||||||
|
activePetId: null,
|
||||||
|
setActivePet: vi.fn(),
|
||||||
|
getActivePet: () => null,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
vi.mocked(useUserStore).mockReturnValue({
|
||||||
|
role: 'User_Guest',
|
||||||
|
isLoggedIn: false,
|
||||||
|
logout: vi.fn(),
|
||||||
|
profile: { firstName: '' },
|
||||||
|
} as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders brand name and login button when guest', () => {
|
||||||
|
render(
|
||||||
|
<Header
|
||||||
|
onNavigate={vi.fn()}
|
||||||
|
onShopNavigate={vi.fn()}
|
||||||
|
currentView="home"
|
||||||
|
onCartOpen={vi.fn()}
|
||||||
|
onSearch={vi.fn()}
|
||||||
|
onB2BOpen={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Canina')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('ایران')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('ورود / ثبتنام')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders user first name and pet selection when logged in', () => {
|
||||||
|
vi.mocked(useUserStore).mockReturnValue({
|
||||||
|
role: 'User_PetOwner',
|
||||||
|
isLoggedIn: true,
|
||||||
|
logout: vi.fn(),
|
||||||
|
profile: { firstName: 'کوروش' },
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
vi.mocked(usePetStore).mockReturnValue({
|
||||||
|
pets: [{ id: 'pet-1', name: 'ملوس', type: 'گربه' }],
|
||||||
|
activePetId: 'pet-1',
|
||||||
|
setActivePet: vi.fn(),
|
||||||
|
getActivePet: () => ({ id: 'pet-1', name: 'ملوس', type: 'گربه' }),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Header
|
||||||
|
onNavigate={vi.fn()}
|
||||||
|
onShopNavigate={vi.fn()}
|
||||||
|
currentView="home"
|
||||||
|
onCartOpen={vi.fn()}
|
||||||
|
onSearch={vi.fn()}
|
||||||
|
onB2BOpen={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText('ورود / ثبتنام')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText('کوروش')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('ملوس')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onCartOpen when click on cart button', () => {
|
||||||
|
const handleCartOpen = vi.fn();
|
||||||
|
render(
|
||||||
|
<Header
|
||||||
|
onNavigate={vi.fn()}
|
||||||
|
onShopNavigate={vi.fn()}
|
||||||
|
currentView="home"
|
||||||
|
onCartOpen={handleCartOpen}
|
||||||
|
onSearch={vi.fn()}
|
||||||
|
onB2BOpen={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const cartBtn = screen.getByText('سبد خرید').closest('button');
|
||||||
|
expect(cartBtn).toBeInTheDocument();
|
||||||
|
fireEvent.click(cartBtn!);
|
||||||
|
expect(handleCartOpen).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
44
frontend/application/components/__tests__/Hero.test.tsx
Normal file
44
frontend/application/components/__tests__/Hero.test.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
"use client";
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import Hero from '../Hero';
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore';
|
||||||
|
|
||||||
|
vi.mock('../../store/settingsStore', () => ({
|
||||||
|
useSettingsStore: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('Hero', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders title and description from settingsStore', () => {
|
||||||
|
const mockGetText = vi.fn().mockImplementation((key, fallback) => {
|
||||||
|
if (key === 'hero_title') return 'Test Title Line 1\nTest Title Line 2';
|
||||||
|
if (key === 'hero_desc') return 'Test Description Text';
|
||||||
|
return fallback;
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(useSettingsStore).mockImplementation((selector: any) => selector({ getText: mockGetText }));
|
||||||
|
|
||||||
|
render(<Hero onShopNavigate={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Test Title Line 1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Test Title Line 2')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Test Description Text')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onShopNavigate when "مشاهده محصولات" button is clicked', () => {
|
||||||
|
const mockGetText = vi.fn().mockImplementation((key, fallback) => fallback);
|
||||||
|
vi.mocked(useSettingsStore).mockImplementation((selector: any) => selector({ getText: mockGetText }));
|
||||||
|
|
||||||
|
const handleShopNavigate = vi.fn();
|
||||||
|
render(<Hero onShopNavigate={handleShopNavigate} />);
|
||||||
|
|
||||||
|
const shopBtn = screen.getByText('مشاهده محصولات');
|
||||||
|
fireEvent.click(shopBtn);
|
||||||
|
expect(handleShopNavigate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
51
frontend/application/components/__tests__/Tooltip.test.tsx
Normal file
51
frontend/application/components/__tests__/Tooltip.test.tsx
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
"use client";
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import Tooltip from '../Tooltip';
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore';
|
||||||
|
|
||||||
|
vi.mock('../../store/settingsStore', () => ({
|
||||||
|
useSettingsStore: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('Tooltip', () => {
|
||||||
|
it('should render children correctly', () => {
|
||||||
|
vi.mocked(useSettingsStore).mockReturnValue({});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Tooltip termKey="mussel">
|
||||||
|
<span>Mussel Term</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Mussel Term')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display tooltip definition on hover', async () => {
|
||||||
|
const mockTerms = {
|
||||||
|
mussel: {
|
||||||
|
key: 'mussel',
|
||||||
|
term: 'Green Mussel',
|
||||||
|
definition: 'Scientific definition of NZ green mussel',
|
||||||
|
wikiId: 'green-mussel',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
vi.mocked(useSettingsStore).mockReturnValue(mockTerms);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Tooltip termKey="mussel">
|
||||||
|
<span>Hover Me</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
|
||||||
|
const trigger = screen.getByText('Hover Me');
|
||||||
|
fireEvent.mouseEnter(trigger);
|
||||||
|
|
||||||
|
expect(screen.getByText('Green Mussel')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Scientific definition of NZ green mussel')).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.mouseLeave(trigger);
|
||||||
|
// Tooltip should exit (though animations might delay actual removal, under standard RTL fireEvent it updates state immediately)
|
||||||
|
});
|
||||||
|
});
|
||||||
273
frontend/application/package-lock.json
generated
273
frontend/application/package-lock.json
generated
@ -8,9 +8,14 @@
|
|||||||
"name": "application",
|
"name": "application",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"axios": "^1.17.0",
|
||||||
|
"lucide-react": "^1.17.0",
|
||||||
|
"motion": "^12.40.0",
|
||||||
"next": "16.2.9",
|
"next": "16.2.9",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
@ -1651,7 +1656,7 @@
|
|||||||
"version": "19.2.17",
|
"version": "19.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
||||||
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
|
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
@ -2339,6 +2344,18 @@
|
|||||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/agent-base": {
|
||||||
|
"version": "6.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||||
|
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "6.15.0",
|
"version": "6.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||||
@ -2566,6 +2583,12 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/asynckit": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/available-typed-arrays": {
|
"node_modules/available-typed-arrays": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
||||||
@ -2592,6 +2615,18 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/axios": {
|
||||||
|
"version": "1.17.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz",
|
||||||
|
"integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"follow-redirects": "^1.16.0",
|
||||||
|
"form-data": "^4.0.5",
|
||||||
|
"https-proxy-agent": "^5.0.1",
|
||||||
|
"proxy-from-env": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/axobject-query": {
|
"node_modules/axobject-query": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||||
@ -2702,7 +2737,6 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
@ -2802,6 +2836,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/combined-stream": {
|
||||||
|
"version": "1.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
|
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"delayed-stream": "~1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/concat-map": {
|
"node_modules/concat-map": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||||
@ -2835,7 +2881,7 @@
|
|||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/damerau-levenshtein": {
|
"node_modules/damerau-levenshtein": {
|
||||||
@ -2903,7 +2949,6 @@
|
|||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
@ -2960,6 +3005,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/delayed-stream": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
@ -2987,7 +3041,6 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind-apply-helpers": "^1.0.1",
|
"call-bind-apply-helpers": "^1.0.1",
|
||||||
@ -3099,7 +3152,6 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@ -3109,7 +3161,6 @@
|
|||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@ -3147,7 +3198,6 @@
|
|||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||||
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0"
|
"es-errors": "^1.3.0"
|
||||||
@ -3160,7 +3210,6 @@
|
|||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
@ -3757,6 +3806,26 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/follow-redirects": {
|
||||||
|
"version": "1.16.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||||
|
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"debug": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/for-each": {
|
"node_modules/for-each": {
|
||||||
"version": "0.3.5",
|
"version": "0.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||||
@ -3773,11 +3842,53 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/form-data": {
|
||||||
|
"version": "4.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||||
|
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"asynckit": "^0.4.0",
|
||||||
|
"combined-stream": "^1.0.8",
|
||||||
|
"es-set-tostringtag": "^2.1.0",
|
||||||
|
"hasown": "^2.0.2",
|
||||||
|
"mime-types": "^2.1.12"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/framer-motion": {
|
||||||
|
"version": "12.40.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.40.0.tgz",
|
||||||
|
"integrity": "sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"motion-dom": "^12.40.0",
|
||||||
|
"motion-utils": "^12.39.0",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@emotion/is-prop-valid": "*",
|
||||||
|
"react": "^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^18.0.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@emotion/is-prop-valid": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/function-bind": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
@ -3838,7 +3949,6 @@
|
|||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind-apply-helpers": "^1.0.2",
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
@ -3863,7 +3973,6 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dunder-proto": "^1.0.1",
|
"dunder-proto": "^1.0.1",
|
||||||
@ -3951,7 +4060,6 @@
|
|||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@ -4023,7 +4131,6 @@
|
|||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@ -4036,7 +4143,6 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"has-symbols": "^1.0.3"
|
"has-symbols": "^1.0.3"
|
||||||
@ -4052,7 +4158,6 @@
|
|||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||||
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"function-bind": "^1.1.2"
|
"function-bind": "^1.1.2"
|
||||||
@ -4078,6 +4183,19 @@
|
|||||||
"hermes-estree": "0.25.1"
|
"hermes-estree": "0.25.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/https-proxy-agent": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"agent-base": "6",
|
||||||
|
"debug": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ignore": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
@ -5043,6 +5161,15 @@
|
|||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lucide-react": {
|
||||||
|
"version": "1.17.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.17.0.tgz",
|
||||||
|
"integrity": "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
@ -5057,7 +5184,6 @@
|
|||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@ -5087,6 +5213,27 @@
|
|||||||
"node": ">=8.6"
|
"node": ">=8.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mime-db": {
|
||||||
|
"version": "1.52.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||||
|
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime-types": {
|
||||||
|
"version": "2.1.35",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||||
|
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-db": "1.52.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.5",
|
"version": "3.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||||
@ -5110,11 +5257,51 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/motion": {
|
||||||
|
"version": "12.40.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/motion/-/motion-12.40.0.tgz",
|
||||||
|
"integrity": "sha512-yjrHUrBFW6kQvjJwRsoiPSAhC5tRwRqNGJWmiJ4CrGnbKp0V88AdzkhBmDoqIsIPfarOe0Uddd37Xq43/gIocA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"framer-motion": "^12.40.0",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@emotion/is-prop-valid": "*",
|
||||||
|
"react": "^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^18.0.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@emotion/is-prop-valid": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/motion-dom": {
|
||||||
|
"version": "12.40.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.40.0.tgz",
|
||||||
|
"integrity": "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"motion-utils": "^12.39.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/motion-utils": {
|
||||||
|
"version": "12.39.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz",
|
||||||
|
"integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
@ -5579,6 +5766,15 @@
|
|||||||
"react-is": "^16.13.1"
|
"react-is": "^16.13.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/proxy-from-env": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/punycode": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
@ -6038,6 +6234,16 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sonner": {
|
||||||
|
"version": "2.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
|
||||||
|
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@ -6772,6 +6978,35 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"zod": "^3.25.0 || ^4.0.0"
|
"zod": "^3.25.0 || ^4.0.0"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zustand": {
|
||||||
|
"version": "5.0.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz",
|
||||||
|
"integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.20.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": ">=18.0.0",
|
||||||
|
"immer": ">=9.0.6",
|
||||||
|
"react": ">=18.0.0",
|
||||||
|
"use-sync-external-store": ">=1.2.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"immer": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"use-sync-external-store": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,9 +9,14 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"axios": "^1.17.0",
|
||||||
|
"lucide-react": "^1.17.0",
|
||||||
|
"motion": "^12.40.0",
|
||||||
"next": "16.2.9",
|
"next": "16.2.9",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user