diff --git a/.dockerignore b/.dockerignore
index d1afd65..0087b1a 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,5 +1,5 @@
node_modules
-dist
+# dist
.env
.git
.dockerignore
diff --git a/Dockerfile b/Dockerfile
index b3cdb73..585b09f 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,14 +1,5 @@
-# Build stage
-FROM node:22-alpine as build
-WORKDIR /app
-COPY package*.json ./
-RUN npm ci
-COPY . .
-RUN npm run build
-
-# Production stage
-FROM nginxinc/nginx-unprivileged:alpine
-COPY --from=build /app/dist /usr/share/nginx/html
+FROM docker.arvancloud.ir/nginxinc/nginx-unprivileged:alpine
+COPY dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 8080
CMD ["nginx", "-g", "daemon off;"]
diff --git a/backend/Dockerfile b/backend/Dockerfile
index e4d25e2..38fafcb 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -1,30 +1,47 @@
-FROM node:22-alpine AS builder
+FROM docker.arvancloud.ir/library/node:22-alpine AS builder
+
+RUN apk add --no-cache openssl
WORKDIR /app
# Copy package files
COPY package*.json ./
+# Set NPM registry mirror
+RUN npm config set registry https://registry.npmmirror.com
+RUN npm config set fetch-retries 10
+RUN npm config set fetch-retry-mintimeout 20000
+RUN npm config set fetch-retry-maxtimeout 120000
+
# Install all dependencies (including devDependencies)
-RUN npm ci
+RUN for i in 1 2 3 4 5; do npm ci && exit 0 || (echo "Retry $i in 10s..." && sleep 10); done; exit 1
# Copy the rest of the application
COPY . .
# Build the application (compiles TypeScript to dist folder)
# We also generate prisma client here if prisma schema is present.
+RUN npx prisma generate
RUN npm run build
# Production image
-FROM node:22-alpine
+FROM docker.arvancloud.ir/library/node:22-alpine
+
+RUN apk add --no-cache openssl
WORKDIR /app
# Copy only package files
COPY package*.json ./
+# Set NPM registry mirror
+RUN npm config set registry https://registry.npmmirror.com
+RUN npm config set fetch-retries 10
+RUN npm config set fetch-retry-mintimeout 20000
+RUN npm config set fetch-retry-maxtimeout 120000
+
# Install only production dependencies
-RUN npm ci --only=production
+RUN for i in 1 2 3 4 5; do npm ci --only=production && exit 0 || (echo "Retry $i in 10s..." && sleep 10); done; exit 1
# Copy built artifacts from the builder stage
COPY --from=builder /app/dist ./dist
diff --git a/backend/src/common/metrics.controller.ts b/backend/src/common/metrics.controller.ts
index f78141b..9f0f576 100644
--- a/backend/src/common/metrics.controller.ts
+++ b/backend/src/common/metrics.controller.ts
@@ -1,7 +1,7 @@
import { Controller, Get, Res } from '@nestjs/common';
import { ApiExcludeController } from '@nestjs/swagger';
-import { PrismaService } from '../../src/prisma/prisma.service';
-import { Response } from 'express';
+import { PrismaService } from '../prisma/prisma.service';
+import * as express from 'express';
@ApiExcludeController()
@Controller('metrics')
@@ -15,7 +15,7 @@ export class MetricsController {
}
@Get()
- async getMetrics(@Res() res: Response) {
+ async getMetrics(@Res() res: express.Response) {
const memory = process.memoryUsage();
const cpu = process.cpuUsage();
diff --git a/backend/tsconfig.build.json b/backend/tsconfig.build.json
index 64f86c6..2c8f61f 100644
--- a/backend/tsconfig.build.json
+++ b/backend/tsconfig.build.json
@@ -1,4 +1,4 @@
{
"extends": "./tsconfig.json",
- "exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
+ "exclude": ["node_modules", "test", "dist", "**/*spec.ts", "prisma"]
}
diff --git a/docker-compose.yml b/docker-compose.yml
index ec7c12c..4002aa9 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -2,7 +2,7 @@ version: '3.8'
services:
db:
- image: postgres:16-alpine
+ image: docker.arvancloud.ir/library/postgres:16-alpine
container_name: canina_db
restart: always
environment:
@@ -15,7 +15,7 @@ services:
- postgres_data:/var/lib/postgresql/data
redis:
- image: redis:7-alpine
+ image: docker.arvancloud.ir/library/redis:7-alpine
container_name: canina_redis
restart: always
ports:
@@ -50,7 +50,7 @@ services:
- backend
prometheus:
- image: prom/prometheus:v2.51.0
+ image: docker.arvancloud.ir/prom/prometheus:v2.51.0
container_name: canina_prometheus
restart: always
volumes:
@@ -61,7 +61,7 @@ services:
- backend
grafana:
- image: grafana/grafana:10.4.1
+ image: docker.arvancloud.ir/grafana/grafana:10.4.1
container_name: canina_grafana
restart: always
ports:
diff --git a/src/components/AddressModal.tsx b/src/components/AddressModal.tsx
index 651ce85..441ae41 100644
--- a/src/components/AddressModal.tsx
+++ b/src/components/AddressModal.tsx
@@ -154,6 +154,7 @@ export default function AddressModal({ isOpen, onClose, onSave, editingAddress }
setFormData({...formData, title: e.target.value})}
diff --git a/src/components/AuthModal.tsx b/src/components/AuthModal.tsx
index e694174..dc8557c 100644
--- a/src/components/AuthModal.tsx
+++ b/src/components/AuthModal.tsx
@@ -278,6 +278,7 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) {
(null);
@@ -88,21 +90,35 @@ export default function Header({
setIsAuthModalOpen(false)} />
-
- {/* Logo Section */}
-
onNavigate("home")}
- >
-
C
-
-
Canina ایران
-
نماینده رسمی Canina Pharma GmbH آلمان
+
+
+ {/* Mobile Menu Toggle */}
+
+
+ {/* Logo Section */}
+
{
+ onNavigate("home");
+ setIsMobileMenuOpen(false);
+ }}
+ >
+
C
+
+ Canina ایران
+ نماینده رسمی Canina Pharma GmbH آلمان
+
{/* Navigation Items */}
-
-
-
- >
- );
-}
+
+
+
+
+
+ {/* Mobile Drawer Menu */}
+
+ {isMobileMenuOpen && (
+ <>
+ {/* Backdrop */}
+ setIsMobileMenuOpen(false)}
+ className="fixed inset-0 bg-black z-40 lg:hidden"
+ />
+ {/* Drawer */}
+
+
+
+
+
+
+ {/* Navigation Links */}
+
+ {/* Treatment Solutions accordion */}
+
+
+
+ {isMobileSolutionsOpen && (
+
+ {MENU_ITEMS.map((item, idx) => (
+
+
{
+ onShopNavigate(item.id);
+ setIsMobileMenuOpen(false);
+ }}
+ >
+ {item.icon}
+ {item.title}
+
+
+ {item.solutions.map((sol, sIdx) => (
+ - {
+ e.stopPropagation();
+ onShopNavigate(item.id, sol);
+ setIsMobileMenuOpen(false);
+ }}
+ >
+ {sol}
+
+ ))}
+
+
+ ))}
+
+ )}
+
+
+
+ {[
+ { id: 'shop', label: 'محصولات تخصصی', action: () => onShopNavigate() },
+ { id: 'wiki', label: 'دانشنامه علمی', action: () => onNavigate('wiki') },
+ { id: 'blog', label: 'مجله سلامت پت', action: () => onNavigate('blog') },
+ { id: 'profile', label: 'شناسنامه پتها', action: () => onNavigate({ view: 'profile', subview: 'index' }) }
+ ].map((item) => (
+
+ ))}
+
+
+ >
+ )}
+
+ >
+ );
+ }
diff --git a/src/components/LoginModal.tsx b/src/components/LoginModal.tsx
index 89a6413..cd6b6f7 100644
--- a/src/components/LoginModal.tsx
+++ b/src/components/LoginModal.tsx
@@ -179,6 +179,7 @@ export default function LoginModal({ isOpen, onClose, onLogin, petName, isAdviso
setFormData({...formData, name: e.target.value})}
@@ -1049,6 +1050,7 @@ export default function PetProfile({ onProductClick, onBack, initialView, adviso
setReminderForm({...reminderForm, title: e.target.value})}
diff --git a/src/components/ProductPage.tsx b/src/components/ProductPage.tsx
index 8571fed..597f3ef 100644
--- a/src/components/ProductPage.tsx
+++ b/src/components/ProductPage.tsx
@@ -1,4 +1,4 @@
-import { useState, useMemo } from "react";
+import { useState, useMemo, useEffect } from "react";
import { motion, AnimatePresence } from "motion/react";
import {
ArrowRight,
@@ -40,7 +40,7 @@ const ICON_MAP: Record
= {
Users
};
import { toPersian, cn } from "../lib/utils";
-import { Product } from "../data/products";
+import { Product, PRODUCTS } from "../data/products";
import { productService } from "../services/productService";
import { SCIENTIFIC_TERMS } from "../data/scientificTerms";
import { useSettingsStore } from "../store/settingsStore";
@@ -97,10 +97,30 @@ export default function ProductPage({ product, onBack, onProductClick, onWikiNav
// Re-hydrate product to ensure methods like calculateDosage exist
const fullProduct = useMemo(() => {
- return allProducts.find(p => p.id === product.id) || product;
+ const apiProduct = allProducts.find(p => p.id === product.id) || product;
+ const staticProduct = PRODUCTS.find(p => p.id === apiProduct.id || p.artNo === apiProduct.artNo);
+ if (staticProduct) {
+ return {
+ ...apiProduct,
+ calculateDosage: staticProduct.calculateDosage
+ };
+ }
+ if (typeof apiProduct.calculateDosage !== 'function') {
+ return {
+ ...apiProduct,
+ calculateDosage: (w: number, y: boolean) => {
+ return {
+ quantity: 1,
+ unit: apiProduct.unit || "قرص",
+ description: "مصرف روزانه بر اساس دستور پزشک"
+ };
+ }
+ };
+ }
+ return apiProduct;
}, [product, allProducts]);
- const syncActivePet = useMemo(() => {
+ useEffect(() => {
if (activePet) {
setWeight(activePet.weight);
setPetType(activePet.age <= 1 ? "young" : "adult");
@@ -110,10 +130,6 @@ export default function ProductPage({ product, onBack, onProductClick, onWikiNav
const calculation = useMemo(() => {
const result = fullProduct.calculateDosage(weight, petType === "young");
const duration = Math.floor(fullProduct.packageSize / result.quantity);
-
- // Smart quantity suggestion: if duration is less than 30 days, suggest 2 packs
- const suggestedQty = (duration < 30 && duration > 0) ? 2 : 1;
- setItemQuantity(suggestedQty);
return {
dailyDose: result.quantity,
@@ -123,6 +139,12 @@ export default function ProductPage({ product, onBack, onProductClick, onWikiNav
};
}, [fullProduct, petType, weight]);
+ // Suggest quantity inside useEffect to avoid render-phase state update
+ useEffect(() => {
+ const suggestedQty = (calculation.duration < 30 && calculation.duration > 0) ? 2 : 1;
+ setItemQuantity(suggestedQty);
+ }, [calculation.duration]);
+
return (
diff --git a/src/components/TopUpModal.tsx b/src/components/TopUpModal.tsx
index 38a9ad0..d8c82aa 100644
--- a/src/components/TopUpModal.tsx
+++ b/src/components/TopUpModal.tsx
@@ -113,6 +113,7 @@ export default function TopUpModal({ isOpen, onClose, onConfirm }: TopUpModalPro
vo
const handleSaveProfile = async (e: React.FormEvent) => {
e.preventDefault();
if (!isEditing) return;
+
+ const cleanPhone = profileForm.mobile.trim();
+ if (!/^09\d{9}$/.test(cleanPhone)) {
+ toast.error("شماره موبایل وارد شده معتبر نیست (باید ۱۱ رقم باشد و با ۰۹ شروع شود)");
+ return;
+ }
+
setIsSavingProfile(true);
try {
await useUserStore.getState().updateProfile({
firstName: profileForm.firstName,
lastName: profileForm.lastName,
- email: profileForm.email
+ email: profileForm.email,
+ mobile: cleanPhone
});
setIsEditing(false);
toast.success("اطلاعات کاربری با موفقیت ویرایش شد");
@@ -239,6 +247,7 @@ export default function UserDashboard({ onBack, onNavigate }: { onBack: () => vo
vo
id="mobile"
name="mobile"
autoComplete="tel"
- readOnly
- disabled
- value={toPersian(profile.mobile || "")}
- className="w-full bg-medical-gray-50 border border-medical-gray-100 p-4 rounded-2xl font-bold outline-none text-left opacity-75 cursor-not-allowed"
+ required
+ readOnly={!isEditing}
+ disabled={!isEditing}
+ value={isEditing ? profileForm.mobile : toPersian(profile.mobile || "")}
+ onChange={e => setProfileForm({ ...profileForm, mobile: e.target.value.replace(/[^0-9]/g, '') })}
+ className={cn(
+ "w-full border p-4 rounded-2xl font-bold outline-none transition-all text-left",
+ isEditing
+ ? "bg-white border-canina-blue/30 focus:ring-2 focus:ring-canina-blue/20"
+ : "bg-medical-gray-50 border-medical-gray-100 opacity-75 cursor-not-allowed"
+ )}
dir="ltr"
/>
diff --git a/src/store/usePetStore.ts b/src/store/usePetStore.ts
index a9dc3e8..f40ff63 100644
--- a/src/store/usePetStore.ts
+++ b/src/store/usePetStore.ts
@@ -1,5 +1,7 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
+import api from "../services/api";
+import { toast } from "sonner";
export interface Reminder {
id: string;
@@ -86,41 +88,87 @@ export const usePetStore = create
()(
}
],
activePetId: "lucy",
- addPet: (pet) => {
- // TODO: [BACKEND_API] POST /api/pets | Payload: Omit | Expected: PetProfile | Errors: [400, 401]
- 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: (id) => {
- // TODO: [BACKEND_API] DELETE /api/pets/{id} | Expected: { success: boolean } | Errors: [401, 404]
- set((state) => {
- const newPets = state.pets.filter((p) => p.id !== id);
- return {
- pets: newPets,
- activePetId: state.activePetId === id ? (newPets[0]?.id || null) : state.activePetId,
+ 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: (id, updates) => {
- // TODO: [BACKEND_API] PATCH /api/pets/{id} | Payload: Partial | Expected: PetProfile | Errors: [400, 401, 404]
- set((state) => ({
- pets: state.pets.map((p) => (p.id === id ? { ...p, ...updates } : p)),
- }));
+ 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: (petId, reminder) => {
// TODO: [BACKEND_API] POST /api/pets/{petId}/reminders | Payload: Omit | Expected: Reminder | Errors: [400, 401, 404]