Initial commit: Craft2Prints with Stages 1-3
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useCart } from '@/lib/cartStore';
|
||||
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);
|
||||
const total = useCart((s) => s.total());
|
||||
const hasCustomDesign = items.some((item) => item.designJson.front.length > 0 || item.designJson.back.length > 0);
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
if (!mounted) return null; // avoid a hydration mismatch on first paint
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-6 py-20 text-center">
|
||||
<h1 className="font-display text-4xl">Your bag</h1>
|
||||
<p className="mt-4 text-muted">Nothing in here yet.</p>
|
||||
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
|
||||
<Link
|
||||
href="/made-to-order"
|
||||
className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark"
|
||||
>
|
||||
Start designing
|
||||
</Link>
|
||||
<Link
|
||||
href="/products"
|
||||
className="border-2 border-ink px-6 py-3 text-sm font-medium text-ink hover:border-clay hover:text-clay"
|
||||
>
|
||||
Shop ready-made
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-6 py-12">
|
||||
<h1 className="font-display text-4xl">Your bag</h1>
|
||||
|
||||
<div className="mt-8 divide-y divide-line border-t border-line">
|
||||
{items.map((item) => (
|
||||
<div key={item.cartItemId} className="flex flex-wrap items-center gap-4 py-6">
|
||||
<div className="h-24 w-24 shrink-0 border border-line bg-surface p-1">
|
||||
{item.placementPreviewUrl || item.designPreviewUrl ? (
|
||||
<img
|
||||
src={item.placementPreviewUrl ?? item.designPreviewUrl}
|
||||
alt={item.name}
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-xs text-muted">No preview</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-[160px] flex-1">
|
||||
<p className="font-display text-lg">{item.name}</p>
|
||||
<div className="mt-1 flex items-center gap-3 text-sm text-muted">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="h-3.5 w-3.5 rounded-full border border-line"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
{item.color}
|
||||
</span>
|
||||
{item.size && <span>Size {item.size}</span>}
|
||||
</div>
|
||||
{(item.designJson.front.length > 0 || item.designJson.back.length > 0) && (
|
||||
<div className="mt-1">
|
||||
<p className="text-xs text-muted">
|
||||
Custom design
|
||||
{item.designJson.front.length > 0 && item.designJson.back.length > 0
|
||||
? ' — front & back'
|
||||
: item.designJson.back.length > 0
|
||||
? ' — back'
|
||||
: ' — front'}
|
||||
</p>
|
||||
<div className="mt-1 flex gap-3">
|
||||
{item.designJson.front.length > 0 && item.designPreviewUrl && (
|
||||
<a
|
||||
href={item.designPreviewUrl}
|
||||
download={`${item.slug}-front-print.png`}
|
||||
className="text-xs text-clay hover:text-clay-dark"
|
||||
>
|
||||
Download front PNG
|
||||
</a>
|
||||
)}
|
||||
{item.designJson.back.length > 0 && item.designPreviewUrlBack && (
|
||||
<a
|
||||
href={item.designPreviewUrlBack}
|
||||
download={`${item.slug}-back-print.png`}
|
||||
className="text-xs text-clay hover:text-clay-dark"
|
||||
>
|
||||
Download back PNG
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center border border-ink">
|
||||
<button
|
||||
onClick={() => setQuantity(item.cartItemId, item.quantity - 1)}
|
||||
className="px-3 py-2 text-sm"
|
||||
aria-label="Decrease quantity"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="w-10 text-center font-mono text-sm">{item.quantity}</span>
|
||||
<button
|
||||
onClick={() => setQuantity(item.cartItemId, item.quantity + 1)}
|
||||
className="px-3 py-2 text-sm"
|
||||
aria-label="Increase quantity"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="w-24 text-right font-mono text-sm">
|
||||
<Price cents={item.unitPrice * item.quantity} />
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => removeItem(item.cartItemId)}
|
||||
className="tag-label text-muted hover:text-splash-pink"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{hasCustomDesign && (
|
||||
<div className="mt-8 border border-splash-orange bg-splash-orange/5 px-4 py-3 text-sm">
|
||||
<p className="font-medium text-ink">Please check your design before checking out</p>
|
||||
<p className="mt-1 text-muted">
|
||||
We print exactly what's shown in your preview above — spelling, wording, and
|
||||
placement included. Once your order's placed, that design goes straight to print,
|
||||
so take a moment to make sure everything looks right.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 flex flex-col items-end gap-4">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<span className="tag-label">Subtotal</span>
|
||||
<span className="font-mono text-xl">
|
||||
<Price cents={total} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<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.
|
||||
</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"
|
||||
>
|
||||
{checkingOut ? 'Redirecting to 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{' '}
|
||||
<Link href="/terms" className="text-clay hover:text-clay-dark">
|
||||
Terms
|
||||
</Link>{' '}
|
||||
and{' '}
|
||||
<Link href="/returns" className="text-clay hover:text-clay-dark">
|
||||
Returns policy
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user