notshop-bundle/client/src/pages/checkout.tsx

389 lines
18 KiB
TypeScript

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>
);
}