Seed: ecommerce template (24 files)

This commit is contained in:
2026-07-23 22:09:22 +00:00
parent 42c461a673
commit 81aee5f8c0
+346
View File
@@ -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>
);
}