chore: save initial uncommitted changes
This commit is contained in:
parent
40ff169dc2
commit
4b69932409
@ -1,5 +1,5 @@
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
# dist
|
||||||
.env
|
.env
|
||||||
.git
|
.git
|
||||||
.dockerignore
|
.dockerignore
|
||||||
|
|||||||
13
Dockerfile
13
Dockerfile
@ -1,14 +1,5 @@
|
|||||||
# Build stage
|
FROM docker.arvancloud.ir/nginxinc/nginx-unprivileged:alpine
|
||||||
FROM node:22-alpine as build
|
COPY dist /usr/share/nginx/html
|
||||||
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
|
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
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
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files
|
# Copy package files
|
||||||
COPY package*.json ./
|
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)
|
# 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 the rest of the application
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Build the application (compiles TypeScript to dist folder)
|
# Build the application (compiles TypeScript to dist folder)
|
||||||
# We also generate prisma client here if prisma schema is present.
|
# We also generate prisma client here if prisma schema is present.
|
||||||
|
RUN npx prisma generate
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Production image
|
# Production image
|
||||||
FROM node:22-alpine
|
FROM docker.arvancloud.ir/library/node:22-alpine
|
||||||
|
|
||||||
|
RUN apk add --no-cache openssl
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy only package files
|
# Copy only package files
|
||||||
COPY package*.json ./
|
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
|
# 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 built artifacts from the builder stage
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { Controller, Get, Res } from '@nestjs/common';
|
import { Controller, Get, Res } from '@nestjs/common';
|
||||||
import { ApiExcludeController } from '@nestjs/swagger';
|
import { ApiExcludeController } from '@nestjs/swagger';
|
||||||
import { PrismaService } from '../../src/prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { Response } from 'express';
|
import * as express from 'express';
|
||||||
|
|
||||||
@ApiExcludeController()
|
@ApiExcludeController()
|
||||||
@Controller('metrics')
|
@Controller('metrics')
|
||||||
@ -15,7 +15,7 @@ export class MetricsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
async getMetrics(@Res() res: Response) {
|
async getMetrics(@Res() res: express.Response) {
|
||||||
const memory = process.memoryUsage();
|
const memory = process.memoryUsage();
|
||||||
const cpu = process.cpuUsage();
|
const cpu = process.cpuUsage();
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"extends": "./tsconfig.json",
|
"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:
|
services:
|
||||||
db:
|
db:
|
||||||
image: postgres:16-alpine
|
image: docker.arvancloud.ir/library/postgres:16-alpine
|
||||||
container_name: canina_db
|
container_name: canina_db
|
||||||
restart: always
|
restart: always
|
||||||
environment:
|
environment:
|
||||||
@ -15,7 +15,7 @@ services:
|
|||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: docker.arvancloud.ir/library/redis:7-alpine
|
||||||
container_name: canina_redis
|
container_name: canina_redis
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
@ -50,7 +50,7 @@ services:
|
|||||||
- backend
|
- backend
|
||||||
|
|
||||||
prometheus:
|
prometheus:
|
||||||
image: prom/prometheus:v2.51.0
|
image: docker.arvancloud.ir/prom/prometheus:v2.51.0
|
||||||
container_name: canina_prometheus
|
container_name: canina_prometheus
|
||||||
restart: always
|
restart: always
|
||||||
volumes:
|
volumes:
|
||||||
@ -61,7 +61,7 @@ services:
|
|||||||
- backend
|
- backend
|
||||||
|
|
||||||
grafana:
|
grafana:
|
||||||
image: grafana/grafana:10.4.1
|
image: docker.arvancloud.ir/grafana/grafana:10.4.1
|
||||||
container_name: canina_grafana
|
container_name: canina_grafana
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@ -154,6 +154,7 @@ export default function AddressModal({ isOpen, onClose, onSave, editingAddress }
|
|||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block pr-2">عنوان آدرس (مثلاً: خانه، محل کار)</label>
|
<label className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block pr-2">عنوان آدرس (مثلاً: خانه، محل کار)</label>
|
||||||
<input
|
<input
|
||||||
|
autoFocus
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.title}
|
value={formData.title}
|
||||||
onChange={e => setFormData({...formData, title: e.target.value})}
|
onChange={e => setFormData({...formData, title: e.target.value})}
|
||||||
|
|||||||
@ -278,6 +278,7 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) {
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Phone className="absolute right-4 top-1/2 -translate-y-1/2 w-5 h-5 text-medical-gray-400" />
|
<Phone className="absolute right-4 top-1/2 -translate-y-1/2 w-5 h-5 text-medical-gray-400" />
|
||||||
<input
|
<input
|
||||||
|
autoFocus
|
||||||
type="tel"
|
type="tel"
|
||||||
maxLength={11}
|
maxLength={11}
|
||||||
placeholder="مثال: ۰۹۱۲۳۴۵۶۷۸۹"
|
placeholder="مثال: ۰۹۱۲۳۴۵۶۷۸۹"
|
||||||
|
|||||||
@ -56,6 +56,8 @@ export default function Header({
|
|||||||
const [isAuthModalOpen, setIsAuthModalOpen] = useState(false);
|
const [isAuthModalOpen, setIsAuthModalOpen] = useState(false);
|
||||||
const [isPetSwitcherOpen, setIsPetSwitcherOpen] = useState(false);
|
const [isPetSwitcherOpen, setIsPetSwitcherOpen] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
|
const [isMobileSolutionsOpen, setIsMobileSolutionsOpen] = useState(false);
|
||||||
|
|
||||||
const petMenuRef = useRef<HTMLDivElement>(null);
|
const petMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@ -88,21 +90,35 @@ export default function Header({
|
|||||||
<AuthModal isOpen={isAuthModalOpen} onClose={() => setIsAuthModalOpen(false)} />
|
<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">
|
<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">
|
<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">
|
||||||
{/* Logo Section */}
|
<div className="flex items-center gap-2">
|
||||||
<div
|
{/* Mobile Menu Toggle */}
|
||||||
className="flex-shrink-0 flex items-center gap-3 cursor-pointer group pr-2"
|
<button
|
||||||
onClick={() => onNavigate("home")}
|
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||||
>
|
className="lg:hidden p-2 rounded-xl text-medical-gray-600 hover:bg-medical-gray-50 transition-all"
|
||||||
<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>
|
aria-label="منو"
|
||||||
<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>
|
{isMobileMenuOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
|
||||||
<span className="text-[10px] text-medical-gray-400 font-bold uppercase tracking-widest leading-none mt-1.5 font-vazir">نماینده رسمی Canina Pharma GmbH آلمان</span>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation Items */}
|
{/* 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
|
<div
|
||||||
className="relative h-full flex items-center group"
|
className="relative h-full flex items-center group"
|
||||||
onMouseEnter={() => setIsMegaMenuOpen(true)}
|
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>
|
<div className="text-[8px] font-black tracking-tight whitespace-nowrap mt-0.5">سبد خرید</div>
|
||||||
</HeaderButton>
|
</HeaderButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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">
|
<div className="relative">
|
||||||
<Phone className="absolute right-4 top-1/2 -translate-y-1/2 w-5 h-5 text-medical-gray-400" />
|
<Phone className="absolute right-4 top-1/2 -translate-y-1/2 w-5 h-5 text-medical-gray-400" />
|
||||||
<input
|
<input
|
||||||
|
autoFocus
|
||||||
type="tel"
|
type="tel"
|
||||||
maxLength={11}
|
maxLength={11}
|
||||||
placeholder="مثال: ۰۹۱۲۳۴۵۶۷۸۹"
|
placeholder="مثال: ۰۹۱۲۳۴۵۶۷۸۹"
|
||||||
|
|||||||
@ -416,6 +416,7 @@ export default function PetProfile({ onProductClick, onBack, initialView, adviso
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-[10px] font-black text-medical-gray-400 uppercase tracking-widest mb-3 pr-2">نام همدم</label>
|
<label className="block text-[10px] font-black text-medical-gray-400 uppercase tracking-widest mb-3 pr-2">نام همدم</label>
|
||||||
<input
|
<input
|
||||||
|
autoFocus
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={e => setFormData({...formData, name: e.target.value})}
|
onChange={e => setFormData({...formData, name: e.target.value})}
|
||||||
@ -1049,6 +1050,7 @@ export default function PetProfile({ onProductClick, onBack, initialView, adviso
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-[10px] font-black text-medical-gray-400 uppercase tracking-widest mb-2 pr-2">عنوان یادآور</label>
|
<label className="block text-[10px] font-black text-medical-gray-400 uppercase tracking-widest mb-2 pr-2">عنوان یادآور</label>
|
||||||
<input
|
<input
|
||||||
|
autoFocus
|
||||||
type="text"
|
type="text"
|
||||||
value={reminderForm.title}
|
value={reminderForm.title}
|
||||||
onChange={e => setReminderForm({...reminderForm, title: e.target.value})}
|
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 { motion, AnimatePresence } from "motion/react";
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
@ -40,7 +40,7 @@ const ICON_MAP: Record<string, any> = {
|
|||||||
Users
|
Users
|
||||||
};
|
};
|
||||||
import { toPersian, cn } from "../lib/utils";
|
import { toPersian, cn } from "../lib/utils";
|
||||||
import { Product } from "../data/products";
|
import { Product, PRODUCTS } from "../data/products";
|
||||||
import { productService } from "../services/productService";
|
import { productService } from "../services/productService";
|
||||||
import { SCIENTIFIC_TERMS } from "../data/scientificTerms";
|
import { SCIENTIFIC_TERMS } from "../data/scientificTerms";
|
||||||
import { useSettingsStore } from "../store/settingsStore";
|
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
|
// Re-hydrate product to ensure methods like calculateDosage exist
|
||||||
const fullProduct = useMemo(() => {
|
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]);
|
}, [product, allProducts]);
|
||||||
|
|
||||||
const syncActivePet = useMemo(() => {
|
useEffect(() => {
|
||||||
if (activePet) {
|
if (activePet) {
|
||||||
setWeight(activePet.weight);
|
setWeight(activePet.weight);
|
||||||
setPetType(activePet.age <= 1 ? "young" : "adult");
|
setPetType(activePet.age <= 1 ? "young" : "adult");
|
||||||
@ -110,10 +130,6 @@ export default function ProductPage({ product, onBack, onProductClick, onWikiNav
|
|||||||
const calculation = useMemo(() => {
|
const calculation = useMemo(() => {
|
||||||
const result = fullProduct.calculateDosage(weight, petType === "young");
|
const result = fullProduct.calculateDosage(weight, petType === "young");
|
||||||
const duration = Math.floor(fullProduct.packageSize / result.quantity);
|
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 {
|
return {
|
||||||
dailyDose: result.quantity,
|
dailyDose: result.quantity,
|
||||||
@ -123,6 +139,12 @@ export default function ProductPage({ product, onBack, onProductClick, onWikiNav
|
|||||||
};
|
};
|
||||||
}, [fullProduct, petType, weight]);
|
}, [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 (
|
return (
|
||||||
<div className="min-h-screen bg-medical-gray-50 pt-2 pb-20 px-4 md:px-0" dir="rtl">
|
<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">
|
<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>
|
<label className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block pr-2">یا مبلغ دلخواه خود را وارد کنید (تومان)</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
|
autoFocus
|
||||||
type="text"
|
type="text"
|
||||||
value={amount ? parseInt(amount).toLocaleString() : ""}
|
value={amount ? parseInt(amount).toLocaleString() : ""}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
|
|||||||
@ -58,12 +58,20 @@ export default function UserDashboard({ onBack, onNavigate }: { onBack: () => vo
|
|||||||
const handleSaveProfile = async (e: React.FormEvent) => {
|
const handleSaveProfile = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!isEditing) return;
|
if (!isEditing) return;
|
||||||
|
|
||||||
|
const cleanPhone = profileForm.mobile.trim();
|
||||||
|
if (!/^09\d{9}$/.test(cleanPhone)) {
|
||||||
|
toast.error("شماره موبایل وارد شده معتبر نیست (باید ۱۱ رقم باشد و با ۰۹ شروع شود)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsSavingProfile(true);
|
setIsSavingProfile(true);
|
||||||
try {
|
try {
|
||||||
await useUserStore.getState().updateProfile({
|
await useUserStore.getState().updateProfile({
|
||||||
firstName: profileForm.firstName,
|
firstName: profileForm.firstName,
|
||||||
lastName: profileForm.lastName,
|
lastName: profileForm.lastName,
|
||||||
email: profileForm.email
|
email: profileForm.email,
|
||||||
|
mobile: cleanPhone
|
||||||
});
|
});
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
toast.success("اطلاعات کاربری با موفقیت ویرایش شد");
|
toast.success("اطلاعات کاربری با موفقیت ویرایش شد");
|
||||||
@ -239,6 +247,7 @@ export default function UserDashboard({ onBack, onNavigate }: { onBack: () => vo
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label htmlFor="firstName" className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block pr-4">نام</label>
|
<label htmlFor="firstName" className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block pr-4">نام</label>
|
||||||
<input
|
<input
|
||||||
|
autoFocus={isEditing}
|
||||||
type="text"
|
type="text"
|
||||||
id="firstName"
|
id="firstName"
|
||||||
name="firstName"
|
name="firstName"
|
||||||
@ -301,10 +310,17 @@ export default function UserDashboard({ onBack, onNavigate }: { onBack: () => vo
|
|||||||
id="mobile"
|
id="mobile"
|
||||||
name="mobile"
|
name="mobile"
|
||||||
autoComplete="tel"
|
autoComplete="tel"
|
||||||
readOnly
|
required
|
||||||
disabled
|
readOnly={!isEditing}
|
||||||
value={toPersian(profile.mobile || "")}
|
disabled={!isEditing}
|
||||||
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"
|
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"
|
dir="ltr"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { persist } from "zustand/middleware";
|
import { persist } from "zustand/middleware";
|
||||||
|
import api from "../services/api";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export interface Reminder {
|
export interface Reminder {
|
||||||
id: string;
|
id: string;
|
||||||
@ -86,41 +88,87 @@ export const usePetStore = create<PetStore>()(
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
activePetId: "lucy",
|
activePetId: "lucy",
|
||||||
addPet: (pet) => {
|
addPet: async (pet) => {
|
||||||
// TODO: [BACKEND_API] POST /api/pets | Payload: Omit<PetProfile, "id"> | Expected: PetProfile | Errors: [400, 401]
|
const { useUserStore } = await import("./userStore");
|
||||||
const id = Math.random().toString(36).substring(7);
|
const isLoggedIn = useUserStore.getState().isLoggedIn;
|
||||||
const newPet = {
|
|
||||||
...pet,
|
if (isLoggedIn) {
|
||||||
id,
|
try {
|
||||||
reminders: pet.reminders || [],
|
await api.post("/pets", {
|
||||||
logs: pet.logs || [],
|
name: pet.name,
|
||||||
consumptions: pet.consumptions || []
|
type: pet.type,
|
||||||
};
|
breed: pet.breed || "",
|
||||||
set((state) => ({
|
weight: Number(pet.weight) || 0
|
||||||
pets: [...state.pets, newPet],
|
});
|
||||||
activePetId: state.activePetId || id,
|
await useUserStore.getState().fetchProfile();
|
||||||
}));
|
} catch (error) {
|
||||||
},
|
console.error("Failed to add pet via backend API:", error);
|
||||||
removePet: (id) => {
|
toast.error("خطا در ثبت پت در سرور");
|
||||||
// TODO: [BACKEND_API] DELETE /api/pets/{id} | Expected: { success: boolean } | Errors: [401, 404]
|
}
|
||||||
set((state) => {
|
} else {
|
||||||
const newPets = state.pets.filter((p) => p.id !== id);
|
const id = Math.random().toString(36).substring(7);
|
||||||
return {
|
const newPet = {
|
||||||
pets: newPets,
|
...pet,
|
||||||
activePetId: state.activePetId === id ? (newPets[0]?.id || null) : state.activePetId,
|
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 }),
|
setActivePet: (id) => set({ activePetId: id }),
|
||||||
getActivePet: () => {
|
getActivePet: () => {
|
||||||
const { pets, activePetId } = get();
|
const { pets, activePetId } = get();
|
||||||
return pets.find((p) => p.id === activePetId) || null;
|
return pets.find((p) => p.id === activePetId) || null;
|
||||||
},
|
},
|
||||||
updatePet: (id, updates) => {
|
updatePet: async (id, updates) => {
|
||||||
// TODO: [BACKEND_API] PATCH /api/pets/{id} | Payload: Partial<PetProfile> | Expected: PetProfile | Errors: [400, 401, 404]
|
const { useUserStore } = await import("./userStore");
|
||||||
set((state) => ({
|
const isLoggedIn = useUserStore.getState().isLoggedIn;
|
||||||
pets: state.pets.map((p) => (p.id === id ? { ...p, ...updates } : p)),
|
|
||||||
}));
|
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) => {
|
addReminder: (petId, reminder) => {
|
||||||
// TODO: [BACKEND_API] POST /api/pets/{petId}/reminders | Payload: Omit<Reminder, "id"> | Expected: Reminder | Errors: [400, 401, 404]
|
// 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