diff --git a/.eslintignore b/.eslintignore index d2d6eed..42fe272 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,5 +1,7 @@ node_modules build public/build +dist +coverage */*.yml .shopify diff --git a/.eslintrc.cjs b/.eslintrc.cjs index c2e4453..064f74b 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -17,7 +17,10 @@ module.exports = { env: { browser: true, commonjs: true, - es6: true, + // es2022, not es6: `parserOptions.ecmaVersion: latest` lets the parser read modern + // syntax but says nothing about globals, so `es6` left `globalThis` undeclared and + // `no-undef` fired on every use of it. + es2022: true, }, ignorePatterns: ["!**/.server", "!**/.client"], diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f506111 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,53 @@ +# The gate the PR description used to be. Every number the reviewer is asked to +# trust — tests green, coverage over the threshold, no type error, no lint error — is +# measured here on every push instead of once, by hand, on someone's laptop. +# +# One job on purpose: the workspace builds in seconds and the steps share the install. +# Split it when something here starts being worth waiting for on its own. +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + # A new push supersedes the run in flight for the same ref. + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + # The workspace's `engines` range excludes Node 21 and 22.0–22.11. + node-version: 20.19 + cache: pnpm + + # `--frozen-lockfile` is the point: a PR that edits a package.json without + # committing the lockfile fails here rather than resolving something new in CI. + # The packages' `prepare` scripts build `packages/*/dist` as part of this, which + # is what the apps typecheck and test against. + - run: pnpm install --frozen-lockfile + + # The root scripts, not `pnpm -r …` directly, so CI runs exactly what a developer + # runs locally — including the libs build the apps resolve through. + - run: pnpm typecheck + + - run: pnpm lint + + # Coverage, not plain `test`: the per-package vitest configs carry the 80% + # thresholds, so this is the step that actually enforces them. + - run: pnpm coverage diff --git a/ONBOARDING.md b/ONBOARDING.md index 6748949..d96f98c 100644 --- a/ONBOARDING.md +++ b/ONBOARDING.md @@ -8,7 +8,9 @@ plus a store locator and SEO store pages. ``` packages/ # reusable libraries (one per Woosmap API) ├── localities-client/ # @woosmap/localities-client: worker-safe Localities client + mappers -├── store-search-client/ # @woosmap/store-search-client: Store Search client + Store model + metaobject mapper +├── distance-client/ # @woosmap/distance-client: worker-safe Distance Matrix client +├── store-search-client/ # @woosmap/store-search-client: Store Search client + Store model +├── local-page-engine/ # @woosmap/local-page-engine: platform-neutral LocalPage document + enrichment └── shopify-app-proxy/ # @woosmap/shopify-app-proxy: server-side App Proxy HMAC + wrapper apps/ ├── checkout-autocomplete/ # full app: private key, server proxy, session-token auth, DB, OAuth diff --git a/README.md b/README.md index da141e7..7e46b80 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,9 @@ checkout address autocomplete (two variants) and a store locator + SEO store pag . ├── packages/ # shared, reusable libraries (one per Woosmap API) │ ├── localities-client/ # @woosmap/localities-client (worker-safe Localities client + mappers) -│ ├── store-search-client/ # @woosmap/store-search-client (Store Search client + Store model + metaobject mapper) +│ ├── distance-client/ # @woosmap/distance-client (worker-safe Distance Matrix client) +│ ├── store-search-client/ # @woosmap/store-search-client (Store Search client + Store model) +│ ├── local-page-engine/ # @woosmap/local-page-engine (platform-neutral LocalPage document + enrichment) │ └── shopify-app-proxy/ # @woosmap/shopify-app-proxy (server-side HMAC + wrapper) └── apps/ ├── checkout-autocomplete/ # full app: private key, server-side proxy, session-token auth, DB, OAuth diff --git a/apps/checkout-autocomplete-lite/extensions/woosmap-address-autocomplete/shopify.d.ts b/apps/checkout-autocomplete-lite/extensions/woosmap-address-autocomplete/shopify.d.ts index c283bf0..f7e06e5 100644 --- a/apps/checkout-autocomplete-lite/extensions/woosmap-address-autocomplete/shopify.d.ts +++ b/apps/checkout-autocomplete-lite/extensions/woosmap-address-autocomplete/shopify.d.ts @@ -1,18 +1,18 @@ import '@shopify/ui-extensions'; -//@ts-ignore +//@ts-expect-error module paths are declared, not resolved declare module './src/suggest.ts' { const shopify: import('@shopify/ui-extensions/purchase.address-autocomplete.suggest').Api; const globalThis: { shopify: typeof shopify }; } -//@ts-ignore +//@ts-expect-error module paths are declared, not resolved declare module './src/format-suggestion.ts' { const shopify: import('@shopify/ui-extensions/purchase.address-autocomplete.format-suggestion').Api; const globalThis: { shopify: typeof shopify }; } -//@ts-ignore +//@ts-expect-error module paths are declared, not resolved declare module './src/woosmap.ts' { const shopify: | import('@shopify/ui-extensions/purchase.address-autocomplete.suggest').Api diff --git a/apps/checkout-autocomplete/package.json b/apps/checkout-autocomplete/package.json index 834107d..546ced1 100644 --- a/apps/checkout-autocomplete/package.json +++ b/apps/checkout-autocomplete/package.json @@ -17,7 +17,7 @@ "prisma": "prisma", "graphql-codegen": "graphql-codegen", "vite": "vite", - "typecheck": "react-router typegen && tsc --noEmit", + "typecheck": "prisma generate && react-router typegen && tsc --noEmit", "test": "vitest run", "test:watch": "vitest", "coverage": "vitest run --coverage" diff --git a/apps/store-pages/README.md b/apps/store-pages/README.md index a790b11..5da864b 100644 --- a/apps/store-pages/README.md +++ b/apps/store-pages/README.md @@ -9,11 +9,12 @@ renders the pages. The sync job is the **only backend**. ``` apps/store-pages/ -├── shopify.app.toml # `store` metaobject definition (renderable, publishable) + scopes +├── shopify.app.toml # scopes + webhooks (the `store` metaobject is NOT declared here) ├── app/ │ ├── woosmap.server.ts # StoreSearchClient from env (PRIVATE key) -│ ├── admin-graphql.server.ts # metaobjectUpsert + enable online_store (injectable executor) ← tested -│ ├── store-sync.server.ts # the sync: iterate Woosmap → upsert metaobjects ← tested +│ ├── metaobject-mapping.server.ts # LocalPage → `store` metaobject fields + the definition schema ← tested +│ ├── admin-graphql.server.ts # metaobjectUpsert + enable online_store (injectable executor) ← tested +│ ├── store-sync.server.ts # the sync: iterate Woosmap → build a LocalPage → upsert ← tested │ └── sync-runner.ts # runnable cron entry wiring the above └── theme/templates/metaobject/ └── store.liquid # SEO page + Woosmap Static Maps + JSON-LD @@ -22,20 +23,29 @@ apps/store-pages/ ## Data flow ``` -Woosmap Store API ──(@woosmap/store-search-client)──▶ syncStores ──▶ metaobjectUpsert (store) - │ - Shopify Online Store ◀────────┘ (templates/metaobject/store.liquid) +Woosmap Store API ──(store-search-client)──▶ buildLocalPage() ──▶ localPageToMetaobjectFields + ▲ │ + Localities Nearby · Distance Matrix ───────┤ ▼ + reverse-geocode · neighbours (haversine) │ metaobjectUpsert (store) + (local-page-engine/enrich) ┘ │ + Shopify Online Store ◀───────────┘ + (templates/metaobject/store.liquid) ``` -The field keys written by the sync come straight from -[`@woosmap/store-search-client`](../../packages/store-search-client)'s -`storeToMetaobjectFields` and **must** match the metaobject definition. +This app is **one adapter** over [`@woosmap/local-page-engine`](../../packages/local-page-engine), +which owns the platform-neutral `LocalPage` document. The engine decides what a store page +*contains*; this app decides how it lands in Shopify. A feed, or a server-rendered page, would be +a sibling of `metaobject-mapping.server.ts` — not a fork of the engine. + +The field keys written by the sync come from `localPageToMetaobjectFields` +(`app/metaobject-mapping.server.ts`) and **must** match `STORE_FIELD_DEFINITIONS` declared in the +same file. `metaobject-contract.test.ts` guards that coupling, which no compiler can catch. ## ⚠️ Ownership: the metaobject is merchant-owned, by design The `store` metaobject is **merchant-owned** (type `store`, no `$app:` prefix) and is **created automatically by the sync** on first run (`ensureStoreDefinition`, from -`@woosmap/store-search-client`'s `STORE_FIELD_DEFINITIONS`). It is intentionally **not** +`STORE_FIELD_DEFINITIONS` in `app/metaobject-mapping.server.ts`). It is intentionally **not** declared in `shopify.app.toml`. Why not app-owned (`$app:store`): app-owned metaobjects are **namespaced to the owning @@ -87,6 +97,18 @@ pnpm --filter woosmap-store-pages sync It is idempotent, so re-running just re-syncs. Set `STORE_METAOBJECT_TYPE` only to point at an existing definition of another type (the default is `store`). +The sync also builds a `LocalPage` per store (`@woosmap/local-page-engine`) and maps it. The +page config is optional — Shopify supplies most of it another way — but a consumer that reads +the document rather than the theme will want it: + +| Variable | Effect | +| --- | --- | +| `STORE_URL_HANDLE` | path pages live under (`/pages//…`, default `stores`). Drives the definition's URL handle, the page's canonical path **and** the neighbour links — one value, so they cannot drift | +| `STORE_BRAND` | `{brand}` in the generated SEO copy | +| `STORE_LOCALE` | BCP 47 tag recorded on the document. It labels the copy; it does not translate it (override the templates for that) | +| `STORE_PAGE_ORIGIN` | e.g. `https://shop.example.com` → absolute canonical and schema.org URLs. Liquid has `canonical_url`, so only an off-platform consumer needs this | +| `WOOSMAP_PUBLIC_KEY` | bakes the static-map URL into the document. The theme reads its own key, so the sync only needs this for a consumer that has no theme | + **4. Render the pages.** Copy `theme/templates/metaobject/store.liquid` into the theme (Online Store → Themes → Edit code → Templates → new `metaobject/store` template), set the Woosmap **public** key (theme setting `woosmap_public_key` or shop metafield @@ -145,6 +167,13 @@ is delivered as a file to copy into the merchant's theme. It renders the store d map is a Woosmap asset by design, not a theme asset. - SEO title/description come from the renderable capability. `description` is merchant-owned and is **never overwritten by the sync**. +- The administrative breadcrumb is built **once** in the template and reused by the + `BreadcrumbList` JSON-LD, dropping blanks and consecutive duplicates — the same rule as + `buildBreadcrumb` in the engine. Keep the two in step until the JSON-LD is fed from + `page.jsonLd` and the duplicate goes away. +- "Other stores nearby" is rewritten on every sync that runs the neighbour search, **including + when a store no longer has any** — the sync writes `[]` so the section disappears. A store + whose neighbours were not recomputed keeps the ones it had. ## Before syncing thousands of stores: verify diff --git a/apps/store-pages/app/admin-enrich.server.ts b/apps/store-pages/app/admin-enrich.server.ts deleted file mode 100644 index cc7b303..0000000 --- a/apps/store-pages/app/admin-enrich.server.ts +++ /dev/null @@ -1,63 +0,0 @@ -// Reverse-geocodes a store (Woosmap Localities) to country-native admin values -// (Gironde, Kent…) for the store-page breadcrumb + geo context. Filled once, only -// when missing (boundaries don't change). Pure + fetch-injected. - -import { LocalitiesClient } from '@woosmap/localities-client'; -import { toTransport } from './woosmap-transport'; -import type { FetchLike } from './woosmap-transport'; -import type { MetaobjectFieldInput } from '@woosmap/store-search-client'; - -const DEFAULT_API_BASE = 'https://api.woosmap.com'; - -/** Admin levels extracted from a reverse-geocode. */ -export interface AdminAreas { - country?: string; - region?: string; - county?: string; - city?: string; -} - -/** Reverse-geocode a point to its admin areas (null on any problem). Maps Woosmap - * component types: `state`→region, `county`→county, `locality`→city. */ -export async function reverseGeocode( - fetchImpl: FetchLike, - privateKey: string, - lat: number, - lng: number, - apiBase: string = DEFAULT_API_BASE, -): Promise { - const client = new LocalitiesClient({ privateKey, privateKeyIn: 'query', baseUrl: apiBase, transport: toTransport(fetchImpl) }); - try { - const res = await client.geocode({ latLng: { lat, lng } }); - const components = res.results?.[0]?.address_components; - if (!components) return null; - const pick = (type: string): string | undefined => { - const c = components.find((comp) => comp.types.includes(type)); - if (!c) return undefined; - const name = Array.isArray(c.long_name) ? c.long_name[0] : c.long_name; - return name || undefined; - }; - return { country: pick('country'), region: pick('state'), county: pick('county'), city: pick('locality') }; - } catch { - return null; - } -} - -/** Country/region/county values, empty levels omitted. `city` isn't written — it - * comes from the base sync. */ -export function buildAdminFields(areas: AdminAreas): MetaobjectFieldInput[] { - const fields: MetaobjectFieldInput[] = []; - const push = (key: string, value: string | undefined): void => { - const trimmed = (value ?? '').trim(); - if (trimmed) fields.push({ key, value: trimmed }); - }; - push('country', areas.country); - push('region', areas.region); - push('county', areas.county); - return fields; -} - -/** True when a reverse-geocode yielded at least a region — i.e. worth writing. */ -export function hasAdmin(areas: AdminAreas | null): areas is AdminAreas { - return !!areas && (!!areas.region || !!areas.county || !!areas.city); -} diff --git a/apps/store-pages/app/admin-graphql.server.test.ts b/apps/store-pages/app/admin-graphql.server.test.ts index 80d6de9..163c371 100644 --- a/apps/store-pages/app/admin-graphql.server.test.ts +++ b/apps/store-pages/app/admin-graphql.server.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, type Mock } from 'vitest'; -import { STORE_FIELD_DEFINITIONS } from '@woosmap/store-search-client'; +import { STORE_FIELD_DEFINITIONS } from './metaobject-mapping.server'; import { AdminGraphQLError, DEFAULT_STORE_METAOBJECT_TYPE, diff --git a/apps/store-pages/app/admin-graphql.server.ts b/apps/store-pages/app/admin-graphql.server.ts index 7508cb6..5fa2786 100644 --- a/apps/store-pages/app/admin-graphql.server.ts +++ b/apps/store-pages/app/admin-graphql.server.ts @@ -6,7 +6,7 @@ import { STORE_FIELD_DEFINITIONS, STORE_METAOBJECT_TYPE, type MetaobjectFieldInput, -} from '@woosmap/store-search-client'; +} from './metaobject-mapping.server'; /** * The default metaobject type — a **merchant-owned** `store` definition (no `$app:` diff --git a/apps/store-pages/app/metaobject-contract.test.ts b/apps/store-pages/app/metaobject-contract.test.ts index 0fd44bf..90e423e 100644 --- a/apps/store-pages/app/metaobject-contract.test.ts +++ b/apps/store-pages/app/metaobject-contract.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect } from 'vitest'; -import { storeToMetaobjectFields, STORE_FIELD_DEFINITIONS, type Store } from '@woosmap/store-search-client'; +import { buildLocalPage } from '@woosmap/local-page-engine'; +import type { Store } from '@woosmap/store-search-client'; +import { localPageToMetaobjectFields, STORE_FIELD_DEFINITIONS } from './metaobject-mapping.server'; // Guards the coupling that has no compiler to catch it: every field KEY the mapper // emits must be declared in STORE_FIELD_DEFINITIONS — the schema used to CREATE the @@ -7,7 +9,7 @@ import { storeToMetaobjectFields, STORE_FIELD_DEFINITIONS, type Store } from '@w // and the schema drift, this fails instead of the sync dropping data at runtime (or // `metaobjectUpsert` rejecting an undeclared key). -/** A fully-populated store so the mapper emits every key it can. */ +/** A fully-populated store so the mapper emits every base key it can. */ const FULL_STORE: Store = { storeId: 'store_1', name: 'Store 1', @@ -22,22 +24,39 @@ const FULL_STORE: Store = { email: 'a@b.co', website: 'https://x', openingHours: { timezone: 'Europe/Paris', days: {} as never }, - types: [], - tags: [], + types: ['Shop'], + tags: ['CC'], lastUpdated: null, userProperties: null, }; +/** Every enrichment slice present, so the enriched keys are exercised too. */ +const FULL_PAGE = buildLocalPage( + FULL_STORE, + { + admin: { country: 'France', region: 'Île-de-France', county: 'Paris', city: 'Paris' }, + nearby: { updated_at: 'T', groups: [] }, + nearbyStores: [{ handle: 'h', url: '/u', name: 'N', city: 'Paris', km: 1 }], + }, + {}, + { now: '2026-08-14T00:00:00.000Z' }, +); + describe('metaobject field contract (mapper ↔ STORE_FIELD_DEFINITIONS)', () => { it('every field key the mapper emits is declared in the definition schema', () => { const declared = new Set(STORE_FIELD_DEFINITIONS.map((f) => f.key)); expect(declared.size).toBeGreaterThan(0); // sanity - const emitted = storeToMetaobjectFields(FULL_STORE).map((f) => f.key); + const emitted = localPageToMetaobjectFields(FULL_PAGE).map((f) => f.key); const missing = emitted.filter((key) => !declared.has(key)); expect(missing).toEqual([]); }); + it('exercises the enriched keys too, not just the store facts', () => { + const emitted = new Set(localPageToMetaobjectFields(FULL_PAGE).map((f) => f.key)); + expect(['nearby', 'region', 'nearby_stores'].filter((k) => !emitted.has(k))).toEqual([]); + }); + it('the schema declares the required identity fields', () => { const required = new Set(STORE_FIELD_DEFINITIONS.filter((f) => f.required).map((f) => f.key)); expect(required.has('store_id')).toBe(true); diff --git a/apps/store-pages/app/metaobject-mapping.server.test.ts b/apps/store-pages/app/metaobject-mapping.server.test.ts new file mode 100644 index 0000000..2940c15 --- /dev/null +++ b/apps/store-pages/app/metaobject-mapping.server.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect } from 'vitest'; +import { buildLocalPage, type LocalPage, type LocalPageEnrichment } from '@woosmap/local-page-engine'; +import type { Store } from '@woosmap/store-search-client'; +import { localPageToMetaobjectFields, STORE_METAOBJECT_TYPE } from './metaobject-mapping.server'; + +const STORE: Store = { + storeId: 'f52b6ee0_4734_4e36_8929_95fb2304aa4b', + name: 'Store 1', + lat: 48.8566, + lng: 2.3522, + address1: '1 rue de Rivoli', + address2: 'Bâtiment B', + city: 'Paris', + zip: '75001', + countryCode: 'FR', + phone: '+33123456789', + email: 'store@example.com', + website: 'https://example.com/store-1', + openingHours: { timezone: 'Europe/Paris', days: {} as never }, + types: ['Shop'], + tags: ['CC'], + lastUpdated: '2026-08-01T00:00:00Z', + userProperties: null, +}; + +function page(store: Store = STORE, enrichment: LocalPageEnrichment = {}): LocalPage { + return buildLocalPage(store, enrichment, {}, { now: '2026-08-14T00:00:00.000Z' }); +} + +function byKey(fields: Array<{ key: string; value: string }>): Record { + return Object.fromEntries(fields.map((f) => [f.key, f.value])); +} + +describe('localPageToMetaobjectFields — store facts', () => { + it('flattens the store onto the metaobject keys', () => { + const fields = byKey(localPageToMetaobjectFields(page())); + expect(fields).toMatchObject({ + store_id: 'f52b6ee0_4734_4e36_8929_95fb2304aa4b', + name: 'Store 1', + address1: '1 rue de Rivoli', + address2: 'Bâtiment B', + city: 'Paris', + zip: '75001', + country_code: 'FR', + phone: '+33123456789', + email: 'store@example.com', + website: 'https://example.com/store-1', + }); + }); + + it('stringifies the coordinates for number_decimal fields', () => { + const fields = byKey(localPageToMetaobjectFields(page())); + expect([fields['lat'], fields['lng']]).toEqual(['48.8566', '2.3522']); + }); + + it('JSON-encodes the opening hours', () => { + const fields = byKey(localPageToMetaobjectFields(page())); + expect(JSON.parse(fields['hours']!).timezone).toBe('Europe/Paris'); + }); + + it('JSON-encodes the list fields', () => { + const fields = byKey(localPageToMetaobjectFields(page())); + expect([fields['types'], fields['tags']]).toEqual(['["Shop"]', '["CC"]']); + }); + + it('omits coordinates and hours when the store has none', () => { + const fields = byKey( + localPageToMetaobjectFields(page({ ...STORE, lat: null, lng: null, openingHours: null })), + ); + expect(['lat', 'lng', 'hours'].filter((k) => k in fields)).toEqual([]); + }); + + it('omits empty list fields rather than sending "[]"', () => { + const fields = byKey(localPageToMetaobjectFields(page({ ...STORE, types: [], tags: [] }))); + expect(['types', 'tags'].filter((k) => k in fields)).toEqual([]); + }); + + it('never writes description — the merchant owns the prose', () => { + const keys = localPageToMetaobjectFields(page()).map((f) => f.key); + expect(keys).not.toContain('description'); + }); +}); + +describe('localPageToMetaobjectFields — enrichment', () => { + it('emits the admin levels the page carries', () => { + const fields = byKey( + localPageToMetaobjectFields( + page(STORE, { admin: { country: 'France', region: 'Île-de-France', county: 'Paris' } }), + ), + ); + expect(fields).toMatchObject({ country: 'France', region: 'Île-de-France', county: 'Paris' }); + }); + + it('never writes city from the admin data — it comes from the store facts', () => { + const fields = byKey( + localPageToMetaobjectFields(page({ ...STORE, city: '' }, { admin: { city: 'Bordeaux' } })), + ); + expect(fields['city']).toBeUndefined(); + }); + + it('omits whitespace-only admin levels', () => { + const fields = byKey(localPageToMetaobjectFields(page(STORE, { admin: { region: ' ' } }))); + expect(fields['region']).toBeUndefined(); + }); + + it('JSON-encodes the nearby payload', () => { + const fields = byKey( + localPageToMetaobjectFields(page(STORE, { nearby: { updated_at: 'T', groups: [] } })), + ); + expect(fields['nearby']).toBe('{"updated_at":"T","groups":[]}'); + }); + + it('omits nearby entirely when the page was not enriched, so the stored value survives', () => { + const fields = byKey(localPageToMetaobjectFields(page())); + expect('nearby' in fields).toBe(false); + }); + + it('JSON-encodes the neighbouring stores', () => { + const neighbours = [{ handle: 'h', url: '/u', name: 'N', city: 'Paris', km: 2 }]; + const fields = byKey(localPageToMetaobjectFields(page(STORE, { nearbyStores: neighbours }))); + expect(JSON.parse(fields['nearby_stores']!)).toEqual(neighbours); + }); + + it('writes "[]" when the search ran and found none, so stale neighbours are cleared', () => { + // Not a no-op: omitting the key would leave yesterday's list in the metaobject and + // `store.liquid` would keep rendering links to it. + const fields = byKey(localPageToMetaobjectFields(page(STORE, { nearbyStores: [] }))); + expect(fields['nearby_stores']).toBe('[]'); + }); + + it('omits nearby_stores when the search never ran, so the stored value survives', () => { + const fields = byKey(localPageToMetaobjectFields(page())); + expect('nearby_stores' in fields).toBe(false); + }); +}); + +describe('STORE_METAOBJECT_TYPE', () => { + it('is the merchant-owned `store` type, with no $app: prefix', () => { + expect(STORE_METAOBJECT_TYPE).toBe('store'); + }); +}); diff --git a/packages/store-search-client/src/metaobject-mapping.ts b/apps/store-pages/app/metaobject-mapping.server.ts similarity index 53% rename from packages/store-search-client/src/metaobject-mapping.ts rename to apps/store-pages/app/metaobject-mapping.server.ts index d04dad5..ead6fb6 100644 --- a/packages/store-search-client/src/metaobject-mapping.ts +++ b/apps/store-pages/app/metaobject-mapping.server.ts @@ -1,23 +1,12 @@ -import type { Store } from './store'; +// The Shopify adapter: a LocalPage → `store` metaobject fields. +// +// This used to live in `@woosmap/store-search-client`, which meant a Shopify +// metaobject schema — enriched keys and all — was the de facto contract of the +// whole workspace. It is now the other way round: `@woosmap/local-page-engine` +// owns the contract, and this file is one consumer of it. A feed adapter, or a +// server-rendered page, is a sibling of this file, not a fork of the engine. -/** - * Maps a Woosmap {@link Store} onto the fields of a Shopify `store` metaobject, - * for `metaobjectUpsert` (Admin GraphQL). Emits the field list and a stable - * handle; the caller owns the mutation and the definition `type`. - * - * The field keys the mapper emits MUST all exist in {@link STORE_FIELD_DEFINITIONS} - * (the definition schema), which is the single source of truth used to create the - * merchant-owned `store` metaobject definition at sync time. Kept in this package - * so the schema contract lives next to the data it maps. - * - * Design choices that matter for a repeatable sync: - * - Empty values are omitted, not sent as `""`. Shopify rejects an empty - * `url` or `number_decimal`, and `metaobjectUpsert` leaves fields it isn't - * given unchanged — so omitting is both safe and correct. - * - `description` and the renderable SEO fields are deliberately NOT emitted: - * they are merchant-editable, and re-sending them each sync would clobber - * hand-written copy. Sync owns the facts; the merchant owns the prose. - */ +import type { LocalPage } from '@woosmap/local-page-engine'; /** The metaobject definition type these fields belong to (merchant-owned; no `$app:` prefix). */ export const STORE_METAOBJECT_TYPE = 'store'; @@ -31,10 +20,16 @@ export interface StoreFieldDefinition { required?: boolean; } +/** A single Shopify metaobject field value, as `metaobjectUpsert` expects. */ +export interface MetaobjectFieldInput { + key: string; + value: string; +} + /** * The `store` metaobject definition schema — the single source of truth for * creating the merchant-owned definition (see `ensureStoreDefinition`). Every key - * {@link storeToMetaobjectFields} can emit is declared here; `description` is + * {@link localPageToMetaobjectFields} can emit is declared here; `description` is * declared (merchant-editable) but never written by the sync. */ export const STORE_FIELD_DEFINITIONS: StoreFieldDefinition[] = [ @@ -53,47 +48,42 @@ export const STORE_FIELD_DEFINITIONS: StoreFieldDefinition[] = [ { key: 'hours', name: 'Opening hours', type: 'json' }, { key: 'types', name: 'Types', type: 'list.single_line_text_field' }, { key: 'tags', name: 'Tags', type: 'list.single_line_text_field' }, - // Server-side enriched nearby POIs (grouped) + `updated_at` for the TTL refresh. - // Written by the store-pages sync (not by storeToMetaobjectFields), so the block - // is rendered in HTML (SEO/GEO) instead of fetched client-side. + // Enrichment, server-side so the blocks are rendered in HTML (SEO/GEO) instead of + // fetched client-side. `nearby` carries its own `updated_at`, which drives the TTL. { key: 'nearby', name: 'Nearby POIs', type: 'json' }, - // Administrative hierarchy (country-native values) — written by the sync's - // reverse-geocode enrichment, for the breadcrumb + geo context. Filled once, - // only when missing. Slugs aren't stored: they're derivable from these values - // if/when nested URLs (area pages) land. { key: 'country', name: 'Country', type: 'single_line_text_field' }, { key: 'region', name: 'Region', type: 'single_line_text_field' }, { key: 'county', name: 'County', type: 'single_line_text_field' }, - // Server-side enriched neighbouring stores within a radius (nearest N), for the - // "other stores nearby" section — internal links between store pages (good for - // crawl/SEO). Recomputed each run from the full store set (haversine, no extra - // API calls). Written by the sync, not by storeToMetaobjectFields. { key: 'nearby_stores', name: 'Nearby stores', type: 'json' }, { key: 'description', name: 'Description', type: 'multi_line_text_field' }, ]; -/** A single Shopify metaobject field value, as `metaobjectUpsert` expects. */ -export interface MetaobjectFieldInput { - key: string; - value: string; -} - /** - * Build the Shopify metaobject handle for a store. Handles allow - * `[a-z0-9_-]`; the Woosmap `store_id` is lower-cased and any other character - * is collapsed to a single hyphen, so the handle is deterministic and the - * upsert is idempotent (one metaobject per store, re-runnable). + * Map a {@link LocalPage} onto Shopify metaobject fields. + * + * Empty values are omitted, not sent as `""`: Shopify rejects an empty `url` or + * `number_decimal`, and `metaobjectUpsert` leaves fields it isn't given unchanged. + * `description` and the renderable SEO fields are never written — they are + * merchant-editable, and the sync owns the facts, not the prose. + * + * Enrichment is emitted only when the page carries it, which preserves the runner's + * conditional behaviour for free: a fresh TTL means no `nearby` key, so the stored + * value survives. + * + * `nearby_stores` is the case where that rule needs care. The neighbour search runs + * on every sync, and a store can legitimately end up with none — a neighbour closed, + * or the radius was tightened. That result must be written as `[]`, because omitting + * it would leave yesterday's neighbours in place and `store.liquid` renders them as + * links to pages that may no longer exist. So the test is `!== null` (did the search + * run?), not `.length > 0` (did it find anything?). + * + * `page.seo`, `page.jsonLd` and `page.map` are deliberately unmapped — Shopify + * covers them (the `renderable` capability, and `store.liquid` builds its own). They + * exist for adapters that have no such platform. The cost is a live duplicate: + * folding the Liquid onto a `json` field fed from `page.jsonLd` would remove it, but + * that changes what the storefront renders and wants its own dev-store pass. */ -export function storeToMetaobjectHandle(store: Pick): string { - return store.storeId - .toLowerCase() - .replace(/[^a-z0-9_-]+/g, '-') - .replace(/^-+|-+$/g, '') - .slice(0, 255); -} - -/** Map a {@link Store} to Shopify metaobject fields, omitting empty values. */ -export function storeToMetaobjectFields(store: Store): MetaobjectFieldInput[] { +export function localPageToMetaobjectFields(page: LocalPage): MetaobjectFieldInput[] { const fields: MetaobjectFieldInput[] = []; const push = (key: string, value: string | null | undefined): void => { if (value !== null && value !== undefined && value !== '') { @@ -101,6 +91,7 @@ export function storeToMetaobjectFields(store: Store): MetaobjectFieldInput[] { } }; + const store = page.store; push('store_id', store.storeId); push('name', store.name); push('address1', store.address1); @@ -118,5 +109,21 @@ export function storeToMetaobjectFields(store: Store): MetaobjectFieldInput[] { push('types', store.types && store.types.length > 0 ? JSON.stringify(store.types) : null); push('tags', store.tags && store.tags.length > 0 ? JSON.stringify(store.tags) : null); + // Administrative levels. `city` is not written here — it already comes from the + // store facts above, and the reverse-geocode is only a fallback for the trail. + if (page.admin) { + push('country', page.admin.country?.trim()); + push('region', page.admin.region?.trim()); + push('county', page.admin.county?.trim()); + } + + if (page.nearby) { + push('nearby', JSON.stringify(page.nearby)); + } + if (page.nearbyStores !== null) { + // `"[]"` is a real instruction — it clears neighbours that are no longer in radius. + push('nearby_stores', JSON.stringify(page.nearbyStores)); + } + return fields; } diff --git a/apps/store-pages/app/store-sync.server.test.ts b/apps/store-pages/app/store-sync.server.test.ts index e16a5fb..246273d 100644 --- a/apps/store-pages/app/store-sync.server.test.ts +++ b/apps/store-pages/app/store-sync.server.test.ts @@ -1,6 +1,11 @@ import { describe, it, expect, vi } from 'vitest'; import type { Store, StoreFeature, StoresSearchRequest } from '@woosmap/store-search-client'; -import { buildSyncQuery, syncStores, type MetaobjectUpserter } from './store-sync.server'; +import { + buildSyncQuery, + mergeEnrichment, + syncStores, + type MetaobjectUpserter, +} from './store-sync.server'; function feature(id: string, name = id, coords: [number, number] = [2.3, 48.8]): StoreFeature { return { @@ -42,6 +47,43 @@ describe('buildSyncQuery', () => { }); }); +describe('mergeEnrichment', () => { + it('combines the slices independent enrichers resolved', () => { + expect( + mergeEnrichment([{ admin: { region: 'Kent' } }, { nearbyStores: [] }, {}]), + ).toEqual({ admin: { region: 'Kent' }, nearbyStores: [] }); + }); + + it('carries a resolved empty array through — it is an instruction, not a no-op', () => { + expect('nearbyStores' in mergeEnrichment([{ nearbyStores: [] }])).toBe(true); + }); + + it('carries a resolved null through', () => { + expect(mergeEnrichment([{ nearby: null }])).toEqual({ nearby: null }); + }); + + it('ignores an undefined slice rather than overwriting one someone else filled', () => { + expect(mergeEnrichment([{ admin: { region: 'Kent' } }, { admin: undefined }])).toEqual({ + admin: { region: 'Kent' }, + }); + }); + + it('keeps the first writer on a collision and reports it', () => { + const onCollision = vi.fn(); + const merged = mergeEnrichment( + [{ admin: { region: 'Kent' } }, { admin: { region: 'Sussex' } }], + onCollision, + ); + expect([merged.admin, onCollision.mock.calls]).toEqual([{ region: 'Kent' }, [['admin']]]); + }); + + it('does not need a collision handler to stay safe', () => { + expect(mergeEnrichment([{ nearby: null }, { nearby: { updated_at: 't', groups: [] } }])).toEqual( + { nearby: null }, + ); + }); +}); + describe('syncStores', () => { it('upserts every store with a stable handle and reports a summary', async () => { const src = source([feature('store_A'), feature('store_B')]); @@ -60,21 +102,61 @@ describe('syncStores', () => { expect(firstFields['lat']).toBe('48.8'); }); - it('merges enrich() fields into the upsert; an enrich error never aborts the store', async () => { + it('carries enrich() onto the page; an enrich error never aborts the store', async () => { const src = source([feature('store_A'), feature('store_B')]); const upsert = vi.fn(async ({ handle }) => ({ id: `gid://${handle}`, handle })); const enrich = vi.fn(async (store: Store) => { if (store.storeId === 'store_B') throw new Error('boom'); - return [{ key: 'nearby', value: '{"updated_at":"t"}' }]; + return { nearby: { updated_at: 't', groups: [] } }; }); const result = await syncStores({ source: src, upsert, enrich }); expect(result.upserted).toBe(2); // both stores upserted, even though enrich threw on B const aFields = Object.fromEntries(upsert.mock.calls[0]![0].fields.map((f) => [f.key, f.value])); - expect(aFields['nearby']).toBe('{"updated_at":"t"}'); + expect(aFields['nearby']).toBe('{"updated_at":"t","groups":[]}'); const bFields = Object.fromEntries(upsert.mock.calls[1]![0].fields.map((f) => [f.key, f.value])); - expect(bFields['nearby']).toBeUndefined(); // enrich threw → no extra field, base upsert still ran + expect(bFields['nearby']).toBeUndefined(); // enrich threw → no key, base upsert still ran + }); + + it('emits the admin levels an enricher resolved', async () => { + const src = source([feature('store_A')]); + const upsert = vi.fn(async ({ handle }) => ({ id: `gid://${handle}`, handle })); + const enrich = async (): Promise<{ admin: { region: string; county: string } }> => ({ + admin: { region: 'Île-de-France', county: 'Paris' }, + }); + + await syncStores({ source: src, upsert, enrich }); + + const fields = Object.fromEntries(upsert.mock.calls[0]![0].fields.map((f) => [f.key, f.value])); + expect([fields['region'], fields['county']]).toEqual(['Île-de-France', 'Paris']); + }); + + it('stamps every store from the injected clock rather than reading the wall clock', async () => { + const src = source([feature('store_A'), feature('store_B')]); + const upsert = vi.fn(async ({ handle }) => ({ id: `gid://${handle}`, handle })); + const now = vi.fn(() => '2026-08-14T00:00:00.000Z'); + // The page's `computedAt` is not mapped onto a metaobject field, so the observable + // contract is that the clock is consulted once per store and never bypassed. + const spy = vi.spyOn(Date.prototype, 'toISOString'); + + await syncStores({ source: src, upsert, now }); + + expect([now.mock.calls.length, spy.mock.calls.length]).toEqual([2, 0]); + spy.mockRestore(); + }); + + it('clears a slice an enricher resolved as empty, instead of leaving the stored value', async () => { + const src = source([feature('store_A')]); + const upsert = vi.fn(async ({ handle }) => ({ id: `gid://${handle}`, handle })); + // The neighbour search ran and found nobody in radius: `[]` must reach the upsert, + // or `store.liquid` keeps rendering the previous run's links. + const enrich = async (): Promise<{ nearbyStores: [] }> => ({ nearbyStores: [] }); + + await syncStores({ source: src, upsert, enrich }); + + const fields = Object.fromEntries(upsert.mock.calls[0]![0].fields.map((f) => [f.key, f.value])); + expect(fields['nearby_stores']).toBe('[]'); }); it('passes a full-sync request (undefined) when no options are given', async () => { diff --git a/apps/store-pages/app/store-sync.server.ts b/apps/store-pages/app/store-sync.server.ts index c3291d8..0b766df 100644 --- a/apps/store-pages/app/store-sync.server.ts +++ b/apps/store-pages/app/store-sync.server.ts @@ -8,13 +8,50 @@ // same `upsertOne` path for a single store. import { featureToStore, - storeToMetaobjectFields, - storeToMetaobjectHandle, - type MetaobjectFieldInput, type Store, type StoreFeature, type StoresSearchRequest, } from '@woosmap/store-search-client'; +import { + buildLocalPage, + storeSlug, + type LocalPageConfig, + type LocalPageEnrichment, +} from '@woosmap/local-page-engine'; +import { localPageToMetaobjectFields, type MetaobjectFieldInput } from './metaobject-mapping.server'; + +/** + * Merge the enrichment slices several independent enrichers resolved for one store. + * + * Spelt out rather than `Object.assign(...)` + a cast: each enricher owns exactly one + * slice, so two of them writing the same one is a wiring mistake, and a spread would + * swallow it — last writer wins, silently, with the compiler talked out of the way. + * Here the first writer keeps the slice and `onCollision` gets told. Not fatal: an + * enrichment problem must never cost a store its base facts. + * + * `undefined` means "did not resolve" and never overwrites a slice someone else + * filled. `null` and `[]` are resolved values and do carry through — that is how the + * neighbour search says "this store has none any more". + */ +export function mergeEnrichment( + parts: LocalPageEnrichment[], + onCollision?: (key: keyof LocalPageEnrichment) => void, +): LocalPageEnrichment { + const merged: LocalPageEnrichment = {}; + for (const part of parts) { + for (const key of Object.keys(part) as Array) { + if (part[key] === undefined) { + continue; + } + if (key in merged) { + onCollision?.(key); + continue; + } + Object.assign(merged, { [key]: part[key] }); + } + } + return merged; +} /** Just the client surface the sync needs — narrow, so tests inject a fake. */ export interface StoreSource { @@ -34,12 +71,16 @@ export interface SyncDeps { /** Optional progress hook (e.g. logging). */ onProgress?: (event: SyncProgress) => void; /** - * Optional per-store enrichment: extra metaobject fields to merge before the - * upsert (e.g. server-side `nearby` POIs). Returning `[]` writes nothing extra, - * so `metaobjectUpsert` leaves any existing value untouched. A throw is caught - * and does not abort the run (the store is still upserted with its base fields). + * Optional per-store enrichment. Returns whatever was resolved — an empty object + * carries nothing, so the mapper emits no enrichment keys and `metaobjectUpsert` + * leaves any existing values untouched. A throw is caught and does not abort the + * run: the store is still upserted with its base facts. */ - enrich?: (store: Store) => Promise; + enrich?: (store: Store) => Promise; + /** Per-client page config (branding, url base, SEO templates, map geometry). */ + pageConfig?: LocalPageConfig; + /** Injected clock (ISO string), so a run is reproducible in tests. */ + now?: () => string; } /** Options controlling which stores are synced. */ @@ -89,20 +130,26 @@ export function buildSyncQuery(options: SyncOptions = {}): string | undefined { } /** - * Run a sync. Iterates matching Woosmap stores, maps each to metaobject fields, - * and upserts it. One store failing does not abort the run — the error is - * collected and the sync continues, so a bad record can't block the rest. + * Run a sync. Iterates matching Woosmap stores, builds a platform-neutral + * `LocalPage` for each, maps it to metaobject fields, and upserts it. One store + * failing does not abort the run — the error is collected and the sync continues, + * so a bad record can't block the rest. + * + * The page is built here rather than in the mapper on purpose: the same document + * is what a feed or a server-rendered page would consume, so this loop is the only + * Shopify-specific thing left in the pipeline. */ export async function syncStores(deps: SyncDeps, options: SyncOptions = {}): Promise { const query = buildSyncQuery(options); const request: StoresSearchRequest | undefined = query ? { query } : undefined; + const clock = deps.now ?? ((): string => new Date().toISOString()); const result: SyncResult = { total: 0, upserted: 0, skipped: 0, failed: 0, errors: [] }; for await (const feature of deps.source.iterateStores(request)) { result.total += 1; const store = featureToStore(feature); - const handle = storeToMetaobjectHandle(store); + const handle = storeSlug(store.storeId); // A store with no id/name can't produce a valid, addressable metaobject. if (!store.storeId || !store.name || !handle) { @@ -112,14 +159,16 @@ export async function syncStores(deps: SyncDeps, options: SyncOptions = {}): Pro } try { - const fields = storeToMetaobjectFields(store); + let enrichment: LocalPageEnrichment = {}; if (deps.enrich) { try { - fields.push(...(await deps.enrich(store))); + enrichment = await deps.enrich(store); } catch { - // Enrichment must never block the base upsert — skip the extra fields. + // Enrichment must never block the base upsert — ship the facts alone. } } + const page = buildLocalPage(store, enrichment, deps.pageConfig ?? {}, { now: clock() }); + const fields = localPageToMetaobjectFields(page); await deps.upsert({ handle, fields }); result.upserted += 1; report(deps, { storeId: store.storeId, handle, status: 'upserted' }); diff --git a/apps/store-pages/app/sync-runner.ts b/apps/store-pages/app/sync-runner.ts index fc2a62a..21f7d2e 100644 --- a/apps/store-pages/app/sync-runner.ts +++ b/apps/store-pages/app/sync-runner.ts @@ -4,7 +4,7 @@ // It wires the real dependencies to the pure `syncStores` logic. In the embedded // Remix app you'd instead call `syncStores` from a resource route, passing an // executor built from the request's `admin.graphql`. -import { featureToStore, storeToMetaobjectHandle, type MetaobjectFieldInput, type Store } from '@woosmap/store-search-client'; +import { featureToStore, type Store } from '@woosmap/store-search-client'; import { createStoreClient } from './woosmap.server'; import { DEFAULT_STORE_METAOBJECT_TYPE, @@ -14,10 +14,18 @@ import { listStoreNearbyTimestamps, upsertStoreMetaobject, } from './admin-graphql.server'; -import { syncStores } from './store-sync.server'; -import { enrichNearby, isNearbyStale, parseNearbyGroups } from './nearby-enrich.server'; -import { buildAdminFields, hasAdmin, reverseGeocode } from './admin-enrich.server'; -import { buildStoreIndex, findNearbyStores } from './nearby-stores.server'; +import { mergeEnrichment, syncStores } from './store-sync.server'; +import { + buildStoreIndex, + enrichNearby, + findNearbyStores, + hasAdmin, + isNearbyStale, + parseNearbyGroups, + reverseGeocode, + storeSlug, + type LocalPageEnrichment, +} from '@woosmap/local-page-engine'; function requireEnv(name: string): string { const value = process.env[name]; @@ -43,12 +51,14 @@ async function main(): Promise { }); const client = createStoreClient(); + // Where the pages live. One expression, used by the definition's url handle, the + // page's canonical path and the neighbour links — they cannot be allowed to drift. + const urlHandle = process.env.STORE_URL_HANDLE ?? 'stores'; + const urlBase = `/pages/${urlHandle}`; + // Create the merchant-owned definition on first run, else ensure its capabilities // (public URL + SEO mapping). Idempotent. - const outcome = await ensureStoreDefinition(execute, { - type, - urlHandle: process.env.STORE_URL_HANDLE ?? 'stores', - }); + const outcome = await ensureStoreDefinition(execute, { type, urlHandle }); if (outcome === 'created') { console.log('Created the merchant-owned `store` metaobject definition (online_store + renderable SEO + publishable).'); } else if (outcome === 'updated') { @@ -56,9 +66,9 @@ async function main(): Promise { } // Optional server-side enrichment (SEO/GEO), composed from independent steps. - // Each returns extra metaobject fields; an empty array leaves existing values - // untouched (metaobjectUpsert doesn't clear unspecified fields). - const enrichers: Array<(store: Store) => Promise> = []; + // Each returns the slice of the page it resolved; an empty object carries nothing, + // so the mapper emits no key and metaobjectUpsert leaves existing values untouched. + const enrichers: Array<(store: Store) => Promise> = []; const nearby = { refreshed: 0, kept: 0 }; const admin = { filled: 0, kept: 0 }; const nearbyStores = { withNeighbours: 0, alone: 0 }; @@ -74,15 +84,15 @@ async function main(): Promise { const now = new Date(); const timestamps = await listStoreNearbyTimestamps(execute, { type }); enrichers.push(async (store) => { - if (store.lat === null || store.lng === null) return []; - const handle = storeToMetaobjectHandle(store); + if (store.lat === null || store.lng === null) return {}; + const handle = storeSlug(store.storeId); if (!isNearbyStale(timestamps.get(handle), maxAgeDays, now)) { nearby.kept += 1; - return []; // fresh → leave the existing `nearby` untouched + return {}; // fresh → leave the existing `nearby` untouched } const data = await enrichNearby(fetch, woosmapPrivateKey, store.lat, store.lng, now.toISOString(), groups); nearby.refreshed += 1; - return [{ key: 'nearby', value: JSON.stringify(data) }]; + return { nearby: data }; }); console.log(`Nearby enrichment ON (TTL ${maxAgeDays} days, ${groups.length} group(s)).`); } @@ -93,16 +103,16 @@ async function main(): Promise { const woosmapPrivateKey = requireEnv('WOOSMAP_PRIVATE_KEY'); const present = await listStoreAdminPresence(execute, { type }); enrichers.push(async (store) => { - if (store.lat === null || store.lng === null) return []; - const handle = storeToMetaobjectHandle(store); + if (store.lat === null || store.lng === null) return {}; + const handle = storeSlug(store.storeId); if (present.has(handle)) { admin.kept += 1; - return []; // already enriched + return {}; // already enriched } const areas = await reverseGeocode(fetch, woosmapPrivateKey, store.lat, store.lng); - if (!hasAdmin(areas)) return []; + if (!hasAdmin(areas)) return {}; admin.filled += 1; - return buildAdminFields(areas); + return { admin: areas }; }); console.log('Admin enrichment ON (fill-once, no TTL).'); } @@ -113,7 +123,6 @@ async function main(): Promise { if (process.env.ENRICH_NEARBY_STORES === 'true') { const radiusKm = Number(process.env.NEARBY_STORES_RADIUS_KM ?? '10'); const limit = Number(process.env.NEARBY_STORES_LIMIT ?? '3'); - const urlBase = `/pages/${process.env.STORE_URL_HANDLE ?? 'stores'}`; const allStores: Store[] = []; for await (const feature of client.iterateStores()) { allStores.push(featureToStore(feature)); @@ -129,21 +138,37 @@ async function main(): Promise { } else { nearbyStores.alone += 1; } - return [{ key: 'nearby_stores', value: JSON.stringify(list) }]; + // Always a resolved value, `[]` included: the search ran, so the stored list is + // replaced. Omitting it for a store with no neighbour left would keep rendering + // yesterday's links. + return { nearbyStores: list }; }); } const enrich = enrichers.length > 0 - ? async (store: Store): Promise => { + ? async (store: Store): Promise => { const parts = await Promise.all(enrichers.map((run) => run(store))); - return parts.flat(); + return mergeEnrichment(parts, (key) => + console.error(` ! two enrichers both resolved \`${key}\`; keeping the first.`), + ); } : undefined; const result = await syncStores( { source: client, + // Per-client page config. The public key is optional here: the Shopify theme + // reads its own, so the sync only needs it if a consumer wants the map URL + // baked into the document (a feed would). STORE_PAGE_ORIGIN likewise — Liquid + // has `canonical_url`, so only an off-platform consumer needs absolute URLs. + pageConfig: { + urlBase, + ...(process.env.WOOSMAP_PUBLIC_KEY ? { publicKey: process.env.WOOSMAP_PUBLIC_KEY } : {}), + ...(process.env.STORE_BRAND ? { brand: process.env.STORE_BRAND } : {}), + ...(process.env.STORE_PAGE_ORIGIN ? { origin: process.env.STORE_PAGE_ORIGIN } : {}), + ...(process.env.STORE_LOCALE ? { locale: process.env.STORE_LOCALE } : {}), + }, upsert: ({ handle, fields }) => upsertStoreMetaobject(execute, { type, handle, fields, status }), onProgress: (event) => { if (event.status === 'failed') { diff --git a/apps/store-pages/app/woosmap-transport.ts b/apps/store-pages/app/woosmap-transport.ts deleted file mode 100644 index 524f16e..0000000 --- a/apps/store-pages/app/woosmap-transport.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** A `fetch`-like function (injected in tests). */ -export type FetchLike = (url: string) => Promise<{ ok: boolean; json: () => Promise }>; - -/** Adapt an injected fetch-like into the client libs' Transport shape. */ -export function toTransport(fetchImpl: FetchLike) { - return (url: string) => fetchImpl(url).then((r) => ({ ok: r.ok, status: r.ok ? 200 : 502, statusText: '', json: r.json })); -} diff --git a/apps/store-pages/package.json b/apps/store-pages/package.json index 3d12023..02b5788 100644 --- a/apps/store-pages/package.json +++ b/apps/store-pages/package.json @@ -17,6 +17,7 @@ }, "dependencies": { "@woosmap/distance-client": "workspace:*", + "@woosmap/local-page-engine": "workspace:*", "@woosmap/localities-client": "workspace:*", "@woosmap/store-search-client": "workspace:*" }, diff --git a/apps/store-pages/theme/templates/metaobject/store.liquid b/apps/store-pages/theme/templates/metaobject/store.liquid index e2e64cf..2908186 100644 --- a/apps/store-pages/theme/templates/metaobject/store.liquid +++ b/apps/store-pages/theme/templates/metaobject/store.liquid @@ -23,8 +23,22 @@
- {%- comment -%} Administrative breadcrumb — values only, consecutive duplicates dropped (city-states). {%- endcomment -%} - {%- capture crumb_raw -%}{% if metaobject.country.value != blank %}{{ metaobject.country.value }}|{% endif %}{% if metaobject.region.value != blank %}{{ metaobject.region.value }}|{% endif %}{% if metaobject.county.value != blank and metaobject.county.value != metaobject.region.value %}{{ metaobject.county.value }}|{% endif %}{% if metaobject.city.value != blank and metaobject.city.value != metaobject.county.value %}{{ metaobject.city.value }}|{% endif %}{%- endcapture -%} + {%- comment -%} + Administrative breadcrumb — values only, blanks and CONSECUTIVE duplicates + dropped. Each level is compared with the one above it, so Paris (county = city) + renders once and Luxembourg (country = region = county = city) renders once + instead of four times. Country vs region is compared too — leaving that pair out + is what used to render "Luxembourg › Luxembourg". + + The trail is built once here and reused by the BreadcrumbList at the bottom of + this file: two renderings, one rule. Same rule as `buildBreadcrumb` in + @woosmap/local-page-engine — keep them in step. + {%- endcomment -%} + {%- assign crumb_country = metaobject.country.value | strip -%} + {%- assign crumb_region = metaobject.region.value | strip -%} + {%- assign crumb_county = metaobject.county.value | strip -%} + {%- assign crumb_city = metaobject.city.value | strip -%} + {%- capture crumb_raw -%}{% if crumb_country != blank %}{{ crumb_country }}|{% endif %}{% if crumb_region != blank and crumb_region != crumb_country %}{{ crumb_region }}|{% endif %}{% if crumb_county != blank and crumb_county != crumb_region %}{{ crumb_county }}|{% endif %}{% if crumb_city != blank and crumb_city != crumb_county %}{{ crumb_city }}|{% endif %}{%- endcapture -%} {%- assign crumbs = crumb_raw | split: '|' -%} {%- if crumbs.size > 0 -%}