Add: Shipping selector component and checkout integration
Added ShippingSelector component that: - Displays country selection dropdown - Fetches available Royal Mail shipping quotes based on cart weight - Shows delivery estimates and international delivery warnings - Lets customers select preferred shipping service - Auto-selects cheapest option by default Integrated into checkout: - Shows shipping selector in both logged-in and guest flows - Displays shipping cost breakdown in order summary - Passes shipping country and service to checkout API - Validates shipping selection before processing payment
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ShippingQuote } from '@/lib/royalmail-shipping';
|
||||
import { formatShippingPrice, getDeliveryEstimate } from '@/lib/royalmail-shipping';
|
||||
|
||||
interface CartItem {
|
||||
productId: string;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
interface ShippingSelectorProps {
|
||||
items: CartItem[];
|
||||
onShippingSelect: (country: string, service: string, cost: number) => void;
|
||||
}
|
||||
|
||||
export default function ShippingSelector({ items, onShippingSelect }: ShippingSelectorProps) {
|
||||
const [country, setCountry] = useState('GB');
|
||||
const [quotes, setQuotes] = useState<ShippingQuote[]>([]);
|
||||
const [selectedService, setSelectedService] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [totalWeight, setTotalWeight] = useState<string | null>(null);
|
||||
|
||||
// Fetch shipping quotes when country changes
|
||||
useEffect(() => {
|
||||
async function fetchQuotes() {
|
||||
if (!items.length) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/checkout/shipping-quotes', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ items, country }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || 'Failed to fetch shipping quotes');
|
||||
}
|
||||
|
||||
setQuotes(data.quotes);
|
||||
setTotalWeight(data.totalWeightKg);
|
||||
|
||||
// Auto-select the first (cheapest) option
|
||||
if (data.quotes.length > 0) {
|
||||
const firstService = data.quotes[0];
|
||||
setSelectedService(firstService.service);
|
||||
onShippingSelect(country, firstService.service, firstService.price);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch shipping quotes');
|
||||
setQuotes([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchQuotes();
|
||||
}, [items, country, onShippingSelect]);
|
||||
|
||||
const selectedQuote = quotes.find((q) => q.service === selectedService);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 border border-line bg-surface p-6">
|
||||
<div>
|
||||
<p className="tag-label mb-2 block">Shipping to</p>
|
||||
<select
|
||||
value={country}
|
||||
onChange={(e) => setCountry(e.target.value)}
|
||||
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||
>
|
||||
{/* Common destinations */}
|
||||
<option value="GB">United Kingdom</option>
|
||||
<option value="IE">Ireland</option>
|
||||
<option value="US">United States</option>
|
||||
<option value="CA">Canada</option>
|
||||
<option value="AU">Australia</option>
|
||||
<option value="NZ">New Zealand</option>
|
||||
<option value="DE">Germany</option>
|
||||
<option value="FR">France</option>
|
||||
<option value="NL">Netherlands</option>
|
||||
<option value="ES">Spain</option>
|
||||
<option value="IT">Italy</option>
|
||||
<option value="JP">Japan</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{totalWeight && (
|
||||
<p className="text-xs text-muted">
|
||||
Total weight: <strong>{totalWeight} kg</strong>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted">Loading shipping options...</p>
|
||||
) : error ? (
|
||||
<p className="text-sm text-splash-pink">{error}</p>
|
||||
) : quotes.length === 0 ? (
|
||||
<p className="text-sm text-muted">No shipping options available for this destination.</p>
|
||||
) : (
|
||||
<div>
|
||||
<p className="tag-label mb-3 block">Delivery method</p>
|
||||
<div className="space-y-3">
|
||||
{quotes.map((quote) => (
|
||||
<label key={quote.service} className="flex items-start gap-3 border border-line p-3 cursor-pointer hover:bg-paper/50">
|
||||
<input
|
||||
type="radio"
|
||||
name="shipping-service"
|
||||
value={quote.service}
|
||||
checked={selectedService === quote.service}
|
||||
onChange={() => {
|
||||
setSelectedService(quote.service);
|
||||
onShippingSelect(country, quote.service, quote.price);
|
||||
}}
|
||||
className="mt-1 h-4 w-4 accent-clay"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<p className="font-medium text-sm">{quote.service}</p>
|
||||
<p className="font-mono text-sm font-medium">{formatShippingPrice(quote.price)}</p>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted">{getDeliveryEstimate(quote)}</p>
|
||||
{quote.warning && (
|
||||
<p className="mt-2 text-xs text-splash-orange">
|
||||
⚠️ {quote.warning}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user