canina/frontend/application/components/CheckoutPage.tsx
parsaaghayi dc88677169
Some checks failed
Deploy Canina / deploy (push) Waiting to run
E2E Playwright Tests / Run Full E2E & Security Suites (push) Failing after 11s
fix(security): resolve all critical, high, and medium security vulnerabilities
2026-09-23 22:05:12 +03:30

933 lines
48 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import React, { useState, useEffect } from "react";
import { motion } from "motion/react";
import {
ChevronRight,
ChevronLeft,
CreditCard,
Truck,
ShieldCheck,
Wallet,
Clock,
ArrowRight,
Heart,
PlusCircle,
Copy,
Phone,
AlertCircle,
Edit2
} from "lucide-react";
import { toPersian, cn } from "../lib/utils";
import { PRODUCTS } from "../lib/data/products";
import { useCartStore } from "../lib/store/cartStore";
import { usePetStore } from "../lib/store/usePetStore";
import { useUserStore } from "../lib/store/userStore";
import { useSettingsStore } from "../lib/store/settingsStore";
import TopUpModal from "./TopUpModal";
import AddressModal, { AddressFormData, validateAddressForm, normalizeDigits } from "./AddressModal";
import SearchableSelect from "./SearchableSelect";
import { IRAN_PROVINCES, PROVINCE_CITIES } from "../lib/data/provinces";
import { useCatalogMode } from "../lib/useCatalogMode";
import { toast } from "sonner";
import { useRouter } from 'next/navigation';
import api from "../lib/services/api";
export default function CheckoutPage() {
const router = useRouter();
const { showPrices } = useCatalogMode();
const { items, getTotal, getSubtotal, isSubscribed, charityDonation, setCharityDonation, addOrder, clearCart } = useCartStore();
const { pets, activePetId, getActivePet, updatePet } = usePetStore();
const { profile, isLoggedIn } = useUserStore();
const [isHydrated, setIsHydrated] = useState(false);
const [loading, setLoading] = useState(false);
useEffect(() => {
setIsHydrated(true);
}, []);
const [useRoundUp, setUseRoundUp] = useState(false);
const [paymentMethod, setPaymentMethod] = useState<string>('card');
const [showTopUpModal, setShowTopUpModal] = useState(false);
const [showAddressModal, setShowAddressModal] = useState(false);
const [editingAddress, setEditingAddress] = useState<any>(null);
const isRefillEnabled = useSettingsStore((state) => state.getText('REFILL_SUBSCRIPTION_ENABLED', 'false') === 'true');
const refillPercent = Number(useSettingsStore((state) => state.getText('REFILL_REWARD_PERCENT', '5'))) || 5;
const [selectedPetId, setSelectedPetId] = useState<string | null>(activePetId || (pets[0]?.id ?? null));
const [selectedAddressId, setSelectedAddressId] = useState<string>('');
// Custom manual address state matching AddressModal if guest or not using stored addresses
const [manualAddressData, setManualAddressData] = useState<AddressFormData>({
title: 'منزل اصلی',
receptorName: '',
phone: '',
province: '',
city: '',
detail: '',
zipCode: '',
isDefault: true
});
const [manualErrors, setManualErrors] = useState<Record<string, string>>({});
const handleCopy = (text: string, title: string) => {
navigator.clipboard.writeText(text);
toast.success(`${title} با موفقیت کپی شد!`);
};
useEffect(() => {
Promise.resolve().then(() => {
// Set default selected address if logged in
if (isLoggedIn && profile.addresses && profile.addresses.length > 0) {
const def = profile.addresses.find(a => a.isDefault) || profile.addresses[0];
setSelectedAddressId(def.id);
} else if (isLoggedIn && (profile.firstName || profile.mobile)) {
// Pre-fill profile info for manual fields
setManualAddressData(prev => ({
...prev,
receptorName: prev.receptorName || `${profile.firstName || ''} ${profile.lastName || ''}`.trim(),
phone: prev.phone || profile.mobile || ''
}));
}
});
}, [isLoggedIn, profile]);
const isCharityEnabled = useSettingsStore((state) =>
state.getBoolean('CHARITY_DONATION_ENABLED', true) &&
state.getBoolean('charity_donation_enabled', true) &&
state.getBoolean('charityDonationEnabled', true)
);
const roundStep = Number(useSettingsStore((state) => state.getText('CHARITY_ROUND_STEP', '10000'))) || 10000;
const shippingFeeSetting = useSettingsStore((state) => state.getText('shipping_fee', '0'));
const baseShippingFee = Number(shippingFeeSetting) || 0;
const freeThreshold = Number(useSettingsStore((state) => state.getText('free_shipping_threshold', '2000000'))) || 0;
const subtotal = getSubtotal();
const shippingFee = (freeThreshold > 0 && subtotal >= freeThreshold) ? 0 : baseShippingFee;
const isCardToCardEnabled = useSettingsStore((state) =>
state.getBoolean('PAY_GATEWAY_CARD_ENABLE', true) &&
state.getBoolean('cardToCardEnabled', true) &&
state.getBoolean('cart_to_cart_enabled', true) &&
state.getBoolean('payment_card_to_card_enabled', true)
);
const remainder = subtotal % roundStep;
const roundUpDiff = remainder === 0 ? roundStep : roundStep - remainder;
const effectiveCharityDonation = isCharityEnabled ? charityDonation : 0;
const handleCharityToggle = () => {
if (!isCharityEnabled) return;
if (!useRoundUp) {
setCharityDonation(roundUpDiff);
setUseRoundUp(true);
} else {
setCharityDonation(0);
setUseRoundUp(false);
}
};
const handleFinalize = async () => {
const totalAmount = getTotal() + shippingFee;
const activePet = getActivePet();
// 0. Ensure user is logged in before placing order
if (!isLoggedIn) {
toast.error("جهت تکمیل خرید و ثبت سفارش، لطفاً ابتدا وارد حساب کاربری خود شوید.");
useUserStore.getState().setAuthModalOpen(true);
return;
}
// 1. Validate and resolve shipping address
const selectedAddr = profile.addresses?.find(a => a.id === selectedAddressId);
let shippingAddressStr = '';
if (selectedAddr) {
const cleanPhone = normalizeDigits(selectedAddr.phone);
const cleanZip = normalizeDigits(selectedAddr.zipCode);
const text = `${selectedAddr.province}، ${selectedAddr.city}، ${selectedAddr.detail}${cleanZip ? ` (کد پستی: ${cleanZip})` : ''} (گیرنده: ${selectedAddr.receptorName} - ${toPersian(cleanPhone)})`;
shippingAddressStr = JSON.stringify({
province: selectedAddr.province,
city: selectedAddr.city,
address: selectedAddr.detail,
postalCode: cleanZip,
fullName: selectedAddr.receptorName,
phone: cleanPhone,
formatted: text
});
} else {
const addrErrors = validateAddressForm(manualAddressData);
setManualErrors(addrErrors);
if (Object.keys(addrErrors).length > 0) {
toast.error("لطفاً اطلاعات آدرس و گیرنده را به صورت کامل و صحیح تکمیل کنید.");
return;
}
const cleanPhone = normalizeDigits(manualAddressData.phone);
const cleanZip = normalizeDigits(manualAddressData.zipCode);
const text = `${manualAddressData.province}، ${manualAddressData.city}، ${manualAddressData.detail}${cleanZip ? ` (کد پستی: ${cleanZip})` : ''} (گیرنده: ${manualAddressData.receptorName} - ${toPersian(cleanPhone)})`;
shippingAddressStr = JSON.stringify({
province: manualAddressData.province,
city: manualAddressData.city,
address: manualAddressData.detail,
postalCode: cleanZip,
fullName: manualAddressData.receptorName,
phone: cleanPhone,
formatted: text
});
// Auto-save the new manual address to user's permanent address list
try {
await useUserStore.getState().addAddress({
id: Math.random().toString(36).substring(2, 9),
title: manualAddressData.title || 'منزل اصلی',
receptorName: manualAddressData.receptorName,
phone: cleanPhone,
province: manualAddressData.province,
city: manualAddressData.city,
detail: manualAddressData.detail,
zipCode: cleanZip,
isDefault: manualAddressData.isDefault
});
} catch (e) {
console.error('Failed to save manual address to user account:', e);
}
// Auto-sync user's first and last name if not set or default
const currentFirst = profile.firstName?.trim() || '';
const currentLast = profile.lastName?.trim() || '';
if (!currentFirst || currentFirst === 'کاربر' || !currentLast || currentLast === 'جدید') {
const nameParts = manualAddressData.receptorName.trim().split(/\s+/);
const newFirst = nameParts[0] || 'کاربر';
const newLast = nameParts.slice(1).join(' ') || 'گرامی';
try {
await useUserStore.getState().updateProfile({
firstName: newFirst,
lastName: newLast
});
} catch (e) {
console.error('Failed to update profile names:', e);
}
}
}
// 2. Validate wallet balance if payment method is wallet
if (paymentMethod === 'wallet' && (profile.walletBalance || 0) < totalAmount) {
const neededAmount = totalAmount - (profile.walletBalance || 0);
toast.error(
`موجودی کیف پول شما (${(profile.walletBalance || 0).toLocaleString('fa-IR')} تومان) کمتر از مبلغ سفارش (${totalAmount.toLocaleString('fa-IR')} تومان) است. کادر افزایش موجودی (${neededAmount.toLocaleString('fa-IR')} تومان) باز شد.`,
);
setShowTopUpModal(true);
return;
}
setLoading(true);
try {
// 3. Register the order in the backend
const orderId = await addOrder({
items: [...items],
total: totalAmount,
charityDonation: effectiveCharityDonation,
paymentMethod: paymentMethod,
isRefill: isSubscribed,
petId: selectedPetId || undefined,
shippingAddress: shippingAddressStr
});
// 4. Handle Online Payment via Zibal IPG
if (paymentMethod === 'online') {
try {
const res = await api.post('/payment/zibal/initiate', { orderId });
if (res.data?.paymentUrl) {
toast.success("در حال انتقال به درگاه پرداخت زیبال...");
window.location.href = res.data.paymentUrl;
return;
} else {
toast.error("خطا در ایجاد تراکنش پرداخت زیبال");
}
} catch (gatewayErr: unknown) {
const gErr = gatewayErr as { response?: { data?: { message?: string } }; message?: string };
const errMsg = gErr.response?.data?.message || gErr.message || "خطا در اتصال به درگاه پرداخت زیبال";
toast.error(errMsg);
router.push(`/checkout/success/${orderId}`);
return;
}
}
// 5. Re-fetch user profile from backend to sync PostgreSQL wallet balance and charity donation total
const { fetchProfile } = useUserStore.getState();
await fetchProfile();
// 6. Update pet consumptions and charity contributions for active pet
const targetPet = activePet || usePetStore.getState().pets[0];
if (targetPet) {
const newConsumptions = [...(targetPet.consumptions || [])];
items.forEach(item => {
const existing = newConsumptions.find(c => c.productId === item.product.id);
if (existing) {
existing.remaining += item.product.packageSize * item.quantity;
existing.packageSize = item.product.packageSize;
} else {
newConsumptions.push({
productId: item.product.id,
packageSize: item.product.packageSize,
remaining: item.product.packageSize * item.quantity
});
}
});
updatePet(targetPet.id, {
consumptions: newConsumptions
});
}
clearCart();
toast.success("سفارش شما با موفقیت ثبت شد!");
router.push(`/checkout/success/${orderId}`);
} catch (err: unknown) {
// Re-fetch profile to guarantee local state is always identical to PostgreSQL
useUserStore.getState().fetchProfile().catch(() => {});
if (!(err as Record<string, boolean>)?._toastShown) {
const errorObj = err as { response?: { data?: { message?: string; details?: Array<{ message?: string }> } }; message?: string };
const backendMessage = errorObj.response?.data?.message || errorObj.response?.data?.details?.[0]?.message || errorObj.message;
if (backendMessage && !backendMessage.includes("Request failed")) {
toast.error(`خطا در ثبت سفارش: ${backendMessage}`);
}
}
} finally {
setLoading(false);
}
};
const getText = useSettingsStore(state => state.getText);
const { isCatalogOnly, allowCheckout, orderDisabledTitle, orderDisabledMessage } = useCatalogMode();
const isCheckoutDisabled = isCatalogOnly && !allowCheckout;
if (isCheckoutDisabled) {
return (
<div className="min-h-[70vh] bg-medical-gray-50 flex items-center justify-center p-6 text-center font-vazir" dir="rtl">
<div className="max-w-md bg-white border border-medical-gray-200 rounded-3xl p-8 shadow-xl space-y-6">
<div className="w-16 h-16 bg-amber-50 text-amber-600 rounded-2xl flex items-center justify-center mx-auto">
<AlertCircle className="w-8 h-8" />
</div>
<h2 className="text-xl font-black text-medical-gray-900">
{orderDisabledTitle}
</h2>
<p className="text-xs sm:text-sm text-medical-gray-600 font-bold leading-relaxed whitespace-pre-line">
{orderDisabledMessage}
</p>
<div className="flex flex-col sm:flex-row gap-3 pt-2">
<a
href={getText('contact_phone_link', `tel:${getText('contact_phone', '02188884444').replace(/[^0-9+]/g, '')}`)}
className="flex-1 px-4 py-3 bg-canina-blue hover:bg-canina-dark text-white rounded-xl text-xs font-black transition-all shadow-md flex items-center justify-center gap-2"
>
<Phone className="w-4 h-4" />
<span>تماس با پشتیبانی ({getText('contact_phone', '۰۲۱-۸۸۸۸۴۴۴۴')})</span>
</a>
<button
onClick={() => router.push('/shop')}
className="flex-1 px-4 py-3 bg-medical-gray-100 hover:bg-medical-gray-200 text-medical-gray-700 rounded-xl text-xs font-bold transition-all"
>
مشاهده کاتالوگ
</button>
</div>
</div>
</div>
);
}
if (isHydrated && items.length === 0) {
return (
<div className="min-h-screen bg-medical-gray-50 flex items-center justify-center p-6" dir="rtl">
<div className="text-center space-y-6">
<div className="w-24 h-24 bg-white rounded-full flex items-center justify-center mx-auto text-medical-gray-200">
<ShieldCheck className="w-12 h-12" />
</div>
<h2 className="text-2xl font-black text-medical-gray-900 italic">سبد خرید شما خالی است</h2>
<button onClick={() => router.push('/shop')} className="text-canina-blue font-black underline underline-offset-8">بازگشت به فروشگاه</button>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-medical-gray-50/60 py-4 sm:py-8 px-3 sm:px-6 font-vazir w-full max-w-full overflow-x-hidden text-right" dir="rtl">
<div className="max-w-7xl mx-auto w-full">
{/* Navigation & Breadcrumbs */}
<div className="flex items-center justify-between gap-2 mb-5">
<div className="flex items-center gap-2 text-xs font-bold text-medical-gray-500">
<button
onClick={() => router.back()}
className="flex items-center gap-1 hover:text-canina-blue transition-colors cursor-pointer"
>
<ChevronRight className="w-4 h-4" />
<span>بازگشت</span>
</button>
<span className="text-medical-gray-300">/</span>
<span className="cursor-pointer hover:text-canina-blue" onClick={() => router.push('/shop')}>سبد خرید</span>
<span className="text-medical-gray-300">/</span>
<span className="text-canina-blue font-black">تسویهحساب و پرداخت</span>
</div>
<div className="hidden sm:flex items-center gap-1.5 text-xs text-emerald-700 bg-emerald-50 border border-emerald-200 px-3 py-1 rounded-full font-bold">
<ShieldCheck className="w-3.5 h-3.5" />
<span>پرداخت ۱۰۰٪ امن با پروتکل SSL</span>
</div>
</div>
<div className="grid lg:grid-cols-12 gap-5 w-full items-start">
{/* Main Content (8 cols) */}
<div className="lg:col-span-8 space-y-4 w-full min-w-0">
{/* Step 1: Shipping */}
<section className="bg-white rounded-2xl border border-medical-gray-200 p-4 sm:p-6 shadow-xs relative w-full overflow-hidden">
<div className="flex items-center justify-between pb-3 mb-4 border-b border-medical-gray-100">
<div className="flex items-center gap-2.5">
<div className="w-7 h-7 bg-canina-blue text-white rounded-lg flex items-center justify-center font-black text-xs">۱</div>
<h2 className="text-base sm:text-lg font-black text-medical-gray-900">اطلاعات تحویل و گیرنده</h2>
</div>
{isLoggedIn && (
<button
type="button"
onClick={() => {
setEditingAddress(null);
setShowAddressModal(true);
}}
className="flex items-center gap-1 text-canina-blue font-bold text-xs hover:underline bg-canina-blue/5 border border-canina-blue/20 px-2.5 py-1 rounded-lg transition-all cursor-pointer"
>
<PlusCircle className="w-3.5 h-3.5" />
<span>+ افزودن آدرس</span>
</button>
)}
</div>
{isLoggedIn && profile.addresses && profile.addresses.length > 0 ? (
<div className="space-y-3">
<div className="grid sm:grid-cols-2 gap-2.5">
{profile.addresses.map((addr) => (
<div
key={addr.id}
onClick={() => setSelectedAddressId(addr.id)}
className={cn(
"p-3 rounded-xl border transition-all flex items-start gap-2.5 cursor-pointer group relative",
selectedAddressId === addr.id ? "border-canina-blue bg-canina-blue/5 shadow-xs" : "border-medical-gray-200 hover:border-medical-gray-300"
)}
>
<input
type="radio"
name="selectedAddress"
checked={selectedAddressId === addr.id}
onChange={() => setSelectedAddressId(addr.id)}
className="mt-1 shrink-0 cursor-pointer"
/>
<div className="flex-1 min-w-0 pr-1">
<div className="flex items-center justify-between mb-0.5">
<div className="flex items-center gap-2">
<span className="font-black text-xs text-medical-gray-900">{addr.title}</span>
{addr.isDefault && (
<span className="bg-canina-blue/10 text-canina-blue text-[9px] font-bold px-1.5 py-0.2 rounded-md">پیشفرض</span>
)}
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setEditingAddress(addr);
setShowAddressModal(true);
}}
className="p-1 rounded-md text-medical-gray-400 hover:text-canina-blue hover:bg-canina-blue/10 transition-colors"
title="ویرایش این آدرس"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
</div>
<p className="text-[11px] text-medical-gray-600 line-clamp-2 leading-relaxed">{addr.province}، {addr.city}، {addr.detail}</p>
<p className="text-[10px] text-medical-gray-400 font-bold mt-1">گیرنده: {addr.receptorName} ({toPersian(addr.phone)})</p>
</div>
</div>
))}
</div>
</div>
) : (
<div className="space-y-4">
{/* Row 1: Title */}
<div className="space-y-1">
<label htmlFor="chk-title" className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block pr-2">عنوان آدرس (مثلاً: خانه، محل کار)</label>
<input
id="chk-title"
name="address-line1"
autoComplete="address-line1"
type="text"
value={manualAddressData.title}
onChange={e => setManualAddressData({...manualAddressData, title: e.target.value})}
className={cn("w-full bg-medical-gray-50 border rounded-2xl py-3 px-5 outline-none font-bold transition-all focus:ring-2 focus:ring-canina-blue/20 text-xs", manualErrors.title ? "border-red-300" : "border-medical-gray-100")}
placeholder="مثال: منزل اصلی"
/>
{manualErrors.title && <p className="text-[10px] text-red-500 font-bold pr-2">{manualErrors.title}</p>}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Row 2: Name & Phone */}
<div className="space-y-1">
<label htmlFor="chk-name" className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block pr-2">نام و نام خانوادگی گیرنده *</label>
<input
id="chk-name"
name="name"
autoComplete="name"
type="text"
value={manualAddressData.receptorName}
onChange={e => setManualAddressData({...manualAddressData, receptorName: e.target.value})}
className={cn("w-full bg-medical-gray-50 border rounded-2xl py-3 px-5 outline-none font-bold transition-all focus:ring-2 focus:ring-canina-blue/20 text-xs", manualErrors.receptorName ? "border-red-300" : "border-medical-gray-100")}
placeholder="نام و نام خانوادگی..."
/>
{manualErrors.receptorName && <p className="text-[10px] text-red-500 font-bold pr-2">{manualErrors.receptorName}</p>}
</div>
<div className="space-y-1">
<label htmlFor="chk-tel" className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block pr-2">شماره همراه گیرنده *</label>
<input
id="chk-tel"
name="tel"
autoComplete="tel"
type="text"
value={manualAddressData.phone}
onChange={e => setManualAddressData({...manualAddressData, phone: e.target.value})}
className={cn("w-full bg-medical-gray-50 border rounded-2xl py-3 px-5 outline-none font-bold transition-all focus:ring-2 focus:ring-canina-blue/20 text-left text-xs", manualErrors.phone ? "border-red-300" : "border-medical-gray-100")}
placeholder="09120000000"
dir="ltr"
/>
{manualErrors.phone && <p className="text-[10px] text-red-500 font-bold pr-2">{manualErrors.phone}</p>}
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Row 3: Province & City */}
<div className="space-y-1">
<label htmlFor="chk-province" className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block pr-2">استان *</label>
<SearchableSelect
id="chk-province"
options={IRAN_PROVINCES}
value={manualAddressData.province}
placeholder="انتخاب استان..."
searchPlaceholder="جستجوی استان..."
onChange={(newProv) => {
const cities = PROVINCE_CITIES[newProv] || [];
setManualAddressData({
...manualAddressData,
province: newProv,
city: cities[0] || ""
});
}}
/>
{manualErrors.province && <p className="text-[10px] text-red-500 font-bold pr-2">{manualErrors.province}</p>}
</div>
<div className="space-y-1">
<label htmlFor="chk-city" className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block pr-2">شهر *</label>
{manualAddressData.province && PROVINCE_CITIES[manualAddressData.province] ? (
<SearchableSelect
id="chk-city"
options={PROVINCE_CITIES[manualAddressData.province]}
value={manualAddressData.city}
placeholder="انتخاب شهر..."
searchPlaceholder="جستجوی شهر..."
onChange={(c) => setManualAddressData({...manualAddressData, city: c})}
/>
) : (
<input
id="chk-city"
name="address-level2"
type="text"
value={manualAddressData.city}
onChange={e => setManualAddressData({...manualAddressData, city: e.target.value})}
className={cn("w-full bg-medical-gray-50 border rounded-2xl py-3 px-5 outline-none font-bold transition-all focus:ring-2 focus:ring-canina-blue/20 text-xs", manualErrors.city ? "border-red-300" : "border-medical-gray-100")}
placeholder="نام شهر..."
/>
)}
{manualErrors.city && <p className="text-[10px] text-red-500 font-bold pr-2">{manualErrors.city}</p>}
</div>
</div>
{/* Row 4: Detail Address */}
<div className="space-y-1">
<label htmlFor="chk-street" className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block pr-2">آدرس دقیق پستی *</label>
<textarea
id="chk-street"
name="street-address"
autoComplete="street-address"
rows={2}
value={manualAddressData.detail}
onChange={e => setManualAddressData({...manualAddressData, detail: e.target.value})}
className={cn("w-full bg-medical-gray-50 border rounded-2xl py-3 px-5 outline-none font-bold transition-all focus:ring-2 focus:ring-canina-blue/20 resize-none text-xs", manualErrors.detail ? "border-red-300" : "border-medical-gray-100")}
placeholder="خیابان، کوچه، پلاک، واحد..."
/>
{manualErrors.detail && <p className="text-[10px] text-red-500 font-bold pr-2">{manualErrors.detail}</p>}
</div>
{/* Row 5: Zip Code */}
<div className="space-y-1">
<label htmlFor="chk-zip" className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block pr-2">کد پستی (۱۰ رقمی) *</label>
<input
id="chk-zip"
name="postal-code"
autoComplete="postal-code"
type="text"
maxLength={10}
value={manualAddressData.zipCode}
onChange={e => setManualAddressData({...manualAddressData, zipCode: e.target.value.replace(/[^0-9]/g, '')})}
className={cn("w-full bg-medical-gray-50 border rounded-2xl py-3 px-5 outline-none font-bold transition-all focus:ring-2 focus:ring-canina-blue/20 text-left text-xs", manualErrors.zipCode ? "border-red-300" : "border-medical-gray-100")}
placeholder="1234567890"
dir="ltr"
/>
{manualErrors.zipCode && <p className="text-[10px] text-red-500 font-bold pr-2">{manualErrors.zipCode}</p>}
</div>
</div>
)}
</section>
{/* Pet Selection (if user has pets) */}
{pets.length > 0 && (
<section className="bg-white rounded-2xl border border-medical-gray-200 p-4 shadow-xs">
<div className="flex items-center gap-2 mb-2.5">
<span className="text-base">🐾</span>
<h3 className="text-xs font-black text-medical-gray-900">اتصال سفارش به پرونده سلامت حیوان خانگی:</h3>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setSelectedPetId(null)}
className={cn(
"px-3 py-1.5 rounded-xl border text-xs font-bold transition-all cursor-pointer",
selectedPetId === null ? "border-canina-blue bg-canina-blue/10 text-canina-blue" : "border-medical-gray-200 text-medical-gray-600 hover:bg-medical-gray-50"
)}
>
خرید عمومی (بدون پت)
</button>
{pets.map(p => (
<button
key={p.id}
type="button"
onClick={() => setSelectedPetId(p.id)}
className={cn(
"px-3 py-1.5 rounded-xl border text-xs font-bold transition-all cursor-pointer",
selectedPetId === p.id ? "border-canina-blue bg-canina-blue/10 text-canina-blue" : "border-medical-gray-200 text-medical-gray-600 hover:bg-medical-gray-50"
)}
>
{p.name} ({p.breed || p.type})
</button>
))}
</div>
</section>
)}
{/* Step 2: Payment Methods */}
<section className="bg-white rounded-2xl border border-medical-gray-200 p-4 sm:p-6 shadow-xs relative">
<div className="flex items-center gap-2.5 pb-3 mb-4 border-b border-medical-gray-100">
<div className="w-7 h-7 bg-canina-blue text-white rounded-lg flex items-center justify-center font-black text-xs">۲</div>
<h2 className="text-base sm:text-lg font-black text-medical-gray-900">شیوه پرداخت</h2>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{(() => {
const getText = useSettingsStore.getState().getText;
const allGateways = [
{
id: 'online',
label: getText('PAY_GATEWAY_ONLINE_LABEL', 'درگاه پرداخت اینترنتی (زیبال / شتاب)'),
desc: 'پرداخت آنلاین و آنی با کلیه کارت‌های شتاب',
icon: <ShieldCheck className="w-5 h-5" />,
enabled: getText('PAY_GATEWAY_ONLINE_ENABLE', 'true') === 'true'
},
{
id: 'wallet',
label: getText('PAY_GATEWAY_WALLET_LABEL', 'اعتبار حساب (کیف پول)'),
desc: `موجودی: ${toPersian((profile.walletBalance || 0).toLocaleString())} تومان`,
icon: <Wallet className="w-5 h-5" />,
enabled: getText('PAY_GATEWAY_WALLET_ENABLE', 'true') === 'true'
},
{
id: 'card',
label: getText('PAY_GATEWAY_CARD_LABEL', 'کارت به کارت (واریز مستقیم)'),
desc: getText('PAY_GATEWAY_CARD_DESC', 'واریز به شماره کارت بانک سامان'),
icon: <CreditCard className="w-5 h-5" />,
enabled: isCardToCardEnabled
},
{
id: 'cod',
label: getText('PAY_GATEWAY_COD_LABEL', 'پرداخت در محل (کارتخوان)'),
desc: getText('PAY_GATEWAY_COD_DESC', 'تحویل کالا و پرداخت درب منزل'),
icon: <Truck className="w-5 h-5" />,
enabled: getText('PAY_GATEWAY_COD_ENABLE', 'false') === 'true'
}
];
const sortedGateways = [...allGateways].sort((a, b) => (b.enabled ? 1 : 0) - (a.enabled ? 1 : 0));
return sortedGateways.map((pay) => (
<div
key={pay.id}
onClick={() => {
if (pay.enabled) {
setPaymentMethod(pay.id);
} else {
toast.info("این روش پرداخت به زودی فعال خواهد شد.");
}
}}
className={cn(
"p-3.5 rounded-xl border-2 transition-all flex items-center justify-between gap-3 relative cursor-pointer",
pay.enabled
? (paymentMethod === pay.id ? "border-canina-blue bg-canina-blue/5 shadow-xs" : "border-medical-gray-200 hover:border-canina-blue/40")
: "border-medical-gray-100 bg-medical-gray-50 opacity-60 cursor-not-allowed"
)}
>
<div className="flex items-center gap-3 min-w-0">
<div className={cn(
"w-9 h-9 rounded-xl flex items-center justify-center shrink-0",
paymentMethod === pay.id ? "bg-canina-blue text-white" : "bg-medical-gray-100 text-medical-gray-500"
)}>
{pay.icon}
</div>
<div className="min-w-0">
<h4 className="font-bold text-xs text-medical-gray-900 truncate">{pay.label}</h4>
<p className="text-[10px] text-medical-gray-500 truncate mt-0.5">{pay.desc}</p>
</div>
</div>
{pay.id === 'wallet' && pay.enabled && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setShowTopUpModal(true);
}}
className="text-[10px] bg-canina-blue text-white px-2.5 py-1 rounded-lg font-bold hover:bg-blue-700 transition-colors shrink-0 shadow-xs cursor-pointer"
>
+ افزایش
</button>
)}
</div>
));
})()}
</div>
{isCardToCardEnabled && paymentMethod === 'card' && (
<div className="mt-4 bg-blue-50/80 border border-blue-200 rounded-xl p-3.5 space-y-2">
<div className="flex items-center gap-2 text-canina-blue font-bold text-xs">
<CreditCard className="w-4 h-4" />
<span>اطلاعات کارت جهت واریز وجه سفارش:</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-xs bg-white p-3 rounded-lg border border-blue-100">
<div className="flex items-center justify-between p-2 bg-gray-50 rounded-lg">
<div>
<span className="text-[10px] text-gray-500 block">شماره کارت سامان:</span>
<span className="font-mono font-bold text-xs" dir="ltr">6219 8619 7401 2071</span>
</div>
<button
type="button"
onClick={() => handleCopy("6219861974012071", "شماره کارت")}
className="text-[10px] text-canina-blue hover:underline font-bold"
>
کپی
</button>
</div>
<div className="flex items-center justify-between p-2 bg-gray-50 rounded-lg">
<div>
<span className="text-[10px] text-gray-500 block">صاحب حساب:</span>
<span className="font-bold text-xs text-gray-900">پارسادرمانی کنینا (نوواگارد) - به نام پارسا آقایی</span>
</div>
</div>
</div>
</div>
)}
</section>
{/* Charity Donation Banner (Compact) */}
{showPrices && isCharityEnabled && (
<section className="bg-pink-50/70 border border-pink-200/80 rounded-2xl p-3.5 flex items-center justify-between gap-3">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 bg-pink-500 text-white rounded-xl flex items-center justify-center shrink-0">
<Heart className="w-4 h-4 fill-white" />
</div>
<div>
<h4 className="text-xs font-bold text-medical-gray-900">کمک به حیوانات پناهگاه (ردپای مهربانی)</h4>
<p className="text-[10px] text-medical-gray-500">رند کردن مبلغ فاکتور تا {toPersian(roundStep.toLocaleString())} تومان بعدی</p>
</div>
</div>
<button
type="button"
onClick={handleCharityToggle}
className={cn(
"px-3 py-1.5 rounded-xl text-xs font-bold transition-all shrink-0 cursor-pointer",
useRoundUp ? "bg-pink-500 text-white shadow-xs" : "bg-white text-pink-600 border border-pink-200 hover:bg-pink-50"
)}
>
{useRoundUp ? `اهدای ${toPersian(charityDonation.toLocaleString())} ت` : 'مشارکت در خیریه'}
</button>
</section>
)}
</div>
{/* Sticky Order Summary Sidebar (4 cols) */}
<div className="lg:col-span-4 w-full">
<div className="bg-medical-gray-900 rounded-2xl p-5 text-white shadow-xl sticky top-24 space-y-4">
<div className="flex items-center justify-between border-b border-white/10 pb-3">
<h3 className="text-base font-black text-white">خلاصه سفارش</h3>
<span className="text-[11px] text-white/60 font-bold">{toPersian(items.length)} قلم کالا</span>
</div>
{/* Items List (Compact) */}
<div className="space-y-2.5 max-h-48 overflow-y-auto pr-1">
{items.map((item) => (
<div key={item.product.id} className="flex justify-between items-center text-xs gap-2">
<div className="min-w-0 flex-1">
<p data-testid="checkout-item-name" className="font-bold text-white/90 truncate">{item.product.name}</p>
<p className="text-[10px] text-white/50">{toPersian(item.quantity)} بسته</p>
</div>
<span className="font-bold font-vazir text-white whitespace-nowrap">
{toPersian((item.product.priceValue * item.quantity).toLocaleString())} ت
</span>
</div>
))}
</div>
{/* Cost Breakdown */}
<div className="space-y-2 pt-3 border-t border-white/10 text-xs">
<div className="flex justify-between text-white/80">
<span>مجموع اقلام</span>
<span className="font-vazir text-white">{toPersian(getSubtotal().toLocaleString())} تومان</span>
</div>
{isRefillEnabled && isSubscribed && (
<div className="flex justify-between text-xs font-bold text-emerald-400 bg-emerald-500/10 p-2 rounded-xl border border-emerald-500/20">
<span>رزرو تمدید ({toPersian(refillPercent)}٪ پاداش خرید بعد):</span>
<span className="font-vazir">{toPersian(Math.round(getSubtotal() * (refillPercent / 100)).toLocaleString())} ت</span>
</div>
)}
{isCharityEnabled && charityDonation > 0 && (
<div className="flex justify-between text-pink-400 font-bold">
<span>ردپای مهربانی</span>
<span className="font-vazir">{toPersian(charityDonation.toLocaleString())} تومان+</span>
</div>
)}
<div className="flex justify-between text-white/80">
<span>هزینه ارسال</span>
{shippingFee > 0 ? (
<span className="font-vazir text-white">{toPersian(shippingFee.toLocaleString())} تومان</span>
) : (
<span className="text-emerald-400 font-bold">رایگان</span>
)}
</div>
<div className="flex justify-between items-center pt-3 border-t border-white/10">
<span className="text-sm font-black text-white">مبلغ قابل پرداخت:</span>
<div className="text-left">
<span className="text-2xl font-black text-canina-gold leading-none font-vazir">
{toPersian((getTotal() + shippingFee).toLocaleString())}
</span>
<span className="text-[10px] text-white/60 font-bold mr-1">تومان</span>
</div>
</div>
</div>
{/* Discount Code Input */}
<div className="pt-1">
<form
className="flex gap-2"
onSubmit={async (e) => {
e.preventDefault();
const code = (e.currentTarget.elements.namedItem('coupon') as HTMLInputElement).value;
const success = await useCartStore.getState().applyCoupon(code);
if (success) {
toast.success("کد تخفیف با موفقیت اعمال شد.");
} else {
toast.error("کد تخفیف نامعتبر است.");
}
}}
>
<input
id="chk-coupon"
name="coupon"
type="text"
autoComplete="off"
placeholder="کد تخفیف..."
className="flex-1 bg-white/10 border border-white/10 rounded-xl px-3 py-2 text-xs focus:ring-1 focus:ring-canina-blue outline-none text-white placeholder-white/40 font-mono"
/>
<button type="submit" className="bg-white/15 px-3 py-2 rounded-xl text-xs font-bold hover:bg-white/25 transition-all cursor-pointer">اعمال</button>
</form>
</div>
{/* Submit Button */}
<button
onClick={handleFinalize}
disabled={loading}
className="w-full bg-canina-blue hover:bg-blue-600 text-white py-3.5 rounded-xl font-black text-sm transition-all shadow-md shadow-canina-blue/20 flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer mt-2"
>
{loading ? (
<>
<div className="w-4 h-4 border-2 border-white/20 border-t-white rounded-full animate-spin" />
در حال ثبت...
</>
) : (
<>
<span>پرداخت و تکمیل سفارش</span>
<ArrowRight className="w-4 h-4 rotate-180" />
</>
)}
</button>
<div className="flex items-center justify-center gap-1.5 text-[10px] font-bold text-white/30 tracking-wide pt-1">
<ShieldCheck className="w-3.5 h-3.5 text-emerald-400" />
<span>تضمین امنیت پرداخت و اصالت کالا</span>
</div>
</div>
</div>
</div>
</div>
<TopUpModal
isOpen={showTopUpModal}
initialAmount={Math.max(0, (getTotal() + shippingFee) - (profile.walletBalance || 0))}
onClose={() => setShowTopUpModal(false)}
onConfirm={(amt) => {
useUserStore.setState(state => ({
profile: {
...state.profile,
walletBalance: (state.profile.walletBalance || 0) + amt
}
}));
}}
/>
<AddressModal
isOpen={showAddressModal}
onClose={() => {
setShowAddressModal(false);
setEditingAddress(null);
}}
editingAddress={editingAddress}
onSave={async (savedAddr) => {
try {
if (editingAddress) {
await useUserStore.getState().updateAddress(savedAddr.id, savedAddr);
} else {
await useUserStore.getState().addAddress(savedAddr);
}
const updatedProfile = useUserStore.getState().profile;
if (updatedProfile.addresses && updatedProfile.addresses.length > 0) {
const matched = updatedProfile.addresses.find((a) => a.id === savedAddr.id);
setSelectedAddressId(matched ? matched.id : updatedProfile.addresses[0].id);
}
setShowAddressModal(false);
setEditingAddress(null);
} catch (e) {
console.error("Failed to save address", e);
}
}}
/>
</div>
);
}