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
@@ -56,6 +56,13 @@ export default function LoginPage({ searchParams }: { searchParams: { error?: st
|
||||
Forgot your password?
|
||||
</Link>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,13 @@ type CheckoutCartItem = {
|
||||
placementPreviewUrlBack: string | null;
|
||||
};
|
||||
|
||||
type GuestInfo = {
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
address: string;
|
||||
};
|
||||
|
||||
export async function POST(req: Request) {
|
||||
if (!process.env.STRIPE_SECRET_KEY) {
|
||||
return NextResponse.json(
|
||||
@@ -40,6 +47,7 @@ export async function POST(req: Request) {
|
||||
|
||||
const body = await req.json();
|
||||
const items: CheckoutCartItem[] = body.items ?? [];
|
||||
const guest: GuestInfo | undefined = body.guest;
|
||||
|
||||
if (items.length === 0) {
|
||||
return NextResponse.json({ error: 'Your bag is empty.' }, { status: 400 });
|
||||
@@ -79,6 +87,9 @@ export async function POST(req: Request) {
|
||||
status: 'PENDING',
|
||||
total,
|
||||
customerId: customer?.id,
|
||||
email: guest?.email ?? customer?.email,
|
||||
guestName: guest?.name,
|
||||
guestPhone: guest?.phone,
|
||||
items: {
|
||||
create: pricedItems.map((i) => ({
|
||||
productId: i.productId,
|
||||
|
||||
+5
-25
@@ -7,8 +7,6 @@ import Price from '@/components/Price';
|
||||
|
||||
export default function CartPage() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [checkingOut, setCheckingOut] = useState(false);
|
||||
const [checkoutError, setCheckoutError] = useState<string | null>(null);
|
||||
const items = useCart((s) => s.items);
|
||||
const removeItem = useCart((s) => s.removeItem);
|
||||
const setQuantity = useCart((s) => s.setQuantity);
|
||||
@@ -17,24 +15,8 @@ export default function CartPage() {
|
||||
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
const handleCheckout = async () => {
|
||||
setCheckingOut(true);
|
||||
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);
|
||||
}
|
||||
const handleCheckout = () => {
|
||||
window.location.href = '/checkout';
|
||||
};
|
||||
|
||||
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="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>
|
||||
<button
|
||||
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 disabled:opacity-60"
|
||||
className="w-full bg-clay px-6 py-3 text-sm font-medium text-paper transition-colors hover:bg-clay-dark"
|
||||
>
|
||||
{checkingOut ? 'Redirecting to checkout…' : 'Checkout'}
|
||||
Checkout
|
||||
</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-1 text-center text-xs text-muted">
|
||||
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