chore: add client/src/pages/checkout.tsx
This commit is contained in:
parent
d1b0b0fd7b
commit
8190af5d1c
1 changed files with 389 additions and 0 deletions
389
client/src/pages/checkout.tsx
Normal file
389
client/src/pages/checkout.tsx
Normal file
|
|
@ -0,0 +1,389 @@
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useLocation } from "wouter";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useCart } from "../hooks/use-cart";
|
||||||
|
import { apiRequest } from "../lib/queryClient";
|
||||||
|
import { toast } from "../hooks/use-toast";
|
||||||
|
import { Shield, Lock, CreditCard } from "lucide-react";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
Accept: {
|
||||||
|
dispatchData: (secureData: any, callback: (response: any) => void) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StoreConfig {
|
||||||
|
authorizeNetClientKey: string;
|
||||||
|
authorizeNetApiLoginId: string;
|
||||||
|
authorizeNetSandbox: boolean;
|
||||||
|
stripePublishableKey?: string;
|
||||||
|
stripeEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Address {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
address1: string;
|
||||||
|
address2?: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
zip: string;
|
||||||
|
country: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const US_STATES = [
|
||||||
|
"AL","AK","AZ","AR","CA","CO","CT","DE","FL","GA","HI","ID","IL","IN","IA",
|
||||||
|
"KS","KY","LA","ME","MD","MA","MI","MN","MS","MO","MT","NE","NV","NH","NJ",
|
||||||
|
"NM","NY","NC","ND","OH","OK","OR","PA","RI","SC","SD","TN","TX","UT","VT",
|
||||||
|
"VA","WA","WV","WI","WY","DC",
|
||||||
|
];
|
||||||
|
|
||||||
|
function AddressForm({ prefix, value, onChange }: {
|
||||||
|
prefix: string;
|
||||||
|
value: Address;
|
||||||
|
onChange: (v: Address) => void;
|
||||||
|
}) {
|
||||||
|
const field = (name: keyof Address, label: string, required = true, type = "text") => (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-muted-foreground mb-1">{label}</label>
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
value={value[name] || ""}
|
||||||
|
onChange={(e) => onChange({ ...value, [name]: e.target.value })}
|
||||||
|
required={required}
|
||||||
|
className="w-full px-3 py-2 border border-border rounded-md text-sm bg-background focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||||
|
data-testid={`input-${prefix}-${name}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{field("firstName", "First name")}
|
||||||
|
{field("lastName", "Last name")}
|
||||||
|
<div className="col-span-2">{field("address1", "Address")}</div>
|
||||||
|
<div className="col-span-2">{field("address2", "Apt, suite, etc.", false)}</div>
|
||||||
|
{field("city", "City")}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-muted-foreground mb-1">State</label>
|
||||||
|
<select
|
||||||
|
value={value.state}
|
||||||
|
onChange={(e) => onChange({ ...value, state: e.target.value })}
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 border border-border rounded-md text-sm bg-background focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||||
|
data-testid={`input-${prefix}-state`}
|
||||||
|
>
|
||||||
|
<option value="">State</option>
|
||||||
|
{US_STATES.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{field("zip", "ZIP code")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CheckoutPage() {
|
||||||
|
const { items, subtotal, isLoading: cartLoading } = useCart();
|
||||||
|
const [, setLocation] = useLocation();
|
||||||
|
const [step, setStep] = useState<"info" | "payment">("info");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [processor, setProcessor] = useState<"authorizenet" | "stripe">("authorizenet");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [shipping, setShipping] = useState<Address>({ firstName: "", lastName: "", address1: "", city: "", state: "", zip: "", country: "US" });
|
||||||
|
const [sameAsBilling, setSameAsBilling] = useState(true);
|
||||||
|
const [billing, setBilling] = useState<Address>({ firstName: "", lastName: "", address1: "", city: "", state: "", zip: "", country: "US" });
|
||||||
|
const [cardNumber, setCardNumber] = useState("");
|
||||||
|
const [expiry, setExpiry] = useState("");
|
||||||
|
const [cvv, setCvv] = useState("");
|
||||||
|
const [discountCode, setDiscountCode] = useState("");
|
||||||
|
const [discountApplied, setDiscountApplied] = useState<{ code: string; type: string; value: string } | null>(null);
|
||||||
|
const [applyingDiscount, setApplyingDiscount] = useState(false);
|
||||||
|
|
||||||
|
const { data: config } = useQuery<StoreConfig>({
|
||||||
|
queryKey: ["/api/config"],
|
||||||
|
queryFn: async () => { const r = await fetch("/api/config"); return r.json(); },
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (config && !config.stripeEnabled) setProcessor("authorizenet");
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (config?.authorizeNetSandbox === false) {
|
||||||
|
const existing = document.getElementById("anet-js");
|
||||||
|
if (!existing) {
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.id = "anet-js";
|
||||||
|
script.src = "https://js.authorize.net/v1/Accept.js";
|
||||||
|
script.async = true;
|
||||||
|
document.head.appendChild(script);
|
||||||
|
}
|
||||||
|
} else if (config) {
|
||||||
|
const existing = document.getElementById("anet-js");
|
||||||
|
if (!existing) {
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.id = "anet-js";
|
||||||
|
script.src = "https://jstest.authorize.net/v1/Accept.js";
|
||||||
|
script.async = true;
|
||||||
|
document.head.appendChild(script);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
if (cartLoading) return <div className="max-w-3xl mx-auto px-4 py-16 text-center text-muted-foreground">Loading...</div>;
|
||||||
|
if (items.length === 0) {
|
||||||
|
setLocation("/");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const calcDiscount = () => {
|
||||||
|
if (!discountApplied) return 0;
|
||||||
|
if (discountApplied.type === "percentage") return subtotal * (parseFloat(discountApplied.value) / 100);
|
||||||
|
return Math.min(parseFloat(discountApplied.value), subtotal);
|
||||||
|
};
|
||||||
|
const discount = calcDiscount();
|
||||||
|
const total = Math.max(0, subtotal - discount);
|
||||||
|
|
||||||
|
const handleApplyDiscount = async () => {
|
||||||
|
if (!discountCode.trim()) return;
|
||||||
|
setApplyingDiscount(true);
|
||||||
|
try {
|
||||||
|
const res = await apiRequest("POST", "/api/cart/discount", { code: discountCode.trim() });
|
||||||
|
const data = await res.json();
|
||||||
|
setDiscountApplied(data.discount);
|
||||||
|
toast({ title: "Discount applied!", description: `${data.discount.code} is active.` });
|
||||||
|
} catch (err: any) {
|
||||||
|
toast({ title: "Invalid code", description: err.message, variant: "destructive" });
|
||||||
|
} finally {
|
||||||
|
setApplyingDiscount(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAuthorizeNetSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!config?.authorizeNetApiLoginId || !config?.authorizeNetClientKey) {
|
||||||
|
toast({ title: "Payment not configured", variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitting(true);
|
||||||
|
|
||||||
|
const secureData = {
|
||||||
|
authData: { clientKey: config.authorizeNetClientKey, apiLoginID: config.authorizeNetApiLoginId },
|
||||||
|
cardData: {
|
||||||
|
cardNumber: cardNumber.replace(/\s/g, ""),
|
||||||
|
month: expiry.split("/")[0]?.trim(),
|
||||||
|
year: `20${expiry.split("/")[1]?.trim()}`,
|
||||||
|
cardCode: cvv,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
window.Accept.dispatchData(secureData, async (response) => {
|
||||||
|
if (response.messages.resultCode === "Error") {
|
||||||
|
toast({ title: "Card error", description: response.messages.message[0]?.text, variant: "destructive" });
|
||||||
|
setSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await apiRequest("POST", "/api/checkout/authorizenet", {
|
||||||
|
dataDescriptor: response.opaqueData.dataDescriptor,
|
||||||
|
dataValue: response.opaqueData.dataValue,
|
||||||
|
email,
|
||||||
|
firstName: shipping.firstName,
|
||||||
|
lastName: shipping.lastName,
|
||||||
|
shippingAddress: shipping,
|
||||||
|
billingAddress: sameAsBilling ? shipping : billing,
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
setLocation(`/order-confirmation?order=${data.orderNumber}`);
|
||||||
|
} catch (err: any) {
|
||||||
|
toast({ title: "Payment failed", description: err.message, variant: "destructive" });
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-5xl mx-auto px-4 py-10" data-testid="checkout-page">
|
||||||
|
<h1 className="text-2xl font-bold mb-8">Checkout</h1>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-5 gap-8">
|
||||||
|
<div className="md:col-span-3 space-y-6">
|
||||||
|
{step === "info" && (
|
||||||
|
<form onSubmit={(e) => { e.preventDefault(); setStep("payment"); }} className="space-y-6">
|
||||||
|
<section className="border border-border rounded-xl p-5">
|
||||||
|
<h2 className="font-semibold mb-4">Contact</h2>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-muted-foreground mb-1">Email address</label>
|
||||||
|
<input
|
||||||
|
type="email" value={email} onChange={(e) => setEmail(e.target.value)} required
|
||||||
|
className="w-full px-3 py-2 border border-border rounded-md text-sm bg-background focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||||
|
data-testid="input-email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="border border-border rounded-xl p-5">
|
||||||
|
<h2 className="font-semibold mb-4">Shipping address</h2>
|
||||||
|
<AddressForm prefix="shipping" value={shipping} onChange={setShipping} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="border border-border rounded-xl p-5">
|
||||||
|
<h2 className="font-semibold mb-4">Discount code</h2>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
value={discountCode}
|
||||||
|
onChange={(e) => setDiscountCode(e.target.value)}
|
||||||
|
placeholder="Enter code"
|
||||||
|
className="flex-1 px-3 py-2 border border-border rounded-md text-sm bg-background focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||||
|
data-testid="input-discount-code"
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={handleApplyDiscount} disabled={applyingDiscount || !discountCode.trim()}
|
||||||
|
className="px-4 py-2 border border-border rounded-md text-sm hover:bg-muted transition-colors disabled:opacity-50">
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{discountApplied && (
|
||||||
|
<p className="text-sm text-green-600 mt-2">✓ {discountApplied.code} applied — {discountApplied.type === "percentage" ? `${discountApplied.value}% off` : `$${discountApplied.value} off`}</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<button type="submit" className="w-full bg-primary text-primary-foreground py-3 rounded-md font-semibold hover:bg-primary/90 transition-colors" data-testid="button-continue-to-payment">
|
||||||
|
Continue to payment
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === "payment" && (
|
||||||
|
<form onSubmit={handleAuthorizeNetSubmit} className="space-y-6">
|
||||||
|
{config?.stripeEnabled && (
|
||||||
|
<section className="border border-border rounded-xl p-5">
|
||||||
|
<h2 className="font-semibold mb-4">Payment method</h2>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button type="button" onClick={() => setProcessor("authorizenet")}
|
||||||
|
className={`flex-1 py-2 px-4 border rounded-md text-sm font-medium transition-colors ${processor === "authorizenet" ? "border-primary bg-primary/5" : "border-border hover:border-primary/50"}`}>
|
||||||
|
Credit / Debit Card
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setProcessor("stripe")}
|
||||||
|
className={`flex-1 py-2 px-4 border rounded-md text-sm font-medium transition-colors ${processor === "stripe" ? "border-primary bg-primary/5" : "border-border hover:border-primary/50"}`}>
|
||||||
|
Stripe
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{processor === "authorizenet" && (
|
||||||
|
<section className="border border-border rounded-xl p-5">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<CreditCard size={18} />
|
||||||
|
<h2 className="font-semibold">Card details</h2>
|
||||||
|
<span className="ml-auto flex items-center gap-1 text-xs text-muted-foreground"><Lock size={11} /> Secure</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-muted-foreground mb-1">Card number</label>
|
||||||
|
<input
|
||||||
|
value={cardNumber} onChange={(e) => setCardNumber(e.target.value.replace(/\D/g, "").replace(/(.{4})/g, "$1 ").trim())}
|
||||||
|
placeholder="1234 5678 9012 3456" maxLength={19} required
|
||||||
|
className="w-full px-3 py-2 border border-border rounded-md text-sm bg-background font-mono focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||||
|
data-testid="input-card-number"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-muted-foreground mb-1">Expiry (MM / YY)</label>
|
||||||
|
<input
|
||||||
|
value={expiry} onChange={(e) => { let v = e.target.value.replace(/\D/g, ""); if (v.length >= 3) v = v.slice(0, 2) + " / " + v.slice(2, 4); setExpiry(v); }}
|
||||||
|
placeholder="MM / YY" maxLength={7} required
|
||||||
|
className="w-full px-3 py-2 border border-border rounded-md text-sm bg-background font-mono focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||||
|
data-testid="input-expiry"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-muted-foreground mb-1">Security code</label>
|
||||||
|
<input
|
||||||
|
value={cvv} onChange={(e) => setCvv(e.target.value.replace(/\D/g, "").slice(0, 4))}
|
||||||
|
placeholder="CVV" maxLength={4} required
|
||||||
|
className="w-full px-3 py-2 border border-border rounded-md text-sm bg-background font-mono focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||||
|
data-testid="input-cvv"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="border border-border rounded-xl p-5">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<h2 className="font-semibold">Billing address</h2>
|
||||||
|
<label className="ml-auto flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<input type="checkbox" checked={sameAsBilling} onChange={(e) => setSameAsBilling(e.target.checked)} />
|
||||||
|
Same as shipping
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{!sameAsBilling && <AddressForm prefix="billing" value={billing} onChange={setBilling} />}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button type="button" onClick={() => setStep("info")} className="px-4 py-3 border border-border rounded-md text-sm hover:bg-muted transition-colors">
|
||||||
|
← Back
|
||||||
|
</button>
|
||||||
|
<button type="submit" disabled={submitting}
|
||||||
|
className="flex-1 bg-primary text-primary-foreground py-3 rounded-md font-semibold hover:bg-primary/90 transition-colors disabled:opacity-60 flex items-center justify-center gap-2"
|
||||||
|
data-testid="button-place-order">
|
||||||
|
<Shield size={16} />
|
||||||
|
{submitting ? "Processing..." : `Pay $${total.toFixed(2)}`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<div className="bg-muted/30 rounded-xl border border-border p-5 sticky top-24">
|
||||||
|
<h3 className="font-semibold mb-4">Order summary</h3>
|
||||||
|
<div className="space-y-3 mb-4">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.id} className="flex gap-3" data-testid={`order-item-${item.id}`}>
|
||||||
|
<div className="relative flex-shrink-0">
|
||||||
|
<div className="w-12 h-12 bg-muted rounded-md overflow-hidden border border-border">
|
||||||
|
{item.imageUrl && <img src={item.imageUrl} alt={item.title} className="w-full h-full object-cover" />}
|
||||||
|
</div>
|
||||||
|
<span className="absolute -top-1.5 -right-1.5 bg-primary text-primary-foreground text-xs rounded-full w-4 h-4 flex items-center justify-center">{item.quantity}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">{item.title}</p>
|
||||||
|
{item.variantTitle && item.variantTitle !== "Default Title" && <p className="text-xs text-muted-foreground">{item.variantTitle}</p>}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium">${(parseFloat(item.price) * item.quantity).toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-border pt-4 space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Subtotal</span>
|
||||||
|
<span>${subtotal.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
{discount > 0 && (
|
||||||
|
<div className="flex justify-between text-sm text-green-600">
|
||||||
|
<span>Discount</span>
|
||||||
|
<span>-${discount.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Shipping</span>
|
||||||
|
<span>Calculated at next step</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between font-semibold pt-2 border-t border-border">
|
||||||
|
<span>Total</span>
|
||||||
|
<span data-testid="checkout-total">${total.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue