Add guest checkout and admin login link
- Add guest checkout flow: customers can now complete orders without creating an account - New /checkout page with login or guest options - Guest form collects: full name, email, phone, address - Orders track guest info via guestName and guestPhone fields - Update cart message to reflect guest option - Add "Admin login" link to customer login page for clear navigation - Update checkout API to accept and store guest information Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Haiku 4.5
parent
39f54dedee
commit
8edb5e1f5b
@@ -187,6 +187,9 @@ model Order {
|
|||||||
customerId String? // set when the buyer was logged in at checkout — null for guest orders
|
customerId String? // set when the buyer was logged in at checkout — null for guest orders
|
||||||
customer Customer? @relation(fields: [customerId], references: [id])
|
customer Customer? @relation(fields: [customerId], references: [id])
|
||||||
|
|
||||||
|
guestName String? // full name for guest orders
|
||||||
|
guestPhone String? // contact phone for guest orders
|
||||||
|
|
||||||
items OrderItem[]
|
items OrderItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,13 @@ export default function LoginPage({ searchParams }: { searchParams: { error?: st
|
|||||||
Forgot your password?
|
Forgot your password?
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-12 border-t border-line pt-6">
|
||||||
|
<p className="text-sm text-muted">Shop owner? </p>
|
||||||
|
<Link href="/admin/login" className="text-clay hover:text-clay-dark">
|
||||||
|
Admin login
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ type CheckoutCartItem = {
|
|||||||
placementPreviewUrlBack: string | null;
|
placementPreviewUrlBack: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type GuestInfo = {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
address: string;
|
||||||
|
};
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
if (!process.env.STRIPE_SECRET_KEY) {
|
if (!process.env.STRIPE_SECRET_KEY) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -40,6 +47,7 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const items: CheckoutCartItem[] = body.items ?? [];
|
const items: CheckoutCartItem[] = body.items ?? [];
|
||||||
|
const guest: GuestInfo | undefined = body.guest;
|
||||||
|
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
return NextResponse.json({ error: 'Your bag is empty.' }, { status: 400 });
|
return NextResponse.json({ error: 'Your bag is empty.' }, { status: 400 });
|
||||||
@@ -79,6 +87,9 @@ export async function POST(req: Request) {
|
|||||||
status: 'PENDING',
|
status: 'PENDING',
|
||||||
total,
|
total,
|
||||||
customerId: customer?.id,
|
customerId: customer?.id,
|
||||||
|
email: guest?.email ?? customer?.email,
|
||||||
|
guestName: guest?.name,
|
||||||
|
guestPhone: guest?.phone,
|
||||||
items: {
|
items: {
|
||||||
create: pricedItems.map((i) => ({
|
create: pricedItems.map((i) => ({
|
||||||
productId: i.productId,
|
productId: i.productId,
|
||||||
|
|||||||
+5
-25
@@ -7,8 +7,6 @@ import Price from '@/components/Price';
|
|||||||
|
|
||||||
export default function CartPage() {
|
export default function CartPage() {
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const [checkingOut, setCheckingOut] = useState(false);
|
|
||||||
const [checkoutError, setCheckoutError] = useState<string | null>(null);
|
|
||||||
const items = useCart((s) => s.items);
|
const items = useCart((s) => s.items);
|
||||||
const removeItem = useCart((s) => s.removeItem);
|
const removeItem = useCart((s) => s.removeItem);
|
||||||
const setQuantity = useCart((s) => s.setQuantity);
|
const setQuantity = useCart((s) => s.setQuantity);
|
||||||
@@ -17,24 +15,8 @@ export default function CartPage() {
|
|||||||
|
|
||||||
useEffect(() => setMounted(true), []);
|
useEffect(() => setMounted(true), []);
|
||||||
|
|
||||||
const handleCheckout = async () => {
|
const handleCheckout = () => {
|
||||||
setCheckingOut(true);
|
window.location.href = '/checkout';
|
||||||
setCheckoutError(null);
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/checkout', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ items }),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok || !data.url) {
|
|
||||||
throw new Error(data.error ?? 'Something went wrong starting checkout.');
|
|
||||||
}
|
|
||||||
window.location.href = data.url;
|
|
||||||
} catch (err) {
|
|
||||||
setCheckoutError(err instanceof Error ? err.message : 'Something went wrong.');
|
|
||||||
setCheckingOut(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!mounted) return null; // avoid a hydration mismatch on first paint
|
if (!mounted) return null; // avoid a hydration mismatch on first paint
|
||||||
@@ -180,16 +162,14 @@ export default function CartPage() {
|
|||||||
|
|
||||||
<div className="w-full max-w-xs">
|
<div className="w-full max-w-xs">
|
||||||
<div className="mb-4 rounded border border-splash-blue/40 bg-splash-blue/5 px-3 py-2 text-xs text-muted">
|
<div className="mb-4 rounded border border-splash-blue/40 bg-splash-blue/5 px-3 py-2 text-xs text-muted">
|
||||||
Your cart will be saved. You'll need to log in again to complete your order.
|
Your cart will be saved. You can log in or continue as a guest to complete your order.
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleCheckout}
|
onClick={handleCheckout}
|
||||||
disabled={checkingOut}
|
className="w-full bg-clay px-6 py-3 text-sm font-medium text-paper transition-colors hover:bg-clay-dark"
|
||||||
className="w-full bg-clay px-6 py-3 text-sm font-medium text-paper transition-colors hover:bg-clay-dark disabled:opacity-60"
|
|
||||||
>
|
>
|
||||||
{checkingOut ? 'Redirecting to checkout…' : 'Checkout'}
|
Checkout
|
||||||
</button>
|
</button>
|
||||||
{checkoutError && <p className="mt-2 text-center text-xs text-splash-pink">{checkoutError}</p>}
|
|
||||||
<p className="mt-2 text-center text-xs text-muted">You'll enter payment details on the next step.</p>
|
<p className="mt-2 text-center text-xs text-muted">You'll enter payment details on the next step.</p>
|
||||||
<p className="mt-1 text-center text-xs text-muted">
|
<p className="mt-1 text-center text-xs text-muted">
|
||||||
By placing an order you agree to our{' '}
|
By placing an order you agree to our{' '}
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useCart } from '@/lib/cartStore';
|
||||||
|
import { loginCustomer } from '@/app/account/actions';
|
||||||
|
import Price from '@/components/Price';
|
||||||
|
|
||||||
|
export default function CheckoutPage() {
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const [mode, setMode] = useState<'login' | 'guest' | null>(null);
|
||||||
|
const [checkingOut, setCheckingOut] = useState(false);
|
||||||
|
const [checkoutError, setCheckoutError] = useState<string | null>(null);
|
||||||
|
const [guestData, setGuestData] = useState({
|
||||||
|
fullName: '',
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
address: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = useCart((s) => s.items);
|
||||||
|
const total = useCart((s) => s.total());
|
||||||
|
|
||||||
|
useEffect(() => setMounted(true), []);
|
||||||
|
|
||||||
|
if (!mounted) return null;
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-md px-6 py-16 text-center">
|
||||||
|
<h1 className="font-display text-3xl">Checkout</h1>
|
||||||
|
<p className="mt-4 text-sm text-muted">Your bag is empty.</p>
|
||||||
|
<Link
|
||||||
|
href="/products"
|
||||||
|
className="mt-6 inline-block bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark"
|
||||||
|
>
|
||||||
|
Continue shopping
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGuestCheckout = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!guestData.fullName || !guestData.email || !guestData.phone || !guestData.address) {
|
||||||
|
setCheckoutError('Please fill in all fields.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCheckingOut(true);
|
||||||
|
setCheckoutError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/checkout', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
items,
|
||||||
|
guest: {
|
||||||
|
name: guestData.fullName,
|
||||||
|
email: guestData.email,
|
||||||
|
phone: guestData.phone,
|
||||||
|
address: guestData.address,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok || !data.url) {
|
||||||
|
throw new Error(data.error ?? 'Something went wrong starting checkout.');
|
||||||
|
}
|
||||||
|
window.location.href = data.url;
|
||||||
|
} catch (err) {
|
||||||
|
setCheckoutError(err instanceof Error ? err.message : 'Something went wrong.');
|
||||||
|
setCheckingOut(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl px-6 py-16">
|
||||||
|
<h1 className="font-display text-3xl">Checkout</h1>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.cartItemId} className="flex justify-between text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{item.name}</p>
|
||||||
|
<p className="text-xs text-muted">Qty: {item.quantity}</p>
|
||||||
|
</div>
|
||||||
|
<Price cents={item.unitPrice * item.quantity} className="font-mono" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 border-t border-line pt-4">
|
||||||
|
<div className="flex justify-between font-display text-lg">
|
||||||
|
<span>Total</span>
|
||||||
|
<Price cents={total} className="font-mono" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Checkout options */}
|
||||||
|
<div>
|
||||||
|
{mode === null ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-muted">How would you like to checkout?</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setMode('login')}
|
||||||
|
className="w-full border-2 border-ink px-6 py-3 text-sm font-medium text-ink hover:border-clay hover:bg-clay hover:text-paper"
|
||||||
|
>
|
||||||
|
Log in to existing account
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setMode('guest')}
|
||||||
|
className="w-full bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark"
|
||||||
|
>
|
||||||
|
Continue as guest
|
||||||
|
</button>
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
Don't have an account?{' '}
|
||||||
|
<Link href="/account/register" className="text-clay hover:text-clay-dark">
|
||||||
|
Create one now
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : mode === 'login' ? (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => setMode(null)}
|
||||||
|
className="text-sm text-clay hover:text-clay-dark mb-4"
|
||||||
|
>
|
||||||
|
← Back
|
||||||
|
</button>
|
||||||
|
<h3 className="font-display text-lg">Log in</h3>
|
||||||
|
<form action={loginCustomer} className="mt-4 space-y-4">
|
||||||
|
<input type="hidden" name="next" value="/checkout" />
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="tag-label mb-2 block">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
className="w-full border border-line px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="tag-label mb-2 block">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
className="w-full border border-line px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="w-full bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark">
|
||||||
|
Log in
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => setMode(null)}
|
||||||
|
className="text-sm text-clay hover:text-clay-dark mb-4"
|
||||||
|
>
|
||||||
|
← Back
|
||||||
|
</button>
|
||||||
|
<h3 className="font-display text-lg">Guest checkout</h3>
|
||||||
|
{checkoutError && (
|
||||||
|
<p className="mt-4 border border-splash-pink/40 bg-splash-pink/10 px-4 py-3 text-sm text-splash-pink">
|
||||||
|
{checkoutError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<form onSubmit={handleGuestCheckout} className="mt-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="fullName" className="tag-label mb-2 block">
|
||||||
|
Full name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="fullName"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={guestData.fullName}
|
||||||
|
onChange={(e) => setGuestData({ ...guestData, fullName: e.target.value })}
|
||||||
|
className="w-full border border-line px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="tag-label mb-2 block">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={guestData.email}
|
||||||
|
onChange={(e) => setGuestData({ ...guestData, email: e.target.value })}
|
||||||
|
className="w-full border border-line px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="phone" className="tag-label mb-2 block">
|
||||||
|
Phone number
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="phone"
|
||||||
|
type="tel"
|
||||||
|
required
|
||||||
|
value={guestData.phone}
|
||||||
|
onChange={(e) => setGuestData({ ...guestData, phone: e.target.value })}
|
||||||
|
className="w-full border border-line px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="address" className="tag-label mb-2 block">
|
||||||
|
Address
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="address"
|
||||||
|
required
|
||||||
|
value={guestData.address}
|
||||||
|
onChange={(e) => setGuestData({ ...guestData, address: e.target.value })}
|
||||||
|
rows={3}
|
||||||
|
className="w-full border border-line px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={checkingOut}
|
||||||
|
className="w-full bg-clay px-6 py-3 text-sm font-medium text-paper transition-colors hover:bg-clay-dark disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{checkingOut ? 'Processing…' : 'Continue to payment'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user