Skip to content

Storefront themes and configuration

A shop’s storefront is a theme plus a configuration (ADR-013). The Storefront Settings page and the internal AI agent (MCP, phase E) both edit the configuration through one service, App\Services\Storefront\StorefrontConfig. Themes receive it read-only through the Storefront API (GET /shop).

Each theme lives in storefront/themes/<id>/ with a theme.json manifest next to its Astro templates (runtime and themes). ThemeRegistry loads and validates every manifest. A malformed manifest fails loudly instead of loosening validation. A manifest declares:

  • the theme’s id, name, semantic version and description (in English and Ukrainian);
  • the templates it provides (home, catalogue, bouquet, flower, builder, cart, checkout, order, not-found);
  • its global settings and the section types a shop can arrange, each with a maximum count and its own settings;
  • the default sections a new shop starts with.

Settings use a small typed DSL (App\Support\Storefront\SettingSchema), not free JSON Schema. Each type has bounds:

Type Value
text, textarea Plain text up to max characters; < and > are refused
color #rrggbb; optionally a contrast floor against the theme’s background (WCAG ratio)
select One of the declared options
range An integer between min and max, in steps
toggle true or false
image An upload of this shop (workspaces/<id>/storefront/…) or bouquet:<id>, meaning that bouquet’s photo
url https, optionally limited to allowed hosts
bouquet_list, category_list Up to max distinct ids of this shop’s visible bouquets or its categories
occasion_list Up to max occasion keys

The same declarations drive the validation rules, the settings form, and (in phase E) the description the AI agent reads. A theme therefore cannot widen what a shop’s configuration may contain.

Two themes ship: Bloom, light and photo-led with nine section types, and Noir, dark and editorial with four section types and different settings. Noir exists to prove that switching themes and per-theme settings work.

{
"schema": 1,
"theme": "bloom",
"profile": { "tagline": "…", "phone": "…", "hours": { "mon": { "open": "09:00", "close": "18:00" } }, "links": { "instagram": "https://instagram.com/…" } },
"themes": { "bloom": { "settings": { "accent": "#7a2e2a" }, "sections": [ { "id": "hero-1", "type": "hero", "enabled": true, "settings": {} } ] } }
}
  • The profile (StorefrontProfile) is independent of the theme. Every field is optional, and a theme shows a block only when its data is present, so nothing is invented. Text is plain (no markup). Each social link must point at its own service over https; Viber is a phone number.
  • Per-theme settings and sections. Switching theme keeps every theme’s own settings, so switching back restores them.
  • Validation is all or nothing. A change is merged into the draft and the whole document is validated. Any error refuses the change and saves nothing, and the errors name document paths (themes.bloom.sections.2.settings.limit). Ids must belong to the shop and be visible, and uploads must sit in the shop’s own folder.

Rows of storefront_versions:

  • Draft. One per shop, edited in place.
  • Published. Each publication copies the draft into the next numbered version and archives the previous one. Published and archived versions are never edited. Partial unique indexes allow one draft and one published version per shop.
  • Revert. Copies an earlier version back into the draft, so restoring it takes one more publish.
  • Audit. Every change records its source (settings, mcp, seed or revert) and the member or operator who made it.
  • Concurrency. Writers lock the shop’s row, so the settings page and the agent cannot interleave.
  • Default. A shop that never configured anything is served the Bloom defaults with an empty profile.

A preview token (PreviewToken) lets a storefront render the draft:

  • It is HMAC-signed with the app key, scoped to one shop, and expires after an hour.
  • It is issued only to members with settings.edit (the “Preview draft” button) and, later, to the internal agent.
  • GET /shop?preview=<token> then returns the draft with preview: true and Cache-Control: no-store. Without a valid token, everyone gets the published version.

The page is available to owners and admins (settings.edit). It has four tabs:

  • Shop details: the profile, opening hours, links and logo.
  • Theme: the theme picker and its settings, generated from the manifest.
  • Home page: sections can be switched on or off, reordered, added up to each type’s maximum, removed, and edited with fields generated from the manifest.
  • History: published versions, with “Restore into draft”.

“Save draft” sends only the current tab’s part as a patch. “Publish” takes an optional note. Images go up through POST /control/settings/storefront/images: JPEG, PNG or WebP, up to 5 MB and 6000 px, stored in workspaces/<id>/storefront/ under a random name.

App\Support\SafeImageDownloader is the only way the server fetches an image by URL (AI results today, agent tools later):

  • https only, and no credentials in the URL;
  • every address the host resolves to must be public, and the connection is pinned to the checked address, so DNS cannot be re-pointed in between;
  • no redirects;
  • the body must decode as a JPEG, PNG or WebP image of at most 5 MB.

It replaced a download in FlowerController that fetched any URL it was given.