7 Commits

Author SHA1 Message Date
metatroncubeswdev
3749b4d134 fix: decode HTML entities in storefront widget label attributes
Some checks failed
CI / Lint, Unit & Integration Tests (push) Has been cancelled
Liquid's `| escape` filter encodes apostrophes/ampersands before the label
strings land in data-* attributes; depending on how the theme processes
translations that could survive into `dataset` still entity-encoded and then
render literally (e.g. "Couldn't load available dates") once assigned to
textContent. readConfig now decodes entities once via a detached <textarea>,
routed through a shared get(key, fallback) helper for every label + heading.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 01:44:48 -04:00
metatroncubeswdev
a2c78d703f feat: close DS study coverage gaps (inventory exclusion, live checkout re-validation, per-day cap, payment fn, checkout ext)
Some checks failed
CI / Lint, Unit & Integration Tests (push) Has been cancelled
Audited the implementation against DS_Delivery_Date_Time_App_Study.docx and
closed the actionable gaps (see IMPLEMENTATION_REVIEW_2026-09-04.md).

Core (code + unit tests, 156 green):
- Wire excludeLocationsWithoutStock into resolveAvailabilityRequest; widget
  now sends variantIds so inventory-based location exclusion actually runs.
- Live slot re-validation at checkout: new checkout-snapshot.server.ts writes
  a shop-metafield capacity snapshot; validation-slot's evaluateCheckout
  rejects a complete selection that has since filled / blacked out / closed /
  hit the daily cap / left the schedule. Refreshed on order webhooks and
  slot/blackout/location/enforcement edits.
- Scopable checkout enforcement: Shop.enforcementMode (all|tagged|off) +
  enforcementTag, new app.settings.tsx admin page, honoured via the snapshot.
- Per-day order cap: Location.dailyOrderCap threaded through getAvailability
  (dailyCap + consumedPerDate); admin field on the location screen.
- Product-rule slot blocking: ProductRule.blockedStartMins, unioned in
  resolveProductRuleConstraints, enforced in the engine and resolveHoldRequest;
  admin field on the product rules screen.
- Product-page placement: product-availability.liquid block + widget
  data-mode="preview" (read-only earliest-date line).
- Second locale: datetime-widget fr.json / fr.schema.json.
- Migration 20260904120000_review_gaps (apply with prisma migrate deploy).

New Functions (source + unit tests; need `shopify app deploy` to ship):
- extensions/payment-customization: cart.payment-methods.transform.run — hides
  cash-on-delivery / pay-in-store gateways on SHIPPING orders.
- extensions/checkout-datetime/src: restored from a gitignored dist-only state
  — Plus native picker + Thank you / Order status confirmation blocks, all
  calling the existing checkout.scheduling.* routes (one capacity pool).
  tsconfig ships checkJs:false pending reconciliation with live checkout types.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 01:31:02 -04:00
metatroncubeswdev
1262e0f5ab feat: cross-theme cart placement for the storefront widget
Some checks failed
CI / Lint, Unit & Integration Tests (push) Has been cancelled
Many themes only expose "Add section" for the cart's checkout area, not
"Add block" next to the actual Checkout button — Shopify gives apps no
supported way to inject a block into an arbitrary spot in a theme's own
section markup, so a manually-placed app block can only ever land as a
disconnected standalone section on those themes.

Add a JS-based fallback via the app embed (already loaded site-wide,
independent of block placement): app-embed.liquid now carries the same
config settings as the block plus an "auto-place on cart page" toggle
(default on), emitted as an inert <template> with the widget's config as
data-* attributes. datetime-widget.ts's maybeAutoPlaceOnCart() finds the
theme's own Checkout button by CSS selector (a few common patterns, most
specific first) and inserts a live widget immediately before it — skipped
entirely if a block-placed widget already exists on the page, or no
Checkout button can be found by any known selector.

Also fix shopify.app.toml: automatically_update_urls_on_dev was left on
after the app got a real, permanent production domain
(metatron-delivery.thedomainnest.com) — leaving it on meant a future local
`shopify app dev` session would silently overwrite the live app's
application_url with a temporary Cloudflare tunnel, breaking the deployed
app until someone noticed and redeployed with the real URL.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 18:19:59 -04:00
03574a4914 feat: product rules, driving-distance zones, and shipping date ranges
Closes remaining DS-parity gaps from the feature audit:

- ProductRule model (product/collection/vendor/type/tag scoping) with
  real server-side enforcement in hold-request.server.ts, plus shaped
  availability in availability-request.server.ts. Covers per-product
  prep time, cart-content-based slot blocking, and product-restricted
  locations in one mechanism. New /app/rules admin page (Growth+).
- Driving-distance delivery zones via Google's Distance Matrix API,
  cached like existing geocoding results.
- SHIPPING-only estimated arrival range (transitMinDays/transitMaxDays
  on SlotTemplate) — widget shows "Arrives Thu-Sat" instead of a
  meaningless ship-out time slot; carried through to the order
  metafield write-back.

Storefront widget and POS extension now send cart contents (vendor/
type from cart.js, product ids for Admin-API-resolved collection/tag
rules) to both availability and hold endpoints.

checkout-datetime remains excluded from this deploy pending Shopify's
Network Access approval (unrelated to this work) — re-add from
../checkout-datetime-disabled and redeploy once granted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 00:28:45 +05:30
metatroncubeswdev
6598691372 feat: Phase 5 — multi-location, zones, rates, auto-assignment
- Prisma: Zone (postal-code list or radius), Rate (zone- or distance-band
  keyed), GeocodeCache (permanent address->lat/lng cache per
  IMPLEMENTATION_PLAN.md §9), Location.shopifyLocationId (maps to
  Shopify's own Location resource for inventory checks), Booking.zoneId
  (needed for per-zone delivery-density counts, not just per-location).
- app/lib/geo.ts: pure haversine distance + postal-code matching, unit
  tested against known city-to-city distances.
- app/services/zones.server.ts: geocoding (Google Maps Geocoding API,
  cached — never re-geocodes the same address twice), zone eligibility,
  nearest-location auto-assignment ranked by distance, delivery-density
  threshold checks (a sparse zone doesn't unlock until minOrders bookings
  have already routed through it), and inventory-based location exclusion
  via Shopify's InventoryLevel API (locations without a mapped
  shopifyLocationId are left in rather than false-negative excluded).
- app/services/rates.server.ts: pure rate resolution by zone or distance
  band, cheapest-match-wins when bands overlap.
- apps.scheduling.availability.tsx: LOCAL_DELIVERY requests with a
  postalCode/address now auto-assign to the nearest eligible,
  density-qualified zone/location instead of the shop's default location;
  response includes the matched rate. Also fixed a real gap left over from
  Phase 4: this route never actually read Booking counts into
  getAvailability's `consumed` map, so capacity always showed as fully
  available regardless of existing bookings — now it does.
- extensions/datetime-widget: LOCAL_DELIVERY now asks for a postal code
  before showing dates; PICKUP shows a Google Maps pin for the location
  (both gated on an optional Maps API key — a block setting in the theme
  editor, since it needs to be public/client-side, not an app secret);
  confirmation display and cart attributes (dd_zone_id, dd_rate_label)
  carry the resolved zone/rate through to checkout.
- extensions/delivery-customization: now appends the resolved rate to the
  relabeled delivery option ("Local delivery — Aug 25 ($5.99)") when one's
  configured — real Cart Transform-based fee *charging* stays deferred to
  v2 per IMPLEMENTATION_PLAN.md §5.4, this is display-only.
- Admin: /app/zones and /app/rates (Polaris CRUD, mirroring Phase 1's
  patterns), plus shopifyLocationId and auto-geocode-on-save added to the
  location edit form.

Fixed one real bug caught only by `npm run build` (not tsc/vitest, which
both passed clean): app.rates._index.tsx's component called
formatPriceLabel from rates.server.ts, and Remix correctly refuses to
bundle anything imported from a .server.ts path for the client. Moved the
pure (no I/O, no Prisma) formatter to app/lib/currency.ts.

Verified: lint, typecheck, 86 unit tests (+21 new: geo, zones, rates,
delivery-customization's rate-label case with a real WASM fixture run),
16 integration tests against live Postgres (+8 new: geocode caching,
postal/radius zone matching, nearest-first ranking, density thresholds),
both builds, and a live script exercising the full
zone-match -> density-check -> rate-resolve -> availability pipeline
together against the Postgres container.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 03:27:21 -04:00
metatroncubeswdev
7a340ad135 feat: Phase 4 — enforcement Functions + slot-holds
The core competitive moat (PRODUCT_STRATEGY.md §3.1): checkout can no
longer complete without a valid, still-available slot, on any Shopify
plan. User confirmed writing Functions in JS rather than Rust — no Rust
toolchain was available in this environment, and IMPLEMENTATION_PLAN.md §1
explicitly allows JS as a fallback ("Rust preferred, JS acceptable").

- app/services/holds.server.ts: Redis-backed soft slot-holds with TTL.
  Hold creation is a Lua script (EVAL) — the "does this slot have room"
  check and the reservation itself have to be one atomic Redis operation,
  or two concurrent requests can both read "one spot left" and both
  succeed. Outstanding holds per slot live in a sorted set scored by expiry
  (so eviction is just ZREMRANGEBYSCORE, no separate expiry job needed to
  read a correct count), and a repeat request from the same cart renews
  its own hold instead of competing against the capacity gate again.
- app/routes/apps.scheduling.hold.tsx: public app-proxy endpoint the widget
  calls the moment a shopper picks a slot — reserves capacity BEFORE the
  cart attribute is written, since the attribute alone is just two
  shoppers racing to write the same field.
- extensions/datetime-widget: now fetches the cart token, requests a hold
  first, and only writes cart attributes on success; shows an error and
  refreshes the slot list if it loses the race.
- app/services/booking.server.ts + webhooks.orders.create.tsx: converts an
  order's dd_* cart attributes into a confirmed Booking (idempotent on
  orderId — webhooks redeliver), releases the matching hold, and writes a
  `delivery_datetime.booking` order metafield so the slot is visible on the
  order record natively (IMPLEMENTATION_PLAN.md §5.2/§2). Deliberately does
  NOT re-check capacity and reject at this point — by the time an order
  exists, payment has happened; that's the Function's job, earlier.
- webhooks.orders.cancelled.tsx: marks the Booking cancelled, freeing its
  capacity.
- extensions/validation-slot (Cart/Checkout Validation Function): blocks
  checkout when the cart's dd_* attributes are missing or incomplete — a
  shopper who bypasses the widget entirely (clears the attribute, calls the
  cart API directly) still cannot check out, because this runs inside
  Shopify's own checkout, not the browser. Scope note documented in
  evaluate.js: this doesn't yet re-validate against a live capacity
  snapshot at the moment checkout completes ("has since been taken" in
  §3.1) — Functions can't call our DB, and a metafield-snapshot refresh
  pipeline for that is unscoped work; the 10-minute hold TTL is the interim
  mitigation for that specific race.
- extensions/delivery-customization: relabels every delivery option to the
  shopper's actual chosen method + date ("Pickup — Aug 25" instead of a
  generic carrier label), directly fixing the "estimated delivery date on a
  pickup order" complaint §3.1 names. payment-customization is deliberately
  NOT built yet — there's no configurable payment-method rule for it to
  enforce until later phases add one; shipping a no-op Function serves
  nothing.
- Prisma: added Booking (no SlotHold table — Redis is the sole source of
  truth for holds, per §4's own "(Redis-backed)" annotation; mirroring it
  into Postgres would just be a sync-consistency burden with no benefit).
- Both Function extensions are hand-scaffolded from Shopify's documented
  JS Function structure (same interactive-login limitation as the Theme
  App Extension) — README.md flags that `shopify app function schema`
  should be run before deploying to confirm the input queries still match
  the live schema.

New tests/integration/ suite (separate vitest config, needs live
Redis+Postgres — `docker compose up -d` locally, a services: block in CI)
holds the tests that can't be meaningfully mocked: the slot-hold
concurrency test CLAUDE.md calls out as non-negotiable (20 concurrent
requests for 1 unit of capacity → exactly 1 succeeds; 30 for 5 → exactly 5;
release-then-retry; TTL expiry; same-cart renewal) and the full
hold-to-booking lifecycle including idempotency on webhook redelivery.
Both Functions' pure decision logic is separately unit-tested (11 tests).
60 total tests now pass (52 unit + 8 integration).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 18:22:51 -04:00
metatroncubeswdev
6c633af598 feat: Phase 3 — storefront widget + cart attributes
Theme App Extension (extensions/datetime-widget/) hand-scaffolded from
Shopify's documented structure, since `shopify app generate extension`
needs the same interactive Partner login `shopify app init`/`dev` do
(unavailable in this session) — user confirmed this approach.

- blocks/app-embed.liquid: site-wide toggle that loads the widget's JS/CSS
  once (target: body)
- blocks/datetime-picker.liquid: the actual app block merchants add to a
  cart/product page section, with per-method show/hide toggles and an
  optional location override, all theme-editor-configurable
- src/datetime-widget.ts: vanilla TS (no framework, ~2kb gzipped) — renders
  method -> date -> slot, calls the app-proxy availability endpoint, and on
  selection writes to /cart/update.js cart attributes using a *method-
  specific* attribute name ("Pickup date" vs "Delivery date" vs "Shipping
  date", from locales/en.default.json) rather than one generic label — this
  is the direct fix for the "estimated delivery date on a pickup order"
  complaint PRODUCT_STRATEGY.md §3.1 calls out. Only collects a selection;
  never enforces anything itself (Phase 4's Validation Function does that).
- locales/en.default.json + en.default.schema.json: i18n from day one
- app/routes/apps.scheduling.availability.tsx: the public app-proxy
  endpoint the widget calls. Path is `apps.scheduling.availability` (not
  the plan's suggested `api.availability`) because shopify.app.toml's
  [app_proxy].url already includes the /apps/scheduling prefix, and
  Shopify forwards a shop-facing /apps/scheduling/availability request to
  {url}/availability against that full url — so the Remix route path has
  to mirror the proxy path exactly for the forwarding to land correctly.
  Resolves (location, method) -> DB rows -> getAvailability(), scoped by
  shopDomain throughout. No consumption wired up (Booking doesn't exist
  until Phase 4), so this correctly shows full capacity everywhere for now.

Theme App Extensions have no CLI build step, so `npm run build:widget`
(esbuild, added as a devDependency) bundles src/ into assets/ and is wired
as a predev/predeploy hook so `shopify app dev`/`deploy` never ship a stale
bundle. CI now also runs both `npm run build` and `npm run build:widget`.

Verified: lint, typecheck, unit tests, both builds pass; a live script
exercising the exact DB-query + getAvailability path the availability route
uses (bypassing HTTP, since real app-proxy signature verification needs a
live tunnel) returned correct results against the Postgres container —
correct EDT offset, correct capacity, and exactly the weekday-filtered set
of open dates for the seeded bakery template.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 18:02:33 -04:00