933 lines
48 KiB
TypeScript
933 lines
48 KiB
TypeScript
"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>
|
||
);
|
||
}
|