chore: save initial uncommitted changes
This commit is contained in:
parent
40ff169dc2
commit
4b69932409
@ -1,5 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
# dist
|
||||
.env
|
||||
.git
|
||||
.dockerignore
|
||||
|
||||
13
Dockerfile
13
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;"]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts", "prisma"]
|
||||
}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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})}
|
||||
|
||||
@ -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="مثال: ۰۹۱۲۳۴۵۶۷۸۹"
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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="مثال: ۰۹۱۲۳۴۵۶۷۸۹"
|
||||
|
||||
@ -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})}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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]
|
||||
|
||||
Loading…
Reference in New Issue
Block a user