1336 lines
54 KiB
TypeScript
1336 lines
54 KiB
TypeScript
/**
|
||
* @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>
|
||
);
|
||
}
|