diff --git a/.env.sample b/.env.sample index bca62ac..92bcf69 100644 --- a/.env.sample +++ b/.env.sample @@ -1,2 +1,3 @@ VITE_BASE_URL="" -VITE_DOMAIN="" \ No newline at end of file +VITE_DOMAIN="" +VITE_STRIPE_PUBLISHABLE_KEY="" \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 32f1374..4d0f46c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,7 +2,7 @@ "biome.enabled": true, "editor.defaultFormatter": "biomejs.biome", - "editor.formatOnSave": true, + "editor.codeActionsOnSave": { "source.organizeImports.biome": "explicit" }, @@ -39,5 +39,7 @@ }, "[css]": { "editor.defaultFormatter": "biomejs.biome" - } + }, + "editor.fontSize": 16, + "workbench.colorTheme": "Gruvbox Dark Hard" } diff --git a/package.json b/package.json index 9e7ebd9..7d1d6e2 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ "@hookform/resolvers": "^5.2.2", "@react-three/drei": "^9.114.3", "@react-three/fiber": "^8.17.10", + "@stripe/react-stripe-js": "^5.4.1", + "@stripe/stripe-js": "^8.5.3", "@tailwindcss/aspect-ratio": "^0.4.2", "@tailwindcss/forms": "^0.5.10", "@tailwindcss/typography": "^0.5.19", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 368e470..d84fbd1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,12 @@ importers: '@react-three/fiber': specifier: ^8.17.10 version: 8.18.0(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.180.0) + '@stripe/react-stripe-js': + specifier: ^5.4.1 + version: 5.4.1(@stripe/stripe-js@8.5.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@stripe/stripe-js': + specifier: ^8.5.3 + version: 8.5.3 '@tailwindcss/aspect-ratio': specifier: ^0.4.2 version: 0.4.2(tailwindcss@4.1.16) @@ -1174,6 +1180,17 @@ packages: '@standard-schema/utils@0.3.0': resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@stripe/react-stripe-js@5.4.1': + resolution: {integrity: sha512-ipeYcAHa4EPmjwfv0lFE+YDVkOQ0TMKkFWamW+BqmnSkEln/hO8rmxGPPWcd9WjqABx6Ro8Xg4pAS7evCcR9cw==} + peerDependencies: + '@stripe/stripe-js': '>=8.0.0 <9.0.0' + react: '>=16.8.0 <20.0.0' + react-dom: '>=16.8.0 <20.0.0' + + '@stripe/stripe-js@8.5.3': + resolution: {integrity: sha512-UM0GHAxlTN7v0lCK2P6t0VOlvBIdApIQxhnM3yZ2kupQ4PpSrLsK/n/NyYKtw2NJGMaNRRD1IicWS7fSL2sFtA==} + engines: {node: '>=12.16'} + '@swc/core-darwin-arm64@1.13.5': resolution: {integrity: sha512-lKNv7SujeXvKn16gvQqUQI5DdyY8v7xcoO3k06/FJbHJS90zEwZdQiMNRiqpYw/orU543tPaWgz7cIYWhbopiQ==} engines: {node: '>=10'} @@ -3791,6 +3808,15 @@ snapshots: '@standard-schema/utils@0.3.0': {} + '@stripe/react-stripe-js@5.4.1(@stripe/stripe-js@8.5.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@stripe/stripe-js': 8.5.3 + prop-types: 15.8.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@stripe/stripe-js@8.5.3': {} + '@swc/core-darwin-arm64@1.13.5': optional: true diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..6f018d5 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,6 @@ +onlyBuiltDependencies: + - '@swc/core' + - esbuild + - msw + - sharp + - workerd diff --git a/src/App.tsx b/src/App.tsx index 29be24a..7cc2e9c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,8 @@ import { ColorProvider } from "./context/ColorContext"; // Lazy load pages for code-splitting const Cart = lazy(() => import("./pages/Cart")); const Checkout = lazy(() => import("./pages/Checkout")); +const Payment = lazy(() => import("./pages/Payment")); +const OrderComplete = lazy(() => import("./pages/OrderComplete")); const ProductPage = lazy(() => import("./pages/Product")); const ProductList = lazy(() => import("./pages/ProductList")); const Profile = lazy(() => import("./pages/Profile")); @@ -38,6 +40,8 @@ function App() { } /> } /> } /> + } /> + } /> {/* Route to ProductPage with a dynamic product ID */} } /> diff --git a/src/context/CartContext.tsx b/src/context/CartContext.tsx index 023ff29..0668fbd 100644 --- a/src/context/CartContext.tsx +++ b/src/context/CartContext.tsx @@ -176,6 +176,7 @@ export function CartProvider({ children }: { children: React.ReactNode }) { return; } const cartId = await ensureCartId(); + const previous = optimisticAdd(item); try { const colorValue = item.color.startsWith("#") diff --git a/src/pages/Checkout.tsx b/src/pages/Checkout.tsx index fa2e6d6..3a36311 100644 --- a/src/pages/Checkout.tsx +++ b/src/pages/Checkout.tsx @@ -43,6 +43,37 @@ interface ShippingCostResponse { shippingCost: number; } +interface PaymentIntentResponse { + checkout_url?: string; + // API returns `clientSecret` (camelCase); accept `client_secret` as well for compatibility + clientSecret?: string; + client_secret?: string; + amount?: number; + currency?: string; + orderId?: string | number; +} + +function isObject(val: unknown): val is Record { + return typeof val === "object" && val !== null; +} + +function parsePaymentIntentResponse(obj: unknown): PaymentIntentResponse | null { + if (!isObject(obj)) return null; + const record = obj as Record; + const checkout_url = typeof record.checkout_url === "string" ? record.checkout_url : undefined; + const clientSecret = typeof record.clientSecret === "string" ? record.clientSecret : undefined; + const client_secret = typeof record.client_secret === "string" ? record.client_secret : undefined; + const amount = typeof record.amount === "number" ? record.amount : undefined; + const currency = typeof record.currency === "string" ? record.currency : undefined; + const orderId = + typeof record.orderId === "string" || typeof record.orderId === "number" + ? (record.orderId as string | number) + : undefined; + if (checkout_url || clientSecret || client_secret || amount || currency || orderId) + return { checkout_url, clientSecret, client_secret, amount, currency, orderId }; + return null; +} + export default function Checkout() { const navigate = useNavigate(); const [profile, setProfile] = useState(undefined); @@ -169,7 +200,52 @@ export default function Checkout() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - console.log("handleSubmit called", profile); + const cartId = localStorage.getItem("cartId"); + if (!cartId) { + setCartError("No cartId found"); + return; + } + setCartLoading(true); + setCartError(null); + try { + const res = await fetch(`${BASE_URL}/cart/${cartId}/payment-intent`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ shippingInfo, profile }), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Payment intent request failed (${res.status}): ${text}`); + } + const dataJson: unknown = await res.json(); + const data = parsePaymentIntentResponse(dataJson); + + if (!data) { + console.warn("Unexpected payment intent response:", dataJson); + throw new Error("Invalid payment intent response"); + } + // If backend provides a checkout URL, redirect there + if (data.checkout_url) { + navigate(data.checkout_url); + return; + } + // If backend returned a Stripe client secret, navigate to a route that can handle it + const clientSecret = data.clientSecret ?? data.client_secret; + if (clientSecret) { + // Navigate to the payment page which loads Stripe Elements and completes confirmation + navigate(`/payment?client_secret=${encodeURIComponent(clientSecret)}`); + return; + } + // Fallback: optionally navigate if order id provided + if (data.orderId) { + navigate(`/order/${data.orderId}`); + } + } catch (err: unknown) { + setCartError(err instanceof Error ? err.message : "Payment intent failed"); + } finally { + setCartLoading(false); + } }; // biome-ignore lint/correctness/useExhaustiveDependencies: TODO: useEventEffect in 19 diff --git a/src/pages/OrderComplete.tsx b/src/pages/OrderComplete.tsx new file mode 100644 index 0000000..6010c2e --- /dev/null +++ b/src/pages/OrderComplete.tsx @@ -0,0 +1,32 @@ +import { Link } from "react-router-dom"; +import { useEffect } from "react"; + +export default function OrderComplete() { + useEffect(() => { + try { + localStorage.removeItem("cartId"); + } catch (e) { + // ignore localStorage errors in some environments + console.log('Could not clear cartId from localStorage', e); + } + }, []); + + return ( + + + Thank you — your order is complete + + We received your payment. You will receive an email confirmation shortly. + + + + Back to shop + + + View account + + + + + ); +} diff --git a/src/pages/Payment.tsx b/src/pages/Payment.tsx new file mode 100644 index 0000000..2bec04c --- /dev/null +++ b/src/pages/Payment.tsx @@ -0,0 +1,97 @@ +import { useMemo, useState } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; +import { loadStripe, type PaymentIntentResult } from "@stripe/stripe-js"; +import { + Elements, + PaymentElement, + useStripe, + useElements, +} from "@stripe/react-stripe-js"; + +const publishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY as string; +const stripePromise = loadStripe(publishableKey); + +function PaymentForm() { + const stripe = useStripe(); + const elements = useElements(); + const navigate = useNavigate(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!stripe || !elements) return; + setLoading(true); + setError(null); + try { + const result: PaymentIntentResult = await stripe.confirmPayment({ + elements, + confirmParams: { + // You can change return_url to an order confirmation route + return_url: window.location.origin + "/order/complete", + }, + redirect: "if_required", + }); + + if (result?.error) { + setError(result.error.message || "Payment confirmation failed"); + } else if (result?.paymentIntent) { + // If payment succeeded or requires action handled by Stripe, navigate to a success page + navigate("/order/complete"); + } else { + // Unknown result — navigate to a generic page or reload + navigate("/order/complete"); + } + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Payment failed"); + } finally { + setLoading(false); + } + }; + + return ( + + + {error && {error}} + + {loading ? "Processing…" : "Pay"} + + + ); +} + +export default function PaymentPage() { + const loc = useLocation(); + const q = useMemo(() => new URLSearchParams(loc.search), [loc.search]); + const clientSecret = q.get("client_secret"); + + if (!publishableKey) { + return ( + + Missing Stripe publishable key. Set `VITE_STRIPE_PUBLISHABLE_KEY`. + + ); + } + + if (!clientSecret) { + return ( + No payment session available. + ); + } + + const options = useMemo(() => ({ clientSecret }), [clientSecret]); + + return ( + + + Complete payment + + + + + + ); +}
+ We received your payment. You will receive an email confirmation shortly. +