Conversation
There was a problem hiding this comment.
Pull request overview
This PR integrates Stripe payment processing into the e-commerce application by adding a dedicated payment flow with two new pages and connecting them to the existing checkout process. The implementation adds Stripe's React components for secure payment handling and establishes the necessary routing and configuration infrastructure.
Key changes:
- Added Payment page with Stripe Elements integration for processing payments
- Added OrderComplete page to confirm successful transactions and clear cart state
- Extended Checkout page to create payment intents and navigate to the payment flow
Reviewed changes
Copilot reviewed 9 out of 10 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
| src/pages/Payment.tsx | New page implementing Stripe payment form with Elements and payment confirmation logic |
| src/pages/OrderComplete.tsx | New order confirmation page that displays success message and clears cart from localStorage |
| src/pages/Checkout.tsx | Added payment intent creation logic, response parsing utility, and navigation to payment page |
| src/context/CartContext.tsx | Added debug logging for cart ID tracking |
| src/App.tsx | Registered new /payment and /order/complete routes with lazy loading |
| pnpm-lock.yaml | Added @stripe/stripe-js and @stripe/react-stripe-js dependencies |
| package.json | Declared Stripe SDK dependencies (v8.5.3 and v5.4.1) |
| .env.sample | Added VITE_STRIPE_PUBLISHABLE_KEY environment variable |
| .vscode/settings.json | Modified editor settings (formatOnSave, fontSize, colorTheme) |
| pnpm-workspace.yaml | New workspace configuration for dependency build settings |
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
src/pages/Payment.tsx
Outdated
There was a problem hiding this comment.
Using any type defeats the purpose of TypeScript's type safety. The Stripe library provides proper types for the confirmPayment result. Use the PaymentIntentResult type from @stripe/stripe-js instead of any.
There was a problem hiding this comment.
@copilot open a new pull request to apply changes based on this feedback
| 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)}`); |
There was a problem hiding this comment.
Passing the Stripe client_secret as a URL query parameter exposes it in browser history, server logs, and referrer headers. While Stripe client secrets are designed to be somewhat safe to expose, best practice is to pass them via state or session storage instead. Consider using navigate('/payment', { state: { clientSecret } }) and retrieving it with useLocation().state in the Payment component.
| navigate(`/payment?client_secret=${encodeURIComponent(clientSecret)}`); | |
| navigate('/payment', { state: { clientSecret } }); |
|
|
||
| const publishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY as string; |
There was a problem hiding this comment.
The VITE_STRIPE_PUBLISHABLE_KEY environment variable should be validated using the existing Zod schema in src/config.ts, consistent with how VITE_BASE_URL and VITE_DOMAIN are validated. This ensures the application fails fast at startup if the required configuration is missing, rather than at runtime when users try to make payments.
| const publishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY as string; | |
| import { stripePublishableKey } from "../config"; | |
| const publishableKey = stripePublishableKey; |
| const publishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY as string; | ||
| const stripePromise = loadStripe(publishableKey); |
There was a problem hiding this comment.
The loadStripe function is called with publishableKey at the module level, which will attempt to load Stripe even when publishableKey is undefined. This should be moved inside the component or wrapped in a conditional to prevent initialization with an invalid key. Consider using lazy initialization or conditionally creating the promise.
|
@benhalverson I've opened a new pull request, #28, to work on those changes. Once the pull request is ready, I'll request review from you. |
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* 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>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 10 changed files in this pull request and generated 8 comments.
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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 ( | ||
| <div className="p-8 text-center text-red-600"> | ||
| Missing Stripe publishable key. Set `VITE_STRIPE_PUBLISHABLE_KEY`. | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| if (!clientSecret) { | ||
| return ( | ||
| <div className="p-8 text-center">No payment session available.</div> | ||
| ); | ||
| } | ||
|
|
||
| const options = useMemo(() => ({ clientSecret }), [clientSecret]); | ||
|
|
||
| return ( | ||
| <div className="min-h-screen bg-gray-50 py-12"> | ||
| <div className="mx-auto max-w-2xl px-4"> | ||
| <h2 className="text-lg font-medium mb-6">Complete payment</h2> | ||
| <Elements stripe={stripePromise} options={options}> | ||
| <PaymentForm /> | ||
| </Elements> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } |
There was a problem hiding this comment.
The PaymentPage component lacks test coverage. Consider adding tests to verify the component handles missing publishable keys, missing client secrets, and successful payment element rendering.
| 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 ( | ||
| <div className="min-h-screen flex items-center justify-center bg-gray-50 py-12"> | ||
| <div className="max-w-xl w-full bg-white shadow rounded-lg p-8 text-center"> | ||
| <h1 className="text-2xl font-semibold mb-4">Thank you — your order is complete</h1> | ||
| <p className="text-gray-600 mb-6"> | ||
| We received your payment. You will receive an email confirmation shortly. | ||
| </p> | ||
| <div className="flex justify-center gap-4"> | ||
| <Link to="/" className="rounded-md bg-indigo-600 text-white px-4 py-2"> | ||
| Back to shop | ||
| </Link> | ||
| <Link to="/profile" className="rounded-md border px-4 py-2"> | ||
| View account | ||
| </Link> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } |
There was a problem hiding this comment.
The OrderComplete component lacks test coverage. Given that other page components have tests, consider adding tests to verify localStorage clearing behavior and UI rendering.
| navigate(data.checkout_url); | ||
| return; |
There was a problem hiding this comment.
The checkout_url from the API response is used directly with navigate() without validation. If this URL could be external or untrusted, consider validating that it's a relative path or matches expected patterns to prevent open redirect vulnerabilities.
| navigate(data.checkout_url); | |
| return; | |
| // Only allow relative URLs starting with a single slash, and not containing "//" | |
| if ( | |
| typeof data.checkout_url === "string" && | |
| /^\/(?!\/)/.test(data.checkout_url) | |
| ) { | |
| navigate(data.checkout_url); | |
| return; | |
| } else { | |
| console.warn("Unsafe checkout_url received, not navigating:", data.checkout_url); | |
| setCartError("Received unsafe checkout URL from server."); | |
| return; | |
| } |
| typeof record.orderId === "string" || typeof record.orderId === "number" | ||
| ? (record.orderId as string | number) | ||
| : undefined; | ||
| if (checkout_url || clientSecret || client_secret || amount || currency || orderId) |
There was a problem hiding this comment.
The condition on line 72 returns a PaymentIntentResponse object if ANY field is present. This could lead to accepting incomplete responses. Consider validating that at least one meaningful field (checkout_url, clientSecret/client_secret, or orderId) is present, rather than accepting responses with only optional fields like amount or currency.
| if (checkout_url || clientSecret || client_secret || amount || currency || orderId) | |
| if (checkout_url || clientSecret || client_secret || orderId) |
| if (!publishableKey) { | ||
| return ( | ||
| <div className="p-8 text-center text-red-600"> | ||
| Missing Stripe publishable key. Set `VITE_STRIPE_PUBLISHABLE_KEY`. | ||
| </div> | ||
| ); | ||
| } |
There was a problem hiding this comment.
The check for publishableKey happens inside the component render, meaning the Stripe promise (stripePromise) is already created at module level (line 12) even if the key is missing. Consider moving the validation to the module level or handling the missing key case before creating the Stripe promise.
| } 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 |
There was a problem hiding this comment.
Lines 38-43 both navigate to "/order/complete" in different conditions. Consider consolidating this logic or adding a comment explaining why the distinction is necessary for code clarity.
| } 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 | |
| } else { | |
| // Payment succeeded or unknown result — navigate to a success page |
| function PaymentForm() { | ||
| const stripe = useStripe(); | ||
| const elements = useElements(); | ||
| const navigate = useNavigate(); | ||
| const [loading, setLoading] = useState(false); | ||
| const [error, setError] = useState<string | null>(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 ( | ||
| <form onSubmit={handleSubmit} className="max-w-lg mx-auto p-4"> | ||
| <PaymentElement /> | ||
| {error && <div className="text-red-600 mt-2">{error}</div>} | ||
| <button | ||
| type="submit" | ||
| disabled={!stripe || loading} | ||
| className="mt-4 w-full rounded bg-indigo-600 text-white py-2"> | ||
| {loading ? "Processing…" : "Pay"} | ||
| </button> | ||
| </form> | ||
| ); | ||
| } |
There was a problem hiding this comment.
The PaymentForm component lacks test coverage. Given that other page components like Product.tsx have comprehensive tests, consider adding tests for this component to cover payment submission, error handling, and loading states.
| const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => { | ||
| 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); | ||
| } | ||
| }; |
There was a problem hiding this comment.
The new payment intent creation logic in handleSubmit lacks test coverage. Consider adding tests to verify proper handling of successful payment intent responses, error cases, and the various navigation paths (checkout_url, clientSecret, orderId).
No description provided.