ifixkart-storefront/app/checkout/page.tsx

1336 lines
54 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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.

/**
* @page Checkout Page (`app/checkout/page.tsx`)
* @purpose Authenticated COD & Razorpay checkout featuring collapsible accordion steps,
* visual payment selection cards, order summary sidebar, trust badges.
* Performs auto-remediation of missing variants, grays out out-of-stock items, and disables checkout.
*/
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useCartStore } from '@/store/cartStore';
import { useAuthStore } from '@/store/authStore';
import { formatCurrency, getImageUrl, parseMoney } from '@/lib/utils';
import { setAccessToken } from '@/services/api/client';
import {
createCheckoutOrder,
createCustomerAddress,
fetchCustomerAddresses,
syncCartItems,
initiatePayment,
cancelPayment,
verifyPayment,
type CustomerAddress,
type CheckoutCreateResponse,
} from '@/services/api/checkoutService';
import GoogleAuthModal from '@/components/auth/GoogleAuthModal';
import { Header } from '@/layout/Header';
import { Footer } from '@/layout/Footer';
import { StickyHeaderSpacer } from '@/components/StickyHeaderSpacer';
import { PhoneInput, PincodeInput } from '@/components/PhoneInput';
import { validatePhone, validatePincode, validateAddress } from '@/lib/validation';
import { toast } from 'sonner';
import {
ShieldCheck,
CreditCard,
CheckCircle,
Lock,
Loader2,
MapPin,
Plus,
Wallet,
ShoppingBag,
ArrowRight,
ArrowLeft,
Info,
Check,
RefreshCw,
Trash2,
KeyRound,
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
type CheckoutStep = 'shipping' | 'payment' | 'review';
const STEP_META: { id: CheckoutStep; label: string; num: number }[] = [
{ id: 'shipping', label: 'Shipping', num: 1 },
{ id: 'payment', label: 'Payment', num: 2 },
{ id: 'review', label: 'Review', num: 3 },
];
export default function CheckoutPage() {
const cart = useCartStore((state) => state.cart);
const getCartTotal = useCartStore((state) => state.getCartTotal);
const clearCart = useCartStore((state) => state.clearCart);
const hasHydrated = useCartStore((state) => state._hasHydrated);
const { isAuthenticated, accessToken, authReady, bootstrapAuth } = useAuthStore();
const [activeStep, setActiveStep] = useState<CheckoutStep>('shipping');
const [authModalOpen, setAuthModalOpen] = useState(false);
const [addresses, setAddresses] = useState<CustomerAddress[]>([]);
const [selectedAddressId, setSelectedAddressId] = useState<string | null>(null);
const [paymentMethod, setPaymentMethod] = useState<'COD' | 'RAZORPAY'>('COD');
const [loadingAddresses, setLoadingAddresses] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [orderResult, setOrderResult] = useState<CheckoutCreateResponse | null>(null);
const [showAddressForm, setShowAddressForm] = useState(false);
const [outOfStockVariantIds, setOutOfStockVariantIds] = useState<Set<string>>(
new Set()
);
const [addressForm, setAddressForm] = useState({
address_type: 'SHIPPING',
full_name: '',
phone: '',
street_address: '',
city: '',
state: '',
pincode: '',
is_default: true,
});
const subtotal = getCartTotal();
// Free shipping on INR orders over ₹999; ₹99 flat shipping for orders below ₹999
const shipping = subtotal > 0 && subtotal < 999 ? 99.0 : 0;
const tax = Math.round(subtotal * 0.18 * 100) / 100;
const total = subtotal + shipping + tax;
const hasOutOfStockItems = cart.some((item) =>
outOfStockVariantIds.has(item.selectedVariant.variant_id)
);
useEffect(() => {
void bootstrapAuth();
}, [bootstrapAuth]);
useEffect(() => {
if (accessToken) {
setAccessToken(accessToken);
}
}, [accessToken]);
useEffect(() => {
if (!authReady) return;
if (!isAuthenticated) {
toast.message('Please sign in to continue', {
description: 'You need to log in before checkout.',
});
setAuthModalOpen(true);
return;
}
setAuthModalOpen(false);
loadAddresses();
void syncAndValidateCart();
}, [isAuthenticated, authReady]);
const syncAndValidateCart = async () => {
if (!isAuthenticated) return;
setErrorMsg(null);
setSubmitting(true);
let attempts = 0;
const maxAttempts = 15;
let localCart = [...cart];
while (attempts < maxAttempts) {
if (localCart.length === 0) {
setSubmitting(false);
return;
}
const itemsToSync = localCart
.filter(
(item) => !outOfStockVariantIds.has(item.selectedVariant.variant_id)
)
.map((item) => ({
variant_id: item.selectedVariant.variant_id,
qty: item.quantity,
}));
try {
if (itemsToSync.length > 0) {
await syncCartItems(itemsToSync);
}
const response = await fetch('/api/v1/cart', {
headers: { Authorization: `Bearer ${accessToken || ''}` },
});
if (response.ok) {
const serverCartData = await response.json();
const newOutOfStock = new Set<string>();
if (serverCartData && serverCartData.items) {
serverCartData.items.forEach((sItem: any) => {
if (
typeof sItem.available_stock === 'number' &&
sItem.available_stock <= 0
) {
newOutOfStock.add(sItem.variant_id);
}
});
setOutOfStockVariantIds(newOutOfStock);
}
}
break;
} catch (err: any) {
const detail = err.message || '';
if (
detail.includes('not found') ||
detail.includes('no longer exists') ||
detail.includes('Variant')
) {
const match =
detail.match(/Variant (\S+) not found/i) ||
detail.match(/Product variant (\S+) no longer exists/i);
const missingId = match
? match[1]
: detail.includes('Variant')
? detail.split(' ')[1]
: null;
if (missingId) {
const itemToRemove = localCart.find(
(item) =>
item.selectedVariant?.variant_id === missingId ||
item.id?.includes(missingId)
);
if (itemToRemove) {
await useCartStore.getState().removeFromCart(itemToRemove.id);
localCart = localCart.filter(
(item) => item.id !== itemToRemove.id
);
attempts++;
continue;
}
}
}
if (detail.includes('Insufficient stock')) {
const match = detail.match(/Available:\s*(\d+)/i);
const availableQty = match ? parseInt(match[1], 10) : 0;
const skuMatch = detail.match(/Insufficient stock for (\S+)\./i);
const sku = skuMatch ? skuMatch[1] : null;
if (sku) {
const itemToAdjust = localCart.find(
(item) =>
item.selectedVariant?.sku === sku ||
item.selectedVariant?.variant_id === sku
);
if (itemToAdjust) {
const variantId = itemToAdjust.selectedVariant.variant_id;
if (availableQty <= 0) {
setOutOfStockVariantIds((prev) => {
const updated = new Set(prev);
updated.add(variantId);
return updated;
});
attempts++;
continue;
} else {
await useCartStore
.getState()
.updateQuantity(itemToAdjust.id, availableQty);
localCart = localCart.map((item) =>
item.id === itemToAdjust.id
? { ...item, quantity: availableQty }
: item
);
attempts++;
continue;
}
}
}
}
setErrorMsg(detail);
break;
}
}
setSubmitting(false);
};
const loadAddresses = async () => {
setLoadingAddresses(true);
setErrorMsg(null);
try {
const addrs = await fetchCustomerAddresses();
setAddresses(addrs);
const defaultAddr = addrs.find((a) => a.is_default) || addrs[0];
setSelectedAddressId(defaultAddr?.address_id || null);
} catch (err: any) {
setErrorMsg(err.message || 'Failed to load addresses. Please sign in again.');
} finally {
setLoadingAddresses(false);
}
};
const handleSaveAddress = async (e: React.FormEvent) => {
e.preventDefault();
setErrorMsg(null);
const phoneErr = validatePhone(addressForm.phone, true);
if (phoneErr) {
setErrorMsg(phoneErr);
return;
}
const pincodeErr = validatePincode(addressForm.pincode, true);
if (pincodeErr) {
setErrorMsg(pincodeErr);
return;
}
const addressErr = validateAddress(addressForm.street_address, true);
if (addressErr) {
setErrorMsg(addressErr);
return;
}
setSubmitting(true);
try {
const created = await createCustomerAddress(addressForm);
setAddresses((prev) => [...prev, created]);
setSelectedAddressId(created.address_id);
setShowAddressForm(false);
setAddressForm({
address_type: 'SHIPPING',
full_name: '',
phone: '',
street_address: '',
city: '',
state: '',
pincode: '',
is_default: true,
});
} catch (err: any) {
setErrorMsg(err.message || 'Failed to save address');
} finally {
setSubmitting(false);
}
};
const loadRazorpayScript = (): Promise<boolean> => {
return new Promise((resolve) => {
if ((window as any).Razorpay) {
resolve(true);
return;
}
const script = document.createElement('script');
script.src = 'https://checkout.razorpay.com/v1/checkout.js';
script.onload = () => resolve(true);
script.onerror = () => resolve(false);
document.body.appendChild(script);
});
};
const handlePlaceOrder = async (e: React.FormEvent) => {
e.preventDefault();
setErrorMsg(null);
if (hasOutOfStockItems) {
setErrorMsg(
'Please remove out-of-stock items from your cart before placing your order.'
);
return;
}
if (!isAuthenticated) {
setAuthModalOpen(true);
return;
}
if (cart.length === 0) {
setErrorMsg('Your cart is empty.');
return;
}
if (!selectedAddressId) {
setErrorMsg('Please select or add a shipping address.');
return;
}
setSubmitting(true);
try {
await syncCartItems(
cart.map((item) => ({
variant_id: item.selectedVariant.variant_id,
qty: item.quantity,
}))
);
const orderData = await createCheckoutOrder({
address_id: selectedAddressId,
payment_method: paymentMethod,
});
if (paymentMethod === 'COD') {
clearCart();
setOrderResult(orderData);
setSubmitting(false);
} else {
const scriptLoaded = await loadRazorpayScript();
if (!scriptLoaded) {
throw new Error(
'Razorpay SDK failed to load. Please check your internet connection.'
);
}
const initRes = await initiatePayment(orderData.order_id);
if (initRes.zero_amount) {
clearCart();
setOrderResult({
...orderData,
payment_status: 'PAYMENT_CAPTURED',
status: 'ORDER_CONFIRMED',
invoice_id: initRes.invoice_id || orderData.invoice_id,
invoice_no: initRes.invoice_no || orderData.invoice_no,
});
setSubmitting(false);
return;
}
const selAddr = addresses.find((a) => a.address_id === selectedAddressId);
const options = {
key: initRes.rzp_key_id,
amount: initRes.amount,
currency: initRes.currency,
name: 'iFixKart',
description: `Order #${orderData.order_no}`,
order_id: initRes.rzp_order_id,
redirect: false,
prefill: {
name: selAddr?.full_name || '',
contact: selAddr?.phone || '',
},
theme: {
color: '#1976F3',
},
handler: async function (response: any) {
try {
setSubmitting(true);
const verifyRes = await verifyPayment({
order_id: orderData.order_id,
razorpay_order_id: response.razorpay_order_id,
razorpay_payment_id: response.razorpay_payment_id,
razorpay_signature: response.razorpay_signature,
});
clearCart();
setOrderResult({
...orderData,
payment_status: 'PAYMENT_CAPTURED',
status: 'ORDER_CONFIRMED',
invoice_no: verifyRes.invoice_no || orderData.invoice_no,
});
} catch (vErr: any) {
setErrorMsg(vErr.message || 'Payment verification failed.');
} finally {
setSubmitting(false);
}
},
modal: {
escape: true,
handleback: true,
confirm_close: true,
ondismiss: async function () {
setSubmitting(false);
setErrorMsg('Payment popup was closed before completion. Order cancelled.');
try {
await cancelPayment(orderData.order_id, 'Payment popup closed by customer');
} catch (cErr) {
console.error('Failed to cancel payment session:', cErr);
}
},
},
};
const rzp = new (window as any).Razorpay(options);
rzp.open();
}
} catch (err: any) {
const detail = err.message || 'Failed to place order';
if (
typeof detail === 'string' &&
(detail.includes('no longer exists') ||
detail.includes('not found') ||
detail.includes('Variant'))
) {
const match =
detail.match(/Product variant (\S+) no longer exists/i) ||
detail.match(/Variant (\S+) not found/i);
const missingId = match
? match[1]
: detail.includes('Variant')
? detail.split(' ')[1]
: null;
if (missingId) {
const itemToRemove = cart.find(
(item) =>
item.selectedVariant?.variant_id === missingId ||
item.id?.includes(missingId)
);
if (itemToRemove) {
void useCartStore.getState().removeFromCart(itemToRemove.id);
setErrorMsg(
`Stale item "${itemToRemove.product.name}" was automatically removed from your cart because it no longer exists. Please review your order and try again.`
);
setSubmitting(false);
void syncAndValidateCart();
return;
}
}
}
setErrorMsg(typeof detail === 'string' ? detail : 'Failed to place order');
setSubmitting(false);
}
};
const currentSelectedAddress = addresses.find(
(a) => a.address_id === selectedAddressId
);
const stepIndex = STEP_META.findIndex((s) => s.id === activeStep);
const itemImage = (item: (typeof cart)[number]) => {
const raw =
(item.product as any)?.images?.[0]?.image_url ||
(item.product as any)?.thumbnail_url ||
'';
return getImageUrl(raw) || '';
};
if (!hasHydrated || !authReady) {
return (
<div className="w-full min-h-screen bg-[#F8F9FB] flex items-center justify-center">
<Loader2 className="w-8 h-8 text-primary animate-spin" />
</div>
);
}
if (orderResult) {
return (
<div className="w-full min-h-screen bg-[#F8F9FB] flex flex-col">
<Header />
<StickyHeaderSpacer />
<div className="flex-grow flex items-center justify-center p-4 py-12">
<motion.div
initial={{ opacity: 0, scale: 0.97 }}
animate={{ opacity: 1, scale: 1 }}
className="bg-white border border-gray-200 rounded-xl p-8 max-w-md w-full text-center shadow-sm"
>
<div className="w-16 h-16 rounded-full bg-emerald-50 text-emerald-600 flex items-center justify-center mx-auto mb-4">
<CheckCircle className="w-8 h-8" />
</div>
<h1 className="text-xl font-bold text-[#1a1a1a]">
Order placed successfully
</h1>
<p className="text-[13px] text-gray-500 mt-3 leading-relaxed">
Thank you for shopping at iFixKart. Your order{' '}
<strong className="text-[#1a1a1a]">{orderResult.order_no}</strong>{' '}
is confirmed.
</p>
{orderResult.invoice_no && (
<div className="mt-4 inline-flex items-center gap-1.5 px-3 py-1.5 bg-emerald-50 text-emerald-700 text-[11px] font-semibold rounded-md border border-emerald-100">
<Check className="w-3 h-3" /> Invoice: {orderResult.invoice_no}
</div>
)}
<div className="mt-6 flex flex-col gap-2.5">
<Link
href="/account"
className="bg-primary hover:brightness-95 text-white text-[13px] font-bold py-3 rounded-lg transition"
>
Track order status
</Link>
<Link
href="/shop"
className="bg-gray-100 hover:bg-gray-150 text-[#1a1a1a] text-[13px] font-bold py-3 rounded-lg transition"
>
Continue shopping
</Link>
</div>
</motion.div>
</div>
<Footer />
</div>
);
}
if (!isAuthenticated) {
return (
<div className="w-full min-h-screen bg-[#F8F9FB] flex flex-col">
<Header />
<StickyHeaderSpacer />
<div className="flex-grow flex items-center justify-center p-4 py-12">
<div className="max-w-[440px] w-full bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden">
<div className="px-6 pt-8 pb-2 text-center space-y-3">
<div className="text-[24px] font-extrabold tracking-tight leading-none">
<span className="text-primary">iFix</span>
<span className="text-[#1a1a1a]">Kart</span>
</div>
<div className="w-14 h-14 bg-primary-light text-primary rounded-xl flex items-center justify-center mx-auto">
<Lock className="w-7 h-7" />
</div>
<div className="space-y-1.5">
<h2 className="text-[20px] font-bold text-[#1a1a1a]">
Sign in to checkout
</h2>
<p className="text-[13px] text-gray-500 leading-relaxed px-2">
Authenticate securely to choose a shipping address and complete
your order.
</p>
</div>
</div>
<div className="px-6 py-6 space-y-4">
<button
type="button"
onClick={() => setAuthModalOpen(true)}
className="w-full bg-primary hover:brightness-95 text-white font-bold py-3.5 rounded-lg text-[14px] transition cursor-pointer shadow-sm flex items-center justify-center gap-2"
>
<KeyRound className="w-4 h-4" />
Continue with Google
</button>
<p className="text-[11px] text-gray-400 text-center flex items-center justify-center gap-1.5">
<ShieldCheck className="w-3.5 h-3.5 text-emerald-500" />
Secure OAuth 2.0 · SSL encrypted checkout
</p>
<Link
href="/cart"
className="block text-center text-[13px] font-semibold text-primary hover:underline"
>
Back to cart
</Link>
</div>
</div>
</div>
<GoogleAuthModal
isOpen={authModalOpen}
onClose={() => setAuthModalOpen(false)}
onSuccess={() => {
setAuthModalOpen(false);
loadAddresses();
}}
/>
<Footer />
</div>
);
}
return (
<div className="w-full min-h-screen bg-[#F8F9FB] flex flex-col">
<Header />
<StickyHeaderSpacer />
<main className="flex-grow py-8 md:py-10">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 space-y-6">
{/* Header + stepper */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<h1 className="text-[22px] md:text-2xl font-bold text-[#1a1a1a] flex items-center gap-2.5">
<span className="w-9 h-9 rounded-lg bg-primary-light text-primary flex items-center justify-center">
<Lock className="w-4.5 h-4.5" />
</span>
Secure Checkout
</h1>
<div className="flex items-center gap-1 sm:gap-2">
{STEP_META.map((step, i) => {
const done = i < stepIndex;
const active = step.id === activeStep;
const clickable =
step.id === 'shipping' ||
(step.id === 'payment' && selectedAddressId) ||
(step.id === 'review' && activeStep === 'review');
return (
<div key={step.id} className="flex items-center gap-1 sm:gap-2">
{i > 0 && (
<div
className={`hidden sm:block w-8 h-px ${
done || active ? 'bg-primary' : 'bg-gray-200'
}`}
/>
)}
<button
type="button"
disabled={!clickable && !done}
onClick={() => {
if (step.id === 'shipping') setActiveStep('shipping');
if (step.id === 'payment' && selectedAddressId)
setActiveStep('payment');
if (step.id === 'review' && activeStep === 'review')
setActiveStep('review');
}}
className={`flex items-center gap-1.5 text-[12px] font-semibold transition-colors ${
active
? 'text-primary'
: done
? 'text-[#1a1a1a]'
: 'text-gray-400'
}`}
>
<span
className={`w-6 h-6 rounded-full flex items-center justify-center text-[11px] font-bold border ${
active
? 'bg-primary border-primary text-white'
: done
? 'bg-primary/10 border-primary text-primary'
: 'bg-white border-gray-200 text-gray-400'
}`}
>
{done ? <Check className="w-3 h-3" /> : step.num}
</span>
<span className="hidden sm:inline">{step.label}</span>
</button>
</div>
);
})}
</div>
</div>
{errorMsg && (
<div className="bg-red-50 border border-red-100 text-red-700 text-[13px] font-medium px-4 py-3 rounded-lg flex items-start gap-2">
<Info className="w-4 h-4 text-red-500 shrink-0 mt-0.5" />
{errorMsg}
</div>
)}
{hasOutOfStockItems && (
<div className="bg-amber-50 border border-amber-100 text-amber-900 text-[13px] font-medium px-4 py-3 rounded-lg flex items-start gap-2">
<Info className="w-4 h-4 text-amber-600 shrink-0 mt-0.5" />
Please remove all out-of-stock items from your cart before placing
the order.
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 lg:gap-8 items-start">
{/* Left: steps */}
<div className="lg:col-span-2 space-y-6">
<AnimatePresence mode="wait">
{activeStep === 'shipping' && (
<motion.div
key="shipping"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.18 }}
className="bg-white border border-gray-200 rounded-xl p-5 md:p-6 shadow-sm space-y-5"
>
<div className="flex items-center justify-between border-b border-gray-100 pb-4">
<h2 className="text-[15px] font-bold text-[#1a1a1a] flex items-center gap-2">
<MapPin className="w-4.5 h-4.5 text-primary" />
Shipping address
</h2>
<button
type="button"
onClick={() => setShowAddressForm(true)}
className="text-[12px] font-bold text-primary flex items-center gap-1 hover:underline cursor-pointer"
>
<Plus className="w-3.5 h-3.5" /> Add address
</button>
</div>
{loadingAddresses ? (
<div className="flex items-center gap-2 text-gray-400 py-10 justify-center text-[13px]">
<Loader2 className="w-5 h-5 animate-spin text-primary" />
Loading addresses...
</div>
) : addresses.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{addresses.map((addr) => {
const isSelected =
selectedAddressId === addr.address_id;
return (
<label
key={addr.address_id}
className={`relative flex flex-col justify-between p-4 border rounded-lg cursor-pointer transition-all ${
isSelected
? 'border-primary bg-primary/5 ring-1 ring-primary'
: 'border-gray-200 bg-white hover:border-gray-300'
}`}
>
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-2">
<span className="font-bold text-[#1a1a1a] text-[12px] uppercase tracking-wide">
{addr.address_type || 'Address'}
</span>
{addr.is_default && (
<span className="bg-gray-100 text-gray-600 text-[9px] font-bold tracking-wide uppercase px-1.5 py-0.5 rounded">
Default
</span>
)}
</div>
<p className="text-[13px] font-semibold text-[#1a1a1a]">
{addr.full_name}
</p>
<p className="text-[12px] text-gray-500 leading-relaxed">
{addr.street_address}, {addr.city}
<br />
{addr.state} - {addr.pincode}
</p>
</div>
<div className="mt-3 pt-3 border-t border-gray-100 flex justify-between items-center">
<span className="text-[12px] text-gray-500">
{addr.phone}
</span>
<input
type="radio"
name="address"
checked={isSelected}
onChange={() =>
setSelectedAddressId(addr.address_id)
}
className="accent-[var(--color-primary,#1976F3)] w-4 h-4 cursor-pointer"
/>
</div>
</label>
);
})}
</div>
) : (
<div className="text-center py-10 bg-gray-50 rounded-lg border border-dashed border-gray-200">
<MapPin className="w-8 h-8 text-gray-300 mx-auto mb-2" />
<p className="text-[13px] text-gray-500">
No saved shipping addresses found.
</p>
<button
type="button"
onClick={() => setShowAddressForm(true)}
className="mt-3 bg-primary text-white text-[12px] font-bold px-4 py-2 rounded-lg hover:brightness-95"
>
Add new address
</button>
</div>
)}
<div className="flex justify-end pt-4 border-t border-gray-100">
<button
type="button"
disabled={!selectedAddressId}
onClick={() => setActiveStep('payment')}
className="bg-primary hover:brightness-95 text-white font-bold text-[13px] py-3 px-5 rounded-lg transition disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5 cursor-pointer"
>
Continue to payment <ArrowRight className="w-4 h-4" />
</button>
</div>
</motion.div>
)}
{activeStep === 'payment' && (
<motion.div
key="payment"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.18 }}
className="bg-white border border-gray-200 rounded-xl p-5 md:p-6 shadow-sm space-y-5"
>
<div className="border-b border-gray-100 pb-4">
<h2 className="text-[15px] font-bold text-[#1a1a1a] flex items-center gap-2">
<CreditCard className="w-4.5 h-4.5 text-primary" />
Payment method
</h2>
</div>
<div className="space-y-3">
<label
className={`flex items-start gap-3 p-4 border rounded-lg cursor-pointer transition-all ${
paymentMethod === 'RAZORPAY'
? 'border-primary bg-primary/5 ring-1 ring-primary'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<input
type="radio"
name="payment"
value="RAZORPAY"
checked={paymentMethod === 'RAZORPAY'}
onChange={() => setPaymentMethod('RAZORPAY')}
className="mt-1 accent-[var(--color-primary,#1976F3)] w-4 h-4"
/>
<div className="flex-1 space-y-1.5">
<div className="flex items-center justify-between gap-2">
<span className="font-bold text-[#1a1a1a] text-[13px] flex items-center gap-2">
Online payment
<span className="bg-emerald-50 text-emerald-700 text-[9px] font-bold px-1.5 py-0.5 rounded uppercase">
Instant
</span>
</span>
<Wallet className="w-5 h-5 text-primary" />
</div>
<p className="text-[12px] text-gray-500 leading-relaxed">
Cards, net banking, wallets, or UPI (GPay, PhonePe,
Paytm).
</p>
</div>
</label>
<label
className={`flex items-start gap-3 p-4 border rounded-lg cursor-pointer transition-all ${
paymentMethod === 'COD'
? 'border-primary bg-primary/5 ring-1 ring-primary'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<input
type="radio"
name="payment"
value="COD"
checked={paymentMethod === 'COD'}
onChange={() => setPaymentMethod('COD')}
className="mt-1 accent-[var(--color-primary,#1976F3)] w-4 h-4"
/>
<div className="flex-1 space-y-1.5">
<div className="flex items-center justify-between gap-2">
<span className="font-bold text-[#1a1a1a] text-[13px]">
Cash on delivery
</span>
<CreditCard className="w-5 h-5 text-gray-400" />
</div>
<p className="text-[12px] text-gray-500 leading-relaxed">
Pay in cash when your order arrives at the door.
</p>
</div>
</label>
</div>
<div className="flex items-center justify-between pt-4 border-t border-gray-100">
<button
type="button"
onClick={() => setActiveStep('shipping')}
className="text-gray-500 hover:text-[#1a1a1a] font-semibold text-[13px] flex items-center gap-1 cursor-pointer"
>
<ArrowLeft className="w-4 h-4" /> Back
</button>
<button
type="button"
onClick={() => setActiveStep('review')}
className="bg-primary hover:brightness-95 text-white font-bold text-[13px] py-3 px-5 rounded-lg transition flex items-center gap-1.5 cursor-pointer"
>
Review order <ArrowRight className="w-4 h-4" />
</button>
</div>
</motion.div>
)}
{activeStep === 'review' && (
<motion.div
key="review"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.18 }}
className="bg-white border border-gray-200 rounded-xl p-5 md:p-6 shadow-sm space-y-5"
>
<div className="border-b border-gray-100 pb-4">
<h2 className="text-[15px] font-bold text-[#1a1a1a] flex items-center gap-2">
<ShoppingBag className="w-4.5 h-4.5 text-primary" />
Review & place order
</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 bg-gray-50 p-4 rounded-lg border border-gray-100 text-[13px]">
<div className="space-y-1.5">
<h4 className="font-bold text-gray-500 text-[11px] uppercase tracking-wide">
Delivery
</h4>
{currentSelectedAddress ? (
<div className="space-y-1">
<p className="font-semibold text-[#1a1a1a]">
{currentSelectedAddress.full_name}
</p>
<p className="text-gray-500 text-[12px] leading-relaxed">
{currentSelectedAddress.street_address},{' '}
{currentSelectedAddress.city}
<br />
{currentSelectedAddress.state} -{' '}
{currentSelectedAddress.pincode}
</p>
<p className="text-gray-400 text-[12px]">
{currentSelectedAddress.phone}
</p>
</div>
) : (
<p className="text-red-500">No address selected.</p>
)}
</div>
<div className="space-y-1.5 border-t md:border-t-0 md:border-l border-gray-200 pt-3 md:pt-0 md:pl-4">
<h4 className="font-bold text-gray-500 text-[11px] uppercase tracking-wide">
Payment
</h4>
<p className="font-semibold text-[#1a1a1a] flex items-center gap-2">
{paymentMethod === 'RAZORPAY'
? 'Online payment'
: 'Cash on delivery'}
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded uppercase bg-primary/10 text-primary">
{paymentMethod}
</span>
</p>
<p className="text-gray-500 text-[12px]">
{paymentMethod === 'RAZORPAY'
? 'Secured via Razorpay.'
: 'Pay in cash on delivery.'}
</p>
</div>
</div>
<div className="flex items-center justify-between pt-4 border-t border-gray-100">
<button
type="button"
onClick={() => setActiveStep('payment')}
className="text-gray-500 hover:text-[#1a1a1a] font-semibold text-[13px] flex items-center gap-1 cursor-pointer"
>
<ArrowLeft className="w-4 h-4" /> Back
</button>
<button
type="button"
disabled={
submitting || cart.length === 0 || hasOutOfStockItems
}
onClick={handlePlaceOrder}
className="bg-primary hover:brightness-95 disabled:opacity-50 text-white font-bold text-[13px] py-3 px-6 rounded-lg transition flex items-center gap-1.5 cursor-pointer disabled:cursor-not-allowed"
>
{submitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />{' '}
Processing...
</>
) : paymentMethod === 'RAZORPAY' ? (
<>Pay {formatCurrency(total)}</>
) : (
<>Place order · {formatCurrency(total)}</>
)}
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Right: summary */}
<div className="lg:col-span-1 lg:sticky lg:top-28 space-y-4">
<div className="bg-white border border-gray-200 rounded-xl p-5 shadow-sm text-[13px] space-y-4">
<h3 className="text-[14px] font-bold text-[#1a1a1a] border-b border-gray-100 pb-3 flex items-center gap-2">
<ShoppingBag className="w-4 h-4 text-primary" />
Order summary
</h3>
{cart.length === 0 ? (
<p className="text-gray-400">
Cart is empty.{' '}
<Link
href="/shop"
className="text-primary font-semibold hover:underline"
>
Continue shopping
</Link>
</p>
) : (
<div className="space-y-3 max-h-56 overflow-y-auto pr-1">
{cart.map((item) => {
const isOutOfStock = outOfStockVariantIds.has(
item.selectedVariant.variant_id
);
const img = itemImage(item);
return (
<div
key={item.id}
className={`flex gap-3 items-center ${
isOutOfStock ? 'opacity-55' : ''
}`}
>
<div className="w-12 h-12 bg-gray-50 rounded-md flex items-center justify-center shrink-0 border border-gray-100 overflow-hidden relative">
{img ? (
<img
src={img}
alt=""
className="w-full h-full object-contain"
/>
) : (
<ShoppingBag className="w-4 h-4 text-gray-300" />
)}
{isOutOfStock && (
<div className="absolute inset-0 bg-red-500/10 flex items-center justify-center">
<span className="bg-red-500 text-white text-[7px] font-bold uppercase px-1 py-0.5 rounded">
OOS
</span>
</div>
)}
</div>
<div className="flex-1 min-w-0">
<span
className={`font-semibold block truncate text-[12px] ${
isOutOfStock
? 'line-through text-gray-400'
: 'text-[#1a1a1a]'
}`}
>
{item.product.name}
</span>
<span className="text-[11px] text-gray-500">
Qty {item.quantity} ×{' '}
{formatCurrency(
parseMoney(item.selectedVariant.price)
)}
</span>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<span
className={`font-bold text-[12px] ${
isOutOfStock
? 'line-through text-gray-400'
: 'text-[#1a1a1a]'
}`}
>
{formatCurrency(
parseMoney(item.selectedVariant.price) *
item.quantity
)}
</span>
<button
type="button"
onClick={async () => {
await useCartStore
.getState()
.removeFromCart(item.id);
void syncAndValidateCart();
}}
title="Remove item"
className="text-gray-400 hover:text-red-500 p-1 rounded hover:bg-gray-50 cursor-pointer"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
})}
</div>
)}
<div className="space-y-2 text-[#1a1a1a] border-t border-gray-100 pt-3">
<div className="flex justify-between text-gray-500">
<span>Subtotal</span>
<span className="font-semibold text-[#1a1a1a]">
{formatCurrency(subtotal)}
</span>
</div>
<div className="flex justify-between text-gray-500">
<span>GST (18%)</span>
<span className="font-semibold text-[#1a1a1a]">
{formatCurrency(tax)}
</span>
</div>
<div className="flex justify-between text-gray-500 items-center">
<span>Shipping</span>
<span>
{shipping === 0 ? (
<strong className="text-emerald-600 text-[11px] font-bold uppercase">
Free
</strong>
) : (
<span className="font-semibold text-[#1a1a1a]">
{formatCurrency(shipping)}
</span>
)}
</span>
</div>
</div>
<div className="border-t border-gray-200 pt-3 flex justify-between items-center">
<span className="font-bold text-[#1a1a1a]">Total</span>
<span className="text-lg font-bold text-primary">
{formatCurrency(total)}
</span>
</div>
</div>
<div className="bg-white border border-gray-200 rounded-xl p-5 shadow-sm space-y-3.5">
<h4 className="text-[12px] font-bold text-[#1a1a1a] uppercase tracking-wide border-b border-gray-100 pb-2">
Why shop with us
</h4>
<div className="space-y-3">
<div className="flex items-start gap-2.5">
<ShieldCheck className="w-4.5 h-4.5 text-emerald-500 shrink-0 mt-0.5" />
<div>
<span className="font-semibold text-[#1a1a1a] text-[12px] block">
SSL encrypted checkout
</span>
<span className="text-[11px] text-gray-500 leading-relaxed block">
Your payment details stay protected.
</span>
</div>
</div>
<div className="flex items-start gap-2.5">
<ShoppingBag className="w-4.5 h-4.5 text-primary shrink-0 mt-0.5" />
<div>
<span className="font-semibold text-[#1a1a1a] text-[12px] block">
Genuine products
</span>
<span className="text-[11px] text-gray-500 leading-relaxed block">
Authentic spares and accessories.
</span>
</div>
</div>
<div className="flex items-start gap-2.5">
<RefreshCw className="w-4.5 h-4.5 text-gray-500 shrink-0 mt-0.5" />
<div>
<span className="font-semibold text-[#1a1a1a] text-[12px] block">
Easy returns
</span>
<span className="text-[11px] text-gray-500 leading-relaxed block">
Hassle-free return window on eligible orders.
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
{showAddressForm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 overflow-y-auto p-4">
<motion.div
initial={{ opacity: 0, scale: 0.97 }}
animate={{ opacity: 1, scale: 1 }}
className="bg-white border border-gray-200 rounded-xl w-full max-w-md text-left shadow-lg flex flex-col overflow-hidden"
>
<div className="p-4 border-b border-gray-100 flex justify-between items-center bg-gray-50">
<div className="flex items-center gap-2">
<MapPin className="w-4.5 h-4.5 text-primary" />
<h3 className="text-[14px] font-bold text-[#1a1a1a]">
Add shipping address
</h3>
</div>
<button
type="button"
onClick={() => setShowAddressForm(false)}
className="p-1 rounded text-gray-400 hover:text-gray-700 text-lg cursor-pointer leading-none"
>
×
</button>
</div>
<form onSubmit={handleSaveAddress} className="p-5 space-y-3.5 text-[13px]">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="block font-semibold text-[#1a1a1a] text-[11px]">
Full name
</label>
<input
required
placeholder="Receiver name"
value={addressForm.full_name}
onChange={(e) =>
setAddressForm({
...addressForm,
full_name: e.target.value,
})
}
className="w-full bg-gray-50 border border-gray-200 rounded-lg p-2.5 outline-none focus:border-primary focus:ring-2 focus:ring-primary/15 text-[#1a1a1a]"
/>
</div>
<div className="space-y-1">
<PhoneInput
label="Phone"
required
value={addressForm.phone}
onChange={(val) => setAddressForm({ ...addressForm, phone: val })}
/>
</div>
</div>
<div className="space-y-1">
<label className="block font-semibold text-[#1a1a1a] text-[11px]">
Street address
</label>
<input
required
placeholder="House no, building, street"
value={addressForm.street_address}
onChange={(e) =>
setAddressForm({
...addressForm,
street_address: e.target.value,
})
}
className="w-full bg-gray-50 border border-gray-200 rounded-lg p-2.5 outline-none focus:border-primary focus:ring-2 focus:ring-primary/15 text-[#1a1a1a]"
/>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1">
<label className="block font-semibold text-[#1a1a1a] text-[11px]">
City
</label>
<input
required
placeholder="City"
value={addressForm.city}
onChange={(e) =>
setAddressForm({ ...addressForm, city: e.target.value })
}
className="w-full bg-gray-50 border border-gray-200 rounded-lg p-2.5 outline-none focus:border-primary focus:ring-2 focus:ring-primary/15 text-[#1a1a1a]"
/>
</div>
<div className="space-y-1">
<label className="block font-semibold text-[#1a1a1a] text-[11px]">
State
</label>
<input
required
placeholder="State"
value={addressForm.state}
onChange={(e) =>
setAddressForm({ ...addressForm, state: e.target.value })
}
className="w-full bg-gray-50 border border-gray-200 rounded-lg p-2.5 outline-none focus:border-primary focus:ring-2 focus:ring-primary/15 text-[#1a1a1a]"
/>
</div>
<div>
<PincodeInput
label="Pincode"
required
value={addressForm.pincode}
onChange={(val) => setAddressForm({ ...addressForm, pincode: val })}
/>
</div>
</div>
<div className="flex gap-2 justify-end pt-3 border-t border-gray-100">
<button
type="button"
onClick={() => setShowAddressForm(false)}
className="px-4 py-2.5 border border-gray-200 rounded-lg text-gray-600 font-semibold hover:bg-gray-50 transition cursor-pointer"
>
Cancel
</button>
<button
type="submit"
disabled={submitting}
className="px-4 py-2.5 bg-primary hover:brightness-95 text-white rounded-lg font-bold transition cursor-pointer disabled:opacity-50"
>
{submitting ? 'Saving...' : 'Save address'}
</button>
</div>
</form>
</motion.div>
</div>
)}
<GoogleAuthModal
isOpen={authModalOpen}
onClose={() => setAuthModalOpen(false)}
onSuccess={() => {
setAuthModalOpen(false);
loadAddresses();
}}
/>
<Footer />
</div>
);
}