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
+40
View File
@@ -0,0 +1,40 @@
'use client';
import Link from 'next/link';
interface AddToCartModalProps {
isOpen: boolean;
onClose: () => void;
productName: string;
}
export default function AddToCartModal({ isOpen, onClose, productName }: AddToCartModalProps) {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="mx-4 max-w-sm rounded border border-line bg-paper p-8 shadow-lg">
<h2 className="font-display text-xl">Added to bag</h2>
<p className="mt-3 text-sm text-muted">
<strong>{productName}</strong> has been added to your bag.
</p>
<div className="mt-8 flex gap-3">
<button
onClick={onClose}
className="flex-1 border-2 border-ink px-6 py-3 text-sm font-medium text-ink hover:border-clay hover:bg-clay hover:text-paper"
>
Continue shopping
</button>
<Link
href="/checkout"
onClick={onClose}
className="flex-1 bg-clay px-6 py-3 text-center text-sm font-medium text-paper hover:bg-clay-dark"
>
Go to checkout
</Link>
</div>
</div>
</div>
);
}
+131
View File
@@ -0,0 +1,131 @@
'use client';
import { useState } from 'react';
import { updateCustomerAddress } from '@/app/account/actions';
interface AddressFormSectionProps {
customer: any;
}
export default function AddressFormSection({ customer }: AddressFormSectionProps) {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="mt-10">
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 text-sm text-clay hover:text-clay-dark font-medium"
>
{isOpen ? '✕' : '✎'} {isOpen ? 'Close' : 'Edit delivery address'}
</button>
{isOpen && (
<form action={updateCustomerAddress} className="mt-4 space-y-4 border border-line bg-surface p-6">
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="phone" className="tag-label mb-2 block">
Phone number
</label>
<input
id="phone"
name="phone"
type="tel"
defaultValue={customer.phone ?? ''}
className="w-full border border-line px-3 py-2 text-sm"
placeholder="e.g. +44 7700 900000"
/>
</div>
<div>
<label htmlFor="country" className="tag-label mb-2 block">
Country
</label>
<select
id="country"
name="country"
defaultValue={customer.country ?? 'GB'}
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" className="tag-label mb-2 block">
Address line 1
</label>
<input
id="addressLine1"
name="addressLine1"
type="text"
defaultValue={customer.addressLine1 ?? ''}
className="w-full border border-line px-3 py-2 text-sm"
placeholder="Street address"
/>
</div>
<div>
<label htmlFor="addressLine2" className="tag-label mb-2 block">
Address line 2 (optional)
</label>
<input
id="addressLine2"
name="addressLine2"
type="text"
defaultValue={customer.addressLine2 ?? ''}
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" className="tag-label mb-2 block">
City
</label>
<input
id="city"
name="city"
type="text"
defaultValue={customer.city ?? ''}
className="w-full border border-line px-3 py-2 text-sm"
placeholder="London"
/>
</div>
<div>
<label htmlFor="postalCode" className="tag-label mb-2 block">
Postal code
</label>
<input
id="postalCode"
name="postalCode"
type="text"
defaultValue={customer.postalCode ?? ''}
className="w-full border border-line px-3 py-2 text-sm"
placeholder="SW1A 1AA"
/>
</div>
</div>
<button
type="submit"
className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark"
>
Save address
</button>
</form>
)}
</div>
);
}
+64
View File
@@ -0,0 +1,64 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
interface OlderOrdersProps {
orders: any[];
formatPrice: (cents: number) => string;
statusLabel: (status: string) => string;
statusClasses: (status: string) => string;
}
export default function OlderOrders({ orders, formatPrice, statusLabel, statusClasses }: OlderOrdersProps) {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="mt-6">
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 text-sm text-clay hover:text-clay-dark font-medium"
>
{isOpen ? '✕' : '▼'} Older orders ({orders.length})
</button>
{isOpen && (
<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>
{/* 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>
);
}
+8 -8
View File
@@ -5,6 +5,7 @@ import { v4 as uuid } from 'uuid';
import ProductMockup from './ProductMockup';
import Price from './Price';
import LoginPromptModal from './LoginPromptModal';
import AddToCartModal from './AddToCartModal';
import { useCart } from '@/lib/cartStore';
import { getColorName } from '@/lib/colorNames';
import { checkCustomerAuth } from '@/app/actions';
@@ -17,6 +18,7 @@ export default function StandardProductView({ product }: { product: ProductDTO }
const [quantity, setQuantity] = useState(1);
const [justAdded, setJustAdded] = useState(false);
const [showLoginPrompt, setShowLoginPrompt] = useState(false);
const [showAddToCartModal, setShowAddToCartModal] = useState(false);
const addItem = useCart((s) => s.addItem);
const hasBack = Boolean(product.imageUrlBack);
@@ -48,11 +50,6 @@ export default function StandardProductView({ product }: { product: ProductDTO }
}
const handleAddToBag = async () => {
const { isAuthenticated } = await checkCustomerAuth();
if (!isAuthenticated) {
setShowLoginPrompt(true);
}
addItem({
cartItemId: uuid(),
productId: product.id,
@@ -69,8 +66,7 @@ export default function StandardProductView({ product }: { product: ProductDTO }
placementPreviewUrl: product.colorPhotos[color] ?? product.imageUrl ?? null,
placementPreviewUrlBack: product.imageUrlBack ?? null,
});
setJustAdded(true);
setTimeout(() => setJustAdded(false), 2200);
setShowAddToCartModal(true);
};
return (
@@ -188,10 +184,14 @@ export default function StandardProductView({ product }: { product: ProductDTO }
Add to bag <Price cents={product.effectivePrice * quantity} />
</button>
</div>
{justAdded && <p className="text-sm text-pine">Added to your bag.</p>}
</div>
<LoginPromptModal isOpen={showLoginPrompt} onClose={() => setShowLoginPrompt(false)} />
<AddToCartModal
isOpen={showAddToCartModal}
onClose={() => setShowAddToCartModal(false)}
productName={product.name}
/>
</div>
);
}