diff --git a/messages/de.json b/messages/de.json index 808c0884..42646baf 100644 --- a/messages/de.json +++ b/messages/de.json @@ -230,6 +230,16 @@ "failedToCompletePaymentSession": "Zahlungssitzung konnte nicht abgeschlossen werden", "noOrder": "Keine Bestellung", "confirmingPayment": "Ihre Zahlung wird bestätigt...", + "selectPaymentMethod": "Bitte wählen Sie eine Zahlungsmethode.", + "manualPaymentInfo": "Sie erhalten Zahlungsanweisungen nach der Bestellung.", + "failedToCreatePayment": "Zahlung konnte nicht erstellt werden. Bitte versuchen Sie es erneut.", + "placeOrder": "Bestellung aufgeben", + "noPaymentRequired": "Keine Zahlung erforderlich — Ihre Bestellung ist vollständig abgedeckt.", + "unsupportedGateway": "Diese Zahlungsmethode wird noch nicht unterstützt.", + "paypalApproveFirst": "Bitte genehmigen Sie die PayPal-Zahlung, bevor Sie fortfahren.", + "paypalApproved": "PayPal-Zahlung genehmigt. Ihre Bestellung wird abgeschlossen...", + "paypalCancelled": "Die PayPal-Zahlung wurde abgebrochen.", + "paypalError": "Bei PayPal ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.", "policyConsentRequired": "Sie müssen den Geschäftsrichtlinien zustimmen, bevor Sie Ihre Bestellung aufgeben", "iAgreeToThe": "Ich stimme zu", "policySeparatorComma": ", ", diff --git a/messages/en.json b/messages/en.json index dbfb1030..c2be4715 100644 --- a/messages/en.json +++ b/messages/en.json @@ -230,6 +230,16 @@ "failedToCompletePaymentSession": "Failed to complete payment session", "noOrder": "No order", "confirmingPayment": "Confirming your payment...", + "selectPaymentMethod": "Please select a payment method.", + "manualPaymentInfo": "You will receive payment instructions after placing your order.", + "failedToCreatePayment": "Failed to create payment. Please try again.", + "placeOrder": "Place Order", + "noPaymentRequired": "No payment required — your order is fully covered.", + "unsupportedGateway": "This payment method is not yet supported in the storefront.", + "paypalApproveFirst": "Please approve the PayPal payment before continuing.", + "paypalApproved": "PayPal payment approved. Completing your order...", + "paypalCancelled": "PayPal payment was cancelled.", + "paypalError": "An error occurred with PayPal. Please try again.", "policyConsentRequired": "You must agree to the store policies before placing your order", "iAgreeToThe": "I agree to the", "policySeparatorComma": ", ", diff --git a/messages/es.json b/messages/es.json index 24ec524d..2ab5f75f 100644 --- a/messages/es.json +++ b/messages/es.json @@ -230,6 +230,16 @@ "failedToCompletePaymentSession": "Error al completar la sesion de pago", "noOrder": "Sin pedido", "confirmingPayment": "Confirmando tu pago...", + "selectPaymentMethod": "Por favor selecciona un método de pago.", + "manualPaymentInfo": "Recibirás instrucciones de pago después de realizar tu pedido.", + "failedToCreatePayment": "No se pudo crear el pago. Inténtalo de nuevo.", + "placeOrder": "Realizar pedido", + "noPaymentRequired": "No se requiere pago — tu pedido está completamente cubierto.", + "unsupportedGateway": "Este método de pago aún no es compatible.", + "paypalApproveFirst": "Por favor aprueba el pago de PayPal antes de continuar.", + "paypalApproved": "Pago de PayPal aprobado. Completando tu pedido...", + "paypalCancelled": "El pago de PayPal fue cancelado.", + "paypalError": "Ocurrió un error con PayPal. Inténtalo de nuevo.", "policyConsentRequired": "Debes aceptar las politicas de la tienda antes de realizar tu pedido", "iAgreeToThe": "Acepto las", "policySeparatorComma": ", ", diff --git a/messages/fr.json b/messages/fr.json index b5c87460..a07f1713 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -230,6 +230,16 @@ "failedToCompletePaymentSession": "Echec de la finalisation de la session de paiement", "noOrder": "Aucune commande", "confirmingPayment": "Confirmation de votre paiement...", + "selectPaymentMethod": "Veuillez sélectionner un mode de paiement.", + "manualPaymentInfo": "Vous recevrez les instructions de paiement après avoir passé votre commande.", + "failedToCreatePayment": "Échec de la création du paiement. Veuillez réessayer.", + "placeOrder": "Passer la commande", + "noPaymentRequired": "Aucun paiement requis — votre commande est entièrement couverte.", + "unsupportedGateway": "Ce mode de paiement n'est pas encore pris en charge.", + "paypalApproveFirst": "Veuillez approuver le paiement PayPal avant de continuer.", + "paypalApproved": "Paiement PayPal approuvé. Finalisation de votre commande...", + "paypalCancelled": "Le paiement PayPal a été annulé.", + "paypalError": "Une erreur est survenue avec PayPal. Veuillez réessayer.", "policyConsentRequired": "Vous devez accepter les politiques du magasin avant de passer votre commande", "iAgreeToThe": "J'accepte les", "policySeparatorComma": ", ", diff --git a/messages/pl.json b/messages/pl.json index 3b666a6d..388315c8 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -230,6 +230,16 @@ "failedToCompletePaymentSession": "Nie udało się zakończyć sesji płatności", "noOrder": "Brak zamówienia", "confirmingPayment": "Potwierdzanie płatności...", + "selectPaymentMethod": "Proszę wybrać metodę płatności.", + "manualPaymentInfo": "Otrzymasz instrukcje płatności po złożeniu zamówienia.", + "failedToCreatePayment": "Nie udało się utworzyć płatności. Spróbuj ponownie.", + "placeOrder": "Złóż zamówienie", + "noPaymentRequired": "Płatność nie jest wymagana — zamówienie jest w pełni pokryte.", + "unsupportedGateway": "Ta metoda płatności nie jest jeszcze obsługiwana.", + "paypalApproveFirst": "Zatwierdź płatność PayPal przed kontynuowaniem.", + "paypalApproved": "Płatność PayPal zatwierdzona. Finalizacja zamówienia...", + "paypalCancelled": "Płatność PayPal została anulowana.", + "paypalError": "Wystąpił błąd z PayPal. Spróbuj ponownie.", "policyConsentRequired": "Musisz zaakceptować regulamin sklepu przed złożeniem zamówienia", "iAgreeToThe": "Zgadzam się z", "policySeparatorComma": ", ", diff --git a/package-lock.json b/package-lock.json index 073ffb30..ed52bcfc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,9 @@ "name": "nextjs-starter-spree", "version": "0.1.0", "dependencies": { + "@adyen/adyen-web": "^6.33.0", "@next/third-parties": "^16.1.6", + "@paypal/react-paypal-js": "^9.1.1", "@react-email/components": "^1.0.10", "@sentry/nextjs": "^10.38.0", "@spree/sdk": "^1.0.0", @@ -69,6 +71,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@adyen/adyen-web": { + "version": "6.33.0", + "resolved": "https://registry.npmjs.org/@adyen/adyen-web/-/adyen-web-6.33.0.tgz", + "integrity": "sha512-URQLv0QKYyxn5CjV6XihVb/UOAo+qCgz17NP+Bt4dW7/fFE55NECxdsJNuqj6krFBwIQ9X+EYuFOqoxQv7dj2A==", + "license": "MIT", + "dependencies": { + "@types/applepayjs": "14.0.9", + "@types/googlepay": "0.7.10", + "classnames": "2.5.1", + "preact": "10.28.3" + } + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -189,7 +203,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -873,7 +886,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -914,7 +926,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -2332,6 +2343,7 @@ "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" @@ -2579,7 +2591,6 @@ "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "license": "MIT", - "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -2676,7 +2687,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -2698,7 +2708,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.5.1.tgz", "integrity": "sha512-MHbu8XxCHcBn6RwvCt2Vpn1WnLMNECfNKYB14LI5XypcgH4IE0/DiVifVR9tAkwPMyLXN8dOoPJfya3IryLQVw==", "license": "Apache-2.0", - "peer": true, "engines": { "node": "^18.19.0 || >=20.6.0" }, @@ -2711,7 +2720,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.1.tgz", "integrity": "sha512-Dwlc+3HAZqpgTYq0MUyZABjFkcrKTePwuiFVLjahGD8cx3enqihmpAmdgNFO1R4m/sIe5afjJrA25Prqy4NXlA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -3135,7 +3143,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.1.tgz", "integrity": "sha512-BViBCdE/GuXRlp9k7nS1w6wJvY5fnFX5XvuEtWsTAOQFIO89Eru7lGW3WbfbxtCuZ/GbrJfAziXG0w0dpxL7eQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.5.1", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -3152,7 +3159,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.1.tgz", "integrity": "sha512-iZH3Gw8cxQn0gjpOjJMmKLd9GIaNh/E3v3ST67vyzLSxHBs14HsG4dy7jMYyC5WXGdBVEcM7U/XTF5hCQxjDMw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.5.1", "@opentelemetry/resources": "2.5.1", @@ -3170,7 +3176,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz", "integrity": "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=14" } @@ -3485,6 +3490,39 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/@paypal/paypal-js": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/@paypal/paypal-js/-/paypal-js-9.6.0.tgz", + "integrity": "sha512-XVUC/2iUFcnz4cOgzTRPB0AltF2VHbALToClefCS7ehnINSx7UWKpShQbs3IIdALCaqtUDciQI8iFaBw12wM0g==", + "license": "Apache-2.0", + "dependencies": { + "promise-polyfill": "^8.3.0" + } + }, + "node_modules/@paypal/react-paypal-js": { + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/@paypal/react-paypal-js/-/react-paypal-js-9.1.1.tgz", + "integrity": "sha512-Rn+i3b7buYkayiIfkwTD4rXjCcZ6tTc6BJWR/6ovSY0ezPY5mFumiJ8FsEOtffgLbjkXNULNb0cAlriAy9iclQ==", + "license": "Apache-2.0", + "dependencies": { + "@paypal/paypal-js": "^9.6.0", + "@paypal/sdk-constants": "^1.0.122", + "server-only": "^0.0.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19", + "react-dom": "^16.8.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/@paypal/sdk-constants": { + "version": "1.0.157", + "resolved": "https://registry.npmjs.org/@paypal/sdk-constants/-/sdk-constants-1.0.157.tgz", + "integrity": "sha512-BjxWT9rK6dM1AOffSpvHYY47/8BY775jgEYYiwH6eL4YaqU5Epcw7zOtwQ8L4UaEn4FCAjZ2EWxaS83dCN7SpA==", + "license": "Apache-2.0", + "dependencies": { + "hi-base32": "^0.5.0" + } + }, "node_modules/@prisma/instrumentation": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@prisma/instrumentation/-/instrumentation-7.2.0.tgz", @@ -5029,7 +5067,6 @@ "resolved": "https://registry.npmjs.org/@react-email/body/-/body-0.3.0.tgz", "integrity": "sha512-uGo0BOOzjbMUo3lu+BIDWayvn5o6Xyfmnlla5VGf05n8gHMvO1ll7U4FtzWe3hxMLwt53pmc4iE0M+B5slG+Ug==", "license": "MIT", - "peer": true, "engines": { "node": ">=20.0.0" }, @@ -5042,7 +5079,6 @@ "resolved": "https://registry.npmjs.org/@react-email/button/-/button-0.2.1.tgz", "integrity": "sha512-qXyj7RZLE7POy9BMKSoqQ00tOXThjOZSUnI2Yu9i29IHngPlmrNayIWBoVKtElES7OWwypUcpiajwi1mUWx6/A==", "license": "MIT", - "peer": true, "engines": { "node": ">=20.0.0" }, @@ -5055,7 +5091,6 @@ "resolved": "https://registry.npmjs.org/@react-email/code-block/-/code-block-0.2.1.tgz", "integrity": "sha512-M3B7JpVH4ytgn83/ujRR1k1DQHvTeABiDM61OvAbjLRPhC/5KLHU5KkzIbbuGIrjWwxAbL1kSQzU8MhLEtSxyw==", "license": "MIT", - "peer": true, "dependencies": { "prismjs": "^1.30.0" }, @@ -5071,7 +5106,6 @@ "resolved": "https://registry.npmjs.org/@react-email/code-inline/-/code-inline-0.0.6.tgz", "integrity": "sha512-jfhebvv3dVsp3OdPgKXnk8+e2pBiDVZejDOBFzBa/IblrAJ9cQDkN6rBD5IyEg8hTOxwbw3iaI/yZFmDmIguIA==", "license": "MIT", - "peer": true, "engines": { "node": ">=20.0.0" }, @@ -5130,7 +5164,6 @@ "resolved": "https://registry.npmjs.org/@react-email/container/-/container-0.0.16.tgz", "integrity": "sha512-QWBB56RkkU0AJ9h+qy33gfT5iuZknPC7Un/IjZv9B0QmMIK+WWacc0cH6y2SV5Cv/b99hU94fjEMOOO4enpkbQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=20.0.0" }, @@ -5167,7 +5200,6 @@ "resolved": "https://registry.npmjs.org/@react-email/heading/-/heading-0.0.16.tgz", "integrity": "sha512-jmsKnQm1ykpBzw4hCYHwBkt5pW2jScXffPeEH5ZRF5tZeF5b1pvlFTO9han7C0pCkZYo1kEvWiRtx69yfCIwuw==", "license": "MIT", - "peer": true, "engines": { "node": ">=20.0.0" }, @@ -5180,7 +5212,6 @@ "resolved": "https://registry.npmjs.org/@react-email/hr/-/hr-0.0.12.tgz", "integrity": "sha512-TwmOmBDibavUQpXBxpmZYi2Iks/yeZOzFYh+di9EltMSnEabH8dMZXrl+pxNXzCgZ2XE8HY7VmUL65Lenfu5PA==", "license": "MIT", - "peer": true, "engines": { "node": ">=20.0.0" }, @@ -5205,7 +5236,6 @@ "resolved": "https://registry.npmjs.org/@react-email/img/-/img-0.0.12.tgz", "integrity": "sha512-sRCpEARNVTf3FQhZOC+JTvu5r6ubiYWkT0ucYXg8ctkyi4G8QG+jgYPiNUqVeTLA2STOfmPM/nrk1nb84y6CPQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=20.0.0" }, @@ -5218,7 +5248,6 @@ "resolved": "https://registry.npmjs.org/@react-email/link/-/link-0.0.13.tgz", "integrity": "sha512-lkWc/NjOcefRZMkQoSDDbuKBEBDES9aXnFEOuPH845wD3TxPwh+QTf0fStuzjoRLUZWpHnio4z7qGGRYusn/sw==", "license": "MIT", - "peer": true, "engines": { "node": ">=20.0.0" }, @@ -5246,7 +5275,6 @@ "resolved": "https://registry.npmjs.org/@react-email/preview/-/preview-0.0.14.tgz", "integrity": "sha512-aYK8q0IPkBXyMsbpMXgxazwHxYJxTrXrV95GFuu2HbEiIToMwSyUgb8HDFYwPqqfV03/jbwqlsXmFxsOd+VNaw==", "license": "MIT", - "peer": true, "engines": { "node": ">=20.0.0" }, @@ -5611,7 +5639,6 @@ "resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.1.6.tgz", "integrity": "sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw==", "license": "MIT", - "peer": true, "engines": { "node": ">=20.0.0" }, @@ -6499,7 +6526,6 @@ "resolved": "https://registry.npmjs.org/@spree/sdk/-/sdk-1.0.0.tgz", "integrity": "sha512-dJSfsupQ3bE16Ykf71n4cYtk/zeowJkr3fQBb4RJzbzlDyuce//WRtQNqMGZevuMnDiLMMDppwqe4RDm4Zr6wA==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -6548,7 +6574,6 @@ "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-8.8.0.tgz", "integrity": "sha512-NNYuyW8qmLjyHnpyFgs/23wUrjB8k0xN9YIZFOMLewCa/pIkIji9e9aY/EgdNryEDDRptc6TcPIHRvG1R0ClFw==", "license": "MIT", - "peer": true, "engines": { "node": ">=12.16" } @@ -7205,12 +7230,19 @@ "path-browserify": "^1.0.1" } }, + "node_modules/@types/applepayjs": { + "version": "14.0.9", + "resolved": "https://registry.npmjs.org/@types/applepayjs/-/applepayjs-14.0.9.tgz", + "integrity": "sha512-xEprYbb0TEP/XIiDPbVnTYpDai8fTFpsQfVSfTd81Is2GOMUy7ie019eyX6Mz2ECxfjoUVKaiGSL577roIeHCg==", + "license": "MIT" + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -7299,6 +7331,7 @@ "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -7309,6 +7342,7 @@ "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", "license": "MIT", + "peer": true, "dependencies": { "@types/eslint": "*", "@types/estree": "*" @@ -7320,11 +7354,18 @@ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, + "node_modules/@types/googlepay": { + "version": "0.7.10", + "resolved": "https://registry.npmjs.org/@types/googlepay/-/googlepay-0.7.10.tgz", + "integrity": "sha512-ByXjDfxEp87zsjw5cVsNKEeA+AIJYcWwJvRqPu85jO0Lp/FeG67csnf4mqbPaIxmdI6+Khp3v5I0yyjq9MrGhw==", + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/mysql": { "version": "2.15.27", @@ -7370,7 +7411,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -7381,7 +7421,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -7644,6 +7683,7 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", "license": "MIT", + "peer": true, "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" @@ -7653,25 +7693,29 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@webassemblyjs/helper-api-error": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@webassemblyjs/helper-buffer": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@webassemblyjs/helper-numbers": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", "license": "MIT", + "peer": true, "dependencies": { "@webassemblyjs/floating-point-hex-parser": "1.13.2", "@webassemblyjs/helper-api-error": "1.13.2", @@ -7682,13 +7726,15 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@webassemblyjs/helper-wasm-section": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", "license": "MIT", + "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -7701,6 +7747,7 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", "license": "MIT", + "peer": true, "dependencies": { "@xtuc/ieee754": "^1.2.0" } @@ -7710,6 +7757,7 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@xtuc/long": "4.2.2" } @@ -7718,13 +7766,15 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@webassemblyjs/wasm-edit": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", "license": "MIT", + "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -7741,6 +7791,7 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", "license": "MIT", + "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", @@ -7754,6 +7805,7 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", "license": "MIT", + "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -7766,6 +7818,7 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", "license": "MIT", + "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-api-error": "1.13.2", @@ -7780,6 +7833,7 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", "license": "MIT", + "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@xtuc/long": "4.2.2" @@ -7789,13 +7843,15 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/@xtuc/long": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/accepts": { "version": "2.0.0", @@ -7840,7 +7896,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -7862,6 +7917,7 @@ "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=10.13.0" }, @@ -7886,7 +7942,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -7903,6 +7958,7 @@ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "license": "MIT", + "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -7920,6 +7976,7 @@ "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3" }, @@ -7942,6 +7999,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -8136,7 +8194,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -8155,7 +8212,8 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/bundle-name": { "version": "4.1.0", @@ -8282,6 +8340,7 @@ "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.0" } @@ -8314,6 +8373,12 @@ "url": "https://polar.sh/cva" } }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, "node_modules/cli-cursor": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", @@ -8898,7 +8963,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dom-serializer": { "version": "2.0.0", @@ -9255,6 +9321,7 @@ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "license": "BSD-2-Clause", + "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -9281,6 +9348,7 @@ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "license": "BSD-2-Clause", + "peer": true, "dependencies": { "estraverse": "^5.2.0" }, @@ -9293,6 +9361,7 @@ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "license": "BSD-2-Clause", + "peer": true, "engines": { "node": ">=4.0" } @@ -9302,6 +9371,7 @@ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "license": "BSD-2-Clause", + "peer": true, "engines": { "node": ">=4.0" } @@ -9326,6 +9396,7 @@ "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.8.x" } @@ -9872,7 +9943,8 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "license": "BSD-2-Clause" + "license": "BSD-2-Clause", + "peer": true }, "node_modules/globals": { "version": "11.12.0", @@ -9916,6 +9988,7 @@ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -9950,12 +10023,17 @@ "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==", "license": "MIT" }, + "node_modules/hi-base32": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/hi-base32/-/hi-base32-0.5.1.tgz", + "integrity": "sha512-EmBBpvdYh/4XxsnUybsPag6VikPYnN30td+vQk+GI3qpahVEG9+gTkG0aXVxTjBqQ5T6ijbWIu77O+C5WFWsnA==", + "license": "MIT" + }, "node_modules/hono": { "version": "4.12.12", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz", "integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -10403,6 +10481,7 @@ "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", @@ -11021,6 +11100,7 @@ "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.11.5" }, @@ -11108,6 +11188,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -11419,14 +11500,14 @@ "version": "2.6.2", "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/next": { "version": "16.2.3", "resolved": "https://registry.npmjs.org/next/-/next-16.2.3.tgz", "integrity": "sha512-9V3zV4oZFza3PVev5/poB9g0dEafVcgNyQ8eTRop8GvxZjV2G15FC5ARuG1eFD42QgeYkzJBJzHghNP8Ad9xtA==", "license": "MIT", - "peer": true, "dependencies": { "@next/env": "16.2.3", "@swc/helpers": "0.5.15", @@ -12228,6 +12309,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/preact": { + "version": "10.28.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.28.3.tgz", + "integrity": "sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/prettier": { "version": "3.8.1", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", @@ -12249,6 +12340,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -12291,6 +12383,12 @@ "node": ">=0.4.0" } }, + "node_modules/promise-polyfill": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.3.0.tgz", + "integrity": "sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg==", + "license": "MIT" + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -12500,7 +12598,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -12643,7 +12740,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-refresh": { "version": "0.18.0", @@ -12886,7 +12984,6 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -13017,6 +13114,7 @@ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "license": "MIT", + "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", @@ -13122,6 +13220,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/server-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz", + "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -13526,6 +13630,7 @@ "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "license": "MIT", + "peer": true, "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -13734,6 +13839,7 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "license": "MIT", + "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -13840,6 +13946,7 @@ "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz", "integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==", "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -13858,6 +13965,7 @@ "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.4.0.tgz", "integrity": "sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==", "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", @@ -13890,7 +13998,8 @@ "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/third-party-capital": { "version": "1.0.20", @@ -14124,7 +14233,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -14347,7 +14455,6 @@ "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -14513,6 +14620,7 @@ "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", "license": "MIT", + "peer": true, "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" @@ -14545,6 +14653,7 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.106.0.tgz", "integrity": "sha512-Pkx5joZ9RrdgO5LBkyX1L2ZAJeK/Taz3vqZ9CbcP0wS5LEMx5QkKsEwLl29QJfihZ+DKRBFldzy1O30pJ1MDpA==", "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -14593,6 +14702,7 @@ "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.4.tgz", "integrity": "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=10.13.0" } @@ -14601,7 +14711,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/whatwg-mimetype": { "version": "5.0.0", @@ -14913,7 +15024,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index be3208ff..7ff0d5ba 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,9 @@ "email:dev": "email dev --port 4000" }, "dependencies": { + "@adyen/adyen-web": "^6.33.0", "@next/third-parties": "^16.1.6", + "@paypal/react-paypal-js": "^9.1.1", "@react-email/components": "^1.0.10", "@sentry/nextjs": "^10.38.0", "@spree/sdk": "^1.0.0", diff --git a/src/app/[country]/[locale]/(checkout)/checkout/[id]/CheckoutPageContent.tsx b/src/app/[country]/[locale]/(checkout)/checkout/[id]/CheckoutPageContent.tsx new file mode 100644 index 00000000..1fc9e4f7 --- /dev/null +++ b/src/app/[country]/[locale]/(checkout)/checkout/[id]/CheckoutPageContent.tsx @@ -0,0 +1,749 @@ +"use client"; + +import type { Address, AddressParams, Cart, Country } from "@spree/sdk"; +import { CircleAlert, Loader2 } from "lucide-react"; +import dynamic from "next/dynamic"; +import Link from "next/link"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { + Suspense, + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { AddressSection } from "@/components/checkout/AddressSection"; +import { DeliveryMethodSection } from "@/components/checkout/DeliveryMethodSection"; +import { + type PaymentCompleteResult, + PaymentSection, + type PaymentSectionHandle, +} from "@/components/checkout/PaymentSection"; +import { PolicyConsent } from "@/components/policy/PolicyConsent"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { useAuth } from "@/contexts/AuthContext"; +import { useCheckout } from "@/contexts/CheckoutContext"; +import { + trackAddPaymentInfo, + trackAddShippingInfo, + trackBeginCheckout, +} from "@/lib/analytics/gtm"; +import { getAddresses, updateAddress } from "@/lib/data/addresses"; +import { + applyCode, + getCheckoutOrder, + removeDiscountCode, + removeGiftCard, + selectDeliveryRate, + updateOrderAddresses, +} from "@/lib/data/checkout"; +import { isAuthenticated as checkAuth } from "@/lib/data/cookies"; +import { getCountry } from "@/lib/data/countries"; +import { getMarketCountries, resolveMarket } from "@/lib/data/markets"; +import { + completeCheckoutOrder, + completeCheckoutPaymentSession, +} from "@/lib/data/payment"; +import { extractBasePath } from "@/lib/utils/path"; +import { CheckoutSidebar } from "./CheckoutSidebar"; +import type { CheckoutInitialData } from "./page"; + +const ExpressCheckoutButton = dynamic( + () => + import("@/components/checkout/ExpressCheckoutButton").then((m) => ({ + default: m.ExpressCheckoutButton, + })), + { ssr: false }, +); + +interface CheckoutPageContentProps { + cartId: string; + urlCountry: string; + initialData: CheckoutInitialData | null; +} + +function CheckoutPageContentInner({ + cartId, + urlCountry, + initialData, +}: CheckoutPageContentProps) { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const basePath = extractBasePath(pathname); + const { setSummaryContent } = useCheckout(); + const t = useTranslations("checkout"); + const tc = useTranslations("common"); + const { user, loading: authLoading } = useAuth(); + + // Pick up payment errors from the confirm-payment redirect + const paymentError = searchParams.get("payment_error"); + + // Initialize state from server-fetched data — no loading skeleton needed + const [cart, setCart] = useState(initialData?.cart ?? null); + const [countries, setCountries] = useState( + initialData?.countries ?? [], + ); + const [savedAddresses, setSavedAddresses] = useState( + initialData?.savedAddresses ?? [], + ); + const [isAuthenticated, setIsAuthenticated] = useState( + initialData?.isAuthenticated ?? false, + ); + const [loading, setLoading] = useState(!initialData); + const [error, setError] = useState(paymentError); + const [processing, setProcessing] = useState(false); + const [expressAvailable, setExpressAvailable] = useState(false); + const [saving, setSaving] = useState(false); + const [sectionErrors, setSectionErrors] = useState>( + {}, + ); + const [policyConsent, setPolicyConsent] = useState(false); + const [policyError, setPolicyError] = useState(false); + const [isSessionPayment, setIsSessionPayment] = useState(true); + + const fulfillments = cart?.fulfillments ?? []; + + const cartRef = useRef(cart); + cartRef.current = cart; + const routerRef = useRef(router); + routerRef.current = router; + const tRef = useRef(t); + tRef.current = t; + const beginCheckoutFiredRef = useRef(false); + const paymentRef = useRef(null); + + // Handle code application (discount code or gift card — single input field) + const handleApplyCode = useCallback(async (code: string) => { + const currentOrder = cartRef.current; + if (!currentOrder) + return { success: false, error: tRef.current("noOrder") }; + + const result = await applyCode(currentOrder.id, code); + if (result.success && result.cart) { + setCart(result.cart); + } + return result; + }, []); + + const handleRemoveDiscount = useCallback(async (discountCode: string) => { + const currentOrder = cartRef.current; + if (!currentOrder) + return { success: false, error: tRef.current("noOrder") }; + + const result = await removeDiscountCode(currentOrder.id, discountCode); + if (result.success && result.cart) { + setCart(result.cart); + } + return result; + }, []); + + const handleRemoveGiftCard = useCallback(async (giftCardId: string) => { + const currentOrder = cartRef.current; + if (!currentOrder) + return { success: false, error: tRef.current("noOrder") }; + + const result = await removeGiftCard(currentOrder.id, giftCardId); + if (result.success && result.cart) { + setCart(result.cart); + } + return result; + }, []); + + // Track cart key for sidebar updates — useLayoutEffect so the sidebar + // renders on the first paint (before the browser paints the empty slot) + const cartKey = cart + ? `${cart.id}-${cart.total}-${cart.total_quantity}-${cart.amount_due ?? ""}` + : null; + const prevOrderKeyRef = useRef(null); + + useLayoutEffect(() => { + if ( + cartKey === prevOrderKeyRef.current && + prevOrderKeyRef.current !== null + ) { + return; + } + prevOrderKeyRef.current = cartKey; + + if (cart) { + setSummaryContent( + , + ); + } else { + setSummaryContent(null); + } + }, [ + cart, + cartKey, + setSummaryContent, + handleApplyCode, + handleRemoveDiscount, + handleRemoveGiftCard, + ]); + + // Refresh cart data (used after coupon changes, express checkout, etc.) + const loadOrder = useCallback(async () => { + setLoading(true); + if (!paymentError) setError(null); + + try { + const [cartData, market, addressesData, authStatus] = await Promise.all([ + getCheckoutOrder(cartId), + resolveMarket(urlCountry).catch(() => null), + getAddresses(), + checkAuth(), + ]); + + const countriesData = market + ? await getMarketCountries(market.id).catch(() => ({ + data: [] as Country[], + })) + : { data: [] as Country[] }; + + if (!cartData) { + setError(tRef.current("orderNotFound")); + setLoading(false); + return; + } + + if (cartData.current_step === "complete") { + routerRef.current.push(`${basePath}/order-placed/${cartId}`); + return; + } + + setCart(cartData); + setCountries(countriesData.data); + setSavedAddresses(addressesData.data); + setIsAuthenticated(authStatus); + + return cartData; + } catch { + setError(tRef.current("failedToLoadCheckout")); + return null; + } finally { + setLoading(false); + } + }, [cartId, urlCountry, basePath, paymentError]); + + // Only fetch on mount if we don't have initial data (e.g. client-side navigation) + useEffect(() => { + if (initialData) { + // Fire begin_checkout analytics for SSR-loaded data + if (!beginCheckoutFiredRef.current && initialData.cart) { + try { + trackBeginCheckout(initialData.cart); + } catch { + // Analytics should never break checkout flow + } + beginCheckoutFiredRef.current = true; + } + return; + } + loadOrder().then((cartData) => { + if (cartData && !beginCheckoutFiredRef.current) { + try { + trackBeginCheckout(cartData); + } catch { + // Analytics should never break checkout flow + } + beginCheckoutFiredRef.current = true; + } + }); + }, [initialData, loadOrder]); + + // Handle email blur — persist email as the first backend call + const handleEmailBlur = useCallback(async (email: string) => { + const currentOrder = cartRef.current; + if (!currentOrder || !email.trim()) return; + + // Only persist email if it changed + if (email === currentOrder.email) return; + + try { + const result = await updateOrderAddresses(currentOrder.id, { email }); + if (result.success && result.cart) { + setCart(result.cart); + } + } catch { + // Email save failure is not critical — will be caught on "Pay now" + } + }, []); + + // Handle auto-save (address + email on blur) + const handleAutoSave = useCallback( + async (addressData: { + email: string; + shipping_address?: AddressParams; + shipping_address_id?: string; + }) => { + const currentOrder = cartRef.current; + if (!currentOrder) return; + + setSaving(true); + setError(null); + + try { + const updateResult = await updateOrderAddresses(currentOrder.id, { + email: addressData.email, + ...(addressData.shipping_address && { + shipping_address: addressData.shipping_address, + }), + ...(addressData.shipping_address_id && { + shipping_address_id: addressData.shipping_address_id, + }), + }); + + if (!updateResult.success) { + setError(updateResult.error || tRef.current("failedToSaveAddress")); + return; + } + + if (updateResult.cart) { + setCart(updateResult.cart); + } + } catch { + setError(tRef.current("generalError")); + } finally { + setSaving(false); + } + }, + [], + ); + + // Handle delivery rate selection + const handleDeliveryRateSelect = useCallback( + async (fulfillmentId: string, rateId: string) => { + const currentOrder = cartRef.current; + if (!currentOrder) return; + + setProcessing(true); + setError(null); + + let trackingOrder: Cart | null = null; + let trackingRateName: string | undefined; + + try { + const result = await selectDeliveryRate( + currentOrder.id, + fulfillmentId, + rateId, + ); + if (!result.success) { + setError(result.error || tRef.current("failedToSelectRate")); + } else if (result.cart) { + setCart(result.cart); + + const selectedRate = result.cart.fulfillments + ?.flatMap((s) => s.delivery_rates || []) + ?.find((r) => r.id === rateId); + trackingOrder = result.cart; + trackingRateName = selectedRate?.name; + } + } catch { + setError(tRef.current("generalError")); + } finally { + setProcessing(false); + } + + if (trackingOrder) { + try { + trackAddShippingInfo(trackingOrder, trackingRateName); + } catch { + // Analytics should never break checkout flow + } + } + }, + [], + ); + + // Handle billing address update (called by PaymentSection before gateway confirmation) + const handleUpdateBillingAddress = useCallback( + async (data: { + billing_address?: AddressParams; + use_shipping?: boolean; + }): Promise => { + const currentOrder = cartRef.current; + if (!currentOrder) return false; + + setError(null); + + try { + const updateResult = await updateOrderAddresses(currentOrder.id, { + ...(data.billing_address && { + billing_address: data.billing_address, + }), + ...(data.use_shipping && { use_shipping: data.use_shipping }), + }); + + if (!updateResult.success) { + setError(updateResult.error || tRef.current("failedToSaveBilling")); + return false; + } + + return true; + } catch { + setError(tRef.current("failedToSaveBillingRetry")); + return false; + } + }, + [], + ); + + // Handle payment completion (called by PaymentSection after payment is confirmed) + const handlePaymentComplete = useCallback( + async (result: PaymentCompleteResult) => { + const currentOrder = cartRef.current; + if (!currentOrder) return; + + setError(null); + + try { + // For session-based payments, complete the payment session first + if (result.type === "session") { + const sessionResult = await completeCheckoutPaymentSession( + currentOrder.id, + result.sessionId, + result.sessionResult + ? { session_result: result.sessionResult } + : undefined, + ); + + if (!sessionResult.success) { + setError( + sessionResult.error || + tRef.current("failedToCompletePaymentSession"), + ); + setProcessing(false); + return; + } + } + // For direct payments, the payment was already created in PaymentSection + + try { + trackAddPaymentInfo(currentOrder); + } catch { + // Analytics should never break checkout flow + } + + // Complete the order — if the backend already completed it during + // session completion, completeCheckoutOrder handles 403/422 gracefully. + const completeResult = await completeCheckoutOrder(currentOrder.id); + if (!completeResult.success) { + setError( + completeResult.error || tRef.current("failedToCompleteOrder"), + ); + setProcessing(false); + return; + } + + // Cache the completed order for the thank-you page + if (completeResult.order) { + const { cacheCompletedOrder } = await import( + "@/lib/utils/completed-order-cache" + ); + cacheCompletedOrder(currentOrder.id, completeResult.order); + } + + routerRef.current.push(`${basePath}/order-placed/${currentOrder.id}`); + } catch { + setError(tRef.current("generalError")); + setProcessing(false); + } + }, + [basePath], + ); + + // Fetch states for a country + const fetchStates = useCallback(async (countryIso: string) => { + try { + const country = await getCountry(countryIso); + return country.states || []; + } catch { + return []; + } + }, []); + + // Update a saved address + const handleUpdateSavedAddress = useCallback( + async (id: string, data: AddressParams): Promise
=> { + const result = await updateAddress(id, data); + + if (!result.success) { + throw new Error(result.error || tRef.current("failedToSaveAddress")); + } + + if (!result.address) { + throw new Error(tRef.current("generalError")); + } + + return result.address; + }, + [], + ); + + // Validate and pay — single "Pay now" action + const validateAndPay = async () => { + if (!cart) return; + + setSectionErrors({}); + setError(null); + + if (!isAuthenticated && !policyConsent) { + setPolicyError(true); + setError(t("policyConsentRequired")); + document + .getElementById("policy-consent") + ?.scrollIntoView({ behavior: "smooth", block: "center" }); + document.getElementById("policy-consent")?.focus(); + return; + } + + // Refresh cart to get latest requirements + const freshOrder = await getCheckoutOrder(cart.id); + if (!freshOrder) { + setError(t("failedToLoadCheckout")); + return; + } + setCart(freshOrder); + + // Check requirements — skip "payment" since we handle that via + // the PaymentSection imperative submit (payment is created at confirmation time) + const prePaymentReqs = (freshOrder.requirements || []).filter( + (req) => req.step !== "payment", + ); + + if (prePaymentReqs.length > 0) { + const errorsBySection: Record = {}; + + for (const req of prePaymentReqs) { + // Map requirement steps to section IDs + const sectionId = + req.step === "address" + ? "address" + : req.step === "delivery" + ? "shipping" + : req.step; + if (!errorsBySection[sectionId]) { + errorsBySection[sectionId] = []; + } + errorsBySection[sectionId].push(req.message); + } + + setSectionErrors(errorsBySection); + + // Scroll to first error section + const firstSection = Object.keys(errorsBySection)[0]; + const el = document.getElementById(`checkout-section-${firstSection}`); + if (el) { + el.scrollIntoView({ behavior: "smooth", block: "start" }); + } + + return; + } + + // All requirements met — submit payment + if (!paymentRef.current) { + setError(t("failedToInitPayment")); + return; + } + + setProcessing(true); + await paymentRef.current.submit(); + // PaymentSection handles setProcessing(false) on error internally + }; + + // Loading state — only shown when no initial data (client-side navigation). + // When initialData is provided (SSR), skip the authLoading check since + // we already know isAuthenticated from the server. + if (loading || (!initialData && authLoading)) { + return ( +
+
+
+
+
+
+
+
+
+ ); + } + + // Error state (no cart loaded) + if (error && !cart) { + return ( +
+

+ {t("checkoutError")} +

+

{error}

+ + {t("returnToCart")} + +
+ ); + } + + if (!cart) return null; + + // Empty cart + if (!cart.items || cart.items.length === 0) { + return ( +
+

+ {t("emptyCart")} +

+

{t("emptyCartDescription")}

+ + {tc("continueShopping")} + +
+ ); + } + + return ( +
+ {/* Error banner */} + {error && ( + + + {error} + + )} + + {/* Express checkout for guests */} + {!isAuthenticated && parseFloat(cart.total) > 0 && ( +
+ {expressAvailable && ( +

+ Express checkout +

+ )} + { + await loadOrder(); + }} + onProcessingChange={setProcessing} + onAvailabilityChange={setExpressAvailable} + maxColumns={2} + showDivider + /> +
+ )} + + {/* Checkout form sections — dimmed & disabled during express checkout */} +
+ {/* Contact + Delivery */} +
+ +
+ + {/* Shipping method */} +
+ +
+ + {/* Payment */} +
+ +
+ + {/* Policy consent — guests only, authenticated users accepted at registration */} + {!isAuthenticated && ( +
+ { + setPolicyConsent(checked); + if (checked) setPolicyError(false); + }} + error={policyError} + /> +
+ )} + + {/* Pay now button */} + +
+
+ ); +} + +export function CheckoutPageContent(props: CheckoutPageContentProps) { + return ( + + + + ); +} diff --git a/src/app/[country]/[locale]/(checkout)/checkout/[id]/CheckoutSidebar.tsx b/src/app/[country]/[locale]/(checkout)/checkout/[id]/CheckoutSidebar.tsx new file mode 100644 index 00000000..387dee92 --- /dev/null +++ b/src/app/[country]/[locale]/(checkout)/checkout/[id]/CheckoutSidebar.tsx @@ -0,0 +1,37 @@ +"use client"; + +import type { Cart } from "@spree/sdk"; +import { CouponCode } from "@/components/checkout/CouponCode"; +import { Summary } from "@/components/checkout/Summary"; + +interface CheckoutSidebarProps { + cart: Cart; + onApplyCode: (code: string) => Promise<{ success: boolean; error?: string }>; + onRemoveDiscount: ( + code: string, + ) => Promise<{ success: boolean; error?: string }>; + onRemoveGiftCard: ( + giftCardId: string, + ) => Promise<{ success: boolean; error?: string }>; +} + +export function CheckoutSidebar({ + cart, + onApplyCode, + onRemoveDiscount, + onRemoveGiftCard, +}: CheckoutSidebarProps) { + return ( + <> + +
+ +
+ + ); +} diff --git a/src/app/[country]/[locale]/(checkout)/checkout/[id]/page.tsx b/src/app/[country]/[locale]/(checkout)/checkout/[id]/page.tsx index 2a225032..8ba5908e 100644 --- a/src/app/[country]/[locale]/(checkout)/checkout/[id]/page.tsx +++ b/src/app/[country]/[locale]/(checkout)/checkout/[id]/page.tsx @@ -1,61 +1,21 @@ -"use client"; - -import type { Address, AddressParams, Cart, Country } from "@spree/sdk"; -import { CircleAlert, Loader2 } from "lucide-react"; -import dynamic from "next/dynamic"; -import Link from "next/link"; -import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import { useTranslations } from "next-intl"; -import { Suspense, use, useCallback, useEffect, useRef, useState } from "react"; -import { AddressSection } from "@/components/checkout/AddressSection"; -import { CouponCode } from "@/components/checkout/CouponCode"; -import { DeliveryMethodSection } from "@/components/checkout/DeliveryMethodSection"; -import { - PaymentSection, - type PaymentSectionHandle, -} from "@/components/checkout/PaymentSection"; -import { Summary } from "@/components/checkout/Summary"; -import { PolicyConsent } from "@/components/policy/PolicyConsent"; -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { useAuth } from "@/contexts/AuthContext"; -import { useCheckout } from "@/contexts/CheckoutContext"; -import { - trackAddPaymentInfo, - trackAddShippingInfo, - trackBeginCheckout, -} from "@/lib/analytics/gtm"; -import { getAddresses, updateAddress } from "@/lib/data/addresses"; -import { - applyCode, - getCheckoutOrder, - removeDiscountCode, - removeGiftCard, - selectDeliveryRate, - updateOrderAddresses, -} from "@/lib/data/checkout"; +import type { Address, Cart, Country } from "@spree/sdk"; +import { redirect } from "next/navigation"; +import { connection } from "next/server"; +import { Suspense } from "react"; +import { getAddresses } from "@/lib/data/addresses"; +import { getCheckoutOrder } from "@/lib/data/checkout"; import { isAuthenticated as checkAuth } from "@/lib/data/cookies"; import { getCountry } from "@/lib/data/countries"; import { getMarketCountries, resolveMarket } from "@/lib/data/markets"; -import { - completeCheckoutOrder, - completeCheckoutPaymentSession, -} from "@/lib/data/payment"; -import { extractBasePath } from "@/lib/utils/path"; -const ExpressCheckoutButton = dynamic( - () => - import("@/components/checkout/ExpressCheckoutButton").then((m) => ({ - default: m.ExpressCheckoutButton, - })), - { - ssr: false, - loading: () => ( -
-
-
- ), - }, -); +import { CheckoutPageContent } from "./CheckoutPageContent"; + +export interface CheckoutInitialData { + cart: Cart; + countries: Country[]; + savedAddresses: Address[]; + isAuthenticated: boolean; +} interface CheckoutPageProps { params: Promise<{ @@ -65,686 +25,62 @@ interface CheckoutPageProps { }>; } -// Sidebar summary component -function CheckoutSidebar({ - cart, - onApplyCode, - onRemoveDiscount, - onRemoveGiftCard, -}: { - cart: Cart; - onApplyCode: (code: string) => Promise<{ success: boolean; error?: string }>; - onRemoveDiscount: ( - code: string, - ) => Promise<{ success: boolean; error?: string }>; - onRemoveGiftCard: ( - giftCardId: string, - ) => Promise<{ success: boolean; error?: string }>; -}) { - return ( - <> - -
- -
- - ); -} - -function CheckoutPageContent({ params }: CheckoutPageProps) { - const { id: cartId, country: urlCountry } = use(params); - const router = useRouter(); - const pathname = usePathname(); - const searchParams = useSearchParams(); - const basePath = extractBasePath(pathname); - const { setSummaryContent } = useCheckout(); - const t = useTranslations("checkout"); - const tc = useTranslations("common"); - const { user, loading: authLoading } = useAuth(); - - // Pick up payment errors from the confirm-payment redirect - const paymentError = searchParams.get("payment_error"); - - const [cart, setCart] = useState(null); - const [countries, setCountries] = useState([]); - const [savedAddresses, setSavedAddresses] = useState([]); - const [isAuthenticated, setIsAuthenticated] = useState(false); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(paymentError); - const [processing, setProcessing] = useState(false); - const [expressAvailable, setExpressAvailable] = useState(false); - const [saving, setSaving] = useState(false); - const [sectionErrors, setSectionErrors] = useState>( - {}, - ); - const [policyConsent, setPolicyConsent] = useState(false); - const [policyError, setPolicyError] = useState(false); - - const fulfillments = cart?.fulfillments ?? []; +async function CheckoutDataLoader({ params }: CheckoutPageProps) { + await connection(); - const cartRef = useRef(cart); - cartRef.current = cart; - const routerRef = useRef(router); - routerRef.current = router; - const tRef = useRef(t); - tRef.current = t; - const beginCheckoutFiredRef = useRef(false); - const paymentRef = useRef(null); + const { id: cartId, country: urlCountry } = await params; - // Handle code application (discount code or gift card — single input field) - const handleApplyCode = useCallback(async (code: string) => { - const currentOrder = cartRef.current; - if (!currentOrder) - return { success: false, error: tRef.current("noOrder") }; + // Check auth first so we can skip address fetch for guests + const authStatus = await checkAuth(); - const result = await applyCode(currentOrder.id, code); - if (result.success && result.cart) { - setCart(result.cart); - } - return result; - }, []); - - const handleRemoveDiscount = useCallback(async (discountCode: string) => { - const currentOrder = cartRef.current; - if (!currentOrder) - return { success: false, error: tRef.current("noOrder") }; - - const result = await removeDiscountCode(currentOrder.id, discountCode); - if (result.success && result.cart) { - setCart(result.cart); - } - return result; - }, []); - - const handleRemoveGiftCard = useCallback(async (giftCardId: string) => { - const currentOrder = cartRef.current; - if (!currentOrder) - return { success: false, error: tRef.current("noOrder") }; - - const result = await removeGiftCard(currentOrder.id, giftCardId); - if (result.success && result.cart) { - setCart(result.cart); - } - return result; - }, []); - - // Track cart key for sidebar updates - const cartKey = cart - ? `${cart.id}-${cart.total}-${cart.total_quantity}-${cart.amount_due ?? ""}` - : null; - const prevOrderKeyRef = useRef(cartKey); - - useEffect(() => { - if ( - cartKey === prevOrderKeyRef.current && - prevOrderKeyRef.current !== null - ) { - return; - } - prevOrderKeyRef.current = cartKey; - - if (cart) { - setSummaryContent( - , - ); - } else { - setSummaryContent(null); - } - }, [ - cart, - cartKey, - setSummaryContent, - handleApplyCode, - handleRemoveDiscount, - handleRemoveGiftCard, + // Fetch initial data in parallel during SSR + const [cartData, market, addressesData] = await Promise.all([ + getCheckoutOrder(cartId), + resolveMarket(urlCountry).catch(() => null), + authStatus ? getAddresses() : Promise.resolve({ data: [] as Address[] }), ]); - // Load cart and market-scoped countries - const loadOrder = useCallback(async () => { - setLoading(true); - if (!paymentError) setError(null); - - try { - const [cartData, market, addressesData, authStatus] = await Promise.all([ - getCheckoutOrder(cartId), - resolveMarket(urlCountry).catch(() => null), - getAddresses(), - checkAuth(), - ]); - - const countriesData = market - ? await getMarketCountries(market.id).catch(() => ({ - data: [] as Country[], - })) - : { data: [] as Country[] }; - - if (!cartData) { - setError(tRef.current("orderNotFound")); - setLoading(false); - return; - } - - if (cartData.current_step === "complete") { - routerRef.current.push(`${basePath}/order-placed/${cartId}`); - return; - } - - setCart(cartData); - setCountries(countriesData.data); - setSavedAddresses(addressesData.data); - setIsAuthenticated(authStatus); - - if (!beginCheckoutFiredRef.current) { - try { - trackBeginCheckout(cartData); - } catch { - // Analytics should never break checkout flow - } - beginCheckoutFiredRef.current = true; - } - - return cartData; - } catch { - setError(tRef.current("failedToLoadCheckout")); - return null; - } finally { - setLoading(false); - } - }, [cartId, urlCountry, basePath, paymentError]); - - useEffect(() => { - loadOrder(); - }, [loadOrder]); - - // Handle email blur — persist email as the first backend call - const handleEmailBlur = useCallback(async (email: string) => { - const currentOrder = cartRef.current; - if (!currentOrder || !email.trim()) return; - - // Only persist email if it changed - if (email === currentOrder.email) return; - - try { - const result = await updateOrderAddresses(currentOrder.id, { email }); - if (result.success && result.cart) { - setCart(result.cart); - } - } catch { - // Email save failure is not critical — will be caught on "Pay now" - } - }, []); - - // Handle auto-save (address + email on blur) - const handleAutoSave = useCallback( - async (addressData: { - email: string; - shipping_address?: AddressParams; - shipping_address_id?: string; - }) => { - const currentOrder = cartRef.current; - if (!currentOrder) return; - - setSaving(true); - setError(null); - - try { - const updateResult = await updateOrderAddresses(currentOrder.id, { - email: addressData.email, - ...(addressData.shipping_address && { - shipping_address: addressData.shipping_address, - }), - ...(addressData.shipping_address_id && { - shipping_address_id: addressData.shipping_address_id, - }), - }); - - if (!updateResult.success) { - setError(updateResult.error || tRef.current("failedToSaveAddress")); - return; - } - - if (updateResult.cart) { - setCart(updateResult.cart); - } - } catch { - setError(tRef.current("generalError")); - } finally { - setSaving(false); - } - }, - [], - ); - - // Handle delivery rate selection - const handleDeliveryRateSelect = useCallback( - async (fulfillmentId: string, rateId: string) => { - const currentOrder = cartRef.current; - if (!currentOrder) return; - - setProcessing(true); - setError(null); - - let trackingOrder: Cart | null = null; - let trackingRateName: string | undefined; - - try { - const result = await selectDeliveryRate( - currentOrder.id, - fulfillmentId, - rateId, - ); - if (!result.success) { - setError(result.error || tRef.current("failedToSelectRate")); - } else if (result.cart) { - setCart(result.cart); - - const selectedRate = result.cart.fulfillments - ?.flatMap((s) => s.delivery_rates || []) - ?.find((r) => r.id === rateId); - trackingOrder = result.cart; - trackingRateName = selectedRate?.name; - } - } catch { - setError(tRef.current("generalError")); - } finally { - setProcessing(false); - } - - if (trackingOrder) { - try { - trackAddShippingInfo(trackingOrder, trackingRateName); - } catch { - // Analytics should never break checkout flow - } - } - }, - [], - ); - - // Handle billing address update (called by PaymentSection before gateway confirmation) - const handleUpdateBillingAddress = useCallback( - async (data: { - billing_address?: AddressParams; - use_shipping?: boolean; - }): Promise => { - const currentOrder = cartRef.current; - if (!currentOrder) return false; - - setError(null); - - try { - const updateResult = await updateOrderAddresses(currentOrder.id, { - ...(data.billing_address && { - billing_address: data.billing_address, - }), - ...(data.use_shipping && { use_shipping: data.use_shipping }), - }); - - if (!updateResult.success) { - setError(updateResult.error || tRef.current("failedToSaveBilling")); - return false; - } - - return true; - } catch { - setError(tRef.current("failedToSaveBillingRetry")); - return false; - } - }, - [], - ); - - // Handle payment completion (called by PaymentSection after Stripe confirms) - const handlePaymentComplete = useCallback( - async (paymentSessionId: string) => { - const currentOrder = cartRef.current; - if (!currentOrder) return; - - setError(null); - - try { - const sessionResult = await completeCheckoutPaymentSession( - currentOrder.id, - paymentSessionId, - ); - - if (!sessionResult.success) { - setError( - sessionResult.error || - tRef.current("failedToCompletePaymentSession"), - ); - setProcessing(false); - return; - } - - try { - trackAddPaymentInfo(currentOrder); - } catch { - // Analytics should never break checkout flow - } - - // Complete the order — if the backend already completed it during - // session completion, completeCheckoutOrder handles 403/422 gracefully. - const completeResult = await completeCheckoutOrder(currentOrder.id); - if (!completeResult.success) { - setError( - completeResult.error || tRef.current("failedToCompleteOrder"), - ); - setProcessing(false); - return; - } - - // Cache the completed order for the thank-you page - if (completeResult.order) { - const { cacheCompletedOrder } = await import( - "@/lib/utils/completed-order-cache" - ); - cacheCompletedOrder(currentOrder.id, completeResult.order); - } - - routerRef.current.push(`${basePath}/order-placed/${currentOrder.id}`); - } catch { - setError(tRef.current("generalError")); - setProcessing(false); - } - }, - [basePath], - ); - - // Fetch states for a country - const fetchStates = useCallback(async (countryIso: string) => { - try { - const country = await getCountry(countryIso); - return country.states || []; - } catch { - return []; - } - }, []); - - // Update a saved address - const handleUpdateSavedAddress = useCallback( - async (id: string, data: AddressParams): Promise
=> { - const result = await updateAddress(id, data); - - if (!result.success) { - throw new Error(result.error || tRef.current("failedToSaveAddress")); - } - - if (!result.address) { - throw new Error(tRef.current("generalError")); - } - - return result.address; - }, - [], - ); - - // Validate and pay — single "Pay now" action - const validateAndPay = async () => { - if (!cart) return; - - setSectionErrors({}); - setError(null); - - if (!isAuthenticated && !policyConsent) { - setPolicyError(true); - setError(t("policyConsentRequired")); - document - .getElementById("policy-consent") - ?.scrollIntoView({ behavior: "smooth", block: "center" }); - document.getElementById("policy-consent")?.focus(); - return; - } - - // Refresh cart to get latest requirements - const freshOrder = await getCheckoutOrder(cart.id); - if (!freshOrder) { - setError(t("failedToLoadCheckout")); - return; - } - setCart(freshOrder); - - // Check requirements — skip "payment" since we handle that via - // the PaymentSection imperative submit (payment is created at confirmation time) - const prePaymentReqs = (freshOrder.requirements || []).filter( - (req) => req.step !== "payment", - ); - - if (prePaymentReqs.length > 0) { - const errorsBySection: Record = {}; - - for (const req of prePaymentReqs) { - // Map requirement steps to section IDs - const sectionId = - req.step === "address" - ? "address" - : req.step === "delivery" - ? "shipping" - : req.step; - if (!errorsBySection[sectionId]) { - errorsBySection[sectionId] = []; - } - errorsBySection[sectionId].push(req.message); - } - - setSectionErrors(errorsBySection); - - // Scroll to first error section - const firstSection = Object.keys(errorsBySection)[0]; - const el = document.getElementById(`checkout-section-${firstSection}`); - if (el) { - el.scrollIntoView({ behavior: "smooth", block: "start" }); - } - - return; - } - - // All requirements met — submit payment - if (!paymentRef.current) { - setError(t("failedToInitPayment")); - return; - } - - setProcessing(true); - await paymentRef.current.submit(); - // PaymentSection handles setProcessing(false) on error internally - }; - - // Loading state - if (loading || authLoading) { - return ( -
-
-
-
-
-
-
-
-
- ); + // Redirect to order-placed if already complete + if (cartData?.current_step === "complete") { + const basePath = `/${urlCountry}/en`; + redirect(`${basePath}/order-placed/${cartId}`); } - // Error state (no cart loaded) - if (error && !cart) { - return ( -
-

- {t("checkoutError")} -

-

{error}

- - {t("returnToCart")} - -
- ); + const countriesData = market + ? await getMarketCountries(market.id).catch(() => ({ + data: [] as Country[], + })) + : { data: [] as Country[] }; + + // Prefetch states for the default country (warms the server cache) + const defaultIso = + cartData?.shipping_address?.country_iso ?? countriesData.data[0]?.iso; + if (defaultIso) { + getCountry(defaultIso).catch(() => {}); } - if (!cart) return null; - - // Empty cart - if (!cart.items || cart.items.length === 0) { - return ( -
-

- {t("emptyCart")} -

-

{t("emptyCartDescription")}

- - {tc("continueShopping")} - -
- ); - } + const initialData: CheckoutInitialData | null = cartData + ? { + cart: cartData, + countries: countriesData.data, + savedAddresses: addressesData.data, + isAuthenticated: authStatus, + } + : null; return ( -
- {/* Error banner */} - {error && ( - - - {error} - - )} - - {/* Express checkout for guests */} - {!isAuthenticated && parseFloat(cart.total) > 0 && ( -
- {expressAvailable && ( -

- Express checkout -

- )} - { - await loadOrder(); - }} - onProcessingChange={setProcessing} - onAvailabilityChange={setExpressAvailable} - maxColumns={2} - showDivider - /> -
- )} - - {/* Checkout form sections — dimmed & disabled during express checkout */} -
- {/* Contact + Delivery */} -
- -
- - {/* Shipping method */} -
- -
- - {/* Payment */} -
- -
- - {/* Policy consent — guests only, authenticated users accepted at registration */} - {!isAuthenticated && ( -
- { - setPolicyConsent(checked); - if (checked) setPolicyError(false); - }} - error={policyError} - /> -
- )} - - {/* Pay now button */} - -
-
+ ); } export default function CheckoutPage({ params }: CheckoutPageProps) { return ( - - -
- } - > - + + ); } diff --git a/src/app/[country]/[locale]/(checkout)/confirm-payment/[id]/page.tsx b/src/app/[country]/[locale]/(checkout)/confirm-payment/[id]/page.tsx index ad89b41d..f9f02423 100644 --- a/src/app/[country]/[locale]/(checkout)/confirm-payment/[id]/page.tsx +++ b/src/app/[country]/[locale]/(checkout)/confirm-payment/[id]/page.tsx @@ -18,8 +18,8 @@ interface ConfirmPaymentPageProps { /** * Intermediate page that offsite payment gateways redirect to. * - * When a customer returns from an offsite gateway (e.g. Stripe 3D Secure), - * the payment webhook may not have arrived yet. This page: + * When a customer returns from an offsite gateway (e.g. Stripe 3D Secure, + * Adyen Klarna/iDEAL), the payment webhook may not have arrived yet. This page: * 1. Tries to complete the payment session (tells Spree to check with the provider) * 2. If successful, completes the order and redirects to order-placed * 3. If failed, redirects back to checkout with an error @@ -39,12 +39,20 @@ export default function ConfirmPaymentPage({ if (attemptedRef.current) return; attemptedRef.current = true; + // Stripe: ?session={spreeSessionId} + // Adyen: ?sessionId={adyenSessionId}&redirectResult=... const sessionId = searchParams.get("session"); + const sessionResult = searchParams.get("sessionResult"); + const redirectResult = searchParams.get("redirectResult"); + const adyenSessionId = searchParams.get("sessionId"); async function confirmAndRedirect() { const result = await confirmPaymentAndCompleteCart( cartId, sessionId ?? undefined, + sessionResult ?? undefined, + redirectResult ?? undefined, + adyenSessionId ?? undefined, ); if (result.success) { diff --git a/src/app/[country]/[locale]/(checkout)/confirm-payment/__tests__/page.test.tsx b/src/app/[country]/[locale]/(checkout)/confirm-payment/__tests__/page.test.tsx index 68134e81..de147221 100644 --- a/src/app/[country]/[locale]/(checkout)/confirm-payment/__tests__/page.test.tsx +++ b/src/app/[country]/[locale]/(checkout)/confirm-payment/__tests__/page.test.tsx @@ -79,11 +79,40 @@ describe("ConfirmPaymentPage", () => { }); await waitFor(() => { - expect(mockConfirm).toHaveBeenCalledWith("cart-1", "session-1"); + expect(mockConfirm).toHaveBeenCalledWith( + "cart-1", + "session-1", + undefined, + undefined, + undefined, + ); expect(mockReplace).toHaveBeenCalledWith("/us/en/order-placed/cart-1"); }); }); + it("passes sessionResult query param to confirmPaymentAndCompleteCart", async () => { + mockSearchParams.set("session", "session-1"); + mockSearchParams.set("sessionResult", "eyJhYmMiOiJ4eXoifQ=="); + mockConfirm.mockResolvedValue({ + success: true as const, + order: { id: "cart-1" }, + }); + + await act(async () => { + renderPage(); + }); + + await waitFor(() => { + expect(mockConfirm).toHaveBeenCalledWith( + "cart-1", + "session-1", + "eyJhYmMiOiJ4eXoifQ==", + undefined, + undefined, + ); + }); + }); + it("redirects to checkout with error on failure", async () => { mockSearchParams.set("session", "session-1"); mockConfirm.mockResolvedValue({ @@ -113,7 +142,13 @@ describe("ConfirmPaymentPage", () => { }); await waitFor(() => { - expect(mockConfirm).toHaveBeenCalledWith("cart-1", undefined); + expect(mockConfirm).toHaveBeenCalledWith( + "cart-1", + undefined, + undefined, + undefined, + undefined, + ); expect(mockReplace).toHaveBeenCalledWith("/us/en/order-placed/cart-1"); }); }); diff --git a/src/app/[country]/[locale]/(storefront)/account/addresses/page.tsx b/src/app/[country]/[locale]/(storefront)/account/addresses/page.tsx index a1805763..d0e1db8f 100644 --- a/src/app/[country]/[locale]/(storefront)/account/addresses/page.tsx +++ b/src/app/[country]/[locale]/(storefront)/account/addresses/page.tsx @@ -4,8 +4,8 @@ import { getTranslations } from "next-intl/server"; import { AddressManagement } from "@/components/addresses/AddressManagement"; import type { User } from "@/contexts/AuthContext"; import { getAddresses } from "@/lib/data/addresses"; -import { getCountries } from "@/lib/data/countries"; import { getCustomer } from "@/lib/data/customer"; +import { getMarketCountries, resolveMarket } from "@/lib/data/markets"; interface AddressesPageProps { params: Promise<{ country: string; locale: string }>; @@ -13,17 +13,21 @@ interface AddressesPageProps { export default async function AddressesPage({ params }: AddressesPageProps) { await connection(); - const { locale } = await params; + const { country: urlCountry, locale } = await params; const t = await getTranslations({ locale: locale as Locale, namespace: "account", }); - const [addressResponse, countriesResponse, customer] = await Promise.all([ + const [addressResponse, market, customer] = await Promise.all([ getAddresses(), - getCountries(), + resolveMarket(urlCountry).catch(() => null), getCustomer().catch(() => null), ]); + const countriesResponse = market + ? await getMarketCountries(market.id).catch(() => ({ data: [] })) + : { data: [] }; + const addresses = addressResponse.data; const countries = countriesResponse.data; const user: User | undefined = customer diff --git a/src/app/[country]/[locale]/(storefront)/cart/page.tsx b/src/app/[country]/[locale]/(storefront)/cart/page.tsx index e9aa6008..ae6a7e02 100644 --- a/src/app/[country]/[locale]/(storefront)/cart/page.tsx +++ b/src/app/[country]/[locale]/(storefront)/cart/page.tsx @@ -19,14 +19,7 @@ const ExpressCheckoutButton = dynamic( import("@/components/checkout/ExpressCheckoutButton").then((m) => ({ default: m.ExpressCheckoutButton, })), - { - ssr: false, - loading: () => ( -
-
-
- ), - }, + { ssr: false }, ); export default function CartPage() { diff --git a/src/components/checkout/AddressSection.tsx b/src/components/checkout/AddressSection.tsx index b595df54..0d1ca878 100644 --- a/src/components/checkout/AddressSection.tsx +++ b/src/components/checkout/AddressSection.tsx @@ -96,9 +96,15 @@ export function AddressSection({ : undefined; const [email, setEmail] = useState(cart.email || ""); + const defaultCountryIso = countries[0]?.iso ?? ""; + const [shipAddress, setShipAddress] = useState(() => { if (initialSavedAddress) return addressToFormData(initialSavedAddress); const formData = addressToFormData(cart.shipping_address); + // Pre-fill country from the market's first country when empty + if (!formData.country_iso && defaultCountryIso) { + formData.country_iso = defaultCountryIso; + } // Pre-fill name from user profile when address has no name yet if (!formData.first_name && user?.first_name) { formData.first_name = user.first_name; diff --git a/src/components/checkout/AdyenPaymentForm.tsx b/src/components/checkout/AdyenPaymentForm.tsx new file mode 100644 index 00000000..ca6cc7e1 --- /dev/null +++ b/src/components/checkout/AdyenPaymentForm.tsx @@ -0,0 +1,210 @@ +"use client"; + +import { CircleAlert } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import "@adyen/adyen-web/styles/adyen.css"; +import { + adyenClientKey, + adyenEnvironment, + isAdyenConfigured, +} from "@/lib/utils/payment-gateway"; + +export interface AdyenPaymentFormHandle { + confirmPayment: (returnUrl: string) => Promise<{ error?: string }>; + fetchUpdates: () => Promise; +} + +interface AdyenPaymentFormProps { + sessionId: string; + sessionData: string; + onReady: (handle: AdyenPaymentFormHandle) => void; + /** Called when Adyen sessions flow completes payment successfully. */ + onApproved: (sessionResult: string) => void; +} + +function AdyenPaymentFormInner({ + sessionId, + sessionData, + onReady, + onApproved, +}: AdyenPaymentFormProps) { + const containerRef = useRef(null); + const dropinRef = useRef(null); + const [error, setError] = useState(null); + const initializedRef = useRef(false); + const approvedRef = useRef(false); + + const onApprovedRef = useRef(onApproved); + onApprovedRef.current = onApproved; + + // Adyen sessions flow handles payment internally — the Drop-in + // calls onPaymentCompleted when done. confirmPayment just checks + // if the payment was already approved. + const confirmPayment = useCallback( + async (_returnUrl: string): Promise<{ error?: string }> => { + if (approvedRef.current) { + return {}; + } + // Adyen sessions flow handles submit internally. + // Trigger the Drop-in's submit to start the payment. + if (!dropinRef.current) { + return { error: "Adyen has not loaded yet" }; + } + try { + dropinRef.current.submit(); + } catch (err) { + return { + error: + err instanceof Error + ? err.message + : "An error occurred during payment.", + }; + } + // The Drop-in will handle the rest via onPaymentCompleted. + // Return a pending promise — it will be resolved by onApproved + // triggering the checkout completion flow. + return {}; + }, + [], + ); + + const fetchUpdates = useCallback(async () => {}, []); + + useEffect(() => { + if (initializedRef.current) return; + if (!containerRef.current) return; + initializedRef.current = true; + + let cancelled = false; + + async function init() { + try { + const adyen = await import("@adyen/adyen-web"); + const { + AdyenCheckout, + Dropin, + Card, + GooglePay, + ApplePay, + PayPal, + Klarna, + Bancontact, + Redirect, + } = adyen; + if (cancelled || !containerRef.current) return; + + AdyenCheckout.register( + Card, + GooglePay, + ApplePay, + PayPal, + Klarna, + Bancontact, + Redirect, + ); + + const checkout = await AdyenCheckout({ + clientKey: adyenClientKey, + environment: adyenEnvironment, + session: { + id: sessionId, + sessionData, + }, + showPayButton: false, + onPaymentCompleted: (result) => { + if ( + result.resultCode === "Authorised" || + result.resultCode === "Pending" || + result.resultCode === "Received" + ) { + approvedRef.current = true; + setError(null); + // Pass sessionResult to the backend for verification + const sr = + "sessionResult" in result ? result.sessionResult : undefined; + onApprovedRef.current(sr ?? ""); + } else { + const msg = `Payment ${result.resultCode?.toLowerCase() || "failed"}. Please try again.`; + setError(msg); + } + }, + onPaymentFailed: (result) => { + const msg = `Payment ${result?.resultCode?.toLowerCase() || "failed"}. Please try again.`; + setError(msg); + }, + onError: (err) => { + const msg = err?.message || "An error occurred during payment."; + setError(msg); + }, + }); + + if (cancelled || !containerRef.current) return; + + const dropin = new Dropin(checkout, { + openFirstPaymentMethod: true, + paymentMethodComponents: [ + Card, + GooglePay, + ApplePay, + PayPal, + Klarna, + Bancontact, + Redirect, + ], + }); + + // Mount into a non-React-managed DOM node to avoid Preact/React conflicts + const mountNode = document.createElement("div"); + containerRef.current.appendChild(mountNode); + dropin.mount(mountNode); + dropinRef.current = dropin; + onReady({ confirmPayment, fetchUpdates }); + } catch (err) { + initializedRef.current = false; + if (!cancelled) { + setError( + err instanceof Error + ? err.message + : "Failed to initialize payment form.", + ); + } + } + } + + init(); + + return () => { + cancelled = true; + try { + dropinRef.current?.unmount(); + } catch { + // unmount can throw if not mounted + } + if (containerRef.current) { + containerRef.current.innerHTML = ""; + } + initializedRef.current = false; + }; + }, [sessionId, sessionData, onReady, confirmPayment, fetchUpdates]); + + return ( +
+
+ {error && ( + + + {error} + + )} +
+ ); +} + +export function AdyenPaymentForm(props: AdyenPaymentFormProps) { + if (!isAdyenConfigured) { + return null; + } + + return ; +} diff --git a/src/components/checkout/ExpressCheckoutButton.tsx b/src/components/checkout/ExpressCheckoutButton.tsx index 6f74ad29..f90b4405 100644 --- a/src/components/checkout/ExpressCheckoutButton.tsx +++ b/src/components/checkout/ExpressCheckoutButton.tsx @@ -30,7 +30,7 @@ import { buildSpreeAddress, parseName, } from "@/lib/utils/express-checkout"; -import { stripePromise } from "@/lib/utils/stripe"; +import { isStripeConfigured, stripePromise } from "@/lib/utils/stripe"; export interface ExpressCheckoutButtonProps { cart: Cart; @@ -474,7 +474,7 @@ function ExpressCheckoutInner({ ); } -export function ExpressCheckoutButton({ +function ExpressCheckoutWithElements({ cart, basePath, onComplete, @@ -520,3 +520,19 @@ export function ExpressCheckoutButton({ ); } + +export function ExpressCheckoutButton(props: ExpressCheckoutButtonProps) { + const { onAvailabilityChange } = props; + + useEffect(() => { + if (!isStripeConfigured) { + onAvailabilityChange?.(false); + } + }, [onAvailabilityChange]); + + if (!isStripeConfigured) { + return null; + } + + return ; +} diff --git a/src/components/checkout/PayPalPaymentForm.tsx b/src/components/checkout/PayPalPaymentForm.tsx new file mode 100644 index 00000000..7aadceae --- /dev/null +++ b/src/components/checkout/PayPalPaymentForm.tsx @@ -0,0 +1,166 @@ +"use client"; + +import { CircleAlert } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { + isPayPalConfigured, + paypalClientId, +} from "@/lib/utils/payment-gateway"; + +export interface PayPalPaymentFormHandle { + confirmPayment: (returnUrl: string) => Promise<{ error?: string }>; + fetchUpdates: () => Promise; +} + +interface PayPalPaymentFormProps { + paypalOrderId: string; + currency: string; + onReady: (handle: PayPalPaymentFormHandle) => void; + /** Called when user approves payment in the PayPal popup. */ + onApproved: () => void; +} + +function PayPalPaymentFormInner({ + paypalOrderId, + currency, + onReady, + onApproved, +}: PayPalPaymentFormProps) { + const t = useTranslations("checkout"); + const [error, setError] = useState(null); + const approvedRef = useRef(false); + const [approvedForUI, setApprovedForUI] = useState(false); + + // For PayPal, confirmPayment is called by the checkout page's "Place Order" + // button. If the user already approved via the popup, resolve immediately. + // Otherwise return an error telling them to use the PayPal button. + const confirmPayment = useCallback( + async (_returnUrl: string): Promise<{ error?: string }> => { + if (approvedRef.current) { + return {}; + } + return { error: t("paypalApproveFirst") }; + }, + [t], + ); + + const fetchUpdates = useCallback(async () => {}, []); + + // Lazy-load the PayPal SDK components + const [PayPalSDK, setPayPalSDK] = useState<{ + PayPalScriptProvider: typeof import("@paypal/react-paypal-js")["PayPalScriptProvider"]; + PayPalButtons: typeof import("@paypal/react-paypal-js")["PayPalButtons"]; + } | null>(null); + + const onReadyRef = useRef(onReady); + onReadyRef.current = onReady; + const confirmPaymentRef = useRef(confirmPayment); + confirmPaymentRef.current = confirmPayment; + const fetchUpdatesRef = useRef(fetchUpdates); + fetchUpdatesRef.current = fetchUpdates; + + useEffect(() => { + if (PayPalSDK) return; + + let cancelled = false; + + import("@paypal/react-paypal-js") + .then((mod) => { + if (cancelled) return; + setPayPalSDK({ + PayPalScriptProvider: mod.PayPalScriptProvider, + PayPalButtons: mod.PayPalButtons, + }); + onReadyRef.current({ + confirmPayment: (...args) => confirmPaymentRef.current(...args), + fetchUpdates: (...args) => fetchUpdatesRef.current(...args), + }); + }) + .catch((err) => { + if (cancelled) return; + setError( + err instanceof Error + ? err.message + : "Failed to load PayPal. Please refresh and try again.", + ); + }); + + return () => { + cancelled = true; + }; + }, [PayPalSDK]); + + // Stable ref for onApproved to avoid re-renders of PayPalButtons + const onApprovedRef = useRef(onApproved); + onApprovedRef.current = onApproved; + + if (!PayPalSDK) { + return ( +
+
+
+ ); + } + + const { PayPalScriptProvider, PayPalButtons } = PayPalSDK; + + return ( +
+ + paypalOrderId} + onApprove={async () => { + approvedRef.current = true; + setApprovedForUI(true); + setError(null); + // Trigger payment completion directly — no need for + // the user to click "Place Order" again. + onApprovedRef.current(); + }} + onError={(err) => { + const msg = err instanceof Error ? err.message : t("paypalError"); + setError(msg); + }} + onCancel={() => { + setError(t("paypalCancelled")); + }} + /> + + + {approvedForUI && ( +

+ {t("paypalApproved")} +

+ )} + + {error && ( + + + {error} + + )} +
+ ); +} + +export function PayPalPaymentForm(props: PayPalPaymentFormProps) { + if (!isPayPalConfigured) { + return null; + } + + return ; +} diff --git a/src/components/checkout/PaymentSection.tsx b/src/components/checkout/PaymentSection.tsx index 3606ca85..19537e5f 100644 --- a/src/components/checkout/PaymentSection.tsx +++ b/src/components/checkout/PaymentSection.tsx @@ -4,13 +4,14 @@ import type { AddressParams, Cart, Country, + PaymentMethod, CreditCard as SpreeCreditCard, State, } from "@spree/sdk"; -import { CircleAlert, CreditCard, Loader2 } from "lucide-react"; +import { CircleAlert, CreditCard, Info, Loader2 } from "lucide-react"; import { useTranslations } from "next-intl"; import { - forwardRef, + type Ref, useCallback, useEffect, useImperativeHandle, @@ -20,6 +21,14 @@ import { } from "react"; import { PaymentIcon } from "react-svg-credit-card-payment-icons"; import { AddressFormFields } from "@/components/checkout/AddressFormFields"; +import { + AdyenPaymentForm, + type AdyenPaymentFormHandle, +} from "@/components/checkout/AdyenPaymentForm"; +import { + PayPalPaymentForm, + type PayPalPaymentFormHandle, +} from "@/components/checkout/PayPalPaymentForm"; import { confirmWithSavedCard, StripePaymentForm, @@ -29,7 +38,10 @@ import { Checkbox } from "@/components/ui/checkbox"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { useCountryStates } from "@/hooks/useCountryStates"; import { getCreditCards } from "@/lib/data/credit-cards"; -import { createCheckoutPaymentSession } from "@/lib/data/payment"; +import { + createCheckoutPaymentSession, + createDirectPayment, +} from "@/lib/data/payment"; import { type AddressFormData, addressToFormData, @@ -38,12 +50,18 @@ import { } from "@/lib/utils/address"; import { getCardIconType, getCardLabel } from "@/lib/utils/credit-card"; import { extractBasePath } from "@/lib/utils/path"; +import { resolveGatewayId } from "@/lib/utils/payment-gateway"; + +export type PaymentCompleteResult = + | { type: "session"; sessionId: string; sessionResult?: string } + | { type: "direct" }; export interface PaymentSectionHandle { submit: () => Promise<{ error?: string }>; } interface PaymentSectionProps { + ref?: Ref; cart: Cart; countries: Country[]; isAuthenticated: boolean; @@ -52,32 +70,61 @@ interface PaymentSectionProps { billing_address?: AddressParams; use_shipping?: boolean; }) => Promise; - onPaymentComplete: (paymentSessionId: string) => Promise; + onPaymentComplete: (result: PaymentCompleteResult) => Promise; processing: boolean; setProcessing: (processing: boolean) => void; + onSessionMethodChange?: (isSessionBased: boolean) => void; errors?: string[]; } -export const PaymentSection = forwardRef< - PaymentSectionHandle, - PaymentSectionProps ->(function PaymentSection( - { - cart, - countries, - isAuthenticated, - fetchStates, - onUpdateBillingAddress, - onPaymentComplete, - processing, - setProcessing, - errors, - }, +export function PaymentSection({ ref, -) { + cart, + countries, + isAuthenticated, + fetchStates, + onUpdateBillingAddress, + onPaymentComplete, + processing, + setProcessing, + onSessionMethodChange, + errors, +}: PaymentSectionProps) { const t = useTranslations("checkout"); - // Initialize billing address from cart, check if it matches shipping + // ── Payment methods from Spree ────────────────────────────────────── + const paymentMethods = cart.payment_methods ?? []; + const hasMultipleMethods = paymentMethods.length > 1; + + // Default to the first method; fall back if the stored ID becomes stale + // (e.g. cart.payment_methods changes after a shipping update). + const [selectedMethodId, setSelectedMethodId] = useState( + () => paymentMethods[0]?.id ?? "", + ); + const selectedMethod: PaymentMethod | undefined = + paymentMethods.find((pm) => pm.id === selectedMethodId) ?? + paymentMethods[0]; + const effectiveSelectedMethodId = selectedMethod?.id ?? ""; + // Zero-amount check + const amountDue = parseFloat(cart.amount_due ?? cart.total); + const isZeroAmount = amountDue === 0; + + // Free orders are always treated as non-session (no payment needed) + const isSessionBased = + !isZeroAmount && (selectedMethod?.session_required ?? false); + + // Notify parent when session method changes (for button text) + const onSessionMethodChangeRef = useRef(onSessionMethodChange); + onSessionMethodChangeRef.current = onSessionMethodChange; + + const prevIsSessionRef = useRef(null); + useEffect(() => { + if (prevIsSessionRef.current === isSessionBased) return; + prevIsSessionRef.current = isSessionBased; + onSessionMethodChangeRef.current?.(isSessionBased); + }, [isSessionBased]); + + // ── Billing address ───────────────────────────────────────────────── const shipAddressData = useMemo( () => addressToFormData(cart.shipping_address), [cart.shipping_address], @@ -94,59 +141,87 @@ export const PaymentSection = forwardRef< ); const [useShippingForBilling, setUseShippingForBilling] = useState(initialUseShipping); - // Saved cards state + + // ── Saved cards (session-based gateways only) ─────────────────────── const [savedCards, setSavedCards] = useState([]); - // null = "add new payment method", string = gateway_payment_profile_id of selected card + // null = "add new payment method", string = gateway_payment_profile_id const [selectedCardId, setSelectedCardId] = useState(null); - // Payment gateway state - const [clientSecret, setClientSecret] = useState(null); + // ── Payment gateway state (session-based) ─────────────────────────── + // Stores the raw external_data from the Spree PaymentSession. + // Each gateway form extracts what it needs (e.g. client_secret for Stripe, + // session_id + session_data for Adyen). + const [sessionExternalData, setSessionExternalData] = useState | null>(null); const [paymentSessionId, setPaymentSessionId] = useState(null); const [gatewayError, setGatewayError] = useState(null); const [loading, setLoading] = useState(false); - const gatewayHandleRef = useRef(null); + const gatewayHandleRef = useRef< + | StripePaymentFormHandle + | AdyenPaymentFormHandle + | PayPalPaymentFormHandle + | null + >(null); const initRef = useRef(false); - // Monotonic counter to discard stale createSession responses const sessionRequestIdRef = useRef(0); - - const handleGatewayReady = useCallback((handle: StripePaymentFormHandle) => { - gatewayHandleRef.current = handle; - }, []); - - // Find the payment method that requires a session (e.g. Stripe, Adyen) - const sessionPaymentMethod = cart.payment_methods?.find( - (pm) => pm.session_required, + const completionInFlightRef = useRef(false); + + const handleGatewayReady = useCallback( + ( + handle: + | StripePaymentFormHandle + | AdyenPaymentFormHandle + | PayPalPaymentFormHandle, + ) => { + gatewayHandleRef.current = handle; + }, + [], ); - // Helper: create a payment session + // ── Session management ────────────────────────────────────────────── const createSession = useCallback( - async (cardId: string | null) => { - if (!sessionPaymentMethod) return; - + async (cardId: string | null, method: PaymentMethod) => { + const currentGatewayId = resolveGatewayId(method.type); const requestId = ++sessionRequestIdRef.current; setLoading(true); setGatewayError(null); - setClientSecret(null); + setSessionExternalData(null); setPaymentSessionId(null); gatewayHandleRef.current = null; try { + // Build gateway-specific external_data + const basePath = extractBasePath(window.location.pathname); + const returnUrl = `${window.location.origin}${basePath}/confirm-payment/${cart.id}`; + + const externalData: Record = { + return_url: returnUrl, + }; + + if (currentGatewayId === "stripe" && cardId) { + externalData.stripe_payment_method_id = cardId; + } + const result = await createCheckoutPaymentSession( cart.id, - sessionPaymentMethod.id, - cardId ?? undefined, + method.id, + externalData, ); - // Discard if a newer request was started while this one was in flight if (requestId !== sessionRequestIdRef.current) return; if (result.success && result.session) { - const secret = result.session.external_data?.client_secret as - | string - | undefined; - if (secret) { - setClientSecret(secret); + const extData = result.session.external_data; + if (extData && Object.keys(extData).length > 0) { + // Include external_id so gateway forms can access the + // provider-side session/order ID (e.g. Adyen session ID). + setSessionExternalData({ + ...extData, + _external_id: result.session.external_id, + }); setPaymentSessionId(result.session.id); } else { setGatewayError(t("failedToInitPayment")); @@ -163,16 +238,20 @@ export const PaymentSection = forwardRef< } } }, - [sessionPaymentMethod, cart.id, t], + [cart.id, t], ); // Track the cart total so we can recreate the session when it changes const lastTotalRef = useRef(null); const selectedCardRef = useRef(null); - // On mount: load saved cards (if authenticated), then create initial session — once. + // On mount: load saved cards (if authenticated + session method), then create initial session useEffect(() => { - if (initRef.current || !sessionPaymentMethod) return; + if (initRef.current) return; + if (!selectedMethod) return; + if (isZeroAmount) return; + if (!isSessionBased) return; + initRef.current = true; const init = async () => { @@ -180,7 +259,6 @@ export const PaymentSection = forwardRef< let initialCardId: string | null = null; - // Load saved cards for authenticated users if (isAuthenticated) { try { const result = await getCreditCards(); @@ -203,22 +281,28 @@ export const PaymentSection = forwardRef< selectedCardRef.current = initialCardId; lastTotalRef.current = cart.total; - // Create the initial payment session - await createSession(initialCardId); + await createSession(initialCardId, selectedMethod); }; init(); - }, [sessionPaymentMethod, isAuthenticated, createSession, cart.total]); + }, [ + selectedMethod, + isSessionBased, + isAuthenticated, + createSession, + cart.total, + isZeroAmount, + ]); - // When cart total changes (shipping rate, coupon, etc.), recreate the - // payment session so the amount matches the new order total. + // When cart total changes, recreate the payment session useEffect(() => { if (!initRef.current) return; + if (!isSessionBased || !selectedMethod) return; if (lastTotalRef.current === cart.total) return; lastTotalRef.current = cart.total; - createSession(selectedCardRef.current); - }, [cart.total, createSession]); + createSession(selectedCardRef.current, selectedMethod); + }, [cart.total, createSession, isSessionBased, selectedMethod]); const [billStates, isPendingBill] = useCountryStates( billAddress.country_iso, @@ -235,91 +319,279 @@ export const PaymentSection = forwardRef< const handleCardSelect = (cardId: string | null) => { if (cardId === selectedCardId) return; + if (!selectedMethod) return; setSelectedCardId(cardId); selectedCardRef.current = cardId; - createSession(cardId); + createSession(cardId, selectedMethod); + }; + + const handleMethodSelect = (methodId: string) => { + if (methodId === selectedMethodId) return; + setSelectedMethodId(methodId); + + const newMethod = paymentMethods.find((pm) => pm.id === methodId); + if (!newMethod) return; + + if (newMethod.session_required) { + // Switching to a session-based method: create session + // Reset saved cards state — will be re-initialized + if (!initRef.current) { + initRef.current = true; + const init = async () => { + setLoading(true); + let cardId: string | null = null; + + if (isAuthenticated) { + try { + const result = await getCreditCards(); + const gatewayCards = result.data.filter( + (card) => card.gateway_payment_profile_id, + ); + setSavedCards(gatewayCards); + if (gatewayCards.length > 0) { + const defaultCard = + gatewayCards.find((c) => c.default) || gatewayCards[0]; + cardId = defaultCard.gateway_payment_profile_id; + setSelectedCardId(cardId); + } + } catch { + // proceed without saved cards + } + } + + selectedCardRef.current = cardId; + lastTotalRef.current = cart.total; + await createSession(cardId, newMethod); + }; + init(); + } else { + createSession(selectedCardRef.current, newMethod); + } + } else { + // Switching to a direct method: invalidate any in-flight session + // request so a late-resolving createSession won't repopulate state. + sessionRequestIdRef.current += 1; + setSessionExternalData(null); + setPaymentSessionId(null); + setGatewayError(null); + gatewayHandleRef.current = null; + setLoading(false); + } }; const updateBillAddress = (field: keyof AddressFormData, value: string) => { setBillAddress((prev) => updateAddressField(prev, field, value)); }; - // Expose submit handle to parent via ref + // ── Auto-complete after gateway approval (PayPal popup / Adyen sessions) ── + const handleGatewayApproved = useCallback( + async (sessionResult?: string) => { + if (completionInFlightRef.current) return; + if (!paymentSessionId) return; + + completionInFlightRef.current = true; + setProcessing(true); + setGatewayError(null); + + try { + // Update billing address + let addressSuccess: boolean; + if (useShippingForBilling) { + addressSuccess = await onUpdateBillingAddress({ + use_shipping: true, + }); + } else { + const billingData = formDataToAddress(billAddress); + addressSuccess = await onUpdateBillingAddress({ + billing_address: billingData, + }); + } + + if (!addressSuccess) { + setProcessing(false); + completionInFlightRef.current = false; + setGatewayError(t("failedToSaveBilling")); + return; + } + + await onPaymentComplete({ + type: "session", + sessionId: paymentSessionId, + sessionResult, + }); + } catch { + setGatewayError(t("paymentError")); + setProcessing(false); + completionInFlightRef.current = false; + } + }, + [ + paymentSessionId, + useShippingForBilling, + billAddress, + onUpdateBillingAddress, + onPaymentComplete, + setProcessing, + t, + ], + ); + + // ── Submit ────────────────────────────────────────────────────────── useImperativeHandle( ref, () => ({ submit: async () => { - if (!paymentSessionId || !clientSecret) { - return { error: t("failedToInitPayment") }; - } - if (!selectedCardId && !gatewayHandleRef.current) { - return { error: t("failedToInitPayment") }; - } - - setProcessing(true); - setGatewayError(null); + if (completionInFlightRef.current) return {}; + completionInFlightRef.current = true; try { - // 1. Update billing address - let addressSuccess: boolean; - if (useShippingForBilling) { - addressSuccess = await onUpdateBillingAddress({ - use_shipping: true, - }); - } else { - const billingData = formDataToAddress(billAddress); - addressSuccess = await onUpdateBillingAddress({ - billing_address: billingData, - }); + // Zero amount — no payment needed + if (isZeroAmount) { + setProcessing(true); + try { + // Still update billing address + let addressSuccess: boolean; + if (useShippingForBilling) { + addressSuccess = await onUpdateBillingAddress({ + use_shipping: true, + }); + } else { + const billingData = formDataToAddress(billAddress); + addressSuccess = await onUpdateBillingAddress({ + billing_address: billingData, + }); + } + if (!addressSuccess) { + setProcessing(false); + return { error: t("failedToSaveBilling") }; + } + await onPaymentComplete({ type: "direct" }); + return {}; + } catch { + const msg = t("paymentError"); + setProcessing(false); + return { error: msg }; + } } - if (!addressSuccess) { - setProcessing(false); - return { error: t("failedToSaveBilling") }; + if (!selectedMethod) { + return { error: t("selectPaymentMethod") }; } - // 2. Confirm payment with gateway - // Point to the confirm-payment intermediate page so that offsite - // gateways (3D Secure, redirect-based) can verify the payment - // session before completing the cart. - const basePath = extractBasePath(window.location.pathname); - const returnUrl = `${window.location.origin}${basePath}/confirm-payment/${cart.id}?session=${paymentSessionId}`; + setProcessing(true); + setGatewayError(null); + + try { + // 1. Update billing address + let addressSuccess: boolean; + if (useShippingForBilling) { + addressSuccess = await onUpdateBillingAddress({ + use_shipping: true, + }); + } else { + const billingData = formDataToAddress(billAddress); + addressSuccess = await onUpdateBillingAddress({ + billing_address: billingData, + }); + } - let error: string | undefined; + if (!addressSuccess) { + setProcessing(false); + return { error: t("failedToSaveBilling") }; + } - if (selectedCardId) { - const result = await confirmWithSavedCard( - clientSecret, - selectedCardId, - returnUrl, + // 2. Process payment based on method type + if (selectedMethod.session_required) { + // Session-based flow (Stripe, Adyen, etc.) + if (!paymentSessionId || !sessionExternalData) { + setProcessing(false); + return { error: t("failedToInitPayment") }; + } + const basePath = extractBasePath(window.location.pathname); + const returnUrl = `${window.location.origin}${basePath}/confirm-payment/${cart.id}?session=${paymentSessionId}`; + + let error: string | undefined; + + const clientSecret = sessionExternalData.client_secret as + | string + | undefined; + const gatewayId = resolveGatewayId(selectedMethod.type); + const isStripe = gatewayId === "stripe"; + const isApprovalDriven = + gatewayId === "adyen" || gatewayId === "paypal"; + const canUseSavedCard = + isStripe && Boolean(selectedCardId && clientSecret); + + if (!canUseSavedCard && !gatewayHandleRef.current) { + setProcessing(false); + return { error: t("failedToInitPayment") }; + } + + if (canUseSavedCard) { + // Stripe saved card flow + const result = await confirmWithSavedCard( + clientSecret!, + selectedCardId!, + returnUrl, + ); + error = result.error; + } else { + // New payment via gateway handle (Stripe PaymentElement, Adyen Drop-in, etc.) + const result = + await gatewayHandleRef.current!.confirmPayment(returnUrl); + error = result.error; + } + + if (error) { + setGatewayError(error); + setProcessing(false); + return { error }; + } + + // Approval-driven gateways (Adyen, PayPal) complete the order + // via handleGatewayApproved when their callback fires — don't + // call onPaymentComplete here or we'll race with the callback. + if (isApprovalDriven) { + return {}; + } + + await onPaymentComplete({ + type: "session", + sessionId: paymentSessionId, + }); + return {}; + } + + // Direct payment flow (Check, Cash on Delivery, etc.) + const paymentResult = await createDirectPayment( + cart.id, + selectedMethod.id, ); - error = result.error; - } else { - const result = - await gatewayHandleRef.current!.confirmPayment(returnUrl); - error = result.error; - } + if (!paymentResult.success) { + const msg = paymentResult.error || t("failedToCreatePayment"); + setGatewayError(msg); + setProcessing(false); + return { error: msg }; + } - if (error) { - setGatewayError(error); + await onPaymentComplete({ type: "direct" }); + return {}; + } catch { + const msg = t("paymentError"); + setGatewayError(msg); setProcessing(false); - return { error }; + return { error: msg }; } - - // 3. Payment succeeded — complete session and cart - await onPaymentComplete(paymentSessionId); - return {}; - } catch { - const msg = t("paymentError"); - setGatewayError(msg); - setProcessing(false); - return { error: msg }; + } finally { + completionInFlightRef.current = false; } }, }), [ + isZeroAmount, + selectedMethod, paymentSessionId, - clientSecret, + sessionExternalData, selectedCardId, useShippingForBilling, billAddress, @@ -333,19 +605,77 @@ export const PaymentSection = forwardRef< const isAddingNew = selectedCardId === null; + // ── Zero amount: no payment required ──────────────────────────────── + if (isZeroAmount) { + return ( +
+

+ {t("paymentMethod")} +

+
+ +

{t("noPaymentRequired")}

+
+ + {/* Billing address */} +
+ + {!useShippingForBilling && ( +
+ +
+ )} +
+
+ ); + } + + // ── No payment methods available ──────────────────────────────────── + if (paymentMethods.length === 0) { + return ( +
+

+ {t("paymentMethod")} +

+
+ +

{t("noPaymentMethods")}

+
+
+ ); + } + + // ── Render ────────────────────────────────────────────────────────── return (
{/* Section Header */}

{t("paymentMethod")}

{t("secureTransactions")}

- {/* Demo-only: Remove for production. */} -

- {t("testCardNote", { testCard: "4242 4242 4242 4242" })} -

{/* Inline requirement errors from parent */} {errors && errors.length > 0 && ( -
+
{errors.map((err, i) => (

{err} @@ -354,126 +684,253 @@ export const PaymentSection = forwardRef<

)} - {/* Payment method bordered container — Shopify style */} + {/* Payment methods */} - handleCardSelect(val === "__new__" ? null : val) - } - className="rounded-sm border overflow-hidden gap-0" + value={effectiveSelectedMethodId} + onValueChange={handleMethodSelect} + className="rounded-sm border overflow-hidden gap-0 mt-3" > - {/* Saved Cards */} - {savedCards.length > 0 && ( - <> - {savedCards.map((card, index) => ( -