diff --git a/prisma/migrations/20260719085000_add_customer_address_fields/migration.sql b/prisma/migrations/20260719085000_add_customer_address_fields/migration.sql new file mode 100644 index 0000000..c85cb46 --- /dev/null +++ b/prisma/migrations/20260719085000_add_customer_address_fields/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 08d6c98..50bf208 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -250,6 +250,12 @@ model Customer { email String @unique passwordHash 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()) orders Order[] diff --git a/src/app/account/actions.ts b/src/app/account/actions.ts index c1786b1..1edc04b 100644 --- a/src/app/account/actions.ts +++ b/src/app/account/actions.ts @@ -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'); +} diff --git a/src/app/account/page.tsx b/src/app/account/page.tsx index 550938b..ed099da 100644 --- a/src/app/account/page.tsx +++ b/src/app/account/page.tsx @@ -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 (
@@ -56,6 +65,8 @@ export default async function AccountPage() {
+ +

Design reviews

{designReviews.length === 0 ? (

No designs awaiting review.

@@ -88,41 +99,49 @@ export default async function AccountPage() { {orders.length === 0 ? (

No orders yet.

) : ( -
- {orders.map((order) => ( -
- -
-

- {new Date(order.createdAt).toLocaleDateString(undefined, { dateStyle: 'medium' })} ·{' '} - {order.items.length} item{order.items.length === 1 ? '' : 's'} -

- - {statusLabel(order.status)} - -
- {formatPrice(order.total)} - + <> + {recentOrders.length > 0 && ( +
+ {recentOrders.map((order) => ( +
+ +
+

+ {new Date(order.createdAt).toLocaleDateString(undefined, { dateStyle: 'medium' })} ·{' '} + {order.items.length} item{order.items.length === 1 ? '' : 's'} +

+ + {statusLabel(order.status)} + +
+ {formatPrice(order.total)} + - {/* Tracking Information */} - {order.carrier && order.trackingNumber && ( -
-

- Tracking: {order.carrier} · {order.trackingNumber} -

- {order.estimatedDeliveryDate && ( -

- Est. delivery: {new Date(order.estimatedDeliveryDate).toLocaleDateString(undefined, { dateStyle: 'medium' })} -

+ {/* Tracking Information */} + {order.carrier && order.trackingNumber && ( +
+

+ Tracking: {order.carrier} · {order.trackingNumber} +

+ {order.estimatedDeliveryDate && ( +

+ Est. delivery: {new Date(order.estimatedDeliveryDate).toLocaleDateString(undefined, { dateStyle: 'medium' })} +

+ )} +
)}
- )} + ))}
- ))} -
+ )} + + {olderOrders.length > 0 && ( + + )} + )}
); diff --git a/src/app/checkout/client.tsx b/src/app/checkout/client.tsx index 6a8574b..618a26f 100644 --- a/src/app/checkout/client.tsx +++ b/src/app/checkout/client.tsx @@ -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(null); - const [shippingCountry, setShippingCountry] = useState('GB'); + const [shippingCountry, setShippingCountry] = useState(customerAddress?.country || 'GB'); const [shippingService, setShippingService] = useState(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 (
-

Checkout

+
+

Checkout

+ + ← Continue shopping + +
{/* Order summary */}

Order summary

-
+
{items.map((item) => ( -
+

{item.name}

-

Qty: {item.quantity}

+
+ + {item.quantity} + +
+
@@ -144,13 +190,30 @@ export default function CheckoutPageClient({ isLoggedIn, customer }: CheckoutPag

Logged in as {customer?.email}

+
+

Delivery address

+ {customerAddressData.addressLine1 ? ( +
+

{customerAddressData.addressLine1}

+ {customerAddressData.addressLine2 &&

{customerAddressData.addressLine2}

} +

{customerAddressData.city}, {customerAddressData.postalCode}

+

Phone: {customerAddressData.phone}

+
+ ) : ( +

No address saved. Please add one below.

+ )} + +
+ { - setShippingCountry(country); - setShippingService(service); - setShippingCost(cost); - }} + onShippingSelect={handleShippingSelect} />
+ ) : mode === 'edit-address' ? ( +
+ +

Edit delivery address

+
+
+
+ + setCustomerAddressData({ ...customerAddressData, phone: e.target.value })} + className="w-full border border-line px-3 py-2 text-sm" + placeholder="e.g. +44 7700 900000" + /> +
+
+ + +
+
+ +
+ + setCustomerAddressData({ ...customerAddressData, addressLine1: e.target.value })} + className="w-full border border-line px-3 py-2 text-sm" + placeholder="Street address" + /> +
+ +
+ + setCustomerAddressData({ ...customerAddressData, addressLine2: e.target.value })} + className="w-full border border-line px-3 py-2 text-sm" + placeholder="Apartment, suite, etc." + /> +
+ +
+
+ + setCustomerAddressData({ ...customerAddressData, city: e.target.value })} + className="w-full border border-line px-3 py-2 text-sm" + placeholder="London" + /> +
+
+ + setCustomerAddressData({ ...customerAddressData, postalCode: e.target.value })} + className="w-full border border-line px-3 py-2 text-sm" + placeholder="SW1A 1AA" + /> +
+
+ + +
+
) : (
+ + Go to checkout + +
+
+
+ ); +} diff --git a/src/components/AddressFormSection.tsx b/src/components/AddressFormSection.tsx new file mode 100644 index 0000000..4dd7192 --- /dev/null +++ b/src/components/AddressFormSection.tsx @@ -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 ( +
+ + + {isOpen && ( +
+
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+ )} +
+ ); +} diff --git a/src/components/OlderOrders.tsx b/src/components/OlderOrders.tsx new file mode 100644 index 0000000..117de55 --- /dev/null +++ b/src/components/OlderOrders.tsx @@ -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 ( +
+ + + {isOpen && ( +
+ {orders.map((order) => ( +
+ +
+

+ {new Date(order.createdAt).toLocaleDateString(undefined, { dateStyle: 'medium' })} ·{' '} + {order.items.length} item{order.items.length === 1 ? '' : 's'} +

+ + {statusLabel(order.status)} + +
+ {formatPrice(order.total)} + + + {/* Tracking Information */} + {order.carrier && order.trackingNumber && ( +
+

+ Tracking: {order.carrier} · {order.trackingNumber} +

+ {order.estimatedDeliveryDate && ( +

+ Est. delivery: {new Date(order.estimatedDeliveryDate).toLocaleDateString(undefined, { dateStyle: 'medium' })} +

+ )} +
+ )} +
+ ))} +
+ )} +
+ ); +} diff --git a/src/components/StandardProductView.tsx b/src/components/StandardProductView.tsx index db28829..ffb5ad0 100644 --- a/src/components/StandardProductView.tsx +++ b/src/components/StandardProductView.tsx @@ -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 —
- {justAdded &&

Added to your bag.

}
setShowLoginPrompt(false)} /> + setShowAddToCartModal(false)} + productName={product.name} + />
); }