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:
co-authored by
Claude Haiku 4.5
parent
5538938020
commit
08038dffb1
@@ -0,0 +1,17 @@
|
|||||||
|
-- AddField phone to Customer
|
||||||
|
ALTER TABLE "Customer" ADD COLUMN "phone" TEXT;
|
||||||
|
|
||||||
|
-- AddField addressLine1 to Customer
|
||||||
|
ALTER TABLE "Customer" ADD COLUMN "addressLine1" TEXT;
|
||||||
|
|
||||||
|
-- AddField addressLine2 to Customer
|
||||||
|
ALTER TABLE "Customer" ADD COLUMN "addressLine2" TEXT;
|
||||||
|
|
||||||
|
-- AddField city to Customer
|
||||||
|
ALTER TABLE "Customer" ADD COLUMN "city" TEXT;
|
||||||
|
|
||||||
|
-- AddField postalCode to Customer
|
||||||
|
ALTER TABLE "Customer" ADD COLUMN "postalCode" TEXT;
|
||||||
|
|
||||||
|
-- AddField country to Customer
|
||||||
|
ALTER TABLE "Customer" ADD COLUMN "country" TEXT;
|
||||||
@@ -250,6 +250,12 @@ model Customer {
|
|||||||
email String @unique
|
email String @unique
|
||||||
passwordHash String
|
passwordHash String
|
||||||
name String?
|
name String?
|
||||||
|
phone String? // delivery phone number
|
||||||
|
addressLine1 String? // street address
|
||||||
|
addressLine2 String? // apartment, suite, etc.
|
||||||
|
city String?
|
||||||
|
postalCode String?
|
||||||
|
country String? // ISO 2-letter country code, e.g., "GB"
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
orders Order[]
|
orders Order[]
|
||||||
|
|||||||
@@ -149,3 +149,30 @@ export async function resetPassword(formData: FormData) {
|
|||||||
await createSession(resetToken.customerId);
|
await createSession(resetToken.customerId);
|
||||||
redirect('/account');
|
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');
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { redirect } from 'next/navigation';
|
|||||||
import { getCurrentCustomer } from '@/lib/auth';
|
import { getCurrentCustomer } from '@/lib/auth';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
import { logoutCustomer } from './actions';
|
import { logoutCustomer } from './actions';
|
||||||
|
import AddressFormSection from '@/components/AddressFormSection';
|
||||||
|
import OlderOrders from '@/components/OlderOrders';
|
||||||
|
|
||||||
function formatPrice(cents: number) {
|
function formatPrice(cents: number) {
|
||||||
return `£${(cents / 100).toFixed(2)}`;
|
return `£${(cents / 100).toFixed(2)}`;
|
||||||
@@ -36,6 +38,13 @@ export default async function AccountPage() {
|
|||||||
orderBy: { createdAt: 'desc' },
|
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 (
|
return (
|
||||||
<div className="mx-auto max-w-3xl px-6 py-12">
|
<div className="mx-auto max-w-3xl px-6 py-12">
|
||||||
<div className="flex items-baseline justify-between gap-3">
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
@@ -56,6 +65,8 @@ export default async function AccountPage() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AddressFormSection customer={customer} />
|
||||||
|
|
||||||
<h2 className="mt-10 font-display text-xl">Design reviews</h2>
|
<h2 className="mt-10 font-display text-xl">Design reviews</h2>
|
||||||
{designReviews.length === 0 ? (
|
{designReviews.length === 0 ? (
|
||||||
<p className="mt-3 text-sm text-muted">No designs awaiting review.</p>
|
<p className="mt-3 text-sm text-muted">No designs awaiting review.</p>
|
||||||
@@ -88,8 +99,10 @@ export default async function AccountPage() {
|
|||||||
{orders.length === 0 ? (
|
{orders.length === 0 ? (
|
||||||
<p className="mt-3 text-sm text-muted">No orders yet.</p>
|
<p className="mt-3 text-sm text-muted">No orders yet.</p>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
|
{recentOrders.length > 0 && (
|
||||||
<div className="mt-4 divide-y divide-line border-t border-line">
|
<div className="mt-4 divide-y divide-line border-t border-line">
|
||||||
{orders.map((order) => (
|
{recentOrders.map((order) => (
|
||||||
<div key={order.id} className="border-b border-line py-4 last:border-b-0">
|
<div key={order.id} className="border-b border-line py-4 last:border-b-0">
|
||||||
<Link
|
<Link
|
||||||
href={`/account/orders/${order.id}`}
|
href={`/account/orders/${order.id}`}
|
||||||
@@ -124,6 +137,12 @@ export default async function AccountPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{olderOrders.length > 0 && (
|
||||||
|
<OlderOrders orders={olderOrders} formatPrice={formatPrice} statusLabel={statusLabel} statusClasses={statusClasses} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+199
-17
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import Link from 'next/link';
|
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';
|
||||||
@@ -10,14 +10,15 @@ import ShippingSelector from '@/components/ShippingSelector';
|
|||||||
interface CheckoutPageClientProps {
|
interface CheckoutPageClientProps {
|
||||||
isLoggedIn: boolean;
|
isLoggedIn: boolean;
|
||||||
customer?: any;
|
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 [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 [checkingOut, setCheckingOut] = useState(false);
|
||||||
const [checkoutError, setCheckoutError] = useState<string | null>(null);
|
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 [shippingService, setShippingService] = useState<string | null>(null);
|
||||||
const [shippingCost, setShippingCost] = useState(0);
|
const [shippingCost, setShippingCost] = useState(0);
|
||||||
const [guestData, setGuestData] = useState({
|
const [guestData, setGuestData] = useState({
|
||||||
@@ -26,11 +27,27 @@ export default function CheckoutPageClient({ isLoggedIn, customer }: CheckoutPag
|
|||||||
phone: '',
|
phone: '',
|
||||||
address: '',
|
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 items = useCart((s) => s.items);
|
||||||
|
const removeItem = useCart((s) => s.removeItem);
|
||||||
|
const setQuantity = useCart((s) => s.setQuantity);
|
||||||
const subtotal = useCart((s) => s.total());
|
const subtotal = useCart((s) => s.total());
|
||||||
const total = subtotal + shippingCost;
|
const total = subtotal + shippingCost;
|
||||||
|
|
||||||
|
const handleShippingSelect = useCallback((country: string, service: string, cost: number) => {
|
||||||
|
setShippingCountry(country);
|
||||||
|
setShippingService(service);
|
||||||
|
setShippingCost(cost);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => setMounted(true), []);
|
useEffect(() => setMounted(true), []);
|
||||||
|
|
||||||
if (!mounted) return null;
|
if (!mounted) return null;
|
||||||
@@ -100,18 +117,47 @@ export default function CheckoutPageClient({ isLoggedIn, customer }: CheckoutPag
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-2xl px-6 py-16">
|
<div className="mx-auto max-w-2xl px-6 py-16">
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
<h1 className="font-display text-3xl">Checkout</h1>
|
<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">
|
<div className="mt-8 grid gap-8 md:grid-cols-2">
|
||||||
{/* Order summary */}
|
{/* Order summary */}
|
||||||
<div>
|
<div>
|
||||||
<h2 className="font-display text-lg">Order summary</h2>
|
<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) => (
|
{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>
|
<div>
|
||||||
<p className="font-medium">{item.name}</p>
|
<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>
|
</div>
|
||||||
<Price cents={item.unitPrice * item.quantity} className="font-mono" />
|
<Price cents={item.unitPrice * item.quantity} className="font-mono" />
|
||||||
</div>
|
</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>
|
<p className="mt-2 text-sm text-muted">Logged in as {customer?.email}</p>
|
||||||
</div>
|
</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
|
<ShippingSelector
|
||||||
items={items}
|
items={items}
|
||||||
onShippingSelect={(country, service, cost) => {
|
onShippingSelect={handleShippingSelect}
|
||||||
setShippingCountry(country);
|
|
||||||
setShippingService(service);
|
|
||||||
setShippingCost(cost);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -272,6 +335,129 @@ export default function CheckoutPageClient({ isLoggedIn, customer }: CheckoutPag
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<button
|
<button
|
||||||
@@ -342,11 +528,7 @@ export default function CheckoutPageClient({ isLoggedIn, customer }: CheckoutPag
|
|||||||
|
|
||||||
<ShippingSelector
|
<ShippingSelector
|
||||||
items={items}
|
items={items}
|
||||||
onShippingSelect={(country, service, cost) => {
|
onShippingSelect={handleShippingSelect}
|
||||||
setShippingCountry(country);
|
|
||||||
setShippingService(service);
|
|
||||||
setShippingCost(cost);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -9,5 +9,18 @@ export default async function CheckoutPage() {
|
|||||||
console.error('Error fetching customer for checkout:', err);
|
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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { v4 as uuid } from 'uuid';
|
|||||||
import ProductMockup from './ProductMockup';
|
import ProductMockup from './ProductMockup';
|
||||||
import Price from './Price';
|
import Price from './Price';
|
||||||
import LoginPromptModal from './LoginPromptModal';
|
import LoginPromptModal from './LoginPromptModal';
|
||||||
|
import AddToCartModal from './AddToCartModal';
|
||||||
import { useCart } from '@/lib/cartStore';
|
import { useCart } from '@/lib/cartStore';
|
||||||
import { getColorName } from '@/lib/colorNames';
|
import { getColorName } from '@/lib/colorNames';
|
||||||
import { checkCustomerAuth } from '@/app/actions';
|
import { checkCustomerAuth } from '@/app/actions';
|
||||||
@@ -17,6 +18,7 @@ export default function StandardProductView({ product }: { product: ProductDTO }
|
|||||||
const [quantity, setQuantity] = useState(1);
|
const [quantity, setQuantity] = useState(1);
|
||||||
const [justAdded, setJustAdded] = useState(false);
|
const [justAdded, setJustAdded] = useState(false);
|
||||||
const [showLoginPrompt, setShowLoginPrompt] = useState(false);
|
const [showLoginPrompt, setShowLoginPrompt] = useState(false);
|
||||||
|
const [showAddToCartModal, setShowAddToCartModal] = useState(false);
|
||||||
const addItem = useCart((s) => s.addItem);
|
const addItem = useCart((s) => s.addItem);
|
||||||
|
|
||||||
const hasBack = Boolean(product.imageUrlBack);
|
const hasBack = Boolean(product.imageUrlBack);
|
||||||
@@ -48,11 +50,6 @@ export default function StandardProductView({ product }: { product: ProductDTO }
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleAddToBag = async () => {
|
const handleAddToBag = async () => {
|
||||||
const { isAuthenticated } = await checkCustomerAuth();
|
|
||||||
if (!isAuthenticated) {
|
|
||||||
setShowLoginPrompt(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
addItem({
|
addItem({
|
||||||
cartItemId: uuid(),
|
cartItemId: uuid(),
|
||||||
productId: product.id,
|
productId: product.id,
|
||||||
@@ -69,8 +66,7 @@ export default function StandardProductView({ product }: { product: ProductDTO }
|
|||||||
placementPreviewUrl: product.colorPhotos[color] ?? product.imageUrl ?? null,
|
placementPreviewUrl: product.colorPhotos[color] ?? product.imageUrl ?? null,
|
||||||
placementPreviewUrlBack: product.imageUrlBack ?? null,
|
placementPreviewUrlBack: product.imageUrlBack ?? null,
|
||||||
});
|
});
|
||||||
setJustAdded(true);
|
setShowAddToCartModal(true);
|
||||||
setTimeout(() => setJustAdded(false), 2200);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -188,10 +184,14 @@ export default function StandardProductView({ product }: { product: ProductDTO }
|
|||||||
Add to bag — <Price cents={product.effectivePrice * quantity} />
|
Add to bag — <Price cents={product.effectivePrice * quantity} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{justAdded && <p className="text-sm text-pine">Added to your bag.</p>}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<LoginPromptModal isOpen={showLoginPrompt} onClose={() => setShowLoginPrompt(false)} />
|
<LoginPromptModal isOpen={showLoginPrompt} onClose={() => setShowLoginPrompt(false)} />
|
||||||
|
<AddToCartModal
|
||||||
|
isOpen={showAddToCartModal}
|
||||||
|
onClose={() => setShowAddToCartModal(false)}
|
||||||
|
productName={product.name}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user