From e56182e0e3807e550a2abf393ed285e59b9b3681 Mon Sep 17 00:00:00 2001 From: metatroncubeswdev Date: Tue, 13 Oct 2026 20:06:49 -0400 Subject: [PATCH] chore: initial Datewise app (rebranded from SlotSure prototype) Co-Authored-By: Claude Opus 5.5 --- .env.example | 18 + .gitignore | 11 + Dockerfile | 11 + README.md | 129 + app/db.server.ts | 32 + app/engine/__tests__/engine.test.ts | 487 + app/engine/__tests__/fixtures.ts | 166 + app/engine/availability.ts | 312 + app/engine/capacity.ts | 255 + app/engine/customer.ts | 66 + app/engine/index.ts | 9 + app/engine/locations.ts | 121 + app/engine/rules.ts | 216 + app/engine/runsheet.ts | 98 + app/engine/time.ts | 108 + app/engine/types.ts | 288 + app/engine/zones.ts | 114 + app/entry.server.tsx | 39 + app/jobs/worker.ts | 110 + app/lib/admin.server.ts | 47 + app/lib/bookings-query.server.ts | 20 + app/lib/format.ts | 32 + app/lib/proxy.server.ts | 47 + app/root.tsx | 20 + app/routes.ts | 4 + app/routes/_index.tsx | 8 + app/routes/api.customer.booking.tsx | 66 + app/routes/app._index.tsx | 141 + app/routes/app.bookings.$id.tsx | 152 + app/routes/app.bookings._index.tsx | 127 + app/routes/app.bookings.export.tsx | 51 + app/routes/app.diagnostics.tsx | 105 + app/routes/app.freshness.tsx | 162 + app/routes/app.locations.$id.tsx | 338 + app/routes/app.locations._index.tsx | 90 + app/routes/app.peak.tsx | 151 + app/routes/app.rules.tsx | 186 + app/routes/app.runsheet.tsx | 112 + app/routes/app.settings.tsx | 336 + app/routes/app.tsx | 37 + app/routes/auth.$.tsx | 10 + app/routes/auth.login.tsx | 31 + app/routes/healthz.tsx | 6 + app/routes/proxy.availability.tsx | 7 + app/routes/proxy.hold.tsx | 10 + app/routes/proxy.offer.tsx | 7 + app/routes/proxy.release.tsx | 8 + app/routes/proxy.waitlist.tsx | 6 + app/routes/webhooks.app.tsx | 23 + app/routes/webhooks.compliance.tsx | 40 + app/routes/webhooks.orders.cancelled.tsx | 11 + app/routes/webhooks.orders.create.tsx | 19 + app/routes/webhooks.orders.fulfilled.tsx | 9 + app/services/__tests__/orders.server.test.ts | 106 + .../__tests__/reservations.server.test.ts | 159 + app/services/bookings.server.ts | 104 + app/services/catalog.server.ts | 118 + app/services/config.server.ts | 158 + app/services/crypto.server.ts | 22 + app/services/fees.server.ts | 103 + app/services/geocode.server.ts | 69 + app/services/notifications.server.ts | 81 + app/services/orders.server.ts | 160 + app/services/plans.ts | 32 + app/services/reminders.server.ts | 95 + app/services/reservations.server.ts | 411 + app/services/settings.ts | 166 + app/services/storefront.server.ts | 285 + app/services/waitlist.server.ts | 49 + app/shopify.server.ts | 51 + docs/DS_Shipping_spec.docx | Bin 0 -> 30010 bytes env.d.ts | 4 + extensions/checkout-guard/package.json | 16 + .../checkout-guard/shopify.extension.toml | 16 + .../src/cart_validations_generate_run.graphql | 37 + .../src/cart_validations_generate_run.js | 57 + extensions/checkout-guard/src/index.js | 1 + extensions/checkout-guard/tests/guard.test.js | 52 + .../datewise-widget/assets/datewise.css | 65 + extensions/datewise-widget/assets/datewise.js | 421 + .../datewise-widget/blocks/embed.liquid | 56 + .../datewise-widget/blocks/scheduler.liquid | 31 + .../datewise-widget/locales/en.default.json | 23 + extensions/datewise-widget/locales/fr.json | 23 + .../datewise-widget/shopify.extension.toml | 2 + extensions/order-reschedule/package.json | 10 + .../order-reschedule/shopify.extension.toml | 15 + .../order-reschedule/src/OrderStatusBlock.jsx | 103 + extensions/order-reschedule/src/config.js | 2 + package-lock.json | 9968 +++++++++++++++++ package.json | 60 + prisma/migrations/0001_init/migration.sql | 216 + prisma/migrations/migration_lock.toml | 1 + prisma/schema.prisma | 244 + prototype/build.mjs | 7 + prototype/datewise-prototype.html | 774 ++ prototype/engine.js | 2 + prototype/src.html | 772 ++ react-router.config.ts | 5 + shopify.app.toml | 52 + shopify.web.toml | 7 + tests/e2e/server.ts | 182 + tests/e2e/widget.e2e.mjs | 84 + tsconfig.json | 24 + vite.config.ts | 27 + 105 files changed, 20537 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/db.server.ts create mode 100644 app/engine/__tests__/engine.test.ts create mode 100644 app/engine/__tests__/fixtures.ts create mode 100644 app/engine/availability.ts create mode 100644 app/engine/capacity.ts create mode 100644 app/engine/customer.ts create mode 100644 app/engine/index.ts create mode 100644 app/engine/locations.ts create mode 100644 app/engine/rules.ts create mode 100644 app/engine/runsheet.ts create mode 100644 app/engine/time.ts create mode 100644 app/engine/types.ts create mode 100644 app/engine/zones.ts create mode 100644 app/entry.server.tsx create mode 100644 app/jobs/worker.ts create mode 100644 app/lib/admin.server.ts create mode 100644 app/lib/bookings-query.server.ts create mode 100644 app/lib/format.ts create mode 100644 app/lib/proxy.server.ts create mode 100644 app/root.tsx create mode 100644 app/routes.ts create mode 100644 app/routes/_index.tsx create mode 100644 app/routes/api.customer.booking.tsx create mode 100644 app/routes/app._index.tsx create mode 100644 app/routes/app.bookings.$id.tsx create mode 100644 app/routes/app.bookings._index.tsx create mode 100644 app/routes/app.bookings.export.tsx create mode 100644 app/routes/app.diagnostics.tsx create mode 100644 app/routes/app.freshness.tsx create mode 100644 app/routes/app.locations.$id.tsx create mode 100644 app/routes/app.locations._index.tsx create mode 100644 app/routes/app.peak.tsx create mode 100644 app/routes/app.rules.tsx create mode 100644 app/routes/app.runsheet.tsx create mode 100644 app/routes/app.settings.tsx create mode 100644 app/routes/app.tsx create mode 100644 app/routes/auth.$.tsx create mode 100644 app/routes/auth.login.tsx create mode 100644 app/routes/healthz.tsx create mode 100644 app/routes/proxy.availability.tsx create mode 100644 app/routes/proxy.hold.tsx create mode 100644 app/routes/proxy.offer.tsx create mode 100644 app/routes/proxy.release.tsx create mode 100644 app/routes/proxy.waitlist.tsx create mode 100644 app/routes/webhooks.app.tsx create mode 100644 app/routes/webhooks.compliance.tsx create mode 100644 app/routes/webhooks.orders.cancelled.tsx create mode 100644 app/routes/webhooks.orders.create.tsx create mode 100644 app/routes/webhooks.orders.fulfilled.tsx create mode 100644 app/services/__tests__/orders.server.test.ts create mode 100644 app/services/__tests__/reservations.server.test.ts create mode 100644 app/services/bookings.server.ts create mode 100644 app/services/catalog.server.ts create mode 100644 app/services/config.server.ts create mode 100644 app/services/crypto.server.ts create mode 100644 app/services/fees.server.ts create mode 100644 app/services/geocode.server.ts create mode 100644 app/services/notifications.server.ts create mode 100644 app/services/orders.server.ts create mode 100644 app/services/plans.ts create mode 100644 app/services/reminders.server.ts create mode 100644 app/services/reservations.server.ts create mode 100644 app/services/settings.ts create mode 100644 app/services/storefront.server.ts create mode 100644 app/services/waitlist.server.ts create mode 100644 app/shopify.server.ts create mode 100644 docs/DS_Shipping_spec.docx create mode 100644 env.d.ts create mode 100644 extensions/checkout-guard/package.json create mode 100644 extensions/checkout-guard/shopify.extension.toml create mode 100644 extensions/checkout-guard/src/cart_validations_generate_run.graphql create mode 100644 extensions/checkout-guard/src/cart_validations_generate_run.js create mode 100644 extensions/checkout-guard/src/index.js create mode 100644 extensions/checkout-guard/tests/guard.test.js create mode 100644 extensions/datewise-widget/assets/datewise.css create mode 100644 extensions/datewise-widget/assets/datewise.js create mode 100644 extensions/datewise-widget/blocks/embed.liquid create mode 100644 extensions/datewise-widget/blocks/scheduler.liquid create mode 100644 extensions/datewise-widget/locales/en.default.json create mode 100644 extensions/datewise-widget/locales/fr.json create mode 100644 extensions/datewise-widget/shopify.extension.toml create mode 100644 extensions/order-reschedule/package.json create mode 100644 extensions/order-reschedule/shopify.extension.toml create mode 100644 extensions/order-reschedule/src/OrderStatusBlock.jsx create mode 100644 extensions/order-reschedule/src/config.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 prisma/migrations/0001_init/migration.sql create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 prisma/schema.prisma create mode 100644 prototype/build.mjs create mode 100644 prototype/datewise-prototype.html create mode 100644 prototype/engine.js create mode 100644 prototype/src.html create mode 100644 react-router.config.ts create mode 100644 shopify.app.toml create mode 100644 shopify.web.toml create mode 100644 tests/e2e/server.ts create mode 100644 tests/e2e/widget.e2e.mjs create mode 100644 tsconfig.json create mode 100644 vite.config.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ecf73bd --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +# Filled in automatically by `shopify app dev`; set by hand in production. +SHOPIFY_API_KEY= +SHOPIFY_API_SECRET= +SHOPIFY_APP_URL=https://your-app.example.com +SCOPES=read_products,write_products,write_publications,read_orders,write_orders,read_customers,read_locations,read_inventory,write_validations + +# PostgreSQL 14+ (row locks are required for capacity holds; SQLite is not supported) +DATABASE_URL=postgresql://user:password@localhost:5432/datewise + +# 32 random bytes, base64: node -e "console.log(require('crypto').randomBytes(32).toString('base64'))" +APP_ENCRYPTION_KEY= + +# Optional: radius / driving-distance zones (Geocoding + Distance Matrix APIs) +GOOGLE_MAPS_API_KEY= + +# Optional: reminder + waitlist emails (resend.com). The domain must be verified in Resend. +RESEND_API_KEY= +EMAIL_FROM_DOMAIN=mail.your-app.example.com diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5add5fb --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +node_modules +/build +/.react-router +.env +.env.* +!.env.example +.shopify +extensions/*/dist +extensions/*/node_modules +*.log +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..aa95e47 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +# One image, two processes: the web app (default) and the worker (`npm run worker`). +FROM node:22-alpine +RUN apk add --no-cache openssl +WORKDIR /app +ENV NODE_ENV=production +COPY package.json package-lock.json* ./ +RUN npm ci --omit=dev && npm i --no-save tsx && npm cache clean --force +COPY . . +RUN npx prisma generate && npm run build +EXPOSE 3000 +CMD ["sh", "-c", "npx prisma migrate deploy && npm run start"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..74aa65a --- /dev/null +++ b/README.md @@ -0,0 +1,129 @@ +# Datewise: delivery & pickup scheduling for Shopify + +A Shopify app (built for public App Store listing) that lets shoppers choose **local delivery, store pickup or shipping**, then a **location, date and time slot**. The merchant controls hours, preparation time, cutoffs, holidays, capacity, product rules, delivery zones and fees. + +It's modelled on the scope of *DS Delivery Date-Time* (see the spec in `docs/`), and aims at the pain points merchants actually lose money on: + +| Pain point | What Datewise does | +|---|---| +| **Overbooking.** Two shoppers take the last slot, or the cart shows a slot that is gone by checkout. | Real capacity **holds**: picking a slot reserves it for N minutes (row-locked in Postgres). Late orders are re-reserved or sent to a **review queue**, never silently overbooked. | +| **Festival rush** (Diwali, Mother's Day, Valentine's) | **Rush control**: separate per-day/per-slot limits, earlier cutoff ("closes 2 days before at 6 PM"), extra prep, peak fee, and a **waitlist** that offers freed places automatically. | +| **Perishables promised on dates they can't be fresh** | **Fresh-stock windows**: tie bookable dates to stock arrival dates + shelf life; the calendar tells customers when the next fresh batch is ready. | +| **"Can I change my delivery time?" emails** | **Customer self-reschedule** on the order status page, within the merchant's policy (up to X hours before, max N changes), with the same rule checks as checkout. | +| **No-shows and missed deliveries** | **Reminders** by email, SMS (Twilio) or WhatsApp (Meta Cloud API), re-planned automatically on every change. | +| **Driver chaos** | **Daily run sheet**: stops grouped by promised window, ordered nearest-next, Google Maps route links, CSV. | +| **Buy now / Shop Pay skipping the cart widget** | **Checkout guard**, a Cart & Checkout Validation Function (works on **every plan, including Basic**): blocks checkout if a required time is missing or past, or if the fee was tampered with. | +| **"Why can't the customer pick Friday?"** | **Diagnostics** screen that runs the exact checkout engine and names the rule, closure, cutoff or capacity limit responsible. | + +--- + +## Architecture + +```mermaid +flowchart LR + subgraph Storefront + W[Theme app block + embed
datewise.js] -->|/apps/datewise/* app proxy, HMAC-signed| P + G[Checkout guard
validation function] + O[Order status block
customer account extension] -->|session token| C + end + subgraph App server [React Router app] + P[proxy.* routes] --> S[storefront.server] + C[api.customer.booking] --> B[bookings.server] + A[Admin UI routes
Polaris web components] --> B + H[webhooks.orders.*] --> OR[orders.server] + S & B & OR --> E[[engine/
pure TS, tested]] + S & B & OR --> R[(reservations.server
row-locked counters)] + end + R --> DB[(PostgreSQL)] + K[worker.ts
sweep holds · reminders · reconcile] --> DB + K --> N[Resend · Twilio · WhatsApp] +``` + +* **`app/engine/`** is the scheduling brain: hours, closures, cutoffs, prep (clock or opening-hours), DST-safe time zones, product rules, freshness, peak days, capacity, zones and fees, location ranking, reminders, run sheets. It's pure TypeScript with no I/O, and the clickable prototype runs this same code in the browser. +* **`app/services/reservations.server.ts`** holds the capacity contract. Every location/method/date/slot has a counter row. Mutations lock the rows they touch in sorted order (`SELECT … FOR UPDATE`), so there are no races and no deadlocks. +* **Selection → order**: the widget writes a `_datewise` cart attribute (token, method, location, date, slot, quoted fee, rule version), plus readable attributes for staff ("Local delivery date: Sat, Nov 7"). The `orders/create` webhook converts the hold into a booking, tags the order, writes a `datewise.booking` metafield and plans reminders. +* **Fees on every plan**: carrier-calculated rates aren't available on Basic, so fees are added as a hidden *Delivery & handling* product line (one variant per amount, created on demand). The checkout guard and the webhook both check that the line matches the quote. + +## Repository layout + +``` +app/engine/ scheduling engine (+ __tests__, 43 tests) +app/services/ reservations, storefront API, orders, bookings, reminders, waitlist, fees, catalog, maps, messaging +app/routes/app.* merchant admin (Today, Bookings, Locations & hours, Product rules, Fresh stock, Rush control, Run sheet, Diagnostics, Settings) +app/routes/proxy.* storefront API behind the Shopify app proxy +app/routes/api.customer.* customer-account API (self-reschedule) +app/routes/webhooks.* orders/create, cancelled, fulfilled, app/uninstalled, GDPR +app/jobs/worker.ts background worker +extensions/datewise-widget theme app extension (cart block + app embed for drawers), en/fr +extensions/checkout-guard validation function (JS) + tests +extensions/order-reschedule customer account UI extension (Preact) +prisma/ schema + SQL migration +prototype/ the clickable demo (runs the real engine) +tests/e2e/ browser test: real services + Postgres, mock theme + Admin API +``` + +## Getting started (developer) + +Prerequisites: Node 20+, PostgreSQL 14+, a Shopify Partner account, the Shopify CLI, and a development store. + +```bash +npm install +cp .env.example .env # set DATABASE_URL and APP_ENCRYPTION_KEY +npx prisma generate +npx prisma migrate deploy +npm run config:link # links shopify.app.toml to your Partner app +npm run dev # shopify app dev: tunnel, install on dev store, live extensions +npm run worker # in a second terminal +``` + +Then, in the dev store: +1. **Locations & hours.** Locations are imported switched off; review hours and turn one on. +2. **Theme editor.** Add the *Delivery date & time* block to the cart page, and switch on the *Datewise* app embed (needed for cart drawers, waitlist links and the checkout-button guard). +3. **Settings → Checkout guard → Turn on.** +4. Set `APP_URL` in `extensions/order-reschedule/src/config.js` (customer self-reschedule needs **new customer accounts**). + +## Deploying + +* Web: `npm run build && npm run start` (or the Dockerfile). Worker: the same image with `npm run worker`. Run at least one worker, or holds only expire lazily and reminders won't send. +* Any Node host with Postgres works (Fly.io, Render, Railway, a VPS). Shared PHP-style hosting won't run it. +* `npm run deploy` publishes the extensions and app config. + +### Permissions (each is used by a shipped feature) +| Scope | Why | +|---|---| +| `read_products`, `write_products`, `write_publications` | rules by collection/tag/type; create and publish the hidden fee product | +| `read_orders`, `write_orders` | order webhooks, reconciliation, tag orders and write the booking metafield | +| `read_customers` | verify that the customer rescheduling actually owns the order | +| `read_locations`, `read_inventory` | link Shopify locations; stock-aware location choice | +| `write_validations` | switch the checkout guard on and off | + +Customer names, emails, phones and addresses are **protected customer data**. Request Level 2 access in the Partner Dashboard before review. + +## What is enforced where (Basic plan and up) + +| Rule | Cart widget | Checkout guard | Order webhook | +|---|---|---|---| +| Closed days, cutoffs, prep, freshness, product rules | ✅ hides/blocks | — | ✅ re-checked on reschedule | +| Time required | ✅ blocks button | ✅ blocks checkout | ✅ review queue | +| Date in the past | ✅ | ✅ | — | +| Capacity | ✅ hold | ❌ functions have no network access | ✅ re-reserve or review | +| Fee matches quote | ✅ | ✅ | ✅ flags mismatch | + +Checkout-page pickers (Plus-only UI extension targets) and carrier-calculated rates are intentionally **not** in v1. + +## Tests + +```bash +npm test # engine + checkout guard (DB tests skip without TEST_DATABASE_URL) +TEST_DATABASE_URL=postgresql://… npm test # + Postgres: concurrency, idempotency, reschedule, webhooks +npm run e2e:server & npm run e2e # real browser drives the widget against the real services +``` + +Covered: cutoff boundaries (13:59 vs 14:00), overnight/weekend operating-hours prep, both DST transitions, closures vs special openings, method-specific exceptions, mixed carts and conflicting rules, freshness windows, peak cutoffs/capacity/waitlist, 25 concurrent shoppers for 3 places, duplicate and concurrent webhooks, expired holds at order time, atomic reschedules (including opposite-direction swaps), cancellation, stale-hold sweeps, postal patterns and ranges, radius zones, maps outages (never accept unverified addresses), split inventory, reminders, run-sheet ordering, CSV escaping, fee tampering, keyboard navigation. + +## Not yet verified in a live store +This code builds, typechecks and passes its tests, but it hasn't been run inside a real Shopify store yet. Before launch, run the spec's acceptance list (`docs/`) on a development store, especially: +* theme compatibility of the drawer injection (selectors for Dawn and its family are built in; others may need the *Cart drawer selector* setting), +* the customer-account extension and checkout guard on real checkouts (Shop Pay, Apple Pay, Buy now), +* WhatsApp template approval and Twilio sender setup, +* App Store requirements: privacy policy, listing, screencast, Built for Shopify performance checks. diff --git a/app/db.server.ts b/app/db.server.ts new file mode 100644 index 0000000..4474532 --- /dev/null +++ b/app/db.server.ts @@ -0,0 +1,32 @@ +import { PrismaClient } from "@prisma/client"; +import { PrismaPg } from "@prisma/adapter-pg"; + +declare global { + // eslint-disable-next-line no-var + var prismaGlobal: PrismaClient | undefined; +} + +/** + * Rust-free Prisma client over node-postgres (no native engine binary needed, + * which also makes shared/VPS hosting simpler). + */ +export function createPrisma(url = process.env.DATABASE_URL) { + if (!url) throw new Error("DATABASE_URL is not set"); + return new PrismaClient({ adapter: new PrismaPg({ connectionString: url }) }); +} + +function instance(): PrismaClient { + if (!global.prismaGlobal) global.prismaGlobal = createPrisma(); + return global.prismaGlobal; +} + +/** Lazily created so importing this module never needs DATABASE_URL (tests, build). */ +const prisma = new Proxy({} as PrismaClient, { + get(_t, prop) { + const db = instance(); + const v = Reflect.get(db, prop); + return typeof v === "function" ? v.bind(db) : v; + }, +}); + +export default prisma; diff --git a/app/engine/__tests__/engine.test.ts b/app/engine/__tests__/engine.test.ts new file mode 100644 index 0000000..bed3578 --- /dev/null +++ b/app/engine/__tests__/engine.test.ts @@ -0,0 +1,487 @@ +import { describe, expect, it } from "vitest"; +import { + MemoryReservationStore, + addOperatingMinutes, + buildRunSheet, + canCustomerReschedule, + capsFor, + computeAvailability, + evaluateRules, + localParts, + matchZone, + normalizePostal, + planReminders, + postalMatches, + quoteFee, + rankLocations, + toCsv, + validateSelection, + zonedToUtc, + type AvailabilityInput, + type Booking, + type Cart, + type LocationConfig, + type ProductRule, +} from "../index"; +import { TZ, at, diwali, kitchener, marigold, pakku, rules, saree, settings, waterloo } from "./fixtures"; + +const base = (over: Partial = {}): AvailabilityInput => ({ + location: kitchener(), + method: "pickup", + cart: { items: [pakku] }, + rules, + peakDays: [], + usage: {}, + settings, + now: at("2026-10-14", "09:00"), // Wednesday + ...over, +}); + +const day = (r: ReturnType, d: string) => r.dates.find((x) => x.date === d)!; + +describe("time zones", () => { + it("round-trips local times", () => { + const ms = zonedToUtc("2026-10-14", "10:30", TZ); + expect(new Date(ms).toISOString()).toBe("2026-10-14T14:30:00.000Z"); + expect(localParts(ms, TZ)).toMatchObject({ date: "2026-10-14", time: "10:30", weekday: 3 }); + }); + + it("keeps local slot labels across the fall-back DST change", () => { + const r = computeAvailability(base({ method: "delivery", now: at("2026-10-30", "08:00") })); + const sat = day(r, "2026-10-31").slots[0]; + const sun = day(r, "2026-11-01").slots[0]; + expect(sat.start).toBe("10:00"); + expect(sun.start).toBe("10:00"); + expect(sat.startUtc).toBe("2026-10-31T14:00:00.000Z"); // EDT + expect(sun.startUtc).toBe("2026-11-01T15:00:00.000Z"); // EST + }); + + it("resolves the spring-forward gap forward", () => { + const ms = zonedToUtc("2027-03-14", "02:30", TZ); + expect(localParts(ms, TZ).time).toBe("03:30"); + }); +}); + +describe("weekly hours, closures and special openings", () => { + it("never offers closed weekdays", () => { + const r = computeAvailability(base()); + const sunday = day(r, "2026-10-18"); + expect(sunday.available).toBe(false); + expect(sunday.reasons[0].code).toBe("CLOSED_WEEKLY"); + }); + + it("date exceptions override the weekly schedule", () => { + const loc = kitchener(); + loc.exceptions = [ + { date: "2026-10-16", closed: true, note: "Staff training" }, + { date: "2026-10-18", hours: [{ start: "12:00", end: "15:00" }], methods: ["pickup"], note: "Special Sunday opening" }, + ]; + const r = computeAvailability(base({ location: loc })); + expect(day(r, "2026-10-16").reasons[0]).toMatchObject({ code: "CLOSED_EXCEPTION", message: "Staff training" }); + const sun = day(r, "2026-10-18"); + expect(sun.available).toBe(true); + expect(sun.slots.map((s) => s.id)).toEqual(["12:00-13:00", "13:00-14:00", "14:00-15:00"]); + }); + + it("a method-specific special opening doesn't change other methods' slots", () => { + const loc = kitchener(); + loc.exceptions = [{ date: "2026-10-18", hours: [{ start: "09:00", end: "15:00" }], methods: ["pickup"] }]; + const r = computeAvailability(base({ location: loc, method: "delivery" })); + expect(day(r, "2026-10-18").slots.map((s) => s.id)).toEqual(["10:00-13:00", "13:00-16:00", "16:00-19:00"]); + }); + + it("respects the booking horizon", () => { + const r = computeAvailability(base()); + expect(r.dates.length).toBe(30); + }); +}); + +describe("cutoffs and preparation time", () => { + it("blocks same-day exactly at the cutoff, allows one minute before", () => { + const before = computeAvailability(base({ now: at("2026-10-14", "13:59") })); + const atCut = computeAvailability(base({ now: at("2026-10-14", "14:00") })); + expect(day(before, "2026-10-14").available).toBe(true); + expect(day(atCut, "2026-10-14").reasons[0].code).toBe("PAST_CUTOFF"); + // a later slot with spare capacity does not override the cutoff + expect(day(atCut, "2026-10-14").slots.length).toBe(0); + }); + + it("prep time removes slots that start too soon", () => { + const r = computeAvailability(base({ now: at("2026-10-14", "10:30") })); // 60 min prep → ready 11:30 + const today = day(r, "2026-10-14"); + expect(today.slots.find((s) => s.id === "11:00-12:00")!.reasons[0].code).toBe("PREP_TIME"); + expect(today.slots.find((s) => s.id === "12:00-13:00")!.available).toBe(true); + expect(today.slots.find((s) => s.id === "10:00-11:00")!.reasons[0].code).toBe("SLOT_IN_PAST"); + }); + + it("operating-hours prep skips closed time overnight and Sunday", () => { + const loc = kitchener(); + // Saturday 17:00 + 3 operating hours: 1h Sat, closed Sun, 2h Mon → Mon 12:00 + const ready = addOperatingMinutes(at("2026-10-17", "17:00"), 180, loc, "pickup"); + expect(localParts(ready, TZ)).toMatchObject({ date: "2026-10-19", time: "12:00" }); + }); + + it("date-only shipping requires readiness before closing", () => { + const loc = kitchener(); + loc.methods.shipping!.prepMinutes = 120; + const r = computeAvailability(base({ location: loc, method: "shipping", now: at("2026-10-14", "13:00") })); + expect(r.dateOnly).toBe(true); + expect(day(r, "2026-10-14").available).toBe(true); // ready 15:00 < close 18:00 + const late = computeAvailability(base({ location: loc, method: "shipping", now: at("2026-10-14", "13:59"), cart: { items: [pakku] } })); + expect(day(late, "2026-10-14").available).toBe(true); + loc.methods.shipping!.prepMinutes = 300; + const tooMuch = computeAvailability(base({ location: loc, method: "shipping", now: at("2026-10-14", "13:30") })); + expect(day(tooMuch, "2026-10-14").reasons[0].code).toBe("PREP_TIME"); + }); +}); + +describe("product rules and mixed carts", () => { + it("prohibition wins over allowance", () => { + const extra: ProductRule = { + id: "allow_all", + name: "Garlands any method", + enabled: true, + priority: 0, + target: { type: "product", values: ["p_marigold"] }, + action: { type: "allowOnlyMethods", methods: ["shipping", "pickup", "delivery"] }, + }; + const c = evaluateRules({ items: [marigold] }, [extra, ...rules]); + expect(c.allowedMethods.has("shipping")).toBe(false); + const r = computeAvailability(base({ method: "shipping", cart: { items: [marigold, pakku] }, rules: [extra, ...rules] })); + expect(r.blockers[0]).toMatchObject({ code: "METHOD_PROHIBITED_BY_PRODUCT", ruleId: "r_fresh_noship" }); + }); + + it("mixed cart uses the longest prep and names the item that drives it", () => { + const c = evaluateRules({ items: [marigold, saree, pakku] }, rules, "longest"); + expect(c.extraPrepMinutes).toBe(1440); + expect(c.prepDriver).toMatchObject({ itemTitle: "Silk Saree", ruleId: "r_saree_prep" }); + const sum = evaluateRules({ items: [marigold, saree] }, rules, "sum"); + expect(sum.extraPrepMinutes).toBe(1680); + }); + + it("restrictLocations intersects and explains", () => { + const r: ProductRule = { + id: "only_wat", + name: "Gold jewellery at Waterloo", + enabled: true, + priority: 1, + target: { type: "vendor", values: ["clicks to cart"] }, + action: { type: "restrictLocations", locationIds: ["wat"] }, + message: "Jewellery pickup is at our Waterloo store", + }; + const res = computeAvailability(base({ rules: [r] })); + expect(res.blockers[0]).toMatchObject({ code: "LOCATION_PROHIBITED_BY_PRODUCT", message: "Jewellery pickup is at our Waterloo store" }); + }); + + it("weekday blocks apply", () => { + const r: ProductRule = { id: "nofri", name: "No Friday", enabled: true, priority: 1, target: { type: "product", values: ["p_pakku"] }, action: { type: "blockWeekdays", weekdays: [5] } }; + const res = computeAvailability(base({ rules: [r] })); + expect(day(res, "2026-10-16").reasons[0].ruleId).toBe("nofri"); + }); + + it("disabled rules are ignored", () => { + const off = rules.map((r) => ({ ...r, enabled: false })); + expect(evaluateRules({ items: [marigold] }, off).allowedMethods.has("shipping")).toBe(true); + }); +}); + +describe("perishable freshness windows", () => { + const fresh: ProductRule = { + id: "fresh_arrival", + name: "Marigold air shipment", + enabled: true, + priority: 1, + target: { type: "product", values: ["p_marigold"] }, + action: { type: "freshness", arrivals: [{ date: "2026-10-15" }, { date: "2026-10-29" }], minDaysAfterArrival: 1, shelfLifeDays: 4 }, + }; + it("only offers dates inside a freshness window and points to the next batch", () => { + const r = computeAvailability(base({ cart: { items: [marigold] }, rules: [fresh] })); + expect(day(r, "2026-10-15").reasons[0]).toMatchObject({ code: "NOT_FRESH_YET" }); + expect(day(r, "2026-10-15").reasons[0].message).toContain("2026-10-16"); + expect(day(r, "2026-10-16").available).toBe(true); + expect(day(r, "2026-10-19").available).toBe(true); + expect(day(r, "2026-10-20").reasons[0].message).toContain("2026-10-30"); + expect(day(r, "2026-10-30").available).toBe(true); + }); + it("location-specific arrivals", () => { + const f: ProductRule = { ...fresh, action: { ...(fresh.action as any), arrivals: [{ date: "2026-10-15", locationIds: ["wat"] }] } }; + const r = computeAvailability(base({ cart: { items: [marigold] }, rules: [f] })); + expect(day(r, "2026-10-16").reasons[0].code).toBe("NO_STOCK_ARRIVAL"); + }); +}); + +describe("peak days (festival rush control)", () => { + const now = at("2026-11-05", "09:00"); // Thursday + it("applies peak capacity, surcharge and banner", () => { + const r = computeAvailability(base({ method: "delivery", peakDays: [diwali], now })); + const d = day(r, "2026-11-08"); + expect(d.peak?.name).toBe("Diwali"); + expect(d.surcharge).toBe(500); + expect(d.remaining).toBe(30); + }); + it("closes early at the peak cutoff", () => { + const r = computeAvailability(base({ method: "delivery", peakDays: [diwali], now: at("2026-11-06", "18:00") })); + expect(day(r, "2026-11-08").reasons[0].code).toBe("PEAK_CUTOFF"); + // a normal day right after is unaffected + expect(day(r, "2026-11-10").available).toBe(true); + }); + it("offers a waitlist when a peak slot is full", () => { + const usage = { "kit|delivery|2026-11-08": { day: 30, slots: { "10:00-13:00": 10 } } }; + const r = computeAvailability(base({ method: "delivery", peakDays: [diwali], now, usage })); + const d = day(r, "2026-11-08"); + expect(d.available).toBe(false); + expect(d.waitlist).toBe(true); + expect(d.reasons[0].code).toBe("DAY_FULL"); + }); +}); + +describe("capacity, holds and bookings", () => { + const sel = { locationId: "kit", method: "delivery" as const, date: "2026-10-15", slot: "10:00-13:00" }; + const caps = { dayCap: 12, slotCap: 1 }; + const now = at("2026-10-14", "09:00"); + + it("only one of two customers gets the last place", () => { + const s = new MemoryReservationStore(); + const a = s.hold("tokA", sel, caps, now, 10); + const b = s.hold("tokB", sel, caps, now, 10); + expect(a.ok).toBe(true); + expect(b.ok).toBe(false); + }); + + it("expired holds return capacity", () => { + const s = new MemoryReservationStore(); + s.hold("tokA", sel, caps, now, 10); + expect(s.hold("tokB", sel, caps, now + 11 * 60000, 10).ok).toBe(true); + }); + + it("moving a hold with the same token doesn't double count", () => { + const s = new MemoryReservationStore(); + s.hold("tokA", sel, caps, now, 10); + expect(s.hold("tokA", sel, caps, now, 10).ok).toBe(true); + }); + + it("duplicate order webhooks don't duplicate bookings", () => { + const s = new MemoryReservationStore(); + s.hold("tokA", sel, caps, now, 10); + const one = s.confirm("order1", "tokA", null, caps, now + 60000, { timezone: TZ }); + const two = s.confirm("order1", "tokA", null, caps, now + 120000, { timezone: TZ }); + expect(one.ok && !one.duplicate).toBe(true); + expect(two.ok && two.duplicate).toBe(true); + expect(s.bookings.size).toBe(1); + }); + + it("late order with expired hold is re-reserved if space, flagged if full", () => { + const s = new MemoryReservationStore(); + s.hold("tokA", sel, caps, now, 10); + const late = now + 20 * 60000; + const ok = s.confirm("o1", "tokA", null, caps, late, { timezone: TZ }); + expect(ok.ok && ok.reReserved).toBe(true); + + s.hold("tokB", sel, { dayCap: 12, slotCap: 2 }, late, 10); // someone else takes 2nd place + s.hold("tokC", { ...sel, slot: "13:00-16:00" }, caps, late, 10); + const s2 = new MemoryReservationStore(); + s2.hold("x", sel, caps, now, 10); + s2.hold("y", sel, caps, now + 11 * 60000, 10); // y takes the place after x expired + const flagged = s2.confirm("o2", "x", null, caps, now + 12 * 60000, { timezone: TZ }); + expect(flagged.ok).toBe(false); + expect(flagged.booking.status).toBe("needs_review"); + }); + + it("orders without scheduling data land in the exception queue", () => { + const s = new MemoryReservationStore(); + const r = s.confirm("o9", null, null, caps, now, { timezone: TZ }); + expect(r.ok).toBe(false); + expect(r.booking.status).toBe("needs_review"); + }); + + it("reschedule is atomic and failure preserves the original", () => { + const s = new MemoryReservationStore(); + s.hold("a", sel, caps, now, 10); + const { booking } = s.confirm("o1", "a", null, caps, now, { timezone: TZ }) as any; + s.hold("b", { ...sel, slot: "13:00-16:00" }, caps, now, 10); // other slot held by someone + const fail = s.reschedule(booking.id, { ...sel, slot: "13:00-16:00" }, caps, now, "merchant:asha"); + expect(fail.ok).toBe(false); + expect(s.bookings.get(booking.id)!.slotStart).toBe("10:00"); + const ok = s.reschedule(booking.id, { ...sel, slot: "16:00-19:00" }, caps, now, "merchant:asha"); + expect(ok.ok).toBe(true); + expect(s.usage(now)["kit|delivery|2026-10-15"].slots["10:00-13:00"]).toBeUndefined(); + expect(s.audit.at(-1)).toMatchObject({ actor: "merchant:asha", action: "rescheduled" }); + }); + + it("capsFor picks peak overrides", () => { + expect(capsFor(kitchener(), "delivery", "2026-11-08", "10:00-13:00", [diwali])).toEqual({ dayCap: 30, slotCap: 10 }); + expect(capsFor(kitchener(), "delivery", "2026-11-09", "10:00-13:00", [diwali])).toEqual({ dayCap: 12, slotCap: 5 }); + }); + + it("validateSelection rejects a full slot using live usage", () => { + const s = new MemoryReservationStore(); + for (let i = 0; i < 5; i++) s.hold(`t${i}`, sel, { dayCap: 12, slotCap: 5 }, now, 10); + const v = validateSelection(base({ method: "delivery", usage: s.usage(now) }), "2026-10-15", "10:00-13:00"); + expect(v.ok).toBe(false); + if (!v.ok) expect(v.reasons[0].code).toBe("SLOT_FULL"); + }); + + it("waitlist is FIFO", () => { + const s = new MemoryReservationStore(); + s.joinWaitlist(sel, { email: "a@x.com" }, now); + s.joinWaitlist(sel, { email: "b@x.com" }, now + 1); + expect(s.nextWaitlisted(sel)!.contact.email).toBe("a@x.com"); + }); +}); + +describe("delivery zones and fees", () => { + it("normalizes postal codes", () => { + expect(normalizePostal(" n2h-5z6 ")).toBe("N2H5Z6"); + expect(postalMatches("n2h 5z6", "N2H*")).toBe(true); + expect(postalMatches("N2K 1A1", "N2J..N2M")).toBe(true); + expect(postalMatches("N2N 1A1", "N2J..N2M")).toBe(false); + expect(postalMatches("N2H5Z6", "N2H")).toBe(true); + expect(postalMatches("02134-1234", "02134*")).toBe(true); + }); + it("matches postal first, then radius", () => { + const loc = kitchener(); + expect(matchZone(loc, { postal: "N2G 4X1" }).zone?.id).toBe("z1"); + const near = matchZone(loc, { postal: "N3C 1A1", lat: 43.42, lng: -80.40 }); // ~8 km + expect(near.zone?.id).toBe("z2"); + expect(near.distanceKm!).toBeLessThan(15); + const far = matchZone(loc, { postal: "L5B 1A1", lat: 43.59, lng: -79.64 }); // Mississauga + expect(far.zone).toBeNull(); + expect(far.reason?.code).toBe("OUTSIDE_DELIVERY_AREA"); + }); + it("never accepts unverified driving-distance addresses", () => { + const loc = kitchener(); + loc.zones = [{ id: "d", name: "Drive 20km", drivingKm: 20, fee: 999 }]; + const m = matchZone(loc, { postal: "L5B 1A1" }); + expect(m.zone).toBeNull(); + expect(m.unverified).toBe(true); + expect(matchZone(loc, { postal: "x", drivingKm: { kit: 12 } }).zone?.id).toBe("d"); + }); + it("keeps eligibility separate from price", () => { + const z = kitchener().zones[0]; + expect(quoteFee(z, 5000).fee).toBe(799); + expect(quoteFee(z, 8000)).toMatchObject({ fee: 0, waived: true }); + expect(quoteFee(z, 2000).belowMinimum).toBe(true); + expect(quoteFee(z, 8000, [{ label: "Diwali", amount: 500 }]).fee).toBe(500); + }); +}); + +describe("location ranking and inventory", () => { + const cart: Cart = { items: [pakku, { ...saree, quantity: 1 }] }; + it("ranks eligible locations and explains the rest", () => { + const { ranked } = rankLocations({ + locations: [waterloo(), kitchener()], + method: "pickup", + cart, + rules: [], + peakDays: [], + usage: {}, + settings, + now: at("2026-10-14", "09:00"), + inventory: { kit: { v_pakku: 5, v_saree: 1 }, wat: { v_pakku: 5 } }, + }); + expect(ranked[0].location.id).toBe("kit"); + expect(ranked[1].reasons[0].code).toBe("OUT_OF_STOCK_AT_LOCATION"); + }); + it("flags a split instead of silently assigning one location", () => { + const { ranked, splitRequired } = rankLocations({ + locations: [waterloo(), kitchener()], + method: "pickup", + cart, + rules: [], + peakDays: [], + usage: {}, + settings, + now: at("2026-10-14", "09:00"), + inventory: { kit: { v_pakku: 5 }, wat: { v_saree: 1 } }, + }); + expect(ranked.every((r) => !r.eligible)).toBe(true); + expect(splitRequired?.code).toBe("OUT_OF_STOCK_AT_LOCATION"); + }); + it("a location without inventory data (not linked) never blocks", () => { + const { ranked } = rankLocations({ + locations: [kitchener()], method: "pickup", cart, rules: [], peakDays: [], usage: {}, settings, + now: at("2026-10-14", "09:00"), inventory: {}, + }); + expect(ranked[0].eligible).toBe(true); + }); + it("delivery ranks by zone + minimum order", () => { + const { ranked } = rankLocations({ + locations: [kitchener(), waterloo()], + method: "delivery", + cart: { items: [pakku] }, // $6.99 < $25 minimum at Kitchener + rules: [], + peakDays: [], + usage: {}, + settings, + now: at("2026-10-14", "09:00"), + address: { postal: "N2J 2Z1" }, // in both + }); + expect(ranked[0].location.id).toBe("wat"); + expect(ranked[1].reasons[0].code).toBe("BELOW_MINIMUM_ORDER"); + }); +}); + +describe("customer self-reschedule and reminders", () => { + const b: Booking = { + id: "b1", + orderName: "#1042", + status: "confirmed", + method: "delivery", + locationId: "kit", + date: "2026-10-20", + slotStart: "13:00", + slotEnd: "16:00", + timezone: TZ, + customer: { name: "Priya", email: "p@example.com", phone: "+15195550100" }, + rescheduleCount: 0, + }; + it("allows changes until the deadline", () => { + expect(canCustomerReschedule(b, settings.reschedule, at("2026-10-19", "12:59")).ok).toBe(true); + expect(canCustomerReschedule(b, settings.reschedule, at("2026-10-19", "13:00")).ok).toBe(false); + expect(canCustomerReschedule({ ...b, rescheduleCount: 2 }, settings.reschedule, at("2026-10-15", "09:00")).ok).toBe(false); + }); + it("plans reminders and renders templates", () => { + const r = planReminders(b, settings.reminders, at("2026-10-15", "09:00")); + expect(r).toHaveLength(2); + expect(r[0].body).toBe("Hi Priya, your delivery is 2026-10-20 13:00–16:00"); + expect(localParts(r[0].sendAt, TZ)).toMatchObject({ date: "2026-10-19", time: "13:00" }); + const noPhone = planReminders({ ...b, customer: { email: "p@example.com" } }, settings.reminders, at("2026-10-15", "09:00")); + expect(noPhone.map((x) => x.channel)).toEqual(["email"]); + }); +}); + +describe("driver run sheet", () => { + const loc: LocationConfig = kitchener(); + const mk = (id: string, slot: string, lat: number, lng: number): Booking => ({ + id, + status: "confirmed", + method: "delivery", + locationId: "kit", + date: "2026-10-20", + slotStart: slot.split("-")[0], + slotEnd: slot.split("-")[1], + timezone: TZ, + address: { lat, lng, line1: id }, + }); + it("orders by window then nearest neighbour, with route links", () => { + const sheet = buildRunSheet( + [ + mk("far-am", "10:00-13:00", 43.5, -80.55), + mk("near-am", "10:00-13:00", 43.466, -80.49), + mk("pm", "13:00-16:00", 43.45, -80.48), + { ...mk("nogeo", "10:00-13:00", 0, 0), address: { line1: "?" } }, + ], + loc, + "2026-10-20", + ); + expect(sheet.stops.map((s) => s.booking.id)).toEqual(["near-am", "far-am", "pm"]); + expect(sheet.unlocated.map((b) => b.id)).toEqual(["nogeo"]); + expect(sheet.mapsUrls[0]).toContain("google.com/maps/dir"); + }); + it("exports Excel-safe CSV", () => { + const csv = toCsv([{ a: 'He said "hi"', b: "x,y" }]); + expect(csv.startsWith("")).toBe(true); + expect(csv).toContain('"He said ""hi"""'); + expect(csv).toContain('"x,y"'); + }); +}); diff --git a/app/engine/__tests__/fixtures.ts b/app/engine/__tests__/fixtures.ts new file mode 100644 index 0000000..23bb5b3 --- /dev/null +++ b/app/engine/__tests__/fixtures.ts @@ -0,0 +1,166 @@ +import type { Weekday, TimeRange, LocationConfig, PeakDay, ProductRule, ShopSettings, CartItem } from "../types"; +import { zonedToUtc } from "../time"; + +export const TZ = "America/Toronto"; +/** Local Toronto wall time → epoch ms */ +export const at = (date: string, time: string) => zonedToUtc(date, time, TZ); + +const h = () => [{ start: "10:00", end: "18:00" }]; +const monSat: Partial> = { 1: h(), 2: h(), 3: h(), 4: h(), 5: h(), 6: h() }; +const deliverySlots = [ + { start: "10:00", end: "13:00" }, + { start: "13:00", end: "16:00" }, + { start: "16:00", end: "19:00", surcharge: 300 }, +]; + +export function kitchener(): LocationConfig { + return { + id: "kit", + name: "Kitchener", + address: "900 Guelph St, Kitchener, ON N2H 5Z6", + timezone: TZ, + lat: 43.4643, + lng: -80.4844, + enabled: true, + priority: 1, + exceptions: [], + zones: [ + { id: "z1", name: "Kitchener-Waterloo", postalPatterns: ["N2H*", "N2G*", "N2J..N2M"], fee: 799, freeOver: 7500, minOrder: 2500 }, + { id: "z2", name: "15 km radius", radiusKm: 15, fee: 1299 }, + ], + pickupInstructions: "Unit 7 — ring the bell", + methods: { + pickup: { + enabled: true, + weeklyHours: { ...monSat }, + slotMode: "generated", + slotMinutes: 60, + prepMinutes: 60, + prepMode: "elapsed", + sameDayCutoff: "14:00", + horizonDays: 30, + slotCapacity: 4, + }, + delivery: { + enabled: true, + weeklyHours: { 0: [{ start: "10:00", end: "19:00" }], 2: [{ start: "10:00", end: "19:00" }], 3: [{ start: "10:00", end: "19:00" }], 4: [{ start: "10:00", end: "19:00" }], 5: [{ start: "10:00", end: "19:00" }], 6: [{ start: "10:00", end: "19:00" }] }, + slotMode: "explicit", + slots: { 0: deliverySlots, 2: deliverySlots, 3: deliverySlots, 4: deliverySlots, 5: deliverySlots, 6: deliverySlots }, + prepMinutes: 120, + prepMode: "elapsed", + sameDayCutoff: "12:00", + horizonDays: 21, + slotCapacity: 5, + dailyCapacity: 12, + }, + shipping: { + enabled: true, + weeklyHours: { ...monSat }, + slotMode: "dateOnly", + prepMinutes: 0, + prepMode: "operating", + sameDayCutoff: "14:00", + horizonDays: 14, + }, + }, + }; +} + +export function waterloo(): LocationConfig { + const l = kitchener(); + return { + ...l, + id: "wat", + name: "Waterloo", + priority: 2, + lat: 43.4723, + lng: -80.5449, + zones: [{ id: "zw", name: "Waterloo", postalPatterns: ["N2L*", "N2J*"], fee: 599 }], + }; +} + +export const settings: ShopSettings = { + mixedPrepPolicy: "longest", + holdMinutes: 10, + weekStartsOn: 0, + reschedule: { enabled: true, minHoursBefore: 24, maxChanges: 2, allowMethodChange: false }, + reminders: [ + { id: "r1", channel: "sms", minutesBefore: 24 * 60, template: "Hi {{name}}, your {{method}} is {{date}} {{time}}", enabled: true }, + { id: "r2", channel: "email", minutesBefore: 120, template: "Reminder {{order}}", enabled: true }, + ], +}; + +export const marigold: CartItem = { + productId: "p_marigold", + variantId: "v_marigold", + title: "Fresh Marigold Garland", + quantity: 2, + price: 1999, + collectionIds: ["c_flowers"], + vendor: "C2C Agricorp", + productType: "Fresh Flowers", + tags: ["perishable"], +}; + +export const pakku: CartItem = { + productId: "p_pakku", + variantId: "v_pakku", + title: "பாக்கு (Pākkŭ) 200g", + quantity: 1, + price: 699, + collectionIds: ["c_puja"], + vendor: "Clicks to Cart", + productType: "Puja", +}; + +export const saree: CartItem = { + productId: "p_saree", + variantId: "v_saree", + title: "Silk Saree", + quantity: 1, + price: 15999, + collectionIds: ["c_apparel"], + productType: "Apparel", +}; + +export const rules: ProductRule[] = [ + { + id: "r_fresh_noship", + name: "Fresh flowers can't ship", + enabled: true, + priority: 1, + target: { type: "productType", values: ["Fresh Flowers"] }, + action: { type: "prohibitMethods", methods: ["shipping"] }, + message: "Fresh flowers are pickup or local delivery only", + }, + { + id: "r_fresh_prep", + name: "Garland stringing time", + enabled: true, + priority: 2, + target: { type: "tag", values: ["perishable"] }, + action: { type: "addPrep", minutes: 240 }, + }, + { + id: "r_saree_prep", + name: "Saree fall & pico", + enabled: true, + priority: 3, + target: { type: "collection", values: ["c_apparel"] }, + action: { type: "addPrep", minutes: 60 * 24 }, + }, +]; + +export const diwali: PeakDay = { + id: "pk_diwali", + name: "Diwali", + dates: ["2026-11-08"], + methods: ["delivery", "pickup"], + dailyCapacity: 30, + slotCapacity: 10, + extraPrepMinutes: 60, + cutoff: { daysBefore: 2, time: "18:00" }, + surcharge: 500, + waitlist: true, + message: "Diwali orders close Friday 6 PM", +}; diff --git a/app/engine/availability.ts b/app/engine/availability.ts new file mode 100644 index 0000000..6b58022 --- /dev/null +++ b/app/engine/availability.ts @@ -0,0 +1,312 @@ +/** + * Availability: which dates and slots a specific cart can book at a specific + * location with a specific method, right now. + * + * Rule precedence (each step only runs if the previous passed): + * 1. Location / method eligibility (incl. product method & location rules) + * 2. Product date restrictions (weekday/date blocks, windows, freshness) + * 3. Holidays / closures / special openings + * 4. Cutoffs (same-day, next-day, peak-day early cutoff) + * 5. Preparation time (base + product rules + peak extra) vs slot start + * 6. Daily and slot capacity (bookings + active holds), with waitlist on peak days + */ +import type { + AvailabilityResult, + Cart, + DateResult, + LocationConfig, + Method, + MethodConfig, + PeakDay, + ProductRule, + Reason, + ShopSettings, + SlotResult, + SlotTemplate, + TimeRange, + UsageSnapshot, +} from "./types"; +import { type CartConstraints, checkFreshness, evaluateRules } from "./rules"; +import { addDays, fromMinutes, iso, localParts, toMinutes, weekdayOf, zonedToUtc } from "./time"; + +export const usageKey = (locationId: string, method: Method, date: string) => `${locationId}|${method}|${date}`; +export const slotId = (start: string, end: string) => `${start}-${end}`; + +export interface AvailabilityInput { + location: LocationConfig; + method: Method; + cart: Cart; + rules: ProductRule[]; + peakDays: PeakDay[]; + usage: UsageSnapshot; + settings: Pick; + now: number; + constraints?: CartConstraints; + /** Limit computed days (e.g. calendar month paging). Defaults to the method horizon. */ + maxDays?: number; +} + +/** Hours for a date after applying closures and special openings. */ +export function hoursFor( + loc: LocationConfig, + method: Method, + date: string, +): { hours: TimeRange[]; exception?: Reason; special?: boolean } { + const cfg = loc.methods[method]; + const ex = loc.exceptions.find((e) => e.date === date && (!e.methods?.length || e.methods.includes(method))); + if (ex?.closed) + return { hours: [], exception: { code: "CLOSED_EXCEPTION", message: ex.note || "Closed on this date" } }; + if (ex?.hours?.length) return { hours: ex.hours, special: true }; + return { hours: cfg?.weeklyHours[weekdayOf(date)] ?? [] }; +} + +export function peakFor(peaks: PeakDay[], locationId: string, method: Method, date: string): PeakDay | null { + return ( + peaks.find( + (p) => + p.dates.includes(date) && + (!p.locationIds?.length || p.locationIds.includes(locationId)) && + (!p.methods?.length || p.methods.includes(method)), + ) ?? null + ); +} + +function slotTemplates(cfg: MethodConfig, date: string, hours: TimeRange[], usingException: boolean): SlotTemplate[] { + if (!hours.length) return []; + if (cfg.slotMode === "dateOnly") return [{ start: hours[0].start, end: hours[hours.length - 1].end }]; + if (cfg.slotMode === "explicit" && !usingException) { + const list = cfg.slots?.[weekdayOf(date)] ?? []; + // Only keep explicit slots that fall within opening hours. + return list.filter((s) => + hours.some((h) => toMinutes(s.start) >= toMinutes(h.start) && toMinutes(s.end) <= toMinutes(h.end)), + ); + } + const step = cfg.slotMinutes || 60; + const out: SlotTemplate[] = []; + for (const h of hours) { + for (let t = toMinutes(h.start); t + step <= toMinutes(h.end); t += step) + out.push({ start: fromMinutes(t), end: fromMinutes(t + step) }); + } + return out; +} + +/** + * Add preparation minutes counting only operating hours of this method at this location. + */ +export function addOperatingMinutes( + startMs: number, + minutes: number, + loc: LocationConfig, + method: Method, +): number { + if (minutes <= 0) return startMs; + let remaining = minutes * 60000; + let date = localParts(startMs, loc.timezone).date; + for (let i = 0; i < 400; i++, date = addDays(date, 1)) { + for (const h of hoursFor(loc, method, date).hours) { + const rs = zonedToUtc(date, h.start, loc.timezone); + const re = zonedToUtc(date, h.end, loc.timezone); + if (re <= startMs) continue; + const from = Math.max(rs, startMs); + const avail = re - from; + if (remaining <= avail) return from + remaining; + remaining -= avail; + } + } + return Number.POSITIVE_INFINITY; +} + +export function computeAvailability(input: AvailabilityInput): AvailabilityResult { + const { location: loc, method, now, usage } = input; + const tz = loc.timezone; + const cfg = loc.methods[method]; + const constraints = + input.constraints ?? evaluateRules(input.cart, input.rules, input.settings.mixedPrepPolicy); + + const blockers: Reason[] = []; + if (!loc.enabled) blockers.push({ code: "LOCATION_DISABLED", message: `${loc.name} isn't taking bookings right now` }); + if (!cfg?.enabled) blockers.push({ code: "METHOD_DISABLED", message: `${loc.name} doesn't offer ${method}` }); + if (!constraints.allowedMethods.has(method)) blockers.push(...(constraints.methodReasons.get(method) ?? [])); + if (constraints.allowedLocations && !constraints.allowedLocations.has(loc.id)) + blockers.push(...constraints.locationReasons); + + const basePrep = (cfg?.prepMinutes ?? 0) + constraints.extraPrepMinutes; + const readyCache = new Map(); + const readyAt = (prep: number) => { + if (!readyCache.has(prep)) + readyCache.set( + prep, + cfg?.prepMode === "operating" ? addOperatingMinutes(now, prep, loc, method) : now + prep * 60000, + ); + return readyCache.get(prep)!; + }; + + const result: AvailabilityResult = { + locationId: loc.id, + method, + timezone: tz, + dateOnly: cfg?.slotMode === "dateOnly", + prepMinutes: basePrep, + earliestReadyUtc: iso(readyAt(basePrep)), + blockers, + dates: [], + }; + if (blockers.length || !cfg) return result; + + const nowLocal = localParts(now, tz); + const days = Math.min(cfg.horizonDays, input.maxDays ?? cfg.horizonDays); + + for (let d = 0; d < days; d++) { + const date = addDays(nowLocal.date, d); + const weekday = weekdayOf(date); + const peak = peakFor(input.peakDays, loc.id, method, date); + const dr: DateResult = { + date, + weekday, + available: false, + remaining: null, + peak: peak ? { id: peak.id, name: peak.name, message: peak.message } : null, + surcharge: peak?.surcharge ?? 0, + waitlist: false, + reasons: [], + slots: [], + }; + result.dates.push(dr); + + // 2. product date restrictions + const wd = constraints.blockedWeekdays.get(weekday); + if (wd) dr.reasons.push(wd); + const bd = constraints.blockedDates.get(date); + if (bd) dr.reasons.push(bd); + for (const w of constraints.windows) + if ((w.from && date < w.from) || (w.to && date > w.to)) dr.reasons.push(w.reason); + const fresh = checkFreshness(constraints.freshness, date, loc.id); + if (fresh) dr.reasons.push(fresh); + if (dr.reasons.length) continue; + + // 3. closures + const { hours, exception, special } = hoursFor(loc, method, date); + if (exception) { + dr.reasons.push(exception); + continue; + } + if (!hours.length) { + dr.reasons.push({ code: "CLOSED_WEEKLY", message: "Closed on this day" }); + continue; + } + + // 4. cutoffs + if (d === 0 && cfg.sameDayCutoff && nowLocal.minutes >= toMinutes(cfg.sameDayCutoff)) { + dr.reasons.push({ code: "PAST_CUTOFF", message: `Same-day orders close at ${cfg.sameDayCutoff}` }); + continue; + } + if (d === 1 && cfg.nextDayCutoff && nowLocal.minutes >= toMinutes(cfg.nextDayCutoff)) { + dr.reasons.push({ code: "PAST_CUTOFF", message: `Next-day orders close at ${cfg.nextDayCutoff}` }); + continue; + } + if (peak?.cutoff) { + const deadline = zonedToUtc(addDays(date, -peak.cutoff.daysBefore), peak.cutoff.time, tz); + if (now >= deadline) { + dr.reasons.push({ + code: "PEAK_CUTOFF", + message: `${peak.name} orders closed on ${addDays(date, -peak.cutoff.daysBefore)} at ${peak.cutoff.time}`, + }); + continue; + } + } + + // 5 + 6. slots + const ready = readyAt(basePrep + (peak?.extraPrepMinutes ?? 0)); + const key = usageKey(loc.id, method, date); + const used = usage[key] ?? { day: 0, slots: {} }; + const dayCap = peak?.dailyCapacity ?? cfg.dailyCapacity ?? null; + const dayLeft = dayCap == null ? null : Math.max(0, dayCap - used.day); + dr.remaining = dayLeft; + const templates = slotTemplates(cfg, date, hours, !!special); + if (!templates.length) { + dr.reasons.push({ code: "CLOSED_WEEKLY", message: "No time slots on this day" }); + continue; + } + + for (const t of templates) { + const startUtc = zonedToUtc(date, t.start, tz); + const endUtc = zonedToUtc(date, t.end, tz); + const id = slotId(t.start, t.end); + const sr: SlotResult = { + id, + start: t.start, + end: t.end, + startUtc: iso(startUtc), + endUtc: iso(endUtc), + available: false, + remaining: null, + surcharge: t.surcharge ?? 0, + waitlist: false, + reasons: [], + }; + dr.slots.push(sr); + + if (result.dateOnly) { + // Dispatch date: the order must be ready before the location closes. + if (endUtc <= now) sr.reasons.push({ code: "SLOT_IN_PAST", message: "Closed for today" }); + else if (ready > endUtc) sr.reasons.push({ code: "PREP_TIME", message: "Not enough preparation time" }); + } else if (startUtc <= now) { + sr.reasons.push({ code: "SLOT_IN_PAST", message: "This time has passed" }); + } else if (ready > startUtc) { + sr.reasons.push({ code: "PREP_TIME", message: "Not enough preparation time" }); + } + if (sr.reasons.length) continue; + + const slotCap = peak?.slotCapacity ?? t.capacity ?? cfg.slotCapacity ?? null; + const slotUsed = used.slots[id] ?? 0; + const slotLeft = slotCap == null ? null : Math.max(0, slotCap - slotUsed); + sr.remaining = slotLeft == null ? dayLeft : dayLeft == null ? slotLeft : Math.min(slotLeft, dayLeft); + if (dayLeft === 0) sr.reasons.push({ code: "DAY_FULL", message: "Fully booked for this day" }); + else if (slotLeft === 0) sr.reasons.push({ code: "SLOT_FULL", message: "This time slot is full" }); + if (sr.reasons.length) { + sr.waitlist = !!peak?.waitlist; + continue; + } + sr.available = true; + } + + dr.available = dr.slots.some((s) => s.available); + dr.waitlist = !dr.available && dr.slots.some((s) => s.waitlist); + if (!dr.available) { + const seen = new Set(); + for (const s of dr.slots) + for (const r of s.reasons) + if (!seen.has(r.code)) { + seen.add(r.code); + dr.reasons.push(r); + } + } + } + return result; +} + +export function firstAvailable(r: AvailabilityResult): { date: string; slot: SlotResult } | null { + for (const d of r.dates) { + const s = d.slots.find((x) => x.available); + if (s) return { date: d.date, slot: s }; + } + return null; +} + +/** + * Re-validate a specific selection (used before hold, at order creation and on reschedule). + */ +export function validateSelection( + input: AvailabilityInput, + date: string, + slot: string, +): { ok: true; slot: SlotResult; date: DateResult } | { ok: false; reasons: Reason[] } { + const r = computeAvailability(input); + if (r.blockers.length) return { ok: false, reasons: r.blockers }; + const dr = r.dates.find((d) => d.date === date); + if (!dr) return { ok: false, reasons: [{ code: "BEYOND_HORIZON", message: "This date can't be booked" }] }; + const sr = dr.slots.find((s) => s.id === slot); + if (!sr) return { ok: false, reasons: dr.reasons.length ? dr.reasons : [{ code: "CLOSED_WEEKLY", message: "Slot not offered" }] }; + if (!sr.available) return { ok: false, reasons: sr.reasons.length ? sr.reasons : dr.reasons }; + return { ok: true, slot: sr, date: dr }; +} diff --git a/app/engine/capacity.ts b/app/engine/capacity.ts new file mode 100644 index 0000000..e580bc6 --- /dev/null +++ b/app/engine/capacity.ts @@ -0,0 +1,255 @@ +/** + * Capacity, holds and bookings. + * + * `MemoryReservationStore` is the reference implementation of the reservation + * semantics. The production store (app/services/reservations.server.ts) implements + * the same contract on Postgres with row-level locking; both are covered by the + * same behavioural tests. + * + * Capacity unit (v1): orders, not items. + */ +import type { Booking, LocationConfig, Method, PeakDay, UsageSnapshot } from "./types"; +import { peakFor, usageKey } from "./availability"; + +export interface Caps { + dayCap: number | null; + slotCap: number | null; +} + +export function capsFor( + loc: LocationConfig, + method: Method, + date: string, + slot: string, + peaks: PeakDay[], +): Caps { + const cfg = loc.methods[method]; + const peak = peakFor(peaks, loc.id, method, date); + let tplCap: number | null | undefined; + if (cfg?.slotMode === "explicit") { + const [start, end] = slot.split("-"); + for (const list of Object.values(cfg.slots ?? {})) + for (const s of list ?? []) if (s.start === start && s.end === end && s.capacity != null) tplCap = s.capacity; + } + return { + dayCap: peak?.dailyCapacity ?? cfg?.dailyCapacity ?? null, + slotCap: peak?.slotCapacity ?? tplCap ?? cfg?.slotCapacity ?? null, + }; +} + +export interface Selection { + locationId: string; + method: Method; + date: string; + slot: string; // "HH:MM-HH:MM" +} + +export interface Hold extends Selection { + token: string; + expiresAt: number; +} + +export type HoldResult = + | { ok: true; hold: Hold } + | { ok: false; code: "DAY_FULL" | "SLOT_FULL"; message: string }; + +export type ConfirmResult = + | { ok: true; booking: Booking; duplicate: boolean; reReserved?: boolean } + | { ok: false; booking: Booking; code: "NEEDS_REVIEW"; message: string }; + +export interface WaitlistEntry extends Selection { + id: string; + contact: { email?: string; phone?: string; name?: string }; + createdAt: number; + notifiedAt?: number; +} + +export class MemoryReservationStore { + holds = new Map(); + bookings = new Map(); + waitlist: WaitlistEntry[] = []; + audit: { at: number; actor: string; bookingId: string; action: string; before?: unknown; after?: unknown }[] = []; + private seq = 0; + + private counts(sel: Selection, now: number, excludeToken?: string, excludeBooking?: string) { + let day = 0; + let slot = 0; + const same = (s: Selection) => s.locationId === sel.locationId && s.method === sel.method && s.date === sel.date; + for (const b of this.bookings.values()) { + // Only confirmed bookings consume capacity; flagged ones wait for a merchant decision. + if (b.id === excludeBooking || b.status !== "confirmed") continue; + const s = { locationId: b.locationId, method: b.method, date: b.date, slot: `${b.slotStart}-${b.slotEnd}` }; + if (same(s)) { + day++; + if (s.slot === sel.slot) slot++; + } + } + for (const h of this.holds.values()) { + if (h.token === excludeToken || h.expiresAt <= now) continue; + if (same(h)) { + day++; + if (h.slot === sel.slot) slot++; + } + } + return { day, slot }; + } + + usage(now: number): UsageSnapshot { + const out: UsageSnapshot = {}; + const add = (s: Selection) => { + const k = usageKey(s.locationId, s.method, s.date); + out[k] ??= { day: 0, slots: {} }; + out[k].day++; + out[k].slots[s.slot] = (out[k].slots[s.slot] ?? 0) + 1; + }; + for (const b of this.bookings.values()) + if (b.status === "confirmed") + add({ locationId: b.locationId, method: b.method, date: b.date, slot: `${b.slotStart}-${b.slotEnd}` }); + for (const h of this.holds.values()) if (h.expiresAt > now) add(h); + return out; + } + + private fits(sel: Selection, caps: Caps, now: number, excludeToken?: string, excludeBooking?: string) { + const c = this.counts(sel, now, excludeToken, excludeBooking); + if (caps.dayCap != null && c.day >= caps.dayCap) return "DAY_FULL" as const; + if (caps.slotCap != null && c.slot >= caps.slotCap) return "SLOT_FULL" as const; + return null; + } + + /** Atomic check-and-reserve. Re-holding with the same token moves the hold. */ + hold(token: string, sel: Selection, caps: Caps, now: number, holdMinutes: number): HoldResult { + const full = this.fits(sel, caps, now, token); + if (full) return { ok: false, code: full, message: full === "DAY_FULL" ? "Fully booked for this day" : "This time slot was just taken" }; + const hold: Hold = { ...sel, token, expiresAt: now + holdMinutes * 60000 }; + this.holds.set(token, hold); + return { ok: true, hold }; + } + + release(token: string) { + this.holds.delete(token); + } + + sweep(now: number): number { + let n = 0; + for (const [t, h] of this.holds) if (h.expiresAt <= now) (this.holds.delete(t), n++); + return n; + } + + /** + * Confirm a hold against an order. Idempotent per orderId (duplicate webhooks are safe). + * Expired hold: re-reserve if capacity still allows, otherwise flag for review — + * never silently overbook. + */ + confirm( + orderId: string, + token: string | null, + fallbackSel: Selection | null, + caps: Caps, + now: number, + details: Partial & { timezone: string }, + ): ConfirmResult { + const existing = [...this.bookings.values()].find((b) => b.orderId === orderId); + if (existing) return { ok: true, booking: existing, duplicate: true }; + + const hold = token ? this.holds.get(token) : undefined; + const sel = hold ?? fallbackSel; + const id = `bk_${++this.seq}`; + const base = (status: Booking["status"]): Booking => ({ + ...details, + id, + orderId, + status, + method: sel!.method, + locationId: sel!.locationId, + date: sel!.date, + slotStart: sel!.slot.split("-")[0], + slotEnd: sel!.slot.split("-")[1], + rescheduleCount: 0, + }); + if (!sel) { + const b: Booking = { ...details, id, orderId, status: "needs_review", method: "pickup", locationId: "", date: "" }; + this.bookings.set(id, b); + return { ok: false, booking: b, code: "NEEDS_REVIEW", message: "Order has no scheduling data" }; + } + const live = hold && hold.expiresAt > now; + if (!live) { + const full = this.fits(sel, caps, now, token ?? undefined); + if (full) { + const b = base("needs_review"); + this.bookings.set(id, b); + if (token) this.holds.delete(token); + this.log(now, "system", id, "flagged", undefined, { reason: full }); + return { ok: false, booking: b, code: "NEEDS_REVIEW", message: "Hold expired and the slot filled up" }; + } + } + const b = base("confirmed"); + this.bookings.set(id, b); + if (token) this.holds.delete(token); + this.log(now, "system", id, "confirmed"); + return { ok: true, booking: b, duplicate: false, reReserved: !live }; + } + + /** Atomic move: validates the new slot, then releases old + takes new together. */ + reschedule( + bookingId: string, + sel: Selection, + caps: Caps, + now: number, + actor: string, + ): { ok: true; booking: Booking; freed: Selection } | { ok: false; message: string } { + const b = this.bookings.get(bookingId); + if (!b || b.status !== "confirmed") return { ok: false, message: "Booking can't be rescheduled" }; + const full = this.fits(sel, caps, now, undefined, bookingId); + if (full) return { ok: false, message: full === "DAY_FULL" ? "That day is fully booked" : "That slot is full" }; + const before = { ...b }; + const freed: Selection = { locationId: b.locationId, method: b.method, date: b.date, slot: `${b.slotStart}-${b.slotEnd}` }; + const [s, e] = sel.slot.split("-"); + Object.assign(b, { + locationId: sel.locationId, + method: sel.method, + date: sel.date, + slotStart: s, + slotEnd: e, + rescheduleCount: (b.rescheduleCount ?? 0) + 1, + }); + this.log(now, actor, bookingId, "rescheduled", pick(before), pick(b)); + return { ok: true, booking: b, freed }; + } + + cancel(orderId: string, now: number, releaseCapacity = true): Booking | null { + const b = [...this.bookings.values()].find((x) => x.orderId === orderId); + if (!b || b.status === "cancelled") return b ?? null; + const before = { ...b }; + b.status = releaseCapacity ? "cancelled" : b.status; + this.log(now, "system", b.id, "cancelled", pick(before), pick(b)); + return b; + } + + joinWaitlist(sel: Selection, contact: WaitlistEntry["contact"], now: number): WaitlistEntry { + const e: WaitlistEntry = { ...sel, id: `wl_${++this.seq}`, contact, createdAt: now }; + this.waitlist.push(e); + return e; + } + + /** Who to notify when capacity frees up for a selection (FIFO, one at a time). */ + nextWaitlisted(sel: Selection): WaitlistEntry | null { + return ( + this.waitlist.find( + (w) => + !w.notifiedAt && + w.locationId === sel.locationId && + w.method === sel.method && + w.date === sel.date && + (w.slot === sel.slot || w.slot === "*"), + ) ?? null + ); + } + + private log(at: number, actor: string, bookingId: string, action: string, before?: unknown, after?: unknown) { + this.audit.push({ at, actor, bookingId, action, before, after }); + } +} + +function pick(b: Booking) { + return { locationId: b.locationId, method: b.method, date: b.date, slotStart: b.slotStart, slotEnd: b.slotEnd, status: b.status }; +} diff --git a/app/engine/customer.ts b/app/engine/customer.ts new file mode 100644 index 0000000..1e5de14 --- /dev/null +++ b/app/engine/customer.ts @@ -0,0 +1,66 @@ +/** + * Customer-facing post-purchase features: self-reschedule eligibility and reminders. + */ +import type { Booking, ReminderPolicy, ShopSettings } from "./types"; +import { zonedToUtc } from "./time"; + +export function slotStartMs(b: Booking): number { + return zonedToUtc(b.date, b.slotStart ?? "00:00", b.timezone); +} + +export function canCustomerReschedule( + b: Booking, + policy: ShopSettings["reschedule"], + now: number, +): { ok: true; deadline: number } | { ok: false; reason: string } { + if (!policy.enabled) return { ok: false, reason: "Rescheduling isn't available for this store. Please contact us." }; + if (b.status !== "confirmed") return { ok: false, reason: "This booking can't be changed." }; + if ((b.rescheduleCount ?? 0) >= policy.maxChanges) + return { ok: false, reason: `You've already changed this booking ${policy.maxChanges} time(s). Please contact us.` }; + const deadline = slotStartMs(b) - policy.minHoursBefore * 3600000; + if (now >= deadline) + return { ok: false, reason: `Changes are allowed up to ${policy.minHoursBefore} hours before your time slot.` }; + return { ok: true, deadline }; +} + +export interface PlannedReminder { + policyId: string; + channel: ReminderPolicy["channel"]; + sendAt: number; + body: string; +} + +export function planReminders( + b: Booking, + policies: ReminderPolicy[], + now: number, + vars: Record = {}, +): PlannedReminder[] { + const start = slotStartMs(b); + return policies + .filter((p) => p.enabled && (!p.methods?.length || p.methods.includes(b.method))) + .map((p) => ({ + policyId: p.id, + channel: p.channel, + sendAt: start - p.minutesBefore * 60000, + body: renderTemplate(p.template, { + name: b.customer?.name ?? "there", + order: b.orderName ?? "", + date: b.date, + time: b.slotStart && b.slotEnd ? `${b.slotStart}–${b.slotEnd}` : "", + method: b.method, + ...vars, + }), + })) + .filter((r) => r.sendAt > now) + .filter((r) => { + // Respect channel requirements. + if (r.channel === "email") return !!b.customer?.email; + return !!b.customer?.phone; + }); +} + +/** {{var}} substitution; unknown vars render empty. */ +export function renderTemplate(tpl: string, vars: Record): string { + return tpl.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, k) => vars[k] ?? ""); +} diff --git a/app/engine/index.ts b/app/engine/index.ts new file mode 100644 index 0000000..98d7f8d --- /dev/null +++ b/app/engine/index.ts @@ -0,0 +1,9 @@ +export * from "./types"; +export * from "./time"; +export * from "./rules"; +export * from "./zones"; +export * from "./availability"; +export * from "./capacity"; +export * from "./locations"; +export * from "./customer"; +export * from "./runsheet"; diff --git a/app/engine/locations.ts b/app/engine/locations.ts new file mode 100644 index 0000000..1743e2b --- /dev/null +++ b/app/engine/locations.ts @@ -0,0 +1,121 @@ +/** + * Location eligibility + ranking for a cart and method. + * + * Policy: never silently assign a whole cart to one location when its items are + * only stocked across several. Instead return SPLIT_REQUIRED with an explanation. + */ +import type { AvailabilityResult, Cart, LocationConfig, Method, PeakDay, ProductRule, Reason, ShopSettings, UsageSnapshot } from "./types"; +import { computeAvailability, firstAvailable } from "./availability"; +import { evaluateRules } from "./rules"; +import { type CustomerAddress, type FeeQuote, type ZoneMatch, matchZone, quoteFee } from "./zones"; + +export type Inventory = Record>; // locationId → variantId → available + +export interface RankedLocation { + location: LocationConfig; + eligible: boolean; + reasons: Reason[]; + zone?: ZoneMatch; + fee?: FeeQuote; + distanceKm: number | null; + first: { date: string; slot: string } | null; + availability?: AvailabilityResult; +} + +export interface RankInput { + locations: LocationConfig[]; + method: Method; + cart: Cart; + rules: ProductRule[]; + peakDays: PeakDay[]; + usage: UsageSnapshot; + settings: Pick; + now: number; + address?: CustomerAddress; + inventory?: Inventory; + ranking?: "priority" | "distance" | "earliest"; +} + +/** + * A location with no inventory entry (not linked to a Shopify location) is "unknown" and + * never blocks. For linked locations, a missing variant means none in stock there. + */ +export function stockedAt(inv: Inventory | undefined, locId: string, cart: Cart): boolean { + if (!inv || !inv[locId]) return true; + const at = inv[locId]; + return cart.items.every((i) => (at[i.variantId] ?? 0) >= i.quantity); +} + +export function rankLocations(input: RankInput): { ranked: RankedLocation[]; splitRequired: Reason | null } { + const constraints = evaluateRules(input.cart, input.rules, input.settings.mixedPrepPolicy); + const subtotal = input.cart.subtotal ?? input.cart.items.reduce((s, i) => s + i.price * i.quantity, 0); + const ranked: RankedLocation[] = []; + + for (const loc of input.locations) { + const r: RankedLocation = { location: loc, eligible: true, reasons: [], distanceKm: null, first: null }; + ranked.push(r); + if (input.method === "delivery") { + const z = matchZone(loc, input.address ?? {}); + r.zone = z; + r.distanceKm = z.distanceKm; + if (!z.zone) { + r.eligible = false; + r.reasons.push(z.reason!); + } else { + r.fee = quoteFee(z.zone, subtotal); + if (r.fee.belowMinimum) { + r.eligible = false; + r.reasons.push({ + code: "BELOW_MINIMUM_ORDER", + message: `Delivery needs a minimum order of $${(r.fee.minOrder! / 100).toFixed(2)}`, + }); + } + } + } + if (input.method !== "shipping" && !stockedAt(input.inventory, loc.id, input.cart)) { + r.eligible = false; + r.reasons.push({ code: "OUT_OF_STOCK_AT_LOCATION", message: `Not all items are in stock at ${loc.name}` }); + } + const av = computeAvailability({ ...input, location: loc, constraints }); + r.availability = av; + if (av.blockers.length) { + r.eligible = false; + r.reasons.push(...av.blockers); + } + const f = firstAvailable(av); + r.first = f ? { date: f.date, slot: f.slot.id } : null; + if (r.eligible && !f) { + r.eligible = false; + r.reasons.push({ code: "DAY_FULL", message: "No available dates in the booking window" }); + } + } + + const mode = input.ranking ?? "priority"; + ranked.sort((a, b) => { + if (a.eligible !== b.eligible) return a.eligible ? -1 : 1; + if (mode === "distance" && a.distanceKm != null && b.distanceKm != null && a.distanceKm !== b.distanceKm) + return a.distanceKm - b.distanceKm; + if (mode === "earliest" && a.first && b.first) { + const ka = `${a.first.date} ${a.first.slot}`; + const kb = `${b.first.date} ${b.first.slot}`; + if (ka !== kb) return ka < kb ? -1 : 1; + } + return a.location.priority - b.location.priority; + }); + + // Split detection: nobody stocks everything, but the union does. + let splitRequired: Reason | null = null; + if (input.inventory && input.method !== "shipping" && !ranked.some((r) => r.eligible)) { + const unionOk = input.cart.items.every( + (i) => input.locations.reduce((s, l) => s + (input.inventory![l.id]?.[i.variantId] ?? 0), 0) >= i.quantity, + ); + const anyStocked = input.locations.some((l) => stockedAt(input.inventory, l.id, input.cart)); + if (unionOk && !anyStocked) + splitRequired = { + code: "OUT_OF_STOCK_AT_LOCATION", + message: + "Your items are stocked at different locations. Choose shipping, or remove some items to schedule a single pickup or delivery.", + }; + } + return { ranked, splitRequired }; +} diff --git a/app/engine/rules.ts b/app/engine/rules.ts new file mode 100644 index 0000000..ef50bf0 --- /dev/null +++ b/app/engine/rules.ts @@ -0,0 +1,216 @@ +/** + * Product rules → cart constraints. + * + * Conflict policy (v1, documented in the merchant UI): + * 1. "allowOnlyMethods" rules intersect across items. + * 2. "prohibitMethods" always wins over any allowance. + * 3. "restrictLocations" intersects across items. + * 4. Prep time: each item's prep = sum of its matching addPrep rules; + * cart prep = longest item (or sum, per shop setting). + * 5. Date/weekday blocks and freshness windows are all applied (most restrictive). + * Every constraint keeps the rule id + item that caused it so the merchant + * diagnostics screen and the customer message can explain it. + */ +import type { Cart, CartItem, Method, ProductRule, Reason, Weekday } from "./types"; +import { METHODS } from "./types"; +import { addDays } from "./time"; + +export interface FreshnessConstraint { + ruleId: string; + itemTitle: string; + arrivals: { date: string; locationIds?: string[] }[]; + minDaysAfterArrival: number; + shelfLifeDays: number; + message?: string; +} + +export interface CartConstraints { + /** Methods the cart may use, with reasons for any removed. */ + allowedMethods: Set; + methodReasons: Map; + /** null = no location restriction. */ + allowedLocations: Set | null; + locationReasons: Reason[]; + extraPrepMinutes: number; + prepDriver?: { itemTitle: string; ruleId: string; minutes: number }; + blockedWeekdays: Map; + blockedDates: Map; + windows: { from?: string; to?: string; reason: Reason }[]; + freshness: FreshnessConstraint[]; + requiresScheduling: boolean; + matched: { ruleId: string; ruleName: string; itemTitle: string }[]; +} + +export function itemMatches(item: CartItem, rule: ProductRule): boolean { + const t = rule.target; + const vals = t.values.map((v) => String(v).toLowerCase()); + switch (t.type) { + case "product": + return vals.includes(String(item.productId).toLowerCase()); + case "variant": + return vals.includes(String(item.variantId).toLowerCase()); + case "collection": + return (item.collectionIds ?? []).some((c) => vals.includes(String(c).toLowerCase())); + case "vendor": + return !!item.vendor && vals.includes(item.vendor.toLowerCase()); + case "productType": + return !!item.productType && vals.includes(item.productType.toLowerCase()); + case "tag": + return (item.tags ?? []).some((tag) => vals.includes(tag.toLowerCase())); + } +} + +function why(rule: ProductRule, item: CartItem, fallback: string): Reason { + return { code: "BLOCKED_BY_PRODUCT", message: rule.message || fallback.replace("{item}", item.title), ruleId: rule.id }; +} + +export function evaluateRules( + cart: Cart, + rules: ProductRule[], + policy: "longest" | "sum" = "longest", +): CartConstraints { + const c: CartConstraints = { + allowedMethods: new Set(METHODS), + methodReasons: new Map(), + allowedLocations: null, + locationReasons: [], + extraPrepMinutes: 0, + blockedWeekdays: new Map(), + blockedDates: new Map(), + windows: [], + freshness: [], + requiresScheduling: false, + matched: [], + }; + const active = rules.filter((r) => r.enabled).sort((a, b) => a.priority - b.priority); + const prohibitions: { m: Method; reason: Reason }[] = []; + let prepTotal = 0; + + for (const item of cart.items) { + let itemPrep = 0; + let itemPrepRule: string | undefined; + for (const rule of active) { + if (!itemMatches(item, rule)) continue; + c.matched.push({ ruleId: rule.id, ruleName: rule.name, itemTitle: item.title }); + const a = rule.action; + switch (a.type) { + case "allowOnlyMethods": + for (const m of METHODS) { + if (!a.methods.includes(m) && c.allowedMethods.has(m)) { + c.allowedMethods.delete(m); + pushReason(c.methodReasons, m, { + ...why(rule, item, `{item} is only available for ${a.methods.join(" or ")}`), + code: "METHOD_PROHIBITED_BY_PRODUCT", + }); + } + } + break; + case "prohibitMethods": + for (const m of a.methods) + prohibitions.push({ + m, + reason: { ...why(rule, item, `{item} can't be sent by ${m}`), code: "METHOD_PROHIBITED_BY_PRODUCT" }, + }); + break; + case "restrictLocations": { + const next = new Set(a.locationIds); + c.allowedLocations = c.allowedLocations + ? new Set([...c.allowedLocations].filter((x) => next.has(x))) + : next; + c.locationReasons.push({ + ...why(rule, item, `{item} is only available from selected locations`), + code: "LOCATION_PROHIBITED_BY_PRODUCT", + }); + break; + } + case "addPrep": + itemPrep += a.minutes; + itemPrepRule = rule.id; + break; + case "blockWeekdays": + for (const w of a.weekdays) + if (!c.blockedWeekdays.has(w)) c.blockedWeekdays.set(w, why(rule, item, `{item} isn't available on this day`)); + break; + case "blockDates": + for (const d of a.dates) + if (!c.blockedDates.has(d)) c.blockedDates.set(d, why(rule, item, `{item} isn't available on this date`)); + break; + case "dateWindow": + c.windows.push({ from: a.from, to: a.to, reason: why(rule, item, `{item} is only available in a limited window`) }); + break; + case "freshness": + c.freshness.push({ + ruleId: rule.id, + itemTitle: item.title, + arrivals: a.arrivals, + minDaysAfterArrival: a.minDaysAfterArrival, + shelfLifeDays: a.shelfLifeDays, + message: rule.message, + }); + break; + case "requireScheduling": + c.requiresScheduling = true; + break; + } + } + if (policy === "sum") prepTotal += itemPrep; + if (itemPrep > (c.prepDriver?.minutes ?? 0) && itemPrepRule) + c.prepDriver = { itemTitle: item.title, ruleId: itemPrepRule, minutes: itemPrep }; + } + + // Prohibitions win over allowances. + for (const { m, reason } of prohibitions) { + c.allowedMethods.delete(m); + pushReason(c.methodReasons, m, reason); + } + c.extraPrepMinutes = policy === "sum" ? prepTotal : c.prepDriver?.minutes ?? 0; + return c; +} + +function pushReason(map: Map, m: Method, r: Reason) { + const list = map.get(m) ?? []; + if (!list.some((x) => x.ruleId === r.ruleId && x.message === r.message)) list.push(r); + map.set(m, list); +} + +/** + * Freshness check for one date at one location. A date passes when, for every + * perishable item, some stock arrival makes the date fall inside + * [arrival + minDays, arrival + shelfLife]. + */ +export function checkFreshness( + constraints: FreshnessConstraint[], + date: string, + locationId: string, +): Reason | null { + for (const f of constraints) { + const arrivals = f.arrivals.filter((a) => !a.locationIds?.length || a.locationIds.includes(locationId)); + if (!arrivals.length) + return { + code: "NO_STOCK_ARRIVAL", + message: f.message || `${f.itemTitle}: no fresh stock is scheduled for this location yet`, + ruleId: f.ruleId, + }; + const ok = arrivals.some( + (a) => date >= addDays(a.date, f.minDaysAfterArrival) && date <= addDays(a.date, f.shelfLifeDays), + ); + if (!ok) { + const nextReady = arrivals + .map((a) => addDays(a.date, f.minDaysAfterArrival)) + .filter((d) => d > date) + .sort()[0]; + return nextReady + ? { + code: "NOT_FRESH_YET", + message: f.message || `${f.itemTitle}: next fresh batch is ready from ${nextReady}`, + ruleId: f.ruleId, + } + : { + code: "PAST_FRESHNESS", + message: f.message || `${f.itemTitle}: outside its freshness window on this date`, + ruleId: f.ruleId, + }; + } + } + return null; +} diff --git a/app/engine/runsheet.ts b/app/engine/runsheet.ts new file mode 100644 index 0000000..0a72134 --- /dev/null +++ b/app/engine/runsheet.ts @@ -0,0 +1,98 @@ +/** + * Driver dispatch: daily run sheet, stop ordering and route links. + * + * Ordering: by slot window first (promises come first), then nearest-neighbour + * within each window starting from the previous stop. This is a pragmatic + * heuristic, not an optimal VRP solver — export to a route optimiser for large fleets. + */ +import type { Booking, LocationConfig } from "./types"; +import { haversineKm } from "./zones"; + +export interface RunStop { + seq: number; + booking: Booking; + window: string; + legKm: number | null; +} + +export interface RunSheet { + locationId: string; + date: string; + stops: RunStop[]; + totalKm: number | null; + unlocated: Booking[]; + mapsUrls: string[]; +} + +export function buildRunSheet(bookings: Booking[], loc: LocationConfig, date: string): RunSheet { + const todays = bookings.filter( + (b) => b.method === "delivery" && b.locationId === loc.id && b.date === date && b.status === "confirmed", + ); + const located = todays.filter((b) => b.address?.lat != null && b.address?.lng != null); + const unlocated = todays.filter((b) => !located.includes(b)); + + const windows = new Map(); + for (const b of located) { + const w = `${b.slotStart}-${b.slotEnd}`; + windows.set(w, [...(windows.get(w) ?? []), b]); + } + const ordered: RunStop[] = []; + let cur = { lat: loc.lat ?? 0, lng: loc.lng ?? 0 }; + let total = 0; + const hasOrigin = loc.lat != null && loc.lng != null; + for (const w of [...windows.keys()].sort()) { + const pool = [...windows.get(w)!]; + while (pool.length) { + let best = 0; + let bestD = Infinity; + pool.forEach((b, i) => { + const d = haversineKm(cur.lat, cur.lng, b.address!.lat!, b.address!.lng!); + if (d < bestD) (bestD = d), (best = i); + }); + const [b] = pool.splice(best, 1); + const leg = hasOrigin || ordered.length ? bestD : null; + if (leg != null) total += leg; + ordered.push({ seq: ordered.length + 1, booking: b, window: w, legKm: leg }); + cur = { lat: b.address!.lat!, lng: b.address!.lng! }; + } + } + return { + locationId: loc.id, + date, + stops: ordered, + totalKm: ordered.length ? Math.round(total * 10) / 10 : null, + unlocated, + mapsUrls: mapsUrls(loc, ordered), + }; +} + +/** Google Maps directions links, chunked to the 9-waypoint limit of the URL API. */ +export function mapsUrls(loc: LocationConfig, stops: RunStop[]): string[] { + if (!stops.length) return []; + const pt = (b: Booking) => `${b.address!.lat},${b.address!.lng}`; + const origin = loc.lat != null ? `${loc.lat},${loc.lng}` : encodeURIComponent(loc.address); + const urls: string[] = []; + let from = origin; + for (let i = 0; i < stops.length; i += 10) { + const chunk = stops.slice(i, i + 10); + const dest = pt(chunk[chunk.length - 1].booking); + const way = chunk.slice(0, -1).map((s) => pt(s.booking)).join("|"); + urls.push( + `https://www.google.com/maps/dir/?api=1&origin=${from}&destination=${dest}` + + (way ? `&waypoints=${encodeURIComponent(way)}` : "") + + `&travelmode=driving`, + ); + from = dest; + } + return urls; +} + +/** RFC 4180 CSV with a UTF-8 BOM so Excel opens accents/Tamil text correctly. */ +export function toCsv(rows: Record[], columns?: string[]): string { + const cols = columns ?? Object.keys(rows[0] ?? {}); + const esc = (v: unknown) => { + const s = v == null ? "" : String(v); + return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; + }; + return "" + [cols.join(","), ...rows.map((r) => cols.map((c) => esc(r[c])).join(","))].join("\r\n"); +} diff --git a/app/engine/time.ts b/app/engine/time.ts new file mode 100644 index 0000000..fd26c95 --- /dev/null +++ b/app/engine/time.ts @@ -0,0 +1,108 @@ +/** + * Time-zone helpers built on Intl only (no dependencies), so the same code + * runs in Node, edge runtimes and the browser. + */ +import type { Weekday } from "./types"; + +const fmtCache = new Map(); +function fmt(tz: string): Intl.DateTimeFormat { + let f = fmtCache.get(tz); + if (!f) { + f = new Intl.DateTimeFormat("en-US", { + timeZone: tz, + hourCycle: "h23", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + weekday: "short", + }); + fmtCache.set(tz, f); + } + return f; +} + +const WD: Record = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 }; + +export interface LocalParts { + date: string; // YYYY-MM-DD + time: string; // HH:MM + minutes: number; // minutes since local midnight + weekday: Weekday; +} + +export function localParts(utcMs: number, tz: string): LocalParts { + const p: Record = {}; + for (const part of fmt(tz).formatToParts(new Date(utcMs))) p[part.type] = part.value; + const hour = p.hour === "24" ? "00" : p.hour; + return { + date: `${p.year}-${p.month}-${p.day}`, + time: `${hour}:${p.minute}`, + minutes: Number(hour) * 60 + Number(p.minute), + weekday: WD[p.weekday], + }; +} + +/** Offset of `tz` from UTC at instant `utcMs`, in minutes (Toronto EDT = -240). */ +export function tzOffsetMinutes(utcMs: number, tz: string): number { + const p: Record = {}; + for (const part of fmt(tz).formatToParts(new Date(utcMs))) p[part.type] = part.value; + const hour = p.hour === "24" ? 0 : Number(p.hour); + const asUtc = Date.UTC(+p.year, +p.month - 1, +p.day, hour, +p.minute, +p.second); + return Math.round((asUtc - Math.floor(utcMs / 1000) * 1000) / 60000); +} + +/** + * Convert a local wall-clock time in `tz` to a UTC instant. + * Non-existent times (spring-forward gap) resolve forward; ambiguous times + * (fall-back overlap) resolve to the first occurrence. + */ +export function zonedToUtc(date: string, time: string, tz: string): number { + const [y, m, d] = date.split("-").map(Number); + const [hh, mm] = time.split(":").map(Number); + const guess = Date.UTC(y, m - 1, d, hh, mm); + const off1 = tzOffsetMinutes(guess, tz); + let utc = guess - off1 * 60000; + const off2 = tzOffsetMinutes(utc, tz); + if (off2 !== off1) { + const alt = guess - off2 * 60000; + // Prefer the candidate that round-trips; otherwise (gap) take the later instant. + if (localParts(alt, tz).time === time) utc = Math.min(utc, alt); + else if (localParts(utc, tz).time !== time) utc = Math.max(utc, alt); + } + return utc; +} + +export function toMinutes(hhmm: string): number { + const [h, m] = hhmm.split(":").map(Number); + return h * 60 + m; +} + +export function fromMinutes(min: number): string { + const h = Math.floor(min / 60); + const m = min % 60; + return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`; +} + +export function addDays(date: string, days: number): string { + const [y, m, d] = date.split("-").map(Number); + const t = new Date(Date.UTC(y, m - 1, d + days)); + return t.toISOString().slice(0, 10); +} + +export function diffDays(a: string, b: string): number { + const [y1, m1, d1] = a.split("-").map(Number); + const [y2, m2, d2] = b.split("-").map(Number); + return Math.round((Date.UTC(y1, m1 - 1, d1) - Date.UTC(y2, m2 - 1, d2)) / 86400000); +} + +export function weekdayOf(date: string): Weekday { + const [y, m, d] = date.split("-").map(Number); + return new Date(Date.UTC(y, m - 1, d)).getUTCDay() as Weekday; +} + +export function iso(ms: number): string { + return new Date(ms).toISOString(); +} diff --git a/app/engine/types.ts b/app/engine/types.ts new file mode 100644 index 0000000..d5ae779 --- /dev/null +++ b/app/engine/types.ts @@ -0,0 +1,288 @@ +/** + * Datewise scheduling engine — shared types. + * + * Conventions + * - Calendar dates are local to the location: "YYYY-MM-DD". + * - Clock times are local wall-clock: "HH:MM" (24h). + * - Instants are UTC ISO strings or epoch milliseconds. + * - Money is integer minor units (cents) in the shop currency. + */ + +export type Method = "pickup" | "delivery" | "shipping"; +export const METHODS: Method[] = ["pickup", "delivery", "shipping"]; + +export type Weekday = 0 | 1 | 2 | 3 | 4 | 5 | 6; // 0 = Sunday + +export interface TimeRange { + start: string; // "HH:MM" + end: string; // "HH:MM" (exclusive) +} + +export interface SlotTemplate extends TimeRange { + /** Overrides MethodConfig.slotCapacity for this slot. */ + capacity?: number | null; + /** Extra fee for this slot (e.g. evening delivery), minor units. */ + surcharge?: number; +} + +export interface MethodConfig { + enabled: boolean; + /** Operating hours per weekday. Missing/empty weekday = closed. */ + weeklyHours: Partial>; + /** + * How slots are produced: + * - "generated": split operating hours into `slotMinutes` slots + * - "explicit": use `slots[weekday]` + * - "dateOnly": customer picks a date only (typical for shipping dispatch) + */ + slotMode: "generated" | "explicit" | "dateOnly"; + slotMinutes?: number; + slots?: Partial>; + /** Minimum preparation time before the slot starts. */ + prepMinutes: number; + /** "elapsed" counts wall-clock time; "operating" counts only open hours. */ + prepMode: "elapsed" | "operating"; + /** At/after this local time, same-day service is no longer bookable. */ + sameDayCutoff?: string | null; + /** At/after this local time, next-day service is no longer bookable. */ + nextDayCutoff?: string | null; + /** Days ahead customers can book (inclusive of today = day 0). */ + horizonDays: number; + dailyCapacity?: number | null; + slotCapacity?: number | null; +} + +export interface DateException { + date: string; // YYYY-MM-DD + /** Closed for the listed methods (or all when omitted). */ + closed?: boolean; + /** Replacement hours for that day (special opening). */ + hours?: TimeRange[]; + methods?: Method[]; + note?: string; +} + +export interface DeliveryZone { + id: string; + name: string; + /** Postal patterns: exact ("N2H 5Z6"), prefix ("N2H*"), bare FSA ("N2H"), or range ("N2A..N2H"). */ + postalPatterns?: string[]; + /** Straight-line radius in km from the location. */ + radiusKm?: number | null; + /** Driving-distance limit in km (requires a maps provider). */ + drivingKm?: number | null; + fee: number; + /** Order subtotal at or above which the fee is waived. */ + freeOver?: number | null; + minOrder?: number | null; +} + +export interface LocationConfig { + id: string; + shopifyLocationId?: string; + name: string; + address: string; + timezone: string; // IANA, e.g. "America/Toronto" + lat?: number; + lng?: number; + enabled: boolean; + priority: number; // lower = preferred + methods: Partial>; + exceptions: DateException[]; + zones: DeliveryZone[]; + pickupInstructions?: string; +} + +/** Peak / festival day — the "rush control" feature. */ +export interface PeakDay { + id: string; + name: string; // "Diwali", "Mother's Day" + dates: string[]; // YYYY-MM-DD + locationIds?: string[]; // all when omitted + methods?: Method[]; // all when omitted + dailyCapacity?: number | null; // replaces normal daily capacity + slotCapacity?: number | null; // replaces normal slot capacity + extraPrepMinutes?: number; + /** Earlier cutoff: bookings for the peak date close `daysBefore` days earlier at `time`. */ + cutoff?: { daysBefore: number; time: string } | null; + surcharge?: number; // per order, minor units + /** When full, offer a waitlist instead of a dead end. */ + waitlist?: boolean; + /** Customer-facing banner text. */ + message?: string; +} + +export type RuleTarget = + | { type: "product"; values: string[] } + | { type: "variant"; values: string[] } + | { type: "collection"; values: string[] } + | { type: "vendor"; values: string[] } + | { type: "productType"; values: string[] } + | { type: "tag"; values: string[] }; + +export type RuleAction = + | { type: "prohibitMethods"; methods: Method[] } + | { type: "allowOnlyMethods"; methods: Method[] } + | { type: "restrictLocations"; locationIds: string[] } + | { type: "addPrep"; minutes: number } + | { type: "blockWeekdays"; weekdays: Weekday[] } + | { type: "blockDates"; dates: string[] } + | { type: "dateWindow"; from?: string; to?: string } + /** + * Perishable stock: the item can only be fulfilled between + * (arrival + minDaysAfterArrival) and (arrival + shelfLifeDays) for some arrival. + */ + | { + type: "freshness"; + arrivals: { date: string; locationIds?: string[] }[]; + minDaysAfterArrival: number; + shelfLifeDays: number; + } + | { type: "requireScheduling" }; + +export interface ProductRule { + id: string; + name: string; + enabled: boolean; + priority: number; // lower runs first (only matters for display/explanations) + target: RuleTarget; + action: RuleAction; + /** Customer-facing explanation. */ + message?: string; +} + +export interface CartItem { + productId: string; + variantId: string; + title: string; + quantity: number; + price: number; // unit, minor units + collectionIds?: string[]; + vendor?: string; + productType?: string; + tags?: string[]; + grams?: number; +} + +export interface Cart { + items: CartItem[]; + subtotal?: number; +} + +/** Bookings + active holds, keyed by location/method/date. */ +export interface UsageSnapshot { + [key: string]: { day: number; slots: Record }; +} + +export interface ShopSettings { + /** Mixed cart prep policy. MVP: longest wins. */ + mixedPrepPolicy: "longest" | "sum"; + holdMinutes: number; + weekStartsOn: Weekday; + /** Customer self-reschedule policy. */ + reschedule: { + enabled: boolean; + /** Must be at least this many hours before the booked slot starts. */ + minHoursBefore: number; + maxChanges: number; + /** Allow switching location/method, or only date/slot. */ + allowMethodChange: boolean; + }; + reminders: ReminderPolicy[]; +} + +export interface ReminderPolicy { + id: string; + channel: "email" | "sms" | "whatsapp"; + /** Minutes before slot start. */ + minutesBefore: number; + methods?: Method[]; + template: string; + enabled: boolean; +} + +export interface Booking { + id: string; + orderId?: string; + orderName?: string; + status: "held" | "confirmed" | "cancelled" | "fulfilled" | "needs_review"; + method: Method; + locationId: string; + date: string; + slotStart?: string; + slotEnd?: string; + timezone: string; + startUtc?: string; + endUtc?: string; + customer?: { name?: string; email?: string; phone?: string }; + address?: { line1?: string; city?: string; postal?: string; lat?: number; lng?: number }; + items?: { title: string; quantity: number }[]; + rescheduleCount?: number; + peakDayId?: string | null; + fee?: number; + ruleVersion?: string; +} + +export type ReasonCode = + | "LOCATION_DISABLED" + | "METHOD_DISABLED" + | "METHOD_PROHIBITED_BY_PRODUCT" + | "LOCATION_PROHIBITED_BY_PRODUCT" + | "OUTSIDE_DELIVERY_AREA" + | "BELOW_MINIMUM_ORDER" + | "OUT_OF_STOCK_AT_LOCATION" + | "BEYOND_HORIZON" + | "CLOSED_WEEKLY" + | "CLOSED_EXCEPTION" + | "BLOCKED_BY_PRODUCT" + | "NOT_FRESH_YET" + | "PAST_FRESHNESS" + | "NO_STOCK_ARRIVAL" + | "PAST_CUTOFF" + | "PEAK_CUTOFF" + | "PREP_TIME" + | "DAY_FULL" + | "SLOT_FULL" + | "SLOT_IN_PAST"; + +export interface Reason { + code: ReasonCode; + message: string; + ruleId?: string; +} + +export interface SlotResult { + id: string; // "HH:MM-HH:MM" + start: string; + end: string; + startUtc: string; + endUtc: string; + available: boolean; + remaining: number | null; // null = unlimited + surcharge: number; + waitlist: boolean; + reasons: Reason[]; +} + +export interface DateResult { + date: string; + weekday: Weekday; + available: boolean; + remaining: number | null; + peak?: { id: string; name: string; message?: string } | null; + surcharge: number; + waitlist: boolean; + reasons: Reason[]; + slots: SlotResult[]; +} + +export interface AvailabilityResult { + locationId: string; + method: Method; + timezone: string; + dateOnly: boolean; + prepMinutes: number; + earliestReadyUtc: string; + blockers: Reason[]; // whole method/location unavailable + dates: DateResult[]; +} diff --git a/app/engine/zones.ts b/app/engine/zones.ts new file mode 100644 index 0000000..62998fc --- /dev/null +++ b/app/engine/zones.ts @@ -0,0 +1,114 @@ +/** + * Delivery areas and fees. Eligibility and pricing are deliberately separate: + * being inside a zone never implies free delivery. + */ +import type { DeliveryZone, LocationConfig, Reason } from "./types"; + +/** Uppercase, strip spaces/dashes. "n2h-5z6" → "N2H5Z6"; "02134-1234" → "021341234". */ +export function normalizePostal(postal: string): string { + return (postal || "").toUpperCase().replace(/[\s-]/g, ""); +} + +export function postalMatches(postal: string, pattern: string): boolean { + const p = normalizePostal(postal); + const raw = pattern.trim().toUpperCase(); + if (!p || !raw) return false; + // Range on a shared-length prefix: "N2A..N2H" or "10001..10010" + if (raw.includes("..")) { + const [lo, hi] = raw.split("..").map(normalizePostal); + if (!lo || lo.length !== hi.length) return false; + const head = p.slice(0, lo.length); + return head >= lo && head <= hi; + } + const pat = normalizePostal(raw); + if (pat.endsWith("*")) return p.startsWith(pat.slice(0, -1)); + return p === pat || (pat.length === 3 && p.startsWith(pat)); // bare Canadian FSA "N2H" +} + +export function haversineKm(lat1: number, lng1: number, lat2: number, lng2: number): number { + const R = 6371; + const toRad = (x: number) => (x * Math.PI) / 180; + const dLat = toRad(lat2 - lat1); + const dLng = toRad(lng2 - lng1); + const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2; + return 2 * R * Math.asin(Math.sqrt(a)); +} + +export interface CustomerAddress { + postal?: string; + lat?: number; + lng?: number; + /** Pre-computed driving distance per location id, from the maps adapter. */ + drivingKm?: Record; +} + +export interface ZoneMatch { + zone: DeliveryZone | null; + distanceKm: number | null; + reason?: Reason; + /** True when a zone needed a maps lookup that wasn't available. */ + unverified?: boolean; +} + +/** + * First matching zone wins (merchant orders zones, typically cheapest/closest first). + * A zone matches when ANY of its configured criteria match. + * If a driving-distance zone can't be checked (maps outage), it does NOT match — + * we never accept unverified addresses. + */ +export function matchZone(loc: LocationConfig, addr: CustomerAddress): ZoneMatch { + const radial = + loc.lat != null && loc.lng != null && addr.lat != null && addr.lng != null + ? haversineKm(loc.lat, loc.lng, addr.lat, addr.lng) + : null; + let unverified = false; + for (const z of loc.zones) { + if (z.postalPatterns?.length && addr.postal && z.postalPatterns.some((pt) => postalMatches(addr.postal!, pt))) + return { zone: z, distanceKm: radial }; + if (z.radiusKm != null && radial != null && radial <= z.radiusKm) return { zone: z, distanceKm: radial }; + if (z.drivingKm != null) { + const d = addr.drivingKm?.[loc.id]; + if (d == null) unverified = true; + else if (d <= z.drivingKm) return { zone: z, distanceKm: d }; + } + } + return { + zone: null, + distanceKm: radial, + unverified, + reason: { + code: "OUTSIDE_DELIVERY_AREA", + message: unverified + ? "We couldn't verify your address right now. Please try pickup or check again shortly." + : `${loc.name} doesn't deliver to this address yet`, + }, + }; +} + +export interface FeeQuote { + fee: number; + baseFee: number; + waived: boolean; + surcharges: { label: string; amount: number }[]; + belowMinimum: boolean; + minOrder: number | null; +} + +export function quoteFee( + zone: DeliveryZone | null, + subtotal: number, + extras: { label: string; amount: number }[] = [], +): FeeQuote { + const base = zone?.fee ?? 0; + const waived = zone?.freeOver != null && subtotal >= zone.freeOver; + const surcharges = extras.filter((e) => e.amount > 0); + const minOrder = zone?.minOrder ?? null; + return { + baseFee: base, + waived, + surcharges, + fee: (waived ? 0 : base) + surcharges.reduce((s, e) => s + e.amount, 0), + belowMinimum: minOrder != null && subtotal < minOrder, + minOrder, + }; +} diff --git a/app/entry.server.tsx b/app/entry.server.tsx new file mode 100644 index 0000000..a2b471b --- /dev/null +++ b/app/entry.server.tsx @@ -0,0 +1,39 @@ +import { PassThrough } from "node:stream"; +import { createReadableStreamFromReadable } from "@react-router/node"; +import { isbot } from "isbot"; +import { renderToPipeableStream } from "react-dom/server"; +import { type EntryContext, ServerRouter } from "react-router"; +import { addDocumentResponseHeaders } from "./shopify.server"; + +export const streamTimeout = 5000; + +export default async function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + reactRouterContext: EntryContext, +) { + addDocumentResponseHeaders(request, responseHeaders); + const userAgent = request.headers.get("user-agent"); + const callbackName = isbot(userAgent ?? "") ? "onAllReady" : "onShellReady"; + + return new Promise((resolve, reject) => { + const { pipe, abort } = renderToPipeableStream(, { + [callbackName]: () => { + const body = new PassThrough(); + const stream = createReadableStreamFromReadable(body); + responseHeaders.set("Content-Type", "text/html"); + resolve(new Response(stream, { headers: responseHeaders, status: responseStatusCode })); + pipe(body); + }, + onShellError(error) { + reject(error); + }, + onError(error) { + responseStatusCode = 500; + console.error(error); + }, + }); + setTimeout(abort, streamTimeout + 1000); + }); +} diff --git a/app/jobs/worker.ts b/app/jobs/worker.ts new file mode 100644 index 0000000..c8703ed --- /dev/null +++ b/app/jobs/worker.ts @@ -0,0 +1,110 @@ +/** + * Background worker — run as a separate process (`npm run worker`), one or more instances. + * + * Every minute: + * - release expired holds (capacity returns even if no shopper touches that date) + * - send due reminders (claimed row-by-row, safe with several workers) + * Every 15 minutes: + * - reconcile: recent orders that carry a selection but have no booking (missed/failed + * webhooks) are replayed through the same handler, so nothing is silently lost. + */ +import prisma from "../db.server"; +import { handleOrderCreate, type OrderPayload } from "../services/orders.server"; +import { runDueReminders } from "../services/reminders.server"; +import { ReservationService } from "../services/reservations.server"; +import { SELECTION_ATTR } from "../services/settings"; +import shopify from "../shopify.server"; + +const reservations = new ReservationService(prisma); +let stopping = false; + +async function tick() { + const now = new Date(); + const released = await reservations.sweepExpired(now).catch((e) => (console.error("sweep", e), 0)); + const sent = await runDueReminders(now).catch((e) => (console.error("reminders", e), 0)); + if (released || sent) console.info(`[worker] released ${released} holds, sent ${sent} reminders`); +} + +async function reconcile() { + const shops = await prisma.shop.findMany({ where: { uninstalledAt: null }, select: { id: true, domain: true } }); + for (const s of shops) { + try { + const { admin } = await shopify.unauthenticated.admin(s.domain); + const since = new Date(Date.now() - 6 * 3600_000).toISOString(); + const res = await admin.graphql( + `#graphql + query($q: String!) { + orders(first: 100, query: $q, sortKey: CREATED_AT, reverse: true) { + nodes { id legacyResourceId name email phone customAttributes { key value } + customer { firstName lastName defaultEmailAddress { emailAddress } defaultPhoneNumber { phoneNumber } } + shippingAddress { address1 address2 city zip latitude longitude phone firstName lastName } + lineItems(first: 100) { nodes { title quantity originalUnitPriceSet { shopMoney { amount } } product { legacyResourceId } variant { legacyResourceId } } } + } + } + }`, + { variables: { q: `created_at:>='${since}'` } }, + ); + const orders = ((await res.json()) as { data: { orders: { nodes: GqlOrder[] } } }).data.orders.nodes; + const ids = orders.map((o) => o.id); + const have = new Set((await prisma.booking.findMany({ where: { shopId: s.id, orderId: { in: ids } }, select: { orderId: true } })).map((b) => b.orderId)); + for (const o of orders) { + if (have.has(o.id) || !o.customAttributes.some((a) => a.key === SELECTION_ATTR)) continue; + console.warn(`[reconcile] ${s.domain} ${o.name} had no booking; replaying`); + await handleOrderCreate(s.domain, toPayload(o), admin); + } + } catch (e) { + console.error(`[reconcile] ${s.domain}`, e); + } + } +} + +interface GqlOrder { + id: string; + legacyResourceId: string; + name: string; + email: string | null; + phone: string | null; + customAttributes: { key: string; value: string }[]; + customer: { firstName: string | null; lastName: string | null; defaultEmailAddress: { emailAddress: string } | null; defaultPhoneNumber: { phoneNumber: string } | null } | null; + shippingAddress: { address1: string; address2: string; city: string; zip: string; latitude: number | null; longitude: number | null; phone: string | null; firstName: string; lastName: string } | null; + lineItems: { nodes: { title: string; quantity: number; originalUnitPriceSet: { shopMoney: { amount: string } }; product: { legacyResourceId: string } | null; variant: { legacyResourceId: string } | null }[] }; +} + +function toPayload(o: GqlOrder): OrderPayload { + const a = o.shippingAddress; + return { + id: Number(o.legacyResourceId), + admin_graphql_api_id: o.id, + name: o.name, + email: o.email, + phone: o.phone, + note_attributes: o.customAttributes.map((x) => ({ name: x.key, value: x.value })), + customer: o.customer ? { first_name: o.customer.firstName, last_name: o.customer.lastName, email: o.customer.defaultEmailAddress?.emailAddress ?? null, phone: o.customer.defaultPhoneNumber?.phoneNumber ?? null } : null, + shipping_address: a ? { address1: a.address1, address2: a.address2, city: a.city, zip: a.zip, latitude: a.latitude, longitude: a.longitude, phone: a.phone, first_name: a.firstName, last_name: a.lastName } : null, + line_items: o.lineItems.nodes.map((l) => ({ + title: l.title, + quantity: l.quantity, + price: l.originalUnitPriceSet.shopMoney.amount, + product_id: l.product ? Number(l.product.legacyResourceId) : null, + variant_id: l.variant ? Number(l.variant.legacyResourceId) : null, + })), + }; +} + +async function main() { + console.info("[worker] started"); + let n = 0; + while (!stopping) { + const started = Date.now(); + await tick(); + if (n++ % 15 === 0) await reconcile(); + await new Promise((r) => setTimeout(r, Math.max(1000, 60_000 - (Date.now() - started)))); + } +} + +process.on("SIGTERM", () => (stopping = true)); +process.on("SIGINT", () => (stopping = true)); +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/app/lib/admin.server.ts b/app/lib/admin.server.ts new file mode 100644 index 0000000..b654983 --- /dev/null +++ b/app/lib/admin.server.ts @@ -0,0 +1,47 @@ +import { authenticate } from "../shopify.server"; +import { loadShopConfig } from "../services/config.server"; +import { features } from "../services/plans"; + +/** Authenticate an embedded admin request and load the shop's config + plan features. */ +export async function adminContext(request: Request, opts: { fresh?: boolean } = {}) { + const ctx = await authenticate.admin(request); + const cfg = await loadShopConfig(ctx.session.shop, opts); + return { ...ctx, cfg, features: features(cfg.shop.plan), actor: `merchant:${ctx.session.onlineAccessInfo?.associated_user?.email ?? "staff"}` }; +} + +export function formString(fd: FormData, k: string): string { + const v = fd.get(k); + return typeof v === "string" ? v.trim() : ""; +} +export function formInt(fd: FormData, k: string): number | null { + const s = formString(fd, k); + if (s === "") return null; + const n = Number(s); + return Number.isFinite(n) ? Math.round(n) : null; +} +export function formMoney(fd: FormData, k: string): number | null { + const s = formString(fd, k); + if (s === "") return null; + const n = Number(s.replace(/[$,]/g, "")); + return Number.isFinite(n) ? Math.round(n * 100) : null; +} +export const formBool = (fd: FormData, k: string) => ["on", "true", "1"].includes(formString(fd, k)); +export const formList = (fd: FormData, k: string) => + formString(fd, k) + .split(/[,\n]/) + .map((s) => s.trim()) + .filter(Boolean); + +/** "10:00-13:00, 14:00-18:00" → ranges; throws on bad input. */ +export function parseRanges(s: string): { start: string; end: string }[] { + if (!s.trim()) return []; + return s.split(",").map((part) => { + const m = part.trim().match(/^(\d{1,2}):(\d{2})\s*[-–]\s*(\d{1,2}):(\d{2})$/); + if (!m) throw new Error(`"${part.trim()}" isn't a time range like 10:00-13:00`); + const pad = (h: string) => h.padStart(2, "0"); + const start = `${pad(m[1])}:${m[2]}`; + const end = `${pad(m[3])}:${m[4]}`; + if (start >= end) throw new Error(`${start}–${end}: end must be after start`); + return { start, end }; + }); +} diff --git a/app/lib/bookings-query.server.ts b/app/lib/bookings-query.server.ts new file mode 100644 index 0000000..ad6998a --- /dev/null +++ b/app/lib/bookings-query.server.ts @@ -0,0 +1,20 @@ +import type { Prisma } from "@prisma/client"; + +/** Booking list filters shared by the list page and the CSV export. */ +export function bookingWhere(shopId: string, sp: URLSearchParams, today: string): Prisma.BookingWhereInput { + const status = sp.get("status") ?? "upcoming"; + const where: Prisma.BookingWhereInput = { shopId }; + if (status === "review") where.OR = [{ status: "needs_review" }, { status: "confirmed", reviewReason: { not: null } }]; + else if (status === "upcoming") Object.assign(where, { status: "confirmed", date: { gte: today } }); + else if (status !== "all") where.status = status; + const method = sp.get("method"); + if (method && method !== "all") where.method = method; + const loc = sp.get("location"); + if (loc && loc !== "all") where.locationId = loc; + const from = sp.get("from"); + const to = sp.get("to"); + if (from || to) where.date = { ...(from ? { gte: from } : {}), ...(to ? { lte: to } : {}) }; + const q = sp.get("q")?.trim(); + if (q) where.AND = [{ OR: [{ orderName: { contains: q, mode: "insensitive" } }, { customerName: { contains: q, mode: "insensitive" } }, { customerEmail: { contains: q, mode: "insensitive" } }] }]; + return where; +} diff --git a/app/lib/format.ts b/app/lib/format.ts new file mode 100644 index 0000000..04b9384 --- /dev/null +++ b/app/lib/format.ts @@ -0,0 +1,32 @@ +/** Shared display helpers for admin screens (browser-safe). */ +export const money = (cents: number, currency = "CAD") => + new Intl.NumberFormat("en-CA", { style: "currency", currency }).format(cents / 100); + +export const niceDate = (d: string | null | undefined, long = false) => + d + ? new Date(`${d}T12:00:00Z`).toLocaleDateString("en-CA", { + weekday: long ? "long" : "short", + month: long ? "long" : "short", + day: "numeric", + timeZone: "UTC", + }) + : "—"; + +export const t12 = (hhmm?: string | null) => { + if (!hhmm) return ""; + const [h, m] = hhmm.split(":").map(Number); + return `${h % 12 || 12}${m ? `:${String(m).padStart(2, "0")}` : ""} ${h >= 12 ? "PM" : "AM"}`; +}; + +export const slotLabel = (s?: string | null, e?: string | null) => (s && e ? `${t12(s)} – ${t12(e)}` : "—"); + +export const STATUS_TONE: Record = { + confirmed: "success", + needs_review: "critical", + cancelled: "neutral", + fulfilled: "info", +}; + +export const METHOD_LABEL: Record = { pickup: "Pickup", delivery: "Delivery", shipping: "Shipping" }; + +export const rangesToText = (r?: { start: string; end: string }[]) => (r ?? []).map((x) => `${x.start}-${x.end}`).join(", "); diff --git a/app/lib/proxy.server.ts b/app/lib/proxy.server.ts new file mode 100644 index 0000000..a66599e --- /dev/null +++ b/app/lib/proxy.server.ts @@ -0,0 +1,47 @@ +import { ZodError } from "zod"; +import { authenticate } from "../shopify.server"; + +/** Simple per-instance token bucket (use Redis/edge rate limiting when running several instances). */ +const buckets = new Map(); +export function rateLimit(key: string, perMinute: number): boolean { + const now = Date.now(); + const b = buckets.get(key) ?? { tokens: perMinute, at: now }; + b.tokens = Math.min(perMinute, b.tokens + ((now - b.at) / 60000) * perMinute); + b.at = now; + if (b.tokens < 1) { + buckets.set(key, b); + return false; + } + b.tokens -= 1; + buckets.set(key, b); + if (buckets.size > 50000) buckets.clear(); + return true; +} + +/** + * Wrap an app-proxy handler: verifies Shopify's HMAC signature, rate limits, + * parses JSON and turns validation errors into 400s. + */ +export async function proxyHandler( + request: Request, + limitPerMinute: number, + fn: (ctx: { shop: string; admin?: Awaited>["admin"]; body: unknown; url: URL }) => Promise, +): Promise { + const ctx = await authenticate.public.appProxy(request); + const url = new URL(request.url); + const shop = ctx.session?.shop ?? url.searchParams.get("shop") ?? ""; + if (!ctx.session) return Response.json({ ok: false, message: "App not installed" }, { status: 404 }); + const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "anon"; + if (!rateLimit(`${shop}|${ip}|${url.pathname}`, limitPerMinute)) + return Response.json({ ok: false, message: "Too many requests. Please wait a moment." }, { status: 429 }); + try { + const body = request.method === "GET" ? Object.fromEntries(url.searchParams) : await request.json().catch(() => ({})); + const out = await fn({ shop, admin: ctx.admin, body, url }); + return Response.json(out, { headers: { "Cache-Control": "no-store" } }); + } catch (e) { + if (e instanceof ZodError) return Response.json({ ok: false, message: "Invalid request", issues: e.issues.slice(0, 5) }, { status: 400 }); + if (e instanceof Response) return e; + console.error("proxy error", e); + return Response.json({ ok: false, message: "Something went wrong. Please try again." }, { status: 500 }); + } +} diff --git a/app/root.tsx b/app/root.tsx new file mode 100644 index 0000000..10ec772 --- /dev/null +++ b/app/root.tsx @@ -0,0 +1,20 @@ +import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router"; + +export default function App() { + return ( + + + + + + + + + + + + + + + ); +} diff --git a/app/routes.ts b/app/routes.ts new file mode 100644 index 0000000..29b7533 --- /dev/null +++ b/app/routes.ts @@ -0,0 +1,4 @@ +import type { RouteConfig } from "@react-router/dev/routes"; +import { flatRoutes } from "@react-router/fs-routes"; + +export default flatRoutes({ ignoredRouteFiles: ["**/__tests__/**"] }) satisfies RouteConfig; diff --git a/app/routes/_index.tsx b/app/routes/_index.tsx new file mode 100644 index 0000000..27a32da --- /dev/null +++ b/app/routes/_index.tsx @@ -0,0 +1,8 @@ +import type { LoaderFunctionArgs } from "react-router"; +import { redirect } from "react-router"; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const url = new URL(request.url); + if (url.searchParams.get("shop")) throw redirect(`/app?${url.searchParams.toString()}`); + throw redirect("/auth/login"); +}; diff --git a/app/routes/api.customer.booking.tsx b/app/routes/api.customer.booking.tsx new file mode 100644 index 0000000..46ece48 --- /dev/null +++ b/app/routes/api.customer.booking.tsx @@ -0,0 +1,66 @@ +import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router"; +import { z } from "zod"; +import prisma from "../db.server"; +import { canCustomerReschedule } from "../engine/customer"; +import type { Method } from "../engine/types"; +import { authenticate, unauthenticated } from "../shopify.server"; +import { rescheduleBooking, rescheduleOptions } from "../services/bookings.server"; +import { loadShopConfig } from "../services/config.server"; +import { rowToBooking } from "../services/reminders.server"; + +/** + * Customer account / order status extension API. + * The session token proves which shop and which customer is calling; we additionally + * check that the order belongs to that customer before showing or changing anything. + */ +async function load(request: Request, orderId: string) { + const { sessionToken, cors } = await authenticate.public.customerAccount(request); + // dest is the bare shop domain for customer-account tokens; tolerate a URL too. + const shop = sessionToken.dest.startsWith("http") ? new URL(sessionToken.dest).hostname : sessionToken.dest; + const cfg = await loadShopConfig(shop); + const b = await prisma.booking.findUnique({ where: { shopId_orderId: { shopId: cfg.shop.id, orderId } } }); + // sessionToken.sub is the customer gid; verify via the Admin API that the order is theirs. + if (b) { + const { admin } = await unauthenticated.admin(shop); + const r = await admin.graphql(`#graphql + query($id: ID!) { order(id: $id) { customer { id } } }`, { variables: { id: orderId } }); + const owner = ((await r.json()) as { data: { order: { customer: { id: string } | null } | null } }).data.order?.customer?.id; + if (!owner || owner !== sessionToken.sub) return { cors, cfg, b: null }; + } + return { cors, cfg, b }; +} + +export const loader = async ({ request }: LoaderFunctionArgs) => { + if (request.method === "OPTIONS") return (await authenticate.public.customerAccount(request)).cors(new Response(null, { status: 204 })); + const orderId = new URL(request.url).searchParams.get("orderId") ?? ""; + const { cors, cfg, b } = await load(request, orderId); + if (!b) return cors(Response.json({ booking: null })); + const can = canCustomerReschedule(rowToBooking(b), cfg.settings.reschedule, Date.now()); + const loc = cfg.locations.find((l) => l.id === b.locationId); + const options = can.ok ? await rescheduleOptions(cfg.shop.domain, b.id, { customer: true }) : []; + return cors( + Response.json({ + booking: { id: b.id, status: b.status, method: b.method, date: b.date, slotStart: b.slotStart, slotEnd: b.slotEnd, location: loc?.name ?? null, address: loc?.address ?? null, instructions: b.method === "pickup" ? loc?.pickupInstructions ?? null : null }, + canReschedule: can.ok, + reason: can.ok ? null : can.reason, + deadline: can.ok ? new Date(can.deadline).toISOString() : null, + options: options.slice(0, 120), + }), + ); +}; + +const Body = z.object({ orderId: z.string(), date: z.string(), slot: z.string() }); + +export const action = async ({ request }: ActionFunctionArgs) => { + const parsed = Body.safeParse(await request.json().catch(() => ({}))); + const orderId = parsed.success ? parsed.data.orderId : ""; + const { cors, cfg, b } = await load(request, orderId); + if (!parsed.success || !b) return cors(Response.json({ ok: false, message: "Booking not found" }, { status: 404 })); + const r = await rescheduleBooking({ + domain: cfg.shop.domain, + bookingId: b.id, + sel: { locationId: b.locationId!, method: b.method as Method, date: parsed.data.date, slot: parsed.data.slot }, + actor: "customer", + }); + return cors(Response.json(r, { status: r.ok ? 200 : 409 })); +}; diff --git a/app/routes/app._index.tsx b/app/routes/app._index.tsx new file mode 100644 index 0000000..b25535c --- /dev/null +++ b/app/routes/app._index.tsx @@ -0,0 +1,141 @@ +import type { LoaderFunctionArgs } from "react-router"; +import { useLoaderData } from "react-router"; +import prisma from "../db.server"; +import { capsFor } from "../engine/capacity"; +import { hoursFor } from "../engine/availability"; +import { addDays, localParts } from "../engine/time"; +import { METHODS } from "../engine/types"; +import { adminContext } from "../lib/admin.server"; +import { METHOD_LABEL, niceDate } from "../lib/format"; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const { cfg, features } = await adminContext(request); + const shopId = cfg.shop.id; + const today = localParts(Date.now(), cfg.shop.timezone).date; + const end = addDays(today, 13); + const [todayCount, weekCount, review, waiting, counters, monthCount] = await Promise.all([ + prisma.booking.count({ where: { shopId, date: today, status: "confirmed" } }), + prisma.booking.count({ where: { shopId, date: { gt: today, lte: addDays(today, 7) }, status: "confirmed" } }), + prisma.booking.count({ where: { shopId, OR: [{ status: "needs_review" }, { status: "confirmed", reviewReason: { not: null } }] } }), + prisma.waitlistEntry.count({ where: { shopId, offeredAt: null, date: { gte: today } } }), + prisma.capacityCounter.findMany({ where: { shopId, slot: "*", date: { gte: today, lte: end } } }), + prisma.booking.count({ where: { shopId, createdAt: { gte: new Date(`${today.slice(0, 7)}-01T00:00:00Z`) } } }), + ]); + + const rows = cfg.locations + .filter((l) => l.enabled) + .flatMap((l) => + METHODS.filter((m) => l.methods[m]?.enabled && m !== "shipping").map((m) => ({ + key: `${l.id}|${m}`, + label: `${l.name} · ${METHOD_LABEL[m]}`, + days: Array.from({ length: 14 }, (_, i) => { + const date = addDays(today, i); + const open = hoursFor(l, m, date).hours.length > 0; + const cap = capsFor(l, m, date, "00:00-00:00", features.peakDays ? cfg.peakDays : []).dayCap; + const used = counters.find((c) => c.locationId === l.id && c.method === m && c.date === date)?.count ?? 0; + const peak = cfg.peakDays.find((p) => p.dates.includes(date) && (!p.locationIds?.length || p.locationIds.includes(l.id))); + return { date, open, cap, used, peak: peak?.name ?? null }; + }), + })), + ); + const peaks = cfg.peakDays + .flatMap((p) => p.dates.filter((d) => d >= today).map((d) => ({ name: p.name, date: d, cutoff: p.cutoff }))) + .sort((a, b) => (a.date < b.date ? -1 : 1)) + .slice(0, 3); + const setup = { + anyLocation: cfg.locations.some((l) => l.enabled), + feeProduct: !!cfg.shop.feeProductId, + }; + return { + today, todayCount, weekCount, review, waiting, rows, peaks, setup, + plan: cfg.shop.plan, monthCount, monthlyLimit: features.monthlyBookings, + themeEditor: `https://${cfg.shop.domain}/admin/themes/current/editor?context=apps&activateAppId=${process.env.SHOPIFY_API_KEY}/datewise-embed`, + }; +}; + +export default function Dashboard() { + const d = useLoaderData(); + const overLimit = d.monthlyLimit != null && d.monthCount >= d.monthlyLimit; + return ( + + View bookings + {!d.setup.anyLocation && ( + + + 1. Review hours and turn on a location in Locations & hours. 2. Add the Datewise + block to your cart page and switch on the app embed in the theme editor. + 3. Turn on the checkout guard in Settings. + + + )} + {overLimit && ( + + + New orders are still recorded, so no customer is turned away. Upgrade to keep reminders and capacity planning running. + + + )} + + + + + 0} /> + + + + + {d.rows.length === 0 && No pickup or delivery locations are switched on yet.} + {d.rows.map((r) => ( + + {r.label} +
+ {r.days.map((x) => { + const pct = x.cap ? Math.min(100, Math.round((100 * x.used) / x.cap)) : 0; + const full = x.cap != null && x.used >= x.cap; + return ( +
+
+ {x.open &&
} +
+ {x.open ? (x.cap != null ? `${x.used}/${x.cap}` : x.used) : "—"} + {x.date.slice(8)} +
+ ); + })} +
+ + ))} + + + {d.peaks.length === 0 ? ( + + No peak days set. Add Diwali, Mother's Day or Valentine's in Rush control to cap orders, close earlier and open a waitlist. + + ) : ( + + {d.peaks.map((p) => ( + + {p.name}, {niceDate(p.date, true)} + {p.cutoff ? `. Orders close ${p.cutoff.daysBefore} day(s) before at ${p.cutoff.time}.` : "."} + + ))} + + )} + + + ); +} + +function Kpi({ label, value, href, alert }: { label: string; value: number; href?: string; alert?: boolean }) { + const body = ( + + + {label} + + {value} + + + + ); + return href ? {body} : body; +} diff --git a/app/routes/app.bookings.$id.tsx b/app/routes/app.bookings.$id.tsx new file mode 100644 index 0000000..b704995 --- /dev/null +++ b/app/routes/app.bookings.$id.tsx @@ -0,0 +1,152 @@ +import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router"; +import { Form, useActionData, useLoaderData, useNavigation } from "react-router"; +import prisma from "../db.server"; +import { adminContext, formBool, formString } from "../lib/admin.server"; +import { METHOD_LABEL, STATUS_TONE, money, niceDate, slotLabel } from "../lib/format"; +import { rescheduleBooking, rescheduleOptions } from "../services/bookings.server"; +import type { Method } from "../engine/types"; + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const { cfg } = await adminContext(request); + const b = await prisma.booking.findFirst({ + where: { id: params.id, shopId: cfg.shop.id }, + include: { events: { orderBy: { at: "desc" } }, reminderJobs: { orderBy: { sendAt: "asc" } } }, + }); + if (!b) throw new Response("Not found", { status: 404 }); + const options = ["confirmed", "needs_review"].includes(b.status) ? await rescheduleOptions(cfg.shop.domain, b.id, { customer: false }) : []; + return { + b: { + ...b, + startUtc: b.startUtc?.toISOString() ?? null, + createdAt: b.createdAt.toISOString(), + events: b.events.map((e) => ({ ...e, at: e.at.toISOString() })), + reminderJobs: b.reminderJobs.map((j) => ({ id: j.id, channel: j.channel, status: j.status, sendAt: j.sendAt.toISOString(), lastError: j.lastError })), + }, + location: cfg.locations.find((l) => l.id === b.locationId)?.name ?? null, + options: options.slice(0, 400), + currency: cfg.shop.currency, + orderUrl: b.orderId ? `shopify://admin/orders/${b.orderId.split("/").pop()}` : null, + }; +}; + +export const action = async ({ request, params }: ActionFunctionArgs) => { + const { cfg, actor } = await adminContext(request); + const fd = await request.formData(); + const intent = formString(fd, "intent"); + if (intent === "dismiss") { + await prisma.booking.updateMany({ where: { id: params.id, shopId: cfg.shop.id, status: "confirmed" }, data: { reviewReason: null } }); + return { ok: true, message: "Marked as checked" }; + } + const [locationId, method, date, slot] = formString(fd, "target").split("|"); + if (!slot) return { ok: false, message: "Choose a new time" }; + const r = await rescheduleBooking({ + domain: cfg.shop.domain, + bookingId: params.id!, + sel: { locationId, method: method as Method, date, slot }, + actor, + force: formBool(fd, "force"), + }); + return r.ok ? { ok: true, message: "Booking moved. Reminders were re-planned." } : { ok: false, message: r.message }; +}; + +export default function BookingDetail() { + const { b, location, options, currency, orderUrl } = useLoaderData(); + const result = useActionData(); + const busy = useNavigation().state !== "idle"; + const addr = (b.address ?? {}) as { line1?: string; city?: string; postal?: string }; + const items = (b.items ?? []) as { title: string; quantity: number }[]; + return ( + + {orderUrl && Open order} + {result && {result.message}} + {b.status === "needs_review" && ( + + {b.reviewReason}. Pick a time below to confirm it, or tick "allow over capacity" if you can fit it in. + + )} + {b.status === "confirmed" && b.reviewReason && ( + + + {b.reviewReason} +
+ + Mark as checked +
+
+
+ )} + + + {b.status.replace("_", " ")} + {METHOD_LABEL[b.method]} + {location ?? "—"} + {niceDate(b.date, true)} + {b.method === "shipping" ? "Dispatch day" : slotLabel(b.slotStart, b.slotEnd)} + {money(b.fee, currency)} + {[b.customerName, b.customerEmail, b.customerPhone].filter(Boolean).join(" · ") || "—"} + {[addr.line1, addr.city, addr.postal].filter(Boolean).join(", ") || "—"} + {items.map((i) => `${i.quantity}× ${i.title}`).join(", ") || "—"} + v{b.configVersion} + + + {options.length > 0 && ( + +
+ + + {options.map((o) => ( + + {`${niceDate(o.date)} · ${o.method === "shipping" ? "dispatch" : slotLabel(o.start, o.end)} · ${o.full ? "full" : o.remaining == null ? "open" : `${o.remaining} left`}`} + + ))} + + + The current place is released only if the new one saves. The customer isn't messaged automatically. + Save new time + +
+
+ )} + + {b.reminderJobs.length === 0 ? ( + No reminders planned. + ) : ( + + {b.reminderJobs.map((j) => ( + + {j.channel} · {new Date(j.sendAt).toLocaleString("en-CA")} · {j.status} + {j.lastError ? ` (${j.lastError})` : ""} + + ))} + + )} + + + + {b.events.map((e) => ( + + {new Date(e.at).toLocaleString("en-CA")} · {e.actor} · {e.action} + {e.before ? ` · from ${describe(e.before)}` : ""} + {e.after ? ` · to ${describe(e.after)}` : ""} + + ))} + + +
+ ); +} + +function describe(v: unknown) { + const o = v as Record; + if (o.date) return `${o.date} ${o.slot ?? ""}`.trim() + (o.forced ? " (over capacity)" : ""); + return Object.entries(o).map(([k, x]) => `${k}: ${String(x)}`).join(", "); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( + + {label} + {children} + + ); +} diff --git a/app/routes/app.bookings._index.tsx b/app/routes/app.bookings._index.tsx new file mode 100644 index 0000000..3ffbafc --- /dev/null +++ b/app/routes/app.bookings._index.tsx @@ -0,0 +1,127 @@ +import type { LoaderFunctionArgs } from "react-router"; +import { Form, useLoaderData, useSearchParams } from "react-router"; +import prisma from "../db.server"; +import { localParts } from "../engine/time"; +import { adminContext } from "../lib/admin.server"; +import { bookingWhere } from "../lib/bookings-query.server"; +import { METHOD_LABEL, STATUS_TONE, niceDate, slotLabel } from "../lib/format"; + + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const { cfg } = await adminContext(request); + const sp = new URL(request.url).searchParams; + const today = localParts(Date.now(), cfg.shop.timezone).date; + const page = Math.max(1, Number(sp.get("page") ?? 1)); + const where = bookingWhere(cfg.shop.id, sp, today); + const [rows, total] = await Promise.all([ + prisma.booking.findMany({ + where, + orderBy: [{ status: "desc" }, { date: "asc" }, { slotStart: "asc" }], + take: 50, + skip: (page - 1) * 50, + }), + prisma.booking.count({ where }), + ]); + return { + rows: rows.map((b) => ({ ...b, startUtc: b.startUtc?.toISOString() ?? null, endUtc: b.endUtc?.toISOString() ?? null, createdAt: b.createdAt.toISOString(), updatedAt: b.updatedAt.toISOString() })), + total, + page, + locations: cfg.locations.map((l) => ({ id: l.id, name: l.name })), + shop: cfg.shop.domain, + }; +}; + +export default function Bookings() { + const { rows, total, page, locations } = useLoaderData(); + const [sp] = useSearchParams(); + const locName = (id: string | null) => locations.find((l) => l.id === id)?.name ?? "—"; + const exportHref = `/app/bookings/export?${sp.toString()}`; + return ( + + Export CSV + +
+ + + Upcoming + Needs review + Confirmed + Fulfilled + Cancelled + All + + + All methods + Delivery + Pickup + Shipping + + + All locations + {locations.map((l) => ( + {l.name} + ))} + + + + + Apply + +
+
+ + 1}> + + Order + Date + Time + Method + Location + Customer + Status + + + {rows.map((b) => ( + + + {b.orderName ?? "No order"} + + {niceDate(b.date)} + {b.method === "shipping" ? "Dispatch" : slotLabel(b.slotStart, b.slotEnd)} + + {METHOD_LABEL[b.method]} {b.peakDayId && Peak} + + {locName(b.locationId)} + {b.customerName ?? b.customerEmail ?? "—"} + + + {b.status.replace("_", " ")} + {b.reviewReason && {b.reviewReason}} + + + + ))} + + + {rows.length === 0 && ( + + No bookings match these filters. + + )} + + + {page > 1 && Previous} + {total} bookings · page {page} + {page * 50 < total && Next} + + + +
+ ); +} + +function withPage(sp: URLSearchParams, p: number) { + const n = new URLSearchParams(sp); + n.set("page", String(p)); + return n.toString(); +} diff --git a/app/routes/app.bookings.export.tsx b/app/routes/app.bookings.export.tsx new file mode 100644 index 0000000..ec333ed --- /dev/null +++ b/app/routes/app.bookings.export.tsx @@ -0,0 +1,51 @@ +import type { LoaderFunctionArgs } from "react-router"; +import prisma from "../db.server"; +import { toCsv } from "../engine/runsheet"; +import { localParts } from "../engine/time"; +import { adminContext } from "../lib/admin.server"; +import { bookingWhere } from "../lib/bookings-query.server"; + +/** Excel-friendly CSV (UTF-8 BOM) of the bookings matching the list filters. */ +export const loader = async ({ request }: LoaderFunctionArgs) => { + const { cfg } = await adminContext(request); + const sp = new URL(request.url).searchParams; + const today = localParts(Date.now(), cfg.shop.timezone).date; + const rows = await prisma.booking.findMany({ + where: bookingWhere(cfg.shop.id, sp, today), + orderBy: [{ date: "asc" }, { slotStart: "asc" }], + take: 20000, + }); + const loc = Object.fromEntries(cfg.locations.map((l) => [l.id, l.name])); + const csv = toCsv( + rows.map((b) => { + const a = (b.address ?? {}) as { line1?: string; city?: string; postal?: string }; + return { + order: b.orderName ?? "", + status: b.status, + review_note: b.reviewReason ?? "", + method: b.method, + location: b.locationId ? loc[b.locationId] ?? b.locationId : "", + date: b.date ?? "", + slot_start: b.slotStart ?? "", + slot_end: b.slotEnd ?? "", + timezone: b.timezone, + start_utc: b.startUtc?.toISOString() ?? "", + end_utc: b.endUtc?.toISOString() ?? "", + customer: b.customerName ?? "", + email: b.customerEmail ?? "", + phone: b.customerPhone ?? "", + address: [a.line1, a.city, a.postal].filter(Boolean).join(", "), + items: ((b.items ?? []) as { title: string; quantity: number }[]).map((i) => `${i.quantity}x ${i.title}`).join("; "), + fee: (b.fee / 100).toFixed(2), + rescheduled: b.rescheduleCount, + }; + }), + ["order", "status", "review_note", "method", "location", "date", "slot_start", "slot_end", "timezone", "start_utc", "end_utc", "customer", "email", "phone", "address", "items", "fee", "rescheduled"], + ); + return new Response(csv, { + headers: { + "Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": `attachment; filename="datewise-bookings-${today}.csv"`, + }, + }); +}; diff --git a/app/routes/app.diagnostics.tsx b/app/routes/app.diagnostics.tsx new file mode 100644 index 0000000..c069950 --- /dev/null +++ b/app/routes/app.diagnostics.tsx @@ -0,0 +1,105 @@ +import { useState } from "react"; +import type { LoaderFunctionArgs } from "react-router"; +import { Form, useLoaderData } from "react-router"; +import { computeAvailability } from "../engine/availability"; +import { evaluateRules } from "../engine/rules"; +import { addDays, localParts } from "../engine/time"; +import type { Method } from "../engine/types"; +import { adminContext } from "../lib/admin.server"; +import { niceDate, slotLabel } from "../lib/format"; +import { enrichCart } from "../services/catalog.server"; +import { reservations } from "../services/storefront.server"; + +/** + * "Why is this slot unavailable?" — runs the exact checkout engine for a sample cart, + * location, method and date and shows every reason with the rule that caused it. + */ +export const loader = async ({ request }: LoaderFunctionArgs) => { + const { cfg, admin } = await adminContext(request); + const sp = new URL(request.url).searchParams; + const today = localParts(Date.now(), cfg.shop.timezone).date; + const method = (sp.get("method") as Method) || "delivery"; + const loc = cfg.locations.find((l) => l.id === sp.get("location")) ?? cfg.locations[0]; + const date = sp.get("date") || addDays(today, 1); + const variants = (sp.get("variants") ?? "").split(",").filter(Boolean); + const base = { locations: cfg.locations.map((l) => ({ id: l.id, name: l.name })), method, locationId: loc?.id ?? "", date, variants }; + if (!loc) return { ...base, result: null }; + const s2a = Object.fromEntries(cfg.locations.filter((l) => l.shopifyLocationId).map((l) => [l.shopifyLocationId!, l.id])); + const { cart } = await enrichCart(admin, cfg.shop.domain, variants.map((v) => ({ variantId: v, quantity: 1 })), s2a); + const usage = await reservations.usage(cfg.shop.id, addDays(today, -1), addDays(today, 370), new Date()); + const cons = evaluateRules(cart, cfg.rules, cfg.settings.mixedPrepPolicy); + const av = computeAvailability({ location: loc, method, cart, rules: cfg.rules, peakDays: cfg.peakDays, usage, settings: cfg.settings, now: Date.now(), constraints: cons }); + const d = av.dates.find((x) => x.date === date); + return { + ...base, + result: { + items: cart.items.map((i) => i.title), + matched: cons.matched, + prep: av.prepMinutes, + prepDriver: cons.prepDriver ?? null, + blockers: av.blockers, + horizon: loc.methods[method]?.horizonDays ?? null, + day: d ? { reasons: d.reasons, peak: d.peak, slots: d.slots.map((s) => ({ id: s.id, start: s.start, end: s.end, available: s.available, remaining: s.remaining, reasons: s.reasons })) } : null, + dateOnly: av.dateOnly, + }, + }; +}; + +export default function Diagnostics() { + const d = useLoaderData(); + const [variants, setVariants] = useState<{ id: string; title: string }[]>(d.variants.map((id) => ({ id, title: id.split("/").pop()! }))); + const pick = async () => { + const sel = (await shopify.resourcePicker({ type: "variant", multiple: true, action: "select" })) as unknown as { id: string; displayName?: string; title?: string }[] | undefined; + if (sel) setVariants(sel.map((v) => ({ id: v.id, title: v.displayName ?? v.title ?? v.id }))); + }; + const r = d.result; + return ( + + +
+ v.id).join(",")} /> + + + + Delivery + Pickup + Shipping + + + {d.locations.map((l) => {l.name})} + + + + + Choose products for the test cart + {variants.length ? variants.map((v) => v.title).join(", ") : "Empty cart (only hours, closures and capacity apply)"} + + Check + +
+
+ {r && ( + + + + Rules matched: {r.matched.length ? r.matched.map((m) => `${m.ruleName} (${m.itemTitle})`).join("; ") : "none"}. Preparation used: {Math.round(r.prep / 6) / 10} h + {r.prepDriver ? `, set by ${r.prepDriver.itemTitle}` : ""}. + + {r.blockers.map((b, i) => {b.message}{b.ruleId ? ` (rule ${b.ruleId})` : ""})} + {!r.blockers.length && !r.day && This date is outside the {r.horizon}-day booking window, or in the past.} + {r.day?.reasons.map((x, i) => {x.message}{x.ruleId ? ` (rule ${x.ruleId})` : ""})} + {r.day?.slots.map((s) => ( + + + {s.available ? "Available" : "Blocked"} + {r.dateOnly ? "Dispatch" : slotLabel(s.start, s.end)} + {s.available ? (s.remaining == null ? "no limit" : `${s.remaining} left`) : s.reasons.map((x) => `${x.code}: ${x.message}`).join("; ")} + + + ))} + + + )} +
+ ); +} diff --git a/app/routes/app.freshness.tsx b/app/routes/app.freshness.tsx new file mode 100644 index 0000000..8df9507 --- /dev/null +++ b/app/routes/app.freshness.tsx @@ -0,0 +1,162 @@ +import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router"; +import { Form, useActionData, useLoaderData } from "react-router"; +import prisma from "../db.server"; +import { checkFreshness, evaluateRules } from "../engine/rules"; +import { addDays, localParts } from "../engine/time"; +import type { RuleAction } from "../engine/types"; +import { adminContext, formInt, formList, formString } from "../lib/admin.server"; +import { niceDate } from "../lib/format"; +import { bumpConfig } from "../services/config.server"; +import { RuleActionSchema, RuleTargetSchema } from "../services/settings"; + +type Fresh = Extract; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const { cfg, features } = await adminContext(request); + const today = localParts(Date.now(), cfg.shop.timezone).date; + const rules = cfg.rules + .filter((r) => r.action.type === "freshness") + .map((r) => { + const a = r.action as Fresh; + // Preview which of the next 28 days this product could be fulfilled. + const cons = evaluateRules({ items: [{ productId: "x", variantId: "x", title: r.name, quantity: 1, price: 0, tags: ["__preview"] }] }, [ + { ...r, enabled: true, target: { type: "tag", values: ["__preview"] } }, + ]); + const strip = Array.from({ length: 28 }, (_, i) => { + const d = addDays(today, i); + return { d, ok: !checkFreshness(cons.freshness, d, cfg.locations[0]?.id ?? "") }; + }); + return { ...r, a, strip }; + }); + return { rules, allowed: features.freshness, locations: cfg.locations.map((l) => ({ id: l.id, name: l.name })) }; +}; + +export const action = async ({ request }: ActionFunctionArgs) => { + const { cfg, features } = await adminContext(request); + if (!features.freshness) return { ok: false, message: "Fresh-stock windows are on the Growth plan" }; + const fd = await request.formData(); + const intent = formString(fd, "intent"); + const find = async (id: string) => prisma.productRule.findFirst({ where: { id, shopId: cfg.shop.id } }); + + if (intent === "create") { + const target = RuleTargetSchema.safeParse({ type: formString(fd, "targetType"), values: formList(fd, "targetValues") }); + const act = RuleActionSchema.safeParse({ + type: "freshness", + arrivals: formList(fd, "arrivals").map((date) => ({ date })), + minDaysAfterArrival: formInt(fd, "minDays") ?? 1, + shelfLifeDays: formInt(fd, "shelfLife") ?? 4, + }); + if (!target.success || !act.success) return { ok: false, message: "Check the product match and the dates (YYYY-MM-DD)" }; + await prisma.productRule.create({ + data: { shopId: cfg.shop.id, name: formString(fd, "name") || "Fresh stock", priority: 5, target: target.data, action: act.data, message: formString(fd, "message") || null }, + }); + } else { + const [op, id, extra] = intent.split(":"); + const r = await find(id); + if (!r) return { ok: false, message: "Not found" }; + const a = r.action as unknown as Fresh; + if (op === "add") { + const date = formString(fd, `arrival_${id}`); + const locs = formString(fd, `arrivalLoc_${id}`); + if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return { ok: false, message: "Pick an arrival date" }; + a.arrivals = [...a.arrivals, { date, ...(locs ? { locationIds: [locs] } : {}) }].sort((x, y) => (x.date < y.date ? -1 : 1)); + } + if (op === "remove") a.arrivals = a.arrivals.filter((_, i) => i !== Number(extra)); + if (op === "window") { + a.minDaysAfterArrival = formInt(fd, `min_${id}`) ?? a.minDaysAfterArrival; + a.shelfLifeDays = formInt(fd, `shelf_${id}`) ?? a.shelfLifeDays; + } + if (op === "delete") { + await prisma.productRule.delete({ where: { id } }); + await bumpConfig(cfg.shop.id, cfg.shop.domain); + return { ok: true, message: "Removed" }; + } + await prisma.productRule.update({ where: { id }, data: { action: a as object } }); + } + await bumpConfig(cfg.shop.id, cfg.shop.domain); + return { ok: true, message: "Saved" }; +}; + +export default function Freshness() { + const { rules, allowed, locations } = useLoaderData(); + const result = useActionData(); + return ( + + {result && {result.message}} + {!allowed && Tie bookable dates to when perishable stock arrives. See plans} + + + For perishables like flowers, produce and sweets: customers can only pick dates from arrival + "ready after" days up to arrival + shelf life. If no batch covers a date, the calendar tells the customer when the next fresh batch is ready. + + + {rules.map((r) => ( + + + Applies to {r.target.type}: {r.target.values.join(", ")} + + {r.a.arrivals.map((x, i) => ( +
+ + + + {niceDate(x.date)}{x.locationIds?.length ? ` · ${locations.find((l) => l.id === x.locationIds![0])?.name ?? ""}` : ""} + × + + +
+ ))} +
+
+ + + + + All locations + {locations.map((l) => {l.name})} + + Add arrival + +
+
+ + + + + Update window + +
+ Bookable in the next 28 days: +
+ {r.strip.map((s) => ( +
+ {s.d.slice(8)} +
+ ))} +
+
Delete this rule
+
+
+ ))} + +
+ + + + + Product tag + Product type + Collection ID + Product ID + + + + + + + Add + +
+
+
+ ); +} diff --git a/app/routes/app.locations.$id.tsx b/app/routes/app.locations.$id.tsx new file mode 100644 index 0000000..d2e8b3d --- /dev/null +++ b/app/routes/app.locations.$id.tsx @@ -0,0 +1,338 @@ +import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router"; +import { Form, useActionData, useLoaderData, useNavigation } from "react-router"; +import { randomUUID } from "node:crypto"; +import prisma from "../db.server"; +import type { DateException, DeliveryZone, LocationConfig, Method, MethodConfig, SlotTemplate } from "../engine/types"; +import { METHODS } from "../engine/types"; +import { + adminContext, + formBool, + formInt, + formList, + formMoney, + formString, + parseRanges, +} from "../lib/admin.server"; +import { METHOD_LABEL, money, niceDate, rangesToText } from "../lib/format"; +import { bumpConfig, toLocationConfig } from "../services/config.server"; +import { ExceptionSchema, MethodConfigSchema, ZoneSchema } from "../services/settings"; + +const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const { cfg, admin, features } = await adminContext(request); + const row = await prisma.location.findFirst({ where: { id: params.id, shopId: cfg.shop.id } }); + if (!row) throw new Response("Not found", { status: 404 }); + const res = await admin.graphql(`#graphql + query { locations(first: 50) { nodes { id name } } }`); + const shopifyLocations = ((await res.json()) as { data: { locations: { nodes: { id: string; name: string }[] } } }).data.locations.nodes; + return { loc: toLocationConfig(row), shopifyLocations, currency: cfg.shop.currency, drivingAllowed: features.drivingDistance }; +}; + +/** "10:00-13:00 +3.00 x4, 13:00-16:00" → explicit slot templates. */ +function parseSlots(s: string): SlotTemplate[] { + if (!s.trim()) return []; + return s.split(",").map((part) => { + const p = part.trim(); + const [range] = parseRanges(p.split(/\s+/)[0]); + const sur = p.match(/\+\s*(\d+(?:\.\d{1,2})?)/); + const cap = p.match(/x\s*(\d+)/i); + return { ...range, surcharge: sur ? Math.round(Number(sur[1]) * 100) : undefined, capacity: cap ? Number(cap[1]) : undefined }; + }); +} +const slotsToText = (list?: SlotTemplate[]) => + (list ?? []) + .map((s) => `${s.start}-${s.end}${s.surcharge ? ` +${(s.surcharge / 100).toFixed(2)}` : ""}${s.capacity != null ? ` x${s.capacity}` : ""}`) + .join(", "); + +export const action = async ({ request, params }: ActionFunctionArgs) => { + const { cfg, features } = await adminContext(request); + const row = await prisma.location.findFirst({ where: { id: params.id, shopId: cfg.shop.id } }); + if (!row) throw new Response("Not found", { status: 404 }); + const loc = toLocationConfig(row); + const fd = await request.formData(); + const intent = formString(fd, "intent"); + const save = async (data: Parameters[0]["data"]) => { + await prisma.location.update({ where: { id: row.id }, data }); + await bumpConfig(cfg.shop.id, cfg.shop.domain); + }; + try { + if (intent === "basics") { + const lat = formString(fd, "lat"); + const lng = formString(fd, "lng"); + await save({ + name: formString(fd, "name") || "Location", + address: formString(fd, "address"), + timezone: formString(fd, "timezone") || cfg.shop.timezone, + lat: lat ? Number(lat) : null, + lng: lng ? Number(lng) : null, + priority: formInt(fd, "priority") ?? 1, + enabled: formBool(fd, "enabled"), + pickupInstructions: formString(fd, "pickupInstructions") || null, + shopifyLocationId: formString(fd, "shopifyLocationId") || null, + }); + return { ok: true, message: "Location saved" }; + } + if (intent.startsWith("method:")) { + const m = intent.slice(7) as Method; + const weeklyHours: MethodConfig["weeklyHours"] = {}; + const slots: NonNullable = {}; + for (let d = 0; d < 7; d++) { + const h = parseRanges(formString(fd, `hours_${d}`)); + if (h.length) weeklyHours[d as 0] = h; + const s = parseSlots(formString(fd, `slots_${d}`)); + if (s.length) slots[d as 0] = s; + } + const cfgM: MethodConfig = { + enabled: formBool(fd, "enabled"), + weeklyHours, + slotMode: formString(fd, "slotMode") as MethodConfig["slotMode"], + slotMinutes: formInt(fd, "slotMinutes") ?? undefined, + slots, + prepMinutes: Math.round((Number(formString(fd, "prepHours")) || 0) * 60), + prepMode: formString(fd, "prepMode") === "operating" ? "operating" : "elapsed", + sameDayCutoff: formString(fd, "sameDayCutoff") || null, + nextDayCutoff: formString(fd, "nextDayCutoff") || null, + horizonDays: formInt(fd, "horizonDays") ?? 30, + dailyCapacity: formInt(fd, "dailyCapacity"), + slotCapacity: formInt(fd, "slotCapacity"), + }; + const parsed = MethodConfigSchema.safeParse(cfgM); + if (!parsed.success) return { ok: false, message: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ") }; + await save({ methods: { ...loc.methods, [m]: cfgM } as object }); + return { ok: true, message: `${METHOD_LABEL[m]} settings saved` }; + } + if (intent === "add-exception") { + const closed = formString(fd, "kind") === "closed"; + const ex: DateException = { + date: formString(fd, "date"), + closed: closed || undefined, + hours: closed ? undefined : parseRanges(formString(fd, "hours")), + methods: METHODS.filter((m) => formBool(fd, `ex_${m}`)), + note: formString(fd, "note") || undefined, + }; + if (!ex.methods?.length) delete ex.methods; + const p = ExceptionSchema.safeParse(ex); + if (!p.success || (!closed && !ex.hours?.length)) return { ok: false, message: "Enter a date, and hours unless the location is closed" }; + const list = [...loc.exceptions, ex].sort((a, b) => (a.date < b.date ? -1 : 1)); + await save({ exceptions: list as object[] }); + return { ok: true, message: `Added ${closed ? "closure" : "special hours"} for ${ex.date}` }; + } + if (intent.startsWith("del-exception:")) { + const i = Number(intent.split(":")[1]); + await save({ exceptions: loc.exceptions.filter((_, n) => n !== i) as object[] }); + return { ok: true, message: "Removed" }; + } + if (intent === "add-zone") { + const driving = formInt(fd, "drivingKm"); + if (driving && !features.drivingDistance) return { ok: false, message: "Driving-distance zones are on the Pro plan" }; + const z: DeliveryZone = { + id: randomUUID().slice(0, 8), + name: formString(fd, "zname") || "Zone", + postalPatterns: formList(fd, "postal"), + radiusKm: formInt(fd, "radiusKm"), + drivingKm: driving, + fee: formMoney(fd, "fee") ?? 0, + freeOver: formMoney(fd, "freeOver"), + minOrder: formMoney(fd, "minOrder"), + }; + const p = ZoneSchema.safeParse(z); + if (!p.success || (!z.postalPatterns?.length && z.radiusKm == null && z.drivingKm == null)) + return { ok: false, message: "Give the zone postal codes, a radius or a driving distance" }; + await save({ zones: [...loc.zones, z] as object[] }); + return { ok: true, message: `Zone "${z.name}" added` }; + } + if (intent.startsWith("del-zone:")) { + const id = intent.split(":")[1]; + await save({ zones: loc.zones.filter((z) => z.id !== id) as object[] }); + return { ok: true, message: "Zone removed" }; + } + if (intent.startsWith("move-zone:")) { + const [, id, dir] = intent.split(":"); + const zs = [...loc.zones]; + const i = zs.findIndex((z) => z.id === id); + const j = dir === "up" ? i - 1 : i + 1; + if (i >= 0 && j >= 0 && j < zs.length) [zs[i], zs[j]] = [zs[j], zs[i]]; + await save({ zones: zs as object[] }); + return { ok: true, message: "Zone order updated" }; + } + } catch (e) { + return { ok: false, message: (e as Error).message }; + } + return { ok: false, message: "Unknown action" }; +}; + +export default function LocationEdit() { + const { loc, shopifyLocations, currency, drivingAllowed } = useLoaderData(); + const result = useActionData(); + const busy = useNavigation().state !== "idle"; + return ( + + Locations + {result && {result.message}} + + +
+ + + + + + + + Not linked + {shopifyLocations.map((s) => ( + {s.name} + ))} + + + + + + + + Save location + +
+
+ + {METHODS.map((m) => ( + + ))} + + + + {loc.exceptions.length === 0 && No closures yet. Add holidays here; they override the weekly hours.} + {loc.exceptions.map((e, i) => ( +
+ + + {e.closed ? "Closed" : rangesToText(e.hours)} + {niceDate(e.date, true)} + {e.methods?.map((x) => METHOD_LABEL[x]).join(", ") || "All methods"}{e.note ? ` · ${e.note}` : ""} + Remove + +
+ ))} + +
+ + + + + Closed + Special hours + + + + + + Applies to (none ticked = all): + {METHODS.map((x) => ( + + ))} + Add + +
+
+
+ + + + The first matching zone sets the fee. Being inside a zone doesn't make delivery free unless you set a free-delivery threshold. + {loc.zones.map((z, i) => ( + + + + {i + 1}. {z.name} + + {[z.postalPatterns?.length ? `Postal: ${z.postalPatterns.join(", ")}` : "", z.radiusKm != null ? `Within ${z.radiusKm} km` : "", z.drivingKm != null ? `Driving ≤ ${z.drivingKm} km` : ""].filter(Boolean).join(" · ")} + + + Fee {money(z.fee, currency)} + {z.freeOver != null ? ` · free over ${money(z.freeOver, currency)}` : ""} + {z.minOrder != null ? ` · minimum order ${money(z.minOrder, currency)}` : ""} + + + +
Move up
+
Remove
+
+
+
+ ))} +
+ + + + + + + + + + + Add zone +
+
+
+
+ ); +} + +function MethodSection({ method, loc, busy }: { method: Method; loc: LocationConfig; busy: boolean }) { + const c: MethodConfig = loc.methods[method] ?? { + enabled: false, weeklyHours: {}, slotMode: method === "shipping" ? "dateOnly" : "generated", slotMinutes: 60, prepMinutes: 120, + prepMode: "elapsed", horizonDays: 30, + }; + return ( + +
+ + + + + + Date and time (even slots) + Date and time (my own slots) + Date only + + + + + Clock hours + Opening hours only + + + + + + + + + + Day + Opening hours + Own slots (if chosen) + + + {DAYS.map((d, i) => ( + + {d} + + + + + + + + ))} + + + Hours like 10:00-13:00, 14:00-18:00. Own slots can add a surcharge (+3.00) and their own limit (x4). + Save {METHOD_LABEL[method].toLowerCase()} + +
+
+ ); +} diff --git a/app/routes/app.locations._index.tsx b/app/routes/app.locations._index.tsx new file mode 100644 index 0000000..d084bfd --- /dev/null +++ b/app/routes/app.locations._index.tsx @@ -0,0 +1,90 @@ +import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router"; +import { redirect, useLoaderData } from "react-router"; +import { Form } from "react-router"; +import prisma from "../db.server"; +import { METHODS } from "../engine/types"; +import { adminContext } from "../lib/admin.server"; +import { METHOD_LABEL } from "../lib/format"; +import { bumpConfig } from "../services/config.server"; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const { cfg, features } = await adminContext(request); + return { + locations: cfg.locations.map((l) => ({ + id: l.id, + name: l.name, + address: l.address, + enabled: l.enabled, + priority: l.priority, + methods: METHODS.filter((m) => l.methods[m]?.enabled), + zones: l.zones.length, + })), + maxLocations: features.maxLocations, + }; +}; + +export const action = async ({ request }: ActionFunctionArgs) => { + const { cfg, features } = await adminContext(request); + if (cfg.locations.length >= features.maxLocations) return { error: `Your plan includes ${features.maxLocations} location(s). Upgrade in Settings to add more.` }; + const hours = Object.fromEntries([1, 2, 3, 4, 5, 6].map((d) => [d, [{ start: "10:00", end: "18:00" }]])); + const loc = await prisma.location.create({ + data: { + shopId: cfg.shop.id, + name: "New location", + address: "", + timezone: cfg.shop.timezone, + enabled: false, + priority: cfg.locations.length + 1, + methods: { + pickup: { enabled: true, weeklyHours: hours, slotMode: "generated", slotMinutes: 60, prepMinutes: 120, prepMode: "operating", sameDayCutoff: "14:00", horizonDays: 30, slotCapacity: 5 }, + }, + }, + }); + await bumpConfig(cfg.shop.id, cfg.shop.domain); + return redirect(`/app/locations/${loc.id}`); +}; + +export default function Locations() { + const { locations, maxLocations } = useLoaderData(); + return ( + +
+ = maxLocations}>Add location +
+ + + + Location + Offers + Delivery zones + Priority + Status + + + {locations.map((l) => ( + + + + {l.name} + {l.address || "No address yet"} + + + {l.methods.map((m) => METHOD_LABEL[m]).join(", ") || "Nothing yet"} + {l.zones} + {l.priority} + + {l.enabled ? "Taking bookings" : "Off"} + + + ))} + + + + + + Locations were imported from Shopify and start switched off. Check the hours, then turn each one on. When several locations can take an order, customers are offered them in priority order (1 first). + + +
+ ); +} diff --git a/app/routes/app.peak.tsx b/app/routes/app.peak.tsx new file mode 100644 index 0000000..12058ff --- /dev/null +++ b/app/routes/app.peak.tsx @@ -0,0 +1,151 @@ +import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router"; +import { Form, useActionData, useLoaderData, useNavigation } from "react-router"; +import prisma from "../db.server"; +import { localParts } from "../engine/time"; +import { METHODS } from "../engine/types"; +import { adminContext, formBool, formInt, formList, formMoney, formString } from "../lib/admin.server"; +import { METHOD_LABEL, money, niceDate } from "../lib/format"; +import { bumpConfig } from "../services/config.server"; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const { cfg, features } = await adminContext(request); + const today = localParts(Date.now(), cfg.shop.timezone).date; + const counters = await prisma.capacityCounter.findMany({ + where: { shopId: cfg.shop.id, slot: "*", date: { in: cfg.peakDays.flatMap((p) => p.dates) } }, + }); + const waitlist = await prisma.waitlistEntry.findMany({ where: { shopId: cfg.shop.id, date: { gte: today } }, orderBy: { createdAt: "asc" }, take: 200 }); + return { + peaks: cfg.peakDays.map((p) => ({ + ...p, + booked: p.dates.map((d) => ({ d, n: counters.filter((c) => c.date === d && (!p.methods?.length || p.methods.includes(c.method as "pickup"))).reduce((s, c) => s + c.count, 0) })), + })), + waitlist: waitlist.map((w) => ({ ...w, createdAt: w.createdAt.toISOString(), offeredAt: w.offeredAt?.toISOString() ?? null })), + locations: cfg.locations.map((l) => ({ id: l.id, name: l.name })), + allowed: features.peakDays, + currency: cfg.shop.currency, + }; +}; + +export const action = async ({ request }: ActionFunctionArgs) => { + const { cfg, features } = await adminContext(request); + if (!features.peakDays) return { ok: false, message: "Rush control is on the Growth plan" }; + const fd = await request.formData(); + const intent = formString(fd, "intent"); + if (intent.startsWith("delete:")) { + await prisma.peakDay.deleteMany({ where: { id: intent.split(":")[1], shopId: cfg.shop.id } }); + await bumpConfig(cfg.shop.id, cfg.shop.domain); + return { ok: true, message: "Peak day removed" }; + } + const dates = formList(fd, "dates").filter((d) => /^\d{4}-\d{2}-\d{2}$/.test(d)); + if (!dates.length) return { ok: false, message: "Add at least one date (YYYY-MM-DD)" }; + const data = { + name: formString(fd, "name") || "Peak day", + dates, + methods: METHODS.filter((m) => formBool(fd, `m_${m}`)), + locationIds: cfg.locations.filter((l) => formBool(fd, `loc_${l.id}`)).map((l) => l.id), + dailyCapacity: formInt(fd, "dailyCapacity"), + slotCapacity: formInt(fd, "slotCapacity"), + extraPrepMinutes: Math.round((Number(formString(fd, "extraPrepHours")) || 0) * 60), + cutoffDaysBefore: formInt(fd, "cutoffDays"), + cutoffTime: formString(fd, "cutoffTime") || null, + surcharge: formMoney(fd, "surcharge") ?? 0, + waitlist: formBool(fd, "waitlist") && features.waitlist, + message: formString(fd, "message") || null, + }; + const id = formString(fd, "id"); + if (id) await prisma.peakDay.updateMany({ where: { id, shopId: cfg.shop.id }, data }); + else await prisma.peakDay.create({ data: { ...data, shopId: cfg.shop.id } }); + await bumpConfig(cfg.shop.id, cfg.shop.domain); + return { ok: true, message: `${data.name} saved` }; +}; + +type PeakView = ReturnType>["peaks"][number]; + +export default function Peak() { + const { peaks, waitlist, locations, allowed, currency } = useLoaderData(); + const result = useActionData(); + const busy = useNavigation().state !== "idle"; + return ( + + {result && {result.message}} + {!allowed && Separate limits, earlier cutoffs and waitlists for festival days. See plans} + + + On festival days demand jumps and mistakes are expensive. Set a separate order limit, close orders earlier, add a peak-handling fee, and let customers join a waitlist instead of leaving when you're full. When an order is cancelled, the first person waiting is offered the place for a limited time. + + + {peaks.map((p) => ( + + + + {p.booked.map((b) => ( + = p.dailyCapacity ? "critical" : "info"}> + {`${niceDate(b.d)}: ${b.n}${p.dailyCapacity != null ? ` / ${p.dailyCapacity}` : ""} booked`} + + ))} + + +
Delete {p.name}
+
+
+ ))} + + + + + + + Customer + Wants + Joined + Status + + + {waitlist.map((w) => ( + + {w.name ?? w.email ?? w.phone} + {`${niceDate(w.date)} · ${METHOD_LABEL[w.method]} · ${w.slot === "*" ? "any time" : w.slot}`} + {new Date(w.createdAt).toLocaleString("en-CA")} + {w.offeredAt ? "Offered a place" : "Waiting"} + + ))} + + + {waitlist.length === 0 && Nobody is waiting.} + +
+ ); +} + +function PeakForm({ p, locations, currency, busy }: { p?: PeakView; locations: { id: string; name: string }[]; currency: string; busy: boolean }) { + return ( +
+ + {p && } + + + + + + + + + + + + + + Methods (none = all): + {METHODS.map((m) => )} + + + Locations (none = all): + {locations.map((l) => )} + + + {p?.surcharge ? Current peak fee {money(p.surcharge, currency)} : null} + {p ? "Save changes" : "Add peak day"} + +
+ ); +} diff --git a/app/routes/app.rules.tsx b/app/routes/app.rules.tsx new file mode 100644 index 0000000..c76de4e --- /dev/null +++ b/app/routes/app.rules.tsx @@ -0,0 +1,186 @@ +import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router"; +import { Form, useActionData, useLoaderData, useNavigation } from "react-router"; +import prisma from "../db.server"; +import type { RuleAction } from "../engine/types"; +import { METHODS } from "../engine/types"; +import { adminContext, formBool, formInt, formList, formString } from "../lib/admin.server"; +import { METHOD_LABEL } from "../lib/format"; +import { bumpConfig } from "../services/config.server"; +import { RuleActionSchema, RuleTargetSchema } from "../services/settings"; + +const TARGETS = [ + ["productType", "Product type"], + ["tag", "Product tag"], + ["collection", "Collection ID"], + ["vendor", "Vendor"], + ["product", "Product ID"], + ["variant", "Variant ID"], +] as const; + +const ACTIONS = [ + ["prohibitMethods", "Block methods"], + ["allowOnlyMethods", "Only allow methods"], + ["addPrep", "Add preparation time"], + ["restrictLocations", "Only from locations"], + ["blockWeekdays", "Block weekdays"], + ["blockDates", "Block dates"], + ["dateWindow", "Only between dates"], + ["requireScheduling", "Always require a time"], +] as const; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const { cfg } = await adminContext(request); + return { + rules: cfg.rules.filter((r) => r.action.type !== "freshness").sort((a, b) => a.priority - b.priority), + locations: cfg.locations.map((l) => ({ id: l.id, name: l.name })), + }; +}; + +export const action = async ({ request }: ActionFunctionArgs) => { + const { cfg } = await adminContext(request); + const fd = await request.formData(); + const intent = formString(fd, "intent"); + const own = async (id: string) => !!(await prisma.productRule.findFirst({ where: { id, shopId: cfg.shop.id } })); + if (intent.startsWith("toggle:") || intent.startsWith("delete:")) { + const id = intent.split(":")[1]; + if (!(await own(id))) return { ok: false, message: "Rule not found" }; + if (intent.startsWith("delete:")) await prisma.productRule.delete({ where: { id } }); + else { + const r = await prisma.productRule.findUniqueOrThrow({ where: { id } }); + await prisma.productRule.update({ where: { id }, data: { enabled: !r.enabled } }); + } + await bumpConfig(cfg.shop.id, cfg.shop.domain); + return { ok: true, message: "Rule updated" }; + } + + const target = { type: formString(fd, "targetType"), values: formList(fd, "targetValues") }; + const type = formString(fd, "actionType"); + const methods = METHODS.filter((m) => formBool(fd, `m_${m}`)); + let action: unknown = { type }; + if (type === "prohibitMethods" || type === "allowOnlyMethods") action = { type, methods }; + if (type === "addPrep") action = { type, minutes: Math.round((Number(formString(fd, "prepHours")) || 0) * 60) }; + if (type === "restrictLocations") action = { type, locationIds: cfg.locations.filter((l) => formBool(fd, `loc_${l.id}`)).map((l) => l.id) }; + if (type === "blockWeekdays") action = { type, weekdays: [0, 1, 2, 3, 4, 5, 6].filter((d) => formBool(fd, `wd_${d}`)) }; + if (type === "blockDates") action = { type, dates: formList(fd, "dates") }; + if (type === "dateWindow") action = { type, from: formString(fd, "from") || undefined, to: formString(fd, "to") || undefined }; + + const t = RuleTargetSchema.safeParse(target); + const a = RuleActionSchema.safeParse(action); + if (!t.success) return { ok: false, message: "Add at least one value the rule applies to" }; + if (!a.success) return { ok: false, message: `Rule settings: ${a.error.issues.map((i) => i.message).join("; ")}` }; + await prisma.productRule.create({ + data: { + shopId: cfg.shop.id, + name: formString(fd, "name") || describe(a.data as RuleAction), + priority: formInt(fd, "priority") ?? 10, + target: t.data, + action: a.data, + message: formString(fd, "message") || null, + }, + }); + await bumpConfig(cfg.shop.id, cfg.shop.domain); + return { ok: true, message: "Rule added" }; +}; + +function describe(a: RuleAction): string { + switch (a.type) { + case "prohibitMethods": return `No ${a.methods.join(" or ")}`; + case "allowOnlyMethods": return `Only ${a.methods.join(" or ")}`; + case "addPrep": return `+${(a.minutes / 60).toFixed(a.minutes % 60 ? 1 : 0)} h preparation`; + case "restrictLocations": return `Only ${a.locationIds.length} location(s)`; + case "blockWeekdays": return `Not on ${a.weekdays.map((d) => ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][d]).join(", ")}`; + case "blockDates": return `Blocked on ${a.dates.length} date(s)`; + case "dateWindow": return `Only ${a.from ?? "…"} to ${a.to ?? "…"}`; + case "requireScheduling": return "Time required"; + case "freshness": return "Fresh stock window"; + } +} + +export default function Rules() { + const { rules, locations } = useLoaderData(); + const result = useActionData(); + const busy = useNavigation().state !== "idle"; + return ( + + {result && {result.message}} + + + Rules apply to items in the cart. When rules disagree, a block beats an allowance, and a mixed cart uses its longest preparation time. Customers see the message you write, so they know which item caused it. + + + + + + Rule + Applies to + Customer message + Status + Actions + + + {rules.map((r) => ( + + {r.name} + {TARGETS.find((t) => t[0] === r.target.type)?.[1]}: {r.target.values.join(", ")} + {r.message ?? "—"} + {r.enabled ? "On" : "Off"} + + +
{r.enabled ? "Turn off" : "Turn on"}
+
Delete
+
+
+
+ ))} +
+
+ {rules.length === 0 && No rules yet.} +
+ +
+ + + + + {TARGETS.map(([v, l]) => {l})} + + + + {ACTIONS.map(([v, l]) => {l})} + + + + + + Fill in only what your chosen action needs. + + Methods: + {METHODS.map((m) => )} + + + Locations: + {locations.map((l) => )} + + + Weekdays: + {["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map((d, i) => )} + + + + + + + + + + + + + + Add rule + +
+
+
+ ); +} diff --git a/app/routes/app.runsheet.tsx b/app/routes/app.runsheet.tsx new file mode 100644 index 0000000..fa7f058 --- /dev/null +++ b/app/routes/app.runsheet.tsx @@ -0,0 +1,112 @@ +import type { LoaderFunctionArgs } from "react-router"; +import { Form, useLoaderData } from "react-router"; +import prisma from "../db.server"; +import { buildRunSheet, toCsv } from "../engine/runsheet"; +import { localParts } from "../engine/time"; +import { adminContext } from "../lib/admin.server"; +import { niceDate, slotLabel } from "../lib/format"; +import { rowToBooking } from "../services/reminders.server"; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const { cfg, features } = await adminContext(request); + const sp = new URL(request.url).searchParams; + const today = localParts(Date.now(), cfg.shop.timezone).date; + const date = sp.get("date") || today; + const delivering = cfg.locations.filter((l) => l.methods.delivery?.enabled); + const loc = delivering.find((l) => l.id === sp.get("location")) ?? delivering[0]; + if (!loc) return { empty: true as const }; + const rows = await prisma.booking.findMany({ where: { shopId: cfg.shop.id, locationId: loc.id, date, method: "delivery", status: "confirmed" } }); + const sheet = buildRunSheet(rows.map(rowToBooking), loc, date); + const csv = toCsv( + sheet.stops.map((s) => ({ + stop: s.seq, + window: `${s.booking.slotStart}-${s.booking.slotEnd}`, + order: s.booking.orderName ?? "", + customer: s.booking.customer?.name ?? "", + phone: s.booking.customer?.phone ?? "", + address: [s.booking.address?.line1, s.booking.address?.city, s.booking.address?.postal].filter(Boolean).join(", "), + items: (s.booking.items ?? []).map((i) => `${i.quantity}x ${i.title}`).join("; "), + leg_km: s.legKm?.toFixed(1) ?? "", + })), + ); + if (sp.get("format") === "csv" && features.runSheets) + return new Response(csv, { + headers: { "Content-Type": "text/csv; charset=utf-8", "Content-Disposition": `attachment; filename="run-sheet-${date}.csv"` }, + }) as never; + return { + empty: false as const, + allowed: features.runSheets, + date, + locationId: loc.id, + locations: delivering.map((l) => ({ id: l.id, name: l.name })), + sheet: { ...sheet, stops: sheet.stops.map((s) => ({ seq: s.seq, legKm: s.legKm, window: s.window, b: s.booking })) }, + csvHref: `/app/runsheet?date=${date}&location=${loc.id}&format=csv`, + }; +}; + +export default function RunSheet() { + const d = useLoaderData(); + if (d.empty) + return ( + + Turn on local delivery for a location to build run sheets. + + ); + const { sheet } = d; + return ( + + {d.allowed && Download CSV} + {!d.allowed && Run sheets and route links. See plans} + +
+ + + + {d.locations.map((l) => {l.name})} + + Show + +
+
+ + + Stops are grouped by promised window, then ordered nearest-first. Distances are straight-line estimates; open a leg in Google Maps for turn-by-turn directions. + {d.allowed && ( + + {sheet.mapsUrls.map((u, i) => Open leg {i + 1} in Google Maps)} + + )} + {sheet.unlocated.length > 0 && ( + {sheet.unlocated.length} order(s) have no map position (checkout didn't geocode the address): {sheet.unlocated.map((b) => b.orderName).join(", ")}. Add them by hand. + )} + + + + + + # + Window + Order + Customer + Address + Leg + Items + + + {sheet.stops.map((s) => ( + + {s.seq} + {slotLabel(s.b.slotStart, s.b.slotEnd)} + {s.b.orderName} + {[s.b.customer?.name, s.b.customer?.phone].filter(Boolean).join(" · ")} + {[s.b.address?.line1, s.b.address?.postal].filter(Boolean).join(", ")} + {s.legKm != null ? `${s.legKm.toFixed(1)} km` : "—"} + {(s.b.items ?? []).map((i) => `${i.quantity}× ${i.title}`).join(", ")} + + ))} + + + +
+ ); +} diff --git a/app/routes/app.settings.tsx b/app/routes/app.settings.tsx new file mode 100644 index 0000000..4070570 --- /dev/null +++ b/app/routes/app.settings.tsx @@ -0,0 +1,336 @@ +import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router"; +import { Form, useActionData, useLoaderData, useNavigation } from "react-router"; +import prisma from "../db.server"; +import type { ReminderPolicy } from "../engine/types"; +import { adminContext, formBool, formInt, formString } from "../lib/admin.server"; +import { PLAN_GROWTH, PLAN_PRO } from "../shopify.server"; +import { bumpConfig } from "../services/config.server"; +import { encrypt } from "../services/crypto.server"; +import { PLAN_COPY } from "../services/plans"; +import { type AppSettings, withDefaults } from "../services/settings"; + +const GUARD_HANDLE = "datewise-checkout-guard"; + +type AdminGraphql = { graphql: (q: string, o?: { variables?: Record }) => Promise }; +async function gql(admin: AdminGraphql, q: string, variables?: Record) { + const r = await admin.graphql(q, { variables }); + return ((await r.json()) as { data: T }).data; +} + +async function findGuard(admin: AdminGraphql) { + const d = await gql<{ + shopifyFunctions: { nodes: { id: string; title: string; apiType: string }[] }; + validations: { nodes: { id: string; enabled: boolean; shopifyFunction: { id: string } }[] }; + }>(admin, `#graphql + query { + shopifyFunctions(first: 50) { nodes { id title apiType } } + validations(first: 25) { nodes { id enabled shopifyFunction { id } } } + }`); + const fn = d.shopifyFunctions.nodes.find((f) => f.title === GUARD_HANDLE || /datewise/i.test(f.title)); + const validation = fn ? d.validations.nodes.find((v) => v.shopifyFunction.id === fn.id) : undefined; + return { fn, validation }; +} + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const { cfg, admin, billing } = await adminContext(request); + // Keep the stored plan in sync with Shopify billing. + const check = await billing.check({ plans: [PLAN_GROWTH, PLAN_PRO], isTest: process.env.NODE_ENV !== "production" }); + const plan = check.appSubscriptions.find((s) => s.status === "ACTIVE")?.name ?? "free"; + if (plan !== cfg.shop.plan) await prisma.shop.update({ where: { id: cfg.shop.id }, data: { plan } }); + const guard = await findGuard(admin).catch(() => ({ fn: undefined, validation: undefined })); + const s = cfg.settings; + return { + plan, + s: { ...s, messaging: { ...s.messaging, twilio: s.messaging.twilio ? { accountSid: s.messaging.twilio.accountSid, from: s.messaging.twilio.from } : null, whatsapp: s.messaging.whatsapp ? { phoneNumberId: s.messaging.whatsapp.phoneNumberId, templateName: s.messaging.whatsapp.templateName, templateLang: s.messaging.whatsapp.templateLang } : null } }, + guard: { deployed: !!guard.fn, enabled: !!guard.validation?.enabled }, + }; +}; + +export const action = async ({ request }: ActionFunctionArgs) => { + const { cfg, admin, billing, features } = await adminContext(request); + const fd = await request.formData(); + const intent = formString(fd, "intent"); + const s: AppSettings = withDefaults(cfg.shop.settings); + const persist = async (next: AppSettings) => { + await prisma.shop.update({ where: { id: cfg.shop.id }, data: { settings: next as object } }); + await bumpConfig(cfg.shop.id, cfg.shop.domain); + }; + + if (intent.startsWith("plan:")) { + const plan = intent.slice(5); + if (plan === "free") { + const check = await billing.check({ plans: [PLAN_GROWTH, PLAN_PRO], isTest: process.env.NODE_ENV !== "production" }); + for (const sub of check.appSubscriptions) await billing.cancel({ subscriptionId: sub.id, isTest: process.env.NODE_ENV !== "production", prorate: true }); + await prisma.shop.update({ where: { id: cfg.shop.id }, data: { plan: "free" } }); + return { ok: true, message: "Switched to Starter" }; + } + // Redirects the merchant to Shopify's approval page. + await billing.request({ plan: plan as typeof PLAN_GROWTH, isTest: process.env.NODE_ENV !== "production", returnUrl: `https://${cfg.shop.domain}/admin/apps/${process.env.SHOPIFY_API_KEY}/app/settings` }); + } + + if (intent === "guard") { + const { fn, validation } = await findGuard(admin); + if (!fn) return { ok: false, message: "The checkout guard isn't deployed yet. Run `shopify app deploy`." }; + const metafields = [{ namespace: "$app:datewise", key: "config", type: "json", value: JSON.stringify({ requireScheduling: s.requireScheduling }) }]; + const turnOn = formBool(fd, "on"); + if (validation) { + await gql(admin, `#graphql + mutation($id: ID!, $v: ValidationUpdateInput!) { validationUpdate(id: $id, validation: $v) { userErrors { message } } }`, { + id: validation.id, + v: { enable: turnOn, blockOnFailure: false, metafields }, + }); + } else if (turnOn) { + await gql(admin, `#graphql + mutation($v: ValidationCreateInput!) { validationCreate(validation: $v) { userErrors { message } } }`, { + v: { functionHandle: GUARD_HANDLE, title: "Datewise checkout guard", enable: true, blockOnFailure: false, metafields }, + }); + } + return { ok: true, message: turnOn ? "Checkout guard is on" : "Checkout guard is off" }; + } + + if (intent === "policy") { + s.holdMinutes = Math.min(60, Math.max(3, formInt(fd, "holdMinutes") ?? 10)); + s.mixedPrepPolicy = formString(fd, "mixedPrepPolicy") === "sum" ? "sum" : "longest"; + s.requireScheduling = formBool(fd, "requireScheduling"); + s.releaseOnCancel = formBool(fd, "releaseOnCancel"); + s.waitlistOfferMinutes = formInt(fd, "waitlistOfferMinutes") ?? 30; + s.locationRanking = (formString(fd, "locationRanking") as AppSettings["locationRanking"]) || "priority"; + s.weekStartsOn = (formInt(fd, "weekStartsOn") ?? 0) as AppSettings["weekStartsOn"]; + s.reschedule = { + enabled: formBool(fd, "rsEnabled"), + minHoursBefore: formInt(fd, "rsHours") ?? 24, + maxChanges: formInt(fd, "rsMax") ?? 2, + allowMethodChange: false, + }; + await persist(s); + // Keep the checkout function's copy of the setting in sync. + const { validation } = await findGuard(admin).catch(() => ({ validation: undefined })); + if (validation) + await gql(admin, `#graphql + mutation($id: ID!, $v: ValidationUpdateInput!) { validationUpdate(id: $id, validation: $v) { userErrors { message } } }`, { + id: validation.id, + v: { metafields: [{ namespace: "$app:datewise", key: "config", type: "json", value: JSON.stringify({ requireScheduling: s.requireScheduling }) }] }, + }); + return { ok: true, message: "Policies saved" }; + } + + if (intent === "widget") { + let translations = s.widget.translations; + const raw = formString(fd, "translations"); + try { + translations = raw ? JSON.parse(raw) : {}; + } catch { + return { ok: false, message: "Translations must be valid JSON, e.g. {\"fr\": {\"heading\": \"Quand ?\"}}" }; + } + s.widget = { + ...s.widget, + heading: formString(fd, "heading") || s.widget.heading, + labels: { pickup: formString(fd, "lp") || "Store pickup", delivery: formString(fd, "ld") || "Local delivery", shipping: formString(fd, "ls") || "Shipping" }, + accent: /^#[0-9a-f]{6}$/i.test(formString(fd, "accent")) ? formString(fd, "accent") : s.widget.accent, + showRemaining: formBool(fd, "showRemaining"), + drawer: formBool(fd, "drawer"), + translations, + }; + await persist(s); + return { ok: true, message: "Widget saved" }; + } + + if (intent === "reminders") { + const list: ReminderPolicy[] = s.reminders.map((r) => ({ + ...r, + enabled: formBool(fd, `en_${r.id}`), + minutesBefore: Math.round((Number(formString(fd, `hrs_${r.id}`)) || r.minutesBefore / 60) * 60), + template: formString(fd, `tpl_${r.id}`) || r.template, + })); + const newTpl = formString(fd, "new_tpl"); + if (newTpl) { + const channel = formString(fd, "new_channel") as ReminderPolicy["channel"]; + if (channel !== "email" && !features.smsWhatsapp) return { ok: false, message: "SMS and WhatsApp reminders are on the Pro plan" }; + list.push({ id: `r${Date.now()}`, channel, minutesBefore: Math.round((Number(formString(fd, "new_hrs")) || 24) * 60), template: newTpl, enabled: true }); + } + s.reminders = list.filter((r) => !formBool(fd, `del_${r.id}`)); + await persist(s); + return { ok: true, message: "Reminders saved. New bookings use them; existing bookings keep their plan until changed." }; + } + + if (intent === "messaging") { + s.messaging.emailFromName = formString(fd, "fromName"); + s.messaging.replyTo = formString(fd, "replyTo") || undefined; + const sid = formString(fd, "twSid"); + const tok = formString(fd, "twToken"); + if (formBool(fd, "twClear")) s.messaging.twilio = null; + else if (sid && formString(fd, "twFrom")) s.messaging.twilio = { accountSid: sid, from: formString(fd, "twFrom"), authTokenEnc: tok ? encrypt(tok) : s.messaging.twilio?.authTokenEnc ?? "" }; + const pid = formString(fd, "waPhone"); + const wtok = formString(fd, "waToken"); + if (formBool(fd, "waClear")) s.messaging.whatsapp = null; + else if (pid && formString(fd, "waTemplate")) + s.messaging.whatsapp = { phoneNumberId: pid, templateName: formString(fd, "waTemplate"), templateLang: formString(fd, "waLang") || "en", tokenEnc: wtok ? encrypt(wtok) : s.messaging.whatsapp?.tokenEnc ?? "" }; + await persist(s); + return { ok: true, message: "Messaging saved. Tokens are stored encrypted." }; + } + return { ok: false, message: "Unknown action" }; +}; + +export default function Settings() { + const { plan, s, guard } = useLoaderData(); + const result = useActionData(); + const busy = useNavigation().state !== "idle"; + return ( + + {result && {result.message}} + + + + + Some shoppers skip the cart (Buy now, Shop Pay, Apple Pay). The checkout guard stops checkout when a time is required but missing, when the chosen date has passed, or when the delivery fee in the cart doesn't match the quote. It works on every Shopify plan. + + + {guard.enabled ? "On" : guard.deployed ? "Off" : "Not deployed"} +
+ + + {guard.enabled ? "Turn off" : "Turn on"} +
+
+
+
+ + +
+ + + + + + The longest preparation time + All preparation times added + + + My priority order + Closest to the customer + Earliest available time + + + Sunday + Monday + + + + + + + Customer self-reschedule + + + + + + Save policies + +
+
+ + +
+ + + + + + + + + + + + + Save widget + +
+
+ + +
+ + + Placeholders: {"{{name}} {{order}} {{date}} {{time}} {{method}} {{location}}"} + {s.reminders.map((r) => ( + + + + + + + + + + + ))} + + + Email + SMS (Pro) + WhatsApp (Pro) + + + + + Save reminders + +
+
+ + +
+ + + + + + + Twilio (SMS) + + + + + + {s.messaging.twilio && } + WhatsApp (Meta Cloud API) + + + + + + + {s.messaging.whatsapp && } + Save messaging + +
+
+ + + + {PLAN_COPY.map((p) => ( + + + {p.name} + {p.price} + + {p.points.map((x) => {x})} + + {plan === p.id ? ( + Current plan + ) : ( +
+ + {p.id === "free" ? "Switch to Starter" : `Choose ${p.name}`} +
+ )} +
+
+ ))} +
+
+
+ ); +} diff --git a/app/routes/app.tsx b/app/routes/app.tsx new file mode 100644 index 0000000..bb0caae --- /dev/null +++ b/app/routes/app.tsx @@ -0,0 +1,37 @@ +import type { HeadersFunction, LoaderFunctionArgs } from "react-router"; +import { Outlet, useLoaderData, useRouteError } from "react-router"; +import { boundary } from "@shopify/shopify-app-react-router/server"; +import { AppProvider } from "@shopify/shopify-app-react-router/react"; +import { authenticate } from "../shopify.server"; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + await authenticate.admin(request); + return { apiKey: process.env.SHOPIFY_API_KEY || "" }; +}; + +export default function App() { + const { apiKey } = useLoaderData(); + return ( + + + Today + Bookings + Locations & hours + Product rules + Fresh stock + Rush control + Driver run sheet + Why unavailable? + Settings + + + + ); +} + +// Shopify needs React Router to catch some thrown responses so headers are included. +export function ErrorBoundary() { + return boundary.error(useRouteError()); +} + +export const headers: HeadersFunction = (headersArgs) => boundary.headers(headersArgs); diff --git a/app/routes/auth.$.tsx b/app/routes/auth.$.tsx new file mode 100644 index 0000000..341fe4a --- /dev/null +++ b/app/routes/auth.$.tsx @@ -0,0 +1,10 @@ +import type { HeadersFunction, LoaderFunctionArgs } from "react-router"; +import { boundary } from "@shopify/shopify-app-react-router/server"; +import { authenticate } from "../shopify.server"; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + await authenticate.admin(request); + return null; +}; + +export const headers: HeadersFunction = (headersArgs) => boundary.headers(headersArgs); diff --git a/app/routes/auth.login.tsx b/app/routes/auth.login.tsx new file mode 100644 index 0000000..dc11f3a --- /dev/null +++ b/app/routes/auth.login.tsx @@ -0,0 +1,31 @@ +import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router"; +import { Form, useActionData, useLoaderData } from "react-router"; +import { LoginErrorType } from "@shopify/shopify-app-react-router/server"; +import { login } from "../shopify.server"; + +function message(errors: Awaited> | undefined) { + if (!errors || errors instanceof Response) return null; + if (errors.shop === LoginErrorType.MissingShop) return "Enter your shop domain to log in"; + if (errors.shop === LoginErrorType.InvalidShop) return "Enter a valid shop domain, like your-store.myshopify.com"; + return null; +} + +export const loader = async ({ request }: LoaderFunctionArgs) => ({ error: message(await login(request)) }); +export const action = async ({ request }: ActionFunctionArgs) => ({ error: message(await login(request)) }); + +export default function Login() { + const loaderData = useLoaderData(); + const actionData = useActionData(); + const error = actionData?.error ?? loaderData.error; + return ( +
+

Log in to Datewise

+
+ + + {error &&

{error}

} + +
+
+ ); +} diff --git a/app/routes/healthz.tsx b/app/routes/healthz.tsx new file mode 100644 index 0000000..1a06925 --- /dev/null +++ b/app/routes/healthz.tsx @@ -0,0 +1,6 @@ +import prisma from "../db.server"; + +export const loader = async () => { + await prisma.$queryRaw`SELECT 1`; + return Response.json({ ok: true }); +}; diff --git a/app/routes/proxy.availability.tsx b/app/routes/proxy.availability.tsx new file mode 100644 index 0000000..b70b987 --- /dev/null +++ b/app/routes/proxy.availability.tsx @@ -0,0 +1,7 @@ +import type { ActionFunctionArgs } from "react-router"; +import { proxyHandler } from "../lib/proxy.server"; +import { getAvailability } from "../services/storefront.server"; + +// POST /apps/datewise/availability { lines, method?, locationId?, postal?, token?, locale? } +export const action = ({ request }: ActionFunctionArgs) => + proxyHandler(request, 120, ({ shop, admin, body }) => getAvailability(shop, admin, body)); diff --git a/app/routes/proxy.hold.tsx b/app/routes/proxy.hold.tsx new file mode 100644 index 0000000..53aedb1 --- /dev/null +++ b/app/routes/proxy.hold.tsx @@ -0,0 +1,10 @@ +import type { ActionFunctionArgs } from "react-router"; +import { proxyHandler } from "../lib/proxy.server"; +import { holdSelection } from "../services/storefront.server"; + +// POST /apps/datewise/hold { lines, method, locationId, date, slot, token? } +export const action = ({ request }: ActionFunctionArgs) => + proxyHandler(request, 30, async ({ shop, admin, body }) => { + if (!admin) return { ok: false, message: "App not installed" }; + return holdSelection(shop, admin, body); + }); diff --git a/app/routes/proxy.offer.tsx b/app/routes/proxy.offer.tsx new file mode 100644 index 0000000..0099ca0 --- /dev/null +++ b/app/routes/proxy.offer.tsx @@ -0,0 +1,7 @@ +import type { LoaderFunctionArgs } from "react-router"; +import { proxyHandler } from "../lib/proxy.server"; +import { adoptOffer } from "../services/storefront.server"; + +// GET /apps/datewise/offer?token=wl_… (link sent to the first person on a waitlist) +export const loader = ({ request }: LoaderFunctionArgs) => + proxyHandler(request, 20, ({ shop, url }) => adoptOffer(shop, url.searchParams.get("token") ?? "")); diff --git a/app/routes/proxy.release.tsx b/app/routes/proxy.release.tsx new file mode 100644 index 0000000..a70377a --- /dev/null +++ b/app/routes/proxy.release.tsx @@ -0,0 +1,8 @@ +import type { ActionFunctionArgs } from "react-router"; +import { z } from "zod"; +import { proxyHandler } from "../lib/proxy.server"; +import { releaseSelection } from "../services/storefront.server"; + +// POST /apps/datewise/release { token } +export const action = ({ request }: ActionFunctionArgs) => + proxyHandler(request, 30, ({ shop, body }) => releaseSelection(shop, z.object({ token: z.string().min(8).max(80) }).parse(body).token)); diff --git a/app/routes/proxy.waitlist.tsx b/app/routes/proxy.waitlist.tsx new file mode 100644 index 0000000..2a0868d --- /dev/null +++ b/app/routes/proxy.waitlist.tsx @@ -0,0 +1,6 @@ +import type { ActionFunctionArgs } from "react-router"; +import { proxyHandler } from "../lib/proxy.server"; +import { joinWaitlist } from "../services/storefront.server"; + +// POST /apps/datewise/waitlist { method, locationId, date, slot?, email?, phone?, name? } +export const action = ({ request }: ActionFunctionArgs) => proxyHandler(request, 10, ({ shop, body }) => joinWaitlist(shop, body)); diff --git a/app/routes/webhooks.app.tsx b/app/routes/webhooks.app.tsx new file mode 100644 index 0000000..66c55a3 --- /dev/null +++ b/app/routes/webhooks.app.tsx @@ -0,0 +1,23 @@ +import type { ActionFunctionArgs } from "react-router"; +import prisma from "../db.server"; +import { authenticate } from "../shopify.server"; + +/** + * app/uninstalled: storefront behaviour stops immediately (proxy calls fail without a + * session and the theme extension is removed by Shopify). Data is kept for 48 hours so a + * quick reinstall keeps bookings; Shopify's shop/redact webhook then deletes everything. + */ +export const action = async ({ request }: ActionFunctionArgs) => { + const { shop, topic, session, payload } = await authenticate.webhook(request); + if (topic === "APP_UNINSTALLED") { + if (session) await prisma.session.deleteMany({ where: { shop } }); + await prisma.shop.updateMany({ where: { domain: shop }, data: { uninstalledAt: new Date() } }); + await prisma.hold.deleteMany({ where: { shop: { domain: shop } } }); + await prisma.reminderJob.updateMany({ where: { shop: { domain: shop }, status: "pending" }, data: { status: "cancelled" } }); + } + if (topic === "APP_SCOPES_UPDATE" && session) { + const current = (payload as { current?: string[] }).current; + if (current) await prisma.session.update({ where: { id: session.id }, data: { scope: current.toString() } }); + } + return new Response(); +}; diff --git a/app/routes/webhooks.compliance.tsx b/app/routes/webhooks.compliance.tsx new file mode 100644 index 0000000..9590010 --- /dev/null +++ b/app/routes/webhooks.compliance.tsx @@ -0,0 +1,40 @@ +import { Prisma } from "@prisma/client"; +import type { ActionFunctionArgs } from "react-router"; +import prisma from "../db.server"; +import { authenticate } from "../shopify.server"; + +/** + * Mandatory privacy webhooks. + * customers/data_request → we hold booking contact details; logged for the merchant to fulfil. + * customers/redact → anonymise that customer's bookings and waitlist entries. + * shop/redact → delete all shop data (48h after uninstall). + */ +export const action = async ({ request }: ActionFunctionArgs) => { + const { shop, topic, payload } = await authenticate.webhook(request); + const p = payload as { customer?: { email?: string; phone?: string }; orders_to_redact?: number[] }; + const s = await prisma.shop.findUnique({ where: { domain: shop } }); + if (!s) return new Response(); + + if (topic === "CUSTOMERS_DATA_REQUEST") { + const n = await prisma.booking.count({ where: { shopId: s.id, customerEmail: p.customer?.email ?? "__none__" } }); + console.info(`data_request for ${shop}: ${n} bookings hold this customer's contact details`); + } + if (topic === "CUSTOMERS_REDACT") { + const orderIds = (p.orders_to_redact ?? []).map((id) => `gid://shopify/Order/${id}`); + const where = { + shopId: s.id, + OR: [{ orderId: { in: orderIds } }, ...(p.customer?.email ? [{ customerEmail: p.customer.email }] : [])], + }; + await prisma.booking.updateMany({ + where, + data: { customerName: null, customerEmail: null, customerPhone: null, address: Prisma.DbNull }, + }); + if (p.customer?.email || p.customer?.phone) + await prisma.waitlistEntry.deleteMany({ where: { shopId: s.id, OR: [{ email: p.customer?.email ?? "__" }, { phone: p.customer?.phone ?? "__" }] } }); + } + if (topic === "SHOP_REDACT") { + await prisma.shop.delete({ where: { id: s.id } }); + await prisma.session.deleteMany({ where: { shop } }); + } + return new Response(); +}; diff --git a/app/routes/webhooks.orders.cancelled.tsx b/app/routes/webhooks.orders.cancelled.tsx new file mode 100644 index 0000000..a909df7 --- /dev/null +++ b/app/routes/webhooks.orders.cancelled.tsx @@ -0,0 +1,11 @@ +import type { ActionFunctionArgs } from "react-router"; +import { authenticate } from "../shopify.server"; +import { firstDelivery, handleOrderCancelled } from "../services/orders.server"; + +export const action = async ({ request }: ActionFunctionArgs) => { + const { shop, topic, payload, webhookId } = await authenticate.webhook(request); + const eventId = request.headers.get("x-shopify-event-id") ?? webhookId; + if (!(await firstDelivery(eventId, topic, shop))) return new Response(); + await handleOrderCancelled(shop, payload as { admin_graphql_api_id: string }); + return new Response(); +}; diff --git a/app/routes/webhooks.orders.create.tsx b/app/routes/webhooks.orders.create.tsx new file mode 100644 index 0000000..0914d6c --- /dev/null +++ b/app/routes/webhooks.orders.create.tsx @@ -0,0 +1,19 @@ +import type { ActionFunctionArgs } from "react-router"; +import prisma from "../db.server"; +import { authenticate } from "../shopify.server"; +import { firstDelivery, handleOrderCreate, type OrderPayload } from "../services/orders.server"; + +export const action = async ({ request }: ActionFunctionArgs) => { + const { shop, topic, payload, admin, webhookId } = await authenticate.webhook(request); + const eventId = request.headers.get("x-shopify-event-id") ?? webhookId; + if (!(await firstDelivery(eventId, topic, shop))) return new Response(); + try { + await handleOrderCreate(shop, payload as OrderPayload, admin); + } catch (e) { + console.error("orders/create failed", shop, e); + // Let Shopify retry; the idempotency row must not block the retry. + await prisma.processedWebhook.delete({ where: { id: eventId } }).catch(() => {}); + return new Response("retry", { status: 500 }); + } + return new Response(); +}; diff --git a/app/routes/webhooks.orders.fulfilled.tsx b/app/routes/webhooks.orders.fulfilled.tsx new file mode 100644 index 0000000..1f029cc --- /dev/null +++ b/app/routes/webhooks.orders.fulfilled.tsx @@ -0,0 +1,9 @@ +import type { ActionFunctionArgs } from "react-router"; +import { authenticate } from "../shopify.server"; +import { handleOrderFulfilled } from "../services/orders.server"; + +export const action = async ({ request }: ActionFunctionArgs) => { + const { shop, payload } = await authenticate.webhook(request); + await handleOrderFulfilled(shop, payload as { admin_graphql_api_id: string }); + return new Response(); +}; diff --git a/app/services/__tests__/orders.server.test.ts b/app/services/__tests__/orders.server.test.ts new file mode 100644 index 0000000..0f49018 --- /dev/null +++ b/app/services/__tests__/orders.server.test.ts @@ -0,0 +1,106 @@ +/** + * Order webhook → booking, against real Postgres (TEST_DATABASE_URL). + * Admin API calls (tagging/metafields) are skipped by passing no admin client. + */ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; + +const url = process.env.TEST_DATABASE_URL; +if (url) process.env.DATABASE_URL = url; +const d = url ? describe : describe.skip; + +d("order webhooks", async () => { + const { default: prisma } = await import("../../db.server"); + const { handleOrderCreate, handleOrderCancelled } = await import("../orders.server"); + const { ReservationService } = await import("../reservations.server"); + const { withDefaults } = await import("../settings"); + const svc = new ReservationService(prisma); + const SHOP = "orders-test.myshopify.com"; + let shopId = ""; + let locId = ""; + const sel = (o: Record = {}) => ({ t: "tok_orders_test_0001", m: "delivery", l: locId, d: "2030-06-04", s: "10:00-13:00", f: 799, v: 1, ...o }); + const order = (id: number, attr: object | null, feePrice = "7.99") => ({ + id, + admin_graphql_api_id: `gid://shopify/Order/${id}`, + name: `#${id}`, + email: "priya@example.com", + phone: "+15195550142", + note_attributes: attr ? [{ name: "_datewise", value: JSON.stringify(attr) }] : [], + customer: { first_name: "Priya", last_name: "R" }, + shipping_address: { address1: "1 King St", city: "Kitchener", zip: "N2H 1A1", latitude: 43.45, longitude: -80.49 }, + line_items: [ + { title: "Marigold", quantity: 2, price: "19.99", product_id: 101, variant_id: 1001 }, + ...(feePrice ? [{ title: "Delivery & handling", quantity: 1, price: feePrice, product_id: 900, variant_id: 9000 }] : []), + ], + }); + + beforeAll(async () => { + await prisma.shop.deleteMany({ where: { domain: SHOP } }); + }); + afterAll(async () => { + await prisma.shop.deleteMany({ where: { domain: SHOP } }); + }); + beforeEach(async () => { + await prisma.shop.deleteMany({ where: { domain: SHOP } }); + const shop = await prisma.shop.create({ + data: { + domain: SHOP, + plan: "Growth", + feeProductId: "gid://shopify/Product/900", + settings: withDefaults({}) as object, + locations: { + create: [{ + name: "Kitchener", + address: "x", + timezone: "America/Toronto", + methods: { delivery: { enabled: true, weeklyHours: { 2: [{ start: "10:00", end: "19:00" }] }, slotMode: "generated", slotMinutes: 180, prepMinutes: 0, prepMode: "elapsed", horizonDays: 3650, slotCapacity: 1 } }, + }], + }, + }, + include: { locations: true }, + }); + shopId = shop.id; + locId = shop.locations[0].id; + }); + + const hold = (token = "tok_orders_test_0001", fee = 799) => + svc.hold(shopId, token, { locationId: locId, method: "delivery", date: "2030-06-04", slot: "10:00-13:00" }, { dayCap: null, slotCap: 1 }, new Date(), 10, fee); + + it("confirms the held place, stores contact + items, plans reminders", async () => { + await hold(); + const r = await handleOrderCreate(SHOP, order(1, sel())); + expect("ok" in r && r.ok).toBe(true); + const b = await prisma.booking.findFirstOrThrow({ where: { shopId }, include: { reminderJobs: true } }); + expect(b).toMatchObject({ status: "confirmed", customerName: "Priya R", fee: 799, slotStart: "10:00", reviewReason: null }); + expect(b.startUtc?.toISOString()).toBe("2030-06-04T14:00:00.000Z"); + expect(b.reminderJobs.length).toBeGreaterThan(0); + }); + + it("is idempotent for the same order", async () => { + await hold(); + await handleOrderCreate(SHOP, order(2, sel())); + await handleOrderCreate(SHOP, order(2, sel())); + expect(await prisma.booking.count({ where: { shopId } })).toBe(1); + }); + + it("flags a fee that doesn't match the quote, but keeps the place", async () => { + await hold(); + await handleOrderCreate(SHOP, order(3, sel(), "0.01")); + const b = await prisma.booking.findFirstOrThrow({ where: { shopId } }); + expect(b.status).toBe("confirmed"); + expect(b.reviewReason).toMatch(/Fee paid 0.01 differs from quote 7.99/); + }); + + it("orders without a time go to review when scheduling is required", async () => { + await handleOrderCreate(SHOP, order(4, null, "")); + const b = await prisma.booking.findFirstOrThrow({ where: { shopId } }); + expect(b.status).toBe("needs_review"); + }); + + it("cancellation frees the place for the next customer", async () => { + await hold(); + await handleOrderCreate(SHOP, order(5, sel())); + expect((await hold("tok_orders_test_0002")).ok).toBe(false); + await handleOrderCancelled(SHOP, { admin_graphql_api_id: "gid://shopify/Order/5" }); + expect((await hold("tok_orders_test_0002")).ok).toBe(true); + }); +}); diff --git a/app/services/__tests__/reservations.server.test.ts b/app/services/__tests__/reservations.server.test.ts new file mode 100644 index 0000000..0e74eca --- /dev/null +++ b/app/services/__tests__/reservations.server.test.ts @@ -0,0 +1,159 @@ +/** + * Runs against a real Postgres. Set TEST_DATABASE_URL (schema from prisma/migrations applied). + * TEST_DATABASE_URL=postgresql://postgres@localhost:5433/datewise_test npx vitest run app/services + */ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { createPrisma } from "../../db.server"; +import { ReservationService } from "../reservations.server"; +import type { PrismaClient } from "@prisma/client"; + +const url = process.env.TEST_DATABASE_URL; +const d = url ? describe : describe.skip; + +d("ReservationService (Postgres)", () => { + let db: PrismaClient; + let svc: ReservationService; + let shopId: string; + const sel = { locationId: "kit", method: "delivery" as const, date: "2026-11-08", slot: "10:00-13:00" }; + const now = new Date("2026-11-05T15:00:00Z"); + const details = { timezone: "America/Toronto", configVersion: 1, snapshot: {} }; + + beforeAll(async () => { + db = createPrisma(url); + svc = new ReservationService(db); + }); + afterAll(async () => db.$disconnect()); + beforeEach(async () => { + await db.shop.deleteMany({ where: { domain: "test.myshopify.com" } }); + const s = await db.shop.create({ data: { domain: "test.myshopify.com", settings: {} } }); + shopId = s.id; + }); + + it("25 concurrent shoppers, 3 places → exactly 3 holds", async () => { + const caps = { dayCap: 30, slotCap: 3 }; + const results = await Promise.all( + Array.from({ length: 25 }, (_, i) => svc.hold(shopId, `t${i}`, sel, caps, now, 10)), + ); + expect(results.filter((r) => r.ok)).toHaveLength(3); + const c = await db.capacityCounter.findMany({ where: { shopId } }); + expect(c.find((x) => x.slot === "*")!.count).toBe(3); + expect(c.find((x) => x.slot === sel.slot)!.count).toBe(3); + }); + + it("expired holds free capacity inside the next hold", async () => { + const caps = { dayCap: 30, slotCap: 1 }; + expect((await svc.hold(shopId, "a", sel, caps, now, 10)).ok).toBe(true); + expect((await svc.hold(shopId, "b", sel, caps, now, 10)).ok).toBe(false); + const later = new Date(now.getTime() + 11 * 60000); + expect((await svc.hold(shopId, "b", sel, caps, later, 10)).ok).toBe(true); + expect(await db.hold.count({ where: { shopId } })).toBe(1); + }); + + it("moving a hold releases the previous slot", async () => { + const caps = { dayCap: 30, slotCap: 1 }; + await svc.hold(shopId, "a", sel, caps, now, 10); + await svc.hold(shopId, "a", { ...sel, slot: "13:00-16:00" }, caps, now, 10); + expect((await svc.hold(shopId, "b", sel, caps, now, 10)).ok).toBe(true); + }); + + it("usage excludes stale holds and the caller's own hold", async () => { + const caps = { dayCap: 30, slotCap: 5 }; + await svc.hold(shopId, "a", sel, caps, now, 10); + await svc.hold(shopId, "b", sel, caps, now, 1); + const u = await svc.usage(shopId, "2026-11-01", "2026-11-30", new Date(now.getTime() + 2 * 60000), "a"); + expect(u["kit|delivery|2026-11-08"].slots[sel.slot]).toBe(0); + }); + + it("concurrent duplicate webhooks create one booking", async () => { + const caps = { dayCap: 30, slotCap: 5 }; + await svc.hold(shopId, "a", sel, caps, now, 10); + const rs = await Promise.all( + Array.from({ length: 6 }, () => svc.confirm(shopId, "order-1", "a", null, caps, now, details)), + ); + expect(await db.booking.count({ where: { shopId } })).toBe(1); + expect(rs.filter((r) => r.ok && !r.duplicate)).toHaveLength(1); + const c = await db.capacityCounter.findFirst({ where: { shopId, slot: sel.slot } }); + expect(c!.count).toBe(1); + }); + + it("expired hold at order time: re-reserve when free, flag when full", async () => { + const caps = { dayCap: 30, slotCap: 1 }; + await svc.hold(shopId, "a", sel, caps, now, 10); + const late = new Date(now.getTime() + 15 * 60000); + const ok = await svc.confirm(shopId, "o1", "a", null, caps, late, details); + expect(ok.ok && ok.reReserved).toBe(true); + + await svc.hold(shopId, "x", { ...sel, slot: "13:00-16:00" }, caps, now, 10); + await svc.hold(shopId, "y", { ...sel, slot: "13:00-16:00" }, caps, new Date(now.getTime() + 11 * 60000), 10); + const flagged = await svc.confirm(shopId, "o2", "x", null, caps, new Date(now.getTime() + 12 * 60000), details); + expect(flagged.ok).toBe(false); + const b = await db.booking.findUnique({ where: { id: flagged.bookingId } }); + expect(b!.status).toBe("needs_review"); + const c = await db.capacityCounter.findFirst({ where: { shopId, slot: "13:00-16:00" } }); + expect(c!.count).toBe(1); // only y's hold — no overbooking + }); + + it("order without a selection lands in review", async () => { + const r = await svc.confirm(shopId, "o9", null, null, { dayCap: 1, slotCap: 1 }, now, details); + expect(r.ok).toBe(false); + }); + + it("reschedule: atomic swap, failure preserves, audit recorded", async () => { + const caps = { dayCap: 30, slotCap: 1 }; + await svc.hold(shopId, "a", sel, caps, now, 10); + const r = await svc.confirm(shopId, "o1", "a", null, caps, now, details); + await svc.hold(shopId, "other", { ...sel, slot: "13:00-16:00" }, caps, now, 10); + const fail = await svc.reschedule(shopId, r.bookingId, { ...sel, slot: "13:00-16:00" }, caps, now, "customer"); + expect(fail.ok).toBe(false); + expect((await db.booking.findUnique({ where: { id: r.bookingId } }))!.slotStart).toBe("10:00"); + const ok = await svc.reschedule(shopId, r.bookingId, { ...sel, slot: "16:00-19:00" }, caps, now, "customer"); + expect(ok.ok && ok.freed?.slot).toBe("10:00-13:00"); + const counters = await db.capacityCounter.findMany({ where: { shopId } }); + expect(counters.find((c) => c.slot === "10:00-13:00")!.count).toBe(0); + expect(counters.find((c) => c.slot === "16:00-19:00")!.count).toBe(1); + expect(counters.find((c) => c.slot === "*")!.count).toBe(2); // booking + other's hold + const b = await db.booking.findUnique({ where: { id: r.bookingId } }); + expect(b!.rescheduleCount).toBe(1); + expect(await db.bookingEvent.count({ where: { bookingId: r.bookingId, action: "rescheduled" } })).toBe(1); + }); + + it("concurrent reschedules across two dates don't deadlock or overbook", async () => { + const caps = { dayCap: 30, slotCap: 1 }; + const a = { ...sel, date: "2026-11-07" }; + const b = { ...sel, date: "2026-11-08" }; + await svc.hold(shopId, "h1", a, caps, now, 10); + const r1 = await svc.confirm(shopId, "o1", "h1", null, caps, now, details); + await svc.hold(shopId, "h2", b, caps, now, 10); + const r2 = await svc.confirm(shopId, "o2", "h2", null, caps, now, details); + // Swap attempts in opposite directions at once: both target full slots → both fail cleanly. + const [x, y] = await Promise.all([ + svc.reschedule(shopId, r1.bookingId, b, caps, now, "merchant:1"), + svc.reschedule(shopId, r2.bookingId, a, caps, now, "merchant:2"), + ]); + expect(x.ok || y.ok).toBe(false); + const total = await db.capacityCounter.findMany({ where: { shopId, slot: sel.slot } }); + expect(total.every((t) => t.count <= 1)).toBe(true); + }); + + it("cancel frees capacity once and is idempotent", async () => { + const caps = { dayCap: 30, slotCap: 1 }; + await svc.hold(shopId, "a", sel, caps, now, 10); + await svc.confirm(shopId, "o1", "a", null, caps, now, details); + const c1 = await svc.cancel(shopId, "o1"); + const c2 = await svc.cancel(shopId, "o1"); + expect(c1.freed?.slot).toBe(sel.slot); + expect(c2.freed).toBeNull(); + const c = await db.capacityCounter.findFirst({ where: { shopId, slot: sel.slot } }); + expect(c!.count).toBe(0); + }); + + it("background sweep releases expired holds", async () => { + const caps = { dayCap: 30, slotCap: 5 }; + await svc.hold(shopId, "a", sel, caps, now, 1); + await svc.hold(shopId, "b", sel, caps, now, 30); + const n = await svc.sweepExpired(new Date(now.getTime() + 5 * 60000), 500, shopId); + expect(n).toBe(1); + const c = await db.capacityCounter.findFirst({ where: { shopId, slot: sel.slot } }); + expect(c!.count).toBe(1); + }); +}); diff --git a/app/services/bookings.server.ts b/app/services/bookings.server.ts new file mode 100644 index 0000000..0ce9da8 --- /dev/null +++ b/app/services/bookings.server.ts @@ -0,0 +1,104 @@ +/** + * Booking changes initiated by staff or customers. Customers go through the same engine + * validation as checkout (rules, cutoffs, freshness, capacity); staff may override capacity. + */ +import prisma from "../db.server"; +import { computeAvailability, validateSelection } from "../engine/availability"; +import { capsFor, type Selection } from "../engine/capacity"; +import { canCustomerReschedule } from "../engine/customer"; +import { addDays, localParts, zonedToUtc } from "../engine/time"; +import type { Cart, Method } from "../engine/types"; +import { loadShopConfig } from "./config.server"; +import { features } from "./plans"; +import { rowToBooking, scheduleReminders } from "./reminders.server"; +import { ReservationService } from "./reservations.server"; +import { offerFreedPlace } from "./waitlist.server"; + +const reservations = new ReservationService(prisma); + +export async function rescheduleBooking(opts: { + domain: string; + bookingId: string; + sel: Selection; + actor: string; // "customer" | "merchant:" + force?: boolean; +}) { + const cfg = await loadShopConfig(opts.domain, { fresh: true }); + const row = await prisma.booking.findFirst({ where: { id: opts.bookingId, shopId: cfg.shop.id } }); + if (!row) return { ok: false as const, message: "Booking not found" }; + const loc = cfg.locations.find((l) => l.id === opts.sel.locationId); + if (!loc) return { ok: false as const, message: "Unknown location" }; + const now = Date.now(); + const isCustomer = opts.actor === "customer"; + + if (isCustomer) { + const can = canCustomerReschedule(rowToBooking(row), cfg.settings.reschedule, now); + if (!can.ok) return { ok: false as const, message: can.reason }; + if (!cfg.settings.reschedule.allowMethodChange && (opts.sel.method !== row.method || opts.sel.locationId !== row.locationId)) + return { ok: false as const, message: "Only the date and time can be changed" }; + } + + // Customers: full rule check with the booked items. Staff: hours/closures only (no product rules). + const items = ((row.items as { title: string; quantity: number }[] | null) ?? []).map((i, n) => ({ + productId: `item-${n}`, variantId: `item-${n}`, title: i.title, quantity: i.quantity, price: 0, + })); + const cart: Cart = { items: isCustomer ? items : [] }; + const today = localParts(now, cfg.shop.timezone).date; + const usage = await reservations.usage(cfg.shop.id, addDays(today, -1), addDays(today, 370), new Date(now)); + if (row.status === "confirmed" && row.locationId && row.date && row.slotStart) { + // Don't count the booking against itself when re-validating. + const k = `${row.locationId}|${row.method}|${row.date}`; + if (usage[k]) { + usage[k].day = Math.max(0, usage[k].day - 1); + const sk = `${row.slotStart}-${row.slotEnd}`; + usage[k].slots[sk] = Math.max(0, (usage[k].slots[sk] ?? 0) - 1); + } + } + const peaks = features(cfg.shop.plan).peakDays ? cfg.peakDays : []; + const v = validateSelection( + { location: loc, method: opts.sel.method as Method, cart, rules: isCustomer ? cfg.rules : [], peakDays: peaks, usage, settings: cfg.settings, now }, + opts.sel.date, + opts.sel.slot, + ); + const capacityOnly = !v.ok && v.reasons.every((r) => r.code === "SLOT_FULL" || r.code === "DAY_FULL"); + if (!v.ok && !(opts.force && !isCustomer && capacityOnly)) return { ok: false as const, message: v.reasons[0]?.message ?? "Not available" }; + + const [s, e] = opts.sel.slot.split("-"); + const r = await reservations.reschedule(cfg.shop.id, row.id, opts.sel, capsFor(loc, opts.sel.method, opts.sel.date, opts.sel.slot, peaks), new Date(now), opts.actor, { + startUtc: new Date(zonedToUtc(opts.sel.date, s, loc.timezone)), + endUtc: new Date(zonedToUtc(opts.sel.date, e, loc.timezone)), + force: opts.force && !isCustomer, + }); + if (!r.ok) return r; + await scheduleReminders(row.id); + if (r.freed) await offerFreedPlace(opts.domain, r.freed); + return { ok: true as const }; +} + +/** Options for a reschedule picker: available slots for a booking's location/method. */ +export async function rescheduleOptions(domain: string, bookingId: string, opts: { customer: boolean }) { + const cfg = await loadShopConfig(domain); + const row = await prisma.booking.findFirst({ where: { id: bookingId, shopId: cfg.shop.id } }); + if (!row) return []; + const loc = cfg.locations.find((l) => l.id === (row.locationId || cfg.locations[0]?.id)); + if (!loc) return []; + const now = Date.now(); + const today = localParts(now, cfg.shop.timezone).date; + const usage = await reservations.usage(cfg.shop.id, addDays(today, -1), addDays(today, 370), new Date(now)); + const items = ((row.items as { title: string; quantity: number }[] | null) ?? []).map((i, n) => ({ productId: `item-${n}`, variantId: `item-${n}`, title: i.title, quantity: i.quantity, price: 0 })); + const av = computeAvailability({ + location: loc, + method: row.method as Method, + cart: { items: opts.customer ? items : [] }, + rules: opts.customer ? cfg.rules : [], + peakDays: features(cfg.shop.plan).peakDays ? cfg.peakDays : [], + usage, + settings: cfg.settings, + now, + }); + return av.dates.flatMap((d) => + d.slots + .filter((s) => s.available || (!opts.customer && s.reasons.every((r) => /FULL/.test(r.code)))) + .map((s) => ({ locationId: loc.id, method: row.method, date: d.date, slot: s.id, start: s.start, end: s.end, remaining: s.remaining, full: !s.available })), + ); +} diff --git a/app/services/catalog.server.ts b/app/services/catalog.server.ts new file mode 100644 index 0000000..04c2540 --- /dev/null +++ b/app/services/catalog.server.ts @@ -0,0 +1,118 @@ +/** + * Cart enrichment: the storefront only knows variant ids and quantities, so we look up + * collections, tags, vendor, type and per-location inventory with the Admin API. + * Results are cached briefly per shop+variant to keep the widget fast. + */ +import type { Cart, CartItem } from "../engine/types"; +import type { Inventory } from "../engine/locations"; + +type AdminGraphql = { graphql: (q: string, o?: { variables?: Record }) => Promise }; + +export interface StorefrontLine { + variantId: string | number; + quantity: number; + price?: number; // minor units, from /cart.js + title?: string; +} + +interface VariantInfo { + item: Omit & { price: number }; + stock: Record; // shopify location gid → available + tracked: boolean; + isFee: boolean; +} + +const cache = new Map(); +const TTL = 60_000; +const gid = (id: string | number) => (String(id).startsWith("gid://") ? String(id) : `gid://shopify/ProductVariant/${id}`); + +export const FEE_TAG = "datewise-fee"; + +export async function enrichCart( + admin: AdminGraphql, + shop: string, + lines: StorefrontLine[], + shopifyToAppLocation: Record, +): Promise<{ cart: Cart; inventory: Inventory; feeLines: StorefrontLine[] }> { + const ids = [...new Set(lines.map((l) => gid(l.variantId)))]; + const missing = ids.filter((id) => { + const c = cache.get(`${shop}|${id}`); + return !c || Date.now() - c.at > TTL; + }); + for (let i = 0; i < missing.length; i += 50) { + const res = await admin.graphql( + `#graphql + query DatewiseCart($ids: [ID!]!) { + nodes(ids: $ids) { + ... on ProductVariant { + id title price + product { + id title vendor productType tags + collections(first: 50) { nodes { id } } + } + inventoryItem { + tracked + inventoryLevels(first: 30) { + nodes { location { id } quantities(names: ["available"]) { quantity } } + } + } + } + } + }`, + { variables: { ids: missing.slice(i, i + 50) } }, + ); + const json = (await res.json()) as { data: { nodes: (RawVariant | null)[] } }; + for (const n of json.data.nodes) { + if (!n) continue; + const tracked = n.inventoryItem?.tracked ?? false; + const stock: Record = {}; + for (const lvl of n.inventoryItem?.inventoryLevels.nodes ?? []) + stock[lvl.location.id] = lvl.quantities[0]?.quantity ?? 0; + cache.set(`${shop}|${n.id}`, { + at: Date.now(), + v: { + isFee: n.product.tags.includes(FEE_TAG), + tracked, + stock, + item: { + productId: n.product.id, + variantId: n.id, + title: n.product.title + (n.title && n.title !== "Default Title" ? ` – ${n.title}` : ""), + price: Math.round(Number(n.price) * 100), + collectionIds: n.product.collections.nodes.map((c) => c.id), + vendor: n.product.vendor, + productType: n.product.productType, + tags: n.product.tags, + }, + }, + }); + } + } + + const items: CartItem[] = []; + const inventory: Inventory = {}; + const feeLines: StorefrontLine[] = []; + for (const l of lines) { + const info = cache.get(`${shop}|${gid(l.variantId)}`)?.v; + if (!info) continue; + if (info.isFee) { + feeLines.push(l); + continue; + } + items.push({ ...info.item, quantity: l.quantity, price: l.price ?? info.item.price }); + // Every linked location gets an explicit figure; untracked items count as unlimited. + for (const [shopifyLoc, appLoc] of Object.entries(shopifyToAppLocation)) { + inventory[appLoc] ??= {}; + inventory[appLoc][info.item.variantId] = info.tracked ? info.stock[shopifyLoc] ?? 0 : Number.MAX_SAFE_INTEGER; + } + } + return { cart: { items, subtotal: items.reduce((s, i) => s + i.price * i.quantity, 0) }, inventory, feeLines }; +} + +interface RawVariant { + id: string; + title: string; + price: string; + product: { id: string; title: string; vendor: string; productType: string; tags: string[]; collections: { nodes: { id: string }[] } }; + inventoryItem: { tracked: boolean; inventoryLevels: { nodes: { location: { id: string }; quantities: { quantity: number }[] }[] } } | null; +} diff --git a/app/services/config.server.ts b/app/services/config.server.ts new file mode 100644 index 0000000..ed2223d --- /dev/null +++ b/app/services/config.server.ts @@ -0,0 +1,158 @@ +/** + * Loads a shop's scheduling configuration and maps DB rows to engine types. + */ +import type { Location, PeakDay as PeakRow, ProductRule as RuleRow, Shop } from "@prisma/client"; +import prisma from "../db.server"; +import type { LocationConfig, Method, PeakDay, ProductRule } from "../engine/types"; +import { type AppSettings, withDefaults } from "./settings"; + +export interface ShopConfig { + shop: Shop; + settings: AppSettings; + locations: LocationConfig[]; + rules: ProductRule[]; + peakDays: PeakDay[]; + version: number; +} + +export function toLocationConfig(l: Location): LocationConfig { + return { + id: l.id, + shopifyLocationId: l.shopifyLocationId ?? undefined, + name: l.name, + address: l.address, + timezone: l.timezone, + lat: l.lat ?? undefined, + lng: l.lng ?? undefined, + enabled: l.enabled, + priority: l.priority, + pickupInstructions: l.pickupInstructions ?? undefined, + methods: l.methods as unknown as LocationConfig["methods"], + exceptions: (l.exceptions ?? []) as unknown as LocationConfig["exceptions"], + zones: (l.zones ?? []) as unknown as LocationConfig["zones"], + }; +} + +export function toRule(r: RuleRow): ProductRule { + return { + id: r.id, + name: r.name, + enabled: r.enabled, + priority: r.priority, + target: r.target as unknown as ProductRule["target"], + action: r.action as unknown as ProductRule["action"], + message: r.message ?? undefined, + }; +} + +export function toPeak(p: PeakRow): PeakDay { + return { + id: p.id, + name: p.name, + dates: p.dates, + locationIds: p.locationIds.length ? p.locationIds : undefined, + methods: p.methods.length ? (p.methods as Method[]) : undefined, + dailyCapacity: p.dailyCapacity, + slotCapacity: p.slotCapacity, + extraPrepMinutes: p.extraPrepMinutes, + cutoff: p.cutoffDaysBefore != null && p.cutoffTime ? { daysBefore: p.cutoffDaysBefore, time: p.cutoffTime } : null, + surcharge: p.surcharge, + waitlist: p.waitlist, + message: p.message ?? undefined, + }; +} + +const cache = new Map(); +const TTL = 5000; + +export async function loadShopConfig(domain: string, opts: { fresh?: boolean } = {}): Promise { + const hit = cache.get(domain); + if (!opts.fresh && hit && Date.now() - hit.at < TTL) return hit.cfg; + const shop = await prisma.shop.findUnique({ + where: { domain }, + include: { locations: { orderBy: { priority: "asc" } }, rules: true, peakDays: true }, + }); + if (!shop || shop.uninstalledAt) throw new Response("Shop not installed", { status: 404 }); + const cfg: ShopConfig = { + shop, + settings: withDefaults(shop.settings), + locations: shop.locations.map(toLocationConfig), + rules: shop.rules.map(toRule), + peakDays: shop.peakDays.map(toPeak), + version: shop.configVersion, + }; + cache.set(domain, { at: Date.now(), cfg }); + return cfg; +} + +/** Call after any configuration write. Bookings keep the version they were accepted under. */ +export async function bumpConfig(shopId: string, domain: string) { + await prisma.shop.update({ where: { id: shopId }, data: { configVersion: { increment: 1 } } }); + cache.delete(domain); +} + +export async function getShop(domain: string) { + const shop = await prisma.shop.findUnique({ where: { domain } }); + if (!shop) throw new Response("Shop not found", { status: 404 }); + return shop; +} + +type AdminGraphql = { graphql: (q: string, o?: { variables?: Record }) => Promise }; + +const DEFAULT_HOURS = { start: "10:00", end: "18:00" }; + +/** + * First install: create the shop row and import Shopify locations as pickup-ready + * (disabled until the merchant reviews hours). Re-install: clear uninstalledAt. + */ +export async function ensureShop(domain: string, admin: AdminGraphql) { + const res = await admin.graphql(`#graphql + query DatewiseInstall { + shop { ianaTimezone currencyCode } + locations(first: 50, includeInactive: false) { + nodes { id name isActive fulfillsOnlineOrders address { formatted latitude longitude } } + } + }`); + const { data } = (await res.json()) as { + data: { + shop: { ianaTimezone: string; currencyCode: string }; + locations: { nodes: { id: string; name: string; fulfillsOnlineOrders: boolean; address: { formatted: string[]; latitude: number | null; longitude: number | null } }[] }; + }; + }; + const existing = await prisma.shop.findUnique({ where: { domain } }); + if (existing) { + await prisma.shop.update({ + where: { domain }, + data: { uninstalledAt: null, timezone: data.shop.ianaTimezone, currency: data.shop.currencyCode }, + }); + return existing; + } + const weekdays = Object.fromEntries([1, 2, 3, 4, 5, 6].map((d) => [d, [DEFAULT_HOURS]])); + return prisma.shop.create({ + data: { + domain, + timezone: data.shop.ianaTimezone, + currency: data.shop.currencyCode, + settings: withDefaults({}) as object, + locations: { + create: data.locations.nodes + .filter((l) => l.fulfillsOnlineOrders) + .map((l, i) => ({ + shopifyLocationId: l.id, + name: l.name, + address: l.address.formatted.join(", "), + timezone: data.shop.ianaTimezone, + lat: l.address.latitude, + lng: l.address.longitude, + enabled: false, + priority: i + 1, + methods: { + pickup: { enabled: true, weeklyHours: weekdays, slotMode: "generated", slotMinutes: 60, prepMinutes: 120, prepMode: "operating", sameDayCutoff: "14:00", horizonDays: 30, slotCapacity: 5 }, + delivery: { enabled: false, weeklyHours: weekdays, slotMode: "generated", slotMinutes: 180, prepMinutes: 240, prepMode: "elapsed", sameDayCutoff: "11:00", horizonDays: 30, slotCapacity: 5, dailyCapacity: 15 }, + shipping: { enabled: false, weeklyHours: Object.fromEntries([1, 2, 3, 4, 5].map((d) => [d, [DEFAULT_HOURS]])), slotMode: "dateOnly", prepMinutes: 0, prepMode: "operating", sameDayCutoff: "13:00", horizonDays: 14 }, + }, + })), + }, + }, + }); +} diff --git a/app/services/crypto.server.ts b/app/services/crypto.server.ts new file mode 100644 index 0000000..e339338 --- /dev/null +++ b/app/services/crypto.server.ts @@ -0,0 +1,22 @@ +/** AES-256-GCM for merchant messaging credentials. APP_ENCRYPTION_KEY = 32 random bytes, base64. */ +import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; + +function key(): Buffer { + const k = Buffer.from(process.env.APP_ENCRYPTION_KEY ?? "", "base64"); + if (k.length !== 32) throw new Error("APP_ENCRYPTION_KEY must be 32 bytes (base64)"); + return k; +} + +export function encrypt(plain: string): string { + const iv = randomBytes(12); + const c = createCipheriv("aes-256-gcm", key(), iv); + const enc = Buffer.concat([c.update(plain, "utf8"), c.final()]); + return [iv, c.getAuthTag(), enc].map((b) => b.toString("base64url")).join("."); +} + +export function decrypt(blob: string): string { + const [iv, tag, enc] = blob.split(".").map((p) => Buffer.from(p, "base64url")); + const d = createDecipheriv("aes-256-gcm", key(), iv); + d.setAuthTag(tag); + return Buffer.concat([d.update(enc), d.final()]).toString("utf8"); +} diff --git a/app/services/fees.server.ts b/app/services/fees.server.ts new file mode 100644 index 0000000..9fa1195 --- /dev/null +++ b/app/services/fees.server.ts @@ -0,0 +1,103 @@ +/** + * Charging delivery fees and peak surcharges on every Shopify plan. + * + * Carrier-calculated rates need Advanced/Plus (or the paid add-on), so instead the + * app manages one hidden "Delivery & handling" product tagged `datewise-fee`, with a + * variant per fee amount. The widget puts exactly one fee line in the cart that + * matches the quote; the checkout validation function and the order webhook both + * re-check it. + */ +import prisma from "../db.server"; +import { FEE_TAG } from "./catalog.server"; + +type AdminGraphql = { graphql: (q: string, o?: { variables?: Record }) => Promise }; + +async function gql(admin: AdminGraphql, q: string, variables?: Record): Promise { + const r = await admin.graphql(q, { variables }); + const j = (await r.json()) as { data: T; errors?: unknown }; + if (j.errors) throw new Error(`Admin API: ${JSON.stringify(j.errors)}`); + return j.data; +} + +const label = (cents: number) => (cents / 100).toFixed(2); +const numericId = (gid: string) => gid.split("/").pop()!; + +export async function ensureFeeProduct(admin: AdminGraphql, shopId: string): Promise { + const shop = await prisma.shop.findUniqueOrThrow({ where: { id: shopId } }); + if (shop.feeProductId) { + const d = await gql<{ product: { id: string } | null }>(admin, `#graphql + query($id: ID!) { product(id: $id) { id } }`, { id: shop.feeProductId }); + if (d.product) return d.product.id; + } + const created = await gql<{ productCreate: { product: { id: string } | null; userErrors: { message: string }[] } }>( + admin, + `#graphql + mutation DatewiseFeeProduct($product: ProductCreateInput!) { + productCreate(product: $product) { product { id } userErrors { message } } + }`, + { + product: { + title: "Delivery & handling", + handle: "datewise-delivery-fee", + tags: [FEE_TAG], + status: "ACTIVE", + productType: "Service", + descriptionHtml: "

Added automatically for scheduled delivery and pickup.

", + productOptions: [{ name: "Fee", values: [{ name: label(0) }] }], + seo: { title: "Delivery & handling" }, + }, + }, + ); + const id = created.productCreate.product?.id; + if (!id) throw new Error(created.productCreate.userErrors.map((e) => e.message).join("; ")); + // Hide from storefront search/collections. + await gql(admin, `#graphql + mutation($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) { userErrors { message } } }`, { + m: [{ ownerId: id, namespace: "seo", key: "hidden", type: "number_integer", value: "1" }], + }); + // Must be on the Online Store channel to be added to carts. + const pubs = await gql<{ publications: { nodes: { id: string; catalog: { title: string } | null }[] } }>(admin, `#graphql + query { publications(first: 20) { nodes { id catalog { title } } } }`); + const online = pubs.publications.nodes.find((p) => /online store/i.test(p.catalog?.title ?? "")); + if (online) + await gql(admin, `#graphql + mutation($id: ID!, $input: [PublicationInput!]!) { publishablePublish(id: $id, input: $input) { userErrors { message } } }`, { + id, + input: [{ publicationId: online.id }], + }); + await prisma.shop.update({ where: { id: shopId }, data: { feeProductId: id } }); + return id; +} + +/** Returns the numeric variant id for a fee amount, creating the variant when needed. */ +export async function ensureFeeVariant(admin: AdminGraphql, shopId: string, cents: number): Promise { + if (cents <= 0) return null; + const productId = await ensureFeeProduct(admin, shopId); + const d = await gql<{ product: { variants: { nodes: { id: string; price: string }[] } } }>(admin, `#graphql + query($id: ID!) { product(id: $id) { variants(first: 250) { nodes { id price } } } }`, { id: productId }); + const found = d.product.variants.nodes.find((v) => Math.round(Number(v.price) * 100) === cents); + if (found) return numericId(found.id); + const c = await gql<{ productVariantsBulkCreate: { productVariants: { id: string }[]; userErrors: { message: string }[] } }>( + admin, + `#graphql + mutation($productId: ID!, $variants: [ProductVariantsBulkInput!]!) { + productVariantsBulkCreate(productId: $productId, variants: $variants) { + productVariants { id } userErrors { message } + } + }`, + { + productId, + variants: [ + { + price: label(cents), + optionValues: [{ optionName: "Fee", name: label(cents) }], + inventoryPolicy: "CONTINUE", + inventoryItem: { tracked: false, requiresShipping: false }, + }, + ], + }, + ); + const v = c.productVariantsBulkCreate.productVariants[0]; + if (!v) throw new Error(c.productVariantsBulkCreate.userErrors.map((e) => e.message).join("; ")); + return numericId(v.id); +} diff --git a/app/services/geocode.server.ts b/app/services/geocode.server.ts new file mode 100644 index 0000000..04532a4 --- /dev/null +++ b/app/services/geocode.server.ts @@ -0,0 +1,69 @@ +/** + * Maps adapter (Google Maps Platform). Used only when a shop has radius or + * driving-distance zones; postal-code zones never call out. + * + * Failure policy: on timeout/error we return the address WITHOUT coordinates/driving + * distances. The engine then refuses distance-based zones ("couldn't verify your + * address") rather than accepting an unverified address. + */ +import type { LocationConfig } from "../engine/types"; +import type { CustomerAddress } from "../engine/zones"; + +const KEY = process.env.GOOGLE_MAPS_API_KEY; +const TIMEOUT = 2500; +const cache = new Map(); +const TTL = 24 * 3600_000; + +async function getJson(url: string): Promise { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT); + try { + const r = await fetch(url, { signal: ctrl.signal }); + if (!r.ok) throw new Error(`maps ${r.status}`); + return await r.json(); + } finally { + clearTimeout(t); + } +} + +export function needsMaps(locations: LocationConfig[]) { + return locations.some((l) => l.zones.some((z) => z.radiusKm != null || z.drivingKm != null)); +} + +export async function resolveAddress( + input: { postal?: string; address?: string; country?: string }, + locations: LocationConfig[], +): Promise { + const base: CustomerAddress = { postal: input.postal }; + if (!KEY || !needsMaps(locations)) return base; + const query = [input.address, input.postal, input.country].filter(Boolean).join(", "); + if (!query) return base; + const ck = `${query}|${locations.map((l) => l.id).join(",")}`; + const hit = cache.get(ck); + if (hit && Date.now() - hit.at < TTL) return hit.v; + try { + const geo = (await getJson( + `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(query)}&key=${KEY}`, + )) as { status: string; results: { geometry: { location: { lat: number; lng: number } } }[] }; + if (geo.status !== "OK" || !geo.results[0]) return base; + const { lat, lng } = geo.results[0].geometry.location; + const out: CustomerAddress = { ...base, lat, lng }; + const driving = locations.filter((l) => l.lat != null && l.zones.some((z) => z.drivingKm != null)); + if (driving.length) { + const dm = (await getJson( + `https://maps.googleapis.com/maps/api/distancematrix/json?origins=${driving + .map((l) => `${l.lat},${l.lng}`) + .join("|")}&destinations=${lat},${lng}&mode=driving&key=${KEY}`, + )) as { rows: { elements: { status: string; distance?: { value: number } }[] }[] }; + out.drivingKm = {}; + driving.forEach((l, i) => { + const el = dm.rows[i]?.elements[0]; + out.drivingKm![l.id] = el?.status === "OK" && el.distance ? el.distance.value / 1000 : null; + }); + } + cache.set(ck, { at: Date.now(), v: out }); + return out; + } catch { + return { ...base, degraded: true }; + } +} diff --git a/app/services/notifications.server.ts b/app/services/notifications.server.ts new file mode 100644 index 0000000..bdf85e1 --- /dev/null +++ b/app/services/notifications.server.ts @@ -0,0 +1,81 @@ +/** + * Outbound messages. Email goes through the app's Resend account (reply-to = merchant); + * SMS and WhatsApp use the merchant's own Twilio / Meta Cloud API credentials so + * messaging costs stay on their account. + * + * WhatsApp business-initiated messages must use a pre-approved template. Datewise sends + * the merchant's chosen template with the rendered reminder text as body parameter {{1}}. + */ +import { decrypt } from "./crypto.server"; +import type { AppSettings } from "./settings"; + +export type Channel = "email" | "sms" | "whatsapp"; + +export interface OutboundMessage { + channel: Channel; + to: string; + subject?: string; + body: string; +} + +export class NotConfigured extends Error {} + +async function post(url: string, init: RequestInit) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 8000); + try { + const r = await fetch(url, { ...init, signal: ctrl.signal }); + if (!r.ok) throw new Error(`${new URL(url).host} ${r.status}: ${(await r.text()).slice(0, 300)}`); + return r; + } finally { + clearTimeout(t); + } +} + +export async function send(settings: AppSettings, shopName: string, msg: OutboundMessage): Promise { + if (msg.channel === "email") { + const key = process.env.RESEND_API_KEY; + const domain = process.env.EMAIL_FROM_DOMAIN; + if (!key || !domain) throw new NotConfigured("Email sending is not configured on the server"); + const from = `${settings.messaging.emailFromName || shopName} `; + await post("https://api.resend.com/emails", { + method: "POST", + headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + from, + to: [msg.to], + reply_to: settings.messaging.replyTo || undefined, + subject: msg.subject ?? `Your order with ${shopName}`, + text: msg.body, + }), + }); + return; + } + if (msg.channel === "sms") { + const t = settings.messaging.twilio; + if (!t) throw new NotConfigured("Connect Twilio in Settings to send SMS"); + const auth = Buffer.from(`${t.accountSid}:${decrypt(t.authTokenEnc)}`).toString("base64"); + await post(`https://api.twilio.com/2010-04-01/Accounts/${t.accountSid}/Messages.json`, { + method: "POST", + headers: { Authorization: `Basic ${auth}`, "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ To: msg.to, From: t.from, Body: msg.body }), + }); + return; + } + const w = settings.messaging.whatsapp; + if (!w) throw new NotConfigured("Connect WhatsApp in Settings to send WhatsApp messages"); + await post(`https://graph.facebook.com/v21.0/${w.phoneNumberId}/messages`, { + method: "POST", + headers: { Authorization: `Bearer ${decrypt(w.tokenEnc)}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + messaging_product: "whatsapp", + to: msg.to.replace(/[^\d]/g, ""), + type: "template", + template: { + name: w.templateName, + language: { code: w.templateLang }, + components: [{ type: "body", parameters: [{ type: "text", text: msg.body.slice(0, 1000) }] }], + }, + }), + }); +} diff --git a/app/services/orders.server.ts b/app/services/orders.server.ts new file mode 100644 index 0000000..2ec15c4 --- /dev/null +++ b/app/services/orders.server.ts @@ -0,0 +1,160 @@ +/** + * Order webhooks → bookings. + * + * orders/create confirm the hold (idempotent), verify the fee that was paid, + * tag the order and write a booking metafield, plan reminders. + * orders/cancelled release capacity (per policy) and offer it to the waitlist. + * orders/fulfilled mark fulfilled. + * + * Orders that reach us without a selection (Buy now, accelerated checkouts that + * skipped the cart, draft orders) go to the review queue when scheduling is required. + */ +import prisma from "../db.server"; +import { capsFor } from "../engine/capacity"; +import { evaluateRules } from "../engine/rules"; +import { zonedToUtc } from "../engine/time"; +import { loadShopConfig } from "./config.server"; +import { features } from "./plans"; +import { scheduleReminders } from "./reminders.server"; +import { ReservationService } from "./reservations.server"; +import { SELECTION_ATTR, SelectionAttrSchema } from "./settings"; +import { offerFreedPlace } from "./waitlist.server"; + +const reservations = new ReservationService(prisma); +type AdminGraphql = { graphql: (q: string, o?: { variables?: Record }) => Promise }; + +export interface OrderPayload { + id: number; + admin_graphql_api_id: string; + name: string; + email?: string | null; + phone?: string | null; + note_attributes?: { name: string; value: string }[]; + customer?: { first_name?: string | null; last_name?: string | null; email?: string | null; phone?: string | null } | null; + shipping_address?: { address1?: string; address2?: string; city?: string; zip?: string; latitude?: number | null; longitude?: number | null; phone?: string | null; first_name?: string; last_name?: string } | null; + line_items: { title: string; quantity: number; price: string; product_id: number | null; variant_id: number | null; requires_shipping?: boolean }[]; + source_name?: string; +} + +/** Webhook de-duplication by Shopify event id (falls back to topic+order). */ +export async function firstDelivery(eventId: string, topic: string, shop: string): Promise { + try { + await prisma.processedWebhook.create({ data: { id: eventId, topic, shop } }); + return true; + } catch { + return false; + } +} + +export async function handleOrderCreate(domain: string, order: OrderPayload, admin?: AdminGraphql) { + const cfg = await loadShopConfig(domain, { fresh: true }); + const feeProductNumeric = cfg.shop.feeProductId?.split("/").pop(); + const feeLines = order.line_items.filter((l) => feeProductNumeric && String(l.product_id) === feeProductNumeric); + const goods = order.line_items.filter((l) => !feeLines.includes(l)); + const paidFee = feeLines.reduce((s, l) => s + Math.round(Number(l.price) * 100) * l.quantity, 0); + const raw = order.note_attributes?.find((a) => a.name === SELECTION_ATTR)?.value; + const parsed = raw ? SelectionAttrSchema.safeParse(safeJson(raw)) : null; + const attr = parsed?.success ? parsed.data : null; + + const customerName = [order.customer?.first_name ?? order.shipping_address?.first_name, order.customer?.last_name ?? order.shipping_address?.last_name].filter(Boolean).join(" ") || undefined; + const sa = order.shipping_address; + const details = { + orderName: order.name, + timezone: cfg.shop.timezone, + customerName, + customerEmail: order.email ?? order.customer?.email ?? undefined, + customerPhone: order.phone ?? sa?.phone ?? order.customer?.phone ?? undefined, + address: sa ? { line1: [sa.address1, sa.address2].filter(Boolean).join(", "), city: sa.city, postal: sa.zip, lat: sa.latitude ?? undefined, lng: sa.longitude ?? undefined } : undefined, + items: goods.map((l) => ({ title: l.title, quantity: l.quantity })), + fee: paidFee, + configVersion: attr?.v ?? cfg.version, + snapshot: { attr: raw ?? null, source: order.source_name ?? null, paidFee }, + }; + const orderId = order.admin_graphql_api_id; + + if (!attr) { + // Does this order need a time at all? + const cons = evaluateRules({ items: goods.map((l) => ({ productId: `gid://shopify/Product/${l.product_id}`, variantId: `gid://shopify/ProductVariant/${l.variant_id}`, title: l.title, quantity: l.quantity, price: 0 })) }, cfg.rules); + const needs = goods.length > 0 && (cfg.settings.requireScheduling || cons.requiresScheduling); + if (!needs) return { skipped: true }; + const r = await reservations.confirm(cfg.shop.id, orderId, null, null, { dayCap: 0, slotCap: 0 }, new Date(), details); + await tagOrder(admin, orderId, ["Datewise: needs time"]); + return r; + } + + const loc = cfg.locations.find((l) => l.id === attr.l); + const sel = { locationId: attr.l, method: attr.m, date: attr.d, slot: attr.s }; + const caps = loc ? capsFor(loc, attr.m, attr.d, attr.s, features(cfg.shop.plan).peakDays ? cfg.peakDays : []) : { dayCap: 0, slotCap: 0 }; + const [start, end] = attr.s.split("-"); + const hold = await prisma.hold.findUnique({ where: { token: attr.t } }); + const quoted = hold?.fee ?? attr.f; + const peak = cfg.peakDays.find((p) => p.dates.includes(attr.d) && (!p.locationIds?.length || p.locationIds.includes(attr.l))); + + const r = await reservations.confirm(cfg.shop.id, orderId, attr.t, loc ? sel : null, caps, new Date(), { + ...details, + startUtc: new Date(zonedToUtc(attr.d, start, cfg.shop.timezone)), + endUtc: new Date(zonedToUtc(attr.d, end, cfg.shop.timezone)), + peakDayId: peak?.id ?? null, + snapshot: { ...details.snapshot, quotedFee: quoted, location: loc?.name ?? null }, + }); + if (r.duplicate) return r; + + if (r.ok && paidFee !== quoted) { + // Keep the place (the customer did book it) but flag the money difference for staff. + await prisma.booking.update({ + where: { id: r.bookingId }, + data: { reviewReason: `Fee paid ${(paidFee / 100).toFixed(2)} differs from quote ${(quoted / 100).toFixed(2)}` }, + }); + } + const label = cfg.settings.widget.labels[attr.m]; + await tagOrder(admin, orderId, r.ok ? [`Datewise`, `${label}: ${attr.d}`] : ["Datewise: needs review"]); + await writeBookingMetafield(admin, orderId, { method: attr.m, location: loc?.name, date: attr.d, start, end, status: r.ok ? "confirmed" : "needs_review" }); + if (r.ok) await scheduleReminders(r.bookingId); + return r; +} + +export async function handleOrderCancelled(domain: string, order: { admin_graphql_api_id: string }) { + const cfg = await loadShopConfig(domain); + if (!cfg.settings.releaseOnCancel) { + const b = await prisma.booking.findUnique({ where: { shopId_orderId: { shopId: cfg.shop.id, orderId: order.admin_graphql_api_id } } }); + if (b) await prisma.reminderJob.updateMany({ where: { bookingId: b.id, status: "pending" }, data: { status: "cancelled" } }); + return { freed: null }; + } + const r = await reservations.cancel(cfg.shop.id, order.admin_graphql_api_id); + if (r.freed) await offerFreedPlace(domain, r.freed); + return r; +} + +export async function handleOrderFulfilled(domain: string, order: { admin_graphql_api_id: string }) { + const cfg = await loadShopConfig(domain); + await prisma.booking.updateMany({ + where: { shopId: cfg.shop.id, orderId: order.admin_graphql_api_id, status: "confirmed" }, + data: { status: "fulfilled" }, + }); +} + +async function tagOrder(admin: AdminGraphql | undefined, id: string, tags: string[]) { + if (!admin) return; + await admin + .graphql(`#graphql + mutation($id: ID!, $tags: [String!]!) { tagsAdd(id: $id, tags: $tags) { userErrors { message } } }`, { variables: { id, tags } }) + .catch((e) => console.error("tagsAdd failed", e)); +} + +async function writeBookingMetafield(admin: AdminGraphql | undefined, ownerId: string, value: object) { + if (!admin) return; + await admin + .graphql(`#graphql + mutation($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) { userErrors { message } } }`, { + variables: { m: [{ ownerId, namespace: "datewise", key: "booking", type: "json", value: JSON.stringify(value) }] }, + }) + .catch((e) => console.error("metafieldsSet failed", e)); +} + +function safeJson(s: string): unknown { + try { + return JSON.parse(s); + } catch { + return null; + } +} diff --git a/app/services/plans.ts b/app/services/plans.ts new file mode 100644 index 0000000..657cbf9 --- /dev/null +++ b/app/services/plans.ts @@ -0,0 +1,32 @@ +/** + * Feature gates per billing plan. "free" = no active subscription. + * Keep in sync with the App Store listing and shopify.server.ts billing config. + */ +export interface Features { + maxLocations: number; + monthlyBookings: number | null; + productRules: boolean; + inventoryAware: boolean; + peakDays: boolean; + waitlist: boolean; + freshness: boolean; + smsWhatsapp: boolean; + runSheets: boolean; + drivingDistance: boolean; +} + +const PLANS: Record = { + free: { maxLocations: 1, monthlyBookings: 50, productRules: true, inventoryAware: false, peakDays: false, waitlist: false, freshness: false, smsWhatsapp: false, runSheets: false, drivingDistance: false }, + Growth: { maxLocations: 3, monthlyBookings: null, productRules: true, inventoryAware: true, peakDays: true, waitlist: true, freshness: true, smsWhatsapp: false, runSheets: true, drivingDistance: false }, + Pro: { maxLocations: 50, monthlyBookings: null, productRules: true, inventoryAware: true, peakDays: true, waitlist: true, freshness: true, smsWhatsapp: true, runSheets: true, drivingDistance: true }, +}; + +export function features(plan: string): Features { + return PLANS[plan] ?? PLANS.free; +} + +export const PLAN_COPY = [ + { id: "free", name: "Starter", price: "Free", points: ["1 location", "50 scheduled orders a month", "Capacity holds and cutoffs", "Email reminders"] }, + { id: "Growth", name: "Growth", price: "$19/month", points: ["Up to 3 locations", "Unlimited orders", "Festival rush control and waitlist", "Fresh-stock windows", "Driver run sheets"] }, + { id: "Pro", name: "Pro", price: "$49/month", points: ["Up to 50 locations", "SMS and WhatsApp reminders", "Driving-distance zones", "Everything in Growth"] }, +]; diff --git a/app/services/reminders.server.ts b/app/services/reminders.server.ts new file mode 100644 index 0000000..360781f --- /dev/null +++ b/app/services/reminders.server.ts @@ -0,0 +1,95 @@ +/** + * Reminder jobs: planned from the engine whenever a booking is confirmed or moved, + * sent by the worker. Retries with backoff; never sends for cancelled bookings. + */ +import type { Booking as BookingRow } from "@prisma/client"; +import prisma from "../db.server"; +import { planReminders } from "../engine/customer"; +import type { Booking, Method } from "../engine/types"; +import { loadShopConfig } from "./config.server"; +import { NotConfigured, send } from "./notifications.server"; +import { features } from "./plans"; + +export function rowToBooking(b: BookingRow): Booking { + return { + id: b.id, + orderId: b.orderId ?? undefined, + orderName: b.orderName ?? undefined, + status: b.status as Booking["status"], + method: b.method as Method, + locationId: b.locationId ?? "", + date: b.date ?? "", + slotStart: b.slotStart ?? undefined, + slotEnd: b.slotEnd ?? undefined, + timezone: b.timezone, + customer: { name: b.customerName ?? undefined, email: b.customerEmail ?? undefined, phone: b.customerPhone ?? undefined }, + address: (b.address as Booking["address"]) ?? undefined, + items: (b.items as Booking["items"]) ?? undefined, + rescheduleCount: b.rescheduleCount, + }; +} + +const fmtTime = (t: string) => { + const [h, m] = t.split(":").map(Number); + return `${h % 12 || 12}${m ? `:${String(m).padStart(2, "0")}` : ""} ${h >= 12 ? "PM" : "AM"}`; +}; + +export async function scheduleReminders(bookingId: string, now = Date.now()) { + const row = await prisma.booking.findUniqueOrThrow({ where: { id: bookingId }, include: { shop: true } }); + await prisma.reminderJob.updateMany({ where: { bookingId, status: "pending" }, data: { status: "cancelled" } }); + if (row.status !== "confirmed" || !row.date) return 0; + const cfg = await loadShopConfig(row.shop.domain); + const f = features(cfg.shop.plan); + const b = rowToBooking(row); + const loc = cfg.locations.find((l) => l.id === b.locationId); + const policies = cfg.settings.reminders.filter((p) => p.channel === "email" || f.smsWhatsapp); + const planned = planReminders(b, policies, now, { + date: new Date(`${b.date}T12:00:00Z`).toLocaleDateString("en-CA", { weekday: "long", month: "long", day: "numeric", timeZone: "UTC" }), + time: b.slotStart && b.slotEnd ? `${fmtTime(b.slotStart)}–${fmtTime(b.slotEnd)}` : "", + method: cfg.settings.widget.labels[b.method].toLowerCase(), + location: loc?.name ?? "", + }); + if (planned.length) + await prisma.reminderJob.createMany({ + data: planned.map((p) => ({ shopId: row.shopId, bookingId, policyId: p.policyId, channel: p.channel, sendAt: new Date(p.sendAt), body: p.body })), + }); + return planned.length; +} + +export async function runDueReminders(now = new Date(), batch = 100) { + const due = await prisma.reminderJob.findMany({ + where: { status: "pending", sendAt: { lte: now }, attempts: { lt: 5 } }, + include: { booking: true, shop: true }, + take: batch, + orderBy: { sendAt: "asc" }, + }); + let sent = 0; + for (const j of due) { + // Claim the job so parallel workers don't double-send. + const claimed = await prisma.reminderJob.updateMany({ where: { id: j.id, status: "pending" }, data: { status: "sending", attempts: { increment: 1 } } }); + if (!claimed.count) continue; + if (j.booking.status !== "confirmed") { + await prisma.reminderJob.update({ where: { id: j.id }, data: { status: "cancelled" } }); + continue; + } + const to = j.channel === "email" ? j.booking.customerEmail : j.booking.customerPhone; + try { + if (!to) throw new NotConfigured("No contact for channel"); + const cfg = await loadShopConfig(j.shop.domain); + await send(cfg.settings, j.shop.domain.replace(".myshopify.com", ""), { channel: j.channel as "email", to, body: j.body, subject: `Reminder: order ${j.booking.orderName ?? ""}` }); + await prisma.reminderJob.update({ where: { id: j.id }, data: { status: "sent", sentAt: new Date() } }); + sent++; + } catch (e) { + const permanent = e instanceof NotConfigured || j.attempts + 1 >= 5; + await prisma.reminderJob.update({ + where: { id: j.id }, + data: { + status: permanent ? "failed" : "pending", + lastError: String((e as Error).message).slice(0, 500), + sendAt: permanent ? undefined : new Date(now.getTime() + 2 ** j.attempts * 60_000), + }, + }); + } + } + return sent; +} diff --git a/app/services/reservations.server.ts b/app/services/reservations.server.ts new file mode 100644 index 0000000..e1e8188 --- /dev/null +++ b/app/services/reservations.server.ts @@ -0,0 +1,411 @@ +/** + * Postgres reservation store — production twin of engine/capacity.ts MemoryReservationStore. + * + * Concurrency model + * - Every (shop, location, method, date, slot) has a CapacityCounter row; slot "*" is the day total. + * - Counters = confirmed bookings + holds that have not been swept yet. + * - Any mutation runs in one transaction that locks the counter rows it touches + * with SELECT … FOR UPDATE, always in sorted key order (no deadlocks; "*" sorts + * before "HH:MM" so the day row is always locked first). + * - Expired holds for a locked date are swept inside the same transaction, so a stale + * hold can never block a live customer. + * + * Guarantees (covered by reservations.server.test.ts against real Postgres): + * - N concurrent customers for the last K places → exactly K succeed. + * - Duplicate order webhooks → one booking (unique shopId+orderId). + * - Expired hold at order time → re-reserved if space, otherwise flagged needs_review. + * - Reschedule releases old + reserves new atomically; failure leaves the booking untouched. + */ +import type { Prisma, PrismaClient } from "@prisma/client"; +import type { Caps, Selection } from "../engine/capacity"; +import type { Method, UsageSnapshot } from "../engine/types"; +import { usageKey } from "../engine/availability"; + +type Tx = Prisma.TransactionClient; +interface Key { + locationId: string; + method: string; + date: string; + slot: string; +} + +const keyStr = (k: Key) => `${k.locationId}|${k.method}|${k.date}|${k.slot}`; +const keysFor = (s: Selection): Key[] => [ + { locationId: s.locationId, method: s.method, date: s.date, slot: "*" }, + { locationId: s.locationId, method: s.method, date: s.date, slot: s.slot }, +]; +const sameSel = (a: Selection, b: Selection) => + a.locationId === b.locationId && a.method === b.method && a.date === b.date && a.slot === b.slot; + +export type HoldResult = + | { ok: true; token: string; expiresAt: Date } + | { ok: false; code: "DAY_FULL" | "SLOT_FULL"; message: string }; + +export interface BookingDetails { + orderName?: string; + timezone: string; + startUtc?: Date; + endUtc?: Date; + customerName?: string; + customerEmail?: string; + customerPhone?: string; + address?: Prisma.InputJsonValue; + items?: Prisma.InputJsonValue; + fee?: number; + peakDayId?: string | null; + configVersion: number; + snapshot: Prisma.InputJsonValue; +} + +export type ConfirmResult = + | { ok: true; bookingId: string; duplicate: boolean; reReserved: boolean } + | { ok: false; bookingId: string; code: "NEEDS_REVIEW"; message: string; duplicate: boolean }; + +export class ReservationService { + constructor( + private db: PrismaClient, + private opts: { txTimeoutMs?: number } = {}, + ) {} + + private tx(fn: (tx: Tx) => Promise): Promise { + return this.db.$transaction(fn, { timeout: this.opts.txTimeoutMs ?? 10000, maxWait: 10000 }); + } + + /** Ensure + lock counter rows in deterministic order; returns counts by key. */ + private async lock(tx: Tx, shopId: string, keys: Key[]): Promise> { + const uniq = [...new Map(keys.map((k) => [keyStr(k), k])).values()].sort((a, b) => + keyStr(a) < keyStr(b) ? -1 : 1, + ); + const out = new Map(); + for (const k of uniq) { + await tx.$executeRaw` + INSERT INTO "CapacityCounter" ("shopId","locationId","method","date","slot","count") + VALUES (${shopId}, ${k.locationId}, ${k.method}, ${k.date}, ${k.slot}, 0) + ON CONFLICT DO NOTHING`; + const rows = await tx.$queryRaw<{ count: number }[]>` + SELECT "count" FROM "CapacityCounter" + WHERE "shopId"=${shopId} AND "locationId"=${k.locationId} AND "method"=${k.method} + AND "date"=${k.date} AND "slot"=${k.slot} + FOR UPDATE`; + out.set(keyStr(k), Number(rows[0].count)); + } + return out; + } + + private async bump(tx: Tx, shopId: string, sel: Selection, delta: 1 | -1, counts?: Map) { + for (const k of keysFor(sel)) { + await tx.$executeRaw` + UPDATE "CapacityCounter" SET "count" = GREATEST(0, "count" + ${delta}) + WHERE "shopId"=${shopId} AND "locationId"=${k.locationId} AND "method"=${k.method} + AND "date"=${k.date} AND "slot"=${k.slot}`; + if (counts) counts.set(keyStr(k), Math.max(0, (counts.get(keyStr(k)) ?? 0) + delta)); + } + } + + /** Delete expired holds for the dates whose day rows we hold locks on. */ + private async sweepLocked(tx: Tx, shopId: string, keys: Key[], now: Date, counts: Map, keep?: string) { + const days = [...new Map(keys.filter((k) => k.slot === "*").map((k) => [keyStr(k), k])).values()]; + for (const d of days) { + const expired = await tx.hold.findMany({ + where: { shopId, locationId: d.locationId, method: d.method, date: d.date, expiresAt: { lte: now }, NOT: keep ? { token: keep } : undefined }, + }); + for (const h of expired) { + await tx.hold.delete({ where: { token: h.token } }); + await this.ensureLocked(tx, shopId, keysFor(h as Selection), counts); + await this.bump(tx, shopId, h as Selection, -1, counts); + } + } + } + + /** Slot rows for expired holds may not have been in the original lock set. */ + private async ensureLocked(tx: Tx, shopId: string, keys: Key[], counts: Map) { + const missing = keys.filter((k) => !counts.has(keyStr(k))); + if (missing.length) for (const [k, v] of await this.lock(tx, shopId, missing)) counts.set(k, v); + } + + private fits(counts: Map, sel: Selection, caps: Caps): null | "DAY_FULL" | "SLOT_FULL" { + const [day, slot] = keysFor(sel).map((k) => counts.get(keyStr(k)) ?? 0); + if (caps.dayCap != null && day >= caps.dayCap) return "DAY_FULL"; + if (caps.slotCap != null && slot >= caps.slotCap) return "SLOT_FULL"; + return null; + } + + async hold( + shopId: string, + token: string, + sel: Selection, + caps: Caps, + now: Date, + holdMinutes: number, + fee = 0, + ): Promise { + const expiresAt = new Date(now.getTime() + holdMinutes * 60000); + return this.tx(async (tx) => { + const prev = await tx.hold.findUnique({ where: { token } }); + if (prev && prev.shopId !== shopId) throw new Error("token belongs to another shop"); + const keys = [...keysFor(sel), ...(prev ? keysFor(prev as Selection) : [])]; + const counts = await this.lock(tx, shopId, keys); + await this.sweepLocked(tx, shopId, keys, now, counts, token); + + const live = prev && prev.expiresAt > now; + if (prev && live && sameSel(prev as Selection, sel)) { + // Re-confirming the same place never shortens an existing (e.g. waitlist-offer) hold. + const until = prev.expiresAt > expiresAt ? prev.expiresAt : expiresAt; + await tx.hold.update({ where: { token }, data: { expiresAt: until, fee } }); + return { ok: true as const, token, expiresAt: until }; + } + if (prev) { + // Moving (or reviving) the hold: release the previous place first. + await this.bump(tx, shopId, prev as Selection, -1, counts); + await tx.hold.delete({ where: { token } }); + } + const full = this.fits(counts, sel, caps); + if (full) + return { + ok: false as const, + code: full, + message: full === "DAY_FULL" ? "Fully booked for this day" : "This time slot was just taken", + }; + await this.bump(tx, shopId, sel, 1, counts); + await tx.hold.create({ data: { token, shopId, ...sel, expiresAt, fee } }); + return { ok: true as const, token, expiresAt }; + }); + } + + async release(shopId: string, token: string): Promise { + await this.tx(async (tx) => { + const h = await tx.hold.findUnique({ where: { token } }); + if (!h || h.shopId !== shopId) return; + await this.lock(tx, shopId, keysFor(h as Selection)); + await tx.hold.delete({ where: { token } }); + await this.bump(tx, shopId, h as Selection, -1); + }); + } + + async confirm( + shopId: string, + orderId: string, + token: string | null, + fallback: (Selection & { method: Method }) | null, + caps: Caps, + now: Date, + details: BookingDetails, + ): Promise { + const existing = await this.db.booking.findUnique({ where: { shopId_orderId: { shopId, orderId } } }); + if (existing) + return existing.status === "needs_review" + ? { ok: false, bookingId: existing.id, code: "NEEDS_REVIEW", message: existing.reviewReason ?? "", duplicate: true } + : { ok: true, bookingId: existing.id, duplicate: true, reReserved: false }; + + try { + return await this.tx(async (tx) => { + const hold = token ? await tx.hold.findUnique({ where: { token } }) : null; + const sel: Selection | null = hold && hold.shopId === shopId ? (hold as Selection) : fallback; + const base = { + shopId, + orderId, + orderName: details.orderName, + timezone: details.timezone, + customerName: details.customerName, + customerEmail: details.customerEmail, + customerPhone: details.customerPhone, + address: details.address, + items: details.items, + fee: details.fee ?? 0, + peakDayId: details.peakDayId ?? null, + configVersion: details.configVersion, + snapshot: details.snapshot, + }; + if (!sel) { + const b = await tx.booking.create({ + data: { ...base, status: "needs_review", method: "pickup", reviewReason: "Order has no scheduling data" }, + }); + await tx.bookingEvent.create({ data: { shopId, bookingId: b.id, actor: "system", action: "flagged", after: { reason: "missing" } } }); + return { ok: false as const, bookingId: b.id, code: "NEEDS_REVIEW" as const, message: "Order has no scheduling data", duplicate: false }; + } + const keys = keysFor(sel); + const counts = await this.lock(tx, shopId, keys); + // Re-check under the lock: a concurrent duplicate webhook may have committed meanwhile. + const already = await tx.booking.findUnique({ where: { shopId_orderId: { shopId, orderId } } }); + if (already) throw new DuplicateSignal(); + const lockedHold = token ? await tx.hold.findUnique({ where: { token } }) : null; + const live = !!lockedHold && lockedHold.shopId === shopId && lockedHold.expiresAt > now && sameSel(lockedHold as Selection, sel); + // Sweep everything expired, including this order's own stale hold. + await this.sweepLocked(tx, shopId, keys, now, counts, live ? token! : undefined); + + const [slotStart, slotEnd] = sel.slot.split("-"); + const slotData = { + method: sel.method, + locationId: sel.locationId, + date: sel.date, + slotStart, + slotEnd, + startUtc: details.startUtc, + endUtc: details.endUtc, + }; + let reReserved = false; + if (live) { + await tx.hold.delete({ where: { token: token! } }); // place converts from hold to booking; counter unchanged + } else { + const full = this.fits(counts, sel, caps); + if (full) { + const reason = token ? "Hold expired and the slot filled up" : "Selected slot was full when the order arrived"; + const b = await tx.booking.create({ data: { ...base, ...slotData, status: "needs_review", reviewReason: reason } }); + await tx.bookingEvent.create({ data: { shopId, bookingId: b.id, actor: "system", action: "flagged", after: { reason: full } } }); + return { ok: false as const, bookingId: b.id, code: "NEEDS_REVIEW" as const, message: reason, duplicate: false }; + } + await this.bump(tx, shopId, sel, 1, counts); + reReserved = true; + } + const b = await tx.booking.create({ data: { ...base, ...slotData, status: "confirmed" } }); + await tx.bookingEvent.create({ data: { shopId, bookingId: b.id, actor: "system", action: "confirmed", after: { ...sel, reReserved } } }); + return { ok: true as const, bookingId: b.id, duplicate: false, reReserved }; + }); + } catch (e: unknown) { + // Concurrent duplicate webhook lost the race on the unique index: whole tx rolled back. + if (e instanceof DuplicateSignal || isUniqueViolation(e)) { + const b = await this.db.booking.findUniqueOrThrow({ where: { shopId_orderId: { shopId, orderId } } }); + return b.status === "needs_review" + ? { ok: false, bookingId: b.id, code: "NEEDS_REVIEW", message: b.reviewReason ?? "", duplicate: true } + : { ok: true, bookingId: b.id, duplicate: true, reReserved: false }; + } + throw e; + } + } + + /** + * Move a booking. Also used to resolve needs_review bookings (they hold no capacity yet). + * `force` lets a merchant knowingly exceed capacity (recorded in the audit log). + */ + async reschedule( + shopId: string, + bookingId: string, + sel: Selection, + caps: Caps, + now: Date, + actor: string, + extra: { startUtc?: Date; endUtc?: Date; force?: boolean } = {}, + ): Promise<{ ok: true; freed: Selection | null } | { ok: false; message: string }> { + return this.tx(async (tx) => { + const b = await tx.booking.findFirst({ where: { id: bookingId, shopId } }); + if (!b || !["confirmed", "needs_review"].includes(b.status)) return { ok: false as const, message: "Booking can't be rescheduled" }; + const old: Selection | null = + b.status === "confirmed" && b.locationId && b.date && b.slotStart + ? { locationId: b.locationId, method: b.method as Method, date: b.date, slot: `${b.slotStart}-${b.slotEnd}` } + : null; + if (old && sameSel(old, sel)) return { ok: true as const, freed: null }; + const keys = [...keysFor(sel), ...(old ? keysFor(old) : [])]; + const counts = await this.lock(tx, shopId, keys); + await this.sweepLocked(tx, shopId, keys, now, counts); + // Evaluate the new place as if the old one were already released. + if (old) await this.bump(tx, shopId, old, -1, counts); + const full = this.fits(counts, sel, caps); + if (full && !extra.force) throw new RollbackSignal(full === "DAY_FULL" ? "That day is fully booked" : "That slot is full"); + await this.bump(tx, shopId, sel, 1, counts); + const [slotStart, slotEnd] = sel.slot.split("-"); + await tx.booking.update({ + where: { id: b.id }, + data: { + locationId: sel.locationId, + method: sel.method, + date: sel.date, + slotStart, + slotEnd, + startUtc: extra.startUtc, + endUtc: extra.endUtc, + status: "confirmed", + reviewReason: null, + rescheduleCount: actor === "customer" ? { increment: 1 } : undefined, + }, + }); + await tx.bookingEvent.create({ + data: { + shopId, + bookingId: b.id, + actor, + action: b.status === "needs_review" ? "resolved" : "rescheduled", + before: old ? { ...old } : { status: b.status }, + after: { ...sel, forced: !!(full && extra.force) }, + }, + }); + return { ok: true as const, freed: old }; + }).catch((e) => { + if (e instanceof RollbackSignal) return { ok: false as const, message: e.message }; + throw e; + }); + } + + async cancel(shopId: string, orderId: string, actor = "system"): Promise<{ freed: Selection | null; bookingId: string | null }> { + return this.tx(async (tx) => { + const b = await tx.booking.findUnique({ where: { shopId_orderId: { shopId, orderId } } }); + if (!b || b.status === "cancelled") return { freed: null, bookingId: b?.id ?? null }; + let freed: Selection | null = null; + if (b.status === "confirmed" && b.locationId && b.date && b.slotStart) { + freed = { locationId: b.locationId, method: b.method as Method, date: b.date, slot: `${b.slotStart}-${b.slotEnd}` }; + await this.lock(tx, shopId, keysFor(freed)); + await this.bump(tx, shopId, freed, -1); + } + await tx.booking.update({ where: { id: b.id }, data: { status: "cancelled" } }); + await tx.bookingEvent.create({ data: { shopId, bookingId: b.id, actor, action: "cancelled", before: { status: b.status } } }); + await tx.reminderJob.updateMany({ where: { bookingId: b.id, status: "pending" }, data: { status: "cancelled" } }); + return { freed, bookingId: b.id }; + }); + } + + /** Usage snapshot for the engine. Excludes expired-but-unswept holds and, optionally, the caller's own hold. */ + async usage(shopId: string, fromDate: string, toDate: string, now: Date, excludeToken?: string): Promise { + const [counters, stale] = await Promise.all([ + this.db.capacityCounter.findMany({ where: { shopId, date: { gte: fromDate, lte: toDate } } }), + this.db.hold.findMany({ + where: { + shopId, + date: { gte: fromDate, lte: toDate }, + OR: [{ expiresAt: { lte: now } }, ...(excludeToken ? [{ token: excludeToken }] : [])], + }, + }), + ]); + const out: UsageSnapshot = {}; + for (const c of counters) { + const k = usageKey(c.locationId, c.method as Method, c.date); + out[k] ??= { day: 0, slots: {} }; + if (c.slot === "*") out[k].day += c.count; + else out[k].slots[c.slot] = (out[k].slots[c.slot] ?? 0) + c.count; + } + for (const h of stale) { + const k = usageKey(h.locationId, h.method as Method, h.date); + if (!out[k]) continue; + out[k].day = Math.max(0, out[k].day - 1); + out[k].slots[h.slot] = Math.max(0, (out[k].slots[h.slot] ?? 0) - 1); + } + return out; + } + + /** Background sweep (worker, every minute). Returns number of holds released. */ + async sweepExpired(now: Date, limit = 500, shopId?: string): Promise { + const expired = await this.db.hold.findMany({ where: { expiresAt: { lte: now }, ...(shopId ? { shopId } : {}) }, take: limit, orderBy: { expiresAt: "asc" } }); + const groups = new Map(); + for (const h of expired) + groups.set(`${h.shopId}|${h.locationId}|${h.method}|${h.date}`, { + shopId: h.shopId, + key: { locationId: h.locationId, method: h.method, date: h.date, slot: "*" }, + }); + let n = 0; + for (const { shopId, key } of groups.values()) { + n += await this.tx(async (tx) => { + const counts = await this.lock(tx, shopId, [key]); + const before = await tx.hold.count({ where: { shopId, locationId: key.locationId, method: key.method, date: key.date, expiresAt: { lte: now } } }); + await this.sweepLocked(tx, shopId, [key], now, counts); + return before; + }); + } + return n; + } +} + +class RollbackSignal extends Error {} +class DuplicateSignal extends Error {} + +function isUniqueViolation(e: unknown): boolean { + const err = e as { code?: string; message?: string; meta?: { code?: string } }; + return err?.code === "P2002" || err?.meta?.code === "23505" || /unique constraint|23505/i.test(err?.message ?? ""); +} diff --git a/app/services/settings.ts b/app/services/settings.ts new file mode 100644 index 0000000..01d6af9 --- /dev/null +++ b/app/services/settings.ts @@ -0,0 +1,166 @@ +/** + * App-level settings stored in Shop.settings (JSON). Engine settings + widget + messaging. + * Shared by server and admin UI (no server-only imports here). + */ +import { z } from "zod"; +import type { ShopSettings } from "../engine/types"; + +export interface WidgetSettings { + heading: string; + labels: { pickup: string; delivery: string; shipping: string }; + accent: string; + dateFormat: "short" | "long"; + showRemaining: boolean; + /** Show the calendar inside the cart drawer too (app embed). */ + drawer: boolean; + /** Per-locale overrides of customer-facing text. */ + translations: Record>; +} + +export interface AppSettings extends ShopSettings { + /** Block checkout (validation function) until a time is chosen. */ + requireScheduling: boolean; + /** Minutes a freed place is held for the first person on a waitlist. */ + waitlistOfferMinutes: number; + /** Return capacity when an order is cancelled. */ + releaseOnCancel: boolean; + locationRanking: "priority" | "distance" | "earliest"; + widget: WidgetSettings; + messaging: { + emailFromName: string; + replyTo?: string; + /** Encrypted with APP_ENCRYPTION_KEY (services/crypto.server.ts). */ + twilio?: { accountSid: string; authTokenEnc: string; from: string } | null; + whatsapp?: { phoneNumberId: string; tokenEnc: string; templateName: string; templateLang: string } | null; + }; +} + +export const DEFAULT_SETTINGS: AppSettings = { + mixedPrepPolicy: "longest", + holdMinutes: 10, + weekStartsOn: 0, + reschedule: { enabled: true, minHoursBefore: 24, maxChanges: 2, allowMethodChange: false }, + reminders: [ + { + id: "day-before", + channel: "email", + minutesBefore: 1440, + enabled: true, + template: "Hi {{name}}, a reminder that your {{method}} for order {{order}} is on {{date}}, {{time}}. Need a different time? Use the link on your order page.", + }, + { + id: "out-for-delivery", + channel: "sms", + minutesBefore: 90, + methods: ["delivery"], + enabled: false, + template: "{{name}}, order {{order}} arrives today {{time}}.", + }, + ], + requireScheduling: true, + waitlistOfferMinutes: 30, + releaseOnCancel: true, + locationRanking: "priority", + widget: { + heading: "When would you like it?", + labels: { pickup: "Store pickup", delivery: "Local delivery", shipping: "Shipping" }, + accent: "#0D6B5D", + dateFormat: "short", + showRemaining: true, + drawer: true, + translations: {}, + }, + messaging: { emailFromName: "", twilio: null, whatsapp: null }, +}; + +/** Deep-merge stored settings over defaults so new keys appear after upgrades. */ +export function withDefaults(stored: unknown): AppSettings { + const s = (stored ?? {}) as Partial; + return { + ...DEFAULT_SETTINGS, + ...s, + reschedule: { ...DEFAULT_SETTINGS.reschedule, ...(s.reschedule ?? {}) }, + widget: { + ...DEFAULT_SETTINGS.widget, + ...(s.widget ?? {}), + labels: { ...DEFAULT_SETTINGS.widget.labels, ...(s.widget?.labels ?? {}) }, + }, + messaging: { ...DEFAULT_SETTINGS.messaging, ...(s.messaging ?? {}) }, + reminders: s.reminders ?? DEFAULT_SETTINGS.reminders, + }; +} + +const hhmm = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, "Use 24-hour HH:MM"); +const ymd = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Use YYYY-MM-DD"); +const method = z.enum(["pickup", "delivery", "shipping"]); +const range = z.object({ start: hhmm, end: hhmm }).refine((r) => r.start < r.end, "End must be after start"); + +export const MethodConfigSchema = z.object({ + enabled: z.boolean(), + weeklyHours: z.record(z.string(), z.array(range)), + slotMode: z.enum(["generated", "explicit", "dateOnly"]), + slotMinutes: z.number().int().min(15).max(720).optional(), + slots: z + .record(z.string(), z.array(range.and(z.object({ capacity: z.number().int().min(0).nullable().optional(), surcharge: z.number().int().min(0).optional() })))) + .optional(), + prepMinutes: z.number().int().min(0).max(60 * 24 * 60), + prepMode: z.enum(["elapsed", "operating"]), + sameDayCutoff: hhmm.nullable().optional(), + nextDayCutoff: hhmm.nullable().optional(), + horizonDays: z.number().int().min(1).max(365), + dailyCapacity: z.number().int().min(0).nullable().optional(), + slotCapacity: z.number().int().min(0).nullable().optional(), +}); + +export const ExceptionSchema = z.object({ + date: ymd, + closed: z.boolean().optional(), + hours: z.array(range).optional(), + methods: z.array(method).optional(), + note: z.string().max(200).optional(), +}); + +export const ZoneSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + postalPatterns: z.array(z.string()).optional(), + radiusKm: z.number().positive().nullable().optional(), + drivingKm: z.number().positive().nullable().optional(), + fee: z.number().int().min(0), + freeOver: z.number().int().min(0).nullable().optional(), + minOrder: z.number().int().min(0).nullable().optional(), +}); + +export const RuleTargetSchema = z.object({ + type: z.enum(["product", "variant", "collection", "vendor", "productType", "tag"]), + values: z.array(z.string().min(1)).min(1), +}); + +export const RuleActionSchema = z.discriminatedUnion("type", [ + z.object({ type: z.literal("prohibitMethods"), methods: z.array(method).min(1) }), + z.object({ type: z.literal("allowOnlyMethods"), methods: z.array(method).min(1) }), + z.object({ type: z.literal("restrictLocations"), locationIds: z.array(z.string()).min(1) }), + z.object({ type: z.literal("addPrep"), minutes: z.number().int().min(1) }), + z.object({ type: z.literal("blockWeekdays"), weekdays: z.array(z.number().int().min(0).max(6)).min(1) }), + z.object({ type: z.literal("blockDates"), dates: z.array(ymd).min(1) }), + z.object({ type: z.literal("dateWindow"), from: ymd.optional(), to: ymd.optional() }), + z.object({ + type: z.literal("freshness"), + arrivals: z.array(z.object({ date: ymd, locationIds: z.array(z.string()).optional() })), + minDaysAfterArrival: z.number().int().min(0), + shelfLifeDays: z.number().int().min(1), + }), + z.object({ type: z.literal("requireScheduling") }), +]); + +export const SelectionAttrSchema = z.object({ + t: z.string().min(8).max(80), // hold token + m: method, + l: z.string(), + d: ymd, + s: z.string().regex(/^\d\d:\d\d-\d\d:\d\d$/), + f: z.number().int().min(0).default(0), // fee (minor units) + v: z.number().int().optional(), // config version +}); +export type SelectionAttr = z.infer; +export const SELECTION_ATTR = "_datewise"; diff --git a/app/services/storefront.server.ts b/app/services/storefront.server.ts new file mode 100644 index 0000000..677c50e --- /dev/null +++ b/app/services/storefront.server.ts @@ -0,0 +1,285 @@ +/** + * Storefront API used by the theme widget via the app proxy. + * Every response is computed by the same engine the tests cover; the widget only renders it. + */ +import { randomBytes } from "node:crypto"; +import { z } from "zod"; +import prisma from "../db.server"; +import { firstAvailable, validateSelection } from "../engine/availability"; +import { capsFor } from "../engine/capacity"; +import { rankLocations } from "../engine/locations"; +import { evaluateRules } from "../engine/rules"; +import { addDays, localParts } from "../engine/time"; +import { METHODS, type Method } from "../engine/types"; +import { quoteFee } from "../engine/zones"; +import { enrichCart } from "./catalog.server"; +import { type ShopConfig, loadShopConfig } from "./config.server"; +import { ensureFeeVariant } from "./fees.server"; +import { resolveAddress } from "./geocode.server"; +import { features } from "./plans"; +import { ReservationService } from "./reservations.server"; +import { SELECTION_ATTR } from "./settings"; + +export const reservations = new ReservationService(prisma); + +type AdminGraphql = { graphql: (q: string, o?: { variables?: Record }) => Promise }; + +const LineSchema = z.object({ + variantId: z.union([z.string(), z.number()]), + quantity: z.number().int().min(1).max(10000), + price: z.number().int().min(0).optional(), + title: z.string().max(300).optional(), +}); +export const QuerySchema = z.object({ + lines: z.array(LineSchema).max(250), + method: z.enum(["pickup", "delivery", "shipping"]).optional(), + locationId: z.string().optional(), + postal: z.string().max(12).optional(), + address: z.string().max(300).optional(), + country: z.string().max(60).optional(), + token: z.string().max(80).optional(), + locale: z.string().max(10).optional(), +}); +export const HoldSchema = QuerySchema.extend({ + method: z.enum(["pickup", "delivery", "shipping"]), + locationId: z.string(), + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + slot: z.string().regex(/^\d\d:\d\d-\d\d:\d\d$/), +}); + +async function context(domain: string, admin: AdminGraphql | undefined, q: z.infer) { + const cfg = await loadShopConfig(domain); + const now = Date.now(); + const s2a = Object.fromEntries(cfg.locations.filter((l) => l.shopifyLocationId).map((l) => [l.shopifyLocationId!, l.id])); + const { cart, inventory, feeLines } = admin + ? await enrichCart(admin, domain, q.lines, s2a) + : { cart: { items: [], subtotal: 0 }, inventory: {}, feeLines: [] }; + const address = await resolveAddress({ postal: q.postal, address: q.address, country: q.country }, cfg.locations); + const today = localParts(now, cfg.shop.timezone).date; + const usage = await reservations.usage(cfg.shop.id, addDays(today, -1), addDays(today, 370), new Date(now), q.token); + const f = features(cfg.shop.plan); + const locations = cfg.locations.slice(0, f.maxLocations); + return { cfg, now, cart, inventory, feeLines, address, usage, locations, f }; +} + +type Ctx = Awaited>; + +function rank(ctx: Ctx, method: Method) { + const { cfg } = ctx; + return rankLocations({ + locations: ctx.locations, + method, + cart: ctx.cart, + rules: ctx.f.productRules ? cfg.rules : [], + peakDays: ctx.f.peakDays ? cfg.peakDays : [], + usage: ctx.usage, + settings: cfg.settings, + now: ctx.now, + address: ctx.address, + inventory: ctx.f.inventoryAware ? ctx.inventory : undefined, + ranking: cfg.settings.locationRanking, + }); +} + +function widgetText(cfg: ShopConfig, locale?: string) { + const w = cfg.settings.widget; + const t = (locale && (w.translations[locale] ?? w.translations[locale.split("-")[0]])) || {}; + return { + heading: t.heading ?? w.heading, + labels: { pickup: t.pickup ?? w.labels.pickup, delivery: t.delivery ?? w.labels.delivery, shipping: t.shipping ?? w.labels.shipping }, + accent: w.accent, + showRemaining: w.showRemaining, + weekStartsOn: cfg.settings.weekStartsOn, + }; +} + +export async function getAvailability(domain: string, admin: AdminGraphql | undefined, raw: unknown) { + const q = QuerySchema.parse(raw); + const ctx = await context(domain, admin, q); + const { cfg } = ctx; + const offered = METHODS.filter((m) => ctx.locations.some((l) => l.enabled && l.methods[m]?.enabled)); + const ranked = Object.fromEntries(offered.map((m) => [m, rank(ctx, m)])) as Record>; + + const noAddress = !q.postal && !q.address; + const methods = offered.map((m) => { + const r = ranked[m]; + const ok = r.ranked.filter((x) => x.eligible); + const first = ok.map((x) => x.first?.date).filter(Boolean).sort()[0] ?? null; + // Delivery can't be judged before we know where: keep it selectable and ask for a postal code, + // unless something else (a product rule, stock) already rules it out. + const onlyAddress = r.ranked.length > 0 && r.ranked.every((x) => x.eligible || x.reasons.every((y) => y.code === "OUTSIDE_DELIVERY_AREA")); + const needsAddress = m === "delivery" && noAddress && !ok.length && onlyAddress; + return { + method: m, + available: ok.length > 0 || needsAddress, + needsAddress, + firstDate: first, + reason: ok.length || needsAddress ? null : r.splitRequired?.message ?? r.ranked[0]?.reasons[0]?.message ?? "Unavailable", + }; + }); + const method: Method | null = + q.method && offered.includes(q.method) ? q.method : methods.find((m) => m.available && !m.needsAddress)?.method ?? methods.find((m) => m.available)?.method ?? null; + if (!method) + return { ok: true, version: cfg.version, text: widgetText(cfg, q.locale), methods, method: null, locations: [], dates: [], requireScheduling: cfg.settings.requireScheduling }; + + const r = ranked[method]; + const chosen = r.ranked.find((x) => x.location.id === q.locationId && x.eligible) ?? r.ranked.find((x) => x.eligible) ?? null; + const cons = evaluateRules(ctx.cart, ctx.f.productRules ? cfg.rules : [], cfg.settings.mixedPrepPolicy); + const av = chosen?.availability; + return { + ok: true, + version: cfg.version, + text: widgetText(cfg, q.locale), + holdMinutes: cfg.settings.holdMinutes, + requireScheduling: cfg.settings.requireScheduling, + methods, + method, + split: r.splitRequired?.message ?? null, + needsAddress: method === "delivery" && noAddress, + locations: r.ranked.map((x) => ({ + id: x.location.id, + name: x.location.name, + address: x.location.address, + eligible: x.eligible, + reason: x.eligible ? null : method === "delivery" && noAddress && x.reasons[0]?.code === "OUTSIDE_DELIVERY_AREA" ? "Enter your postal code to see delivery times" : x.reasons[0]?.message ?? null, + first: x.first, + distanceKm: x.distanceKm != null ? Math.round(x.distanceKm * 10) / 10 : null, + zone: x.zone?.zone?.name ?? null, + fee: x.fee ? { fee: x.fee.fee, waived: x.fee.waived, freeOver: x.zone?.zone?.freeOver ?? null } : null, + pickupInstructions: method === "pickup" ? x.location.pickupInstructions ?? null : null, + })), + locationId: chosen?.location.id ?? null, + prepNote: cons.prepDriver && av ? { item: cons.prepDriver.itemTitle, minutes: av.prepMinutes } : null, + dateOnly: av?.dateOnly ?? false, + timezone: av?.timezone ?? cfg.shop.timezone, + dates: (av?.dates ?? []).map((d) => ({ + date: d.date, + available: d.available, + remaining: d.remaining, + peak: d.peak, + surcharge: d.surcharge, + waitlist: d.waitlist && ctx.f.waitlist, + reasons: d.reasons.map((x) => x.message), + slots: d.slots.map((s) => ({ + id: s.id, + start: s.start, + end: s.end, + available: s.available, + remaining: s.remaining, + surcharge: s.surcharge, + reason: s.reasons[0]?.message ?? null, + })), + })), + first: av ? firstAvailable(av) : null, + }; +} + +export async function holdSelection(domain: string, admin: AdminGraphql, raw: unknown) { + const q = HoldSchema.parse(raw); + const ctx = await context(domain, admin, q); + const { cfg } = ctx; + const r = rank(ctx, q.method); + const entry = r.ranked.find((x) => x.location.id === q.locationId); + if (!entry || !entry.eligible) + return { ok: false, message: entry?.reasons[0]?.message ?? r.splitRequired?.message ?? "That location isn't available for this cart" }; + const loc = entry.location; + const v = validateSelection( + { + location: loc, + method: q.method, + cart: ctx.cart, + rules: ctx.f.productRules ? cfg.rules : [], + peakDays: ctx.f.peakDays ? cfg.peakDays : [], + usage: ctx.usage, + settings: cfg.settings, + now: ctx.now, + }, + q.date, + q.slot, + ); + if (!v.ok) return { ok: false, message: v.reasons[0]?.message ?? "That time isn't available" }; + + const extras: { label: string; amount: number }[] = []; + if (v.slot.surcharge) extras.push({ label: "Time slot", amount: v.slot.surcharge }); + if (v.date.peak && v.date.surcharge) extras.push({ label: v.date.peak.name, amount: v.date.surcharge }); + const fee = quoteFee(q.method === "delivery" ? entry.zone?.zone ?? null : null, ctx.cart.subtotal ?? 0, extras); + + const token = q.token && /^[A-Za-z0-9_-]{16,80}$/.test(q.token) ? q.token : randomBytes(18).toString("base64url"); + const caps = capsFor(loc, q.method, q.date, q.slot, ctx.f.peakDays ? cfg.peakDays : []); + const held = await reservations.hold(cfg.shop.id, token, { locationId: loc.id, method: q.method, date: q.date, slot: q.slot }, caps, new Date(ctx.now), cfg.settings.holdMinutes, fee.fee); + if (!held.ok) return { ok: false, message: held.message }; + + const feeVariantId = fee.fee > 0 ? await ensureFeeVariant(admin, cfg.shop.id, fee.fee) : null; + const dateOnly = loc.methods[q.method]?.slotMode === "dateOnly"; + const human = humanAttributes(cfg, q.method, loc.name, q.date, v.slot.start, v.slot.end, v.date.peak?.name, dateOnly); + return { + ok: true, + token, + expiresAt: held.expiresAt.toISOString(), + fee, + feeVariantId, + attributes: { + [SELECTION_ATTR]: JSON.stringify({ t: token, m: q.method, l: loc.id, d: q.date, s: q.slot, f: fee.fee, v: cfg.version }), + ...human, + }, + }; +} + +/** Readable cart/order attributes for staff (visible on the Shopify order page and packing slips). */ +function humanAttributes(cfg: ShopConfig, method: Method, location: string, date: string, start: string, end: string, peak?: string, dateOnly?: boolean) { + const label = cfg.settings.widget.labels[method]; + const nice = new Date(`${date}T12:00:00Z`).toLocaleDateString("en-CA", { weekday: "short", month: "short", day: "numeric", year: "numeric", timeZone: "UTC" }); + return { + "Fulfillment method": label, + [method === "pickup" ? "Pickup location" : "Fulfilled from"]: location, + [method === "shipping" ? "Dispatch date" : `${label} date`]: nice, + ...(method !== "shipping" && !dateOnly ? { [`${label} time`]: `${start}–${end}` } : {}), + ...(peak ? { "Peak day": peak } : {}), + }; +} + +export async function releaseSelection(domain: string, token: string) { + const cfg = await loadShopConfig(domain); + await reservations.release(cfg.shop.id, token); + return { ok: true }; +} + +const WaitSchema = z.object({ + method: z.enum(["pickup", "delivery", "shipping"]), + locationId: z.string(), + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + slot: z.string().default("*"), + email: z.string().email().optional(), + phone: z.string().min(7).max(20).optional(), + name: z.string().max(100).optional(), +}); + +export async function joinWaitlist(domain: string, raw: unknown) { + const w = WaitSchema.parse(raw); + if (!w.email && !w.phone) return { ok: false, message: "Add an email or phone number" }; + const cfg = await loadShopConfig(domain); + if (!features(cfg.shop.plan).waitlist) return { ok: false, message: "Waitlist isn't available" }; + const dup = await prisma.waitlistEntry.findFirst({ + where: { shopId: cfg.shop.id, locationId: w.locationId, method: w.method, date: w.date, OR: [{ email: w.email ?? "__" }, { phone: w.phone ?? "__" }] }, + }); + if (!dup) await prisma.waitlistEntry.create({ data: { shopId: cfg.shop.id, ...w } }); + return { ok: true }; +} + +/** Customer arriving from a waitlist offer link adopts the reserved hold. */ +export async function adoptOffer(domain: string, offerToken: string) { + const cfg = await loadShopConfig(domain); + const hold = await prisma.hold.findUnique({ where: { token: offerToken } }); + if (!hold || hold.shopId !== cfg.shop.id || hold.expiresAt <= new Date()) return { ok: false, message: "This offer has expired" }; + const loc = cfg.locations.find((l) => l.id === hold.locationId); + return { + ok: true, + token: hold.token, + method: hold.method, + locationId: hold.locationId, + date: hold.date, + slot: hold.slot, + expiresAt: hold.expiresAt.toISOString(), + location: loc?.name, + }; +} diff --git a/app/services/waitlist.server.ts b/app/services/waitlist.server.ts new file mode 100644 index 0000000..468888b --- /dev/null +++ b/app/services/waitlist.server.ts @@ -0,0 +1,49 @@ +/** + * When a place frees up (cancellation or reschedule), offer it to the first person + * waiting: reserve it as a hold for `waitlistOfferMinutes` and message them a link + * that adopts the hold in their cart. + */ +import { randomBytes } from "node:crypto"; +import prisma from "../db.server"; +import { capsFor, type Selection } from "../engine/capacity"; +import { loadShopConfig } from "./config.server"; +import { send } from "./notifications.server"; +import { features } from "./plans"; +import { ReservationService } from "./reservations.server"; + +const reservations = new ReservationService(prisma); + +export async function offerFreedPlace(domain: string, freed: Selection, now = new Date()) { + const cfg = await loadShopConfig(domain); + if (!features(cfg.shop.plan).waitlist) return null; + const entry = await prisma.waitlistEntry.findFirst({ + where: { + shopId: cfg.shop.id, + locationId: freed.locationId, + method: freed.method, + date: freed.date, + slot: { in: [freed.slot, "*"] }, + offeredAt: null, + }, + orderBy: { createdAt: "asc" }, + }); + const loc = cfg.locations.find((l) => l.id === freed.locationId); + if (!entry || !loc) return null; + const token = `wl_${randomBytes(16).toString("base64url")}`; + const caps = capsFor(loc, freed.method, freed.date, freed.slot, cfg.peakDays); + const held = await reservations.hold(cfg.shop.id, token, freed, caps, now, cfg.settings.waitlistOfferMinutes); + if (!held.ok) return null; + await prisma.waitlistEntry.update({ where: { id: entry.id }, data: { offeredAt: now, offerToken: token } }); + const link = `https://${domain}/cart?datewise_offer=${token}`; + const nice = new Date(`${freed.date}T12:00:00Z`).toLocaleDateString("en-CA", { weekday: "long", month: "long", day: "numeric", timeZone: "UTC" }); + const body = `Good news${entry.name ? `, ${entry.name}` : ""}! A ${freed.method} place opened on ${nice} (${freed.slot.replace("-", "–")}). We're holding it for you for ${cfg.settings.waitlistOfferMinutes} minutes: ${link}`; + const channel = entry.email ? "email" : features(cfg.shop.plan).smsWhatsapp ? "sms" : null; + if (channel) + await send(cfg.settings, domain.replace(".myshopify.com", ""), { + channel, + to: (entry.email ?? entry.phone)!, + subject: "A place opened up for you", + body, + }).catch((e) => console.error("waitlist notify failed", e)); + return { entryId: entry.id, token }; +} diff --git a/app/shopify.server.ts b/app/shopify.server.ts new file mode 100644 index 0000000..db2aa49 --- /dev/null +++ b/app/shopify.server.ts @@ -0,0 +1,51 @@ +import "@shopify/shopify-app-react-router/adapters/node"; +import { + ApiVersion, + AppDistribution, + BillingInterval, + shopifyApp, +} from "@shopify/shopify-app-react-router/server"; +import { PrismaSessionStorage } from "@shopify/shopify-app-session-storage-prisma"; +import prisma from "./db.server"; +import { ensureShop } from "./services/config.server"; + +/** Plan names are referenced by billing checks and feature gates (services/plans.ts). */ +export const PLAN_GROWTH = "Growth"; +export const PLAN_PRO = "Pro"; + +const shopify = shopifyApp({ + apiKey: process.env.SHOPIFY_API_KEY, + apiSecretKey: process.env.SHOPIFY_API_SECRET || "", + apiVersion: ApiVersion.January26, + scopes: process.env.SCOPES?.split(","), + appUrl: process.env.SHOPIFY_APP_URL || "", + authPathPrefix: "/auth", + sessionStorage: new PrismaSessionStorage(prisma), + distribution: AppDistribution.AppStore, + future: { expiringOfflineAccessTokens: true }, + billing: { + [PLAN_GROWTH]: { + trialDays: 14, + lineItems: [{ amount: 19, currencyCode: "USD", interval: BillingInterval.Every30Days }], + }, + [PLAN_PRO]: { + trialDays: 14, + lineItems: [{ amount: 49, currencyCode: "USD", interval: BillingInterval.Every30Days }], + }, + }, + hooks: { + afterAuth: async ({ session, admin }) => { + await ensureShop(session.shop, admin); + }, + }, + ...(process.env.SHOP_CUSTOM_DOMAIN ? { customShopDomains: [process.env.SHOP_CUSTOM_DOMAIN] } : {}), +}); + +export default shopify; +export const apiVersion = ApiVersion.January26; +export const addDocumentResponseHeaders = shopify.addDocumentResponseHeaders; +export const authenticate = shopify.authenticate; +export const unauthenticated = shopify.unauthenticated; +export const login = shopify.login; +export const registerWebhooks = shopify.registerWebhooks; +export const sessionStorage = shopify.sessionStorage; diff --git a/docs/DS_Shipping_spec.docx b/docs/DS_Shipping_spec.docx new file mode 100644 index 0000000000000000000000000000000000000000..9f45db28f2acb6cec7f1ccea7d653111109df684 GIT binary patch literal 30010 zcmeF2(~~DakmuXBZA{y?ZQHhO{@S*!Y1_7K+tZqM-|Svo#9r+E2YdC9FO@GAR! z%=jqEfP$d`K>$Gk0Ra&MF(*1`m;(a=m4X8Sp#njH=!iJjyPDa%8mM|Xnz`uFd)nC& z6@h{LDgXlc=l}oL|KJ;FPMNYEVnh=A8}=Qt(7G<&`KxqzqA1=B*YX`WidA0pS-80S zyZ4Bbsg#U~bz+ogZrf)LiYa7aGs_klxy6J03X?A_C|Tc%gJxsxm@dE+o8lmaaaU-9 zJ9CF<7edZ{4YEAvaT<7S`(!BYZ0hETSG zW(?H56R|U^0^QUEd8w;@c|?Psy0%+aQyHV&4#DxgY7s@-?*^kNJy6oaUT#6XX5M}- zAZ3W|8jXaC0dY~=L)dT=tA7fWwRh)F0t$33aJ|c|sOG@aqcvLJG>;JVWNu_-(3DB@ zYAzCB6^M23djn4=TP1OsD~_M&cj{|=!(X3b^)pt-z>KF0jWvp-=Z#09o+Pvi&<)=% z(+Giok@fQf3Z(dd$&(-vr|s_Fj{HCM!Tyt{fwP&d3j_Us*#9rf|G`@PFGsIQ8u%wm zIKjJ+uaJcvrOg4XVtEFW`7NvsNGKgCS=6m{>$UGM-t~15y$d6Wsl}z_d2g2-aku>x zy?dNAHTb9==+!szUOE%c~49Eil%$c&kO z)Dvpamy>c>D-x=?WfQWd;yfp{wZ9quECrcNE34L$IzEt9d}6)f_?@%Z{xIeO4e2a& z3Gsuwje5hXm&xtSvDQ@2v^chm4CFb9#x*cF_uUJZ-o<_8BY-ek=t5`!!-ucAE{gW| zS=1@E0Q()WR@%?1Qy<_!#l{O?jc9Gp!VOdU+z?EdAj z|43fjeiP2yl8dK))2H}`+{q{}VLNJenq6i~SxEr36$?hxiD3n@z)|5Szsn`w{_WB0 z{Pb!{Y>#*-pv)4@#CarvD7RmU9f5%n5fKde0pDkRzIq9(|uVx}A3^tuj+~+Nx$RLtSGvet**me?9^}9*0(1H=iPEuF={>H}v_Ivk7-^ zx@vph?+bUXn?yEy1&*`JtgH3@`m5mct9I%2PVn=p&b)-wT2p_ia;#9;=)C$EY44Wb z@c4JkXmnKDWj_J;#$d7v*uGdTnHh}PSXvkizwi4w=Icx5I}v*d>-l(79bL<85Z>43 z>5k{P5w}-UYBZkeU1UZyo#{yhc#~V~rpASk^t zL^ijdpUHLG<-0y97#?nsYb!SonXUA9XWB_$R~^Gb8u&h7W+OyeW?)QcYL&n4YaJ$O zzQ3jpK0L29u0KBW4{)`J|5_Z(c@Z|&=`FY)cE!eAudk8XYf4%ZX4L?3shf4pT@7&K zo$AByZbwIN_mKsDh!FGtRkTUcxzQ=3GoE79S3G@r+r)=)KVvXZQXyAIWnl6ZImL+k zDQiy#wY-0gumG5o=*lg=M#7#e&>K21PYem`4xOyM)?<5c=u@U|s~>XPs@-$H{nC_M zgvi^SJ_gzPu8}zpZ%6!u?BnlH@49LIc%-LXJqPvYxLNeuM92=;bE33@8b+U)K+g$Jo19Azk+0o6)^n z!l8XLa(VvKT9fru7RVEPQ{9iCY6*V-Q!xID_?6*Tx~3HHir57bu>Jy*HS8e}jOwXk zNBCzCXP2?=vRO)CkYF)abA^P(Q=)DVdGnwe;RjK_2PDGf<0QhP>e4IgN_$8qKifh- zrcrMbb7#qeUqOGc%QCMAu6OAR%T(;S;P`hE_0~0ke`V8YzJzqW)fB73!1#PB zwPId%zvAW#K3*x;-_2SP9xK!zH50o(Z=f&;KYQ~(SLHNz;E9)StsP~l->%d`y3e)B z_V3;6!FU|?o9P@>^i;Pcb_V;xC2qg3AIfg%9ie43U+L)mVQ5;__D!<8s_rZ=a8Nj) z9$JlHL$i|H@9mHnCtQ{|;Kjv-HcxR&=@*r`p0azKK@Vqeusb%55<=rp8R*vDT|$0R zzJk6IF#pz{)!nKvzFu}Ae)h!c{K<-|^TX;5L>%gUT?Fy7ud+jg(fTW=7qlZ(*owQJ zfD*hQJEbf#rk`qc5arfWcj^0AvTC_d91YuYxXhvJ5{Q`HQYkDxI?ooK+M}?a7)l4Z z`vEUz`Uw11;*}OFh>DD#8+ipR;-a@f{jN`>ePmvIkhDnCk2c zQn8w&@Buh#UHpd)EDkhUM{ zuP=PI&x{K?fM}!H3yzOG>xYMs+)$U+);_N;yy}s%`ijN2pg??B64bmOEWKZemj}ak zssvqV9eG5Q^`9M3qJ5&W{yT6_g z1V>aI@B{zEEb^^7gEXsdN&13{J->jxLfezqQR@Gaq+$>%-)Z~98dxeady8RZ7@%cSivm_o-x>T<8AzFpPRDy|j#y5L?aOGSmmIfy zS1-0TOMoGJTw(-{kg*n5 zyTH5Mn8UdHhkk4k-abfA1^-te@A0CU`slog#M0^=M&=n!Kw|3-<~?>3#g;$B3W6d2 z?fK+RJh2wyyw|*8UPzGHfOcT$wCxnU9F5j?cW=pzfv9&bLQIs^QEdf*DT-7YM1)wy zA)$T?ilz%~fu?odmDx&Jad$}yxCU#{u9|ma5z?oD-Sb=94mqdpIMuky2-Z+81pzsG-)FC0?=|p*v>nD`96uU&W)7&c&_+W2KwSz>4>21B0O51+B;a?sQFnSz3 zYx;vFOPtXAdb9U~xmQwhTgw3B4SWL2khX%l2EUuCG~Vx`%i^8A_?0x^;j-D0qKG>? z2l=!+TC^tc(R}?tKet(F0%GHY()xg^E^f2JDcg^(<@fBErc0VOeD&y#_>G@8s1x_b(l__K>^NJMa(#;0ZlOSKWr73)upGo-(5Raw_N=w4=VWl?Y$-Xh8@16pyY5Ii%;57_Lq9z7<9W|f1E5=x&G za&8*?rVfXql6 z$q*7ILqFeOzcQ4W6vAM$RgA;rkY(T_Q2(n^{~M})lQ%m5o3ph=`oPwgZduRad37zj z7*0w*j_A>SIL7eZc-jLYj#Qw-Q|KdRU9@EytEA;!_mq~{e*ytLZ5o0}`N1|hEkn=x zpar8yf8m43F7#>L6*S;$hO|i(10_5h)1cukzl|?vC(M+y)B568Ck^GK6SD7I7kH!L z68z7*(aEE`zIG7?aWVq1R|Gy3Q}#V1^EJ;TP4Ja5-)W9GA`i2Oh1c{sN#lo4`0J@{ z6#nq!vZpCOkyls}hS)RFnqXx_RWbII$%BEnbi1U!Inso{uZT~Z)*ig3_>h^72}ZfC zUX7wC7!W)Cex7x35j!=oGFl!+)tb0@HJH=FGG}URD#>(wZKGwu1^2F)a~gNQhNB38BoGHoJ^g!p_ah$EGZHiIbm1GY>-z z^ONzM;b##Vui_*jl+cDPZxvO#;>av>%o7rLh6h`dx7t#(;U{RkT#*3J#W?y>LGcW- zpuTrO_dL_uB@uTjltpr0yf0_*f8STsEsoXr>zqo?3k_vF`4KlS6}4rkzX|DEmqOzF z?pQjVI5oYA9%;S_`1e)6m*ifCc-D37R<|wg{JTd3uOwL=f0tFcJB6$X&5KnVBUniPL>1+I4dY0#I zns+ZJJ>W?@$L{9wJdLzT+dH6Og5U||PpL*#$)=%48rl3z*tjP)4z=2DG@;K?3^3=$JFE^Fz!k!(A?zW1@^>g}!Jz zwU%@qTQv{BbwRKde$ql#Fplu87wawjPNDRd(x;}H zx@WHFe4y$XawyML4*A;PMTp5QTIai2N-{-NcjJqTwN$HYeYJ^-SqZ(Zt85hFIg+06 zAVPMMM;&pa5jK_I%4&=9FRhaP`o8}LfzJIXmNz?;5*pK#@*pbs(^S+9U^@0xf)jKu zIhnm>B9srn2cLR!&L7;XOU(GdgKr65Z&>zJi!ELoHjbj&nX00_?*QMzZ$)G@4l4AB zW$sgXQBhTwMJjIg{B3<+!>3KVcnN&}J~c;7%&zR%yGJ8%WDk@Xa@TT4zm?KHEqZl3 zE=xmhq~cPuuFdBv{Xu{u;jTWeIXwJ)Gj6~;_NS<~VHEiVPCA{v4clHW(@_+*N7DuM zv*}8I8(UOpC!TQ1&OhHFkyG~RU_rk_Av@;<5E_cNrufXM=@;ga7dn^-P!Y#+-w*9H zs}+FDB#$w`My*USF@1MB{sF$MZ2}{XDPo1KHm|TRs=O+i9Xytubk%_wIkW0SKX|H7 zWw_w&L<6%bD2E7l%-<_W_K^qIa>HMPU(HX+B+q8RmKo*EeJ7F-y7nemdA{qmz8+VR-^UTT~mfjUS?OuPsy%pSh-_-!#6Igmq99DuYsDDxV5 zA_< zB}^fjZ?L#26kI!^OEp*bX_F@_N*k)%R}^2og4t#iEBtb)>c(F@1)W4{QeHX-rj?M& zP-_Vo&kDhx2hi|TPSVJrwZ`!CDoS5Xzr;SJ8&cKWRQ}_K1rdW)-{w>_BDI4Ipl(bfGG~c075R{b4!BR zDJz^p6Y*QZ0z4*#IjpW0Ll|Cl2H(U3+I|8%th1#x97m6n@A~wpBNNYeHtgLhEn$*MY&VQhoCGj z_vwAS_~z-VUOGo0N{rEak|okW4vFS3^W=#Qzm=w=epU@NhghQC9dpk-B{Vi9OuYUl zB-322EZ9;87cvMiVgc0|y`UwkOX~9X9fseEWSiC^ZFFiqh?0(DV-k^Cn5xJ*F^cBJ z0#G%Ptw=O|%nz=3E8)qmPF{IET7A(7fM%tIJSf2pwtE~J?B1?8BS-_lCs(D; zJ4%P0MveXjKQ+B0uQZKlvgdEm0o*q)C2jP8jy`!O<>rzr(+mteiz|;@bHGifjdCz7 z+ImH}%G|s9y-gkq&fq-$+NwgSsinRlxTy4}MB3tG#(ShfH0tjmP&04uO}zU>5WGd4 znf6bPUf}z>>`VT1GJOuzEwH85D$pk=<5+rh+a0=6?vt2ohp^UOf7uXuhFSh03SV3c zc>9KF$+mQbF*?%RzPRRq(3q^|M;a`qDM?A#;Q8R8Z#(I8$_MHL5I(6hVv%G9kJk}L za+~KJy~kmaO^VDVxx+-{sH3e~^UCVKP^VBQr8pXkrW+^y!|>&L5DhxSn2lOemK6uM zE2KCpaRZxy!Vwt`O%k%Z;6(!9$80ozEmKS(A8_2S!{yN!=$g|Bo&!v^{D!$AB$RfJ zMHRM~hXvhBw@qJt^jope6vCU8h8utPb4irk3yzDuxj>~(P52VHim*s62VHC?dKBq~ zV{;Xrka!(GID79(iAYC22{!OC3^MOJz+G^`zZE+02noau$izGG2?eQyJ_L*B9_ga6 z@^4KB9O>*7bAm)RPpERgb?aFh!c4bZL zIrE0|CXZr8m&y;`c+juk=Z0&hND_;`1pZX*1Sun^oV=m)Tg5GHdKfP7MOqO?AFAjQ zcJ+N^o}MyB&Wjuq^uq|rd*D5{U8@mSDy23L=*{s7?TTEK31dB!%3H~l$+oI^)2Z3t z_mlU4II>-=gGum7$>gzvuiG2NtF`w#bDCvllf(H+8CUUCgL}s{+cuj2Z{aGGIK( ziBXOr$uBy18{@5r-kT;SP-fbmsj5Qyr?8Xwx6Ji_pt#>7Z^&xy!mZyk@}q0PeI+L& z-^8)r2Hp{awD{_bU$%!`P;Sit7aG&!#OyMo=HU9aq6>$alj#JAg}(7>d%ym*<+5eG zsSmT;1?4OgV{p)g=2frVBez8G3x(2{2~vMU#1+@|9Vp~4Our~o#r%3j#{HO*Sg+Z) zG?~KyA(QguLVy?Tl%ohwBRDB(IL1Kc>*1oqz1N2N&SwnNQ_ zUnqQ##6s6!3a71r?jNV#>k-kXS&t+QrIzew|w`V@)`o0RUm4-hNoGTv( zI;yDivSKiHZ?qI31KY!Or~Gm{P8u1LDF;{#=qaM9ZrTsO0Yu^mY&Tzp=m)!NCNKAV zCdoYB%vrP#A^U-dLlz6?|KtRP%Zf%bI@v>g(OK}X{ zpiL}yC7h3r*<-`pfW#Bb_QX7Ys1EB{vO_@%%V*%wMa;mPF(9~deO`XD0X#yrVMyO@ z8%X(DKdA;uN}8c7Zh$yz4-T=&UaqH`Lvl2Y-YQ;E&@DOs1TL@5$O zyFcyX3TY|PuaINgTyt8`w$U-$cuP+)=ZHW(dB4rtJGKca~wF!GGHYEL+?@(;Nc zYy|)pSIMr>m4wd~ar*6?GF!bHK|Iuuwtr;zmea}`yf3^&TKWz*rGJ+@9BK|ccZoNZ5Y>lL78b>XNvg`Qg$VfL<+B*8M%l23YDJExbnbw5Edxko}fK5_DL z&1iY~HPvmxU9pwFinE)A6cRS19~k4M2gUGIP{@fdwISnrX>?D zf}wYiWSU>~wlbTOm>$n;#w9WwxU6c4NK(f@G}uS(6XlWPDbK#*KqB{O6xopA999tP zCO}?W^;kuATejoqmR?>`Fmwqt2*aC`M08?*lOO6=TX)BzUBteQmD+Zjbo4g)gYfua zz)oS_LzZEWPb}xrlU7b$AB#q-?HHhgP>s7|C39e>Nh*02*?>K_<)Z2GG;WVGs^6Vr zJsoa9Vr93whExtTPrRwxn7Yy@<{E9blD7!K2@FfkI;d#+YVJ=QJ21ZZns@3pBF6jq zyybsNJ?@?xLmRo83qr1puvV_3O zFt3sSkYSZ37?fg{%+F{6&Bfntby?9peg>F;l5XSMlilW^uDF8kI9Z>f zdxlQ*B8loF(XPT5(!x7g*W2Y?wTdP>uxD5+M8B#%t0VN~UJ?wVa0`7dfsjh;J_U@) zB;KXBosMH!S|8xD$?=C|AxS+74;^E!vgEXRh9yC`Lr|MCidW z8!yM!I(K(ls zAFdz^xQs*R%8|?RH^m@E2R{~i|9WHHp!h(n_qf#7p#X7vT?3+FR) zBsV6sXmKxs=$c6R=q^nI%j-R9>ObC)vD0fqozwjJ&JY8^Fs#GJWB$2jqT|E=Aa~ow zDytj2LR_;@((&CTom3RTK!=hR`zx61aQpr~o{Q~O7%urOToMkZwk+xPghITi!~{qy z8kmx2j9s*)OLOp&FR2yy{EBN?kjNrK0K;Ezr(c4!HNweAruY>Yok(ZMJ$`x^S&U!J zuKRHCTvtS{Eq)*Vd!)DX^SGZJ+(DNL(sfi*VuQ#OD&S!Yp-pfg&=5kfgOvESc~X0C7w`B3IaQL&;6q6= zM5(!QMCPoBsFI4{Zb668kG;r<`QhMpIsD5r`|_7ty4kXp1SCEQ$=>YW3_*pZL(iNn96( z@Vf%}XA3@=V}pOp?$dY6G@1AYkkSK7s?u~O3gVJ3CTFYTmW&-4=aPQXgoW8XA~fLp z5cZ%|8h2FKK#&%bx*Xliz~RF=!uAc!>m=PSz&kt-RX=pnG`mO89fW_kqM-Dm>|b+r z-s@sLsIPKP(VfWY67H2l=lU#-+(cFW^@RGI;>2mf(CGWqvh3{KCvcSbD6~RgWM9ylH0|IgF zGHng2?P@@sDXiK*&y!2qfI+pXH8A{F19FJ8`xOj>{?b}zV@qi`SJx1B+rHLvf{&N^Nn$IDSobvPm&85P}@;gHlQ-pJAy*Ib#rV8FkTkX zx37)}3J4)@kWhD>{hIyWl(h5%nxco@Vf^>~H)hk{CqC;i7OwzzH=Q}x&Cf++ju~G~ zgL11A>xJXM0~by?+Ma*)n)FYW7(~rY){Bgr;v%DM#U4rP3mu|6Mc=m{R)&nwX3MNt z%R|1($STFX#51<@Q;!&bYFHdyhNVE!rRh{ov3f5DQUd-HqB_|E1&qX1(h=+ z6B z(OX`5B_~%Ie#WETSRi1^$5AV~8fQf<--tT6K9sjl#X#MMq&Be$e22nM%}O)RKQ}h)6AV z-XHvy3$4f!-!H{hZpZq}$y5C$E(6f`9X7;7X!urE+^2^hw!s&RRWKX|JBjRIyTVi8 z>;3o`e>)1{7I<}@dX9ofW8+otr5abj9Il$F&ojag#onwXO+AHjZLn*YXv!^7{+f`a z3nxKInt>kz1#7KhFtm%u<|L>?;#q$*q_|{+BATOLl33OYxtl7Zjhq)sCYnh1r z2C=(FBY=}AWLQoh^hn8mYkinwRBSQ^oGy6D`SP{S@$Bi9&CKOFfnImpfad2s z{fC{s!#4p}Ox~Ezv0qGrtdMTuo%Jp4rkkSc26=qikN03}f;Y%|Gbo?7VN2deTg>FC zl>|wsYtCZ_%!(+7;<7xc30EA=(q;W&E{+x|cUa~$gKZyF$_pkt2Z3q>R|pSBCd?rv z>|+!RACS$aM-xXI0IZgdu-)(Hyz;uD*fWP^4EZ@c&y+qcW0&tpBMX4m+Cl_Buwa4F zR(AvtdO-YH1a~ zOW;dINUn^Bp)^z<7c0>&2}^4ND{+4Lb zUX;C?hSA+?Id!d{VS{nE^t308jK>-F0(5{*24b@IR&t zOo#Tt0AiYFi!-EQ4kd)!0>JyrrgmNbDU-(S7%oTF3}TO9y4l|PDdj!g#&N#jFySd% z)*{sIy_n0i@oDW}h>H`o32p@;^PQv6&394fbV7p!bS>M#=GWUiZ0QU-&hyDcygSx% z)wYEuKL}J$6+h58{|v@j7!3H$$eaL&yjqQo4=bcFH|3pbb#@5uju1w86g}p;XY&<5 z?(ge!FV|;<8ttAZ?ZYW*;tl|ll*$B|NQ_pWY*5aL%4r581#ZWl{hBjVtj)T@O=FLE z_+07MXK}R~GhkM;8tFU@+XaE;@TOst`(s)iA&{IsKSRv;1#uNgGu#6X#S>%K6H#Mie@Kzl8v!ok~k@Xn&vD zd9|@75lStPfVl+QN%sO%Q@9xE>|188`8eb*ZBs5-PX0MH)k~P$4<&2!3Urg2Pwj;b z7yWij@;F@j#1l&9(?mPiQ4FApwYF~X_i@vF9qA z=8d6q%B;KL+!2+C(V_}A>m`U`AqLX!A;JW#3>*u<%~e z3Qr18Ofv4^T#0AxS@;a~FgkFC{eX>QvDsGMO_~MtCw#QqfBng_9x!luknl8#DA3TX za)Pl=($?4U?bgdz`64gkGbat%f3O zk_n{T)+O-3V1UGOIw9;aZbh(QU0K)cX=l`Rar@;_Y-#2>qkrYrMxBAI7nPletu@ki zs@2XdlUi%>7`jaBh>-_)emoPl45isux2Q*sWe_b+2h5G(jr??2KW-tPghHf=5z*$X zH`jh2I^^T%SdN`ceR}dWOPoBO_$qv@x&A{Z1TwVzKB-#r9!m5wb||H_F;^#x#rZIv z$89oDFI-h>s}J?|#P#d=3h$O4s#RzfXYJi0p}}PNlF1NDIueKp2w^TES(Z5hE=yps z3gtHMZo<|x62plYW#+Y7@_f{%CGg%M&PLt(D)q+@y!lQz3~rkfW=hCWPbeyC66wa8 z5CNO*roHWKrU?JsS@t#+u*|wC=_v?FYUMR6=@4-2Y7TfKM+ULIc-?-?1VihMwRbaA zjJ5e#F*cU>t2HaoTxs3Tyhn>;qB;Ojg)FfYxMx;sD{m!wB?UFT5TBsjPqa$6r`?bB zD!qjoWwk~c)E?MFol}?MRnB(m?O3##P)|u1S&9TcaWV92ud!o39!PwGtrcXgJpqzP zYZ1-Kj0&!qgAI4dQ~D(jT;7Uhlm zM*3~qhc^-{rjs8kQ!3V)*#@c09_6a;L_yA8oq+PXGYH2THce4=4u+R86n0(El^}8n z?NPK_05T`vCsJSK&}LH=9Mbn-3W;Am*iy*#k%+Ju?{$dZF+eZm$TA`bCE2}L2If{QJ=8ROx}KInvq&VLsUym(^Oa0(LB`~vnF!EAe(rm! zoax<(dOjNLiyTe>8Fba-O>t2Q2e&@s=Rah*I`)!-mMN?l{Cn}}Z(PFNgGAt|HN|>8 zc+I8j<)f9wUXD9Iu9io#BY+8vTs`677HUZzao)H+0|9gpi?=ByBH6pDZc`?Oc|w3B zBFC^^4FIa6O)h}fNQYq-=PWJ?#(ts01G`XN9fW!%bKeM`S%jJ=t&5gJ5IVh`61`o* z*NRc55>4O6TWbTk3sO$|F18@p1(uy>rksx}M&6XkHs}0g3LO~cYR1y2ClG~U`gbi> zbB)vwx{EPbnsTWh0)y_bFzEsDnsb@Xr7*6zc$Q>aAmY29#Q+Ua>LFFD!3$y?t zJmm?u=}CJFurKE6EWdgI$^1Rn++)R`3^kGsnv?m1Y17d-k2K+&qU1dj!XDxyrzm4uXK+*jTgFzd8?ab(9@ielYRq7Mh10slzHV|&nQ|q>~m{1kIfcrT;>EB1S_w~lchcWdNeP0Bcu!8zC0{E>xEz1jKbSG z{)8!QHex$WDc?@TOndI~a>?66H=urun;DvR>)BgI3A^3c+-4q@c(h)Jp~ANYQbM7A zPPYI#-ub(&ASaRxIR!%`5x`IqQ(IY8S$&G~ds24JbHo*F5{bd`->hWWC>boMNAfZ& z13Vlb2;w#^ewg>*Bb*a8lO&5a*#w;tR4w*Pqt3c7CHPG>)l1@j7nzACgDY*$d^`!2 zwJL=@2%hm;EFpu%CM^gZY@j-G)xru8Lj-pOez-o-ZGJikfI(@*b}Rs)V_d|A5yUx> z-G^H@4O?ci#mzFW_N1x%WF0?YS-dD5Yo>aqLl~DrQsF45A`G zDYL{Dk_5c;%*?ll-2oCpxK6$1iiQ?-<(68+%vCNmq8Q5D<;5ALVn3MZR@^mGo1BBk zS9TnQX1)1dRy1{}L0PfK@?L)8J8X9BP{Rl720_Vi_sInPWe>UKHcOS;Ac<1QDp=zJ znG}8m4kS^9b@-D{+c$TVdJ&lQ(ivqk(I!3gxE!Lc zl;(cARd8~C1GlH_q#4k#$FuI;EMs92Xv)MJTRxukj6YB-!TVljwm%HRkkX0fcwH{Te9{h`!ILiayE(a1(krye%sklVSkK>tal ze}f(>_pG2CUXn$=A3|UG0cgX}lJTfgM%I9*?~gYGZ>&_R`=LRh{StHWJxrqO;f$kI zWdp2@a!1d+X~Kw+WDcu~U9&)LcZW+_NzJPDrZV1Hf^!!RnTx+@wkPJV3&zKBzqDgf z`7l`L($GpUZygy~b!NghhGvIj|_paJ%d7F&a;$ z$$ZF|ILkaGk*h|#)2J_oUWzr$sLzmxopfK>*Wq0mjx#koI7umM3GY29*XB#!><4+{ z#V{VA+eDyGp_859OVQd$14G*4QTv}`qZp%EbSg;qWd5P{EZp zJWPrewK^xSY1gmM)fleAXu?_eUy3@iy7Y1?d@dW$UiMH86Kfv`?XIYW zxb4`K+n?uXg{h6JkQeDaUKKD;cPZY1_-WJ^42RM(#P<+?!Bk@A~Eup z--cddFc(Vzx5U`vxX&QL)T^%E5fCf^VFC8>RCqKbEuBR4cnq4e0cOYIB!RzI4A)%j zMw~@#&RSl$XCukvX8cm610hXL@l%<}fe+H8LJFwlC==>1r4a9g;ln6Ea*tI6f}AWp zRHagT$F*2BXpQQv4bQ$6&R6QYvkw^3P%!NG%SYo{^)Y3JI?#rjzQv_xs)6(moeQ%Z z>l8T%xo{tf2ojL}YuB^VyCZ_7>A`5Ei&I8-7UJH;1Mf-`PHnEx`?ST(XYKUSX| zaNF-V&{UXjj{}w85SaLVQS2?&xqaWoqW25*{sQqI$o1b*ucqE;eDHq|SSu(H5FQW| z@PDFS|C@*WufW&;VIzV6Aczpc7rFf^>EM0-O4L zi9DN>$Z7ZaiAkeGKrDsCu^3zQhOKx%{zm61cjptQ2C ztPuhx^z>v6@QnlzYj_dd{+VB8%TXQ}CQTaD221i+sUO&{f^qmo>E5K_-?6zi2- zBKKH=8?R&DsKwZKexa>_d+i3GxAi6F)Nw(Y%0NOh{{!Nf(r3;-c8`}_*bok*RRuk^krm<@ z1^p-NhCxa5n|9R_y@c$V`^J?x%w2(kR@FYM$=>I(aGmKm<&Fst={?ju32Q_%D ztrL&ShUPDED4_Y6XKwCXmsO-obz|SPxr6cN+%yN9Bk#|+yE})lWCib^;83tg$OO+L zVB!QpkUxX*mCI36@%=Bd6i34%R69j5H{zSG54lHkXLyA9vd%}b(HVg(!<=POdw*&> z5CkW3AUL5vX1|uKeqRC68=?pbqb_@_M)Ejp`+l|H-gibS#ssD)yt=J>kPt6kJ#*(z z8I9L1ppiS<3u(cHT{qSfFnZEIkCxna*>@3$^9+`xPSVwi)#V9vyp?{U>XIqx&L&%%+BFdIqR zsy`>r9fI{wEqJgNVFf&Yy5#ES>wrbCk0|t>sh_NXa^_CtS?JAb)~vhq1jfs328NT^o?0LFxz8$!cS)X=KJh_j_3&wS}Um8*d5}a42ct9 zy2n97&Y}Iga3kNgHM;$4~=w% zMz^nf2vJ#p7sI3gt;<6c27ThZ{>-NUp}+JTo!-jX9cL)Tjhpq@Lp%a`&oxwe&eK+^ zUJmB<@HiJw`1Sg_pb#(cy8oy6t{)hI)F`nFJh`6s@7NmQ&)dnz{$Il0_oHNm8&CaF zfv=aL^1jb^kIOKjtERjB@7J&7If3_^Yz5kT;E*QfPqeYfkgjt)yd_Qh`9`3uhO=bO zSR#5ExTJ(3FluaAZxRwvKM#25=Kb*T<|9ywr7-x{)5}7kS2AOoAi91YC0igwDsqv@ z8lTSMRcalh)X3cJJMfYcI&72z*x%0Dgxpb6v}zy zOw+Fvy3klU*h3j9iI9q->`9b1>>O~jVNb4+P~gcxPWH(iW2+E5Jc_4u)mw-Y-0X?6 z`=#(+KvZ<3W3F6LhB?|?M`Y9{dxtXEMM!u`M&v>>s73FKh>Vr4e?ep5JOn;$_tLG# z35cxCM{FfPlpc9&CqPKDAAqA{(Rd8kaqx%KEJI&_g~9TMfQTJ`=y3DO!e`n_93+^l zw}siaw!_V9s{Di7Yf@F6Oq^&>#mDms64dXxbnnRW*kp~zt62B+vWQtSnY^&<{z#w7 zkfs`!A^grA<|DxUs9yF)6C$lq&EGn5n}PfJN@k%jBlw2Je%fYcZul|T$`LlEt%uK0 zi5v)`+mS{d`qDvm%V}Ppq;6wGyjg{R&Ml%GonWCZ_^EwJd>%<==xsJ+DBo-VH7J>_ zlqT<5kwdp{E5la8*w^SQ!5&g`4n3wb4$@a+)j*&5=`E_{p3`ufR>OBu8(SCC7shX* z(|ZCfm@y&vO{oA+f%4e2HUiyoh`D9QeK0;fA(U=J!jvZX0X zi7b1=`bwyR1eq-S|^&+?ztM4FX4=VqC>0K*pieP>iYt+?C zv<)R|ET<}$kafvlFwEtEo|BhqEtkU~nqblf!qXj3v0{<`9&t(>j%xy6K2d|x{%s9& z>KQhLU`w0>{<;T)iQ;Xh<%2ocN!vz+ggG*a&F`X_$mT3lsoP{B_O=l}uyAU#Lz{cK zAosu8`^vDocCB4pOL4d2QoLA^BE{Wd;ZVG|yA~^2++7#$?rx>U-QC^guJ-J`yHC$P z-}l`6`;vzwbB??t$yg&RZOQ)e!V+nOt}wWOmA zW&9WT+3HQ)xOa+HV+2iTDcozMqa6+O?D8(>jQ{xcx_qrhApr#jmh=MbmqWgtp}oDS zrLoLz0FPS5K@(o=#5N0al_hd08nir@|N)Vu^@SLnEv=+cvYru)G#>!KmmI zi7=vBei74c&sTcsX8JD@c8U07xHfNDXYk0znI>-ItaBJYkyB73W^%He0NF`I_X?vP zHLZhRj8JE9;ZVx6BH-aLBhf17Aql0AYr@kbe9%&m$6%!z>2fQm>n_p4A}!;j2@AoY z?R*6*zegWCmwlQp47oYwci>qaOlZ7hNi1qLbEHk}e&#IcIwe5+1-)d|oAVrgtP+XG z8QlJ}2Y5v?Jr}ig1X_m^6EQDa1 zsnB0E?|2Tn8lB2n*`3{pQuwmJ-I4m$ilyTK$gmE0x&>e>C~?ql6|tjJRI69vPx|NI zOfZhS`cKyE`4}M-HX5_WY`M6$(fAAE`q_KW@#-3n8X9}8Mjm$24|EJzSR@N~96(5T zA<-Wep)}XYQpmqu__z}&kMT(byiOeBqu+~HdFp7rntsE$0+H?S7OA^4Yg~fQJ979j z-$YPr;FENi1nQ=%ibEz^eGzORF{77%X~L1kD#2tG*rU_Az@MyS9fFTM%{o8XXxY9Z zPx_MEYf4EK0zDa!yE7lr&sD#3T8@8q&kq3;kd?qQ_qlB3%NGjrwmhAI;_h@>3+h*m zQ+J5C57giabN%T)WD%m`aZrf}gBLO#3*vORv9EzKa@Q0vmdzSW>KH--U5O$P-4Cm3J|tx?CPCqjPX;zN#1CQN*}Zaf}O` zB2|>NQ+M?c$sqYsYNTbTJ$pQjFoOC)k~&MZ9^wQ;ksdq8`eUu2+4vee)}AuW#TQvB zy7~B^JH=p$sks)x%DfzoU|7H2=(sH(TgGYS+AD2}>Tlsd+SBb+TL-hDDp@3gil#Z_ ze)*o+s7kLl9}BuC=El5>BV*W-E2Vjma2Cy_&Ea)dkh-YS-3Hjjvn5j)3LTE1Hyw?2 z7wOaiYlehPl8jB}hLDW-)s>m?NE);KLn11*{!f`1iG1J{k7I*~9SJk&?$diZL3^Y- z^ic-#*RC1y1<=yx(=A#OFPX&Q@?ctXD0&D84|K&vrdleDcBpC3ho|<6E8N3%w_WXG zyNthvuPtxt8_UV~gzuA$&V9F)r}|nD!2U`P0XMVx2su9lmImv#B{kX4$ef-syW`AJ zz9`Vr6?d9uWuYxnKb*BA>}%7@kJDE2tWAmZ(Jp+!mQ;@&?pW6#uTcvxmSD^H^;!3~ z5n0bkl6%ien$Mu;!~c9dOtZPmyZ}u)9d6)YXn(HjEgdZM4Q)YF&(B4D>TLLYKd%2V zT zSTz{dkW-IO8_&=C_?~z3r|)~tA4p^lu#e3{d+web*q*+*%$ciPFCMu*V2q0HU!U)C z0!rf}lCRe{K~CK6DdFo!`z(p}xS8bl%d~*-^~<^5V3Cmc?~RI|@V!1_Sww|uA9k4X ze<$P%i;{k@!1t?tN!lbZO#rJ;uO&{}G^p`Ot4{7PU56Pty^9sl)`VytHr~9} zR;|`k<=YCOt^6q8nJKT{#SH$(cosXD4tU z1UsPi#_L03#4>!L*u4{8*TNn4#$u*qkkh3k$0koh&$NsAq@0NxoN}~8kV!j^>`@GP zIk+axoKp6%GuB~3I)czbL^j)D()27S0twcaC2J-)funrZRx`}lbhtj%q!QuAqg64m zh;iFtqK$HBdy;+bqS++9_Wjkzy)^Z1TCRtqSJm&znA+ZjsW_6SM;!Bc0O+E>cMZq& zJ1%q2LmpS+i9OdKy?yL|!uJ}g%|Fb)8C|D)9NoBOAVlLPEU7_;ON#j_Ec&>{izf_AhlTj7^6ep=D+l+jo-He{!G|)tEB#xvd@o`ONe!wYhA(uO zov{|RO%9C*c)MqkIYTDOPmT6fizVZm(_*TcpiTdj-Ml7^{MV&lN}GimYi~+_5@SI` zEmEzHR5Ils*gd@6J;|Kc!FC0Mj&(AsI%HaQU+ioCbv3O?wzz_}OS?({tXxDIJ3P8g zVf{P~m5aS~MHv*Bx$&r-rtOAK4(L{*2IQ(M^RiKj)s?iWJM_lM`= zTf}D<*o_6Yz@r(71zn}VXPLFv4-U2IfFK36TtsQcRrQhA8SGE@_g)Ng_{J_7)Aub4BhdYm_@)X0stKtkd{Jaq9xnUwvmHOtKeXm%GB82Fkmf{{;v4&>v zNaMSrXa$j>Z38?ir?&1gZdW2pUR5O>vTgJ9=T`4xmOyEoXl#oyr6*@v06uMG6ofOL7g4`ENN%WZ7t0~GLg+YsD+JoS} zXn2VzxzhrL5@1>$$7Tmq>_8_}$KL|xZY`^T>17>SgB|^ZnvoVTC}mgGW0E^dn5>BVt2g+b1w+e!JaWszf|fuT6z+ zrt-^37&bljy$eOUBQ?JYQM2&S?HLPkp(|GVj44@H`NZDyIhTqY6j@n_Rp{?l|HKFVWA3l7N|*8^ZH+WVnJnGZYrOL;U~#JkrRUi2#{n% zN+`&|QA1%sAb|UO3tC1AD#(6I6NLFx;B(PRW`IJQYfw7oi4O*%@sl7KK!Z|RaS#mU z4^V?R!l|nrJa(pTp3X!-pM2!V=eN zjH9FLyEEP>=mP;oTho)UTl&n{Aq_L*mHG@=b`=RBJ(_e|`uJj>8`Oq-RHF15u3(3I zTw9t#t|_odPV-PG?|CWC5hwn)4mzd1!XmAHmdG^g50ckZa!{-R|JnaBeicxf6NT}I z_&{=vOwK_mi&sTJwI$js2J>_>@b9$7QU)P>@DON@w3o~57UH!jL+;0x7dof&_xDR} zbne)-7UG|_YA-6oQLl5Y#(O+=tt@MJwB)AV^JvQHz-hhH+O84_l85X`;!O?w(UU2$ z;D#ByP=YP}kK{jlIO@=n5W-7C^RMoMO629g%Rnh}>Qjf%1M;1;k_kyG|FDhAp z{cJ01Bqk^=B>(Y&{?rCI>J3Vo?)}9IL41+7^k!Wksb(^-%B|M@_@ujK|EZsm6X@0X zk?+D-QCphD4rzqM1Vg9%fR;dZu%Ehw*N*^7m7;wAtN;qbHX38?@<>gXw9AD0jI<^n zr3(^YVBG^6q)tKU#XoeCg61z>7@en@KeT&|>NO}<5dYDlKRVnmtq?Tbl&qYN$4hW^M(Xl_)#)1q zjrLPIHv|8r=|+{cec|eWTGg}- z>1PWfVJd$}CN}4|hFKv4Y_es$8 z#e}uVBe*LAL`;10$A1V)_CJUClPrzFm+yU~`Qb0PCY-q%(s`(|0|6}b3YutyaxPpZA|zD4dD@%Q1%&#T#tTsSAGS>FNlmV7W4bZ| za0UET@;^w3_M7LfuW$I*HXyE}u-=pG3VkzQt<264M39b+acL_E8ysSn$7a(ATA>EY z3o1y5kY8C50Zvc7X)mOyGi^tw*O56p>^%ftedm-T(+Gue?LectPgVIP zVJP&(6;m&PUTR53tDpQJJQAx7E5jDBd>EVf3Hkk#QL>g5hiDd zI>=2S$M-ciITw_5jn4ypd!GgW8*WE@Q9j+$5WOdrvh{b8=F-sJxkW{Wn?)DXnnu*B zp`1-Ws%v@8Bw|AfA*wc=%dI1_U2iBa0X1EEwO7qhlPZax$x1fN*20FdRpOP)fJpS*5(hh6!CDT3&Gg&i zo4)6N{I$d~Qo}f86fm&cJTR~yn`}S#KJ4sW%ng6OnmN##x1AS3^H>Hx!eXR55)){N zeW|3CkccX&`Dh_pu`zOvg7Z0u-y6|8F1BpB0i2+c`IYN8eT?b2B04nPbll|0B;2f*7^llFUfO27bhDI zZ0A^SFd4zudQgnmSM~;rzgkd$0J+GspKolTlQxSbX?=w@{ACXP>)8~{bLnn??|TA3 z&|F6poqIX+Tgz`UX6^a@8`h|4Ep4(@DbnaZ`_CmmbG$(c~IOq zUk`?m8ijFhpS@pCE2l0QiMPQ(erXVb%p==#R3`P}p<+qtLEZ{SS)R%bPH2u*bRpUa-($ zT`IxllJItY#&i#q*52%Sg#%9Fg4*t0#$&59&LQC#UgBm?3-+Zm;Rl9r(lm8GscoBX zujKO!$&T#HE1j}fvScK^T8W`M8`T5p#Epw+R470 zgZ^(*27KF6Y3{z+7I2du7~K`s)Wz8ry_yz;O?Q~U%+*#ryu+f*Xb(2lCDr$!BqygO zA^>C9jvXWa3ui>Za+eX<9s@xj-gnNI6`3{!>VoT>IljF%)Zk(3*7mI7T@fGxhww@a zVCPJ5Xg2Vrl~v!ONBBwCqKCwf2nG}po;+GR^*Qk^EUqpfJ2_okfo>I_*1G~Ro3U9W zhn8rQ4FQQp6v!JnbsTt-&~bzPrY9TX5!>Xj;$Yci$!}4wMGzD3TIb)#h&_&O=SmUy znPUyFxh0-m9C6CFosnytc?*AOCxaSp5E~>QTU#H0n|_*-Pm*tM&Lx)!53_5=OmQ+o z(L~j?W-7$vj!7ymS3sz~F=|H>mC#;@O*dTiq&0*CaKAype(aO*g^DjCUlmeIH_?>R zyBtNrkb0ir@3DUTJ?}BvYyotm9(2Rzjmcx*Ap-+C z=Gq~kPyTvtQ8{YhBX!HY4gPXIkaiw_U_#0dKd$ONg?`(o*o>oVt}oPLn=KignkkomC3QmS>pU;wK+zK;2!y$ zMqKg5r`onTwb|vaDTw;;b6+W#k#Z=a2XPr=id2j*R*OSVgc=_%<0l=JNDtF|$aiv^ zS0vB_mU-!27{)3Cm&B$4Q3eq_S?`Y0+&L2Pv0UgMWWi(pmIC zbvGDVg0+t>=geI_JS>6^iT1XHIXyaB4_sl;7-Us^`2uOhP$h2G_r54|j+RuR1wp|6X1~=9BXf zmL%`)t8Z~ZArYicD8m65OI~V3Lyai01xI4-#g%#$Nl#z9o-&UVu_BcMJOQ!5r-Ho3 z!K9a^&#ZcorOy}w6*Mx)V_xtt9YI$emhJL{2g;qFNgcHXFMG4O77!xtobRc;U>GGU z-D@RwV9FuL@jDPA&EcvHYQzu+$yA&_!4{tsM8tnH#2^w7msx#$9YMyzf`NmHju848 z23n-Y2ZD?u=)AAc%h(eL3fvQAzG)l=!=>@3B zN|96)nOZgi%z?I38o)F;mSGzlZ5Ew)WoSWd?YmX#p%J%f$HYP^0avG0NgG^Ts)=u25trbrtygfY~FMi!QhQviBXSPg5C3DA-hS+(V z^={v1lS$1z;QNwavDlip9^8)=j^iR!f^@7Er8FUFHQ}YfTbz`kc~M%A!@bT)#5$m|1$QzZQeV zW73W5*k)aWYPhgI^LC-0gsYE`LueapBVY$3IAyCKazkX7Ab9~L)h`Agg0N%-7aY4J zgO@>Fz%AVbTHZZNIRfB~pLK5Ej^u+y`-va1rcbH^$p?*==^BkzNB|L2FvA~#xlEbo9%=c6AP*{zVv;EcS1mW`UJ>}e!TeFE zGa>94GX*iK3iDMrG+#qx3ps^TreQNK%Na@JpzGYMFf^Hh4Ds8@J}4a5hRWD`2c z!fU@#M9OR~3;OkKQ5PWkGaF&f1PtPWQ$f(2t5oe}ZyO9IBu2BdZic>G_HI%dj+RQ4TPu80k2Ae&v z9EgB=`rpW!wG%=Pqv)0Bz3Cu66B29;8n%icK1l^2KGI6-mARB4W>L|963uc!tQvk) z@)z-EO#|W} zCgO-|i<&W`5j?$D2u*Z{Y`m;j$XN#a2sVklEPBbJJf?O<&QuhbI0Ee2vMsTc$2~Fu z!f;G~jsSabwRt_+855j+CmarA!V}9VVm`w_m^G)(X2)jSu+-!@xyK-(M&0Mw~pQ8 zCTW5mcTWnxBxJZ^SpzFEntM6P!uGkB%ML$o-(hQGYsfXVK@Qk_G<|8pqSBh_Sxnk~ z>rL}m#xazv0QGINMnsKKQdP^mHa>TJz8HGlJ!N}lmxmJSJLjkk>^5LX0p#i(GkTzH zwAh?_q%0h8JlqlxU_Re+QJZ$eX5Sd!u;|y@60jWXru_cMx=c%^8d>!%dsle{f8N~G zxUz_hD)aUAxB9^y|E9shLmK6oz_hH5_s299PtOw9rDe+(7eVOxq6zo4COm5!_a|_< zQSsQ^u6N$r5VZraQdpAfkn7k-kYw|)DGV6~71J5kXX;P$j&KcoV{?Q@`VC3P{%b|~ z98xJuy0mDlOH8*6&6#*7+b`hUww@;j+Hj@uo@8~XpPO)0NoR_lBB;)Eu%DLYhEa96 zZ#jy;ojl($4tlY5j}|(+_P%GHp<`t%)3?X@m?>liOG2gnP=0DJpO%o2jwm?X4 zcw3tr4Q!rQKOM)^%{O*%EU`^8vFr)FF1#of-NkZ{&9;(yAN;7wBmR!%ao>FR$+Xj0 zfmr|ed!{S+_S93Mwq&PidQr$r8E?OluRCRj;hC!Op_k)KUm=Q$i#cA@xv~}=i#gfP zSt|!+HpKGH94(8S+Bd};8Czo%Lw;3(`e^U2x&YLi!>n46-+6XWV9jmJ+0D902vSf? z*muRdd%`_Z1PZYf7ky?32o!peEJ)4U<)zYVRW+Hf9%LYieWht07Bs_B(;we8)%w~| zOvT=2q-PKw^>rT6&7(;5 z0`abz3Nu@nVx#=fuD4|aLqM$~|9{ig3OXhOaX z7_jNHeK}LMJvJGKZr564Jnf2~NF+4tT@+iH^2x&UR&I(=WI77}B zdWzOa(OWS1trq=7Ag9kYATf1RbG-MfAlS7iCHSn>T9ZI<$U`f{qCF|cHlzF6&2S9c zYeh!V@{!ObiA-_-3CjKtZxT;=n&#@t3UwZcRN`2iHi23hBXJFzJ91C^op=s-%|t*E zt~+4&+QdkwOz?rjMv|6_HXER*griE_XQAcrJG`N zE5&O6 znP_y9bCbbRM~nGXiNnDtjQ_v9;Gl5p({+a)<~+E^jVKX#Z#4ZSEBy^Ow}O4jGv+vWcPTcrC+8$;a>7zY z%1PEiU+UMOUd=7QfTjx#7BHQ2H>eLWFVd=s@?ySv7nf(pIUvcn4Bs!VL^-mvM5?n# z0*}+P>$W=&UT-PUu`Qv4!YR|G+~uJwrZ<*{s@9F-myMTN zH7E@u$FdsXit28*9OMQXiqq-XbR3dMtUF^&;n*PxZ@;kKw8GZ_RcH=?0UBiBr28pwc-O2I?os^g{0``Vw)#1q^?(Syr$03)X1jcRyr z87e*!#8f%33gGy<)4dAtJ9isk1)I%7Cp{!SxAsG{Dt}c4_>SasAitp>Oabk)7L^cU z*)8CwX2F!X`!RZ;F_@hP9C_?gZfrKbW-Oo>D#}_FroI9K*|BYgn8P{xuw{8b0l&c- z23T7lYgwL5LyCIw_)1mX*-JbICT?XSPWSQ@g-`sz@q&|Rl{2(YZv$)07e_Z-o?hy; zlMFd_VD_|$xDuPZtz<~Hm*fj4HYLUOY0e5C<`N(^T!|+a1Mp=}j5m-aISrCv)wM zUfqr(9lypntrPEQ(E1|U!kmJeKY(u*D(_RM+Ed89lM^t3`(}Eh|2ECA?j!TgGZN_c zROUNcFjvE(#~Qg(Fe)qVh`FfKC5m3-TJF6$;N;IgQ-~Mf!c8A4sxouBTReNPU^$#I z)zLob&cxU_A&v#$m*0Sn5c^NN44=W5ymp{@;}oQts35a_kgdL)t(CPM;|D9-pIQkz z$LRmt>VqcK&?tFXKPC+SW$6dxoMXAc;VdY%EF)P4W*d=p4U6MiE|cV1{Q0)JcHJzu zlf$L7V}Q`Y)+7=?b1f&aO~qL3;#s5hDQfy;r*M77 z?~+3f-!T}N)h%?&Ge_d=k`sg;14xnjjut~I&W1piocR@h;x?-fx?1BWF2-qZEX{Tu z%kuQ{UB;GWJS+cDL)!_Wbs?A$bVKl1o$ZitxxD5sh^04&?qqgr4v*ld$CwzSdQN0`= z`j#?6OdiBStU%)aPfb-)@p4-OY3drt!35>+nyP1Q{bTz4|B4C{8|W`ZvBPqn=|v0j z3?EJ-M`bkYJOrR8Qh!HJ1G3v}6Pl=3PP|LKWv-gUoS6grb19^K3~=z9qb z(ivC_+#4zUVf(3TQvIFl3CGkZ>yMju$dqF)fKuvaAtw52RZBGT?6o_bXVvN&mRrP# zk%3GiW26h25V!}oJHxL?txaY}5G+ykX- ztArxRguJv5TC4-x;|*Ef`kPbv2O!mYI|;(-UNCkX@#mac`b%I%*mywUmN5F>`9r2f zuLZ(I;;BYwpGYK#^;RC^?k+S=9B z!!fMVFx$~(!d4Z{&7FQ4vDbSggJaH|isE=<>}(y@BF%l49~f`Ien*9g%uPl9(okEa zXx|;al3<<$kzU3jX;7$~yV-Ag7wTd9Ad`2xSth5(l^~t2Or;8~O)ugrL22hj+|93X z0S-YATIK!oA(8*mf?x4J92qGm`R@$={TRDnP%yA`&|v%9p?1Fme?Q;r7qkuLA5Q!F z9sch=W52*)U_hQf;Qz&g>~~JTyAA!~b^7kVI1&BM;&;>NUo7NA{@jcE+hY26`0oae zzu*HBf589E-0^q(@0)+W@Q12@R{z&l;P2qySO34jG$wz5e|r}Aox|_T(_b70P5 +/// +/// +/// diff --git a/extensions/checkout-guard/package.json b/extensions/checkout-guard/package.json new file mode 100644 index 0000000..7c24469 --- /dev/null +++ b/extensions/checkout-guard/package.json @@ -0,0 +1,16 @@ +{ + "name": "datewise-checkout-guard", + "version": "1.0.0", + "private": true, + "type": "module", + "license": "UNLICENSED", + "scripts": { + "shopify": "npm exec -- shopify", + "typegen": "npm exec -- shopify app function typegen", + "build": "npm exec -- shopify app function build", + "preview": "npm exec -- shopify app function run" + }, + "dependencies": { + "@shopify/shopify_function": "^2.0.1" + } +} diff --git a/extensions/checkout-guard/shopify.extension.toml b/extensions/checkout-guard/shopify.extension.toml new file mode 100644 index 0000000..82d3e94 --- /dev/null +++ b/extensions/checkout-guard/shopify.extension.toml @@ -0,0 +1,16 @@ +api_version = "2026-01" + +[[extensions]] +name = "Datewise checkout guard" +handle = "datewise-checkout-guard" +type = "function" +description = "Blocks checkout when a required delivery/pickup time is missing, past, or its fee doesn't match." + + [[extensions.targeting]] + target = "cart.validations.generate.run" + input_query = "src/cart_validations_generate_run.graphql" + export = "cart-validations-generate-run" + + [extensions.build] + command = "" + path = "dist/function.wasm" diff --git a/extensions/checkout-guard/src/cart_validations_generate_run.graphql b/extensions/checkout-guard/src/cart_validations_generate_run.graphql new file mode 100644 index 0000000..6618ae1 --- /dev/null +++ b/extensions/checkout-guard/src/cart_validations_generate_run.graphql @@ -0,0 +1,37 @@ +query CartValidationsGenerateRunInput { + buyerJourney { + step + } + cart { + selection: attribute(key: "_datewise") { + value + } + lines { + quantity + cost { + amountPerQuantity { + amount + } + } + merchandise { + __typename + ... on ProductVariant { + requiresShipping + product { + isFee: hasAnyTag(tags: ["datewise-fee"]) + } + } + } + } + } + shop { + localTime { + date + } + } + validation { + metafield(namespace: "$app:datewise", key: "config") { + jsonValue + } + } +} diff --git a/extensions/checkout-guard/src/cart_validations_generate_run.js b/extensions/checkout-guard/src/cart_validations_generate_run.js new file mode 100644 index 0000000..bc2b707 --- /dev/null +++ b/extensions/checkout-guard/src/cart_validations_generate_run.js @@ -0,0 +1,57 @@ +// @ts-check +/** + * Datewise checkout guard (Cart and Checkout Validation Function, all Shopify plans). + * + * Covers the paths the cart widget can't see — Buy now buttons, accelerated checkouts, + * shared cart links. It cannot see live capacity (functions have no network access), so + * capacity is enforced at order time by the app (re-reserve or review queue). + * + * Checks, only at checkout (never while adding to cart): + * 1. Scheduling required but no selection → block + * 2. Selected date is before today in the shop's time zone → block + * 3. Delivery/peak fee in the cart ≠ fee quoted with the selection → block + * 4. Fee line present without a selection → block + */ + +/** @param {any} input */ +export function cartValidationsGenerateRun(input) { + const step = input.buyerJourney?.step; + if (step !== "CHECKOUT_COMPLETION" && step !== "CHECKOUT_INTERACTION") return { operations: [] }; + + const config = input.validation?.metafield?.jsonValue ?? {}; + const requireScheduling = config.requireScheduling !== false; + const lines = input.cart?.lines ?? []; + const isFee = (/** @type {any} */ l) => l.merchandise?.__typename === "ProductVariant" && l.merchandise.product?.isFee; + const goods = lines.filter((/** @type {any} */ l) => !isFee(l)); + const feeCents = lines + .filter(isFee) + .reduce((/** @type {number} */ s, /** @type {any} */ l) => s + Math.round(Number(l.cost.amountPerQuantity.amount) * 100) * l.quantity, 0); + + /** @type {{message: string, target: string}[]} */ + const errors = []; + const raw = input.cart?.selection?.value; + let sel = null; + if (raw) { + try { + sel = JSON.parse(raw); + } catch { + sel = null; + } + } + const valid = sel && typeof sel.d === "string" && /^\d{4}-\d{2}-\d{2}$/.test(sel.d) && typeof sel.s === "string"; + + if (!valid) { + if (requireScheduling && goods.length > 0) + errors.push({ message: "Please choose a delivery or pickup time in your cart before checking out.", target: "$.cart" }); + if (feeCents > 0) errors.push({ message: "Your cart has a delivery fee but no delivery time. Please return to your cart.", target: "$.cart" }); + } else { + const today = input.shop?.localTime?.date; + if (today && sel.d < today) + errors.push({ message: "The delivery or pickup date you chose has passed. Please choose a new time in your cart.", target: "$.cart" }); + const quoted = Number.isInteger(sel.f) ? sel.f : 0; + if (feeCents !== quoted) + errors.push({ message: "The delivery fee doesn't match your selected time. Please return to your cart to update it.", target: "$.cart" }); + } + + return errors.length ? { operations: [{ validationAdd: { errors } }] } : { operations: [] }; +} diff --git a/extensions/checkout-guard/src/index.js b/extensions/checkout-guard/src/index.js new file mode 100644 index 0000000..ac1ccb1 --- /dev/null +++ b/extensions/checkout-guard/src/index.js @@ -0,0 +1 @@ +export * from "./cart_validations_generate_run"; diff --git a/extensions/checkout-guard/tests/guard.test.js b/extensions/checkout-guard/tests/guard.test.js new file mode 100644 index 0000000..cf23188 --- /dev/null +++ b/extensions/checkout-guard/tests/guard.test.js @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { cartValidationsGenerateRun as run } from "../src/cart_validations_generate_run.js"; + +const line = (amount, qty = 1, fee = false) => ({ + quantity: qty, + cost: { amountPerQuantity: { amount: String(amount) } }, + merchandise: { __typename: "ProductVariant", requiresShipping: true, product: { isFee: fee } }, +}); +const input = (o = {}) => ({ + buyerJourney: { step: "CHECKOUT_COMPLETION" }, + cart: { selection: o.sel === undefined ? null : { value: o.sel }, lines: o.lines ?? [line("19.99", 2)] }, + shop: { localTime: { date: o.today ?? "2026-11-05" } }, + validation: { metafield: o.config === undefined ? { jsonValue: { requireScheduling: true } } : o.config }, +}); +const sel = (o = {}) => JSON.stringify({ t: "tok_aaaaaaaaaaaaaaaa", m: "delivery", l: "kit", d: "2026-11-07", s: "10:00-13:00", f: 0, ...o }); +const msgs = (r) => r.operations.flatMap((op) => op.validationAdd.errors.map((e) => e.message)); + +describe("checkout guard", () => { + it("never blocks while adding to cart", () => { + expect(run({ ...input(), buyerJourney: { step: "CART_INTERACTION" } }).operations).toEqual([]); + }); + it("blocks a missing time when required", () => { + expect(msgs(run(input()))[0]).toMatch(/choose a delivery or pickup time/); + }); + it("allows a missing time when not required", () => { + expect(run(input({ config: { jsonValue: { requireScheduling: false } } })).operations).toEqual([]); + }); + it("defaults to required when the metafield is missing", () => { + expect(msgs(run(input({ config: null }))).length).toBe(1); + }); + it("passes a valid selection with no fee", () => { + expect(run(input({ sel: sel() })).operations).toEqual([]); + }); + it("blocks a past date", () => { + expect(msgs(run(input({ sel: sel({ d: "2026-11-04" }) })))[0]).toMatch(/passed/); + }); + it("checks the fee line against the quote", () => { + const ok = input({ sel: sel({ f: 1299 }), lines: [line("19.99"), line("12.99", 1, true)] }); + expect(run(ok).operations).toEqual([]); + const tampered = input({ sel: sel({ f: 0 }), lines: [line("19.99"), line("12.99", 1, true)] }); + expect(msgs(run(tampered))[0]).toMatch(/fee doesn't match/); + const removed = input({ sel: sel({ f: 1299 }), lines: [line("19.99")] }); + expect(msgs(run(removed))[0]).toMatch(/fee doesn't match/); + }); + it("blocks a fee without a selection", () => { + const r = run(input({ config: { jsonValue: { requireScheduling: false } }, lines: [line("5"), line("7.99", 1, true)] })); + expect(msgs(r)[0]).toMatch(/no delivery time/); + }); + it("treats malformed JSON as missing", () => { + expect(msgs(run(input({ sel: "{oops" })))[0]).toMatch(/choose a delivery/); + }); +}); diff --git a/extensions/datewise-widget/assets/datewise.css b/extensions/datewise-widget/assets/datewise.css new file mode 100644 index 0000000..20a230a --- /dev/null +++ b/extensions/datewise-widget/assets/datewise.css @@ -0,0 +1,65 @@ +/* Datewise widget — inherits the theme's fonts and text colour; one accent variable. */ +.datewise { + --ss-accent: #0d6b5d; + --ss-line: rgba(127, 127, 127, 0.32); + --ss-soft: color-mix(in srgb, var(--ss-accent) 12%, transparent); + --ss-warn: #9a5b00; + --ss-warn-soft: rgba(240, 180, 74, 0.18); + --ss-bad: #b42318; + --ss-good: #1e7a3a; + display: grid; + gap: 12px; + margin: 16px 0; + font-size: 0.95em; + line-height: 1.4; + min-width: 0; +} +.datewise--drawer { margin: 0 0 12px; font-size: 0.9em; } +.datewise * { box-sizing: border-box; } +.datewise button { font: inherit; color: inherit; cursor: pointer; } +.datewise [aria-disabled="true"] { cursor: not-allowed; opacity: 0.5; } +.datewise :focus-visible { outline: 2px solid var(--ss-accent); outline-offset: 2px; } +.ss-h { margin: 0; font-size: 1.15em; } +.ss-live:empty { display: none; } +.ss-msg { margin: 0; padding: 8px 12px; border-radius: 8px; background: var(--ss-soft); } +.ss-critical { background: rgba(180, 35, 24, 0.1); color: var(--ss-bad); } +.ss-success { background: rgba(30, 122, 58, 0.1); color: var(--ss-good); } +.ss-peak { background: var(--ss-warn-soft); color: var(--ss-warn); } +.ss-note { margin: 0; opacity: 0.8; font-size: 0.9em; } +.ss-methods { display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: 8px; } +.ss-method, .ss-loc, .ss-slot { + display: grid; gap: 2px; text-align: left; padding: 10px; border-radius: 10px; + border: 1.5px solid var(--ss-line); background: transparent; +} +.ss-method small, .ss-loc small, .ss-slot small { opacity: 0.75; font-size: 0.82em; } +.ss-method[aria-pressed="true"], .ss-loc[aria-pressed="true"], .ss-slot[aria-pressed="true"] { border-color: var(--ss-accent); background: var(--ss-soft); } +.ss-locs { display: grid; gap: 6px; } +.ss-loc { grid-template-columns: minmax(0, 1fr) auto; align-items: center; } +.ss-loc span:first-child { display: grid; } +.ss-postal { display: grid; gap: 4px; } +.ss-postal label { font-size: 0.85em; opacity: 0.8; } +.ss-row { display: flex; gap: 8px; flex-wrap: wrap; } +.ss-row input { flex: 1; min-width: 0; padding: 8px 10px; border: 1px solid var(--ss-line); border-radius: 8px; font: inherit; background: transparent; color: inherit; } +.ss-row button { padding: 8px 14px; border-radius: 8px; border: 1px solid var(--ss-accent); background: var(--ss-accent); color: #fff; } +.ss-calhead { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; } +.ss-calhead button { border: 0; background: transparent; font-size: 1.3em; padding: 2px 10px; } +.ss-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 4px; } +.ss-dow { text-align: center; font-size: 0.75em; opacity: 0.7; } +.ss-day { position: relative; aspect-ratio: 1 / 0.9; border: 1px solid transparent; border-radius: 8px; background: transparent; padding: 0; font-variant-numeric: tabular-nums; } +.ss-avail { background: var(--ss-soft); } +.ss-avail:hover { border-color: var(--ss-accent); } +.ss-full { background: var(--ss-warn-soft); color: var(--ss-warn); } +.ss-closed { text-decoration: line-through; opacity: 0.45; } +.ss-out { opacity: 0.25; } +.ss-sel { background: var(--ss-accent); color: #fff; } +.ss-peakday::after { content: ""; position: absolute; top: 4px; right: 5px; width: 6px; height: 6px; border-radius: 50%; background: #c77c02; } +.ss-left { position: absolute; bottom: 1px; left: 0; right: 0; font-size: 0.6em; font-style: normal; opacity: 0.8; } +.ss-day-detail { display: grid; gap: 8px; } +.ss-day-detail h3 { margin: 4px 0 0; font-size: 1em; } +.ss-badge { font-size: 0.75em; padding: 1px 8px; border-radius: 99px; background: var(--ss-warn-soft); color: var(--ss-warn); } +.ss-slots { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 8px; } +.ss-hold { margin: 0; padding: 8px 12px; border-radius: 8px; background: var(--ss-soft); } +.ss-skeleton { height: 120px; border-radius: 10px; background: linear-gradient(90deg, var(--ss-line), transparent, var(--ss-line)); background-size: 200% 100%; opacity: 0.35; animation: ss-shimmer 1.2s infinite linear; } +@keyframes ss-shimmer { to { background-position: -200% 0; } } +@media (prefers-reduced-motion: reduce) { .ss-skeleton { animation: none; } } +.datewise[aria-busy="true"] .ss-slots, .datewise[aria-busy="true"] .ss-grid { opacity: 0.6; pointer-events: none; } diff --git a/extensions/datewise-widget/assets/datewise.js b/extensions/datewise-widget/assets/datewise.js new file mode 100644 index 0000000..2a5a4f3 --- /dev/null +++ b/extensions/datewise-widget/assets/datewise.js @@ -0,0 +1,421 @@ +/* Datewise storefront widget. Vanilla JS, no dependencies, safe to load twice. + * Talks to the app through the shop's app proxy (/apps/datewise/*), so it works on any theme. + * + * Lifecycle + * cart change ─▶ refresh(): read /cart.js ─▶ POST availability ─▶ revalidate selection ─▶ render + * slot click ─▶ POST hold (capacity reserved for N minutes) ─▶ write cart attributes + fee line + * checkout ─▶ (optional) block click until a time is chosen; the checkout function enforces it server-side + */ +(function () { + "use strict"; + if (window.__datewiseLoaded) return; + window.__datewiseLoaded = true; + + // ---------- config ---------- + var cfgEl = document.querySelector("[data-datewise-config]"); + var CFG = {}; + try { CFG = cfgEl ? JSON.parse(cfgEl.textContent) : {}; } catch (e) { CFG = {}; } + var T = Object.assign({ + choose: "Pick a highlighted date", needTime: "Please choose a delivery or pickup time before checking out.", + held: "Held for you for", expired: "Your hold expired. Tap the time again to check it's still free.", left: "left", full: "Full", + postal: "Postal / ZIP code", check: "Check", waitlist: "Join the waitlist", joined: "You're on the waitlist. We'll message you if a place opens.", + dispatch: "Ships this day (not an arrival date)", change: "Change", prep: "Earliest times include preparation for", free: "Free", fee: "fee", + nextAvailable: "Next available", offer: "A place opened up and we're holding it for you", error: "Something went wrong. Please try again." + }, CFG.text || {}); + var ROOT = ((window.Shopify && Shopify.routes && Shopify.routes.root) || "/").replace(/\/?$/, "/"); + var API = ROOT + "apps/datewise"; + var LOCALE = CFG.locale || document.documentElement.lang || "en"; + var CURRENCY = CFG.currency || (window.Shopify && Shopify.currency && Shopify.currency.active) || "CAD"; + var ATTR = "_datewise"; + + // ---------- storage (best effort) ---------- + function lsGet(k) { try { return localStorage.getItem("datewise_" + k); } catch (e) { return null; } } + function lsSet(k, v) { try { v == null ? localStorage.removeItem("datewise_" + k) : localStorage.setItem("datewise_" + k, v); } catch (e) {} } + + // ---------- state ---------- + var S = { + data: null, cart: null, lines: [], loading: false, message: null, messageTone: "info", + method: lsGet("method"), locationId: lsGet("location"), postal: lsGet("postal") || "", + date: null, sel: null, token: lsGet("token"), expiresAt: null, monthOffset: 0, waitlistDone: false, mutating: 0 + }; + + // ---------- helpers ---------- + function esc(s) { return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) { return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]; }); } + function money(c) { try { return new Intl.NumberFormat(LOCALE, { style: "currency", currency: CURRENCY }).format(c / 100); } catch (e) { return "$" + (c / 100).toFixed(2); } } + function dateLabel(d, long) { return new Intl.DateTimeFormat(LOCALE, { weekday: long ? "long" : "short", month: long ? "long" : "short", day: "numeric", timeZone: "UTC" }).format(new Date(d + "T12:00:00Z")); } + function timeLabel(hhmm) { var p = hhmm.split(":"); return new Intl.DateTimeFormat(LOCALE, { hour: "numeric", minute: p[1] === "00" ? undefined : "2-digit", timeZone: "UTC" }).format(new Date(Date.UTC(2000, 0, 1, +p[0], +p[1]))); } + function slotText(s) { return timeLabel(s.start) + " – " + timeLabel(s.end); } + function addMonths(ym, n) { var y = +ym.slice(0, 4), m = +ym.slice(5, 7) - 1 + n; y += Math.floor(m / 12); m = ((m % 12) + 12) % 12; return y + "-" + String(m + 1).padStart(2, "0"); } + function daysIn(ym) { return new Date(Date.UTC(+ym.slice(0, 4), +ym.slice(5, 7), 0)).getUTCDate(); } + function weekday(d) { return new Date(d + "T12:00:00Z").getUTCDay(); } + + function api(path, body, method) { + var url = API + path; + var init = { method: method || "POST", headers: { "Content-Type": "application/json", Accept: "application/json" }, credentials: "same-origin" }; + if (body && init.method !== "GET") init.body = JSON.stringify(body); + return realFetch(url, init).then(function (r) { return r.json().catch(function () { return { ok: false, message: T.error }; }); }); + } + function cartReq(path, body) { + S.mutating++; + return realFetch(ROOT + path, { method: body ? "POST" : "GET", headers: { "Content-Type": "application/json", Accept: "application/json" }, body: body ? JSON.stringify(body) : undefined, credentials: "same-origin" }) + .then(function (r) { return r.json(); }) + .finally(function () { setTimeout(function () { S.mutating--; }, 50); }); + } + + // ---------- cart ---------- + function readCart() { + return cartReq("cart.js").then(function (cart) { + S.cart = cart; + S.lines = cart.items.filter(function (i) { return !(i.properties && i.properties._datewise_fee); }) + .map(function (i) { return { variantId: i.variant_id, quantity: i.quantity, price: i.final_price, title: i.product_title }; }); + var raw = cart.attributes && cart.attributes[ATTR]; + if (raw) { + try { + var a = JSON.parse(raw); + S.sel = { method: a.m, locationId: a.l, date: a.d, slot: a.s, fee: a.f }; + S.token = a.t; lsSet("token", a.t); + S.method = S.method || a.m; S.locationId = S.locationId || a.l; S.date = S.date || a.d; + } catch (e) { S.sel = null; } + } else S.sel = null; + return cart; + }); + } + + function feeLines() { return (S.cart ? S.cart.items : []).filter(function (i) { return i.properties && i.properties._datewise_fee; }); } + + function syncFee(variantId) { + var current = feeLines(); + var chain = Promise.resolve(); + var keep = null; + current.forEach(function (line) { + if (!keep && variantId && String(line.variant_id) === String(variantId) && line.quantity === 1) keep = line; + else chain = chain.then(function () { return cartReq("cart/change.js", { id: line.key, quantity: 0 }); }); + }); + if (variantId && !keep) chain = chain.then(function () { return cartReq("cart/add.js", { items: [{ id: Number(variantId), quantity: 1, properties: { _datewise_fee: "1" } }] }); }); + var changed = current.length !== (keep ? 1 : 0) || (variantId && !keep); + return chain.then(function () { return changed; }); + } + + function writeAttributes(attrs) { + var prev = []; + try { prev = JSON.parse(lsGet("keys") || "[]"); } catch (e) {} + var out = {}; + prev.forEach(function (k) { out[k] = ""; }); + Object.keys(attrs).forEach(function (k) { out[k] = attrs[k]; }); + lsSet("keys", JSON.stringify(Object.keys(attrs))); + return cartReq("cart/update.js", { attributes: out }); + } + + function notifyTheme(feeChanged) { + var detail = { source: "datewise" }; + ["cart:refresh", "cart:updated", "cart:change", "theme:cart:change"].forEach(function (n) { document.dispatchEvent(new CustomEvent(n, { detail: detail, bubbles: true })); }); + if (typeof window.publish === "function" && window.PUB_SUB_EVENTS) { + readCart().then(function (cart) { window.publish(window.PUB_SUB_EVENTS.cartUpdate, { source: "datewise", cartData: cart }); }); + } else if (feeChanged && /\/cart\/?$/.test(location.pathname)) { + setTimeout(function () { location.reload(); }, 300); // totals must show the fee line + } + } + + function clearSelection(reason) { + var token = S.token; + S.sel = null; S.expiresAt = null; + if (reason) setMessage(reason, "critical"); + if (token) api("/release", { token: token }).catch(function () {}); + return writeAttributes({ [ATTR]: "" }).then(function () { return syncFee(null); }).then(function (changed) { lsSet("keys", "[]"); if (changed) notifyTheme(true); }); + } + + // ---------- data ---------- + var timer = null; + function refresh(soon) { + clearTimeout(timer); + timer = setTimeout(load, soon ? 0 : 300); + } + function load() { + if (!roots().length && !CFG.checkoutGuard) return Promise.resolve(); + S.loading = true; renderAll(); + return readCart().then(function () { + if (!S.lines.length) { S.data = null; S.loading = false; if (S.sel) clearSelection(null); renderAll(); return; } + return api("/availability", { lines: S.lines, method: S.method || undefined, locationId: S.locationId || undefined, postal: S.postal || undefined, token: S.token || undefined, locale: LOCALE }) + .then(function (data) { + S.loading = false; + if (!data || data.ok === false) { setMessage((data && data.message) || T.error, "critical"); renderAll(); return; } + S.data = data; + S.method = data.method; S.locationId = data.locationId; + revalidate(); + renderAll(); + }); + }).catch(function () { S.loading = false; setMessage(T.error, "critical"); renderAll(); }); + } + + function findSlot(date, slot) { + var d = S.data && S.data.dates.filter(function (x) { return x.date === date; })[0]; + return { d: d, s: d && d.slots.filter(function (x) { return x.id === slot; })[0] }; + } + + /** The cart, method or location changed: drop a selection the rules no longer allow, with a reason. */ + function revalidate() { + if (!S.sel) return; + var d = S.data; + if (!d || d.method !== S.sel.method || d.locationId !== S.sel.locationId) return clearSelection(null); + var f = findSlot(S.sel.date, S.sel.slot); + if (!f.s || !f.s.available) { + var why = (f.s && f.s.reason) || (f.d && f.d.reasons[0]) || ""; + clearSelection(why ? why + ". " + T.choose + "." : T.choose); + } + } + + function hold(date, slot) { + if (!S.data) return; + setMessage(null); + S.loading = true; renderAll(); + return api("/hold", { lines: S.lines, method: S.data.method, locationId: S.data.locationId, date: date, slot: slot, token: S.token || undefined, postal: S.postal || undefined, locale: LOCALE }) + .then(function (res) { + if (!res.ok) { S.loading = false; setMessage(res.message || T.error, "critical"); refresh(true); return; } + S.token = res.token; lsSet("token", res.token); + S.expiresAt = Date.parse(res.expiresAt); + S.sel = { method: S.data.method, locationId: S.data.locationId, date: date, slot: slot, fee: res.fee ? res.fee.fee : 0 }; + return writeAttributes(res.attributes) + .then(function () { return syncFee(res.feeVariantId); }) + .then(function (changed) { S.loading = false; renderAll(); notifyTheme(changed); }); + }) + .catch(function () { S.loading = false; setMessage(T.error, "critical"); renderAll(); }); + } + + function setMessage(text, tone) { S.message = text; S.messageTone = tone || "info"; } + + // ---------- rendering ---------- + function roots() { return Array.prototype.slice.call(document.querySelectorAll("[data-datewise-root]")); } + function renderAll() { roots().forEach(render); } + + function render(root) { + if (!root.classList.contains("datewise")) root.classList.add("datewise"); // custom mounts get styles too + var d = S.data; + var heading = root.getAttribute("data-heading") || (d && d.text && d.text.heading) || ""; + if (d && d.text && d.text.accent) root.style.setProperty("--ss-accent", d.text.accent); + var h = ""; + if (heading) h += '

' + esc(heading) + "

"; + h += '
' + (S.message ? '

' + esc(S.message) + "

" : "") + "
"; + if (!d) { root.innerHTML = h + (S.loading ? '' : ""); root.setAttribute("aria-busy", S.loading); return; } + + // methods + h += '
'; + d.methods.forEach(function (m) { + var on = m.method === d.method; + h += '"; + }); + h += "
"; + if (!d.method) { root.innerHTML = h; return; } + + if (d.method === "delivery") { + h += '
"; + } + if (d.split) h += '

' + esc(d.split) + "

"; + + if (d.method !== "shipping" && d.locations.length > 1) { + h += '
'; + d.locations.forEach(function (l) { + var on = l.id === d.locationId; + var right = l.fee ? (l.fee.waived ? T.free : money(l.fee.fee)) : l.distanceKm != null ? l.distanceKm + " km" : ""; + h += '"; + }); + h += "
"; + } else if (d.locationId) { + var only = d.locations.filter(function (l) { return l.id === d.locationId; })[0]; + if (only && d.method !== "shipping") h += '

' + esc(only.name) + (only.fee ? " · " + (only.fee.waived ? T.free : money(only.fee.fee) + " " + T.fee) : "") + "

"; + } + if (!d.locationId) { + if (d.needsAddress) { root.innerHTML = h; return; } + var why = d.locations[0] && d.locations[0].reason; + root.innerHTML = h + (why ? '

' + esc(why) + "

" : ""); + return; + } + if (d.prepNote && d.prepNote.minutes >= 60) h += '

' + esc(T.prep + " " + d.prepNote.item) + " (" + Math.round(d.prepNote.minutes / 6) / 10 + " h)

"; + var peak = d.dates.filter(function (x) { return x.peak && x.peak.message; })[0]; + if (peak) h += '

' + esc(peak.peak.message) + "

"; + + h += calendar(d); + h += slots(d, root); + root.innerHTML = h; + root.setAttribute("aria-busy", S.loading); + tickTimer(); + } + + var ridSeq = 0; + function rid(root) { if (!root.__ssid) root.__ssid = ++ridSeq; return root.__ssid; } + + function calendar(d) { + var map = {}; + d.dates.forEach(function (x) { map[x.date] = x; }); + var firstAvail = (d.dates.filter(function (x) { return x.available; })[0] || d.dates[0] || {}).date; + if (!firstAvail) return ""; + var ym = addMonths(firstAvail.slice(0, 7), S.monthOffset); + var start = d.text.weekStartsOn || 0; + var first = ym + "-01"; + var lead = (weekday(first) - start + 7) % 7; + var h = '
' + + esc(new Intl.DateTimeFormat(LOCALE, { month: "long", year: "numeric", timeZone: "UTC" }).format(new Date(first + "T12:00:00Z"))) + + '
'; + for (var w = 0; w < 7; w++) { + var wd = (start + w) % 7; + h += '"; + } + for (var i = 0; i < lead; i++) h += ""; + for (var day = 1; day <= daysIn(ym); day++) { + var date = ym + "-" + String(day).padStart(2, "0"); + var x = map[date]; + var cls = "ss-day", label = dateLabel(date, true), extra = ""; + if (!x) cls += " ss-out"; + else if (x.available) { cls += " ss-avail"; if (d.text.showRemaining && x.remaining != null && x.remaining <= 5) extra = '' + x.remaining + ""; } + else if (x.waitlist || /full|booked/i.test(x.reasons[0] || "")) { cls += " ss-full"; label += ", " + T.full; } + else { cls += " ss-closed"; label += ", " + (x.reasons[0] || ""); } + if (S.date === date) cls += " ss-sel"; + if (x && x.peak) { cls += " ss-peakday"; label += ", " + x.peak.name; } + h += '"; + } + return h + "
"; + } + + function slots(d, root) { + if (!S.date) return '

' + esc(T.choose) + "

"; + var x = d.dates.filter(function (y) { return y.date === S.date; })[0]; + if (!x) return ""; + var h = '

' + esc(dateLabel(x.date, true)) + (x.peak ? ' ' + esc(x.peak.name) + "" : "") + "

"; + if (d.dateOnly) h += '

' + esc(T.dispatch) + "

"; + if (!x.available) { + h += '

' + esc(x.reasons.join(". ")) + "

"; + if (x.waitlist && !S.waitlistDone) + h += '
"; + if (S.waitlistDone) h += '

' + esc(T.joined) + "

"; + return h + "
"; + } + h += '
'; + x.slots.forEach(function (s) { + var on = S.sel && S.sel.date === x.date && S.sel.slot === s.id; + var sub = s.available ? (d.text.showRemaining && s.remaining != null ? s.remaining + " " + T.left : "") + (s.surcharge ? (s.remaining != null && d.text.showRemaining ? " · " : "") + "+" + money(s.surcharge) : "") : s.reason || ""; + h += '"; + }); + h += "
"; + if (S.sel && S.sel.date === x.date) { + var live = S.expiresAt && S.expiresAt > Date.now(); + h += '

' + (live ? esc(T.held) + ' ' : S.expiresAt ? esc(T.expired) : "✓") + "

"; + var loc = d.locations.filter(function (l) { return l.id === d.locationId; })[0]; + if (loc && loc.pickupInstructions) h += '

' + esc(loc.pickupInstructions) + "

"; + } + return h + "
"; + } + + function tickTimer() { + var left = S.expiresAt ? S.expiresAt - Date.now() : 0; + var els = document.querySelectorAll("[data-ss-timer]"); + if (left <= 0 && els.length) return renderAll(); + var s = Math.max(0, Math.round(left / 1000)); + for (var i = 0; i < els.length; i++) els[i].textContent = Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0"); + } + setInterval(tickTimer, 1000); + + // ---------- events ---------- + document.addEventListener("click", function (e) { + var t = e.target.closest && e.target.closest("[data-datewise-root] [data-ss]"); + if (!t || t.tagName === "FORM") return; + if (t.getAttribute("aria-disabled") === "true" && t.getAttribute("data-ss") !== "date") return; + var a = t.getAttribute("data-ss"), v = t.getAttribute("data-v"); + if (a === "method" && v !== S.method) { S.method = v; lsSet("method", v); S.date = null; S.monthOffset = 0; refresh(true); } + else if (a === "loc" && v !== S.locationId) { S.locationId = v; lsSet("location", v); S.date = null; refresh(true); } + else if (a === "month") { S.monthOffset += +v; renderAll(); } + else if (a === "date") { S.date = v; S.waitlistDone = false; setMessage(null); renderAll(); focusFirst(".ss-slot:not([aria-disabled])"); } + else if (a === "slot") hold(S.date, v); + }); + + document.addEventListener("submit", function (e) { + var f = e.target; + if (!f.closest || !f.closest("[data-datewise-root]")) return; + var a = f.getAttribute("data-ss"); + if (!a) return; + e.preventDefault(); + if (a === "postal") { S.postal = f.postal.value.trim(); lsSet("postal", S.postal); S.date = null; refresh(true); } + if (a === "waitlist" && S.data) { + api("/waitlist", { method: S.data.method, locationId: S.data.locationId, date: S.date, slot: "*", email: f.email.value.trim() }).then(function (r) { + if (r.ok) S.waitlistDone = true; else setMessage(r.message || T.error, "critical"); + renderAll(); + }); + } + }); + + // Arrow-key navigation in the calendar grid. + document.addEventListener("keydown", function (e) { + var t = e.target; + if (!t.classList || !t.classList.contains("ss-day")) return; + var step = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 }[e.key]; + if (!step) return; + var days = Array.prototype.slice.call(t.parentNode.querySelectorAll(".ss-day")); + var next = days[days.indexOf(t) + step]; + if (next) { e.preventDefault(); t.tabIndex = -1; next.tabIndex = 0; next.focus(); } + }); + function focusFirst(sel) { setTimeout(function () { var el = document.querySelector("[data-datewise-root] " + sel); if (el) el.focus(); }, 0); } + + // Checkout guard (convenience; the checkout function is the real enforcement). + function needsTime() { return S.data && S.data.requireScheduling && S.lines.length > 0 && !S.sel; } + function guard(e) { + if (!CFG.checkoutGuard || !needsTime()) return; + var t = e.target.closest && e.target.closest('[name="checkout"], a[href$="/checkout"], form[action$="/checkout"] [type="submit"]'); + if (!t) return; + e.preventDefault(); e.stopPropagation(); + setMessage(T.needTime, "critical"); + renderAll(); + var r = roots()[0]; + if (r) { r.scrollIntoView({ behavior: "smooth", block: "center" }); } + } + document.addEventListener("click", guard, true); + + // React to cart changes made by the theme (add, quantity, remove) — revalidate the selection. + var realFetch = window.fetch.bind(window); + window.fetch = function (input, init) { + var url = typeof input === "string" ? input : input && input.url; + var p = realFetch(input, init); + if (url && /\/cart\/(add|change|update|clear)(\.js)?/.test(url) && !S.mutating) p.then(function () { refresh(); }); + return p; + }; + ["cart:updated", "cart:change", "cart-drawer:open", "ajaxCart:updated"].forEach(function (n) { + document.addEventListener(n, function (e) { if (!(e.detail && e.detail.source === "datewise")) refresh(); }); + }); + if (typeof window.subscribe === "function" && window.PUB_SUB_EVENTS) + window.subscribe(window.PUB_SUB_EVENTS.cartUpdate, function (ev) { if (!ev || ev.source !== "datewise") refresh(); }); + + // ---------- cart drawer mounting ---------- + var DRAWER_SELECTORS = [CFG.drawerSelector, "cart-drawer .drawer__footer", "#CartDrawer .drawer__footer", "#CartDrawer-Footer", ".cart-drawer__footer", "#cart-drawer .cart__footer", ".js-cart-drawer .cart-drawer__footer", "[data-cart-drawer] footer"].filter(Boolean); + function mountDrawer() { + for (var i = 0; i < DRAWER_SELECTORS.length; i++) { + var foot = document.querySelector(DRAWER_SELECTORS[i]); + if (foot && !foot.querySelector("[data-datewise-root]")) { + var el = document.createElement("div"); + el.className = "datewise datewise--drawer"; + el.setAttribute("data-datewise-root", ""); + el.setAttribute("data-drawer", ""); + foot.insertBefore(el, foot.firstChild); + if (S.data) render(el); else refresh(true); + return; + } + } + } + if (cfgEl) { + mountDrawer(); + new MutationObserver(function () { mountDrawer(); }).observe(document.body, { childList: true, subtree: true }); + } + + // ---------- waitlist offer links (?datewise_offer=…) ---------- + function adoptOffer() { + var m = location.search.match(/[?&]datewise_offer=([^&]+)/); + if (!m) return Promise.resolve(); + return api("/offer?token=" + encodeURIComponent(m[1]), null, "GET").then(function (o) { + if (!o.ok) { setMessage(o.message, "critical"); return; } + S.token = o.token; lsSet("token", o.token); + S.method = o.method; S.locationId = o.locationId; S.date = o.date; + setMessage(T.offer + ": " + dateLabel(o.date, true) + ".", "success"); + return load().then(function () { if (S.data && S.data.locationId === o.locationId) return hold(o.date, o.slot); }); + }); + } + + adoptOffer().then(function () { if (!S.data) refresh(true); }); +})(); diff --git a/extensions/datewise-widget/blocks/embed.liquid b/extensions/datewise-widget/blocks/embed.liquid new file mode 100644 index 0000000..42a8fc3 --- /dev/null +++ b/extensions/datewise-widget/blocks/embed.liquid @@ -0,0 +1,56 @@ +{%- comment -%} + App embed: loads the widget on every page so it can (1) appear inside the cart drawer, + (2) adopt waitlist offers from links, and (3) stop checkout clicks when a time is required. +{%- endcomment -%} + + +{% schema %} +{ + "name": "Datewise", + "target": "body", + "javascript": "datewise.js", + "stylesheet": "datewise.css", + "settings": [ + { + "type": "checkbox", + "id": "checkout_guard", + "label": "Ask for a time before checkout", + "default": true, + "info": "Stops the checkout button until a time is chosen, when your Datewise settings require one." + }, + { + "type": "text", + "id": "drawer_selector", + "label": "Cart drawer selector (advanced)", + "info": "Only if the widget doesn't appear in your cart drawer. CSS selector for the drawer's footer." + } + ] +} +{% endschema %} diff --git a/extensions/datewise-widget/blocks/scheduler.liquid b/extensions/datewise-widget/blocks/scheduler.liquid new file mode 100644 index 0000000..3dc4d53 --- /dev/null +++ b/extensions/datewise-widget/blocks/scheduler.liquid @@ -0,0 +1,31 @@ +{%- comment -%} + App block for the cart page. The widget renders into this element; the app embed + (embed.liquid) adds the same widget to cart drawers. +{%- endcomment -%} +
+ + +
+ +{% schema %} +{ + "name": "Delivery date & time", + "target": "section", + "javascript": "datewise.js", + "stylesheet": "datewise.css", + "enabled_on": { "templates": ["cart"] }, + "settings": [ + { + "type": "text", + "id": "heading", + "label": "Heading", + "info": "Leave blank to use the heading from the Datewise app settings." + } + ] +} +{% endschema %} diff --git a/extensions/datewise-widget/locales/en.default.json b/extensions/datewise-widget/locales/en.default.json new file mode 100644 index 0000000..c57e635 --- /dev/null +++ b/extensions/datewise-widget/locales/en.default.json @@ -0,0 +1,23 @@ +{ + "datewise": { + "noscript": "Turn on JavaScript to choose a delivery or pickup time.", + "choose": "Pick a highlighted date", + "need_time": "Please choose a delivery or pickup time before checking out.", + "held": "Held for you for", + "expired": "Your hold expired. Tap the time again to check it's still free.", + "left": "left", + "full": "Full", + "postal": "Postal / ZIP code", + "check": "Check", + "waitlist": "Join the waitlist", + "joined": "You're on the waitlist. We'll message you if a place opens.", + "dispatch": "Ships this day (not an arrival date)", + "change": "Change", + "prep": "Earliest times include preparation for", + "free": "Free", + "fee": "fee", + "next_available": "Next available", + "offer": "A place opened up and we're holding it for you", + "error": "Something went wrong. Please try again." + } +} diff --git a/extensions/datewise-widget/locales/fr.json b/extensions/datewise-widget/locales/fr.json new file mode 100644 index 0000000..486dba3 --- /dev/null +++ b/extensions/datewise-widget/locales/fr.json @@ -0,0 +1,23 @@ +{ + "datewise": { + "noscript": "Activez JavaScript pour choisir une heure de livraison ou de cueillette.", + "choose": "Choisissez une date en surbrillance", + "need_time": "Veuillez choisir une heure de livraison ou de cueillette avant de payer.", + "held": "Réservé pour vous pendant", + "expired": "Votre réservation a expiré. Touchez l'heure de nouveau pour vérifier qu'elle est libre.", + "left": "restant(s)", + "full": "Complet", + "postal": "Code postal", + "check": "Vérifier", + "waitlist": "Rejoindre la liste d'attente", + "joined": "Vous êtes sur la liste d'attente. Nous vous écrirons si une place se libère.", + "dispatch": "Expédié ce jour-là (pas une date d'arrivée)", + "change": "Modifier", + "prep": "Les premières heures incluent la préparation de", + "free": "Gratuit", + "fee": "frais", + "next_available": "Prochaine disponibilité", + "offer": "Une place s'est libérée et nous la gardons pour vous", + "error": "Une erreur s'est produite. Veuillez réessayer." + } +} diff --git a/extensions/datewise-widget/shopify.extension.toml b/extensions/datewise-widget/shopify.extension.toml new file mode 100644 index 0000000..29feb3a --- /dev/null +++ b/extensions/datewise-widget/shopify.extension.toml @@ -0,0 +1,2 @@ +name = "Datewise widget" +type = "theme" diff --git a/extensions/order-reschedule/package.json b/extensions/order-reschedule/package.json new file mode 100644 index 0000000..3f08ee2 --- /dev/null +++ b/extensions/order-reschedule/package.json @@ -0,0 +1,10 @@ +{ + "name": "datewise-order-reschedule", + "private": true, + "version": "1.0.0", + "license": "UNLICENSED", + "dependencies": { + "@shopify/ui-extensions": "2026.1.x", + "preact": "^10.26.0" + } +} diff --git a/extensions/order-reschedule/shopify.extension.toml b/extensions/order-reschedule/shopify.extension.toml new file mode 100644 index 0000000..3d9af60 --- /dev/null +++ b/extensions/order-reschedule/shopify.extension.toml @@ -0,0 +1,15 @@ +api_version = "2026-01" + +[[extensions]] +name = "Datewise: delivery time" +handle = "datewise-order-reschedule" +type = "ui_extension" + + [[extensions.targeting]] + module = "./src/OrderStatusBlock.jsx" + target = "customer-account.order-status.block.render" + + [extensions.capabilities] + # Calls the Datewise backend with the customer's session token. + network_access = true + api_access = true diff --git a/extensions/order-reschedule/src/OrderStatusBlock.jsx b/extensions/order-reschedule/src/OrderStatusBlock.jsx new file mode 100644 index 0000000..ecd1eef --- /dev/null +++ b/extensions/order-reschedule/src/OrderStatusBlock.jsx @@ -0,0 +1,103 @@ +/** + * Order status page block (new customer accounts): shows the booked date/time and lets the + * customer change it within the merchant's policy. All rules and capacity are checked on + * the server; this UI only lists the options the server returns. + */ +import "@shopify/ui-extensions/preact"; +import { render } from "preact"; +import { useEffect, useState } from "preact/hooks"; +import { APP_URL } from "./config"; + +export default async () => { + render(, document.body); +}; + +const fmtDate = (d) => + new Date(`${d}T12:00:00Z`).toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric", timeZone: "UTC" }); +const fmtTime = (t) => + new Date(Date.UTC(2000, 0, 1, +t.slice(0, 2), +t.slice(3, 5))).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit", timeZone: "UTC" }); + +async function call(path, init = {}) { + const token = await shopify.sessionToken.get(); + const res = await fetch(`${APP_URL}${path}`, { + ...init, + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...(init.headers || {}) }, + }); + return res.json(); +} + +function Extension() { + const orderId = shopify.order.value?.id; + const [data, setData] = useState(null); + const [editing, setEditing] = useState(false); + const [choice, setChoice] = useState(""); + const [busy, setBusy] = useState(false); + const [msg, setMsg] = useState(null); + + const load = () => + orderId && + call(`/api/customer/booking?orderId=${encodeURIComponent(orderId)}`) + .then(setData) + .catch(() => setData({ booking: null })); + useEffect(() => { + load(); + }, [orderId]); + + if (!data?.booking) return null; + const b = data.booking; + const label = { pickup: "Pickup", delivery: "Delivery", shipping: "Dispatch" }[b.method]; + + const save = async () => { + const opt = data.options.find((o) => `${o.date}|${o.slot}` === choice); + if (!opt) return; + setBusy(true); + const r = await call("/api/customer/booking", { method: "POST", body: JSON.stringify({ orderId, date: opt.date, slot: opt.slot }) }); + setBusy(false); + if (r.ok) { + setMsg({ tone: "success", text: "Your new time is saved." }); + setEditing(false); + load(); + } else setMsg({ tone: "critical", text: r.message || "That time is no longer available." }); + }; + + return ( + + + {msg && {msg.text}} + + {fmtDate(b.date)} + {b.method !== "shipping" && b.slotStart ? `, ${fmtTime(b.slotStart)} – ${fmtTime(b.slotEnd)}` : ""} + + {b.location && {b.method === "pickup" ? `Pick up at ${b.location}` : `From ${b.location}`}} + {b.instructions && {b.instructions}} + {data.canReschedule && !editing && ( + + setEditing(true)}>Change date or time + You can change it until {new Date(data.deadline).toLocaleString()}. + + )} + {!data.canReschedule && data.reason && {data.reason}} + {editing && ( + + {data.options.length === 0 ? ( + There are no other times available right now. + ) : ( + setChoice(e.currentTarget.value)}> + Choose a time + {data.options.map((o) => ( + + {`${fmtDate(o.date)}${b.method !== "shipping" ? `, ${fmtTime(o.start)} – ${fmtTime(o.end)}` : ""}`} + + ))} + + )} + + Save new time + setEditing(false)}>Cancel + + + )} + + + ); +} diff --git a/extensions/order-reschedule/src/config.js b/extensions/order-reschedule/src/config.js new file mode 100644 index 0000000..1ebaf8e --- /dev/null +++ b/extensions/order-reschedule/src/config.js @@ -0,0 +1,2 @@ +// Set to your deployed app URL (same as application_url in shopify.app.toml). +export const APP_URL = "https://REPLACE-WITH-APP-URL"; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..249ae3b --- /dev/null +++ b/package-lock.json @@ -0,0 +1,9968 @@ +{ + "name": "datewise", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "datewise", + "version": "0.1.0", + "dependencies": { + "@prisma/adapter-pg": "6.19", + "@prisma/client": "^6.17.1", + "@react-router/dev": "^7.9.0", + "@react-router/fs-routes": "^7.9.0", + "@react-router/node": "^7.9.0", + "@react-router/serve": "^7.9.0", + "@shopify/app-bridge-react": "^4.2.0", + "@shopify/shopify-app-react-router": "^3.0.2", + "@shopify/shopify-app-session-storage-prisma": "^11.0.1", + "isbot": "^5.1.0", + "pg": "^8.23.1", + "prisma": "^6.17.1", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router": "^7.9.0", + "vite": "^6.3.0", + "vite-tsconfig-paths": "^5.1.4", + "zod": "^3.25.0" + }, + "devDependencies": { + "@shopify/api-codegen-preset": "^1.1.0", + "@shopify/app-bridge-types": "^0.7.2", + "@shopify/polaris-types": "^1.1.0", + "@types/node": "^22.0.0", + "@types/pg": "^8.23.1", + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "esbuild": "^0.25.0", + "playwright": "^1.56.0", + "tsx": "^4.19.0", + "typescript": "^5.8.0", + "vitest": "^3.2.0" + }, + "engines": { + "node": ">=20.10" + } + }, + "node_modules/@ardatan/relay-compiler": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/@ardatan/relay-compiler/-/relay-compiler-13.0.2.tgz", + "integrity": "sha512-VFpv9UP820SiwDUPYtq7PmD3jifzZlevkQ26bhbSzFeruSTys0eHzQCZyKg+IhgmZzwPI9AFjPe26ABNjGeIKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^8.0.0", + "immutable": "^5.1.9", + "invariant": "^2.2.4" + }, + "peerDependencies": { + "graphql": "*" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", + "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz", + "integrity": "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/traverse": "^7.29.7", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz", + "integrity": "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz", + "integrity": "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz", + "integrity": "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz", + "integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.10.tgz", + "integrity": "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", + "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz", + "integrity": "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.9.tgz", + "integrity": "sha512-FFwIwzU+7SCOuxxV4YtJql6T9981ZVTm+FHO5GhVsRqCTdy0WwrEZh3l42ARpXruJUDGOptdduHW6Zr7pNPLNg==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/plugin-syntax-typescript": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.29.7.tgz", + "integrity": "sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "@babel/plugin-syntax-jsx": "^7.29.7", + "@babel/plugin-transform-modules-commonjs": "^7.29.7", + "@babel/plugin-transform-typescript": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-8.0.7.tgz", + "integrity": "sha512-k0hagDDq/6w4v+0Sp4wFvIz/yswH5O7Va6aRTnD7N62Tnborp4X+YB+H3XUcWVSR4aE47WPEXiIIZchBs0GmYg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.10.tgz", + "integrity": "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.9", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@envelop/core": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/@envelop/core/-/core-5.6.1.tgz", + "integrity": "sha512-KEbJ7yrAA+W7IY9MZzpZJ77w/72ndaV/16+hhPapd+0Ohjqffl0mmF3QWy62/m3nRP4qidD+1eiEjJhS/O38Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@envelop/instrumentation": "^1.0.1", + "@envelop/types": "^5.2.2", + "@whatwg-node/promise-helpers": "^1.2.4", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@envelop/instrumentation": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@envelop/instrumentation/-/instrumentation-1.0.1.tgz", + "integrity": "sha512-FuexE2qd3Yc1Kh1DjTliwGxb9kyyv4ySBNnfJOxnQasVmGdcqX6AkkO5sIMxLWFRftp8PErOcIn2dQW/1RwytQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@whatwg-node/promise-helpers": "^1.2.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@envelop/types": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@envelop/types/-/types-5.2.2.tgz", + "integrity": "sha512-nl9cmUh8F+2/NNzpcLjzNqtaVn0m8XSzzxrPU2ByHhlz4Hqlc5QBfRsemZkoJMKrMviJXyJYdKZiUP1o70F3Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@whatwg-node/promise-helpers": "^1.0.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fastify/busboy": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.3.tgz", + "integrity": "sha512-fmo/IKgkenPzz3qTKG+sIUXdF/Kv83AkvvOXJHPTHJeKJbat8SsL1ihUaFaDotqoEl4UZ1Lf8Eb/3vK6eaWX2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@graphql-codegen/add": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@graphql-codegen/add/-/add-5.0.3.tgz", + "integrity": "sha512-SxXPmramkth8XtBlAHu4H4jYcYXM/o3p01+psU+0NADQowA8jtYkK6MW5rV6T+CxkEaNZItfSmZRPgIuypcqnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-codegen/plugin-helpers": "^5.0.3", + "tslib": "~2.6.0" + }, + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" + } + }, + "node_modules/@graphql-codegen/add/node_modules/tslib": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", + "dev": true, + "license": "0BSD" + }, + "node_modules/@graphql-codegen/cli": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/@graphql-codegen/cli/-/cli-5.0.7.tgz", + "integrity": "sha512-h/sxYvSaWtxZxo8GtaA8SvcHTyViaaPd7dweF/hmRDpaQU1o3iU3EZxlcJ+oLTunU0tSMFsnrIXm/mhXxI11Cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/generator": "^7.18.13", + "@babel/template": "^7.18.10", + "@babel/types": "^7.18.13", + "@graphql-codegen/client-preset": "^4.8.2", + "@graphql-codegen/core": "^4.0.2", + "@graphql-codegen/plugin-helpers": "^5.1.1", + "@graphql-tools/apollo-engine-loader": "^8.0.0", + "@graphql-tools/code-file-loader": "^8.0.0", + "@graphql-tools/git-loader": "^8.0.0", + "@graphql-tools/github-loader": "^8.0.0", + "@graphql-tools/graphql-file-loader": "^8.0.0", + "@graphql-tools/json-file-loader": "^8.0.0", + "@graphql-tools/load": "^8.1.0", + "@graphql-tools/prisma-loader": "^8.0.0", + "@graphql-tools/url-loader": "^8.0.0", + "@graphql-tools/utils": "^10.0.0", + "@whatwg-node/fetch": "^0.10.0", + "chalk": "^4.1.0", + "cosmiconfig": "^8.1.3", + "debounce": "^1.2.0", + "detect-indent": "^6.0.0", + "graphql-config": "^5.1.1", + "inquirer": "^8.0.0", + "is-glob": "^4.0.1", + "jiti": "^1.17.1", + "json-to-pretty-yaml": "^1.2.2", + "listr2": "^4.0.5", + "log-symbols": "^4.0.0", + "micromatch": "^4.0.5", + "shell-quote": "^1.7.3", + "string-env-interpolation": "^1.0.1", + "ts-log": "^2.2.3", + "tslib": "^2.4.0", + "yaml": "^2.3.1", + "yargs": "^17.0.0" + }, + "bin": { + "gql-gen": "cjs/bin.js", + "graphql-code-generator": "cjs/bin.js", + "graphql-codegen": "cjs/bin.js", + "graphql-codegen-esm": "esm/bin.js" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "@parcel/watcher": "^2.1.0", + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" + }, + "peerDependenciesMeta": { + "@parcel/watcher": { + "optional": true + } + } + }, + "node_modules/@graphql-codegen/client-preset": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/@graphql-codegen/client-preset/-/client-preset-4.8.3.tgz", + "integrity": "sha512-QpEsPSO9fnRxA6Z66AmBuGcwHjZ6dYSxYo5ycMlYgSPzAbyG8gn/kWljofjJfWqSY+T/lRn+r8IXTH14ml24vQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/template": "^7.20.7", + "@graphql-codegen/add": "^5.0.3", + "@graphql-codegen/gql-tag-operations": "4.0.17", + "@graphql-codegen/plugin-helpers": "^5.1.1", + "@graphql-codegen/typed-document-node": "^5.1.2", + "@graphql-codegen/typescript": "^4.1.6", + "@graphql-codegen/typescript-operations": "^4.6.1", + "@graphql-codegen/visitor-plugin-common": "^5.8.0", + "@graphql-tools/documents": "^1.0.0", + "@graphql-tools/utils": "^10.0.0", + "@graphql-typed-document-node/core": "3.2.0", + "tslib": "~2.6.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0", + "graphql-sock": "^1.0.0" + }, + "peerDependenciesMeta": { + "graphql-sock": { + "optional": true + } + } + }, + "node_modules/@graphql-codegen/client-preset/node_modules/@graphql-codegen/schema-ast": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@graphql-codegen/schema-ast/-/schema-ast-4.1.0.tgz", + "integrity": "sha512-kZVn0z+th9SvqxfKYgztA6PM7mhnSZaj4fiuBWvMTqA+QqQ9BBed6Pz41KuD/jr0gJtnlr2A4++/0VlpVbCTmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-codegen/plugin-helpers": "^5.0.3", + "@graphql-tools/utils": "^10.0.0", + "tslib": "~2.6.0" + }, + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" + } + }, + "node_modules/@graphql-codegen/client-preset/node_modules/@graphql-codegen/typescript": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@graphql-codegen/typescript/-/typescript-4.1.6.tgz", + "integrity": "sha512-vpw3sfwf9A7S+kIUjyFxuvrywGxd4lmwmyYnnDVjVE4kSQ6Td3DpqaPTy8aNQ6O96vFoi/bxbZS2BW49PwSUUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-codegen/plugin-helpers": "^5.1.0", + "@graphql-codegen/schema-ast": "^4.0.2", + "@graphql-codegen/visitor-plugin-common": "5.8.0", + "auto-bind": "~4.0.0", + "tslib": "~2.6.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "graphql": "^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" + } + }, + "node_modules/@graphql-codegen/client-preset/node_modules/tslib": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", + "dev": true, + "license": "0BSD" + }, + "node_modules/@graphql-codegen/core": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@graphql-codegen/core/-/core-4.0.2.tgz", + "integrity": "sha512-IZbpkhwVqgizcjNiaVzNAzm/xbWT6YnGgeOLwVjm4KbJn3V2jchVtuzHH09G5/WkkLSk2wgbXNdwjM41JxO6Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-codegen/plugin-helpers": "^5.0.3", + "@graphql-tools/schema": "^10.0.0", + "@graphql-tools/utils": "^10.0.0", + "tslib": "~2.6.0" + }, + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" + } + }, + "node_modules/@graphql-codegen/core/node_modules/tslib": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", + "dev": true, + "license": "0BSD" + }, + "node_modules/@graphql-codegen/gql-tag-operations": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@graphql-codegen/gql-tag-operations/-/gql-tag-operations-4.0.17.tgz", + "integrity": "sha512-2pnvPdIG6W9OuxkrEZ6hvZd142+O3B13lvhrZ48yyEBh2ujtmKokw0eTwDHtlXUqjVS0I3q7+HB2y12G/m69CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-codegen/plugin-helpers": "^5.1.0", + "@graphql-codegen/visitor-plugin-common": "5.8.0", + "@graphql-tools/utils": "^10.0.0", + "auto-bind": "~4.0.0", + "tslib": "~2.6.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" + } + }, + "node_modules/@graphql-codegen/gql-tag-operations/node_modules/tslib": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", + "dev": true, + "license": "0BSD" + }, + "node_modules/@graphql-codegen/introspection": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@graphql-codegen/introspection/-/introspection-4.0.3.tgz", + "integrity": "sha512-4cHRG15Zu4MXMF4wTQmywNf4+fkDYv5lTbzraVfliDnB8rJKcaurQpRBi11KVuQUe24YTq/Cfk4uwewfNikWoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-codegen/plugin-helpers": "^5.0.3", + "@graphql-codegen/visitor-plugin-common": "^5.0.0", + "tslib": "~2.6.0" + }, + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" + } + }, + "node_modules/@graphql-codegen/introspection/node_modules/tslib": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", + "dev": true, + "license": "0BSD" + }, + "node_modules/@graphql-codegen/plugin-helpers": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@graphql-codegen/plugin-helpers/-/plugin-helpers-5.1.1.tgz", + "integrity": "sha512-28GHODK2HY1NhdyRcPP3sCz0Kqxyfiz7boIZ8qIxFYmpLYnlDgiYok5fhFLVSZihyOpCs4Fa37gVHf/Q4I2FEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/utils": "^10.0.0", + "change-case-all": "1.0.15", + "common-tags": "1.8.2", + "import-from": "4.0.0", + "lodash": "~4.17.0", + "tslib": "~2.6.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" + } + }, + "node_modules/@graphql-codegen/plugin-helpers/node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@graphql-codegen/plugin-helpers/node_modules/tslib": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", + "dev": true, + "license": "0BSD" + }, + "node_modules/@graphql-codegen/schema-ast": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@graphql-codegen/schema-ast/-/schema-ast-5.0.2.tgz", + "integrity": "sha512-jl1F/9IjRkJisEb9B0ayG4QGqYlPldLRy8ojDdmL9NE1NsdB5ROfxQnSqyC3g+wuvBhWX7kZgMRQYn3RU1I5bA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-codegen/plugin-helpers": "^6.3.0", + "@graphql-tools/utils": "^11.0.0", + "tslib": "^2.8.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" + } + }, + "node_modules/@graphql-codegen/schema-ast/node_modules/@graphql-codegen/plugin-helpers": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@graphql-codegen/plugin-helpers/-/plugin-helpers-6.3.0.tgz", + "integrity": "sha512-Auc+/B7okDx9+pVgLVliZtZLYh6iltWXlnzzM+bRE+zh1T4r3hKbnr8xAmtT937ArfSgk5GHcQHr8LfPYnrRBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/utils": "^11.0.0", + "change-case-all": "1.0.15", + "common-tags": "1.8.2", + "import-from": "4.0.0", + "tslib": "^2.8.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" + } + }, + "node_modules/@graphql-codegen/schema-ast/node_modules/@graphql-tools/utils": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-11.2.2.tgz", + "integrity": "sha512-Do2A/t6ayqOma8m7PvpYMsCBswL+NB35BQSng4GWSBqafL2/BnfAZy/NSENPwV721Rm2fG0BHETFC0+FJJZmLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "@whatwg-node/promise-helpers": "^1.0.0", + "cross-inspect": "1.0.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-codegen/typed-document-node": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@graphql-codegen/typed-document-node/-/typed-document-node-5.1.2.tgz", + "integrity": "sha512-jaxfViDqFRbNQmfKwUY8hDyjnLTw2Z7DhGutxoOiiAI0gE/LfPe0LYaVFKVmVOOD7M3bWxoWfu4slrkbWbUbEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-codegen/plugin-helpers": "^5.1.0", + "@graphql-codegen/visitor-plugin-common": "5.8.0", + "auto-bind": "~4.0.0", + "change-case-all": "1.0.15", + "tslib": "~2.6.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" + } + }, + "node_modules/@graphql-codegen/typed-document-node/node_modules/tslib": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", + "dev": true, + "license": "0BSD" + }, + "node_modules/@graphql-codegen/typescript": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/@graphql-codegen/typescript/-/typescript-5.0.10.tgz", + "integrity": "sha512-Pa8OFmL9TdhEYnLYJLYA9EhP8eEeivP/YDYq4Nb8LQaL7GXm4TGX8zELYaCM9Fu8M3iZb7iQGMt7qc+1lXz8XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-codegen/plugin-helpers": "^6.3.0", + "@graphql-codegen/schema-ast": "^5.0.2", + "@graphql-codegen/visitor-plugin-common": "^6.3.0", + "auto-bind": "~4.0.0", + "tslib": "^2.8.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "graphql": "^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" + } + }, + "node_modules/@graphql-codegen/typescript-operations": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/@graphql-codegen/typescript-operations/-/typescript-operations-4.6.1.tgz", + "integrity": "sha512-k92laxhih7s0WZ8j5WMIbgKwhe64C0As6x+PdcvgZFMudDJ7rPJ/hFqJ9DCRxNjXoHmSjnr6VUuQZq4lT1RzCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-codegen/plugin-helpers": "^5.1.0", + "@graphql-codegen/typescript": "^4.1.6", + "@graphql-codegen/visitor-plugin-common": "5.8.0", + "auto-bind": "~4.0.0", + "tslib": "~2.6.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0", + "graphql-sock": "^1.0.0" + }, + "peerDependenciesMeta": { + "graphql-sock": { + "optional": true + } + } + }, + "node_modules/@graphql-codegen/typescript-operations/node_modules/@graphql-codegen/schema-ast": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@graphql-codegen/schema-ast/-/schema-ast-4.1.0.tgz", + "integrity": "sha512-kZVn0z+th9SvqxfKYgztA6PM7mhnSZaj4fiuBWvMTqA+QqQ9BBed6Pz41KuD/jr0gJtnlr2A4++/0VlpVbCTmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-codegen/plugin-helpers": "^5.0.3", + "@graphql-tools/utils": "^10.0.0", + "tslib": "~2.6.0" + }, + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" + } + }, + "node_modules/@graphql-codegen/typescript-operations/node_modules/@graphql-codegen/typescript": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@graphql-codegen/typescript/-/typescript-4.1.6.tgz", + "integrity": "sha512-vpw3sfwf9A7S+kIUjyFxuvrywGxd4lmwmyYnnDVjVE4kSQ6Td3DpqaPTy8aNQ6O96vFoi/bxbZS2BW49PwSUUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-codegen/plugin-helpers": "^5.1.0", + "@graphql-codegen/schema-ast": "^4.0.2", + "@graphql-codegen/visitor-plugin-common": "5.8.0", + "auto-bind": "~4.0.0", + "tslib": "~2.6.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "graphql": "^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" + } + }, + "node_modules/@graphql-codegen/typescript-operations/node_modules/tslib": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", + "dev": true, + "license": "0BSD" + }, + "node_modules/@graphql-codegen/typescript/node_modules/@graphql-codegen/plugin-helpers": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@graphql-codegen/plugin-helpers/-/plugin-helpers-6.3.0.tgz", + "integrity": "sha512-Auc+/B7okDx9+pVgLVliZtZLYh6iltWXlnzzM+bRE+zh1T4r3hKbnr8xAmtT937ArfSgk5GHcQHr8LfPYnrRBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/utils": "^11.0.0", + "change-case-all": "1.0.15", + "common-tags": "1.8.2", + "import-from": "4.0.0", + "tslib": "^2.8.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" + } + }, + "node_modules/@graphql-codegen/typescript/node_modules/@graphql-codegen/visitor-plugin-common": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@graphql-codegen/visitor-plugin-common/-/visitor-plugin-common-6.3.0.tgz", + "integrity": "sha512-vGBoE+4huzZyNhyGSAhXAkdROHlwKxxuziZm4XtP1mxe7nuI+VgyOmXebafLijbmuDsptPXQN0C/htL54O8hrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-codegen/plugin-helpers": "^6.3.0", + "@graphql-tools/optimize": "^2.0.0", + "@graphql-tools/relay-operation-optimizer": "^7.1.1", + "@graphql-tools/utils": "^11.0.0", + "auto-bind": "~4.0.0", + "change-case-all": "1.0.15", + "dependency-graph": "^1.0.0", + "graphql-tag": "^2.11.0", + "parse-filepath": "^1.0.2", + "tslib": "^2.8.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" + } + }, + "node_modules/@graphql-codegen/typescript/node_modules/@graphql-tools/utils": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-11.2.2.tgz", + "integrity": "sha512-Do2A/t6ayqOma8m7PvpYMsCBswL+NB35BQSng4GWSBqafL2/BnfAZy/NSENPwV721Rm2fG0BHETFC0+FJJZmLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "@whatwg-node/promise-helpers": "^1.0.0", + "cross-inspect": "1.0.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-codegen/typescript/node_modules/dependency-graph": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/dependency-graph/-/dependency-graph-1.0.0.tgz", + "integrity": "sha512-cW3gggJ28HZ/LExwxP2B++aiKxhJXMSIt9K48FOXQkm+vuG5gyatXnLsONRJdzO/7VfjDIiaOOa/bs4l464Lwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@graphql-codegen/visitor-plugin-common": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@graphql-codegen/visitor-plugin-common/-/visitor-plugin-common-5.8.0.tgz", + "integrity": "sha512-lC1E1Kmuzi3WZUlYlqB4fP6+CvbKH9J+haU1iWmgsBx5/sO2ROeXJG4Dmt8gP03bI2BwjiwV5WxCEMlyeuzLnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-codegen/plugin-helpers": "^5.1.0", + "@graphql-tools/optimize": "^2.0.0", + "@graphql-tools/relay-operation-optimizer": "^7.0.0", + "@graphql-tools/utils": "^10.0.0", + "auto-bind": "~4.0.0", + "change-case-all": "1.0.15", + "dependency-graph": "^0.11.0", + "graphql-tag": "^2.11.0", + "parse-filepath": "^1.0.2", + "tslib": "~2.6.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" + } + }, + "node_modules/@graphql-codegen/visitor-plugin-common/node_modules/tslib": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", + "dev": true, + "license": "0BSD" + }, + "node_modules/@graphql-hive/signal": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@graphql-hive/signal/-/signal-1.0.0.tgz", + "integrity": "sha512-RiwLMc89lTjvyLEivZ/qxAC5nBHoS2CtsWFSOsN35sxG9zoo5Z+JsFHM8MlvmO9yt+MJNIyC5MLE1rsbOphlag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@graphql-tools/apollo-engine-loader": { + "version": "8.0.39", + "resolved": "https://registry.npmjs.org/@graphql-tools/apollo-engine-loader/-/apollo-engine-loader-8.0.39.tgz", + "integrity": "sha512-9WL8LVUAT/Eq8dtkRJ3M8qarlilGoYlGVVw3RRh06YzRsyXxHOCM32yQEzyQAVNp3VY0/zwJMBeSMi7anGS4PA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/utils": "^12.0.4", + "@whatwg-node/fetch": "^0.12.0", + "sync-fetch": "0.7.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/apollo-engine-loader/node_modules/@graphql-tools/utils": { + "version": "12.0.4", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-12.0.4.tgz", + "integrity": "sha512-hJmeBRrK1OUABqRxLXB1YwrBB+3zTXeRJYb5PaF7CweV80zNGnN640Jz+0BrCaUE8HBk6KtoVXM/tUScUsKdeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "@whatwg-node/promise-helpers": "^2.0.0", + "cross-inspect": "1.0.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/apollo-engine-loader/node_modules/@whatwg-node/disposablestack": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@whatwg-node/disposablestack/-/disposablestack-0.1.0.tgz", + "integrity": "sha512-ok0A9+D+mdeb3w1GNCpVr+2gVVLRWKo9Ywm/whNsoWSNfNg3jNxS0/wUF6NSUYbfOtFGu2wZgJnLjasbaQEfvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@whatwg-node/promise-helpers": "^2.0.0", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=22.15.0" + } + }, + "node_modules/@graphql-tools/apollo-engine-loader/node_modules/@whatwg-node/fetch": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/@whatwg-node/fetch/-/fetch-0.12.1.tgz", + "integrity": "sha512-MjSXerFVxhWwPXZMNp4b4Q3WZMEIqzinHVWuH5tF2LNGy4hHypqPJCNpaR6bEZbaGdlH+r5dRAmQ8bxowcetjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@whatwg-node/node-fetch": "^0.9.1", + "urlpattern-polyfill": "^10.0.0" + }, + "engines": { + "node": ">=22.15.0" + } + }, + "node_modules/@graphql-tools/apollo-engine-loader/node_modules/@whatwg-node/node-fetch": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@whatwg-node/node-fetch/-/node-fetch-0.9.1.tgz", + "integrity": "sha512-LX/1X2QEHupCz6YXRjefZRoOsycCGOIJdXtQPVVxhzPQ/CpWpWFaXl53ShqLystvLZSvtUDKLhCA/yLveHA/LA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^3.1.1", + "@whatwg-node/disposablestack": "^0.1.0", + "@whatwg-node/promise-helpers": "^2.0.0", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=22.15.0" + } + }, + "node_modules/@graphql-tools/apollo-engine-loader/node_modules/@whatwg-node/promise-helpers": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-2.0.0.tgz", + "integrity": "sha512-/i9CS0Z96o+cPwaYoAyVxAThDiUX1wV3tDmNWPJbn+vtdR+JftuCDg1NGCXBrzIYVDGkJ8uT6ctwmrBOUzOpKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=22.15.0" + } + }, + "node_modules/@graphql-tools/batch-execute": { + "version": "9.0.19", + "resolved": "https://registry.npmjs.org/@graphql-tools/batch-execute/-/batch-execute-9.0.19.tgz", + "integrity": "sha512-VGamgY4PLzSx48IHPoblRw0oTaBa7S26RpZXt0Y4NN90ytoE0LutlpB2484RbkfcTjv9wa64QD474+YP1kEgGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/utils": "^10.9.1", + "@whatwg-node/promise-helpers": "^1.3.0", + "dataloader": "^2.2.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/code-file-loader": { + "version": "8.1.42", + "resolved": "https://registry.npmjs.org/@graphql-tools/code-file-loader/-/code-file-loader-8.1.42.tgz", + "integrity": "sha512-HW4xhLO7xU5FY5ukNHCU8bvXoxubU1BJVMVcm3pCTFdtKAmVhf+qURIP0KDf6uICnGR1wcAt/JZAetZeRaXC0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/graphql-tag-pluck": "8.3.40", + "@graphql-tools/utils": "^12.0.4", + "globby": "^11.0.3", + "tslib": "^2.4.0", + "unixify": "^1.0.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/code-file-loader/node_modules/@graphql-tools/utils": { + "version": "12.0.4", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-12.0.4.tgz", + "integrity": "sha512-hJmeBRrK1OUABqRxLXB1YwrBB+3zTXeRJYb5PaF7CweV80zNGnN640Jz+0BrCaUE8HBk6KtoVXM/tUScUsKdeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "@whatwg-node/promise-helpers": "^2.0.0", + "cross-inspect": "1.0.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/code-file-loader/node_modules/@whatwg-node/promise-helpers": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-2.0.0.tgz", + "integrity": "sha512-/i9CS0Z96o+cPwaYoAyVxAThDiUX1wV3tDmNWPJbn+vtdR+JftuCDg1NGCXBrzIYVDGkJ8uT6ctwmrBOUzOpKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=22.15.0" + } + }, + "node_modules/@graphql-tools/delegate": { + "version": "10.2.23", + "resolved": "https://registry.npmjs.org/@graphql-tools/delegate/-/delegate-10.2.23.tgz", + "integrity": "sha512-xrPtl7f1LxS+B6o+W7ueuQh67CwRkfl+UKJncaslnqYdkxKmNBB4wnzVcW8ZsRdwbsla/v43PtwAvSlzxCzq2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/batch-execute": "^9.0.19", + "@graphql-tools/executor": "^1.4.9", + "@graphql-tools/schema": "^10.0.25", + "@graphql-tools/utils": "^10.9.1", + "@repeaterjs/repeater": "^3.0.6", + "@whatwg-node/promise-helpers": "^1.3.0", + "dataloader": "^2.2.3", + "dset": "^3.1.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/documents": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@graphql-tools/documents/-/documents-1.0.2.tgz", + "integrity": "sha512-l/0r+E0857uSa0zr70qFxzV7SiqGVWbR8KwxLyynENmIeHw6JpChUpYmp2cSUjrUZ9aRMlZ8X01utcsZFeiBFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/executor": { + "version": "1.5.7", + "resolved": "https://registry.npmjs.org/@graphql-tools/executor/-/executor-1.5.7.tgz", + "integrity": "sha512-UcXVClkBml+qyGEsQxfEaAkboqySVGFUd9ivn7pDc9jZSckgF6zL21cNxuRH5ZA2exneV3PTtcc0I0rDOdE0Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/utils": "^11.2.2", + "@graphql-typed-document-node/core": "^3.2.0", + "@repeaterjs/repeater": "^3.1.0", + "@whatwg-node/disposablestack": "^0.0.6", + "@whatwg-node/promise-helpers": "^1.0.0", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/executor-common": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@graphql-tools/executor-common/-/executor-common-0.0.4.tgz", + "integrity": "sha512-SEH/OWR+sHbknqZyROCFHcRrbZeUAyjCsgpVWCRjqjqRbiJiXq6TxNIIOmpXgkrXWW/2Ev4Wms6YSGJXjdCs6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@envelop/core": "^5.2.3", + "@graphql-tools/utils": "^10.8.1" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/executor-graphql-ws": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@graphql-tools/executor-graphql-ws/-/executor-graphql-ws-2.0.7.tgz", + "integrity": "sha512-J27za7sKF6RjhmvSOwOQFeNhNHyP4f4niqPnerJmq73OtLx9Y2PGOhkXOEB0PjhvPJceuttkD2O1yMgEkTGs3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/executor-common": "^0.0.6", + "@graphql-tools/utils": "^10.9.1", + "@whatwg-node/disposablestack": "^0.0.6", + "graphql-ws": "^6.0.6", + "isomorphic-ws": "^5.0.0", + "tslib": "^2.8.1", + "ws": "^8.18.3" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/executor-graphql-ws/node_modules/@graphql-tools/executor-common": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@graphql-tools/executor-common/-/executor-common-0.0.6.tgz", + "integrity": "sha512-JAH/R1zf77CSkpYATIJw+eOJwsbWocdDjY+avY7G+P5HCXxwQjAjWVkJI1QJBQYjPQDVxwf1fmTZlIN3VOadow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@envelop/core": "^5.3.0", + "@graphql-tools/utils": "^10.9.1" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/executor-http": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@graphql-tools/executor-http/-/executor-http-1.3.3.tgz", + "integrity": "sha512-LIy+l08/Ivl8f8sMiHW2ebyck59JzyzO/yF9SFS4NH6MJZUezA1xThUXCDIKhHiD56h/gPojbkpcFvM2CbNE7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-hive/signal": "^1.0.0", + "@graphql-tools/executor-common": "^0.0.4", + "@graphql-tools/utils": "^10.8.1", + "@repeaterjs/repeater": "^3.0.4", + "@whatwg-node/disposablestack": "^0.0.6", + "@whatwg-node/fetch": "^0.10.4", + "@whatwg-node/promise-helpers": "^1.3.0", + "meros": "^1.2.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/executor-legacy-ws": { + "version": "1.1.38", + "resolved": "https://registry.npmjs.org/@graphql-tools/executor-legacy-ws/-/executor-legacy-ws-1.1.38.tgz", + "integrity": "sha512-mamb2so0wYF27Zkwgs+idffOswL3L6got2pimXV42mBtxlgDp6QaKOa/0Pv3+GRtWdeetPt5SS5jypTWHwvp9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/utils": "^12.0.4", + "@types/ws": "^8.18.2", + "isomorphic-ws": "^5.0.0", + "tslib": "^2.4.0", + "ws": "^8.21.3" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/executor-legacy-ws/node_modules/@graphql-tools/utils": { + "version": "12.0.4", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-12.0.4.tgz", + "integrity": "sha512-hJmeBRrK1OUABqRxLXB1YwrBB+3zTXeRJYb5PaF7CweV80zNGnN640Jz+0BrCaUE8HBk6KtoVXM/tUScUsKdeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "@whatwg-node/promise-helpers": "^2.0.0", + "cross-inspect": "1.0.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/executor-legacy-ws/node_modules/@whatwg-node/promise-helpers": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-2.0.0.tgz", + "integrity": "sha512-/i9CS0Z96o+cPwaYoAyVxAThDiUX1wV3tDmNWPJbn+vtdR+JftuCDg1NGCXBrzIYVDGkJ8uT6ctwmrBOUzOpKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=22.15.0" + } + }, + "node_modules/@graphql-tools/executor/node_modules/@graphql-tools/utils": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-11.2.2.tgz", + "integrity": "sha512-Do2A/t6ayqOma8m7PvpYMsCBswL+NB35BQSng4GWSBqafL2/BnfAZy/NSENPwV721Rm2fG0BHETFC0+FJJZmLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "@whatwg-node/promise-helpers": "^1.0.0", + "cross-inspect": "1.0.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/git-loader": { + "version": "8.0.45", + "resolved": "https://registry.npmjs.org/@graphql-tools/git-loader/-/git-loader-8.0.45.tgz", + "integrity": "sha512-Msmvvfc9SLXVbVS9rRTQqbyREgbyEW2mnckl4GEPfDUYjADyHwBiuZN/aHQTvhTnnLGjBQ+o8jCDTvg/xiPRDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/graphql-tag-pluck": "8.3.40", + "@graphql-tools/utils": "^12.0.4", + "is-glob": "4.0.3", + "micromatch": "^4.0.8", + "tslib": "^2.4.0", + "unixify": "^1.0.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/git-loader/node_modules/@graphql-tools/utils": { + "version": "12.0.4", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-12.0.4.tgz", + "integrity": "sha512-hJmeBRrK1OUABqRxLXB1YwrBB+3zTXeRJYb5PaF7CweV80zNGnN640Jz+0BrCaUE8HBk6KtoVXM/tUScUsKdeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "@whatwg-node/promise-helpers": "^2.0.0", + "cross-inspect": "1.0.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/git-loader/node_modules/@whatwg-node/promise-helpers": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-2.0.0.tgz", + "integrity": "sha512-/i9CS0Z96o+cPwaYoAyVxAThDiUX1wV3tDmNWPJbn+vtdR+JftuCDg1NGCXBrzIYVDGkJ8uT6ctwmrBOUzOpKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=22.15.0" + } + }, + "node_modules/@graphql-tools/github-loader": { + "version": "8.0.22", + "resolved": "https://registry.npmjs.org/@graphql-tools/github-loader/-/github-loader-8.0.22.tgz", + "integrity": "sha512-uQ4JNcNPsyMkTIgzeSbsoT9hogLjYrZooLUYd173l5eUGUi49EAcsGdiBCKaKfEjanv410FE8hjaHr7fjSRkJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/executor-http": "^1.1.9", + "@graphql-tools/graphql-tag-pluck": "^8.3.21", + "@graphql-tools/utils": "^10.9.1", + "@whatwg-node/fetch": "^0.10.0", + "@whatwg-node/promise-helpers": "^1.0.0", + "sync-fetch": "0.6.0-2", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/github-loader/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/@graphql-tools/github-loader/node_modules/sync-fetch": { + "version": "0.6.0-2", + "resolved": "https://registry.npmjs.org/sync-fetch/-/sync-fetch-0.6.0-2.tgz", + "integrity": "sha512-c7AfkZ9udatCuAy9RSfiGPpeOKKUAUK5e1cXadLOGUjasdxqYqAK0jTNkM/FSEyJ3a5Ra27j/tw/PS0qLmaF/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "node-fetch": "^3.3.2", + "timeout-signal": "^2.0.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@graphql-tools/github-loader/node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@graphql-tools/graphql-file-loader": { + "version": "8.1.23", + "resolved": "https://registry.npmjs.org/@graphql-tools/graphql-file-loader/-/graphql-file-loader-8.1.23.tgz", + "integrity": "sha512-J/AdQ3Lr9TKHkpFFgOffiGObASvgyDI6ivr3FzoNAwXMMHGKelysd+kCETj0ZLcdncy3gHLfazfwv6XsW6HxZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/import": "^7.2.3", + "@graphql-tools/utils": "^12.0.4", + "globby": "^11.0.3", + "tslib": "^2.4.0", + "unixify": "^1.0.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/graphql-file-loader/node_modules/@graphql-tools/utils": { + "version": "12.0.4", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-12.0.4.tgz", + "integrity": "sha512-hJmeBRrK1OUABqRxLXB1YwrBB+3zTXeRJYb5PaF7CweV80zNGnN640Jz+0BrCaUE8HBk6KtoVXM/tUScUsKdeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "@whatwg-node/promise-helpers": "^2.0.0", + "cross-inspect": "1.0.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/graphql-file-loader/node_modules/@whatwg-node/promise-helpers": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-2.0.0.tgz", + "integrity": "sha512-/i9CS0Z96o+cPwaYoAyVxAThDiUX1wV3tDmNWPJbn+vtdR+JftuCDg1NGCXBrzIYVDGkJ8uT6ctwmrBOUzOpKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=22.15.0" + } + }, + "node_modules/@graphql-tools/graphql-tag-pluck": { + "version": "8.3.40", + "resolved": "https://registry.npmjs.org/@graphql-tools/graphql-tag-pluck/-/graphql-tag-pluck-8.3.40.tgz", + "integrity": "sha512-KCi2gyn/Ki/8u81hpzF4vPPU42xjtWi3t90ldhqrjdwYeR/Im8YWQBamHVUjylqaDC9XAdZUKfIEPA0dGX/kdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.7", + "@babel/parser": "^7.29.3", + "@babel/traverse": "^7.26.10", + "@babel/types": "^7.26.10", + "@graphql-tools/utils": "^12.0.4", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/graphql-tag-pluck/node_modules/@graphql-tools/utils": { + "version": "12.0.4", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-12.0.4.tgz", + "integrity": "sha512-hJmeBRrK1OUABqRxLXB1YwrBB+3zTXeRJYb5PaF7CweV80zNGnN640Jz+0BrCaUE8HBk6KtoVXM/tUScUsKdeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "@whatwg-node/promise-helpers": "^2.0.0", + "cross-inspect": "1.0.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/graphql-tag-pluck/node_modules/@whatwg-node/promise-helpers": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-2.0.0.tgz", + "integrity": "sha512-/i9CS0Z96o+cPwaYoAyVxAThDiUX1wV3tDmNWPJbn+vtdR+JftuCDg1NGCXBrzIYVDGkJ8uT6ctwmrBOUzOpKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=22.15.0" + } + }, + "node_modules/@graphql-tools/import": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/@graphql-tools/import/-/import-7.2.3.tgz", + "integrity": "sha512-mVljeKUsUJRbJ+vmUAzaATQSeDAeXoRVmAVC17Ro1r7wCupzmS2OXnWtiSB3zWP5ZvBUuvPwo9rAKYLFkVcoCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/utils": "^12.0.4", + "resolve-from": "5.0.0", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/import/node_modules/@graphql-tools/utils": { + "version": "12.0.4", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-12.0.4.tgz", + "integrity": "sha512-hJmeBRrK1OUABqRxLXB1YwrBB+3zTXeRJYb5PaF7CweV80zNGnN640Jz+0BrCaUE8HBk6KtoVXM/tUScUsKdeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "@whatwg-node/promise-helpers": "^2.0.0", + "cross-inspect": "1.0.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/import/node_modules/@whatwg-node/promise-helpers": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-2.0.0.tgz", + "integrity": "sha512-/i9CS0Z96o+cPwaYoAyVxAThDiUX1wV3tDmNWPJbn+vtdR+JftuCDg1NGCXBrzIYVDGkJ8uT6ctwmrBOUzOpKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=22.15.0" + } + }, + "node_modules/@graphql-tools/json-file-loader": { + "version": "8.0.37", + "resolved": "https://registry.npmjs.org/@graphql-tools/json-file-loader/-/json-file-loader-8.0.37.tgz", + "integrity": "sha512-NH+xyYZo/mCSi+esjrqRR++nKbFlHa/JDUewznZJl5SY4ZXivYaFkWZt3gQ+vanQaTOZa6h4MCGmxG2scaAwqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/utils": "^12.0.4", + "globby": "^11.0.3", + "tslib": "^2.4.0", + "unixify": "^1.0.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/json-file-loader/node_modules/@graphql-tools/utils": { + "version": "12.0.4", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-12.0.4.tgz", + "integrity": "sha512-hJmeBRrK1OUABqRxLXB1YwrBB+3zTXeRJYb5PaF7CweV80zNGnN640Jz+0BrCaUE8HBk6KtoVXM/tUScUsKdeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "@whatwg-node/promise-helpers": "^2.0.0", + "cross-inspect": "1.0.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/json-file-loader/node_modules/@whatwg-node/promise-helpers": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-2.0.0.tgz", + "integrity": "sha512-/i9CS0Z96o+cPwaYoAyVxAThDiUX1wV3tDmNWPJbn+vtdR+JftuCDg1NGCXBrzIYVDGkJ8uT6ctwmrBOUzOpKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=22.15.0" + } + }, + "node_modules/@graphql-tools/load": { + "version": "8.1.20", + "resolved": "https://registry.npmjs.org/@graphql-tools/load/-/load-8.1.20.tgz", + "integrity": "sha512-BUaEThdacdQSyMdPfWfd7HbYJ1h5IeAyifd0/yfufBHPEoNZ462OecdFwcW9ikmOfJoqnBF+xEuOQMG7e2bU7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/schema": "^10.1.4", + "@graphql-tools/utils": "^12.0.4", + "p-limit": "3.1.0", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/load/node_modules/@graphql-tools/utils": { + "version": "12.0.4", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-12.0.4.tgz", + "integrity": "sha512-hJmeBRrK1OUABqRxLXB1YwrBB+3zTXeRJYb5PaF7CweV80zNGnN640Jz+0BrCaUE8HBk6KtoVXM/tUScUsKdeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "@whatwg-node/promise-helpers": "^2.0.0", + "cross-inspect": "1.0.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/load/node_modules/@whatwg-node/promise-helpers": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-2.0.0.tgz", + "integrity": "sha512-/i9CS0Z96o+cPwaYoAyVxAThDiUX1wV3tDmNWPJbn+vtdR+JftuCDg1NGCXBrzIYVDGkJ8uT6ctwmrBOUzOpKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=22.15.0" + } + }, + "node_modules/@graphql-tools/merge": { + "version": "9.2.7", + "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-9.2.7.tgz", + "integrity": "sha512-Zo69OGURoOupi9v+3PCNOuQSgwz0/KNOsfTNMudSLzHTKMu40Bz3KqHaIxWuUiFr/ZDRXClI8ou48vdJWtwh4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/utils": "^12.0.4", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/merge/node_modules/@graphql-tools/utils": { + "version": "12.0.4", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-12.0.4.tgz", + "integrity": "sha512-hJmeBRrK1OUABqRxLXB1YwrBB+3zTXeRJYb5PaF7CweV80zNGnN640Jz+0BrCaUE8HBk6KtoVXM/tUScUsKdeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "@whatwg-node/promise-helpers": "^2.0.0", + "cross-inspect": "1.0.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/merge/node_modules/@whatwg-node/promise-helpers": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-2.0.0.tgz", + "integrity": "sha512-/i9CS0Z96o+cPwaYoAyVxAThDiUX1wV3tDmNWPJbn+vtdR+JftuCDg1NGCXBrzIYVDGkJ8uT6ctwmrBOUzOpKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=22.15.0" + } + }, + "node_modules/@graphql-tools/optimize": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@graphql-tools/optimize/-/optimize-2.0.1.tgz", + "integrity": "sha512-I63H75wg7SD/+iPj+QdvZgvul0dxxf/el18Yiry5zmwKeqRvzcm6l2KUYRNgMR/ydytp0VuHw3XeyGhPWGigAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/prisma-loader": { + "version": "8.0.17", + "resolved": "https://registry.npmjs.org/@graphql-tools/prisma-loader/-/prisma-loader-8.0.17.tgz", + "integrity": "sha512-fnuTLeQhqRbA156pAyzJYN0KxCjKYRU5bz1q/SKOwElSnAU4k7/G1kyVsWLh7fneY78LoMNH5n+KlFV8iQlnyg==", + "deprecated": "This package was intended to be used with an older versions of Prisma.\\nThe newer versions of Prisma has a different approach to GraphQL integration.\\nTherefore, this package is no longer needed and has been deprecated and removed.\\nLearn more: https://www.prisma.io/graphql", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/url-loader": "^8.0.15", + "@graphql-tools/utils": "^10.5.6", + "@types/js-yaml": "^4.0.0", + "@whatwg-node/fetch": "^0.10.0", + "chalk": "^4.1.0", + "debug": "^4.3.1", + "dotenv": "^16.0.0", + "graphql-request": "^6.0.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "jose": "^5.0.0", + "js-yaml": "^4.0.0", + "lodash": "^4.17.20", + "scuid": "^1.1.0", + "tslib": "^2.4.0", + "yaml-ast-parser": "^0.0.43" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/relay-operation-optimizer": { + "version": "7.1.13", + "resolved": "https://registry.npmjs.org/@graphql-tools/relay-operation-optimizer/-/relay-operation-optimizer-7.1.13.tgz", + "integrity": "sha512-mgCaOvins1QJ7gLdmd8QPHyxrLIDYnxk/tlCKVnJgpQ4QNYuMUFbl6x4KQ0l7a7OqApi3bnBADUEjhKSTKXAuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ardatan/relay-compiler": "^13.0.2", + "@graphql-tools/utils": "^12.0.4", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/relay-operation-optimizer/node_modules/@graphql-tools/utils": { + "version": "12.0.4", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-12.0.4.tgz", + "integrity": "sha512-hJmeBRrK1OUABqRxLXB1YwrBB+3zTXeRJYb5PaF7CweV80zNGnN640Jz+0BrCaUE8HBk6KtoVXM/tUScUsKdeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "@whatwg-node/promise-helpers": "^2.0.0", + "cross-inspect": "1.0.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/relay-operation-optimizer/node_modules/@whatwg-node/promise-helpers": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-2.0.0.tgz", + "integrity": "sha512-/i9CS0Z96o+cPwaYoAyVxAThDiUX1wV3tDmNWPJbn+vtdR+JftuCDg1NGCXBrzIYVDGkJ8uT6ctwmrBOUzOpKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=22.15.0" + } + }, + "node_modules/@graphql-tools/schema": { + "version": "10.1.4", + "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-10.1.4.tgz", + "integrity": "sha512-talZuSrjeklX7RVYLgF7fxHgywZXSYWzFZf3NG5CNFgkeK3xTocYMvlC3FLAApMWUW59qkyuHN1/HIgaIGfjRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/merge": "^9.2.7", + "@graphql-tools/utils": "^12.0.4", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/schema/node_modules/@graphql-tools/utils": { + "version": "12.0.4", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-12.0.4.tgz", + "integrity": "sha512-hJmeBRrK1OUABqRxLXB1YwrBB+3zTXeRJYb5PaF7CweV80zNGnN640Jz+0BrCaUE8HBk6KtoVXM/tUScUsKdeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "@whatwg-node/promise-helpers": "^2.0.0", + "cross-inspect": "1.0.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/schema/node_modules/@whatwg-node/promise-helpers": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-2.0.0.tgz", + "integrity": "sha512-/i9CS0Z96o+cPwaYoAyVxAThDiUX1wV3tDmNWPJbn+vtdR+JftuCDg1NGCXBrzIYVDGkJ8uT6ctwmrBOUzOpKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=22.15.0" + } + }, + "node_modules/@graphql-tools/url-loader": { + "version": "8.0.33", + "resolved": "https://registry.npmjs.org/@graphql-tools/url-loader/-/url-loader-8.0.33.tgz", + "integrity": "sha512-Fu626qcNHcqAj8uYd7QRarcJn5XZ863kmxsg1sm0fyjyfBJnsvC7ddFt6Hayz5kxVKfsnjxiDfPMXanvsQVBKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/executor-graphql-ws": "^2.0.1", + "@graphql-tools/executor-http": "^1.1.9", + "@graphql-tools/executor-legacy-ws": "^1.1.19", + "@graphql-tools/utils": "^10.9.1", + "@graphql-tools/wrap": "^10.0.16", + "@types/ws": "^8.0.0", + "@whatwg-node/fetch": "^0.10.0", + "@whatwg-node/promise-helpers": "^1.0.0", + "isomorphic-ws": "^5.0.0", + "sync-fetch": "0.6.0-2", + "tslib": "^2.4.0", + "ws": "^8.17.1" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/url-loader/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/@graphql-tools/url-loader/node_modules/sync-fetch": { + "version": "0.6.0-2", + "resolved": "https://registry.npmjs.org/sync-fetch/-/sync-fetch-0.6.0-2.tgz", + "integrity": "sha512-c7AfkZ9udatCuAy9RSfiGPpeOKKUAUK5e1cXadLOGUjasdxqYqAK0jTNkM/FSEyJ3a5Ra27j/tw/PS0qLmaF/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "node-fetch": "^3.3.2", + "timeout-signal": "^2.0.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@graphql-tools/url-loader/node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@graphql-tools/utils": { + "version": "10.11.0", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-10.11.0.tgz", + "integrity": "sha512-iBFR9GXIs0gCD+yc3hoNswViL1O5josI33dUqiNStFI/MHLCEPduasceAcazRH77YONKNiviHBV8f7OgcT4o2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "@whatwg-node/promise-helpers": "^1.0.0", + "cross-inspect": "1.0.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/wrap": { + "version": "10.1.4", + "resolved": "https://registry.npmjs.org/@graphql-tools/wrap/-/wrap-10.1.4.tgz", + "integrity": "sha512-7pyNKqXProRjlSdqOtrbnFRMQAVamCmEREilOXtZujxY6kYit3tvWWSjUrcIOheltTffoRh7EQSjpy2JDCzasg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/delegate": "^10.2.23", + "@graphql-tools/schema": "^10.0.25", + "@graphql-tools/utils": "^10.9.1", + "@whatwg-node/promise-helpers": "^1.3.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-typed-document-node/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@inquirer/external-editor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", + "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mjackson/node-fetch-server": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@mjackson/node-fetch-server/-/node-fetch-server-0.2.0.tgz", + "integrity": "sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng==", + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz", + "integrity": "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.6.0", + "@parcel/watcher-darwin-arm64": "2.6.0", + "@parcel/watcher-darwin-x64": "2.6.0", + "@parcel/watcher-freebsd-x64": "2.6.0", + "@parcel/watcher-linux-arm-glibc": "2.6.0", + "@parcel/watcher-linux-arm-musl": "2.6.0", + "@parcel/watcher-linux-arm64-glibc": "2.6.0", + "@parcel/watcher-linux-arm64-musl": "2.6.0", + "@parcel/watcher-linux-x64-glibc": "2.6.0", + "@parcel/watcher-linux-x64-musl": "2.6.0", + "@parcel/watcher-win32-arm64": "2.6.0", + "@parcel/watcher-win32-x64": "2.6.0" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz", + "integrity": "sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz", + "integrity": "sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz", + "integrity": "sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz", + "integrity": "sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz", + "integrity": "sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz", + "integrity": "sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz", + "integrity": "sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz", + "integrity": "sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz", + "integrity": "sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz", + "integrity": "sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz", + "integrity": "sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz", + "integrity": "sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@prisma/adapter-pg": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-6.19.3.tgz", + "integrity": "sha512-ocbvVIP/wX2uJWWLvo1/UK0nH8L5dv4MlE1DddVPID0/VRiEZDguWmAgKLVuPM6BuWTH3wGluZR40VgXb4WjiQ==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/driver-adapter-utils": "6.19.3", + "pg": "^8.11.3", + "postgres-array": "3.0.4" + } + }, + "node_modules/@prisma/client": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz", + "integrity": "sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/config": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz", + "integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==", + "license": "Apache-2.0", + "dependencies": { + "c12": "3.1.0", + "deepmerge-ts": "7.1.5", + "effect": "3.21.0", + "empathic": "2.0.0" + } + }, + "node_modules/@prisma/debug": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz", + "integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/driver-adapter-utils": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-6.19.3.tgz", + "integrity": "sha512-UUxn6VLfKKVqm5n9vexOQgFJ9TCBIxupb7F5FzLQ6iM2VV0WZxhgzbQVqBDsGY+VmQLmqaSoKRusEfy3O5KZpA==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3" + } + }, + "node_modules/@prisma/engines": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz", + "integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/fetch-engine": "6.19.3", + "@prisma/get-platform": "6.19.3" + } + }, + "node_modules/@prisma/engines-version": { + "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz", + "integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz", + "integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/get-platform": "6.19.3" + } + }, + "node_modules/@prisma/get-platform": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz", + "integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3" + } + }, + "node_modules/@react-router/dev": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/@react-router/dev/-/dev-7.18.4.tgz", + "integrity": "sha512-4sxQa18Znjz1zw1MGK7f97zXjK4NVcNgESvWPPEYScRFAHJWq6Q3/oejDPfYjKWLygsX1yF9jboxbGLw+V9f+g==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.7", + "@babel/generator": "^7.27.5", + "@babel/parser": "^7.27.7", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/preset-typescript": "^7.27.1", + "@babel/traverse": "^7.27.7", + "@babel/types": "^7.27.7", + "@react-router/node": "7.18.4", + "@remix-run/node-fetch-server": "^0.13.0", + "arg": "^5.0.1", + "babel-dead-code-elimination": "^1.0.6", + "chokidar": "^4.0.0", + "dedent": "^1.5.3", + "es-module-lexer": "^1.3.1", + "exit-hook": "2.2.1", + "isbot": "^5.1.11", + "jsesc": "3.0.2", + "lodash": "^4.17.21", + "p-map": "^7.0.3", + "pathe": "^1.1.2", + "picocolors": "^1.1.1", + "pkg-types": "^2.3.0", + "prettier": "^3.6.2", + "react-refresh": "^0.14.0", + "semver": "^7.3.7", + "tinyglobby": "^0.2.14", + "valibot": "^1.2.0", + "vite-node": "^3.2.2" + }, + "bin": { + "react-router": "bin.js" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@react-router/serve": "^7.18.4", + "@vitejs/plugin-rsc": "~0.5.21", + "react-router": "^7.18.4", + "react-server-dom-webpack": "^19.2.3", + "typescript": "^5.1.0 || ^6.0.0", + "vite": "^5.1.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "wrangler": "^3.28.2 || ^4.0.0" + }, + "peerDependenciesMeta": { + "@react-router/serve": { + "optional": true + }, + "@vitejs/plugin-rsc": { + "optional": true + }, + "react-server-dom-webpack": { + "optional": true + }, + "typescript": { + "optional": true + }, + "wrangler": { + "optional": true + } + } + }, + "node_modules/@react-router/express": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/@react-router/express/-/express-7.18.4.tgz", + "integrity": "sha512-2q0MUBCxbNimYZIZoqp4LubquGK0TYYkRPR8wgWejCBzMHI9mbfWkI/8Fs2u2Qra5gDrOm28ig5ndgP3f15W2g==", + "license": "MIT", + "dependencies": { + "@react-router/node": "7.18.4" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "express": "^4.17.1 || ^5", + "react-router": "7.18.4", + "typescript": "^5.1.0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@react-router/fs-routes": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/@react-router/fs-routes/-/fs-routes-7.18.4.tgz", + "integrity": "sha512-PQL3b+bNCSzVRqjHZqjECWjFx45bXbNkXmOA5QBWsOQdDHi+9F3+jPKwl+/1EmTFqNdE93uhqW6NyG90o75R6w==", + "license": "MIT", + "dependencies": { + "minimatch": "^9.0.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@react-router/dev": "^7.18.4", + "typescript": "^5.1.0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@react-router/node": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/@react-router/node/-/node-7.18.4.tgz", + "integrity": "sha512-PXNpc4fi0xa5Vp+uaYDFbzIEr4ZmLgch/w25AvxGV2eW32BzDh/OLlpGG074M/FKKi2CzDMsTsU8D3cDH4jywg==", + "license": "MIT", + "dependencies": { + "@mjackson/node-fetch-server": "^0.2.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react-router": "7.18.4", + "typescript": "^5.1.0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@react-router/serve": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/@react-router/serve/-/serve-7.18.4.tgz", + "integrity": "sha512-MbSgKv0k3kwqV4DPK8XzZeYpBkxu9wsK/o2JQgCgk6GCUqARD9RhA1mOQPBkR88TUwpJ7Sj3+VPTamA6qmdiVA==", + "license": "MIT", + "dependencies": { + "@mjackson/node-fetch-server": "^0.2.0", + "@react-router/express": "7.18.4", + "@react-router/node": "7.18.4", + "compression": "^1.8.1", + "express": "^4.19.2", + "get-port": "5.1.1", + "morgan": "^1.10.1", + "source-map-support": "^0.5.21" + }, + "bin": { + "react-router-serve": "bin.js" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react-router": "7.18.4" + } + }, + "node_modules/@remix-run/node-fetch-server": { + "version": "0.13.3", + "resolved": "https://registry.npmjs.org/@remix-run/node-fetch-server/-/node-fetch-server-0.13.3.tgz", + "integrity": "sha512-UfjOXed/DQteaM5VyTfqTeGpHwyL2J5aoRGY6cydip4tt1ehNNeSwuXCC7AEGE0RWBs/7bgKxYkL/B/+UDe4AA==", + "license": "MIT" + }, + "node_modules/@repeaterjs/repeater": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@repeaterjs/repeater/-/repeater-3.1.0.tgz", + "integrity": "sha512-TaoVksZRSx2KWYYpyLQtMQXXeS98VsgZImzW65xmiVgbYhXLk+aEsmzPLirqVuE4/XuUapH2iMtxUzaBNDzdSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.5.tgz", + "integrity": "sha512-MrfgRQSpD5hkkRg6XI16B6EDLb+6+xp8xje6bpdSwkYw+qxrBKbUVrRAQu9h82UT+QumSIVQ8HXsXjPd2dlhqw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.5.tgz", + "integrity": "sha512-+uu2p/R9Tzn8GpoQyX1pAo5dmKT1DhXevfaCzqzmPFhnLjHLWw7uWkaVGlxmhwYoRiYYhsGfRcD5cTBpL7rgSw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.5.tgz", + "integrity": "sha512-17dys+YEmzwzzFVHQAVDONg5FALXY9kd99HFEpfk4gJVHYxoh5bGea3D4t1fXXnwkTcOe25wPyd6nWS0I8IdLQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.5.tgz", + "integrity": "sha512-wg0zQEKkEvFZf1UaaX1UELOoPs//OSKUG7D+yITuqOsAvza7LvHTv5Wu4nmMfzNG7EYI2jp2PJC7Id6OETmsYQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.5.tgz", + "integrity": "sha512-CoJmWcpEMuwin+TZ/ev/H9uDyF/uxUKQSWXdmIPn/UBx/uGV9+4ebMp0FwnlhWR9PE1AdzYsKf3bZevhif38+A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.5.tgz", + "integrity": "sha512-PxZnLAaTUAL5xG0W2BXLeYPKrK2b3LIASV2/e/Kf7PZvSLI1iAQIm9B9aJD+YyFIT7hc6qaMV3+OOO35R28LfA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.5.tgz", + "integrity": "sha512-uPo3IvnONjhbFXwWgroBeFwOicuhT7ZwQrnzkzmOtBJzeQbkUJUE1SJDfDI31ijQ5+ZUIA6sPrdUMxTSnxUWAg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.5.tgz", + "integrity": "sha512-ywI8Jyuc4LyHYTjC0mw4yA1ulvZBkHusdpWnoiEBsmBuHvSnt90btTBGCZ+s/2y+YNNfVAEKy33nPo5UI9zTmg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.5.tgz", + "integrity": "sha512-tmuXJoQ/3ovSr637aykZMdQteU/CB2APAz2CrvsbibuoHZi1BS1Db13cVViBdEARGwXCLn1DSJ8wsjoThLJPGA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.5.tgz", + "integrity": "sha512-5FMjSYNcUOHx7oW8Qu9KB6jGPpvF/8sG7+BDHwU/Ihh1/6FMJ9ySmsFG4tJ1qbNRuP8OLzCBDso4sF0mQaLuFQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.5.tgz", + "integrity": "sha512-K8ZJ+neLDbUkIrL8dqF/5LtuVCrKwo27kREgOlxjBEjlOXwHvDnHLU8Z/UKv/B49qi7e8hsSDOyoJwhwqhvX4w==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.5.tgz", + "integrity": "sha512-NlVxsq7PFPv0DBKGuNWRDKixQYQCjM53zl04mxEcKMBmJbCBIUm1JdPfIWDRHyOOFzzYPmLQzfeEwh4716ayQg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.5.tgz", + "integrity": "sha512-ARhRHJYs5eCFet5azfrHD97vOkD8OzNW2RSMlvo5CcBqXaTOLfp0OHDtUQh7/K5AL5fUYoFp2fgC9KDg4vVYMQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.5.tgz", + "integrity": "sha512-atSgreM5C7jpxd9aEXN+6vJ5wTd05iYw+S3FiQpw7VsoEl8B90llbq35ARON+GOHj5QlVQEq2vOKWCHTob/BSQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.5.tgz", + "integrity": "sha512-vmlmwBFGnjVWxSRoVabNkyYuscSmg52Pd0fM8jEbNT3Jmka4A6HcB84x4uAmuumznWfBVaxKOqU6jbEgomWKCA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.5.tgz", + "integrity": "sha512-hhJrP6S9WkbsbcHgUkvCBS5Cn8IdsG8XjlLZyxjnv9+NdoYfbbZE4TWJYr8apdaj1M5t5Y1id2StnEslxpNFyA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.5.tgz", + "integrity": "sha512-NNVCyN9pGrwFmQa4bf+Cnf5aVH3g6ZpnlOg1Cs/H9swIHT9bRixijfzUqyEIWm1WtiAuR1LJ+rJH6zpBm8kgQA==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.5.tgz", + "integrity": "sha512-dR9nd6OKnvSS60Ifz/L7Mj5zzYyoqyRn2NwTuaOONKaa3xsVGXyp8YusMbvFyEOR94DxnwgcbgsFqkHd29ZSzQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.5.tgz", + "integrity": "sha512-1tzsMjaqhsh3bAO6SJiJqh8hIr9sSPRd+i2vPPxJHod8/XYnf9TEnK0z/u3r1z1AkI347X0KbRSg9dWcxeGahw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.5.tgz", + "integrity": "sha512-01mWMQblKoXXsmk28KXtpbA96w7g/aOWUoV3AC16fdHdqkykGalqyyM+5u8M7uP5/MEMiiPv69Hv7iv/hEH/dA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.5.tgz", + "integrity": "sha512-trdnMPb/DNm4V/CgsaoGgr90HfQQ071wfK0KoIs4YpLvjBgVQGXhGabg2Z6nK4EcYNilrb9ZZPcsP6+zpJIdxQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.5.tgz", + "integrity": "sha512-x2LxZlfdfsw99T6/qb9EqE/Qi84JWzibjyOJyhJsgFJy6CafaTiqIXCCnslCGrV5XWNh7fwrS3FbRENcuKnooA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.5.tgz", + "integrity": "sha512-/Qd/9aDQIo0+jmItgBmPIs8195YVbDxRRApn47GyufgbutCDOh7eP5vSKKlI4QNFeaI+wKQAkSMsPnF3EuMpaA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.5.tgz", + "integrity": "sha512-ngUJ/XvOWrqbd2IMsfXfh2xXFLTP3sY7sCKvxbGvYdBZ1KmXFd3ewDZ9n3/GNzAUgzRuhLT/UvJRivbNoAQRBw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.5.tgz", + "integrity": "sha512-IhV6ew/DuzUWwHaYmlh7MrPCV3lKFUq/amTEwysi4r/mwNPAEfAMh5dYWeTSPTeMy2pFHGMEWYtPkHzfX3tG5A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@shopify/admin-api-client": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@shopify/admin-api-client/-/admin-api-client-2.0.1.tgz", + "integrity": "sha512-s9Ysgq5we9QI5XYFkOxLX2Sj6F1I/S+f/PDC7WolaELzDhyjvFgR5d68JQDpgrGc8a8uvwzS/OuiXvybUU6sXw==", + "license": "MIT", + "dependencies": { + "@shopify/graphql-client": "^2.0.1" + } + }, + "node_modules/@shopify/api-codegen-preset": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@shopify/api-codegen-preset/-/api-codegen-preset-1.2.2.tgz", + "integrity": "sha512-NizmrrrxmoIBfrGv/qvrm1ZytkGsFfeaMj14Gj+IDx6JZAK9A72jsTIyQ7DTTKWMPB6F4d7E20dygA7SEYPVEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-codegen/cli": "^5.0.7", + "@graphql-codegen/introspection": "^4.0.3", + "@graphql-codegen/typescript": "^5.0.2", + "@parcel/watcher": "^2.5.1", + "@shopify/graphql-codegen": "^0.1.0", + "graphql": "^16.10.0" + } + }, + "node_modules/@shopify/app-bridge-react": { + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@shopify/app-bridge-react/-/app-bridge-react-4.2.13.tgz", + "integrity": "sha512-9ArXhCNRefHZwVwu/DNll1FOQPiKwPhlGs9IwE9FWw39osG+e0SNImczW4B0WF1b92sdRsTgmFkqp3Vvmi3ARg==", + "license": "MIT", + "dependencies": { + "@shopify/app-bridge-types": "0.7.2" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/@shopify/app-bridge-types": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@shopify/app-bridge-types/-/app-bridge-types-0.7.2.tgz", + "integrity": "sha512-Fl4GnQXzJ3h43kvD/i8e1iWP+ynIxue0L1Eezf7GVoGiIYjvu3BqX3au+kHXGx7Us6nQ4Ku6lKdFZP+nAFepCQ==", + "license": "ISC", + "dependencies": { + "@standard-schema/spec": "^1.0.0" + } + }, + "node_modules/@shopify/graphql-client": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@shopify/graphql-client/-/graphql-client-2.0.1.tgz", + "integrity": "sha512-rMWnql45haEQdVnz62azY6yKt67W2jNgyuYLb6cJc6GuQzEp3LNgIUuQRuroGrF0rklt/ZxqPWS2g013ci3lWQ==", + "license": "MIT" + }, + "node_modules/@shopify/graphql-codegen": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@shopify/graphql-codegen/-/graphql-codegen-0.1.0.tgz", + "integrity": "sha512-G3sSesLj7Czt/J2Bj+XlQ8u4pkfQEt32hsjoS3UGZlf1eAiVw0aBBddp+NI5HqBAi0gM/f7GLRAhG3kktPhZmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-codegen/add": "^5.0.1", + "@graphql-codegen/typescript": "^4.0.2", + "@graphql-codegen/typescript-operations": "^4.1.0", + "type-fest": "^4.5.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" + } + }, + "node_modules/@shopify/graphql-codegen/node_modules/@graphql-codegen/schema-ast": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@graphql-codegen/schema-ast/-/schema-ast-4.1.0.tgz", + "integrity": "sha512-kZVn0z+th9SvqxfKYgztA6PM7mhnSZaj4fiuBWvMTqA+QqQ9BBed6Pz41KuD/jr0gJtnlr2A4++/0VlpVbCTmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-codegen/plugin-helpers": "^5.0.3", + "@graphql-tools/utils": "^10.0.0", + "tslib": "~2.6.0" + }, + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" + } + }, + "node_modules/@shopify/graphql-codegen/node_modules/@graphql-codegen/typescript": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@graphql-codegen/typescript/-/typescript-4.1.6.tgz", + "integrity": "sha512-vpw3sfwf9A7S+kIUjyFxuvrywGxd4lmwmyYnnDVjVE4kSQ6Td3DpqaPTy8aNQ6O96vFoi/bxbZS2BW49PwSUUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-codegen/plugin-helpers": "^5.1.0", + "@graphql-codegen/schema-ast": "^4.0.2", + "@graphql-codegen/visitor-plugin-common": "5.8.0", + "auto-bind": "~4.0.0", + "tslib": "~2.6.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "graphql": "^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" + } + }, + "node_modules/@shopify/graphql-codegen/node_modules/tslib": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", + "dev": true, + "license": "0BSD" + }, + "node_modules/@shopify/polaris-types": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@shopify/polaris-types/-/polaris-types-1.1.0.tgz", + "integrity": "sha512-hqd+58XbpsShQmTiMakiUraLL9yjeqcFkaEfiajdxAArQJGREsnNBxY1+cN0egWHFQ7/1Xsqjx0RObd3V0Fy1w==", + "dev": true + }, + "node_modules/@shopify/shopify-api": { + "version": "15.0.1", + "resolved": "https://registry.npmjs.org/@shopify/shopify-api/-/shopify-api-15.0.1.tgz", + "integrity": "sha512-WLRmMar1JoMxqAYNvlPxukqEu/gYZ+tSICy8IkpVFa9erInI3xP/rEmWDtxCC0ZG1E41B6VsZFlFfKwQOrdKfQ==", + "license": "MIT", + "dependencies": { + "@shopify/admin-api-client": "^2.0.1", + "@shopify/graphql-client": "^2.0.1", + "@shopify/storefront-api-client": "^2.0.1", + "compare-versions": "^6.1.1", + "isbot": "^5.2.0", + "jose": "^5.9.6", + "lossless-json": "^4.3.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@shopify/shopify-app-react-router": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@shopify/shopify-app-react-router/-/shopify-app-react-router-3.0.2.tgz", + "integrity": "sha512-h33VBhK1OimAnGmB1SIHOOMC8q7Ce/Dxr/2/OxEZeJAXT6zF3p83ADDJoWwCHoY7vTdk6Jrf+W/VoVzS37eEiw==", + "license": "MIT", + "dependencies": { + "@shopify/admin-api-client": "^2.0.1", + "@shopify/shopify-api": "^15.0.1", + "@shopify/shopify-app-session-storage": "^7.0.1", + "@shopify/storefront-api-client": "^2.0.1", + "compare-versions": "^6.1.1", + "isbot": "^5.2.0" + }, + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18", + "react-router": "^7.6.2" + } + }, + "node_modules/@shopify/shopify-app-session-storage": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@shopify/shopify-app-session-storage/-/shopify-app-session-storage-7.0.1.tgz", + "integrity": "sha512-YWkFNffVbGZGGBlg6dSCCqkl2dtGzl37SpxM5hZ38ObllYQvuPvAc9e/zvUStYkEVTbkStIeUuEEpKZ/4Y2HKA==", + "license": "MIT", + "peerDependencies": { + "@shopify/shopify-api": "^15.0.0" + } + }, + "node_modules/@shopify/shopify-app-session-storage-prisma": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/@shopify/shopify-app-session-storage-prisma/-/shopify-app-session-storage-prisma-11.0.1.tgz", + "integrity": "sha512-foafXTxvOSgVJZGxLQiJ7e9MW3XwOX1SlJZOwcZTWrKJ0gNkMCSTyt1Pd0763W0tYaR847U90RcPxkAQ5k709A==", + "license": "MIT", + "peerDependencies": { + "@prisma/client": "^6.19.0", + "@shopify/shopify-api": "^15.0.0", + "@shopify/shopify-app-session-storage": "^7.0.0", + "prisma": "^6.19.0" + } + }, + "node_modules/@shopify/storefront-api-client": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@shopify/storefront-api-client/-/storefront-api-client-2.0.1.tgz", + "integrity": "sha512-GsEb5L3y8ffqWHr7LPyrs8tFtfZJPlyC1kS3YClGQXX1eWS3HqWysvMwnEiXbYwCA0pUkthvAcqLZj/xXx3wCQ==", + "license": "MIT", + "dependencies": { + "@shopify/graphql-client": "^2.0.1" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@whatwg-node/disposablestack": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@whatwg-node/disposablestack/-/disposablestack-0.0.6.tgz", + "integrity": "sha512-LOtTn+JgJvX8WfBVJtF08TGrdjuFzGJc4mkP8EdDI8ADbvO7kiexYep1o8dwnt0okb0jYclCDXF13xU7Ge4zSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@whatwg-node/promise-helpers": "^1.0.0", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@whatwg-node/fetch": { + "version": "0.10.13", + "resolved": "https://registry.npmjs.org/@whatwg-node/fetch/-/fetch-0.10.13.tgz", + "integrity": "sha512-b4PhJ+zYj4357zwk4TTuF2nEe0vVtOrwdsrNo5hL+u1ojXNhh1FgJ6pg1jzDlwlT4oBdzfSwaBwMCtFCsIWg8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@whatwg-node/node-fetch": "^0.8.3", + "urlpattern-polyfill": "^10.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@whatwg-node/node-fetch": { + "version": "0.8.6", + "resolved": "https://registry.npmjs.org/@whatwg-node/node-fetch/-/node-fetch-0.8.6.tgz", + "integrity": "sha512-BDMdYFcerLQkwA2RTldxOqRCs6ZQD1S7UgP3pUdGUkcbgTrP/V5ko77ZkCww9DHmC4lpoYuwigGfQYj285gMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^3.1.1", + "@whatwg-node/disposablestack": "^0.0.6", + "@whatwg-node/promise-helpers": "^1.3.2", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@whatwg-node/promise-helpers": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-1.3.2.tgz", + "integrity": "sha512-Nst5JdK47VIl9UcGwtv2Rcgyn5lWtZ0/mhRQ4G8NN2isxpq2TO30iqHzmwoJycjWuyUfg3GFXqP/gFHXeV57IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-escapes/node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/auto-bind": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-4.0.0.tgz", + "integrity": "sha512-Hdw8qdNiqdJ8LqT0iK0sVzkFbzg6fhnQqqfWhBDxcHZvU75+B+ayzTy8x+k5Ix0Y92XOhOUlx74ps+bA6BeYMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/babel-dead-code-elimination": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/babel-dead-code-elimination/-/babel-dead-code-elimination-1.0.12.tgz", + "integrity": "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.23.7", + "@babel/parser": "^7.23.6", + "@babel/traverse": "^7.23.7", + "@babel/types": "^7.23.6" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.28", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.28.tgz", + "integrity": "sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/basic-auth/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.8", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz", + "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.16.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/c12": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", + "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.3", + "confbox": "^0.2.2", + "defu": "^6.1.4", + "dotenv": "^16.6.1", + "exsolve": "^1.0.7", + "giget": "^2.0.0", + "jiti": "^2.4.2", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^1.0.0", + "pkg-types": "^2.2.0", + "rc9": "^2.1.2" + }, + "peerDependencies": { + "magicast": "^0.3.5" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "node_modules/c12/node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "license": "MIT" + }, + "node_modules/c12/node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/c12/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camel-case": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", + "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pascal-case": "^3.1.2", + "tslib": "^2.0.3" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001815", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001815.tgz", + "integrity": "sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/capital-case": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/capital-case/-/capital-case-1.0.4.tgz", + "integrity": "sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3", + "upper-case-first": "^2.0.2" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/change-case": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-4.1.2.tgz", + "integrity": "sha512-bSxY2ws9OtviILG1EiY5K7NNxkqg/JnRnFxLtKQ96JaviiIxi7djMrSd0ECT9AC+lttClmYwKw53BWpOMblo7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "camel-case": "^4.1.2", + "capital-case": "^1.0.4", + "constant-case": "^3.0.4", + "dot-case": "^3.0.4", + "header-case": "^2.0.4", + "no-case": "^3.0.4", + "param-case": "^3.0.4", + "pascal-case": "^3.1.2", + "path-case": "^3.0.4", + "sentence-case": "^3.0.4", + "snake-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/change-case-all": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/change-case-all/-/change-case-all-1.0.15.tgz", + "integrity": "sha512-3+GIFhk3sNuvFAJKU46o26OdzudQlPNBCu1ZQi3cMeMHhty1bhDxu2WrEilVNYaGvqUtR1VSigFcJOiS13dRhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "change-case": "^4.1.2", + "is-lower-case": "^2.0.2", + "is-upper-case": "^2.0.2", + "lower-case": "^2.0.2", + "lower-case-first": "^2.0.2", + "sponge-case": "^1.0.1", + "swap-case": "^2.0.2", + "title-case": "^3.0.3", + "upper-case": "^2.0.2", + "upper-case-first": "^2.0.2" + } + }, + "node_modules/chardet": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz", + "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", + "dev": true, + "license": "MIT" + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "license": "MIT", + "dependencies": { + "consola": "^3.2.3" + } + }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", + "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^3.0.0", + "string-width": "^4.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-width": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", + "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/common-tags": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", + "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/compare-versions": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.1.tgz", + "integrity": "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==", + "license": "MIT" + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.2.tgz", + "integrity": "sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "destroy": "1.2.0", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/confbox": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.3.1.tgz", + "integrity": "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==", + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/constant-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/constant-case/-/constant-case-3.0.4.tgz", + "integrity": "sha512-I2hSBi7Vvs7BEuJDr5dDHfzb/Ruj3FyvFyh7KLilAjNQw3Be+xgqUBA2W6scVEcL0hL1dwPRtIqEPVUCKkSsyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3", + "upper-case": "^2.0.2" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cross-fetch": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz", + "integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "node-fetch": "^2.7.0" + } + }, + "node_modules/cross-inspect": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cross-inspect/-/cross-inspect-1.0.1.tgz", + "integrity": "sha512-Pcw1JTvZLSJH83iiGWt6fRcT+BjZlCDRVwYLbUcHzv/CRpB7r0MlSrGbIyQvVSNyGnbt7G4AXuyCiDR3POvZ1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/dataloader": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/dataloader/-/dataloader-2.2.3.tgz", + "integrity": "sha512-y2krtASINtPFS1rSDjacrFgn1dcUuoREVabwlOGOe4SdxenREqwjwjElAdwvbGM7kgZz9a3KVicWR7vcz8rnzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/debounce": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", + "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dependency-graph": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/dependency-graph/-/dependency-graph-0.11.0.tgz", + "integrity": "sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "license": "MIT" + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-indent": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz", + "integrity": "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dot-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", + "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dset": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/dset/-/dset-3.1.4.tgz", + "integrity": "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/effect": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz", + "integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.452", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.452.tgz", + "integrity": "sha512-cVvk1YBSj6571VmV6hdJTqj94Y79xSu8cj6Y9t6ae4Y4431z8/7EJFvu3rVp90xhfuv9BpPKUmaLfTo4+FOGBg==", + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/exit-hook": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz", + "integrity": "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/express": { + "version": "4.22.3", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz", + "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.13", + "proxy-addr": "~2.0.7", + "qs": "~6.16.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/exsolve": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.3.tgz", + "integrity": "sha512-kuzL9zherLQoqDeQaS55AiiIg1kb4KYXaSusTtgZTPlvKP2gGCI82LNmBNh5RjDVUXIcmgx+/Mx7vPwRzBbhJA==", + "license": "MIT" + }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fastq": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/figures": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", + "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^1.0.5" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-port": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz", + "integrity": "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/giget": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", + "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.6", + "nypm": "^0.6.0", + "pathe": "^2.0.3" + }, + "bin": { + "giget": "dist/cli.mjs" + } + }, + "node_modules/giget/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globrex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", + "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", + "license": "MIT" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graphql": { + "version": "16.14.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz", + "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/graphql-config": { + "version": "5.1.8", + "resolved": "https://registry.npmjs.org/graphql-config/-/graphql-config-5.1.8.tgz", + "integrity": "sha512-7DyT4ctUiNRMAU19IFy8o1zoQCJPjik1/vBwWIZlILGI6O1mHZXd59rUzF81oglNyHtPrNNCWonqfwu58BG66A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/graphql-file-loader": "^8.1.22", + "@graphql-tools/json-file-loader": "^8.0.36", + "@graphql-tools/load": "^8.1.19", + "@graphql-tools/merge": "^9.2.6", + "@graphql-tools/url-loader": "^9.1.12", + "@graphql-tools/utils": "^12.0.3", + "cosmiconfig": "^8.1.0", + "jiti": "^2.0.0", + "minimatch": "^10.0.0", + "string-env-interpolation": "^1.0.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">= 16.0.0" + }, + "peerDependencies": { + "cosmiconfig-toml-loader": "^1.0.0", + "graphql": "^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + }, + "peerDependenciesMeta": { + "cosmiconfig-toml-loader": { + "optional": true + } + } + }, + "node_modules/graphql-config/node_modules/@graphql-hive/signal": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@graphql-hive/signal/-/signal-2.0.0.tgz", + "integrity": "sha512-Pz8wB3K0iU6ae9S1fWfsmJX24CcGeTo6hE7T44ucmV/ALKRj+bxClmqrYcDT7v3f0d12Rh4FAXBb6gon+WkDpQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/graphql-config/node_modules/@graphql-tools/batch-execute": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@graphql-tools/batch-execute/-/batch-execute-10.2.0.tgz", + "integrity": "sha512-lXz9fGum2shGKMMU7zmt0mXHUFo/KbPzHpv/Qzb2/MrJUYDNfWCKPPpiJi0e4uS09xmuafub02VPH2IZfFlGJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/utils": "^12.0.1", + "@whatwg-node/promise-helpers": "^1.3.2", + "dataloader": "^2.2.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/graphql-config/node_modules/@graphql-tools/delegate": { + "version": "12.2.1", + "resolved": "https://registry.npmjs.org/@graphql-tools/delegate/-/delegate-12.2.1.tgz", + "integrity": "sha512-kGCrmSM2VMwLqrYtSWVYJC9AiwWxk4zZCLWmS0rln1uzHbhTuZ359UnQyGrz+P9hemUEBp4DGw3lmZPhtuAL3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/batch-execute": "^10.2.0", + "@graphql-tools/executor": "^2.0.1", + "@graphql-tools/schema": "^10.1.1", + "@graphql-tools/utils": "^12.0.1", + "@repeaterjs/repeater": "^3.0.6", + "@whatwg-node/promise-helpers": "^1.3.2", + "dataloader": "^2.2.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/graphql-config/node_modules/@graphql-tools/executor": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@graphql-tools/executor/-/executor-2.0.4.tgz", + "integrity": "sha512-Rr0Q69vP9zE2svHibKi2bFsO4DF97JmlSvrTKBj4ldDuQqygeOWB7SxjQrAMNQHm/b9UjFYD36Rk5RRhi3lL3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/utils": "^12.0.4", + "@graphql-typed-document-node/core": "^3.2.0", + "@repeaterjs/repeater": "^3.1.0", + "@whatwg-node/disposablestack": "^0.1.0", + "@whatwg-node/promise-helpers": "^2.0.0", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/graphql-config/node_modules/@graphql-tools/executor-common": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@graphql-tools/executor-common/-/executor-common-1.1.0.tgz", + "integrity": "sha512-It8E6ZqeLTZnkdMP4GciWQB8l04Rl0OzDHUo9QYNyxClrgAEqaPbsPuvo2avfkIE5ngE0S1dkA7dtF/7DD/BdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@envelop/core": "^5.4.0", + "@graphql-tools/utils": "^12.0.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/graphql-config/node_modules/@graphql-tools/executor-graphql-ws": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@graphql-tools/executor-graphql-ws/-/executor-graphql-ws-3.2.0.tgz", + "integrity": "sha512-Yi84qS3C77BIy0YJw1+nHJ+GAI4bTzHaCWLW1j/9PpHUqmlp32iMxPQOdI7QrXke+pBkIl03aMxyZpmZ/jDidA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/executor-common": "^1.1.0", + "@graphql-tools/utils": "^12.0.1", + "@whatwg-node/disposablestack": "^0.0.6", + "graphql-ws": "^6.0.6", + "isows": "^1.0.7", + "tslib": "^2.8.1", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/graphql-config/node_modules/@graphql-tools/executor-http": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@graphql-tools/executor-http/-/executor-http-3.4.1.tgz", + "integrity": "sha512-OcIfgG/F8ysubB20PCf9SiCcxkwMOd8n9GmnpwpYhU7s/DDF13Ady969dPzRzAsgjNWjSS5DqzvVN8e2OphtiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-hive/signal": "^2.0.0", + "@graphql-tools/executor-common": "^1.1.0", + "@graphql-tools/utils": "^12.0.1", + "@repeaterjs/repeater": "^3.0.4", + "@whatwg-node/disposablestack": "^0.0.6", + "@whatwg-node/fetch": "^0.10.13", + "@whatwg-node/promise-helpers": "^1.3.2", + "meros": "^1.3.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/graphql-config/node_modules/@graphql-tools/executor/node_modules/@whatwg-node/disposablestack": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@whatwg-node/disposablestack/-/disposablestack-0.1.0.tgz", + "integrity": "sha512-ok0A9+D+mdeb3w1GNCpVr+2gVVLRWKo9Ywm/whNsoWSNfNg3jNxS0/wUF6NSUYbfOtFGu2wZgJnLjasbaQEfvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@whatwg-node/promise-helpers": "^2.0.0", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=22.15.0" + } + }, + "node_modules/graphql-config/node_modules/@graphql-tools/executor/node_modules/@whatwg-node/promise-helpers": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-2.0.0.tgz", + "integrity": "sha512-/i9CS0Z96o+cPwaYoAyVxAThDiUX1wV3tDmNWPJbn+vtdR+JftuCDg1NGCXBrzIYVDGkJ8uT6ctwmrBOUzOpKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=22.15.0" + } + }, + "node_modules/graphql-config/node_modules/@graphql-tools/url-loader": { + "version": "9.1.13", + "resolved": "https://registry.npmjs.org/@graphql-tools/url-loader/-/url-loader-9.1.13.tgz", + "integrity": "sha512-Uo2+I16vrpvbRuh9q0wGoO6Aox7JSokk/ODHMgja8/vwzZ6hBGCgLtwDKV4xsd7PhdYPwm+sQICT6j1JseaK/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/executor-graphql-ws": "^3.2.0", + "@graphql-tools/executor-http": "^3.4.0", + "@graphql-tools/executor-legacy-ws": "^1.1.38", + "@graphql-tools/utils": "^12.0.4", + "@graphql-tools/wrap": "^11.1.1", + "@types/ws": "^8.18.2", + "@whatwg-node/fetch": "^0.12.0", + "@whatwg-node/promise-helpers": "^2.0.0", + "isomorphic-ws": "^5.0.0", + "sync-fetch": "0.7.1", + "tslib": "^2.4.0", + "ws": "^8.21.3" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/graphql-config/node_modules/@graphql-tools/url-loader/node_modules/@whatwg-node/fetch": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/@whatwg-node/fetch/-/fetch-0.12.1.tgz", + "integrity": "sha512-MjSXerFVxhWwPXZMNp4b4Q3WZMEIqzinHVWuH5tF2LNGy4hHypqPJCNpaR6bEZbaGdlH+r5dRAmQ8bxowcetjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@whatwg-node/node-fetch": "^0.9.1", + "urlpattern-polyfill": "^10.0.0" + }, + "engines": { + "node": ">=22.15.0" + } + }, + "node_modules/graphql-config/node_modules/@graphql-tools/url-loader/node_modules/@whatwg-node/promise-helpers": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-2.0.0.tgz", + "integrity": "sha512-/i9CS0Z96o+cPwaYoAyVxAThDiUX1wV3tDmNWPJbn+vtdR+JftuCDg1NGCXBrzIYVDGkJ8uT6ctwmrBOUzOpKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=22.15.0" + } + }, + "node_modules/graphql-config/node_modules/@graphql-tools/utils": { + "version": "12.0.4", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-12.0.4.tgz", + "integrity": "sha512-hJmeBRrK1OUABqRxLXB1YwrBB+3zTXeRJYb5PaF7CweV80zNGnN640Jz+0BrCaUE8HBk6KtoVXM/tUScUsKdeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "@whatwg-node/promise-helpers": "^2.0.0", + "cross-inspect": "1.0.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/graphql-config/node_modules/@graphql-tools/utils/node_modules/@whatwg-node/promise-helpers": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-2.0.0.tgz", + "integrity": "sha512-/i9CS0Z96o+cPwaYoAyVxAThDiUX1wV3tDmNWPJbn+vtdR+JftuCDg1NGCXBrzIYVDGkJ8uT6ctwmrBOUzOpKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=22.15.0" + } + }, + "node_modules/graphql-config/node_modules/@graphql-tools/wrap": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@graphql-tools/wrap/-/wrap-11.2.1.tgz", + "integrity": "sha512-N/3Z4SD9MKhfycX8RgkoBAx2uNBIOCfY2LlrVY5hNNqLEBndgYQ29Lluhye5PO+NeTR5lN5ScCDfGEfTVk58FA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/delegate": "^12.2.1", + "@graphql-tools/schema": "^10.1.1", + "@graphql-tools/utils": "^12.0.1", + "@whatwg-node/promise-helpers": "^1.3.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/graphql-config/node_modules/@whatwg-node/node-fetch": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@whatwg-node/node-fetch/-/node-fetch-0.9.1.tgz", + "integrity": "sha512-LX/1X2QEHupCz6YXRjefZRoOsycCGOIJdXtQPVVxhzPQ/CpWpWFaXl53ShqLystvLZSvtUDKLhCA/yLveHA/LA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^3.1.1", + "@whatwg-node/disposablestack": "^0.1.0", + "@whatwg-node/promise-helpers": "^2.0.0", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=22.15.0" + } + }, + "node_modules/graphql-config/node_modules/@whatwg-node/node-fetch/node_modules/@whatwg-node/disposablestack": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@whatwg-node/disposablestack/-/disposablestack-0.1.0.tgz", + "integrity": "sha512-ok0A9+D+mdeb3w1GNCpVr+2gVVLRWKo9Ywm/whNsoWSNfNg3jNxS0/wUF6NSUYbfOtFGu2wZgJnLjasbaQEfvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@whatwg-node/promise-helpers": "^2.0.0", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=22.15.0" + } + }, + "node_modules/graphql-config/node_modules/@whatwg-node/node-fetch/node_modules/@whatwg-node/promise-helpers": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-2.0.0.tgz", + "integrity": "sha512-/i9CS0Z96o+cPwaYoAyVxAThDiUX1wV3tDmNWPJbn+vtdR+JftuCDg1NGCXBrzIYVDGkJ8uT6ctwmrBOUzOpKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=22.15.0" + } + }, + "node_modules/graphql-config/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/graphql-config/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/graphql-config/node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/graphql-config/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/graphql-request": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/graphql-request/-/graphql-request-6.1.0.tgz", + "integrity": "sha512-p+XPfS4q7aIpKVcgmnZKhMNqhltk20hfXtkaIkTfjjmiKMJ5xrt5c743cL03y/K7y1rg3WrIC49xGiEQ4mxdNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.2.0", + "cross-fetch": "^3.1.5" + }, + "peerDependencies": { + "graphql": "14 - 16" + } + }, + "node_modules/graphql-tag": { + "version": "2.12.7", + "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.7.tgz", + "integrity": "sha512-xnE/NFzy+0eIesvAsREJZ284zTl/wYuBAvpsFSDhRGRdRHdnE90M21Q3xAWyYInb0J756c6x0pIQ62+vtvOs1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "graphql": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/graphql-ws": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/graphql-ws/-/graphql-ws-6.3.0.tgz", + "integrity": "sha512-qBAXPtFjWHF3QypDyNcBp8Rycd+rmofxUSJsu3bGI9QMMq7pImKBSLSg9hZ2ojIMbJ54T/LPjKBIeOUoJbNBnQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@fastify/websocket": "^10 || ^11", + "crossws": "~0.3 || ~0.4", + "graphql": "^15.10.1 || ^16 || ^17", + "ws": "^8" + }, + "peerDependenciesMeta": { + "@fastify/websocket": { + "optional": true + }, + "crossws": { + "optional": true + }, + "ws": { + "optional": true + } + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/header-case": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/header-case/-/header-case-2.0.4.tgz", + "integrity": "sha512-H/vuk5TEEVZwrR0lp2zed9OCo1uAILMlx0JEMgC26rzyJJ3N1v6XkwHHXJQdR2doSjcGPM6OKPYoJgf0plJ11Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "capital-case": "^1.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immutable": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz", + "integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==", + "dev": true, + "license": "MIT" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/import-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/import-from/-/import-from-4.0.0.tgz", + "integrity": "sha512-P9J71vT5nLlDeV8FHs5nNxaLbrpfAV5cF5srvbZfpwpcJoM/xZR3hiv+q+SAnuSmuGbXMWud063iIMx/V/EWZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/inquirer": { + "version": "8.2.7", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.7.tgz", + "integrity": "sha512-UjOaSel/iddGZJ5xP/Eixh6dY1XghiBw4XK13rCCIJcJfyhhoul/7KhLLUGtebEj6GDYM6Vnx/mVsjx2L/mFIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/external-editor": "^1.0.0", + "ansi-escapes": "^4.2.1", + "chalk": "^4.1.1", + "cli-cursor": "^3.1.0", + "cli-width": "^3.0.0", + "figures": "^3.0.0", + "lodash": "^4.17.21", + "mute-stream": "0.0.8", + "ora": "^5.4.1", + "run-async": "^2.4.0", + "rxjs": "^7.5.5", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "through": "^2.3.6", + "wrap-ansi": "^6.0.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-absolute": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-absolute/-/is-absolute-1.0.0.tgz", + "integrity": "sha512-dOWoqflvcydARa360Gvv18DZ/gRuHKi2NU/wU5X1ZFzdYfH29nkiNZsF3mp4OJ3H4yo9Mx8A/uAGNzpzPN3yBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-relative": "^1.0.0", + "is-windows": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-lower-case/-/is-lower-case-2.0.2.tgz", + "integrity": "sha512-bVcMJy4X5Og6VZfdOZstSexlEy20Sr0k/p/b2IlQJlfdKAQuMpiv5w2Ccxb8sKdRUNAG1PnHVHjFSdRDVS6NlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-relative": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-relative/-/is-relative-1.0.0.tgz", + "integrity": "sha512-Kw/ReK0iqwKeu0MITLFuj0jbPAmEiOsIwyIXvvbfa6QfmN9pkD1M+8pdk7Rl/dTKbH34/XBFMbgD4iMJhLQbGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-unc-path": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-unc-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-unc-path/-/is-unc-path-1.0.0.tgz", + "integrity": "sha512-mrGpVd0fs7WWLfVsStvgF6iEJnbjDFZh9/emhRDcGWTduTfNHd9CHeUwH3gYIjdbwo4On6hunkztwOaAw0yllQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "unc-path-regex": "^0.1.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-upper-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-upper-case/-/is-upper-case-2.0.2.tgz", + "integrity": "sha512-44pxmxAvnnAOwBg4tHPnkfvgjPwbc5QIsSstNU+YcJ1ovxVzCWpSGosPJOZh/a1tdl81fbgnLc9LLv+x2ywbPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/is-windows": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", + "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isbot": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.2.2.tgz", + "integrity": "sha512-iQcBXcd+Rv/pkubRyGh2utW2j1oPG5hZY6TUhVPpqK4G+o3IbxpJNx04hgksjc/N7GK5pEorUxDeg31cFgEk/w==", + "license": "Unlicense", + "engines": { + "node": ">=18" + } + }, + "node_modules/isomorphic-ws": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz", + "integrity": "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, + "node_modules/isows": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz", + "integrity": "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "devOptional": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", + "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-to-pretty-yaml": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/json-to-pretty-yaml/-/json-to-pretty-yaml-1.2.2.tgz", + "integrity": "sha512-rvm6hunfCcqegwYaG5T4yKJWxc9FXFgBVrcTZ4XfSVRwa5HA/Xs+vB/Eo9treYYHCeNM0nrSUr82V/M31Urc7A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "remedial": "^1.0.7", + "remove-trailing-spaces": "^1.0.6" + }, + "engines": { + "node": ">= 0.2.0" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/listr2": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-4.0.5.tgz", + "integrity": "sha512-juGHV1doQdpNT3GSTs9IUN43QJb7KHdF9uqg7Vufs/tG9VTzpFphqF4pm/ICdAABGQxsyNn9CiYA3StkI6jpwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^2.1.0", + "colorette": "^2.0.16", + "log-update": "^4.0.0", + "p-map": "^4.0.0", + "rfdc": "^1.3.0", + "rxjs": "^7.5.5", + "through": "^2.3.8", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "enquirer": ">= 2.3.0 < 3" + }, + "peerDependenciesMeta": { + "enquirer": { + "optional": true + } + } + }, + "node_modules/listr2/node_modules/p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/listr2/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-4.0.0.tgz", + "integrity": "sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^4.3.0", + "cli-cursor": "^3.1.0", + "slice-ansi": "^4.0.0", + "wrap-ansi": "^6.2.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lossless-json": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/lossless-json/-/lossless-json-4.3.1.tgz", + "integrity": "sha512-SqD/Bg3ZfltBJ2Z14hJ/BihnvtV553WO4g9/ePtlp4lrnl9jF3AdIJt53A/Wkg/0Li+LMfxaBqgx1MiFZdQlpQ==", + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/lower-case-first": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case-first/-/lower-case-first-2.0.2.tgz", + "integrity": "sha512-EVm/rR94FJTZi3zefZ82fLWab+GX14LJN4HrWBcuo6Evmsl9hEfnqxgcHCKb9q+mNf6EVdsjx/qucYFIIB84pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/map-cache": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz", + "integrity": "sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/meros": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/meros/-/meros-1.3.2.tgz", + "integrity": "sha512-Q3mobPbvEx7XbwhnC1J1r60+5H6EZyNccdzSz0eGexJRwouUtTZxPVRGdqKtxlpD84ScK4+tIGldkqDtCKdI0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=13" + }, + "peerDependencies": { + "@types/node": ">=13" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/morgan": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.12.1.tgz", + "integrity": "sha512-tljKC0ex20AjO58Ob/eZ53JloycbVswbVNCHx6V6VLGzqt/w8dIynVGL0G8qVjNKwiA7sYSogCrN6QtJ82IV+g==", + "license": "MIT", + "dependencies": { + "basic-auth": "~2.0.1", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-finished": "~2.4.1", + "on-headers": "~1.1.0" + }, + "engines": { + "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/morgan/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/morgan/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "dev": true, + "license": "ISC" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/no-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", + "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.58", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.58.tgz", + "integrity": "sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz", + "integrity": "sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "remove-trailing-separator": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nypm": { + "version": "0.6.10", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.10.tgz", + "integrity": "sha512-W72Hrj1petq+b3Hk2aAC+9zswetlIFTnDW4s5djseZh2nYqBbyQLOtj472HwcbcWykPBUW1WpWpjOd4nK6gMRw==", + "license": "MIT", + "dependencies": { + "citty": "^0.2.2", + "pathe": "^2.0.3", + "tinyexec": "^1.3.1" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/nypm/node_modules/citty": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", + "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", + "license": "MIT" + }, + "node_modules/nypm/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ohash": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.12.tgz", + "integrity": "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==", + "license": "MIT" + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-map": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.1.1.tgz", + "integrity": "sha512-6Lh3jQPLthYujSrlw6ccWXBGZTkoTCz27Hd92g79LH/kjAbgoEu0t442cCCNbegPSJsA0lwDX8z0bcKtkpA32g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/param-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", + "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-filepath": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/parse-filepath/-/parse-filepath-1.0.2.tgz", + "integrity": "sha512-FwdRXKCohSVeXqwtYonZTXtbGJKrn+HNyWDYVcp5yuJlesTwNH4rsmRZ+GrKAPJ5bLpRxESMeS+Rl0VCHRvB2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-absolute": "^1.0.0", + "map-cache": "^0.2.0", + "path-root": "^0.1.1" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/pascal-case": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", + "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/path-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/path-case/-/path-case-3.0.4.tgz", + "integrity": "sha512-qO4qCFjXqVTrcbPt/hQfhTQ+VhFsqNKOPtytgNKkKxSoEp3XPUQ8ObFuePylOIok5gjn69ry8XiULxCwot3Wfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/path-root": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/path-root/-/path-root-0.1.1.tgz", + "integrity": "sha512-QLcPegTHF11axjfojBIoDygmS2E3Lf+8+jI6wOVmNVenrKSo3mFdSGiIgdSHenczw3wPtlVMQaFVwGmM7BJdtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-root-regex": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-root-regex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/path-root-regex/-/path-root-regex-0.1.2.tgz", + "integrity": "sha512-4GlJ6rZDhQZFE0DPVKh0e9jmZ5egZfxTkp7bcRDuPlJXbAwhxcl2dINPUAsjLdejqaLsCeg8axcLjIbvBjN4pQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.1", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.1", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.1" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz", + "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz", + "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz", + "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pg-types/node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-types": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.3.tgz", + "integrity": "sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w==", + "license": "MIT", + "dependencies": { + "confbox": "^0.3.1", + "exsolve": "^1.1.1", + "pathe": "^2.0.3" + } + }, + "node_modules/pkg-types/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, + "node_modules/playwright": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz", + "integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.64.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz", + "integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/postcss": { + "version": "8.5.30", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz", + "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postgres-array": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz", + "integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/prettier": { + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.10.tgz", + "integrity": "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==", + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prisma": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz", + "integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/config": "6.19.3", + "@prisma/engines": "6.19.3" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rc9": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", + "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "destr": "^2.0.3" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", + "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz", + "integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/remedial": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/remedial/-/remedial-1.0.8.tgz", + "integrity": "sha512-/62tYiOe6DzS5BqVsNpH/nkGlX45C/Sp6V+NtiN6JQNS1Viay7cWkazmRkrQrdFj2eshDe96SIQNIoMxqhzBOg==", + "dev": true, + "license": "(MIT OR Apache-2.0)", + "engines": { + "node": "*" + } + }, + "node_modules/remove-trailing-separator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", + "integrity": "sha512-/hS+Y0u3aOfIETiaiirUFwDBDzmXPvO+jAfKTitUngIPzdKc6Z0LoFjM/CK5PL4C+eKwHohlHAb6H0VFfmmUsw==", + "dev": true, + "license": "ISC" + }, + "node_modules/remove-trailing-spaces": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/remove-trailing-spaces/-/remove-trailing-spaces-1.0.9.tgz", + "integrity": "sha512-xzG7w5IRijvIkHIjDk65URsJJ7k4J95wmcArY5PRcmjldIOl7oTvG8+X2Ag690R7SfwiOcHrWZKVc1Pp5WIOzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.5.tgz", + "integrity": "sha512-EAP5ZHcz2ijbgCWwPkpI7zh6bfsri2Ez5FtCy2dCp1k62duXzfG7DLGGh7RU2QQXWwavT+u4qpvDF/TWOiiGTw==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.64.5", + "@rollup/rollup-android-arm64": "4.64.5", + "@rollup/rollup-darwin-arm64": "4.64.5", + "@rollup/rollup-darwin-x64": "4.64.5", + "@rollup/rollup-freebsd-arm64": "4.64.5", + "@rollup/rollup-freebsd-x64": "4.64.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.64.5", + "@rollup/rollup-linux-arm-musleabihf": "4.64.5", + "@rollup/rollup-linux-arm64-gnu": "4.64.5", + "@rollup/rollup-linux-arm64-musl": "4.64.5", + "@rollup/rollup-linux-loong64-gnu": "4.64.5", + "@rollup/rollup-linux-loong64-musl": "4.64.5", + "@rollup/rollup-linux-ppc64-gnu": "4.64.5", + "@rollup/rollup-linux-ppc64-musl": "4.64.5", + "@rollup/rollup-linux-riscv64-gnu": "4.64.5", + "@rollup/rollup-linux-riscv64-musl": "4.64.5", + "@rollup/rollup-linux-s390x-gnu": "4.64.5", + "@rollup/rollup-linux-x64-gnu": "4.64.5", + "@rollup/rollup-linux-x64-musl": "4.64.5", + "@rollup/rollup-openbsd-x64": "4.64.5", + "@rollup/rollup-openharmony-arm64": "4.64.5", + "@rollup/rollup-win32-arm64-msvc": "4.64.5", + "@rollup/rollup-win32-ia32-msvc": "4.64.5", + "@rollup/rollup-win32-x64-gnu": "4.64.5", + "@rollup/rollup-win32-x64-msvc": "4.64.5", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-async": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", + "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/scuid": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/scuid/-/scuid-1.1.0.tgz", + "integrity": "sha512-MuCAyrGZcTLfQoH2XoBlQ8C6bzwN88XT/0slOGz0pn8+gIP85BOAfYa44ZXQUTOwRwPU0QvgU+V+OSajl/59Xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/sentence-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/sentence-case/-/sentence-case-3.0.4.tgz", + "integrity": "sha512-8LS0JInaQMCRoQ7YUytAo/xUu5W2XnQxV2HI/6uM6U7CITS1RqPElr30V6uIqyMKM9lJGRVFy5/4CuzcixNYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3", + "upper-case-first": "^2.0.2" + } + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shell-quote": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.12.0.tgz", + "integrity": "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/slice-ansi": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", + "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/snake-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", + "integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sponge-case": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/sponge-case/-/sponge-case-1.0.1.tgz", + "integrity": "sha512-dblb9Et4DAtiZ5YSUZHLl4XhH4uK80GhAZrVXdN4O2P4gQ40Wa5UIOPUHlA/nFd2PLblBZWUioLMMAVrgpoYcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-env-interpolation": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/string-env-interpolation/-/string-env-interpolation-1.0.1.tgz", + "integrity": "sha512-78lwMoCcn0nNu8LszbP1UA7g55OeE4v7rCeWnM5B453rnNr4aq+5it3FEYtZrSEiMvHZOZ9Jlqb0OD0M2VInqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/swap-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/swap-case/-/swap-case-2.0.2.tgz", + "integrity": "sha512-kc6S2YS/2yXbtkSMunBtKdah4VFETZ8Oh6ONSmSd9bRxhqTrtARUCBUiWXH3xVPpvR7tz2CSnkuXVE42EcGnMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/sync-fetch": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/sync-fetch/-/sync-fetch-0.7.1.tgz", + "integrity": "sha512-tTw2sPVgJb/ZO+Uz5Cs1haWg7aL0cw+DvD8mXos+ws5YaelM3IBZ50M717T1+RD5Qq0TdeZ/EI/wVMnAdJA0Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "node-fetch": "^3.3.2", + "timeout-signal": "^2.0.0", + "whatwg-mimetype": "^5.0.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/sync-fetch/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/timeout-signal": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/timeout-signal/-/timeout-signal-2.0.0.tgz", + "integrity": "sha512-YBGpG4bWsHoPvofT6y/5iqulfXIiIErl5B0LdtHT1mGXDFTAhhRrbUpTvBgYbovr+3cKblya2WAOcpoy90XguA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz", + "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/title-case": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/title-case/-/title-case-3.0.3.tgz", + "integrity": "sha512-e1zGYRvbffpcHIrnuqT0Dh+gEJtDaxDSoG4JAIpq4oDFyooziLBIiYQv0GBT4FUAnUop5uZ1hiIAj7oAF6sOCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ts-log": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/ts-log/-/ts-log-2.2.7.tgz", + "integrity": "sha512-320x5Ggei84AxzlXp91QkIGSw5wgaLT6GeAH0KsqDmRZdVWW2OiSeVvElVoatk3f7nicwXlElXsoFkARiGE2yg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tsconfck": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz", + "integrity": "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==", + "deprecated": "unmaintained", + "license": "MIT", + "bin": { + "tsconfck": "bin/tsconfck.js" + }, + "engines": { + "node": "^18 || >=20" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "devOptional": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unc-path-regex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/unc-path-regex/-/unc-path-regex-0.1.2.tgz", + "integrity": "sha512-eXL4nmJT7oCpkZsHZUOJo8hcX3GbsiDOa0Qu9F646fi8dT3XuSVopVqAcEiVzSKKH7UoDti23wNX3qGFxcW5Qg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/unixify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unixify/-/unixify-1.0.0.tgz", + "integrity": "sha512-6bc58dPYhCMHHuwxldQxO3RRNZ4eCogZ/st++0+fcC1nr0jiGUtAdBJ2qzmLQWSxbtz42pWt4QQMiZ9HvZf5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "normalize-path": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/upper-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/upper-case/-/upper-case-2.0.2.tgz", + "integrity": "sha512-KgdgDGJt2TpuwBUIjgG6lzw2GWFRCW9Qkfkiv0DxqHHLYJHmtmdUIKcZd8rHgFSjopVTlw6ggzCm1b8MFQwikg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/upper-case-first": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/upper-case-first/-/upper-case-first-2.0.2.tgz", + "integrity": "sha512-514ppYHBaKwfJRK/pNC6c/OxfGa0obSnAl106u97Ed0I625Nin96KAjttZF6ZL3e1XLtphxnqrOi9iWgm+u+bg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/urlpattern-polyfill": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.1.0.tgz", + "integrity": "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/valibot": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.5.0.tgz", + "integrity": "sha512-nil6AkP2TChWL43Z5uJ6GTxX01CUA+g8LWUM+N/rB9NBbkUMaUsi9PUNzlUPgoKASgmx9f7eGOYpJ04/fSa6FQ==", + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.4.tgz", + "integrity": "sha512-xGjU/SyjeNyb0E4KxXBvYz/1C+3JXuujvf2hXNOtnlcE+58o5ajd9N2/n2VhkiJvpCP2qQasnUpx5PXNeI+p1Q==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, + "node_modules/vite-tsconfig-paths": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-5.1.4.tgz", + "integrity": "sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "globrex": "^0.1.2", + "tsconfck": "^3.0.3" + }, + "peerDependencies": { + "vite": "*" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vitest/node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "devOptional": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yaml-ast-parser": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", + "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..333ab8c --- /dev/null +++ b/package.json @@ -0,0 +1,60 @@ +{ + "name": "datewise", + "private": true, + "version": "0.1.0", + "type": "module", + "description": "Datewise \u2014 delivery & pickup date/time scheduling for Shopify, with capacity holds, peak-day rush control, perishable-aware dates, customer self-reschedule, reminders and driver run sheets.", + "scripts": { + "build": "react-router build", + "dev": "shopify app dev", + "config:link": "shopify app config link", + "deploy": "shopify app deploy", + "start": "react-router-serve ./build/server/index.js", + "setup": "prisma generate && prisma migrate deploy", + "typecheck": "react-router typegen && tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "worker": "node --import tsx ./app/jobs/worker.ts", + "prototype": "esbuild app/engine/index.ts --bundle --format=iife --global-name=DatewiseEngine --target=es2019 --minify --outfile=prototype/engine.js && node prototype/build.mjs", + "test:db": "TEST_DATABASE_URL=${TEST_DATABASE_URL:-postgresql://postgres@localhost:5432/datewise_test} vitest run", + "e2e:server": "tsx tests/e2e/server.ts", + "e2e": "node tests/e2e/widget.e2e.mjs" + }, + "engines": { + "node": ">=20.10" + }, + "dependencies": { + "@prisma/adapter-pg": "6.19", + "@prisma/client": "^6.17.1", + "@react-router/dev": "^7.9.0", + "@react-router/fs-routes": "^7.9.0", + "@react-router/node": "^7.9.0", + "@react-router/serve": "^7.9.0", + "@shopify/app-bridge-react": "^4.2.0", + "@shopify/shopify-app-react-router": "^3.0.2", + "@shopify/shopify-app-session-storage-prisma": "^11.0.1", + "isbot": "^5.1.0", + "pg": "^8.23.1", + "prisma": "^6.17.1", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router": "^7.9.0", + "vite": "^6.3.0", + "vite-tsconfig-paths": "^5.1.4", + "zod": "^3.25.0" + }, + "devDependencies": { + "@shopify/api-codegen-preset": "^1.1.0", + "@shopify/app-bridge-types": "^0.7.2", + "@shopify/polaris-types": "^1.1.0", + "@types/node": "^22.0.0", + "@types/pg": "^8.23.1", + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "esbuild": "^0.25.0", + "tsx": "^4.19.0", + "typescript": "^5.8.0", + "vitest": "^3.2.0", + "playwright": "^1.56.0" + } +} diff --git a/prisma/migrations/0001_init/migration.sql b/prisma/migrations/0001_init/migration.sql new file mode 100644 index 0000000..f98622a --- /dev/null +++ b/prisma/migrations/0001_init/migration.sql @@ -0,0 +1,216 @@ +-- Datewise initial schema (PostgreSQL) + +CREATE TABLE "Session" ( + "id" TEXT NOT NULL, + "shop" TEXT NOT NULL, + "state" TEXT NOT NULL, + "isOnline" BOOLEAN NOT NULL DEFAULT false, + "scope" TEXT, + "expires" TIMESTAMP(3), + "accessToken" TEXT NOT NULL, + "userId" BIGINT, + "firstName" TEXT, + "lastName" TEXT, + "email" TEXT, + "accountOwner" BOOLEAN NOT NULL DEFAULT false, + "locale" TEXT, + "collaborator" BOOLEAN DEFAULT false, + "emailVerified" BOOLEAN DEFAULT false, + "refreshToken" TEXT, + "refreshTokenExpires" TIMESTAMP(3), + CONSTRAINT "Session_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "Shop" ( + "id" TEXT NOT NULL, + "domain" TEXT NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'CAD', + "timezone" TEXT NOT NULL DEFAULT 'America/Toronto', + "settings" JSONB NOT NULL, + "configVersion" INTEGER NOT NULL DEFAULT 1, + "plan" TEXT NOT NULL DEFAULT 'free', + "feeProductId" TEXT, + "installedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "uninstalledAt" TIMESTAMP(3), + CONSTRAINT "Shop_pkey" PRIMARY KEY ("id") +); +CREATE UNIQUE INDEX "Shop_domain_key" ON "Shop"("domain"); + +CREATE TABLE "Location" ( + "id" TEXT NOT NULL, + "shopId" TEXT NOT NULL, + "shopifyLocationId" TEXT, + "name" TEXT NOT NULL, + "address" TEXT NOT NULL, + "timezone" TEXT NOT NULL, + "lat" DOUBLE PRECISION, + "lng" DOUBLE PRECISION, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "priority" INTEGER NOT NULL DEFAULT 1, + "pickupInstructions" TEXT, + "methods" JSONB NOT NULL, + "exceptions" JSONB NOT NULL DEFAULT '[]', + "zones" JSONB NOT NULL DEFAULT '[]', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "Location_pkey" PRIMARY KEY ("id") +); +CREATE INDEX "Location_shopId_idx" ON "Location"("shopId"); + +CREATE TABLE "ProductRule" ( + "id" TEXT NOT NULL, + "shopId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "priority" INTEGER NOT NULL DEFAULT 10, + "target" JSONB NOT NULL, + "action" JSONB NOT NULL, + "message" TEXT, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "ProductRule_pkey" PRIMARY KEY ("id") +); +CREATE INDEX "ProductRule_shopId_idx" ON "ProductRule"("shopId"); + +CREATE TABLE "PeakDay" ( + "id" TEXT NOT NULL, + "shopId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "dates" TEXT[], + "locationIds" TEXT[], + "methods" TEXT[], + "dailyCapacity" INTEGER, + "slotCapacity" INTEGER, + "extraPrepMinutes" INTEGER NOT NULL DEFAULT 0, + "cutoffDaysBefore" INTEGER, + "cutoffTime" TEXT, + "surcharge" INTEGER NOT NULL DEFAULT 0, + "waitlist" BOOLEAN NOT NULL DEFAULT false, + "message" TEXT, + CONSTRAINT "PeakDay_pkey" PRIMARY KEY ("id") +); +CREATE INDEX "PeakDay_shopId_idx" ON "PeakDay"("shopId"); + +CREATE TABLE "CapacityCounter" ( + "shopId" TEXT NOT NULL, + "locationId" TEXT NOT NULL, + "method" TEXT NOT NULL, + "date" TEXT NOT NULL, + "slot" TEXT NOT NULL, + "count" INTEGER NOT NULL DEFAULT 0, + CONSTRAINT "CapacityCounter_pkey" PRIMARY KEY ("shopId","locationId","method","date","slot"), + CONSTRAINT "CapacityCounter_count_nonneg" CHECK ("count" >= 0) +); + +CREATE TABLE "Hold" ( + "token" TEXT NOT NULL, + "shopId" TEXT NOT NULL, + "locationId" TEXT NOT NULL, + "method" TEXT NOT NULL, + "date" TEXT NOT NULL, + "slot" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "fee" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "Hold_pkey" PRIMARY KEY ("token") +); +CREATE INDEX "Hold_shopId_locationId_method_date_idx" ON "Hold"("shopId", "locationId", "method", "date"); +CREATE INDEX "Hold_expiresAt_idx" ON "Hold"("expiresAt"); + +CREATE TABLE "Booking" ( + "id" TEXT NOT NULL, + "shopId" TEXT NOT NULL, + "orderId" TEXT, + "orderName" TEXT, + "status" TEXT NOT NULL, + "reviewReason" TEXT, + "method" TEXT NOT NULL, + "locationId" TEXT, + "date" TEXT, + "slotStart" TEXT, + "slotEnd" TEXT, + "timezone" TEXT NOT NULL, + "startUtc" TIMESTAMP(3), + "endUtc" TIMESTAMP(3), + "customerName" TEXT, + "customerEmail" TEXT, + "customerPhone" TEXT, + "address" JSONB, + "items" JSONB, + "fee" INTEGER NOT NULL DEFAULT 0, + "peakDayId" TEXT, + "rescheduleCount" INTEGER NOT NULL DEFAULT 0, + "configVersion" INTEGER NOT NULL, + "snapshot" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "Booking_pkey" PRIMARY KEY ("id") +); +CREATE UNIQUE INDEX "Booking_shopId_orderId_key" ON "Booking"("shopId", "orderId"); +CREATE INDEX "Booking_shopId_date_idx" ON "Booking"("shopId", "date"); +CREATE INDEX "Booking_shopId_status_idx" ON "Booking"("shopId", "status"); + +CREATE TABLE "BookingEvent" ( + "id" TEXT NOT NULL, + "shopId" TEXT NOT NULL, + "bookingId" TEXT NOT NULL, + "actor" TEXT NOT NULL, + "action" TEXT NOT NULL, + "before" JSONB, + "after" JSONB, + "at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "BookingEvent_pkey" PRIMARY KEY ("id") +); +CREATE INDEX "BookingEvent_bookingId_idx" ON "BookingEvent"("bookingId"); + +CREATE TABLE "WaitlistEntry" ( + "id" TEXT NOT NULL, + "shopId" TEXT NOT NULL, + "locationId" TEXT NOT NULL, + "method" TEXT NOT NULL, + "date" TEXT NOT NULL, + "slot" TEXT NOT NULL, + "email" TEXT, + "phone" TEXT, + "name" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "offeredAt" TIMESTAMP(3), + "offerToken" TEXT, + CONSTRAINT "WaitlistEntry_pkey" PRIMARY KEY ("id") +); +CREATE INDEX "WaitlistEntry_shopId_locationId_method_date_idx" ON "WaitlistEntry"("shopId", "locationId", "method", "date"); + +CREATE TABLE "ReminderJob" ( + "id" TEXT NOT NULL, + "shopId" TEXT NOT NULL, + "bookingId" TEXT NOT NULL, + "policyId" TEXT NOT NULL, + "channel" TEXT NOT NULL, + "sendAt" TIMESTAMP(3) NOT NULL, + "body" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'pending', + "attempts" INTEGER NOT NULL DEFAULT 0, + "lastError" TEXT, + "sentAt" TIMESTAMP(3), + CONSTRAINT "ReminderJob_pkey" PRIMARY KEY ("id") +); +CREATE INDEX "ReminderJob_status_sendAt_idx" ON "ReminderJob"("status", "sendAt"); + +CREATE TABLE "ProcessedWebhook" ( + "id" TEXT NOT NULL, + "topic" TEXT NOT NULL, + "shop" TEXT NOT NULL, + "receivedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "ProcessedWebhook_pkey" PRIMARY KEY ("id") +); + +ALTER TABLE "Location" ADD CONSTRAINT "Location_shopId_fkey" FOREIGN KEY ("shopId") REFERENCES "Shop"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "ProductRule" ADD CONSTRAINT "ProductRule_shopId_fkey" FOREIGN KEY ("shopId") REFERENCES "Shop"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "PeakDay" ADD CONSTRAINT "PeakDay_shopId_fkey" FOREIGN KEY ("shopId") REFERENCES "Shop"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "CapacityCounter" ADD CONSTRAINT "CapacityCounter_shopId_fkey" FOREIGN KEY ("shopId") REFERENCES "Shop"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "Hold" ADD CONSTRAINT "Hold_shopId_fkey" FOREIGN KEY ("shopId") REFERENCES "Shop"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "Booking" ADD CONSTRAINT "Booking_shopId_fkey" FOREIGN KEY ("shopId") REFERENCES "Shop"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "BookingEvent" ADD CONSTRAINT "BookingEvent_shopId_fkey" FOREIGN KEY ("shopId") REFERENCES "Shop"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "BookingEvent" ADD CONSTRAINT "BookingEvent_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_shopId_fkey" FOREIGN KEY ("shopId") REFERENCES "Shop"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "ReminderJob" ADD CONSTRAINT "ReminderJob_shopId_fkey" FOREIGN KEY ("shopId") REFERENCES "Shop"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "ReminderJob" ADD CONSTRAINT "ReminderJob_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..2fe25d8 --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1 @@ +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..43823a5 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,244 @@ +// Datewise data model. Postgres is required: reservations rely on row locks. +generator client { + provider = "prisma-client-js" + previewFeatures = ["queryCompiler", "driverAdapters"] + engineType = "client" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +// Required by @shopify/shopify-app-session-storage-prisma +model Session { + id String @id + shop String + state String + isOnline Boolean @default(false) + scope String? + expires DateTime? + accessToken String + userId BigInt? + firstName String? + lastName String? + email String? + accountOwner Boolean @default(false) + locale String? + collaborator Boolean? @default(false) + emailVerified Boolean? @default(false) + refreshToken String? + refreshTokenExpires DateTime? +} + +model Shop { + id String @id @default(cuid()) + domain String @unique + currency String @default("CAD") + timezone String @default("America/Toronto") + /// ShopSettings JSON (holds, reschedule policy, reminders, mixed-cart policy, widget text) + settings Json + /// Incremented on every config change; stored on bookings as the rule version used. + configVersion Int @default(1) + plan String @default("free") + feeProductId String? + installedAt DateTime @default(now()) + uninstalledAt DateTime? + locations Location[] + rules ProductRule[] + peakDays PeakDay[] + bookings Booking[] + holds Hold[] + counters CapacityCounter[] + waitlist WaitlistEntry[] + reminderJobs ReminderJob[] + events BookingEvent[] +} + +model Location { + id String @id @default(cuid()) + shopId String + shop Shop @relation(fields: [shopId], references: [id], onDelete: Cascade) + shopifyLocationId String? + name String + address String + timezone String + lat Float? + lng Float? + enabled Boolean @default(true) + priority Int @default(1) + pickupInstructions String? + /// Partial> + methods Json + /// DateException[] + exceptions Json @default("[]") + /// DeliveryZone[] + zones Json @default("[]") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([shopId]) +} + +model ProductRule { + id String @id @default(cuid()) + shopId String + shop Shop @relation(fields: [shopId], references: [id], onDelete: Cascade) + name String + enabled Boolean @default(true) + priority Int @default(10) + target Json + action Json + message String? + updatedAt DateTime @updatedAt + + @@index([shopId]) +} + +model PeakDay { + id String @id @default(cuid()) + shopId String + shop Shop @relation(fields: [shopId], references: [id], onDelete: Cascade) + name String + dates String[] + locationIds String[] + methods String[] + dailyCapacity Int? + slotCapacity Int? + extraPrepMinutes Int @default(0) + cutoffDaysBefore Int? + cutoffTime String? + surcharge Int @default(0) + waitlist Boolean @default(false) + message String? + + @@index([shopId]) +} + +/// One row per (location, method, date, slot). slot = "*" is the day total. +/// Counts confirmed bookings + live holds. Mutated only inside row-locked transactions. +model CapacityCounter { + shopId String + shop Shop @relation(fields: [shopId], references: [id], onDelete: Cascade) + locationId String + method String + date String + slot String + count Int @default(0) + + @@id([shopId, locationId, method, date, slot]) +} + +model Hold { + token String @id + shopId String + shop Shop @relation(fields: [shopId], references: [id], onDelete: Cascade) + locationId String + method String + date String + slot String + expiresAt DateTime + /// Fee quoted to the customer for this selection (minor units); re-checked against the paid order. + fee Int @default(0) + createdAt DateTime @default(now()) + + @@index([shopId, locationId, method, date]) + @@index([expiresAt]) +} + +model Booking { + id String @id @default(cuid()) + shopId String + shop Shop @relation(fields: [shopId], references: [id], onDelete: Cascade) + orderId String? + orderName String? + status String // confirmed | needs_review | cancelled | fulfilled + reviewReason String? + method String + locationId String? + date String? // local YYYY-MM-DD + slotStart String? // local HH:MM + slotEnd String? + timezone String + startUtc DateTime? + endUtc DateTime? + customerName String? + customerEmail String? + customerPhone String? + address Json? + items Json? + fee Int @default(0) + peakDayId String? + rescheduleCount Int @default(0) + configVersion Int + /// Snapshot of what the customer agreed to (method, location, slot, fee, rules matched) + snapshot Json + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + events BookingEvent[] + reminderJobs ReminderJob[] + + @@unique([shopId, orderId]) + @@index([shopId, date]) + @@index([shopId, status]) +} + +model BookingEvent { + id String @id @default(cuid()) + shopId String + shop Shop @relation(fields: [shopId], references: [id], onDelete: Cascade) + bookingId String + booking Booking @relation(fields: [bookingId], references: [id], onDelete: Cascade) + actor String // system | merchant: | customer + action String // confirmed | flagged | rescheduled | cancelled | resolved + before Json? + after Json? + at DateTime @default(now()) + + @@index([bookingId]) +} + +model WaitlistEntry { + id String @id @default(cuid()) + shopId String + shop Shop @relation(fields: [shopId], references: [id], onDelete: Cascade) + locationId String + method String + date String + slot String // "*" = any slot that day + email String? + phone String? + name String? + createdAt DateTime @default(now()) + offeredAt DateTime? + /// Hold token reserved for this person when a place is offered. + offerToken String? + + @@index([shopId, locationId, method, date]) +} + +model ReminderJob { + id String @id @default(cuid()) + shopId String + shop Shop @relation(fields: [shopId], references: [id], onDelete: Cascade) + bookingId String + booking Booking @relation(fields: [bookingId], references: [id], onDelete: Cascade) + policyId String + channel String + sendAt DateTime + body String + status String @default("pending") // pending | sent | failed | cancelled + attempts Int @default(0) + lastError String? + sentAt DateTime? + + @@index([status, sendAt]) +} + +/// Webhook idempotency: Shopify may deliver the same event more than once. +model ProcessedWebhook { + id String @id // X-Shopify-Event-Id (falls back to webhook id) + topic String + shop String + receivedAt DateTime @default(now()) +} diff --git a/prototype/build.mjs b/prototype/build.mjs new file mode 100644 index 0000000..e24d897 --- /dev/null +++ b/prototype/build.mjs @@ -0,0 +1,7 @@ +// Inlines the bundled engine into the prototype page. +import { readFileSync, writeFileSync } from "node:fs"; +const dir = new URL(".", import.meta.url); +const src = readFileSync(new URL("src.html", dir), "utf8"); +const engine = readFileSync(new URL("engine.js", dir), "utf8").replace(/<\/script/gi, "<\\/script"); +writeFileSync(new URL("datewise-prototype.html", dir), src.replace("/*ENGINE*/", () => engine)); +console.log("built prototype/datewise-prototype.html"); diff --git a/prototype/datewise-prototype.html b/prototype/datewise-prototype.html new file mode 100644 index 0000000..433b48e --- /dev/null +++ b/prototype/datewise-prototype.html @@ -0,0 +1,774 @@ +Datewise Prototype + + + + + +
+
+ + Datewiseprototype · sample data +
+
+ + +
+
+ Store time + + + +
+
+
+ + + diff --git a/prototype/engine.js b/prototype/engine.js new file mode 100644 index 0000000..4a84a71 --- /dev/null +++ b/prototype/engine.js @@ -0,0 +1,2 @@ +"use strict";var DatewiseEngine=(()=>{var K=Object.defineProperty;var Ue=Object.getOwnPropertyDescriptor;var $e=Object.getOwnPropertyNames;var Be=Object.prototype.hasOwnProperty;var Fe=(e,t,n)=>t in e?K(e,t,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[t]=n;var Ne=(e,t)=>{for(var n in t)K(e,n,{get:t[n],enumerable:!0})},We=(e,t,n,o)=>{if(t&&typeof t=="object"||typeof t=="function")for(let r of $e(t))!Be.call(e,r)&&r!==n&&K(e,r,{get:()=>t[r],enumerable:!(o=Ue(t,r))||o.enumerable});return e};var Ke=e=>We(K({},"__esModule",{value:!0}),e);var O=(e,t,n)=>Fe(e,typeof t!="symbol"?t+"":t,n);var Je={};Ne(Je,{METHODS:()=>H,MemoryReservationStore:()=>se,addDays:()=>I,addOperatingMinutes:()=>Oe,buildRunSheet:()=>Xe,canCustomerReschedule:()=>Ve,capsFor:()=>ze,checkFreshness:()=>G,computeAvailability:()=>V,diffDays:()=>Ye,evaluateRules:()=>F,firstAvailable:()=>ne,fromMinutes:()=>Y,haversineKm:()=>Z,hoursFor:()=>te,iso:()=>B,itemMatches:()=>Te,localParts:()=>L,mapsUrls:()=>Ee,matchZone:()=>J,normalizePostal:()=>q,peakFor:()=>j,planReminders:()=>Qe,postalMatches:()=>Me,quoteFee:()=>ee,rankLocations:()=>je,renderTemplate:()=>Le,slotId:()=>we,slotStartMs:()=>re,stockedAt:()=>oe,toCsv:()=>Ge,toMinutes:()=>D,tzOffsetMinutes:()=>X,usageKey:()=>z,validateSelection:()=>Ze,weekdayOf:()=>$,zonedToUtc:()=>v});var H=["pickup","delivery","shipping"];var De=new Map;function Ie(e){let t=De.get(e);return t||(t=new Intl.DateTimeFormat("en-US",{timeZone:e,hourCycle:"h23",year:"numeric",month:"2-digit",day:"2-digit",hour:"2-digit",minute:"2-digit",second:"2-digit",weekday:"short"}),De.set(e,t)),t}var He={Sun:0,Mon:1,Tue:2,Wed:3,Thu:4,Fri:5,Sat:6};function L(e,t){let n={};for(let r of Ie(t).formatToParts(new Date(e)))n[r.type]=r.value;let o=n.hour==="24"?"00":n.hour;return{date:`${n.year}-${n.month}-${n.day}`,time:`${o}:${n.minute}`,minutes:Number(o)*60+Number(n.minute),weekday:He[n.weekday]}}function X(e,t){let n={};for(let i of Ie(t).formatToParts(new Date(e)))n[i.type]=i.value;let o=n.hour==="24"?0:Number(n.hour),r=Date.UTC(+n.year,+n.month-1,+n.day,o,+n.minute,+n.second);return Math.round((r-Math.floor(e/1e3)*1e3)/6e4)}function v(e,t,n){let[o,r,i]=e.split("-").map(Number),[s,a]=t.split(":").map(Number),u=Date.UTC(o,r-1,i,s,a),d=X(u,n),l=u-d*6e4,c=X(l,n);if(c!==d){let g=u-c*6e4;L(g,n).time===t?l=Math.min(l,g):L(l,n).time!==t&&(l=Math.max(l,g))}return l}function D(e){let[t,n]=e.split(":").map(Number);return t*60+n}function Y(e){let t=Math.floor(e/60),n=e%60;return`${String(t).padStart(2,"0")}:${String(n).padStart(2,"0")}`}function I(e,t){let[n,o,r]=e.split("-").map(Number);return new Date(Date.UTC(n,o-1,r+t)).toISOString().slice(0,10)}function Ye(e,t){let[n,o,r]=e.split("-").map(Number),[i,s,a]=t.split("-").map(Number);return Math.round((Date.UTC(n,o-1,r)-Date.UTC(i,s-1,a))/864e5)}function $(e){let[t,n,o]=e.split("-").map(Number);return new Date(Date.UTC(t,n-1,o)).getUTCDay()}function B(e){return new Date(e).toISOString()}function Te(e,t){var r,i;let n=t.target,o=n.values.map(s=>String(s).toLowerCase());switch(n.type){case"product":return o.includes(String(e.productId).toLowerCase());case"variant":return o.includes(String(e.variantId).toLowerCase());case"collection":return((r=e.collectionIds)!=null?r:[]).some(s=>o.includes(String(s).toLowerCase()));case"vendor":return!!e.vendor&&o.includes(e.vendor.toLowerCase());case"productType":return!!e.productType&&o.includes(e.productType.toLowerCase());case"tag":return((i=e.tags)!=null?i:[]).some(s=>o.includes(s.toLowerCase()))}}function E(e,t,n){return{code:"BLOCKED_BY_PRODUCT",message:e.message||n.replace("{item}",t.title),ruleId:e.id}}function F(e,t,n="longest"){var a,u,d,l;let o={allowedMethods:new Set(H),methodReasons:new Map,allowedLocations:null,locationReasons:[],extraPrepMinutes:0,blockedWeekdays:new Map,blockedDates:new Map,windows:[],freshness:[],requiresScheduling:!1,matched:[]},r=t.filter(c=>c.enabled).sort((c,g)=>c.priority-g.priority),i=[],s=0;for(let c of e.items){let g=0,y;for(let m of r){if(!Te(c,m))continue;o.matched.push({ruleId:m.id,ruleName:m.name,itemTitle:c.title});let f=m.action;switch(f.type){case"allowOnlyMethods":for(let h of H)!f.methods.includes(h)&&o.allowedMethods.has(h)&&(o.allowedMethods.delete(h),ve(o.methodReasons,h,{...E(m,c,`{item} is only available for ${f.methods.join(" or ")}`),code:"METHOD_PROHIBITED_BY_PRODUCT"}));break;case"prohibitMethods":for(let h of f.methods)i.push({m:h,reason:{...E(m,c,`{item} can't be sent by ${h}`),code:"METHOD_PROHIBITED_BY_PRODUCT"}});break;case"restrictLocations":{let h=new Set(f.locationIds);o.allowedLocations=o.allowedLocations?new Set([...o.allowedLocations].filter(C=>h.has(C))):h,o.locationReasons.push({...E(m,c,"{item} is only available from selected locations"),code:"LOCATION_PROHIBITED_BY_PRODUCT"});break}case"addPrep":g+=f.minutes,y=m.id;break;case"blockWeekdays":for(let h of f.weekdays)o.blockedWeekdays.has(h)||o.blockedWeekdays.set(h,E(m,c,"{item} isn't available on this day"));break;case"blockDates":for(let h of f.dates)o.blockedDates.has(h)||o.blockedDates.set(h,E(m,c,"{item} isn't available on this date"));break;case"dateWindow":o.windows.push({from:f.from,to:f.to,reason:E(m,c,"{item} is only available in a limited window")});break;case"freshness":o.freshness.push({ruleId:m.id,itemTitle:c.title,arrivals:f.arrivals,minDaysAfterArrival:f.minDaysAfterArrival,shelfLifeDays:f.shelfLifeDays,message:m.message});break;case"requireScheduling":o.requiresScheduling=!0;break}}n==="sum"&&(s+=g),g>((u=(a=o.prepDriver)==null?void 0:a.minutes)!=null?u:0)&&y&&(o.prepDriver={itemTitle:c.title,ruleId:y,minutes:g})}for(let{m:c,reason:g}of i)o.allowedMethods.delete(c),ve(o.methodReasons,c,g);return o.extraPrepMinutes=n==="sum"?s:(l=(d=o.prepDriver)==null?void 0:d.minutes)!=null?l:0,o}function ve(e,t,n){var r;let o=(r=e.get(t))!=null?r:[];o.some(i=>i.ruleId===n.ruleId&&i.message===n.message)||o.push(n),e.set(t,o)}function G(e,t,n){for(let o of e){let r=o.arrivals.filter(s=>{var a;return!((a=s.locationIds)!=null&&a.length)||s.locationIds.includes(n)});if(!r.length)return{code:"NO_STOCK_ARRIVAL",message:o.message||`${o.itemTitle}: no fresh stock is scheduled for this location yet`,ruleId:o.ruleId};if(!r.some(s=>t>=I(s.date,o.minDaysAfterArrival)&&t<=I(s.date,o.shelfLifeDays))){let s=r.map(a=>I(a.date,o.minDaysAfterArrival)).filter(a=>a>t).sort()[0];return s?{code:"NOT_FRESH_YET",message:o.message||`${o.itemTitle}: next fresh batch is ready from ${s}`,ruleId:o.ruleId}:{code:"PAST_FRESHNESS",message:o.message||`${o.itemTitle}: outside its freshness window on this date`,ruleId:o.ruleId}}}return null}function q(e){return(e||"").toUpperCase().replace(/[\s-]/g,"")}function Me(e,t){let n=q(e),o=t.trim().toUpperCase();if(!n||!o)return!1;if(o.includes("..")){let[i,s]=o.split("..").map(q);if(!i||i.length!==s.length)return!1;let a=n.slice(0,i.length);return a>=i&&a<=s}let r=q(o);return r.endsWith("*")?n.startsWith(r.slice(0,-1)):n===r||r.length===3&&n.startsWith(r)}function Z(e,t,n,o){let i=d=>d*Math.PI/180,s=i(n-e),a=i(o-t),u=Math.sin(s/2)**2+Math.cos(i(e))*Math.cos(i(n))*Math.sin(a/2)**2;return 2*6371*Math.asin(Math.sqrt(u))}function J(e,t){var r,i;let n=e.lat!=null&&e.lng!=null&&t.lat!=null&&t.lng!=null?Z(e.lat,e.lng,t.lat,t.lng):null,o=!1;for(let s of e.zones){if((r=s.postalPatterns)!=null&&r.length&&t.postal&&s.postalPatterns.some(a=>Me(t.postal,a)))return{zone:s,distanceKm:n};if(s.radiusKm!=null&&n!=null&&n<=s.radiusKm)return{zone:s,distanceKm:n};if(s.drivingKm!=null){let a=(i=t.drivingKm)==null?void 0:i[e.id];if(a==null)o=!0;else if(a<=s.drivingKm)return{zone:s,distanceKm:a}}}return{zone:null,distanceKm:n,unverified:o,reason:{code:"OUTSIDE_DELIVERY_AREA",message:o?"We couldn't verify your address right now. Please try pickup or check again shortly.":`${e.name} doesn't deliver to this address yet`}}}function ee(e,t,n=[]){var a,u;let o=(a=e==null?void 0:e.fee)!=null?a:0,r=(e==null?void 0:e.freeOver)!=null&&t>=e.freeOver,i=n.filter(d=>d.amount>0),s=(u=e==null?void 0:e.minOrder)!=null?u:null;return{baseFee:o,waived:r,surcharges:i,fee:(r?0:o)+i.reduce((d,l)=>d+l.amount,0),belowMinimum:s!=null&&t`${e}|${t}|${n}`,we=(e,t)=>`${e}-${t}`;function te(e,t,n){var i,s;let o=e.methods[t],r=e.exceptions.find(a=>{var u;return a.date===n&&(!((u=a.methods)!=null&&u.length)||a.methods.includes(t))});return r!=null&&r.closed?{hours:[],exception:{code:"CLOSED_EXCEPTION",message:r.note||"Closed on this date"}}:(i=r==null?void 0:r.hours)!=null&&i.length?{hours:r.hours,special:!0}:{hours:(s=o==null?void 0:o.weeklyHours[$(n)])!=null?s:[]}}function j(e,t,n,o){var r;return(r=e.find(i=>{var s,a;return i.dates.includes(o)&&(!((s=i.locationIds)!=null&&s.length)||i.locationIds.includes(t))&&(!((a=i.methods)!=null&&a.length)||i.methods.includes(n))}))!=null?r:null}function qe(e,t,n,o){var s,a;if(!n.length)return[];if(e.slotMode==="dateOnly")return[{start:n[0].start,end:n[n.length-1].end}];if(e.slotMode==="explicit"&&!o)return((a=(s=e.slots)==null?void 0:s[$(t)])!=null?a:[]).filter(d=>n.some(l=>D(d.start)>=D(l.start)&&D(d.end)<=D(l.end)));let r=e.slotMinutes||60,i=[];for(let u of n)for(let d=D(u.start);d+r<=D(u.end);d+=r)i.push({start:Y(d),end:Y(d+r)});return i}function Oe(e,t,n,o){if(t<=0)return e;let r=t*6e4,i=L(e,n.timezone).date;for(let s=0;s<400;s++,i=I(i,1))for(let a of te(n,o,i).hours){let u=v(i,a.start,n.timezone),d=v(i,a.end,n.timezone);if(d<=e)continue;let l=Math.max(u,e),c=d-l;if(r<=c)return l+r;r-=c}return Number.POSITIVE_INFINITY}function V(e){var f,h,C,M,w,P,N,_,ie,ae,le,de,ue,ce;let{location:t,method:n,now:o,usage:r}=e,i=t.timezone,s=t.methods[n],a=(f=e.constraints)!=null?f:F(e.cart,e.rules,e.settings.mixedPrepPolicy),u=[];t.enabled||u.push({code:"LOCATION_DISABLED",message:`${t.name} isn't taking bookings right now`}),s!=null&&s.enabled||u.push({code:"METHOD_DISABLED",message:`${t.name} doesn't offer ${n}`}),a.allowedMethods.has(n)||u.push(...(h=a.methodReasons.get(n))!=null?h:[]),a.allowedLocations&&!a.allowedLocations.has(t.id)&&u.push(...a.locationReasons);let d=((C=s==null?void 0:s.prepMinutes)!=null?C:0)+a.extraPrepMinutes,l=new Map,c=x=>(l.has(x)||l.set(x,(s==null?void 0:s.prepMode)==="operating"?Oe(o,x,t,n):o+x*6e4),l.get(x)),g={locationId:t.id,method:n,timezone:i,dateOnly:(s==null?void 0:s.slotMode)==="dateOnly",prepMinutes:d,earliestReadyUtc:B(c(d)),blockers:u,dates:[]};if(u.length||!s)return g;let y=L(o,i),m=Math.min(s.horizonDays,(M=e.maxDays)!=null?M:s.horizonDays);for(let x=0;xb.to)&&k.reasons.push(b.reason);let he=G(a.freshness,R,t.id);if(he&&k.reasons.push(he),k.reasons.length)continue;let{hours:pe,exception:ye,special:Pe}=te(t,n,R);if(ye){k.reasons.push(ye);continue}if(!pe.length){k.reasons.push({code:"CLOSED_WEEKLY",message:"Closed on this day"});continue}if(x===0&&s.sameDayCutoff&&y.minutes>=D(s.sameDayCutoff)){k.reasons.push({code:"PAST_CUTOFF",message:`Same-day orders close at ${s.sameDayCutoff}`});continue}if(x===1&&s.nextDayCutoff&&y.minutes>=D(s.nextDayCutoff)){k.reasons.push({code:"PAST_CUTOFF",message:`Next-day orders close at ${s.nextDayCutoff}`});continue}if(p!=null&&p.cutoff){let b=v(I(R,-p.cutoff.daysBefore),p.cutoff.time,i);if(o>=b){k.reasons.push({code:"PEAK_CUTOFF",message:`${p.name} orders closed on ${I(R,-p.cutoff.daysBefore)} at ${p.cutoff.time}`});continue}}let be=c(d+((P=p==null?void 0:p.extraPrepMinutes)!=null?P:0)),_e=z(t.id,n,R),ke=(N=r[_e])!=null?N:{day:0,slots:{}},Re=(ie=(_=p==null?void 0:p.dailyCapacity)!=null?_:s.dailyCapacity)!=null?ie:null,A=Re==null?null:Math.max(0,Re-ke.day);k.remaining=A;let Ce=qe(s,R,pe,!!Pe);if(!Ce.length){k.reasons.push({code:"CLOSED_WEEKLY",message:"No time slots on this day"});continue}for(let b of Ce){let U=v(R,b.start,i),T=v(R,b.end,i),Se=we(b.start,b.end),S={id:Se,start:b.start,end:b.end,startUtc:B(U),endUtc:B(T),available:!1,remaining:null,surcharge:(ae=b.surcharge)!=null?ae:0,waitlist:!1,reasons:[]};if(k.slots.push(S),g.dateOnly?T<=o?S.reasons.push({code:"SLOT_IN_PAST",message:"Closed for today"}):be>T&&S.reasons.push({code:"PREP_TIME",message:"Not enough preparation time"}):U<=o?S.reasons.push({code:"SLOT_IN_PAST",message:"This time has passed"}):be>U&&S.reasons.push({code:"PREP_TIME",message:"Not enough preparation time"}),S.reasons.length)continue;let xe=(ue=(de=(le=p==null?void 0:p.slotCapacity)!=null?le:b.capacity)!=null?de:s.slotCapacity)!=null?ue:null,Ae=(ce=ke.slots[Se])!=null?ce:0,W=xe==null?null:Math.max(0,xe-Ae);if(S.remaining=W==null?A:A==null?W:Math.min(W,A),A===0?S.reasons.push({code:"DAY_FULL",message:"Fully booked for this day"}):W===0&&S.reasons.push({code:"SLOT_FULL",message:"This time slot is full"}),S.reasons.length){S.waitlist=!!(p!=null&&p.waitlist);continue}S.available=!0}if(k.available=k.slots.some(b=>b.available),k.waitlist=!k.available&&k.slots.some(b=>b.waitlist),!k.available){let b=new Set;for(let U of k.slots)for(let T of U.reasons)b.has(T.code)||(b.add(T.code),k.reasons.push(T))}}return g}function ne(e){for(let t of e.dates){let n=t.slots.find(o=>o.available);if(n)return{date:t.date,slot:n}}return null}function Ze(e,t,n){let o=V(e);if(o.blockers.length)return{ok:!1,reasons:o.blockers};let r=o.dates.find(s=>s.date===t);if(!r)return{ok:!1,reasons:[{code:"BEYOND_HORIZON",message:"This date can't be booked"}]};let i=r.slots.find(s=>s.id===n);return i?i.available?{ok:!0,slot:i,date:r}:{ok:!1,reasons:i.reasons.length?i.reasons:r.reasons}:{ok:!1,reasons:r.reasons.length?r.reasons:[{code:"CLOSED_WEEKLY",message:"Slot not offered"}]}}function ze(e,t,n,o,r){var u,d,l,c,g,y;let i=e.methods[t],s=j(r,e.id,t,n),a;if((i==null?void 0:i.slotMode)==="explicit"){let[m,f]=o.split("-");for(let h of Object.values((u=i.slots)!=null?u:{}))for(let C of h!=null?h:[])C.start===m&&C.end===f&&C.capacity!=null&&(a=C.capacity)}return{dayCap:(l=(d=s==null?void 0:s.dailyCapacity)!=null?d:i==null?void 0:i.dailyCapacity)!=null?l:null,slotCap:(y=(g=(c=s==null?void 0:s.slotCapacity)!=null?c:a)!=null?g:i==null?void 0:i.slotCapacity)!=null?y:null}}var se=class{constructor(){O(this,"holds",new Map);O(this,"bookings",new Map);O(this,"waitlist",[]);O(this,"audit",[]);O(this,"seq",0)}counts(t,n,o,r){let i=0,s=0,a=u=>u.locationId===t.locationId&&u.method===t.method&&u.date===t.date;for(let u of this.bookings.values()){if(u.id===r||u.status!=="confirmed")continue;let d={locationId:u.locationId,method:u.method,date:u.date,slot:`${u.slotStart}-${u.slotEnd}`};a(d)&&(i++,d.slot===t.slot&&s++)}for(let u of this.holds.values())u.token===o||u.expiresAt<=n||a(u)&&(i++,u.slot===t.slot&&s++);return{day:i,slot:s}}usage(t){let n={},o=r=>{var s,a;let i=z(r.locationId,r.method,r.date);(s=n[i])!=null||(n[i]={day:0,slots:{}}),n[i].day++,n[i].slots[r.slot]=((a=n[i].slots[r.slot])!=null?a:0)+1};for(let r of this.bookings.values())r.status==="confirmed"&&o({locationId:r.locationId,method:r.method,date:r.date,slot:`${r.slotStart}-${r.slotEnd}`});for(let r of this.holds.values())r.expiresAt>t&&o(r);return n}fits(t,n,o,r,i){let s=this.counts(t,o,r,i);return n.dayCap!=null&&s.day>=n.dayCap?"DAY_FULL":n.slotCap!=null&&s.slot>=n.slotCap?"SLOT_FULL":null}hold(t,n,o,r,i){let s=this.fits(n,o,r,t);if(s)return{ok:!1,code:s,message:s==="DAY_FULL"?"Fully booked for this day":"This time slot was just taken"};let a={...n,token:t,expiresAt:r+i*6e4};return this.holds.set(t,a),{ok:!0,hold:a}}release(t){this.holds.delete(t)}sweep(t){let n=0;for(let[o,r]of this.holds)r.expiresAt<=t&&(this.holds.delete(o),n++);return n}confirm(t,n,o,r,i,s){let a=[...this.bookings.values()].find(m=>m.orderId===t);if(a)return{ok:!0,booking:a,duplicate:!0};let u=n?this.holds.get(n):void 0,d=u!=null?u:o,l=`bk_${++this.seq}`,c=m=>({...s,id:l,orderId:t,status:m,method:d.method,locationId:d.locationId,date:d.date,slotStart:d.slot.split("-")[0],slotEnd:d.slot.split("-")[1],rescheduleCount:0});if(!d){let m={...s,id:l,orderId:t,status:"needs_review",method:"pickup",locationId:"",date:""};return this.bookings.set(l,m),{ok:!1,booking:m,code:"NEEDS_REVIEW",message:"Order has no scheduling data"}}let g=u&&u.expiresAt>i;if(!g){let m=this.fits(d,r,i,n!=null?n:void 0);if(m){let f=c("needs_review");return this.bookings.set(l,f),n&&this.holds.delete(n),this.log(i,"system",l,"flagged",void 0,{reason:m}),{ok:!1,booking:f,code:"NEEDS_REVIEW",message:"Hold expired and the slot filled up"}}}let y=c("confirmed");return this.bookings.set(l,y),n&&this.holds.delete(n),this.log(i,"system",l,"confirmed"),{ok:!0,booking:y,duplicate:!1,reReserved:!g}}reschedule(t,n,o,r,i){var g;let s=this.bookings.get(t);if(!s||s.status!=="confirmed")return{ok:!1,message:"Booking can't be rescheduled"};let a=this.fits(n,o,r,void 0,t);if(a)return{ok:!1,message:a==="DAY_FULL"?"That day is fully booked":"That slot is full"};let u={...s},d={locationId:s.locationId,method:s.method,date:s.date,slot:`${s.slotStart}-${s.slotEnd}`},[l,c]=n.slot.split("-");return Object.assign(s,{locationId:n.locationId,method:n.method,date:n.date,slotStart:l,slotEnd:c,rescheduleCount:((g=s.rescheduleCount)!=null?g:0)+1}),this.log(r,i,t,"rescheduled",Q(u),Q(s)),{ok:!0,booking:s,freed:d}}cancel(t,n,o=!0){let r=[...this.bookings.values()].find(s=>s.orderId===t);if(!r||r.status==="cancelled")return r!=null?r:null;let i={...r};return r.status=o?"cancelled":r.status,this.log(n,"system",r.id,"cancelled",Q(i),Q(r)),r}joinWaitlist(t,n,o){let r={...t,id:`wl_${++this.seq}`,contact:n,createdAt:o};return this.waitlist.push(r),r}nextWaitlisted(t){var n;return(n=this.waitlist.find(o=>!o.notifiedAt&&o.locationId===t.locationId&&o.method===t.method&&o.date===t.date&&(o.slot===t.slot||o.slot==="*")))!=null?n:null}log(t,n,o,r,i,s){this.audit.push({at:t,actor:n,bookingId:o,action:r,before:i,after:s})}};function Q(e){return{locationId:e.locationId,method:e.method,date:e.date,slotStart:e.slotStart,slotEnd:e.slotEnd,status:e.status}}function oe(e,t,n){if(!e||!e[t])return!0;let o=e[t];return n.items.every(r=>{var i;return((i=o[r.variantId])!=null?i:0)>=r.quantity})}function je(e){var s,a,u;let t=F(e.cart,e.rules,e.settings.mixedPrepPolicy),n=(s=e.cart.subtotal)!=null?s:e.cart.items.reduce((d,l)=>d+l.price*l.quantity,0),o=[];for(let d of e.locations){let l={location:d,eligible:!0,reasons:[],distanceKm:null,first:null};if(o.push(l),e.method==="delivery"){let y=J(d,(a=e.address)!=null?a:{});l.zone=y,l.distanceKm=y.distanceKm,y.zone?(l.fee=ee(y.zone,n),l.fee.belowMinimum&&(l.eligible=!1,l.reasons.push({code:"BELOW_MINIMUM_ORDER",message:`Delivery needs a minimum order of $${(l.fee.minOrder/100).toFixed(2)}`}))):(l.eligible=!1,l.reasons.push(y.reason))}e.method!=="shipping"&&!oe(e.inventory,d.id,e.cart)&&(l.eligible=!1,l.reasons.push({code:"OUT_OF_STOCK_AT_LOCATION",message:`Not all items are in stock at ${d.name}`}));let c=V({...e,location:d,constraints:t});l.availability=c,c.blockers.length&&(l.eligible=!1,l.reasons.push(...c.blockers));let g=ne(c);l.first=g?{date:g.date,slot:g.slot.id}:null,l.eligible&&!g&&(l.eligible=!1,l.reasons.push({code:"DAY_FULL",message:"No available dates in the booking window"}))}let r=(u=e.ranking)!=null?u:"priority";o.sort((d,l)=>{if(d.eligible!==l.eligible)return d.eligible?-1:1;if(r==="distance"&&d.distanceKm!=null&&l.distanceKm!=null&&d.distanceKm!==l.distanceKm)return d.distanceKm-l.distanceKm;if(r==="earliest"&&d.first&&l.first){let c=`${d.first.date} ${d.first.slot}`,g=`${l.first.date} ${l.first.slot}`;if(c!==g)return cd.eligible)){let d=e.cart.items.every(c=>e.locations.reduce((g,y)=>{var m,f;return g+((f=(m=e.inventory[y.id])==null?void 0:m[c.variantId])!=null?f:0)},0)>=c.quantity),l=e.locations.some(c=>oe(e.inventory,c.id,e.cart));d&&!l&&(i={code:"OUT_OF_STOCK_AT_LOCATION",message:"Your items are stocked at different locations. Choose shipping, or remove some items to schedule a single pickup or delivery."})}return{ranked:o,splitRequired:i}}function re(e){var t;return v(e.date,(t=e.slotStart)!=null?t:"00:00",e.timezone)}function Ve(e,t,n){var r;if(!t.enabled)return{ok:!1,reason:"Rescheduling isn't available for this store. Please contact us."};if(e.status!=="confirmed")return{ok:!1,reason:"This booking can't be changed."};if(((r=e.rescheduleCount)!=null?r:0)>=t.maxChanges)return{ok:!1,reason:`You've already changed this booking ${t.maxChanges} time(s). Please contact us.`};let o=re(e)-t.minHoursBefore*36e5;return n>=o?{ok:!1,reason:`Changes are allowed up to ${t.minHoursBefore} hours before your time slot.`}:{ok:!0,deadline:o}}function Qe(e,t,n,o={}){let r=re(e);return t.filter(i=>{var s;return i.enabled&&(!((s=i.methods)!=null&&s.length)||i.methods.includes(e.method))}).map(i=>{var s,a,u;return{policyId:i.id,channel:i.channel,sendAt:r-i.minutesBefore*6e4,body:Le(i.template,{name:(a=(s=e.customer)==null?void 0:s.name)!=null?a:"there",order:(u=e.orderName)!=null?u:"",date:e.date,time:e.slotStart&&e.slotEnd?`${e.slotStart}\u2013${e.slotEnd}`:"",method:e.method,...o})}}).filter(i=>i.sendAt>n).filter(i=>{var s,a;return i.channel==="email"?!!((s=e.customer)!=null&&s.email):!!((a=e.customer)!=null&&a.phone)})}function Le(e,t){return e.replace(/\{\{\s*(\w+)\s*\}\}/g,(n,o)=>{var r;return(r=t[o])!=null?r:""})}function Xe(e,t,n){var c,g,y;let o=e.filter(m=>m.method==="delivery"&&m.locationId===t.id&&m.date===n&&m.status==="confirmed"),r=o.filter(m=>{var f,h;return((f=m.address)==null?void 0:f.lat)!=null&&((h=m.address)==null?void 0:h.lng)!=null}),i=o.filter(m=>!r.includes(m)),s=new Map;for(let m of r){let f=`${m.slotStart}-${m.slotEnd}`;s.set(f,[...(c=s.get(f))!=null?c:[],m])}let a=[],u={lat:(g=t.lat)!=null?g:0,lng:(y=t.lng)!=null?y:0},d=0,l=t.lat!=null&&t.lng!=null;for(let m of[...s.keys()].sort()){let f=[...s.get(m)];for(;f.length;){let h=0,C=1/0;f.forEach((P,N)=>{let _=Z(u.lat,u.lng,P.address.lat,P.address.lng);_`${s.address.lat},${s.address.lng}`,o=e.lat!=null?`${e.lat},${e.lng}`:encodeURIComponent(e.address),r=[],i=o;for(let s=0;sn(l.booking)).join("|");r.push(`https://www.google.com/maps/dir/?api=1&origin=${i}&destination=${u}`+(d?`&waypoints=${encodeURIComponent(d)}`:"")+"&travelmode=driving"),i=u}return r}function Ge(e,t){var r;let n=t!=null?t:Object.keys((r=e[0])!=null?r:{}),o=i=>{let s=i==null?"":String(i);return/[",\r\n]/.test(s)?`"${s.replace(/"/g,'""')}"`:s};return"\uFEFF"+[n.join(","),...e.map(i=>n.map(s=>o(i[s])).join(","))].join(`\r +`)}return Ke(Je);})(); diff --git a/prototype/src.html b/prototype/src.html new file mode 100644 index 0000000..9e3ced7 --- /dev/null +++ b/prototype/src.html @@ -0,0 +1,772 @@ +Datewise Prototype + + + + + +
+
+ + Datewiseprototype · sample data +
+
+ + +
+
+ Store time + + + +
+
+
+ + + diff --git a/react-router.config.ts b/react-router.config.ts new file mode 100644 index 0000000..e45e273 --- /dev/null +++ b/react-router.config.ts @@ -0,0 +1,5 @@ +import type { Config } from "@react-router/dev/config"; + +export default { + ssr: true, +} satisfies Config; diff --git a/shopify.app.toml b/shopify.app.toml new file mode 100644 index 0000000..fa7107d --- /dev/null +++ b/shopify.app.toml @@ -0,0 +1,52 @@ +# Learn more: https://shopify.dev/docs/apps/tools/cli/configuration +# Run `shopify app config link` to fill client_id and URLs from your Partner app. + +client_id = "REPLACE_WITH_CLIENT_ID" +name = "Datewise: Delivery & Pickup" +handle = "datewise" +application_url = "https://example.com" +embedded = true + +[build] +automatically_update_urls_on_dev = true +include_config_on_deploy = true + +[access_scopes] +# Each scope is used by a shipped feature — see README "Permissions". +scopes = "read_products,write_products,write_publications,read_orders,write_orders,read_customers,read_locations,read_inventory,write_validations" + +[auth] +redirect_urls = ["https://example.com/auth/callback"] + +[webhooks] +api_version = "2026-01" + + [[webhooks.subscriptions]] + topics = ["app/uninstalled", "app/scopes_update"] + uri = "/webhooks/app" + + [[webhooks.subscriptions]] + topics = ["orders/create"] + uri = "/webhooks/orders/create" + + [[webhooks.subscriptions]] + topics = ["orders/cancelled"] + uri = "/webhooks/orders/cancelled" + + [[webhooks.subscriptions]] + topics = ["orders/fulfilled"] + uri = "/webhooks/orders/fulfilled" + + [[webhooks.subscriptions]] + compliance_topics = ["customers/data_request", "customers/redact", "shop/redact"] + uri = "/webhooks/compliance" + +# Storefront widget talks to the app through the shop's own domain: +# https://{shop}/apps/datewise/availability → {application_url}/proxy/availability +[app_proxy] +url = "https://example.com/proxy" +subpath = "datewise" +prefix = "apps" + +[pos] +embedded = false diff --git a/shopify.web.toml b/shopify.web.toml new file mode 100644 index 0000000..be901b4 --- /dev/null +++ b/shopify.web.toml @@ -0,0 +1,7 @@ +name = "react-router" +roles = ["frontend", "backend"] +webhooks_path = "/webhooks/app" + +[commands] +predev = "npx prisma generate" +dev = "npx prisma migrate deploy && npx react-router dev" diff --git a/tests/e2e/server.ts b/tests/e2e/server.ts new file mode 100644 index 0000000..af7daf8 --- /dev/null +++ b/tests/e2e/server.ts @@ -0,0 +1,182 @@ +/** + * End-to-end harness: a fake theme cart page + Ajax Cart API, wired to the REAL + * storefront service (engine + Postgres reservations). Only Shopify's Admin API is mocked. + * + * DATABASE_URL=postgresql://… npx tsx tests/e2e/server.ts → http://localhost:4545/cart + */ +import { createServer } from "node:http"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import prisma from "../../app/db.server"; +import { getAvailability, holdSelection, joinWaitlist, releaseSelection } from "../../app/services/storefront.server"; +import { withDefaults } from "../../app/services/settings"; + +const SHOP = "e2e-demo.myshopify.com"; +const PORT = Number(process.env.PORT ?? 4545); +const ext = join(import.meta.dirname, "../../extensions/datewise-widget"); + +// ---------- mock catalog + Admin API ---------- +const CATALOG: Record = { + "101": { title: "Fresh Marigold Garland", price: 1999, type: "Fresh Flowers", tags: ["perishable"], vendor: "C2C Agricorp" }, + "102": { title: "பாக்கு (Pākkŭ) 200 g", price: 699, type: "Puja", tags: [], vendor: "Clicks to Cart" }, +}; +const feeVariants: { id: string; price: string }[] = []; +const admin = { + async graphql(q: string, o?: { variables?: Record }) { + const v = o?.variables ?? {}; + let data: unknown = {}; + if (q.includes("DatewiseCart")) + data = { + nodes: (v.ids as string[]).map((gid) => { + const id = gid.split("/").pop()!; + const fee = feeVariants.find((f) => f.id.endsWith(`/${id}`)); + if (fee) return { id: gid, title: fee.price, price: fee.price, product: { id: "gid://shopify/Product/900", title: "Delivery & handling", vendor: "", productType: "Service", tags: ["datewise-fee"], collections: { nodes: [] } }, inventoryItem: null }; + const p = CATALOG[id]; + if (!p) return null; + return { + id: gid, title: "Default Title", price: (p.price / 100).toFixed(2), + product: { id: `gid://shopify/Product/${id}`, title: p.title, vendor: p.vendor, productType: p.type, tags: p.tags, collections: { nodes: [] } }, + inventoryItem: { tracked: false, inventoryLevels: { nodes: [] } }, + }; + }), + }; + else if (q.includes("productCreate")) data = { productCreate: { product: { id: "gid://shopify/Product/900" }, userErrors: [] } }; + else if (q.includes("variants(first: 250)")) data = { product: { variants: { nodes: feeVariants } } }; + else if (q.includes("product(id")) data = { product: { id: "gid://shopify/Product/900" } }; + else if (q.includes("productVariantsBulkCreate")) { + const price = ((v.variants as { price: string }[])[0]).price; + const n = { id: `gid://shopify/ProductVariant/${9000 + feeVariants.length}`, price }; + feeVariants.push(n); + data = { productVariantsBulkCreate: { productVariants: [n], userErrors: [] } }; + } else if (q.includes("publications")) data = { publications: { nodes: [{ id: "gid://shopify/Publication/1", catalog: { title: "Online Store" } }] } }; + else data = { metafieldsSet: { userErrors: [] }, publishablePublish: { userErrors: [] } }; + return new Response(JSON.stringify({ data })); + }, +}; + +// ---------- seed ---------- +async function seed() { + await prisma.shop.deleteMany({ where: { domain: SHOP } }); + const h = (a: string, b: string) => [{ start: a, end: b }]; + const all = (a: string, b: string) => Object.fromEntries([0, 1, 2, 3, 4, 5, 6].map((d) => [d, h(a, b)])); + const slots = [{ start: "10:00", end: "13:00" }, { start: "13:00", end: "16:00" }, { start: "16:00", end: "19:00", surcharge: 300 }]; + await prisma.shop.create({ + data: { + domain: SHOP, + timezone: "America/Toronto", + plan: "Pro", + settings: withDefaults({ holdMinutes: 10 }) as object, + locations: { + create: [{ + name: "Kitchener — Guelph St", + address: "900 Guelph St, Kitchener, ON", + timezone: "America/Toronto", + lat: 43.4643, lng: -80.4844, enabled: true, priority: 1, + methods: { + delivery: { enabled: true, weeklyHours: all("10:00", "19:00"), slotMode: "explicit", slots: Object.fromEntries([0, 1, 2, 3, 4, 5, 6].map((d) => [d, slots])), prepMinutes: 120, prepMode: "elapsed", sameDayCutoff: "12:00", horizonDays: 21, slotCapacity: 2, dailyCapacity: 6 }, + pickup: { enabled: true, weeklyHours: all("10:00", "18:00"), slotMode: "generated", slotMinutes: 120, prepMinutes: 60, prepMode: "elapsed", sameDayCutoff: "14:00", horizonDays: 21, slotCapacity: 3 }, + shipping: { enabled: true, weeklyHours: all("10:00", "17:00"), slotMode: "dateOnly", prepMinutes: 0, prepMode: "operating", sameDayCutoff: "13:00", horizonDays: 14 }, + }, + zones: [{ id: "kw", name: "Kitchener–Waterloo", postalPatterns: ["N2H*", "N2G*"], fee: 799, freeOver: 7500, minOrder: 1000 }], + }], + }, + rules: { + create: [ + { name: "Fresh flowers can't ship", target: { type: "productType", values: ["Fresh Flowers"] }, action: { type: "prohibitMethods", methods: ["shipping"] }, message: "Fresh flowers are pickup or local delivery only", priority: 1 }, + { name: "Garland stringing", target: { type: "tag", values: ["perishable"] }, action: { type: "addPrep", minutes: 240 }, priority: 2 }, + ], + }, + }, + }); +} + +// ---------- fake theme + Ajax Cart API ---------- +let cart = { token: "c1", items: [] as { key: string; variant_id: number; quantity: number; final_price: number; product_title: string; properties: Record }[], attributes: {} as Record }; +const addLine = (variant: number, qty: number, properties: Record = {}) => { + const p = CATALOG[String(variant)]; + const fee = feeVariants.find((f) => f.id.endsWith(`/${variant}`)); + cart.items.push({ key: `${variant}:${Date.now()}${Math.random()}`, variant_id: variant, quantity: qty, final_price: p ? p.price : Math.round(Number(fee?.price ?? 0) * 100), product_title: p?.title ?? "Delivery & handling", properties }); +}; +const cartJson = () => ({ ...cart, item_count: cart.items.reduce((s, i) => s + i.quantity, 0), total_price: cart.items.reduce((s, i) => s + i.final_price * i.quantity, 0) }); + +const page = () => ` +Demo cart +

Your cart

Total:

+
+

+ +`; + +const body = (req: import("node:http").IncomingMessage) => + new Promise((r) => { + let s = ""; + req.on("data", (c) => (s += c)); + req.on("end", () => r(s ? JSON.parse(s) : {})); + }); + +createServer(async (req, res) => { + const url = new URL(req.url!, `http://localhost:${PORT}`); + const json = (o: unknown, status = 200) => { + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(JSON.stringify(o)); + }; + try { + if (url.pathname === "/" || url.pathname === "/cart") { + res.writeHead(200, { "Content-Type": "text/html" }); + return res.end(page()); + } + if (url.pathname.startsWith("/assets/")) { + const f = url.pathname.slice(8); + res.writeHead(200, { "Content-Type": f.endsWith(".css") ? "text/css" : "text/javascript" }); + return res.end(readFileSync(join(ext, "assets", f))); + } + if (url.pathname === "/cart.js") return json(cartJson()); + if (url.pathname === "/cart/update.js") { + const b = (await body(req)) as { attributes?: Record }; + for (const [k, v] of Object.entries(b.attributes ?? {})) v === "" ? delete cart.attributes[k] : (cart.attributes[k] = v); + return json(cartJson()); + } + if (url.pathname === "/cart/add.js") { + const b = (await body(req)) as { items: { id: number; quantity: number; properties?: Record }[] }; + b.items.forEach((i) => addLine(i.id, i.quantity, i.properties)); + return json({ items: cart.items }); + } + if (url.pathname === "/cart/change.js") { + const b = (await body(req)) as { id: string; quantity: number }; + cart.items = cart.items.map((i) => (i.key === b.id ? { ...i, quantity: b.quantity } : i)).filter((i) => i.quantity > 0); + return json(cartJson()); + } + if (url.pathname === "/__reset") { + cart = { token: "c1", items: [], attributes: {} }; + addLine(101, 2); + addLine(102, 1); + await prisma.hold.deleteMany({}); + await prisma.capacityCounter.deleteMany({}); + return json({ ok: true }); + } + if (url.pathname === "/__rival-hold") { + // Another shopper grabs a place directly through the same service. + const b = (await body(req)) as Record; + return json(await holdSelection(SHOP, admin, { lines: [{ variantId: 101, quantity: 1 }, { variantId: 102, quantity: 1 }], ...b })); + } + const p = url.pathname.replace("/apps/datewise", ""); + const b = req.method === "POST" ? await body(req) : Object.fromEntries(url.searchParams); + if (p === "/availability") return json(await getAvailability(SHOP, admin, b)); + if (p === "/hold") return json(await holdSelection(SHOP, admin, b)); + if (p === "/release") return json(await releaseSelection(SHOP, (b as { token: string }).token)); + if (p === "/waitlist") return json(await joinWaitlist(SHOP, b)); + res.writeHead(404); + res.end(); + } catch (e) { + console.error(e); + json({ ok: false, message: String(e) }, 500); + } +}).listen(PORT, async () => { + await seed(); + console.log(`e2e harness on http://localhost:${PORT}/cart`); +}); diff --git a/tests/e2e/widget.e2e.mjs b/tests/e2e/widget.e2e.mjs new file mode 100644 index 0000000..5ee67f3 --- /dev/null +++ b/tests/e2e/widget.e2e.mjs @@ -0,0 +1,84 @@ +/** + * Browser test for the storefront widget against tests/e2e/server.ts. + * node tests/e2e/widget.e2e.mjs (needs `npm i -D playwright` and the harness running) + */ +import { chromium } from "playwright"; +import assert from "node:assert/strict"; + +const BASE = process.env.BASE ?? "http://localhost:4545"; +const SHOTS = process.env.SHOTS ?? "/tmp"; +const browser = await chromium.launch({ executablePath: process.env.CHROMIUM ?? undefined }); +const page = await browser.newPage({ viewport: { width: 520, height: 1100 } }); +const errors = []; +page.on("pageerror", (e) => errors.push(e.message)); +const cart = () => page.evaluate(() => fetch("/cart.js").then((r) => r.json())); +const step = (s) => console.log("•", s); + +await fetch(`${BASE}/__reset`); +await page.goto(`${BASE}/cart`); +await page.waitForSelector(".ss-methods"); + +step("shipping is blocked by the fresh-flower rule, with the reason shown"); +const ship = page.locator('.ss-method[data-v="shipping"]'); +assert.equal(await ship.getAttribute("aria-disabled"), "true"); +assert.match(await ship.innerText(), /Fresh flowers are pickup or local delivery only/); + +step("checkout is stopped until a time is chosen"); +await page.click('button[name="checkout"]'); +assert.match(await page.locator(".ss-live").innerText(), /choose a delivery or pickup time/i); +assert.equal(await page.locator("#checkedout").innerText(), ""); + +step("delivery: postal code → zone fee → pick first date and slot → hold"); +await page.click('.ss-method[data-v="delivery"]'); +await page.waitForSelector(".ss-postal"); +await page.fill(".ss-postal input", "N2H 5Z6"); +await page.click(".ss-postal button"); +await page.waitForSelector(".ss-day.ss-avail"); +await page.locator(".ss-day.ss-avail").first().click(); +await page.locator('.ss-slot:not([aria-disabled="true"])').first().click(); +await page.waitForSelector("[data-ss-timer]"); +let c = await cart(); +const sel = JSON.parse(c.attributes._datewise); +assert.equal(sel.m, "delivery"); +const fees = c.items.filter((i) => i.properties._datewise_fee); +assert.equal(fees.length, 1, "exactly one fee line"); +assert.equal(fees[0].final_price, sel.f, "fee line matches the quote"); +assert.ok(c.attributes["Local delivery date"], "readable attribute for staff"); +await page.screenshot({ path: `${SHOTS}/e2e-held.png`, fullPage: true }); + +step("a rival shopper takes the last place in that slot; ours stays held"); +const rival = await (await fetch(`${BASE}/__rival-hold`, { method: "POST", body: JSON.stringify({ method: "delivery", locationId: sel.l, date: sel.d, slot: sel.s, postal: "N2H 5Z6" }) })).json(); +assert.equal(rival.ok, true, "rival gets the 2nd of 2 places"); +const rival2 = await (await fetch(`${BASE}/__rival-hold`, { method: "POST", body: JSON.stringify({ method: "delivery", locationId: sel.l, date: sel.d, slot: sel.s, postal: "N2H 5Z6" }) })).json(); +assert.equal(rival2.ok, false, "third shopper is refused"); + +step("theme changes the cart quantity → widget re-checks and keeps the still-valid hold"); +await page.locator("[data-k]").nth(2).click(); // + on the second line +await page.waitForTimeout(1200); +c = await cart(); +assert.ok(c.attributes._datewise, "selection kept"); + +step("switching to pickup releases the delivery time and removes the fee"); +await page.click('.ss-method[data-v="pickup"]'); +await page.waitForTimeout(1500); +c = await cart(); +assert.equal(c.attributes._datewise, undefined); +assert.equal(c.items.filter((i) => i.properties._datewise_fee).length, 0); + +step("pickup hold → checkout goes through with the selection attached"); +await page.locator(".ss-day.ss-avail").first().click(); +await page.locator('.ss-slot:not([aria-disabled="true"])').first().click(); +await page.waitForSelector("[data-ss-timer]"); +await page.click('button[name="checkout"]'); +await page.waitForFunction(() => document.getElementById("checkedout").textContent.startsWith("CHECKOUT")); +assert.match(await page.locator("#checkedout").innerText(), /"m\\?":\\?"pickup/); +await page.screenshot({ path: `${SHOTS}/e2e-pickup.png`, fullPage: true }); + +step("keyboard: arrow keys move between calendar days"); +await page.locator(".ss-day[tabindex='0']").focus(); +await page.keyboard.press("ArrowRight"); +assert.equal(await page.evaluate(() => document.activeElement.classList.contains("ss-day")), true); + +assert.deepEqual(errors, [], "no page errors"); +console.log("\nAll widget checks passed"); +await browser.close(); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..7e21500 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,24 @@ +{ + "include": ["env.d.ts", "**/*.ts", "**/*.tsx", ".react-router/types/**/*"], + "exclude": ["node_modules", "build", "extensions/**", "prototype/**"], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "strict": true, + "skipLibCheck": true, + "isolatedModules": true, + "allowSyntheticDefaultImports": true, + "removeComments": false, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "allowJs": true, + "resolveJsonModule": true, + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "Bundler", + "target": "ES2022", + "baseUrl": ".", + "types": ["@react-router/node", "vite/client", "node"], + "rootDirs": [".", "./.react-router/types"], + "paths": { "~/*": ["./app/*"] } + } +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..b3ad11c --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,27 @@ +import { reactRouter } from "@react-router/dev/vite"; +import { defineConfig, type UserConfig } from "vite"; +import tsconfigPaths from "vite-tsconfig-paths"; + +// Shopify CLI sets HOST/SHOPIFY_APP_URL; HMR goes through the tunnel when not on localhost. +if (process.env.HOST && (!process.env.SHOPIFY_APP_URL || process.env.SHOPIFY_APP_URL === process.env.HOST)) { + process.env.SHOPIFY_APP_URL = process.env.HOST; + delete process.env.HOST; +} +const host = new URL(process.env.SHOPIFY_APP_URL || "http://localhost").hostname; +const hmr = + host === "localhost" + ? { protocol: "ws", host: "localhost", port: 64999, clientPort: 64999 } + : { protocol: "wss", host, port: parseInt(process.env.FRONTEND_PORT!) || 8002, clientPort: 443 }; + +export default defineConfig({ + server: { + allowedHosts: [host], + cors: { preflightContinue: true }, + port: Number(process.env.PORT || 3000), + hmr, + fs: { allow: ["app", "node_modules"] }, + }, + plugins: [reactRouter(), tsconfigPaths()], + build: { assetsInlineLimit: 0 }, + optimizeDeps: { include: ["@shopify/app-bridge-react"] }, +}) satisfies UserConfig;