Items in the Square "Build" category don't need the "Online" category
to appear in the bouquet builder. A new /api/bouquet-items endpoint
calls getSquareCatalog({ filterCategory: 'build' }) which skips the
Online filter and returns only Build-category items directly from Square.
BouquetPicker now fetches from /api/bouquet-items instead of /api/catalog.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Both mylar and latex items come from the Build category in Square.
Add the 11" latex item to Build and it automatically gets the color
picker. Add any mylar/foil item to Build and it gets a quantity picker.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously both mylar and latex items had to be in the Build category.
Now: mylar options = Build category items without showColors; latex
options = all showColors items in the catalog, no extra tagging needed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The IIFE with an inline type declaration inside a JSX ternary didn't
compile. Pulled renderEntry and cartGroups into plain variables above
the cartBody const so the JSX stays clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Tags are never populated from Square, so the tag check never worked.
Any item whose name starts with "Build Your" now opens the bouquet picker.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
New BouquetPicker modal lets customers assemble a bouquet from catalog
items tagged with the Square "Build" category — up to 6 mylars (with
per-item quantity and variation selection) and 6 latex balloons (with
inline color swatch picker).
Product cards tagged 'bouquet-builder' in Square open the new picker
instead of ColorPicker. Each selected balloon is added to the cart as
its own line item grouped under a "Your Bouquet" header in the drawer,
with a single "Remove all" button for the whole bouquet.
The "build" category is hidden from the main catalog tab bar so
component items don't clutter the shop unless they're also in another
display category.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sold-out variant buttons now show "Sold Out" and can't be selected.
The picker also defaults to the first in-stock variation instead of
always starting on variations[0], which could be out of stock.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously, if any single variant hit 0 stock, the whole product card
would show as sold out and block ordering. Now it checks all tracked
variants — the product is only sold out when every variant is unavailable.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sharp in this container lacks HEIF support, causing the entire form
submission to fail when someone uploads a HEIC file. Wrap each file
conversion in try/catch so unsupported formats are skipped with a
warning and the message still goes through.
Also remove HEIC from the advertised accepted formats in the UI.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Require message to have at least 3 words — catches single-token
random strings like 'EhdRpaTrHsSahuiuz'
- Require message to be at least 10 characters
- Validate email format server-side (was client-side only)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The root cause (wrong port 5001 vs 5002 in NPMplus /uploads location)
is now fixed. Remove the window.location.origin fallback and the
Express proxy routes that were added while diagnosing the issue.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Try same-origin (/photos, /uploads via main nginx) before falling back
to photobackend subdomain, which returns 502 for static files.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
50000 was taking >1 minute on some devices. 2000 should be ~1-2s in
most browsers while still being meaningful work for bots. The HMAC
key and expiration are the real security — cost is just friction.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
auto="onload" starts the proof-of-work in the background as soon as
the page loads. By the time a user fills out the form it's already
verified — no manual click required.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
cost 100 → 50000: was solvable in milliseconds by a bot, now takes
~1-2s in a real browser, making mass automation impractical.
expiresAt 10min: embeds expiry in the HMAC-signed challenge so the
server rejects replayed tokens without needing to store seen challenges.
ALTCHA_HMAC_KEY must be set to a strong secret in production.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
robots.txt blocks /admin/ from crawlers and points to the sitemap.
sitemap.xml lists all six public pages with appropriate priorities.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
align-items: flex-start lets each tile shrink to its content height
instead of stretching to match the tallest tile (Shop Hours).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add #status and #hours-* IDs to the contact page hours tile and load
update.js so the same live open/closed logic from the homepage runs
on the contact page. Guard against missing #message element so update.js
works on pages that don't have the marquee banner.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace plain-text address/phone/hours on the homepage with the same
three-tile card layout used on the contact page. Consolidates the
separate hours section into the tile, removing the duplicate. Dynamic
#status and #hours-* IDs are preserved inside the tile for script.js.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add a gradient hero header, three icon-based contact tiles (address,
phone, hours), and a "Send a Message" heading above the form to give
the page more structure and visual interest.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The base64 atob() pattern is trivially decoded by scrapers, making it
ineffective. The contact form is the preferred channel anyway.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two bugs prevented form submission entirely:
1. `challengeurl` attribute was renamed to `challenge` in altcha v3 — the
widget silently ignored the old name so it never fetched a challenge.
2. `altchaWidget.value` is not an exposed property on the v3 custom element;
read the solved payload from the hidden `<input name="altcha">` the widget
renders in light DOM instead.
Also clears the err-altcha error message at the start of each submit attempt
so it doesn't linger after the user completes verification and retries.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
v3 moved the widget from dist/altcha.min.js to dist/main/altcha.min.js;
the old path served a cached v2 widget which expected a 'challenge'
string field and threw split-on-undefined against v3's parameters format.
Pin to @3.1.0 to prevent future surprise upgrades.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Node 18 requires --experimental-global-webcrypto for crypto.subtle /
crypto.getRandomValues as globals; Node 20 LTS exposes them by default,
which altcha/lib needs for createChallenge.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The route had a redundant isAuthed() checking for 'bpb_admin' cookie,
but login sets 'admin_token'. The middleware already guards all
/api/admin/* routes, so the in-route check was just wrong.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Node 18 enforces the package exports map; the deep path
'altcha/dist/lib/index.umd.cjs' is not exported, but 'altcha/lib' is.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Server: /api/altcha generates a SHA-256 challenge (v3 API); /api/contact
verifies the widget payload before processing the submission
- Widget: added <altcha-widget> from CDN above the submit button
- contact-form.js: blocks submission if altcha value is missing and
appends it to FormData
- docker-compose.yml: passes ALTCHA_HMAC_KEY env var to main-site container
- package.json: added altcha@3.1.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
High impact:
- Cart items now show product thumbnail (52px) so customers can visually
confirm what they ordered
- Delivery quote auto-fetches 800ms after the address stops changing,
removing the manual "Check availability" step; also persists across
sessions and restores when the same address is loaded from localStorage
- Calendar error fallback now shows a clear explanation before the
booking request form
Medium impact:
- "Copy shareable cart link" button in cart footer encodes the current
cart as a ?cart= URL param; opening the link re-hydrates the cart from
the catalog so customers can share or continue on another device
- Order status page at /order/[orderId] shows state, fulfillment time,
and items; linked from the post-checkout success screen
- Delivery quote is saved to localStorage and restored automatically
when the same address is loaded in a new session
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Delete Hero, ReviewsSection, TrustedBrands components (never imported)
- Delete /api/admin/orders/[orderId]/complete route (never called; order
state transitions go through /status instead)
- Extract maxColorsFor() from ProductCard and CartDrawer into
src/lib/colors.ts to eliminate the duplicated implementation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Force explicit light-mode colors with inline styles so Bulma's dark
mode cannot override the closure banner text and background.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Admin panel shows a prominent open/closed toggle above the tabs. When
closed, the shop displays a branded closure message and the checkout API
returns 503. The closure state persists in data/store-status.json.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Unique title + meta description on every main-site page
- OpenGraph + Twitter card tags sitewide (hero image on homepage, logo elsewhere)
- LocalBusiness JSON-LD on homepage for Google rich results
- Custom 404 page on main-site (branded, links home + contact)
- Custom not-found page on estore
- Fix typo: "Delivery avalable" → "Delivery available"
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- First and last name are now separate required fields
- Server combines them into a full name for emails
- Sends a push notification to NTFY_URL on new inquiry (fire-and-forget)
- NTFY_URL env var wired through docker-compose
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
click events are unreliable on mobile due to scroll handling. Use
touchstart (fires immediately) for mobile and click for desktop with
deduplication. Lower threshold to 5 taps, widen window to 3 seconds.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Long press on the logo link was unreliable. Switch to detecting 7 rapid
taps on any non-link/button area within 2 seconds — works on mobile and
desktop without conflicting with navigation.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Hold the logo for 0.6s (works on mobile touch and desktop mouse) to launch
22 balloon silhouettes floating up from the bottom. Balloons drift with a
slight sway, have a shine highlight, and naturally disappear under the navbar.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
NPM proxies beachpartyballoons.com → host:3000. Binding to 80 conflicts
with NPM which owns that port.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>