Skip to content
Open
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
4 changes: 3 additions & 1 deletion .env.production
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
VITE_BASE_URL=https://api.benhalverson.dev
VITE_DOMAIN=https://rc-store.benhalverson.dev
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_51RUggnFtN1eiSjAecw8NdboDU4D9MjxRBIlKSQj2y78HuLDK7z1h26EfEp7RUpro2jdYvW9Uzu4FA6SHoiVOEg0o00rPmfffwj
VITE_SQUARE_APPLICATION_ID=replace-with-square-application-id
VITE_SQUARE_LOCATION_ID=replace-with-square-location-id
VITE_SQUARE_ENVIRONMENT=production
4 changes: 3 additions & 1 deletion .env.sample
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
VITE_BASE_URL=""
VITE_DOMAIN=""
VITE_STRIPE_PUBLISHABLE_KEY=""
VITE_SQUARE_APPLICATION_ID=""
VITE_SQUARE_LOCATION_ID=""
VITE_SQUARE_ENVIRONMENT="sandbox"
2 changes: 0 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,6 @@
"@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
157 changes: 86 additions & 71 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

2 changes: 0 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ 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"));
Expand Down Expand Up @@ -40,7 +39,6 @@ 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 */}
Expand Down
138 changes: 138 additions & 0 deletions src/components/SquarePaymentForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { BASE_URL, SQUARE_APPLICATION_ID, SQUARE_LOCATION_ID } from "../config";

type SquareCard = {
attach(selector: string): Promise<void>;
destroy(): Promise<void>;
tokenize(): Promise<{ status: string; token?: string; errors?: unknown }>;
};

declare global {
interface Window {
Square?: {
payments(applicationId: string, locationId: string): Promise<{
card(): Promise<SquareCard>;
}>;
};
}
}

const SDK_URL =
import.meta.env.VITE_SQUARE_ENVIRONMENT === "production"
? "https://web.squarecdn.com/v1/square.js"
: "https://sandbox.web.squarecdn.com/v1/square.js";
Comment on lines +21 to +24

async function loadSquareSdk() {
if (window.Square) return;
await new Promise<void>((resolve, reject) => {
const existing = document.querySelector<HTMLScriptElement>(
`script[src="${SDK_URL}"]`,
);
if (existing) {
existing.addEventListener("load", () => resolve(), { once: true });
existing.addEventListener("error", () => reject(new Error("Square failed to load")), {
once: true,
});
return;
}
const script = document.createElement("script");
script.src = SDK_URL;
script.async = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error("Square failed to load"));
document.head.appendChild(script);
});
}

export function SquarePaymentForm({
cartId,
customerEmail,
disabled,
onError,
}: {
cartId: string;
customerEmail?: string;
disabled?: boolean;
onError: (message: string | null) => void;
}) {
const navigate = useNavigate();
const cardRef = useRef<SquareCard | null>(null);
const idempotencyKeyRef = useRef(crypto.randomUUID());
const [ready, setReady] = useState(false);
Comment on lines +59 to +62
const [submitting, setSubmitting] = useState(false);

useEffect(() => {
let active = true;
void (async () => {
try {
await loadSquareSdk();
if (!window.Square) throw new Error("Square is unavailable");
const payments = await window.Square.payments(
SQUARE_APPLICATION_ID,
SQUARE_LOCATION_ID,
);
const card = await payments.card();
await card.attach("#square-card-container");
if (active) {
cardRef.current = card;
setReady(true);
} else {
await card.destroy();
}
} catch (error) {
onError(error instanceof Error ? error.message : "Payment form failed to load");
}
})();
return () => {
active = false;
const card = cardRef.current;
cardRef.current = null;
if (card) void card.destroy();
};
}, [onError]);

const pay = async () => {
if (!cardRef.current) return;
setSubmitting(true);
onError(null);
try {
const tokenResult = await cardRef.current.tokenize();
if (tokenResult.status !== "OK" || !tokenResult.token) {
throw new Error("Please check your payment details and try again");
}
const response = await fetch(`${BASE_URL}/cart/${cartId}/square-payment`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
sourceId: tokenResult.token,
idempotencyKey: idempotencyKeyRef.current,
customerEmail,
}),
});
const payload = (await response.json()) as { error?: string; orderId?: string };
if (!response.ok) throw new Error(payload.error ?? "Payment failed");
navigate("/order/complete", { state: { orderId: payload.orderId } });
Comment on lines +114 to +116
} catch (error) {
onError(error instanceof Error ? error.message : "Payment failed");
} finally {
setSubmitting(false);
}
};

return (
<div className="space-y-4">
<div id="square-card-container" aria-label="Card payment details" />
<button
type="button"
disabled={!ready || disabled || submitting}
onClick={pay}
className="w-full rounded-md bg-indigo-600 px-4 py-3 font-medium text-white hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{submitting ? "Processing…" : "Pay securely"}
</button>
<p className="text-center text-xs text-gray-500">Payments processed securely by Square</p>
</div>
);
}
4 changes: 4 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@ import { z } from "zod";
const zEnv = z.object({
VITE_BASE_URL: z.string().url(),
VITE_DOMAIN: z.string().min(1).max(100),
VITE_SQUARE_APPLICATION_ID: z.string().min(1),
VITE_SQUARE_LOCATION_ID: z.string().min(1),
});
Comment on lines 3 to 8

const parsed = zEnv.parse(import.meta.env);

export const BASE_URL = parsed.VITE_BASE_URL;
export const DOMAIN = parsed.VITE_DOMAIN;
export const SQUARE_APPLICATION_ID = parsed.VITE_SQUARE_APPLICATION_ID;
export const SQUARE_LOCATION_ID = parsed.VITE_SQUARE_LOCATION_ID;
Comment on lines 12 to +15
132 changes: 0 additions & 132 deletions src/pages/Checkout.test.tsx

This file was deleted.

Loading