feat: add BackButton, protect UserDashboard auth, fix product loading metadata title and add WebOTP auto-submit

This commit is contained in:
parsa aghaei 2026-08-01 18:32:33 +03:30
parent 2ce24b0473
commit 9870516256
9 changed files with 185 additions and 51 deletions

View File

@ -62,7 +62,7 @@
---
## 📍 بخش ۳: استانداردسازی دکمه بازگشت (Standard Back Button)
- [ ] **TASK-3.1 [Frontend Site UI]:** پیاده‌سازی کامپوننت استاندارد و اتمیک BackButton با استفاده از `router.back()` و حفظ دقیق موقعیت Scroll صفحه قبلی کاربر.
- [x] **TASK-3.1 [Frontend Site UI]:** پیاده‌سازی کامپوننت استاندارد و اتمیک BackButton با استفاده از `router.back()` و حفظ دقیق موقعیت Scroll صفحه قبلی کاربر.
---
@ -72,7 +72,7 @@
---
## 📍 بخش ۵: داشبورد کاربر و کیف پول (User Dashboard & Wallet Security)
- [ ] **TASK-5.1 [Frontend Protection]:** جلوگیری از دسترسی کاربران غیرلاگین به صفحه داشبورد و هدایت خودکار به صفحه ورود.
- [x] **TASK-5.1 [Frontend Protection]:** جلوگیری از دسترسی کاربران غیرلاگین به صفحه داشبورد و هدایت خودکار به صفحه ورود.
- [ ] **TASK-5.2 [Frontend Wallet UX]:** رفع مشکل نمایش پیام موفقیت کاذب هنگام شارژ کیف پول بدون لاگین و بهینه‌سازی سرعت فرآیند شارژ.
- [ ] **TASK-5.3 [Frontend & Backend Wallet]:** اصلاح استایل و Layout شارژ کیف پول برای اعداد بزرگ + فعال‌سازی منطق دکمه درخواست برداشت وجه (ثبت درخواست + ارسال SMS به کاربر و مدیر + مدیریت در پنل ادمین).
- [ ] **TASK-5.4 [Frontend UX]:** بررسی و رفع نمایش نشان "حساب تایید شده" برای کاربران غیر لاگین.
@ -90,7 +90,7 @@
---
## 📍 بخش ۸: رفع باگ عنوان صفحه محصول (Product Page Title Fix)
- [ ] **TASK-8.1 [Frontend Site Fix]:** رفع باگ عنوان صفحه محصول (عدم نمایش متن "محصول یافت نشد!" هنگام لود شدن دیتای محصول).
- [x] **TASK-8.1 [Frontend Site Fix]:** رفع باگ عنوان صفحه محصول (عدم نمایش متن "محصول یافت نشد!" هنگام لود شدن دیتای محصول).
---
@ -104,8 +104,8 @@
---
## 📍 بخش ۱۰: بهینه‌سازی ورود با OTP و خواندن خودکار SMS
- [ ] **TASK-10.1 [Frontend OTP UX]:** پشتیبانی کامل از WebOTP API جهت خواندن خودکار کد SMS در موبایل.
- [ ] **TASK-10.2 [Frontend OTP UX]:** پشتیبانی از Paste یکباره کد ۵ رقمی روی اولین input و توزیع خودکار آن در سایر خانه‌ها.
- [ ] **TASK-10.3 [Frontend OTP UX]:** ارسال خودکار (Auto Submit) به محض پر شدن خانه پنجم و امکان ویرایش سریع در صورت نادرست بودن کد.
- [x] **TASK-10.1 [Frontend OTP UX]:** پشتیبانی کامل از WebOTP API جهت خواندن خودکار کد SMS در موبایل.
- [x] **TASK-10.2 [Frontend OTP UX]:** پشتیبانی از Paste یکباره کد ۵ رقمی روی اولین input و توزیع خودکار آن در سایر خانه‌ها.
- [x] **TASK-10.3 [Frontend OTP UX]:** ارسال خودکار (Auto Submit) به محض پر شدن خانه پنجم و امکان ویرایش سریع در صورت نادرست بودن کد.
---

View File

@ -1,9 +1,11 @@
import { useState, useEffect } from 'react';
import { Search, Plus, Edit2, Trash2, FileText, Image as ImageIcon, CheckCircle2, XCircle } from 'lucide-react';
import { toast } from 'react-hot-toast';
import api, { BASE_DOMAIN } from '../services/api';
import Spinner from '../components/ui/Spinner';
import Pagination from '../components/ui/Pagination';
import MediaSelector from '../components/ui/MediaSelector';
import ConfirmModal from '../components/ui/ConfirmModal';
export default function Blogs() {
const [blogs, setBlogs] = useState<any[]>([]);
@ -16,7 +18,8 @@ export default function Blogs() {
const [isModalOpen, setIsModalOpen] = useState(false);
const [isMediaSelectorOpen, setIsMediaSelectorOpen] = useState(false);
const [editingBlog, setEditingBlog] = useState<any>(null);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const [formData, setFormData] = useState({
title: '',
slug: '',
@ -55,24 +58,30 @@ export default function Blogs() {
try {
if (editingBlog) {
await api.put(`/admin/blogs/${editingBlog.id}`, formData);
toast.success('مقاله با موفقیت ویرایش شد');
} else {
await api.post('/admin/blogs', formData);
toast.success('مقاله جدید با موفقیت ذخیره شد');
}
setIsModalOpen(false);
fetchBlogs();
} catch (error) {
} catch (error: any) {
console.error('Save failed', error);
alert('خطا در ذخیره مقاله');
toast.error('خطا در ذخیره مقاله: ' + (error.response?.data?.message || error.message || ''));
}
};
const handleDelete = async (id: string) => {
if (!window.confirm('آیا از حذف این مقاله مطمئن هستید؟')) return;
const confirmDelete = async () => {
if (!deleteTargetId) return;
try {
await api.delete(`/admin/blogs/${id}`);
await api.delete(`/admin/blogs/${deleteTargetId}`);
toast.success('مقاله با موفقیت حذف شد');
fetchBlogs();
} catch (error) {
} catch (error: any) {
console.error('Delete failed', error);
toast.error('خطا در حذف مقاله');
} finally {
setDeleteTargetId(null);
}
};

View File

@ -1,9 +1,11 @@
import { useState, useEffect } from 'react';
import { Search, Plus, Edit2, Trash2, FolderTree, Image as ImageIcon, X } from 'lucide-react';
import { toast } from 'react-hot-toast';
import api, { BASE_DOMAIN } from '../services/api';
import Spinner from '../components/ui/Spinner';
import Pagination from '../components/ui/Pagination';
import MediaSelector from '../components/ui/MediaSelector';
import ConfirmModal from '../components/ui/ConfirmModal';
export default function Categories() {
const [categories, setCategories] = useState<any[]>([]);
@ -16,7 +18,8 @@ export default function Categories() {
const [isModalOpen, setIsModalOpen] = useState(false);
const [isMediaSelectorOpen, setIsMediaSelectorOpen] = useState(false);
const [editingCategory, setEditingCategory] = useState<any>(null);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const [formData, setFormData] = useState({
name: '',
slug: '',
@ -54,24 +57,30 @@ export default function Categories() {
try {
if (editingCategory) {
await api.put(`/admin/categories/${editingCategory.id}`, formData);
toast.success('دسته‌بندی با موفقیت ویرایش شد');
} else {
await api.post('/admin/categories', formData);
toast.success('دسته‌بندی جدید با موفقیت ایجاد شد');
}
setIsModalOpen(false);
fetchCategories();
} catch (error) {
} catch (error: any) {
console.error('Save failed', error);
alert('خطا در ذخیره دسته‌بندی');
toast.error('خطا در ذخیره دسته‌بندی: ' + (error.response?.data?.message || error.message || ''));
}
};
const handleDelete = async (id: string) => {
if (!window.confirm('آیا مطمئن هستید؟')) return;
const confirmDelete = async () => {
if (!deleteTargetId) return;
try {
await api.delete(`/admin/categories/${id}`);
await api.delete(`/admin/categories/${deleteTargetId}`);
toast.success('دسته‌بندی با موفقیت حذف شد');
fetchCategories();
} catch (error) {
} catch (error: any) {
console.error('Delete failed', error);
toast.error('خطا در حذف دسته‌بندی');
} finally {
setDeleteTargetId(null);
}
};
@ -179,7 +188,7 @@ export default function Categories() {
<button onClick={() => openModal(cat)} className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors">
<Edit2 className="w-4 h-4" />
</button>
<button onClick={() => handleDelete(cat.id)} className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors">
<button onClick={() => setDeleteTargetId(cat.id)} className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors">
<Trash2 className="w-4 h-4" />
</button>
</div>
@ -318,6 +327,14 @@ export default function Categories() {
onClose={() => setIsMediaSelectorOpen(false)}
onSelect={(url) => setFormData({...formData, imageUrl: url})}
/>
<ConfirmModal
isOpen={!!deleteTargetId}
title="حذف دسته‌بندی"
message="آیا از حذف این دسته‌بندی مطمئن هستید؟ این عملیات قابل بازگشت نیست."
onConfirm={confirmDelete}
onCancel={() => setDeleteTargetId(null)}
/>
</div>
);
}

View File

@ -5,6 +5,7 @@ import api, { BASE_DOMAIN } from '../services/api';
import Spinner from '../components/ui/Spinner';
import Pagination from '../components/ui/Pagination';
import MediaSelector from '../components/ui/MediaSelector';
import ConfirmModal from '../components/ui/ConfirmModal';
export default function Products() {
const [products, setProducts] = useState<any[]>([]);
@ -54,6 +55,7 @@ export default function Products() {
const [isModalOpen, setIsModalOpen] = useState(false);
const [isMediaSelectorOpen, setIsMediaSelectorOpen] = useState(false);
const [editingProduct, setEditingProduct] = useState<any>(null);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState('general');
const [formData, setFormData] = useState({
@ -65,6 +67,7 @@ export default function Products() {
shortDescription: '',
categoryId: '',
priceValue: 0,
wholesalePrice: '' as number | string,
priceDisplay: '',
unit: '',
packageSize: 0,
@ -169,26 +172,32 @@ export default function Products() {
if (editingProduct) {
await api.put(`/admin/products/${editingProduct.id}`, finalPayload);
toast.success('محصول با موفقیت ویرایش شد');
} else {
await api.post('/admin/products', finalPayload);
toast.success('محصول با موفقیت ایجاد شد');
}
setIsModalOpen(false);
setImageErrors({});
fetchData();
} catch (error) {
} catch (error: any) {
console.error('Save failed', error);
alert('خطا در ذخیره محصول');
toast.error('خطا در ذخیره محصول: ' + (error.response?.data?.message || error.message || ''));
}
};
const handleDelete = async (id: string) => {
if (!window.confirm('آیا از حذف این محصول مطمئن هستید؟')) return;
const confirmDelete = async () => {
if (!deleteTargetId) return;
try {
await api.delete(`/admin/products/${id}`);
await api.delete(`/admin/products/${deleteTargetId}`);
toast.success('محصول با موفقیت حذف شد');
setImageErrors({});
fetchData();
} catch (error) {
} catch (error: any) {
console.error('Delete failed', error);
toast.error('خطا در حذف محصول');
} finally {
setDeleteTargetId(null);
}
};
@ -297,7 +306,7 @@ export default function Products() {
<button onClick={() => openModal(product)} className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors">
<Edit2 className="w-4 h-4" />
</button>
<button onClick={() => handleDelete(product.id)} className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors">
<button onClick={() => setDeleteTargetId(product.id)} className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors">
<Trash2 className="w-4 h-4" />
</button>
</div>
@ -810,6 +819,14 @@ export default function Products() {
}}
selectedUrl={formData.imageUrl.startsWith('http') ? formData.imageUrl : formData.imageUrl ? `${BASE_DOMAIN}${formData.imageUrl}` : undefined}
/>
<ConfirmModal
isOpen={!!deleteTargetId}
title="حذف محصول"
message="آیا از حذف این محصول مطمئن هستید؟ این عملیات قابل بازگشت نیست."
onConfirm={confirmDelete}
onCancel={() => setDeleteTargetId(null)}
/>
</div>
);
}

View File

@ -2,12 +2,14 @@
import React, { useState, useEffect, useRef } from "react";
import SafeImage from "../../components/SafeImage";
import BackButton from "../../components/BackButton";
import { Download, Printer, ShieldCheck, Sparkles, CheckCircle2, ChevronLeft, Search, Filter } from "lucide-react";
import Link from "next/link";
import { productService } from "../../lib/services/productService";
import { Product } from "../../lib/data/products";
import { toPersian } from "../../lib/utils";
export default function CatalogClient() {
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
@ -89,6 +91,7 @@ export default function CatalogClient() {
</div>
<div className="flex flex-wrap items-center gap-4">
<BackButton className="bg-white/10 text-white border-white/20 hover:bg-white/20 hover:text-white" />
<button
onClick={handlePrint}
className="bg-amber-400 hover:bg-amber-500 text-slate-900 font-black px-6 py-4 rounded-2xl shadow-xl hover:scale-105 transition-all flex items-center gap-3 text-sm cursor-pointer"

View File

@ -11,7 +11,7 @@ export async function generateMetadata(
if (!product) {
return {
title: 'محصول یافت نشد',
title: 'خرید مکمل درمانی پت | کانینا ایران',
};
}

View File

@ -0,0 +1,38 @@
"use client";
import React from "react";
import { useRouter } from "next/navigation";
import { ArrowRight } from "lucide-react";
import { cn } from "../lib/utils";
interface BackButtonProps {
className?: string;
label?: string;
}
export default function BackButton({ className, label = "بازگشت" }: BackButtonProps) {
const router = useRouter();
const handleBack = () => {
// router.back() preserves browser history and scroll position natively
if (typeof window !== "undefined" && window.history.length > 1) {
router.back();
} else {
router.push("/");
}
};
return (
<button
type="button"
onClick={handleBack}
className={cn(
"inline-flex items-center gap-2 px-4 py-2 bg-white/80 backdrop-blur-sm border border-medical-gray-200 hover:border-canina-blue hover:text-canina-blue text-medical-gray-700 text-xs font-black rounded-xl transition-all shadow-xs active:scale-95 whitespace-nowrap cursor-pointer",
className
)}
>
<ArrowRight className="w-4 h-4" />
<span>{label}</span>
</button>
);
}

View File

@ -33,6 +33,64 @@ export default function LoginModal({ isOpen, onClose, onLogin, petName, isAdviso
}
}, [step]);
// WebOTP API & Auto-Submit
useEffect(() => {
if (step === "otp" && typeof window !== "undefined" && "OTPCredential" in window) {
const ac = new AbortController();
(navigator as any).credentials
.get({
otp: { transport: ["sms"] },
signal: ac.signal,
})
.then((otp: any) => {
if (otp && otp.code) {
setOtpCode(otp.code);
handleVerifyOtpWithCode(otp.code);
}
})
.catch(() => {});
return () => ac.abort();
}
}, [step]);
const handleVerifyOtpWithCode = async (code: string) => {
const cleanCode = code.trim();
if (cleanCode.length !== 5) return;
setIsLoading(true);
try {
const response = await authService.verifyOtp(phoneNumber.trim(), cleanCode);
if (response.success) {
await fetchProfile();
toast.success("ورود با موفقیت انجام شد");
onLogin();
onClose();
}
} catch (err: any) {
toast.error(err.message || "کد تایید اشتباه است");
} finally {
setIsLoading(false);
}
};
const handleOtpInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value.replace(/[^0-9]/g, "").slice(0, 5);
setOtpCode(val);
if (val.length === 5) {
handleVerifyOtpWithCode(val);
}
};
const handleOtpPaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
const pasted = e.clipboardData.getData("text").replace(/[^0-9]/g, "").slice(0, 5);
if (pasted.length === 5) {
e.preventDefault();
setOtpCode(pasted);
handleVerifyOtpWithCode(pasted);
}
};
// Reset modal state when closed or opened
useEffect(() => {
if (!isOpen) {
@ -79,26 +137,7 @@ export default function LoginModal({ isOpen, onClose, onLogin, petName, isAdviso
const handleVerifyOtp = async (e: React.FormEvent) => {
e.preventDefault();
const cleanCode = otpCode.trim();
if (cleanCode.length !== 5) {
toast.error("کد تایید باید ۵ رقم باشد");
return;
}
setIsLoading(true);
try {
const response = await authService.verifyOtp(phoneNumber.trim(), cleanCode);
if (response.success) {
await fetchProfile();
toast.success("ورود با موفقیت انجام شد");
onLogin();
onClose();
}
} catch (err: any) {
toast.error(err.message || "کد تایید اشتباه است");
} finally {
setIsLoading(false);
}
handleVerifyOtpWithCode(otpCode);
};
const handleResendOtp = async () => {
@ -234,9 +273,11 @@ export default function LoginModal({ isOpen, onClose, onLogin, petName, isAdviso
ref={otpInputRef}
type="text"
maxLength={5}
autoComplete="one-time-code"
placeholder="کد ۵ رقمی"
value={otpCode}
onChange={(e) => setOtpCode(e.target.value.replace(/[^0-9]/g, ''))}
onChange={handleOtpInputChange}
onPaste={handleOtpPaste}
disabled={isLoading}
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-2xl py-4 pr-12 pl-4 focus:ring-2 focus:ring-canina-blue/20 focus:border-canina-blue outline-none font-black text-2xl text-center tracking-[0.5em]"
/>

View File

@ -16,11 +16,20 @@ import { useRouter } from 'next/navigation';
export default function UserDashboard() {
const router = useRouter();
const { profile, logout, addAddress, updateAddress, deleteAddress, setDefaultAddress, topUpWallet } = useUserStore();
const { profile, isLoggedIn, logout, addAddress, updateAddress, deleteAddress, setDefaultAddress, topUpWallet } = useUserStore();
React.useEffect(() => {
if (!isLoggedIn && typeof window !== "undefined") {
router.push("/");
toast.error("برای دسترسی به داشبورد لطفاً ابتدا وارد حساب کاربری خود شوید.");
}
}, [isLoggedIn, router]);
const { orders } = useCartStore();
const [activeTab, setActiveTab] = React.useState<"profile" | "orders" | "wallet" | "addresses" | "tickets">("profile");
const [isLoadingOrders, setIsLoadingOrders] = useState(false);
// Fetch fresh profile data (which includes orders) whenever orders tab is opened
React.useEffect(() => {
if (activeTab === "orders") {