From 27ffe7fe2e4f0af753ea210e48884bbf93f0fe89 Mon Sep 17 00:00:00 2001 From: Damian Legawiec Date: Mon, 16 Feb 2026 21:30:29 +0100 Subject: [PATCH 1/6] Feat: add checkout stripe payments --- .env.example | 3 + package-lock.json | 541 ++++++++++-------- package.json | 6 +- .../(checkout)/checkout/[id]/page.tsx | 82 ++- .../(checkout)/order-placed/[id]/page.tsx | 280 +++++++++ src/components/checkout/PaymentStep.tsx | 176 +++++- src/components/checkout/StripePaymentForm.tsx | 101 ++++ src/components/checkout/index.ts | 1 + src/contexts/CartContext.tsx | 8 +- src/lib/constants.ts | 2 + src/lib/data/cart.ts | 9 +- src/lib/data/checkout.ts | 16 +- src/lib/data/payment.ts | 37 ++ src/lib/utils/address.ts | 33 +- 14 files changed, 987 insertions(+), 308 deletions(-) create mode 100644 src/app/[country]/[locale]/(checkout)/order-placed/[id]/page.tsx create mode 100644 src/components/checkout/StripePaymentForm.tsx create mode 100644 src/lib/constants.ts create mode 100644 src/lib/data/payment.ts diff --git a/.env.example b/.env.example index 252c58c43..d28c0e660 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,9 @@ SPREE_PUBLISHABLE_KEY=your_publishable_api_key NEXT_PUBLIC_DEFAULT_COUNTRY=us NEXT_PUBLIC_DEFAULT_LOCALE=en +# Stripe (optional - required for Stripe payment method) +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_your_stripe_publishable_key + # Sentry Error Tracking (optional - set DSN to enable) SENTRY_DSN=https://examplePublicKey@o0.ingest.sentry.io/0 SENTRY_ORG=your-org diff --git a/package-lock.json b/package-lock.json index c8657bf29..17787d238 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,10 @@ "version": "0.1.0", "dependencies": { "@sentry/nextjs": "^10.38.0", - "@spree/next": "^0.2.0", - "@spree/sdk": "^0.2.0", + "@spree/next": "^0.2.2", + "@spree/sdk": "^0.2.2", + "@stripe/react-stripe-js": "^5.6.0", + "@stripe/stripe-js": "^8.7.0", "next": "^16", "react": "^19", "react-dom": "^19" @@ -101,9 +103,9 @@ } }, "node_modules/@asamuzakjp/dom-selector": { - "version": "6.7.8", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.8.tgz", - "integrity": "sha512-stisC1nULNc9oH5lakAj8MH88ZxeGxzyWNDfbdCxvJSJIvDsHNZqYvscGTgy/ysgXWLJPt6K/4t0/GjvtKcFJQ==", + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", "dev": true, "license": "MIT", "dependencies": { @@ -111,7 +113,7 @@ "bidi-js": "^1.0.3", "css-tree": "^3.1.0", "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.5" + "lru-cache": "^11.2.6" } }, "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { @@ -1850,9 +1852,9 @@ } }, "node_modules/@biomejs/biome": { - "version": "2.3.14", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.14.tgz", - "integrity": "sha512-QMT6QviX0WqXJCaiqVMiBUCr5WRQ1iFSjvOLoTk6auKukJMvnMzWucXpwZB0e8F00/1/BsS9DzcKgWH+CLqVuA==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.1.tgz", + "integrity": "sha512-8c5DZQl1hfpLRlTZ21W5Ef2R314E4UJUEtkMbo303ElTVe6fYtapwldv7tZlgwm+9YP0Mhk7dUSTkOY8nQ2/2w==", "dev": true, "license": "MIT OR Apache-2.0", "bin": { @@ -1866,20 +1868,20 @@ "url": "https://opencollective.com/biome" }, "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "2.3.14", - "@biomejs/cli-darwin-x64": "2.3.14", - "@biomejs/cli-linux-arm64": "2.3.14", - "@biomejs/cli-linux-arm64-musl": "2.3.14", - "@biomejs/cli-linux-x64": "2.3.14", - "@biomejs/cli-linux-x64-musl": "2.3.14", - "@biomejs/cli-win32-arm64": "2.3.14", - "@biomejs/cli-win32-x64": "2.3.14" + "@biomejs/cli-darwin-arm64": "2.4.1", + "@biomejs/cli-darwin-x64": "2.4.1", + "@biomejs/cli-linux-arm64": "2.4.1", + "@biomejs/cli-linux-arm64-musl": "2.4.1", + "@biomejs/cli-linux-x64": "2.4.1", + "@biomejs/cli-linux-x64-musl": "2.4.1", + "@biomejs/cli-win32-arm64": "2.4.1", + "@biomejs/cli-win32-x64": "2.4.1" } }, "node_modules/@biomejs/cli-darwin-arm64": { - "version": "2.3.14", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.14.tgz", - "integrity": "sha512-UJGPpvWJMkLxSRtpCAKfKh41Q4JJXisvxZL8ChN1eNW3m/WlPFJ6EFDCE7YfUb4XS8ZFi3C1dFpxUJ0Ety5n+A==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.1.tgz", + "integrity": "sha512-wKiX2znbgFRaivRplSbu53hiREp1ohlGRuWqOL90IPetLi5E32tkiMYu8uSLXVzDgbIVM58WsesPaczIVtJkOQ==", "cpu": [ "arm64" ], @@ -1894,9 +1896,9 @@ } }, "node_modules/@biomejs/cli-darwin-x64": { - "version": "2.3.14", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.14.tgz", - "integrity": "sha512-PNkLNQG6RLo8lG7QoWe/hhnMxJIt1tEimoXpGQjwS/dkdNiKBLPv4RpeQl8o3s1OKI3ZOR5XPiYtmbGGHAOnLA==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.1.tgz", + "integrity": "sha512-rxLYVg3skeXh9K0om7JdkKcCdvtqrF9ECZ7dsmLuYObboK7DZ1J0z6xc2NGKSXw+cEQo3ie6NQgWBcdGJ16yQg==", "cpu": [ "x64" ], @@ -1911,9 +1913,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64": { - "version": "2.3.14", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.14.tgz", - "integrity": "sha512-KT67FKfzIw6DNnUNdYlBg+eU24Go3n75GWK6NwU4+yJmDYFe9i/MjiI+U/iEzKvo0g7G7MZqoyrhIYuND2w8QQ==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.1.tgz", + "integrity": "sha512-nlGO5KzoEKhGj2i3QXyyNCeFk8SVwyes0wo0/X9w943darnlAHfi8MYYunPf8lsz5C0JaH6pJYB6D9HnDwUPQA==", "cpu": [ "arm64" ], @@ -1928,9 +1930,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64-musl": { - "version": "2.3.14", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.14.tgz", - "integrity": "sha512-LInRbXhYujtL3sH2TMCH/UBwJZsoGwfQjBrMfl84CD4hL/41C/EU5mldqf1yoFpsI0iPWuU83U+nB2TUUypWeg==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.1.tgz", + "integrity": "sha512-Brwh/QL3wfX5UyZcyEamS1Q+EF8Q7ud+MS5mq/9BWX2ArfxQlgsqlukwK92xrGpXWcspXkSG9U0CoxvCZZkTKQ==", "cpu": [ "arm64" ], @@ -1945,9 +1947,9 @@ } }, "node_modules/@biomejs/cli-linux-x64": { - "version": "2.3.14", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.14.tgz", - "integrity": "sha512-ZsZzQsl9U+wxFrGGS4f6UxREUlgHwmEfu1IrXlgNFrNnd5Th6lIJr8KmSzu/+meSa9f4rzFrbEW9LBBA6ScoMA==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.1.tgz", + "integrity": "sha512-Rmhm/mQ/3pejy1WtWLKurV1fN6zvCrqKz/ART2ZzgqY4ozL07uys5R9jA0A+yLjA79JTkcpIe85ygXv0FnSPRg==", "cpu": [ "x64" ], @@ -1962,9 +1964,9 @@ } }, "node_modules/@biomejs/cli-linux-x64-musl": { - "version": "2.3.14", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.14.tgz", - "integrity": "sha512-KQU7EkbBBuHPW3/rAcoiVmhlPtDSGOGRPv9js7qJVpYTzjQmVR+C9Rfcz+ti8YCH+zT1J52tuBybtP4IodjxZQ==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.1.tgz", + "integrity": "sha512-kz1QpA+PXouNyWw2VzeoMlzMn99hlyOC/El2uSy+DS8gcb6tOsKEeZ5e2onnFIfZKe9AeKMFbTowDNLXwjwGjw==", "cpu": [ "x64" ], @@ -1979,9 +1981,9 @@ } }, "node_modules/@biomejs/cli-win32-arm64": { - "version": "2.3.14", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.14.tgz", - "integrity": "sha512-+IKYkj/pUBbnRf1G1+RlyA3LWiDgra1xpS7H2g4BuOzzRbRB+hmlw0yFsLprHhbbt7jUzbzAbAjK/Pn0FDnh1A==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.1.tgz", + "integrity": "sha512-e+PrlbQ/tez7W9EAzzCGUH1ovq31kR5r8sfCDzasrmoADLnDafet8pA8LdXnt0GwkeOem5Hz6WHCVZPRmaXiXw==", "cpu": [ "arm64" ], @@ -1996,9 +1998,9 @@ } }, "node_modules/@biomejs/cli-win32-x64": { - "version": "2.3.14", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.14.tgz", - "integrity": "sha512-oizCjdyQ3WJEswpb3Chdngeat56rIdSYK12JI3iI11Mt5T5EXcZ7WLuowzEaFPNJ3zmOQFliMN8QY1Pi+qsfdQ==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.1.tgz", + "integrity": "sha512-kfjOCzvaHC7olg8pmEuSsYzHntxdipkAGzr5nFiaEU2EPDWRE/myqUBaFDl9pHqEc8yEtQFiXF945PlTSkuOTw==", "cpu": [ "x64" ], @@ -2012,6 +2014,19 @@ "node": ">=14.21.3" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@csstools/color-helpers": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.1.tgz", @@ -2033,9 +2048,9 @@ } }, "node_modules/@csstools/css-calc": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.0.1.tgz", - "integrity": "sha512-bsDKIP6f4ta2DO9t+rAbSSwv4EMESXy5ZIvzQl1afmD6Z1XHkVu9ijcG9QR/qSgQS1dVa+RaQ/MfQ7FIB/Dn1Q==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", "dev": true, "funding": [ { @@ -2597,9 +2612,9 @@ } }, "node_modules/@exodus/bytes": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.13.0.tgz", - "integrity": "sha512-VnfL2lS43Z9F8li1faMH9hDZwqfrF5JvOePmrF8oESfo0ijaujnT81zYtienQRpoFa+FJbq0E5rrnMWEW73gOw==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.14.1.tgz", + "integrity": "sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ==", "dev": true, "license": "MIT", "engines": { @@ -3884,12 +3899,6 @@ } } }, - "node_modules/@rollup/plugin-commonjs/node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "license": "MIT" - }, "node_modules/@rollup/pluginutils": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", @@ -3912,12 +3921,6 @@ } } }, - "node_modules/@rollup/pluginutils/node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "license": "MIT" - }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.57.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", @@ -4244,50 +4247,50 @@ ] }, "node_modules/@sentry-internal/browser-utils": { - "version": "10.38.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.38.0.tgz", - "integrity": "sha512-UOJtYmdcxHCcV0NPfXFff/a95iXl/E0EhuQ1y0uE0BuZDMupWSF5t2BgC4HaE5Aw3RTjDF3XkSHWoIF6ohy7eA==", + "version": "10.39.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.39.0.tgz", + "integrity": "sha512-W6WODonMGiI13Az5P7jd/m2lj/JpIyuVKg7wE4X+YdlMehLspAv6I7gRE4OBSumS14ZjdaYDpD/lwtnBwKAzcA==", "license": "MIT", "dependencies": { - "@sentry/core": "10.38.0" + "@sentry/core": "10.39.0" }, "engines": { "node": ">=18" } }, "node_modules/@sentry-internal/feedback": { - "version": "10.38.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.38.0.tgz", - "integrity": "sha512-JXneg9zRftyfy1Fyfc39bBlF/Qd8g4UDublFFkVvdc1S6JQPlK+P6q22DKz3Pc8w3ySby+xlIq/eTu9Pzqi4KA==", + "version": "10.39.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.39.0.tgz", + "integrity": "sha512-cRXmmDeOr5FzVsBNRLU4WDEuC3fhuD0XV362EWl4DI3XBGao8ukaueKcLIKic5WZx6uXimjWw/UJmDLgxeCqkg==", "license": "MIT", "dependencies": { - "@sentry/core": "10.38.0" + "@sentry/core": "10.39.0" }, "engines": { "node": ">=18" } }, "node_modules/@sentry-internal/replay": { - "version": "10.38.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.38.0.tgz", - "integrity": "sha512-YWIkL6/dnaiQyFiZXJ/nN+NXGv/15z45ia86bE/TMq01CubX/DUOilgsFz0pk2v/pg3tp/U2MskLO9Hz0cnqeg==", + "version": "10.39.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.39.0.tgz", + "integrity": "sha512-obZoYOrUfxIYBHkmtPpItRdE38VuzF1VIxSgZ8Mbtq/9UvCWh+eOaVWU2stN/cVu1KYuYX0nQwBvdN28L6y/JA==", "license": "MIT", "dependencies": { - "@sentry-internal/browser-utils": "10.38.0", - "@sentry/core": "10.38.0" + "@sentry-internal/browser-utils": "10.39.0", + "@sentry/core": "10.39.0" }, "engines": { "node": ">=18" } }, "node_modules/@sentry-internal/replay-canvas": { - "version": "10.38.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.38.0.tgz", - "integrity": "sha512-OXWM9jEqNYh4VTvrMu7v+z1anz+QKQ/fZXIZdsO7JTT2lGNZe58UUMeoq386M+Saxen8F9SUH7yTORy/8KI5qw==", + "version": "10.39.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.39.0.tgz", + "integrity": "sha512-TTiX0XWCcqTqFGJjEZYObk93j/sJmXcqPzcu0cN2mIkKnnaHDY3w74SHZCshKqIr0AOQdt1HDNa36s3TCdt0Jw==", "license": "MIT", "dependencies": { - "@sentry-internal/replay": "10.38.0", - "@sentry/core": "10.38.0" + "@sentry-internal/replay": "10.39.0", + "@sentry/core": "10.39.0" }, "engines": { "node": ">=18" @@ -4303,16 +4306,16 @@ } }, "node_modules/@sentry/browser": { - "version": "10.38.0", - "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.38.0.tgz", - "integrity": "sha512-3phzp1YX4wcQr9mocGWKbjv0jwtuoDBv7+Y6Yfrys/kwyaL84mDLjjQhRf4gL5SX7JdYkhBp4WaiNlR0UC4kTA==", + "version": "10.39.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.39.0.tgz", + "integrity": "sha512-I50W/1PDJWyqgNrGufGhBYCmmO3Bb159nx2Ut2bKoVveTfgH/hLEtDyW0kHo8Fu454mW+ukyXfU4L4s+kB9aaw==", "license": "MIT", "dependencies": { - "@sentry-internal/browser-utils": "10.38.0", - "@sentry-internal/feedback": "10.38.0", - "@sentry-internal/replay": "10.38.0", - "@sentry-internal/replay-canvas": "10.38.0", - "@sentry/core": "10.38.0" + "@sentry-internal/browser-utils": "10.39.0", + "@sentry-internal/feedback": "10.39.0", + "@sentry-internal/replay": "10.39.0", + "@sentry-internal/replay-canvas": "10.39.0", + "@sentry/core": "10.39.0" }, "engines": { "node": ">=18" @@ -4514,56 +4517,31 @@ "node": ">=10" } }, - "node_modules/@sentry/cli/node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "license": "MIT", - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/@sentry/cli/node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "license": "MIT", - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/@sentry/core": { - "version": "10.38.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.38.0.tgz", - "integrity": "sha512-1pubWDZE5y5HZEPMAZERP4fVl2NH3Ihp1A+vMoVkb3Qc66Diqj1WierAnStlZP7tCx0TBa0dK85GTW/ZFYyB9g==", + "version": "10.39.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.39.0.tgz", + "integrity": "sha512-xCLip2mBwCdRrvXHtVEULX0NffUTYZZBhEUGht0WFL+GNdNQ7gmBOGOczhZlrf2hgFFtDO0fs1xiP9bqq5orEQ==", "license": "MIT", "engines": { "node": ">=18" } }, "node_modules/@sentry/nextjs": { - "version": "10.38.0", - "resolved": "https://registry.npmjs.org/@sentry/nextjs/-/nextjs-10.38.0.tgz", - "integrity": "sha512-MW2f6mK54jFyS/lmJxT7GWr5d12E+3qvIhR5EdjdyzMX8udSOCGyFJaFIwUfMyEMuggPEvNQVFFpjIrvWXCSGA==", + "version": "10.39.0", + "resolved": "https://registry.npmjs.org/@sentry/nextjs/-/nextjs-10.39.0.tgz", + "integrity": "sha512-u8Wp8V5zA2OTREmBXNm1UNtdQv3oJOH/ZC83tQH8wV8GCJfkSw7qDp89T1fbyMvYKOdQuhT4pxeriHCq8S5L0g==", "license": "MIT", "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/semantic-conventions": "^1.37.0", "@rollup/plugin-commonjs": "28.0.1", - "@sentry-internal/browser-utils": "10.38.0", - "@sentry/bundler-plugin-core": "^4.8.0", - "@sentry/core": "10.38.0", - "@sentry/node": "10.38.0", - "@sentry/opentelemetry": "10.38.0", - "@sentry/react": "10.38.0", - "@sentry/vercel-edge": "10.38.0", + "@sentry-internal/browser-utils": "10.39.0", + "@sentry/bundler-plugin-core": "^4.9.0", + "@sentry/core": "10.39.0", + "@sentry/node": "10.39.0", + "@sentry/opentelemetry": "10.39.0", + "@sentry/react": "10.39.0", + "@sentry/vercel-edge": "10.39.0", "@sentry/webpack-plugin": "^4.8.0", "rollup": "^4.35.0", "stacktrace-parser": "^0.1.10" @@ -4576,9 +4554,9 @@ } }, "node_modules/@sentry/node": { - "version": "10.38.0", - "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.38.0.tgz", - "integrity": "sha512-wriyDtWDAoatn8EhOj0U4PJR1WufiijTsCGALqakOHbFiadtBJANLe6aSkXoXT4tegw59cz1wY4NlzHjYksaPw==", + "version": "10.39.0", + "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.39.0.tgz", + "integrity": "sha512-dx66DtU/xkCTPEDsjU+mYSIEbzu06pzKNQcDA2wvx7wvwsUciZ5yA32Ce/o6p2uHHgy0/joJX9rP5J/BIijaOA==", "license": "MIT", "dependencies": { "@opentelemetry/api": "^1.9.0", @@ -4611,9 +4589,9 @@ "@opentelemetry/sdk-trace-base": "^2.5.0", "@opentelemetry/semantic-conventions": "^1.39.0", "@prisma/instrumentation": "7.2.0", - "@sentry/core": "10.38.0", - "@sentry/node-core": "10.38.0", - "@sentry/opentelemetry": "10.38.0", + "@sentry/core": "10.39.0", + "@sentry/node-core": "10.39.0", + "@sentry/opentelemetry": "10.39.0", "import-in-the-middle": "^2.0.6", "minimatch": "^9.0.0" }, @@ -4622,14 +4600,14 @@ } }, "node_modules/@sentry/node-core": { - "version": "10.38.0", - "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.38.0.tgz", - "integrity": "sha512-ErXtpedrY1HghgwM6AliilZPcUCoNNP1NThdO4YpeMq04wMX9/GMmFCu46TnCcg6b7IFIOSr2S4yD086PxLlHQ==", + "version": "10.39.0", + "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.39.0.tgz", + "integrity": "sha512-xdeBG00TmtAcGvXnZNbqOCvnZ5kY3s5aT/L8wUQ0w0TT2KmrC9XL/7UHUfJ45TLbjl10kZOtaMQXgUjpwSJW+g==", "license": "MIT", "dependencies": { "@apm-js-collab/tracing-hooks": "^0.3.1", - "@sentry/core": "10.38.0", - "@sentry/opentelemetry": "10.38.0", + "@sentry/core": "10.39.0", + "@sentry/opentelemetry": "10.39.0", "import-in-the-middle": "^2.0.6" }, "engines": { @@ -4643,15 +4621,38 @@ "@opentelemetry/resources": "^1.30.1 || ^2.1.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", "@opentelemetry/semantic-conventions": "^1.39.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@opentelemetry/context-async-hooks": { + "optional": true + }, + "@opentelemetry/core": { + "optional": true + }, + "@opentelemetry/instrumentation": { + "optional": true + }, + "@opentelemetry/resources": { + "optional": true + }, + "@opentelemetry/sdk-trace-base": { + "optional": true + }, + "@opentelemetry/semantic-conventions": { + "optional": true + } } }, "node_modules/@sentry/opentelemetry": { - "version": "10.38.0", - "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.38.0.tgz", - "integrity": "sha512-YPVhWfYmC7nD3EJqEHGtjp4fp5LwtAbE5rt9egQ4hqJlYFvr8YEz9sdoqSZxO0cZzgs2v97HFl/nmWAXe52G2Q==", + "version": "10.39.0", + "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.39.0.tgz", + "integrity": "sha512-eU8t/pyxjy7xYt6PNCVxT+8SJw5E3pnupdcUNN4ClqG4O5lX4QCDLtId48ki7i30VqrLtR7vmCHMSvqXXdvXPA==", "license": "MIT", "dependencies": { - "@sentry/core": "10.38.0" + "@sentry/core": "10.39.0" }, "engines": { "node": ">=18" @@ -4665,13 +4666,13 @@ } }, "node_modules/@sentry/react": { - "version": "10.38.0", - "resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.38.0.tgz", - "integrity": "sha512-3UiKo6QsqTyPGUt0XWRY9KLaxc/cs6Kz4vlldBSOXEL6qPDL/EfpwNJT61osRo81VFWu8pKu7ZY2bvLPryrnBQ==", + "version": "10.39.0", + "resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.39.0.tgz", + "integrity": "sha512-qxReWHFhDcXNGEyAlYzhR7+K70es+vXaSknTZui1q7TfQwCT1rZlLKn/K8GDpNsb35RC5QhiIphU6pKbyYgZqw==", "license": "MIT", "dependencies": { - "@sentry/browser": "10.38.0", - "@sentry/core": "10.38.0" + "@sentry/browser": "10.39.0", + "@sentry/core": "10.39.0" }, "engines": { "node": ">=18" @@ -4681,14 +4682,14 @@ } }, "node_modules/@sentry/vercel-edge": { - "version": "10.38.0", - "resolved": "https://registry.npmjs.org/@sentry/vercel-edge/-/vercel-edge-10.38.0.tgz", - "integrity": "sha512-lElDFktj/PyRC/LDHejPFhQmHVMCB9Celj+IHi36aw96a/LekqF6/7vmp26hDtH58QtuiPO3h5voqEAMUOkSlw==", + "version": "10.39.0", + "resolved": "https://registry.npmjs.org/@sentry/vercel-edge/-/vercel-edge-10.39.0.tgz", + "integrity": "sha512-73QxAyOSBUpmBc/A8AcJfom/sPN+0thGQRLHzH6uvRxbap2+a3qhKJzQsDkcEUkzY0ScTf2R3j/soYNn1pKlaQ==", "license": "MIT", "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/resources": "^2.5.0", - "@sentry/core": "10.38.0" + "@sentry/core": "10.39.0" }, "engines": { "node": ">=18" @@ -4712,9 +4713,9 @@ } }, "node_modules/@spree/next": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@spree/next/-/next-0.2.0.tgz", - "integrity": "sha512-SaEjd8j969eufc+W+lVgI/my0Llu8jlu13lLeBSLGZe344lUqEuWV+xDf5MoUNkPBeUEbRW1nGeVxohPzNXv+g==", + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@spree/next/-/next-0.2.2.tgz", + "integrity": "sha512-SJfxbEy4YY4PpEoqljvd/FD1fyfXhx5Vmux2HV8N37Lu7W8UQkPDBw3FuBBwBMcSOS+JArughyJqj//oq5CSHg==", "license": "MIT", "peerDependencies": { "@spree/sdk": ">=0.2.0", @@ -4723,9 +4724,9 @@ } }, "node_modules/@spree/sdk": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@spree/sdk/-/sdk-0.2.0.tgz", - "integrity": "sha512-zCdNFg8+kMFj5giDY18u5er+9j4Ch3mDKq2nHAshgRtiNSMDw4euGDRvySujnMtJyMXSS1YejdcGX/RDKA432g==", + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@spree/sdk/-/sdk-0.2.2.tgz", + "integrity": "sha512-xQZ3VYZZb1LegV/hhauo9MS4OSm4JPBB7He0oTPuFv7CA6jzub8pzQI0lhFLMktKMtHLlcCHf7o79T5zAv5e3Q==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -4750,6 +4751,29 @@ "dev": true, "license": "MIT" }, + "node_modules/@stripe/react-stripe-js": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-5.6.0.tgz", + "integrity": "sha512-tucu/vTGc+5NXbo2pUiaVjA4ENdRBET8qGS00BM4BAU8J4Pi3eY6BHollsP2+VSuzzlvXwMg0it3ZLhbCj2fPg==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.7.2" + }, + "peerDependencies": { + "@stripe/stripe-js": ">=8.0.0 <9.0.0", + "react": ">=16.8.0 <20.0.0", + "react-dom": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@stripe/stripe-js": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-8.7.0.tgz", + "integrity": "sha512-tNUerSstwNC1KuHgX4CASGO0Md3CB26IJzSXmVlSuFvhsBP4ZaEPpY4jxWOn9tfdDscuVT4Kqb8cZ2o9nLCgRQ==", + "license": "MIT", + "engines": { + "node": ">=12.16" + } + }, "node_modules/@svgr/babel-plugin-add-jsx-attribute": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz", @@ -4952,19 +4976,6 @@ "url": "https://github.com/sponsors/gregberge" } }, - "node_modules/@svgr/hast-util-to-babel-ast/node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, "node_modules/@svgr/plugin-jsx": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-8.1.0.tgz", @@ -5662,6 +5673,16 @@ } } }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/@vitest/pretty-format": { "version": "4.0.18", "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", @@ -5938,19 +5959,21 @@ } }, "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", "license": "MIT", + "dependencies": { + "debug": "4" + }, "engines": { - "node": ">= 14" + "node": ">= 6.0.0" } }, "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "peer": true, "dependencies": { @@ -6241,9 +6264,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001769", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz", - "integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==", + "version": "1.0.30001770", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz", + "integrity": "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==", "funding": [ { "type": "opencollective", @@ -6499,16 +6522,16 @@ "license": "CC0-1.0" }, "node_modules/cssstyle": { - "version": "5.3.7", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", - "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.0.1.tgz", + "integrity": "sha512-IoJs7La+oFp/AB033wBStxNOJt4+9hHMxsXUPANcoXL2b3W4DZKghlJ2cI/eyeRZIQ9ysvYEorVhjrcYctWbog==", "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/css-color": "^4.1.1", - "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "@asamuzakjp/css-color": "^4.1.2", + "@csstools/css-syntax-patches-for-csstree": "^1.0.26", "css-tree": "^3.1.0", - "lru-cache": "^11.2.4" + "lru-cache": "^11.2.5" }, "engines": { "node": ">=20" @@ -6622,19 +6645,6 @@ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" } }, - "node_modules/dom-serializer/node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, "node_modules/domelementtype": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", @@ -6734,9 +6744,9 @@ } }, "node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -6862,14 +6872,10 @@ } }, "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" }, "node_modules/esutils": { "version": "2.0.3", @@ -7121,18 +7127,27 @@ "node": ">= 14" } }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "dev": true, "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", "dependencies": { - "agent-base": "^7.1.2", + "agent-base": "6", "debug": "4" }, "engines": { - "node": ">= 14" + "node": ">= 6" } }, "node_modules/import-fresh": { @@ -7330,16 +7345,17 @@ } }, "node_modules/jsdom": { - "version": "28.0.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.0.0.tgz", - "integrity": "sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA==", + "version": "28.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", + "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", "dev": true, "license": "MIT", "dependencies": { "@acemir/cssom": "^0.9.31", - "@asamuzakjp/dom-selector": "^6.7.6", + "@asamuzakjp/dom-selector": "^6.8.1", + "@bramus/specificity": "^2.4.2", "@exodus/bytes": "^1.11.0", - "cssstyle": "^5.3.7", + "cssstyle": "^6.0.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", @@ -7350,7 +7366,7 @@ "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.0", - "undici": "^7.20.0", + "undici": "^7.21.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", @@ -7369,6 +7385,30 @@ } } }, + "node_modules/jsdom/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -7710,6 +7750,18 @@ "dev": true, "license": "MIT" }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/lower-case": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", @@ -8019,6 +8071,15 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -8111,6 +8172,19 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -8317,6 +8391,23 @@ "node": ">=0.4.0" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -9248,9 +9339,9 @@ } }, "node_modules/undici": { - "version": "7.21.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.21.0.tgz", - "integrity": "sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg==", + "version": "7.22.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", + "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", "dev": true, "license": "MIT", "engines": { @@ -9602,9 +9693,9 @@ } }, "node_modules/webpack-sources": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.3.tgz", - "integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==", + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.4.tgz", + "integrity": "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==", "license": "MIT", "engines": { "node": ">=10.13.0" diff --git a/package.json b/package.json index 51480790a..e571715e0 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,10 @@ }, "dependencies": { "@sentry/nextjs": "^10.38.0", - "@spree/next": "^0.2.0", - "@spree/sdk": "^0.2.0", + "@spree/next": "^0.2.2", + "@spree/sdk": "^0.2.2", + "@stripe/react-stripe-js": "^5.6.0", + "@stripe/stripe-js": "^8.7.0", "next": "^16", "react": "^19", "react-dom": "^19" diff --git a/src/app/[country]/[locale]/(checkout)/checkout/[id]/page.tsx b/src/app/[country]/[locale]/(checkout)/checkout/[id]/page.tsx index 987f02371..912925730 100644 --- a/src/app/[country]/[locale]/(checkout)/checkout/[id]/page.tsx +++ b/src/app/[country]/[locale]/(checkout)/checkout/[id]/page.tsx @@ -20,7 +20,6 @@ import { getAddresses, updateAddress } from "@/lib/data/addresses"; import { advanceCheckout, applyCouponCode, - completeOrder, getCheckoutOrder, removeCouponCode, selectShippingRate, @@ -28,6 +27,10 @@ import { } from "@/lib/data/checkout"; import { isAuthenticated as checkAuth } from "@/lib/data/cookies"; import { getCountries, getCountry } from "@/lib/data/countries"; +import { + completeCheckoutOrder, + completeCheckoutPaymentSession, +} from "@/lib/data/payment"; import { extractBasePath } from "@/lib/utils/path"; // Checkout steps @@ -187,7 +190,7 @@ export default function CheckoutPage({ params }: CheckoutPageProps) { // Check if order is already complete if (orderData.state === "complete") { - router.push(`${basePath}/account/orders/${orderId}`); + router.push(`${basePath}/order-placed/${orderId}`); return; } @@ -276,12 +279,9 @@ export default function CheckoutPage({ params }: CheckoutPageProps) { const result = await selectShippingRate(order.id, shipmentId, rateId); if (!result.success) { setError(result.error || "Failed to select shipping rate"); - } - // Reload order to get updated totals (includes shipments) - const orderData = await getCheckoutOrder(order.id); - if (orderData) { - setOrder(orderData); - setShipments(orderData.shipments || []); + } else if (result.order) { + setOrder(result.order); + setShipments(result.order.shipments || []); } } catch { setError("An error occurred. Please try again."); @@ -315,43 +315,61 @@ export default function CheckoutPage({ params }: CheckoutPageProps) { } }; - // Handle payment submission (billing address + complete order) - const handlePaymentSubmit = async (paymentData: { + // Handle billing address update (called by PaymentStep before Stripe confirmation) + const handleUpdateBillingAddress = async (data: { bill_address: AddressParams; - use_shipping_for_billing: boolean; - }) => { + }): Promise => { + if (!order) return false; + + setError(null); + + const updateResult = await updateOrderAddresses(order.id, { + bill_address: data.bill_address, + }); + + if (!updateResult.success) { + setError(updateResult.error || "Failed to save billing address"); + return false; + } + + return true; + }; + + // Handle payment completion (called by PaymentStep after Stripe confirms) + const handlePaymentComplete = async (paymentSessionId: string) => { if (!order) return; - setProcessing(true); setError(null); try { - // Update billing address - const updateResult = await updateOrderAddresses(order.id, { - bill_address: paymentData.bill_address, - }); + // Complete the payment session on the backend + const sessionResult = await completeCheckoutPaymentSession( + order.id, + paymentSessionId, + ); - if (!updateResult.success) { - setError(updateResult.error || "Failed to save billing address"); + if (!sessionResult.success) { + setError(sessionResult.error || "Failed to complete payment session"); setProcessing(false); return; } - // Complete the order (skip actual payment for now) - const completeResult = await completeOrder(order.id); - if (!completeResult.success) { - setError(completeResult.error || "Failed to complete order"); - setProcessing(false); - return; + // Check if the order was already completed by the payment session completion. + // If not, explicitly complete it. + const updatedOrder = await getCheckoutOrder(order.id); + if (updatedOrder && updatedOrder.state !== "complete") { + const completeResult = await completeCheckoutOrder(order.id); + if (!completeResult.success) { + setError(completeResult.error || "Failed to complete order"); + setProcessing(false); + return; + } } - // Redirect to order confirmation - if (completeResult.order) { - router.push(`${basePath}/account/orders/${completeResult.order.id}`); - } + // Redirect to order placed page (cart cookie is cleared there) + router.push(`${basePath}/order-placed/${order.id}`); } catch { setError("An error occurred. Please try again."); - } finally { setProcessing(false); } }; @@ -554,9 +572,11 @@ export default function CheckoutPage({ params }: CheckoutPageProps) { order={order} countries={countries} fetchStates={fetchStates} - onSubmit={handlePaymentSubmit} + onUpdateBillingAddress={handleUpdateBillingAddress} + onPaymentComplete={handlePaymentComplete} onBack={() => goToStep("delivery")} processing={processing} + setProcessing={setProcessing} /> )} diff --git a/src/app/[country]/[locale]/(checkout)/order-placed/[id]/page.tsx b/src/app/[country]/[locale]/(checkout)/order-placed/[id]/page.tsx new file mode 100644 index 000000000..cc3e5e93c --- /dev/null +++ b/src/app/[country]/[locale]/(checkout)/order-placed/[id]/page.tsx @@ -0,0 +1,280 @@ +"use client"; + +import type { StoreOrder } from "@spree/sdk"; +import Link from "next/link"; +import { usePathname, useSearchParams } from "next/navigation"; +import { use, useEffect, useRef, useState } from "react"; +import { CheckCircleSolidIcon, ImagePlaceholderIcon } from "@/components/icons"; +import { useCheckout } from "@/contexts/CheckoutContext"; +import { getCheckoutOrder } from "@/lib/data/checkout"; +import { completeCheckoutOrder } from "@/lib/data/payment"; +import { extractBasePath } from "@/lib/utils/path"; + +interface OrderPlacedPageProps { + params: Promise<{ + id: string; + country: string; + locale: string; + }>; +} + +export default function OrderPlacedPage({ params }: OrderPlacedPageProps) { + const { id: orderId } = use(params); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const basePath = extractBasePath(pathname); + const { setSummaryContent } = useCheckout(); + + const [order, setOrder] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Clear sidebar summary + useEffect(() => { + setSummaryContent(null); + }, [setSummaryContent]); + + // Track whether we've already loaded the order to avoid re-fetching + // after the cart token cookie is cleared by CartProvider + const loadedRef = useRef(false); + + useEffect(() => { + if (loadedRef.current) return; + let cancelled = false; + + async function loadAndComplete() { + try { + // Handle Stripe redirect return (3DS, etc.) + const paymentIntent = searchParams.get("payment_intent"); + if (paymentIntent) { + // Find the payment session and complete it + const orderData = await getCheckoutOrder(orderId); + if (orderData && orderData.state !== "complete") { + // Look for a pending payment session matching this payment intent + // The backend will find it via the webhook, but we also try to complete via API + // to ensure the user sees the confirmation immediately + await completeCheckoutOrder(orderId); + } + } + + // Load the order + const orderData = await getCheckoutOrder(orderId); + if (!cancelled) { + loadedRef.current = true; + if (orderData) { + setOrder(orderData); + } else { + setError("Order not found."); + } + setLoading(false); + } + } catch { + if (!cancelled) { + loadedRef.current = true; + setError("Failed to load order details."); + setLoading(false); + } + } + } + + loadAndComplete(); + + return () => { + cancelled = true; + }; + }, [orderId, searchParams]); + + if (loading) { + return ( +
+
+
+
+
+
+ ); + } + + if (error || !order) { + return ( +
+

+ {error || "Order not found"} +

+ + Continue Shopping + +
+ ); + } + + const customerName = + order.bill_address?.full_name || order.ship_address?.full_name || ""; + + return ( +
+ {/* Success Header */} +
+ +

+ Thanks for your order + {customerName ? `, ${customerName.split(" ")[0]}` : ""}! +

+

Order #{order.number}

+

+ You will receive an email confirmation shortly. +

+
+ + {/* Order Items */} +
+
+

Order Items

+
+
    + {order.line_items?.map((item) => ( +
  • +
    + {item.thumbnail_url ? ( + {item.name} + ) : ( +
    + +
    + )} +
    +
    +

    + {item.name} +

    + {item.options_text && ( +

    {item.options_text}

    + )} +

    Qty: {item.quantity}

    +
    +
    + {item.display_total} +
    +
  • + ))} +
+ + {/* Totals */} +
+
+ Subtotal + {order.display_item_total} +
+
+ Shipping + {order.display_ship_total} +
+ {order.promo_total && Number.parseFloat(order.promo_total) !== 0 && ( +
+ Discount + + {order.display_promo_total} + +
+ )} +
+ Tax + {order.display_tax_total} +
+
+ Total + + {order.display_total} + +
+
+
+ + {/* Contact & Addresses */} +
+
+

+ Contact Information +

+
+
+ {order.email && ( +

{order.email}

+ )} + +
+ {order.ship_address && ( +
+

+ Shipping Address +

+
+

+ {order.ship_address.full_name} +

+ {order.ship_address.company && ( +

{order.ship_address.company}

+ )} +

{order.ship_address.address1}

+ {order.ship_address.address2 && ( +

{order.ship_address.address2}

+ )} +

+ {order.ship_address.city}, {order.ship_address.state_text}{" "} + {order.ship_address.zipcode} +

+

{order.ship_address.country_name}

+ {order.ship_address.phone && ( +

{order.ship_address.phone}

+ )} +
+
+ )} + + {order.bill_address && ( +
+

+ Billing Address +

+
+

+ {order.bill_address.full_name} +

+ {order.bill_address.company && ( +

{order.bill_address.company}

+ )} +

{order.bill_address.address1}

+ {order.bill_address.address2 && ( +

{order.bill_address.address2}

+ )} +

+ {order.bill_address.city}, {order.bill_address.state_text}{" "} + {order.bill_address.zipcode} +

+

{order.bill_address.country_name}

+
+
+ )} +
+
+
+ + {/* Actions */} +
+ + Continue Shopping + +
+
+ ); +} diff --git a/src/components/checkout/PaymentStep.tsx b/src/components/checkout/PaymentStep.tsx index d3916f33b..0b64b4628 100644 --- a/src/components/checkout/PaymentStep.tsx +++ b/src/components/checkout/PaymentStep.tsx @@ -6,8 +6,9 @@ import type { StoreOrder, StoreState, } from "@spree/sdk"; -import { useEffect, useState, useTransition } from "react"; +import { useCallback, useEffect, useRef, useState, useTransition } from "react"; import { CreditCardIcon } from "@/components/icons"; +import { createCheckoutPaymentSession } from "@/lib/data/payment"; import { type AddressFormData, addressesMatch, @@ -15,26 +16,33 @@ import { formDataToAddress, } from "@/lib/utils/address"; import { AddressFormFields } from "./AddressFormFields"; +import { + StripePaymentForm, + type StripePaymentFormHandle, +} from "./StripePaymentForm"; interface PaymentStepProps { order: StoreOrder; countries: StoreCountry[]; fetchStates: (countryIso: string) => Promise; - onSubmit: (data: { + onUpdateBillingAddress: (data: { bill_address: AddressParams; - use_shipping_for_billing: boolean; - }) => Promise; + }) => Promise; + onPaymentComplete: (paymentSessionId: string) => Promise; onBack: () => void; processing: boolean; + setProcessing: (processing: boolean) => void; } export function PaymentStep({ order, countries, fetchStates, - onSubmit, + onUpdateBillingAddress, + onPaymentComplete, onBack, processing, + setProcessing, }: PaymentStepProps) { // Initialize billing address from order, check if it matches shipping const shipAddressData = addressToFormData(order.ship_address); @@ -50,6 +58,66 @@ export function PaymentStep({ const [billStates, setBillStates] = useState([]); const [isPendingBill, startTransitionBill] = useTransition(); + // Stripe state + const [clientSecret, setClientSecret] = useState(null); + const [paymentSessionId, setPaymentSessionId] = useState(null); + const [stripeError, setStripeError] = useState(null); + const [loadingSession, setLoadingSession] = useState(false); + const stripeHandleRef = useRef(null); + const lastOrderTotalRef = useRef(order.total); + + const handleStripeReady = useCallback((handle: StripePaymentFormHandle) => { + stripeHandleRef.current = handle; + }, []); + + // Find Stripe payment method + const stripePaymentMethod = order.payment_methods?.find( + (pm) => pm.session_required, + ); + + // Reset payment session when order total changes (e.g., coupon applied/removed) + if (order.total !== lastOrderTotalRef.current && clientSecret) { + lastOrderTotalRef.current = order.total; + setClientSecret(null); + setPaymentSessionId(null); + setStripeError(null); + stripeHandleRef.current = null; + } + lastOrderTotalRef.current = order.total; + + // Create payment session when needed (on mount or after total changes) + useEffect(() => { + if (!stripePaymentMethod || clientSecret) return; + + let cancelled = false; + setLoadingSession(true); + + createCheckoutPaymentSession(order.id, stripePaymentMethod.id).then( + (result) => { + if (cancelled) return; + setLoadingSession(false); + + if (result.success && result.session) { + const secret = result.session.external_data?.client_secret as + | string + | undefined; + if (secret) { + setClientSecret(secret); + setPaymentSessionId(result.session.id); + } else { + setStripeError("Failed to initialize payment. Please try again."); + } + } else if (!result.success) { + setStripeError(result.error || "Failed to create payment session."); + } + }, + ); + + return () => { + cancelled = true; + }; + }, [stripePaymentMethod, order.id, clientSecret]); + // Load states when billing country changes useEffect(() => { if (useShippingForBilling || !billAddress.country_iso) { @@ -81,14 +149,45 @@ export function PaymentStep({ } }; - const handleSubmit = (e: React.FormEvent) => { + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - onSubmit({ - bill_address: formDataToAddress( + + if (!stripeHandleRef.current || !paymentSessionId) return; + + setProcessing(true); + setStripeError(null); + + try { + // 1. Update billing address + const billingData = formDataToAddress( useShippingForBilling ? shipAddressData : billAddress, - ), - use_shipping_for_billing: useShippingForBilling, - }); + ); + const addressSuccess = await onUpdateBillingAddress({ + bill_address: billingData, + }); + + if (!addressSuccess) { + setProcessing(false); + return; + } + + // 2. Confirm payment with Stripe + const returnUrl = `${window.location.origin}${window.location.pathname.replace(/\/checkout\/.*/, `/order-placed/${order.id}`)}`; + const { error } = + await stripeHandleRef.current!.confirmPayment(returnUrl); + + if (error) { + setStripeError(error); + setProcessing(false); + return; + } + + // 3. Payment succeeded — complete session and order + await onPaymentComplete(paymentSessionId); + } catch { + setStripeError("An error occurred during payment. Please try again."); + setProcessing(false); + } }; const updateBillAddress = (field: keyof AddressFormData, value: string) => { @@ -169,23 +268,46 @@ export function PaymentStep({ )}
- {/* Payment Methods - Placeholder */} + {/* Payment Method */}

Payment Method

-
- +
+ + Loading payment form... + +
+ )} + + {stripeError && !loadingSession && ( +
+ {stripeError} +
+ )} + + {clientSecret && !loadingSession && ( + -

- Payment methods will be available soon. -

-

- For now, orders can be completed without payment. -

-
+ )} + + {!stripePaymentMethod && !loadingSession && ( +
+ +

+ No payment methods available for this order. +

+
+ )}
{/* Actions */} @@ -200,10 +322,14 @@ export function PaymentStep({
diff --git a/src/components/checkout/StripePaymentForm.tsx b/src/components/checkout/StripePaymentForm.tsx new file mode 100644 index 000000000..b404ed529 --- /dev/null +++ b/src/components/checkout/StripePaymentForm.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { + Elements, + PaymentElement, + useElements, + useStripe, +} from "@stripe/react-stripe-js"; +import { loadStripe } from "@stripe/stripe-js"; +import { useCallback, useEffect, useState } from "react"; + +const stripePromise = loadStripe( + process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!, +); + +export interface StripePaymentFormHandle { + confirmPayment: (returnUrl: string) => Promise<{ error?: string }>; +} + +interface StripePaymentFormProps { + clientSecret: string; + onReady: (handle: StripePaymentFormHandle) => void; +} + +function StripePaymentFormInner({ + onReady, +}: { + onReady: (handle: StripePaymentFormHandle) => void; +}) { + const stripe = useStripe(); + const elements = useElements(); + const [error, setError] = useState(null); + + const confirmPayment = useCallback( + async (returnUrl: string) => { + if (!stripe || !elements) { + return { error: "Stripe has not loaded yet" }; + } + + setError(null); + + const result = await stripe.confirmPayment({ + elements, + confirmParams: { + return_url: returnUrl, + }, + redirect: "if_required", + }); + + if (result.error) { + const message = + result.error.message || "An error occurred during payment."; + setError(message); + return { error: message }; + } + + return {}; + }, + [stripe, elements], + ); + + useEffect(() => { + if (stripe && elements) { + onReady({ confirmPayment }); + } + }, [stripe, elements, confirmPayment, onReady]); + + return ( +
+ + {error &&

{error}

} +
+ ); +} + +export function StripePaymentForm({ + clientSecret, + onReady, +}: StripePaymentFormProps) { + return ( + + + + ); +} diff --git a/src/components/checkout/index.ts b/src/components/checkout/index.ts index 0f30f2aa9..b5d8abb7a 100644 --- a/src/components/checkout/index.ts +++ b/src/components/checkout/index.ts @@ -4,3 +4,4 @@ export { CouponCode } from "./CouponCode"; export { DeliveryStep } from "./DeliveryStep"; export { OrderSummary } from "./OrderSummary"; export { PaymentStep } from "./PaymentStep"; +export { StripePaymentForm } from "./StripePaymentForm"; diff --git a/src/contexts/CartContext.tsx b/src/contexts/CartContext.tsx index 742ac4cc0..47f04addd 100644 --- a/src/contexts/CartContext.tsx +++ b/src/contexts/CartContext.tsx @@ -1,7 +1,7 @@ "use client"; import type { StoreLineItem, StoreOrder } from "@spree/sdk"; -import { useRouter } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; import { createContext, type ReactNode, @@ -39,6 +39,7 @@ export function CartProvider({ children }: { children: ReactNode }) { const [updating, setUpdating] = useState(false); const [isOpen, setIsOpen] = useState(false); const router = useRouter(); + const pathname = usePathname(); const openCart = useCallback(() => setIsOpen(true), []); const closeCart = useCallback(() => setIsOpen(false), []); @@ -114,9 +115,12 @@ export function CartProvider({ children }: { children: ReactNode }) { [mutateCart], ); + // Re-fetch cart on navigation (e.g., after checkout completes, the stale + // cart token will be cleared by getCart and the cart state will update) + // biome-ignore lint/correctness/useExhaustiveDependencies: pathname triggers re-fetch on navigation useEffect(() => { refreshCart(); - }, [refreshCart]); + }, [refreshCart, pathname]); const itemCount = cart?.line_items?.reduce( diff --git a/src/lib/constants.ts b/src/lib/constants.ts new file mode 100644 index 000000000..74d68f486 --- /dev/null +++ b/src/lib/constants.ts @@ -0,0 +1,2 @@ +/** Cookie name used by @spree/next for the cart/order token */ +export const CART_TOKEN_KEY = "_spree_cart_token"; diff --git a/src/lib/data/cart.ts b/src/lib/data/cart.ts index 8830f8394..8017cb9c8 100644 --- a/src/lib/data/cart.ts +++ b/src/lib/data/cart.ts @@ -28,24 +28,21 @@ export async function clearCart() { export async function addToCart(variantId: string, quantity: number) { return actionResult(async () => { - await addItem(variantId, quantity); - const cart = await _getCart(); + const cart = await addItem(variantId, quantity); return { cart }; }, "Failed to add item to cart"); } export async function updateCartItem(lineItemId: string, quantity: number) { return actionResult(async () => { - await updateItem(lineItemId, quantity); - const cart = await _getCart(); + const cart = await updateItem(lineItemId, quantity); return { cart }; }, "Failed to update cart item"); } export async function removeCartItem(lineItemId: string) { return actionResult(async () => { - await removeItem(lineItemId); - const cart = await _getCart(); + const cart = await removeItem(lineItemId); return { cart }; }, "Failed to remove cart item"); } diff --git a/src/lib/data/checkout.ts b/src/lib/data/checkout.ts index 137a8c429..43dcb200c 100644 --- a/src/lib/data/checkout.ts +++ b/src/lib/data/checkout.ts @@ -11,8 +11,20 @@ import { updateAddresses, } from "@spree/next"; import type { AddressParams } from "@spree/sdk"; +import { cookies } from "next/headers"; +import { CART_TOKEN_KEY } from "@/lib/constants"; import { actionResult, withFallback } from "./utils"; +/** + * Clear the cart cookie without triggering revalidation. + * Used on the order-placed page to prevent the storefront from showing + * the completed order, without causing a re-render that would lose the order data. + */ +export async function clearCartCookie() { + const cookieStore = await cookies(); + cookieStore.set(CART_TOKEN_KEY, "", { maxAge: -1, path: "/" }); +} + export async function getCheckoutOrder(orderId: string) { return withFallback(() => getCheckout(orderId), null); } @@ -53,8 +65,8 @@ export async function selectShippingRate( shippingRateId: string, ) { return actionResult(async () => { - await _selectShippingRate(orderId, shipmentId, shippingRateId); - return {}; + const order = await _selectShippingRate(orderId, shipmentId, shippingRateId); + return { order }; }, "Failed to select shipping rate"); } diff --git a/src/lib/data/payment.ts b/src/lib/data/payment.ts new file mode 100644 index 000000000..38f81f363 --- /dev/null +++ b/src/lib/data/payment.ts @@ -0,0 +1,37 @@ +"use server"; + +import { + complete, + completePaymentSession, + createPaymentSession, +} from "@spree/next"; +import { actionResult } from "./utils"; + +export async function createCheckoutPaymentSession( + orderId: string, + paymentMethodId: string, +) { + return actionResult(async () => { + const session = await createPaymentSession(orderId, { + payment_method_id: paymentMethodId, + }); + return { session }; + }, "Failed to create payment session"); +} + +export async function completeCheckoutPaymentSession( + orderId: string, + sessionId: string, +) { + return actionResult(async () => { + const session = await completePaymentSession(orderId, sessionId); + return { session }; + }, "Failed to complete payment session"); +} + +export async function completeCheckoutOrder(orderId: string) { + return actionResult(async () => { + const order = await complete(orderId); + return { order }; + }, "Failed to complete order"); +} diff --git a/src/lib/utils/address.ts b/src/lib/utils/address.ts index 5f7daf35b..a0fab2bbb 100644 --- a/src/lib/utils/address.ts +++ b/src/lib/utils/address.ts @@ -28,19 +28,21 @@ export const emptyAddress: AddressFormData = { state_name: "", }; -export function addressToFormData(address?: { - firstname: string | null; - lastname: string | null; - address1: string | null; - address2: string | null; - city: string | null; - zipcode: string | null; - phone: string | null; - company: string | null; - country_iso: string; - state_abbr: string | null; - state_name: string | null; -}): AddressFormData { +export function addressToFormData( + address?: { + firstname: string | null; + lastname: string | null; + address1: string | null; + address2: string | null; + city: string | null; + zipcode: string | null; + phone: string | null; + company: string | null; + country_iso: string; + state_abbr: string | null; + state_name: string | null; + } | null, +): AddressFormData { if (!address) return { ...emptyAddress }; return { firstname: address.firstname || "", @@ -74,7 +76,7 @@ export function formDataToAddress(data: AddressFormData): AddressParams { } export function addressesMatch( - a: AddressFormData | undefined, + a: AddressFormData | undefined | null, b: | { firstname: string | null; @@ -84,7 +86,8 @@ export function addressesMatch( zipcode: string | null; country_iso: string; } - | undefined, + | undefined + | null, ): boolean { if (!a || !b) return false; return ( From c97bd5af1dcea8e0b567238865d6ea6e87bf2915 Mon Sep 17 00:00:00 2001 From: Damian Legawiec Date: Tue, 17 Feb 2026 09:47:50 +0100 Subject: [PATCH 2/6] Add ability to use saved credit cards --- package-lock.json | 16 +- package.json | 4 +- .../(checkout)/checkout/[id]/page.tsx | 1 + src/components/checkout/PaymentStep.tsx | 240 ++++++++++++++---- src/components/checkout/StripePaymentForm.tsx | 29 ++- src/lib/data/payment.ts | 4 + 6 files changed, 236 insertions(+), 58 deletions(-) diff --git a/package-lock.json b/package-lock.json index 17787d238..b545276bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,8 @@ "version": "0.1.0", "dependencies": { "@sentry/nextjs": "^10.38.0", - "@spree/next": "^0.2.2", - "@spree/sdk": "^0.2.2", + "@spree/next": "^0.2.3", + "@spree/sdk": "^0.2.3", "@stripe/react-stripe-js": "^5.6.0", "@stripe/stripe-js": "^8.7.0", "next": "^16", @@ -4713,9 +4713,9 @@ } }, "node_modules/@spree/next": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@spree/next/-/next-0.2.2.tgz", - "integrity": "sha512-SJfxbEy4YY4PpEoqljvd/FD1fyfXhx5Vmux2HV8N37Lu7W8UQkPDBw3FuBBwBMcSOS+JArughyJqj//oq5CSHg==", + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@spree/next/-/next-0.2.3.tgz", + "integrity": "sha512-Df5gezt5ENUjgRrbQc8N1j80QDc0vJ8KxEpAOuDHIRKEKoLWpSE8myTKZQL74V9AHVT8xV4DHfiRTYax54h0/Q==", "license": "MIT", "peerDependencies": { "@spree/sdk": ">=0.2.0", @@ -4724,9 +4724,9 @@ } }, "node_modules/@spree/sdk": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@spree/sdk/-/sdk-0.2.2.tgz", - "integrity": "sha512-xQZ3VYZZb1LegV/hhauo9MS4OSm4JPBB7He0oTPuFv7CA6jzub8pzQI0lhFLMktKMtHLlcCHf7o79T5zAv5e3Q==", + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@spree/sdk/-/sdk-0.2.3.tgz", + "integrity": "sha512-mMCRtkN3ItTsTZZe371DOpbGQi10yACv3EbYQSRFLb3i8p6CYPAEh8qWaTJsFjpgTgT3ejJN1tWY3wiU07ia1Q==", "license": "MIT", "engines": { "node": ">=18.0.0" diff --git a/package.json b/package.json index e571715e0..cf4e8e962 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ }, "dependencies": { "@sentry/nextjs": "^10.38.0", - "@spree/next": "^0.2.2", - "@spree/sdk": "^0.2.2", + "@spree/next": "^0.2.3", + "@spree/sdk": "^0.2.3", "@stripe/react-stripe-js": "^5.6.0", "@stripe/stripe-js": "^8.7.0", "next": "^16", diff --git a/src/app/[country]/[locale]/(checkout)/checkout/[id]/page.tsx b/src/app/[country]/[locale]/(checkout)/checkout/[id]/page.tsx index 912925730..c44279d89 100644 --- a/src/app/[country]/[locale]/(checkout)/checkout/[id]/page.tsx +++ b/src/app/[country]/[locale]/(checkout)/checkout/[id]/page.tsx @@ -571,6 +571,7 @@ export default function CheckoutPage({ params }: CheckoutPageProps) { Promise; onUpdateBillingAddress: (data: { bill_address: AddressParams; @@ -34,9 +38,27 @@ interface PaymentStepProps { setProcessing: (processing: boolean) => void; } +function getCardLabel(ccType: string): string { + switch (ccType.toLowerCase()) { + case "visa": + return "Visa"; + case "mastercard": + case "master": + return "Mastercard"; + case "american_express": + case "amex": + return "Amex"; + case "discover": + return "Discover"; + default: + return ccType || "Card"; + } +} + export function PaymentStep({ order, countries, + isAuthenticated, fetchStates, onUpdateBillingAddress, onPaymentComplete, @@ -58,13 +80,18 @@ export function PaymentStep({ const [billStates, setBillStates] = useState([]); const [isPendingBill, startTransitionBill] = useTransition(); + // Saved cards state + const [savedCards, setSavedCards] = useState([]); + // null = "add new payment method", string = gateway_payment_profile_id of selected card + const [selectedCardId, setSelectedCardId] = useState(null); + // Stripe state const [clientSecret, setClientSecret] = useState(null); const [paymentSessionId, setPaymentSessionId] = useState(null); const [stripeError, setStripeError] = useState(null); - const [loadingSession, setLoadingSession] = useState(false); + const [loading, setLoading] = useState(false); const stripeHandleRef = useRef(null); - const lastOrderTotalRef = useRef(order.total); + const initRef = useRef(false); const handleStripeReady = useCallback((handle: StripePaymentFormHandle) => { stripeHandleRef.current = handle; @@ -75,48 +102,78 @@ export function PaymentStep({ (pm) => pm.session_required, ); - // Reset payment session when order total changes (e.g., coupon applied/removed) - if (order.total !== lastOrderTotalRef.current && clientSecret) { - lastOrderTotalRef.current = order.total; - setClientSecret(null); - setPaymentSessionId(null); - setStripeError(null); - stripeHandleRef.current = null; - } - lastOrderTotalRef.current = order.total; + // Helper: create a payment session + const createSession = useCallback( + async (cardId: string | null) => { + if (!stripePaymentMethod) return; + + setLoading(true); + setStripeError(null); + setClientSecret(null); + setPaymentSessionId(null); + stripeHandleRef.current = null; + + const result = await createCheckoutPaymentSession( + order.id, + stripePaymentMethod.id, + cardId ?? undefined, + ); - // Create payment session when needed (on mount or after total changes) - useEffect(() => { - if (!stripePaymentMethod || clientSecret) return; + setLoading(false); + + if (result.success && result.session) { + const secret = result.session.external_data?.client_secret as + | string + | undefined; + if (secret) { + setClientSecret(secret); + setPaymentSessionId(result.session.id); + } else { + setStripeError("Failed to initialize payment. Please try again."); + } + } else if (!result.success) { + setStripeError(result.error || "Failed to create payment session."); + } + }, + [stripePaymentMethod, order.id], + ); - let cancelled = false; - setLoadingSession(true); - - createCheckoutPaymentSession(order.id, stripePaymentMethod.id).then( - (result) => { - if (cancelled) return; - setLoadingSession(false); - - if (result.success && result.session) { - const secret = result.session.external_data?.client_secret as - | string - | undefined; - if (secret) { - setClientSecret(secret); - setPaymentSessionId(result.session.id); - } else { - setStripeError("Failed to initialize payment. Please try again."); + // On mount: load saved cards (if authenticated), then create initial session — once. + useEffect(() => { + if (initRef.current || !stripePaymentMethod) return; + initRef.current = true; + + const init = async () => { + setLoading(true); + + let initialCardId: string | null = null; + + // Load saved cards for authenticated users + if (isAuthenticated) { + try { + const result = await getCreditCards(); + const stripeCards = result.data.filter( + (card) => card.gateway_payment_profile_id, + ); + setSavedCards(stripeCards); + + if (stripeCards.length > 0) { + const defaultCard = + stripeCards.find((c) => c.default) || stripeCards[0]; + initialCardId = defaultCard.gateway_payment_profile_id; + setSelectedCardId(initialCardId); } - } else if (!result.success) { - setStripeError(result.error || "Failed to create payment session."); + } catch { + // Cards failed to load — proceed without saved cards } - }, - ); + } - return () => { - cancelled = true; + // Create the initial payment session + await createSession(initialCardId); }; - }, [stripePaymentMethod, order.id, clientSecret]); + + init(); + }, [stripePaymentMethod, isAuthenticated, createSession]); // Load states when billing country changes useEffect(() => { @@ -149,10 +206,17 @@ export function PaymentStep({ } }; + const handleCardSelect = (cardId: string | null) => { + if (cardId === selectedCardId) return; + setSelectedCardId(cardId); + createSession(cardId); + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (!stripeHandleRef.current || !paymentSessionId) return; + if (!paymentSessionId || !clientSecret) return; + if (!selectedCardId && !stripeHandleRef.current) return; setProcessing(true); setStripeError(null); @@ -173,8 +237,23 @@ export function PaymentStep({ // 2. Confirm payment with Stripe const returnUrl = `${window.location.origin}${window.location.pathname.replace(/\/checkout\/.*/, `/order-placed/${order.id}`)}`; - const { error } = - await stripeHandleRef.current!.confirmPayment(returnUrl); + + let error: string | undefined; + + if (selectedCardId) { + // Confirm with saved payment method — no Elements needed + const result = await confirmWithSavedCard( + clientSecret, + selectedCardId, + returnUrl, + ); + error = result.error; + } else { + // Confirm with new card via PaymentElement + const result = + await stripeHandleRef.current!.confirmPayment(returnUrl); + error = result.error; + } if (error) { setStripeError(error); @@ -201,6 +280,8 @@ export function PaymentStep({ }); }; + const isAddingNew = selectedCardId === null; + return (
{/* Shipping Address Summary */} @@ -274,7 +355,74 @@ export function PaymentStep({ Payment Method - {loadingSession && ( + {/* Saved Cards Selector */} + {savedCards.length > 0 && ( +
+ {savedCards.map((card) => ( + + ))} + + {/* Add new payment method option */} + +
+ )} + + {loading && (
@@ -283,13 +431,13 @@ export function PaymentStep({
)} - {stripeError && !loadingSession && ( + {stripeError && !loading && (
{stripeError}
)} - {clientSecret && !loadingSession && ( + {clientSecret && !loading && isAddingNew && ( )} - {!stripePaymentMethod && !loadingSession && ( + {!stripePaymentMethod && !loading && (
{ - if (stripe && elements) { + if (stripe) { onReady({ confirmPayment }); } - }, [stripe, elements, confirmPayment, onReady]); + }, [stripe, confirmPayment, onReady]); return (
@@ -99,3 +99,28 @@ export function StripePaymentForm({ ); } + +/** + * Confirm payment with a saved card (no Elements/PaymentElement needed). + */ +export async function confirmWithSavedCard( + clientSecret: string, + paymentMethodId: string, + returnUrl: string, +): Promise<{ error?: string }> { + const stripe = await stripePromise; + if (!stripe) { + return { error: "Stripe has not loaded yet" }; + } + + const result = await stripe.confirmCardPayment(clientSecret, { + payment_method: paymentMethodId, + return_url: returnUrl, + }); + + if (result.error) { + return { error: result.error.message || "An error occurred during payment." }; + } + + return {}; +} diff --git a/src/lib/data/payment.ts b/src/lib/data/payment.ts index 38f81f363..4b0a63fe7 100644 --- a/src/lib/data/payment.ts +++ b/src/lib/data/payment.ts @@ -10,10 +10,14 @@ import { actionResult } from "./utils"; export async function createCheckoutPaymentSession( orderId: string, paymentMethodId: string, + stripePaymentMethodId?: string, ) { return actionResult(async () => { const session = await createPaymentSession(orderId, { payment_method_id: paymentMethodId, + ...(stripePaymentMethodId && { + external_data: { stripe_payment_method_id: stripePaymentMethodId }, + }), }); return { session }; }, "Failed to create payment session"); From b5e048d2dc01eb12f90195f1b846033163acccf2 Mon Sep 17 00:00:00 2001 From: Damian Legawiec Date: Tue, 17 Feb 2026 10:28:10 +0100 Subject: [PATCH 3/6] Added nicer Credit Card icons --- package-lock.json | 13 ++++- package.json | 3 +- .../account/credit-cards/page.tsx | 52 +++++-------------- src/components/checkout/PaymentStep.tsx | 29 +++-------- src/lib/utils/credit-card.ts | 43 +++++++++++++++ 5 files changed, 76 insertions(+), 64 deletions(-) create mode 100644 src/lib/utils/credit-card.ts diff --git a/package-lock.json b/package-lock.json index b545276bd..ba6670c4f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,8 @@ "@stripe/stripe-js": "^8.7.0", "next": "^16", "react": "^19", - "react-dom": "^19" + "react-dom": "^19", + "react-svg-credit-card-payment-icons": "^5.1.2" }, "devDependencies": { "@biomejs/biome": "^2.3.14", @@ -8473,6 +8474,16 @@ "node": ">=0.10.0" } }, + "node_modules/react-svg-credit-card-payment-icons": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/react-svg-credit-card-payment-icons/-/react-svg-credit-card-payment-icons-5.1.2.tgz", + "integrity": "sha512-KnCrg0U5v6SPqDw1DJ0zKCrxg1fuSaaWNn1Ls08x0fWCeqaRVctAOrKNUFE6RVaEgsvg81GOJb0bYHQaxhozNQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", diff --git a/package.json b/package.json index cf4e8e962..81200aed9 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "@stripe/stripe-js": "^8.7.0", "next": "^16", "react": "^19", - "react-dom": "^19" + "react-dom": "^19", + "react-svg-credit-card-payment-icons": "^5.1.2" }, "devDependencies": { "@biomejs/biome": "^2.3.14", diff --git a/src/app/[country]/[locale]/(storefront)/account/credit-cards/page.tsx b/src/app/[country]/[locale]/(storefront)/account/credit-cards/page.tsx index 4c815454d..44ebad8b1 100644 --- a/src/app/[country]/[locale]/(storefront)/account/credit-cards/page.tsx +++ b/src/app/[country]/[locale]/(storefront)/account/credit-cards/page.tsx @@ -1,42 +1,17 @@ "use client"; +import type { StoreCreditCard } from "@spree/sdk"; +import { PaymentIcon } from "react-svg-credit-card-payment-icons"; import { useEffect, useState } from "react"; import { CreditCardIcon, LockIcon } from "@/components/icons"; import { deleteCreditCard, getCreditCards } from "@/lib/data/credit-cards"; - -// Credit card type (matches SDK StoreCreditCard) -interface CreditCard { - id: string; - cc_type: string; - last_digits: string; - month: number; - year: number; - name: string | null; - default: boolean; -} - -function getCardIcon(ccType: string): string { - switch (ccType.toLowerCase()) { - case "visa": - return "💳 Visa"; - case "mastercard": - case "master": - return "💳 Mastercard"; - case "american_express": - case "amex": - return "💳 Amex"; - case "discover": - return "💳 Discover"; - default: - return "💳 " + ccType; - } -} +import { getCardIconType, getCardLabel } from "@/lib/utils/credit-card"; function CreditCardItem({ card, onDelete, }: { - card: CreditCard; + card: StoreCreditCard; onDelete: () => void; }) { const [deleting, setDeleting] = useState(false); @@ -52,20 +27,17 @@ function CreditCardItem({
-
- - {card.cc_type?.slice(0, 4).toUpperCase() || "CARD"} - -
+

- {getCardIcon(card.cc_type || "Card")} -

-

- •••• •••• •••• {card.last_digits} + {getCardLabel(card.cc_type)} ending in {card.last_digits}

- Expires {card.month}/{card.year} + Expires {String(card.month).padStart(2, "0")}/{card.year}

{card.name && (

{card.name}

@@ -92,7 +64,7 @@ function CreditCardItem({ } export default function CreditCardsPage() { - const [cards, setCards] = useState([]); + const [cards, setCards] = useState([]); const [loading, setLoading] = useState(true); const loadCards = async () => { diff --git a/src/components/checkout/PaymentStep.tsx b/src/components/checkout/PaymentStep.tsx index a8b1360cd..f7a2b9095 100644 --- a/src/components/checkout/PaymentStep.tsx +++ b/src/components/checkout/PaymentStep.tsx @@ -7,6 +7,7 @@ import type { StoreOrder, StoreState, } from "@spree/sdk"; +import { PaymentIcon } from "react-svg-credit-card-payment-icons"; import { useCallback, useEffect, useRef, useState, useTransition } from "react"; import { CreditCardIcon } from "@/components/icons"; import { getCreditCards } from "@/lib/data/credit-cards"; @@ -17,6 +18,7 @@ import { addressToFormData, formDataToAddress, } from "@/lib/utils/address"; +import { getCardIconType, getCardLabel } from "@/lib/utils/credit-card"; import { AddressFormFields } from "./AddressFormFields"; import { StripePaymentForm, @@ -38,23 +40,6 @@ interface PaymentStepProps { setProcessing: (processing: boolean) => void; } -function getCardLabel(ccType: string): string { - switch (ccType.toLowerCase()) { - case "visa": - return "Visa"; - case "mastercard": - case "master": - return "Mastercard"; - case "american_express": - case "amex": - return "Amex"; - case "discover": - return "Discover"; - default: - return ccType || "Card"; - } -} - export function PaymentStep({ order, countries, @@ -378,11 +363,11 @@ export function PaymentStep({ } className="w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-500" /> -
- - {card.cc_type?.slice(0, 4).toUpperCase() || "CARD"} - -
+
{getCardLabel(card.cc_type)} ending in {card.last_digits} diff --git a/src/lib/utils/credit-card.ts b/src/lib/utils/credit-card.ts new file mode 100644 index 000000000..89cd8e381 --- /dev/null +++ b/src/lib/utils/credit-card.ts @@ -0,0 +1,43 @@ +import type { PaymentType } from "react-svg-credit-card-payment-icons"; + +const CC_TYPE_MAP: Record = { + visa: "Visa", + mastercard: "Mastercard", + master: "Mastercard", + american_express: "AmericanExpress", + amex: "AmericanExpress", + discover: "Discover", + jcb: "JCB", + diners_club: "DinersClub", + maestro: "Maestro", + unionpay: "UnionPay", +}; + +export function getCardIconType(ccType: string): PaymentType { + return CC_TYPE_MAP[ccType.toLowerCase()] ?? "Generic"; +} + +export function getCardLabel(ccType: string): string { + switch (ccType.toLowerCase()) { + case "visa": + return "Visa"; + case "mastercard": + case "master": + return "Mastercard"; + case "american_express": + case "amex": + return "Amex"; + case "discover": + return "Discover"; + case "jcb": + return "JCB"; + case "diners_club": + return "Diners Club"; + case "maestro": + return "Maestro"; + case "unionpay": + return "UnionPay"; + default: + return ccType || "Card"; + } +} From cd0ca9df47e4ad83dd51b798ac18f6a4f90713f8 Mon Sep 17 00:00:00 2001 From: Damian Legawiec Date: Tue, 17 Feb 2026 12:01:08 +0100 Subject: [PATCH 4/6] Fixed Biome warnings and tests --- biome.json | 2 +- .../(checkout)/checkout/[id]/page.tsx | 30 ++++--- .../(checkout)/order-placed/[id]/page.tsx | 5 +- .../account/credit-cards/page.tsx | 15 ++-- src/components/checkout/PaymentStep.tsx | 79 +++++++++---------- src/components/checkout/StripePaymentForm.tsx | 4 +- src/lib/data/__tests__/cart.test.ts | 22 +----- src/lib/data/checkout.ts | 6 +- 8 files changed, 85 insertions(+), 78 deletions(-) diff --git a/biome.json b/biome.json index dab68b558..bad2ea938 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.3.14/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.1/schema.json", "vcs": { "enabled": true, "clientKind": "git", diff --git a/src/app/[country]/[locale]/(checkout)/checkout/[id]/page.tsx b/src/app/[country]/[locale]/(checkout)/checkout/[id]/page.tsx index c44279d89..05013a23d 100644 --- a/src/app/[country]/[locale]/(checkout)/checkout/[id]/page.tsx +++ b/src/app/[country]/[locale]/(checkout)/checkout/[id]/page.tsx @@ -315,7 +315,7 @@ export default function CheckoutPage({ params }: CheckoutPageProps) { } }; - // Handle billing address update (called by PaymentStep before Stripe confirmation) + // Handle billing address update (called by PaymentStep before gateway confirmation) const handleUpdateBillingAddress = async (data: { bill_address: AddressParams; }): Promise => { @@ -323,16 +323,21 @@ export default function CheckoutPage({ params }: CheckoutPageProps) { setError(null); - const updateResult = await updateOrderAddresses(order.id, { - bill_address: data.bill_address, - }); + try { + const updateResult = await updateOrderAddresses(order.id, { + bill_address: data.bill_address, + }); - if (!updateResult.success) { - setError(updateResult.error || "Failed to save billing address"); + if (!updateResult.success) { + setError(updateResult.error || "Failed to save billing address"); + return false; + } + + return true; + } catch { + setError("Failed to save billing address. Please try again."); return false; } - - return true; }; // Handle payment completion (called by PaymentStep after Stripe confirms) @@ -357,7 +362,14 @@ export default function CheckoutPage({ params }: CheckoutPageProps) { // Check if the order was already completed by the payment session completion. // If not, explicitly complete it. const updatedOrder = await getCheckoutOrder(order.id); - if (updatedOrder && updatedOrder.state !== "complete") { + + if (!updatedOrder) { + setError("Order not found after payment. Please contact support."); + setProcessing(false); + return; + } + + if (updatedOrder.state !== "complete") { const completeResult = await completeCheckoutOrder(order.id); if (!completeResult.success) { setError(completeResult.error || "Failed to complete order"); diff --git a/src/app/[country]/[locale]/(checkout)/order-placed/[id]/page.tsx b/src/app/[country]/[locale]/(checkout)/order-placed/[id]/page.tsx index cc3e5e93c..7374b4179 100644 --- a/src/app/[country]/[locale]/(checkout)/order-placed/[id]/page.tsx +++ b/src/app/[country]/[locale]/(checkout)/order-placed/[id]/page.tsx @@ -1,6 +1,7 @@ "use client"; import type { StoreOrder } from "@spree/sdk"; +import Image from "next/image"; import Link from "next/link"; import { usePathname, useSearchParams } from "next/navigation"; import { use, useEffect, useRef, useState } from "react"; @@ -139,9 +140,11 @@ export default function OrderPlacedPage({ params }: OrderPlacedPageProps) {
  • {item.thumbnail_url ? ( - {item.name} ) : ( diff --git a/src/app/[country]/[locale]/(storefront)/account/credit-cards/page.tsx b/src/app/[country]/[locale]/(storefront)/account/credit-cards/page.tsx index 44ebad8b1..7abfb55c5 100644 --- a/src/app/[country]/[locale]/(storefront)/account/credit-cards/page.tsx +++ b/src/app/[country]/[locale]/(storefront)/account/credit-cards/page.tsx @@ -1,8 +1,8 @@ "use client"; import type { StoreCreditCard } from "@spree/sdk"; +import { useCallback, useEffect, useState } from "react"; import { PaymentIcon } from "react-svg-credit-card-payment-icons"; -import { useEffect, useState } from "react"; import { CreditCardIcon, LockIcon } from "@/components/icons"; import { deleteCreditCard, getCreditCards } from "@/lib/data/credit-cards"; import { getCardIconType, getCardLabel } from "@/lib/utils/credit-card"; @@ -19,8 +19,11 @@ function CreditCardItem({ const handleDelete = async () => { if (!confirm("Are you sure you want to remove this card?")) return; setDeleting(true); - await onDelete(); - setDeleting(false); + try { + await onDelete(); + } finally { + setDeleting(false); + } }; return ( @@ -67,10 +70,10 @@ export default function CreditCardsPage() { const [cards, setCards] = useState([]); const [loading, setLoading] = useState(true); - const loadCards = async () => { + const loadCards = useCallback(async () => { const response = await getCreditCards(); setCards(response.data); - }; + }, []); useEffect(() => { async function loadData() { @@ -78,7 +81,7 @@ export default function CreditCardsPage() { setLoading(false); } loadData(); - }, []); + }, [loadCards]); const handleDelete = async (id: string) => { const result = await deleteCreditCard(id); diff --git a/src/components/checkout/PaymentStep.tsx b/src/components/checkout/PaymentStep.tsx index f7a2b9095..a67c658e8 100644 --- a/src/components/checkout/PaymentStep.tsx +++ b/src/components/checkout/PaymentStep.tsx @@ -7,8 +7,8 @@ import type { StoreOrder, StoreState, } from "@spree/sdk"; -import { PaymentIcon } from "react-svg-credit-card-payment-icons"; import { useCallback, useEffect, useRef, useState, useTransition } from "react"; +import { PaymentIcon } from "react-svg-credit-card-payment-icons"; import { CreditCardIcon } from "@/components/icons"; import { getCreditCards } from "@/lib/data/credit-cards"; import { createCheckoutPaymentSession } from "@/lib/data/payment"; @@ -21,9 +21,9 @@ import { import { getCardIconType, getCardLabel } from "@/lib/utils/credit-card"; import { AddressFormFields } from "./AddressFormFields"; import { + confirmWithSavedCard, StripePaymentForm, type StripePaymentFormHandle, - confirmWithSavedCard, } from "./StripePaymentForm"; interface PaymentStepProps { @@ -70,37 +70,37 @@ export function PaymentStep({ // null = "add new payment method", string = gateway_payment_profile_id of selected card const [selectedCardId, setSelectedCardId] = useState(null); - // Stripe state + // Payment gateway state const [clientSecret, setClientSecret] = useState(null); const [paymentSessionId, setPaymentSessionId] = useState(null); - const [stripeError, setStripeError] = useState(null); + const [gatewayError, setGatewayError] = useState(null); const [loading, setLoading] = useState(false); - const stripeHandleRef = useRef(null); + const gatewayHandleRef = useRef(null); const initRef = useRef(false); - const handleStripeReady = useCallback((handle: StripePaymentFormHandle) => { - stripeHandleRef.current = handle; + const handleGatewayReady = useCallback((handle: StripePaymentFormHandle) => { + gatewayHandleRef.current = handle; }, []); - // Find Stripe payment method - const stripePaymentMethod = order.payment_methods?.find( + // Find the payment method that requires a session (e.g. Stripe, Adyen) + const sessionPaymentMethod = order.payment_methods?.find( (pm) => pm.session_required, ); // Helper: create a payment session const createSession = useCallback( async (cardId: string | null) => { - if (!stripePaymentMethod) return; + if (!sessionPaymentMethod) return; setLoading(true); - setStripeError(null); + setGatewayError(null); setClientSecret(null); setPaymentSessionId(null); - stripeHandleRef.current = null; + gatewayHandleRef.current = null; const result = await createCheckoutPaymentSession( order.id, - stripePaymentMethod.id, + sessionPaymentMethod.id, cardId ?? undefined, ); @@ -114,18 +114,18 @@ export function PaymentStep({ setClientSecret(secret); setPaymentSessionId(result.session.id); } else { - setStripeError("Failed to initialize payment. Please try again."); + setGatewayError("Failed to initialize payment. Please try again."); } } else if (!result.success) { - setStripeError(result.error || "Failed to create payment session."); + setGatewayError(result.error || "Failed to create payment session."); } }, - [stripePaymentMethod, order.id], + [sessionPaymentMethod, order.id], ); // On mount: load saved cards (if authenticated), then create initial session — once. useEffect(() => { - if (initRef.current || !stripePaymentMethod) return; + if (initRef.current || !sessionPaymentMethod) return; initRef.current = true; const init = async () => { @@ -137,14 +137,14 @@ export function PaymentStep({ if (isAuthenticated) { try { const result = await getCreditCards(); - const stripeCards = result.data.filter( + const gatewayCards = result.data.filter( (card) => card.gateway_payment_profile_id, ); - setSavedCards(stripeCards); + setSavedCards(gatewayCards); - if (stripeCards.length > 0) { + if (gatewayCards.length > 0) { const defaultCard = - stripeCards.find((c) => c.default) || stripeCards[0]; + gatewayCards.find((c) => c.default) || gatewayCards[0]; initialCardId = defaultCard.gateway_payment_profile_id; setSelectedCardId(initialCardId); } @@ -158,7 +158,7 @@ export function PaymentStep({ }; init(); - }, [stripePaymentMethod, isAuthenticated, createSession]); + }, [sessionPaymentMethod, isAuthenticated, createSession]); // Load states when billing country changes useEffect(() => { @@ -201,10 +201,10 @@ export function PaymentStep({ e.preventDefault(); if (!paymentSessionId || !clientSecret) return; - if (!selectedCardId && !stripeHandleRef.current) return; + if (!selectedCardId && !gatewayHandleRef.current) return; setProcessing(true); - setStripeError(null); + setGatewayError(null); try { // 1. Update billing address @@ -220,7 +220,7 @@ export function PaymentStep({ return; } - // 2. Confirm payment with Stripe + // 2. Confirm payment with gateway const returnUrl = `${window.location.origin}${window.location.pathname.replace(/\/checkout\/.*/, `/order-placed/${order.id}`)}`; let error: string | undefined; @@ -234,14 +234,14 @@ export function PaymentStep({ ); error = result.error; } else { - // Confirm with new card via PaymentElement + // Confirm with new card via payment form const result = - await stripeHandleRef.current!.confirmPayment(returnUrl); + await gatewayHandleRef.current!.confirmPayment(returnUrl); error = result.error; } if (error) { - setStripeError(error); + setGatewayError(error); setProcessing(false); return; } @@ -249,7 +249,7 @@ export function PaymentStep({ // 3. Payment succeeded — complete session and order await onPaymentComplete(paymentSessionId); } catch { - setStripeError("An error occurred during payment. Please try again."); + setGatewayError("An error occurred during payment. Please try again."); setProcessing(false); } }; @@ -355,9 +355,7 @@ export function PaymentStep({ handleCardSelect(card.gateway_payment_profile_id) } @@ -399,7 +397,10 @@ export function PaymentStep({ onChange={() => handleCardSelect(null)} className="w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-500" /> - + Add new payment method @@ -416,9 +417,9 @@ export function PaymentStep({
    )} - {stripeError && !loading && ( + {gatewayError && !loading && (
    - {stripeError} + {gatewayError}
    )} @@ -426,11 +427,11 @@ export function PaymentStep({ )} - {!stripePaymentMethod && !loading && ( + {!sessionPaymentMethod && !loading && (
    diff --git a/src/components/checkout/StripePaymentForm.tsx b/src/components/checkout/StripePaymentForm.tsx index 8b1b48c5c..ec4f3f0a7 100644 --- a/src/components/checkout/StripePaymentForm.tsx +++ b/src/components/checkout/StripePaymentForm.tsx @@ -119,7 +119,9 @@ export async function confirmWithSavedCard( }); if (result.error) { - return { error: result.error.message || "An error occurred during payment." }; + return { + error: result.error.message || "An error occurred during payment.", + }; } return {}; diff --git a/src/lib/data/__tests__/cart.test.ts b/src/lib/data/__tests__/cart.test.ts index 81dc0960e..e8aa4118c 100644 --- a/src/lib/data/__tests__/cart.test.ts +++ b/src/lib/data/__tests__/cart.test.ts @@ -74,13 +74,11 @@ describe("cart server actions", () => { describe("addToCart", () => { it("returns success with cart", async () => { - mockAddItem.mockResolvedValue({}); - mockGetCart.mockResolvedValue(mockCart); + mockAddItem.mockResolvedValue(mockCart); const result = await addToCart("variant-1", 2); expect(mockAddItem).toHaveBeenCalledWith("variant-1", 2); - expect(mockGetCart).toHaveBeenCalledOnce(); expect(result).toEqual({ success: true, cart: mockCart }); }); @@ -105,24 +103,11 @@ describe("cart server actions", () => { error: "Failed to add item to cart", }); }); - - it("returns error when getCart fails after successful add", async () => { - mockAddItem.mockResolvedValue({}); - mockGetCart.mockRejectedValue(new Error("Network error")); - - const result = await addToCart("variant-1", 1); - - expect(result).toEqual({ - success: false, - error: "Network error", - }); - }); }); describe("updateCartItem", () => { it("returns success with refreshed cart", async () => { - mockUpdateItem.mockResolvedValue({}); - mockGetCart.mockResolvedValue(mockCart); + mockUpdateItem.mockResolvedValue(mockCart); const result = await updateCartItem("li-1", 3); @@ -144,8 +129,7 @@ describe("cart server actions", () => { describe("removeCartItem", () => { it("returns success with refreshed cart", async () => { - mockRemoveItem.mockResolvedValue(undefined); - mockGetCart.mockResolvedValue(mockCart); + mockRemoveItem.mockResolvedValue(mockCart); const result = await removeCartItem("li-1"); diff --git a/src/lib/data/checkout.ts b/src/lib/data/checkout.ts index 43dcb200c..6c7c7b6e3 100644 --- a/src/lib/data/checkout.ts +++ b/src/lib/data/checkout.ts @@ -65,7 +65,11 @@ export async function selectShippingRate( shippingRateId: string, ) { return actionResult(async () => { - const order = await _selectShippingRate(orderId, shipmentId, shippingRateId); + const order = await _selectShippingRate( + orderId, + shipmentId, + shippingRateId, + ); return { order }; }, "Failed to select shipping rate"); } From fab8ef830b08e233dfa190e1d1e86aff99855109 Mon Sep 17 00:00:00 2001 From: Damian Legawiec Date: Tue, 17 Feb 2026 12:46:38 +0100 Subject: [PATCH 5/6] Apply some branding --- .../(checkout)/checkout/[id]/page.tsx | 14 ++++---- .../(checkout)/order-placed/[id]/page.tsx | 10 +++--- .../(storefront)/FeaturedProducts.tsx | 2 +- .../(storefront)/account/addresses/page.tsx | 12 +++---- .../account/credit-cards/page.tsx | 8 ++--- .../(storefront)/account/gift-cards/page.tsx | 10 +++--- .../[locale]/(storefront)/account/layout.tsx | 8 ++--- .../(storefront)/account/orders/[id]/page.tsx | 12 +++---- .../(storefront)/account/orders/page.tsx | 10 +++--- .../[locale]/(storefront)/account/page.tsx | 36 +++++++++---------- .../(storefront)/account/profile/page.tsx | 16 ++++----- .../(storefront)/account/register/page.tsx | 14 ++++---- .../[locale]/(storefront)/cart/page.tsx | 12 +++---- .../[country]/[locale]/(storefront)/page.tsx | 16 ++++----- .../products/[slug]/ProductDetails.tsx | 6 ++-- .../products/[slug]/ProductDetailsWrapper.tsx | 2 +- .../[locale]/(storefront)/taxonomies/page.tsx | 4 +-- src/app/globals.css | 22 +++++++++--- src/app/layout.tsx | 13 +++---- src/components/cart/CartDrawer.tsx | 12 +++---- src/components/checkout/AddressEditModal.tsx | 8 ++--- src/components/checkout/AddressFormFields.tsx | 22 ++++++------ src/components/checkout/AddressSelector.tsx | 14 ++++---- src/components/checkout/AddressStep.tsx | 10 +++--- src/components/checkout/CouponCode.tsx | 6 ++-- src/components/checkout/DeliveryStep.tsx | 16 ++++----- src/components/checkout/OrderSummary.tsx | 4 +-- src/components/checkout/PaymentStep.tsx | 34 +++++++++--------- src/components/checkout/StripePaymentForm.tsx | 2 +- src/components/layout/CountrySwitcher.tsx | 6 ++-- src/components/layout/Header.tsx | 2 +- src/components/navigation/Breadcrumbs.tsx | 2 +- src/components/products/MediaGallery.tsx | 8 ++--- src/components/products/ProductCard.tsx | 4 +-- src/components/products/ProductFilters.tsx | 6 ++-- .../products/ProductListingLayout.tsx | 2 +- src/components/products/VariantPicker.tsx | 6 ++-- src/components/search/SearchBar.tsx | 6 ++-- 38 files changed, 203 insertions(+), 194 deletions(-) diff --git a/src/app/[country]/[locale]/(checkout)/checkout/[id]/page.tsx b/src/app/[country]/[locale]/(checkout)/checkout/[id]/page.tsx index 05013a23d..badd437f9 100644 --- a/src/app/[country]/[locale]/(checkout)/checkout/[id]/page.tsx +++ b/src/app/[country]/[locale]/(checkout)/checkout/[id]/page.tsx @@ -449,7 +449,7 @@ export default function CheckoutPage({ params }: CheckoutPageProps) {

    {error}

    Return to Cart @@ -471,7 +471,7 @@ export default function CheckoutPage({ params }: CheckoutPageProps) {

    Continue Shopping @@ -508,9 +508,9 @@ export default function CheckoutPage({ params }: CheckoutPageProps) {
    @@ -519,7 +519,7 @@ export default function CheckoutPage({ params }: CheckoutPageProps) {
    +
    {error}
    )} diff --git a/src/app/[country]/[locale]/(checkout)/order-placed/[id]/page.tsx b/src/app/[country]/[locale]/(checkout)/order-placed/[id]/page.tsx index 7374b4179..3702854e1 100644 --- a/src/app/[country]/[locale]/(checkout)/order-placed/[id]/page.tsx +++ b/src/app/[country]/[locale]/(checkout)/order-placed/[id]/page.tsx @@ -104,7 +104,7 @@ export default function OrderPlacedPage({ params }: OrderPlacedPageProps) { Continue Shopping @@ -131,14 +131,14 @@ export default function OrderPlacedPage({ params }: OrderPlacedPageProps) {
    {/* Order Items */} -
    +

    Order Items

      {order.line_items?.map((item) => (
    • -
      +
      {item.thumbnail_url ? ( {/* Contact & Addresses */} -
      +

      Contact Information @@ -273,7 +273,7 @@ export default function OrderPlacedPage({ params }: OrderPlacedPageProps) {
      Continue Shopping diff --git a/src/app/[country]/[locale]/(storefront)/FeaturedProducts.tsx b/src/app/[country]/[locale]/(storefront)/FeaturedProducts.tsx index 83274180a..c3206df8c 100644 --- a/src/app/[country]/[locale]/(storefront)/FeaturedProducts.tsx +++ b/src/app/[country]/[locale]/(storefront)/FeaturedProducts.tsx @@ -52,7 +52,7 @@ export function FeaturedProducts({ basePath }: FeaturedProductsProps) {
      {[...Array(8)].map((_, i) => (
      -
      +
      diff --git a/src/app/[country]/[locale]/(storefront)/account/addresses/page.tsx b/src/app/[country]/[locale]/(storefront)/account/addresses/page.tsx index b37afca6e..22a880721 100644 --- a/src/app/[country]/[locale]/(storefront)/account/addresses/page.tsx +++ b/src/app/[country]/[locale]/(storefront)/account/addresses/page.tsx @@ -31,7 +31,7 @@ function AddressCard({ }; return ( -
      +

      {address.full_name}

      @@ -53,7 +53,7 @@ function AddressCard({
      @@ -147,7 +147,7 @@ export default function AddressesPage() { {[1, 2].map((i) => (
      @@ -164,7 +164,7 @@ export default function AddressesPage() {

      Addresses

      {addresses.length === 0 ? ( -
      +

      No addresses saved @@ -182,7 +182,7 @@ export default function AddressesPage() {

      diff --git a/src/app/[country]/[locale]/(storefront)/account/credit-cards/page.tsx b/src/app/[country]/[locale]/(storefront)/account/credit-cards/page.tsx index 7abfb55c5..6945701d8 100644 --- a/src/app/[country]/[locale]/(storefront)/account/credit-cards/page.tsx +++ b/src/app/[country]/[locale]/(storefront)/account/credit-cards/page.tsx @@ -27,7 +27,7 @@ function CreditCardItem({ }; return ( -
      +
      (
      @@ -116,7 +116,7 @@ export default function CreditCardsPage() {

      Payment Methods

      {cards.length === 0 ? ( -
      +

      No payment methods saved @@ -137,7 +137,7 @@ export default function CreditCardsPage() {

      )} -
      +

      Your payment information is securely stored and encrypted. We never diff --git a/src/app/[country]/[locale]/(storefront)/account/gift-cards/page.tsx b/src/app/[country]/[locale]/(storefront)/account/gift-cards/page.tsx index 0f33a5bc9..f632106e1 100644 --- a/src/app/[country]/[locale]/(storefront)/account/gift-cards/page.tsx +++ b/src/app/[country]/[locale]/(storefront)/account/gift-cards/page.tsx @@ -120,7 +120,7 @@ function GiftCardItem({ card }: { card: GiftCard }) { card.amount > 0 ? Math.round((card.amount_used / card.amount) * 100) : 0; return ( -

      +
      @@ -159,7 +159,7 @@ function GiftCardItem({ card }: { card: GiftCard }) {
      = 100 ? "bg-gray-400" : "bg-indigo-600" + usagePercentage >= 100 ? "bg-gray-400" : "bg-primary-600" }`} style={{ width: `${Math.min(usagePercentage, 100)}%` }} /> @@ -201,7 +201,7 @@ export default function GiftCardsPage() { {[1, 2].map((i) => (
      @@ -222,7 +222,7 @@ export default function GiftCardsPage() {

      Gift Cards

      {cards.length === 0 ? ( -
      +

      No gift cards @@ -263,7 +263,7 @@ export default function GiftCardsPage() { )} -
      +

      Gift cards can be used during checkout to pay for your orders. The diff --git a/src/app/[country]/[locale]/(storefront)/account/layout.tsx b/src/app/[country]/[locale]/(storefront)/account/layout.tsx index 6d5da7996..1d3413279 100644 --- a/src/app/[country]/[locale]/(storefront)/account/layout.tsx +++ b/src/app/[country]/[locale]/(storefront)/account/layout.tsx @@ -70,7 +70,7 @@ function AccountShell({

      {/* Sidebar Navigation */}