trptk/app/checkout/page.tsx
2026-02-24 17:14:07 +01:00

806 lines
30 KiB
TypeScript

"use client";
import { useState, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import Image from "next/image";
import Link from "next/link";
import { useCart } from "@/components/cart/CartContext";
import { useAuth } from "@/components/auth/AuthContext";
import { hasPhysicalItems, hasDigitalItems } from "@/lib/cartUtils";
import type { MedusaShippingOption } from "@/lib/medusa";
import { FORMAT_GROUPS } from "@/lib/variants";
import { CountrySelect } from "@/components/CountrySelect";
import { IconButton } from "@/components/IconButton";
import { IoArrowForwardOutline, IoTimeOutline } from "react-icons/io5";
import type { MedusaLineItem } from "@/lib/medusa";
import { AuthForm } from "@/components/auth/AuthForm";
import { ArrowLink, ArrowButton } from "@/components/ArrowLink";
// ── Helpers (shared with CartDrawer) ────────────────────────────────
const suffixToLabel: Record<string, string> = {};
for (const group of FORMAT_GROUPS) {
for (const v of group.variants ?? []) suffixToLabel[v.suffix] = v.title;
for (const sg of group.subgroups ?? []) {
for (const v of sg.variants) suffixToLabel[v.suffix] = v.title;
}
}
function variantLabel(item: MedusaLineItem): string {
const sku = item.variant?.sku;
if (sku) {
const suffix = sku.split("_").pop()?.toUpperCase();
if (suffix && suffixToLabel[suffix]) return suffixToLabel[suffix];
}
return item.variant?.title ?? item.title;
}
function formatPrice(amount: number, currencyCode: string) {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: currencyCode,
minimumFractionDigits: 2,
}).format(amount);
}
async function apiPost(url: string, body: unknown) {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error ?? `Request failed: ${res.status}`);
}
return res.json();
}
// ── Component ───────────────────────────────────────────────────────
export default function CheckoutPage() {
const router = useRouter();
const { cart, isLoading, applyPromo, removePromo } = useCart();
const { customer, isAuthenticated, isLoading: authLoading } = useAuth();
// Billing address (always collected)
const [email, setEmail] = useState("");
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [address1, setAddress1] = useState("");
const [address2, setAddress2] = useState("");
const [city, setCity] = useState("");
const [province, setProvince] = useState("");
const [postalCode, setPostalCode] = useState("");
const [countryCode, setCountryCode] = useState("nl");
const [phone, setPhone] = useState("");
// Separate shipping address (only when "Ship to different address" is checked)
const [shipToDifferent, setShipToDifferent] = useState(false);
const [shipFirstName, setShipFirstName] = useState("");
const [shipLastName, setShipLastName] = useState("");
const [shipAddress1, setShipAddress1] = useState("");
const [shipAddress2, setShipAddress2] = useState("");
const [shipCity, setShipCity] = useState("");
const [shipProvince, setShipProvince] = useState("");
const [shipPostalCode, setShipPostalCode] = useState("");
const [shipCountryCode, setShipCountryCode] = useState("nl");
const [shipPhone, setShipPhone] = useState("");
// Shipping
const [shippingOptions, setShippingOptions] = useState<MedusaShippingOption[]>([]);
const [selectedShipping, setSelectedShipping] = useState<string | null>(null);
const [loadingShipping, setLoadingShipping] = useState(false);
// Promo code
const [promoCode, setPromoCode] = useState("");
const [promoError, setPromoError] = useState<string | null>(null);
const [promoLoading, setPromoLoading] = useState(false);
// Terms & conditions
const [termsAccepted, setTermsAccepted] = useState(false);
// Inline login toggle
const [showLogin, setShowLogin] = useState(false);
// Submission
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const needsShipping = cart ? hasPhysicalItems(cart) : false;
const hasDigital = cart ? hasDigitalItems(cart) : false;
// Pre-fill email and address from customer account
useEffect(() => {
if (!isAuthenticated || !customer) return;
if (customer.email && !email) setEmail(customer.email);
// Fetch saved addresses and pre-fill the first one
async function prefillAddress() {
try {
const res = await fetch("/api/account/addresses");
if (!res.ok) return;
const data = await res.json();
const addr = data.addresses?.[0];
if (!addr) return;
// Only pre-fill if billing fields are still empty
if (!firstName && !lastName && !address1) {
setFirstName(addr.first_name ?? "");
setLastName(addr.last_name ?? "");
setAddress1(addr.address_1 ?? "");
setAddress2(addr.address_2 ?? "");
setCity(addr.city ?? "");
setProvince(addr.province ?? "");
setPostalCode(addr.postal_code ?? "");
setCountryCode(addr.country_code ?? "nl");
setPhone(addr.phone ?? "");
}
} catch {
// Non-critical — user can fill in manually
}
}
prefillAddress();
}, [isAuthenticated, customer]); // eslint-disable-line react-hooks/exhaustive-deps
// Build address objects
const billingAddress = {
first_name: firstName,
last_name: lastName,
address_1: address1,
address_2: address2 || undefined,
city,
province: province || undefined,
postal_code: postalCode,
country_code: countryCode,
phone: phone || undefined,
};
const effectiveShippingAddress =
needsShipping && shipToDifferent
? {
first_name: shipFirstName,
last_name: shipLastName,
address_1: shipAddress1,
address_2: shipAddress2 || undefined,
city: shipCity,
province: shipProvince || undefined,
postal_code: shipPostalCode,
country_code: shipCountryCode,
phone: shipPhone || undefined,
}
: billingAddress;
// Redirect if cart is empty (after loading)
useEffect(() => {
if (!isLoading && (!cart || cart.items.length === 0)) {
router.replace("/");
}
}, [isLoading, cart, router]);
// Auto-fetch shipping options when required address fields are filled
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const shipAddrFields =
needsShipping && shipToDifferent
? {
fn: shipFirstName,
ln: shipLastName,
a1: shipAddress1,
c: shipCity,
pc: shipPostalCode,
cc: shipCountryCode,
}
: { fn: firstName, ln: lastName, a1: address1, c: city, pc: postalCode, cc: countryCode };
const addressComplete =
needsShipping &&
!!shipAddrFields.fn &&
!!shipAddrFields.ln &&
!!shipAddrFields.a1 &&
!!shipAddrFields.c &&
!!shipAddrFields.pc &&
!!shipAddrFields.cc;
useEffect(() => {
if (!addressComplete || !cart?.id) {
setShippingOptions([]);
setSelectedShipping(null);
return;
}
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(async () => {
setLoadingShipping(true);
setShippingOptions([]);
setSelectedShipping(null);
try {
// Update cart with address so Medusa knows the destination
await apiPost("/api/checkout/update", {
cartId: cart.id,
email: email || undefined,
shipping_address: effectiveShippingAddress,
});
const res = await fetch(`/api/checkout/shipping-options?cartId=${cart.id}`);
if (res.ok) {
const options: MedusaShippingOption[] = await res.json();
setShippingOptions(options);
if (options.length > 0) setSelectedShipping(options[0].id);
}
} catch (e) {
console.error("Failed to fetch shipping options:", e);
} finally {
setLoadingShipping(false);
}
}, 800);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
cart?.id,
addressComplete,
shipAddrFields.fn,
shipAddrFields.ln,
shipAddrFields.a1,
shipAddrFields.c,
shipAddrFields.pc,
shipAddrFields.cc,
email,
]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!cart?.id) return;
setSubmitting(true);
setError(null);
try {
// 1. Update cart with email + addresses
const updateBody: Record<string, unknown> = {
cartId: cart.id,
email,
billing_address: billingAddress,
shipping_address: effectiveShippingAddress,
};
await apiPost("/api/checkout/update", updateBody);
// 2. Add shipping method
if (needsShipping && selectedShipping) {
await apiPost("/api/checkout/shipping-method", {
cartId: cart.id,
optionId: selectedShipping,
});
} else if (!needsShipping) {
// Digital-only: auto-select the first (free) shipping option
const shipRes = await fetch(`/api/checkout/shipping-options?cartId=${cart.id}`);
if (shipRes.ok) {
const options: MedusaShippingOption[] = await shipRes.json();
if (options.length > 0) {
await apiPost("/api/checkout/shipping-method", {
cartId: cart.id,
optionId: options[0].id,
});
}
}
}
// 3. Create payment collection + Mollie session
const paymentCollection = await apiPost("/api/checkout/payment", {
cartId: cart.id,
});
// 4. Find the Mollie redirect URL from the payment session
const session = paymentCollection.payment_sessions?.[0];
const redirectUrl =
session?.data?.session_url ??
session?.data?.checkout_url ??
session?.data?._links?.checkout?.href;
if (redirectUrl) {
// Validate the payment redirect points to a trusted Mollie domain
try {
const parsedUrl = new URL(redirectUrl);
if (!parsedUrl.hostname.endsWith("mollie.com")) {
throw new Error("Untrusted payment redirect URL");
}
} catch {
throw new Error("Invalid payment redirect URL received.");
}
window.location.href = redirectUrl;
} else {
throw new Error("No payment redirect URL received. Check your Mollie configuration.");
}
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong");
setSubmitting(false);
}
};
if (isLoading || authLoading || !cart || cart.items.length === 0) {
return (
<main className="flex min-h-[60vh] items-center justify-center">
<p className="text-lightsec dark:text-darksec">Loading</p>
</main>
);
}
const currencyCode = cart.currency_code ?? "eur";
const inputClass =
"no-ring w-full rounded-xl border border-lightline px-6 py-3 shadow-lg text-lighttext transition-all duration-200 ease-in-out placeholder:text-lightsec hover:border-lightline-hover focus:border-lightline-focus dark:border-darkline dark:text-darktext dark:placeholder:text-darksec dark:hover:border-darkline-hover dark:focus:border-darkline-focus";
const billingValid = !!firstName && !!lastName && !!address1 && !!city && !!postalCode;
const shipValid =
!shipToDifferent ||
(!!shipFirstName && !!shipLastName && !!shipAddress1 && !!shipCity && !!shipPostalCode);
const needsLogin = hasDigital && !isAuthenticated;
const formDisabled =
submitting ||
!email ||
!billingValid ||
!termsAccepted ||
needsLogin ||
(needsShipping && (!shipValid || !selectedShipping));
return (
<main className="mx-auto max-w-300 px-6 py-12 md:px-8 md:py-16">
<h1 className="font-argesta text-3xl">Checkout</h1>
<form onSubmit={handleSubmit} className="mt-10 grid gap-12 lg:grid-cols-[1fr_380px]">
{/* ── Left: Form ── */}
<div className="flex flex-col gap-14">
{/* Contact */}
<section>
<h2 className="mb-4 font-silkasb text-lg">Contact</h2>
{isAuthenticated ? (
<p className="mb-3 text-sm text-lightsec dark:text-darksec">
Signed in as{" "}
<span className="font-silkasb text-lighttext dark:text-darktext">
{customer?.email}
</span>
</p>
) : (
<div className="mb-3">
<p className="text-sm text-lightsec dark:text-darksec">
{hasDigital ? (
<>
An account is required for digital downloads.{" "}
<ArrowButton
onClick={() => setShowLogin(!showLogin)}
className="text-trptkblue dark:text-white"
>
{showLogin ? "Hide" : "Sign in or create an account"}
</ArrowButton>
</>
) : (
<>
Have an account?{" "}
<ArrowButton
onClick={() => setShowLogin(!showLogin)}
className="text-trptkblue dark:text-white"
>
{showLogin ? "Continue as guest" : "Log in"}
</ArrowButton>
</>
)}
</p>
{showLogin && (
<div className="mt-4">
<AuthForm compact onSuccess={() => setShowLogin(false)} />
</div>
)}
</div>
)}
{!showLogin && (
<input
type="email"
required
placeholder="Email address"
value={email}
onChange={(e) => setEmail(e.target.value)}
className={inputClass}
/>
)}
</section>
{/* Billing Address (always shown) + Ship toggle */}
<div className="flex flex-col gap-6">
<section>
<h2 className="mb-4 font-silkasb text-lg">Billing Address</h2>
<div className="grid gap-3 sm:grid-cols-2">
<input
type="text"
required
placeholder="First name"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
className={inputClass}
/>
<input
type="text"
required
placeholder="Last name"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
className={inputClass}
/>
</div>
<input
type="text"
required
placeholder="Address"
value={address1}
onChange={(e) => setAddress1(e.target.value)}
className={inputClass + " mt-3"}
/>
<input
type="text"
placeholder="Apartment, suite, etc. (optional)"
value={address2}
onChange={(e) => setAddress2(e.target.value)}
className={inputClass + " mt-3"}
/>
<div className="mt-3 grid gap-3 sm:grid-cols-3">
<input
type="text"
required
placeholder="City"
value={city}
onChange={(e) => setCity(e.target.value)}
className={inputClass}
/>
<input
type="text"
placeholder="Province / State"
value={province}
onChange={(e) => setProvince(e.target.value)}
className={inputClass}
/>
<input
type="text"
required
placeholder="Postal code"
value={postalCode}
onChange={(e) => setPostalCode(e.target.value)}
className={inputClass}
/>
</div>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<CountrySelect
value={countryCode}
onChange={setCountryCode}
className={inputClass}
/>
<input
type="tel"
placeholder="Phone (optional)"
value={phone}
onChange={(e) => setPhone(e.target.value)}
className={inputClass}
/>
</div>
</section>
{/* Ship to different address (only for physical items) */}
{needsShipping && (
<>
<label className="flex cursor-pointer items-center gap-3 text-sm">
<input
type="checkbox"
checked={shipToDifferent}
onChange={(e) => setShipToDifferent(e.target.checked)}
className="h-4 w-4 rounded border-lightline accent-trptkblue dark:border-darkline"
/>
Ship to a different address?
</label>
{shipToDifferent && (
<section>
<h2 className="mb-4 font-silkasb text-lg">Shipping Address</h2>
<div className="grid gap-3 sm:grid-cols-2">
<input
type="text"
required
placeholder="First name"
value={shipFirstName}
onChange={(e) => setShipFirstName(e.target.value)}
className={inputClass}
/>
<input
type="text"
required
placeholder="Last name"
value={shipLastName}
onChange={(e) => setShipLastName(e.target.value)}
className={inputClass}
/>
</div>
<input
type="text"
required
placeholder="Address"
value={shipAddress1}
onChange={(e) => setShipAddress1(e.target.value)}
className={inputClass + " mt-3"}
/>
<input
type="text"
placeholder="Apartment, suite, etc. (optional)"
value={shipAddress2}
onChange={(e) => setShipAddress2(e.target.value)}
className={inputClass + " mt-3"}
/>
<div className="mt-3 grid gap-3 sm:grid-cols-3">
<input
type="text"
required
placeholder="City"
value={shipCity}
onChange={(e) => setShipCity(e.target.value)}
className={inputClass}
/>
<input
type="text"
placeholder="Province / State"
value={shipProvince}
onChange={(e) => setShipProvince(e.target.value)}
className={inputClass}
/>
<input
type="text"
required
placeholder="Postal code"
value={shipPostalCode}
onChange={(e) => setShipPostalCode(e.target.value)}
className={inputClass}
/>
</div>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<CountrySelect
value={shipCountryCode}
onChange={setShipCountryCode}
className={inputClass}
/>
<input
type="tel"
placeholder="Phone (optional)"
value={shipPhone}
onChange={(e) => setShipPhone(e.target.value)}
className={inputClass}
/>
</div>
</section>
)}
{loadingShipping && (
<p className="text-sm text-lightsec dark:text-darksec">
Loading shipping options
</p>
)}
</>
)}
</div>
{/* Shipping Method (auto-selected, shown as info) */}
{needsShipping &&
selectedShipping &&
shippingOptions.length > 0 &&
(() => {
const option = shippingOptions.find((o) => o.id === selectedShipping);
if (!option) return null;
return (
<section>
<h2 className="mb-4 font-silkasb text-lg">Shipping</h2>
<div className="flex items-center justify-between rounded-xl border border-lightline p-4 text-sm dark:border-darkline">
<span>{option.name}</span>
<span className="font-silkasb">
{option.amount === 0 ? "Free" : formatPrice(option.amount, currencyCode)}
</span>
</div>
</section>
);
})()}
{/* Error */}
{error && (
<div className="rounded-xl border border-red-300 bg-red-50 p-4 text-sm text-red-700 dark:border-red-800 dark:bg-red-950/30 dark:text-red-400">
{error}
</div>
)}
{/* Terms & Submit */}
<div className="flex flex-col gap-4">
<label className="flex cursor-pointer items-start gap-3 text-sm">
<input
type="checkbox"
checked={termsAccepted}
onChange={(e) => setTermsAccepted(e.target.checked)}
className="mt-0.5 h-4 w-4 rounded border-lightline accent-trptkblue dark:border-darkline"
/>
<span className="text-lightsec dark:text-darksec">
I have read and agree to the{" "}
<ArrowLink
href="/terms-conditions"
target="_blank"
className="text-trptkblue dark:text-white"
>
Terms &amp; Conditions
</ArrowLink>
</span>
</label>
<button
type="submit"
disabled={formDisabled}
className="w-full rounded-xl bg-trptkblue px-4 py-4 font-silkasb text-sm text-white shadow-lg transition-all duration-200 ease-in-out hover:opacity-90 disabled:pointer-events-none disabled:opacity-50 dark:bg-white dark:text-lighttext"
>
{submitting ? "Redirecting to payment…" : "Pay with Mollie"}
</button>
</div>
</div>
{/* ── Right: Order Summary ── */}
<aside className="order-first lg:order-last">
<h2 className="mb-4 font-silkasb text-lg">Order Summary</h2>
<ul className="grid gap-4 sm:grid-cols-2 lg:grid-cols-1">
{cart.items.map((item) => {
const content = (
<>
<div className="relative aspect-square w-21 flex-shrink-0 overflow-hidden rounded-xl bg-lightline dark:bg-darkline">
{item.thumbnail ? (
<Image
src={item.thumbnail}
alt={item.product_title ?? item.title}
fill
sizes="84px"
className="object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-xs text-lightsec dark:text-darksec">
No img
</div>
)}
</div>
<div className="min-w-0 flex-1 py-3 pr-3">
<p className="truncate font-silkasb text-sm">
{item.product_title ?? item.title}
</p>
<p className="truncate text-xs text-lightsec dark:text-darksec">
{variantLabel(item)} &times; {item.quantity}
</p>
<p className="mt-1 font-silkasb text-sm">
{formatPrice(item.total, currencyCode)}
</p>
</div>
</>
);
return (
<li
key={item.id}
className="overflow-hidden rounded-xl shadow-lg ring-1 ring-lightline transition-all duration-200 ease-in-out hover:ring-lightline-hover dark:ring-darkline dark:hover:ring-darkline-hover"
>
{item.product_handle ? (
<Link
href={`/release/${item.product_handle}`}
className="flex items-center gap-3"
>
{content}
</Link>
) : (
<div className="flex items-center gap-3">{content}</div>
)}
</li>
);
})}
</ul>
<div className="mt-6 space-y-2 pt-4 text-sm">
<div className="flex justify-between">
<span className="text-lightsec dark:text-darksec">Subtotal</span>
<span>{formatPrice(cart.subtotal, currencyCode)}</span>
</div>
{needsShipping && (
<div className="flex justify-between">
<span className="text-lightsec dark:text-darksec">Shipping</span>
<span>
{cart.shipping_total != null
? formatPrice(cart.shipping_total, currencyCode)
: "Calculated at next step"}
</span>
</div>
)}
{(cart.discount_total ?? 0) > 0 && (
<div className="flex justify-between">
<span className="text-lightsec dark:text-darksec">Discount</span>
<span className="text-green-600 dark:text-green-400">
&minus;{formatPrice(cart.discount_total!, currencyCode)}
</span>
</div>
)}
<div className="flex justify-between pt-2 font-silkasb">
<span>Total</span>
<span>{formatPrice(cart.total, currencyCode)}</span>
</div>
</div>
{/* Promo Code */}
<div className="mt-8 pt-4">
<div className="flex gap-2">
<input
type="text"
placeholder="Promo code"
value={promoCode}
onChange={(e) => {
setPromoCode(e.target.value);
setPromoError(null);
}}
className={inputClass + " flex-1"}
/>
<IconButton
disabled={promoLoading || !promoCode.trim()}
onClick={async () => {
setPromoLoading(true);
setPromoError(null);
try {
await applyPromo(promoCode.trim());
setPromoCode("");
} catch (e) {
setPromoError(e instanceof Error ? e.message : "Invalid promo code");
} finally {
setPromoLoading(false);
}
}}
className="flex-shrink-0"
>
{promoLoading ? (
<IoTimeOutline className="animate-spin" />
) : (
<IoArrowForwardOutline />
)}
</IconButton>
</div>
{promoError && (
<p className="mt-2 text-sm text-red-600 dark:text-red-400">{promoError}</p>
)}
{(cart.promotions?.length ?? 0) > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{cart.promotions!.map((promo) => (
<span
key={promo.id}
className="inline-flex items-center gap-1.5 rounded-lg bg-green-50 px-3 py-1.5 font-silkasb text-xs text-green-700 dark:bg-green-950/30 dark:text-green-400"
>
{promo.code ?? promo.id}
<button
type="button"
onClick={async () => {
try {
await removePromo(promo.code ?? promo.id);
} catch (e) {
setPromoError(e instanceof Error ? e.message : "Failed to remove code");
}
}}
className="ml-0.5 text-green-500 transition-colors hover:text-green-800 dark:hover:text-green-200"
aria-label={`Remove promo ${promo.code ?? promo.id}`}
>
&times;
</button>
</span>
))}
</div>
)}
</div>
</aside>
</form>
</main>
);
}