From 1896c531664bf43245391dcfb84b719e9f2600ae Mon Sep 17 00:00:00 2001 From: Damian Legawiec Date: Sun, 29 Mar 2026 23:02:40 +0200 Subject: [PATCH 1/4] Bump Spree SDK and Spree Next package to 0.19.0 * rename Metafields to CustomFields * move Next server actions to storefront directly for better DX --- README.md | 50 ++--- package-lock.json | 45 ++++- package.json | 4 +- .../(checkout)/checkout/[id]/page.tsx | 4 +- .../products/[slug]/ProductDetails.tsx | 6 +- src/components/account/GiftCardList.tsx | 13 +- ...Metafields.tsx => ProductCustomFields.tsx} | 38 ++-- src/lib/data/__tests__/cart.test.ts | 138 +++++++------ src/lib/data/__tests__/checkout.test.ts | 187 +++++++++++------- src/lib/data/__tests__/customer.test.ts | 112 +++++++---- src/lib/data/__tests__/payment.test.ts | 166 ++++++++++------ src/lib/data/addresses.ts | 41 ++-- src/lib/data/cached.ts | 5 +- src/lib/data/cart.ts | 129 ++++++++++-- src/lib/data/categories.ts | 18 +- src/lib/data/checkout.ts | 65 ++++-- src/lib/data/cookies.ts | 7 +- src/lib/data/countries.ts | 11 +- src/lib/data/credit-cards.ts | 20 +- src/lib/data/customer.ts | 181 +++++++++++++++-- src/lib/data/gift-cards.ts | 18 +- src/lib/data/markets.ts | 44 ++--- src/lib/data/orders.ts | 44 +++-- src/lib/data/payment.ts | 57 ++++-- src/lib/data/products.ts | 15 +- src/lib/seo.ts | 6 +- 26 files changed, 948 insertions(+), 476 deletions(-) rename src/components/products/{ProductMetafields.tsx => ProductCustomFields.tsx} (50%) diff --git a/README.md b/README.md index 7ccaf2be3..feb3b53f6 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ A modern, headless e-commerce storefront built with Next.js 16, React 19, and [S - **TypeScript** - Full type safety - **Sentry** - Error tracking and performance monitoring with source maps - [@spree/sdk](https://spreecommerce.org/docs/developer/sdk/quickstart) - Official Spree Commerce SDK -- [@spree/next](https://spreecommerce.org/docs/developer/storefront/nextjs/spree-next-package) - Server actions, caching, and cookie-based auth +- [@spree/next](https://spreecommerce.org/docs/developer/storefront/nextjs/spree-next-package) - Cookie-based auth, middleware, and webhook helpers ## Architecture @@ -41,8 +41,8 @@ This starter follows a **server-first pattern**: 4. **Cache Revalidation** - Uses Next.js cache tags for efficient updates ``` -Browser → Server Action → Spree API - (with httpOnly cookies) +Browser → Server Action → @spree/sdk → Spree API + (with httpOnly cookies via @spree/next helpers) ``` ## Getting Started @@ -149,17 +149,17 @@ src/ ## Server Actions -All data fetching is done through server actions in `src/lib/data/`: +All data fetching is done through server actions in `src/lib/data/`. These call `@spree/sdk` directly, using `@spree/next` helpers for auth cookies and locale resolution: ```typescript -// Products +// Products — uses getLocaleOptions() for locale-aware reads import { getProducts, getProduct, getProductFilters } from '@/lib/data/products' const products = await getProducts({ limit: 12 }) -const product = await getProduct('product-slug', { expand: ['variants', 'images'] }) -const filters = await getProductFilters({ taxon_id: 'txn_xxx' }) +const product = await getProduct('product-slug', { expand: ['variants', 'media'] }) +const filters = await getProductFilters() -// Cart +// Cart — uses getCartOptions()/requireCartId() for cart operations import { getCart, addToCart, updateCartItem, removeCartItem } from '@/lib/data/cart' const cart = await getCart() @@ -167,7 +167,7 @@ await addToCart('var_xxx', 1) await updateCartItem('li_xxx', 2) await removeCartItem('li_xxx') -// Authentication +// Authentication — uses withAuthRefresh() for authenticated endpoints import { login, register, logout, getCustomer } from '@/lib/data/customer' const result = await login('user@example.com', 'password') @@ -181,29 +181,35 @@ await register({ const customer = await getCustomer() await logout() -// Addresses +// Addresses — uses withAuthRefresh() for customer data import { getAddresses, createAddress, updateAddress, deleteAddress } from '@/lib/data/addresses' const addresses = await getAddresses() -await createAddress({ firstname: 'John', ... }) +await createAddress({ first_name: 'John', ... }) ``` ## Authentication Flow 1. User submits login form -2. Server action calls Spree API with credentials -3. JWT token is stored in an httpOnly cookie -4. Subsequent requests include the token automatically +2. Server action calls `@spree/sdk` to authenticate +3. JWT token is stored in an httpOnly cookie via `@spree/next` cookie helpers +4. Subsequent requests use `withAuthRefresh()` which reads the token automatically 5. Token is never accessible to client-side JavaScript ```typescript -// src/lib/data/cookies.ts -export async function setAuthToken(token: string) { - const cookieStore = await cookies() - cookieStore.set('_spree_jwt', token, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'strict', +// src/lib/data/customer.ts +import { getClient, withAuthRefresh, setAccessToken, setRefreshToken } from '@spree/next' + +export async function login(email: string, password: string) { + const result = await getClient().auth.login({ email, password }) + await setAccessToken(result.token) + await setRefreshToken(result.refresh_token) + return { success: true, user: result.user } +} + +export async function getCustomer() { + return withAuthRefresh(async (options) => { + return getClient().customer.get(options) }) } ``` @@ -235,7 +241,7 @@ All components are in `src/components/` and can be customized or replaced as nee ### Data Layer -To customize API behavior, modify the server actions in `src/lib/data/`. +To customize API behavior, modify the server actions in `src/lib/data/`. These call `@spree/sdk` directly, using `@spree/next` helpers for auth cookies and locale resolution. ## Transactional Emails diff --git a/package-lock.json b/package-lock.json index d65e227f5..f304a5f9f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,8 +11,8 @@ "@next/third-parties": "^16.1.6", "@react-email/components": "^1.0.10", "@sentry/nextjs": "^10.38.0", - "@spree/next": "^0.18.0", - "@spree/sdk": "^0.18.0", + "@spree/next": "^0.19.0", + "@spree/sdk": "^0.19.1", "@stripe/react-stripe-js": "^5.6.0", "@stripe/stripe-js": "^8.7.0", "class-variance-authority": "^0.7.1", @@ -1095,6 +1095,7 @@ "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1682,6 +1683,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1704,6 +1706,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1726,6 +1729,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1742,6 +1746,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1758,6 +1763,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1774,6 +1780,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1790,6 +1797,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1806,6 +1814,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1822,6 +1831,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1838,6 +1848,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1854,6 +1865,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1870,6 +1882,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1886,6 +1899,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1908,6 +1922,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1930,6 +1945,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1952,6 +1968,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1974,6 +1991,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1996,6 +2014,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2018,6 +2037,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2040,6 +2060,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2062,6 +2083,7 @@ "cpu": [ "wasm32" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { @@ -2081,6 +2103,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -2100,6 +2123,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -2119,6 +2143,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -6083,20 +6108,20 @@ "license": "MIT" }, "node_modules/@spree/next": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/@spree/next/-/next-0.18.0.tgz", - "integrity": "sha512-rn1W8EyxqDCkHVJfo/k+yrteCmd0/ktU68VHBAo5EFu/KPrK87oUfwtjb0gi/wgppMu9GlmnV+qUfLUuMAm+4w==", + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@spree/next/-/next-0.19.0.tgz", + "integrity": "sha512-8MO68QEt6NPyOc2JXE+5+fCMneuaGzCIGvsQy99jIZGh2nvoTlee/vIu6mTbRgybysCDuSpPKKHExVLTxkdC5w==", "license": "MIT", "peerDependencies": { - "@spree/sdk": ">=0.18.0", - "next": ">=15.0.0", + "@spree/sdk": ">=0.19.0", + "next": ">=16.0.0", "react": ">=19.0.0" } }, "node_modules/@spree/sdk": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/@spree/sdk/-/sdk-0.18.0.tgz", - "integrity": "sha512-9GOMxC4bCg/L8cArSS5hRrn8op9nF2GkBdMR4G5foQWdCdDNBKGZjc8o9GTMEUIJdY9Pw5kY/jVcPAsCYSRLYA==", + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@spree/sdk/-/sdk-0.19.1.tgz", + "integrity": "sha512-H4vX8363QzjzWu7rWL9wqLqG2toBlaATx9XO8gmMSRjmHvdTUxPCvQfjitHLH+Q8ll17cUHznki4eunqkT6vVQ==", "license": "MIT", "engines": { "node": ">=18.0.0" diff --git a/package.json b/package.json index 983a4c94c..f39d4c89e 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,8 @@ "@next/third-parties": "^16.1.6", "@react-email/components": "^1.0.10", "@sentry/nextjs": "^10.38.0", - "@spree/next": "^0.18.0", - "@spree/sdk": "^0.18.0", + "@spree/next": "^0.19.0", + "@spree/sdk": "^0.19.1", "@stripe/react-stripe-js": "^5.6.0", "@stripe/stripe-js": "^8.7.0", "class-variance-authority": "^0.7.1", diff --git a/src/app/[country]/[locale]/(checkout)/checkout/[id]/page.tsx b/src/app/[country]/[locale]/(checkout)/checkout/[id]/page.tsx index eb18106b5..68d1bda87 100644 --- a/src/app/[country]/[locale]/(checkout)/checkout/[id]/page.tsx +++ b/src/app/[country]/[locale]/(checkout)/checkout/[id]/page.tsx @@ -143,7 +143,9 @@ export default function CheckoutPage({ params }: CheckoutPageProps) { }, []); // Track cart key for sidebar updates - const cartKey = cart ? `${cart.id}-${cart.updated_at}` : null; + const cartKey = cart + ? `${cart.id}-${cart.total}-${cart.total_quantity}` + : null; const prevOrderKeyRef = useRef(cartKey); useEffect(() => { diff --git a/src/app/[country]/[locale]/(storefront)/products/[slug]/ProductDetails.tsx b/src/app/[country]/[locale]/(storefront)/products/[slug]/ProductDetails.tsx index f6cbac97c..23e24b214 100644 --- a/src/app/[country]/[locale]/(storefront)/products/[slug]/ProductDetails.tsx +++ b/src/app/[country]/[locale]/(storefront)/products/[slug]/ProductDetails.tsx @@ -4,7 +4,7 @@ import type { Media, Product, Variant } from "@spree/sdk"; import { CircleCheckBig, CircleX, Loader2, ShoppingBag } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { MediaGallery } from "@/components/products/MediaGallery"; -import { ProductMetafields } from "@/components/products/ProductMetafields"; +import { ProductCustomFields } from "@/components/products/ProductCustomFields"; import { VariantPicker } from "@/components/products/VariantPicker"; import { Button } from "@/components/ui/button"; import { QuantityPicker } from "@/components/ui/quantity-picker"; @@ -221,8 +221,8 @@ export function ProductDetails({ product, basePath }: ProductDetailsProps) { )} - {/* Metafields */} - + {/* Custom Fields */} + {/* Product Details */}
diff --git a/src/components/account/GiftCardList.tsx b/src/components/account/GiftCardList.tsx index 80683d740..987873c5d 100644 --- a/src/components/account/GiftCardList.tsx +++ b/src/components/account/GiftCardList.tsx @@ -139,14 +139,13 @@ function GiftCardItem({ card }: { card: GiftCard }) {
{/* Additional info */} -
-
- Added on {formatDate(card.created_at)} - {card.redeemed_at && ( - Fully redeemed on {formatDate(card.redeemed_at)} - )} + {card.redeemed_at && ( +
+

+ Fully redeemed on {formatDate(card.redeemed_at)} +

-
+ )}
); } diff --git a/src/components/products/ProductMetafields.tsx b/src/components/products/ProductCustomFields.tsx similarity index 50% rename from src/components/products/ProductMetafields.tsx rename to src/components/products/ProductCustomFields.tsx index 3d5f7d9f9..031fa2579 100644 --- a/src/components/products/ProductMetafields.tsx +++ b/src/components/products/ProductCustomFields.tsx @@ -1,7 +1,7 @@ -import type { Metafield } from "@spree/sdk"; +import type { CustomField } from "@spree/sdk"; -interface ProductMetafieldsProps { - metafields?: Array; +interface ProductCustomFieldsProps { + customFields?: Array; } function normalizeType(type: string): string { @@ -9,28 +9,28 @@ function normalizeType(type: string): string { } function renderValue( - metafield: Metafield, + field: CustomField, normalizedType: string, ): React.ReactNode { switch (normalizedType) { case "Boolean": - return metafield.value ? "Yes" : "No"; + return field.value ? "Yes" : "No"; case "Json": - return typeof metafield.value === "string" - ? metafield.value - : JSON.stringify(metafield.value); + return typeof field.value === "string" + ? field.value + : JSON.stringify(field.value); case "RichText": // Value is admin-authored HTML from the Spree CMS backend (trusted source) - return ; + return ; default: - return String(metafield.value); + return String(field.value); } } -export function ProductMetafields({ - metafields, -}: ProductMetafieldsProps): React.JSX.Element | null { - if (!metafields || metafields.length === 0) { +export function ProductCustomFields({ + customFields, +}: ProductCustomFieldsProps): React.JSX.Element | null { + if (!customFields || customFields.length === 0) { return null; } @@ -38,15 +38,15 @@ export function ProductMetafields({

Properties

- {metafields.map((metafield) => { - const type = normalizeType(metafield.type); + {customFields.map((field) => { + const type = normalizeType(field.type); return ( -
+
- {metafield.name} + {field.name}
- {renderValue(metafield, type)} + {renderValue(field, type)}
); diff --git a/src/lib/data/__tests__/cart.test.ts b/src/lib/data/__tests__/cart.test.ts index 92c48b552..361d2d244 100644 --- a/src/lib/data/__tests__/cart.test.ts +++ b/src/lib/data/__tests__/cart.test.ts @@ -1,24 +1,36 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +const mockClient = { + carts: { + get: vi.fn(), + list: vi.fn(), + create: vi.fn(), + associate: vi.fn(), + items: { + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + }, +}; + vi.mock("@spree/next", () => ({ - getCart: vi.fn(), - getOrCreateCart: vi.fn(), - clearCart: vi.fn(), - addItem: vi.fn(), - associateCart: vi.fn(), - removeItem: vi.fn(), - updateItem: vi.fn(), + getClient: () => mockClient, + getCartToken: vi.fn().mockResolvedValue("order-token-123"), + getCartId: vi.fn().mockResolvedValue("cart-1"), + getAccessToken: vi.fn().mockResolvedValue(undefined), + setCartCookies: vi.fn(), + clearCartCookies: vi.fn(), + getCartOptions: vi.fn().mockResolvedValue({ + spreeToken: "order-token-123", + token: undefined, + }), + requireCartId: vi.fn().mockResolvedValue("cart-1"), })); -import { - addItem, - associateCart, - clearCart as clearCartSdk, - getCart as getCartSdk, - getOrCreateCart as getOrCreateCartSdk, - removeItem, - updateItem, -} from "@spree/next"; +vi.mock("next/cache", () => ({ + updateTag: vi.fn(), +})); import { addToCart, @@ -30,15 +42,6 @@ import { updateCartItem, } from "@/lib/data/cart"; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test fixtures are intentionally partial -const mockGetCart = getCartSdk as any; -const mockGetOrCreateCart = getOrCreateCartSdk as any; -const mockAddItem = addItem as any; -const mockUpdateItem = updateItem as any; -const mockRemoveItem = removeItem as any; -const mockClearCart = clearCartSdk as any; -const mockAssociateCart = associateCart as any; - // Minimal cart fixture for tests const mockCart = { id: "cart-1", @@ -55,35 +58,46 @@ describe("cart server actions", () => { }); describe("getCart", () => { - it("delegates to @spree/next getCart", async () => { - mockGetCart.mockResolvedValue(mockCart); + it("fetches cart by ID and token", async () => { + mockClient.carts.get.mockResolvedValue(mockCart); const result = await getCart(); - expect(mockGetCart).toHaveBeenCalledOnce(); + expect(mockClient.carts.get).toHaveBeenCalledWith("cart-1", { + spreeToken: "order-token-123", + token: undefined, + }); expect(result).toBe(mockCart); }); }); describe("getOrCreateCart", () => { - it("delegates to @spree/next getOrCreateCart", async () => { - mockGetOrCreateCart.mockResolvedValue(mockCart); + it("returns existing cart if found", async () => { + mockClient.carts.get.mockResolvedValue(mockCart); const result = await getOrCreateCart(); - expect(mockGetOrCreateCart).toHaveBeenCalledOnce(); expect(result).toBe(mockCart); + expect(mockClient.carts.create).not.toHaveBeenCalled(); }); }); describe("addToCart", () => { it("returns success with cart", async () => { - mockAddItem.mockResolvedValue(mockCart); + mockClient.carts.get.mockResolvedValue(mockCart); + mockClient.carts.items.create.mockResolvedValue(mockCart); const result = await addToCart("variant-1", 2); - expect(mockAddItem).toHaveBeenCalledWith("variant-1", 2); + expect(mockClient.carts.items.create).toHaveBeenCalledWith( + "cart-1", + { variant_id: "variant-1", quantity: 2 }, + { spreeToken: "order-token-123", token: undefined }, + ); expect(result).toEqual({ success: true, cart: mockCart }); }); it("returns error when addItem throws", async () => { - mockAddItem.mockRejectedValue(new Error("Variant not found")); + mockClient.carts.get.mockResolvedValue(mockCart); + mockClient.carts.items.create.mockRejectedValue( + new Error("Variant not found"), + ); const result = await addToCart("bad-variant", 1); @@ -94,7 +108,8 @@ describe("cart server actions", () => { }); it("returns fallback message for non-Error throws", async () => { - mockAddItem.mockRejectedValue("unexpected"); + mockClient.carts.get.mockResolvedValue(mockCart); + mockClient.carts.items.create.mockRejectedValue("unexpected"); const result = await addToCart("variant-1", 1); @@ -107,16 +122,23 @@ describe("cart server actions", () => { describe("updateCartItem", () => { it("returns success with refreshed cart", async () => { - mockUpdateItem.mockResolvedValue(mockCart); + mockClient.carts.items.update.mockResolvedValue(mockCart); const result = await updateCartItem("li-1", 3); - expect(mockUpdateItem).toHaveBeenCalledWith("li-1", { quantity: 3 }); + expect(mockClient.carts.items.update).toHaveBeenCalledWith( + "cart-1", + "li-1", + { quantity: 3 }, + { spreeToken: "order-token-123", token: undefined }, + ); expect(result).toEqual({ success: true, cart: mockCart }); }); it("returns error on failure", async () => { - mockUpdateItem.mockRejectedValue(new Error("Insufficient stock")); + mockClient.carts.items.update.mockRejectedValue( + new Error("Insufficient stock"), + ); const result = await updateCartItem("li-1", 999); @@ -129,16 +151,22 @@ describe("cart server actions", () => { describe("removeCartItem", () => { it("returns success with refreshed cart", async () => { - mockRemoveItem.mockResolvedValue(mockCart); + mockClient.carts.items.delete.mockResolvedValue(mockCart); const result = await removeCartItem("li-1"); - expect(mockRemoveItem).toHaveBeenCalledWith("li-1"); + expect(mockClient.carts.items.delete).toHaveBeenCalledWith( + "cart-1", + "li-1", + { spreeToken: "order-token-123", token: undefined }, + ); expect(result).toEqual({ success: true, cart: mockCart }); }); it("returns error on failure", async () => { - mockRemoveItem.mockRejectedValue(new Error("Item not found")); + mockClient.carts.items.delete.mockRejectedValue( + new Error("Item not found"), + ); const result = await removeCartItem("li-999"); @@ -151,40 +179,22 @@ describe("cart server actions", () => { describe("clearCart", () => { it("returns success", async () => { - mockClearCart.mockResolvedValue(undefined); - const result = await clearCart(); - expect(result).toEqual({ success: true }); }); - - it("returns error on failure", async () => { - mockClearCart.mockRejectedValue(new Error("Server error")); - - const result = await clearCart(); - - expect(result).toEqual({ success: false, error: "Server error" }); - }); }); describe("associateCartWithUser", () => { it("returns success", async () => { - mockAssociateCart.mockResolvedValue({}); + const { getAccessToken } = await import("@spree/next"); + (getAccessToken as ReturnType).mockResolvedValue( + "jwt-token", + ); + mockClient.carts.associate.mockResolvedValue({}); const result = await associateCartWithUser(); expect(result).toEqual({ success: true }); }); - - it("returns fallback message for non-Error throws", async () => { - mockAssociateCart.mockRejectedValue("unexpected"); - - const result = await associateCartWithUser(); - - expect(result).toEqual({ - success: false, - error: "Failed to associate cart", - }); - }); }); }); diff --git a/src/lib/data/__tests__/checkout.test.ts b/src/lib/data/__tests__/checkout.test.ts index 4e2e4a727..7a4269b5f 100644 --- a/src/lib/data/__tests__/checkout.test.ts +++ b/src/lib/data/__tests__/checkout.test.ts @@ -1,14 +1,35 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +const mockClient = { + carts: { + get: vi.fn(), + list: vi.fn(), + update: vi.fn(), + complete: vi.fn(), + fulfillments: { update: vi.fn() }, + discountCodes: { apply: vi.fn(), remove: vi.fn() }, + giftCards: { apply: vi.fn(), remove: vi.fn() }, + }, + orders: { get: vi.fn() }, +}; + vi.mock("@spree/next", () => ({ - getCart: vi.fn(), - getOrder: vi.fn(), - updateCart: vi.fn(), - selectDeliveryRate: vi.fn(), - applyDiscountCode: vi.fn(), - applyGiftCard: vi.fn(), - removeDiscountCode: vi.fn(), - removeGiftCard: vi.fn(), + getClient: () => mockClient, + getCartToken: vi.fn().mockResolvedValue("order-token-123"), + getCartId: vi.fn().mockResolvedValue("order-1"), + getAccessToken: vi.fn().mockResolvedValue(undefined), + setCartCookies: vi.fn(), + clearCartCookies: vi.fn(), + getCartOptions: vi.fn().mockResolvedValue({ + spreeToken: "order-token-123", + token: undefined, + }), + requireCartId: vi.fn().mockResolvedValue("order-1"), + withAuthRefresh: vi.fn( + async (fn: (options: { token: string }) => Promise) => { + return fn({ token: "jwt-token" }); + }, + ), })); vi.mock("@spree/sdk", () => ({ @@ -26,16 +47,9 @@ vi.mock("@spree/sdk", () => ({ }, })); -import { - applyDiscountCode as applyDiscountCodeSdk, - applyGiftCard as applyGiftCardSdk, - getCart, - getOrder, - removeDiscountCode as removeDiscountCodeSdk, - removeGiftCard as removeGiftCardSdk, - selectDeliveryRate as selectDeliveryRateSdk, - updateCart, -} from "@spree/next"; +vi.mock("next/cache", () => ({ + updateTag: vi.fn(), +})); import { applyCode, @@ -47,21 +61,12 @@ import { updateOrderMarket, } from "@/lib/data/checkout"; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test fixtures are intentionally partial -const mockGetCart = getCart as any; -const mockGetOrder = getOrder as any; -const mockUpdateCart = updateCart as any; -const mockSelectDeliveryRate = selectDeliveryRateSdk as any; -const mockApplyDiscountCode = applyDiscountCodeSdk as any; -const mockApplyGiftCard = applyGiftCardSdk as any; -const mockRemoveDiscountCode = removeDiscountCodeSdk as any; -const mockRemoveGiftCard = removeGiftCardSdk as any; - const mockOrder = { id: "order-1", number: "R100", current_step: "address", }; + describe("checkout server actions", () => { beforeEach(() => { vi.clearAllMocks(); @@ -69,30 +74,28 @@ describe("checkout server actions", () => { describe("getCheckoutOrder", () => { it("returns cart when still in checkout", async () => { - mockGetCart.mockResolvedValue(mockOrder); + mockClient.carts.get.mockResolvedValue(mockOrder); const result = await getCheckoutOrder("order-1"); - expect(mockGetCart).toHaveBeenCalled(); - expect(mockGetOrder).not.toHaveBeenCalled(); + expect(mockClient.carts.get).toHaveBeenCalled(); expect(result).toBe(mockOrder); }); it("falls back to getOrder when cart is null (completed)", async () => { const completedOrder = { ...mockOrder, current_step: "complete" }; - mockGetCart.mockResolvedValue(null); - mockGetOrder.mockResolvedValue(completedOrder); + mockClient.carts.get.mockRejectedValue(new Error("Not found")); + mockClient.orders.get.mockResolvedValue(completedOrder); const result = await getCheckoutOrder("order-1"); - expect(mockGetCart).toHaveBeenCalled(); - expect(mockGetOrder).toHaveBeenCalledWith("order-1"); + expect(mockClient.orders.get).toHaveBeenCalled(); expect(result).toBe(completedOrder); }); it("returns null when both cart and order fail", async () => { - mockGetCart.mockResolvedValue(null); - mockGetOrder.mockRejectedValue(new Error("Not found")); + mockClient.carts.get.mockRejectedValue(new Error("Not found")); + mockClient.orders.get.mockRejectedValue(new Error("Not found")); const result = await getCheckoutOrder("bad-id"); @@ -102,17 +105,21 @@ describe("checkout server actions", () => { describe("updateOrderAddresses", () => { it("returns success with order", async () => { - mockUpdateCart.mockResolvedValue(mockOrder); + mockClient.carts.update.mockResolvedValue(mockOrder); const addresses = { email: "test@example.com" }; const result = await updateOrderAddresses("order-1", addresses); - expect(mockUpdateCart).toHaveBeenCalledWith(addresses); + expect(mockClient.carts.update).toHaveBeenCalledWith( + "order-1", + addresses, + { spreeToken: "order-token-123", token: undefined }, + ); expect(result).toEqual({ success: true, cart: mockOrder }); }); it("returns error on failure", async () => { - mockUpdateCart.mockRejectedValue(new Error("Invalid address")); + mockClient.carts.update.mockRejectedValue(new Error("Invalid address")); const result = await updateOrderAddresses("order-1", {}); @@ -123,7 +130,7 @@ describe("checkout server actions", () => { }); it("returns fallback message for non-Error throws", async () => { - mockUpdateCart.mockRejectedValue("unexpected"); + mockClient.carts.update.mockRejectedValue("unexpected"); const result = await updateOrderAddresses("order-1", {}); @@ -137,22 +144,25 @@ describe("checkout server actions", () => { describe("updateOrderMarket", () => { it("returns success with updated order", async () => { const updatedOrder = { ...mockOrder, currency: "EUR", locale: "de" }; - mockUpdateCart.mockResolvedValue(updatedOrder); + mockClient.carts.update.mockResolvedValue(updatedOrder); const result = await updateOrderMarket("order-1", { currency: "EUR", locale: "de", }); - expect(mockUpdateCart).toHaveBeenCalledWith({ - currency: "EUR", - locale: "de", - }); + expect(mockClient.carts.update).toHaveBeenCalledWith( + "order-1", + { currency: "EUR", locale: "de" }, + { spreeToken: "order-token-123", token: undefined }, + ); expect(result).toEqual({ success: true, cart: updatedOrder }); }); it("returns error on failure", async () => { - mockUpdateCart.mockRejectedValue(new Error("Currency not supported")); + mockClient.carts.update.mockRejectedValue( + new Error("Currency not supported"), + ); const result = await updateOrderMarket("order-1", { currency: "XYZ", @@ -166,7 +176,7 @@ describe("checkout server actions", () => { }); it("returns fallback message for non-Error throws", async () => { - mockUpdateCart.mockRejectedValue("unexpected"); + mockClient.carts.update.mockRejectedValue("unexpected"); const result = await updateOrderMarket("order-1", { currency: "EUR", @@ -182,16 +192,23 @@ describe("checkout server actions", () => { describe("selectDeliveryRate", () => { it("returns success", async () => { - mockSelectDeliveryRate.mockResolvedValue(undefined); + mockClient.carts.fulfillments.update.mockResolvedValue(undefined); const result = await selectDeliveryRate("order-1", "ship-1", "rate-1"); - expect(mockSelectDeliveryRate).toHaveBeenCalledWith("ship-1", "rate-1"); + expect(mockClient.carts.fulfillments.update).toHaveBeenCalledWith( + "order-1", + "ship-1", + { selected_delivery_rate_id: "rate-1" }, + { spreeToken: "order-token-123", token: undefined }, + ); expect(result).toEqual({ success: true }); }); it("returns error on failure", async () => { - mockSelectDeliveryRate.mockRejectedValue(new Error("Rate not available")); + mockClient.carts.fulfillments.update.mockRejectedValue( + new Error("Rate not available"), + ); const result = await selectDeliveryRate("order-1", "ship-1", "rate-1"); @@ -204,7 +221,7 @@ describe("checkout server actions", () => { describe("applyCode", () => { it("applies discount code when valid", async () => { - mockApplyDiscountCode.mockResolvedValue(mockOrder); + mockClient.carts.discountCodes.apply.mockResolvedValue(mockOrder); const result = await applyCode("order-1", "SAVE10"); @@ -213,19 +230,20 @@ describe("checkout server actions", () => { cart: mockOrder, type: "discount", }); - expect(mockApplyDiscountCode).toHaveBeenCalledWith("SAVE10"); - expect(mockApplyGiftCard).not.toHaveBeenCalled(); + expect(mockClient.carts.giftCards.apply).not.toHaveBeenCalled(); }); it("falls back to gift card when discount code returns 422", async () => { const { SpreeError } = await import("@spree/sdk"); - mockApplyDiscountCode.mockRejectedValue( + mockClient.carts.discountCodes.apply.mockRejectedValue( new SpreeError( - { error: { code: "processing_error", message: "Coupon not found" } }, + { + error: { code: "processing_error", message: "Coupon not found" }, + }, 422, ), ); - mockApplyGiftCard.mockResolvedValue(mockOrder); + mockClient.carts.giftCards.apply.mockResolvedValue(mockOrder); const result = await applyCode("order-1", "GC-ABCD-1234"); @@ -234,19 +252,19 @@ describe("checkout server actions", () => { cart: mockOrder, type: "gift_card", }); - expect(mockApplyDiscountCode).toHaveBeenCalledWith("GC-ABCD-1234"); - expect(mockApplyGiftCard).toHaveBeenCalledWith("GC-ABCD-1234"); }); it("returns error when both discount and gift card fail", async () => { const { SpreeError } = await import("@spree/sdk"); - mockApplyDiscountCode.mockRejectedValue( + mockClient.carts.discountCodes.apply.mockRejectedValue( new SpreeError( - { error: { code: "processing_error", message: "Coupon not found" } }, + { + error: { code: "processing_error", message: "Coupon not found" }, + }, 422, ), ); - mockApplyGiftCard.mockRejectedValue( + mockClient.carts.giftCards.apply.mockRejectedValue( new SpreeError( { error: { @@ -268,13 +286,15 @@ describe("checkout server actions", () => { it("shows backend gift card error when gift card is expired", async () => { const { SpreeError } = await import("@spree/sdk"); - mockApplyDiscountCode.mockRejectedValue( + mockClient.carts.discountCodes.apply.mockRejectedValue( new SpreeError( - { error: { code: "processing_error", message: "Coupon not found" } }, + { + error: { code: "processing_error", message: "Coupon not found" }, + }, 422, ), ); - mockApplyGiftCard.mockRejectedValue( + mockClient.carts.giftCards.apply.mockRejectedValue( new SpreeError( { error: { @@ -295,20 +315,25 @@ describe("checkout server actions", () => { }); it("does not fall back to gift card on network errors", async () => { - mockApplyDiscountCode.mockRejectedValue(new Error("Network error")); + mockClient.carts.discountCodes.apply.mockRejectedValue( + new Error("Network error"), + ); const result = await applyCode("order-1", "SAVE10"); expect(result).toEqual({ success: false, error: "Network error" }); - expect(mockApplyGiftCard).not.toHaveBeenCalled(); + expect(mockClient.carts.giftCards.apply).not.toHaveBeenCalled(); }); it("does not fall back to gift card on 500 errors", async () => { const { SpreeError } = await import("@spree/sdk"); - mockApplyDiscountCode.mockRejectedValue( + mockClient.carts.discountCodes.apply.mockRejectedValue( new SpreeError( { - error: { code: "internal_error", message: "Internal server error" }, + error: { + code: "internal_error", + message: "Internal server error", + }, }, 500, ), @@ -320,22 +345,26 @@ describe("checkout server actions", () => { success: false, error: "Internal server error", }); - expect(mockApplyGiftCard).not.toHaveBeenCalled(); + expect(mockClient.carts.giftCards.apply).not.toHaveBeenCalled(); }); }); describe("removeDiscountCode", () => { it("returns success with order", async () => { - mockRemoveDiscountCode.mockResolvedValue(mockOrder); + mockClient.carts.discountCodes.remove.mockResolvedValue(mockOrder); const result = await removeDiscountCode("order-1", "SAVE10"); - expect(mockRemoveDiscountCode).toHaveBeenCalledWith("SAVE10"); + expect(mockClient.carts.discountCodes.remove).toHaveBeenCalledWith( + "order-1", + "SAVE10", + { spreeToken: "order-token-123", token: undefined }, + ); expect(result).toEqual({ success: true, cart: mockOrder }); }); it("returns error on failure", async () => { - mockRemoveDiscountCode.mockRejectedValue( + mockClient.carts.discountCodes.remove.mockRejectedValue( new Error("Promotion not found"), ); @@ -350,16 +379,22 @@ describe("checkout server actions", () => { describe("removeGiftCard", () => { it("returns success with order", async () => { - mockRemoveGiftCard.mockResolvedValue(mockOrder); + mockClient.carts.giftCards.remove.mockResolvedValue(mockOrder); const result = await removeGiftCard("order-1", "gc_abc123"); - expect(mockRemoveGiftCard).toHaveBeenCalledWith("gc_abc123"); + expect(mockClient.carts.giftCards.remove).toHaveBeenCalledWith( + "order-1", + "gc_abc123", + { spreeToken: "order-token-123", token: undefined }, + ); expect(result).toEqual({ success: true, cart: mockOrder }); }); it("returns error on failure", async () => { - mockRemoveGiftCard.mockRejectedValue(new Error("Gift card not found")); + mockClient.carts.giftCards.remove.mockRejectedValue( + new Error("Gift card not found"), + ); const result = await removeGiftCard("order-1", "gc_abc123"); diff --git a/src/lib/data/__tests__/customer.test.ts b/src/lib/data/__tests__/customer.test.ts index af0c1c8cd..d1077d450 100644 --- a/src/lib/data/__tests__/customer.test.ts +++ b/src/lib/data/__tests__/customer.test.ts @@ -1,20 +1,43 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +const mockClient = { + auth: { + login: vi.fn(), + logout: vi.fn(), + }, + customers: { + create: vi.fn(), + }, + customer: { + get: vi.fn(), + update: vi.fn(), + }, + carts: { + associate: vi.fn(), + }, +}; + vi.mock("@spree/next", () => ({ - getCustomer: vi.fn(), - login: vi.fn(), - register: vi.fn(), - logout: vi.fn(), - updateCustomer: vi.fn(), + getClient: () => mockClient, + withAuthRefresh: vi.fn( + async (fn: (options: { token: string }) => Promise) => { + return fn({ token: "jwt-token" }); + }, + ), + getAccessToken: vi.fn().mockResolvedValue("jwt-token"), + setAccessToken: vi.fn(), + clearAccessToken: vi.fn(), + getRefreshToken: vi.fn().mockResolvedValue(undefined), + setRefreshToken: vi.fn(), + clearRefreshToken: vi.fn(), + getCartToken: vi.fn().mockResolvedValue(undefined), + getCartId: vi.fn().mockResolvedValue(undefined), + clearCartCookies: vi.fn(), })); -import { - getCustomer as getCustomerSdk, - login as loginSdk, - logout as logoutSdk, - register as registerSdk, - updateCustomer as updateCustomerSdk, -} from "@spree/next"; +vi.mock("next/cache", () => ({ + updateTag: vi.fn(), +})); import { getCustomer, @@ -24,13 +47,6 @@ import { updateCustomer, } from "@/lib/data/customer"; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test fixtures are intentionally partial -const mockGetCustomer = getCustomerSdk as any; -const mockLogin = loginSdk as any; -const mockRegister = registerSdk as any; -const mockLogout = logoutSdk as any; -const mockUpdateCustomer = updateCustomerSdk as any; - const mockUser = { id: "user-1", email: "test@example.com", @@ -44,30 +60,43 @@ describe("customer server actions", () => { }); describe("getCustomer", () => { - it("delegates to @spree/next", async () => { - mockGetCustomer.mockResolvedValue(mockUser); + it("fetches current customer via SDK", async () => { + mockClient.customer.get.mockResolvedValue(mockUser); const result = await getCustomer(); - expect(mockGetCustomer).toHaveBeenCalledOnce(); + expect(mockClient.customer.get).toHaveBeenCalledWith({ + token: "jwt-token", + }); expect(result).toBe(mockUser); }); }); describe("login", () => { - it("delegates with email and password", async () => { - mockLogin.mockResolvedValue(mockUser); + it("logs in and returns user", async () => { + mockClient.auth.login.mockResolvedValue({ + token: "jwt", + refresh_token: "rt", + user: mockUser, + }); const result = await login("test@example.com", "password123"); - expect(mockLogin).toHaveBeenCalledWith("test@example.com", "password123"); - expect(result).toBe(mockUser); + expect(mockClient.auth.login).toHaveBeenCalledWith({ + email: "test@example.com", + password: "password123", + }); + expect(result).toEqual({ success: true, user: mockUser }); }); }); describe("register", () => { - it("delegates with params object", async () => { - mockRegister.mockResolvedValue(mockUser); + it("creates account and returns user", async () => { + mockClient.customers.create.mockResolvedValue({ + token: "jwt", + refresh_token: "rt", + user: mockUser, + }); const result = await register({ email: "test@example.com", @@ -77,41 +106,44 @@ describe("customer server actions", () => { last_name: "User", }); - expect(mockRegister).toHaveBeenCalledWith({ + expect(mockClient.customers.create).toHaveBeenCalledWith({ email: "test@example.com", password: "pass", password_confirmation: "pass", first_name: "Test", last_name: "User", }); - expect(result).toBe(mockUser); + expect(result).toEqual({ success: true, user: mockUser }); }); }); describe("logout", () => { - it("delegates to @spree/next", async () => { - mockLogout.mockResolvedValue(undefined); - + it("clears cookies", async () => { await logout(); - expect(mockLogout).toHaveBeenCalledOnce(); + const { clearAccessToken, clearRefreshToken, clearCartCookies } = + await import("@spree/next"); + expect(clearAccessToken).toHaveBeenCalled(); + expect(clearRefreshToken).toHaveBeenCalled(); + expect(clearCartCookies).toHaveBeenCalled(); }); }); describe("updateCustomer", () => { it("returns success with customer", async () => { - mockUpdateCustomer.mockResolvedValue(mockUser); + mockClient.customer.update.mockResolvedValue(mockUser); const result = await updateCustomer({ first_name: "Updated" }); - expect(mockUpdateCustomer).toHaveBeenCalledWith({ - first_name: "Updated", - }); + expect(mockClient.customer.update).toHaveBeenCalledWith( + { first_name: "Updated" }, + { token: "jwt-token" }, + ); expect(result).toEqual({ success: true, customer: mockUser }); }); it("returns error on failure", async () => { - mockUpdateCustomer.mockRejectedValue(new Error("Email taken")); + mockClient.customer.update.mockRejectedValue(new Error("Email taken")); const result = await updateCustomer({ email: "taken@example.com" }); @@ -122,7 +154,7 @@ describe("customer server actions", () => { }); it("returns fallback message for non-Error throws", async () => { - mockUpdateCustomer.mockRejectedValue("unexpected"); + mockClient.customer.update.mockRejectedValue("unexpected"); const result = await updateCustomer({ first_name: "Test" }); diff --git a/src/lib/data/__tests__/payment.test.ts b/src/lib/data/__tests__/payment.test.ts index c915bdc80..3b2129584 100644 --- a/src/lib/data/__tests__/payment.test.ts +++ b/src/lib/data/__tests__/payment.test.ts @@ -1,18 +1,34 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +const mockClient = { + carts: { + get: vi.fn(), + list: vi.fn(), + complete: vi.fn(), + paymentSessions: { + create: vi.fn(), + complete: vi.fn(), + }, + }, +}; + vi.mock("@spree/next", () => ({ - createPaymentSession: vi.fn(), - completePaymentSession: vi.fn(), - complete: vi.fn(), - getCart: vi.fn(), + getClient: () => mockClient, + getCartToken: vi.fn().mockResolvedValue("order-token-123"), + getCartId: vi.fn().mockResolvedValue("cart-1"), + getAccessToken: vi.fn().mockResolvedValue(undefined), + setCartCookies: vi.fn(), + clearCartCookies: vi.fn(), + getCartOptions: vi.fn().mockResolvedValue({ + spreeToken: "order-token-123", + token: undefined, + }), + requireCartId: vi.fn().mockResolvedValue("cart-1"), })); -import { - complete, - completePaymentSession, - createPaymentSession, - getCart, -} from "@spree/next"; +vi.mock("next/cache", () => ({ + updateTag: vi.fn(), +})); import { completeCheckoutOrder, @@ -21,12 +37,6 @@ import { createCheckoutPaymentSession, } from "@/lib/data/payment"; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test fixtures are intentionally partial -const mockCreatePaymentSession = createPaymentSession as any; -const mockCompletePaymentSession = completePaymentSession as any; -const mockComplete = complete as any; -const mockGetCart = getCart as any; - const mockSession = { id: "session-1", status: "pending", @@ -46,29 +56,35 @@ describe("payment server actions", () => { describe("createCheckoutPaymentSession", () => { it("returns success with session", async () => { - mockCreatePaymentSession.mockResolvedValue(mockSession); + mockClient.carts.paymentSessions.create.mockResolvedValue(mockSession); const result = await createCheckoutPaymentSession("cart-1", "pm-1"); - expect(mockCreatePaymentSession).toHaveBeenCalledWith({ - payment_method_id: "pm-1", - }); + expect(mockClient.carts.paymentSessions.create).toHaveBeenCalledWith( + "cart-1", + { payment_method_id: "pm-1" }, + { spreeToken: "order-token-123", token: undefined }, + ); expect(result).toEqual({ success: true, session: mockSession }); }); it("passes stripe payment method id in external_data", async () => { - mockCreatePaymentSession.mockResolvedValue(mockSession); + mockClient.carts.paymentSessions.create.mockResolvedValue(mockSession); await createCheckoutPaymentSession("cart-1", "pm-1", "spm_123"); - expect(mockCreatePaymentSession).toHaveBeenCalledWith({ - payment_method_id: "pm-1", - external_data: { stripe_payment_method_id: "spm_123" }, - }); + expect(mockClient.carts.paymentSessions.create).toHaveBeenCalledWith( + "cart-1", + { + payment_method_id: "pm-1", + external_data: { stripe_payment_method_id: "spm_123" }, + }, + { spreeToken: "order-token-123", token: undefined }, + ); }); it("returns error on failure", async () => { - mockCreatePaymentSession.mockRejectedValue( + mockClient.carts.paymentSessions.create.mockRejectedValue( new Error("Gateway unavailable"), ); @@ -84,19 +100,26 @@ describe("payment server actions", () => { describe("completeCheckoutPaymentSession", () => { it("returns success with session", async () => { const completedSession = { ...mockSession, status: "completed" }; - mockCompletePaymentSession.mockResolvedValue(completedSession); + mockClient.carts.paymentSessions.complete.mockResolvedValue( + completedSession, + ); const result = await completeCheckoutPaymentSession( "cart-1", "session-1", ); - expect(mockCompletePaymentSession).toHaveBeenCalledWith("session-1"); + expect(mockClient.carts.paymentSessions.complete).toHaveBeenCalledWith( + "cart-1", + "session-1", + undefined, + { spreeToken: "order-token-123", token: undefined }, + ); expect(result).toEqual({ success: true, session: completedSession }); }); it("returns error on failure", async () => { - mockCompletePaymentSession.mockRejectedValue( + mockClient.carts.paymentSessions.complete.mockRejectedValue( new Error("Session expired"), ); @@ -111,11 +134,14 @@ describe("payment server actions", () => { describe("completeCheckoutOrder", () => { it("returns success with order", async () => { - mockComplete.mockResolvedValue(mockOrder); + mockClient.carts.complete.mockResolvedValue(mockOrder); const result = await completeCheckoutOrder("cart-1"); - expect(mockComplete).toHaveBeenCalledWith("cart-1"); + expect(mockClient.carts.complete).toHaveBeenCalledWith("cart-1", { + spreeToken: "order-token-123", + token: undefined, + }); expect(result).toEqual({ success: true, order: mockOrder }); }); @@ -123,7 +149,7 @@ describe("payment server actions", () => { const spreeError = Object.assign(new Error("Not authorized"), { status: 403, }); - mockComplete.mockRejectedValue(spreeError); + mockClient.carts.complete.mockRejectedValue(spreeError); const result = await completeCheckoutOrder("cart-1"); @@ -134,7 +160,7 @@ describe("payment server actions", () => { const spreeError = Object.assign(new Error("Unprocessable Content"), { status: 422, }); - mockComplete.mockRejectedValue(spreeError); + mockClient.carts.complete.mockRejectedValue(spreeError); const result = await completeCheckoutOrder("cart-1"); @@ -142,7 +168,9 @@ describe("payment server actions", () => { }); it("returns error on non-403 failure", async () => { - mockComplete.mockRejectedValue(new Error("Payment required")); + mockClient.carts.complete.mockRejectedValue( + new Error("Payment required"), + ); const result = await completeCheckoutOrder("cart-1"); @@ -150,7 +178,7 @@ describe("payment server actions", () => { }); it("returns fallback message for non-Error throws", async () => { - mockComplete.mockRejectedValue("unexpected"); + mockClient.carts.complete.mockRejectedValue("unexpected"); const result = await completeCheckoutOrder("cart-1"); @@ -163,18 +191,21 @@ describe("payment server actions", () => { describe("confirmPaymentAndCompleteCart", () => { it("passes cartId to getCart for explicit lookup", async () => { - mockGetCart.mockResolvedValue({ + mockClient.carts.get.mockResolvedValue({ id: "cart-1", current_step: "complete", }); await confirmPaymentAndCompleteCart("cart-1", "session-1"); - expect(mockGetCart).toHaveBeenCalledWith("cart-1"); + expect(mockClient.carts.get).toHaveBeenCalledWith("cart-1", { + spreeToken: "order-token-123", + token: undefined, + }); }); it("succeeds when cart is already complete", async () => { - mockGetCart.mockResolvedValue({ + mockClient.carts.get.mockResolvedValue({ id: "cart-1", current_step: "complete", }); @@ -185,34 +216,37 @@ describe("payment server actions", () => { success: true, order: { id: "cart-1", current_step: "complete" }, }); - expect(mockCompletePaymentSession).not.toHaveBeenCalled(); - expect(mockComplete).not.toHaveBeenCalled(); + expect(mockClient.carts.paymentSessions.complete).not.toHaveBeenCalled(); + expect(mockClient.carts.complete).not.toHaveBeenCalled(); }); it("completes payment session then completes the order", async () => { - mockGetCart.mockResolvedValue({ + mockClient.carts.get.mockResolvedValue({ id: "cart-1", current_step: "payment", }); - mockCompletePaymentSession.mockResolvedValue({ + mockClient.carts.paymentSessions.complete.mockResolvedValue({ id: "session-1", status: "completed", }); - mockComplete.mockResolvedValue(mockOrder); + mockClient.carts.complete.mockResolvedValue(mockOrder); const result = await confirmPaymentAndCompleteCart("cart-1", "session-1"); - expect(mockCompletePaymentSession).toHaveBeenCalledWith("session-1"); - expect(mockComplete).toHaveBeenCalledWith("cart-1"); + expect(mockClient.carts.paymentSessions.complete).toHaveBeenCalled(); + expect(mockClient.carts.complete).toHaveBeenCalledWith("cart-1", { + spreeToken: "order-token-123", + token: undefined, + }); expect(result).toEqual({ success: true, order: mockOrder }); }); it("returns error when payment session fails", async () => { - mockGetCart.mockResolvedValue({ + mockClient.carts.get.mockResolvedValue({ id: "cart-1", current_step: "payment", }); - mockCompletePaymentSession.mockResolvedValue({ + mockClient.carts.paymentSessions.complete.mockResolvedValue({ id: "session-1", status: "failed", }); @@ -223,38 +257,43 @@ describe("payment server actions", () => { success: false, error: "Payment was not successful. Please try again.", }); - expect(mockComplete).not.toHaveBeenCalled(); + expect(mockClient.carts.complete).not.toHaveBeenCalled(); }); it("skips session completion when no session ID provided", async () => { - mockGetCart.mockResolvedValue({ + mockClient.carts.get.mockResolvedValue({ id: "cart-1", current_step: "payment", }); - mockComplete.mockResolvedValue(mockOrder); + mockClient.carts.complete.mockResolvedValue(mockOrder); const result = await confirmPaymentAndCompleteCart("cart-1"); - expect(mockCompletePaymentSession).not.toHaveBeenCalled(); - expect(mockComplete).toHaveBeenCalledWith("cart-1"); + expect(mockClient.carts.paymentSessions.complete).not.toHaveBeenCalled(); + expect(mockClient.carts.complete).toHaveBeenCalledWith("cart-1", { + spreeToken: "order-token-123", + token: undefined, + }); expect(result).toEqual({ success: true, order: mockOrder }); }); it("returns success when cart is not found (already completed by webhook)", async () => { - mockGetCart.mockResolvedValue(null); + mockClient.carts.get.mockRejectedValue(new Error("Not found")); const result = await confirmPaymentAndCompleteCart("cart-1", "session-1"); - expect(mockComplete).not.toHaveBeenCalled(); + expect(mockClient.carts.complete).not.toHaveBeenCalled(); expect(result).toEqual({ success: true, order: null }); }); it("returns error when complete throws non-403 error", async () => { - mockGetCart.mockResolvedValue({ + mockClient.carts.get.mockResolvedValue({ id: "cart-1", current_step: "payment", }); - mockComplete.mockRejectedValue(new Error("Order cannot be completed")); + mockClient.carts.complete.mockRejectedValue( + new Error("Order cannot be completed"), + ); const result = await confirmPaymentAndCompleteCart("cart-1"); @@ -265,29 +304,28 @@ describe("payment server actions", () => { }); it("treats 403 from complete as success (order already completed)", async () => { - mockGetCart.mockResolvedValue({ + mockClient.carts.get.mockResolvedValue({ id: "cart-1", current_step: "payment", }); const spreeError = Object.assign(new Error("Not authorized"), { status: 403, }); - mockComplete.mockRejectedValue(spreeError); + mockClient.carts.complete.mockRejectedValue(spreeError); const result = await confirmPaymentAndCompleteCart("cart-1"); expect(result).toEqual({ success: true, order: null }); }); - it("returns fallback message for non-Error throws", async () => { - mockGetCart.mockRejectedValue("unexpected"); + it("returns success when getCart throws (cart may have been completed)", async () => { + mockClient.carts.get.mockRejectedValue("unexpected"); const result = await confirmPaymentAndCompleteCart("cart-1"); - expect(result).toEqual({ - success: false, - error: "Failed to confirm payment. Please try again.", - }); + // getCart() returns null on error (clears stale cookies), + // so confirmPaymentAndCompleteCart treats it as already completed + expect(result).toEqual({ success: true, order: null }); }); }); }); diff --git a/src/lib/data/addresses.ts b/src/lib/data/addresses.ts index 86022b989..7e8ca4498 100644 --- a/src/lib/data/addresses.ts +++ b/src/lib/data/addresses.ts @@ -1,26 +1,35 @@ "use server"; -import { - createAddress as _createAddress, - deleteAddress as _deleteAddress, - getAddress as _getAddress, - updateAddress as _updateAddress, - listAddresses, -} from "@spree/next"; -import type { AddressParams } from "@spree/sdk"; +import { getClient, withAuthRefresh } from "@spree/next"; +import type { Address, AddressParams } from "@spree/sdk"; +import { updateTag } from "next/cache"; import { actionResult, withFallback } from "./utils"; export async function getAddresses() { - return withFallback(() => listAddresses(), { data: [] }); + return withFallback( + async () => { + return withAuthRefresh(async (options) => { + return getClient().customer.addresses.list(undefined, options); + }); + }, + { data: [] } as { data: Address[] }, + ); } export async function getAddress(id: string) { - return withFallback(() => _getAddress(id), null); + return withFallback(async () => { + return withAuthRefresh(async (options) => { + return getClient().customer.addresses.get(id, options); + }); + }, null); } export async function createAddress(address: AddressParams) { return actionResult(async () => { - const result = await _createAddress(address); + const result = await withAuthRefresh(async (options) => { + return getClient().customer.addresses.create(address, options); + }); + updateTag("addresses"); return { address: result }; }, "Failed to create address"); } @@ -30,14 +39,20 @@ export async function updateAddress( address: Partial, ) { return actionResult(async () => { - const result = await _updateAddress(id, address); + const result = await withAuthRefresh(async (options) => { + return getClient().customer.addresses.update(id, address, options); + }); + updateTag("addresses"); return { address: result }; }, "Failed to update address"); } export async function deleteAddress(id: string) { return actionResult(async () => { - await _deleteAddress(id); + await withAuthRefresh(async (options) => { + return getClient().customer.addresses.delete(id, options); + }); + updateTag("addresses"); return {}; }, "Failed to delete address"); } diff --git a/src/lib/data/cached.ts b/src/lib/data/cached.ts index faef602b0..a249414ee 100644 --- a/src/lib/data/cached.ts +++ b/src/lib/data/cached.ts @@ -1,12 +1,13 @@ -import { getCategory, getProduct } from "@spree/next"; import { cache } from "react"; +import { getCategory } from "./categories"; +import { getProduct } from "./products"; /** Expand list used on the product detail page (shared by page + metadata). */ export const PRODUCT_PAGE_EXPAND = [ "variants", "media", "option_types", - "metafields", + "custom_fields", ]; export const getCachedProduct = cache((slugOrId: string, expand: string[]) => diff --git a/src/lib/data/cart.ts b/src/lib/data/cart.ts index 832604143..951648b2b 100644 --- a/src/lib/data/cart.ts +++ b/src/lib/data/cart.ts @@ -1,55 +1,150 @@ "use server"; import { - clearCart as _clearCart, - getCart as _getCart, - getOrCreateCart as _getOrCreateCart, - addItem, - associateCart, - removeItem, - updateItem, + clearCartCookies, + getAccessToken, + getCartId, + getCartOptions, + getCartToken, + getClient, + requireCartId, + setCartCookies, } from "@spree/next"; +import type { Cart, CreateCartParams } from "@spree/sdk"; +import { updateTag } from "next/cache"; import { actionResult } from "./utils"; -export async function getCart() { - return _getCart(); +/** + * Get the current cart. Returns null if no cart exists. + */ +export async function getCart(explicitCartId?: string): Promise { + const spreeToken = await getCartToken(); + const token = await getAccessToken(); + const cartId = explicitCartId ?? (await getCartId()); + + if (!cartId && !token) return null; + + try { + if (cartId) { + return await getClient().carts.get(cartId, { spreeToken, token }); + } + + // Authenticated user without stored cart ID — find their most recent cart + if (token) { + const response = await getClient().carts.list({ token }); + if (response.data.length > 0) { + const cart = response.data[0]; + await setCartCookies(cart.id, cart.token); + return cart; + } + } + + return null; + } catch { + // Cart not found (e.g., order was completed) — clear stale cookies + if (!explicitCartId) { + await clearCartCookies(); + } + return null; + } } -export async function getOrCreateCart() { - return _getOrCreateCart(); +/** + * Get existing cart or create a new one. + */ +export async function getOrCreateCart( + params?: CreateCartParams, +): Promise { + const existing = await getCart(); + if (existing) return existing; + + const token = await getAccessToken(); + const cartParams = + params && Object.keys(params).length > 0 ? params : undefined; + const cart = await getClient().carts.create( + cartParams, + token ? { token } : undefined, + ); + + await setCartCookies(cart.id, cart.token); + + updateTag("cart"); + return cart; } export async function clearCart() { return actionResult(async () => { - await _clearCart(); + await clearCartCookies(); + updateTag("cart"); return {}; }, "Failed to clear cart"); } export async function addToCart(variantId: string, quantity: number) { return actionResult(async () => { - const cart = await addItem(variantId, quantity); - return { cart }; + const cart = await getOrCreateCart(); + const spreeToken = await getCartToken(); + const token = await getAccessToken(); + + const updatedCart = await getClient().carts.items.create( + cart.id, + { variant_id: variantId, quantity }, + { spreeToken, token }, + ); + + updateTag("cart"); + return { cart: updatedCart }; }, "Failed to add item to cart"); } export async function updateCartItem(lineItemId: string, quantity: number) { return actionResult(async () => { - const cart = await updateItem(lineItemId, { quantity }); + const options = await getCartOptions(); + const cartId = await requireCartId(); + + const cart = await getClient().carts.items.update( + cartId, + lineItemId, + { quantity }, + options, + ); + + updateTag("cart"); return { cart }; }, "Failed to update cart item"); } export async function removeCartItem(lineItemId: string) { return actionResult(async () => { - const cart = await removeItem(lineItemId); + const options = await getCartOptions(); + const cartId = await requireCartId(); + + const cart = await getClient().carts.items.delete( + cartId, + lineItemId, + options, + ); + + updateTag("cart"); return { cart }; }, "Failed to remove cart item"); } export async function associateCartWithUser() { return actionResult(async () => { - await associateCart(); + const spreeToken = await getCartToken(); + const token = await getAccessToken(); + const cartId = await getCartId(); + if (!cartId || !token) return {}; + + try { + await getClient().carts.associate(cartId, { spreeToken, token }); + updateTag("cart"); + } catch { + // Cart might already belong to another user — clear it + await clearCartCookies(); + updateTag("cart"); + } return {}; }, "Failed to associate cart"); } diff --git a/src/lib/data/categories.ts b/src/lib/data/categories.ts index 4a63ae366..c8c449873 100644 --- a/src/lib/data/categories.ts +++ b/src/lib/data/categories.ts @@ -1,26 +1,28 @@ "use server"; -import { - getCategory as _getCategory, - listCategories, - listCategoryProducts, -} from "@spree/next"; +import { getClient, getLocaleOptions } from "@spree/next"; import type { CategoryListParams, ProductListParams } from "@spree/sdk"; export async function getCategories(params?: CategoryListParams) { - return listCategories(params); + const options = await getLocaleOptions(); + return getClient().categories.list(params, options); } export async function getCategory( idOrPermalink: string, params?: CategoryListParams, ) { - return _getCategory(idOrPermalink, params); + const options = await getLocaleOptions(); + return getClient().categories.get(idOrPermalink, params, options); } export async function getCategoryProducts( categoryId: string, params?: ProductListParams, ) { - return listCategoryProducts(categoryId, params); + const options = await getLocaleOptions(); + return getClient().products.list( + { ...params, in_category: categoryId }, + options, + ); } diff --git a/src/lib/data/checkout.ts b/src/lib/data/checkout.ts index 13b54e4a7..4c945f87b 100644 --- a/src/lib/data/checkout.ts +++ b/src/lib/data/checkout.ts @@ -1,17 +1,11 @@ "use server"; -import { - applyDiscountCode as _applyDiscountCode, - applyGiftCard as _applyGiftCard, - removeDiscountCode as _removeDiscountCode, - removeGiftCard as _removeGiftCard, - selectDeliveryRate as _selectDeliveryRate, - getCart, - getOrder, - updateCart, -} from "@spree/next"; +import { getCartOptions, getClient, requireCartId } from "@spree/next"; import type { AddressParams, Cart } from "@spree/sdk"; import { SpreeError } from "@spree/sdk"; +import { updateTag } from "next/cache"; +import { getCart } from "./cart"; +import { getOrder } from "./orders"; import { actionResult, withFallback } from "./utils"; export async function getCheckoutOrder(cartId: string): Promise { @@ -49,7 +43,10 @@ export async function updateOrderAddresses( }, ) { return actionResult(async () => { - const cart = await updateCart(addresses); + const options = await getCartOptions(); + const id = await requireCartId(); + const cart = await getClient().carts.update(id, addresses, options); + updateTag("checkout"); return { cart }; }, "Failed to update addresses"); } @@ -59,7 +56,10 @@ export async function updateOrderMarket( params: { currency: string; locale: string }, ) { return actionResult(async () => { - const cart = await updateCart(params); + const options = await getCartOptions(); + const id = await requireCartId(); + const cart = await getClient().carts.update(id, params, options); + updateTag("checkout"); return { cart }; }, "Failed to update order market"); } @@ -70,7 +70,15 @@ export async function selectDeliveryRate( deliveryRateId: string, ) { return actionResult(async () => { - const cart = await _selectDeliveryRate(fulfillmentId, deliveryRateId); + const options = await getCartOptions(); + const id = await requireCartId(); + const cart = await getClient().carts.fulfillments.update( + id, + fulfillmentId, + { selected_delivery_rate_id: deliveryRateId }, + options, + ); + updateTag("checkout"); return { cart }; }, "Failed to select delivery rate"); } @@ -80,9 +88,14 @@ export async function selectDeliveryRate( * Single input field on checkout, backend determines the type. */ export async function applyCode(cartId: string, code: string) { + const options = await getCartOptions(); + const id = await requireCartId(); + // Try discount code first (more common) try { - const cart = await _applyDiscountCode(code); + const cart = await getClient().carts.discountCodes.apply(id, code, options); + updateTag("checkout"); + updateTag("cart"); return { success: true, cart, type: "discount" as const }; } catch (discountError) { // Only fall back to gift card if the discount code was not found (422/404). @@ -97,7 +110,9 @@ export async function applyCode(cartId: string, code: string) { // Discount code not found — try gift card try { - const cart = await _applyGiftCard(code); + const cart = await getClient().carts.giftCards.apply(id, code, options); + updateTag("checkout"); + updateTag("cart"); return { success: true, cart, type: "gift_card" as const }; } catch (giftCardError) { // Gift card also failed. If it's a specific error (expired, redeemed, etc.) @@ -124,14 +139,30 @@ function errorMessage(err: unknown): string { export async function removeDiscountCode(cartId: string, code: string) { return actionResult(async () => { - const cart = await _removeDiscountCode(code); + const options = await getCartOptions(); + const id = await requireCartId(); + const cart = await getClient().carts.discountCodes.remove( + id, + code, + options, + ); + updateTag("checkout"); + updateTag("cart"); return { cart }; }, "Failed to remove discount code"); } export async function removeGiftCard(cartId: string, giftCardId: string) { return actionResult(async () => { - const cart = await _removeGiftCard(giftCardId); + const options = await getCartOptions(); + const id = await requireCartId(); + const cart = await getClient().carts.giftCards.remove( + id, + giftCardId, + options, + ); + updateTag("checkout"); + updateTag("cart"); return { cart }; }, "Failed to remove gift card"); } diff --git a/src/lib/data/cookies.ts b/src/lib/data/cookies.ts index 8584798f3..a4e15a435 100644 --- a/src/lib/data/cookies.ts +++ b/src/lib/data/cookies.ts @@ -1,10 +1,7 @@ "use server"; -import { cookies } from "next/headers"; - -const AUTH_TOKEN_KEY = "_spree_jwt"; +import { getAccessToken } from "@spree/next"; export async function isAuthenticated(): Promise { - const cookieStore = await cookies(); - return !!cookieStore.get(AUTH_TOKEN_KEY)?.value; + return !!(await getAccessToken()); } diff --git a/src/lib/data/countries.ts b/src/lib/data/countries.ts index e1c1467f2..a289ac069 100644 --- a/src/lib/data/countries.ts +++ b/src/lib/data/countries.ts @@ -1,14 +1,13 @@ "use server"; -import { - getCountry as _getCountry, - listCountries as _listCountries, -} from "@spree/next"; +import { getClient, getLocaleOptions } from "@spree/next"; export async function getCountries() { - return _listCountries(); + const options = await getLocaleOptions(); + return getClient().countries.list(options); } export async function getCountry(iso: string) { - return _getCountry(iso, { expand: ["states"] }); + const options = await getLocaleOptions(); + return getClient().countries.get(iso, { expand: ["states"] }, options); } diff --git a/src/lib/data/credit-cards.ts b/src/lib/data/credit-cards.ts index 10d38e65b..9ea32f85a 100644 --- a/src/lib/data/credit-cards.ts +++ b/src/lib/data/credit-cards.ts @@ -1,19 +1,27 @@ "use server"; -import { - deleteCreditCard as _deleteCreditCard, - listCreditCards, -} from "@spree/next"; +import { getClient, withAuthRefresh } from "@spree/next"; import type { CreditCard } from "@spree/sdk"; +import { updateTag } from "next/cache"; import { actionResult, withFallback } from "./utils"; export async function getCreditCards(): Promise<{ data: CreditCard[] }> { - return withFallback(() => listCreditCards(), { data: [] }); + return withFallback( + async () => { + return withAuthRefresh(async (options) => { + return getClient().customer.creditCards.list(undefined, options); + }); + }, + { data: [] } as { data: CreditCard[] }, + ); } export async function deleteCreditCard(id: string) { return actionResult(async () => { - await _deleteCreditCard(id); + await withAuthRefresh(async (options) => { + return getClient().customer.creditCards.delete(id, options); + }); + updateTag("credit-cards"); return {}; }, "Failed to delete credit card"); } diff --git a/src/lib/data/customer.ts b/src/lib/data/customer.ts index 8772d72f3..b70f3b9a3 100644 --- a/src/lib/data/customer.ts +++ b/src/lib/data/customer.ts @@ -1,51 +1,191 @@ "use server"; import { - getCustomer as _getCustomer, - login as _login, - logout as _logout, - register as _register, - requestPasswordReset as _requestPasswordReset, - resetPassword as _resetPassword, - updateCustomer as _updateCustomer, + clearAccessToken, + clearCartCookies, + clearRefreshToken, + getAccessToken, + getCartId, + getCartToken, + getClient, + getRefreshToken, + setAccessToken, + setRefreshToken, + withAuthRefresh, } from "@spree/next"; +import type { Customer } from "@spree/sdk"; +import { updateTag } from "next/cache"; import { actionResult } from "./utils"; -export async function getCustomer() { - return _getCustomer(); +/** + * Get the currently authenticated customer. Returns null if not logged in. + */ +export async function getCustomer(): Promise { + const token = await getAccessToken(); + if (!token) return null; + + try { + return await withAuthRefresh(async (options) => { + return getClient().customer.get(options); + }); + } catch { + await clearAccessToken(); + await clearRefreshToken(); + return null; + } } -export async function login(email: string, password: string) { - return _login(email, password); +/** + * Login with email and password. + * Automatically associates any guest cart with the authenticated user. + */ +export async function login( + email: string, + password: string, +): Promise<{ + success: boolean; + user?: { + id: string; + email: string; + first_name?: string | null; + last_name?: string | null; + }; + error?: string; +}> { + try { + const result = await getClient().auth.login({ email, password }); + await setAccessToken(result.token); + await setRefreshToken(result.refresh_token); + + // Associate guest cart if one exists + const cartToken = await getCartToken(); + const cartId = await getCartId(); + if (cartToken && cartId) { + try { + await getClient().carts.associate(cartId, { + token: result.token, + spreeToken: cartToken, + }); + } catch { + // Cart association failure is non-fatal + } + } + + updateTag("customer"); + updateTag("cart"); + return { success: true, user: result.user }; + } catch (error) { + return { + success: false, + error: + error instanceof Error ? error.message : "Invalid email or password", + }; + } } +/** + * Register a new customer account. + * Automatically associates any guest cart with the new account. + */ export async function register(params: { email: string; password: string; password_confirmation: string; first_name?: string; last_name?: string; -}) { - return _register(params); +}): Promise<{ + success: boolean; + user?: { + id: string; + email: string; + first_name?: string | null; + last_name?: string | null; + }; + error?: string; +}> { + try { + const result = await getClient().customers.create(params); + await setAccessToken(result.token); + await setRefreshToken(result.refresh_token); + + // Associate guest cart + const cartToken = await getCartToken(); + const cartId = await getCartId(); + if (cartToken && cartId) { + try { + await getClient().carts.associate(cartId, { + token: result.token, + spreeToken: cartToken, + }); + } catch { + // Non-fatal + } + } + + updateTag("customer"); + updateTag("cart"); + return { success: true, user: result.user }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : "Registration failed", + }; + } } -export async function logout() { - return _logout(); +/** + * Logout the current user. + */ +export async function logout(): Promise { + const refreshToken = await getRefreshToken(); + if (refreshToken) { + try { + await getClient().auth.logout({ refresh_token: refreshToken }); + } catch { + // Non-fatal — token may already be expired/revoked + } + } + + await clearAccessToken(); + await clearRefreshToken(); + await clearCartCookies(); + updateTag("customer"); + updateTag("cart"); + updateTag("addresses"); + updateTag("credit-cards"); } export async function requestPasswordReset( email: string, redirectUrl?: string, ) { - return _requestPasswordReset(email, redirectUrl); + return getClient().passwordResets.create({ + email, + ...(redirectUrl && { redirect_url: redirectUrl }), + }); } export async function resetPassword( token: string, password: string, passwordConfirmation: string, -) { - return _resetPassword(token, password, passwordConfirmation); +): Promise<{ success: boolean; error?: string }> { + try { + const result = await getClient().passwordResets.update(token, { + password, + password_confirmation: passwordConfirmation, + }); + await setAccessToken(result.token); + await setRefreshToken(result.refresh_token); + updateTag("customer"); + updateTag("cart"); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : "Password reset failed", + }; + } } export async function updateCustomer(data: { @@ -54,7 +194,10 @@ export async function updateCustomer(data: { email?: string; }) { return actionResult(async () => { - const customer = await _updateCustomer(data); + const customer = await withAuthRefresh(async (options) => { + return getClient().customer.update(data, options); + }); + updateTag("customer"); return { customer }; }, "Update failed"); } diff --git a/src/lib/data/gift-cards.ts b/src/lib/data/gift-cards.ts index 3310c9757..40b4d2b5a 100644 --- a/src/lib/data/gift-cards.ts +++ b/src/lib/data/gift-cards.ts @@ -1,12 +1,24 @@ "use server"; -import { getGiftCard as _getGiftCard, listGiftCards } from "@spree/next"; +import { getClient, withAuthRefresh } from "@spree/next"; +import type { GiftCard } from "@spree/sdk"; import { withFallback } from "./utils"; export async function getGiftCards() { - return withFallback(() => listGiftCards(), { data: [] }); + return withFallback( + async () => { + return withAuthRefresh(async (options) => { + return getClient().customer.giftCards.list(undefined, options); + }); + }, + { data: [] } as { data: GiftCard[] }, + ); } export async function getGiftCard(id: string) { - return withFallback(() => _getGiftCard(id), null); + return withFallback(async () => { + return withAuthRefresh(async (options) => { + return getClient().customer.giftCards.get(id, options); + }); + }, null); } diff --git a/src/lib/data/markets.ts b/src/lib/data/markets.ts index 5baa72486..2a6faf89c 100644 --- a/src/lib/data/markets.ts +++ b/src/lib/data/markets.ts @@ -1,53 +1,43 @@ "use server"; -import type { SpreeNextOptions } from "@spree/next"; -import { - listMarketCountries as _listMarketCountries, - listMarkets as _listMarkets, - resolveMarket as _resolveMarket, -} from "@spree/next"; +import { getClient, getLocaleOptions } from "@spree/next"; import type { Market } from "@spree/sdk"; import { cacheLife, cacheTag } from "next/cache"; -import { cookies } from "next/headers"; -const DEFAULT_COUNTRY_COOKIE = "spree_country"; -const DEFAULT_LOCALE_COOKIE = "spree_locale"; - -async function getLocaleOptions(): Promise { - const cookieStore = await cookies(); - return { - locale: cookieStore.get(DEFAULT_LOCALE_COOKIE)?.value, - country: cookieStore.get(DEFAULT_COUNTRY_COOKIE)?.value, - }; -} - -async function cachedListMarkets(options: SpreeNextOptions) { +async function cachedListMarkets(options: { + locale?: string; + country?: string; +}) { "use cache"; cacheLife("hours"); cacheTag("markets"); - return _listMarkets(options); + return getClient().markets.list(options); } -async function cachedResolveMarket(country: string, options: SpreeNextOptions) { +async function cachedResolveMarket( + country: string, + options: { locale?: string; country?: string }, +) { "use cache"; cacheLife("hours"); cacheTag("resolved-market"); - return _resolveMarket(country, options); + return getClient().markets.resolve(country, options); } async function cachedListMarketCountries( marketId: string, - options: SpreeNextOptions, + options: { locale?: string; country?: string }, ) { "use cache"; cacheLife("hours"); cacheTag("market-countries"); - return _listMarketCountries(marketId, options); + return getClient().markets.countries.list(marketId, options); } -export async function getMarkets( - options?: SpreeNextOptions, -): Promise<{ data: Market[] }> { +export async function getMarkets(options?: { + locale?: string; + country?: string; +}): Promise<{ data: Market[] }> { const resolvedOptions = options ?? (await getLocaleOptions()); return cachedListMarkets(resolvedOptions); } diff --git a/src/lib/data/orders.ts b/src/lib/data/orders.ts index 74af34837..0ebaf64a3 100644 --- a/src/lib/data/orders.ts +++ b/src/lib/data/orders.ts @@ -1,26 +1,40 @@ "use server"; -import { getOrder as _getOrder, listOrders } from "@spree/next"; +import { getCartOptions, getClient, withAuthRefresh } from "@spree/next"; import type { OrderListParams } from "@spree/sdk"; import { withFallback } from "./utils"; export async function getOrders(params?: OrderListParams) { - return withFallback(() => listOrders(params), { - data: [], - meta: { - page: 1, - limit: 25, - count: 0, - pages: 0, - from: 0, - to: 0, - in: 0, - previous: null, - next: null, + return withFallback( + async () => { + return withAuthRefresh(async (options) => { + return getClient().customer.orders.list(params, options); + }); }, - }); + { + data: [], + meta: { + page: 1, + limit: 25, + count: 0, + pages: 0, + from: 0, + to: 0, + in: 0, + previous: null, + next: null, + }, + }, + ); } +/** + * Get a single order by ID or number. + * Works for both authenticated users (JWT) and guests (spreeToken). + */ export async function getOrder(id: string, params?: Record) { - return withFallback(() => _getOrder(id, params), null); + return withFallback(async () => { + const options = await getCartOptions(); + return getClient().orders.get(id, params, options); + }, null); } diff --git a/src/lib/data/payment.ts b/src/lib/data/payment.ts index f10f1009a..57fd68fab 100644 --- a/src/lib/data/payment.ts +++ b/src/lib/data/payment.ts @@ -1,11 +1,9 @@ "use server"; -import { - complete, - completePaymentSession, - createPaymentSession, - getCart, -} from "@spree/next"; +import { getCartOptions, getClient, requireCartId } from "@spree/next"; +import type { Order } from "@spree/sdk"; +import { updateTag } from "next/cache"; +import { getCart } from "./cart"; import { actionResult } from "./utils"; export async function createCheckoutPaymentSession( @@ -14,12 +12,19 @@ export async function createCheckoutPaymentSession( stripePaymentMethodId?: string, ) { return actionResult(async () => { - const session = await createPaymentSession({ - payment_method_id: paymentMethodId, - ...(stripePaymentMethodId && { - external_data: { stripe_payment_method_id: stripePaymentMethodId }, - }), - }); + const options = await getCartOptions(); + const id = await requireCartId(); + const session = await getClient().carts.paymentSessions.create( + id, + { + payment_method_id: paymentMethodId, + ...(stripePaymentMethodId && { + external_data: { stripe_payment_method_id: stripePaymentMethodId }, + }), + }, + options, + ); + updateTag("checkout"); return { session }; }, "Failed to create payment session"); } @@ -29,7 +34,15 @@ export async function completeCheckoutPaymentSession( sessionId: string, ) { return actionResult(async () => { - const session = await completePaymentSession(sessionId); + const options = await getCartOptions(); + const id = await requireCartId(); + const session = await getClient().carts.paymentSessions.complete( + id, + sessionId, + undefined, + options, + ); + updateTag("checkout"); return { session }; }, "Failed to complete payment session"); } @@ -41,7 +54,10 @@ export async function completeCheckoutPaymentSession( */ export async function completeCheckoutOrder(cartId: string) { try { - const order = await complete(cartId); + const options = await getCartOptions(); + const order: Order = await getClient().carts.complete(cartId, options); + updateTag("checkout"); + updateTag("cart"); return { success: true as const, order }; } catch (error: unknown) { if (error && typeof error === "object" && "status" in error) { @@ -61,8 +77,6 @@ export async function completeCheckoutOrder(cartId: string) { /** * Confirms payment and completes the order after returning from an offsite * payment gateway (e.g. CashApp, 3D Secure). - * - * Flow: getCart(cartId) → completePaymentSession → completeCheckoutOrder */ export async function confirmPaymentAndCompleteCart( cartId: string, @@ -82,7 +96,14 @@ export async function confirmPaymentAndCompleteCart( } if (sessionId) { - const sessionResult = await completePaymentSession(sessionId); + const options = await getCartOptions(); + const id = await requireCartId(); + const sessionResult = await getClient().carts.paymentSessions.complete( + id, + sessionId, + undefined, + options, + ); if (sessionResult.status === "failed") { return { success: false, @@ -91,8 +112,6 @@ export async function confirmPaymentAndCompleteCart( } } - // Complete the order — if the backend already completed it during - // session completion, completeCheckoutOrder handles 403/422 gracefully. const result = await completeCheckoutOrder(cartId); if (result.success) { return { success: true, order: result.order }; diff --git a/src/lib/data/products.ts b/src/lib/data/products.ts index 72a74b07c..5d20ce5a1 100644 --- a/src/lib/data/products.ts +++ b/src/lib/data/products.ts @@ -1,23 +1,22 @@ "use server"; -import { - getProduct as _getProduct, - getProductFilters as _getProductFilters, - listProducts, -} from "@spree/next"; +import { getClient, getLocaleOptions } from "@spree/next"; import type { ProductListParams } from "@spree/sdk"; export async function getProducts(params?: ProductListParams) { - return listProducts(params); + const options = await getLocaleOptions(); + return getClient().products.list(params, options); } export async function getProduct( slugOrId: string, params?: { expand?: string[] }, ) { - return _getProduct(slugOrId, params); + const options = await getLocaleOptions(); + return getClient().products.get(slugOrId, params, options); } export async function getProductFilters(params?: Record) { - return _getProductFilters(params); + const options = await getLocaleOptions(); + return getClient().products.filters(params, options); } diff --git a/src/lib/seo.ts b/src/lib/seo.ts index 31e881d6a..b289324e1 100644 --- a/src/lib/seo.ts +++ b/src/lib/seo.ts @@ -1,4 +1,4 @@ -import type { Category, StoreProduct } from "@spree/sdk"; +import type { Category, Media, Product } from "@spree/sdk"; /** * Default social image path (stored in public/). @@ -64,7 +64,7 @@ export function stripHtml(html: string): string { * https://schema.org/Product */ export function buildProductJsonLd( - product: StoreProduct, + product: Product, canonicalUrl: string, ): Record { const schema: Record = { @@ -83,7 +83,7 @@ export function buildProductJsonLd( } const imageUrls = (product.media || []) - .map((img) => img.original_url || img.large_url) + .map((img: Media) => img.original_url || img.large_url) .filter(Boolean); // Fall back to thumbnail_url if no media from expand if (imageUrls.length === 0 && product.thumbnail_url) { From 4fc9d6a6d6e5a18dc274983f93bf5d9077994dd2 Mon Sep 17 00:00:00 2001 From: Damian Legawiec Date: Sun, 29 Mar 2026 23:05:21 +0200 Subject: [PATCH 2/4] Update PaymentSection.tsx --- src/components/checkout/PaymentSection.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/checkout/PaymentSection.tsx b/src/components/checkout/PaymentSection.tsx index b089c6cc8..2c450c5a9 100644 --- a/src/components/checkout/PaymentSection.tsx +++ b/src/components/checkout/PaymentSection.tsx @@ -319,7 +319,6 @@ export const PaymentSection = forwardRef< clientSecret, selectedCardId, useShippingForBilling, - shipAddressData, billAddress, onUpdateBillingAddress, onPaymentComplete, From e2fb54b77143af768de7be693c8f498ae3807132 Mon Sep 17 00:00:00 2001 From: Damian Legawiec Date: Mon, 30 Mar 2026 08:56:44 +0200 Subject: [PATCH 3/4] fixes --- src/lib/data/__tests__/customer.test.ts | 51 ++++++++++++++ src/lib/data/customer.ts | 88 ++++++++++++------------- 2 files changed, 94 insertions(+), 45 deletions(-) diff --git a/src/lib/data/__tests__/customer.test.ts b/src/lib/data/__tests__/customer.test.ts index d1077d450..a76b797c8 100644 --- a/src/lib/data/__tests__/customer.test.ts +++ b/src/lib/data/__tests__/customer.test.ts @@ -35,6 +35,21 @@ vi.mock("@spree/next", () => ({ clearCartCookies: vi.fn(), })); +vi.mock("@spree/sdk", () => ({ + SpreeError: class SpreeError extends Error { + code: string; + status: number; + constructor( + response: { error: { code: string; message: string } }, + status: number, + ) { + super(response.error.message); + this.code = response.error.code; + this.status = status; + } + }, +})); + vi.mock("next/cache", () => ({ updateTag: vi.fn(), })); @@ -70,6 +85,42 @@ describe("customer server actions", () => { }); expect(result).toBe(mockUser); }); + + it("clears tokens on 401 auth failure", async () => { + const { SpreeError } = await import("@spree/sdk"); + const { withAuthRefresh } = await import("@spree/next"); + (withAuthRefresh as ReturnType).mockRejectedValueOnce( + new SpreeError( + { error: { code: "unauthorized", message: "Unauthorized" } }, + 401, + ), + ); + + const result = await getCustomer(); + + expect(result).toBeNull(); + const { clearAccessToken, clearRefreshToken } = await import( + "@spree/next" + ); + expect(clearAccessToken).toHaveBeenCalled(); + expect(clearRefreshToken).toHaveBeenCalled(); + }); + + it("does not clear tokens on transient errors", async () => { + const { withAuthRefresh } = await import("@spree/next"); + (withAuthRefresh as ReturnType).mockRejectedValueOnce( + new Error("Network error"), + ); + + const result = await getCustomer(); + + expect(result).toBeNull(); + const { clearAccessToken, clearRefreshToken } = await import( + "@spree/next" + ); + expect(clearAccessToken).not.toHaveBeenCalled(); + expect(clearRefreshToken).not.toHaveBeenCalled(); + }); }); describe("login", () => { diff --git a/src/lib/data/customer.ts b/src/lib/data/customer.ts index b70f3b9a3..4e01e45ac 100644 --- a/src/lib/data/customer.ts +++ b/src/lib/data/customer.ts @@ -14,9 +14,36 @@ import { withAuthRefresh, } from "@spree/next"; import type { Customer } from "@spree/sdk"; +import { SpreeError } from "@spree/sdk"; import { updateTag } from "next/cache"; import { actionResult } from "./utils"; +/** + * Post-auth bootstrap: store tokens, associate guest cart, invalidate caches. + * Shared by login, register, and resetPassword. + */ +async function finalizeAuth(token: string, refreshToken: string) { + await setAccessToken(token); + await setRefreshToken(refreshToken); + + // Associate guest cart if one exists + const cartToken = await getCartToken(); + const cartId = await getCartId(); + if (cartToken && cartId) { + try { + await getClient().carts.associate(cartId, { + token, + spreeToken: cartToken, + }); + } catch { + // Cart association failure is non-fatal + } + } + + updateTag("customer"); + updateTag("cart"); +} + /** * Get the currently authenticated customer. Returns null if not logged in. */ @@ -28,9 +55,15 @@ export async function getCustomer(): Promise { return await withAuthRefresh(async (options) => { return getClient().customer.get(options); }); - } catch { - await clearAccessToken(); - await clearRefreshToken(); + } catch (error) { + // Only clear tokens on auth failures — transient errors should not log users out + if ( + error instanceof SpreeError && + (error.status === 401 || error.status === 403) + ) { + await clearAccessToken(); + await clearRefreshToken(); + } return null; } } @@ -54,25 +87,7 @@ export async function login( }> { try { const result = await getClient().auth.login({ email, password }); - await setAccessToken(result.token); - await setRefreshToken(result.refresh_token); - - // Associate guest cart if one exists - const cartToken = await getCartToken(); - const cartId = await getCartId(); - if (cartToken && cartId) { - try { - await getClient().carts.associate(cartId, { - token: result.token, - spreeToken: cartToken, - }); - } catch { - // Cart association failure is non-fatal - } - } - - updateTag("customer"); - updateTag("cart"); + await finalizeAuth(result.token, result.refresh_token); return { success: true, user: result.user }; } catch (error) { return { @@ -105,25 +120,7 @@ export async function register(params: { }> { try { const result = await getClient().customers.create(params); - await setAccessToken(result.token); - await setRefreshToken(result.refresh_token); - - // Associate guest cart - const cartToken = await getCartToken(); - const cartId = await getCartId(); - if (cartToken && cartId) { - try { - await getClient().carts.associate(cartId, { - token: result.token, - spreeToken: cartToken, - }); - } catch { - // Non-fatal - } - } - - updateTag("customer"); - updateTag("cart"); + await finalizeAuth(result.token, result.refresh_token); return { success: true, user: result.user }; } catch (error) { return { @@ -165,6 +162,10 @@ export async function requestPasswordReset( }); } +/** + * Reset password using a token from the password reset email. + * On success, the user is automatically logged in and guest cart is associated. + */ export async function resetPassword( token: string, password: string, @@ -175,10 +176,7 @@ export async function resetPassword( password, password_confirmation: passwordConfirmation, }); - await setAccessToken(result.token); - await setRefreshToken(result.refresh_token); - updateTag("customer"); - updateTag("cart"); + await finalizeAuth(result.token, result.refresh_token); return { success: true }; } catch (error) { return { From 823750748fd7d510e2c002ab195e1d564f8ed2bd Mon Sep 17 00:00:00 2001 From: Damian Legawiec Date: Mon, 30 Mar 2026 09:42:41 +0200 Subject: [PATCH 4/4] Update customer.ts --- src/lib/data/customer.ts | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/lib/data/customer.ts b/src/lib/data/customer.ts index 4e01e45ac..feecb7a1b 100644 --- a/src/lib/data/customer.ts +++ b/src/lib/data/customer.ts @@ -36,7 +36,8 @@ async function finalizeAuth(token: string, refreshToken: string) { spreeToken: cartToken, }); } catch { - // Cart association failure is non-fatal + // Cart belongs to another user or is invalid — clear stale cookies + await clearCartCookies(); } } @@ -192,9 +193,21 @@ export async function updateCustomer(data: { email?: string; }) { return actionResult(async () => { - const customer = await withAuthRefresh(async (options) => { - return getClient().customer.update(data, options); - }); + let customer; + try { + customer = await withAuthRefresh(async (options) => { + return getClient().customer.update(data, options); + }); + } catch (error) { + if ( + error instanceof SpreeError && + (error.status === 401 || error.status === 403) + ) { + await clearAccessToken(); + await clearRefreshToken(); + } + throw error; + } updateTag("customer"); return { customer }; }, "Update failed");