fix(lint&types): resolve all lint warnings, typecheck errors and effect state updates across frontend application and admin panel
This commit is contained in:
parent
920dd532e8
commit
c1b2f416ed
@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { X, Upload, Image as ImageIcon, Trash2, CheckCircle2, Clipboard } from 'lucide-react';
|
import { X, Upload, Image as ImageIcon, Trash2, CheckCircle2, Clipboard } from 'lucide-react';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
import api, { BASE_DOMAIN } from '../../services/api';
|
import api, { BASE_DOMAIN } from '../../services/api';
|
||||||
@ -190,7 +190,7 @@ export default function MediaSelector({ isOpen, onClose, onSelect, multiple = fa
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-gray-900/60 backdrop-blur-sm transition-opacity">
|
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-gray-900/60 backdrop-blur-sm transition-opacity">
|
||||||
<div
|
<div
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
@ -202,7 +202,7 @@ export default function MediaSelector({ isOpen, onClose, onSelect, multiple = fa
|
|||||||
<p className="text-xl font-bold">تصویر را اینجا رها کنید تا آپلود شود</p>
|
<p className="text-xl font-bold">تصویر را اینجا رها کنید تا آپلود شود</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between bg-gray-50/50">
|
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between bg-gray-50/50">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@ -234,14 +234,14 @@ export default function MediaSelector({ isOpen, onClose, onSelect, multiple = fa
|
|||||||
<Clipboard className="w-3.5 h-3.5" />
|
<Clipboard className="w-3.5 h-3.5" />
|
||||||
Ctrl+V برای چسباندن تصویر
|
Ctrl+V برای چسباندن تصویر
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
onChange={handleFileUpload}
|
onChange={handleFileUpload}
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
disabled={isUploading}
|
disabled={isUploading}
|
||||||
className="bg-purple-600 hover:bg-purple-700 disabled:opacity-70 text-white px-4 py-2 rounded-xl flex items-center gap-2 text-sm font-bold transition-colors"
|
className="bg-purple-600 hover:bg-purple-700 disabled:opacity-70 text-white px-4 py-2 rounded-xl flex items-center gap-2 text-sm font-bold transition-colors"
|
||||||
@ -273,7 +273,7 @@ export default function MediaSelector({ isOpen, onClose, onSelect, multiple = fa
|
|||||||
const imgUrl = media.url.startsWith('http') ? media.url : `${BASE_DOMAIN}${media.url}`;
|
const imgUrl = media.url.startsWith('http') ? media.url : `${BASE_DOMAIN}${media.url}`;
|
||||||
const isSelected = selectedUrl && imgUrl === selectedUrl;
|
const isSelected = selectedUrl && imgUrl === selectedUrl;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={media.id}
|
key={media.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onSelect(imgUrl);
|
onSelect(imgUrl);
|
||||||
@ -282,19 +282,19 @@ export default function MediaSelector({ isOpen, onClose, onSelect, multiple = fa
|
|||||||
className={`group relative bg-gray-100 rounded-xl overflow-hidden border-2 cursor-pointer transition-all hover:shadow-lg hover:shadow-purple-100 ${isSelected ? 'border-purple-600 ring-2 ring-purple-300' : 'border-transparent hover:border-purple-500'}`}
|
className={`group relative bg-gray-100 rounded-xl overflow-hidden border-2 cursor-pointer transition-all hover:shadow-lg hover:shadow-purple-100 ${isSelected ? 'border-purple-600 ring-2 ring-purple-300' : 'border-transparent hover:border-purple-500'}`}
|
||||||
>
|
>
|
||||||
<div className="aspect-square bg-white p-2 flex items-center justify-center border-b border-gray-100">
|
<div className="aspect-square bg-white p-2 flex items-center justify-center border-b border-gray-100">
|
||||||
<img
|
<img
|
||||||
src={imgUrl}
|
src={imgUrl}
|
||||||
alt={media.filename}
|
alt={media.filename}
|
||||||
className="max-w-full max-h-full object-contain object-center"
|
className="max-w-full max-h-full object-contain object-center"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-3">
|
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); setDeleteTargetId(media.id); }}
|
onClick={(e) => { e.stopPropagation(); setDeleteTargetId(media.id); }}
|
||||||
className="w-8 h-8 rounded-full bg-red-500 text-white flex items-center justify-center hover:bg-red-600 transform hover:scale-110 transition-transform"
|
className="w-8 h-8 rounded-full bg-red-500 text-white flex items-center justify-center hover:bg-red-600 transform hover:scale-110 transition-transform"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="w-8 h-8 rounded-full bg-purple-500 text-white flex items-center justify-center hover:bg-purple-600 transform hover:scale-110 transition-transform"
|
className="w-8 h-8 rounded-full bg-purple-500 text-white flex items-center justify-center hover:bg-purple-600 transform hover:scale-110 transition-transform"
|
||||||
>
|
>
|
||||||
<CheckCircle2 className="w-4 h-4" />
|
<CheckCircle2 className="w-4 h-4" />
|
||||||
@ -323,7 +323,7 @@ export default function MediaSelector({ isOpen, onClose, onSelect, multiple = fa
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
@ -336,3 +336,4 @@ export default function MediaSelector({ isOpen, onClose, onSelect, multiple = fa
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,10 @@ import MediaSelector from '../components/ui/MediaSelector';
|
|||||||
import ConfirmModal from '../components/ui/ConfirmModal';
|
import ConfirmModal from '../components/ui/ConfirmModal';
|
||||||
|
|
||||||
export interface BlogPost {
|
export interface BlogPost {
|
||||||
|
author?: {
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
};
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
@ -130,7 +134,7 @@ export default function Blogs() {
|
|||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-500 font-medium mt-1">مدیریت محتوای آموزشی و مقالات علمی سایت</p>
|
<p className="text-gray-500 font-medium mt-1">مدیریت محتوای آموزشی و مقالات علمی سایت</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => openModal()}
|
onClick={() => openModal()}
|
||||||
className="bg-purple-600 hover:bg-purple-700 text-white px-5 py-2.5 rounded-xl flex items-center gap-2 font-bold transition-all shadow-md shadow-purple-200"
|
className="bg-purple-600 hover:bg-purple-700 text-white px-5 py-2.5 rounded-xl flex items-center gap-2 font-bold transition-all shadow-md shadow-purple-200"
|
||||||
>
|
>
|
||||||
@ -142,9 +146,9 @@ export default function Blogs() {
|
|||||||
<div className="bg-white p-4 rounded-2xl shadow-sm border border-gray-200 flex flex-col sm:flex-row gap-4">
|
<div className="bg-white p-4 rounded-2xl shadow-sm border border-gray-200 flex flex-col sm:flex-row gap-4">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search className="w-5 h-5 absolute right-4 top-1/2 -translate-y-1/2 text-gray-400" />
|
<Search className="w-5 h-5 absolute right-4 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="جستجو در مقالات..."
|
placeholder="جستجو در مقالات..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
||||||
className="w-full pl-4 pr-12 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none transition-all"
|
className="w-full pl-4 pr-12 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none transition-all"
|
||||||
@ -185,9 +189,9 @@ export default function Blogs() {
|
|||||||
<td className="py-4 px-6 text-gray-500 text-sm">{(blog.author?.firstName && blog.author?.lastName) ? `${blog.author.firstName} ${blog.author.lastName}` : 'نامشخص'}</td>
|
<td className="py-4 px-6 text-gray-500 text-sm">{(blog.author?.firstName && blog.author?.lastName) ? `${blog.author.firstName} ${blog.author.lastName}` : 'نامشخص'}</td>
|
||||||
<td className="py-4 px-6">
|
<td className="py-4 px-6">
|
||||||
{blog.isPublished ? (
|
{blog.isPublished ? (
|
||||||
<span className="flex items-center gap-1 text-green-600 text-sm font-bold"><CheckCircle2 className="w-4 h-4"/>منتشر شده</span>
|
<span className="flex items-center gap-1 text-green-600 text-sm font-bold"><CheckCircle2 className="w-4 h-4" />منتشر شده</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="flex items-center gap-1 text-orange-600 text-sm font-bold"><XCircle className="w-4 h-4"/>پیشنویس</span>
|
<span className="flex items-center gap-1 text-orange-600 text-sm font-bold"><XCircle className="w-4 h-4" />پیشنویس</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-4 px-6">
|
<td className="py-4 px-6">
|
||||||
@ -220,23 +224,23 @@ export default function Blogs() {
|
|||||||
<XCircle className="w-6 h-6" />
|
<XCircle className="w-6 h-6" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6 overflow-y-auto flex-1">
|
<div className="p-6 overflow-y-auto flex-1">
|
||||||
<form id="blogForm" onSubmit={handleSave} className="space-y-6">
|
<form id="blogForm" onSubmit={handleSave} className="space-y-6">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-bold text-gray-700">عنوان مقاله *</label>
|
<label className="text-sm font-bold text-gray-700">عنوان مقاله *</label>
|
||||||
<input required type="text" value={formData.title} onChange={(e) => setFormData({...formData, title: e.target.value})} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none" />
|
<input required type="text" value={formData.title} onChange={(e) => setFormData({ ...formData, title: e.target.value })} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-bold text-gray-700">اسلاگ (Slug) *</label>
|
<label className="text-sm font-bold text-gray-700">اسلاگ (Slug) *</label>
|
||||||
<input required type="text" value={formData.slug} dir="ltr" onChange={(e) => setFormData({...formData, slug: e.target.value})} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none font-mono text-sm" />
|
<input required type="text" value={formData.slug} dir="ltr" onChange={(e) => setFormData({ ...formData, slug: e.target.value })} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none font-mono text-sm" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-bold text-gray-700">محتوای اصلی مقاله *</label>
|
<label className="text-sm font-bold text-gray-700">محتوای اصلی مقاله *</label>
|
||||||
<textarea required value={formData.content} onChange={(e) => setFormData({...formData, content: e.target.value})} rows={10} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none font-mono text-sm" dir="rtl" placeholder="محتوای مقاله (متن ساده یا HTML)"></textarea>
|
<textarea required value={formData.content} onChange={(e) => setFormData({ ...formData, content: e.target.value })} rows={10} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none font-mono text-sm" dir="rtl" placeholder="محتوای مقاله (متن ساده یا HTML)"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
@ -253,31 +257,31 @@ export default function Blogs() {
|
|||||||
<button type="button" onClick={() => setIsMediaSelectorOpen(true)} className="px-4 py-2 border-2 border-dashed border-gray-300 rounded-xl text-gray-600 font-bold hover:border-purple-500 hover:text-purple-600">انتخاب از گالری</button>
|
<button type="button" onClick={() => setIsMediaSelectorOpen(true)} className="px-4 py-2 border-2 border-dashed border-gray-300 rounded-xl text-gray-600 font-bold hover:border-purple-500 hover:text-purple-600">انتخاب از گالری</button>
|
||||||
</div>
|
</div>
|
||||||
<label className="flex items-center gap-3 cursor-pointer p-4 border border-gray-200 rounded-xl hover:bg-gray-50 transition-colors">
|
<label className="flex items-center gap-3 cursor-pointer p-4 border border-gray-200 rounded-xl hover:bg-gray-50 transition-colors">
|
||||||
<input type="checkbox" checked={formData.isPublished} onChange={(e) => setFormData({...formData, isPublished: e.target.checked})} className="w-5 h-5 text-purple-600 rounded focus:ring-purple-500" />
|
<input type="checkbox" checked={formData.isPublished} onChange={(e) => setFormData({ ...formData, isPublished: e.target.checked })} className="w-5 h-5 text-purple-600 rounded focus:ring-purple-500" />
|
||||||
<span className="font-bold text-gray-700">انتشار فوری در سایت</span>
|
<span className="font-bold text-gray-700">انتشار فوری در سایت</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4 border border-gray-200 p-4 rounded-xl bg-gray-50/50">
|
<div className="space-y-4 border border-gray-200 p-4 rounded-xl bg-gray-50/50">
|
||||||
<h4 className="font-bold text-purple-700 text-sm mb-2">تنظیمات سئو (SEO)</h4>
|
<h4 className="font-bold text-purple-700 text-sm mb-2">تنظیمات سئو (SEO)</h4>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-bold text-gray-700">عنوان متا</label>
|
<label className="text-xs font-bold text-gray-700">عنوان متا</label>
|
||||||
<input type="text" value={formData.metaTitle} onChange={(e) => setFormData({...formData, metaTitle: e.target.value})} className="w-full px-3 py-2 rounded-lg border border-gray-200 focus:border-purple-500 outline-none text-sm" />
|
<input type="text" value={formData.metaTitle} onChange={(e) => setFormData({ ...formData, metaTitle: e.target.value })} className="w-full px-3 py-2 rounded-lg border border-gray-200 focus:border-purple-500 outline-none text-sm" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-bold text-gray-700">کلمات کلیدی</label>
|
<label className="text-xs font-bold text-gray-700">کلمات کلیدی</label>
|
||||||
<input type="text" value={formData.keywords} onChange={(e) => setFormData({...formData, keywords: e.target.value})} className="w-full px-3 py-2 rounded-lg border border-gray-200 focus:border-purple-500 outline-none text-sm" />
|
<input type="text" value={formData.keywords} onChange={(e) => setFormData({ ...formData, keywords: e.target.value })} className="w-full px-3 py-2 rounded-lg border border-gray-200 focus:border-purple-500 outline-none text-sm" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-bold text-gray-700">توضیحات متا</label>
|
<label className="text-xs font-bold text-gray-700">توضیحات متا</label>
|
||||||
<textarea rows={2} value={formData.metaDescription} onChange={(e) => setFormData({...formData, metaDescription: e.target.value})} className="w-full px-3 py-2 rounded-lg border border-gray-200 focus:border-purple-500 outline-none text-sm"></textarea>
|
<textarea rows={2} value={formData.metaDescription} onChange={(e) => setFormData({ ...formData, metaDescription: e.target.value })} className="w-full px-3 py-2 rounded-lg border border-gray-200 focus:border-purple-500 outline-none text-sm"></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 border-t border-gray-100 bg-gray-50 flex justify-end gap-3 rounded-b-2xl">
|
<div className="p-4 border-t border-gray-100 bg-gray-50 flex justify-end gap-3 rounded-b-2xl">
|
||||||
<button type="button" onClick={() => setIsModalOpen(false)} className="px-5 py-2.5 rounded-xl font-bold text-gray-600 hover:bg-gray-200 transition-colors">انصراف</button>
|
<button type="button" onClick={() => setIsModalOpen(false)} className="px-5 py-2.5 rounded-xl font-bold text-gray-600 hover:bg-gray-200 transition-colors">انصراف</button>
|
||||||
<button type="submit" form="blogForm" className="bg-purple-600 hover:bg-purple-700 text-white px-8 py-2.5 rounded-xl font-bold transition-all shadow-md shadow-purple-200">ذخیره مقاله</button>
|
<button type="submit" form="blogForm" className="bg-purple-600 hover:bg-purple-700 text-white px-8 py-2.5 rounded-xl font-bold transition-all shadow-md shadow-purple-200">ذخیره مقاله</button>
|
||||||
@ -286,7 +290,7 @@ export default function Blogs() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<MediaSelector isOpen={isMediaSelectorOpen} onClose={() => setIsMediaSelectorOpen(false)} onSelect={(url) => setFormData({...formData, imageUrl: url})} />
|
<MediaSelector isOpen={isMediaSelectorOpen} onClose={() => setIsMediaSelectorOpen(false)} onSelect={(url) => setFormData({ ...formData, imageUrl: url })} />
|
||||||
|
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
isOpen={!!deleteTargetId}
|
isOpen={!!deleteTargetId}
|
||||||
|
|||||||
@ -9,13 +9,23 @@ import ConfirmModal from '../components/ui/ConfirmModal';
|
|||||||
export interface Pet {
|
export interface Pet {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
species: string;
|
species?: string;
|
||||||
|
type?: string;
|
||||||
breed?: string;
|
breed?: string;
|
||||||
age?: number;
|
age?: number;
|
||||||
weight?: number;
|
weight?: number;
|
||||||
ownerName?: string;
|
activityLevel?: string;
|
||||||
|
imageUrl?: string;
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
createdAt: string;
|
user?: {
|
||||||
|
firstName: ReactNode;
|
||||||
|
lastName: ReactNode;
|
||||||
|
name?: string;
|
||||||
|
mobile?: string;
|
||||||
|
email?: string;
|
||||||
|
};
|
||||||
|
ownerName?: string;
|
||||||
|
createdAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Pets() {
|
export default function Pets() {
|
||||||
@ -79,9 +89,9 @@ export default function Pets() {
|
|||||||
<div className="bg-white p-4 rounded-2xl shadow-sm border border-gray-200 flex flex-col sm:flex-row gap-4">
|
<div className="bg-white p-4 rounded-2xl shadow-sm border border-gray-200 flex flex-col sm:flex-row gap-4">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search className="w-5 h-5 absolute right-4 top-1/2 -translate-y-1/2 text-gray-400" />
|
<Search className="w-5 h-5 absolute right-4 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="جستجو در نام حیوان..."
|
placeholder="جستجو در نام حیوان..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
||||||
className="w-full pl-4 pr-12 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none transition-all"
|
className="w-full pl-4 pr-12 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none transition-all"
|
||||||
@ -127,7 +137,7 @@ export default function Pets() {
|
|||||||
<div className="text-gray-500 text-xs font-mono">{pet.user?.mobile || pet.user?.email}</div>
|
<div className="text-gray-500 text-xs font-mono">{pet.user?.mobile || pet.user?.email}</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-4 px-6 text-gray-600 text-sm">
|
<td className="py-4 px-6 text-gray-600 text-sm">
|
||||||
{pet.age} ساله، {pet.weight} کیلوگرم<br/>
|
{pet.age} ساله، {pet.weight} کیلوگرم<br />
|
||||||
<span className="text-xs text-gray-400">تحرک: {pet.activityLevel}</span>
|
<span className="text-xs text-gray-400">تحرک: {pet.activityLevel}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-4 px-6">
|
<td className="py-4 px-6">
|
||||||
|
|||||||
@ -11,9 +11,12 @@ export interface Category {
|
|||||||
id: string;
|
id: string;
|
||||||
nameFa: string;
|
nameFa: string;
|
||||||
nameEn?: string;
|
nameEn?: string;
|
||||||
|
name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Product {
|
export interface Product {
|
||||||
|
name?: string;
|
||||||
|
category?: Category | string;
|
||||||
id: string;
|
id: string;
|
||||||
artNo: string;
|
artNo: string;
|
||||||
nameFa: string;
|
nameFa: string;
|
||||||
@ -46,7 +49,7 @@ export default function Products() {
|
|||||||
const [products, setProducts] = useState<Product[]>([]);
|
const [products, setProducts] = useState<Product[]>([]);
|
||||||
const [categories, setCategories] = useState<Category[]>([]);
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
// URL and String Helper Functions
|
// URL and String Helper Functions
|
||||||
const getProductImageUrl = (url: string) => {
|
const getProductImageUrl = (url: string) => {
|
||||||
if (!url) return '';
|
if (!url) return '';
|
||||||
@ -254,7 +257,7 @@ export default function Products() {
|
|||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-500 font-medium mt-1">افزودن و ویرایش پیشرفته محصولات با تنظیمات سئو</p>
|
<p className="text-gray-500 font-medium mt-1">افزودن و ویرایش پیشرفته محصولات با تنظیمات سئو</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => openModal()}
|
onClick={() => openModal()}
|
||||||
className="bg-purple-600 hover:bg-purple-700 text-white px-5 py-2.5 rounded-xl flex items-center gap-2 font-bold transition-all shadow-md shadow-purple-200"
|
className="bg-purple-600 hover:bg-purple-700 text-white px-5 py-2.5 rounded-xl flex items-center gap-2 font-bold transition-all shadow-md shadow-purple-200"
|
||||||
>
|
>
|
||||||
@ -266,9 +269,9 @@ export default function Products() {
|
|||||||
<div className="bg-white p-4 rounded-2xl shadow-sm border border-gray-200 flex flex-col sm:flex-row gap-4">
|
<div className="bg-white p-4 rounded-2xl shadow-sm border border-gray-200 flex flex-col sm:flex-row gap-4">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search className="w-5 h-5 absolute right-4 top-1/2 -translate-y-1/2 text-gray-400" />
|
<Search className="w-5 h-5 absolute right-4 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="جستجو در نام و کد محصول..."
|
placeholder="جستجو در نام و کد محصول..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
||||||
className="w-full pl-4 pr-12 py-3 rounded-xl border border-gray-200 focus:border-purple-500 focus:ring-2 focus:ring-purple-200 outline-none transition-all"
|
className="w-full pl-4 pr-12 py-3 rounded-xl border border-gray-200 focus:border-purple-500 focus:ring-2 focus:ring-purple-200 outline-none transition-all"
|
||||||
@ -276,7 +279,7 @@ export default function Products() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="relative w-full sm:w-64">
|
<div className="relative w-full sm:w-64">
|
||||||
<Filter className="w-5 h-5 absolute right-4 top-1/2 -translate-y-1/2 text-gray-400" />
|
<Filter className="w-5 h-5 absolute right-4 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||||
<select
|
<select
|
||||||
value={categoryFilter}
|
value={categoryFilter}
|
||||||
onChange={(e) => { setCategoryFilter(e.target.value); setPage(1); }}
|
onChange={(e) => { setCategoryFilter(e.target.value); setPage(1); }}
|
||||||
className="w-full pl-4 pr-12 py-3 rounded-xl border border-gray-200 focus:border-purple-500 focus:ring-2 focus:ring-purple-200 outline-none transition-all appearance-none"
|
className="w-full pl-4 pr-12 py-3 rounded-xl border border-gray-200 focus:border-purple-500 focus:ring-2 focus:ring-purple-200 outline-none transition-all appearance-none"
|
||||||
@ -314,22 +317,22 @@ export default function Products() {
|
|||||||
) : (
|
) : (
|
||||||
products.map((product) => (
|
products.map((product) => (
|
||||||
<tr key={product.id} className="hover:bg-gray-50/50 transition-colors">
|
<tr key={product.id} className="hover:bg-gray-50/50 transition-colors">
|
||||||
<td className="py-3 px-6">
|
<td className="py-3 px-6">
|
||||||
{product.imageUrl && !imageErrors[product.id] ? (
|
{product.imageUrl && !imageErrors[product.id] ? (
|
||||||
<div className="w-12 h-12 rounded-xl bg-gray-50 border border-gray-200 p-1 flex items-center justify-center overflow-hidden">
|
<div className="w-12 h-12 rounded-xl bg-gray-50 border border-gray-200 p-1 flex items-center justify-center overflow-hidden">
|
||||||
<img
|
<img
|
||||||
src={getProductImageUrl(product.imageUrl)}
|
src={getProductImageUrl(product.imageUrl)}
|
||||||
alt=""
|
alt=""
|
||||||
onError={() => setImageErrors(prev => ({ ...prev, [product.id]: true }))}
|
onError={() => setImageErrors(prev => ({ ...prev, [product.id]: true }))}
|
||||||
className="max-w-full max-h-full object-contain object-center"
|
className="max-w-full max-h-full object-contain object-center"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-12 h-12 rounded-xl bg-gray-100 flex items-center justify-center text-gray-400">
|
<div className="w-12 h-12 rounded-xl bg-gray-100 flex items-center justify-center text-gray-400">
|
||||||
<ImageIcon className="w-5 h-5" />
|
<ImageIcon className="w-5 h-5" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-4 px-6 text-gray-500 font-mono text-sm">{product.artNo}</td>
|
<td className="py-4 px-6 text-gray-500 font-mono text-sm">{product.artNo}</td>
|
||||||
<td className="py-4 px-6">
|
<td className="py-4 px-6">
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
@ -370,27 +373,27 @@ export default function Products() {
|
|||||||
{isModalOpen && (
|
{isModalOpen && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-gray-900/60 backdrop-blur-sm">
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-gray-900/60 backdrop-blur-sm">
|
||||||
<div className="bg-white rounded-2xl w-full max-w-4xl max-h-[90vh] flex flex-col shadow-2xl animate-in zoom-in duration-200">
|
<div className="bg-white rounded-2xl w-full max-w-4xl max-h-[90vh] flex flex-col shadow-2xl animate-in zoom-in duration-200">
|
||||||
<div className="px-6 py-4 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
|
<div className="px-6 py-4 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h3 className="text-xl font-bold text-gray-900">
|
<h3 className="text-xl font-bold text-gray-900">
|
||||||
{editingProduct ? 'ویرایش پیشرفته محصول' : 'افزودن محصول جدید'}
|
{editingProduct ? 'ویرایش پیشرفته محصول' : 'افزودن محصول جدید'}
|
||||||
</h3>
|
</h3>
|
||||||
{editingProduct && formData.slug && (
|
{editingProduct && formData.slug && (
|
||||||
<a
|
<a
|
||||||
href={getStoreProductUrl(formData.slug)}
|
href={getStoreProductUrl(formData.slug)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-bold bg-purple-50 border border-purple-100 text-purple-600 hover:bg-purple-100 transition-all cursor-pointer"
|
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-bold bg-purple-50 border border-purple-100 text-purple-600 hover:bg-purple-100 transition-all cursor-pointer"
|
||||||
>
|
>
|
||||||
مشاهده آنلاین در سایت 🔗
|
مشاهده آنلاین در سایت 🔗
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setIsModalOpen(false)} className="text-gray-400 hover:text-red-500 transition-colors">
|
<button onClick={() => setIsModalOpen(false)} className="text-gray-400 hover:text-red-500 transition-colors">
|
||||||
<X className="w-6 h-6" />
|
<X className="w-6 h-6" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex border-b border-gray-100 px-6 pt-2 gap-4">
|
<div className="flex border-b border-gray-100 px-6 pt-2 gap-4">
|
||||||
{[
|
{[
|
||||||
{ id: 'general', label: 'اطلاعات پایه' },
|
{ id: 'general', label: 'اطلاعات پایه' },
|
||||||
@ -411,7 +414,7 @@ export default function Products() {
|
|||||||
|
|
||||||
<div className="p-6 overflow-y-auto h-[550px] min-h-[550px] max-h-[550px] bg-gray-50/30">
|
<div className="p-6 overflow-y-auto h-[550px] min-h-[550px] max-h-[550px] bg-gray-50/30">
|
||||||
<form id="productForm" onSubmit={handleSave} className="space-y-6">
|
<form id="productForm" onSubmit={handleSave} className="space-y-6">
|
||||||
|
|
||||||
{/* General Tab */}
|
{/* General Tab */}
|
||||||
<div className={activeTab === 'general' ? 'block space-y-4' : 'hidden'}>
|
<div className={activeTab === 'general' ? 'block space-y-4' : 'hidden'}>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
@ -425,7 +428,7 @@ export default function Products() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<input required type="text" value={formData.artNo} onChange={(e) => setFormData({...formData, artNo: e.target.value})} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none font-mono" dir="ltr" />
|
<input required type="text" value={formData.artNo} onChange={(e) => setFormData({ ...formData, artNo: e.target.value })} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none font-mono" dir="ltr" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-bold text-gray-700 flex items-center gap-1.5">
|
<label className="text-sm font-bold text-gray-700 flex items-center gap-1.5">
|
||||||
@ -437,7 +440,7 @@ export default function Products() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<input required type="text" value={formData.nameFa} onChange={(e) => setFormData({...formData, nameFa: e.target.value})} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none" dir="rtl" placeholder="مثال: ژل دندان میکروسیلور" />
|
<input required type="text" value={formData.nameFa} onChange={(e) => setFormData({ ...formData, nameFa: e.target.value })} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none" dir="rtl" placeholder="مثال: ژل دندان میکروسیلور" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-bold text-gray-700 flex items-center gap-1.5">
|
<label className="text-sm font-bold text-gray-700 flex items-center gap-1.5">
|
||||||
@ -449,7 +452,7 @@ export default function Products() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<input required type="text" value={formData.nameEn} onChange={(e) => setFormData({...formData, nameEn: e.target.value})} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none font-mono" dir="ltr" placeholder="e.g. Canina Mikrosilber Zahngel - 50ml" />
|
<input required type="text" value={formData.nameEn} onChange={(e) => setFormData({ ...formData, nameEn: e.target.value })} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none font-mono" dir="ltr" placeholder="e.g. Canina Mikrosilber Zahngel - 50ml" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 md:col-span-2">
|
<div className="space-y-2 md:col-span-2">
|
||||||
<label className="text-sm font-bold text-gray-700 flex items-center gap-1.5">
|
<label className="text-sm font-bold text-gray-700 flex items-center gap-1.5">
|
||||||
@ -461,7 +464,7 @@ export default function Products() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<input type="text" value={formData.scientificTagline} onChange={(e) => setFormData({...formData, scientificTagline: e.target.value})} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none" />
|
<input type="text" value={formData.scientificTagline} onChange={(e) => setFormData({ ...formData, scientificTagline: e.target.value })} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-bold text-gray-700 flex items-center gap-1.5">
|
<label className="text-sm font-bold text-gray-700 flex items-center gap-1.5">
|
||||||
@ -473,7 +476,7 @@ export default function Products() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<select required value={formData.categoryId} onChange={(e) => setFormData({...formData, categoryId: e.target.value})} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none bg-white">
|
<select required value={formData.categoryId} onChange={(e) => setFormData({ ...formData, categoryId: e.target.value })} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none bg-white">
|
||||||
<option value="">انتخاب کنید</option>
|
<option value="">انتخاب کنید</option>
|
||||||
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
@ -488,7 +491,7 @@ export default function Products() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<select value={formData.suitableFor} onChange={(e) => setFormData({...formData, suitableFor: e.target.value})} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none bg-white">
|
<select value={formData.suitableFor} onChange={(e) => setFormData({ ...formData, suitableFor: e.target.value })} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none bg-white">
|
||||||
<option value="سگ">سگ</option>
|
<option value="سگ">سگ</option>
|
||||||
<option value="گربه">گربه</option>
|
<option value="گربه">گربه</option>
|
||||||
<option value="سگ و گربه">سگ و گربه</option>
|
<option value="سگ و گربه">سگ و گربه</option>
|
||||||
@ -504,7 +507,7 @@ export default function Products() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<textarea rows={2} value={formData.shortDescription} onChange={(e) => setFormData({...formData, shortDescription: e.target.value})} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none"></textarea>
|
<textarea rows={2} value={formData.shortDescription} onChange={(e) => setFormData({ ...formData, shortDescription: e.target.value })} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 md:col-span-2">
|
<div className="space-y-2 md:col-span-2">
|
||||||
<label className="text-sm font-bold text-gray-700 flex items-center gap-1.5">
|
<label className="text-sm font-bold text-gray-700 flex items-center gap-1.5">
|
||||||
@ -516,9 +519,9 @@ export default function Products() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<textarea rows={5} value={formData.description} onChange={(e) => setFormData({...formData, description: e.target.value})} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none"></textarea>
|
<textarea rows={5} value={formData.description} onChange={(e) => setFormData({ ...formData, description: e.target.value })} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 md:col-span-2">
|
<div className="space-y-2 md:col-span-2">
|
||||||
<label className="text-sm font-bold text-gray-700 flex items-center gap-1.5">
|
<label className="text-sm font-bold text-gray-700 flex items-center gap-1.5">
|
||||||
<span>منطق دوز مصرفی (Dosage Logic)</span>
|
<span>منطق دوز مصرفی (Dosage Logic)</span>
|
||||||
<div className="group relative inline-block">
|
<div className="group relative inline-block">
|
||||||
@ -528,9 +531,9 @@ export default function Products() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<textarea rows={2} value={formData.dosageLogic} onChange={(e) => setFormData({...formData, dosageLogic: e.target.value})} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none"></textarea>
|
<textarea rows={2} value={formData.dosageLogic} onChange={(e) => setFormData({ ...formData, dosageLogic: e.target.value })} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 md:col-span-2">
|
<div className="space-y-2 md:col-span-2">
|
||||||
<label className="text-sm font-bold text-gray-700 flex items-center gap-1.5">
|
<label className="text-sm font-bold text-gray-700 flex items-center gap-1.5">
|
||||||
<span>علائم درمانی مرتبط (Symptom Tags)</span>
|
<span>علائم درمانی مرتبط (Symptom Tags)</span>
|
||||||
<div className="group relative inline-block">
|
<div className="group relative inline-block">
|
||||||
@ -540,46 +543,46 @@ export default function Products() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<div className="flex flex-wrap gap-2 p-3 bg-white border border-gray-200 rounded-xl min-h-[50px] items-center focus-within:border-purple-500 focus-within:ring-2 focus-within:ring-purple-200 transition-all">
|
<div className="flex flex-wrap gap-2 p-3 bg-white border border-gray-200 rounded-xl min-h-[50px] items-center focus-within:border-purple-500 focus-within:ring-2 focus-within:ring-purple-200 transition-all">
|
||||||
{formData.symptoms && formData.symptoms.map((symptom, idx) => (
|
{formData.symptoms && formData.symptoms.map((symptom, idx) => (
|
||||||
<span key={idx} className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-bold bg-purple-50 border border-purple-200 text-purple-700">
|
<span key={idx} className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-bold bg-purple-50 border border-purple-200 text-purple-700">
|
||||||
{symptom}
|
{symptom}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const newSymptoms = formData.symptoms.filter((_, i) => i !== idx);
|
const newSymptoms = formData.symptoms.filter((_, i) => i !== idx);
|
||||||
setFormData({ ...formData, symptoms: newSymptoms });
|
setFormData({ ...formData, symptoms: newSymptoms });
|
||||||
}}
|
}}
|
||||||
className="text-purple-400 hover:text-purple-600 focus:outline-none transition-colors"
|
className="text-purple-400 hover:text-purple-600 focus:outline-none transition-colors"
|
||||||
>
|
>
|
||||||
<X className="w-3.5 h-3.5" />
|
<X className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="تایپ و اینتر کنید..."
|
placeholder="تایپ و اینتر کنید..."
|
||||||
className="flex-1 bg-transparent border-none outline-none text-sm min-w-[150px] text-right font-vazir"
|
className="flex-1 bg-transparent border-none outline-none text-sm min-w-[150px] text-right font-vazir"
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const input = e.currentTarget;
|
const input = e.currentTarget;
|
||||||
const value = input.value.trim();
|
const value = input.value.trim();
|
||||||
if (value && !formData.symptoms.includes(value)) {
|
if (value && !formData.symptoms.includes(value)) {
|
||||||
setFormData({
|
setFormData({
|
||||||
...formData,
|
...formData,
|
||||||
symptoms: [...(formData.symptoms || []), value]
|
symptoms: [...(formData.symptoms || []), value]
|
||||||
});
|
});
|
||||||
input.value = '';
|
input.value = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-gray-400 font-medium">پس از نوشتن هر علامت درمانی (مثلاً «درد مفاصل»)، کلید Enter را فشار دهید.</p>
|
<p className="text-[11px] text-gray-400 font-medium">پس از نوشتن هر علامت درمانی (مثلاً «درد مفاصل»)، کلید Enter را فشار دهید.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pricing Tab */}
|
{/* Pricing Tab */}
|
||||||
<div className={activeTab === 'pricing' ? 'block space-y-4' : 'hidden'}>
|
<div className={activeTab === 'pricing' ? 'block space-y-4' : 'hidden'}>
|
||||||
@ -608,15 +611,15 @@ export default function Products() {
|
|||||||
<span>قیمت اختصاصی B2B (تومان)</span>
|
<span>قیمت اختصاصی B2B (تومان)</span>
|
||||||
<span className="text-[10px] font-normal text-gray-400">(اختیاری)</span>
|
<span className="text-[10px] font-normal text-gray-400">(اختیاری)</span>
|
||||||
</label>
|
</label>
|
||||||
<input type="number" min="0" placeholder="در صورت عدم ورود، درصد کلی اعمال میشود" value={formData.wholesalePrice} onChange={(e) => setFormData({...formData, wholesalePrice: e.target.value ? Number(e.target.value) : ''})} className="w-full px-4 py-3 rounded-xl border border-purple-200 focus:border-purple-600 outline-none font-bold bg-purple-50/20" dir="ltr" />
|
<input type="number" min="0" placeholder="در صورت عدم ورود، درصد کلی اعمال میشود" value={formData.wholesalePrice} onChange={(e) => setFormData({ ...formData, wholesalePrice: e.target.value ? Number(e.target.value) : '' })} className="w-full px-4 py-3 rounded-xl border border-purple-200 focus:border-purple-600 outline-none font-bold bg-purple-50/20" dir="ltr" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-bold text-gray-700">موجودی *</label>
|
<label className="text-sm font-bold text-gray-700">موجودی *</label>
|
||||||
<input required type="number" min="0" value={formData.packageSize} onChange={(e) => setFormData({...formData, packageSize: Number(e.target.value)})} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none" dir="ltr" />
|
<input required type="number" min="0" value={formData.packageSize} onChange={(e) => setFormData({ ...formData, packageSize: Number(e.target.value) })} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none" dir="ltr" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-bold text-gray-700">واحد (Unit) *</label>
|
<label className="text-sm font-bold text-gray-700">واحد (Unit) *</label>
|
||||||
<input required type="text" placeholder="عدد، گرم، میلیلیتر" value={formData.unit} onChange={(e) => setFormData({...formData, unit: e.target.value})} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none" />
|
<input required type="text" placeholder="عدد، گرم، میلیلیتر" value={formData.unit} onChange={(e) => setFormData({ ...formData, unit: e.target.value })} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -644,13 +647,13 @@ export default function Products() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.slug}
|
value={formData.slug}
|
||||||
onChange={(e) => setFormData({...formData, slug: e.target.value})}
|
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
|
||||||
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none"
|
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none"
|
||||||
dir="ltr"
|
dir="ltr"
|
||||||
placeholder={derivedSlug}
|
placeholder={derivedSlug}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -664,11 +667,11 @@ export default function Products() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.metaTitle}
|
value={formData.metaTitle}
|
||||||
onChange={(e) => setFormData({...formData, metaTitle: e.target.value})}
|
onChange={(e) => setFormData({ ...formData, metaTitle: e.target.value })}
|
||||||
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none"
|
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none"
|
||||||
placeholder={derivedMetaTitle}
|
placeholder={derivedMetaTitle}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -683,10 +686,10 @@ export default function Products() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
rows={3}
|
rows={3}
|
||||||
value={formData.metaDescription}
|
value={formData.metaDescription}
|
||||||
onChange={(e) => setFormData({...formData, metaDescription: e.target.value})}
|
onChange={(e) => setFormData({ ...formData, metaDescription: e.target.value })}
|
||||||
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none"
|
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none"
|
||||||
placeholder={derivedMetaDescription}
|
placeholder={derivedMetaDescription}
|
||||||
></textarea>
|
></textarea>
|
||||||
@ -702,11 +705,11 @@ export default function Products() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.keywords}
|
value={formData.keywords}
|
||||||
onChange={(e) => setFormData({...formData, keywords: e.target.value})}
|
onChange={(e) => setFormData({ ...formData, keywords: e.target.value })}
|
||||||
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none"
|
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none"
|
||||||
placeholder="مثال: مکمل سگ, تقویت مفاصل, کنهیدروکس"
|
placeholder="مثال: مکمل سگ, تقویت مفاصل, کنهیدروکس"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -721,12 +724,12 @@ export default function Products() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.canonicalUrl}
|
value={formData.canonicalUrl}
|
||||||
onChange={(e) => setFormData({...formData, canonicalUrl: e.target.value})}
|
onChange={(e) => setFormData({ ...formData, canonicalUrl: e.target.value })}
|
||||||
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none font-mono text-sm"
|
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none font-mono text-sm"
|
||||||
dir="ltr"
|
dir="ltr"
|
||||||
placeholder={derivedCanonicalUrl}
|
placeholder={derivedCanonicalUrl}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -735,7 +738,7 @@ export default function Products() {
|
|||||||
{/* Left Column: Sticky Google SERP Preview */}
|
{/* Left Column: Sticky Google SERP Preview */}
|
||||||
<div className="lg:col-span-5 lg:sticky lg:top-4 bg-gray-100 border border-gray-200 rounded-2xl p-5 space-y-3">
|
<div className="lg:col-span-5 lg:sticky lg:top-4 bg-gray-100 border border-gray-200 rounded-2xl p-5 space-y-3">
|
||||||
<span className="text-[10px] font-black text-gray-400 uppercase tracking-widest block">پیشنمایش نتایج در گوگل (Google SERP Preview)</span>
|
<span className="text-[10px] font-black text-gray-400 uppercase tracking-widest block">پیشنمایش نتایج در گوگل (Google SERP Preview)</span>
|
||||||
|
|
||||||
<div className="bg-white rounded-xl p-4 border border-gray-100 shadow-sm text-right font-sans" dir="rtl">
|
<div className="bg-white rounded-xl p-4 border border-gray-100 shadow-sm text-right font-sans" dir="rtl">
|
||||||
{/* Domain / Breadcrumb */}
|
{/* Domain / Breadcrumb */}
|
||||||
<div className="flex items-center gap-1 text-xs text-[#202124] mb-1">
|
<div className="flex items-center gap-1 text-xs text-[#202124] mb-1">
|
||||||
@ -745,12 +748,12 @@ export default function Products() {
|
|||||||
<span className="text-gray-400">›</span>
|
<span className="text-gray-400">›</span>
|
||||||
<span className="text-gray-500 max-w-[150px] truncate">{derivedSlug}</span>
|
<span className="text-gray-500 max-w-[150px] truncate">{derivedSlug}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<h4 className="text-[19px] text-[#1a0dab] hover:underline cursor-pointer leading-tight font-medium font-sans mb-1 select-none">
|
<h4 className="text-[19px] text-[#1a0dab] hover:underline cursor-pointer leading-tight font-medium font-sans mb-1 select-none">
|
||||||
{derivedMetaTitle}
|
{derivedMetaTitle}
|
||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<p className="text-sm text-[#4d5156] leading-relaxed font-sans select-none break-words">
|
<p className="text-sm text-[#4d5156] leading-relaxed font-sans select-none break-words">
|
||||||
{derivedMetaDescription}
|
{derivedMetaDescription}
|
||||||
@ -775,19 +778,19 @@ export default function Products() {
|
|||||||
<span className="text-[11px] text-gray-400 font-normal">امکان چسباندن (Ctrl+V) مستقیم آدرس اینترنتی تصویر</span>
|
<span className="text-[11px] text-gray-400 font-normal">امکان چسباندن (Ctrl+V) مستقیم آدرس اینترنتی تصویر</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.imageUrl}
|
value={formData.imageUrl}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setFormData({...formData, imageUrl: e.target.value});
|
setFormData({ ...formData, imageUrl: e.target.value });
|
||||||
setMediaImageError(false);
|
setMediaImageError(false);
|
||||||
}}
|
}}
|
||||||
placeholder="https://example.com/images/product.png یا /uploads/photo.jpg"
|
placeholder="https://example.com/images/product.png یا /uploads/photo.jpg"
|
||||||
className="flex-1 px-4 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none font-mono text-sm"
|
className="flex-1 px-4 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none font-mono text-sm"
|
||||||
dir="ltr"
|
dir="ltr"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsMediaSelectorOpen(true)}
|
onClick={() => setIsMediaSelectorOpen(true)}
|
||||||
className="bg-purple-100 text-purple-700 hover:bg-purple-200 px-4 py-2.5 rounded-xl text-xs font-bold transition-colors whitespace-nowrap"
|
className="bg-purple-100 text-purple-700 hover:bg-purple-200 px-4 py-2.5 rounded-xl text-xs font-bold transition-colors whitespace-nowrap"
|
||||||
>
|
>
|
||||||
@ -801,15 +804,15 @@ export default function Products() {
|
|||||||
{formData.imageUrl && !mediaImageError ? (
|
{formData.imageUrl && !mediaImageError ? (
|
||||||
<div className="w-full flex flex-col items-center">
|
<div className="w-full flex flex-col items-center">
|
||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
<img
|
<img
|
||||||
src={getProductImageUrl(formData.imageUrl)}
|
src={getProductImageUrl(formData.imageUrl)}
|
||||||
alt="پیشنمایش تصویر محصول"
|
alt="پیشنمایش تصویر محصول"
|
||||||
onError={() => setMediaImageError(true)}
|
onError={() => setMediaImageError(true)}
|
||||||
className="max-w-xs h-48 object-contain rounded-xl shadow-md border border-gray-100"
|
className="max-w-xs h-48 object-contain rounded-xl shadow-md border border-gray-100"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity rounded-xl flex items-center justify-center gap-3">
|
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity rounded-xl flex items-center justify-center gap-3">
|
||||||
<button type="button" onClick={() => setIsMediaSelectorOpen(true)} className="bg-white text-gray-800 px-4 py-2 rounded-lg font-bold text-sm">انتخاب از گالری</button>
|
<button type="button" onClick={() => setIsMediaSelectorOpen(true)} className="bg-white text-gray-800 px-4 py-2 rounded-lg font-bold text-sm">انتخاب از گالری</button>
|
||||||
<button type="button" onClick={() => setFormData({...formData, imageUrl: ''})} className="bg-red-500 text-white px-4 py-2 rounded-lg font-bold text-sm hover:bg-red-600">حذف عکس</button>
|
<button type="button" onClick={() => setFormData({ ...formData, imageUrl: '' })} className="bg-red-500 text-white px-4 py-2 rounded-lg font-bold text-sm hover:bg-red-600">حذف عکس</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 text-center" dir="ltr">
|
<div className="mt-4 text-center" dir="ltr">
|
||||||
@ -839,7 +842,7 @@ export default function Products() {
|
|||||||
{/* Multimedia Attachments (TASK-2.3 & TASK-2.4) */}
|
{/* Multimedia Attachments (TASK-2.3 & TASK-2.4) */}
|
||||||
<div className="bg-white p-5 rounded-2xl border border-gray-200 shadow-sm space-y-4">
|
<div className="bg-white p-5 rounded-2xl border border-gray-200 shadow-sm space-y-4">
|
||||||
<h4 className="font-bold text-gray-800 text-sm border-b pb-2">پیوستهای مالتیمدیا و مستندات علمی</h4>
|
<h4 className="font-bold text-gray-800 text-sm border-b pb-2">پیوستهای مالتیمدیا و مستندات علمی</h4>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs font-bold text-gray-700 block">لینک فصلی پادکست (Podcast URL)</label>
|
<label className="text-xs font-bold text-gray-700 block">لینک فصلی پادکست (Podcast URL)</label>
|
||||||
@ -880,7 +883,7 @@ export default function Products() {
|
|||||||
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 border-t border-gray-100 bg-gray-50 flex justify-end gap-3 rounded-b-2xl">
|
<div className="p-4 border-t border-gray-100 bg-gray-50 flex justify-end gap-3 rounded-b-2xl">
|
||||||
<button type="button" onClick={() => setIsModalOpen(false)} className="px-5 py-2.5 rounded-xl font-bold text-gray-600 hover:bg-gray-200 transition-colors">
|
<button type="button" onClick={() => setIsModalOpen(false)} className="px-5 py-2.5 rounded-xl font-bold text-gray-600 hover:bg-gray-200 transition-colors">
|
||||||
انصراف
|
انصراف
|
||||||
@ -894,13 +897,13 @@ export default function Products() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Media Selector Modal */}
|
{/* Media Selector Modal */}
|
||||||
<MediaSelector
|
<MediaSelector
|
||||||
isOpen={isMediaSelectorOpen}
|
isOpen={isMediaSelectorOpen}
|
||||||
onClose={() => setIsMediaSelectorOpen(false)}
|
onClose={() => setIsMediaSelectorOpen(false)}
|
||||||
onSelect={(url) => {
|
onSelect={(url) => {
|
||||||
setFormData({...formData, imageUrl: url});
|
setFormData({ ...formData, imageUrl: url });
|
||||||
setMediaImageError(false);
|
setMediaImageError(false);
|
||||||
}}
|
}}
|
||||||
selectedUrl={formData.imageUrl.startsWith('http') ? formData.imageUrl : formData.imageUrl ? `${BASE_DOMAIN}${formData.imageUrl}` : undefined}
|
selectedUrl={formData.imageUrl.startsWith('http') ? formData.imageUrl : formData.imageUrl ? `${BASE_DOMAIN}${formData.imageUrl}` : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import Spinner from '../components/ui/Spinner';
|
|||||||
import Pagination from '../components/ui/Pagination';
|
import Pagination from '../components/ui/Pagination';
|
||||||
|
|
||||||
export interface UserRecord {
|
export interface UserRecord {
|
||||||
|
[x: string]: string;
|
||||||
id: string;
|
id: string;
|
||||||
firstName?: string;
|
firstName?: string;
|
||||||
lastName?: string;
|
lastName?: string;
|
||||||
@ -18,7 +19,7 @@ export interface UserRecord {
|
|||||||
export default function Users() {
|
export default function Users() {
|
||||||
const [users, setUsers] = useState<UserRecord[]>([]);
|
const [users, setUsers] = useState<UserRecord[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
// Queries
|
// Queries
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
@ -85,9 +86,9 @@ export default function Users() {
|
|||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-500 font-medium mt-1">مشاهده و تایید حسابهای کاربری و کلینیکها</p>
|
<p className="text-gray-500 font-medium mt-1">مشاهده و تایید حسابهای کاربری و کلینیکها</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row items-center gap-3 w-full sm:w-auto">
|
<div className="flex flex-col sm:flex-row items-center gap-3 w-full sm:w-auto">
|
||||||
<select
|
<select
|
||||||
className="bg-white border border-gray-200 text-gray-700 px-4 py-2 rounded-xl flex items-center gap-2 font-bold hover:bg-gray-50 transition-all outline-none w-full sm:w-auto"
|
className="bg-white border border-gray-200 text-gray-700 px-4 py-2 rounded-xl flex items-center gap-2 font-bold hover:bg-gray-50 transition-all outline-none w-full sm:w-auto"
|
||||||
value={role}
|
value={role}
|
||||||
onChange={(e) => { setRole(e.target.value); setPage(1); }}
|
onChange={(e) => { setRole(e.target.value); setPage(1); }}
|
||||||
@ -99,9 +100,9 @@ export default function Users() {
|
|||||||
</select>
|
</select>
|
||||||
<div className="relative w-full sm:w-64">
|
<div className="relative w-full sm:w-64">
|
||||||
<Search className="w-5 h-5 text-gray-400 absolute right-3 top-1/2 -translate-y-1/2" />
|
<Search className="w-5 h-5 text-gray-400 absolute right-3 top-1/2 -translate-y-1/2" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="جستجو کاربر (نام، ایمیل، موبایل)..."
|
placeholder="جستجو کاربر (نام، ایمیل، موبایل)..."
|
||||||
className="pl-4 pr-10 py-2 border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-purple-500 w-full font-medium"
|
className="pl-4 pr-10 py-2 border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-purple-500 w-full font-medium"
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
value={search}
|
value={search}
|
||||||
@ -146,7 +147,7 @@ export default function Users() {
|
|||||||
<td className="py-4 px-6 text-gray-500" dir="ltr">{user.email}</td>
|
<td className="py-4 px-6 text-gray-500" dir="ltr">{user.email}</td>
|
||||||
<td className="py-4 px-6">
|
<td className="py-4 px-6">
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<select
|
<select
|
||||||
className="border border-purple-300 rounded p-1 text-xs font-bold"
|
className="border border-purple-300 rounded p-1 text-xs font-bold"
|
||||||
value={editRole}
|
value={editRole}
|
||||||
onChange={(e) => setEditRole(e.target.value)}
|
onChange={(e) => setEditRole(e.target.value)}
|
||||||
@ -157,13 +158,12 @@ export default function Users() {
|
|||||||
<option value="ADMIN">ادمین</option>
|
<option value="ADMIN">ادمین</option>
|
||||||
</select>
|
</select>
|
||||||
) : (
|
) : (
|
||||||
<span className={`px-3 py-1 rounded-full text-xs font-bold ${
|
<span className={`px-3 py-1 rounded-full text-xs font-bold ${user.role === 'User_Wholesale' ? 'bg-amber-100 text-amber-800' :
|
||||||
user.role === 'User_Wholesale' ? 'bg-amber-100 text-amber-800' :
|
user.role?.includes('B2B') ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-700'
|
||||||
user.role?.includes('B2B') ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-700'
|
}`}>
|
||||||
}`}>
|
|
||||||
{user.role === 'User_Wholesale' ? 'خریدار عمده' :
|
{user.role === 'User_Wholesale' ? 'خریدار عمده' :
|
||||||
user.role?.includes('B2B') ? 'همکار (B2B)' :
|
user.role?.includes('B2B') ? 'همکار (B2B)' :
|
||||||
(user.role === 'ADMIN' ? 'مدیر سیستم' : 'مشتری عادی')}
|
(user.role === 'ADMIN' ? 'مدیر سیستم' : 'مشتری عادی')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -433,3 +433,8 @@ export default function Videos() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fetchVideosList() {
|
||||||
|
throw new Error('Function not implemented.');
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
import ConfirmModal from '../components/ui/ConfirmModal';
|
import ConfirmModal from '../components/ui/ConfirmModal';
|
||||||
@ -99,9 +99,8 @@ export default function WholesaleApplications() {
|
|||||||
<div className="text-xs text-gray-400 dir-ltr text-right">{u.email || u.phoneNumber}</div>
|
<div className="text-xs text-gray-400 dir-ltr text-right">{u.email || u.phoneNumber}</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<span className={`inline-flex px-3 py-1 rounded-full text-xs font-bold ${
|
<span className={`inline-flex px-3 py-1 rounded-full text-xs font-bold ${u.role === 'User_Wholesale' ? 'bg-green-100 text-green-700' : 'bg-amber-100 text-amber-700'
|
||||||
u.role === 'User_Wholesale' ? 'bg-green-100 text-green-700' : 'bg-amber-100 text-amber-700'
|
}`}>
|
||||||
}`}>
|
|
||||||
{u.role === 'User_Wholesale' ? 'خریدار عمده تاییدشده' : 'در انتظار بررسی B2B'}
|
{u.role === 'User_Wholesale' ? 'خریدار عمده تاییدشده' : 'در انتظار بررسی B2B'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { Search, Plus, Edit2, Trash2, BookOpen, XCircle } from 'lucide-react';
|
import { Search, Plus, Edit2, Trash2, BookOpen, XCircle } from 'lucide-react';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
|
|||||||
@ -103,7 +103,7 @@ export default function ClientLayout({ children }: { children: React.ReactNode }
|
|||||||
onLogin={() => {
|
onLogin={() => {
|
||||||
setLoginModalOpen(false);
|
setLoginModalOpen(false);
|
||||||
}}
|
}}
|
||||||
petName={advisorData?.name}
|
petName={advisorData?.name as string | undefined}
|
||||||
isAdvisorContext={!!advisorData}
|
isAdvisorContext={!!advisorData}
|
||||||
/>
|
/>
|
||||||
<Toaster position="top-center" expand={true} richColors closeButton />
|
<Toaster position="top-center" expand={true} richColors closeButton />
|
||||||
|
|||||||
@ -117,7 +117,7 @@ export default function PetProfile({ initialView, advisorNeed }: {
|
|||||||
const recommendedProducts = useMemo(() => {
|
const recommendedProducts = useMemo(() => {
|
||||||
if (!activePet || products.length === 0) return [];
|
if (!activePet || products.length === 0) return [];
|
||||||
|
|
||||||
let picks: { product: Product, reason?: string }[] = [];
|
const picks: { product: Product, reason?: string }[] = [];
|
||||||
|
|
||||||
// Priority 1: Match activePet.medicalConditions directly against product.symptoms
|
// Priority 1: Match activePet.medicalConditions directly against product.symptoms
|
||||||
if (activePet.medicalConditions && activePet.medicalConditions.length > 0) {
|
if (activePet.medicalConditions && activePet.medicalConditions.length > 0) {
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { motion, AnimatePresence } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
import { User, ShoppingBag, Wallet, MapPin, LogOut, ChevronRight, Package, Calendar, UserCircle, ShoppingCart, Trash2, Edit2, Phone, Hash, CheckCircle2, ArrowUpCircle, ArrowDownCircle, Info, Clock, CheckCircle, Heart, Sparkles, MessageSquare, Send, Stethoscope, FileText } from "lucide-react";
|
import { User, ShoppingBag, Wallet, MapPin, LogOut, ChevronRight, Package, Calendar, UserCircle, ShoppingCart, Trash2, Edit2, Phone, Hash, CheckCircle2, ArrowUpCircle, ArrowDownCircle, Info, Clock, CheckCircle, Heart, Sparkles, MessageSquare, Send, Stethoscope, FileText } from "lucide-react";
|
||||||
import { OrderRowSkeleton } from "./Skeleton";
|
import { OrderRowSkeleton } from "./Skeleton";
|
||||||
import { useUserStore, Address } from "../lib/store/userStore";
|
import { useUserStore, Address } from "../lib/store/userStore";
|
||||||
import { useCartStore } from "../lib/store/cartStore";
|
import { useCartStore } from "../lib/store/cartStore";
|
||||||
import { toPersian, cn } from "../lib/utils";
|
import { toPersian, toEnglishDigits, cn } from "../lib/utils";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import OrderDetailsModal from "./OrderDetailsModal";
|
import OrderDetailsModal from "./OrderDetailsModal";
|
||||||
import AddressModal from "./AddressModal";
|
import AddressModal from "./AddressModal";
|
||||||
@ -16,7 +16,7 @@ import { useRouter } from 'next/navigation';
|
|||||||
|
|
||||||
export default function UserDashboard() {
|
export default function UserDashboard() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { profile, isLoggedIn, logout, addAddress, updateAddress, deleteAddress, setDefaultAddress, topUpWallet } = useUserStore();
|
const { profile, isLoggedIn, logout, updateProfile, addAddress, updateAddress, deleteAddress, setDefaultAddress, topUpWallet } = useUserStore();
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!isLoggedIn && typeof window !== "undefined") {
|
if (!isLoggedIn && typeof window !== "undefined") {
|
||||||
@ -29,12 +29,19 @@ export default function UserDashboard() {
|
|||||||
const [activeTab, setActiveTab] = React.useState<"profile" | "orders" | "wallet" | "addresses" | "tickets">("profile");
|
const [activeTab, setActiveTab] = React.useState<"profile" | "orders" | "wallet" | "addresses" | "tickets">("profile");
|
||||||
const [isLoadingOrders, setIsLoadingOrders] = useState(false);
|
const [isLoadingOrders, setIsLoadingOrders] = useState(false);
|
||||||
|
|
||||||
|
|
||||||
// Fetch fresh profile data (which includes orders) whenever orders tab is opened
|
// Fetch fresh profile data (which includes orders) whenever orders tab is opened
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (activeTab === "orders") {
|
if (activeTab === "orders") {
|
||||||
setIsLoadingOrders(true);
|
let isSubscribed = true;
|
||||||
useUserStore.getState().fetchProfile().finally(() => setIsLoadingOrders(false));
|
Promise.resolve().then(() => {
|
||||||
|
if (isSubscribed) setIsLoadingOrders(true);
|
||||||
|
});
|
||||||
|
useUserStore.getState().fetchProfile().finally(() => {
|
||||||
|
if (isSubscribed) setIsLoadingOrders(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
isSubscribed = false;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}, [activeTab]);
|
}, [activeTab]);
|
||||||
const [selectedOrder, setSelectedOrder] = useState<any>(null);
|
const [selectedOrder, setSelectedOrder] = useState<any>(null);
|
||||||
@ -51,21 +58,29 @@ export default function UserDashboard() {
|
|||||||
// Profile State
|
// Profile State
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [isSavingProfile, setIsSavingProfile] = useState(false);
|
const [isSavingProfile, setIsSavingProfile] = useState(false);
|
||||||
const [profileForm, setProfileForm] = useState({
|
const [profileForm, setProfileForm] = useState(() => ({
|
||||||
firstName: profile.firstName || "",
|
firstName: profile.firstName || "",
|
||||||
lastName: profile.lastName || "",
|
lastName: profile.lastName || "",
|
||||||
email: profile.email || "",
|
email: profile.email || "",
|
||||||
mobile: profile.mobile || ""
|
mobile: profile.mobile || ""
|
||||||
});
|
}));
|
||||||
|
|
||||||
// Sync profile data on change
|
// Sync profile data on change
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setProfileForm({
|
let isSubscribed = true;
|
||||||
firstName: profile.firstName || "",
|
Promise.resolve().then(() => {
|
||||||
lastName: profile.lastName || "",
|
if (isSubscribed) {
|
||||||
email: profile.email || "",
|
setProfileForm({
|
||||||
mobile: profile.mobile || ""
|
firstName: profile.firstName || "",
|
||||||
|
lastName: profile.lastName || "",
|
||||||
|
email: profile.email || "",
|
||||||
|
mobile: profile.mobile || ""
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
return () => {
|
||||||
|
isSubscribed = false;
|
||||||
|
};
|
||||||
}, [profile]);
|
}, [profile]);
|
||||||
|
|
||||||
if (!isLoggedIn) {
|
if (!isLoggedIn) {
|
||||||
@ -74,17 +89,10 @@ export default function UserDashboard() {
|
|||||||
|
|
||||||
const handleSaveProfile = async (e: React.FormEvent) => {
|
const handleSaveProfile = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
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({
|
const cleanPhone = toEnglishDigits(profileForm.mobile);
|
||||||
|
await updateProfile({
|
||||||
firstName: profileForm.firstName,
|
firstName: profileForm.firstName,
|
||||||
lastName: profileForm.lastName,
|
lastName: profileForm.lastName,
|
||||||
email: profileForm.email,
|
email: profileForm.email,
|
||||||
@ -92,8 +100,8 @@ export default function UserDashboard() {
|
|||||||
});
|
});
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
toast.success("اطلاعات کاربری با موفقیت ویرایش شد");
|
toast.success("اطلاعات کاربری با موفقیت ویرایش شد");
|
||||||
} catch (err: any) {
|
} catch {
|
||||||
toast.error(err.message || "خطا در ویرایش اطلاعات");
|
toast.error("خطا در ویرایش اطلاعات");
|
||||||
} finally {
|
} finally {
|
||||||
setIsSavingProfile(false);
|
setIsSavingProfile(false);
|
||||||
}
|
}
|
||||||
@ -111,7 +119,7 @@ export default function UserDashboard() {
|
|||||||
} else {
|
} else {
|
||||||
await addAddress(addr);
|
await addAddress(addr);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch {
|
||||||
toast.error("خطا در ثبت آدرس");
|
toast.error("خطا در ثبت آدرس");
|
||||||
}
|
}
|
||||||
setEditingAddress(null);
|
setEditingAddress(null);
|
||||||
@ -127,7 +135,7 @@ export default function UserDashboard() {
|
|||||||
try {
|
try {
|
||||||
await deleteAddress(addressToDelete.id);
|
await deleteAddress(addressToDelete.id);
|
||||||
toast.success("آدرس با موفقیت حذف شد");
|
toast.success("آدرس با موفقیت حذف شد");
|
||||||
} catch (err) {
|
} catch {
|
||||||
toast.error("خطا در حذف آدرس");
|
toast.error("خطا در حذف آدرس");
|
||||||
}
|
}
|
||||||
setAddressToDelete(null);
|
setAddressToDelete(null);
|
||||||
@ -370,7 +378,7 @@ export default function UserDashboard() {
|
|||||||
<div className="text-sm font-black text-medical-gray-900 group-hover:text-canina-blue transition-colors italic">سفارش {toPersian(order.trackingNumber || order.id?.substring(0, 8))}</div>
|
<div className="text-sm font-black text-medical-gray-900 group-hover:text-canina-blue transition-colors italic">سفارش {toPersian(order.trackingNumber || order.id?.substring(0, 8))}</div>
|
||||||
<div className="text-[10px] font-bold text-medical-gray-400 flex items-center gap-1">
|
<div className="text-[10px] font-bold text-medical-gray-400 flex items-center gap-1">
|
||||||
<Calendar className="w-3 h-3 opacity-30" />
|
<Calendar className="w-3 h-3 opacity-30" />
|
||||||
{toPersian(new Date(order.date || (order as any).createdAt).toLocaleDateString("fa-IR"))} - ساعت {toPersian(new Date(order.date || (order as any).createdAt).toLocaleTimeString("fa-IR", { hour: '2-digit', minute: '2-digit' }))}
|
{toPersian(new Date(order.date || (order as unknown as Record<string, string>).createdAt || 0).toLocaleDateString("fa-IR"))} - ساعت {toPersian(new Date(order.date || (order as unknown as Record<string, string>).createdAt || 0).toLocaleTimeString("fa-IR", { hour: '2-digit', minute: '2-digit' }))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -477,7 +485,7 @@ export default function UserDashboard() {
|
|||||||
try {
|
try {
|
||||||
await setDefaultAddress(addr.id);
|
await setDefaultAddress(addr.id);
|
||||||
toast.success("آدرس پیشفرض با موفقیت تغییر کرد");
|
toast.success("آدرس پیشفرض با موفقیت تغییر کرد");
|
||||||
} catch (err) {
|
} catch {
|
||||||
toast.error("خطا در تغییر آدرس پیشفرض");
|
toast.error("خطا در تغییر آدرس پیشفرض");
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -1,13 +1,24 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { Play, PlayCircle, Star, ShieldCheck, X } from "lucide-react";
|
import { Play, Star, ShieldCheck, X } from "lucide-react";
|
||||||
import { motion, AnimatePresence } from "motion/react";
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import SafeImage from "./SafeImage";
|
import SafeImage from "./SafeImage";
|
||||||
import { useSettingsStore } from "../lib/store/settingsStore";
|
import { useSettingsStore } from "../lib/store/settingsStore";
|
||||||
import { videoService, Video } from "../lib/services/videoService";
|
import { videoService, Video } from "../lib/services/videoService";
|
||||||
|
|
||||||
|
export interface TestimonialItem {
|
||||||
|
id?: string;
|
||||||
|
clinicName?: string;
|
||||||
|
vetName?: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
quote?: string;
|
||||||
|
title?: string;
|
||||||
|
thumbnail?: string;
|
||||||
|
videoUrl?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
const FALLBACK_VIDEOS = [
|
const FALLBACK_VIDEOS = [
|
||||||
{
|
{
|
||||||
id: "v1",
|
id: "v1",
|
||||||
@ -38,11 +49,10 @@ const FALLBACK_VIDEOS = [
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function VetGallery({ testimonials = [] }: { testimonials?: any[] }) {
|
export default function VetGallery({ testimonials = [] }: { testimonials?: TestimonialItem[] }) {
|
||||||
const router = useRouter();
|
|
||||||
const getText = useSettingsStore(state => state.getText);
|
const getText = useSettingsStore(state => state.getText);
|
||||||
const [apiVideos, setApiVideos] = useState<Video[]>([]);
|
const [apiVideos, setApiVideos] = useState<Video[]>([]);
|
||||||
const [selectedVideo, setSelectedVideo] = useState<any>(null);
|
const [selectedVideo, setSelectedVideo] = useState<any | null>(null);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
videoService.getVideos({ featured: true, limit: 3 }).then((data) => {
|
videoService.getVideos({ featured: true, limit: 3 }).then((data) => {
|
||||||
@ -55,13 +65,13 @@ export default function VetGallery({ testimonials = [] }: { testimonials?: any[]
|
|||||||
const displayItems = testimonials.length > 0
|
const displayItems = testimonials.length > 0
|
||||||
? testimonials.map((t, idx) => ({
|
? testimonials.map((t, idx) => ({
|
||||||
id: t.id || `t-${idx}`,
|
id: t.id || `t-${idx}`,
|
||||||
title: t.clinicName ? `${t.vetName} — ${t.clinicName}` : t.vetName,
|
title: t.clinicName ? `${t.vetName} — ${t.clinicName}` : (t.vetName || ''),
|
||||||
doctor: t.vetName,
|
doctor: t.vetName || '',
|
||||||
duration: "۰۲:۰۰",
|
duration: "۰۲:۰۰",
|
||||||
thumbnail: t.imageUrl || "https://images.unsplash.com/photo-1576091160550-217359f42f8c?auto=format&fit=crop&q=80&w=400",
|
thumbnail: t.imageUrl || "https://images.unsplash.com/photo-1576091160550-217359f42f8c?auto=format&fit=crop&q=80&w=400",
|
||||||
videoUrl: "https://www.w3schools.com/html/mov_bbb.mp4",
|
videoUrl: "https://www.w3schools.com/html/mov_bbb.mp4",
|
||||||
description: t.quote,
|
description: t.quote || '',
|
||||||
quote: t.quote
|
quote: t.quote || ''
|
||||||
}))
|
}))
|
||||||
: (apiVideos.length > 0 ? apiVideos : FALLBACK_VIDEOS);
|
: (apiVideos.length > 0 ? apiVideos : FALLBACK_VIDEOS);
|
||||||
|
|
||||||
@ -102,7 +112,7 @@ export default function VetGallery({ testimonials = [] }: { testimonials?: any[]
|
|||||||
<div className="absolute inset-0 bg-medical-gray-900/20 group-hover:bg-transparent transition-colors z-10" />
|
<div className="absolute inset-0 bg-medical-gray-900/20 group-hover:bg-transparent transition-colors z-10" />
|
||||||
<SafeImage
|
<SafeImage
|
||||||
src={video.thumbnail || video.imageUrl || '/images/vets/vet1.webp'}
|
src={video.thumbnail || video.imageUrl || '/images/vets/vet1.webp'}
|
||||||
alt={video.title || video.vetName}
|
alt={video.title || video.vetName || 'ویدیو دامپزشک'}
|
||||||
className="w-full h-full"
|
className="w-full h-full"
|
||||||
imgClassName="object-cover grayscale-[30%] group-hover:grayscale-0 transition-all duration-700"
|
imgClassName="object-cover grayscale-[30%] group-hover:grayscale-0 transition-all duration-700"
|
||||||
/>
|
/>
|
||||||
@ -117,7 +127,7 @@ export default function VetGallery({ testimonials = [] }: { testimonials?: any[]
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h4 className="text-lg font-black text-medical-gray-900 mb-2 group-hover:text-canina-blue transition-colors leading-tight font-vazir">
|
<h4 className="text-lg font-black text-medical-gray-900 mb-2 group-hover:text-canina-blue transition-colors leading-tight font-vazir">
|
||||||
{video.title || video.quote?.substring(0, 40) + '...'}
|
{video.title || (video.quote ? video.quote.substring(0, 40) + '...' : '')}
|
||||||
</h4>
|
</h4>
|
||||||
<div className="flex items-center gap-2 text-medical-gray-500 font-bold text-sm font-vazir">
|
<div className="flex items-center gap-2 text-medical-gray-500 font-bold text-sm font-vazir">
|
||||||
<ShieldCheck className="w-4 h-4 text-canina-blue" />
|
<ShieldCheck className="w-4 h-4 text-canina-blue" />
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { motion, AnimatePresence } from "motion/react";
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
import { ChevronRight, PlayCircle, Star, ShieldCheck, X, Search, Clock, User } from "lucide-react";
|
import { ChevronRight, PlayCircle, ShieldCheck, X, Search, Clock, User } from "lucide-react";
|
||||||
import SafeImage from "./SafeImage";
|
import SafeImage from "./SafeImage";
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -44,7 +44,7 @@ describe('CartDrawer', () => {
|
|||||||
coupon: null,
|
coupon: null,
|
||||||
applyCoupon: vi.fn(),
|
applyCoupon: vi.fn(),
|
||||||
addItem: vi.fn(),
|
addItem: vi.fn(),
|
||||||
} as any);
|
} as unknown as ReturnType<typeof useCartStore>);
|
||||||
|
|
||||||
render(<CartDrawer isOpen={true} onClose={vi.fn()} onCheckout={vi.fn()} />);
|
render(<CartDrawer isOpen={true} onClose={vi.fn()} onCheckout={vi.fn()} />);
|
||||||
|
|
||||||
@ -64,7 +64,7 @@ describe('CartDrawer', () => {
|
|||||||
coupon: null,
|
coupon: null,
|
||||||
applyCoupon: vi.fn(),
|
applyCoupon: vi.fn(),
|
||||||
addItem: vi.fn(),
|
addItem: vi.fn(),
|
||||||
} as any);
|
} as unknown as ReturnType<typeof useCartStore>);
|
||||||
|
|
||||||
render(<CartDrawer isOpen={true} onClose={vi.fn()} onCheckout={vi.fn()} />);
|
render(<CartDrawer isOpen={true} onClose={vi.fn()} onCheckout={vi.fn()} />);
|
||||||
|
|
||||||
@ -86,7 +86,7 @@ describe('CartDrawer', () => {
|
|||||||
coupon: null,
|
coupon: null,
|
||||||
applyCoupon: vi.fn(),
|
applyCoupon: vi.fn(),
|
||||||
addItem: vi.fn(),
|
addItem: vi.fn(),
|
||||||
} as any);
|
} as unknown as ReturnType<typeof useCartStore>);
|
||||||
|
|
||||||
render(<CartDrawer isOpen={true} onClose={vi.fn()} onCheckout={vi.fn()} />);
|
render(<CartDrawer isOpen={true} onClose={vi.fn()} onCheckout={vi.fn()} />);
|
||||||
|
|
||||||
|
|||||||
@ -37,7 +37,7 @@ describe('FeaturedProducts', () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.mocked(usePetStore).mockReturnValue({
|
vi.mocked(usePetStore).mockReturnValue({
|
||||||
getActivePet: () => null,
|
getActivePet: () => null,
|
||||||
} as any);
|
} as unknown as ReturnType<typeof usePetStore>);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders loading skeletons initially', () => {
|
it('renders loading skeletons initially', () => {
|
||||||
@ -48,7 +48,7 @@ describe('FeaturedProducts', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders products once loaded', async () => {
|
it('renders products once loaded', async () => {
|
||||||
vi.mocked(productService.getFeaturedProducts).mockResolvedValue(mockProducts as any);
|
vi.mocked(productService.getFeaturedProducts).mockResolvedValue(mockProducts as unknown as ReturnType<typeof productService.getFeaturedProducts>);
|
||||||
render(<FeaturedProducts onProductClick={vi.fn()} onShopNavigate={vi.fn()} />);
|
render(<FeaturedProducts onProductClick={vi.fn()} onShopNavigate={vi.fn()} />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@ -59,7 +59,7 @@ describe('FeaturedProducts', () => {
|
|||||||
|
|
||||||
it('calls onProductClick when product card is clicked', async () => {
|
it('calls onProductClick when product card is clicked', async () => {
|
||||||
const handleProductClick = vi.fn();
|
const handleProductClick = vi.fn();
|
||||||
vi.mocked(productService.getFeaturedProducts).mockResolvedValue(mockProducts as any);
|
vi.mocked(productService.getFeaturedProducts).mockResolvedValue(mockProducts as unknown as ReturnType<typeof productService.getFeaturedProducts>);
|
||||||
render(<FeaturedProducts onProductClick={handleProductClick} onShopNavigate={vi.fn()} />);
|
render(<FeaturedProducts onProductClick={handleProductClick} onShopNavigate={vi.fn()} />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@ -72,7 +72,7 @@ describe('FeaturedProducts', () => {
|
|||||||
|
|
||||||
it('calls onShopNavigate when navigation link is clicked', async () => {
|
it('calls onShopNavigate when navigation link is clicked', async () => {
|
||||||
const handleShopNavigate = vi.fn();
|
const handleShopNavigate = vi.fn();
|
||||||
vi.mocked(productService.getFeaturedProducts).mockResolvedValue(mockProducts as any);
|
vi.mocked(productService.getFeaturedProducts).mockResolvedValue(mockProducts as unknown as ReturnType<typeof productService.getFeaturedProducts>);
|
||||||
render(<FeaturedProducts onProductClick={vi.fn()} onShopNavigate={handleShopNavigate} />);
|
render(<FeaturedProducts onProductClick={vi.fn()} onShopNavigate={handleShopNavigate} />);
|
||||||
|
|
||||||
const navBtn = screen.getByText('مشاهده تمامی محصولات');
|
const navBtn = screen.getByText('مشاهده تمامی محصولات');
|
||||||
|
|||||||
@ -25,21 +25,21 @@ describe('Header', () => {
|
|||||||
|
|
||||||
vi.mocked(useCartStore).mockReturnValue({
|
vi.mocked(useCartStore).mockReturnValue({
|
||||||
getTotalItems: () => 3,
|
getTotalItems: () => 3,
|
||||||
} as any);
|
} as unknown as ReturnType<typeof useCartStore>);
|
||||||
|
|
||||||
vi.mocked(usePetStore).mockReturnValue({
|
vi.mocked(usePetStore).mockReturnValue({
|
||||||
pets: [],
|
pets: [],
|
||||||
activePetId: null,
|
activePetId: null,
|
||||||
setActivePet: vi.fn(),
|
setActivePet: vi.fn(),
|
||||||
getActivePet: () => null,
|
getActivePet: () => null,
|
||||||
} as any);
|
} as unknown as ReturnType<typeof usePetStore>);
|
||||||
|
|
||||||
vi.mocked(useUserStore).mockReturnValue({
|
vi.mocked(useUserStore).mockReturnValue({
|
||||||
role: 'User_Guest',
|
role: 'User_Guest',
|
||||||
isLoggedIn: false,
|
isLoggedIn: false,
|
||||||
logout: vi.fn(),
|
logout: vi.fn(),
|
||||||
profile: { firstName: '' },
|
profile: { firstName: '' },
|
||||||
} as any);
|
} as unknown as ReturnType<typeof useUserStore>);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders brand name and login button when guest', () => {
|
it('renders brand name and login button when guest', () => {
|
||||||
@ -65,14 +65,14 @@ describe('Header', () => {
|
|||||||
isLoggedIn: true,
|
isLoggedIn: true,
|
||||||
logout: vi.fn(),
|
logout: vi.fn(),
|
||||||
profile: { firstName: 'کوروش' },
|
profile: { firstName: 'کوروش' },
|
||||||
} as any);
|
} as unknown as ReturnType<typeof useUserStore>);
|
||||||
|
|
||||||
vi.mocked(usePetStore).mockReturnValue({
|
vi.mocked(usePetStore).mockReturnValue({
|
||||||
pets: [{ id: 'pet-1', name: 'ملوس', type: 'گربه' }],
|
pets: [{ id: 'pet-1', name: 'ملوس', type: 'گربه' }],
|
||||||
activePetId: 'pet-1',
|
activePetId: 'pet-1',
|
||||||
setActivePet: vi.fn(),
|
setActivePet: vi.fn(),
|
||||||
getActivePet: () => ({ id: 'pet-1', name: 'ملوس', type: 'گربه' }),
|
getActivePet: () => ({ id: 'pet-1', name: 'ملوس', type: 'گربه' }),
|
||||||
} as any);
|
} as unknown as ReturnType<typeof usePetStore>);
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<Header
|
<Header
|
||||||
|
|||||||
@ -21,7 +21,7 @@ describe('Hero', () => {
|
|||||||
return fallback;
|
return fallback;
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mocked(useSettingsStore).mockImplementation((selector: any) => selector({ getText: mockGetText }));
|
vi.mocked(useSettingsStore).mockImplementation((selector: (state: { getText: (key: string, fallback: string) => string }) => unknown) => selector({ getText: mockGetText }) as ReturnType<typeof selector>);
|
||||||
|
|
||||||
render(<Hero onShopNavigate={vi.fn()} />);
|
render(<Hero onShopNavigate={vi.fn()} />);
|
||||||
|
|
||||||
@ -31,8 +31,8 @@ describe('Hero', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('calls onShopNavigate when "مشاهده محصولات" button is clicked', () => {
|
it('calls onShopNavigate when "مشاهده محصولات" button is clicked', () => {
|
||||||
const mockGetText = vi.fn().mockImplementation((key, fallback) => fallback);
|
const mockGetText = vi.fn().mockImplementation((_key, fallback) => fallback);
|
||||||
vi.mocked(useSettingsStore).mockImplementation((selector: any) => selector({ getText: mockGetText }));
|
vi.mocked(useSettingsStore).mockImplementation((selector: (state: { getText: (key: string, fallback: string) => string }) => unknown) => selector({ getText: mockGetText }) as ReturnType<typeof selector>);
|
||||||
|
|
||||||
const handleShopNavigate = vi.fn();
|
const handleShopNavigate = vi.fn();
|
||||||
render(<Hero onShopNavigate={handleShopNavigate} />);
|
render(<Hero onShopNavigate={handleShopNavigate} />);
|
||||||
|
|||||||
@ -756,7 +756,7 @@ export const PRODUCTS: Product[] = [
|
|||||||
contraindications: ["مصرف همزمان ویتامین D2 مجاز نیست"],
|
contraindications: ["مصرف همزمان ویتامین D2 مجاز نیست"],
|
||||||
calculateDosage: (weight, isPregnant) => {
|
calculateDosage: (weight, isPregnant) => {
|
||||||
const rate = isPregnant ? 3 : 2;
|
const rate = isPregnant ? 3 : 2;
|
||||||
let qty = (weight / 10) * rate;
|
const qty = (weight / 10) * rate;
|
||||||
return { quantity: Math.round(qty), unit: "قرص", description: isPregnant ? "دوز بارداری/شیردهی" : "دوز معمول" };
|
return { quantity: Math.round(qty), unit: "قرص", description: isPregnant ? "دوز بارداری/شیردهی" : "دوز معمول" };
|
||||||
},
|
},
|
||||||
analysis: { "کلسیم": "۲۲٪" },
|
analysis: { "کلسیم": "۲۲٪" },
|
||||||
@ -788,7 +788,7 @@ export const PRODUCTS: Product[] = [
|
|||||||
symptoms: ["خستگی مزمن", "رشد نامتناسب", "استرس"],
|
symptoms: ["خستگی مزمن", "رشد نامتناسب", "استرس"],
|
||||||
suitableFor: "هر دو",
|
suitableFor: "هر دو",
|
||||||
calculateDosage: (weight) => {
|
calculateDosage: (weight) => {
|
||||||
let qty = Math.max(1, weight / 10);
|
const qty = Math.max(1, weight / 10);
|
||||||
return { quantity: Math.round(qty), unit: "قرص", description: "روزانه" };
|
return { quantity: Math.round(qty), unit: "قرص", description: "روزانه" };
|
||||||
},
|
},
|
||||||
analysis: { "ویتامینها": "A تا K3 کامل" },
|
analysis: { "ویتامینها": "A تا K3 کامل" },
|
||||||
@ -853,7 +853,7 @@ export const PRODUCTS: Product[] = [
|
|||||||
symptoms: ["بینی روشن", "پوشش کدر", "کمبود ید"],
|
symptoms: ["بینی روشن", "پوشش کدر", "کمبود ید"],
|
||||||
suitableFor: "هر دو",
|
suitableFor: "هر دو",
|
||||||
calculateDosage: (weight) => {
|
calculateDosage: (weight) => {
|
||||||
let tablets = (weight / 10) * 2;
|
const tablets = (weight / 10) * 2;
|
||||||
return { quantity: Math.round(tablets), unit: "قرص", description: "روزانه" };
|
return { quantity: Math.round(tablets), unit: "قرص", description: "روزانه" };
|
||||||
},
|
},
|
||||||
analysis: { "ید": "غنی", "لیزین": "+" },
|
analysis: { "ید": "غنی", "لیزین": "+" },
|
||||||
@ -1024,7 +1024,7 @@ export const PRODUCTS: Product[] = [
|
|||||||
suitableFor: "گربه",
|
suitableFor: "گربه",
|
||||||
onSetOfAction: "هفته دوم",
|
onSetOfAction: "هفته دوم",
|
||||||
calculateDosage: (weight) => {
|
calculateDosage: (weight) => {
|
||||||
let tsp = weight > 6 ? 1 : 0.5;
|
const tsp = weight > 6 ? 1 : 0.5;
|
||||||
return { quantity: tsp, unit: "قاشق چایخوری", description: "روزانه با غذا مخلوط شود" };
|
return { quantity: tsp, unit: "قاشق چایخوری", description: "روزانه با غذا مخلوط شود" };
|
||||||
},
|
},
|
||||||
analysis: { "تورین": "۷۰۰ گرم/kg" },
|
analysis: { "تورین": "۷۰۰ گرم/kg" },
|
||||||
|
|||||||
@ -1,13 +1,11 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
export function useNetworkStatus() {
|
export function useNetworkStatus() {
|
||||||
const [isOnline, setIsOnline] = useState(true);
|
const [isOnline, setIsOnline] = useState(() =>
|
||||||
|
typeof navigator !== 'undefined' ? navigator.onLine : true
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== 'undefined' && typeof navigator !== 'undefined') {
|
|
||||||
setIsOnline(navigator.onLine);
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleOnline = () => setIsOnline(true);
|
const handleOnline = () => setIsOnline(true);
|
||||||
const handleOffline = () => setIsOnline(false);
|
const handleOffline = () => setIsOnline(false);
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { useUserStore } from '../store/userStore';
|
||||||
|
|
||||||
const baseURL = process.env.NEXT_PUBLIC_API_URL || (process.env.NODE_ENV === 'development' ? 'http://localhost:4001/api' : '/api');
|
const baseURL = process.env.NEXT_PUBLIC_API_URL || (process.env.NODE_ENV === 'development' ? 'http://localhost:4001/api' : '/api');
|
||||||
|
|
||||||
@ -8,7 +9,7 @@ export interface ApiErrorPayload {
|
|||||||
statusCode: number;
|
statusCode: number;
|
||||||
message: string;
|
message: string;
|
||||||
code: string;
|
code: string;
|
||||||
details?: Array<{ field: string; message: string }> | Record<string, any>;
|
details?: Array<{ field: string; message: string }> | Record<string, unknown>;
|
||||||
timestamp?: string;
|
timestamp?: string;
|
||||||
path?: string;
|
path?: string;
|
||||||
}
|
}
|
||||||
@ -50,7 +51,6 @@ api.interceptors.response.use(
|
|||||||
localStorage.removeItem('accessToken');
|
localStorage.removeItem('accessToken');
|
||||||
localStorage.removeItem('refreshToken');
|
localStorage.removeItem('refreshToken');
|
||||||
try {
|
try {
|
||||||
const { useUserStore } = require('../store/userStore');
|
|
||||||
useUserStore.getState().logout();
|
useUserStore.getState().logout();
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore if store not ready
|
// Ignore if store not ready
|
||||||
|
|||||||
@ -11,8 +11,8 @@ export interface User {
|
|||||||
charityDonationTotal: number;
|
charityDonationTotal: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
pets?: any[];
|
pets?: unknown[];
|
||||||
orders?: any[];
|
orders?: unknown[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthResponse {
|
export interface AuthResponse {
|
||||||
@ -23,6 +23,15 @@ export interface AuthResponse {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ApiErr {
|
||||||
|
response?: {
|
||||||
|
status?: number;
|
||||||
|
data?: {
|
||||||
|
message?: string | string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
private static instance: AuthService;
|
private static instance: AuthService;
|
||||||
|
|
||||||
@ -42,8 +51,9 @@ export class AuthService {
|
|||||||
try {
|
try {
|
||||||
const response = await api.post('/auth/send-otp', { phoneNumber });
|
const response = await api.post('/auth/send-otp', { phoneNumber });
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
const message = error.response?.data?.message || 'خطا در ارسال کد تایید';
|
const err = error as ApiErr;
|
||||||
|
const message = err.response?.data?.message || 'خطا در ارسال کد تایید';
|
||||||
throw new Error(Array.isArray(message) ? message[0] : message);
|
throw new Error(Array.isArray(message) ? message[0] : message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -59,8 +69,9 @@ export class AuthService {
|
|||||||
localStorage.setItem('accessToken', data.accessToken);
|
localStorage.setItem('accessToken', data.accessToken);
|
||||||
}
|
}
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
const message = error.response?.data?.message || 'کد تایید نامعتبر است';
|
const err = error as ApiErr;
|
||||||
|
const message = err.response?.data?.message || 'کد تایید نامعتبر است';
|
||||||
throw new Error(Array.isArray(message) ? message[0] : message);
|
throw new Error(Array.isArray(message) ? message[0] : message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -68,7 +79,7 @@ export class AuthService {
|
|||||||
/**
|
/**
|
||||||
* Register with email/mobile and password
|
* Register with email/mobile and password
|
||||||
*/
|
*/
|
||||||
public async register(data: any): Promise<AuthResponse> {
|
public async register(data: Record<string, unknown>): Promise<AuthResponse> {
|
||||||
try {
|
try {
|
||||||
const response = await api.post('/auth/register', data);
|
const response = await api.post('/auth/register', data);
|
||||||
const { data: resData } = response.data;
|
const { data: resData } = response.data;
|
||||||
@ -76,8 +87,9 @@ export class AuthService {
|
|||||||
localStorage.setItem('accessToken', resData.accessToken);
|
localStorage.setItem('accessToken', resData.accessToken);
|
||||||
}
|
}
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
const message = error.response?.data?.message || 'خطا در ثبتنام';
|
const err = error as ApiErr;
|
||||||
|
const message = err.response?.data?.message || 'خطا در ثبتنام';
|
||||||
throw new Error(Array.isArray(message) ? message[0] : message);
|
throw new Error(Array.isArray(message) ? message[0] : message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -93,8 +105,9 @@ export class AuthService {
|
|||||||
localStorage.setItem('accessToken', data.accessToken);
|
localStorage.setItem('accessToken', data.accessToken);
|
||||||
}
|
}
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
const message = error.response?.data?.message || 'نام کاربری یا رمز عبور اشتباه است';
|
const err = error as ApiErr;
|
||||||
|
const message = err.response?.data?.message || 'نام کاربری یا رمز عبور اشتباه است';
|
||||||
throw new Error(Array.isArray(message) ? message[0] : message);
|
throw new Error(Array.isArray(message) ? message[0] : message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -106,11 +119,12 @@ export class AuthService {
|
|||||||
try {
|
try {
|
||||||
const response = await api.get('/users/profile');
|
const response = await api.get('/users/profile');
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
if (error.response?.status === 401 || error.response?.status === 403) {
|
const err = error as ApiErr;
|
||||||
|
if (err.response?.status === 401 || err.response?.status === 403) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const message = error.response?.data?.message || 'خطا در دریافت اطلاعات کاربری';
|
const message = err.response?.data?.message || 'خطا در دریافت اطلاعات کاربری';
|
||||||
throw new Error(Array.isArray(message) ? message[0] : message);
|
throw new Error(Array.isArray(message) ? message[0] : message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -122,8 +136,9 @@ export class AuthService {
|
|||||||
try {
|
try {
|
||||||
const response = await api.patch('/users/profile', profileData);
|
const response = await api.patch('/users/profile', profileData);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
const message = error.response?.data?.message || 'خطا در ویرایش اطلاعات کاربری';
|
const err = error as ApiErr;
|
||||||
|
const message = err.response?.data?.message || 'خطا در ویرایش اطلاعات کاربری';
|
||||||
throw new Error(Array.isArray(message) ? message[0] : message);
|
throw new Error(Array.isArray(message) ? message[0] : message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,11 @@
|
|||||||
import api from './api';
|
import api from './api';
|
||||||
import { CartItem } from '../store/cartStore';
|
|
||||||
|
|
||||||
export interface OrderItem {
|
export interface OrderItem {
|
||||||
id: string;
|
id: string;
|
||||||
orderId: string;
|
orderId: string;
|
||||||
productId: string | null;
|
productId: string | null;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
product?: any;
|
product?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Order {
|
export interface Order {
|
||||||
@ -21,6 +20,14 @@ export interface Order {
|
|||||||
orderItems: OrderItem[];
|
orderItems: OrderItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ApiErr {
|
||||||
|
response?: {
|
||||||
|
data?: {
|
||||||
|
message?: string | string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export class OrderService {
|
export class OrderService {
|
||||||
private static instance: OrderService;
|
private static instance: OrderService;
|
||||||
|
|
||||||
@ -50,8 +57,9 @@ export class OrderService {
|
|||||||
try {
|
try {
|
||||||
const response = await api.post('/orders', orderData);
|
const response = await api.post('/orders', orderData);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
const message = error.response?.data?.message || 'خطا در ثبت سفارش';
|
const err = error as ApiErr;
|
||||||
|
const message = err.response?.data?.message || 'خطا در ثبت سفارش';
|
||||||
throw new Error(Array.isArray(message) ? message[0] : message);
|
throw new Error(Array.isArray(message) ? message[0] : message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -63,8 +71,9 @@ export class OrderService {
|
|||||||
try {
|
try {
|
||||||
const response = await api.get('/orders');
|
const response = await api.get('/orders');
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
const message = error.response?.data?.message || 'خطا در دریافت لیست سفارشها';
|
const err = error as ApiErr;
|
||||||
|
const message = err.response?.data?.message || 'خطا در دریافت لیست سفارشها';
|
||||||
throw new Error(Array.isArray(message) ? message[0] : message);
|
throw new Error(Array.isArray(message) ? message[0] : message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -76,8 +85,9 @@ export class OrderService {
|
|||||||
try {
|
try {
|
||||||
const response = await api.get(`/orders/${id}`);
|
const response = await api.get(`/orders/${id}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
const message = error.response?.data?.message || 'خطا در دریافت جزئیات سفارش';
|
const err = error as ApiErr;
|
||||||
|
const message = err.response?.data?.message || 'خطا در دریافت جزئیات سفارش';
|
||||||
throw new Error(Array.isArray(message) ? message[0] : message);
|
throw new Error(Array.isArray(message) ? message[0] : message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,18 +23,19 @@ export class ProductService {
|
|||||||
return ProductService.instance;
|
return ProductService.instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
private mapBackendToFrontend(data: any): Product {
|
private mapBackendToFrontend(inputData: Record<string, unknown>): Product {
|
||||||
|
const data = inputData as any;
|
||||||
// Find the local static product to inherit functions like calculateDosage
|
// Find the local static product to inherit functions like calculateDosage
|
||||||
const local = PRODUCTS.find(p =>
|
const local = PRODUCTS.find(p =>
|
||||||
p.artNo === data.artNo ||
|
p.artNo === data.artNo ||
|
||||||
p.id === data.productGroup ||
|
p.id === data.productGroup ||
|
||||||
(data.slug && data.slug.includes(p.id))
|
(typeof data.slug === 'string' && data.slug.includes(p.id))
|
||||||
);
|
);
|
||||||
|
|
||||||
const safeParse = (val: any, fallback: any) => {
|
const safeParse = (val: unknown, fallback: unknown) => {
|
||||||
if (!val) return fallback;
|
if (!val) return fallback;
|
||||||
if (typeof val === 'string') {
|
if (typeof val === 'string') {
|
||||||
try { return JSON.parse(val); } catch (e) { return fallback; }
|
try { return JSON.parse(val); } catch { return fallback; }
|
||||||
}
|
}
|
||||||
return val;
|
return val;
|
||||||
};
|
};
|
||||||
@ -68,8 +69,8 @@ export class ProductService {
|
|||||||
? data.imageUrl
|
? data.imageUrl
|
||||||
: (local?.image || (data.imageUrl ? `/api${data.imageUrl}` : data.image || '')),
|
: (local?.image || (data.imageUrl ? `/api${data.imageUrl}` : data.image || '')),
|
||||||
|
|
||||||
main_ingredients: data.ingredientList?.map((i: any) => i.ingredient) || data.ingredients?.split(/[،,-]/).map((s: string) => s.trim()).filter(Boolean) || [],
|
main_ingredients: data.ingredientList?.map((i: { ingredient: string }) => i.ingredient) || data.ingredients?.split(/[،,-]/).map((s: string) => s.trim()).filter(Boolean) || [],
|
||||||
symptoms: data.symptoms?.map((s: any) => s.symptom || s) || [],
|
symptoms: data.symptoms?.map((s: { symptom?: string }) => s.symptom || s) || [],
|
||||||
keyBenefits: safeParse(data.keyBenefits, []),
|
keyBenefits: safeParse(data.keyBenefits, []),
|
||||||
expectedResults: safeParse(data.expectedResults, []),
|
expectedResults: safeParse(data.expectedResults, []),
|
||||||
benefitsList: safeParse(data.benefitsList, []),
|
benefitsList: safeParse(data.benefitsList, []),
|
||||||
@ -102,7 +103,7 @@ export class ProductService {
|
|||||||
try {
|
try {
|
||||||
const response = await api.get(`/products?${params.toString()}`);
|
const response = await api.get(`/products?${params.toString()}`);
|
||||||
return {
|
return {
|
||||||
data: response.data.data.map((item: any) => this.mapBackendToFrontend(item)),
|
data: response.data.data.map((item: Record<string, unknown>) => this.mapBackendToFrontend(item)),
|
||||||
meta: response.data.meta,
|
meta: response.data.meta,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -134,7 +135,7 @@ export class ProductService {
|
|||||||
public async getFeaturedProducts(): Promise<Product[]> {
|
public async getFeaturedProducts(): Promise<Product[]> {
|
||||||
try {
|
try {
|
||||||
const response = await api.get('/products?limit=30');
|
const response = await api.get('/products?limit=30');
|
||||||
const all = response.data.data.map((item: any) => this.mapBackendToFrontend(item));
|
const all = response.data.data.map((item: Record<string, unknown>) => this.mapBackendToFrontend(item));
|
||||||
const featured: Product[] = [];
|
const featured: Product[] = [];
|
||||||
const categoriesSeen = new Set<string>();
|
const categoriesSeen = new Set<string>();
|
||||||
|
|
||||||
|
|||||||
@ -32,11 +32,11 @@ const mockProduct = {
|
|||||||
dosage_logic: '1',
|
dosage_logic: '1',
|
||||||
benefits: '',
|
benefits: '',
|
||||||
symptoms: [],
|
symptoms: [],
|
||||||
suitableFor: 'سگ' as any,
|
suitableFor: 'سگ' as never,
|
||||||
calculateDosage: () => ({ quantity: 1, unit: 'tablet', description: '' }),
|
calculateDosage: () => ({ quantity: 1, unit: 'tablet', description: '' }),
|
||||||
analysis: {},
|
analysis: {},
|
||||||
feedingAdvice: '',
|
feedingAdvice: '',
|
||||||
specialist: null as any,
|
specialist: null as never,
|
||||||
image: '',
|
image: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -44,7 +44,7 @@ interface CartStore {
|
|||||||
addOrder: (orderData: Omit<Order, 'id' | 'date' | 'status' | 'trackingNumber'>) => Promise<string>;
|
addOrder: (orderData: Omit<Order, 'id' | 'date' | 'status' | 'trackingNumber'>) => Promise<string>;
|
||||||
clearCart: () => void;
|
clearCart: () => void;
|
||||||
removeCoupon: () => void;
|
removeCoupon: () => void;
|
||||||
setOrders: (orders: any[]) => void;
|
setOrders: (orders: Record<string, unknown>[]) => void;
|
||||||
getTotalItems: () => number;
|
getTotalItems: () => number;
|
||||||
getSubtotal: () => number;
|
getSubtotal: () => number;
|
||||||
getDiscount: () => number;
|
getDiscount: () => number;
|
||||||
@ -120,6 +120,7 @@ export const useCartStore = create<CartStore>()(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const backendOrder = await orderService.createOrder(payload);
|
const backendOrder = await orderService.createOrder(payload);
|
||||||
|
const bo = backendOrder as unknown as Record<string, unknown>;
|
||||||
|
|
||||||
const newOrder: Order = {
|
const newOrder: Order = {
|
||||||
...orderData,
|
...orderData,
|
||||||
@ -127,15 +128,15 @@ export const useCartStore = create<CartStore>()(
|
|||||||
date: backendOrder.createdAt,
|
date: backendOrder.createdAt,
|
||||||
total: Number(backendOrder.totalAmount),
|
total: Number(backendOrder.totalAmount),
|
||||||
charityDonation: Number(backendOrder.charityDonation),
|
charityDonation: Number(backendOrder.charityDonation),
|
||||||
status: backendOrder.status as any,
|
status: (['processing', 'shipped', 'delivered'].includes(backendOrder.status) ? backendOrder.status : 'processing') as Order['status'],
|
||||||
isRefill: (backendOrder as any).isRefill !== undefined ? Boolean((backendOrder as any).isRefill) : Boolean(orderData.isRefill || get().isSubscribed),
|
isRefill: bo.isRefill !== undefined ? Boolean(bo.isRefill) : Boolean(orderData.isRefill || get().isSubscribed),
|
||||||
paymentMethod: (backendOrder as any).paymentMethod || orderData.paymentMethod,
|
paymentMethod: (bo.paymentMethod as string) || orderData.paymentMethod,
|
||||||
trackingNumber: backendOrder.trackingNumber || `CN-${Math.floor(Math.random() * 90000) + 10000}`
|
trackingNumber: backendOrder.trackingNumber || `CN-${Math.floor(Math.random() * 90000) + 10000}`
|
||||||
};
|
};
|
||||||
|
|
||||||
set({ orders: [newOrder, ...get().orders] });
|
set({ orders: [newOrder, ...get().orders] });
|
||||||
return backendOrder.id;
|
return backendOrder.id;
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
console.error("Order creation failed on backend:", error);
|
console.error("Order creation failed on backend:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@ -144,20 +145,20 @@ export const useCartStore = create<CartStore>()(
|
|||||||
removeCoupon: () => set({ coupon: null }),
|
removeCoupon: () => set({ coupon: null }),
|
||||||
setOrders: (backendOrders) => {
|
setOrders: (backendOrders) => {
|
||||||
const mappedOrders: Order[] = backendOrders.map(bo => ({
|
const mappedOrders: Order[] = backendOrders.map(bo => ({
|
||||||
id: bo.id,
|
id: String(bo.id),
|
||||||
date: bo.createdAt,
|
date: String(bo.createdAt),
|
||||||
items: bo.orderItems?.map((oi: any) => ({
|
items: (bo.orderItems as Array<{ product: Product; quantity: number }>)?.map((oi) => ({
|
||||||
product: oi.product,
|
product: oi.product,
|
||||||
quantity: oi.quantity
|
quantity: oi.quantity
|
||||||
})) || [],
|
})) || [],
|
||||||
total: Number(bo.totalAmount),
|
total: Number(bo.totalAmount),
|
||||||
charityDonation: Number(bo.charityDonation),
|
charityDonation: Number(bo.charityDonation),
|
||||||
status: bo.status,
|
status: (['processing', 'shipped', 'delivered'].includes(String(bo.status)) ? bo.status : 'processing') as Order['status'],
|
||||||
isRefill: Boolean((bo as any).isRefill),
|
isRefill: Boolean(bo.isRefill),
|
||||||
paymentMethod: (bo as any).paymentMethod,
|
paymentMethod: bo.paymentMethod as string | undefined,
|
||||||
petId: bo.petId || bo.pet?.id,
|
petId: (bo.petId as string) || ((bo.pet as { id?: string })?.id),
|
||||||
shippingAddress: bo.shippingAddress,
|
shippingAddress: bo.shippingAddress as string | undefined,
|
||||||
trackingNumber: bo.trackingNumber || `CN-${Math.floor(Math.random() * 90000) + 10000}`
|
trackingNumber: (bo.trackingNumber as string) || `CN-${Math.floor(Math.random() * 90000) + 10000}`
|
||||||
}));
|
}));
|
||||||
set({ orders: mappedOrders });
|
set({ orders: mappedOrders });
|
||||||
},
|
},
|
||||||
|
|||||||
@ -4,9 +4,9 @@ interface UIState {
|
|||||||
isCartOpen: boolean;
|
isCartOpen: boolean;
|
||||||
isLoginModalOpen: boolean;
|
isLoginModalOpen: boolean;
|
||||||
isB2BPortalOpen: boolean;
|
isB2BPortalOpen: boolean;
|
||||||
advisorData: any;
|
advisorData: Record<string, unknown> | null;
|
||||||
setCartOpen: (open: boolean) => void;
|
setCartOpen: (open: boolean) => void;
|
||||||
setLoginModalOpen: (open: boolean, advisorData?: any) => void;
|
setLoginModalOpen: (open: boolean, advisorData?: Record<string, unknown> | null) => void;
|
||||||
setB2BPortalOpen: (open: boolean) => void;
|
setB2BPortalOpen: (open: boolean) => void;
|
||||||
clearAdvisorData: () => void;
|
clearAdvisorData: () => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { create } from "zustand";
|
|||||||
import { persist } from "zustand/middleware";
|
import { persist } from "zustand/middleware";
|
||||||
import api from "../services/api";
|
import api from "../services/api";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { useUserStore } from "./userStore";
|
||||||
|
|
||||||
export interface Reminder {
|
export interface Reminder {
|
||||||
id: string;
|
id: string;
|
||||||
@ -54,7 +55,7 @@ interface PetStore {
|
|||||||
addReminder: (petId: string, reminder: Omit<Reminder, "id" | "completedDates">) => Promise<void>;
|
addReminder: (petId: string, reminder: Omit<Reminder, "id" | "completedDates">) => Promise<void>;
|
||||||
toggleReminder: (petId: string, reminderId: string, date: string) => Promise<void>;
|
toggleReminder: (petId: string, reminderId: string, date: string) => Promise<void>;
|
||||||
addHealthLog: (petId: string, log: Omit<HealthLog, "id" | "date">) => Promise<void>;
|
addHealthLog: (petId: string, log: Omit<HealthLog, "id" | "date">) => Promise<void>;
|
||||||
setPets: (pets: any[]) => void;
|
setPets: (pets: Record<string, unknown>[]) => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -125,7 +126,6 @@ export const usePetStore = create<PetStore>()(
|
|||||||
getActivePet: () => {
|
getActivePet: () => {
|
||||||
const { pets, activePetId } = get();
|
const { pets, activePetId } = get();
|
||||||
try {
|
try {
|
||||||
const { useUserStore } = require("./userStore");
|
|
||||||
if (!useUserStore.getState().isLoggedIn) {
|
if (!useUserStore.getState().isLoggedIn) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -195,29 +195,29 @@ export const usePetStore = create<PetStore>()(
|
|||||||
const mappedPets: PetProfile[] = backendPets.map(bp => {
|
const mappedPets: PetProfile[] = backendPets.map(bp => {
|
||||||
const existingLocal = currentLocalPets.find(lp => lp.id === bp.id);
|
const existingLocal = currentLocalPets.find(lp => lp.id === bp.id);
|
||||||
return {
|
return {
|
||||||
id: bp.id,
|
id: String(bp.id),
|
||||||
name: bp.name,
|
name: String(bp.name),
|
||||||
type: bp.type as any,
|
type: (bp.type as PetProfile['type']) || "سگ",
|
||||||
breed: bp.breed,
|
breed: String(bp.breed || ''),
|
||||||
age: Number(bp.age),
|
age: Number(bp.age || 0),
|
||||||
weight: Number(bp.weight),
|
weight: Number(bp.weight || 0),
|
||||||
activityLevel: bp.activityLevel as any,
|
activityLevel: (bp.activityLevel as PetProfile['activityLevel']) || "متوسط",
|
||||||
medicalConditions: bp.medicalConditions?.map((mc: any) => mc.condition) || [],
|
medicalConditions: (bp.medicalConditions as Array<{ condition: string }>)?.map((mc) => mc.condition) || [],
|
||||||
image: bp.imageUrl || undefined,
|
image: (bp.imageUrl as string) || undefined,
|
||||||
reminders: bp.reminders?.map((r: any) => ({
|
reminders: (bp.reminders as Array<{ id: string; title: string; time: string; frequency: Reminder['frequency']; productId?: string; completions?: Array<{ completedDate: string }> }>)?.map((r) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
title: r.title,
|
title: r.title,
|
||||||
time: r.time,
|
time: r.time,
|
||||||
frequency: r.frequency as any,
|
frequency: r.frequency,
|
||||||
productId: r.productId,
|
productId: r.productId,
|
||||||
completedDates: r.completions?.map((c: any) => c.completedDate) || []
|
completedDates: r.completions?.map((c) => c.completedDate) || []
|
||||||
})) || [],
|
})) || [],
|
||||||
logs: bp.healthLogs?.map((hl: any) => ({
|
logs: (bp.healthLogs as Array<{ id: string; loggedDate: string; appetite: HealthLog['appetite']; energy: HealthLog['energy']; digestion: HealthLog['digestion']; note?: string }>)?.map((hl) => ({
|
||||||
id: hl.id,
|
id: hl.id,
|
||||||
date: hl.loggedDate,
|
date: hl.loggedDate,
|
||||||
appetite: hl.appetite as any,
|
appetite: hl.appetite,
|
||||||
energy: hl.energy as any,
|
energy: hl.energy,
|
||||||
digestion: hl.digestion as any,
|
digestion: hl.digestion,
|
||||||
note: hl.note || undefined
|
note: hl.note || undefined
|
||||||
})) || [],
|
})) || [],
|
||||||
consumptions: existingLocal?.consumptions || []
|
consumptions: existingLocal?.consumptions || []
|
||||||
|
|||||||
@ -94,7 +94,16 @@ export const useUserStore = create<UserStore>()(
|
|||||||
addAddress: async (address) => {
|
addAddress: async (address) => {
|
||||||
try {
|
try {
|
||||||
// Remove ID so backend generates UUID
|
// Remove ID so backend generates UUID
|
||||||
const { id, ...addressData } = address;
|
const addressData = {
|
||||||
|
title: address.title,
|
||||||
|
receptorName: address.receptorName,
|
||||||
|
phone: address.phone,
|
||||||
|
province: address.province,
|
||||||
|
city: address.city,
|
||||||
|
detail: address.detail,
|
||||||
|
zipCode: address.zipCode,
|
||||||
|
isDefault: address.isDefault,
|
||||||
|
};
|
||||||
await api.post('/users/addresses', addressData);
|
await api.post('/users/addresses', addressData);
|
||||||
await useUserStore.getState().fetchProfile();
|
await useUserStore.getState().fetchProfile();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -104,7 +113,16 @@ export const useUserStore = create<UserStore>()(
|
|||||||
},
|
},
|
||||||
updateAddress: async (id, updated) => {
|
updateAddress: async (id, updated) => {
|
||||||
try {
|
try {
|
||||||
const { id: _, ...addressData } = updated;
|
const addressData = {
|
||||||
|
title: updated.title,
|
||||||
|
receptorName: updated.receptorName,
|
||||||
|
phone: updated.phone,
|
||||||
|
province: updated.province,
|
||||||
|
city: updated.city,
|
||||||
|
detail: updated.detail,
|
||||||
|
zipCode: updated.zipCode,
|
||||||
|
isDefault: updated.isDefault,
|
||||||
|
};
|
||||||
await api.patch(`/users/addresses/${id}`, addressData);
|
await api.patch(`/users/addresses/${id}`, addressData);
|
||||||
await useUserStore.getState().fetchProfile();
|
await useUserStore.getState().fetchProfile();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -201,14 +219,15 @@ export const useUserStore = create<UserStore>()(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
set((state) => {
|
set((state) => {
|
||||||
|
const pd = profileData as unknown as Record<string, unknown>;
|
||||||
const backendWallet = Number(profileData.walletBalance || 0);
|
const backendWallet = Number(profileData.walletBalance || 0);
|
||||||
const backendCharity = Number(profileData.charityDonationTotal || 0);
|
const backendCharity = Number(profileData.charityDonationTotal || 0);
|
||||||
const backendTransactions = (profileData as any).walletTransactions?.map((t: any) => ({
|
const backendTransactions = (pd.walletTransactions as Array<{ id: string; type: string; amount: number; createdAt: string; status: string }>)?.map((t) => ({
|
||||||
id: t.id,
|
id: t.id,
|
||||||
type: t.type === 'deposit' ? 'top_up' : 'purchase',
|
type: (t.type === 'deposit' ? 'top_up' : 'purchase') as Transaction['type'],
|
||||||
amount: Number(t.amount),
|
amount: Number(t.amount),
|
||||||
date: t.createdAt,
|
date: t.createdAt,
|
||||||
status: t.status === 'completed' ? 'success' : t.status === 'failed' ? 'failed' : 'pending'
|
status: (t.status === 'completed' ? 'success' : t.status === 'failed' ? 'failed' : 'pending') as Transaction['status']
|
||||||
})) || [];
|
})) || [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -221,7 +240,7 @@ export const useUserStore = create<UserStore>()(
|
|||||||
mobile: profileData.mobile || "",
|
mobile: profileData.mobile || "",
|
||||||
walletBalance: backendWallet,
|
walletBalance: backendWallet,
|
||||||
charityDonationTotal: backendCharity,
|
charityDonationTotal: backendCharity,
|
||||||
addresses: (profileData as any).addresses?.length > 0 ? (profileData as any).addresses : state.profile.addresses,
|
addresses: Array.isArray(pd.addresses) && pd.addresses.length > 0 ? (pd.addresses as Address[]) : state.profile.addresses,
|
||||||
transactions: backendTransactions.length > 0 ? backendTransactions : state.profile.transactions
|
transactions: backendTransactions.length > 0 ? backendTransactions : state.profile.transactions
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -230,11 +249,11 @@ export const useUserStore = create<UserStore>()(
|
|||||||
// Sync orders list to CartStore while preserving local petId & address mappings
|
// Sync orders list to CartStore while preserving local petId & address mappings
|
||||||
if (profileData.orders) {
|
if (profileData.orders) {
|
||||||
const currentLocalOrders = useCartStore.getState().orders;
|
const currentLocalOrders = useCartStore.getState().orders;
|
||||||
const mergedOrders = profileData.orders.map((bo: any) => {
|
const mergedOrders = (profileData.orders as Record<string, unknown>[]).map((bo) => {
|
||||||
const localMatch = currentLocalOrders.find(lo => lo.id === bo.id);
|
const localMatch = currentLocalOrders.find(lo => lo.id === bo.id);
|
||||||
return {
|
return {
|
||||||
...bo,
|
...bo,
|
||||||
petId: bo.petId || bo.pet?.id || localMatch?.petId,
|
petId: bo.petId || (bo.pet as { id?: string })?.id || localMatch?.petId,
|
||||||
shippingAddress: bo.shippingAddress || localMatch?.shippingAddress
|
shippingAddress: bo.shippingAddress || localMatch?.shippingAddress
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@ -242,7 +261,7 @@ export const useUserStore = create<UserStore>()(
|
|||||||
}
|
}
|
||||||
// Sync pets list to PetStore
|
// Sync pets list to PetStore
|
||||||
if (profileData.pets) {
|
if (profileData.pets) {
|
||||||
usePetStore.getState().setPets(profileData.pets);
|
usePetStore.getState().setPets(profileData.pets as Record<string, unknown>[]);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch user profile:", error);
|
console.error("Failed to fetch user profile:", error);
|
||||||
|
|||||||
@ -3,6 +3,12 @@ export const toPersian = (n: number | string | undefined | null) => {
|
|||||||
return n.toString().replace(/\d/g, d => '۰۱۲۳۴۵۶۷۸۹'[parseInt(d)]);
|
return n.toString().replace(/\d/g, d => '۰۱۲۳۴۵۶۷۸۹'[parseInt(d)]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const toEnglishDigits = (str: string) => {
|
||||||
|
if (!str) return "";
|
||||||
|
return str.replace(/[۰-۹]/g, d => '۰۱۲۳۴۵۶۷۸۹'.indexOf(d).toString());
|
||||||
|
};
|
||||||
|
|
||||||
export function cn(...classes: (string | boolean | undefined)[]) {
|
export function cn(...classes: (string | boolean | undefined)[]) {
|
||||||
return classes.filter(Boolean).join(" ");
|
return classes.filter(Boolean).join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user