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

)}
{/* Category tabs + search */} -
+
)} + {/* Welcome modal + guided tour */} + {showWelcome && } + {showTour && } + {/* Product grid */} {loading ? ( @@ -184,10 +243,11 @@ export default function FeaturedProducts() {

) : (
- {filtered.map((item) => ( + {filtered.map((item, index) => (
diff --git a/src/components/GuidedTour.tsx b/src/components/GuidedTour.tsx new file mode 100644 index 0000000..e6daeaa --- /dev/null +++ b/src/components/GuidedTour.tsx @@ -0,0 +1,315 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' + +interface TourStep { + target: string | null // CSS selector, or null = centered modal + title: string + body: string + position: 'top' | 'bottom' | 'right' | 'center' + onEnter?: () => void // fired when this step becomes active + noOverlay?: boolean // skip dark overlay (use when target is inside a modal) +} + +const STEPS: TourStep[] = [ + { + target: '[data-tour="tabs"]', + title: 'Browse by occasion or category', + body: 'Tap any tab to filter the collection — or "All" to see everything. Seasonal occasions like birthdays and weddings appear here automatically.', + position: 'bottom', + }, + { + target: '[data-tour="first-card"]', + title: 'Tap any arrangement to customize', + body: 'Each card shows your arrangement options. Tap the card or "Customize & Order" to open the builder.', + position: 'bottom', + }, + { + target: '[data-tour="color-section"]', + title: 'Pick your balloon colors', + body: 'Tap a color family to browse 40+ latex shades. Build your palette by selecting one or more colors.', + position: 'bottom', + noOverlay: true, + onEnter: () => { + const card = document.querySelector('[data-tour="first-card"] .product-card') as HTMLElement | null + card?.click() + }, + }, + { + target: '[data-tour="add-to-order"]', + title: 'Add to your order', + body: "When you're happy with your colors and options, tap \"Add to Order\" at the bottom of the screen to add the arrangement to your cart.", + position: 'center', + noOverlay: true, + }, + { + target: null, + title: 'Delivery or pickup — your choice', + body: "At checkout, pick a delivery time and we'll bring everything to you, or choose a pickup time at our Milford, CT shop. Payment is fully secure.", + position: 'center', + onEnter: () => { + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + }, + }, +] + +const PAD = 10 // px padding around spotlight +const TIP_WIDTH = 300 // tooltip width in px + +interface Props { + onDone: () => void +} + +export default function GuidedTour({ onDone }: Props) { + const [step, setStep] = useState(0) + const [targetRect, setTargetRect] = useState(null) + + const current = STEPS[step] + + const measureTarget = useCallback(() => { + if (!current.target) { setTargetRect(null); return } + const el = document.querySelector(current.target) + if (el) setTargetRect(el.getBoundingClientRect()) + }, [current.target]) + + // On step change: fire onEnter, poll until target appears, then scroll + measure. + useEffect(() => { + setTargetRect(null) // clear stale rect immediately + current.onEnter?.() + + if (!current.target) return + + let cancelled = false + let attempts = 0 + const MAX = 15 + + const tryMeasure = () => { + if (cancelled) return + const el = document.querySelector(current.target!) + if (el) { + // Scroll within modal body (more reliable than scrollIntoView in fixed containers on mobile) + const scrollParent = el.closest('.modal-card-body') as HTMLElement | null + if (scrollParent) { + const parentRect = scrollParent.getBoundingClientRect() + const elRect = el.getBoundingClientRect() + scrollParent.scrollTop = elRect.top - parentRect.top + scrollParent.scrollTop - 20 + } else { + el.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } + // Measure after layout settles (double-rAF ensures paint is done) + requestAnimationFrame(() => { + requestAnimationFrame(() => { + if (!cancelled) measureTarget() + }) + }) + } else if (attempts < MAX) { + attempts++ + setTimeout(tryMeasure, 200) + } + } + tryMeasure() + + return () => { cancelled = true } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [step]) + + // Keep spotlight synced with resize / scroll + useEffect(() => { + window.addEventListener('resize', measureTarget) + window.addEventListener('scroll', measureTarget, true) + return () => { + window.removeEventListener('resize', measureTarget) + window.removeEventListener('scroll', measureTarget, true) + } + }, [measureTarget]) + + // Hide the Bulma modal-background for the entire tour so there's no dark flash when modals open + useEffect(() => { + const style = document.createElement('style') + style.textContent = '.modal-background { opacity: 0 !important; pointer-events: none !important; }' + document.head.appendChild(style) + return () => style.remove() + }, []) + + const next = () => { + if (step < STEPS.length - 1) setStep((s) => s + 1) + else onDone() + } + + const prev = () => setStep((s) => Math.max(0, s - 1)) + + // ── Spotlight geometry ─────────────────────────────────────────────────────── + const spot = targetRect + ? { + top: targetRect.top - PAD, + left: targetRect.left - PAD, + width: targetRect.width + PAD * 2, + height: targetRect.height + PAD * 2, + } + : null + + // ── Tooltip geometry ───────────────────────────────────────────────────────── + const vw = typeof window !== 'undefined' ? window.innerWidth : 800 + const vh = typeof window !== 'undefined' ? window.innerHeight : 600 + + let tooltipStyle: React.CSSProperties = {} + + if (!spot || current.position === 'center') { + tooltipStyle = { + position: 'fixed', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + width: Math.min(TIP_WIDTH, vw - 32), + zIndex: 10002, + } + } else if (current.position === 'bottom') { + const left = Math.min( + Math.max(8, spot.left + spot.width / 2 - TIP_WIDTH / 2), + vw - TIP_WIDTH - 8 + ) + tooltipStyle = { + position: 'fixed', + top: spot.top + spot.height + 12, + left, + width: Math.min(TIP_WIDTH, vw - 16), + zIndex: 10002, + } + } else if (current.position === 'top') { + const left = Math.min( + Math.max(8, spot.left + spot.width / 2 - TIP_WIDTH / 2), + vw - TIP_WIDTH - 8 + ) + tooltipStyle = { + position: 'fixed', + bottom: vh - spot.top + 12, + left, + width: Math.min(TIP_WIDTH, vw - 16), + zIndex: 10002, + } + } else { + // right + tooltipStyle = { + position: 'fixed', + top: Math.max(8, spot.top + spot.height / 2 - 80), + left: Math.min(spot.left + spot.width + 12, vw - TIP_WIDTH - 8), + width: Math.min(TIP_WIDTH, vw - 16), + zIndex: 10002, + } + } + + const isLast = step === STEPS.length - 1 + const isFirst = step === 0 + + return ( + <> + {/* Dark overlay — only when not targeting a modal element */} + {!current.noOverlay && ( +
+ )} + + {/* Spotlight — dark surround with cutout */} + {spot && !current.noOverlay && ( +
+ )} + + {/* Highlight ring — bright border, no overlay, for modal steps */} + {spot && current.noOverlay && ( +
+ )} + + {/* Tooltip card */} +
e.stopPropagation()} + > + {/* Step dots */} +
+ {STEPS.map((_, i) => ( +
+ ))} +
+ +

+ {current.title} +

+

+ {current.body} +

+ +
+ +
+ {!isFirst && ( + + )} + +
+
+
+ + ) +} diff --git a/src/components/PaymentForm.tsx b/src/components/PaymentForm.tsx index acad01e..4eb72ee 100644 --- a/src/components/PaymentForm.tsx +++ b/src/components/PaymentForm.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' // useRef kept for cardRef import { fmt } from '@/lib/format' // ── Minimal Square Web Payments SDK types ───────────────────────────────────── @@ -66,12 +66,11 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) { const appId = process.env.NEXT_PUBLIC_SQUARE_APP_ID ?? '' const locationId = process.env.NEXT_PUBLIC_SQUARE_LOCATION_ID ?? '' - const [sdkReady, setSdkReady] = useState(false) - const [cardReady, setCardReady] = useState(false) + const [sdkReady, setSdkReady] = useState(false) + const [cardReady, setCardReady] = useState(false) const [submitting, setSubmitting] = useState(false) - const [error, setError] = useState('') - const cardRef = useRef(null) - const attemptKeyRef = useRef(null) // stable per checkout attempt + const [error, setError] = useState('') + const cardRef = useRef(null) // 1 — Load Square SDK script (idempotent) useEffect(() => { @@ -99,12 +98,24 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) { ;(async () => { try { + // Double-rAF: wait for the browser to finish painting so #sq-card is in the DOM + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + ) + if (!mounted) return + if (!document.getElementById('sq-card')) { + if (mounted) setError('Could not load payment form — please refresh and try again.') + return + } + const payments = await window.Square!.payments(appId, locationId) const card = await payments.card() await card.attach('#sq-card') if (mounted) { cardRef.current = card setCardReady(true) + } else { + card.destroy().catch(() => {}) } } catch (e) { console.error('[PaymentForm] init:', e) @@ -122,13 +133,6 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) { const handlePay = async () => { if (!cardRef.current || submitting) return - - // Generate once per checkout attempt; reuse on retries so the server can - // deduplicate the Square order and detect a completed payment. - if (!attemptKeyRef.current) { - attemptKeyRef.current = crypto.randomUUID() - } - setSubmitting(true) setError('') @@ -143,11 +147,38 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) { return } - const res = await fetch('/api/checkout', { + const checkoutBody = JSON.stringify({ ...payload, sourceId: tokenResult.token }) + + // Attempt the checkout request. On a network-level failure (fetch throws), + // wait 2 seconds and retry once automatically — the idempotency key ensures + // no double charge and will return success if the first attempt already + // captured payment but the response was lost. + const attemptCheckout = () => fetch('/api/checkout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ...payload, sourceId: tokenResult.token, idempotencyKey: attemptKeyRef.current }), + body: checkoutBody, }) + + let res: Response + try { + res = await attemptCheckout() + } catch { + // Network failure on first attempt — pause and retry once + await new Promise((r) => setTimeout(r, 2000)) + try { + res = await attemptCheckout() + } catch { + // Both attempts failed — payment may or may not have gone through. + // The idempotency key is preserved in localStorage, so clicking + // "Place Order" again will safely resolve either way. + setError( + 'Connection issue — your payment may have already been processed. ' + + 'Please tap "Place Order" again to confirm, or contact us if this persists.' + ) + return + } + } + const data = await res.json() if (!res.ok || !data.success) { @@ -156,16 +187,7 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) { return } - if (data.calendarWarning) { - // Payment succeeded but slot may conflict — we handle manually - console.warn('[checkout] calendarWarning:', data.calendarWarning) - } - - attemptKeyRef.current = null // reset so a new cart gets a fresh key onSuccess(data.orderId as string, data.shortRef as string) - } catch (err) { - const msg = err instanceof Error ? err.message : null - setError(msg ?? 'Network error — please check your connection and try again.') } finally { setSubmitting(false) } diff --git a/src/components/WelcomeModal.tsx b/src/components/WelcomeModal.tsx new file mode 100644 index 0000000..7dba6a7 --- /dev/null +++ b/src/components/WelcomeModal.tsx @@ -0,0 +1,101 @@ +'use client' + +interface Props { + onTour: () => void + onDismiss: () => void +} + +const HOW_IT_WORKS = [ + { emoji: '🎈', title: 'Browse arrangements', body: 'Filter by occasion or category and find your perfect setup.' }, + { emoji: '🎨', title: 'Pick your colors', body: 'Choose from 40+ latex colors and customize every detail.' }, + { emoji: '📅', title: 'Choose delivery or pickup', body: 'We deliver to your door, or you can pick up at our Milford, CT shop.' }, + { emoji: '✅', title: 'Pay securely', body: 'Powered by Square — your card info never touches our servers.' }, +] + +export default function WelcomeModal({ onTour, onDismiss }: Props) { + return ( + <> + {/* Backdrop */} +
+ + {/* Modal */} +
e.stopPropagation()} + > + {/* Header */} +
+
🎈
+

+ Welcome to Beach Party Balloons! +

+

+ Here's how ordering works: +

+
+ + {/* Steps */} +
+ {HOW_IT_WORKS.map(({ emoji, title, body }) => ( +
+ + {emoji} + +
+

{title}

+

{body}

+
+
+ ))} +
+ + {/* Actions */} +
+ + +
+
+ + ) +} diff --git a/src/lib/caldav.ts b/src/lib/caldav.ts index c08a3c2..097b5fe 100644 --- a/src/lib/caldav.ts +++ b/src/lib/caldav.ts @@ -1,7 +1,6 @@ import { createDAVClient } from 'tsdav' import ICAL from 'ical.js' import { addDays } from 'date-fns' -import { randomUUID } from 'crypto' import type { DeliveryTier } from './delivery' import { JOB_MINUTES } from './delivery' @@ -121,7 +120,9 @@ export async function createDeliveryEvent(params: { // startTime is the customer arrival time; the calendar block covers only the on-site window. // Drive time is used for scheduling (slot availability) but not shown in the calendar event. const endTime = new Date(startTime.getTime() + JOB_MINUTES[tier] * 60_000) - const uid = `${randomUUID()}@beachpartyballoons.com` + // UID is derived from the Square order ID so retries are idempotent — writing + // the same event twice is a no-op rather than creating a duplicate. + const uid = `delivery-${orderId}@beachpartyballoons.com` const descParts = [ customerName, @@ -156,11 +157,19 @@ export async function createDeliveryEvent(params: { const { client, targetCal } = await getCalendarClient() if (!targetCal) throw new Error('No calendar found') - await client.createCalendarObject({ - calendar: targetCal, - filename: `${uid}.ics`, - iCalString: ical, - }) + try { + await client.createCalendarObject({ + calendar: targetCal, + filename: `${uid}.ics`, + iCalString: ical, + }) + } catch (err) { + // 412 Precondition Failed means the event already exists (same UID/filename). + // This is expected on retries — treat it as success. + const msg = String(err) + if (msg.includes('412') || msg.toLowerCase().includes('precondition')) return + throw err + } } export async function createPickupEvent(params: { @@ -175,7 +184,7 @@ export async function createPickupEvent(params: { const { startTime, lineItems, colors, customerName, customerPhone, notes, orderId } = params const endTime = new Date(startTime.getTime() + 15 * 60_000) // 15-min marker - const uid = `${randomUUID()}@beachpartyballoons.com` + const uid = `pickup-${orderId}@beachpartyballoons.com` const descParts = [ customerName, @@ -209,11 +218,17 @@ export async function createPickupEvent(params: { const { client, targetCal } = await getCalendarClient() if (!targetCal) throw new Error('No calendar found') - await client.createCalendarObject({ - calendar: targetCal, - filename: `${uid}.ics`, - iCalString: ical, - }) + try { + await client.createCalendarObject({ + calendar: targetCal, + filename: `${uid}.ics`, + iCalString: ical, + }) + } catch (err) { + const msg = String(err) + if (msg.includes('412') || msg.toLowerCase().includes('precondition')) return + throw err + } } /** diff --git a/src/lib/categories-display.ts b/src/lib/categories-display.ts index 90fb4fa..4ef9599 100644 --- a/src/lib/categories-display.ts +++ b/src/lib/categories-display.ts @@ -1,5 +1,6 @@ -import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs' +import { readFileSync, existsSync } from 'fs' import path from 'path' +import { atomicWriteJSON } from './file-utils' export interface CategoryDisplayConfig { order: string[] // category keys in desired tab order; unlisted keys go at the end @@ -20,7 +21,5 @@ export function getCategoryDisplayConfig(): CategoryDisplayConfig { } export function saveCategoryDisplayConfig(config: CategoryDisplayConfig): void { - const dir = path.dirname(PATH) - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) - writeFileSync(PATH, JSON.stringify(config, null, 2), 'utf-8') + atomicWriteJSON(PATH, config) } diff --git a/src/lib/file-utils.ts b/src/lib/file-utils.ts new file mode 100644 index 0000000..a7dccb2 --- /dev/null +++ b/src/lib/file-utils.ts @@ -0,0 +1,14 @@ +import { writeFileSync, renameSync, mkdirSync, existsSync } from 'fs' +import path from 'path' + +/** + * Atomically write JSON to a file by writing to a temp file first, then + * renaming it into place. Prevents partial writes from corrupting the file. + */ +export function atomicWriteJSON(filePath: string, data: unknown): void { + const dir = path.dirname(filePath) + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) + const tmp = `${filePath}.tmp` + writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf-8') + renameSync(tmp, filePath) +} diff --git a/src/lib/hours.ts b/src/lib/hours.ts index 4fdbd84..d5efcae 100644 --- a/src/lib/hours.ts +++ b/src/lib/hours.ts @@ -1,5 +1,6 @@ -import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs' +import { readFileSync, existsSync } from 'fs' import path from 'path' +import { atomicWriteJSON } from './file-utils' import { DEFAULT_HOURS } from './hours-config' import type { HoursConfig } from './hours-config' @@ -22,7 +23,5 @@ export function getHoursConfig(): HoursConfig { } export function saveHoursConfig(config: HoursConfig): void { - const dir = path.dirname(HOURS_PATH) - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) - writeFileSync(HOURS_PATH, JSON.stringify(config, null, 2), 'utf-8') + atomicWriteJSON(HOURS_PATH, config) } diff --git a/src/lib/notify.ts b/src/lib/notify.ts index 942143b..6e5883a 100644 --- a/src/lib/notify.ts +++ b/src/lib/notify.ts @@ -37,16 +37,24 @@ async function send(params: { const transporter = getTransporter() if (!transporter) { - console.warn('[notify] SMTP not configured — email skipped:', params.subject) + const missing = ['SMTP_HOST', 'SMTP_USER', 'SMTP_PASS'].filter((k) => !process.env[k]) + console.error('[notify] SMTP not configured — missing env vars:', missing.join(', '), '— email skipped:', params.subject) return } - await transporter.sendMail({ - from, - to: params.to, - subject: params.subject, - text: params.text, - }) + try { + await transporter.sendMail({ + from, + to: params.to, + subject: params.subject, + text: params.text, + }) + console.log('[notify] Email sent:', params.subject, '→', params.to) + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err) + console.error('[notify] SMTP send failed:', msg, '| subject:', params.subject, '| to:', params.to) + throw err // re-throw so callers can handle/log it + } } // ── Public helpers ───────────────────────────────────────────────────────────── diff --git a/src/lib/occasions-store.ts b/src/lib/occasions-store.ts index 78cf57e..c40d884 100644 --- a/src/lib/occasions-store.ts +++ b/src/lib/occasions-store.ts @@ -1,5 +1,6 @@ -import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs' +import { readFileSync, existsSync } from 'fs' import path from 'path' +import { atomicWriteJSON } from './file-utils' import type { OccasionsConfig } from './occasions' const OCCASIONS_PATH = path.join(process.cwd(), 'data', 'occasions.json') @@ -14,7 +15,5 @@ export function getOccasionsConfig(): OccasionsConfig { } export function saveOccasionsConfig(config: OccasionsConfig): void { - const dir = path.dirname(OCCASIONS_PATH) - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) - writeFileSync(OCCASIONS_PATH, JSON.stringify(config, null, 2), 'utf-8') + atomicWriteJSON(OCCASIONS_PATH, config) } diff --git a/src/lib/overrides.ts b/src/lib/overrides.ts index 4d3b818..0525a79 100644 --- a/src/lib/overrides.ts +++ b/src/lib/overrides.ts @@ -1,5 +1,6 @@ -import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs' +import { readFileSync, existsSync } from 'fs' import path from 'path' +import { atomicWriteJSON } from './file-utils' export interface ItemOverride { hidden?: boolean @@ -35,9 +36,7 @@ export function readOverrides(): OverridesMap { } export function writeOverrides(overrides: OverridesMap): void { - const dir = path.dirname(OVERRIDES_PATH) - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) - writeFileSync(OVERRIDES_PATH, JSON.stringify(overrides, null, 2), 'utf-8') + atomicWriteJSON(OVERRIDES_PATH, overrides) } export function setOverride(itemId: string, patch: Partial): void { diff --git a/src/lib/slots.ts b/src/lib/slots.ts index 1946b3b..96be753 100644 --- a/src/lib/slots.ts +++ b/src/lib/slots.ts @@ -111,6 +111,8 @@ export async function getAvailableSlots( const closeTotalMin = hours.close const closeUTC = etToUtc(date, Math.floor(hours.close / 60), hours.close % 60) + const cutoffUTC = new Date(Date.now() + 24 * 60 * 60_000) + for (let arrivalTotalMin = openTotalMin; arrivalTotalMin < closeTotalMin; arrivalTotalMin += SLOT_STEP) { const arrivalH = Math.floor(arrivalTotalMin / 60) const arrivalM = arrivalTotalMin % 60 @@ -118,6 +120,7 @@ export async function getAvailableSlots( const departUTC = new Date(arrivalUTC.getTime() - driveMinutes * 60_000) const returnUTC = new Date(departUTC.getTime() + blockMinutes * 60_000) + if (arrivalUTC < cutoffUTC) continue // enforce 24-hour lead time if (returnUTC > closeUTC) break // Reject any slot whose full driver block overlaps an existing event @@ -161,14 +164,15 @@ export function getPickupSlots(date: string, hoursConfig?: HoursConfig): TimeSlo const openTotalMins = hours.open const closeTotalMins = hours.close + const cutoffUTC = new Date(Date.now() + 24 * 60 * 60_000) + const slots: TimeSlot[] = [] for (let total = openTotalMins; total < closeTotalMins; total += SLOT_STEP) { - const h = Math.floor(total / 60) - const m = total % 60 - slots.push({ - startISO: etToUtc(date, h, m).toISOString(), - label: fmtLabel(h, m), - }) + const h = Math.floor(total / 60) + const m = total % 60 + const slotUTC = etToUtc(date, h, m) + if (slotUTC < cutoffUTC) continue // enforce 24-hour lead time + slots.push({ startISO: slotUTC.toISOString(), label: fmtLabel(h, m) }) } return slots } diff --git a/src/lib/square.ts b/src/lib/square.ts index 3fa5b5b..dbf8dbd 100644 --- a/src/lib/square.ts +++ b/src/lib/square.ts @@ -325,11 +325,12 @@ export async function createSquareOrder(params: { } export async function createSquarePayment(params: { - sourceId: string - orderId: string - amountMoney: { amount: bigint; currency: string } - note: string + sourceId: string + orderId: string + amountMoney: { amount: bigint; currency: string } + note: string idempotencyKey: string + autocomplete?: boolean // false = pre-authorize (hold) without capturing }) { const client = getClient() const { result } = await client.paymentsApi.createPayment({ @@ -339,6 +340,28 @@ export async function createSquarePayment(params: { orderId: params.orderId, locationId: process.env.SQUARE_LOCATION_ID!, note: params.note, + autocomplete: params.autocomplete ?? true, }) return result.payment } + +/** Capture a pre-authorized payment (created with autocomplete: false). */ +export async function completeSquarePayment(paymentId: string) { + const client = getClient() + const { result } = await client.paymentsApi.completePayment(paymentId, {}) + return result.payment +} + +/** Void a pre-authorized payment that was never captured. Customer is not charged. */ +export async function cancelSquarePayment(paymentId: string) { + const client = getClient() + const { result } = await client.paymentsApi.cancelPayment(paymentId) + return result.payment +} + +/** Retrieve a Square order with its full tender/payment details. */ +export async function retrieveSquareOrder(orderId: string) { + const client = getClient() + const { result } = await client.ordersApi.retrieveOrder(orderId) + return result.order +} diff --git a/src/middleware.ts b/src/middleware.ts index 20db083..50f869a 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -3,24 +3,52 @@ import type { NextRequest } from 'next/server' const COOKIE = 'admin_token' -export function middleware(request: NextRequest) { +/** Constant-time string comparison to prevent timing attacks */ +function safeEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false + let diff = 0 + for (let i = 0; i < a.length; i++) { + diff |= a.charCodeAt(i) ^ b.charCodeAt(i) + } + return diff === 0 +} + +/** + * Derive a session token from the admin password using SHA-256. + * The raw password is never stored in the cookie. + */ +async function deriveSessionToken(password: string): Promise { + const data = new TextEncoder().encode(`admin-session-v1:${password}`) + const hash = await crypto.subtle.digest('SHA-256', data) + return Array.from(new Uint8Array(hash)) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') +} + +export async function middleware(request: NextRequest) { const { pathname } = request.nextUrl - // Always allow the login page and login API endpoint if (pathname === '/shop/admin/login' || pathname === '/api/admin/login') { return NextResponse.next() } if (pathname.startsWith('/shop/admin') || pathname.startsWith('/api/admin')) { - const token = request.cookies.get(COOKIE)?.value - const expected = process.env.ADMIN_PASSWORD + const token = request.cookies.get(COOKIE)?.value + const password = process.env.ADMIN_PASSWORD - if (!expected || !token || token !== expected) { + if (!password || !token) { if (pathname.startsWith('/api/')) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const loginUrl = new URL('/shop/admin/login', request.url) - return NextResponse.redirect(loginUrl) + return NextResponse.redirect(new URL('/shop/admin/login', request.url)) + } + + const expected = await deriveSessionToken(password) + if (!safeEqual(token, expected)) { + if (pathname.startsWith('/api/')) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + return NextResponse.redirect(new URL('/shop/admin/login', request.url)) } }