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 */} + +
+
+
C
+ Canina +
+ +
+ + {/* Navigation Links */} + +
+ + )} +
+ + ); + } 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]