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:
@@ -87,7 +87,8 @@
|
|||||||
"Bash(git commit -m 'Refactor: Move start design server action to separate file *)",
|
"Bash(git commit -m 'Refactor: Move start design server action to separate file *)",
|
||||||
"Bash(git commit -m 'Add: Prominent '\\\\''Shop plain items'\\\\'' section to homepage *)",
|
"Bash(git commit -m 'Add: Prominent '\\\\''Shop plain items'\\\\'' section to homepage *)",
|
||||||
"Bash(git commit -m 'Feature: Royal Mail shipping integration with weight-based postage *)",
|
"Bash(git commit -m 'Feature: Royal Mail shipping integration with weight-based postage *)",
|
||||||
"Bash(git commit -m 'Add: Weight field to product admin pages *)"
|
"Bash(git commit -m 'Add: Weight field to product admin pages *)",
|
||||||
|
"Bash(git commit -m 'Add: Shipping selector component and checkout integration *)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Link from 'next/link';
|
|||||||
import { useCart } from '@/lib/cartStore';
|
import { useCart } from '@/lib/cartStore';
|
||||||
import { loginCustomer } from '@/app/account/actions';
|
import { loginCustomer } from '@/app/account/actions';
|
||||||
import Price from '@/components/Price';
|
import Price from '@/components/Price';
|
||||||
|
import ShippingSelector from '@/components/ShippingSelector';
|
||||||
|
|
||||||
interface CheckoutPageClientProps {
|
interface CheckoutPageClientProps {
|
||||||
isLoggedIn: boolean;
|
isLoggedIn: boolean;
|
||||||
@@ -16,6 +17,9 @@ export default function CheckoutPageClient({ isLoggedIn, customer }: CheckoutPag
|
|||||||
const [mode, setMode] = useState<'logged-in' | 'login' | 'guest' | null>(isLoggedIn ? 'logged-in' : null);
|
const [mode, setMode] = useState<'logged-in' | 'login' | 'guest' | null>(isLoggedIn ? 'logged-in' : null);
|
||||||
const [checkingOut, setCheckingOut] = useState(false);
|
const [checkingOut, setCheckingOut] = useState(false);
|
||||||
const [checkoutError, setCheckoutError] = useState<string | null>(null);
|
const [checkoutError, setCheckoutError] = useState<string | null>(null);
|
||||||
|
const [shippingCountry, setShippingCountry] = useState('GB');
|
||||||
|
const [shippingService, setShippingService] = useState<string | null>(null);
|
||||||
|
const [shippingCost, setShippingCost] = useState(0);
|
||||||
const [guestData, setGuestData] = useState({
|
const [guestData, setGuestData] = useState({
|
||||||
fullName: '',
|
fullName: '',
|
||||||
email: '',
|
email: '',
|
||||||
@@ -24,7 +28,8 @@ export default function CheckoutPageClient({ isLoggedIn, customer }: CheckoutPag
|
|||||||
});
|
});
|
||||||
|
|
||||||
const items = useCart((s) => s.items);
|
const items = useCart((s) => s.items);
|
||||||
const total = useCart((s) => s.total());
|
const subtotal = useCart((s) => s.total());
|
||||||
|
const total = subtotal + shippingCost;
|
||||||
|
|
||||||
useEffect(() => setMounted(true), []);
|
useEffect(() => setMounted(true), []);
|
||||||
|
|
||||||
@@ -51,6 +56,10 @@ export default function CheckoutPageClient({ isLoggedIn, customer }: CheckoutPag
|
|||||||
setCheckoutError('Please fill in all fields.');
|
setCheckoutError('Please fill in all fields.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!shippingService) {
|
||||||
|
setCheckoutError('Please select a shipping method.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setCheckingOut(true);
|
setCheckingOut(true);
|
||||||
setCheckoutError(null);
|
setCheckoutError(null);
|
||||||
@@ -71,6 +80,8 @@ export default function CheckoutPageClient({ isLoggedIn, customer }: CheckoutPag
|
|||||||
phone: guestData.phone,
|
phone: guestData.phone,
|
||||||
address: guestData.address,
|
address: guestData.address,
|
||||||
},
|
},
|
||||||
|
shippingCountry,
|
||||||
|
shippingService,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -106,8 +117,18 @@ export default function CheckoutPageClient({ isLoggedIn, customer }: CheckoutPag
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 border-t border-line pt-4">
|
<div className="mt-4 border-t border-line pt-4 space-y-2">
|
||||||
<div className="flex justify-between font-display text-lg">
|
<div className="flex justify-between text-sm">
|
||||||
|
<span>Subtotal</span>
|
||||||
|
<Price cents={subtotal} className="font-mono" />
|
||||||
|
</div>
|
||||||
|
{shippingCost > 0 && (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span>Shipping ({shippingService})</span>
|
||||||
|
<Price cents={shippingCost} className="font-mono" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-between font-display text-lg pt-2 border-t border-line">
|
||||||
<span>Total</span>
|
<span>Total</span>
|
||||||
<Price cents={total} className="font-mono" />
|
<Price cents={total} className="font-mono" />
|
||||||
</div>
|
</div>
|
||||||
@@ -117,11 +138,28 @@ export default function CheckoutPageClient({ isLoggedIn, customer }: CheckoutPag
|
|||||||
{/* Checkout options */}
|
{/* Checkout options */}
|
||||||
<div>
|
<div>
|
||||||
{mode === 'logged-in' ? (
|
{mode === 'logged-in' ? (
|
||||||
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-display text-lg">Ready to checkout</h3>
|
<h3 className="font-display text-lg">Ready to checkout</h3>
|
||||||
<p className="mt-2 text-sm text-muted">Logged in as {customer?.email}</p>
|
<p className="mt-2 text-sm text-muted">Logged in as {customer?.email}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ShippingSelector
|
||||||
|
items={items}
|
||||||
|
onShippingSelect={(country, service, cost) => {
|
||||||
|
setShippingCountry(country);
|
||||||
|
setShippingService(service);
|
||||||
|
setShippingCost(cost);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
|
if (!shippingService) {
|
||||||
|
setCheckoutError('Please select a shipping method.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
console.log('💳 Checkout button clicked, cart items:', items.length);
|
console.log('💳 Checkout button clicked, cart items:', items.length);
|
||||||
setCheckingOut(true);
|
setCheckingOut(true);
|
||||||
setCheckoutError(null);
|
setCheckoutError(null);
|
||||||
@@ -137,7 +175,12 @@ export default function CheckoutPageClient({ isLoggedIn, customer }: CheckoutPag
|
|||||||
const res = await fetch('/api/checkout', {
|
const res = await fetch('/api/checkout', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ items, customerId: customer.id }),
|
body: JSON.stringify({
|
||||||
|
items,
|
||||||
|
customerId: customer.id,
|
||||||
|
shippingCountry,
|
||||||
|
shippingService,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
console.log('📦 Checkout response:', { status: res.status, ok: res.ok, hasUrl: !!data.url });
|
console.log('📦 Checkout response:', { status: res.status, ok: res.ok, hasUrl: !!data.url });
|
||||||
@@ -296,6 +339,16 @@ export default function CheckoutPageClient({ isLoggedIn, customer }: CheckoutPag
|
|||||||
className="w-full border border-line px-3 py-2 text-sm"
|
className="w-full border border-line px-3 py-2 text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ShippingSelector
|
||||||
|
items={items}
|
||||||
|
onShippingSelect={(country, service, cost) => {
|
||||||
|
setShippingCountry(country);
|
||||||
|
setShippingService(service);
|
||||||
|
setShippingCost(cost);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={checkingOut}
|
disabled={checkingOut}
|
||||||
|
|||||||
@@ -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