diff --git a/frontend/application/app/globals.css b/frontend/application/app/globals.css index a2dc41e..2791940 100644 --- a/frontend/application/app/globals.css +++ b/frontend/application/app/globals.css @@ -1,26 +1,74 @@ +@import url('https://fonts.googleapis.com/css2?family=Vazirmatn:wght@100;300;400;500;700;900&family=Inter:wght@400;500;600;700;800;900&display=swap'); @import "tailwindcss"; -:root { - --background: #ffffff; - --foreground: #171717; +@theme { + --font-sans: "Vazirmatn", "Inter", sans-serif; + --font-vazir: "Vazirmatn", sans-serif; + --font-inter: "Inter", sans-serif; + + --color-canina-blue: #004182; + --color-medical-gray-50: #f8fafc; + --color-medical-gray-100: #f1f5f9; + --color-medical-gray-200: #e2e8f0; + --color-medical-gray-300: #cbd5e1; + --color-medical-gray-400: #94a3b8; + --color-medical-gray-500: #64748b; + --color-medical-gray-600: #475569; + --color-medical-gray-700: #334155; + --color-medical-gray-800: #1e293b; + --color-medical-gray-900: #0f172a; } -@theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); -} - -@media (prefers-color-scheme: dark) { +@layer base { :root { - --background: #0a0a0a; - --foreground: #ededed; + font-family: "Vazirmatn", "Inter", sans-serif !important; + } + + html, body, #root { + font-family: "Vazirmatn", "Inter", sans-serif !important; + @apply bg-medical-gray-50 text-medical-gray-900 antialiased; + font-feature-settings: "ss01", "ss02", "cv01", "cv11"; + } + + button, a, [role="button"], .cursor-pointer { + @apply cursor-pointer; + } + + /* Smooth hover transitions for all interactive elements */ + button, a, .group, .transition-all { + @apply transition-all duration-300; + } + + /* Default mild hover effects */ + button:hover, .cursor-pointer:hover { + @apply opacity-90; + } + + a:hover { + @apply opacity-80; } } -body { - background: var(--background); - color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; +@layer utilities { + .font-vazir { + font-family: "Vazirmatn", sans-serif; + } +} + +/* Custom scrollbar for a professional look */ +::-webkit-scrollbar { + width: 6px; +} + +::-webkit-scrollbar-track { + background: var(--color-medical-gray-100); +} + +::-webkit-scrollbar-thumb { + background: var(--color-medical-gray-300); + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--color-canina-blue); } diff --git a/frontend/application/lib/data/products.ts b/frontend/application/lib/data/products.ts new file mode 100644 index 0000000..0f3efb0 --- /dev/null +++ b/frontend/application/lib/data/products.ts @@ -0,0 +1,513 @@ +export interface Specialist { + name: string; + title: string; + image: string; + message: string; +} + +export type PetType = "سگ" | "گربه" | "هر دو"; + +export interface IngredientInfo { + id: string; + name: string; + description: string; + benefits: string[]; +} + +export interface DosageResult { + quantity: number; + unit: string; + description: string; +} + +export interface FAQ { + question: string; + answer: string; +} + +export interface Product { + id: string; + artNo: string; + name: string; + scientificTagline: string; + description: string; + shortDescription?: string; + keyBenefits?: { icon: string; title: string; description: string }[]; + price: string; + priceValue: number; + category: string; + categorySlug?: string; + unit: string; + packageSize: number; + main_ingredients: string[]; + dosage_logic: string; + benefits: string; + benefitsList?: string[]; + storage?: string; + symptoms: string[]; + suitableFor: PetType; + specialBadge?: string; + contraindications?: string[]; + onSetOfAction?: string; + expectedResults?: { icon: string; text: string }[]; + optimisticTemplate?: string; + calculateDosage: (weight: number, isYoung: boolean) => DosageResult; + analysis: Record; + feedingAdvice: string; + specialist: Specialist; + image: string; + relatedProducts?: string[]; + faqs?: FAQ[]; +} + +export const INGREDIENTS_WIKI: IngredientInfo[] = [ + { + id: "green-mussel", + name: "صدف لب‌سبز (Perna Canaliculus)", + description: "این صدف بومی سواحل بکر نیوزیلند است و تنها منبع طبیعی حاوی گلیکوزآمین‌گلیکان‌ها (GAG) در غلظت بسیار بالا محسوب می‌شود.", + benefits: [ + "بازسازی بافت غضروفی تخریب شده", + "خواص ضدالتهابی طبیعی برای رباط‌ها", + "تامین اسیدهای چرب امگا ۳ خاص", + ] + }, + { + id: "silver", + name: "نقره میکروسیلور (Microsilver)", + description: "ذرات نقره خالص با ساختار اسفنجی که به دلیل اندازه ذرات درشت، جذب خون نمی‌شوند اما اثر آنتی‌باکتریال پایداری بر سطح غشاها دارند.", + benefits: [ + "مبارزه با باکتری‌های مقاوم در دهان و پوست", + "عدم آسیب به فلور طبیعی بدن", + "جایگزین ایمن برای آنتی‌بیوتیک‌های موضعی", + ] + }, + { + id: "colostrum", + name: "آغوز (Colostrum)", + description: "اولین شیر مادر که حاوی دوز بالایی از ایمونوگلوبولین‌ها، ویتامین‌ها و مواد معدنی برای فعال‌سازی فوری سیستم ایمنی است.", + benefits: [ + "تقویت سد دفاعی روده", + "انتقال مستقیم آنتی‌بادی به نوزادان", + "تسریع نقاهت بعد از جرافی", + ] + }, + { + id: "peat-extract", + name: "عصاره پیت (Peat Extract)", + description: "ماده‌ای کاملا طبیعی حاصل از تجزیه گیاهان در طول هزاران سال که غنی از اسیدهای هومیک و مواد معدنی است.", + benefits: [ + "جذب سموم در دستگاه گوارش", + "تنظیم فلور روده", + "بهبود اشتها و هضم" + ] + } +]; + +export const PRODUCTS: Product[] = [ + { + id: "canhydrox-gag", + artNo: "123101", + name: "Canhydrox GAG (کنهیدروکس)", + scientificTagline: "استاندارد طلایی ارتوپدی سگ‌های آلمان", + description: "درمان تخصصی مفاصل، استخوان، تاندون و دندان در سگ‌های بزرگ و سنگین‌وزن.", + shortDescription: "قوی‌ترین فرمول ارتوپدی آلمان برای تقویت اسکلت‌بندی و رشد سالم استخوان‌ها در نژادهای بزرگ.", + keyBenefits: [ + { icon: "ShieldCheck", title: "هیدروکسی آپاتیت", description: "جذب مستقیم در استخوان" }, + { icon: "Activity", title: "صدف لب‌سبز", description: "بازسازی کامل غضروف‌ها" }, + { icon: "Flame", title: "گرید دارویی", description: "بالاترین سطح خلوص مواد" } + ], + category: "مفاصل و استخوان", + categorySlug: "joints", + price: "۴,۲۴۰,۰۰۰ تومان", + priceValue: 4240000, + unit: "60 Tablets / 100g", + packageSize: 60, + main_ingredients: ["30% تری‌کلسیم فسفات", "10% پودر صدف لب‌سبز", "ویتامین C", "متیونین", "سلنیوم", "هیدروکسی آپاتیت"], + dosage_logic: "جوان (از 8 هفتگی): 2 قرص به ازای 10kg وزن (تا 15 ماهگی). مسن/کاری: 4 قرص به ازای 10kg وزن. حداکثر 16 قرص.", + benefits: "درمان تخصصی مفاصل، استخوان، تاندون و دندان در سگ‌های بزرگ و سنگین‌وزن.", + benefitsList: [ + "تقویت بافت همبند، مفاصل و غضروف‌ها", + "پیشگیری از مشکلات مفاصل در نژادهای بزرگ", + "تثبیت استخوان‌بندی و رشد سالم در توله‌ها", + "بهبود بازسازی استخوان پس از جراحی یا شکستگی" + ], + storage: "در دمای اتاق و دور از تابش مستقیم نور خورشید نگهداری شود.", + symptoms: ["لنگیدن", "سختی در بلند شدن", "درد مفاصل", "رشد سریع توله‌سگ"], + suitableFor: "سگ", + specialBadge: "انتخاب اول ارتوپدی", + onSetOfAction: "هفته چهارم", + optimisticTemplate: "[PetName] از [OnSet] با نشاط بیشتری می‌دود و بازی می‌کند.", + expectedResults: [ + { icon: "Sparkles", text: "استحکام مفاصل" }, + { icon: "Activity", text: "حرکت بدون درد" }, + { icon: "ShieldCheck", text: "دندان‌های قوی" } + ], + calculateDosage: (weight, isYoung) => { + const rate = isYoung ? 2 : 4; + let qty = (weight / 10) * rate; + qty = Math.min(qty, 16); + return { + quantity: Number(qty.toFixed(1)), + unit: "قرص", + description: isYoung ? "دوز رشد (تا ۱۵ ماهگی)" : "دوز حمایتی / سگ کاری" + }; + }, + analysis: { "کلسیم": "۱۸.۲٪", "فسفر": "۷.۱٪", "پروتئین": "۱۲.۴٪" }, + feedingAdvice: "قرص‌ها را می‌توان مستقیماً در دهان قرار داد یا با غذای روزانه مخلوط کرد.", + specialist: { + name: "دکتر کلاوس هنینگ", + title: "متخصص جراحی استخوان و مفاصل دامپزشکی", + image: "https://images.unsplash.com/photo-1612349317150-e413f6a5b16d?auto=format&fit=crop&q=80&w=200", + message: "کنهیدروکس قوی‌ترین محصول بازسازی کننده بافت‌های نگهدارنده است. هیدروکسی آپاتیت موجود در آن مستقیماً در توده استخوانی نفوذ کرده و استحکام اسکلت را تضمین می‌کند." + }, + image: "https://www.canina.de/media/83/86/e1/123000_123005_Canhydrox_GAG_Canina-Pharma_1280x1280.png", + relatedProducts: ["lachs-ol"], + faqs: [ + { question: "آیا برای سگ‌های در حال رشد مناسب است؟", answer: "بله، این محصول به طور ویژه برای سگ‌های نژاد بزرگ از هفته ۸ تا ماه ۱۵ برای استحکام اسکلت و دندان‌ها توصیه می‌شود." }, + { question: "آیا برای دندان‌ها هم موثر است؟", answer: "بله، بیش از ۹۰٪ بافت دندان از هیدروکسی آپاتیت ساخته شده که ماده اصلی این محصول است." } + ] + }, + { + id: "immun-booster", + artNo: "140504", + name: "Immun-Booster (ایمیون بوستر)", + scientificTagline: "سیستم دفاعی هوشمند بیولوژیک", + description: "کمک به تقویت فوری سیستم ایمنی با آغوز و ویتامین‌های ضروری.", + shortDescription: "سپر دفاعی هوشمند؛ خمیر آغوز متمرکز برای فعال‌سازی فوری گلبول‌های سفید و تقویت توله‌ها و مادران باردار.", + keyBenefits: [ + { icon: "ShieldCheck", title: "آغوز باکیفیت", description: "تامین فوری ایمونوگلوبولین" }, + { icon: "Zap", title: "جذب سریع", description: "فعال‌سازی سیستم ایمنی در ۳ روز" }, + { icon: "Heart", title: "حمایت حیاتی", description: "مناسب برای دوران نقاهت سنگین" } + ], + category: "تقویت سیستم ایمنی و گوارش", + categorySlug: "immune", + price: "۲,۹۹۰,۰۰۰ تومان", + priceValue: 2990000, + unit: "15 ml Paste", + packageSize: 15, + main_ingredients: ["پودر آغوز (Colostrum)", "روغن‌های گیاهی", "ویتامین A, D3, E"], + dosage_logic: "توله‌سگ: 0.25ml. سگ کوچک (<5kg): 0.25-0.5ml. سگ متوسط (5-10kg): 0.5-1ml. سگ بزرگ (>10kg): 1-2ml.", + benefits: "تقویت فوری سیستم ایمنی، حاوی ایمونوگلوبولین‌های آغوز، مناسب برای دوران نقاهت و زایمان.", + symptoms: ["ضعف بعد از بیماری", "بی‌اشتهایی", "بعد از زایمان"], + suitableFor: "هر دو", + specialBadge: "مراقب ویژه بعد از زایمان", + onSetOfAction: "روز سوم", + optimisticTemplate: "[PetName] تنها پس از [OnSet] انرژی از دست رفته‌اش را دوباره به دست می‌آورد.", + expectedResults: [ + { icon: "Heart", text: "تقویت ایمنی" }, + { icon: "Flame", text: "رفع خستگی" }, + { icon: "Zap", text: "هوشیاری بیشتر" } + ], + calculateDosage: (weight, isYoung) => { + let ml = 0.25; + if (isYoung) ml = 0.25; + else if (weight < 5) ml = 0.5; + else if (weight <= 10) ml = 1; + else ml = 2; + return { quantity: ml, unit: "میلی‌لیتر", description: "خمیر را روی زبان قرار دهید" }; + }, + analysis: { "چربی": "۶۵٪", "پروتئین": "۱۵٪" }, + feedingAdvice: "توصیه می‌شود در زمان‌های استرس (واکسیناسیون، جابجایی) استفاده شود.", + specialist: { + name: "دکتر النا اشمیت", + title: "جراح و متخصص طب داخلی پت‌ها", + image: "https://images.unsplash.com/photo-1594824812397-237f41c63230?auto=format&fit=crop&q=80&w=200", + message: "خمیر ایمون بوستر با استفاده از آغوز با کیفیت دارویی، بلافاصله سیستم ایمنی غیرفعال پت را فعال می‌کند." + }, + image: "https://www.canina.de/media/8c/fb/8e/311000_Canina_Immun_Booster_Paste_Abwehrkraefte_1280x1280.png" + }, + { + id: "mikrosilber-zahngel", + artNo: "131454", + name: "Mikrosilber Zahngel (ژل دندان میکروسیلور)", + scientificTagline: "مراقبت آنتی‌سپتیک دهان با تکنولوژی نقره میکروسیمی", + description: "کمک به رفع جرم و پلاک دندان با استفاده از ذرات نقره.", + shortDescription: "انقلاب در بهداشت دهان؛ ژل نقره میکروسیمی برای حذف باکتری‌های مولد جرم و بوی بد دهان بدون نیاز به بیهوشی.", + keyBenefits: [ + { icon: "Sparkles", title: "نقره میکروسیلور", description: "اثر آنتی‌باکتریال ۲۴ ساعته" }, + { icon: "ShieldCheck", title: "حذف پلاک", description: "جلوگیری از تشکیل رسوبات جدید" }, + { icon: "Activity", title: "لثه سالم", description: "کاهش التهاب و قرمزی لثه" } + ], + category: "مراقبت‌های ویژه (پوست، دندان و چشم)", + categorySlug: "special-care", + price: "۲,۷۶۵,۰۰۰ تومان", + priceValue: 2765000, + unit: "50 ml", + packageSize: 50, + main_ingredients: ["نقره میکروسیلور", "عصاره دانه گریپ‌فروت", "ویتامین C"], + dosage_logic: "تا 10kg: یک سانت ژل. 10-20kg: دو سانت ژل. 20-30kg: سه سانت ژل. بالای 30kg: چهار سانت ژل.", + benefits: "رفع پلاک، جرم و التهاب لثه با استفاده از تکنولوژی نقره کلوئیدی.", + symptoms: ["جرم دندان", "التهاب لثه", "بوی بد دهان"], + suitableFor: "هر دو", + onSetOfAction: "هفته دوم", + optimisticTemplate: "لبخند سفیدتر و نفس تازه‌تر [PetName] از [OnSet] نمایان می‌شود.", + expectedResults: [ + { icon: "Sparkles", text: "رفع بوی دهان" }, + { icon: "ShieldCheck", text: "کاهش جرم" }, + { icon: "Heart", text: "لثه‌های سالم" } + ], + calculateDosage: (weight) => { + let cm = 1; + if (weight <= 10) cm = 1; + else if (weight <= 20) cm = 2; + else if (weight <= 30) cm = 3; + else cm = 4; + return { quantity: cm, unit: "سانتی‌متر", description: "روی لثه‌ها ماساژ دهید" }; + }, + analysis: { "نقره": "بسیار بالا" }, + feedingAdvice: "تا ۳۰ دقیقه بعد از استفاده، پت نباید آب یا غذا بخورد.", + specialist: { + name: "دکتر توماس بروات", + title: "متخصص دندانپزشکی دامپزشکی", + image: "https://images.unsplash.com/photo-1622253692010-333f2da6031d?auto=format&fit=crop&q=80&w=200", + message: "ذرات میکروسیلور در این ژل به قدری درشت هستند که جذب جریان خون نمی‌شوند اما اثر آنتی‌باکتریال فوق‌العاده‌ای بر سطح لثه دارند." + }, + image: "https://www.canina.de/media/9d/5e/54/131454_Mikrosilber_Zahngel_Canina-Pharma_1280x1280.png" + }, + { + id: "ballaststoff-mix", + artNo: "140108", + name: "Ballaststoff Mix (فیبر میکس)", + scientificTagline: "بهینه‌سازی بیوم روده با فیبرهای گیاهی ارگانیک", + description: "ترکیب متعادل فیبرهای گیاهی برای تنظیم سیستم گوارش و بهبود کیفیت دفع.", + shortDescription: "راه‌کار نهایی مشکلات گوارشی؛ تنظیم‌کننده حرکات روده و کمک به تخلیه طبیعی غدد مقعدی با فیبر سیب.", + category: "تقویت سیستم ایمنی و گوارش", + categorySlug: "immune", + price: "۱,۵۵۰,۰۰۰ تومان", + priceValue: 1550000, + unit: "100 g", + packageSize: 100, + main_ingredients: ["فیبر سیب (پکتین)", "یونجه", "نخود فرنگی", "هویج"], + dosage_logic: "۰.۵ گرم به ازای هر کیلوگرم وزن بدن. حداکثر ۱۵ گرم در روز.", + benefits: "تنظیم حرکات روده، بهبود تخلیه غدد مقعدی، کنترل وزن و ایجاد فلور پایدار در روده.", + symptoms: ["یبوست", "اسهال", "تورم غدد مقعدی", "اضافه وزن"], + suitableFor: "هر دو", + onSetOfAction: "روز چهارم", + optimisticTemplate: "سیستم گوارشی [PetName] از حدود [OnSet] شروع به تنظیم مجدد و دفع راحت‌تر می‌کند.", + expectedResults: [ + { icon: "ShieldCheck", text: "دفع منظم" }, + { icon: "Activity", text: "کاهش التهاب روده" }, + { icon: "Sparkles", text: "کنترل بوی مدفوع" } + ], + calculateDosage: (weight) => { + let g = weight * 0.5; + g = Math.min(g, 15); + return { quantity: Number(g.toFixed(1)), unit: "گرم", description: "با غذا مخلوط شود" }; + }, + analysis: { "فیبر": "۲۵٪" }, + feedingAdvice: "بهتر است با آب ولرم خیسانده و سپس با غذا مخلوط شود.", + specialist: { + name: "دکتر بایر", + title: "گاستروانترولوژیست", + image: "https://images.unsplash.com/photo-1559839734-2b71f1536783?auto=format&fit=crop&q=80&w=200", + message: "فیبر سیب منبع پکتین است که به رشد باکتری‌های مفید روده کمک شایانی می‌کند." + }, + image: "https://www.canina.de/media/4c/32/38/1715077271/Ballaststoff-Mix-140108-V1-Canina-100g_600x600.webp" + }, + { + id: "lachs-ol", + artNo: "111803", + name: "Lachs-Öl (روغن سالمون)", + scientificTagline: "اسیدهای چرب ضروری با خلوص دارویی", + description: "روغن سالمون ۱۰۰٪ خالص برای درخشش پوشش مویی.", + shortDescription: "اکسیر درخشش و سلامت؛ روغن سالمون نروژی با امگا ۳ غلیظ برای رفع خشکی پوست و ریزش مو.", + keyBenefits: [ + { icon: "Flame", title: "پرس سرد", description: "حفظ حداکثری ویتامین‌ها" }, + { icon: "Zap", title: "امگا ۳ غلیظ", description: "تقویت قلب و سیستم ایمنی" }, + { icon: "Star", title: "صد درصد طبیعی", description: "بدون هیچ افزودنی شیمیایی" } + ], + category: "مراقبت‌های ویژه (پوست، دندان و چشم)", + categorySlug: "special-care", + price: "۲,۲۴۰,۰۰۰ تومان", + priceValue: 2240000, + unit: "250 ml", + packageSize: 250, + main_ingredients: ["100% روغن سالمون خالص", "امگا 3 (EPA & DHA)"], + dosage_logic: "2 میلی‌لیتر به ازای هر 10 کیلوگرم وزن بدن. حداکثر 10 میلی‌لیتر.", + benefits: "پشتیبانی از بازسازی پوست، رشد موی براق و تقویت متابولیسم.", + symptoms: ["ریزش مو", "خشکی پوست", "خارش"], + suitableFor: "هر دو", + onSetOfAction: "هفته سوم", + optimisticTemplate: "شاهد درخشش خیره‌کننده در پوشش موی [PetName] از [OnSet] باشید.", + expectedResults: [ + { icon: "Sparkles", text: "موی براق‌تر" }, + { icon: "Heart", text: "پوست سالم" }, + { icon: "ShieldCheck", text: "کاهش ریزش" } + ], + calculateDosage: (weight) => { + let ml = (weight / 10) * 2; + ml = Math.min(ml, 10); + return { quantity: Number(ml.toFixed(1)), unit: "میلی‌لیتر", description: "دوز روزانه" }; + }, + analysis: { "چربی خام": "۹۹٪" }, + feedingAdvice: "روزانه به غذای اصلی پت اضافه شود.", + specialist: { + name: "دکتر ماری کونیگ", + title: "متخصص تغذیه دامپزشکی", + image: "https://images.unsplash.com/photo-1559839734-2b71f1536783?auto=format&fit=crop&q=80&w=200", + message: "روغن سالمون کانینا به روش پرس سرد تهیه شده تا تمامی ویتامین‌های حساس به حرارت حفظ شوند." + }, + image: "https://www.canina.de/media/6b/48/81/731000_Canina_Lachsoel_Lachs_Oel_1280x1280.png" + }, + { + id: "herz-vital", + artNo: "111858", + name: "Herz Vital (هرز ویتال)", + scientificTagline: "محافظت تخصصی از میوکارد و سیستم گردش خون", + description: "حمایت از عملکرد قلب در سگ‌ها و گربه‌ها.", + shortDescription: "موتور تپنده زندگی؛ مکمل تخصصی حاوی ال-کارنیتین و زالزالک برای تقویت عضله قلب در پت‌های مسن و کاری.", + keyBenefits: [ + { icon: "Heart", title: "تقویت میوکارد", description: "بهبود پمپاژ خون" }, + { icon: "Activity", title: "نشاط فیزیکی", description: "کاهش خستگی زودرس" }, + { icon: "Zap", title: "ال-کارنیتین", description: "تامین انرژی سلولی قلب" } + ], + category: "ویتامین‌ها و انرژی‌بخش‌ها", + categorySlug: "energy", + price: "۴,۲۸۵,۰۰۰ تومان", + priceValue: 4285000, + unit: "100 ml", + packageSize: 100, + main_ingredients: ["ال-کارنیتین", "زالزالک", "عصاره مخمر"], + dosage_logic: "0.5 قاشق چایخوری (تقریباً 1.5ml) به ازای هر 10kg وزن. حداکثر 3 قاشق چایخوری.", + benefits: "تقویت عملکرد فیزیولوژی قلب و افزایش انرژی در سگ‌ها و گربه‌های مسن.", + symptoms: ["نفس‌نفس زدن", "خستگی", "سن بالا"], + suitableFor: "هر دو", + calculateDosage: (weight) => { + let tsp = (weight / 10) * 0.5; + tsp = Math.min(tsp, 3); + return { quantity: Number(tsp.toFixed(1)), unit: "قاشق چایخوری", description: "معادل " + (tsp * 3).toFixed(1) + " میلی‌لیتر" }; + }, + analysis: { "ال-کارنیتین": "۳۰۰۰۰mg" }, + feedingAdvice: "قبل از مصرف تکان دهید.", + specialist: { + name: "دکتر مایر", + title: "کاردیولوژیست دامپزشکی", + image: "https://images.unsplash.com/photo-1559839734-2b71f1536783?auto=format&fit=crop&q=80&w=200", + message: "زالزالک جریان خون کرونر را بهبود بخشیده و فشار بر قلب را کاهش می‌دهد." + }, + image: "https://www.canina.de/media/73/6e/8b/1715077271/Herz-Vital-111858-V1-Canina-100ml_600x600.webp" + }, + { + id: "energy-gel", + artNo: "111452", + name: "Petvital Energy-Gel (پتویتال انرژی ژل)", + scientificTagline: "تامین فوری گلوکز و ویتامین‌های متابولیک برای بازیابی توان", + description: "کمک‌درمانی تخصصی برای زمان‌های بی‌اشتهایی، نقاهت و دوره‌های پراسترس سگ و گربه.", + shortDescription: "منبع انرژی متمرکز و خوش‌خوراک؛ حاوی ویتامین‌های ب-کمپلکس و گلوکز برای بازگشت سریع انرژی در دوران نقاهت.", + category: "ویتامین‌ها و انرژی‌بخش‌ها", + categorySlug: "energy", + price: "۲,۵۰۰,۰۰۰ تومان", + priceValue: 2500000, + unit: "100 g", + packageSize: 100, + main_ingredients: ["ویتامین‌های گروه B", "ویتامین E", "کنسانتره انرژی", "منیزیم"], + dosage_logic: "۱ قاشق چایخوری به ازای هر ۱۰kg وزن. حداکثر ۴ قاشق چایخوری.", + benefits: "تامین سریع انرژی در زمان بی‌اشتهایی، نقاهت بعد از جراحی، دوره‌های بارداری و شیردهی و مسابقات ورزشی.", + symptoms: ["بی‌اشتهایی", "نقاهت بعد جراحی", "خستگی مزمن", "کاهش وزن ناگهانی"], + suitableFor: "هر دو", + onSetOfAction: "بلافاصله", + optimisticTemplate: "[PetName] بلافاصله پس از مصرف انرژی مضاعفی برای فعالیت‌های روزانه پیدا می‌کند.", + expectedResults: [ + { icon: "Zap", text: "رفع فوری خستگی" }, + { icon: "Activity", text: "بهبود اشتها" }, + { icon: "Heart", text: "نشاط متابولیک" } + ], + calculateDosage: (weight) => { + let qty = Math.max(1, weight / 10); + qty = Math.min(qty, 4); + return { quantity: Math.round(qty), unit: "قاشق چایخوری", description: "بمب انرژی" }; + }, + analysis: { "ویتامین B1": "400mg" }, + feedingAdvice: "به دلیل غلظت بالا، به تنهایی یا با غذا داده شود.", + specialist: { + name: "دکتر اشمیت", + title: "متخصص تغذیه بالینی", + image: "https://images.unsplash.com/photo-1559839734-2b71f1536783?auto=format&fit=crop&q=80&w=200", + message: "این ژل برای پت‌هایی که در شرایط بحرانی نیاز به کالری فوزی دارند بی‌نظیر است." + }, + image: "https://www.canina.de/media/cc/e0/75/1715077271/Petvital-Energy-Gel-111452-V1-Canina-100g_600x600.webp" + }, + { + id: "moor-tranke", + artNo: "112404", + name: "Moor-Tränke (عصاره پیت)", + scientificTagline: "اکسیر طبیعی بر پایه اسیدهای هومیک گیاهی", + description: "اکسیر طبیعی برای سلامت گوارش.", + shortDescription: "سم‌زدای قدرتمند طبیعت؛ عصاره پیت حاوی اسیدهای هومیک برای پاکسازی روده از توکسین‌ها و تنظیم اشتها.", + keyBenefits: [ + { icon: "Activity", title: "اسید هومیک", description: "دفع سموم و فلزات سنگین" }, + { icon: "ShieldCheck", title: "ترمیم مخاط", description: "ایجاد لایه محافظ در روده" }, + { icon: "Sparkles", title: "اشتا آور", description: "بهبود جذب مواد مغذی" } + ], + category: "تقویت سیستم ایمنی و گوارش", + categorySlug: "immune", + price: "۳,۳۱۰,۰۰۰ تومان", + priceValue: 3310000, + unit: "250 ml", + packageSize: 250, + main_ingredients: ["100% عصاره گیاهی پیت (Peat Extract)", "اسیدهای هومیک"], + dosage_logic: "تا 5kg: مقدار 2.5ml. تا 10kg: مقدار 5ml. تا 20kg: مقدار 10ml. بالای 35kg: مقدار 20ml.", + benefits: "محافظت از غشاهای مخاطی دستگاه گوارش، تحریک اشتها و تقویت سیستم ایمنی روده.", + symptoms: ["اسهال", "سم‌زدایی", "تقویت روده"], + suitableFor: "هر دو", + calculateDosage: (weight) => { + let ml = 2.5; + if (weight <= 5) ml = 2.5; + else if (weight <= 10) ml = 5; + else if (weight <= 20) ml = 10; + else ml = 20; + return { quantity: ml, unit: "میلی‌لیتر", description: "قبل از مصرف تکان دهید" }; + }, + analysis: { "اسید هومیک": "بالا" }, + feedingAdvice: "روزانه با آب آشامیدنی یا غذا مخلوط شود.", + specialist: { + name: "دکتر کوخ", + title: "متخصص گوارش دامپزشکی", + image: "https://images.unsplash.com/photo-1559839734-2b71f1536783?auto=format&fit=crop&q=80&w=200", + message: "اسیدهای هومیک سموم را در روده به دام انداخته و دفع می‌کنند." + }, + image: "https://www.canina.de/media/89/3b/bc/112404_Moortraenke_Canina-Pharma_1280x1280.png", + faqs: [ + { question: "آیا برای اسهال مزمن موثر است؟", answer: "بله، اسیدهای هومیک موجود در این محصول با ایجاد لایه محافظ روی مخاط روده و جذب سموم، به سرعت وضعیت دفع را بهبود می‌بخند." }, + { question: "نحوه نگهداری محصول چگونه است؟", answer: "پس از باز شدن درب، در جای خنک (یخچال نباشد) نگهداری شود و قبل از هر بار مصرف به خوبی تکان دهید." } + ] + }, + { + id: "schwarz-kummel", + artNo: "112503", + name: "Schwarz-Kümmel (روغن سیاه دانه)", + scientificTagline: "درکان سنتی ارتقا یافته با تکنولوژی آلمانی", + description: "روغن سیاه دانه مصری ۱۰۰٪ خالص برای تقویت سیستم ایمنی و متابولیسم.", + shortDescription: "درمان سنتی کهن؛ روغن سیاه دانه مصری با پرس سرد برای مقابله با آلرژی و دفع طبیعی پارازیت‌ها.", + keyBenefits: [ + { icon: "Flame", title: "پرس سرد", description: "خلوص ۱۰۰ درصد دارویی" }, + { icon: "ShieldCheck", title: "ضد انگل", description: "دفاع طبیعی در برابر کک و کنه" }, + { icon: "Zap", title: "تقویت متابولیک", description: "بهبود سیستم تنفسی و ایمنی" } + ], + category: "تقویت عمومی", + categorySlug: "general", + price: "۱,۴۵۰,۰۰۰ تومان", + priceValue: 1450000, + unit: "250 ml", + packageSize: 250, + main_ingredients: ["۱۰۰٪ روغن سیاه دانه مصری"], + dosage_logic: "۰.۳ میلی‌لیتر به ازای هر کیلوگرم وزن بدن.", + benefits: "بهبود کیفیت پوست و مو، مقابله با انگل‌ها و تقویت کلی سیستم دفاعی.", + symptoms: ["ضعف سیستم ایمنی", "الرژی", "انگل‌های خارجی"], + suitableFor: "سگ", + contraindications: ["برای سگ‌های باردار استفاده نشود", "برای سگ‌های شیرده استفاده نشود"], + calculateDosage: (weight) => { + const ml = weight * 0.3; + return { quantity: Number(ml.toFixed(1)), unit: "میلی‌لیتر", description: "به همراه غذا میل شود" }; + }, + analysis: { "چربی خالص": "۱۰۰٪" }, + feedingAdvice: "به دلیل غلظت بالای تیموکینون، دقیقاً مطابق دوز مصرف شود.", + specialist: { + name: "دکتر مونیخ", + title: "متخصص طب طبیعی دامپزشکی", + image: "https://images.unsplash.com/photo-1559839734-2b71f1536783?auto=format&fit=crop&q=80&w=200", + message: "روغن سیاه دانه مصری کانی‌نا حاوی مقادیر بالایی اسیدهای چرب چند غیراشباع است." + }, + image: "https://www.canina.de/media/1b/83/7d/1715077272/Schwarz-Kuemmel-112503-V1-Canina-250ml_600x600.webp" + } +]; diff --git a/frontend/application/lib/data/scientificTerms.ts b/frontend/application/lib/data/scientificTerms.ts new file mode 100644 index 0000000..a08e1f7 --- /dev/null +++ b/frontend/application/lib/data/scientificTerms.ts @@ -0,0 +1,43 @@ +export interface ScientificTerm { + term: string; + definition: string; + wikiId?: string; +} + +export const SCIENTIFIC_TERMS: Record = { + "گلیکوزآمینوگلیکان": { + term: "گلیکوزآمینوگلیکان (GAG)", + definition: "ترکیبات ساختاری ضروری برای بازسازی غضروف‌ها و مایع مفصلی که در Canhydrox GAG به وفور یافت می‌شود.", + wikiId: "gag" + }, + "اسیدهای هومیک": { + term: "اسیدهای هومیک", + definition: "مواد آلی طبیعی موجود در عصاره پیت (Moor) که از مخاط روده محافظت کرده و سموم را جذب می‌کنند.", + wikiId: "humic-acids" + }, + "کلاژن زیست‌فعال": { + term: "کلاژن زیست‌فعال", + definition: "پروتئین‌های اختصاصی هیدرولیز شده که به سرعت جذب استخوان و تاندون شده و استحکام ساختاری ایجاد می‌کنند.", + wikiId: "collagen" + }, + "تیموکینون": { + term: "تیموکینون", + definition: "ماده موثره اصلی در روغن سیاه دانه با خواص ضدالتهابی و ضدآلرژی بسیار قوی.", + wikiId: "thymoquinone" + }, + "امگا ۳": { + term: "امگا ۳", + definition: "اسیدهای چرب ضروری که برای سلامت پوست، مو و عملکرد قلب حیاتی هستند.", + wikiId: "omega3" + }, + "میکروسیلور": { + term: "نقره میکروسیلور (Microsilver BG)", + definition: "ذرات نقره خالص با ساختار اسفنجی که بدون جذب در خون، اثر آنتی‌باکتریال پایداری بر سطح پوست و لثه دارند.", + wikiId: "silver" + }, + "صدف لب‌سبز": { + term: "صدف لب‌سبز (Green-lipped Mussel)", + definition: "صدف بومی نیوزلند حاوی گلوکوزامین‌گلیکان‌های طبیعی که نقش حیاتی در بازسازی غضروف و مفاصل دارند.", + wikiId: "joints" + } +}; diff --git a/frontend/application/lib/hooks/useNetworkStatus.ts b/frontend/application/lib/hooks/useNetworkStatus.ts new file mode 100644 index 0000000..f2682fb --- /dev/null +++ b/frontend/application/lib/hooks/useNetworkStatus.ts @@ -0,0 +1,20 @@ +import { useState, useEffect } from 'react'; + +export function useNetworkStatus() { + const [isOnline, setIsOnline] = useState(navigator.onLine); + + useEffect(() => { + const handleOnline = () => setIsOnline(true); + const handleOffline = () => setIsOnline(false); + + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + + return () => { + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); + }; + }, []); + + return isOnline; +} diff --git a/frontend/application/lib/services/api.ts b/frontend/application/lib/services/api.ts new file mode 100644 index 0000000..adfe2ca --- /dev/null +++ b/frontend/application/lib/services/api.ts @@ -0,0 +1,23 @@ +import axios from 'axios'; + +const api = axios.create({ + baseURL: '/api', + timeout: 10000, + headers: { + 'Content-Type': 'application/json', + }, +}); + +// Interceptor to add auth token in the future +api.interceptors.request.use( + (config) => { + const token = localStorage.getItem('accessToken'); + if (token && config.headers) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }, + (error) => Promise.reject(error) +); + +export default api; diff --git a/frontend/application/lib/services/authService.ts b/frontend/application/lib/services/authService.ts new file mode 100644 index 0000000..2c6dc00 --- /dev/null +++ b/frontend/application/lib/services/authService.ts @@ -0,0 +1,102 @@ +import api from './api'; + +export interface User { + id: string; + firstName: string; + lastName: string; + email: string; + mobile: string | null; + role: string; + walletBalance: number; + charityDonationTotal: number; + createdAt: string; + updatedAt: string; + pets?: any[]; + orders?: any[]; +} + +export interface AuthResponse { + success: boolean; + data: { + user: User; + accessToken: string; + }; +} + +export class AuthService { + private static instance: AuthService; + + private constructor() {} + + public static getInstance(): AuthService { + if (!AuthService.instance) { + AuthService.instance = new AuthService(); + } + return AuthService.instance; + } + + /** + * Send SMS verification code (OTP) + */ + public async sendOtp(phoneNumber: string): Promise<{ success: boolean; message: string }> { + try { + const response = await api.post('/auth/send-otp', { phoneNumber }); + return response.data; + } catch (error: any) { + const message = error.response?.data?.message || 'خطا در ارسال کد تایید'; + throw new Error(Array.isArray(message) ? message[0] : message); + } + } + + /** + * Verify SMS verification code and login/register + */ + public async verifyOtp(phoneNumber: string, code: string): Promise { + try { + const response = await api.post('/auth/verify-otp', { phoneNumber, code }); + const { data } = response.data; + if (data?.accessToken) { + localStorage.setItem('accessToken', data.accessToken); + } + return response.data; + } catch (error: any) { + const message = error.response?.data?.message || 'کد تایید نامعتبر است'; + throw new Error(Array.isArray(message) ? message[0] : message); + } + } + + /** + * Get current user profile + */ + public async getProfile(): Promise { + try { + const response = await api.get('/users/profile'); + return response.data; + } catch (error: any) { + const message = error.response?.data?.message || 'خطا در دریافت اطلاعات کاربری'; + throw new Error(Array.isArray(message) ? message[0] : message); + } + } + + /** + * Update current user profile + */ + public async updateProfile(profileData: Partial): Promise { + try { + const response = await api.patch('/users/profile', profileData); + return response.data; + } catch (error: any) { + const message = error.response?.data?.message || 'خطا در ویرایش اطلاعات کاربری'; + throw new Error(Array.isArray(message) ? message[0] : message); + } + } + + /** + * Logout user and clear tokens + */ + public logout(): void { + localStorage.removeItem('accessToken'); + } +} + +export const authService = AuthService.getInstance(); diff --git a/frontend/application/lib/services/orderService.ts b/frontend/application/lib/services/orderService.ts new file mode 100644 index 0000000..c8d9240 --- /dev/null +++ b/frontend/application/lib/services/orderService.ts @@ -0,0 +1,79 @@ +import api from './api'; +import { CartItem } from '../store/cartStore'; + +export interface OrderItem { + id: string; + orderId: string; + productId: string | null; + quantity: number; + product?: any; +} + +export interface Order { + id: string; + userId: string; + couponId: string | null; + totalAmount: number; + charityDonation: number; + status: string; + trackingNumber: string | null; + createdAt: string; + orderItems: OrderItem[]; +} + +export class OrderService { + private static instance: OrderService; + + private constructor() {} + + public static getInstance(): OrderService { + if (!OrderService.instance) { + OrderService.instance = new OrderService(); + } + return OrderService.instance; + } + + /** + * Create a new order on the backend + */ + public async createOrder(orderData: { + petId?: string; + items: { productId: string; quantity: number }[]; + }): Promise { + try { + const response = await api.post('/orders', orderData); + return response.data; + } catch (error: any) { + const message = error.response?.data?.message || 'خطا در ثبت سفارش'; + throw new Error(Array.isArray(message) ? message[0] : message); + } + } + + /** + * Get all orders for the current user + */ + public async getUserOrders(): Promise { + try { + const response = await api.get('/orders'); + return response.data; + } catch (error: any) { + const message = error.response?.data?.message || 'خطا در دریافت لیست سفارش‌ها'; + throw new Error(Array.isArray(message) ? message[0] : message); + } + } + + /** + * Get order details by ID + */ + public async getOrderById(id: string): Promise { + try { + const response = await api.get(`/orders/${id}`); + return response.data; + } catch (error: any) { + const message = error.response?.data?.message || 'خطا در دریافت جزئیات سفارش'; + throw new Error(Array.isArray(message) ? message[0] : message); + } + } +} + +export const orderService = OrderService.getInstance(); diff --git a/frontend/application/lib/services/productService.ts b/frontend/application/lib/services/productService.ts new file mode 100644 index 0000000..ca09d23 --- /dev/null +++ b/frontend/application/lib/services/productService.ts @@ -0,0 +1,106 @@ +import api from './api'; +import { PRODUCTS, Product, PetType } from "../data/products"; + +export class ProductService { + private static instance: ProductService; + + private constructor() {} + + public static getInstance(): ProductService { + if (!ProductService.instance) { + ProductService.instance = new ProductService(); + } + return ProductService.instance; + } + + private mapBackendToFrontend(data: any): Product { + // Find the local static product to inherit functions like calculateDosage + const local = PRODUCTS.find(p => p.artNo === data.artNo); + + const safeParse = (val: any, fallback: any) => { + if (!val) return fallback; + if (typeof val === 'string') { + try { return JSON.parse(val); } catch (e) { return fallback; } + } + return val; + }; + + return { + id: data.id, + artNo: data.artNo, + name: data.name, + scientificTagline: data.scientificTagline, + description: data.description, + shortDescription: data.shortDescription, + price: data.priceDisplay || `${data.priceValue.toLocaleString('fa-IR')} تومان`, + priceValue: Number(data.priceValue), + category: data.category, + categorySlug: data.categorySlug, + unit: data.unit, + packageSize: data.packageSize, + dosage_logic: data.dosageLogic, + benefits: data.benefits, + suitableFor: data.suitableFor as PetType, + storage: data.storage, + specialBadge: data.specialBadge, + onSetOfAction: data.onSetOfAction, + optimisticTemplate: data.optimisticTemplate, + feedingAdvice: data.feedingAdvice, + image: data.image, + + main_ingredients: data.ingredients?.map((i: any) => i.ingredient) || [], + symptoms: data.symptoms?.map((s: any) => s.symptom) || [], + keyBenefits: safeParse(data.keyBenefits, []), + expectedResults: safeParse(data.expectedResults, []), + benefitsList: safeParse(data.benefitsList, []), + faqs: safeParse(data.faqs, []), + analysis: safeParse(data.analysis, {}), + specialist: safeParse(data.specialist, null), + relatedProducts: safeParse(data.relatedProducts, []), + contraindications: safeParse(data.contraindications, []), + + calculateDosage: local?.calculateDosage || (() => ({ quantity: 0, unit: '', description: '' })) + }; + } + + public async getProducts(filters?: { + category?: string; + petType?: PetType | "all"; + query?: string; + }): Promise { + const params = new URLSearchParams(); + if (filters?.category && filters.category !== "all") params.append('category', filters.category); + if (filters?.petType && filters.petType !== "all") params.append('petType', filters.petType); + if (filters?.query) params.append('query', filters.query); + + try { + const response = await api.get(`/products?${params.toString()}`); + return response.data.map((item: any) => this.mapBackendToFrontend(item)); + } catch (error) { + console.error("[ProductService] Failed to fetch products:", error); + return []; + } + } + + public async getProductById(id: string): Promise { + try { + const response = await api.get(`/products/${id}`); + return this.mapBackendToFrontend(response.data); + } catch (error) { + console.error(`[ProductService] Failed to fetch product ${id}:`, error); + return null; + } + } + + public async getFeaturedProducts(): Promise { + try { + const response = await api.get('/products'); + return response.data.slice(0, 4).map((item: any) => this.mapBackendToFrontend(item)); + } catch (error) { + console.error("[ProductService] Failed to fetch featured products:", error); + return []; + } + } +} + +export const productService = ProductService.getInstance(); diff --git a/frontend/application/lib/store/__tests__/cartStore.test.ts b/frontend/application/lib/store/__tests__/cartStore.test.ts new file mode 100644 index 0000000..911127d --- /dev/null +++ b/frontend/application/lib/store/__tests__/cartStore.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { useCartStore } from '../cartStore'; +import { orderService } from '../../services/orderService'; + +vi.mock('../../services/orderService', () => ({ + orderService: { + createOrder: vi.fn(), + }, +})); + +const mockProduct = { + id: 'prod-1', + artNo: 'art-1', + name: 'Canhydrox GAG', + scientificTagline: 'Joint helper', + description: 'Joints', + price: '۱,۰۰۰ تومان', + priceValue: 1000, + category: 'joints', + unit: 'tablet', + packageSize: 100, + main_ingredients: [], + dosage_logic: '1', + benefits: '', + symptoms: [], + suitableFor: 'سگ' as any, + calculateDosage: () => ({ quantity: 1, unit: 'tablet', description: '' }), + analysis: {}, + feedingAdvice: '', + specialist: null as any, + image: '', +}; + +describe('cartStore', () => { + beforeEach(() => { + vi.clearAllMocks(); + useCartStore.setState({ + items: [], + isSubscribed: false, + charityDonation: 0, + coupon: null, + orders: [], + }); + }); + + it('should add item to cart', () => { + const store = useCartStore.getState(); + store.addItem(mockProduct, 2); + + const state = useCartStore.getState(); + expect(state.items).toHaveLength(1); + expect(state.items[0].product.id).toBe('prod-1'); + expect(state.items[0].quantity).toBe(2); + }); + + it('should increment quantity if item already in cart', () => { + const store = useCartStore.getState(); + store.addItem(mockProduct, 2); + store.addItem(mockProduct, 3); + + const state = useCartStore.getState(); + expect(state.items).toHaveLength(1); + expect(state.items[0].quantity).toBe(5); + }); + + it('should remove item from cart', () => { + const store = useCartStore.getState(); + store.addItem(mockProduct, 2); + store.removeItem('prod-1'); + + const state = useCartStore.getState(); + expect(state.items).toHaveLength(0); + }); + + it('should update quantity', () => { + const store = useCartStore.getState(); + store.addItem(mockProduct, 2); + store.updateQuantity('prod-1', 4); + + const state = useCartStore.getState(); + expect(state.items[0].quantity).toBe(4); + }); + + it('should remove item if update quantity is <= 0', () => { + const store = useCartStore.getState(); + store.addItem(mockProduct, 2); + store.updateQuantity('prod-1', 0); + + const state = useCartStore.getState(); + expect(state.items).toHaveLength(0); + }); + + it('should apply discount coupon', () => { + const store = useCartStore.getState(); + const success = store.applyCoupon('CANINA2024'); + expect(success).toBe(true); + + const state = useCartStore.getState(); + expect(state.coupon).toEqual({ code: 'CANINA2024', discount: 0.1 }); + }); + + it('should calculate subtotal, discount and total', () => { + const store = useCartStore.getState(); + store.addItem(mockProduct, 2); // 2 * 1000 = 2000 + store.applyCoupon('CANINA2024'); // 10% discount + + expect(useCartStore.getState().getSubtotal()).toBe(2000); + expect(useCartStore.getState().getDiscount()).toBe(200); + expect(useCartStore.getState().getTotal()).toBe(1800); + }); + + it('should create order successfully via orderService', async () => { + const mockBackendOrder = { + id: 'backend-order-id', + createdAt: '2026-05-26T18:00:00.000Z', + totalAmount: '2000.00', + charityDonation: '0.00', + status: 'processing', + trackingNumber: 'TRK-12345', + }; + + vi.mocked(orderService.createOrder).mockResolvedValue(mockBackendOrder); + + const store = useCartStore.getState(); + store.addItem(mockProduct, 2); + + const orderId = await store.addOrder({ + items: store.items, + total: 2000, + charityDonation: 0, + petId: 'pet-id', + }); + + expect(orderService.createOrder).toHaveBeenCalled(); + expect(orderId).toBe('backend-order-id'); + + const state = useCartStore.getState(); + expect(state.orders).toHaveLength(1); + expect(state.orders[0].id).toBe('backend-order-id'); + expect(state.orders[0].total).toBe(2000); + }); +}); diff --git a/frontend/application/lib/store/__tests__/settingsStore.test.ts b/frontend/application/lib/store/__tests__/settingsStore.test.ts new file mode 100644 index 0000000..548d31a --- /dev/null +++ b/frontend/application/lib/store/__tests__/settingsStore.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { useSettingsStore } from '../settingsStore'; +import api from '../../services/api'; + +vi.mock('../../services/api', () => ({ + default: { + get: vi.fn(), + }, +})); + +describe('settingsStore', () => { + beforeEach(() => { + vi.clearAllMocks(); + useSettingsStore.setState({ + texts: {}, + scientificTerms: {}, + isLoading: false, + }); + }); + + it('should return fallback if key is not found', () => { + const text = useSettingsStore.getState().getText('non-existent', 'Fallback Text'); + expect(text).toBe('Fallback Text'); + }); + + it('should return value if key is found', () => { + useSettingsStore.setState({ + texts: { 'test-key': 'Real Value' }, + }); + const text = useSettingsStore.getState().getText('test-key', 'Fallback Text'); + expect(text).toBe('Real Value'); + }); + + it('should fetch settings successfully and map array to object', async () => { + const mockUiTexts = [ + { key: 'hero_title', value: 'Welcome to Canina' }, + { key: 'hero_desc', value: 'Best supplements' }, + ]; + const mockTerms = [ + { key: 'mussel', term: 'Mussel', definition: 'NZ Green Mussel', wikiId: 'general' }, + ]; + + vi.mocked(api.get).mockImplementation((url: string) => { + if (url === '/settings/ui-texts') { + return Promise.resolve({ data: mockUiTexts }); + } + if (url === '/settings/scientific-terms') { + return Promise.resolve({ data: mockTerms }); + } + return Promise.reject(new Error('Unknown URL')); + }); + + const store = useSettingsStore.getState(); + await store.fetchSettings(); + + const state = useSettingsStore.getState(); + expect(state.texts['hero_title']).toBe('Welcome to Canina'); + expect(state.texts['hero_desc']).toBe('Best supplements'); + expect(state.scientificTerms['mussel']).toEqual(mockTerms[0]); + expect(state.isLoading).toBe(false); + }); +}); diff --git a/frontend/application/lib/store/__tests__/userStore.test.ts b/frontend/application/lib/store/__tests__/userStore.test.ts new file mode 100644 index 0000000..caaddf6 --- /dev/null +++ b/frontend/application/lib/store/__tests__/userStore.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { useUserStore } from '../userStore'; +import { authService } from '../../services/authService'; +import api from '../../services/api'; + +vi.mock('../../services/authService', () => ({ + authService: { + updateProfile: vi.fn(), + logout: vi.fn(), + getProfile: vi.fn(), + }, +})); + +vi.mock('../../services/api', () => ({ + default: { + post: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + }, +})); + +describe('userStore', () => { + beforeEach(() => { + vi.clearAllMocks(); + useUserStore.setState({ + role: 'User_Guest', + isLoggedIn: false, + profile: { + firstName: '', + lastName: '', + email: '', + mobile: '', + walletBalance: 0, + charityDonationTotal: 0, + addresses: [], + transactions: [], + }, + }); + }); + + it('should logout correctly', () => { + useUserStore.setState({ + role: 'User_PetOwner', + isLoggedIn: true, + profile: { + firstName: 'Ali', + lastName: 'Ahmadi', + email: 'ali@example.com', + mobile: '0912', + walletBalance: 100, + charityDonationTotal: 10, + addresses: [], + transactions: [], + }, + }); + + useUserStore.getState().logout(); + + expect(authService.logout).toHaveBeenCalled(); + const state = useUserStore.getState(); + expect(state.isLoggedIn).toBe(false); + expect(state.role).toBe('User_Guest'); + expect(state.profile.firstName).toBe(''); + }); + + it('should fetch profile successfully', async () => { + const mockProfileData = { + firstName: 'Ali', + lastName: 'Ahmadi', + email: 'ali@example.com', + mobile: '09123456789', + role: 'User_PetOwner', + walletBalance: '1500.00', + charityDonationTotal: '50.00', + addresses: [], + pets: [], + walletTransactions: [], + }; + + vi.mocked(authService.getProfile).mockResolvedValue(mockProfileData); + + const store = useUserStore.getState(); + await store.fetchProfile(); + + expect(authService.getProfile).toHaveBeenCalled(); + const state = useUserStore.getState(); + expect(state.isLoggedIn).toBe(true); + expect(state.role).toBe('User_PetOwner'); + expect(state.profile.firstName).toBe('Ali'); + expect(state.profile.walletBalance).toBe(1500); + }); + + it('should update profile correctly', async () => { + const mockUpdatedData = { + firstName: 'Ali Updated', + lastName: 'Ahmadi', + email: 'ali@example.com', + mobile: '09123456789', + }; + + vi.mocked(authService.updateProfile).mockResolvedValue(mockUpdatedData); + + const store = useUserStore.getState(); + await store.updateProfile({ firstName: 'Ali Updated' }); + + expect(authService.updateProfile).toHaveBeenCalledWith({ firstName: 'Ali Updated' }); + const state = useUserStore.getState(); + expect(state.profile.firstName).toBe('Ali Updated'); + }); + + it('should add address and call fetchProfile', async () => { + const address = { + id: 'addr-1', + title: 'Home', + receptorName: 'Ali', + phone: '0912', + province: 'Teh', + city: 'Teh', + detail: 'Det', + zipCode: '123', + isDefault: false, + }; + + const fetchSpy = vi.spyOn(useUserStore.getState(), 'fetchProfile').mockResolvedValue(undefined); + vi.mocked(api.post).mockResolvedValue({ data: { success: true } }); + + const store = useUserStore.getState(); + await store.addAddress(address); + + expect(api.post).toHaveBeenCalled(); + expect(fetchSpy).toHaveBeenCalled(); + }); +}); diff --git a/frontend/application/lib/store/cartStore.ts b/frontend/application/lib/store/cartStore.ts new file mode 100644 index 0000000..eb416ca --- /dev/null +++ b/frontend/application/lib/store/cartStore.ts @@ -0,0 +1,170 @@ +"use client"; +import { create } from "zustand"; +import { persist } from "zustand/middleware"; +import { Product } from "../data/products"; +import { orderService } from "../services/orderService"; +import api from "../services/api"; + + +export interface CartItem { + product: Product; + quantity: number; + calculatedDose?: { + quantity: number; + unit: string; + }; +} + +interface Order { + id: string; + date: string; + items: CartItem[]; + total: number; + charityDonation?: number; + status: 'processing' | 'shipped' | 'delivered'; + petId?: string; + trackingNumber: string; +} + +interface CartStore { + items: CartItem[]; + isSubscribed: boolean; + charityDonation: number; + coupon: { code: string; discount: number } | null; + orders: Order[]; + addItem: (product: Product, quantity: number, dose?: { quantity: number; unit: string }) => void; + removeItem: (productId: string) => void; + updateQuantity: (productId: string, quantity: number) => void; + toggleSubscription: () => void; + setCharityDonation: (amount: number) => void; + applyCoupon: (code: string) => Promise; + addOrder: (order: Omit) => Promise; + clearCart: () => void; + removeCoupon: () => void; + setOrders: (orders: any[]) => void; + getTotalItems: () => number; + getSubtotal: () => number; + getDiscount: () => number; + getTotal: () => number; +} + +export const useCartStore = create()( + persist( + (set, get) => ({ + items: [], + isSubscribed: false, + charityDonation: 0, + coupon: null, + orders: [], + addItem: (product, quantity, dose) => { + const items = get().items; + const existing = items.find((i) => i.product.id === product.id); + if (existing) { + set({ + items: items.map((i) => + i.product.id === product.id ? { ...i, quantity: i.quantity + quantity } : i + ), + }); + } else { + set({ items: [...items, { product, quantity, calculatedDose: dose }] }); + } + }, + removeItem: (productId) => { + set({ items: get().items.filter((i) => i.product.id !== productId) }); + }, + updateQuantity: (productId, quantity) => { + if (quantity <= 0) { + get().removeItem(productId); + return; + } + set({ + items: get().items.map((i) => + i.product.id === productId ? { ...i, quantity } : i + ), + }); + }, + toggleSubscription: () => set({ isSubscribed: !get().isSubscribed }), + setCharityDonation: (amount) => set({ charityDonation: amount }), + applyCoupon: async (code) => { + try { + const cleanCode = code.toUpperCase().trim(); + // Assuming API returns { discountValue: number } or similar + const response = await api.post('/cart/apply-coupon', { code: cleanCode }); + if (response.data && response.data.discountValue) { + set({ coupon: { code: cleanCode, discount: response.data.discountValue } }); + return true; + } + return false; + } catch (error) { + console.error("Failed to apply coupon:", error); + return false; + } + }, + addOrder: async (orderData) => { + const payload = { + petId: orderData.petId, + items: orderData.items.map(i => ({ + productId: i.product.id, + quantity: i.quantity + })) + }; + + try { + const backendOrder = await orderService.createOrder(payload); + + const newOrder: Order = { + ...orderData, + id: backendOrder.id, + date: backendOrder.createdAt, + total: Number(backendOrder.totalAmount), + charityDonation: Number(backendOrder.charityDonation), + status: backendOrder.status as any, + trackingNumber: backendOrder.trackingNumber || `CN-${Math.floor(Math.random() * 90000) + 10000}` + }; + + set({ orders: [newOrder, ...get().orders] }); + return backendOrder.id; + } catch (error: any) { + console.error("Order creation failed on backend:", error); + throw error; + } + }, + clearCart: () => set({ items: [], coupon: null }), + removeCoupon: () => set({ coupon: null }), + setOrders: (backendOrders) => { + const mappedOrders: Order[] = backendOrders.map(bo => ({ + id: bo.id, + date: bo.createdAt, + items: bo.orderItems?.map((oi: any) => ({ + product: oi.product, + quantity: oi.quantity + })) || [], + total: Number(bo.totalAmount), + charityDonation: Number(bo.charityDonation), + status: bo.status, + trackingNumber: bo.trackingNumber || `CN-${Math.floor(Math.random() * 90000) + 10000}` + })); + set({ orders: mappedOrders }); + }, + getTotalItems: () => get().items.reduce((acc, item) => acc + item.quantity, 0), + getSubtotal: () => get().items.reduce((acc, item) => acc + item.product.priceValue * item.quantity, 0), + getDiscount: () => { + const subtotal = get().getSubtotal(); + let discount = get().isSubscribed ? subtotal * 0.05 : 0; + + if (get().coupon) { + const c = get().coupon!; + if (c.discount < 1) { + discount += subtotal * c.discount; + } else { + discount += c.discount; + } + } + + return Math.min(discount, subtotal); + }, + getTotal: () => get().getSubtotal() - get().getDiscount() + get().charityDonation, + }), + { name: "canina-cart" } + ) +); diff --git a/frontend/application/lib/store/settingsStore.ts b/frontend/application/lib/store/settingsStore.ts new file mode 100644 index 0000000..0144921 --- /dev/null +++ b/frontend/application/lib/store/settingsStore.ts @@ -0,0 +1,60 @@ +"use client"; +import { create } from "zustand"; +import api from "../services/api"; + +export interface ScientificTerm { + key: string; + term: string; + definition: string; + wikiId: string; +} + +interface SettingsStore { + texts: Record; + scientificTerms: Record; + isLoading: boolean; + fetchSettings: () => Promise; + getText: (key: string, fallback: string) => string; +} + +export const useSettingsStore = create()((set, get) => ({ + texts: {}, + scientificTerms: {}, + isLoading: false, + fetchSettings: async () => { + set({ isLoading: true }); + try { + const [textsRes, termsRes] = await Promise.all([ + api.get('/settings/ui-texts'), + api.get('/settings/scientific-terms'), + ]); + + const textsObj: Record = {}; + if (Array.isArray(textsRes.data)) { + textsRes.data.forEach((item: { key: string; value: string }) => { + textsObj[item.key] = item.value; + }); + } + + const termsObj: Record = {}; + if (Array.isArray(termsRes.data)) { + termsRes.data.forEach((item: ScientificTerm) => { + termsObj[item.key] = item; + }); + } + + set({ texts: textsObj, scientificTerms: termsObj }); + } catch (error) { + console.error("Failed to fetch settings/ui-texts:", error); + } finally { + set({ isLoading: false }); + } + }, + getText: (key, fallback) => { + const value = get().texts[key]; + if (value === undefined || value === null) { + return fallback; + } + return value; + } +})); diff --git a/frontend/application/lib/store/usePetStore.ts b/frontend/application/lib/store/usePetStore.ts new file mode 100644 index 0000000..0f59c0c --- /dev/null +++ b/frontend/application/lib/store/usePetStore.ts @@ -0,0 +1,211 @@ +"use client"; +import { create } from "zustand"; +import { persist } from "zustand/middleware"; +import api from "../services/api"; +import { toast } from "sonner"; + +export interface Reminder { + id: string; + title: string; + time: string; + frequency: "روزانه" | "هفتگی"; + productId?: string; + completedDates: string[]; // List of ISO dates when this was completed +} + +export interface HealthLog { + id: string; + date: string; + appetite: "عالی" | "متوسط" | "کم"; + energy: "زیاد" | "نرمال" | "بی‌حال"; + digestion: "نرمال" | "حساس" | "مشکل‌دار"; + note?: string; +} + +export interface PetConsumption { + productId: string; + packageSize: number; + remaining: number; +} + +export interface PetProfile { + id: string; + name: string; + type: "سگ" | "گربه"; + breed: string; + age: number; + weight: number; + activityLevel: "کم" | "متوسط" | "زیاد"; + medicalConditions: string[]; + image?: string; + reminders: Reminder[]; + logs: HealthLog[]; + consumptions: PetConsumption[]; +} + +interface PetStore { + pets: PetProfile[]; + activePetId: string | null; + addPet: (pet: Omit) => void; + removePet: (id: string) => void; + setActivePet: (id: string) => void; + getActivePet: () => PetProfile | null; + updatePet: (id: string, updates: Partial) => void; + addReminder: (petId: string, reminder: Omit) => Promise; + toggleReminder: (petId: string, reminderId: string, date: string) => Promise; + addHealthLog: (petId: string, log: Omit) => Promise; + setPets: (pets: any[]) => void; +} + +export const usePetStore = create()( + persist( + (set, get) => ({ + pets: [], + activePetId: null, + addPet: async (pet) => { + const { useUserStore } = await import("./userStore"); + const isLoggedIn = useUserStore.getState().isLoggedIn; + + if (isLoggedIn) { + try { + await api.post("/pets", { + name: pet.name, + type: pet.type, + breed: pet.breed || "", + weight: Number(pet.weight) || 0 + }); + await useUserStore.getState().fetchProfile(); + } catch (error) { + console.error("Failed to add pet via backend API:", error); + toast.error("خطا در ثبت پت در سرور"); + } + } else { + const id = Math.random().toString(36).substring(7); + const newPet = { + ...pet, + id, + reminders: pet.reminders || [], + logs: pet.logs || [], + consumptions: pet.consumptions || [] + }; + set((state) => ({ + pets: [...state.pets, newPet], + activePetId: state.activePetId || id, + })); + } + }, + removePet: async (id) => { + const { useUserStore } = await import("./userStore"); + const isLoggedIn = useUserStore.getState().isLoggedIn; + + if (isLoggedIn) { + try { + await api.delete(`/pets/${id}`); + await useUserStore.getState().fetchProfile(); + } catch (error) { + console.error("Failed to remove pet via backend API:", error); + toast.error("خطا در حذف پت از سرور"); + } + } else { + set((state) => { + const newPets = state.pets.filter((p) => p.id !== id); + return { + pets: newPets, + activePetId: state.activePetId === id ? (newPets[0]?.id || null) : state.activePetId, + }; + }); + } + }, + setActivePet: (id) => set({ activePetId: id }), + getActivePet: () => { + const { pets, activePetId } = get(); + return pets.find((p) => p.id === activePetId) || null; + }, + updatePet: async (id, updates) => { + const { useUserStore } = await import("./userStore"); + const isLoggedIn = useUserStore.getState().isLoggedIn; + + if (isLoggedIn) { + try { + await api.patch(`/pets/${id}`, { + name: updates.name, + type: updates.type, + breed: updates.breed, + weight: updates.weight ? Number(updates.weight) : undefined + }); + await useUserStore.getState().fetchProfile(); + } catch (error) { + console.error("Failed to update pet via backend API:", error); + toast.error("خطا در ویرایش اطلاعات پت در سرور"); + } + } else { + set((state) => ({ + pets: state.pets.map((p) => (p.id === id ? { ...p, ...updates } : p)), + })); + } + }, + addReminder: async (petId, reminder) => { + try { + await api.post(`/pets/${petId}/reminders`, reminder); + const { useUserStore } = await import("./userStore"); + await useUserStore.getState().fetchProfile(); + } catch (error) { + console.error("Failed to add reminder:", error); + toast.error("خطا در ثبت یادآور"); + } + }, + toggleReminder: async (petId, reminderId, date) => { + try { + await api.post(`/pets/${petId}/reminders/${reminderId}/toggle`, { date }); + const { useUserStore } = await import("./userStore"); + await useUserStore.getState().fetchProfile(); + } catch (error) { + console.error("Failed to toggle reminder:", error); + toast.error("خطا در تغییر وضعیت یادآور"); + } + }, + addHealthLog: async (petId, log) => { + try { + await api.post(`/pets/${petId}/health-logs`, log); + const { useUserStore } = await import("./userStore"); + await useUserStore.getState().fetchProfile(); + } catch (error) { + console.error("Failed to add health log:", error); + toast.error("خطا در ثبت گزارش سلامتی"); + } + }, + setPets: (backendPets) => { + const mappedPets: PetProfile[] = backendPets.map(bp => ({ + id: bp.id, + name: bp.name, + type: bp.type as any, + breed: bp.breed, + age: Number(bp.age), + weight: Number(bp.weight), + activityLevel: bp.activityLevel as any, + medicalConditions: bp.medicalConditions?.map((mc: any) => mc.condition) || [], + image: bp.imageUrl || undefined, + reminders: bp.reminders?.map((r: any) => ({ + id: r.id, + title: r.title, + time: r.time, + frequency: r.frequency as any, + productId: r.productId, + completedDates: r.completions?.map((c: any) => c.completedDate) || [] + })) || [], + logs: bp.healthLogs?.map((hl: any) => ({ + id: hl.id, + date: hl.loggedDate, + appetite: hl.appetite as any, + energy: hl.energy as any, + digestion: hl.digestion as any, + note: hl.note || undefined + })) || [], + consumptions: [] + })); + set({ pets: mappedPets, activePetId: get().activePetId && mappedPets.some(p => p.id === get().activePetId) ? get().activePetId : (mappedPets[0]?.id || null) }); + }, + }), + { name: "canina-pets" } + ) +); diff --git a/frontend/application/lib/store/userStore.ts b/frontend/application/lib/store/userStore.ts new file mode 100644 index 0000000..db3b3f1 --- /dev/null +++ b/frontend/application/lib/store/userStore.ts @@ -0,0 +1,196 @@ +"use client"; +import { create } from "zustand"; +import { persist } from "zustand/middleware"; +import { authService } from "../services/authService"; +import api from "../services/api"; +import { useCartStore } from "./cartStore"; +import { usePetStore } from "./usePetStore"; + + + +export type UserRole = "User_Guest" | "User_PetOwner" | "User_Partner"; + +export interface Address { + id: string; + title: string; + receptorName: string; + phone: string; + province: string; + city: string; + detail: string; + zipCode: string; + isDefault: boolean; +} + +export interface Transaction { + id: string; + type: 'top_up' | 'purchase'; + amount: number; + date: string; + status: 'success' | 'failed' | 'pending'; +} + +interface UserProfile { + firstName: string; + lastName: string; + email: string; + mobile: string; + walletBalance: number; + charityDonationTotal: number; + addresses: Address[]; + transactions: Transaction[]; +} + +interface UserStore { + role: UserRole; + isLoggedIn: boolean; + profile: UserProfile; + setRole: (role: UserRole) => void; + setLoggedIn: (isLoggedIn: boolean) => void; + updateProfile: (profile: Partial) => Promise; + addAddress: (address: Address) => Promise; + updateAddress: (id: string, address: Address) => Promise; + deleteAddress: (id: string) => Promise; + setDefaultAddress: (id: string) => Promise; + topUpWallet: (amount: number) => Promise; + logout: () => void; + fetchProfile: () => Promise; +} + +export const useUserStore = create()( + persist( + (set) => ({ + role: "User_Guest", + isLoggedIn: false, + profile: { + firstName: "", + lastName: "", + email: "", + mobile: "", + walletBalance: 0, + charityDonationTotal: 0, + addresses: [], + transactions: [], + }, + setRole: (role) => set({ role }), + setLoggedIn: (isLoggedIn) => set({ isLoggedIn }), + updateProfile: async (updates) => { + try { + const profileData = await authService.updateProfile(updates); + set((state) => ({ + profile: { + ...state.profile, + firstName: profileData.firstName, + lastName: profileData.lastName, + email: profileData.email, + mobile: profileData.mobile || "", + } + })); + } catch (error) { + console.error("Failed to update profile:", error); + throw error; + } + }, + addAddress: async (address) => { + try { + // Remove ID so backend generates UUID + const { id, ...addressData } = address; + await api.post('/users/addresses', addressData); + await useUserStore.getState().fetchProfile(); + } catch (error) { + console.error("Failed to add address:", error); + throw error; + } + }, + updateAddress: async (id, updated) => { + try { + const { id: _, ...addressData } = updated; + await api.patch(`/users/addresses/${id}`, addressData); + await useUserStore.getState().fetchProfile(); + } catch (error) { + console.error("Failed to update address:", error); + throw error; + } + }, + deleteAddress: async (id) => { + try { + await api.delete(`/users/addresses/${id}`); + await useUserStore.getState().fetchProfile(); + } catch (error) { + console.error("Failed to delete address:", error); + throw error; + } + }, + setDefaultAddress: async (id) => { + try { + await api.patch(`/users/addresses/${id}/default`); + await useUserStore.getState().fetchProfile(); + } catch (error) { + console.error("Failed to set default address:", error); + throw error; + } + }, + topUpWallet: async (amount) => { + try { + await api.post('/users/wallet/top-up', { amount }); + await useUserStore.getState().fetchProfile(); + } catch (error) { + console.error("Failed to top-up wallet:", error); + throw error; + } + }, + logout: () => { + authService.logout(); + set({ role: "User_Guest", isLoggedIn: false, profile: { + firstName: "", + lastName: "", + email: "", + mobile: "", + walletBalance: 0, + charityDonationTotal: 0, + addresses: [], + transactions: [] + }}); + }, + fetchProfile: async () => { + try { + const profileData = await authService.getProfile(); + set({ + isLoggedIn: true, + role: (profileData.role as UserRole) || "User_PetOwner", + profile: { + firstName: profileData.firstName, + lastName: profileData.lastName, + email: profileData.email, + mobile: profileData.mobile || "", + walletBalance: Number(profileData.walletBalance), + charityDonationTotal: Number(profileData.charityDonationTotal), + addresses: (profileData as any).addresses || [], + transactions: (profileData as any).walletTransactions?.map((t: any) => ({ + id: t.id, + type: t.type === 'deposit' ? 'top_up' : 'purchase', + amount: Number(t.amount), + date: t.createdAt, + status: t.status === 'completed' ? 'success' : t.status === 'failed' ? 'failed' : 'pending' + })) || [] + } + }); + + // Sync orders list to CartStore + if (profileData.orders) { + useCartStore.getState().setOrders(profileData.orders); + } + // Sync pets list to PetStore + if (profileData.pets) { + usePetStore.getState().setPets(profileData.pets); + } + } catch (error) { + console.error("Failed to fetch user profile:", error); + authService.logout(); + set({ isLoggedIn: false, role: "User_Guest" }); + } + }, + }), + { name: "canina-user" } + ) +); diff --git a/frontend/application/lib/utils/utils.ts b/frontend/application/lib/utils/utils.ts new file mode 100644 index 0000000..c1d3216 --- /dev/null +++ b/frontend/application/lib/utils/utils.ts @@ -0,0 +1,8 @@ +export const toPersian = (n: number | string | undefined | null) => { + if (n === undefined || n === null) return ""; + return n.toString().replace(/\d/g, d => '۰۱۲۳۴۵۶۷۸۹'[parseInt(d)]); +}; + +export function cn(...classes: (string | boolean | undefined)[]) { + return classes.filter(Boolean).join(" "); +} diff --git a/frontend/application/public/assets/images/regenerated_image_1779109861747.png b/frontend/application/public/assets/images/regenerated_image_1779109861747.png new file mode 100644 index 0000000..a41ae52 Binary files /dev/null and b/frontend/application/public/assets/images/regenerated_image_1779109861747.png differ