Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .env.sample
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
VITE_BASE_URL=""
VITE_DOMAIN=""
VITE_DOMAIN=""
VITE_STRIPE_PUBLISHABLE_KEY=""
6 changes: 4 additions & 2 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"biome.enabled": true,

"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true,

"editor.codeActionsOnSave": {
"source.organizeImports.biome": "explicit"
},
Expand Down Expand Up @@ -39,5 +39,7 @@
},
"[css]": {
"editor.defaultFormatter": "biomejs.biome"
}
},
"editor.fontSize": 16,
"workbench.colorTheme": "Gruvbox Dark Hard"
}
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
26 changes: 26 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
onlyBuiltDependencies:
- '@swc/core'
- esbuild
- msw
- sharp
- workerd
4 changes: 4 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down Expand Up @@ -38,6 +40,8 @@ function App() {
<Route path="profile" element={<Profile />} />
<Route path="/cart" element={<Cart />} />
<Route path="/checkout" element={<Checkout />} />
<Route path="/payment" element={<Payment />} />
<Route path="/order/complete" element={<OrderComplete />} />

{/* Route to ProductPage with a dynamic product ID */}
<Route path="product/:id" element={<ProductPage />} />
Expand Down
1 change: 1 addition & 0 deletions src/context/CartContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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("#")
Expand Down
78 changes: 77 additions & 1 deletion src/pages/Checkout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> {
return typeof val === "object" && val !== null;
}

function parsePaymentIntentResponse(obj: unknown): PaymentIntentResponse | null {
if (!isObject(obj)) return null;
const record = obj as Record<string, unknown>;
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)
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
if (checkout_url || clientSecret || client_secret || amount || currency || orderId)
if (checkout_url || clientSecret || client_secret || orderId)

Copilot uses AI. Check for mistakes.
return { checkout_url, clientSecret, client_secret, amount, currency, orderId };
return null;
}

export default function Checkout() {
const navigate = useNavigate();
const [profile, setProfile] = useState<Profile | undefined>(undefined);
Expand Down Expand Up @@ -169,7 +200,52 @@ export default function Checkout() {

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;
Comment on lines +230 to +231
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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;
}

Copilot uses AI. Check for mistakes.
}
// 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)}`);
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
navigate(`/payment?client_secret=${encodeURIComponent(clientSecret)}`);
navigate('/payment', { state: { clientSecret } });

Copilot uses AI. Check for mistakes.
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);
}
};
Comment on lines 201 to 249
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.

// biome-ignore lint/correctness/useExhaustiveDependencies: TODO: useEventEffect in 19
Expand Down
32 changes: 32 additions & 0 deletions src/pages/OrderComplete.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<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>
);
}
Comment on lines +4 to +32
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The OrderComplete component lacks test coverage. Given that other page components have tests, consider adding tests to verify localStorage clearing behavior and UI rendering.

Copilot uses AI. Check for mistakes.
97 changes: 97 additions & 0 deletions src/pages/Payment.tsx
Original file line number Diff line number Diff line change
@@ -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;
Comment on lines +10 to +11
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
const publishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY as string;
import { stripePublishableKey } from "../config";
const publishableKey = stripePublishableKey;

Copilot uses AI. Check for mistakes.
const stripePromise = loadStripe(publishableKey);
Comment on lines +11 to +12
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

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
Comment on lines +38 to +42
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
} 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

Copilot uses AI. Check for mistakes.
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>
);
}
Comment on lines +14 to +64
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

export default function PaymentPage() {
const loc = useLocation();
const q = useMemo(() => new URLSearchParams(loc.search), [loc.search]);
const clientSecret = q.get("client_secret");
Comment on lines +68 to +69
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Retrieving the Stripe client_secret from URL query parameters 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 receive it via navigation state instead. Consider using useLocation().state to retrieve the client secret passed from the Checkout page.

Suggested change
const q = useMemo(() => new URLSearchParams(loc.search), [loc.search]);
const clientSecret = q.get("client_secret");
const clientSecret = loc.state?.clientSecret;

Copilot uses AI. Check for mistakes.

if (!publishableKey) {
return (
<div className="p-8 text-center text-red-600">
Missing Stripe publishable key. Set `VITE_STRIPE_PUBLISHABLE_KEY`.
</div>
);
}
Comment on lines +71 to +77
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

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>
);
}
Comment on lines +66 to +97
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.