Seed: ecommerce template (24 files)
This commit is contained in:
@@ -0,0 +1,346 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Check, CreditCard, MapPin, ClipboardList, ArrowLeft, ArrowRight, Lock } from "lucide-react";
|
||||||
|
import Header from "@/components/Header";
|
||||||
|
import Footer from "@/components/Footer";
|
||||||
|
import { useCart } from "@/lib/cart-context";
|
||||||
|
import { useSaasMutation } from "@/hooks/use-tygarun";
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{ num: 1, label: "Shipping", icon: MapPin },
|
||||||
|
{ num: 2, label: "Payment", icon: CreditCard },
|
||||||
|
{ num: 3, label: "Review", icon: ClipboardList },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function CheckoutPage() {
|
||||||
|
const [step, setStep] = useState(1);
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
const { items, total, clearCart } = useCart();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { mutate: createCheckout, loading: placingOrder } = useSaasMutation("billing", "/checkout");
|
||||||
|
|
||||||
|
const [shipping, setShipping] = useState({
|
||||||
|
firstName: "", lastName: "", email: "", address: "", city: "", state: "", zip: "",
|
||||||
|
});
|
||||||
|
const [payment, setPayment] = useState({
|
||||||
|
cardNumber: "", expiry: "", cvv: "", cardholder: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const shippingCost = total >= 100 ? 0 : 12.99;
|
||||||
|
const tax = total * 0.08;
|
||||||
|
const grandTotal = total + shippingCost + tax;
|
||||||
|
|
||||||
|
const validateShipping = () => {
|
||||||
|
const e: Record<string, string> = {};
|
||||||
|
if (!shipping.firstName.trim()) e.firstName = "First name is required";
|
||||||
|
if (!shipping.lastName.trim()) e.lastName = "Last name is required";
|
||||||
|
if (!shipping.email.includes("@")) e.email = "Valid email is required";
|
||||||
|
if (!shipping.address.trim()) e.address = "Address is required";
|
||||||
|
if (!shipping.city.trim()) e.city = "City is required";
|
||||||
|
if (!shipping.zip.trim()) e.zip = "ZIP code is required";
|
||||||
|
setErrors(e);
|
||||||
|
return Object.keys(e).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const validatePayment = () => {
|
||||||
|
const e: Record<string, string> = {};
|
||||||
|
if (payment.cardNumber.replace(/\s/g, "").length < 16) e.cardNumber = "Enter a valid 16-digit card number";
|
||||||
|
if (!payment.expiry.trim()) e.expiry = "Expiry date is required";
|
||||||
|
if (payment.cvv.length < 3) e.cvv = "CVV must be at least 3 digits";
|
||||||
|
setErrors(e);
|
||||||
|
return Object.keys(e).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePlaceOrder = async () => {
|
||||||
|
try {
|
||||||
|
const res = await createCheckout({
|
||||||
|
amount: Math.round(grandTotal * 100),
|
||||||
|
email: shipping.email,
|
||||||
|
items: items.map((it) => ({ id: it.id, name: it.name, quantity: it.quantity, priceCents: Math.round(it.price * 100) })),
|
||||||
|
});
|
||||||
|
if (res?.url && res.url.indexOf("#") !== 0) { window.location.href = res.url; return; }
|
||||||
|
toast.success("Order placed!");
|
||||||
|
clearCart();
|
||||||
|
navigate("/order-confirmation");
|
||||||
|
} catch {
|
||||||
|
toast.error("Payment could not be started. Please try again.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateShipping = (field: string, value: string) => {
|
||||||
|
setShipping((prev) => ({ ...prev, [field]: value }));
|
||||||
|
if (errors[field]) setErrors((prev) => { const n = { ...prev }; delete n[field]; return n; });
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatePayment = (field: string, value: string) => {
|
||||||
|
setPayment((prev) => ({ ...prev, [field]: value }));
|
||||||
|
if (errors[field]) setErrors((prev) => { const n = { ...prev }; delete n[field]; return n; });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-stone-50 text-stone-900 antialiased">
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<div className="mx-auto max-w-6xl px-6 pt-28 pb-20">
|
||||||
|
{/* Step Indicator */}
|
||||||
|
<div className="flex items-center justify-center gap-4 mb-12">
|
||||||
|
{steps.map((s, i) => (
|
||||||
|
<div key={s.num} className="flex items-center gap-2">
|
||||||
|
<div className={`flex items-center gap-2 cursor-pointer \${step >= s.num ? "text-stone-900" : "text-stone-400"}`} onClick={() => s.num < step && setStep(s.num)}>
|
||||||
|
<div className={`h-9 w-9 rounded-full flex items-center justify-center text-sm font-semibold backdrop-blur-xl border-white/20 \${
|
||||||
|
step > s.num ? "bg-green-500/80 text-white" : step === s.num ? "bg-stone-900/80 text-white" : "bg-white/80 dark:bg-slate-900/80 text-stone-500"
|
||||||
|
}`}>
|
||||||
|
{step > s.num ? <Check className="h-4 w-4" /> : s.num}
|
||||||
|
</div>
|
||||||
|
<span className="hidden sm:inline text-sm font-medium">{s.label}</span>
|
||||||
|
</div>
|
||||||
|
{i < steps.length - 1 && (
|
||||||
|
<div className={`w-12 sm:w-20 h-px \${step > s.num ? "bg-green-500" : "bg-stone-200"}`} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid lg:grid-cols-3 gap-10">
|
||||||
|
{/* Main Form */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
{/* Step 1: Shipping */}
|
||||||
|
{step === 1 && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6 sm:p-8">
|
||||||
|
<h2 className="text-xl font-bold mb-6 flex items-center gap-2 animate-fade-up">
|
||||||
|
<MapPin className="h-5 w-5 text-rose-500" /> Shipping Information
|
||||||
|
</h2>
|
||||||
|
<form onSubmit={(e) => { e.preventDefault(); if (validateShipping()) setStep(2); }}>
|
||||||
|
<div className="grid sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-stone-700 mb-1.5 block">First Name</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input value={shipping.firstName} onChange={(e) => updateShipping("firstName", e.target.value)} placeholder="John" className={`\${errors.firstName ? "border-red-500 focus-visible:ring-red-500" : ""}`} />
|
||||||
|
{!errors.firstName && shipping.firstName.trim() && <Check className="h-4 w-4 text-emerald-500 absolute right-3 top-1/2 -translate-y-1/2" />}
|
||||||
|
</div>
|
||||||
|
{errors.firstName && <p className="text-xs text-red-500 mt-1">{errors.firstName}</p>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-stone-700 mb-1.5 block">Last Name</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input value={shipping.lastName} onChange={(e) => updateShipping("lastName", e.target.value)} placeholder="Doe" className={`\${errors.lastName ? "border-red-500 focus-visible:ring-red-500" : ""}`} />
|
||||||
|
{!errors.lastName && shipping.lastName.trim() && <Check className="h-4 w-4 text-emerald-500 absolute right-3 top-1/2 -translate-y-1/2" />}
|
||||||
|
</div>
|
||||||
|
{errors.lastName && <p className="text-xs text-red-500 mt-1">{errors.lastName}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<label className="text-sm font-medium text-stone-700 mb-1.5 block">Email</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input type="email" value={shipping.email} onChange={(e) => updateShipping("email", e.target.value)} placeholder="john@example.com" className={`\${errors.email ? "border-red-500 focus-visible:ring-red-500" : ""}`} />
|
||||||
|
{!errors.email && shipping.email?.includes("@") && <Check className="h-4 w-4 text-emerald-500 absolute right-3 top-1/2 -translate-y-1/2" />}
|
||||||
|
</div>
|
||||||
|
{errors.email && <p className="text-xs text-red-500 mt-1">{errors.email}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<label className="text-sm font-medium text-stone-700 mb-1.5 block">Address</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input value={shipping.address} onChange={(e) => updateShipping("address", e.target.value)} placeholder="123 Main Street" className={`\${errors.address ? "border-red-500 focus-visible:ring-red-500" : ""}`} />
|
||||||
|
{!errors.address && shipping.address.trim() && <Check className="h-4 w-4 text-emerald-500 absolute right-3 top-1/2 -translate-y-1/2" />}
|
||||||
|
</div>
|
||||||
|
{errors.address && <p className="text-xs text-red-500 mt-1">{errors.address}</p>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-stone-700 mb-1.5 block">City</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input value={shipping.city} onChange={(e) => updateShipping("city", e.target.value)} placeholder="New York" className={`\${errors.city ? "border-red-500 focus-visible:ring-red-500" : ""}`} />
|
||||||
|
{!errors.city && shipping.city.trim() && <Check className="h-4 w-4 text-emerald-500 absolute right-3 top-1/2 -translate-y-1/2" />}
|
||||||
|
</div>
|
||||||
|
{errors.city && <p className="text-xs text-red-500 mt-1">{errors.city}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-stone-700 mb-1.5 block">State</label>
|
||||||
|
<Input value={shipping.state} onChange={(e) => updateShipping("state", e.target.value)} placeholder="NY" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-stone-700 mb-1.5 block">ZIP</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input value={shipping.zip} onChange={(e) => updateShipping("zip", e.target.value)} placeholder="10001" className={`\${errors.zip ? "border-red-500 focus-visible:ring-red-500" : ""}`} />
|
||||||
|
{!errors.zip && shipping.zip.trim() && <Check className="h-4 w-4 text-emerald-500 absolute right-3 top-1/2 -translate-y-1/2" />}
|
||||||
|
</div>
|
||||||
|
{errors.zip && <p className="text-xs text-red-500 mt-1">{errors.zip}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between mt-8">
|
||||||
|
<Button asChild variant="outline" className="rounded-full active:scale-[0.98] transition-all">
|
||||||
|
<Link to="/cart"><ArrowLeft className="mr-2 h-4 w-4" /> Back to Cart</Link>
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" className="bg-stone-900 hover:bg-stone-800 rounded-full active:scale-[0.98] transition-all">
|
||||||
|
Continue to Payment <ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 2: Payment */}
|
||||||
|
{step === 2 && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6 sm:p-8">
|
||||||
|
<h2 className="text-xl font-bold mb-6 flex items-center gap-2 animate-fade-up">
|
||||||
|
<CreditCard className="h-5 w-5 text-rose-500" /> Payment Details
|
||||||
|
</h2>
|
||||||
|
<form onSubmit={(e) => { e.preventDefault(); if (validatePayment()) setStep(3); }}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-stone-700 mb-1.5 block">Cardholder Name</label>
|
||||||
|
<Input value={payment.cardholder} onChange={(e) => updatePayment("cardholder", e.target.value)} placeholder="John Doe" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-stone-700 mb-1.5 block">Card Number</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input value={payment.cardNumber} onChange={(e) => updatePayment("cardNumber", e.target.value)} placeholder="4242 4242 4242 4242" className={`\${errors.cardNumber ? "border-red-500 focus-visible:ring-red-500" : ""}`} />
|
||||||
|
{!errors.cardNumber && payment.cardNumber.replace(/\s/g, "").length >= 16 && <Check className="h-4 w-4 text-emerald-500 absolute right-3 top-1/2 -translate-y-1/2" />}
|
||||||
|
</div>
|
||||||
|
{errors.cardNumber && <p className="text-xs text-red-500 mt-1">{errors.cardNumber}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-stone-700 mb-1.5 block">Expiry Date</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input value={payment.expiry} onChange={(e) => updatePayment("expiry", e.target.value)} placeholder="MM/YY" className={`\${errors.expiry ? "border-red-500 focus-visible:ring-red-500" : ""}`} />
|
||||||
|
{!errors.expiry && payment.expiry.trim() && <Check className="h-4 w-4 text-emerald-500 absolute right-3 top-1/2 -translate-y-1/2" />}
|
||||||
|
</div>
|
||||||
|
{errors.expiry && <p className="text-xs text-red-500 mt-1">{errors.expiry}</p>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-stone-700 mb-1.5 block">CVV</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input value={payment.cvv} onChange={(e) => updatePayment("cvv", e.target.value)} placeholder="123" className={`\${errors.cvv ? "border-red-500 focus-visible:ring-red-500" : ""}`} />
|
||||||
|
{!errors.cvv && payment.cvv.length >= 3 && <Check className="h-4 w-4 text-emerald-500 absolute right-3 top-1/2 -translate-y-1/2" />}
|
||||||
|
</div>
|
||||||
|
{errors.cvv && <p className="text-xs text-red-500 mt-1">{errors.cvv}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-4 text-xs text-stone-500">
|
||||||
|
<Lock className="h-3 w-3" /> Your payment information is encrypted and secure.
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between mt-8">
|
||||||
|
<Button type="button" variant="outline" className="rounded-full active:scale-[0.98] transition-all" onClick={() => setStep(1)}>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" /> Back
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" className="bg-stone-900 hover:bg-stone-800 rounded-full active:scale-[0.98] transition-all">
|
||||||
|
Review Order <ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3: Review */}
|
||||||
|
{step === 3 && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6 sm:p-8">
|
||||||
|
<h2 className="text-xl font-bold mb-6 flex items-center gap-2 animate-fade-up">
|
||||||
|
<ClipboardList className="h-5 w-5 text-rose-500" /> Review Your Order
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Items */}
|
||||||
|
<div className="space-y-4 mb-6">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.id} className="flex items-center gap-4">
|
||||||
|
<div className="h-14 w-12 rounded-lg bg-gradient-to-br from-rose-100 to-stone-200 shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">{item.name}</p>
|
||||||
|
<p className="text-xs text-stone-500">Qty: {item.quantity} | {item.size} | {item.color}</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-semibold">\${(item.price * item.quantity).toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="my-6" />
|
||||||
|
|
||||||
|
{/* Shipping Address */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="text-sm font-semibold mb-2">Shipping Address</h3>
|
||||||
|
<p className="text-sm text-stone-600">
|
||||||
|
{shipping.firstName} {shipping.lastName}<br />
|
||||||
|
{shipping.address}<br />
|
||||||
|
{shipping.city}, {shipping.state} {shipping.zip}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Payment */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="text-sm font-semibold mb-2">Payment Method</h3>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-stone-600">
|
||||||
|
<CreditCard className="h-4 w-4" />
|
||||||
|
<span>Card ending in {payment.cardNumber.slice(-4) || "****"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between mt-8">
|
||||||
|
<Button variant="outline" className="rounded-full active:scale-[0.98] transition-all" onClick={() => setStep(2)}>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" /> Back
|
||||||
|
</Button>
|
||||||
|
<Button size="lg" disabled={placingOrder} className="bg-rose-600 hover:bg-rose-700 rounded-full px-8 active:scale-[0.98] transition-all" onClick={handlePlaceOrder}>
|
||||||
|
{placingOrder ? "Processing…" : <>Place Order <Check className="ml-2 h-4 w-4" /></>}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Sidebar:Order Summary */}
|
||||||
|
<div>
|
||||||
|
<Card className="border-stone-200 sticky top-28">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<h2 className="text-lg font-bold mb-4">Order Summary</h2>
|
||||||
|
<div className="space-y-3 mb-4">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.id} className="flex justify-between text-sm">
|
||||||
|
<span className="text-stone-600 truncate mr-2">{item.name} x{item.quantity}</span>
|
||||||
|
<span className="font-medium shrink-0">\${(item.price * item.quantity).toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Separator className="my-4" />
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-stone-500">Subtotal</span>
|
||||||
|
<span>\${total.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-stone-500">Shipping</span>
|
||||||
|
<span className={shippingCost === 0 ? "text-green-600" : ""}>
|
||||||
|
{shippingCost === 0 ? "Free" : `\$\${shippingCost.toFixed(2)}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-stone-500">Tax</span>
|
||||||
|
<span>\${tax.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Separator className="my-4" />
|
||||||
|
<div className="flex justify-between text-lg font-bold">
|
||||||
|
<span>Total</span>
|
||||||
|
<span>\${grandTotal.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user