Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
node_modules
build
public/build
dist
coverage
*/*.yml
.shopify
5 changes: 4 additions & 1 deletion .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"],

Expand Down
53 changes: 53 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 3 additions & 1 deletion ONBOARDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion apps/checkout-autocomplete/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
49 changes: 39 additions & 10 deletions apps/store-pages/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <img> + JSON-LD
Expand All @@ -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
Expand Down Expand Up @@ -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/<handle>/…`, 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
Expand Down Expand Up @@ -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

Expand Down
63 changes: 0 additions & 63 deletions apps/store-pages/app/admin-enrich.server.ts

This file was deleted.

2 changes: 1 addition & 1 deletion apps/store-pages/app/admin-graphql.server.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
2 changes: 1 addition & 1 deletion apps/store-pages/app/admin-graphql.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:`
Expand Down
29 changes: 24 additions & 5 deletions apps/store-pages/app/metaobject-contract.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
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
// merchant-owned `store` metaobject definition (ensureStoreDefinition). If the mapper
// 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',
Expand All @@ -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);
Expand Down
Loading
Loading