chore: save initial uncommitted changes

This commit is contained in:
پارسا آقایی 2026-06-12 00:21:03 +03:30
parent 40ff169dc2
commit 4b69932409
15 changed files with 313 additions and 83 deletions

View File

@ -1,5 +1,5 @@
node_modules
dist
# dist
.env
.git
.dockerignore

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
"exclude": ["node_modules", "test", "dist", "**/*spec.ts", "prisma"]
}

View File

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

View File

@ -154,6 +154,7 @@ export default function AddressModal({ isOpen, onClose, onSave, editingAddress }
<div className="space-y-1">
<label className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block pr-2">عنوان آدرس (مثلاً: خانه، محل کار)</label>
<input
autoFocus
type="text"
value={formData.title}
onChange={e => setFormData({...formData, title: e.target.value})}

View File

@ -278,6 +278,7 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) {
<div className="relative">
<Phone className="absolute right-4 top-1/2 -translate-y-1/2 w-5 h-5 text-medical-gray-400" />
<input
autoFocus
type="tel"
maxLength={11}
placeholder="مثال: ۰۹۱۲۳۴۵۶۷۸۹"

View File

@ -56,6 +56,8 @@ export default function Header({
const [isAuthModalOpen, setIsAuthModalOpen] = useState(false);
const [isPetSwitcherOpen, setIsPetSwitcherOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const [isMobileSolutionsOpen, setIsMobileSolutionsOpen] = useState(false);
const petMenuRef = useRef<HTMLDivElement>(null);
@ -88,21 +90,35 @@ export default function Header({
<AuthModal isOpen={isAuthModalOpen} onClose={() => setIsAuthModalOpen(false)} />
<header className="sticky top-0 z-50 bg-white/95 backdrop-blur-md border-b border-medical-gray-100 shadow-sm">
<div className="max-w-7xl h-24 flex items-center justify-between gap-8" dir="rtl">
{/* Logo Section */}
<div
className="flex-shrink-0 flex items-center gap-3 cursor-pointer group pr-2"
onClick={() => onNavigate("home")}
>
<div className="w-12 h-12 bg-canina-blue rounded-2xl flex items-center justify-center text-white font-bold text-2xl group-hover:bg-medical-gray-900 transition-all shadow-xl shadow-canina-blue/20 italic">C</div>
<div className="flex flex-col">
<span className="text-canina-blue font-black text-2xl tracking-tighter leading-none italic font-inter">Canina <span className="text-sm not-italic font-medium border-l-2 border-medical-gray-100 pl-2 ml-2 font-vazir">ایران</span></span>
<span className="text-[10px] text-medical-gray-400 font-bold uppercase tracking-widest leading-none mt-1.5 font-vazir">نماینده رسمی Canina Pharma GmbH آلمان</span>
<div className="max-w-7xl mx-auto h-24 flex items-center justify-between gap-4 px-4 sm:px-6 lg:px-8" dir="rtl">
<div className="flex items-center gap-2">
{/* Mobile Menu Toggle */}
<button
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
className="lg:hidden p-2 rounded-xl text-medical-gray-600 hover:bg-medical-gray-50 transition-all"
aria-label="منو"
>
{isMobileMenuOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
</button>
{/* Logo Section */}
<div
className="flex-shrink-0 flex items-center gap-3 cursor-pointer group"
onClick={() => {
onNavigate("home");
setIsMobileMenuOpen(false);
}}
>
<div className="w-12 h-12 bg-canina-blue rounded-2xl flex items-center justify-center text-white font-bold text-2xl group-hover:bg-medical-gray-900 transition-all shadow-xl shadow-canina-blue/20 italic">C</div>
<div className="flex flex-col">
<span className="text-canina-blue font-black text-2xl tracking-tighter leading-none italic font-inter">Canina <span className="text-sm not-italic font-medium border-l-2 border-medical-gray-100 pl-2 ml-2 font-vazir">ایران</span></span>
<span className="text-[10px] text-medical-gray-400 font-bold uppercase tracking-widest leading-none mt-1.5 font-vazir">نماینده رسمی Canina Pharma GmbH آلمان</span>
</div>
</div>
</div>
{/* Navigation Items */}
<nav className="hidden xl:flex items-center gap-1 flex-grow justify-center h-full">
<nav className="hidden lg:flex items-center gap-1 flex-grow justify-center h-full">
<div
className="relative h-full flex items-center group"
onMouseEnter={() => setIsMegaMenuOpen(true)}
@ -354,10 +370,124 @@ export default function Header({
>
<div className="text-[8px] font-black tracking-tight whitespace-nowrap mt-0.5">سبد خرید</div>
</HeaderButton>
</div>
</div>
</div>
</header>
</>
);
}
</div>
</div>
</div>
</header>
{/* Mobile Drawer Menu */}
<AnimatePresence>
{isMobileMenuOpen && (
<>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 0.5 }}
exit={{ opacity: 0 }}
onClick={() => setIsMobileMenuOpen(false)}
className="fixed inset-0 bg-black z-40 lg:hidden"
/>
{/* Drawer */}
<motion.div
initial={{ x: "100%" }}
animate={{ x: 0 }}
exit={{ x: "100%" }}
transition={{ type: "spring", damping: 25, stiffness: 200 }}
className="fixed top-0 right-0 bottom-0 w-80 max-w-[85vw] bg-white z-50 shadow-2xl p-6 flex flex-col gap-6 overflow-y-auto lg:hidden"
dir="rtl"
>
<div className="flex items-center justify-between border-b border-medical-gray-100 pb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-canina-blue rounded-xl flex items-center justify-center text-white font-bold text-xl italic">C</div>
<span className="text-canina-blue font-black text-xl italic font-inter">Canina</span>
</div>
<button
onClick={() => setIsMobileMenuOpen(false)}
className="p-2 rounded-xl hover:bg-medical-gray-50 text-medical-gray-600 transition-all"
>
<X className="w-6 h-6" />
</button>
</div>
{/* Navigation Links */}
<nav className="flex flex-col gap-2">
{/* Treatment Solutions accordion */}
<div className="flex flex-col">
<button
onClick={() => setIsMobileSolutionsOpen(!isMobileSolutionsOpen)}
className="w-full flex items-center justify-between px-4 py-3 rounded-xl text-sm font-black text-medical-gray-600 hover:bg-medical-gray-50 transition-all font-vazir"
>
<span>راهکارهای درمانی</span>
<ChevronDown className={`w-4 h-4 transition-transform duration-300 ${isMobileSolutionsOpen ? 'rotate-180' : ''}`} />
</button>
<AnimatePresence>
{isMobileSolutionsOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden mr-4 pr-2 border-r-2 border-medical-gray-100 mt-1 space-y-3"
>
{MENU_ITEMS.map((item, idx) => (
<div key={idx} className="py-1">
<div
className="text-[13px] font-bold text-medical-gray-800 font-vazir hover:text-canina-blue cursor-pointer flex items-center gap-2"
onClick={() => {
onShopNavigate(item.id);
setIsMobileMenuOpen(false);
}}
>
<span className="text-canina-blue/80">{item.icon}</span>
<span>{item.title}</span>
</div>
<ul className="mt-1 space-y-1 mr-6">
{item.solutions.map((sol, sIdx) => (
<li
key={sIdx}
className="text-[11px] text-medical-gray-500 hover:text-canina-blue cursor-pointer py-1 font-vazir"
onClick={(e) => {
e.stopPropagation();
onShopNavigate(item.id, sol);
setIsMobileMenuOpen(false);
}}
>
{sol}
</li>
))}
</ul>
</div>
))}
</motion.div>
)}
</AnimatePresence>
</div>
{[
{ id: 'shop', label: 'محصولات تخصصی', action: () => onShopNavigate() },
{ id: 'wiki', label: 'دانشنامه علمی', action: () => onNavigate('wiki') },
{ id: 'blog', label: 'مجله سلامت پت', action: () => onNavigate('blog') },
{ id: 'profile', label: 'شناسنامه پتها', action: () => onNavigate({ view: 'profile', subview: 'index' }) }
].map((item) => (
<button
key={item.id}
onClick={() => {
item.action();
setIsMobileMenuOpen(false);
}}
className={`w-full text-right px-4 py-3 rounded-xl text-sm font-black transition-all font-vazir ${
currentView === item.id
? 'bg-canina-blue/5 text-canina-blue'
: 'text-medical-gray-600 hover:bg-medical-gray-50'
}`}
>
{item.label}
</button>
))}
</nav>
</motion.div>
</>
)}
</AnimatePresence>
</>
);
}

View File

@ -179,6 +179,7 @@ export default function LoginModal({ isOpen, onClose, onLogin, petName, isAdviso
<div className="relative">
<Phone className="absolute right-4 top-1/2 -translate-y-1/2 w-5 h-5 text-medical-gray-400" />
<input
autoFocus
type="tel"
maxLength={11}
placeholder="مثال: ۰۹۱۲۳۴۵۶۷۸۹"

View File

@ -416,6 +416,7 @@ export default function PetProfile({ onProductClick, onBack, initialView, adviso
<div>
<label className="block text-[10px] font-black text-medical-gray-400 uppercase tracking-widest mb-3 pr-2">نام همدم</label>
<input
autoFocus
type="text"
value={formData.name}
onChange={e => setFormData({...formData, name: e.target.value})}
@ -1049,6 +1050,7 @@ export default function PetProfile({ onProductClick, onBack, initialView, adviso
<div>
<label className="block text-[10px] font-black text-medical-gray-400 uppercase tracking-widest mb-2 pr-2">عنوان یادآور</label>
<input
autoFocus
type="text"
value={reminderForm.title}
onChange={e => setReminderForm({...reminderForm, title: e.target.value})}

View File

@ -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<string, any> = {
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 (
<div className="min-h-screen bg-medical-gray-50 pt-2 pb-20 px-4 md:px-0" dir="rtl">
<div className="max-w-7xl mx-auto">

View File

@ -113,6 +113,7 @@ export default function TopUpModal({ isOpen, onClose, onConfirm }: TopUpModalPro
<label className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block pr-2">یا مبلغ دلخواه خود را وارد کنید (تومان)</label>
<div className="relative">
<input
autoFocus
type="text"
value={amount ? parseInt(amount).toLocaleString() : ""}
onChange={handleInputChange}

View File

@ -58,12 +58,20 @@ export default function UserDashboard({ onBack, onNavigate }: { onBack: () => 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
<div className="space-y-2">
<label htmlFor="firstName" className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block pr-4">نام</label>
<input
autoFocus={isEditing}
type="text"
id="firstName"
name="firstName"
@ -301,10 +310,17 @@ export default function UserDashboard({ onBack, onNavigate }: { onBack: () => 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"
/>
</div>

View File

@ -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<PetStore>()(
}
],
activePetId: "lucy",
addPet: (pet) => {
// TODO: [BACKEND_API] POST /api/pets | Payload: Omit<PetProfile, "id"> | 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<PetProfile> | 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<Reminder, "id"> | Expected: Reminder | Errors: [400, 401, 404]