"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 = {}; 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([]); const [selectedShipping, setSelectedShipping] = useState(null); const [loadingShipping, setLoadingShipping] = useState(false); // Promo code const [promoCode, setPromoCode] = useState(""); const [promoError, setPromoError] = useState(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(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 | 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 = { 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 (

Loading…

); } 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 (

Checkout

{/* ── Left: Form ── */}
{/* Contact */}

Contact

{isAuthenticated ? (

Signed in as{" "} {customer?.email}

) : (

{hasDigital ? ( <> An account is required for digital downloads.{" "} setShowLogin(!showLogin)} className="text-trptkblue dark:text-white" > {showLogin ? "Hide" : "Sign in or create an account"} ) : ( <> Have an account?{" "} setShowLogin(!showLogin)} className="text-trptkblue dark:text-white" > {showLogin ? "Continue as guest" : "Log in"} )}

{showLogin && (
setShowLogin(false)} />
)}
)} {!showLogin && ( setEmail(e.target.value)} className={inputClass} /> )}
{/* Billing Address (always shown) + Ship toggle */}

Billing Address

setFirstName(e.target.value)} className={inputClass} /> setLastName(e.target.value)} className={inputClass} />
setAddress1(e.target.value)} className={inputClass + " mt-3"} /> setAddress2(e.target.value)} className={inputClass + " mt-3"} />
setCity(e.target.value)} className={inputClass} /> setProvince(e.target.value)} className={inputClass} /> setPostalCode(e.target.value)} className={inputClass} />
setPhone(e.target.value)} className={inputClass} />
{/* Ship to different address (only for physical items) */} {needsShipping && ( <> {shipToDifferent && (

Shipping Address

setShipFirstName(e.target.value)} className={inputClass} /> setShipLastName(e.target.value)} className={inputClass} />
setShipAddress1(e.target.value)} className={inputClass + " mt-3"} /> setShipAddress2(e.target.value)} className={inputClass + " mt-3"} />
setShipCity(e.target.value)} className={inputClass} /> setShipProvince(e.target.value)} className={inputClass} /> setShipPostalCode(e.target.value)} className={inputClass} />
setShipPhone(e.target.value)} className={inputClass} />
)} {loadingShipping && (

Loading shipping options…

)} )}
{/* 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 (

Shipping

{option.name} {option.amount === 0 ? "Free" : formatPrice(option.amount, currencyCode)}
); })()} {/* Error */} {error && (
{error}
)} {/* Terms & Submit */}
{/* ── Right: Order Summary ── */}
); }