774 lines
40 KiB
TypeScript
774 lines
40 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
|
||
} 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 from "./AddressModal";
|
||
import SearchableSelect from "./SearchableSelect";
|
||
import { IRAN_PROVINCES, PROVINCE_CITIES } from "../lib/data/provinces";
|
||
import { toast } from "sonner";
|
||
import { useRouter } from 'next/navigation';
|
||
import api from "../lib/services/api";
|
||
|
||
export default function CheckoutPage() {
|
||
const router = useRouter();
|
||
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 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 if guest or not using stored addresses
|
||
const [manualName, setManualName] = useState('');
|
||
const [manualPhone, setManualPhone] = useState('');
|
||
const [manualProvince, setManualProvince] = useState('');
|
||
const [manualCity, setManualCity] = useState('');
|
||
const [manualZipCode, setManualZipCode] = useState('');
|
||
const [manualAddress, setManualAddress] = useState('');
|
||
|
||
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) {
|
||
// Pre-fill profile info for manual fields
|
||
setManualName(`${profile.firstName} ${profile.lastName}`.trim());
|
||
setManualPhone(profile.mobile || '');
|
||
}
|
||
});
|
||
}, [isLoggedIn, profile]);
|
||
|
||
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 handleCharityToggle = () => {
|
||
if (!useRoundUp) {
|
||
setCharityDonation(roundUpDiff);
|
||
setUseRoundUp(true);
|
||
} else {
|
||
setCharityDonation(0);
|
||
setUseRoundUp(false);
|
||
}
|
||
};
|
||
|
||
const handleFinalize = async () => {
|
||
const totalAmount = getTotal() + shippingFee;
|
||
const activePet = getActivePet();
|
||
|
||
// 1. Validate shipping address
|
||
const selectedAddr = profile.addresses?.find(a => a.id === selectedAddressId);
|
||
let shippingAddressStr = '';
|
||
if (selectedAddr) {
|
||
shippingAddressStr = `${selectedAddr.province}، ${selectedAddr.city}، ${selectedAddr.detail}${selectedAddr.zipCode ? ` (کد پستی: ${selectedAddr.zipCode})` : ''} (گیرنده: ${selectedAddr.receptorName} - ${toPersian(selectedAddr.phone)})`;
|
||
} else if (manualAddress) {
|
||
const provCity = [manualProvince, manualCity].filter(Boolean).join('، ');
|
||
shippingAddressStr = `${provCity ? provCity + '، ' : ''}${manualAddress}${manualZipCode ? ` (کد پستی: ${manualZipCode})` : ''}${manualName ? ` (گیرنده: ${manualName}${manualPhone ? ` - ${toPersian(manualPhone)}` : ''})` : ''}`;
|
||
}
|
||
|
||
if (!selectedAddressId && !manualAddress) {
|
||
toast.error("لطفاً یک آدرس برای ارسال سفارش انتخاب کرده یا آدرس جدید وارد کنید.");
|
||
return;
|
||
}
|
||
|
||
// 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: charityDonation,
|
||
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 isCatalogMode = getText('catalog_mode', 'false') === 'true';
|
||
const isCatalogDisableCheckout = getText('catalog_disable_checkout', 'false') === 'true';
|
||
const isCheckoutDisabled = isCatalogMode || isCatalogDisableCheckout;
|
||
|
||
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">
|
||
امکان ثبت سفارش آنلاین موقتاً غیرفعال است
|
||
</h2>
|
||
<p className="text-xs sm:text-sm text-medical-gray-600 font-bold leading-relaxed">
|
||
سامانه در حال حاضر در حالت کاتالوگ قرار دارد. جهت استعلام قیمت و دریافت مشاوره تخصصی میتوانید با کارشناسان ما تماس بگیرید.
|
||
</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={() => 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",
|
||
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">
|
||
<div className="flex items-center gap-2 mb-0.5">
|
||
<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>
|
||
<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="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-3">
|
||
<div>
|
||
<label htmlFor="chk-fullname" className="text-[11px] font-bold text-medical-gray-700 block mb-1">نام و نام خانوادگی *</label>
|
||
<input
|
||
id="chk-fullname"
|
||
name="name"
|
||
type="text"
|
||
autoComplete="name"
|
||
value={manualName}
|
||
onChange={(e) => setManualName(e.target.value)}
|
||
className="w-full bg-medical-gray-50/70 border border-medical-gray-200 rounded-xl py-2 px-3 text-xs focus:ring-1 focus:ring-canina-blue outline-none"
|
||
placeholder="مثلاً: پارسا آقایی"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label htmlFor="chk-tel" className="text-[11px] font-bold text-medical-gray-700 block mb-1">شماره همراه *</label>
|
||
<input
|
||
id="chk-tel"
|
||
name="tel"
|
||
type="tel"
|
||
autoComplete="tel"
|
||
value={manualPhone}
|
||
onChange={(e) => setManualPhone(e.target.value.replace(/[^0-9]/g, ''))}
|
||
className="w-full bg-medical-gray-50/70 border border-medical-gray-200 rounded-xl py-2 px-3 text-xs focus:ring-1 focus:ring-canina-blue outline-none dir-ltr text-right"
|
||
placeholder="۰۹۱۲XXXXXXX"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label htmlFor="chk-province" className="text-[11px] font-bold text-medical-gray-700 block mb-1">استان *</label>
|
||
<SearchableSelect
|
||
id="chk-province"
|
||
options={IRAN_PROVINCES}
|
||
value={manualProvince}
|
||
placeholder="انتخاب استان..."
|
||
searchPlaceholder="جستجوی استان..."
|
||
onChange={(prov) => {
|
||
const cities = PROVINCE_CITIES[prov] || [];
|
||
setManualProvince(prov);
|
||
setManualCity(cities[0] || '');
|
||
}}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label htmlFor="chk-city" className="text-[11px] font-bold text-medical-gray-700 block mb-1">شهر *</label>
|
||
{manualProvince && PROVINCE_CITIES[manualProvince] ? (
|
||
<SearchableSelect
|
||
id="chk-city"
|
||
options={PROVINCE_CITIES[manualProvince]}
|
||
value={manualCity}
|
||
placeholder="انتخاب شهر..."
|
||
searchPlaceholder="جستجوی شهر..."
|
||
onChange={(c) => setManualCity(c)}
|
||
/>
|
||
) : (
|
||
<input
|
||
id="chk-city"
|
||
type="text"
|
||
value={manualCity}
|
||
onChange={(e) => setManualCity(e.target.value)}
|
||
className="w-full bg-medical-gray-50/70 border border-medical-gray-200 rounded-xl py-2 px-3 text-xs focus:ring-1 focus:ring-canina-blue outline-none"
|
||
placeholder="نام شهر..."
|
||
/>
|
||
)}
|
||
</div>
|
||
<div className="sm:col-span-2 md:col-span-3">
|
||
<label htmlFor="chk-street" className="text-[11px] font-bold text-medical-gray-700 block mb-1">آدرس دقیق پستی *</label>
|
||
<input
|
||
id="chk-street"
|
||
name="street-address"
|
||
autoComplete="street-address"
|
||
value={manualAddress}
|
||
onChange={(e) => setManualAddress(e.target.value)}
|
||
className="w-full bg-medical-gray-50/70 border border-medical-gray-200 rounded-xl py-2 px-3 text-xs focus:ring-1 focus:ring-canina-blue outline-none"
|
||
placeholder="خیابان، کوچه، پلاک، واحد..."
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label htmlFor="chk-zipcode" className="text-[11px] font-bold text-medical-gray-700 block mb-1">کد پستی (۱۰ رقمی)</label>
|
||
<input
|
||
id="chk-zipcode"
|
||
type="text"
|
||
maxLength={10}
|
||
value={manualZipCode}
|
||
onChange={(e) => setManualZipCode(e.target.value.replace(/[^0-9]/g, ''))}
|
||
className="w-full bg-medical-gray-50/70 border border-medical-gray-200 rounded-xl py-2 px-3 text-xs focus:ring-1 focus:ring-canina-blue outline-none dir-ltr text-right"
|
||
placeholder="۱۲۳۴۵۶۷۸۹۰"
|
||
/>
|
||
</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) */}
|
||
<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>
|
||
)}
|
||
|
||
{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)}
|
||
onSave={async (newAddr) => {
|
||
try {
|
||
await useUserStore.getState().addAddress(newAddr);
|
||
const updatedProfile = useUserStore.getState().profile;
|
||
if (updatedProfile.addresses && updatedProfile.addresses.length > 0) {
|
||
setSelectedAddressId(updatedProfile.addresses[0].id);
|
||
}
|
||
setShowAddressModal(false);
|
||
} catch (e) {
|
||
console.error("Failed to add address", e);
|
||
}
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|