diff --git a/next.config.mjs b/next.config.mjs
index b1ba137..773f7b6 100644
--- a/next.config.mjs
+++ b/next.config.mjs
@@ -1,15 +1,42 @@
+const isDev = process.env.NODE_ENV === 'development'
+
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
images: {
remotePatterns: [
- { protocol: 'https', hostname: '**.squarecdn.com' },
- {
- protocol: 'https',
- hostname: 'items-images-production.s3.us-west-2.amazonaws.com',
- },
+ { protocol: 'https', hostname: 'items-images-production.s3.us-west-2.amazonaws.com' },
+ { protocol: 'https', hostname: 'items-images-sandbox.s3.us-west-2.amazonaws.com' },
+ { protocol: 'https', hostname: 'square-web-sdk-production.squarecdn.com' },
+ { protocol: 'https', hostname: 'square-web-sdk-sandbox.squarecdn.com' },
],
},
+ async headers() {
+ return [
+ {
+ source: '/(.*)',
+ headers: [
+ { key: 'X-Content-Type-Options', value: 'nosniff' },
+ { key: 'X-Frame-Options', value: 'DENY' },
+ { key: 'X-XSS-Protection', value: '1; mode=block' },
+ { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
+ { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
+ {
+ key: 'Content-Security-Policy',
+ value: [
+ "default-src 'self'",
+ `script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ''} https://web.squarecdn.com https://sandbox.web.squarecdn.com https://cdnjs.cloudflare.com`,
+ "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdnjs.cloudflare.com https://web.squarecdn.com https://sandbox.web.squarecdn.com",
+ "font-src 'self' https://fonts.gstatic.com https://cdnjs.cloudflare.com https://cash-f.squarecdn.com",
+ "img-src 'self' data: https://items-images-production.s3.us-west-2.amazonaws.com https://items-images-sandbox.s3.us-west-2.amazonaws.com https://*.squarecdn.com",
+ "connect-src 'self' https://web.squarecdn.com https://sandbox.web.squarecdn.com https://pci-connect.squareup.com https://pci-connect.squareupsandbox.com",
+ "frame-src https://web.squarecdn.com https://sandbox.web.squarecdn.com",
+ ].join('; '),
+ },
+ ],
+ },
+ ]
+ },
}
export default nextConfig
diff --git a/package-lock.json b/package-lock.json
index 3d2ec26..7e6cf42 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -17,6 +17,7 @@
"nodemailer": "^8.0.5",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+ "sharp": "^0.34.5",
"square": "^34.0.0",
"tsdav": "^2.0.11"
},
@@ -225,7 +226,6 @@
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
"integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
- "dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -344,6 +344,471 @@
"dev": true,
"license": "BSD-3-Clause"
},
+ "node_modules/@img/colour": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+ "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
+ "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
+ "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
+ "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
+ "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
+ "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
+ "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
+ "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
+ "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
+ "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
+ "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
+ "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
+ "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
+ "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
+ "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
+ "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-ppc64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-riscv64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
+ "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-riscv64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
+ "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
+ "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
+ "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
+ "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
+ "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.7.0"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
+ "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
+ "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
+ "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -2204,6 +2669,15 @@
"integrity": "sha512-53rsFbGdwMwlF7qvCt0ypLM5V5/Mbl0szB7GPN8y9NCcbknYOeVVXdrXEq+90IwAfrrzt6Hd+u2E2ntakICU8w==",
"license": "MIT"
},
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/detect-node": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
@@ -5387,7 +5861,6 @@
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
- "dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -5445,6 +5918,50 @@
"node": ">= 0.4"
}
},
+ "node_modules/sharp": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
+ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@img/colour": "^1.0.0",
+ "detect-libc": "^2.1.2",
+ "semver": "^7.7.3"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.34.5",
+ "@img/sharp-darwin-x64": "0.34.5",
+ "@img/sharp-libvips-darwin-arm64": "1.2.4",
+ "@img/sharp-libvips-darwin-x64": "1.2.4",
+ "@img/sharp-libvips-linux-arm": "1.2.4",
+ "@img/sharp-libvips-linux-arm64": "1.2.4",
+ "@img/sharp-libvips-linux-ppc64": "1.2.4",
+ "@img/sharp-libvips-linux-riscv64": "1.2.4",
+ "@img/sharp-libvips-linux-s390x": "1.2.4",
+ "@img/sharp-libvips-linux-x64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
+ "@img/sharp-linux-arm": "0.34.5",
+ "@img/sharp-linux-arm64": "0.34.5",
+ "@img/sharp-linux-ppc64": "0.34.5",
+ "@img/sharp-linux-riscv64": "0.34.5",
+ "@img/sharp-linux-s390x": "0.34.5",
+ "@img/sharp-linux-x64": "0.34.5",
+ "@img/sharp-linuxmusl-arm64": "0.34.5",
+ "@img/sharp-linuxmusl-x64": "0.34.5",
+ "@img/sharp-wasm32": "0.34.5",
+ "@img/sharp-win32-arm64": "0.34.5",
+ "@img/sharp-win32-ia32": "0.34.5",
+ "@img/sharp-win32-x64": "0.34.5"
+ }
+ },
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
diff --git a/package.json b/package.json
index 12a5322..f569e46 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,7 @@
"nodemailer": "^8.0.5",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+ "sharp": "^0.34.5",
"square": "^34.0.0",
"tsdav": "^2.0.11"
},
diff --git a/src/app/api/admin/categories-display/route.ts b/src/app/api/admin/categories-display/route.ts
index 05887cd..07cde29 100644
--- a/src/app/api/admin/categories-display/route.ts
+++ b/src/app/api/admin/categories-display/route.ts
@@ -1,21 +1,12 @@
import { NextRequest, NextResponse } from 'next/server'
import { getCategoryDisplayConfig, saveCategoryDisplayConfig } from '@/lib/categories-display'
import type { CategoryDisplayConfig } from '@/lib/categories-display'
-import { cookies } from 'next/headers'
-
-function isAuthed(): boolean {
- const token = cookies().get('admin_token')?.value
- return !!token && token === process.env.ADMIN_PASSWORD
-}
export function GET() {
return NextResponse.json(getCategoryDisplayConfig())
}
export async function PUT(req: NextRequest) {
- if (!isAuthed()) {
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
- }
try {
const config = (await req.json()) as CategoryDisplayConfig
saveCategoryDisplayConfig(config)
diff --git a/src/app/api/admin/hours/route.ts b/src/app/api/admin/hours/route.ts
index bc4a2ea..8b6bdb9 100644
--- a/src/app/api/admin/hours/route.ts
+++ b/src/app/api/admin/hours/route.ts
@@ -1,21 +1,12 @@
import { NextRequest, NextResponse } from 'next/server'
import { getHoursConfig, saveHoursConfig } from '@/lib/hours'
import type { HoursConfig } from '@/lib/hours'
-import { cookies } from 'next/headers'
-
-function isAuthed(): boolean {
- const token = cookies().get('admin_token')?.value
- return !!token && token === process.env.ADMIN_PASSWORD
-}
export async function GET() {
return NextResponse.json(getHoursConfig())
}
export async function PUT(req: NextRequest) {
- if (!isAuthed()) {
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
- }
try {
const config = (await req.json()) as HoursConfig
saveHoursConfig(config)
diff --git a/src/app/api/admin/items/[id]/images/route.ts b/src/app/api/admin/items/[id]/images/route.ts
index 2434bf2..7d7141c 100644
--- a/src/app/api/admin/items/[id]/images/route.ts
+++ b/src/app/api/admin/items/[id]/images/route.ts
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'
import { Client, Environment, FileWrapper } from 'square'
import { randomUUID } from 'crypto'
+import sharp from 'sharp'
function getClient() {
const token = process.env.SQUARE_CATALOG_ACCESS_TOKEN ?? process.env.SQUARE_ACCESS_TOKEN!
@@ -10,57 +11,88 @@ function getClient() {
return new Client({ accessToken: token, environment: env })
}
-// Square accepted MIME types
-const SUPPORTED = new Set(['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/bmp', 'image/webp'])
+// Square accepted MIME types (before conversion)
+const SUPPORTED = new Set(['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/bmp', 'image/webp'])
+const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20 MB per image
+const MAX_DIMENSION = 2048 // px — Square recommends ≤ 2048 on the long edge
+
+/**
+ * Compress and convert an image to WebP.
+ * Resizes to fit within MAX_DIMENSION × MAX_DIMENSION, preserving aspect ratio.
+ * Returns the processed buffer and its byte size.
+ */
+async function processImage(input: Buffer): Promise<{ buffer: Buffer; originalBytes: number; finalBytes: number }> {
+ const originalBytes = input.byteLength
+ const buffer = await sharp(input)
+ .rotate() // auto-rotate based on EXIF orientation
+ .resize(MAX_DIMENSION, MAX_DIMENSION, { fit: 'inside', withoutEnlargement: true })
+ .webp({ quality: 82 })
+ .toBuffer()
+ return { buffer, originalBytes, finalBytes: buffer.byteLength }
+}
export async function POST(
request: Request,
{ params }: { params: { id: string } }
) {
- const client = getClient()
+ const client = getClient()
const formData = await request.formData()
- const files = formData.getAll('images') as File[]
+ const files = formData.getAll('images') as File[]
if (!files.length) {
return NextResponse.json({ error: 'No files provided' }, { status: 400 })
}
- const results: { name: string; url: string | null | undefined; error?: string }[] = []
+ const results: { name: string; url: string | null | undefined; originalKB?: number; finalKB?: number; error?: string }[] = []
for (const file of files) {
const mimeType = file.type || 'image/jpeg'
if (!SUPPORTED.has(mimeType)) {
results.push({
- name: file.name,
- url: null,
+ name: file.name,
+ url: null,
error: `Unsupported format: ${mimeType}. Supported: JPEG, PNG, GIF, BMP, WEBP. Convert HEIC/HEIF before uploading.`,
})
continue
}
+ if (file.size > MAX_FILE_SIZE) {
+ results.push({ name: file.name, url: null, error: 'File too large (max 20 MB)' })
+ continue
+ }
+
try {
- const buffer = Buffer.from(await file.arrayBuffer())
- const blob = new Blob([buffer], { type: mimeType })
- const wrapper = new FileWrapper(blob, { filename: file.name, contentType: mimeType })
+ const raw = Buffer.from(await file.arrayBuffer())
+ const { buffer, originalBytes, finalBytes } = await processImage(raw)
+
+ console.log(
+ `[admin/images] ${file.name}: ${Math.round(originalBytes / 1024)} KB → ${Math.round(finalBytes / 1024)} KB WebP`
+ )
+
+ const baseName = file.name.replace(/\.[^.]+$/, '')
+ const blob = new Blob([new Uint8Array(buffer)], { type: 'image/webp' })
+ const wrapper = new FileWrapper(blob, { filename: `${baseName}.webp`, contentType: 'image/webp' })
const { result } = await client.catalogApi.createCatalogImage(
{
idempotencyKey: randomUUID(),
- objectId: params.id,
+ objectId: params.id,
image: {
type: 'IMAGE',
- id: '#NEW_IMAGE',
- imageData: {
- name: file.name.replace(/\.[^.]+$/, ''),
- caption: '',
- },
+ id: '#NEW_IMAGE',
+ imageData: { name: baseName, caption: '' },
},
},
wrapper
)
- results.push({ name: file.name, url: result.image?.imageData?.url })
+ results.push({
+ name: file.name,
+ url: result.image?.imageData?.url,
+ originalKB: Math.round(originalBytes / 1024),
+ finalKB: Math.round(finalBytes / 1024),
+ })
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err)
console.error('[admin/images] upload error:', msg)
diff --git a/src/app/api/admin/login/route.ts b/src/app/api/admin/login/route.ts
index c623ed4..74e7dce 100644
--- a/src/app/api/admin/login/route.ts
+++ b/src/app/api/admin/login/route.ts
@@ -1,19 +1,62 @@
import { NextResponse } from 'next/server'
+import { createHash } from 'crypto'
+
+// Simple in-memory brute-force protection: max 5 attempts per IP per 15 min
+const loginAttempts = new Map()
+const MAX_ATTEMPTS = 5
+const WINDOW_MS = 15 * 60 * 1000
+
+function checkRateLimit(ip: string): boolean {
+ const now = Date.now()
+ const entry = loginAttempts.get(ip)
+ if (!entry || entry.resetAt < now) {
+ loginAttempts.set(ip, { count: 1, resetAt: now + WINDOW_MS })
+ return true
+ }
+ if (entry.count >= MAX_ATTEMPTS) return false
+ entry.count++
+ return true
+}
+
+/**
+ * Derive a deterministic session token from the admin password.
+ * Must match the derivation in src/middleware.ts (SHA-256, same prefix).
+ */
+function deriveSessionToken(password: string): string {
+ return createHash('sha256')
+ .update(`admin-session-v1:${password}`)
+ .digest('hex')
+}
export async function POST(request: Request) {
- const { password } = await request.json()
+ const ip = (request.headers.get('x-forwarded-for') ?? 'unknown').split(',')[0].trim()
+
+ if (!checkRateLimit(ip)) {
+ return NextResponse.json(
+ { error: 'Too many login attempts. Try again in 15 minutes.' },
+ { status: 429 }
+ )
+ }
+
+ let password: string | undefined
+ try {
+ ;({ password } = await request.json())
+ } catch {
+ return NextResponse.json({ error: 'Invalid request' }, { status: 400 })
+ }
if (!process.env.ADMIN_PASSWORD || password !== process.env.ADMIN_PASSWORD) {
return NextResponse.json({ error: 'Invalid password' }, { status: 401 })
}
+ const token = deriveSessionToken(process.env.ADMIN_PASSWORD)
const response = NextResponse.json({ ok: true })
- response.cookies.set('admin_token', process.env.ADMIN_PASSWORD, {
+ response.cookies.set('admin_token', token, {
httpOnly: true,
- secure: process.env.NODE_ENV === 'production',
+ secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
- maxAge: 60 * 60 * 24 * 7, // 7 days
- path: '/',
+ maxAge: 60 * 60 * 24 * 7, // 7 days
+ path: '/',
})
return response
}
diff --git a/src/app/api/admin/occasions/route.ts b/src/app/api/admin/occasions/route.ts
index a223b7a..c997127 100644
--- a/src/app/api/admin/occasions/route.ts
+++ b/src/app/api/admin/occasions/route.ts
@@ -2,12 +2,6 @@ import { NextRequest, NextResponse } from 'next/server'
import { OCCASIONS, getOccasionWindow } from '@/lib/occasions'
import type { OccasionsConfig, CustomOccasionDef } from '@/lib/occasions'
import { getOccasionsConfig, saveOccasionsConfig } from '@/lib/occasions-store'
-import { cookies } from 'next/headers'
-
-function isAuthed(): boolean {
- const token = cookies().get('admin_token')?.value
- return !!token && token === process.env.ADMIN_PASSWORD
-}
export function GET() {
const config = getOccasionsConfig()
@@ -66,9 +60,6 @@ export function GET() {
}
export async function PUT(req: NextRequest) {
- if (!isAuthed()) {
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
- }
try {
const config = (await req.json()) as OccasionsConfig
saveOccasionsConfig(config)
diff --git a/src/app/api/checkout/route.ts b/src/app/api/checkout/route.ts
index 3bcd567..8f11239 100644
--- a/src/app/api/checkout/route.ts
+++ b/src/app/api/checkout/route.ts
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'
-import { randomUUID } from 'crypto'
+import { createHash } from 'crypto'
import { inferTier, JOB_MINUTES, type DeliveryTier } from '@/lib/delivery'
interface LineItem {
@@ -13,21 +13,21 @@ interface LineItem {
}
interface CheckoutBody {
- lineItems: LineItem[]
- selectedColors: string[]
- deliverySlotISO?: string // UTC ISO start time from slot picker
- driveMinutes?: number
+ lineItems: LineItem[]
+ selectedColors: string[]
+ deliverySlotISO?: string
+ driveMinutes?: number
deliveryAddress?: string
- deliveryTier?: string
- deliveryNotes?: string
- deliveryCents?: number
- pickupSlotISO?: string
- sourceId: string
- idempotencyKey?: string
- customerFirstName?: string
- customerLastName?: string
- customerEmail?: string
- customerPhone?: string
+ deliveryTier?: string
+ deliveryNotes?: string
+ deliveryCents?: number
+ pickupSlotISO?: string
+ sourceId: string
+ idempotencyKey?: string
+ customerFirstName?: string
+ customerLastName?: string
+ customerEmail?: string
+ customerPhone?: string
}
export async function POST(req: NextRequest) {
@@ -38,26 +38,56 @@ export async function POST(req: NextRequest) {
)
}
- const body = (await req.json()) as CheckoutBody
+ let body: CheckoutBody
+ try {
+ body = (await req.json()) as CheckoutBody
+ } catch {
+ return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
+ }
+
+ // ── Input validation ────────────────────────────────────────────────────────
const {
lineItems, selectedColors, deliverySlotISO, driveMinutes,
deliveryAddress, deliveryTier, deliveryNotes, deliveryCents, pickupSlotISO, sourceId,
idempotencyKey, customerFirstName, customerLastName, customerEmail, customerPhone,
} = body
+
+ if (!Array.isArray(lineItems) || lineItems.length === 0) {
+ return NextResponse.json({ error: 'Cart is empty' }, { status: 400 })
+ }
+ for (const li of lineItems) {
+ if (typeof li.name !== 'string' || li.name.length > 255) {
+ return NextResponse.json({ error: 'Invalid item name' }, { status: 400 })
+ }
+ if (!Number.isInteger(li.quantity) || li.quantity < 1 || li.quantity > 999) {
+ return NextResponse.json({ error: 'Invalid item quantity' }, { status: 400 })
+ }
+ if (!Number.isInteger(li.priceCents) || li.priceCents < 0) {
+ return NextResponse.json({ error: 'Invalid item price' }, { status: 400 })
+ }
+ }
+ if (typeof sourceId !== 'string' || !sourceId) {
+ return NextResponse.json({ error: 'Missing payment source' }, { status: 400 })
+ }
+ if (customerEmail && (typeof customerEmail !== 'string' || customerEmail.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(customerEmail))) {
+ return NextResponse.json({ error: 'Invalid email address' }, { status: 400 })
+ }
+ if (deliveryAddress && typeof deliveryAddress === 'string' && deliveryAddress.length > 500) {
+ return NextResponse.json({ error: 'Delivery address too long' }, { status: 400 })
+ }
+ if (deliveryNotes && typeof deliveryNotes === 'string' && deliveryNotes.length > 1000) {
+ return NextResponse.json({ error: 'Delivery notes too long' }, { status: 400 })
+ }
+
const customerName = [customerFirstName, customerLastName].filter(Boolean).join(' ') || undefined
- // Resolve tier — use the client-computed value from the delivery quote so the
- // calendar block duration matches what the customer was shown. Fall back to
- // name-based inference only when the field is absent.
const resolvedTier: DeliveryTier =
(deliveryTier === 'dropoff' || deliveryTier === 'classic' || deliveryTier === 'organic')
? deliveryTier
: inferTier(lineItems.map((l) => l.name))
- // deliverySlotISO is the customer's arrival time (slots already account for drive).
- // Use it directly as the Square fulfillment deliverAt.
const arrivalISO = deliverySlotISO
- const jobMin = JOB_MINUTES[resolvedTier]
+ const jobMin = JOB_MINUTES[resolvedTier]
const windowDurationStr = deliverySlotISO
? `PT${Math.floor(jobMin / 60) > 0 ? `${Math.floor(jobMin / 60)}H` : ''}${jobMin % 60 > 0 ? `${jobMin % 60}M` : ''}`
: undefined
@@ -74,12 +104,17 @@ export async function POST(req: NextRequest) {
].filter(Boolean)
const note = noteParts.join(' | ')
- // ── Guard: verify delivery slot is still free BEFORE charging ─────────────
+ // ── Slot pre-check ──────────────────────────────────────────────────────────
+ // If an idempotency key is present this may be a retry of an already-completed
+ // order. The calendar event already exists from the first attempt so the slot
+ // will appear taken — we must not block here. Fall through to Square to check
+ // for idempotency replay.
+ let slotConflict = false
+
if (deliverySlotISO && driveMinutes != null && process.env.CALDAV_URL) {
try {
const { getBusyDates } = await import('@/lib/caldav')
const { getAvailableSlots } = await import('@/lib/slots')
-
const slotDate = deliverySlotISO.slice(0, 10)
const dayStart = new Date(`${slotDate}T00:00:00Z`)
const dayEnd = new Date(`${slotDate}T23:59:59Z`)
@@ -88,49 +123,56 @@ export async function POST(req: NextRequest) {
.some((s) => s.startISO === deliverySlotISO)
if (!stillFree) {
- return NextResponse.json(
- { error: 'That delivery time was just booked by someone else. Please go back and choose a different slot.' },
- { status: 409 }
- )
+ if (!idempotencyKey) {
+ return NextResponse.json(
+ { error: 'That delivery time was just booked by someone else. Please go back and choose a different slot.' },
+ { status: 409 }
+ )
+ }
+ slotConflict = true
+ console.warn('[checkout] Slot appears taken on retry — checking Square for idempotency replay:', idempotencyKey)
}
} catch (slotErr) {
- // CalDAV unreachable — log and allow checkout to proceed rather than
- // blocking all orders when the calendar server is down.
console.error('[checkout] Slot pre-check failed, proceeding without guard:', slotErr)
}
}
- // ── Guard: verify inventory before charging ───────────────────────────────
- try {
- const { getInventoryCounts } = await import('@/lib/square')
- const variationIds = lineItems
- .filter((li) => li.catalogItemId)
- .map((li) => li.catalogItemId as string)
+ // ── Inventory check (skip on likely replay) ─────────────────────────────────
+ if (!slotConflict) {
+ try {
+ const { getInventoryCounts } = await import('@/lib/square')
+ const variationIds = lineItems
+ .filter((li) => li.catalogItemId)
+ .map((li) => li.catalogItemId as string)
- if (variationIds.length) {
- const counts = await getInventoryCounts(variationIds)
- for (const li of lineItems) {
- if (!li.catalogItemId) continue
- const stock = counts.get(li.catalogItemId)
- if (stock !== undefined && stock < li.quantity) {
- return NextResponse.json(
- { error: `"${li.name}" ${stock === 0 ? 'is sold out' : `only has ${stock} left`}. Please update your cart.` },
- { status: 409 }
- )
+ if (variationIds.length) {
+ const counts = await getInventoryCounts(variationIds)
+ for (const li of lineItems) {
+ if (!li.catalogItemId) continue
+ const stock = counts.get(li.catalogItemId)
+ if (stock !== undefined && stock < li.quantity) {
+ return NextResponse.json(
+ { error: `"${li.name}" ${stock === 0 ? 'is sold out' : `only has ${stock} left`}. Please update your cart.` },
+ { status: 409 }
+ )
+ }
}
}
+ } catch (invErr) {
+ console.error('[checkout] Inventory pre-check failed, proceeding:', invErr)
}
- } catch (invErr) {
- // Non-fatal — log and proceed if inventory API is unreachable
- console.error('[checkout] Inventory pre-check failed, proceeding:', invErr)
}
try {
- const { createSquareOrder, createSquarePayment, upsertSquareCustomer } = await import('@/lib/square')
+ const {
+ createSquareOrder, createSquarePayment,
+ completeSquarePayment, cancelSquarePayment,
+ retrieveSquareOrder, upsertSquareCustomer,
+ } = await import('@/lib/square')
- // Create or find Square customer
+ // ── Customer upsert (skip on likely replay) ─────────────────────────────
let customerId: string | undefined
- if (customerEmail && customerPhone) {
+ if (customerEmail && customerPhone && !slotConflict) {
try {
customerId = await upsertSquareCustomer({
givenName: customerFirstName?.trim() ?? '',
@@ -140,7 +182,6 @@ export async function POST(req: NextRequest) {
})
} catch (custErr) {
console.error('[checkout] Failed to upsert customer:', custErr)
- // Non-fatal — proceed without linking customer
}
}
@@ -159,10 +200,9 @@ export async function POST(req: NextRequest) {
quantity: String(li.quantity),
basePriceMoney: { amount: BigInt(li.priceCents), currency: 'USD' },
note: [
- li.colors?.length ? `Colors: ${li.colors.join(', ')}` : null,
- li.note || null,
- ].filter(Boolean).join(' | ') || undefined,
- // Modifiers sent at $0 — price already included in basePriceMoney
+ li.colors?.length ? `Colors: ${li.colors.join(', ')}` : null,
+ li.note || null,
+ ].filter(Boolean).join(' | ') || undefined,
modifiers: li.modifiers?.length
? li.modifiers.map((m) => ({
catalogObjectId: isSandbox ? undefined : m.catalogObjectId,
@@ -195,119 +235,136 @@ export async function POST(req: NextRequest) {
throw new Error('Order creation returned no ID or total')
}
- // Idempotency replay: if the order is already COMPLETED, payment was already
- // captured on a previous attempt whose response never reached the client.
- // Return success without charging again.
- if (order.state === 'COMPLETED') {
- console.log('[checkout] Idempotency replay — order already completed:', order.id)
- return NextResponse.json({
- success: true,
- orderId: order.id,
- shortRef: order.id!.slice(-6).toUpperCase(),
- paymentId: undefined,
- })
- }
-
- const payment = await createSquarePayment({
- sourceId,
- orderId: order.id,
- amountMoney: {
- amount: order.totalMoney.amount!,
- currency: 'USD',
- },
- note,
- idempotencyKey: randomUUID(),
- })
-
- const shortRef = order.id!.slice(-6).toUpperCase()
+ const shortRef = order.id!.slice(-6).toUpperCase()
const itemsSummary = lineItems.map((l) => `${l.quantity}× ${l.name}`).join(', ')
const totalCents = order.totalMoney!.amount!
- // ── Retry helper ───────────────────────────────────────────────────────
- const withRetry = async (fn: () => Promise, attempts = 3): Promise => {
- for (let i = 0; i < attempts; i++) {
- try { return await fn() } catch (e) {
- if (i === attempts - 1) throw e
- await new Promise((r) => setTimeout(r, 500 * 2 ** i)) // 500ms, 1s, 2s
- }
- }
- throw new Error('unreachable')
+ // ── Idempotency replay: order already fully completed ───────────────────
+ // The entire flow ran on a previous attempt — payment captured, calendar
+ // written. The response just never reached the client.
+ if (order.state === 'COMPLETED') {
+ console.log('[checkout] Idempotency replay — order already completed:', order.id)
+ return NextResponse.json({
+ success: true, orderId: order.id,
+ shortRef, paymentId: undefined,
+ })
}
- // ── Fire-and-forget: calendar writes + emails (payment already captured) ─
- void (async () => {
- // Delivery calendar
- if (deliverySlotISO && driveMinutes != null && deliveryAddress && process.env.CALDAV_URL) {
- try {
+ // ── Genuine slot conflict on retry ──────────────────────────────────────
+ // The slot is taken AND Square created a brand-new OPEN order (not a replay
+ // of a prior pre-auth). Someone else genuinely booked the slot.
+ // Check the full order for existing tenders to confirm this is truly new.
+ if (slotConflict) {
+ const fullOrder = await retrieveSquareOrder(order.id!)
+ const hasExistingPayment = (fullOrder?.tenders?.length ?? 0) > 0
+ if (!hasExistingPayment) {
+ console.warn('[checkout] Genuine slot conflict on retry — no prior tender found:', order.id)
+ return NextResponse.json(
+ { error: 'That delivery time was just booked by someone else. Please go back and choose a different slot.' },
+ { status: 409 }
+ )
+ }
+ // Prior tender exists — fall through to re-attempt calendar write + capture
+ console.log('[checkout] Retry with existing tender — completing pre-auth:', order.id)
+ }
+
+ // ── Payment idempotency key ─────────────────────────────────────────────
+ // Derived from the nonce (sourceId) so the key is unique per card tokenization.
+ // Square nonces are single-use — tying the key to the nonce means:
+ // • Same nonce on auto-retry → same key → Square idempotency replay (safe)
+ // • New nonce on manual retry → new key → fresh payment attempt (no mismatch error)
+ const paymentIdempotencyKey = createHash('sha256').update(sourceId).digest('hex').slice(0, 45)
+
+ // ── Pre-authorize card (hold funds, do not capture yet) ─────────────────
+ // We capture only AFTER the calendar write succeeds. If the calendar fails,
+ // we void the hold — the customer is never charged without a confirmed booking.
+ const payment = await createSquarePayment({
+ sourceId: sourceId,
+ orderId: order.id!,
+ amountMoney: { amount: order.totalMoney.amount!, currency: 'USD' },
+ note,
+ idempotencyKey: paymentIdempotencyKey,
+ autocomplete: false, // hold only — capture after calendar write
+ })
+
+ if (!payment?.id) throw new Error('Pre-authorization returned no payment ID')
+
+ // ── Write to calendar (awaited) ─────────────────────────────────────────
+ // This is no longer fire-and-forget. If it fails, we void the hold.
+ // Calendar writes are idempotent (orderId-based UID) so retries are safe.
+ const calendarWriteError = await (async () => {
+ try {
+ if (deliverySlotISO && driveMinutes != null && deliveryAddress && process.env.CALDAV_URL) {
const { createDeliveryEvent } = await import('@/lib/caldav')
- await withRetry(() => createDeliveryEvent({
- startTime: new Date(deliverySlotISO!),
+ await createDeliveryEvent({
+ startTime: new Date(deliverySlotISO),
tier: resolvedTier,
- driveMinutes: driveMinutes!,
- address: deliveryAddress!,
+ driveMinutes,
+ address: deliveryAddress,
lineItems,
colors: selectedColors,
customerName: customerName ?? 'Customer',
customerPhone: customerPhone ?? '',
notes: deliveryNotes,
- orderId: order!.id!,
- }))
- } catch (calErr) {
- console.error('[checkout] CRITICAL: calendar write failed after retries', {
- orderId: order.id, deliverySlotISO, error: calErr,
+ orderId: order.id!,
})
- try {
- const { sendSlotConflictAlert } = await import('@/lib/notify')
- await sendSlotConflictAlert({
- shortRef,
- orderId: order.id!,
- customerName: customerName ?? 'Unknown',
- customerPhone: customerPhone ?? '',
- slotISO: deliverySlotISO!,
- address: deliveryAddress!,
- items: itemsSummary,
- })
- } catch (notifyErr) {
- console.error('[checkout] Also failed to send slot conflict alert:', notifyErr)
- }
- }
- }
-
- // Pickup calendar
- if (pickupSlotISO && process.env.CALDAV_URL) {
- try {
+ } else if (pickupSlotISO && process.env.CALDAV_URL) {
const { createPickupEvent } = await import('@/lib/caldav')
- await withRetry(() => createPickupEvent({
- startTime: new Date(pickupSlotISO!),
+ await createPickupEvent({
+ startTime: new Date(pickupSlotISO),
lineItems,
colors: selectedColors,
customerName: customerName ?? 'Customer',
customerPhone: customerPhone ?? '',
notes: deliveryNotes,
- orderId: order!.id!,
- }))
- } catch (calErr) {
- console.error('[checkout] CRITICAL: pickup calendar write failed after retries', {
- orderId: order.id, pickupSlotISO, error: calErr,
+ orderId: order.id!,
})
- try {
- const { sendSlotConflictAlert } = await import('@/lib/notify')
- await sendSlotConflictAlert({
- shortRef,
- orderId: order.id!,
- customerName: customerName ?? 'Unknown',
- customerPhone: customerPhone ?? '',
- slotISO: pickupSlotISO!,
- address: 'PICKUP — shop address',
- items: itemsSummary,
- })
- } catch (notifyErr) {
- console.error('[checkout] Also failed to send slot conflict alert:', notifyErr)
- }
}
+ return null // success
+ } catch (err) {
+ return err
}
+ })()
- // New order alert (to you)
+ // ── Calendar failed → void the hold, never charge the customer ──────────
+ if (calendarWriteError) {
+ console.error('[checkout] CRITICAL: calendar write failed — voiding pre-auth to avoid charge without booking:', {
+ orderId: order.id, paymentId: payment.id, error: calendarWriteError,
+ })
+ try {
+ await cancelSquarePayment(payment.id!)
+ console.log('[checkout] Pre-auth voided successfully:', payment.id)
+ } catch (cancelErr) {
+ // Cancellation failed — this is a critical state: pre-auth exists but
+ // calendar is not written. Alert immediately so it can be manually voided.
+ console.error('[checkout] CRITICAL: failed to void pre-auth after calendar failure — MANUAL ACTION REQUIRED:', {
+ orderId: order.id, paymentId: payment.id, cancelErr,
+ })
+ try {
+ const { sendSlotConflictAlert } = await import('@/lib/notify')
+ await sendSlotConflictAlert({
+ shortRef,
+ orderId: order.id!,
+ customerName: customerName ?? 'Unknown',
+ customerPhone: customerPhone ?? '',
+ slotISO: (deliverySlotISO ?? pickupSlotISO)!,
+ address: deliveryAddress ?? 'PICKUP',
+ items: `URGENT: pre-auth ${payment.id!} could not be voided after calendar failure. Manual void required. Items: ${itemsSummary}`,
+ })
+ } catch { /* best effort */ }
+ }
+ return NextResponse.json(
+ { error: 'Our booking system is temporarily unavailable. Your card has not been charged — please try again in a few minutes or contact us.' },
+ { status: 503 }
+ )
+ }
+
+ // ── Calendar written — now capture the payment ──────────────────────────
+ const captured = await completeSquarePayment(payment.id!)
+ if (!captured) throw new Error('Payment capture returned no result')
+
+ // ── Fire-and-forget: emails only (calendar already written above) ────────
+ void (async () => {
try {
const { sendNewOrderAlert } = await import('@/lib/notify')
await sendNewOrderAlert({
@@ -323,11 +380,10 @@ export async function POST(req: NextRequest) {
colors: selectedColors,
totalCents,
})
- } catch (notifyErr) {
- console.error('[checkout] Failed to send new order alert:', notifyErr)
+ } catch (err) {
+ console.error('[checkout] Failed to send new order alert:', err)
}
- // Order confirmation (to customer)
if (customerEmail && (deliverySlotISO ?? pickupSlotISO)) {
try {
const { sendOrderConfirmationEmail } = await import('@/lib/notify')
@@ -343,8 +399,8 @@ export async function POST(req: NextRequest) {
colors: selectedColors,
totalCents,
})
- } catch (notifyErr) {
- console.error('[checkout] Failed to send order confirmation email:', notifyErr)
+ } catch (err) {
+ console.error('[checkout] Failed to send order confirmation email:', err)
}
}
})()
@@ -353,15 +409,33 @@ export async function POST(req: NextRequest) {
success: true,
orderId: order.id,
shortRef,
- paymentId: payment?.id,
+ paymentId: captured.id,
})
} catch (err: unknown) {
console.error('[checkout] Error:', err)
const squareErrors = (err as { errors?: Array<{ code?: string; detail?: string; category?: string }> })?.errors
- const userMessage = squareErrors?.[0]?.detail ?? 'Checkout failed — please try again or contact us.'
- return NextResponse.json(
- { error: userMessage, details: squareErrors ?? String(err) },
- { status: 500 }
- )
+ const code = squareErrors?.[0]?.code ?? ''
+
+ const CARD_MESSAGES: Record = {
+ CARD_DECLINED: 'Your card was declined. Please try a different card or contact your bank.',
+ CARD_DECLINED_CALL_ISSUER: 'Your bank declined this charge. Please call the number on the back of your card to authorise it, then try again.',
+ CARD_DECLINED_VERIFICATION_REQUIRED: 'Your bank requires additional verification. Please contact them, then try again.',
+ CARD_EXPIRED: 'Your card has expired. Please use a different card.',
+ CARD_NOT_SUPPORTED: 'This card type is not accepted. Please try a Visa, Mastercard, Discover, or Amex.',
+ CVV_FAILURE: 'The security code (CVV) you entered is incorrect. Please double-check and try again.',
+ ADDRESS_VERIFICATION_FAILURE: 'The billing zip code did not match your card. Please check and try again.',
+ INSUFFICIENT_FUNDS: 'Your card has insufficient funds. Please use a different card.',
+ INVALID_CARD: 'Your card details could not be verified. Please check your number and try again.',
+ INVALID_EXPIRATION: 'The expiration date you entered is invalid. Please check and try again.',
+ NONCE_USED: 'Your payment session expired — please tap "Place Order" once more to try again.',
+ INVALID_NONCE: 'Your payment session expired — please tap "Place Order" once more to try again.',
+ IDEMPOTENCY_KEY_REUSED: 'A duplicate request was detected. If you were charged, please contact us — otherwise tap "Place Order" again.',
+ GENERIC_DECLINE: 'Your card was declined. Please try a different card or contact your bank.',
+ }
+
+ const userMessage = CARD_MESSAGES[code]
+ ?? 'Something went wrong with your payment. Please try again or contact us for help.'
+
+ return NextResponse.json({ error: userMessage }, { status: 500 })
}
}
diff --git a/src/components/CartDrawer.tsx b/src/components/CartDrawer.tsx
index 71b3dba..f967039 100644
--- a/src/components/CartDrawer.tsx
+++ b/src/components/CartDrawer.tsx
@@ -82,7 +82,14 @@ export default function CartDrawer() {
const [custEmail, setCustEmail] = useStoredString('bpb_email', '')
const [custPhone, setCustPhone] = useStoredString('bpb_phone', '')
- const [infoErrors, setInfoErrors] = useState<{ firstName?: string; lastName?: string; email?: string; phone?: string }>({})
+ // Idempotency key — persisted across component remounts and page refreshes so
+ // that a retry after a network error always uses the same key, preventing
+ // double charges even if the user closes and reopens the cart drawer.
+ // Cleared only on confirmed payment success.
+ const [checkoutKey, setCheckoutKey] = useStoredString('bpb_checkout_key', '')
+
+ const [infoErrors, setInfoErrors] = useState<{ firstName?: string; lastName?: string; email?: string; phone?: string; balloon?: string }>({})
+ const [balloonAgreement, setBalloonAgreement] = useState(false)
const isValidEmail = (v: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.trim())
const isValidPhone = (v: string) => v.replace(/\D/g, '').length >= 10
@@ -94,7 +101,14 @@ export default function CartDrawer() {
if (!isValidEmail(custEmail)) errors.email = 'Enter a valid email address'
if (!isValidPhone(custPhone)) errors.phone = 'Enter a valid phone number'
setInfoErrors(errors)
- if (Object.keys(errors).length === 0) setStep('payment')
+ if (!balloonAgreement) errors.balloon = 'Please confirm the balloon use agreement to continue'
+ if (Object.keys(errors).length === 0) {
+ // Generate a fresh idempotency key for this checkout attempt if we don't
+ // already have one. An existing key means we're retrying after a failure —
+ // keep it so the server can detect idempotency replay and avoid double charge.
+ if (!checkoutKey) setCheckoutKey(crypto.randomUUID())
+ setStep('payment')
+ }
}
const fullAddress = [street, city, state, zip].filter(Boolean).join(', ')
@@ -167,6 +181,7 @@ export default function CartDrawer() {
customerEmail: custEmail,
customerPhone: custPhone,
grandTotal,
+ idempotencyKey: checkoutKey || undefined,
// eslint-disable-next-line react-hooks/exhaustive-deps
}), [entries, fulfillmentType, deliverySlot, pickupSlot, fullAddress, quote, deliveryInstructions, custFirst, custLast, custEmail, custPhone, grandTotal, entryUnitPrice])
@@ -174,7 +189,8 @@ export default function CartDrawer() {
setOrderId(id)
setShortRef(ref)
clearCart()
- setStep('cart') // reset step so next order starts fresh
+ setCheckoutKey('') // clear so the next order gets a fresh idempotency key
+ setStep('cart')
setQuote(null)
setDeliverySlot(null)
}
@@ -187,6 +203,7 @@ export default function CartDrawer() {
}
const handleClose = () => {
+ setBalloonAgreement(false)
closeDrawer()
}
@@ -571,6 +588,35 @@ export default function CartDrawer() {
)}
+
+ {/* Balloon release agreement */}
+
+
+ {infoErrors.balloon && (
+
+ {infoErrors.balloon}
+
+ )}
+
>
)
diff --git a/src/components/ColorPicker.tsx b/src/components/ColorPicker.tsx
index ace67d1..c130190 100644
--- a/src/components/ColorPicker.tsx
+++ b/src/components/ColorPicker.tsx
@@ -147,9 +147,8 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
const total = basePrice > 0 ? fmt(unitPrice * quantity) : 'Get Quote'
return (
-
-
-
+
+
e.stopPropagation()}>
{product.name}
@@ -252,7 +251,7 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
{/* ── Color picker — only for Latex items ── */}
{product.showColors && (
-
+
{/* Intro */}
@@ -535,6 +534,7 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
)}