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
1229 lines
53 KiB
TypeScript
1229 lines
53 KiB
TypeScript
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>
|
||
);
|
||
}
|