From 8a15699d379649e6744322aaf91be9fe19ba515e Mon Sep 17 00:00:00 2001 From: Ben Halverson <7907232+benhalverson@users.noreply.github.com> Date: Sun, 6 Sep 2026 17:00:10 -0700 Subject: [PATCH] Replace Stripe Elements with Square Web Payments --- .env.production | 4 +- .env.sample | 4 +- package.json | 2 - pnpm-lock.yaml | 157 +++++++++-------- src/App.tsx | 2 - src/components/SquarePaymentForm.tsx | 138 +++++++++++++++ src/config.ts | 4 + src/pages/Checkout.test.tsx | 132 --------------- src/pages/Checkout.tsx | 241 +-------------------------- src/pages/Payment.tsx | 99 ----------- src/pages/Product.tsx | 2 - 11 files changed, 243 insertions(+), 542 deletions(-) create mode 100644 src/components/SquarePaymentForm.tsx delete mode 100644 src/pages/Checkout.test.tsx delete mode 100644 src/pages/Payment.tsx diff --git a/.env.production b/.env.production index 24ff172..3c1cab7 100644 --- a/.env.production +++ b/.env.production @@ -1,3 +1,5 @@ VITE_BASE_URL=https://api.benhalverson.dev VITE_DOMAIN=https://rc-store.benhalverson.dev -VITE_STRIPE_PUBLISHABLE_KEY=pk_test_51RUggnFtN1eiSjAecw8NdboDU4D9MjxRBIlKSQj2y78HuLDK7z1h26EfEp7RUpro2jdYvW9Uzu4FA6SHoiVOEg0o00rPmfffwj \ No newline at end of file +VITE_SQUARE_APPLICATION_ID=replace-with-square-application-id +VITE_SQUARE_LOCATION_ID=replace-with-square-location-id +VITE_SQUARE_ENVIRONMENT=production diff --git a/.env.sample b/.env.sample index 92bcf69..44efae9 100644 --- a/.env.sample +++ b/.env.sample @@ -1,3 +1,5 @@ VITE_BASE_URL="" VITE_DOMAIN="" -VITE_STRIPE_PUBLISHABLE_KEY="" \ No newline at end of file +VITE_SQUARE_APPLICATION_ID="" +VITE_SQUARE_LOCATION_ID="" +VITE_SQUARE_ENVIRONMENT="sandbox" diff --git a/package.json b/package.json index a371d87..9bb4924 100644 --- a/package.json +++ b/package.json @@ -25,8 +25,6 @@ "@hookform/resolvers": "^5.2.2", "@react-three/drei": "^9.114.3", "@react-three/fiber": "^8.17.10", - "@stripe/react-stripe-js": "^5.4.1", - "@stripe/stripe-js": "^8.5.3", "@tailwindcss/aspect-ratio": "^0.4.2", "@tailwindcss/forms": "^0.5.10", "@tailwindcss/typography": "^0.5.19", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2bd4e96..38627bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,12 +23,6 @@ importers: '@react-three/fiber': specifier: ^8.17.10 version: 8.18.0(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.180.0) - '@stripe/react-stripe-js': - specifier: ^5.4.1 - version: 5.4.1(@stripe/stripe-js@8.5.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@stripe/stripe-js': - specifier: ^8.5.3 - version: 8.5.3 '@tailwindcss/aspect-ratio': specifier: ^0.4.2 version: 0.4.2(tailwindcss@4.1.16) @@ -107,22 +101,22 @@ importers: version: 7.0.0-dev.20251027.1 '@vitejs/plugin-react': specifier: ^5.1.0 - version: 5.1.0(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.7.0)) + version: 5.1.0(supports-color@10.2.2)(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.7.0)) '@vitejs/plugin-react-swc': specifier: ^4.2.0 version: 4.2.0(@swc/helpers@0.5.17)(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.7.0)) '@vitest/browser': specifier: ^4.0.4 - version: 4.0.4(msw@2.7.3(@types/node@24.9.1)(typescript@5.9.3))(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.7.0))(vitest@4.0.4(@types/node@24.9.1)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.6))(lightningcss@1.30.2)(msw@2.7.3(@types/node@24.9.1)(typescript@5.9.3))(yaml@2.7.0)) + version: 4.0.4(msw@2.7.3(@types/node@24.9.1)(typescript@5.9.3))(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.7.0))(vitest@4.0.4(@types/node@24.9.1)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.6)(supports-color@10.2.2))(lightningcss@1.30.2)(msw@2.7.3(@types/node@24.9.1)(typescript@5.9.3))(supports-color@10.2.2)(yaml@2.7.0)) '@vitest/coverage-v8': specifier: ^4.0.4 - version: 4.0.4(@vitest/browser@4.0.4(msw@2.7.3(@types/node@24.9.1)(typescript@5.9.3))(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.7.0))(vitest@4.0.4(@types/node@24.9.1)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.6))(lightningcss@1.30.2)(msw@2.7.3(@types/node@24.9.1)(typescript@5.9.3))(yaml@2.7.0)))(vitest@4.0.4(@types/node@24.9.1)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.6))(lightningcss@1.30.2)(msw@2.7.3(@types/node@24.9.1)(typescript@5.9.3))(yaml@2.7.0)) + version: 4.0.4(@vitest/browser@4.0.4(msw@2.7.3(@types/node@24.9.1)(typescript@5.9.3))(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.7.0))(vitest@4.0.4(@types/node@24.9.1)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.6)(supports-color@10.2.2))(lightningcss@1.30.2)(msw@2.7.3(@types/node@24.9.1)(typescript@5.9.3))(supports-color@10.2.2)(yaml@2.7.0)))(supports-color@10.2.2)(vitest@4.0.4(@types/node@24.9.1)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.6)(supports-color@10.2.2))(lightningcss@1.30.2)(msw@2.7.3(@types/node@24.9.1)(typescript@5.9.3))(supports-color@10.2.2)(yaml@2.7.0)) autoprefixer: specifier: ^10.4.21 version: 10.4.21(postcss@8.5.6) jsdom: specifier: ^27.0.1 - version: 27.0.1(postcss@8.5.6) + version: 27.0.1(postcss@8.5.6)(supports-color@10.2.2) playwright: specifier: ^1.56.1 version: 1.56.1 @@ -140,7 +134,7 @@ importers: version: 7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.7.0) vitest: specifier: ^4.0.4 - version: 4.0.4(@types/node@24.9.1)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.6))(lightningcss@1.30.2)(msw@2.7.3(@types/node@24.9.1)(typescript@5.9.3))(yaml@2.7.0) + version: 4.0.4(@types/node@24.9.1)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.6)(supports-color@10.2.2))(lightningcss@1.30.2)(msw@2.7.3(@types/node@24.9.1)(typescript@5.9.3))(supports-color@10.2.2)(yaml@2.7.0) wrangler: specifier: ^4.45.0 version: 4.45.0(@cloudflare/workers-types@4.20251014.0) @@ -280,24 +274,28 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [musl] '@biomejs/cli-linux-arm64@2.3.1': resolution: {integrity: sha512-td5O8pFIgLs8H1sAZsD6v+5quODihyEw4nv2R8z7swUfIK1FKk+15e4eiYVLcAE4jUqngvh4j3JCNgg0Y4o4IQ==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [glibc] '@biomejs/cli-linux-x64-musl@2.3.1': resolution: {integrity: sha512-Y3Ob4nqgv38Mh+6EGHltuN+Cq8aj/gyMTJYzkFZV2AEj+9XzoXB9VNljz9pjfFNHUxvLEV4b55VWyxozQTBaUQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [musl] '@biomejs/cli-linux-x64@2.3.1': resolution: {integrity: sha512-PYWgEO7up7XYwSAArOpzsVCiqxBCXy53gsReAb1kKYIyXaoAlhBaBMvxR/k2Rm9aTuZ662locXUmPk/Aj+Xu+Q==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [glibc] '@biomejs/cli-win32-arm64@2.3.1': resolution: {integrity: sha512-RHIG/zgo+69idUqVvV3n8+j58dKYABRpMyDmfWu2TITC+jwGPiEaT0Q3RKD+kQHiS80mpBrST0iUGeEXT0bU9A==} @@ -780,67 +778,79 @@ packages: resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.0.5': resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.0.4': resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.0.4': resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.0.4': resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.0.4': resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.33.5': resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.33.5': resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.33.5': resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.33.5': resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.33.5': resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.33.5': resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.33.5': resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==} @@ -1096,56 +1106,67 @@ packages: resolution: {integrity: sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.52.5': resolution: {integrity: sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.52.5': resolution: {integrity: sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.52.5': resolution: {integrity: sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.52.5': resolution: {integrity: sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.52.5': resolution: {integrity: sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.52.5': resolution: {integrity: sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.52.5': resolution: {integrity: sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.52.5': resolution: {integrity: sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.52.5': resolution: {integrity: sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.52.5': resolution: {integrity: sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openharmony-arm64@4.52.5': resolution: {integrity: sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==} @@ -1188,17 +1209,6 @@ packages: '@standard-schema/utils@0.3.0': resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} - '@stripe/react-stripe-js@5.4.1': - resolution: {integrity: sha512-ipeYcAHa4EPmjwfv0lFE+YDVkOQ0TMKkFWamW+BqmnSkEln/hO8rmxGPPWcd9WjqABx6Ro8Xg4pAS7evCcR9cw==} - peerDependencies: - '@stripe/stripe-js': '>=8.0.0 <9.0.0' - react: '>=16.8.0 <20.0.0' - react-dom: '>=16.8.0 <20.0.0' - - '@stripe/stripe-js@8.5.3': - resolution: {integrity: sha512-UM0GHAxlTN7v0lCK2P6t0VOlvBIdApIQxhnM3yZ2kupQ4PpSrLsK/n/NyYKtw2NJGMaNRRD1IicWS7fSL2sFtA==} - engines: {node: '>=12.16'} - '@swc/core-darwin-arm64@1.13.5': resolution: {integrity: sha512-lKNv7SujeXvKn16gvQqUQI5DdyY8v7xcoO3k06/FJbHJS90zEwZdQiMNRiqpYw/orU543tPaWgz7cIYWhbopiQ==} engines: {node: '>=10'} @@ -1222,24 +1232,28 @@ packages: engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [glibc] '@swc/core-linux-arm64-musl@1.13.5': resolution: {integrity: sha512-9+ZxFN5GJag4CnYnq6apKTnnezpfJhCumyz0504/JbHLo+Ue+ZtJnf3RhyA9W9TINtLE0bC4hKpWi8ZKoETyOQ==} engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [musl] '@swc/core-linux-x64-gnu@1.13.5': resolution: {integrity: sha512-WD530qvHrki8Ywt/PloKUjaRKgstQqNGvmZl54g06kA+hqtSE2FTG9gngXr3UJxYu/cNAjJYiBifm7+w4nbHbA==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [glibc] '@swc/core-linux-x64-musl@1.13.5': resolution: {integrity: sha512-Luj8y4OFYx4DHNQTWjdIuKTq2f5k6uSXICqx+FSabnXptaOBAbJHNbHT/06JZh6NRUouaf0mYXN0mcsqvkhd7Q==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [musl] '@swc/core-win32-arm64-msvc@1.13.5': resolution: {integrity: sha512-cZ6UpumhF9SDJvv4DA2fo9WIzlNFuKSkZpZmPG1c+4PFSEMy5DFOjBSllCvnqihCabzXzpn6ykCwBmHpy31vQw==} @@ -1325,24 +1339,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.16': resolution: {integrity: sha512-H81UXMa9hJhWhaAUca6bU2wm5RRFpuHImrwXBUvPbYb+3jo32I9VIwpOX6hms0fPmA6f2pGVlybO6qU8pF4fzQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.16': resolution: {integrity: sha512-ZGHQxDtFC2/ruo7t99Qo2TTIvOERULPl5l0K1g0oK6b5PGqjYMga+FcY1wIUnrUxY56h28FxybtDEla+ICOyew==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.16': resolution: {integrity: sha512-Oi1tAaa0rcKf1Og9MzKeINZzMLPbhxvm7rno5/zuP1WYmpiG0bEHq4AcRUiG2165/WUzvxkW4XDYCscZWbTLZw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.16': resolution: {integrity: sha512-B01u/b8LteGRwucIBmCQ07FVXLzImWESAIMcUU6nvFt/tYsQ6IHz8DmZ5KtvmwxD+iTYBtM1xwoGXswnlu9v0Q==} @@ -2133,24 +2151,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} @@ -2998,20 +3020,20 @@ snapshots: '@babel/compat-data@7.28.5': {} - '@babel/core@7.28.5': + '@babel/core@7.28.5(supports-color@10.2.2)': dependencies: '@babel/code-frame': 7.27.1 '@babel/generator': 7.28.5 '@babel/helper-compilation-targets': 7.27.2 - '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5) + '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5(supports-color@10.2.2))(supports-color@10.2.2) '@babel/helpers': 7.28.4 '@babel/parser': 7.28.5 '@babel/template': 7.27.2 - '@babel/traverse': 7.28.5 + '@babel/traverse': 7.28.5(supports-color@10.2.2) '@babel/types': 7.28.5 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -3036,19 +3058,19 @@ snapshots: '@babel/helper-globals@7.28.0': {} - '@babel/helper-module-imports@7.27.1': + '@babel/helper-module-imports@7.27.1(supports-color@10.2.2)': dependencies: - '@babel/traverse': 7.28.5 + '@babel/traverse': 7.28.5(supports-color@10.2.2) '@babel/types': 7.28.5 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.5)': + '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.5(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-module-imports': 7.27.1 + '@babel/core': 7.28.5(supports-color@10.2.2) + '@babel/helper-module-imports': 7.27.1(supports-color@10.2.2) '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.28.5 + '@babel/traverse': 7.28.5(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -3069,14 +3091,14 @@ snapshots: dependencies: '@babel/types': 7.28.5 - '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.5)': + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.5(supports-color@10.2.2))': dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.28.5(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.28.5)': + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.28.5(supports-color@10.2.2))': dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.28.5(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.27.1 '@babel/runtime@7.28.4': {} @@ -3089,7 +3111,7 @@ snapshots: '@babel/parser': 7.28.5 '@babel/types': 7.28.5 - '@babel/traverse@7.28.5': + '@babel/traverse@7.28.5(supports-color@10.2.2)': dependencies: '@babel/code-frame': 7.27.1 '@babel/generator': 7.28.5 @@ -3097,7 +3119,7 @@ snapshots: '@babel/parser': 7.28.5 '@babel/template': 7.27.2 '@babel/types': 7.28.5 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -3825,15 +3847,6 @@ snapshots: '@standard-schema/utils@0.3.0': {} - '@stripe/react-stripe-js@5.4.1(@stripe/stripe-js@8.5.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@stripe/stripe-js': 8.5.3 - prop-types: 15.8.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - '@stripe/stripe-js@8.5.3': {} - '@swc/core-darwin-arm64@1.13.5': optional: true @@ -4177,11 +4190,11 @@ snapshots: transitivePeerDependencies: - '@swc/helpers' - '@vitejs/plugin-react@5.1.0(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.7.0))': + '@vitejs/plugin-react@5.1.0(supports-color@10.2.2)(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.7.0))': dependencies: - '@babel/core': 7.28.5 - '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.5) + '@babel/core': 7.28.5(supports-color@10.2.2) + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5(supports-color@10.2.2)) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.5(supports-color@10.2.2)) '@rolldown/pluginutils': 1.0.0-beta.43 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 @@ -4189,7 +4202,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/browser@4.0.4(msw@2.7.3(@types/node@24.9.1)(typescript@5.9.3))(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.7.0))(vitest@4.0.4(@types/node@24.9.1)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.6))(lightningcss@1.30.2)(msw@2.7.3(@types/node@24.9.1)(typescript@5.9.3))(yaml@2.7.0))': + '@vitest/browser@4.0.4(msw@2.7.3(@types/node@24.9.1)(typescript@5.9.3))(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.7.0))(vitest@4.0.4(@types/node@24.9.1)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.6)(supports-color@10.2.2))(lightningcss@1.30.2)(msw@2.7.3(@types/node@24.9.1)(typescript@5.9.3))(supports-color@10.2.2)(yaml@2.7.0))': dependencies: '@vitest/mocker': 4.0.4(msw@2.7.3(@types/node@24.9.1)(typescript@5.9.3))(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.7.0)) '@vitest/utils': 4.0.4 @@ -4198,7 +4211,7 @@ snapshots: pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.0.3 - vitest: 4.0.4(@types/node@24.9.1)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.6))(lightningcss@1.30.2)(msw@2.7.3(@types/node@24.9.1)(typescript@5.9.3))(yaml@2.7.0) + vitest: 4.0.4(@types/node@24.9.1)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.6)(supports-color@10.2.2))(lightningcss@1.30.2)(msw@2.7.3(@types/node@24.9.1)(typescript@5.9.3))(supports-color@10.2.2)(yaml@2.7.0) ws: 8.18.3 transitivePeerDependencies: - bufferutil @@ -4206,22 +4219,22 @@ snapshots: - utf-8-validate - vite - '@vitest/coverage-v8@4.0.4(@vitest/browser@4.0.4(msw@2.7.3(@types/node@24.9.1)(typescript@5.9.3))(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.7.0))(vitest@4.0.4(@types/node@24.9.1)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.6))(lightningcss@1.30.2)(msw@2.7.3(@types/node@24.9.1)(typescript@5.9.3))(yaml@2.7.0)))(vitest@4.0.4(@types/node@24.9.1)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.6))(lightningcss@1.30.2)(msw@2.7.3(@types/node@24.9.1)(typescript@5.9.3))(yaml@2.7.0))': + '@vitest/coverage-v8@4.0.4(@vitest/browser@4.0.4(msw@2.7.3(@types/node@24.9.1)(typescript@5.9.3))(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.7.0))(vitest@4.0.4(@types/node@24.9.1)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.6)(supports-color@10.2.2))(lightningcss@1.30.2)(msw@2.7.3(@types/node@24.9.1)(typescript@5.9.3))(supports-color@10.2.2)(yaml@2.7.0)))(supports-color@10.2.2)(vitest@4.0.4(@types/node@24.9.1)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.6)(supports-color@10.2.2))(lightningcss@1.30.2)(msw@2.7.3(@types/node@24.9.1)(typescript@5.9.3))(supports-color@10.2.2)(yaml@2.7.0))': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.0.4 ast-v8-to-istanbul: 0.3.8 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 - istanbul-lib-source-maps: 5.0.6 + istanbul-lib-source-maps: 5.0.6(supports-color@10.2.2) istanbul-reports: 3.2.0 magicast: 0.3.5 std-env: 3.10.0 tinyrainbow: 3.0.3 - vitest: 4.0.4(@types/node@24.9.1)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.6))(lightningcss@1.30.2)(msw@2.7.3(@types/node@24.9.1)(typescript@5.9.3))(yaml@2.7.0) + vitest: 4.0.4(@types/node@24.9.1)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.6)(supports-color@10.2.2))(lightningcss@1.30.2)(msw@2.7.3(@types/node@24.9.1)(typescript@5.9.3))(supports-color@10.2.2)(yaml@2.7.0) optionalDependencies: - '@vitest/browser': 4.0.4(msw@2.7.3(@types/node@24.9.1)(typescript@5.9.3))(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.7.0))(vitest@4.0.4(@types/node@24.9.1)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.6))(lightningcss@1.30.2)(msw@2.7.3(@types/node@24.9.1)(typescript@5.9.3))(yaml@2.7.0)) + '@vitest/browser': 4.0.4(msw@2.7.3(@types/node@24.9.1)(typescript@5.9.3))(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.7.0))(vitest@4.0.4(@types/node@24.9.1)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.6)(supports-color@10.2.2))(lightningcss@1.30.2)(msw@2.7.3(@types/node@24.9.1)(typescript@5.9.3))(supports-color@10.2.2)(yaml@2.7.0)) transitivePeerDependencies: - supports-color @@ -4415,9 +4428,11 @@ snapshots: whatwg-mimetype: 4.0.0 whatwg-url: 15.1.0 - debug@4.4.3: + debug@4.4.3(supports-color@10.2.2): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.2 decimal.js@10.6.0: {} @@ -4587,17 +4602,17 @@ snapshots: html-escaper@2.0.2: {} - http-proxy-agent@7.0.2: + http-proxy-agent@7.0.2(supports-color@10.2.2): dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color - https-proxy-agent@7.0.6: + https-proxy-agent@7.0.6(supports-color@10.2.2): dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -4640,10 +4655,10 @@ snapshots: make-dir: 4.0.0 supports-color: 7.2.0 - istanbul-lib-source-maps@5.0.6: + istanbul-lib-source-maps@5.0.6(supports-color@10.2.2): dependencies: '@jridgewell/trace-mapping': 0.3.31 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) istanbul-lib-coverage: 3.2.2 transitivePeerDependencies: - supports-color @@ -4703,15 +4718,15 @@ snapshots: js-tokens@9.0.1: {} - jsdom@27.0.1(postcss@8.5.6): + jsdom@27.0.1(postcss@8.5.6)(supports-color@10.2.2): dependencies: '@asamuzakjp/dom-selector': 6.7.3 cssstyle: 5.3.1(postcss@8.5.6) data-urls: 6.0.0 decimal.js: 10.6.0 html-encoding-sniffer: 4.0.0 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 + http-proxy-agent: 7.0.2(supports-color@10.2.2) + https-proxy-agent: 7.0.6(supports-color@10.2.2) is-potential-custom-element-name: 1.0.1 parse5: 8.0.0 rrweb-cssom: 0.8.0 @@ -5363,7 +5378,7 @@ snapshots: lightningcss: 1.30.2 yaml: 2.7.0 - vitest@4.0.4(@types/node@24.9.1)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.6))(lightningcss@1.30.2)(msw@2.7.3(@types/node@24.9.1)(typescript@5.9.3))(yaml@2.7.0): + vitest@4.0.4(@types/node@24.9.1)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.6)(supports-color@10.2.2))(lightningcss@1.30.2)(msw@2.7.3(@types/node@24.9.1)(typescript@5.9.3))(supports-color@10.2.2)(yaml@2.7.0): dependencies: '@vitest/expect': 4.0.4 '@vitest/mocker': 4.0.4(msw@2.7.3(@types/node@24.9.1)(typescript@5.9.3))(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.7.0)) @@ -5372,7 +5387,7 @@ snapshots: '@vitest/snapshot': 4.0.4 '@vitest/spy': 4.0.4 '@vitest/utils': 4.0.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) es-module-lexer: 1.7.0 expect-type: 1.2.2 magic-string: 0.30.21 @@ -5387,7 +5402,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.9.1 - jsdom: 27.0.1(postcss@8.5.6) + jsdom: 27.0.1(postcss@8.5.6)(supports-color@10.2.2) transitivePeerDependencies: - jiti - less diff --git a/src/App.tsx b/src/App.tsx index 7cc2e9c..f6422b7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,7 +9,6 @@ import { ColorProvider } from "./context/ColorContext"; // Lazy load pages for code-splitting const Cart = lazy(() => import("./pages/Cart")); const Checkout = lazy(() => import("./pages/Checkout")); -const Payment = lazy(() => import("./pages/Payment")); const OrderComplete = lazy(() => import("./pages/OrderComplete")); const ProductPage = lazy(() => import("./pages/Product")); const ProductList = lazy(() => import("./pages/ProductList")); @@ -40,7 +39,6 @@ function App() { } /> } /> } /> - } /> } /> {/* Route to ProductPage with a dynamic product ID */} diff --git a/src/components/SquarePaymentForm.tsx b/src/components/SquarePaymentForm.tsx new file mode 100644 index 0000000..3d68164 --- /dev/null +++ b/src/components/SquarePaymentForm.tsx @@ -0,0 +1,138 @@ +import { useEffect, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { BASE_URL, SQUARE_APPLICATION_ID, SQUARE_LOCATION_ID } from "../config"; + +type SquareCard = { + attach(selector: string): Promise; + destroy(): Promise; + tokenize(): Promise<{ status: string; token?: string; errors?: unknown }>; +}; + +declare global { + interface Window { + Square?: { + payments(applicationId: string, locationId: string): Promise<{ + card(): Promise; + }>; + }; + } +} + +const SDK_URL = + import.meta.env.VITE_SQUARE_ENVIRONMENT === "production" + ? "https://web.squarecdn.com/v1/square.js" + : "https://sandbox.web.squarecdn.com/v1/square.js"; + +async function loadSquareSdk() { + if (window.Square) return; + await new Promise((resolve, reject) => { + const existing = document.querySelector( + `script[src="${SDK_URL}"]`, + ); + if (existing) { + existing.addEventListener("load", () => resolve(), { once: true }); + existing.addEventListener("error", () => reject(new Error("Square failed to load")), { + once: true, + }); + return; + } + const script = document.createElement("script"); + script.src = SDK_URL; + script.async = true; + script.onload = () => resolve(); + script.onerror = () => reject(new Error("Square failed to load")); + document.head.appendChild(script); + }); +} + +export function SquarePaymentForm({ + cartId, + customerEmail, + disabled, + onError, +}: { + cartId: string; + customerEmail?: string; + disabled?: boolean; + onError: (message: string | null) => void; +}) { + const navigate = useNavigate(); + const cardRef = useRef(null); + const idempotencyKeyRef = useRef(crypto.randomUUID()); + const [ready, setReady] = useState(false); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + let active = true; + void (async () => { + try { + await loadSquareSdk(); + if (!window.Square) throw new Error("Square is unavailable"); + const payments = await window.Square.payments( + SQUARE_APPLICATION_ID, + SQUARE_LOCATION_ID, + ); + const card = await payments.card(); + await card.attach("#square-card-container"); + if (active) { + cardRef.current = card; + setReady(true); + } else { + await card.destroy(); + } + } catch (error) { + onError(error instanceof Error ? error.message : "Payment form failed to load"); + } + })(); + return () => { + active = false; + const card = cardRef.current; + cardRef.current = null; + if (card) void card.destroy(); + }; + }, [onError]); + + const pay = async () => { + if (!cardRef.current) return; + setSubmitting(true); + onError(null); + try { + const tokenResult = await cardRef.current.tokenize(); + if (tokenResult.status !== "OK" || !tokenResult.token) { + throw new Error("Please check your payment details and try again"); + } + const response = await fetch(`${BASE_URL}/cart/${cartId}/square-payment`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + sourceId: tokenResult.token, + idempotencyKey: idempotencyKeyRef.current, + customerEmail, + }), + }); + const payload = (await response.json()) as { error?: string; orderId?: string }; + if (!response.ok) throw new Error(payload.error ?? "Payment failed"); + navigate("/order/complete", { state: { orderId: payload.orderId } }); + } catch (error) { + onError(error instanceof Error ? error.message : "Payment failed"); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+ +

Payments processed securely by Square

+
+ ); +} diff --git a/src/config.ts b/src/config.ts index 370afc7..9163d7a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,9 +3,13 @@ import { z } from "zod"; const zEnv = z.object({ VITE_BASE_URL: z.string().url(), VITE_DOMAIN: z.string().min(1).max(100), + VITE_SQUARE_APPLICATION_ID: z.string().min(1), + VITE_SQUARE_LOCATION_ID: z.string().min(1), }); const parsed = zEnv.parse(import.meta.env); export const BASE_URL = parsed.VITE_BASE_URL; export const DOMAIN = parsed.VITE_DOMAIN; +export const SQUARE_APPLICATION_ID = parsed.VITE_SQUARE_APPLICATION_ID; +export const SQUARE_LOCATION_ID = parsed.VITE_SQUARE_LOCATION_ID; diff --git a/src/pages/Checkout.test.tsx b/src/pages/Checkout.test.tsx deleted file mode 100644 index 2a8562b..0000000 --- a/src/pages/Checkout.test.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { createMemoryRouter, RouterProvider } from "react-router-dom"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import Checkout from "./Checkout"; - -vi.mock("../config", () => ({ - BASE_URL: "http://test.local", -})); - -const updateQuantity = vi.fn(); - -vi.mock("../context/CartContext", () => ({ - useCart: () => ({ - updateQuantity, - }), -})); - -function jsonResponse(body: unknown, init: ResponseInit = {}) { - return new Response(JSON.stringify(body), { - headers: { "Content-Type": "application/json" }, - ...init, - }); -} - -function renderCheckout() { - const router = createMemoryRouter( - [ - { - path: "/checkout", - element: , - }, - ], - { - initialEntries: ["/checkout"], - future: { v7_relativeSplatPath: true }, - }, - ); - - render(); -} - -describe("Checkout", () => { - beforeEach(() => { - localStorage.setItem("cartId", "cart-1"); - vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { - const url = String(input); - - if (url === "http://test.local/profile") { - return jsonResponse({ - email: "customer@example.com", - firstName: "Casey", - lastName: "Customer", - address: "123 Test St", - city: "Phoenix", - state: "AZ", - zipCode: "85001", - country: "US", - phone: "555-555-5555", - }); - } - - if (url === "http://test.local/cart/cart-1") { - return jsonResponse({ - items: [ - { - id: 7, - name: "RC Wheel", - skuNumber: "WHEEL-1", - productId: "WHEEL-1", - quantity: 1, - color: "ff0000", - filamentType: "PLA", - filamentId: "00000000-0000-4000-8000-000000000000", - stripePriceId: "price_123", - price: 12, - }, - ], - total: 12, - }); - } - - if (url === "http://test.local/cart/cart-1/stripe-items") { - return jsonResponse({ - line_items: [{ price: "price_123", quantity: 1 }], - }); - } - - if (url === "http://test.local/cart/cart-1/payment-intent") { - return jsonResponse( - { - error: "Cart is not ready for checkout", - items: [ - { - cartItemId: 7, - skuNumber: "WHEEL-1", - reasons: [ - "missing_public_file_service_id", - "unavailable_filament_id", - ], - }, - ], - }, - { status: 409 }, - ); - } - - throw new Error(`Unexpected fetch: ${url}`); - }); - }); - - afterEach(() => { - localStorage.clear(); - vi.restoreAllMocks(); - updateQuantity.mockReset(); - }); - - it("shows cart readiness failures next to checkout actions", async () => { - const user = userEvent.setup(); - - renderCheckout(); - - await screen.findByText("RC Wheel"); - await user.click(screen.getByRole("button", { name: /confirm order/i })); - - const alert = await screen.findByRole("alert"); - expect(alert).toHaveTextContent("Cart is not ready for checkout"); - expect(alert).toHaveTextContent("RC Wheel"); - expect(alert).toHaveTextContent("missing printable file"); - expect(alert).toHaveTextContent("selected filament is unavailable"); - }); -}); diff --git a/src/pages/Checkout.tsx b/src/pages/Checkout.tsx index 4037579..5ab262b 100644 --- a/src/pages/Checkout.tsx +++ b/src/pages/Checkout.tsx @@ -2,6 +2,7 @@ import { ChevronDownIcon } from "@heroicons/react/16/solid"; import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import InputField from "../components/InputField"; +import { SquarePaymentForm } from "../components/SquarePaymentForm"; import { BASE_URL } from "../config"; import { useCart } from "../context/CartContext"; @@ -14,7 +15,6 @@ interface CartApiItem { color: string; filamentType: string; filamentId: string; - stripePriceId: string | null; price: number; } @@ -23,169 +23,15 @@ interface CartApiResponse { total: number; } -interface StripeLineItem { - price: string; // Stripe price ID - quantity: number; - price_data?: { - currency: string; - product_data: { - name: string; - }; - unit_amount: number; - }; -} - -interface StripeItemsResponse { - line_items: StripeLineItem[]; - total_amount?: number; -} - interface ShippingCostResponse { shippingCost: number; } -interface PaymentIntentResponse { - checkout_url?: string; - // API returns `clientSecret` (camelCase); accept `client_secret` as well for compatibility - clientSecret?: string; - client_secret?: string; - amount?: number; - currency?: string; - orderId?: string | number; -} - -interface CheckoutReadinessErrorItem { - cartItemId: number; - skuNumber: string | null; - reasons: string[]; -} - -interface CheckoutReadinessErrorResponse { - error: string; - items: CheckoutReadinessErrorItem[]; -} - -const readinessReasonLabels: Record = { - product_missing: "product is no longer available", - missing_stripe_price_id: "missing checkout price", - missing_public_file_service_id: "missing printable file", - invalid_quantity: "invalid quantity", - invalid_filament_id: "invalid filament selection", - unavailable_filament_id: "selected filament is unavailable", -}; - -function isObject(val: unknown): val is Record { - return typeof val === "object" && val !== null; -} - -function isCheckoutReadinessError( - obj: unknown, -): obj is CheckoutReadinessErrorResponse { - if (!isObject(obj) || typeof obj.error !== "string") return false; - - return ( - Array.isArray(obj.items) && - obj.items.every( - (item) => - isObject(item) && - typeof item.cartItemId === "number" && - (typeof item.skuNumber === "string" || item.skuNumber === null) && - Array.isArray(item.reasons) && - item.reasons.every((reason) => typeof reason === "string"), - ) - ); -} - -function formatReadinessError( - response: CheckoutReadinessErrorResponse, - cartItems: CartApiItem[], -) { - const itemMessages = response.items.map((item) => { - const cartItem = cartItems.find( - (candidate) => - candidate.id === item.cartItemId || - candidate.skuNumber === item.skuNumber, - ); - const itemName = - cartItem?.name ?? item.skuNumber ?? `Cart item ${item.cartItemId}`; - const reasons = item.reasons - .map((reason) => readinessReasonLabels[reason] ?? reason) - .join(", "); - - return `${itemName}: ${reasons}`; - }); - - return `${response.error}. ${itemMessages.join("; ")}`; -} - -async function paymentErrorMessage(res: Response, cartItems: CartApiItem[]) { - const text = await res.text(); - let parsed: unknown = null; - - if (text) { - try { - parsed = JSON.parse(text); - } catch { - parsed = null; - } - } - - if (isCheckoutReadinessError(parsed)) { - return formatReadinessError(parsed, cartItems); - } - - if (isObject(parsed) && typeof parsed.error === "string") { - const details = - typeof parsed.details === "string" ? `: ${parsed.details}` : ""; - return `${parsed.error}${details}`; - } - - return `Payment intent request failed (${res.status})${text ? `: ${text}` : ""}`; -} - -function parsePaymentIntentResponse( - obj: unknown, -): PaymentIntentResponse | null { - if (!isObject(obj)) return null; - const record = obj as Record; - const checkout_url = - typeof record.checkout_url === "string" ? record.checkout_url : undefined; - const clientSecret = - typeof record.clientSecret === "string" ? record.clientSecret : undefined; - const client_secret = - typeof record.client_secret === "string" ? record.client_secret : undefined; - const amount = typeof record.amount === "number" ? record.amount : undefined; - const currency = - typeof record.currency === "string" ? record.currency : undefined; - const orderId = - typeof record.orderId === "string" || typeof record.orderId === "number" - ? (record.orderId as string | number) - : undefined; - if ( - checkout_url || - clientSecret || - client_secret || - amount || - currency || - orderId - ) - return { - checkout_url, - clientSecret, - client_secret, - amount, - currency, - orderId, - }; - return null; -} - export default function Checkout() { const navigate = useNavigate(); const [profile, setProfile] = useState(undefined); const { updateQuantity } = useCart(); const [remoteCart, setRemoteCart] = useState([]); - const [stripeItems, setStripeItems] = useState([]); const [cartLoading, setCartLoading] = useState(true); const [cartError, setCartError] = useState(null); const [checkoutError, setCheckoutError] = useState(null); @@ -255,24 +101,6 @@ export default function Checkout() { setRemoteCart(cartData.items); - try { - const stripeRes = await fetch( - `${BASE_URL}/cart/${cartId}/stripe-items`, - { - credentials: "include", - }, - ); - if (stripeRes.ok) { - const stripeData: StripeItemsResponse = await stripeRes.json(); - setStripeItems(stripeData.line_items); - } else { - console.warn( - "Stripe items not available - some items may not have Stripe price IDs", - ); - } - } catch (stripeError) { - console.warn("Failed to fetch Stripe items:", stripeError); - } } catch (e) { setCartError(e instanceof Error ? e.message : "Failed to load cart"); setRemoteCart([]); @@ -306,55 +134,6 @@ export default function Checkout() { setShippingCost(data.shippingCost); }; - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - const cartId = localStorage.getItem("cartId"); - if (!cartId) { - setCartError("No cartId found"); - return; - } - setCartLoading(true); - setCheckoutError(null); - try { - const res = await fetch(`${BASE_URL}/cart/${cartId}/payment-intent`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - credentials: "include", - body: JSON.stringify({ shippingInfo, profile }), - }); - if (!res.ok) { - throw new Error(await paymentErrorMessage(res, remoteCart)); - } - const dataJson: unknown = await res.json(); - const data = parsePaymentIntentResponse(dataJson); - - if (!data) { - console.warn("Unexpected payment intent response:", dataJson); - throw new Error("Invalid payment intent response"); - } - // If backend provides a checkout URL, redirect there - if (data.checkout_url) { - navigate(data.checkout_url); - return; - } - const clientSecret = data.clientSecret ?? data.client_secret; - if (clientSecret) { - navigate(`/payment`, { state: { clientSecret } }); - return; - } - // Fallback: optionally navigate if order id provided - if (data.orderId) { - navigate(`/order/${data.orderId}`); - } - } catch (err: unknown) { - setCheckoutError( - err instanceof Error ? err.message : "Payment intent failed", - ); - } finally { - setCartLoading(false); - } - }; - // biome-ignore lint/correctness/useExhaustiveDependencies: TODO: useEventEffect in 19 useEffect(() => { getProfileData(); @@ -381,7 +160,7 @@ export default function Checkout() {
+ onSubmit={(event) => event.preventDefault()}> {/* Shipping info for estimating shipping cost */}

@@ -564,15 +343,13 @@ export default function Checkout() { {checkoutError}

)} - - {stripeItems.length > 0 && ( -

- {stripeItems.length} item(s) ready for Stripe checkout -

+ {localStorage.getItem("cartId") && ( + )}
diff --git a/src/pages/Payment.tsx b/src/pages/Payment.tsx deleted file mode 100644 index f9e8257..0000000 --- a/src/pages/Payment.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { - Elements, - PaymentElement, - useElements, - useStripe, -} from "@stripe/react-stripe-js"; -import { loadStripe, type PaymentIntentResult } from "@stripe/stripe-js"; -import { useMemo, useState } from "react"; -import { useLocation, useNavigate } from "react-router-dom"; - -const publishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY as string; -const stripePromise = loadStripe(publishableKey); - -function PaymentForm() { - const stripe = useStripe(); - const elements = useElements(); - const navigate = useNavigate(); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (!stripe || !elements) return; - setLoading(true); - setError(null); - try { - const result: PaymentIntentResult = await stripe.confirmPayment({ - elements, - confirmParams: { - // You can change return_url to an order confirmation route - return_url: `${window.location.origin}/order/complete`, - }, - redirect: "if_required", - }); - - if (result?.error) { - setError(result.error.message || "Payment confirmation failed"); - } else if (result?.paymentIntent) { - // If payment succeeded or requires action handled by Stripe, navigate to a success page - navigate("/order/complete"); - } else { - // Unknown result — navigate to a generic page or reload - navigate("/order/complete"); - } - } catch (err: unknown) { - setError(err instanceof Error ? err.message : "Payment failed"); - } finally { - setLoading(false); - } - }; - - return ( - - - {error &&
{error}
} - - - ); -} - -export default function PaymentPage() { - const loc = useLocation(); - const q = useMemo(() => new URLSearchParams(loc.search), [loc.search]); - // Prefer router state (in-memory) to avoid exposing secrets in the URL. - const state = (loc.state as { clientSecret?: string } | null) ?? null; - const clientSecret = state?.clientSecret ?? q.get("client_secret"); - const options = useMemo( - () => (clientSecret ? { clientSecret } : undefined), - [clientSecret], - ); - - if (!publishableKey) { - return ( -
- Missing Stripe publishable key. Set `VITE_STRIPE_PUBLISHABLE_KEY`. -
- ); - } - - if (!clientSecret) { - return
No payment session available.
; - } - - return ( -
-
-

Complete payment

- - - -
-
- ); -} diff --git a/src/pages/Product.tsx b/src/pages/Product.tsx index d0e702a..7f99550 100644 --- a/src/pages/Product.tsx +++ b/src/pages/Product.tsx @@ -231,6 +231,4 @@ interface Product { filamentType: string; skuNumber: string; color: string; - stripeProductId?: string; - stripePriceId?: string; }