From d29749d4e738b75b9d5e94fdd5c60c9d4eee6dd9 Mon Sep 17 00:00:00 2001 From: Ben Halverson Date: Tue, 9 Dec 2025 22:46:10 -0800 Subject: [PATCH 1/3] stripe setup --- .env.sample | 3 +- .vscode/settings.json | 6 ++- package.json | 2 + pnpm-lock.yaml | 26 ++++++++++ pnpm-workspace.yaml | 6 +++ src/App.tsx | 4 ++ src/context/CartContext.tsx | 1 + src/pages/Checkout.tsx | 79 +++++++++++++++++++++++++++++- src/pages/OrderComplete.tsx | 32 ++++++++++++ src/pages/Payment.tsx | 97 +++++++++++++++++++++++++++++++++++++ 10 files changed, 252 insertions(+), 4 deletions(-) create mode 100644 pnpm-workspace.yaml create mode 100644 src/pages/OrderComplete.tsx create mode 100644 src/pages/Payment.tsx 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..b0bfe74 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,7 +2,7 @@ "biome.enabled": true, "editor.defaultFormatter": "biomejs.biome", - "editor.formatOnSave": true, + "editor.formatOnSave": false, "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..eaf3852 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(); + console.log('Using cartId:', cartId); const previous = optimisticAdd(item); try { const colorValue = item.color.startsWith("#") diff --git a/src/pages/Checkout.tsx b/src/pages/Checkout.tsx index fa2e6d6..1761744 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,53 @@ 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); + console.log('data', data); + 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 as string); + 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: log the response and optionally navigate if order id provided + console.log("Payment intent response:", data); + 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..2f3737b --- /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 } 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: any = 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}
} + + + ); +} + +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

+ + + +
+
+ ); +} From c9550ff919cc5214f9197dc34c72ec66c61c7347 Mon Sep 17 00:00:00 2001 From: Ben Halverson <7907232+benhalverson@users.noreply.github.com> Date: Tue, 9 Dec 2025 22:57:08 -0800 Subject: [PATCH 2/3] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .vscode/settings.json | 2 +- src/context/CartContext.tsx | 2 +- src/pages/Checkout.tsx | 7 +++---- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index b0bfe74..4d0f46c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,7 +2,7 @@ "biome.enabled": true, "editor.defaultFormatter": "biomejs.biome", - "editor.formatOnSave": false, + "editor.codeActionsOnSave": { "source.organizeImports.biome": "explicit" }, diff --git a/src/context/CartContext.tsx b/src/context/CartContext.tsx index eaf3852..0668fbd 100644 --- a/src/context/CartContext.tsx +++ b/src/context/CartContext.tsx @@ -176,7 +176,7 @@ export function CartProvider({ children }: { children: React.ReactNode }) { return; } const cartId = await ensureCartId(); - console.log('Using cartId:', cartId); + const previous = optimisticAdd(item); try { const colorValue = item.color.startsWith("#") diff --git a/src/pages/Checkout.tsx b/src/pages/Checkout.tsx index 1761744..3a36311 100644 --- a/src/pages/Checkout.tsx +++ b/src/pages/Checkout.tsx @@ -220,14 +220,14 @@ export default function Checkout() { } const dataJson: unknown = await res.json(); const data = parsePaymentIntentResponse(dataJson); - console.log('data', data); + 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 as string); + navigate(data.checkout_url); return; } // If backend returned a Stripe client secret, navigate to a route that can handle it @@ -237,8 +237,7 @@ export default function Checkout() { navigate(`/payment?client_secret=${encodeURIComponent(clientSecret)}`); return; } - // Fallback: log the response and optionally navigate if order id provided - console.log("Payment intent response:", data); + // Fallback: optionally navigate if order id provided if (data.orderId) { navigate(`/order/${data.orderId}`); } From adb266456e3612ae80c1ce1b9805fda85bda698f Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 9 Dec 2025 22:59:02 -0800 Subject: [PATCH 3/3] [WIP] WIP address feedback on stripe setup implementation (#28) * Initial plan * Replace any type with PaymentIntentResult for type safety Co-authored-by: benhalverson <7907232+benhalverson@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: benhalverson <7907232+benhalverson@users.noreply.github.com> --- src/pages/Payment.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/Payment.tsx b/src/pages/Payment.tsx index 2f3737b..2bec04c 100644 --- a/src/pages/Payment.tsx +++ b/src/pages/Payment.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from "react"; import { useLocation, useNavigate } from "react-router-dom"; -import { loadStripe } from "@stripe/stripe-js"; +import { loadStripe, type PaymentIntentResult } from "@stripe/stripe-js"; import { Elements, PaymentElement, @@ -24,7 +24,7 @@ function PaymentForm() { setLoading(true); setError(null); try { - const result: any = await stripe.confirmPayment({ + const result: PaymentIntentResult = await stripe.confirmPayment({ elements, confirmParams: { // You can change return_url to an order confirmation route