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

2 lines
9.1 KiB
JavaScript

"use strict";(()=>{var C=Object.defineProperty;var v=(a,t,e)=>t in a?C(a,t,{enumerable:!0,configurable:!0,writable:!0,value:e}):a[t]=e;var l=(a,t,e)=>v(a,typeof t!="symbol"?t+"":t,e);var y="/apps/scheduling";function d(a){let t=Math.floor(a/60),e=a%60,i=t<12?"AM":"PM";return`${t%12===0?12:t%12}:${e.toString().padStart(2,"0")} ${i}`}function u(a){let[t,e,i]=a.split("-").map(Number);return new Date(Date.UTC(t,e-1,i)).toLocaleDateString(void 0,{weekday:"short",month:"short",day:"numeric",timeZone:"UTC"})}function M(a){let t=a.dataset,e=[];return t.showShipping==="true"&&e.push({value:"SHIPPING",label:t.labelShipping||"Shipping",attrLabel:t.attrLabelShipping||"Shipping date"}),t.showLocalDelivery==="true"&&e.push({value:"LOCAL_DELIVERY",label:t.labelLocalDelivery||"Local delivery",attrLabel:t.attrLabelLocalDelivery||"Delivery date"}),t.showPickup==="true"&&e.push({value:"PICKUP",label:t.labelPickup||"Pickup",attrLabel:t.attrLabelPickup||"Pickup date"}),{root:a,heading:t.heading||"",locationId:t.locationId||null,googleMapsApiKey:t.googleMapsApiKey||null,methods:e,labels:{chooseDate:t.labelChooseDate||"Choose a date",chooseTime:t.labelChooseTime||"Choose a time",noDates:t.labelNoDates||"No dates are available right now.",confirmed:t.labelConfirmed||"Confirmed for",change:t.labelChange||"Change",loading:t.labelLoading||"Loading available dates\u2026",error:t.labelError||"Couldn't load available dates. Please try again.",postalCodeLabel:t.labelPostalCode||"Enter your postal/ZIP code",postalCodeSubmit:t.labelPostalCodeSubmit||"Check availability",outOfArea:t.labelOutOfArea||"Sorry, we don't deliver to this address."}}}async function L(a,t,e){let i=new URLSearchParams({method:a,days:"14"});t&&i.set("locationId",t),e&&i.set("postalCode",e);let n=await fetch(`${y}/availability?${i.toString()}`,{headers:{Accept:"application/json"}}),o=await n.json();if(!n.ok)throw new Error(o.error||`Request failed (${n.status})`);return o}async function R(){return(await(await fetch("/cart.js",{headers:{Accept:"application/json"}})).json()).token}async function f(a){let t=await fetch(`${y}/hold`,{method:"POST",headers:{"Content-Type":"application/json",Accept:"application/json"},body:JSON.stringify(a)}),e=await t.json();return!t.ok&&a.intent==="create"?{success:!1,error:e.error||"Slot unavailable"}:e}async function _(a,t,e){await fetch("/cart/update.js",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({attributes:{[a]:e,...t}})})}var c=null;function E(a){return c||(c=new Promise((t,e)=>{let i="__ddMapsReady";window[i]=()=>t();let n=document.createElement("script");n.src=`https://maps.googleapis.com/maps/api/js?key=${encodeURIComponent(a)}&callback=${i}`,n.async=!0,n.onerror=()=>e(new Error("Failed to load Google Maps")),document.head.appendChild(n)}),c)}async function S(a,t,e,i,n){try{await E(t);let o=window.google,s=new o.maps.Map(a,{center:{lat:e,lng:i},zoom:13});new o.maps.Marker({position:{lat:e,lng:i},map:s,title:n})}catch{a.hidden=!0}}var m=class{constructor(t){l(this,"config");l(this,"el",{heading:document.createElement("h3"),methodRow:document.createElement("div"),postalRow:document.createElement("div"),mapContainer:document.createElement("div"),dateRow:document.createElement("div"),timeRow:document.createElement("div"),status:document.createElement("p"),confirmation:document.createElement("div")});l(this,"selectedMethod",null);l(this,"selectedDate",null);l(this,"availability",null);l(this,"heldSlot",null);this.config=t}mount(){let{root:t,heading:e,methods:i}=this.config;t.classList.add("dd-widget--ready"),t.innerHTML="",i.length!==0&&(e&&(this.el.heading.className="dd-widget__heading",this.el.heading.textContent=e,t.appendChild(this.el.heading)),this.el.methodRow.className="dd-widget__row dd-widget__methods",this.el.postalRow.className="dd-widget__row dd-widget__postal",this.el.postalRow.hidden=!0,this.el.mapContainer.className="dd-widget__map",this.el.mapContainer.hidden=!0,this.el.dateRow.className="dd-widget__row dd-widget__dates",this.el.timeRow.className="dd-widget__row dd-widget__times",this.el.status.className="dd-widget__status",this.el.confirmation.className="dd-widget__confirmation",this.el.confirmation.hidden=!0,t.append(this.el.confirmation,this.el.methodRow,this.el.postalRow,this.el.mapContainer,this.el.dateRow,this.el.timeRow,this.el.status),i.length===1?this.selectMethod(i[0]):this.renderMethods())}renderMethods(){var t;this.el.methodRow.innerHTML="";for(let e of this.config.methods){let i=document.createElement("button");i.type="button",i.className="dd-widget__pill",i.textContent=e.label,i.setAttribute("aria-pressed",String(((t=this.selectedMethod)==null?void 0:t.value)===e.value)),i.addEventListener("click",()=>this.selectMethod(e)),this.el.methodRow.appendChild(i)}}async selectMethod(t){if(this.selectedMethod=t,this.selectedDate=null,this.el.timeRow.innerHTML="",this.el.dateRow.innerHTML="",this.el.mapContainer.hidden=!0,this.el.confirmation.hidden=!1,this.el.confirmation.hidden=!0,this.config.methods.length>1&&this.renderMethods(),t.value==="LOCAL_DELIVERY"){this.renderPostalCodeInput(t);return}this.el.postalRow.hidden=!0,await this.loadAvailability(t)}renderPostalCodeInput(t){this.el.postalRow.hidden=!1,this.el.postalRow.innerHTML="",this.el.status.textContent="";let e=document.createElement("input");e.type="text",e.className="dd-widget__input",e.placeholder=this.config.labels.postalCodeLabel,e.setAttribute("aria-label",this.config.labels.postalCodeLabel);let i=document.createElement("button");i.type="button",i.className="dd-widget__pill",i.textContent=this.config.labels.postalCodeSubmit,i.addEventListener("click",async()=>{let n=e.value.trim();n&&await this.loadAvailability(t,n)}),e.addEventListener("keydown",n=>{n.key==="Enter"&&i.click()}),this.el.postalRow.append(e,i)}async loadAvailability(t,e){this.el.status.textContent=this.config.labels.loading,this.el.dateRow.innerHTML="";try{if(this.availability=await L(t.value,this.config.locationId,e),!this.availability.locationId){this.el.status.textContent=this.availability.error||this.config.labels.outOfArea;return}t.value==="PICKUP"&&this.config.googleMapsApiKey&&this.showPickupMap(),this.renderDates()}catch{this.el.status.textContent=this.config.labels.error}}showPickupMap(){var e;let t=this.availability;!(t!=null&&t.locationLat)||!(t!=null&&t.locationLng)||!this.config.googleMapsApiKey||(this.el.mapContainer.hidden=!1,S(this.el.mapContainer,this.config.googleMapsApiKey,t.locationLat,t.locationLng,(e=t.locationName)!=null?e:""))}renderDates(){var e,i;let t=Object.keys((i=(e=this.availability)==null?void 0:e.dates)!=null?i:{}).sort();if(this.el.dateRow.innerHTML="",t.length===0){this.el.status.textContent=this.config.labels.noDates;return}this.el.status.textContent=this.config.labels.chooseDate;for(let n of t){let o=document.createElement("button");o.type="button",o.className="dd-widget__pill",o.textContent=u(n),o.setAttribute("aria-pressed",String(this.selectedDate===n)),o.addEventListener("click",()=>this.selectDate(n)),this.el.dateRow.appendChild(o)}}selectDate(t){var i,n;this.selectedDate=t;for(let o of Array.from(this.el.dateRow.children))o.setAttribute("aria-pressed",String(o.textContent===u(t)));let e=(n=(i=this.availability)==null?void 0:i.dates[t])!=null?n:[];this.el.timeRow.innerHTML="",this.el.status.textContent=this.config.labels.chooseTime;for(let o of e){let s=document.createElement("button");s.type="button",s.className="dd-widget__pill",s.textContent=`${d(o.startMin)}\u2013${d(o.endMin)}`,s.addEventListener("click",()=>this.selectSlot(t,o)),this.el.timeRow.appendChild(s)}}async selectSlot(t,e){var g;let i=this.selectedMethod,n=this.availability,o=n.rate?` (${n.rate.label})`:"",s=`${u(t)}, ${d(e.startMin)}\u2013${d(e.endMin)}${o}`;this.el.status.textContent=this.config.labels.loading;try{let p=await R();if(!(await f({intent:"create",locationId:n.locationId,method:i.value,date:t,startMin:e.startMin,cartToken:p})).success){this.el.status.textContent=this.config.labels.error,await this.selectMethod(i);return}this.heldSlot={locationId:n.locationId,method:i.value,date:t,startMin:e.startMin,cartToken:p,zoneId:(g=n.zoneId)!=null?g:null};let h={dd_method:i.value,dd_date:t,dd_start_min:String(e.startMin),dd_end_min:String(e.endMin),dd_location_id:n.locationId};n.zoneId&&(h.dd_zone_id=n.zoneId),n.rate&&(h.dd_rate_label=n.rate.label),await _(i.attrLabel,h,s),this.el.status.textContent="",this.el.methodRow.hidden=!0,this.el.postalRow.hidden=!0,this.el.mapContainer.hidden=!0,this.el.dateRow.hidden=!0,this.el.timeRow.hidden=!0,this.el.confirmation.hidden=!1,this.el.confirmation.innerHTML="";let b=document.createElement("p");b.textContent=`${this.config.labels.confirmed} ${s}`;let r=document.createElement("button");r.type="button",r.className="dd-widget__link",r.textContent=this.config.labels.change,r.addEventListener("click",()=>{this.heldSlot&&(f({intent:"release",...this.heldSlot}),this.heldSlot=null),this.el.methodRow.hidden=!1,this.el.dateRow.hidden=!1,this.el.timeRow.hidden=!1,this.el.confirmation.hidden=!0}),this.el.confirmation.append(b,r)}catch{this.el.status.textContent=this.config.labels.error}}};function w(){document.querySelectorAll("[data-dd-widget]").forEach(t=>{new m(M(t)).mount()})}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",w):w();})();