+ )}
+
+ )}
+
{loading ? null : user ? (
<>
diff --git a/web/components/ProductCard.js b/web/components/ProductCard.js
index 0b4c76ed..a4250091 100644
--- a/web/components/ProductCard.js
+++ b/web/components/ProductCard.js
@@ -1,26 +1,97 @@
+"use client";
+
import Link from "next/link";
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { useAuth } from "@/context/AuthContext";
+import { useCart } from "@/context/CartContext";
export default function ProductCard({ prodId, prodName, categoryName, prodPrice, imageUrl }) {
+ const { user } = useAuth();
+ const { addItem, selectedStoreId } = useCart();
+ const router = useRouter();
+ const [quantity, setQuantity] = useState(1);
+ const [adding, setAdding] = useState(false);
+ const [feedback, setFeedback] = useState(null);
+
+ async function handleAddToCart(e) {
+ e.preventDefault();
+ if (!user) {
+ router.push("/login");
+ return;
+ }
+ if (!selectedStoreId) {
+ setFeedback("Please select a store first");
+ setTimeout(() => setFeedback(null), 2500);
+ return;
+ }
+ setAdding(true);
+ setFeedback(null);
+ try {
+ await addItem(prodId, quantity);
+ setFeedback("Added!");
+ setTimeout(() => setFeedback(null), 1500);
+ } catch (err) {
+ setFeedback(err.message || "Failed to add");
+ setTimeout(() => setFeedback(null), 2500);
+ } finally {
+ setAdding(false);
+ }
+ }
+
return (
-
-

{
- e.currentTarget.onerror = null;
- e.currentTarget.src = "/images/pet-placeholder.png";
- }}
- />
+
+
+
+

{
+ e.currentTarget.onerror = null;
+ e.currentTarget.src = "/images/pet-placeholder.png";
+ }}
+ />
+
+
+
{prodName}
+
{categoryName}
+ {prodPrice != null && (
+
${parseFloat(prodPrice).toFixed(2)}
+ )}
+
+
+
+
+
+
+ {quantity}
+
+
+
+ {feedback &&
{feedback}
}
-
-
{prodName}
-
{categoryName}
- {prodPrice != null && (
-
${parseFloat(prodPrice).toFixed(2)}
- )}
-
-
+
);
}
diff --git a/web/context/CartContext.js b/web/context/CartContext.js
new file mode 100644
index 00000000..3b604b09
--- /dev/null
+++ b/web/context/CartContext.js
@@ -0,0 +1,171 @@
+"use client";
+
+import { createContext, useContext, useState, useEffect, useCallback } from "react";
+import { useAuth } from "@/context/AuthContext";
+import {
+ fetchCart,
+ apiAddToCart,
+ apiUpdateCartItem,
+ apiRemoveCartItem,
+ apiClearCart,
+ apiApplyCoupon,
+ apiCheckout,
+} from "@/lib/cartApi";
+
+const CartContext = createContext(null);
+
+const STORE_KEY = "selected_store_id";
+
+export function CartProvider({ children }) {
+ const { user, token } = useAuth();
+ const [cart, setCart] = useState(null);
+ const [selectedStoreId, setSelectedStoreIdState] = useState(null);
+ const [cartLoading, setCartLoading] = useState(false);
+ const [cartError, setCartError] = useState(null);
+
+ const setStoreId = useCallback((id) => {
+ const parsed = id ? Number(id) : null;
+ setSelectedStoreIdState(parsed);
+ if (parsed) {
+ localStorage.setItem(STORE_KEY, String(parsed));
+ }
+
+ else {
+ localStorage.removeItem(STORE_KEY);
+ }
+ }, []);
+
+ useEffect(() => {
+ const stored = localStorage.getItem(STORE_KEY);
+ if (stored) {
+ setSelectedStoreIdState(Number(stored));
+ }
+ }, []);
+
+ const refreshCart = useCallback(async () => {
+ if (!token || !selectedStoreId) {
+ setCart(null);
+ return;
+ }
+ setCartLoading(true);
+ setCartError(null);
+ try {
+ const data = await fetchCart(token, selectedStoreId);
+ setCart(data);
+ }
+
+ catch (err) {
+ setCartError(err.message);
+ }
+
+ finally {
+ setCartLoading(false);
+ }
+ }, [token, selectedStoreId]);
+
+ useEffect(() => {
+ if (user && selectedStoreId) {
+ refreshCart();
+ }
+
+ else {
+ setCart(null);
+ }
+ }, [user, selectedStoreId, refreshCart]);
+
+ const addItem = useCallback(
+ async (prodId, quantity = 1) => {
+ if (!token || !selectedStoreId) throw new Error("Select a store first");
+ const updated = await apiAddToCart(token, { prodId, storeId: selectedStoreId, quantity });
+ setCart(updated);
+
+ return updated;
+ },
+ [token, selectedStoreId]
+ );
+
+ const updateItem = useCallback(
+ async (cartItemId, quantity) => {
+ if (!token) return;
+ const updated = await apiUpdateCartItem(token, { cartItemId, quantity });
+ setCart(updated);
+
+ return updated;
+ },
+ [token]
+ );
+
+ const removeItem = useCallback(
+ async (cartItemId) => {
+ if (!token) return;
+ const updated = await apiRemoveCartItem(token, cartItemId);
+ setCart(updated);
+
+ return updated;
+ },
+ [token]
+ );
+
+ const clearCart = useCallback(async () => {
+ if (!token || !selectedStoreId) return;
+ await apiClearCart(token, selectedStoreId);
+ setCart(null);
+ }, [token, selectedStoreId]);
+
+ const applyCoupon = useCallback(
+ async (couponCode) => {
+ if (!token || !selectedStoreId) throw new Error("Select a store first");
+ const updated = await apiApplyCoupon(token, selectedStoreId, couponCode);
+ setCart(updated);
+
+ return updated;
+ },
+ [token, selectedStoreId]
+ );
+
+ const checkout = useCallback(
+ async (paymentMethodId) => {
+ if (!token || !selectedStoreId) throw new Error("Select a store first");
+ const result = await apiCheckout(token, {
+ storeId: selectedStoreId,
+ paymentMethodId,
+ });
+ if (result?.status === "succeeded") {
+ setCart(null);
+ }
+
+ return result;
+ },
+ [token, selectedStoreId]
+ );
+
+ const itemCount = cart?.items?.reduce((sum, i) => sum + i.quantity, 0) ?? 0;
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useCart() {
+ const ctx = useContext(CartContext);
+ if (!ctx) throw new Error("useCart must be used within a CartProvider");
+ return ctx;
+}
diff --git a/web/lib/cartApi.js b/web/lib/cartApi.js
new file mode 100644
index 00000000..bc6d8a22
--- /dev/null
+++ b/web/lib/cartApi.js
@@ -0,0 +1,84 @@
+const BASE = "/api/v1/cart";
+
+function authHeaders(token) {
+ return {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${token}`,
+ };
+}
+
+async function handleResponse(res) {
+ if (res.status === 204) return null;
+ const data = await res.json();
+
+ if (!res.ok) throw new Error(data.message || "Cart request failed");
+ return data;
+}
+
+export async function fetchCart(token, storeId) {
+ const res = await fetch(`${BASE}?storeId=${storeId}`, {
+ headers: authHeaders(token),
+ });
+ if (res.status === 204) return null;
+ const data = await res.json();
+
+ if (!res.ok) throw new Error(data.message || "Failed to fetch cart");
+ return data;
+}
+
+export async function apiAddToCart(token, { prodId, storeId, quantity }) {
+ const res = await fetch(`${BASE}/add`, {
+ method: "POST",
+ headers: authHeaders(token),
+ body: JSON.stringify({ prodId, storeId, quantity }),
+ });
+ return handleResponse(res);
+}
+
+export async function apiUpdateCartItem(token, { cartItemId, quantity }) {
+ const res = await fetch(`${BASE}/update`, {
+ method: "PUT",
+ headers: authHeaders(token),
+ body: JSON.stringify({ cartItemId, quantity }),
+ });
+
+ return handleResponse(res);
+}
+
+export async function apiRemoveCartItem(token, cartItemId) {
+ const res = await fetch(`${BASE}/remove/${cartItemId}`, {
+ method: "DELETE",
+ headers: authHeaders(token),
+ });
+
+ return handleResponse(res);
+}
+
+export async function apiClearCart(token, storeId) {
+ const res = await fetch(`${BASE}/clear?storeId=${storeId}`, {
+ method: "DELETE",
+ headers: authHeaders(token),
+ });
+
+ return handleResponse(res);
+}
+
+export async function apiApplyCoupon(token, storeId, couponCode) {
+ const res = await fetch(`${BASE}/apply-coupon?storeId=${storeId}`, {
+ method: "POST",
+ headers: authHeaders(token),
+ body: JSON.stringify({ couponCode }),
+ });
+
+ return handleResponse(res);
+}
+
+export async function apiCheckout(token, { storeId, paymentMethodId }) {
+ const res = await fetch(`${BASE}/checkout`, {
+ method: "POST",
+ headers: authHeaders(token),
+ body: JSON.stringify({ storeId, paymentMethodId }),
+ });
+
+ return handleResponse(res);
+}
diff --git a/web/package-lock.json b/web/package-lock.json
index d85b7abd..411fd1f9 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -8,6 +8,8 @@
"name": "threaded-pets",
"version": "0.1.0",
"dependencies": {
+ "@stripe/react-stripe-js": "^3.1.1",
+ "@stripe/stripe-js": "^5.5.0",
"next": "^16.2.2",
"react": "19.2.3",
"react-dom": "19.2.3"
@@ -1230,6 +1232,29 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@stripe/react-stripe-js": {
+ "version": "3.10.0",
+ "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-3.10.0.tgz",
+ "integrity": "sha512-UPqHZwMwDzGSax0ZI7XlxR3tZSpgIiZdk3CiwjbTK978phwR/fFXeAXQcN/h8wTAjR4ZIAzdlI9DbOqJhuJdeg==",
+ "license": "MIT",
+ "dependencies": {
+ "prop-types": "^15.7.2"
+ },
+ "peerDependencies": {
+ "@stripe/stripe-js": ">=1.44.1 <8.0.0",
+ "react": ">=16.8.0 <20.0.0",
+ "react-dom": ">=16.8.0 <20.0.0"
+ }
+ },
+ "node_modules/@stripe/stripe-js": {
+ "version": "5.10.0",
+ "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-5.10.0.tgz",
+ "integrity": "sha512-PTigkxMdMUP6B5ISS7jMqJAKhgrhZwjprDqR1eATtFfh0OpKVNp110xiH+goeVdrJ29/4LeZJR4FaHHWstsu0A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.16"
+ }
+ },
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -4408,7 +4433,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
- "dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
@@ -4819,7 +4843,6 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
- "dev": true,
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
@@ -5064,7 +5087,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -5363,7 +5385,6 @@
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
@@ -5427,7 +5448,6 @@
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
- "dev": true,
"license": "MIT"
},
"node_modules/reflect.getprototypeof": {
diff --git a/web/package.json b/web/package.json
index 5cb43e9f..29d0960e 100644
--- a/web/package.json
+++ b/web/package.json
@@ -9,6 +9,8 @@
"lint": "eslint"
},
"dependencies": {
+ "@stripe/react-stripe-js": "^3.1.1",
+ "@stripe/stripe-js": "^5.5.0",
"next": "^16.2.2",
"react": "19.2.3",
"react-dom": "19.2.3"