Knowledge base
A collection of "how to" procedures in the admin panel and via the API. Each section is self-contained.
How to add a new content model
/admin/models→ New model. Provide a key (snake_case), a name, and a kind
(collection, single, component).
- In Taxonomy choose a parent category and the category name (one per model)
and comma-separated tags.
- Add fields in the field editor: kind, requiredness, validation, UI variants,
localized. - Save draft → Publish. The panel will show a diff and flag breaking changes
(column removal, type change) before running the migration.
- After publishing, the model immediately has REST, JSON:API, GraphQL, OpenAPI, and a form — check
/admin/interfaces.
Alternative: /admin/models/ai — describe the model in plain language, and AI will generate a definition for review. On this page you also get:
- Domain templates — a ready brief for a typical domain (e-commerce, editorial CMS, PIM, CRM, events, knowledge base, real estate, telecom TMF, HR, IoT). Check the add-ons (localized content,
seocomponent, category+tags taxonomy, workflow fields,external_id), add your own requirements, and click *Use this brief*. - Samples / Tests — for each proposed model we generate sample records and run the same validator on them as the API (missing required field, wrong enum, exceeded limits, wrong formats, invalid relation). A red test = the proposed model is inconsistent, fix the field before saving.
- DDL / GraphQL SDL / OpenAPI — the exact contract that will result from publishing:
cms.*tables with indexes and RLS, GraphQL types, and OpenAPI 3.2 paths. Nothing is saved until you choose *Save as drafts* or *Save and publish*.
How to organize taxonomy
/admin/models→ Manage taxonomy.- Add category: name + parent (
No parent= root). The category is saved in the term registry,
so it's visible (with an empty badge) even when no model uses it yet.
- Add tag: the tag is normalized to a
slug(lowercase,-), and it too can exist "empty". - Rename / move: a new path moves the entire subtree (
Marketing / Web→Growth / Web). - Delete: models fall back to the parent or to the indicated category — nothing is left without a category.
- Tags: global rename and deletion across the entire registry.
- Assign models: select models, choose a category and/or provide comma-separated tags → a single save
rewrites all selected definitions as new draft versions (with an audit entry).
How to ingest files from Google Drive
Drive has folders and nothing else — we add categories, tags, alt text, and descriptions at import time.
/admin/media→ Import from Drive. If the company Drive account isn't connected, the dialog will
say so directly — connecting it is done by an administrator in the project's connectors.
- Browse the Drive tree (breadcrumb at the top) or search by name. Select files with checkboxes.
Google Docs/Sheets/Slides files import as PDF/XLSX; forms and shortcuts are skipped.
- Choose a Mode:
- Copy into library — bytes land in our bucket: CDN, variants, checksum deduplication,
no dependency on Drive permissions.
- Reference — the file stays in Drive, we keep metadata and a link. Zero duplication, but access
depends on Drive.
- Set the Library folder, Category (
brand/logos), and Tags. Empty fields aren't a problem:
the Drive folder path (Brand/Logos & Marks) itself yields the brand/logos-marks category and tags.
- Leave Let AI fill missing… enabled — AI fills in only the missing title, description, alt text
(for images), and tags. Nothing you set manually or that a rule provided will be overwritten.
- Save folder rule saves the selected Drive folder as a rule: the next import from it (including
subfolders) automatically gets the same destination folder, category, tags, and mode. Rules are visible and removable at the bottom of the dialog; the deepest rule wins over a parent rule.
After import, the panel shows the result per file (imported / linked / skipped / failed) along with whether AI filled in the metadata. Every import and every rule change is audited (drive.imported, drive.mapping.saved).
How to set up the Geolocation field (address, map, reverse geocoding)
The geolocation field stores { lat, lng, label?, zoom? }. In the admin panel you have three equivalent ways:
- Type an address in the search field above the map. After ~350 ms a list of suggestions (autocomplete) appears;
Enter picks the first one, Esc closes the list. Selecting one sets lat, lng, and label (the full address) and keeps the current zoom.
- Click the map or drag the pin. Coordinates update immediately, and in the background
*reverse geocoding* starts, filling in the address into label. If you move the point in the meantime, the late response is discarded — label will never describe a different place than lat/lng.
- Type lat/lng manually — this also works when the geocoding service is unavailable.
Example of a value saved after choosing a suggestion:
{ "lat": 52.231924, "lng": 21.006727, "label": "Pałac Kultury i Nauki, Warszawa, Polska", "zoom": 13 }Technical details: requests go through the authenticated server functions searchPlaces and reverseGeocode (src/lib/platform.functions.ts), the adapter is src/platform/server/geocode.server.ts (OpenStreetMap Nominatim, no API key — the same data source as the map tiles). Parsing and validation of the response live in src/platform/geo/geocode.ts: a row without valid coordinates is rejected rather than coerced to 0,0. Results are cached for 5 minutes and debounced, because the provider is shared and rate-limited.
How to publish an entry
draft → review → scheduled → publishedIn the entry editor, the Pipeline panel shows the transitions allowed for your role. scheduled requires a date — the scheduler publishes the entry automatically. Every transition creates an immutable revision with a JSONB snapshot, so a rollback is always possible.
How to translate content
Fields with localized: true have a version per locale in cms.entry_translations. Each translation row carries its own lifecycle: draft → in_review → published (src/platform/i18n/coverage.ts). Delivery — public REST, JSON:API and the SSR site — only overlays translations whose status is published; draft and in_review values stay visible in the workspace and behind preview tokens (publicationState=draft|any), so an unfinished translation can never reach a public read. Unresolved fields keep walking the fallback chain from platform_locales.
The Translations panel on the entry sets the status and shows a chip per locale with how many localized fields are filled. The entry list adds a Localization strip (LocaleCoverageStrip) with a per-locale roll-up over the visible page and a "Show gaps" list that links straight to the entries still missing translations.
Translation work does not have to happen in the panel. Every localized model exposes GET /api/public/v1/{model}/{id}/translations, GET|PUT|DELETE /api/public/v1/{model}/{id}/translations/{locale} and GET /api/public/v1/{model}/{id}/translations/revisions, plus the matching GraphQL queries and mutations — so a translation agency, a machine-translation job or an agent can push a locale, keep it in in_review, and let an editor promote it to published. The API accepts localized fields only, refuses the default locale (those values live on the entry), and records every write as a translation revision.
How to expose data publicly
/admin/tokens→ create a token with a scope (read:article).- Query REST with the headers:
curl -H "Authorization: Bearer $TOKEN" -H "x-account: $ACCOUNT_ID" \
"https://schemas.xeper.io/api/public/v1/article?filter[status]=published"- Draft previews: a preview token from
/admin/environmentsand thex-preview-tokenheader.
How to deliver content to multiple channels
/admin/channels→ define channels (e.g.web,smart-watch,ai-agent,in-car-display).
The platform suggests examples; disable the ones you don't support and add your own. The channel key is exactly what the consumer sends in ?channel=.
- In the model editor, open a field → the Rules tab → *Delivery channels*.
Leave All channels if the field is universal; select channels if it should go only to them (e.g. body → Web, short_title → Smart Watch + Voice).
- Query REST with the channel:
curl -H "Authorization: Bearer $TOKEN" \
"https://schemas.xeper.io/api/public/v1/article?channel=smart-watch"
# response: universal fields + those tagged 'smart-watch'; x-cms-channel and Vary headers- The Field coverage panel at
/admin/channelsshows how many fields each channel gets
and flags required fields that never reach a given channel — this is usually a modeling error (the consumer will get an incomplete payload).
Omitting ?channel= returns the full payload, so existing integrations don't change behavior after tags are added. Tagging has no effect on writes, validation, or forms.
How to promote between environments
/admin/environments → the Promotion panel. The engine computes a plan per entry (publish, submit, skip) and respects requires review. The result is written to platform_environment_promotions and to the audit log.
How to manage the team and access
/admin/people (visible to a role with manage_users).
- Invitation — email + baseline role + optional account. The person receives an email
invitation; if the account already exists, we update the role instead of creating a duplicate.
- Baseline role — one platform role per user (
Administrator,
Developer, Product manager, Content editor, Viewer); it determines capabilities everywhere in the panel and API.
- Per-account access — "Manage access" → add an account with the role that applies in that
tenant; access is inherited down the account tree. "Revoke" removes it immediately.
- Groups — the *Groups* tab: hierarchical teams (optionally per account).
Permission sets can target a group instead of individual people.
Effective access = baseline role + roles from account memberships, narrowed by permission sets (grant → restrict) and FLS. You can check it in the Access simulator at /admin/permissions.
How to grant permissions
/admin/permissions→ agrantset (adds) orrestrictset (subtracts).- Assign to a role, group, or user; add record conditions and field-level FLS.
- Check the effect in the Access simulator — it shows the decision and the rule that made it.
How to import a model from another CMS
/admin/models/ai→ Import definitions → *Open import wizard*.- Upload or paste the export (
.json,.yaml): Strapi content-types, a Directus snapshot,
Contentful content types, or a canonical export of this platform. *Load a Strapi example* shows the expected shape.
- Analyse export — the wizard names the detected dialect and lists every field with its match
quality: exact, approximate, widened (the type had to be broadened) and unsupported (not importable, skipped on purpose).
- Correct anything you disagree with: click the canonical type to pick another field type, or
toggle *included / excluded* per field.
- Use this import loads the models into the review panel. Check the derived contract (DDL,
GraphQL SDL, OpenAPI 3.2) and sample records, then save as drafts or publish.
Relations, components, dynamic zones and many-to-any references are only kept when the target model is in the same export or already in the registry — otherwise they are dropped and listed under *Auto-corrected*. Import the target model first, or import both exports together.
How to reword the interface (translations)
English is the source language: every key is authored in src/platform/i18n/messages/en.ts, and other locales are translations of the same key set. /admin/translations adds an editable layer on top of the shipped catalogs, scoped to the selected account.
- Pick the locale you are editing (English stays the untouched source).
- Filter the inventory: free-text search across key, English source and current translation, plus a
namespace filter (nav, content, access, …) and a state filter — *Shipped copy*, *Customised*, *Needs translation*.
- Edit the value. Live preview renders real panel chrome with your unsaved value applied, so the
wording is judged next to its neighbours.
- Save stores the override and appends a version; Reset to shipped copy removes it.
- Version history lists every edit with author and timestamp; *Restore* writes an old value
forward as a new version — nothing is rewritten.
Resolution order at runtime is override → bundled translation → English source, so a missing, blank or removed override can never blank the interface. Overrides are inherited down the account tree: a value set on the organisation applies to its brands and projects unless they define their own. Editing requires the manage_schema capability; other roles see the screen read-only.
How to connect an AI agent
- Content MCP:
/mcp(OAuth 2.1) — reading and writing content. - Management MCP: managing schemas and permissions.
- Live events:
GET /api/public/v1/events/stream(SSE). Every frame carries anid:cursor (<createdAt>|<eventId>); on reconnect the browser sends it back asLast-Event-ID(non-browser clients can pass?since=<cursor>) and the stream resumes without gaps or duplicates.?types=entry.*,asset.createdfilters events — an invalid pattern returns400instead of being ignored. The server advertisesretry: 3000, closes after 5 minutes and allows 4 concurrent streams per principal (429withRetry-Afterbeyond that).
How to search from the admin panel
Two surfaces, one engine (searchContent), so permissions, field-level security and ranking are identical wherever you search from.
- Quick search (⌘K / Ctrl+K) — available on every admin screen. Type at least two characters,
move with the arrow keys, press Enter to open the entry, or choose "See all results" to continue on the full screen with the query carried over in the URL.
- Search workspace (`/admin/search`) — facet rail on the left, ranked results in the middle,
preview of the selected hit on the right. The preview shows status, model, locale, relevance, highlighted excerpts and the entry's scalar fields, with shortcuts to edit the entry or open the model list. Query and page live in the URL, so a result page is shareable.
- Search preferences (button next to the search box) — pagination style (numbered pages with a
counter, or a "load more" stream), results per page (25/50/100) and where a hit opens (side panel or sheet). Preferences are stored per person and inherit from a role default; "Reset to defaults" removes your row so the role default applies again.
- Ranking is never configurable: relevance is computed by the query engine over the whole match set
before paging, which is why page 2 continues page 1 instead of reshuffling it.
How to author a recipe and change it at order time
The recipe lives on the menu item, next to the option groups: recipe[] is what the kitchen puts on before the guest touches anything, and the option groups are the questions asked at order time.
- Recipe line —
ingredientCode,quantity+unit,allergens, plus three switches:
removable: false marks a structural line (dough, bun, patty) the guest cannot take off, removalCredit is the money returned when the line does come off, and swapOptions[] lists the option codes that may replace the line.
- Removals — a removal without a credit travels with the order verbatim so the kitchen reads
"no onion"; a removal with a credit also reduces optionsTotal. Structural lines are rejected.
- Swaps — a swap is an ordinary option with
action: "swap", so it is priced, validated and
audited like every other selection; the recipe only says which swaps make sense for that line.
- Live configurator (`/admin/configurator`) — pick a menu item that exists as content, toggle live recalculation and
every click recomputes the base price, each option line with its reason code (base_size, size_factor, free_allowance, extra_portions, removal_credit, implied_by_rule), the recipe credits and the rule verdicts (satisfied / violated / advisory / implied). Turning live mode off queues changes and recomputes on demand, which is how you demo an expensive pricing run.
- The screen calls
configureItem+explainConfigurationfromsrc/platform/commerce— the same
functions the REST and GraphQL order endpoints use, so a price shown here is the price charged.
Behaviours: audit, history, attachments, duplicates
Each model has a Behaviours panel in the Schema Builder (/admin/models/{key}):
- Audit changes / Track changes — turn the audit trail and the revision history off for
high-volume models where diffs are noise. Both are on by default.
- Attachments — adds an
attachmentsasset list to the model, so authors can attach files from
the media library to any record. Turning it off removes the managed field again.
- Quick create — relation pickers can create the related entry inline.
- Help link / help text — shown above the entry form, so editorial guidance lives with the model.
- Duplicate detection — pick the columns that define identity, then tune each one separately:
*Exact*, *Ignore case*, *Normalized* (ignores accents, punctuation and double spaces, so „ACME, Inc." equals „acme inc"), *Same prefix* (first N characters — good for surnames), *± tolerance* for numbers and dates (e.g. amounts within 100, dates within 7 days) or *Similar* for fuzzy text matching with a minimum similarity. Each column carries a weight (how much it counts towards the score) and can be marked optional (an empty value no longer cancels the rule). The rule fires when every required column agrees and the weighted score reaches your threshold, and then either *Blocks the save* (the author gets a 409 with your message) or *Warns and audits* (the save goes through and entry.duplicate_warning lands in the audit log). Narrow a rule further with a scope: only within the same locale, or only against chosen lifecycle statuses (e.g. compare against published rows only). Use a field's *unique* switch when one column alone defines identity.
Behaviours are saved as a new draft version — publish the model to apply them.
Where recipes and options live (Content Manager, not code)
Ingredients, allergens, options, option groups and recipes are ordinary entries of the restaurant-menu models (food_ingredient, food_allergen, food_option, food_option_group, food_menu_item, …), so they are edited through normal layouts, versioned, translated, permissioned and exposed over REST/GraphQL like any other content. No code change is needed to add a topping, re-price a size or write a new recipe.
- Seed —
src/platform/templates/seeds/restaurant-menu.tsholds the reference Margherita and
house burger. References between rows use @schema_key:code tokens that the seeder resolves to the generated UUIDs, so the file stays readable. Seeding is idempotent: existing codes are skipped.
- Server —
src/platform/server/menu-configurator.server.tspublishes the template if needed,
seeds on request (seedRestaurantMenu), lists configurable items and loads one full snapshot (loadConfigurableMenuItem) with its groups, options, ingredients and allergens.
- Bridge —
src/platform/commerce/food-menu.ts(buildConfigurableItem) maps those rows to the
engine's ConfigurableItem: recipe lines with quantity/unit/costPerUnit/allergens, removable lines, option ingredient costs and size factors.
- RPC —
src/lib/menu.functions.tsexposeslistMenuItemsFn,getConfigurableItemFnand
seedRestaurantMenuFn to the admin UI. The configurator route fetches the item and holds no menu data of its own; an empty workspace shows an "install models & seed demo menu" panel.
- Editor layouts —
src/platform/templates/seeds/configurator-layouts.tsships curatedtable
and form layouts for food_menu_item, food_modifier_group, food_modifier_option, food_ingredient, food_pricing_policy and food_menu_category: linked name column, filters and grouping in the lists; tabbed dish form (Presentation / Pricing / Recipe & cost / Guest options / Service) with a relationList for upsell items. installConfiguratorLayouts (src/platform/server/configurator-layouts.server.ts, exposed as seedConfiguratorLayoutsFn, button "Install configurator layouts" on /admin/configurator) publishes them through the normal saveViewDraft → publishView lifecycle, so product and variant editing is standard CRUD in the Content Manager, with the generated REST/GraphQL endpoints unchanged. Re-running updates instead of duplicating; a layout whose model is not published in the account is reported as skipped.
- Recipe line behaviour is data — the
food_recipe_linecomponent carriesremovable(per-dish
override; empty inherits the ingredient's own flag), removal_credit (money returned when a guest takes the line off) and swap_options (many-to-many to food_modifier_option, the alternatives offered instead). buildConfigurableItem maps them to RecipeLine.removable / removalCredit / swapOptions and derives removableIngredients from the line overrides, so removals, credits and swaps that used to exist only in the hard-coded demo objects are now editable content. DEMO_PIZZA / DEMO_BURGER in src/platform/commerce/demo-configurations.ts are test fixtures only — the reference pizza and burger the workbench prices come from the restaurant-menu seed.
The engine stays in code (pricing, COGS, rules, half portions), the catalogue stays in the database.
How to track COGS and margins on a recipe
Every recipe line and configurable option can carry a costPerUnit. The costing engine uses the same configuration the pricing engine uses, so the margin shown next to the price is auditable line by line.
- Recipe cost — sum of
quantity * costPerUnit * sizeCostFactorfor every recipe line still on the dish. - Options cost — added options and extras that are not part of the recipe.
- Removal credits — negative cost lines for recipe ingredients the guest removed (only when the line has a cost).
- Swap adjustment — swapping out a recipe ingredient credits its cost; swapping in an option adds its cost.
- Half portions — options with
action: "half_left"or"half_right"scale both price and cost by0.5. - Margin / markup — computed from the priced
lineTotal: margin = lineTotal - lineCostmarginPercent = (margin / lineTotal) * 100markupPercent = (margin / lineCost) * 100
In the admin configurator (/admin/configurator) the Cost & margin panel shows the unit cost, line cost, margin, margin percent and markup percent, plus a per-line breakdown with reason codes (recipe_default, recipe_scaled, option_added, extra_portion, half_portion, recipe_removal, swap_in, swap_out). Use this to validate that a half-pizza or a "no cheese" order still leaves the kitchen with a positive margin.
How to steer pricing from a policy entry instead of code
Publish a Pricing policy (food_pricing_policy) entry and the configurator reads its numbers:
| Field | Effect on the cost breakdown | ||
|---|---|---|---|
currency | overrides the dish currency everywhere in the workbench | ||
target_margin_percent | verdict below_target plus a suggested price that hits it | ||
min_margin_percent | verdict below_floor — the configuration loses money | ||
labour_cost_per_item, labour_cost_per_minute | a labour line (per minute uses prep_minutes of the dish) | ||
packaging_cost | a packaging line | ||
overhead_percent | an overhead line, percentage of the ingredient cost | ||
waste_percent | waste_allowance lines for recipe lines that declare no own waste | ||
rounding | rounds the suggested price (up_05, up_09, `nearest_10 | 50 | 100`) |
Scope the policy with item_types, categories and channels and order competing policies with priority — an empty scope array means "everything". A dish can pin one policy through its pricing_policy relation, which always wins over scope matching. Set waste_percent on a single recipe line to override the policy default (trimming loss on onions, dough scrap), and cost_factor on a size to scale ingredient cost separately from the topping price factor.
The rule trace next to the price now names the entry that authored each rule (item, group or option, with the group code), so fixing a contradictory rule means opening that entry — no code change.
How to get a price change approved (policy versions)
Editing a pricing policy does not change any price. The policy entry is a working copy; prices are produced from an approved version — a frozen snapshot of the numbers above with a checksum and an approval trail. The whole flow lives in the "Pricing policy approvals" panel on /admin/configurator:
- Edit the policy entry in the Content Manager as usual. The panel now reports **Working copy
drifted** and lists every changed number (before → after). Prices keep using the previously approved version.
- Write a short note (why the numbers change) and press Submit working copy for review. That
freezes the numbers as v(N) with status in_review. Submitting unchanged numbers is refused.
- A controller presses Approve v(N) — the version becomes effective immediately, the version it
replaces is retired with an effective_to stamp, and every price from that moment names v(N). Reject sends it back without touching prices.
Because exactly one approved version is effective at a time, every cost breakdown can answer "which ruleset produced this price, who signed it off and when" — the version number, checksum, approver and effective window travel with the price. The history table in the panel keeps all versions, including rejected ones, and each submission and decision also lands in the audit log and fires a pricing_policy.version_* webhook event.
States you may see: Approved & effective (working copy matches what was signed off), Working copy drifted (edits waiting to be submitted), Awaiting decision (a version is in review) and Never approved (nothing was signed off yet, so the policy does not price anything).
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
403 on REST | missing token scope or restrict | check the Access simulator |
| Field not in the API | FLS or password/secret | secret fields are never returned |
| Draft not visible publicly | delivery enforces published | use a preview token |
| Publish blocked | requires review in the environment | go through review |
Read-only in the admin panel (UI capabilities)
Authorization is resolved on the server (requireCapability), and the UI only reflects it, so as not to show buttons that would return a 403. The useCapabilities() hook (src/hooks/use-capabilities.ts) reads roles from the session:
const { can } = useCapabilities();
const canManage = can("manage_schema");
<PageHeader title="Channels" actions={canManage ? undefined : <ReadOnlyBadge />} />
{canManage ? <Panel title="Define a channel">…</Panel> : null}Rules:
- Preview for all logged-in users:
/admin/api,/admin/interfaces,/admin/channels,/admin/sdk
(public contract — the same as openapi.json). A Content editor sees them with a read-only badge.
- Hidden without the permission (
requiresinNAV_GROUPS): Environments and Webhooks (manage_api),
Accounts (manage_users), Permissions (manage_permissions), Audit (read_audit).
- Writes always remain gated by a capability: e.g.
saveChannel/applyManifest→manage_schema.
Who has access to what over the API (RBAC + ABAC + scopes)
The access decision is formed in four steps — each subsequent one can only narrow:
- The actor's baseline role (
user_rolesor token roles) → capabilities and actions on a model. - Permission sets (
grant, thenrestrict) and FLS — per user, group, and account. - Layout sections and fields — a tab/group with
roles/visibleWhenand a field'sAccesstab
(permissions.read, permissions.write, readWhen, writeWhen).
- API token scopes — an absolute limit for machine calls.
Scope grammar (/admin/tokens → the *Scopes* section):
article:read # read a single model
article:read:title,slug # read only the indicated fields
*:read # read everything the roles allow
article:write # write to a single model
*:* # unrestricted (the only scope allowing management operations)The same result applies across REST (/api/public/v1/...), JSON:API, GraphQL, search, export, and both MCP servers — field projection is computed once, in readableFieldsFor/writableFieldsFor.
Bulk operations on models (Schema builder)
The list at /admin/models lets you select multiple models and change them with a single operation:
- Move to category — moves the selected models within the category hierarchy.
- Add tags / Remove tags — a comma-separated list (
crm, catalog). - Publish latest — publishes the latest draft of each selected model (compiles DDL and refreshes REST/OpenAPI/GraphQL).
- Deprecate / Reactivate — changes the status in the registry without touching versions.
The selection works together with filters: "Select all filtered" takes exactly what's visible after searching, status, kind, category, and tags. Every taxonomy change is a new draft version, so a bulk operation is versioned and audited the same way as a manual edit, and a status change requires the manage_schema capability.
Contextual help ("Related")
Every panel screen and every public page ends with a Related block with links to the knowledge base and to articles and use cases matching where the user currently is.
- The rules are described in
src/platform/docs/related.ts— one rule per route (/admin/tokens,
/admin/models/ai, /blog, …). For nested routes, the most specific one wins.
- Documentation links point to a specific section (
/docs/knowledge-base#how-to-grant-permissions)
and are verified against the real headings in docs/*.md. Renaming a heading removes the link instead of creating a dead anchor — this is enforced by the related-content.test.ts test.
- Articles and use cases are read through the public delivery layer (the same mechanism as the blog)
and sorted by context tag coverage, then category. The currently viewed entry is excluded.
When adding a new page: add a rule to RULES (title, one introductory sentence, documentation sections, editorial tags, optional in-app shortcuts). Nothing else is needed in the admin panel — AdminShell renders the panel automatically; on a public page, add <ContextualReading pathname=… />.
Cookie consent (per locale)
The banner lives in src/platform/consent/ and is mounted once in the root shell, so it covers the public site and the admin workspace.
- Categories: strictly necessary (locked on), preferences, analytics,
marketing. Optional categories are denied until the visitor allows them.
- The decision is stored in a first-party cookie
cookie_consent
({"v":1,"byLocale":{"en":{...},"pl":{...}}}, 182 days). Consent is recorded per UI locale: the wording differs per language, so switching language re-opens the banner instead of reusing a decision made against other copy.
CONSENT_VERSIONinvalidates every stored record — bump it whenever the
categories or the policy wording change.
- Feature gates read
useCookieConsent().allows("analytics"); on the server use
consentFromCookieHeader(request.headers.get("cookie")).
- Visitors can reopen the panel any time via Cookie settings in the footer;
the banner also links to /legal/cookies.
How to search across models (facets)
- Open Search in the admin sidebar (
/admin/search) and type at least two characters. - The left rail lists every dimension with a hit count: models, status, locale, categories, tags,
then per-model entry fields (brand, tone, featured, published date, ...).
- Checking values narrows the results. Values inside one group are an OR, groups combine with AND.
Counts update as a drill-down: the group you are editing keeps showing its alternatives with usable numbers, so you never click your way into an empty list.
- "Not set" filters entries where the field is empty. Dates are grouped into today / last 7 days /
last 30 days / older.
- "Why these results" shows, per model, which dictionary was used and whether the query was
answered by the stored index or computed per row.
- Same thing over the API:
GET /api/public/v1/search?q=lens&facet[status]=published&facet[shop_product.brand]=zeiss
— see docs/API-COOKBOOK.md. GraphQL uses search(q:, facets: [{dimension, values}]).
If a count looks low, check whether the response says the counts are approximate — that means a model hit the scan cap (perModelLimit) and the number is a lower bound; narrow the models or raise the cap.
How to model a telco bundle with the same configurator
DEMO_MOBILE_BUNDLE shows that the configurator is not food-specific — it is a generic option/rule/price engine over a schema:
- Sizes = plan tiers.
priceis the recurring base amount,toppingFactorscales every
add-on slot (a booster costs less on a higher tier), costFactor scales wholesale cost.
- Recipe = what the tariff already includes. Network access and included minutes are
removable: false; SMS, voicemail and cloud carry a removalCredit, so trading them away lowers the recurring price with an auditable reason code.
- Groups = the commercial catalogue. Contract term, data boosters (
quantised, first slot
free), SMS/A2P bundles, roaming, bundled subscriptions, device instalment, insurance, accessories (one-off) and extra lines / multi-SIM.
- Rules replace hand-written checks.
requiresties the flagship instalment to a 24 month
term and the eSIM watch to multi-SIM, excludes blocks data boosters on unlimited data, implies auto-adds Care Basic with the security add-on, recommends warns only.
- Margin.
costPerUniton every line and option is the wholesale/interconnect cost, so the
Cost & margin panel reports COGS and margin for the bundle exactly like for a dish.
Recurring versus one-off is a property of the price model in commerce-core (recurring vs one_time); in the workbench it is stated in the group name.