feat: nextjs setup and configuration (lib, store, css, assets)

This commit is contained in:
پارسا آقایی 2026-06-12 01:00:54 +03:30
parent eff6c00a9c
commit c5a1b8e5d2
17 changed files with 1933 additions and 17 deletions

View File

@ -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);
}

View File

@ -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<string, string>;
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"
}
];

View File

@ -0,0 +1,43 @@
export interface ScientificTerm {
term: string;
definition: string;
wikiId?: string;
}
export const SCIENTIFIC_TERMS: Record<string, ScientificTerm> = {
"گلیکوزآمینوگلیکان": {
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"
}
};

View File

@ -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;
}

View File

@ -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;

View File

@ -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<AuthResponse> {
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<User> {
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<User>): Promise<User> {
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();

View File

@ -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<Order> {
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<Order[]> {
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<Order> {
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();

View File

@ -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<Product[]> {
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<Product | null> {
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<Product[]> {
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();

View File

@ -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);
});
});

View File

@ -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);
});
});

View File

@ -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();
});
});

View File

@ -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<boolean>;
addOrder: (order: Omit<Order, 'id' | 'date' | 'status' | 'trackingNumber'>) => Promise<string>;
clearCart: () => void;
removeCoupon: () => void;
setOrders: (orders: any[]) => void;
getTotalItems: () => number;
getSubtotal: () => number;
getDiscount: () => number;
getTotal: () => number;
}
export const useCartStore = create<CartStore>()(
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" }
)
);

View File

@ -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<string, string>;
scientificTerms: Record<string, ScientificTerm>;
isLoading: boolean;
fetchSettings: () => Promise<void>;
getText: (key: string, fallback: string) => string;
}
export const useSettingsStore = create<SettingsStore>()((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<string, string> = {};
if (Array.isArray(textsRes.data)) {
textsRes.data.forEach((item: { key: string; value: string }) => {
textsObj[item.key] = item.value;
});
}
const termsObj: Record<string, ScientificTerm> = {};
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;
}
}));

View File

@ -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<PetProfile, "id">) => void;
removePet: (id: string) => void;
setActivePet: (id: string) => void;
getActivePet: () => PetProfile | null;
updatePet: (id: string, updates: Partial<PetProfile>) => void;
addReminder: (petId: string, reminder: Omit<Reminder, "id" | "completedDates">) => Promise<void>;
toggleReminder: (petId: string, reminderId: string, date: string) => Promise<void>;
addHealthLog: (petId: string, log: Omit<HealthLog, "id" | "date">) => Promise<void>;
setPets: (pets: any[]) => void;
}
export const usePetStore = create<PetStore>()(
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" }
)
);

View File

@ -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<UserProfile>) => Promise<void>;
addAddress: (address: Address) => Promise<void>;
updateAddress: (id: string, address: Address) => Promise<void>;
deleteAddress: (id: string) => Promise<void>;
setDefaultAddress: (id: string) => Promise<void>;
topUpWallet: (amount: number) => Promise<void>;
logout: () => void;
fetchProfile: () => Promise<void>;
}
export const useUserStore = create<UserStore>()(
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" }
)
);

View File

@ -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(" ");
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB