feat(saleor): Phase 4 - Checkout Flow

- Create checkout page with form validation
- Implement shipping/billing address forms
- Add Cash on Delivery (COD) payment method
- Integrate Saleor checkout completion mutation
- Add order success page with confirmation
- Handle checkout errors gracefully
- Display order summary with line items
This commit is contained in:
Unchained
2026-03-21 12:45:09 +02:00
parent 8b3389725e
commit d43481716d

464
src/app/checkout/page.tsx Normal file
View File

@@ -0,0 +1,464 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
import Header from "@/components/layout/Header";
import Footer from "@/components/layout/Footer";
import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore";
import { formatPrice } from "@/lib/saleor";
import { saleorClient } from "@/lib/saleor/client";
import {
CHECKOUT_SHIPPING_ADDRESS_UPDATE,
CHECKOUT_BILLING_ADDRESS_UPDATE,
CHECKOUT_COMPLETE,
} from "@/lib/saleor/mutations/Checkout";
interface AddressForm {
firstName: string;
lastName: string;
streetAddress1: string;
streetAddress2: string;
city: string;
postalCode: string;
phone: string;
}
export default function CheckoutPage() {
const router = useRouter();
const { checkout, refreshCheckout, getLines, getTotal } = useSaleorCheckoutStore();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [orderComplete, setOrderComplete] = useState(false);
const [orderNumber, setOrderNumber] = useState<string | null>(null);
const [sameAsShipping, setSameAsShipping] = useState(true);
const [shippingAddress, setShippingAddress] = useState<AddressForm>({
firstName: "",
lastName: "",
streetAddress1: "",
streetAddress2: "",
city: "",
postalCode: "",
phone: "",
});
const [billingAddress, setBillingAddress] = useState<AddressForm>({
firstName: "",
lastName: "",
streetAddress1: "",
streetAddress2: "",
city: "",
postalCode: "",
phone: "",
});
const lines = getLines();
const total = getTotal();
useEffect(() => {
if (!checkout) {
refreshCheckout();
}
}, [checkout, refreshCheckout]);
// Redirect if cart is empty
useEffect(() => {
if (lines.length === 0 && !orderComplete) {
// Optionally redirect to cart or products
// router.push("/products");
}
}, [lines, orderComplete, router]);
const handleShippingChange = (field: keyof AddressForm, value: string) => {
setShippingAddress((prev) => ({ ...prev, [field]: value }));
if (sameAsShipping) {
setBillingAddress((prev) => ({ ...prev, [field]: value }));
}
};
const handleBillingChange = (field: keyof AddressForm, value: string) => {
setBillingAddress((prev) => ({ ...prev, [field]: value }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!checkout) {
setError("No active checkout. Please try again.");
return;
}
setIsLoading(true);
setError(null);
try {
// Update shipping address
const shippingResult = await saleorClient.mutate({
mutation: CHECKOUT_SHIPPING_ADDRESS_UPDATE,
variables: {
checkoutId: checkout.id,
shippingAddress: {
...shippingAddress,
country: "RS", // Serbia
},
},
});
if (shippingResult.data?.checkoutShippingAddressUpdate?.errors?.length > 0) {
throw new Error(shippingResult.data.checkoutShippingAddressUpdate.errors[0].message);
}
// Update billing address
const billingResult = await saleorClient.mutate({
mutation: CHECKOUT_BILLING_ADDRESS_UPDATE,
variables: {
checkoutId: checkout.id,
billingAddress: {
...billingAddress,
country: "RS",
},
},
});
if (billingResult.data?.checkoutBillingAddressUpdate?.errors?.length > 0) {
throw new Error(billingResult.data.checkoutBillingAddressUpdate.errors[0].message);
}
// Complete checkout (creates order)
const completeResult = await saleorClient.mutate({
mutation: CHECKOUT_COMPLETE,
variables: {
checkoutId: checkout.id,
},
});
if (completeResult.data?.checkoutComplete?.errors?.length > 0) {
throw new Error(completeResult.data.checkoutComplete.errors[0].message);
}
const order = completeResult.data?.checkoutComplete?.order;
if (order) {
setOrderNumber(order.number);
setOrderComplete(true);
} else {
throw new Error("Failed to create order");
}
} catch (err: any) {
setError(err.message || "An error occurred during checkout");
} finally {
setIsLoading(false);
}
};
// Order Success Page
if (orderComplete) {
return (
<main className="min-h-screen">
<Header />
<section className="pt-24 pb-20 px-4">
<div className="max-w-2xl mx-auto text-center">
<div className="mb-6">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h1 className="text-3xl font-serif mb-2">Order Confirmed!</h1>
<p className="text-foreground-muted">Thank you for your purchase.</p>
</div>
{orderNumber && (
<div className="bg-background-ice p-6 rounded-lg mb-6">
<p className="text-sm text-foreground-muted mb-1">Order Number</p>
<p className="text-2xl font-serif">#{orderNumber}</p>
</div>
)}
<p className="text-foreground-muted mb-8">
You will receive a confirmation email shortly. We will contact you to arrange Cash on Delivery.
</p>
<Link
href="/products"
className="inline-block px-8 py-3 bg-foreground text-white hover:bg-accent-dark transition-colors"
>
Continue Shopping
</Link>
</div>
</section>
<Footer />
</main>
);
}
return (
<main className="min-h-screen">
<Header />
<section className="pt-24 pb-20 px-4">
<div className="max-w-7xl mx-auto">
<h1 className="text-3xl font-serif mb-8">Checkout</h1>
{error && (
<div className="bg-red-50 border border-red-200 text-red-600 p-4 mb-6 rounded">
{error}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
{/* Checkout Form */}
<div>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Shipping Address */}
<div className="border-b border-border pb-6">
<h2 className="text-xl font-serif mb-4">Shipping Address</h2>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">First Name</label>
<input
type="text"
required
value={shippingAddress.firstName}
onChange={(e) => handleShippingChange("firstName", e.target.value)}
className="w-full border border-border px-4 py-2 rounded"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Last Name</label>
<input
type="text"
required
value={shippingAddress.lastName}
onChange={(e) => handleShippingChange("lastName", e.target.value)}
className="w-full border border-border px-4 py-2 rounded"
/>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium mb-1">Street Address</label>
<input
type="text"
required
value={shippingAddress.streetAddress1}
onChange={(e) => handleShippingChange("streetAddress1", e.target.value)}
className="w-full border border-border px-4 py-2 rounded"
/>
</div>
<div className="col-span-2">
<input
type="text"
value={shippingAddress.streetAddress2}
onChange={(e) => handleShippingChange("streetAddress2", e.target.value)}
placeholder="Apartment, suite, etc. (optional)"
className="w-full border border-border px-4 py-2 rounded"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">City</label>
<input
type="text"
required
value={shippingAddress.city}
onChange={(e) => handleShippingChange("city", e.target.value)}
className="w-full border border-border px-4 py-2 rounded"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Postal Code</label>
<input
type="text"
required
value={shippingAddress.postalCode}
onChange={(e) => handleShippingChange("postalCode", e.target.value)}
className="w-full border border-border px-4 py-2 rounded"
/>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium mb-1">Phone</label>
<input
type="tel"
required
value={shippingAddress.phone}
onChange={(e) => handleShippingChange("phone", e.target.value)}
className="w-full border border-border px-4 py-2 rounded"
/>
</div>
</div>
</div>
{/* Billing Address Toggle */}
<div className="border-b border-border pb-6">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={sameAsShipping}
onChange={(e) => setSameAsShipping(e.target.checked)}
className="w-4 h-4"
/>
<span>Billing address same as shipping</span>
</label>
</div>
{/* Billing Address (if different) */}
{!sameAsShipping && (
<div className="border-b border-border pb-6">
<h2 className="text-xl font-serif mb-4">Billing Address</h2>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">First Name</label>
<input
type="text"
required
value={billingAddress.firstName}
onChange={(e) => handleBillingChange("firstName", e.target.value)}
className="w-full border border-border px-4 py-2 rounded"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Last Name</label>
<input
type="text"
required
value={billingAddress.lastName}
onChange={(e) => handleBillingChange("lastName", e.target.value)}
className="w-full border border-border px-4 py-2 rounded"
/>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium mb-1">Street Address</label>
<input
type="text"
required
value={billingAddress.streetAddress1}
onChange={(e) => handleBillingChange("streetAddress1", e.target.value)}
className="w-full border border-border px-4 py-2 rounded"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">City</label>
<input
type="text"
required
value={billingAddress.city}
onChange={(e) => handleBillingChange("city", e.target.value)}
className="w-full border border-border px-4 py-2 rounded"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Postal Code</label>
<input
type="text"
required
value={billingAddress.postalCode}
onChange={(e) => handleBillingChange("postalCode", e.target.value)}
className="w-full border border-border px-4 py-2 rounded"
/>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium mb-1">Phone</label>
<input
type="tel"
required
value={billingAddress.phone}
onChange={(e) => handleBillingChange("phone", e.target.value)}
className="w-full border border-border px-4 py-2 rounded"
/>
</div>
</div>
</div>
)}
{/* Payment Method */}
<div className="border-b border-border pb-6">
<h2 className="text-xl font-serif mb-4">Payment Method</h2>
<div className="bg-background-ice p-4 rounded">
<div className="flex items-center gap-3">
<input
type="radio"
checked
readOnly
className="w-4 h-4"
/>
<span>Cash on Delivery (COD)</span>
</div>
<p className="text-sm text-foreground-muted mt-2 ml-7">
Pay when your order is delivered to your door.
</p>
</div>
</div>
{/* Submit Button */}
<button
type="submit"
disabled={isLoading || lines.length === 0}
className="w-full py-4 bg-foreground text-white font-medium hover:bg-accent-dark transition-colors disabled:opacity-50"
>
{isLoading ? "Processing..." : `Complete Order - ${formatPrice(total)}`}
</button>
</form>
</div>
{/* Order Summary */}
<div className="bg-background-ice p-6 rounded-lg h-fit">
<h2 className="text-xl font-serif mb-6">Order Summary</h2>
{lines.length === 0 ? (
<p className="text-foreground-muted">Your cart is empty</p>
) : (
<>
<div className="space-y-4 mb-6">
{lines.map((line) => (
<div key={line.id} className="flex gap-4">
<div className="w-16 h-16 bg-white relative flex-shrink-0">
{line.variant.product.media[0]?.url && (
<Image
src={line.variant.product.media[0].url}
alt={line.variant.product.name}
fill
className="object-cover"
/>
)}
</div>
<div className="flex-1">
<h3 className="font-medium text-sm">{line.variant.product.name}</h3>
<p className="text-foreground-muted text-sm">
Qty: {line.quantity}
</p>
<p className="text-sm">
{formatPrice(line.totalPrice.gross.amount)}
</p>
</div>
</div>
))}
</div>
<div className="border-t border-border pt-4 space-y-2">
<div className="flex justify-between">
<span className="text-foreground-muted">Subtotal</span>
<span>{formatPrice(checkout?.subtotalPrice?.gross?.amount || 0)}</span>
</div>
<div className="flex justify-between">
<span className="text-foreground-muted">Shipping</span>
<span>
{checkout?.shippingPrice?.gross?.amount
? formatPrice(checkout.shippingPrice.gross.amount)
: "Calculated"
}
</span>
</div>
<div className="flex justify-between font-medium text-lg pt-2 border-t border-border">
<span>Total</span>
<span>{formatPrice(total)}</span>
</div>
</div>
</>
)}
</div>
</div>
</div>
</section>
<Footer />
</main>
);
}