806 lines
30 KiB
TypeScript
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 & 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)} × {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">
|
|
−{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}`}
|
|
>
|
|
×
|
|
</button>
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</aside>
|
|
</form>
|
|
</main>
|
|
);
|
|
}
|