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:
chris 2026-04-13 18:27:33 -04:00
parent 3cb9eae975
commit cdaf79ac71
25 changed files with 1602 additions and 303 deletions

View File

@ -1,15 +1,42 @@
const isDev = process.env.NODE_ENV === 'development'
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
output: 'standalone', output: 'standalone',
images: { images: {
remotePatterns: [ remotePatterns: [
{ protocol: 'https', hostname: '**.squarecdn.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', { protocol: 'https', hostname: 'square-web-sdk-production.squarecdn.com' },
hostname: 'items-images-production.s3.us-west-2.amazonaws.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 export default nextConfig

521
package-lock.json generated
View File

@ -17,6 +17,7 @@
"nodemailer": "^8.0.5", "nodemailer": "^8.0.5",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"sharp": "^0.34.5",
"square": "^34.0.0", "square": "^34.0.0",
"tsdav": "^2.0.11" "tsdav": "^2.0.11"
}, },
@ -225,7 +226,6 @@
"version": "1.9.2", "version": "1.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
"integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@ -344,6 +344,471 @@
"dev": true, "dev": true,
"license": "BSD-3-Clause" "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": { "node_modules/@isaacs/cliui": {
"version": "8.0.2", "version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@ -2204,6 +2669,15 @@
"integrity": "sha512-53rsFbGdwMwlF7qvCt0ypLM5V5/Mbl0szB7GPN8y9NCcbknYOeVVXdrXEq+90IwAfrrzt6Hd+u2E2ntakICU8w==", "integrity": "sha512-53rsFbGdwMwlF7qvCt0ypLM5V5/Mbl0szB7GPN8y9NCcbknYOeVVXdrXEq+90IwAfrrzt6Hd+u2E2ntakICU8w==",
"license": "MIT" "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": { "node_modules/detect-node": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
@ -5387,7 +5861,6 @@
"version": "7.7.4", "version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"dev": true,
"license": "ISC", "license": "ISC",
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
@ -5445,6 +5918,50 @@
"node": ">= 0.4" "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": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",

View File

@ -19,6 +19,7 @@
"nodemailer": "^8.0.5", "nodemailer": "^8.0.5",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"sharp": "^0.34.5",
"square": "^34.0.0", "square": "^34.0.0",
"tsdav": "^2.0.11" "tsdav": "^2.0.11"
}, },

View File

@ -1,21 +1,12 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { getCategoryDisplayConfig, saveCategoryDisplayConfig } from '@/lib/categories-display' import { getCategoryDisplayConfig, saveCategoryDisplayConfig } from '@/lib/categories-display'
import type { CategoryDisplayConfig } 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() { export function GET() {
return NextResponse.json(getCategoryDisplayConfig()) return NextResponse.json(getCategoryDisplayConfig())
} }
export async function PUT(req: NextRequest) { export async function PUT(req: NextRequest) {
if (!isAuthed()) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try { try {
const config = (await req.json()) as CategoryDisplayConfig const config = (await req.json()) as CategoryDisplayConfig
saveCategoryDisplayConfig(config) saveCategoryDisplayConfig(config)

View File

@ -1,21 +1,12 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { getHoursConfig, saveHoursConfig } from '@/lib/hours' import { getHoursConfig, saveHoursConfig } from '@/lib/hours'
import type { HoursConfig } 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() { export async function GET() {
return NextResponse.json(getHoursConfig()) return NextResponse.json(getHoursConfig())
} }
export async function PUT(req: NextRequest) { export async function PUT(req: NextRequest) {
if (!isAuthed()) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try { try {
const config = (await req.json()) as HoursConfig const config = (await req.json()) as HoursConfig
saveHoursConfig(config) saveHoursConfig(config)

View File

@ -1,6 +1,7 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { Client, Environment, FileWrapper } from 'square' import { Client, Environment, FileWrapper } from 'square'
import { randomUUID } from 'crypto' import { randomUUID } from 'crypto'
import sharp from 'sharp'
function getClient() { function getClient() {
const token = process.env.SQUARE_CATALOG_ACCESS_TOKEN ?? process.env.SQUARE_ACCESS_TOKEN! 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 }) return new Client({ accessToken: token, environment: env })
} }
// Square accepted MIME types // Square accepted MIME types (before conversion)
const SUPPORTED = new Set(['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/bmp', 'image/webp']) 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( export async function POST(
request: Request, request: Request,
{ params }: { params: { id: string } } { params }: { params: { id: string } }
) { ) {
const client = getClient() const client = getClient()
const formData = await request.formData() const formData = await request.formData()
const files = formData.getAll('images') as File[] const files = formData.getAll('images') as File[]
if (!files.length) { if (!files.length) {
return NextResponse.json({ error: 'No files provided' }, { status: 400 }) 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) { for (const file of files) {
const mimeType = file.type || 'image/jpeg' const mimeType = file.type || 'image/jpeg'
if (!SUPPORTED.has(mimeType)) { if (!SUPPORTED.has(mimeType)) {
results.push({ results.push({
name: file.name, name: file.name,
url: null, url: null,
error: `Unsupported format: ${mimeType}. Supported: JPEG, PNG, GIF, BMP, WEBP. Convert HEIC/HEIF before uploading.`, error: `Unsupported format: ${mimeType}. Supported: JPEG, PNG, GIF, BMP, WEBP. Convert HEIC/HEIF before uploading.`,
}) })
continue continue
} }
if (file.size > MAX_FILE_SIZE) {
results.push({ name: file.name, url: null, error: 'File too large (max 20 MB)' })
continue
}
try { try {
const buffer = Buffer.from(await file.arrayBuffer()) const raw = Buffer.from(await file.arrayBuffer())
const blob = new Blob([buffer], { type: mimeType }) const { buffer, originalBytes, finalBytes } = await processImage(raw)
const wrapper = new FileWrapper(blob, { filename: file.name, contentType: mimeType })
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( const { result } = await client.catalogApi.createCatalogImage(
{ {
idempotencyKey: randomUUID(), idempotencyKey: randomUUID(),
objectId: params.id, objectId: params.id,
image: { image: {
type: 'IMAGE', type: 'IMAGE',
id: '#NEW_IMAGE', id: '#NEW_IMAGE',
imageData: { imageData: { name: baseName, caption: '' },
name: file.name.replace(/\.[^.]+$/, ''),
caption: '',
},
}, },
}, },
wrapper 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) { } catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err) const msg = err instanceof Error ? err.message : String(err)
console.error('[admin/images] upload error:', msg) console.error('[admin/images] upload error:', msg)

View File

@ -1,19 +1,62 @@
import { NextResponse } from 'next/server' 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) { 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) { if (!process.env.ADMIN_PASSWORD || password !== process.env.ADMIN_PASSWORD) {
return NextResponse.json({ error: 'Invalid password' }, { status: 401 }) return NextResponse.json({ error: 'Invalid password' }, { status: 401 })
} }
const token = deriveSessionToken(process.env.ADMIN_PASSWORD)
const response = NextResponse.json({ ok: true }) const response = NextResponse.json({ ok: true })
response.cookies.set('admin_token', process.env.ADMIN_PASSWORD, { response.cookies.set('admin_token', token, {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === 'production', secure: process.env.NODE_ENV === 'production',
sameSite: 'strict', sameSite: 'strict',
maxAge: 60 * 60 * 24 * 7, // 7 days maxAge: 60 * 60 * 24 * 7, // 7 days
path: '/', path: '/',
}) })
return response return response
} }

View File

@ -2,12 +2,6 @@ import { NextRequest, NextResponse } from 'next/server'
import { OCCASIONS, getOccasionWindow } from '@/lib/occasions' import { OCCASIONS, getOccasionWindow } from '@/lib/occasions'
import type { OccasionsConfig, CustomOccasionDef } from '@/lib/occasions' import type { OccasionsConfig, CustomOccasionDef } from '@/lib/occasions'
import { getOccasionsConfig, saveOccasionsConfig } from '@/lib/occasions-store' 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() { export function GET() {
const config = getOccasionsConfig() const config = getOccasionsConfig()
@ -66,9 +60,6 @@ export function GET() {
} }
export async function PUT(req: NextRequest) { export async function PUT(req: NextRequest) {
if (!isAuthed()) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try { try {
const config = (await req.json()) as OccasionsConfig const config = (await req.json()) as OccasionsConfig
saveOccasionsConfig(config) saveOccasionsConfig(config)

View File

@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { randomUUID } from 'crypto' import { createHash } from 'crypto'
import { inferTier, JOB_MINUTES, type DeliveryTier } from '@/lib/delivery' import { inferTier, JOB_MINUTES, type DeliveryTier } from '@/lib/delivery'
interface LineItem { interface LineItem {
@ -13,21 +13,21 @@ interface LineItem {
} }
interface CheckoutBody { interface CheckoutBody {
lineItems: LineItem[] lineItems: LineItem[]
selectedColors: string[] selectedColors: string[]
deliverySlotISO?: string // UTC ISO start time from slot picker deliverySlotISO?: string
driveMinutes?: number driveMinutes?: number
deliveryAddress?: string deliveryAddress?: string
deliveryTier?: string deliveryTier?: string
deliveryNotes?: string deliveryNotes?: string
deliveryCents?: number deliveryCents?: number
pickupSlotISO?: string pickupSlotISO?: string
sourceId: string sourceId: string
idempotencyKey?: string idempotencyKey?: string
customerFirstName?: string customerFirstName?: string
customerLastName?: string customerLastName?: string
customerEmail?: string customerEmail?: string
customerPhone?: string customerPhone?: string
} }
export async function POST(req: NextRequest) { 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 { const {
lineItems, selectedColors, deliverySlotISO, driveMinutes, lineItems, selectedColors, deliverySlotISO, driveMinutes,
deliveryAddress, deliveryTier, deliveryNotes, deliveryCents, pickupSlotISO, sourceId, deliveryAddress, deliveryTier, deliveryNotes, deliveryCents, pickupSlotISO, sourceId,
idempotencyKey, customerFirstName, customerLastName, customerEmail, customerPhone, idempotencyKey, customerFirstName, customerLastName, customerEmail, customerPhone,
} = body } = 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 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 = const resolvedTier: DeliveryTier =
(deliveryTier === 'dropoff' || deliveryTier === 'classic' || deliveryTier === 'organic') (deliveryTier === 'dropoff' || deliveryTier === 'classic' || deliveryTier === 'organic')
? deliveryTier ? deliveryTier
: inferTier(lineItems.map((l) => l.name)) : 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 arrivalISO = deliverySlotISO
const jobMin = JOB_MINUTES[resolvedTier] const jobMin = JOB_MINUTES[resolvedTier]
const windowDurationStr = deliverySlotISO const windowDurationStr = deliverySlotISO
? `PT${Math.floor(jobMin / 60) > 0 ? `${Math.floor(jobMin / 60)}H` : ''}${jobMin % 60 > 0 ? `${jobMin % 60}M` : ''}` ? `PT${Math.floor(jobMin / 60) > 0 ? `${Math.floor(jobMin / 60)}H` : ''}${jobMin % 60 > 0 ? `${jobMin % 60}M` : ''}`
: undefined : undefined
@ -74,12 +104,17 @@ export async function POST(req: NextRequest) {
].filter(Boolean) ].filter(Boolean)
const note = noteParts.join(' | ') 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) { if (deliverySlotISO && driveMinutes != null && process.env.CALDAV_URL) {
try { try {
const { getBusyDates } = await import('@/lib/caldav') const { getBusyDates } = await import('@/lib/caldav')
const { getAvailableSlots } = await import('@/lib/slots') const { getAvailableSlots } = await import('@/lib/slots')
const slotDate = deliverySlotISO.slice(0, 10) const slotDate = deliverySlotISO.slice(0, 10)
const dayStart = new Date(`${slotDate}T00:00:00Z`) const dayStart = new Date(`${slotDate}T00:00:00Z`)
const dayEnd = new Date(`${slotDate}T23:59:59Z`) const dayEnd = new Date(`${slotDate}T23:59:59Z`)
@ -88,49 +123,56 @@ export async function POST(req: NextRequest) {
.some((s) => s.startISO === deliverySlotISO) .some((s) => s.startISO === deliverySlotISO)
if (!stillFree) { if (!stillFree) {
return NextResponse.json( if (!idempotencyKey) {
{ error: 'That delivery time was just booked by someone else. Please go back and choose a different slot.' }, return NextResponse.json(
{ status: 409 } { 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) { } 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) console.error('[checkout] Slot pre-check failed, proceeding without guard:', slotErr)
} }
} }
// ── Guard: verify inventory before charging ─────────────────────────────── // ── Inventory check (skip on likely replay) ─────────────────────────────────
try { if (!slotConflict) {
const { getInventoryCounts } = await import('@/lib/square') try {
const variationIds = lineItems const { getInventoryCounts } = await import('@/lib/square')
.filter((li) => li.catalogItemId) const variationIds = lineItems
.map((li) => li.catalogItemId as string) .filter((li) => li.catalogItemId)
.map((li) => li.catalogItemId as string)
if (variationIds.length) { if (variationIds.length) {
const counts = await getInventoryCounts(variationIds) const counts = await getInventoryCounts(variationIds)
for (const li of lineItems) { for (const li of lineItems) {
if (!li.catalogItemId) continue if (!li.catalogItemId) continue
const stock = counts.get(li.catalogItemId) const stock = counts.get(li.catalogItemId)
if (stock !== undefined && stock < li.quantity) { if (stock !== undefined && stock < li.quantity) {
return NextResponse.json( return NextResponse.json(
{ error: `"${li.name}" ${stock === 0 ? 'is sold out' : `only has ${stock} left`}. Please update your cart.` }, { error: `"${li.name}" ${stock === 0 ? 'is sold out' : `only has ${stock} left`}. Please update your cart.` },
{ status: 409 } { 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 { 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 let customerId: string | undefined
if (customerEmail && customerPhone) { if (customerEmail && customerPhone && !slotConflict) {
try { try {
customerId = await upsertSquareCustomer({ customerId = await upsertSquareCustomer({
givenName: customerFirstName?.trim() ?? '', givenName: customerFirstName?.trim() ?? '',
@ -140,7 +182,6 @@ export async function POST(req: NextRequest) {
}) })
} catch (custErr) { } catch (custErr) {
console.error('[checkout] Failed to upsert customer:', 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), quantity: String(li.quantity),
basePriceMoney: { amount: BigInt(li.priceCents), currency: 'USD' }, basePriceMoney: { amount: BigInt(li.priceCents), currency: 'USD' },
note: [ note: [
li.colors?.length ? `Colors: ${li.colors.join(', ')}` : null, li.colors?.length ? `Colors: ${li.colors.join(', ')}` : null,
li.note || null, li.note || null,
].filter(Boolean).join(' | ') || undefined, ].filter(Boolean).join(' | ') || undefined,
// Modifiers sent at $0 — price already included in basePriceMoney
modifiers: li.modifiers?.length modifiers: li.modifiers?.length
? li.modifiers.map((m) => ({ ? li.modifiers.map((m) => ({
catalogObjectId: isSandbox ? undefined : m.catalogObjectId, 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') throw new Error('Order creation returned no ID or total')
} }
// Idempotency replay: if the order is already COMPLETED, payment was already const shortRef = order.id!.slice(-6).toUpperCase()
// 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 itemsSummary = lineItems.map((l) => `${l.quantity}× ${l.name}`).join(', ') const itemsSummary = lineItems.map((l) => `${l.quantity}× ${l.name}`).join(', ')
const totalCents = order.totalMoney!.amount! const totalCents = order.totalMoney!.amount!
// ── Retry helper ─────────────────────────────────────────────────────── // ── Idempotency replay: order already fully completed ───────────────────
const withRetry = async <T>(fn: () => Promise<T>, attempts = 3): Promise<T> => { // The entire flow ran on a previous attempt — payment captured, calendar
for (let i = 0; i < attempts; i++) { // written. The response just never reached the client.
try { return await fn() } catch (e) { if (order.state === 'COMPLETED') {
if (i === attempts - 1) throw e console.log('[checkout] Idempotency replay — order already completed:', order.id)
await new Promise((r) => setTimeout(r, 500 * 2 ** i)) // 500ms, 1s, 2s return NextResponse.json({
} success: true, orderId: order.id,
} shortRef, paymentId: undefined,
throw new Error('unreachable') })
} }
// ── Fire-and-forget: calendar writes + emails (payment already captured) ─ // ── Genuine slot conflict on retry ──────────────────────────────────────
void (async () => { // The slot is taken AND Square created a brand-new OPEN order (not a replay
// Delivery calendar // of a prior pre-auth). Someone else genuinely booked the slot.
if (deliverySlotISO && driveMinutes != null && deliveryAddress && process.env.CALDAV_URL) { // Check the full order for existing tenders to confirm this is truly new.
try { 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') const { createDeliveryEvent } = await import('@/lib/caldav')
await withRetry(() => createDeliveryEvent({ await createDeliveryEvent({
startTime: new Date(deliverySlotISO!), startTime: new Date(deliverySlotISO),
tier: resolvedTier, tier: resolvedTier,
driveMinutes: driveMinutes!, driveMinutes,
address: deliveryAddress!, address: deliveryAddress,
lineItems, lineItems,
colors: selectedColors, colors: selectedColors,
customerName: customerName ?? 'Customer', customerName: customerName ?? 'Customer',
customerPhone: customerPhone ?? '', customerPhone: customerPhone ?? '',
notes: deliveryNotes, notes: deliveryNotes,
orderId: order!.id!, orderId: order.id!,
}))
} catch (calErr) {
console.error('[checkout] CRITICAL: calendar write failed after retries', {
orderId: order.id, deliverySlotISO, error: calErr,
}) })
try { } else if (pickupSlotISO && process.env.CALDAV_URL) {
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 {
const { createPickupEvent } = await import('@/lib/caldav') const { createPickupEvent } = await import('@/lib/caldav')
await withRetry(() => createPickupEvent({ await createPickupEvent({
startTime: new Date(pickupSlotISO!), startTime: new Date(pickupSlotISO),
lineItems, lineItems,
colors: selectedColors, colors: selectedColors,
customerName: customerName ?? 'Customer', customerName: customerName ?? 'Customer',
customerPhone: customerPhone ?? '', customerPhone: customerPhone ?? '',
notes: deliveryNotes, notes: deliveryNotes,
orderId: order!.id!, orderId: order.id!,
}))
} catch (calErr) {
console.error('[checkout] CRITICAL: pickup calendar write failed after retries', {
orderId: order.id, pickupSlotISO, error: calErr,
}) })
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 { try {
const { sendNewOrderAlert } = await import('@/lib/notify') const { sendNewOrderAlert } = await import('@/lib/notify')
await sendNewOrderAlert({ await sendNewOrderAlert({
@ -323,11 +380,10 @@ export async function POST(req: NextRequest) {
colors: selectedColors, colors: selectedColors,
totalCents, totalCents,
}) })
} catch (notifyErr) { } catch (err) {
console.error('[checkout] Failed to send new order alert:', notifyErr) console.error('[checkout] Failed to send new order alert:', err)
} }
// Order confirmation (to customer)
if (customerEmail && (deliverySlotISO ?? pickupSlotISO)) { if (customerEmail && (deliverySlotISO ?? pickupSlotISO)) {
try { try {
const { sendOrderConfirmationEmail } = await import('@/lib/notify') const { sendOrderConfirmationEmail } = await import('@/lib/notify')
@ -343,8 +399,8 @@ export async function POST(req: NextRequest) {
colors: selectedColors, colors: selectedColors,
totalCents, totalCents,
}) })
} catch (notifyErr) { } catch (err) {
console.error('[checkout] Failed to send order confirmation email:', notifyErr) console.error('[checkout] Failed to send order confirmation email:', err)
} }
} }
})() })()
@ -353,15 +409,33 @@ export async function POST(req: NextRequest) {
success: true, success: true,
orderId: order.id, orderId: order.id,
shortRef, shortRef,
paymentId: payment?.id, paymentId: captured.id,
}) })
} catch (err: unknown) { } catch (err: unknown) {
console.error('[checkout] Error:', err) console.error('[checkout] Error:', err)
const squareErrors = (err as { errors?: Array<{ code?: string; detail?: string; category?: string }> })?.errors 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.' const code = squareErrors?.[0]?.code ?? ''
return NextResponse.json(
{ error: userMessage, details: squareErrors ?? String(err) }, const CARD_MESSAGES: Record<string, string> = {
{ status: 500 } 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 })
} }
} }

View File

@ -82,7 +82,14 @@ export default function CartDrawer() {
const [custEmail, setCustEmail] = useStoredString('bpb_email', '') const [custEmail, setCustEmail] = useStoredString('bpb_email', '')
const [custPhone, setCustPhone] = useStoredString('bpb_phone', '') 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 isValidEmail = (v: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.trim())
const isValidPhone = (v: string) => v.replace(/\D/g, '').length >= 10 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 (!isValidEmail(custEmail)) errors.email = 'Enter a valid email address'
if (!isValidPhone(custPhone)) errors.phone = 'Enter a valid phone number' if (!isValidPhone(custPhone)) errors.phone = 'Enter a valid phone number'
setInfoErrors(errors) 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(', ') const fullAddress = [street, city, state, zip].filter(Boolean).join(', ')
@ -167,6 +181,7 @@ export default function CartDrawer() {
customerEmail: custEmail, customerEmail: custEmail,
customerPhone: custPhone, customerPhone: custPhone,
grandTotal, grandTotal,
idempotencyKey: checkoutKey || undefined,
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}), [entries, fulfillmentType, deliverySlot, pickupSlot, fullAddress, quote, deliveryInstructions, custFirst, custLast, custEmail, custPhone, grandTotal, entryUnitPrice]) }), [entries, fulfillmentType, deliverySlot, pickupSlot, fullAddress, quote, deliveryInstructions, custFirst, custLast, custEmail, custPhone, grandTotal, entryUnitPrice])
@ -174,7 +189,8 @@ export default function CartDrawer() {
setOrderId(id) setOrderId(id)
setShortRef(ref) setShortRef(ref)
clearCart() 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) setQuote(null)
setDeliverySlot(null) setDeliverySlot(null)
} }
@ -187,6 +203,7 @@ export default function CartDrawer() {
} }
const handleClose = () => { const handleClose = () => {
setBalloonAgreement(false)
closeDrawer() closeDrawer()
} }
@ -571,6 +588,35 @@ export default function CartDrawer() {
</p> </p>
)} )}
</div> </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>
</> </>
) )

View File

@ -147,9 +147,8 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
const total = basePrice > 0 ? fmt(unitPrice * quantity) : 'Get Quote' const total = basePrice > 0 ? fmt(unitPrice * quantity) : 'Get Quote'
return ( return (
<div className="modal is-active"> <div className="modal is-active" onClick={onClose}>
<div className="modal-background" onClick={onClose} /> <div className="modal-card" style={{ maxWidth: '780px', width: '95vw' }} onClick={(e) => e.stopPropagation()}>
<div className="modal-card" style={{ maxWidth: '780px', width: '95vw' }}>
<header className="modal-card-head" style={{ background: '#11b3be' }}> <header className="modal-card-head" style={{ background: '#11b3be' }}>
<p className="modal-card-title has-text-white">{product.name}</p> <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 ── */} {/* ── Color picker — only for Latex items ── */}
{product.showColors && ( {product.showColors && (
<div style={{ marginBottom: '1.5rem' }}> <div data-tour="color-section" style={{ marginBottom: '1.5rem' }}>
{/* Intro */} {/* Intro */}
<p className="is-size-7 has-text-grey mb-3"> <p className="is-size-7 has-text-grey mb-3">
@ -535,6 +534,7 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
</p> </p>
)} )}
<button <button
data-tour="add-to-order"
className="button is-info" className="button is-info"
disabled={!canAdd} disabled={!canAdd}
onClick={() => { onClick={() => {

View File

@ -2,6 +2,8 @@
import { useState, useEffect, useMemo } from 'react' import { useState, useEffect, useMemo } from 'react'
import ProductCard from './ProductCard' import ProductCard from './ProductCard'
import WelcomeModal from './WelcomeModal'
import GuidedTour from './GuidedTour'
import type { CatalogItem } from '@/data/mock-catalog' import type { CatalogItem } from '@/data/mock-catalog'
interface ActiveOccasion { interface ActiveOccasion {
@ -24,6 +26,33 @@ export default function FeaturedProducts() {
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [searchOpen, setSearchOpen] = useState(false) 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 productCategories = useMemo(() => {
const seen = new Map<string, string>() const seen = new Map<string, string>()
items.forEach((item) => { items.forEach((item) => {
@ -100,19 +129,45 @@ export default function FeaturedProducts() {
<div className="container"> <div className="container">
{/* Section header */} {/* 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> <h2 className="is-size-3">Shop</h2>
<p className="is-size-6 has-text-grey"> <p className="is-size-6 has-text-grey">
Choose an arrangement, pick your colors, and place your order. Choose an arrangement, pick your colors, and place your order.
</p> </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> </div>
{/* Category tabs + search */} {/* 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 }}> <div className="tabs is-centered" style={{ flex: 1, marginBottom: 0 }}>
<ul> <ul>
{tabs.map(({ key, label }) => ( {tabs.map(({ key, label, occasion }) => (
<li key={key} className={category === key ? 'is-active' : ''}> <li key={occasion ? `occ-${key}` : `cat-${key}`} className={category === key ? 'is-active' : ''}>
<a onClick={() => { setCategory(key); setSearch(''); setSearchOpen(false) }}>{label}</a> <a onClick={() => { setCategory(key); setSearch(''); setSearchOpen(false) }}>{label}</a>
</li> </li>
))} ))}
@ -166,6 +221,10 @@ export default function FeaturedProducts() {
</div> </div>
)} )}
{/* Welcome modal + guided tour */}
{showWelcome && <WelcomeModal onTour={startTour} onDismiss={dismissWelcome} />}
{showTour && <GuidedTour onDone={endTour} />}
{/* Product grid */} {/* Product grid */}
{loading ? ( {loading ? (
<SkeletonGrid /> <SkeletonGrid />
@ -184,10 +243,11 @@ export default function FeaturedProducts() {
</p> </p>
) : ( ) : (
<div className="columns is-multiline is-centered"> <div className="columns is-multiline is-centered">
{filtered.map((item) => ( {filtered.map((item, index) => (
<div <div
key={item.id} key={item.id}
className="column is-3-desktop is-6-tablet is-12-mobile" className="column is-3-desktop is-6-tablet is-12-mobile"
{...(index === 0 ? { 'data-tour': 'first-card' } : {})}
> >
<ProductCard item={item} /> <ProductCard item={item} />
</div> </div>

View 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>
</>
)
}

View File

@ -1,6 +1,6 @@
'use client' 'use client'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react' // useRef kept for cardRef
import { fmt } from '@/lib/format' import { fmt } from '@/lib/format'
// ── Minimal Square Web Payments SDK types ───────────────────────────────────── // ── 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 appId = process.env.NEXT_PUBLIC_SQUARE_APP_ID ?? ''
const locationId = process.env.NEXT_PUBLIC_SQUARE_LOCATION_ID ?? '' const locationId = process.env.NEXT_PUBLIC_SQUARE_LOCATION_ID ?? ''
const [sdkReady, setSdkReady] = useState(false) const [sdkReady, setSdkReady] = useState(false)
const [cardReady, setCardReady] = useState(false) const [cardReady, setCardReady] = useState(false)
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const cardRef = useRef<SquareCard | null>(null) const cardRef = useRef<SquareCard | null>(null)
const attemptKeyRef = useRef<string | null>(null) // stable per checkout attempt
// 1 — Load Square SDK script (idempotent) // 1 — Load Square SDK script (idempotent)
useEffect(() => { useEffect(() => {
@ -99,12 +98,24 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) {
;(async () => { ;(async () => {
try { 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 payments = await window.Square!.payments(appId, locationId)
const card = await payments.card() const card = await payments.card()
await card.attach('#sq-card') await card.attach('#sq-card')
if (mounted) { if (mounted) {
cardRef.current = card cardRef.current = card
setCardReady(true) setCardReady(true)
} else {
card.destroy().catch(() => {})
} }
} catch (e) { } catch (e) {
console.error('[PaymentForm] init:', e) console.error('[PaymentForm] init:', e)
@ -122,13 +133,6 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) {
const handlePay = async () => { const handlePay = async () => {
if (!cardRef.current || submitting) return 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) setSubmitting(true)
setError('') setError('')
@ -143,11 +147,38 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) {
return 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', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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() const data = await res.json()
if (!res.ok || !data.success) { if (!res.ok || !data.success) {
@ -156,16 +187,7 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) {
return 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) 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 { } finally {
setSubmitting(false) setSubmitting(false)
} }

View 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&apos;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>
</>
)
}

View File

@ -1,7 +1,6 @@
import { createDAVClient } from 'tsdav' import { createDAVClient } from 'tsdav'
import ICAL from 'ical.js' import ICAL from 'ical.js'
import { addDays } from 'date-fns' import { addDays } from 'date-fns'
import { randomUUID } from 'crypto'
import type { DeliveryTier } from './delivery' import type { DeliveryTier } from './delivery'
import { JOB_MINUTES } 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. // 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. // 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 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 = [ const descParts = [
customerName, customerName,
@ -156,11 +157,19 @@ export async function createDeliveryEvent(params: {
const { client, targetCal } = await getCalendarClient() const { client, targetCal } = await getCalendarClient()
if (!targetCal) throw new Error('No calendar found') if (!targetCal) throw new Error('No calendar found')
await client.createCalendarObject({ try {
calendar: targetCal, await client.createCalendarObject({
filename: `${uid}.ics`, calendar: targetCal,
iCalString: ical, 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: { export async function createPickupEvent(params: {
@ -175,7 +184,7 @@ export async function createPickupEvent(params: {
const { startTime, lineItems, colors, customerName, customerPhone, notes, orderId } = params const { startTime, lineItems, colors, customerName, customerPhone, notes, orderId } = params
const endTime = new Date(startTime.getTime() + 15 * 60_000) // 15-min marker 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 = [ const descParts = [
customerName, customerName,
@ -209,11 +218,17 @@ export async function createPickupEvent(params: {
const { client, targetCal } = await getCalendarClient() const { client, targetCal } = await getCalendarClient()
if (!targetCal) throw new Error('No calendar found') if (!targetCal) throw new Error('No calendar found')
await client.createCalendarObject({ try {
calendar: targetCal, await client.createCalendarObject({
filename: `${uid}.ics`, calendar: targetCal,
iCalString: ical, filename: `${uid}.ics`,
}) iCalString: ical,
})
} catch (err) {
const msg = String(err)
if (msg.includes('412') || msg.toLowerCase().includes('precondition')) return
throw err
}
} }
/** /**

View File

@ -1,5 +1,6 @@
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs' import { readFileSync, existsSync } from 'fs'
import path from 'path' import path from 'path'
import { atomicWriteJSON } from './file-utils'
export interface CategoryDisplayConfig { export interface CategoryDisplayConfig {
order: string[] // category keys in desired tab order; unlisted keys go at the end 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 { export function saveCategoryDisplayConfig(config: CategoryDisplayConfig): void {
const dir = path.dirname(PATH) atomicWriteJSON(PATH, config)
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
writeFileSync(PATH, JSON.stringify(config, null, 2), 'utf-8')
} }

14
src/lib/file-utils.ts Normal file
View 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)
}

View File

@ -1,5 +1,6 @@
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs' import { readFileSync, existsSync } from 'fs'
import path from 'path' import path from 'path'
import { atomicWriteJSON } from './file-utils'
import { DEFAULT_HOURS } from './hours-config' import { DEFAULT_HOURS } from './hours-config'
import type { HoursConfig } from './hours-config' import type { HoursConfig } from './hours-config'
@ -22,7 +23,5 @@ export function getHoursConfig(): HoursConfig {
} }
export function saveHoursConfig(config: HoursConfig): void { export function saveHoursConfig(config: HoursConfig): void {
const dir = path.dirname(HOURS_PATH) atomicWriteJSON(HOURS_PATH, config)
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
writeFileSync(HOURS_PATH, JSON.stringify(config, null, 2), 'utf-8')
} }

View File

@ -37,16 +37,24 @@ async function send(params: {
const transporter = getTransporter() const transporter = getTransporter()
if (!transporter) { 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 return
} }
await transporter.sendMail({ try {
from, await transporter.sendMail({
to: params.to, from,
subject: params.subject, to: params.to,
text: params.text, 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 ───────────────────────────────────────────────────────────── // ── Public helpers ─────────────────────────────────────────────────────────────

View File

@ -1,5 +1,6 @@
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs' import { readFileSync, existsSync } from 'fs'
import path from 'path' import path from 'path'
import { atomicWriteJSON } from './file-utils'
import type { OccasionsConfig } from './occasions' import type { OccasionsConfig } from './occasions'
const OCCASIONS_PATH = path.join(process.cwd(), 'data', 'occasions.json') const OCCASIONS_PATH = path.join(process.cwd(), 'data', 'occasions.json')
@ -14,7 +15,5 @@ export function getOccasionsConfig(): OccasionsConfig {
} }
export function saveOccasionsConfig(config: OccasionsConfig): void { export function saveOccasionsConfig(config: OccasionsConfig): void {
const dir = path.dirname(OCCASIONS_PATH) atomicWriteJSON(OCCASIONS_PATH, config)
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
writeFileSync(OCCASIONS_PATH, JSON.stringify(config, null, 2), 'utf-8')
} }

View File

@ -1,5 +1,6 @@
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs' import { readFileSync, existsSync } from 'fs'
import path from 'path' import path from 'path'
import { atomicWriteJSON } from './file-utils'
export interface ItemOverride { export interface ItemOverride {
hidden?: boolean hidden?: boolean
@ -35,9 +36,7 @@ export function readOverrides(): OverridesMap {
} }
export function writeOverrides(overrides: OverridesMap): void { export function writeOverrides(overrides: OverridesMap): void {
const dir = path.dirname(OVERRIDES_PATH) atomicWriteJSON(OVERRIDES_PATH, overrides)
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
writeFileSync(OVERRIDES_PATH, JSON.stringify(overrides, null, 2), 'utf-8')
} }
export function setOverride(itemId: string, patch: Partial<ItemOverride>): void { export function setOverride(itemId: string, patch: Partial<ItemOverride>): void {

View File

@ -111,6 +111,8 @@ export async function getAvailableSlots(
const closeTotalMin = hours.close const closeTotalMin = hours.close
const closeUTC = etToUtc(date, Math.floor(hours.close / 60), hours.close % 60) 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) { for (let arrivalTotalMin = openTotalMin; arrivalTotalMin < closeTotalMin; arrivalTotalMin += SLOT_STEP) {
const arrivalH = Math.floor(arrivalTotalMin / 60) const arrivalH = Math.floor(arrivalTotalMin / 60)
const arrivalM = arrivalTotalMin % 60 const arrivalM = arrivalTotalMin % 60
@ -118,6 +120,7 @@ export async function getAvailableSlots(
const departUTC = new Date(arrivalUTC.getTime() - driveMinutes * 60_000) const departUTC = new Date(arrivalUTC.getTime() - driveMinutes * 60_000)
const returnUTC = new Date(departUTC.getTime() + blockMinutes * 60_000) const returnUTC = new Date(departUTC.getTime() + blockMinutes * 60_000)
if (arrivalUTC < cutoffUTC) continue // enforce 24-hour lead time
if (returnUTC > closeUTC) break if (returnUTC > closeUTC) break
// Reject any slot whose full driver block overlaps an existing event // 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 openTotalMins = hours.open
const closeTotalMins = hours.close const closeTotalMins = hours.close
const cutoffUTC = new Date(Date.now() + 24 * 60 * 60_000)
const slots: TimeSlot[] = [] const slots: TimeSlot[] = []
for (let total = openTotalMins; total < closeTotalMins; total += SLOT_STEP) { for (let total = openTotalMins; total < closeTotalMins; total += SLOT_STEP) {
const h = Math.floor(total / 60) const h = Math.floor(total / 60)
const m = total % 60 const m = total % 60
slots.push({ const slotUTC = etToUtc(date, h, m)
startISO: etToUtc(date, h, m).toISOString(), if (slotUTC < cutoffUTC) continue // enforce 24-hour lead time
label: fmtLabel(h, m), slots.push({ startISO: slotUTC.toISOString(), label: fmtLabel(h, m) })
})
} }
return slots return slots
} }

View File

@ -325,11 +325,12 @@ export async function createSquareOrder(params: {
} }
export async function createSquarePayment(params: { export async function createSquarePayment(params: {
sourceId: string sourceId: string
orderId: string orderId: string
amountMoney: { amount: bigint; currency: string } amountMoney: { amount: bigint; currency: string }
note: string note: string
idempotencyKey: string idempotencyKey: string
autocomplete?: boolean // false = pre-authorize (hold) without capturing
}) { }) {
const client = getClient() const client = getClient()
const { result } = await client.paymentsApi.createPayment({ const { result } = await client.paymentsApi.createPayment({
@ -339,6 +340,28 @@ export async function createSquarePayment(params: {
orderId: params.orderId, orderId: params.orderId,
locationId: process.env.SQUARE_LOCATION_ID!, locationId: process.env.SQUARE_LOCATION_ID!,
note: params.note, note: params.note,
autocomplete: params.autocomplete ?? true,
}) })
return result.payment 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
}

View File

@ -3,24 +3,52 @@ import type { NextRequest } from 'next/server'
const COOKIE = 'admin_token' 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 const { pathname } = request.nextUrl
// Always allow the login page and login API endpoint
if (pathname === '/shop/admin/login' || pathname === '/api/admin/login') { if (pathname === '/shop/admin/login' || pathname === '/api/admin/login') {
return NextResponse.next() return NextResponse.next()
} }
if (pathname.startsWith('/shop/admin') || pathname.startsWith('/api/admin')) { if (pathname.startsWith('/shop/admin') || pathname.startsWith('/api/admin')) {
const token = request.cookies.get(COOKIE)?.value const token = request.cookies.get(COOKIE)?.value
const expected = process.env.ADMIN_PASSWORD const password = process.env.ADMIN_PASSWORD
if (!expected || !token || token !== expected) { if (!password || !token) {
if (pathname.startsWith('/api/')) { if (pathname.startsWith('/api/')) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const loginUrl = new URL('/shop/admin/login', request.url) return NextResponse.redirect(new URL('/shop/admin/login', request.url))
return NextResponse.redirect(loginUrl) }
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))
} }
} }