Security hardening, checkout reliability, onboarding tour, and UX fixes
Security: - Replace raw password cookie with HMAC-derived session token + constant-time compare - Add rate limiting (5 attempts / 15 min) to admin login - Atomic JSON writes via file-utils to prevent corruption on crash - Tighten CSP headers; add Square CDN to style-src and font-src - WebP conversion + 20 MB limit on admin image uploads Checkout reliability: - Delayed capture flow: pre-auth → calendar write → capture (never charge without booking) - Derive payment idempotency key from SHA-256(nonce) to prevent nonce/key mismatch on retry - Idempotency key persisted in localStorage; auto-retry on network failure - Idempotent CalDAV writes using orderId-based UIDs; treat 412 as success - User-friendly Square error messages instead of raw API detail strings UX: - Welcome modal + 5-step guided tour with spotlight and scroll-into-view - Balloon release agreement checkbox required before payment - 24-hour lead time enforced server-side in both delivery and pickup slot generators - Fix Square card form race condition with double-rAF before attach() - Tour hides Bulma modal-background for bright, unobscured modal steps Notifications: - Improved SMTP error logging; re-throw on failure so callers see it Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3cb9eae975
commit
cdaf79ac71
@ -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
|
||||
|
||||
521
package-lock.json
generated
521
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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<string, { count: number; resetAt: number }>()
|
||||
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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 <T>(fn: () => Promise<T>, attempts = 3): Promise<T> => {
|
||||
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<string, string> = {
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Balloon release agreement */}
|
||||
<div style={{
|
||||
marginTop: '1rem',
|
||||
background: '#fff8e1',
|
||||
border: `1.5px solid ${infoErrors.balloon ? '#e53e3e' : '#f6c000'}`,
|
||||
borderRadius: '8px',
|
||||
padding: '0.85rem 1rem',
|
||||
}}>
|
||||
<label style={{ display: 'flex', gap: '10px', alignItems: 'flex-start', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={balloonAgreement}
|
||||
onChange={(e) => {
|
||||
setBalloonAgreement(e.target.checked)
|
||||
if (e.target.checked) setInfoErrors((p) => ({ ...p, balloon: undefined }))
|
||||
}}
|
||||
style={{ marginTop: '3px', flexShrink: 0, width: '16px', height: '16px', accentColor: '#11b3be' }}
|
||||
/>
|
||||
<span style={{ fontSize: '0.82rem', color: '#5a4000', lineHeight: 1.5 }}>
|
||||
<strong>I agree not to release these balloons outdoors.</strong> I understand that balloon releases are harmful to wildlife and the environment, and I will keep all balloons weighted, anchored, or indoors at all times.
|
||||
</span>
|
||||
</label>
|
||||
{infoErrors.balloon && (
|
||||
<p style={{ color: '#e53e3e', fontSize: '0.78rem', marginTop: '0.4rem', marginLeft: '26px' }}>
|
||||
{infoErrors.balloon}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
|
||||
@ -147,9 +147,8 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
|
||||
const total = basePrice > 0 ? fmt(unitPrice * quantity) : 'Get Quote'
|
||||
|
||||
return (
|
||||
<div className="modal is-active">
|
||||
<div className="modal-background" onClick={onClose} />
|
||||
<div className="modal-card" style={{ maxWidth: '780px', width: '95vw' }}>
|
||||
<div className="modal is-active" onClick={onClose}>
|
||||
<div className="modal-card" style={{ maxWidth: '780px', width: '95vw' }} onClick={(e) => e.stopPropagation()}>
|
||||
|
||||
<header className="modal-card-head" style={{ background: '#11b3be' }}>
|
||||
<p className="modal-card-title has-text-white">{product.name}</p>
|
||||
@ -252,7 +251,7 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
|
||||
|
||||
{/* ── Color picker — only for Latex items ── */}
|
||||
{product.showColors && (
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<div data-tour="color-section" style={{ marginBottom: '1.5rem' }}>
|
||||
|
||||
{/* Intro */}
|
||||
<p className="is-size-7 has-text-grey mb-3">
|
||||
@ -535,6 +534,7 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
data-tour="add-to-order"
|
||||
className="button is-info"
|
||||
disabled={!canAdd}
|
||||
onClick={() => {
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import ProductCard from './ProductCard'
|
||||
import WelcomeModal from './WelcomeModal'
|
||||
import GuidedTour from './GuidedTour'
|
||||
import type { CatalogItem } from '@/data/mock-catalog'
|
||||
|
||||
interface ActiveOccasion {
|
||||
@ -24,6 +26,33 @@ export default function FeaturedProducts() {
|
||||
const [search, setSearch] = useState('')
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
|
||||
const [showWelcome, setShowWelcome] = useState(false)
|
||||
const [showTour, setShowTour] = useState(false)
|
||||
|
||||
// Show welcome modal once per browser (after products load so tour targets exist)
|
||||
useEffect(() => {
|
||||
if (!loading && !localStorage.getItem('bpb_seen_welcome')) {
|
||||
setShowWelcome(true)
|
||||
}
|
||||
}, [loading])
|
||||
|
||||
const dismissWelcome = () => {
|
||||
localStorage.setItem('bpb_seen_welcome', '1')
|
||||
setShowWelcome(false)
|
||||
}
|
||||
|
||||
const startTour = () => {
|
||||
localStorage.setItem('bpb_seen_welcome', '1')
|
||||
setShowWelcome(false)
|
||||
setShowTour(true)
|
||||
}
|
||||
|
||||
const endTour = () => {
|
||||
setShowTour(false)
|
||||
// Close any customization modal that may have been opened during the tour
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
}
|
||||
|
||||
const productCategories = useMemo(() => {
|
||||
const seen = new Map<string, string>()
|
||||
items.forEach((item) => {
|
||||
@ -100,19 +129,45 @@ export default function FeaturedProducts() {
|
||||
<div className="container">
|
||||
|
||||
{/* Section header */}
|
||||
<div className="has-text-centered mb-5">
|
||||
<div className="has-text-centered mb-5" style={{ position: 'relative', paddingLeft: '36px', paddingRight: '36px' }}>
|
||||
<h2 className="is-size-3">Shop</h2>
|
||||
<p className="is-size-6 has-text-grey">
|
||||
Choose an arrangement, pick your colors, and place your order.
|
||||
</p>
|
||||
{/* Persistent tour button */}
|
||||
<button
|
||||
onClick={startTour}
|
||||
title="How does this work?"
|
||||
aria-label="How does this work?"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
borderRadius: '50%',
|
||||
border: '1.5px solid #ccc',
|
||||
background: '#fff',
|
||||
color: '#888',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
lineHeight: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
?
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Category tabs + search */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<div data-tour="tabs" style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<div className="tabs is-centered" style={{ flex: 1, marginBottom: 0 }}>
|
||||
<ul>
|
||||
{tabs.map(({ key, label }) => (
|
||||
<li key={key} className={category === key ? 'is-active' : ''}>
|
||||
{tabs.map(({ key, label, occasion }) => (
|
||||
<li key={occasion ? `occ-${key}` : `cat-${key}`} className={category === key ? 'is-active' : ''}>
|
||||
<a onClick={() => { setCategory(key); setSearch(''); setSearchOpen(false) }}>{label}</a>
|
||||
</li>
|
||||
))}
|
||||
@ -166,6 +221,10 @@ export default function FeaturedProducts() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Welcome modal + guided tour */}
|
||||
{showWelcome && <WelcomeModal onTour={startTour} onDismiss={dismissWelcome} />}
|
||||
{showTour && <GuidedTour onDone={endTour} />}
|
||||
|
||||
{/* Product grid */}
|
||||
{loading ? (
|
||||
<SkeletonGrid />
|
||||
@ -184,10 +243,11 @@ export default function FeaturedProducts() {
|
||||
</p>
|
||||
) : (
|
||||
<div className="columns is-multiline is-centered">
|
||||
{filtered.map((item) => (
|
||||
{filtered.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="column is-3-desktop is-6-tablet is-12-mobile"
|
||||
{...(index === 0 ? { 'data-tour': 'first-card' } : {})}
|
||||
>
|
||||
<ProductCard item={item} />
|
||||
</div>
|
||||
|
||||
315
src/components/GuidedTour.tsx
Normal file
315
src/components/GuidedTour.tsx
Normal file
@ -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<DOMRect | null>(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 && (
|
||||
<div
|
||||
onClick={onDone}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: spot ? 'rgba(0,0,0,0.55)' : 'rgba(0,0,0,0.65)',
|
||||
zIndex: 9998,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Spotlight — dark surround with cutout */}
|
||||
{spot && !current.noOverlay && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: spot.top,
|
||||
left: spot.left,
|
||||
width: spot.width,
|
||||
height: spot.height,
|
||||
borderRadius: '6px',
|
||||
boxShadow: '0 0 0 9999px rgba(0,0,0,0.55)',
|
||||
zIndex: 9999,
|
||||
pointerEvents: 'none',
|
||||
outline: '2px solid rgba(17,179,190,0.7)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Highlight ring — bright border, no overlay, for modal steps */}
|
||||
{spot && current.noOverlay && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: spot.top,
|
||||
left: spot.left,
|
||||
width: spot.width,
|
||||
height: spot.height,
|
||||
borderRadius: '8px',
|
||||
border: '2.5px solid #11b3be',
|
||||
boxShadow: '0 0 0 4px rgba(17,179,190,0.25)',
|
||||
zIndex: 10001,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tooltip card */}
|
||||
<div
|
||||
style={{
|
||||
...tooltipStyle,
|
||||
background: '#fff',
|
||||
borderRadius: '12px',
|
||||
padding: '1.25rem 1.25rem 1rem',
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.22)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Step dots */}
|
||||
<div style={{ display: 'flex', gap: '6px', marginBottom: '0.75rem' }}>
|
||||
{STEPS.map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
width: i === step ? '18px' : '8px',
|
||||
height: '8px',
|
||||
borderRadius: '4px',
|
||||
background: i === step ? '#11b3be' : '#d0d0d0',
|
||||
transition: 'width 0.25s, background 0.25s',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p style={{ fontWeight: 700, fontSize: '0.95rem', marginBottom: '0.4rem', color: '#111' }}>
|
||||
{current.title}
|
||||
</p>
|
||||
<p style={{ fontSize: '0.85rem', color: '#555', lineHeight: 1.5, marginBottom: '1rem' }}>
|
||||
{current.body}
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<button
|
||||
onClick={onDone}
|
||||
style={{ fontSize: '0.8rem', color: '#999', background: 'none', border: 'none', cursor: 'pointer', padding: 0 }}
|
||||
>
|
||||
Skip tour
|
||||
</button>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
{!isFirst && (
|
||||
<button onClick={prev} className="button is-light is-small">
|
||||
Back
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={next}
|
||||
className="button is-small"
|
||||
style={{ background: '#11b3be', color: '#fff', border: 'none' }}
|
||||
>
|
||||
{isLast ? "Let's go!" : 'Next →'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -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<SquareCard | null>(null)
|
||||
const attemptKeyRef = useRef<string | null>(null) // stable per checkout attempt
|
||||
const [error, setError] = useState('')
|
||||
const cardRef = useRef<SquareCard | null>(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<void>((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)
|
||||
}
|
||||
|
||||
101
src/components/WelcomeModal.tsx
Normal file
101
src/components/WelcomeModal.tsx
Normal file
@ -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 */}
|
||||
<div
|
||||
onClick={onDismiss}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.45)',
|
||||
zIndex: 9990,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
background: '#fff',
|
||||
borderRadius: '16px',
|
||||
padding: '2rem 1.75rem 1.5rem',
|
||||
width: 'min(480px, calc(100vw - 32px))',
|
||||
zIndex: 9991,
|
||||
boxShadow: '0 16px 48px rgba(0,0,0,0.2)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{ textAlign: 'center', marginBottom: '1.5rem' }}>
|
||||
<div style={{ fontSize: '2.5rem', marginBottom: '0.5rem' }}>🎈</div>
|
||||
<h2 style={{ fontWeight: 700, fontSize: '1.2rem', marginBottom: '0.25rem' }}>
|
||||
Welcome to Beach Party Balloons!
|
||||
</h2>
|
||||
<p style={{ color: '#666', fontSize: '0.875rem' }}>
|
||||
Here's how ordering works:
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Steps */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', marginBottom: '1.5rem' }}>
|
||||
{HOW_IT_WORKS.map(({ emoji, title, body }) => (
|
||||
<div
|
||||
key={title}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '0.875rem',
|
||||
alignItems: 'flex-start',
|
||||
background: '#f8f9fa',
|
||||
borderRadius: '10px',
|
||||
padding: '0.75rem',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '1.4rem', lineHeight: 1, flexShrink: 0, marginTop: '1px' }}>
|
||||
{emoji}
|
||||
</span>
|
||||
<div>
|
||||
<p style={{ fontWeight: 600, fontSize: '0.875rem', marginBottom: '0.1rem' }}>{title}</p>
|
||||
<p style={{ fontSize: '0.8rem', color: '#666', lineHeight: 1.4 }}>{body}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
<button
|
||||
onClick={onTour}
|
||||
className="button is-fullwidth"
|
||||
style={{ background: '#11b3be', color: '#fff', border: 'none', fontWeight: 600 }}
|
||||
>
|
||||
Take a Quick Tour →
|
||||
</button>
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="button is-fullwidth is-light"
|
||||
style={{ fontSize: '0.875rem' }}
|
||||
>
|
||||
Start Shopping
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
14
src/lib/file-utils.ts
Normal file
14
src/lib/file-utils.ts
Normal file
@ -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)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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<ItemOverride>): void {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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<string> {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user