diff --git a/client/src/pages/checkout.tsx b/client/src/pages/checkout.tsx new file mode 100644 index 0000000..01a41f1 --- /dev/null +++ b/client/src/pages/checkout.tsx @@ -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") => ( +
+ + 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}`} + /> +
+ ); + + return ( +
+ {field("firstName", "First name")} + {field("lastName", "Last name")} +
{field("address1", "Address")}
+
{field("address2", "Apt, suite, etc.", false)}
+ {field("city", "City")} +
+ + +
+ {field("zip", "ZIP code")} +
+ ); +} + +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
({ firstName: "", lastName: "", address1: "", city: "", state: "", zip: "", country: "US" }); + const [sameAsBilling, setSameAsBilling] = useState(true); + const [billing, setBilling] = useState
({ 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({ + 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
Loading...
; + 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 ( +
+

Checkout

+ +
+
+ {step === "info" && ( +
{ e.preventDefault(); setStep("payment"); }} className="space-y-6"> +
+

Contact

+
+ + 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" + /> +
+
+ +
+

Shipping address

+ +
+ +
+

Discount code

+
+ 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" + /> + +
+ {discountApplied && ( +

✓ {discountApplied.code} applied — {discountApplied.type === "percentage" ? `${discountApplied.value}% off` : `$${discountApplied.value} off`}

+ )} +
+ + +
+ )} + + {step === "payment" && ( +
+ {config?.stripeEnabled && ( +
+

Payment method

+
+ + +
+
+ )} + + {processor === "authorizenet" && ( +
+
+ +

Card details

+ Secure +
+
+
+ + 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" + /> +
+
+
+ + { 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" + /> +
+
+ + 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" + /> +
+
+
+
+ )} + +
+
+

Billing address

+ +
+ {!sameAsBilling && } +
+ +
+ + +
+
+ )} +
+ +
+
+

Order summary

+
+ {items.map((item) => ( +
+
+
+ {item.imageUrl && {item.title}} +
+ {item.quantity} +
+
+

{item.title}

+ {item.variantTitle && item.variantTitle !== "Default Title" &&

{item.variantTitle}

} +
+ ${(parseFloat(item.price) * item.quantity).toFixed(2)} +
+ ))} +
+
+
+ Subtotal + ${subtotal.toFixed(2)} +
+ {discount > 0 && ( +
+ Discount + -${discount.toFixed(2)} +
+ )} +
+ Shipping + Calculated at next step +
+
+ Total + ${total.toFixed(2)} +
+
+
+
+
+
+ ); +}