Files
WooNooW/customer-spa/src/pages/Checkout/index.tsx
Dwindi Ramadhana e8c60b3a09 fix: checkout improvements
1. Hidden fields now respected in SPA
   - Added isFieldHidden helper to check if PHP sets type to 'hidden'
   - Country/state/city fields conditionally rendered based on API response
   - Set default country value for hidden country fields (Indonesia-only stores)

2. Coupon discount now shows correct amount
   - Added calculate_totals() before reading discount
   - Changed coupons response to include {code, discount, type} per coupon
   - Added discount_total at root level for frontend compatibility

3. Order details page now shows shipping info and AWB tracking
   - Added shipping_lines, tracking_number, tracking_url to Order interface
   - Added Shipping Method section with courier name and cost
   - Added AWB tracking section for processing/completed orders
   - Track Shipment button with link to tracking URL
2026-01-08 20:51:26 +07:00

1229 lines
53 KiB
TypeScript
Raw 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.

import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useCartStore } from '@/lib/cart/store';
import { useCheckoutSettings } from '@/hooks/useAppearanceSettings';
import { Button } from '@/components/ui/button';
import { SearchableSelect } from '@/components/ui/searchable-select';
import { DynamicCheckoutField, type CheckoutField } from '@/components/DynamicCheckoutField';
import Container from '@/components/Layout/Container';
import SEOHead from '@/components/SEOHead';
import { formatPrice } from '@/lib/currency';
import { ArrowLeft, ShoppingBag, MapPin, Check, Edit2, Loader2, X, Tag } from 'lucide-react';
import { toast } from 'sonner';
import { apiClient } from '@/lib/api/client';
import { api } from '@/lib/api/client';
import { AddressSelector } from '@/components/AddressSelector';
import { applyCoupon, removeCoupon, fetchCart } from '@/lib/cart/api';
interface SavedAddress {
id: number;
label: string;
type: 'billing' | 'shipping' | 'both';
first_name: string;
last_name: string;
company?: string;
address_1: string;
address_2?: string;
city: string;
state: string;
postcode: string;
country: string;
email?: string;
phone?: string;
is_default: boolean;
}
export default function Checkout() {
const navigate = useNavigate();
const { cart } = useCartStore();
const { layout, elements } = useCheckoutSettings();
const [isProcessing, setIsProcessing] = useState(false);
const [couponCode, setCouponCode] = useState('');
const [isApplyingCoupon, setIsApplyingCoupon] = useState(false);
const [appliedCoupons, setAppliedCoupons] = useState<{ code: string; discount: number }[]>([]);
const [discountTotal, setDiscountTotal] = useState(0);
const user = (window as any).woonoowCustomer?.user;
// Check if cart needs shipping (virtual-only carts don't need shipping)
// Use cart.needs_shipping from WooCommerce API, fallback to item-level check
const isVirtualOnly = React.useMemo(() => {
// Prefer the needs_shipping flag from the cart API ( WooCommerce calculates this properly)
if (typeof cart.needs_shipping === 'boolean') {
return !cart.needs_shipping;
}
// Fallback: check individual items if needs_shipping not available
if (cart.items.length === 0) return false;
return cart.items.every(item => item.virtual || item.downloadable);
}, [cart.items, cart.needs_shipping]);
// Calculate totals
const subtotal = cart.items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
const tax = 0; // TODO: Calculate tax
// Note: shipping is calculated from dynamic shippingCost state (defined below)
// Form state
const [billingData, setBillingData] = useState({
firstName: '',
lastName: '',
email: '',
phone: '',
address: '',
city: '',
state: '',
postcode: '',
country: '',
});
const [shippingData, setShippingData] = useState({
firstName: '',
lastName: '',
address: '',
city: '',
state: '',
postcode: '',
country: '',
});
const [shipToDifferentAddress, setShipToDifferentAddress] = useState(false);
const [orderNotes, setOrderNotes] = useState('');
const [paymentMethod, setPaymentMethod] = useState(isVirtualOnly ? 'bacs' : 'cod');
// Saved addresses
const [savedAddresses, setSavedAddresses] = useState<SavedAddress[]>([]);
const [selectedBillingAddressId, setSelectedBillingAddressId] = useState<number | null>(null);
const [selectedShippingAddressId, setSelectedShippingAddressId] = useState<number | null>(null);
const [loadingAddresses, setLoadingAddresses] = useState(true);
const [showBillingModal, setShowBillingModal] = useState(false);
const [showShippingModal, setShowShippingModal] = useState(false);
const [showBillingForm, setShowBillingForm] = useState(true);
const [showShippingForm, setShowShippingForm] = useState(true);
// Countries and states data
const [countries, setCountries] = useState<{ code: string; name: string }[]>([]);
const [states, setStates] = useState<Record<string, Record<string, string>>>({});
const [defaultCountry, setDefaultCountry] = useState('');
// Load countries and states
useEffect(() => {
const loadCountries = async () => {
try {
const data = await api.get<{
countries: { code: string; name: string }[];
states: Record<string, Record<string, string>>;
default_country: string;
}>('/countries');
setCountries(data.countries || []);
setStates(data.states || {});
setDefaultCountry(data.default_country || '');
// Set default country if not already set
if (!billingData.country && data.default_country) {
setBillingData(prev => ({ ...prev, country: data.default_country }));
}
if (!shippingData.country && data.default_country) {
setShippingData(prev => ({ ...prev, country: data.default_country }));
}
} catch (error) {
console.error('Failed to load countries:', error);
}
};
loadCountries();
}, []);
// Country/state options for SearchableSelect
const countryOptions = countries.map(c => ({ value: c.code, label: `${c.name} (${c.code})` }));
const billingStateOptions = Object.entries(states[billingData.country] || {}).map(([code, name]) => ({ value: code, label: name }));
const shippingStateOptions = Object.entries(states[shippingData.country] || {}).map(([code, name]) => ({ value: code, label: name }));
// Clear state when country changes
useEffect(() => {
if (billingData.country && billingData.state) {
const countryStates = states[billingData.country] || {};
if (!countryStates[billingData.state]) {
setBillingData(prev => ({ ...prev, state: '' }));
}
}
}, [billingData.country, states]);
useEffect(() => {
if (shippingData.country && shippingData.state) {
const countryStates = states[shippingData.country] || {};
if (!countryStates[shippingData.state]) {
setShippingData(prev => ({ ...prev, state: '' }));
}
}
}, [shippingData.country, states]);
// Dynamic checkout fields from API
const [checkoutFields, setCheckoutFields] = useState<CheckoutField[]>([]);
const [customFieldData, setCustomFieldData] = useState<Record<string, string>>({});
// Dynamic shipping rates
interface ShippingRate {
id: string;
label: string;
cost: number;
method_id: string;
instance_id: number;
}
const [shippingRates, setShippingRates] = useState<ShippingRate[]>([]);
const [selectedShippingRate, setSelectedShippingRate] = useState<string>('');
const [isLoadingRates, setIsLoadingRates] = useState(false);
const [shippingCost, setShippingCost] = useState(0);
// Fetch checkout fields from API
useEffect(() => {
const loadCheckoutFields = async () => {
if (cart.items.length === 0) return;
try {
const items = cart.items.map(item => ({
product_id: item.product_id,
qty: item.quantity,
}));
const data = await api.post<{
ok: boolean;
fields: CheckoutField[];
is_digital_only: boolean;
}>('/checkout/fields', { items, is_digital_only: isVirtualOnly });
if (data.ok && data.fields) {
setCheckoutFields(data.fields);
// Initialize custom field values with defaults
const customDefaults: Record<string, string> = {};
data.fields.forEach(field => {
if (field.default) {
customDefaults[field.key] = field.default;
}
});
if (Object.keys(customDefaults).length > 0) {
setCustomFieldData(prev => ({ ...customDefaults, ...prev }));
}
// Set billing default values for hidden fields (e.g., Indonesia-only stores)
const billingCountryField = data.fields.find(f => f.key === 'billing_country');
if (billingCountryField?.type === 'hidden' && billingCountryField.default) {
setBillingData(prev => ({ ...prev, country: billingCountryField.default || prev.country }));
}
// Set shipping default values for hidden fields
const shippingCountryField = data.fields.find(f => f.key === 'shipping_country');
if (shippingCountryField?.type === 'hidden' && shippingCountryField.default) {
setShippingData(prev => ({ ...prev, country: shippingCountryField.default || prev.country }));
}
}
} catch (error) {
console.error('Failed to load checkout fields:', error);
}
};
loadCheckoutFields();
}, [cart.items, isVirtualOnly]);
// Helper to check if a standard field should be hidden (set to type: 'hidden' in PHP)
const isFieldHidden = (fieldKey: string): boolean => {
const field = checkoutFields.find(f => f.key === fieldKey);
return field?.type === 'hidden';
};
// Filter custom fields by fieldset
const billingCustomFields = checkoutFields.filter(f => f.fieldset === 'billing' && f.custom && !f.hidden);
const shippingCustomFields = checkoutFields.filter(f => f.fieldset === 'shipping' && f.custom && !f.hidden);
// Handler for custom field changes
const handleCustomFieldChange = (key: string, value: string) => {
setCustomFieldData(prev => ({ ...prev, [key]: value }));
};
// Listen for label events from searchable_select
useEffect(() => {
const handleLabelEvent = (e: Event) => {
const { key, value } = (e as CustomEvent).detail;
setCustomFieldData(prev => ({ ...prev, [key]: value }));
};
document.addEventListener('woonoow:field_label', handleLabelEvent);
return () => document.removeEventListener('woonoow:field_label', handleLabelEvent);
}, []);
// Fetch shipping rates when address changes
const fetchShippingRates = async () => {
if (isVirtualOnly || !cart.items.length) return;
// Get address data (use shipping if different, otherwise billing)
const addressData = shipToDifferentAddress ? shippingData : billingData;
// Need at least country to calculate shipping
if (!addressData.country) {
setShippingRates([]);
return;
}
setIsLoadingRates(true);
try {
const items = cart.items.map(item => ({
product_id: item.product_id,
quantity: item.quantity,
}));
const destinationId = shipToDifferentAddress
? customFieldData['shipping_destination_id']
: customFieldData['billing_destination_id'];
const response = await api.post<{ ok: boolean; rates: ShippingRate[]; zone_name?: string }>('/checkout/shipping-rates', {
shipping: {
country: addressData.country,
state: addressData.state,
city: addressData.city,
postcode: addressData.postcode,
destination_id: destinationId || undefined,
},
items,
});
if (response.ok && response.rates) {
setShippingRates(response.rates);
// Auto-select first rate if none selected
if (response.rates.length > 0 && !selectedShippingRate) {
setSelectedShippingRate(response.rates[0].id);
setShippingCost(response.rates[0].cost);
}
}
} catch (error) {
console.error('Failed to fetch shipping rates:', error);
setShippingRates([]);
} finally {
setIsLoadingRates(false);
}
};
// Trigger shipping rate fetch when address or destination changes
useEffect(() => {
const addressData = shipToDifferentAddress ? shippingData : billingData;
const destinationId = shipToDifferentAddress
? customFieldData['shipping_destination_id']
: customFieldData['billing_destination_id'];
// Debounce the fetch
const timeoutId = setTimeout(() => {
if (addressData.country) {
fetchShippingRates();
}
}, 500);
return () => clearTimeout(timeoutId);
}, [
billingData.country, billingData.state, billingData.city, billingData.postcode,
shippingData.country, shippingData.state, shippingData.city, shippingData.postcode,
shipToDifferentAddress,
customFieldData['billing_destination_id'],
customFieldData['shipping_destination_id'],
cart.items.length,
]);
// Update shipping cost when rate selected
const handleShippingRateChange = (rateId: string) => {
setSelectedShippingRate(rateId);
const rate = shippingRates.find(r => r.id === rateId);
setShippingCost(rate?.cost || 0);
};
useEffect(() => {
const loadAddresses = async () => {
if (!user?.isLoggedIn) {
setLoadingAddresses(false);
return;
}
try {
const addresses = await api.get<SavedAddress[]>('/account/addresses');
setSavedAddresses(addresses);
// Auto-select default addresses
const defaultBilling = addresses.find(a => a.is_default && (a.type === 'billing' || a.type === 'both'));
const defaultShipping = addresses.find(a => a.is_default && (a.type === 'shipping' || a.type === 'both'));
if (defaultBilling) {
setSelectedBillingAddressId(defaultBilling.id);
fillBillingFromAddress(defaultBilling);
setShowBillingForm(false); // Hide form when default address is auto-selected
}
if (defaultShipping && !isVirtualOnly) {
setSelectedShippingAddressId(defaultShipping.id);
fillShippingFromAddress(defaultShipping);
setShowShippingForm(false); // Hide form when default address is auto-selected
}
} catch (error) {
console.error('Failed to load addresses:', error);
} finally {
setLoadingAddresses(false);
}
};
loadAddresses();
}, [user, isVirtualOnly]);
// Helper functions to fill forms from saved addresses
const fillBillingFromAddress = (address: SavedAddress) => {
setBillingData({
firstName: address.first_name,
lastName: address.last_name,
email: address.email || billingData.email,
phone: address.phone || billingData.phone,
address: address.address_1,
city: address.city,
state: address.state,
postcode: address.postcode,
country: address.country,
});
};
const fillShippingFromAddress = (address: SavedAddress) => {
setShippingData({
firstName: address.first_name,
lastName: address.last_name,
address: address.address_1,
city: address.city,
state: address.state,
postcode: address.postcode,
country: address.country,
});
};
const handleSelectBillingAddress = (address: SavedAddress) => {
setSelectedBillingAddressId(address.id);
fillBillingFromAddress(address);
setShowBillingForm(false); // Hide form when address is selected
};
const handleSelectShippingAddress = (address: SavedAddress) => {
setSelectedShippingAddressId(address.id);
fillShippingFromAddress(address);
setShowShippingForm(false); // Hide form when address is selected
};
// Auto-fill form with user data if logged in
useEffect(() => {
if (user?.isLoggedIn && user?.billing && savedAddresses.length === 0) {
setBillingData({
firstName: user.billing.first_name || '',
lastName: user.billing.last_name || '',
email: user.billing.email || user.email || '',
phone: user.billing.phone || '',
address: user.billing.address_1 || '',
city: user.billing.city || '',
state: user.billing.state || '',
postcode: user.billing.postcode || '',
country: user.billing.country || '',
});
}
if (user?.isLoggedIn && user?.shipping) {
setShippingData({
firstName: user.shipping.first_name || '',
lastName: user.shipping.last_name || '',
address: user.shipping.address_1 || '',
city: user.shipping.city || '',
state: user.shipping.state || '',
postcode: user.shipping.postcode || '',
country: user.shipping.country || '',
});
}
}, [user]);
const handleApplyCoupon = async () => {
if (!couponCode.trim()) return;
setIsApplyingCoupon(true);
try {
const updatedCart = await applyCoupon(couponCode.trim());
if (updatedCart.coupons) {
setAppliedCoupons(updatedCart.coupons);
setDiscountTotal(updatedCart.discount_total || 0);
}
setCouponCode('');
toast.success('Coupon applied successfully');
} catch (error: any) {
toast.error(error.message || 'Failed to apply coupon');
} finally {
setIsApplyingCoupon(false);
}
};
const handleRemoveCoupon = async (code: string) => {
setIsApplyingCoupon(true);
try {
const updatedCart = await removeCoupon(code);
if (updatedCart.coupons) {
setAppliedCoupons(updatedCart.coupons);
setDiscountTotal(updatedCart.discount_total || 0);
}
toast.success('Coupon removed');
} catch (error: any) {
toast.error(error.message || 'Failed to remove coupon');
} finally {
setIsApplyingCoupon(false);
}
};
// Load cart data including coupons on mount
useEffect(() => {
const loadCartData = async () => {
try {
const cartData = await fetchCart();
if (cartData.coupons) {
setAppliedCoupons(cartData.coupons);
setDiscountTotal(cartData.discount_total || 0);
}
} catch (error) {
console.error('Failed to load cart data:', error);
}
};
loadCartData();
}, []);
const handlePlaceOrder = async (e: React.FormEvent) => {
e.preventDefault();
setIsProcessing(true);
try {
// Prepare order data
const orderData = {
items: cart.items.map(item => ({
product_id: item.product_id,
variation_id: item.variation_id,
qty: item.quantity,
meta: item.attributes ? Object.entries(item.attributes).map(([key, value]) => ({
key,
value
})) : []
})),
billing: {
first_name: billingData.firstName,
last_name: billingData.lastName,
email: billingData.email,
phone: billingData.phone,
address_1: billingData.address,
city: billingData.city,
state: billingData.state,
postcode: billingData.postcode,
country: billingData.country,
// Include custom billing fields
...Object.fromEntries(
billingCustomFields.map(f => [f.key.replace('billing_', ''), customFieldData[f.key] || ''])
),
},
shipping: shipToDifferentAddress ? {
first_name: shippingData.firstName,
last_name: shippingData.lastName,
address_1: shippingData.address,
city: shippingData.city,
state: shippingData.state,
postcode: shippingData.postcode,
country: shippingData.country,
ship_to_different: true,
// Include custom shipping fields
...Object.fromEntries(
shippingCustomFields.map(f => [f.key.replace('shipping_', ''), customFieldData[f.key] || ''])
),
} : {
ship_to_different: false,
},
payment_method: paymentMethod,
shipping_method: selectedShippingRate || undefined, // Selected shipping rate ID
customer_note: orderNotes,
// Include all custom field data for backend processing
custom_fields: customFieldData,
};
// Submit order
const response = await apiClient.post('/checkout/submit', orderData);
const data = (response as any).data || response;
if (data.ok && data.order_id) {
// Clear cart - use store method directly
useCartStore.getState().clearCart();
toast.success('Order placed successfully!');
// Navigate to thank you page via SPA routing
// Using window.location.replace to prevent back button issues
const thankYouUrl = `/order-received/${data.order_id}?key=${data.order_key}`;
navigate(thankYouUrl, { replace: true });
return; // Stop execution here
} else {
throw new Error(data.error || 'Failed to create order');
}
} catch (error: any) {
toast.error(error.message || 'Failed to place order');
console.error('Order creation error:', error);
} finally {
setIsProcessing(false);
}
};
// Empty cart redirect (but only if not processing)
if (cart.items.length === 0 && !isProcessing) {
return (
<Container>
<div className="text-center py-16">
<ShoppingBag className="mx-auto h-16 w-16 text-gray-400 mb-4" />
<h2 className="text-2xl font-bold mb-2">Your cart is empty</h2>
<p className="text-gray-600 mb-6">Add some products before checking out!</p>
<Button onClick={() => navigate('/shop')}>
<ArrowLeft className="mr-2 h-4 w-4" />
Continue Shopping
</Button>
</div>
</Container>
);
}
return (
<Container>
<SEOHead title="Checkout" description="Complete your purchase" />
<div className="py-8">
{/* Header */}
<div className="mb-8">
<Button variant="ghost" onClick={() => navigate('/cart')} className="mb-4">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Cart
</Button>
<h1 className="text-3xl font-bold">Checkout</h1>
</div>
<form onSubmit={handlePlaceOrder}>
<div className={`grid gap-8 ${layout.style === 'single-column' ? 'grid-cols-1' : layout.order_summary === 'top' ? 'grid-cols-1' : 'lg:grid-cols-3'}`}>
{/* Billing & Shipping Forms */}
<div className={`space-y-6 ${layout.style === 'single-column' || layout.order_summary === 'top' ? '' : 'lg:col-span-2'}`}>
{/* Selected Billing Address Summary */}
{!loadingAddresses && savedAddresses.length > 0 && savedAddresses.some(a => a.type === 'billing' || a.type === 'both') && (
<div className="bg-white border rounded-lg p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold flex items-center gap-2">
<MapPin className="w-5 h-5" />
Billing Address
</h2>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setShowBillingModal(true)}
className="flex items-center gap-2"
>
<Edit2 className="w-4 h-4" />
Change Address
</Button>
</div>
{selectedBillingAddressId ? (
(() => {
const selected = savedAddresses.find(a => a.id === selectedBillingAddressId);
return selected ? (
<div>
<div className="bg-primary/5 border-2 border-primary rounded-lg p-4">
<div className="flex items-center gap-2 mb-2">
<p className="font-semibold">{selected.label}</p>
{selected.is_default && (
<span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded">Default</span>
)}
</div>
<p className="text-sm font-medium text-gray-900">{selected.first_name} {selected.last_name}</p>
{selected.phone && <p className="text-sm text-gray-600">{selected.phone}</p>}
<p className="text-sm text-gray-600 mt-2">{selected.address_1}</p>
{selected.address_2 && <p className="text-sm text-gray-600">{selected.address_2}</p>}
<p className="text-sm text-gray-600">{selected.city}, {selected.state} {selected.postcode}</p>
<p className="text-sm text-gray-600">{selected.country}</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowBillingForm(true)}
className="mt-3 text-primary hover:text-primary"
>
Use a different address
</Button>
</div>
) : null;
})()
) : (
<p className="text-gray-500 text-sm">No address selected</p>
)}
</div>
)}
{/* Billing Address Modal */}
<AddressSelector
isOpen={showBillingModal}
onClose={() => setShowBillingModal(false)}
addresses={savedAddresses}
selectedAddressId={selectedBillingAddressId}
onSelectAddress={handleSelectBillingAddress}
type="billing"
/>
{/* Billing Details Form - Only show if no saved address selected or user wants to enter manually */}
{(savedAddresses.length === 0 || !selectedBillingAddressId || showBillingForm) && (
<div className="bg-white border rounded-lg p-6">
<h2 className="text-xl font-bold mb-4">Billing Details</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-2">First Name *</label>
<input
type="text"
required
value={billingData.firstName}
onChange={(e) => setBillingData({ ...billingData, firstName: e.target.value })}
className="w-full border rounded-lg px-4 py-2"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">Last Name *</label>
<input
type="text"
required
value={billingData.lastName}
onChange={(e) => setBillingData({ ...billingData, lastName: e.target.value })}
className="w-full border rounded-lg px-4 py-2"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium mb-2">Email Address *</label>
<input
type="email"
required
value={billingData.email}
onChange={(e) => setBillingData({ ...billingData, email: e.target.value })}
className="w-full border rounded-lg px-4 py-2"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium mb-2">Phone *</label>
<input
type="tel"
required
value={billingData.phone}
onChange={(e) => setBillingData({ ...billingData, phone: e.target.value })}
className="w-full border rounded-lg px-4 py-2"
/>
</div>
{/* Address fields - only for physical products */}
{!isVirtualOnly && (
<>
<div className="md:col-span-2">
<label className="block text-sm font-medium mb-2">Street Address *</label>
<input
type="text"
required
value={billingData.address}
onChange={(e) => setBillingData({ ...billingData, address: e.target.value })}
className="w-full border rounded-lg px-4 py-2"
/>
</div>
{/* City field - hidden if PHP sets type to 'hidden' */}
{!isFieldHidden('billing_city') && (
<div>
<label className="block text-sm font-medium mb-2">City *</label>
<input
type="text"
required
value={billingData.city}
onChange={(e) => setBillingData({ ...billingData, city: e.target.value })}
className="w-full border rounded-lg px-4 py-2"
/>
</div>
)}
{/* Country field - hidden if PHP sets type to 'hidden' */}
{!isFieldHidden('billing_country') && (
<div>
<label className="block text-sm font-medium mb-2">Country *</label>
<SearchableSelect
options={countryOptions}
value={billingData.country}
onChange={(v) => setBillingData({ ...billingData, country: v })}
placeholder="Select country"
disabled={countries.length === 1}
/>
</div>
)}
{/* State field - hidden if PHP sets type to 'hidden' */}
{!isFieldHidden('billing_state') && (
<div>
<label className="block text-sm font-medium mb-2">State / Province *</label>
{billingStateOptions.length > 0 ? (
<SearchableSelect
options={billingStateOptions}
value={billingData.state}
onChange={(v) => setBillingData({ ...billingData, state: v })}
placeholder="Select state"
/>
) : (
<input
type="text"
value={billingData.state}
onChange={(e) => setBillingData({ ...billingData, state: e.target.value })}
placeholder="Enter state/province"
className="w-full border rounded-lg px-4 py-2"
/>
)}
</div>
)}
<div>
<label className="block text-sm font-medium mb-2">Postcode / ZIP *</label>
<input
type="text"
required
value={billingData.postcode}
onChange={(e) => setBillingData({ ...billingData, postcode: e.target.value })}
className="w-full border rounded-lg px-4 py-2"
/>
</div>
{/* Custom billing fields from plugins */}
{billingCustomFields.map(field => (
<DynamicCheckoutField
key={field.key}
field={field}
value={customFieldData[field.key] || ''}
onChange={(v) => handleCustomFieldChange(field.key, v)}
countryOptions={countryOptions}
stateOptions={billingStateOptions}
/>
))}
</>
)}
</div>
</div>
)}
{/* Ship to Different Address - only for physical products */}
{!isVirtualOnly && (
<div className="bg-white border rounded-lg p-6">
<label className="flex items-center gap-2 mb-4">
<input
type="checkbox"
checked={shipToDifferentAddress}
onChange={(e) => setShipToDifferentAddress(e.target.checked)}
className="w-4 h-4"
/>
<span className="font-medium">Ship to a different address?</span>
</label>
{shipToDifferentAddress && (
<>
{/* Selected Shipping Address Summary */}
{!loadingAddresses && savedAddresses.length > 0 && savedAddresses.some(a => a.type === 'shipping' || a.type === 'both') && (
<div className="mb-6">
<div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-semibold flex items-center gap-2">
<MapPin className="w-4 h-4" />
Shipping Address
</h3>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setShowShippingModal(true)}
className="flex items-center gap-2"
>
<Edit2 className="w-4 h-4" />
Change Address
</Button>
</div>
{selectedShippingAddressId ? (
(() => {
const selected = savedAddresses.find(a => a.id === selectedShippingAddressId);
return selected ? (
<div>
<div className="bg-primary/5 border-2 border-primary rounded-lg p-4">
<div className="flex items-center gap-2 mb-2">
<p className="font-semibold">{selected.label}</p>
{selected.is_default && (
<span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded">Default</span>
)}
</div>
<p className="text-sm font-medium text-gray-900">{selected.first_name} {selected.last_name}</p>
{selected.phone && <p className="text-sm text-gray-600">{selected.phone}</p>}
<p className="text-sm text-gray-600 mt-2">{selected.address_1}</p>
{selected.address_2 && <p className="text-sm text-gray-600">{selected.address_2}</p>}
<p className="text-sm text-gray-600">{selected.city}, {selected.state} {selected.postcode}</p>
<p className="text-sm text-gray-600">{selected.country}</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowShippingForm(true)}
className="mt-3 text-primary hover:text-primary"
>
Use a different address
</Button>
</div>
) : null;
})()
) : (
<p className="text-gray-500 text-sm">No address selected</p>
)}
</div>
)}
{/* Shipping Address Modal */}
<AddressSelector
isOpen={showShippingModal}
onClose={() => setShowShippingModal(false)}
addresses={savedAddresses}
selectedAddressId={selectedShippingAddressId}
onSelectAddress={handleSelectShippingAddress}
type="shipping"
/>
{/* Shipping Form - Only show if no saved address selected or user wants to enter manually */}
{(!selectedShippingAddressId || showShippingForm) && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-2">First Name *</label>
<input
type="text"
required
value={shippingData.firstName}
onChange={(e) => setShippingData({ ...shippingData, firstName: e.target.value })}
className="w-full border rounded-lg px-4 py-2"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">Last Name *</label>
<input
type="text"
required
value={shippingData.lastName}
onChange={(e) => setShippingData({ ...shippingData, lastName: e.target.value })}
className="w-full border rounded-lg px-4 py-2"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium mb-2">Street Address *</label>
<input
type="text"
required
value={shippingData.address}
onChange={(e) => setShippingData({ ...shippingData, address: e.target.value })}
className="w-full border rounded-lg px-4 py-2"
/>
</div>
{/* City field - hidden if PHP sets type to 'hidden' */}
{!isFieldHidden('shipping_city') && (
<div>
<label className="block text-sm font-medium mb-2">City *</label>
<input
type="text"
required
value={shippingData.city}
onChange={(e) => setShippingData({ ...shippingData, city: e.target.value })}
className="w-full border rounded-lg px-4 py-2"
/>
</div>
)}
{/* Country field - hidden if PHP sets type to 'hidden' */}
{!isFieldHidden('shipping_country') && (
<div>
<label className="block text-sm font-medium mb-2">Country *</label>
<SearchableSelect
options={countryOptions}
value={shippingData.country}
onChange={(v) => setShippingData({ ...shippingData, country: v })}
placeholder="Select country"
disabled={countries.length === 1}
/>
</div>
)}
{/* State field - hidden if PHP sets type to 'hidden' */}
{!isFieldHidden('shipping_state') && (
<div>
<label className="block text-sm font-medium mb-2">State / Province *</label>
{shippingStateOptions.length > 0 ? (
<SearchableSelect
options={shippingStateOptions}
value={shippingData.state}
onChange={(v) => setShippingData({ ...shippingData, state: v })}
placeholder="Select state"
/>
) : (
<input
type="text"
value={shippingData.state}
onChange={(e) => setShippingData({ ...shippingData, state: e.target.value })}
placeholder="Enter state/province"
className="w-full border rounded-lg px-4 py-2"
/>
)}
</div>
)}
<div>
<label className="block text-sm font-medium mb-2">Postcode / ZIP *</label>
<input
type="text"
required
value={shippingData.postcode}
onChange={(e) => setShippingData({ ...shippingData, postcode: e.target.value })}
className="w-full border rounded-lg px-4 py-2"
/>
</div>
{/* Custom shipping fields from plugins */}
{shippingCustomFields.map(field => (
<DynamicCheckoutField
key={field.key}
field={field}
value={customFieldData[field.key] || ''}
onChange={(v) => handleCustomFieldChange(field.key, v)}
countryOptions={countryOptions}
stateOptions={shippingStateOptions}
/>
))}
</div>
)}
</>
)}
</div>
)}
{/* Order Notes */}
{elements.order_notes && (
<div className="bg-white border rounded-lg p-6">
<h2 className="text-xl font-bold mb-4">Order Notes (Optional)</h2>
<textarea
value={orderNotes}
onChange={(e) => setOrderNotes(e.target.value)}
placeholder="Notes about your order, e.g. special notes for delivery."
className="w-full border rounded-lg px-4 py-2 h-32"
/>
</div>
)}
</div>
{/* Order Summary */}
<div className={`${layout.style === 'single-column' || layout.order_summary === 'top' ? 'order-first' : 'lg:col-span-1'}`}>
<div className="bg-white border rounded-lg p-6 sticky top-4">
<h2 className="text-xl font-bold mb-4">Your Order</h2>
{/* Coupon Field */}
{elements.coupon_field && (
<div className="mb-4 pb-4 border-b">
<label className="block text-sm font-medium mb-2">Coupon Code</label>
<div className="flex gap-2">
<input
type="text"
placeholder="Enter coupon code"
value={couponCode}
onChange={(e) => setCouponCode(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleApplyCoupon())}
className="flex-1 px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
disabled={isApplyingCoupon}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleApplyCoupon}
disabled={isApplyingCoupon || !couponCode.trim()}
>
{isApplyingCoupon ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Apply'}
</Button>
</div>
{/* Applied Coupons */}
{appliedCoupons.length > 0 && (
<div className="mt-3 space-y-2">
{appliedCoupons.map((coupon) => (
<div key={coupon.code} className="flex items-center justify-between p-2 bg-green-50 border border-green-200 rounded-md">
<div className="flex items-center gap-2">
<Tag className="h-4 w-4 text-green-600" />
<span className="text-sm font-medium text-green-800">{coupon.code}</span>
<span className="text-sm text-green-600">-{formatPrice(coupon.discount)}</span>
</div>
<button
type="button"
onClick={() => handleRemoveCoupon(coupon.code)}
className="text-green-600 hover:text-green-800 p-1"
disabled={isApplyingCoupon}
>
<X className="h-4 w-4" />
</button>
</div>
))}
</div>
)}
</div>
)}
{/* Order Items */}
<div className="space-y-3 mb-4 pb-4 border-b">
{cart.items.map((item) => (
<div key={item.key} className="flex justify-between text-sm">
<span>
{item.name} × {item.quantity}
</span>
<span className="font-medium">
{formatPrice(item.price * item.quantity)}
</span>
</div>
))}
</div>
{/* Shipping Options */}
{!isVirtualOnly && elements.shipping_options && (
<div className="mb-4 pb-4 border-b">
<h3 className="font-medium mb-3">Shipping Method</h3>
<div className="space-y-2">
{isLoadingRates ? (
<div className="flex items-center gap-2 p-3 text-sm text-gray-500">
<Loader2 className="h-4 w-4 animate-spin" />
<span>Calculating shipping rates...</span>
</div>
) : shippingRates.length === 0 ? (
<div className="p-3 text-sm text-gray-500">
{billingData.country
? 'No shipping methods available for your location'
: 'Enter your address to see shipping options'}
</div>
) : (
shippingRates.map((rate) => (
<label
key={rate.id}
className={`flex items-center justify-between p-3 border rounded-lg cursor-pointer hover:bg-gray-50 ${selectedShippingRate === rate.id ? 'border-primary bg-primary/5' : ''
}`}
>
<div className="flex items-center gap-2">
<input
type="radio"
name="shipping_method"
value={rate.id}
checked={selectedShippingRate === rate.id}
onChange={() => handleShippingRateChange(rate.id)}
className="w-4 h-4"
/>
<span className="text-sm">{rate.label}</span>
</div>
<span className="text-sm font-medium">
{rate.cost === 0 ? 'Free' : formatPrice(rate.cost)}
</span>
</label>
))
)}
</div>
</div>
)}
{/* Totals */}
<div className="space-y-2 mb-6">
<div className="flex justify-between text-sm">
<span>Subtotal</span>
<span>{formatPrice(subtotal)}</span>
</div>
{/* Show discount if coupons applied */}
{discountTotal > 0 && (
<div className="flex justify-between text-sm text-green-600">
<span>Discount</span>
<span>-{formatPrice(discountTotal)}</span>
</div>
)}
{!isVirtualOnly && (
<div className="flex justify-between text-sm">
<span>Shipping</span>
<span>{shippingCost === 0 ? 'Free' : formatPrice(shippingCost)}</span>
</div>
)}
{tax > 0 && (
<div className="flex justify-between text-sm">
<span>Tax</span>
<span>{formatPrice(tax)}</span>
</div>
)}
<div className="border-t pt-2 flex justify-between font-bold text-lg">
<span>Total</span>
<span>{formatPrice(subtotal + (isVirtualOnly ? 0 : shippingCost) + tax - discountTotal)}</span>
</div>
</div>
{/* Payment Method */}
<div className="mb-6">
<h3 className="font-medium mb-3">Payment Method</h3>
<div className="space-y-2">
{/* Hide COD for virtual-only products */}
{!isVirtualOnly && (
<label className="flex items-center gap-2 p-3 border rounded-lg cursor-pointer hover:bg-gray-50">
<input
type="radio"
name="payment"
value="cod"
checked={paymentMethod === 'cod'}
onChange={(e) => setPaymentMethod(e.target.value)}
className="w-4 h-4"
/>
<span>Cash on Delivery</span>
</label>
)}
<label className="flex items-center gap-2 p-3 border rounded-lg cursor-pointer hover:bg-gray-50">
<input
type="radio"
name="payment"
value="bacs"
checked={paymentMethod === 'bacs'}
onChange={(e) => setPaymentMethod(e.target.value)}
className="w-4 h-4"
/>
<span>Bank Transfer</span>
</label>
</div>
{/* Payment Icons */}
{elements.payment_icons && (
<div className="flex items-center gap-2 mt-3 pt-3 border-t">
<span className="text-xs text-gray-500">We accept:</span>
<div className="flex gap-1">
<div className="w-10 h-6 bg-gray-100 rounded flex items-center justify-center text-xs font-bold">VISA</div>
<div className="w-10 h-6 bg-gray-100 rounded flex items-center justify-center text-xs font-bold">MC</div>
<div className="w-10 h-6 bg-gray-100 rounded flex items-center justify-center text-xs font-bold">AMEX</div>
</div>
</div>
)}
</div>
{/* Place Order Button - Only show in sidebar layout */}
{layout.order_summary !== 'top' && (
<Button
type="submit"
size="lg"
className="w-full"
disabled={isProcessing}
>
{isProcessing ? 'Processing...' : 'Place Order'}
</Button>
)}
</div>
</div>
</div>
{/* Place Order Button - Show at bottom when summary is on top */}
{layout.order_summary === 'top' && (
<div className="mt-6">
<Button
type="submit"
size="lg"
className="w-full"
disabled={isProcessing}
>
{isProcessing ? 'Processing...' : 'Place Order'}
</Button>
</div>
)}
</form>
</div>
</Container>
);
}