Feature: Add quantity adjustment and fix shipping selection

- Add quantity adjustment controls (+ and -) on checkout order summary
- Prevent quantity from going below 1 with disabled minus button at qty=1
- Fix shipping selector not updating by wrapping callback in useCallback
- Shipping option changes now properly update order total
- Totals recalculate reactively when adjusting quantities or shipping

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Andymick
2026-07-19 07:33:03 +01:00
co-authored by Claude Haiku 4.5
parent 5538938020
commit 08038dffb1
10 changed files with 557 additions and 58 deletions
+27
View File
@@ -149,3 +149,30 @@ export async function resetPassword(formData: FormData) {
await createSession(resetToken.customerId);
redirect('/account');
}
export async function updateCustomerAddress(formData: FormData) {
const { getCurrentCustomer } = await import('@/lib/auth');
const customer = await getCurrentCustomer();
if (!customer) redirect('/account/login');
const phone = String(formData.get('phone') ?? '').trim();
const addressLine1 = String(formData.get('addressLine1') ?? '').trim();
const addressLine2 = String(formData.get('addressLine2') ?? '').trim();
const city = String(formData.get('city') ?? '').trim();
const postalCode = String(formData.get('postalCode') ?? '').trim();
const country = String(formData.get('country') ?? '').trim();
await prisma.customer.update({
where: { id: customer.id },
data: {
phone: phone || null,
addressLine1: addressLine1 || null,
addressLine2: addressLine2 || null,
city: city || null,
postalCode: postalCode || null,
country: country || null,
},
});
redirect('/account?addressUpdated=1');
}
+50 -31
View File
@@ -3,6 +3,8 @@ import { redirect } from 'next/navigation';
import { getCurrentCustomer } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { logoutCustomer } from './actions';
import AddressFormSection from '@/components/AddressFormSection';
import OlderOrders from '@/components/OlderOrders';
function formatPrice(cents: number) {
return `£${(cents / 100).toFixed(2)}`;
@@ -36,6 +38,13 @@ export default async function AccountPage() {
orderBy: { createdAt: 'desc' },
});
// Separate recent (< 7 days) and older (>= 7 days) orders
const now = new Date();
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const recentOrders = orders.filter(order => new Date(order.createdAt) > sevenDaysAgo);
const olderOrders = orders.filter(order => new Date(order.createdAt) <= sevenDaysAgo);
return (
<div className="mx-auto max-w-3xl px-6 py-12">
<div className="flex items-baseline justify-between gap-3">
@@ -56,6 +65,8 @@ export default async function AccountPage() {
</form>
</div>
<AddressFormSection customer={customer} />
<h2 className="mt-10 font-display text-xl">Design reviews</h2>
{designReviews.length === 0 ? (
<p className="mt-3 text-sm text-muted">No designs awaiting review.</p>
@@ -88,41 +99,49 @@ export default async function AccountPage() {
{orders.length === 0 ? (
<p className="mt-3 text-sm text-muted">No orders yet.</p>
) : (
<div className="mt-4 divide-y divide-line border-t border-line">
{orders.map((order) => (
<div key={order.id} className="border-b border-line py-4 last:border-b-0">
<Link
href={`/account/orders/${order.id}`}
className="flex flex-wrap items-center justify-between gap-2 hover:opacity-80"
>
<div>
<p className="text-sm">
{new Date(order.createdAt).toLocaleDateString(undefined, { dateStyle: 'medium' })} ·{' '}
{order.items.length} item{order.items.length === 1 ? '' : 's'}
</p>
<span className={`tag-label mt-1 inline-block rounded-full px-3 py-1 ${statusClasses(order.status)}`}>
{statusLabel(order.status)}
</span>
</div>
<span className="font-mono text-sm">{formatPrice(order.total)}</span>
</Link>
<>
{recentOrders.length > 0 && (
<div className="mt-4 divide-y divide-line border-t border-line">
{recentOrders.map((order) => (
<div key={order.id} className="border-b border-line py-4 last:border-b-0">
<Link
href={`/account/orders/${order.id}`}
className="flex flex-wrap items-center justify-between gap-2 hover:opacity-80"
>
<div>
<p className="text-sm">
{new Date(order.createdAt).toLocaleDateString(undefined, { dateStyle: 'medium' })} ·{' '}
{order.items.length} item{order.items.length === 1 ? '' : 's'}
</p>
<span className={`tag-label mt-1 inline-block rounded-full px-3 py-1 ${statusClasses(order.status)}`}>
{statusLabel(order.status)}
</span>
</div>
<span className="font-mono text-sm">{formatPrice(order.total)}</span>
</Link>
{/* Tracking Information */}
{order.carrier && order.trackingNumber && (
<div className="mt-3 border-t border-line pt-3 text-sm">
<p>
<strong>Tracking:</strong> {order.carrier} · {order.trackingNumber}
</p>
{order.estimatedDeliveryDate && (
<p className="text-muted">
Est. delivery: {new Date(order.estimatedDeliveryDate).toLocaleDateString(undefined, { dateStyle: 'medium' })}
</p>
{/* Tracking Information */}
{order.carrier && order.trackingNumber && (
<div className="mt-3 border-t border-line pt-3 text-sm">
<p>
<strong>Tracking:</strong> {order.carrier} · {order.trackingNumber}
</p>
{order.estimatedDeliveryDate && (
<p className="text-muted">
Est. delivery: {new Date(order.estimatedDeliveryDate).toLocaleDateString(undefined, { dateStyle: 'medium' })}
</p>
)}
</div>
)}
</div>
)}
))}
</div>
))}
</div>
)}
{olderOrders.length > 0 && (
<OlderOrders orders={olderOrders} formatPrice={formatPrice} statusLabel={statusLabel} statusClasses={statusClasses} />
)}
</>
)}
</div>
);
+200 -18
View File
@@ -1,6 +1,6 @@
'use client';
import { useEffect, useState } from 'react';
import { useEffect, useState, useCallback } from 'react';
import Link from 'next/link';
import { useCart } from '@/lib/cartStore';
import { loginCustomer } from '@/app/account/actions';
@@ -10,14 +10,15 @@ import ShippingSelector from '@/components/ShippingSelector';
interface CheckoutPageClientProps {
isLoggedIn: boolean;
customer?: any;
customerAddress?: any;
}
export default function CheckoutPageClient({ isLoggedIn, customer }: CheckoutPageClientProps) {
export default function CheckoutPageClient({ isLoggedIn, customer, customerAddress }: CheckoutPageClientProps) {
const [mounted, setMounted] = useState(false);
const [mode, setMode] = useState<'logged-in' | 'login' | 'guest' | null>(isLoggedIn ? 'logged-in' : null);
const [mode, setMode] = useState<'logged-in' | 'login' | 'guest' | 'edit-address' | null>(isLoggedIn ? 'logged-in' : null);
const [checkingOut, setCheckingOut] = useState(false);
const [checkoutError, setCheckoutError] = useState<string | null>(null);
const [shippingCountry, setShippingCountry] = useState('GB');
const [shippingCountry, setShippingCountry] = useState(customerAddress?.country || 'GB');
const [shippingService, setShippingService] = useState<string | null>(null);
const [shippingCost, setShippingCost] = useState(0);
const [guestData, setGuestData] = useState({
@@ -26,11 +27,27 @@ export default function CheckoutPageClient({ isLoggedIn, customer }: CheckoutPag
phone: '',
address: '',
});
const [customerAddressData, setCustomerAddressData] = useState({
phone: customerAddress?.phone || '',
addressLine1: customerAddress?.addressLine1 || '',
addressLine2: customerAddress?.addressLine2 || '',
city: customerAddress?.city || '',
postalCode: customerAddress?.postalCode || '',
country: customerAddress?.country || 'GB',
});
const items = useCart((s) => s.items);
const removeItem = useCart((s) => s.removeItem);
const setQuantity = useCart((s) => s.setQuantity);
const subtotal = useCart((s) => s.total());
const total = subtotal + shippingCost;
const handleShippingSelect = useCallback((country: string, service: string, cost: number) => {
setShippingCountry(country);
setShippingService(service);
setShippingCost(cost);
}, []);
useEffect(() => setMounted(true), []);
if (!mounted) return null;
@@ -100,18 +117,47 @@ export default function CheckoutPageClient({ isLoggedIn, customer }: CheckoutPag
return (
<div className="mx-auto max-w-2xl px-6 py-16">
<h1 className="font-display text-3xl">Checkout</h1>
<div className="flex items-baseline justify-between">
<h1 className="font-display text-3xl">Checkout</h1>
<Link
href="/products"
className="text-sm text-clay hover:text-clay-dark"
>
Continue shopping
</Link>
</div>
<div className="mt-8 grid gap-8 md:grid-cols-2">
{/* Order summary */}
<div>
<h2 className="font-display text-lg">Order summary</h2>
<div className="mt-4 space-y-3 border-t border-line pt-4">
<div className="mt-4 space-y-4 border-t border-line pt-4">
{items.map((item) => (
<div key={item.cartItemId} className="flex justify-between text-sm">
<div key={item.cartItemId} className="flex justify-between items-start text-sm">
<div>
<p className="font-medium">{item.name}</p>
<p className="text-xs text-muted">Qty: {item.quantity}</p>
<div className="flex items-center gap-2 mt-2 border border-ink w-fit">
<button
onClick={() => setQuantity(item.cartItemId, item.quantity - 1)}
disabled={item.quantity <= 1}
className="px-2 py-1 text-xs disabled:opacity-40"
>
</button>
<span className="w-6 text-center text-xs font-mono">{item.quantity}</span>
<button
onClick={() => setQuantity(item.cartItemId, item.quantity + 1)}
className="px-2 py-1 text-xs hover:bg-surface"
>
+
</button>
</div>
<button
onClick={() => removeItem(item.cartItemId)}
className="mt-2 text-xs text-splash-pink hover:text-splash-pink-dark"
>
Remove
</button>
</div>
<Price cents={item.unitPrice * item.quantity} className="font-mono" />
</div>
@@ -144,13 +190,30 @@ export default function CheckoutPageClient({ isLoggedIn, customer }: CheckoutPag
<p className="mt-2 text-sm text-muted">Logged in as {customer?.email}</p>
</div>
<div className="space-y-3 border border-line bg-surface p-4">
<p className="font-medium text-sm">Delivery address</p>
{customerAddressData.addressLine1 ? (
<div className="text-sm text-muted space-y-1">
<p>{customerAddressData.addressLine1}</p>
{customerAddressData.addressLine2 && <p>{customerAddressData.addressLine2}</p>}
<p>{customerAddressData.city}, {customerAddressData.postalCode}</p>
<p className="text-xs mt-2">Phone: {customerAddressData.phone}</p>
</div>
) : (
<p className="text-sm text-splash-orange">No address saved. Please add one below.</p>
)}
<button
type="button"
onClick={() => setMode('edit-address')}
className="text-sm text-clay hover:text-clay-dark"
>
{customerAddressData.addressLine1 ? 'Edit address' : 'Add address'}
</button>
</div>
<ShippingSelector
items={items}
onShippingSelect={(country, service, cost) => {
setShippingCountry(country);
setShippingService(service);
setShippingCost(cost);
}}
onShippingSelect={handleShippingSelect}
/>
<button
@@ -272,6 +335,129 @@ export default function CheckoutPageClient({ isLoggedIn, customer }: CheckoutPag
</button>
</form>
</div>
) : mode === 'edit-address' ? (
<div>
<button
onClick={() => setMode('logged-in')}
className="text-sm text-clay hover:text-clay-dark mb-4"
>
Back
</button>
<h3 className="font-display text-lg">Edit delivery address</h3>
<form className="mt-4 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="phone-edit" className="tag-label mb-2 block">
Phone number
</label>
<input
id="phone-edit"
type="tel"
required
value={customerAddressData.phone}
onChange={(e) => setCustomerAddressData({ ...customerAddressData, phone: e.target.value })}
className="w-full border border-line px-3 py-2 text-sm"
placeholder="e.g. +44 7700 900000"
/>
</div>
<div>
<label htmlFor="country-edit" className="tag-label mb-2 block">
Country
</label>
<select
id="country-edit"
value={customerAddressData.country}
onChange={(e) => {
setCustomerAddressData({ ...customerAddressData, country: e.target.value });
setShippingCountry(e.target.value);
}}
className="w-full border border-line bg-paper px-3 py-2 text-sm"
>
<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>
</div>
<div>
<label htmlFor="addressLine1-edit" className="tag-label mb-2 block">
Address line 1
</label>
<input
id="addressLine1-edit"
type="text"
required
value={customerAddressData.addressLine1}
onChange={(e) => setCustomerAddressData({ ...customerAddressData, addressLine1: e.target.value })}
className="w-full border border-line px-3 py-2 text-sm"
placeholder="Street address"
/>
</div>
<div>
<label htmlFor="addressLine2-edit" className="tag-label mb-2 block">
Address line 2 (optional)
</label>
<input
id="addressLine2-edit"
type="text"
value={customerAddressData.addressLine2}
onChange={(e) => setCustomerAddressData({ ...customerAddressData, addressLine2: e.target.value })}
className="w-full border border-line px-3 py-2 text-sm"
placeholder="Apartment, suite, etc."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="city-edit" className="tag-label mb-2 block">
City
</label>
<input
id="city-edit"
type="text"
required
value={customerAddressData.city}
onChange={(e) => setCustomerAddressData({ ...customerAddressData, city: e.target.value })}
className="w-full border border-line px-3 py-2 text-sm"
placeholder="London"
/>
</div>
<div>
<label htmlFor="postalCode-edit" className="tag-label mb-2 block">
Postal code
</label>
<input
id="postalCode-edit"
type="text"
required
value={customerAddressData.postalCode}
onChange={(e) => setCustomerAddressData({ ...customerAddressData, postalCode: e.target.value })}
className="w-full border border-line px-3 py-2 text-sm"
placeholder="SW1A 1AA"
/>
</div>
</div>
<button
type="button"
onClick={() => setMode('logged-in')}
className="w-full bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark"
>
Done editing
</button>
</form>
</div>
) : (
<div>
<button
@@ -342,11 +528,7 @@ export default function CheckoutPageClient({ isLoggedIn, customer }: CheckoutPag
<ShippingSelector
items={items}
onShippingSelect={(country, service, cost) => {
setShippingCountry(country);
setShippingService(service);
setShippingCost(cost);
}}
onShippingSelect={handleShippingSelect}
/>
<button
+14 -1
View File
@@ -9,5 +9,18 @@ export default async function CheckoutPage() {
console.error('Error fetching customer for checkout:', err);
}
return <CheckoutPageClient isLoggedIn={!!customer} customer={customer} />;
return (
<CheckoutPageClient
isLoggedIn={!!customer}
customer={customer}
customerAddress={customer ? {
phone: customer.phone,
addressLine1: customer.addressLine1,
addressLine2: customer.addressLine2,
city: customer.city,
postalCode: customer.postalCode,
country: customer.country,
} : undefined}
/>
);
}