Storefront Developer Guide¶
The Vectis storefront is a SvelteKit 2 app (Svelte 5 runes) at vectis/storefront/, running on port 5174. It uses the BFF pattern — the browser never talks to the FastAPI backend directly.
Key paths: src/lib/houdiniClient.ts (Houdini client → /api/graphql), src/lib/houdini/ (named GraphQL ops), src/lib/server/api.ts (BFF apiHeaders() that refreshes the JWT from the session cookie), src/lib/i18n/ (translations), src/lib/format.ts (currency), src/hooks.server.ts (locale/session resolution).
Server-Side Data Loading (Houdini)¶
Every page uses a +page.ts (not +page.server.ts) with a load_<QueryName> helper from $houdini:
// src/lib/houdini/ProductList.ts
import { graphql } from '$houdini';
export const ProductListStore = graphql(`
query ProductList($limit: Int!) {
products(limit: $limit) { products { id name slug } total }
}
`);
// src/routes/products/+page.ts
import { load_ProductList } from '$houdini';
import type { PageLoad } from './$types';
export const load: PageLoad = async (event) => {
const main = await load_ProductList({ event, variables: { limit: 20 } });
return { ...main };
};
Houdini's client ($lib/houdiniClient.ts) targets the BFF at /api/graphql; the SvelteKit route there forwards to the backend and attaches the JWT server-side via apiHeaders(). The browser never sees a token (Decided #88).
Cookies¶
| Cookie | Purpose | HttpOnly |
|---|---|---|
vectis_session | Auth session ID (Redis-backed) | Yes |
vectis_cart_session | Guest cart ID (UUID) | Yes |
vectis_locale | User's language preference | No |
vectis_currency | Active currency code | No |
hooks.server.ts auto-generates vectis_cart_session if missing and resolves locale from cookie → Accept-Language → "en".
Channel Awareness¶
The root +layout.server.ts fetches channelInfo and exposes commerceMode, supportedLanguages, supportedCurrencies, and loginWallMode to all child routes:
<script lang="ts">
let { data, children } = $props();
let isB2B = $derived(data.commerceMode === 'b2b');
</script>
{#if isB2B}
<div class="bg-blue-900 text-white text-center text-xs py-1">
B2B WHOLESALE — Minimum order quantities apply
</div>
{/if}
{@render children()}
Currency & i18n¶
Use formatMoney() from $lib/format.ts for all prices. Import t() from $lib/i18n for UI strings:
<script lang="ts">
import { formatMoney } from '$lib/format';
import { t } from '$lib/i18n';
let { price, currency, locale } = $props();
</script>
<span class="font-semibold">{formatMoney(price, currency, locale)}</span>
<button>{t('product.add_to_cart')}</button>
See i18n.md for the full internationalization guide.
Login Wall Modes¶
The channel login_wall_mode controls anonymous access (Decided #97):
| Mode | Catalog visible? | Prices visible? |
|---|---|---|
public | Yes | Yes |
prices_hidden | Yes | No — shows "Login to see pricing" |
full_wall | No | No — redirects to /login |
Crawlers always bypass the wall for SEO.
Tip
Use data.loginWallMode from layout data to conditionally render price components.
Auth pages and theme colors¶
The auth routes (/login, /register, /forgot-password, /reset-password), checkout (/checkout), and signed-in account (/account/**) use Tailwind arbitrary values wired to theme CSS variables — for example text-[--color-primary], hover:text-[--color-primary-hover], bg-[--color-danger]/10, text-[--color-success], text-[--color-warning], and accent-[--color-primary] — so primary, error, success, and warning states follow channel theming. Shared storefront components under $lib/components/ (for example chat, variation grid, payment tokenization, age verification, and product cards) use the same tokens instead of hardcoded Tailwind palette utilities such as blue-*, rose-*, or emerald-*. Per-route status badge maps (order/return/claim status chips) may keep distinct Tailwind palette classes so each status stays visually distinguishable. Keep third-party OAuth button colors (e.g. provider brand purple) and payment or extension brand marks (e.g. card network icons, AgeChecker.Net styling) as defined by those providers; do not revert those surfaces to the generic primary token.
Instant Search Autocomplete (Decided #105)¶
The desktop header includes a SearchAutocomplete component that queries Typesense directly from the browser using a scoped API key. This is the one sanctioned exception to the BFF rule (see bff-pattern.md).
The root +layout.server.ts fetches the scoped key via the storefrontSearchConfig GraphQL query and passes it as data.searchConfig:
// In +layout.server.ts
searchConfig = {
host: env.PUBLIC_TYPESENSE_URL, // browser-reachable URL
collection: cfg.collection,
scopedKey: cfg.scopedKey,
autocompleteEnabled: cfg.autocompleteEnabled,
autocompleteMinChars: cfg.autocompleteMinChars,
autocompleteDebounceMs: cfg.autocompleteDebounceMs,
autocompleteMaxResults: cfg.autocompleteMaxResults
};
Autocomplete behavior is tuned by admin Settings → Search (search.autocomplete_* keys in the database). Defaults match the GraphQL storefrontSearchConfig resolver when no rows exist.
| Env var | Where | Purpose |
|---|---|---|
PUBLIC_TYPESENSE_URL | storefront | Browser-reachable Typesense URL |
When PUBLIC_TYPESENSE_URL is empty (default in Docker Compose), searchConfig is null and autocomplete is disabled — search falls back to the SSR /search?q= form.
The SearchAutocomplete component ($lib/components/SearchAutocomplete.svelte):
- Debounces input using
autocompleteDebounceMsfrom config (fallback 200ms if omitted) - Requires at least
autocompleteMinCharscharacters (fallback 2) - Shows up to
autocompleteMaxResultshits (fallback 6) with product name, brand, price, and thumbnail - Skips Typesense when
autocompleteEnabledis false (plain GET/searchonly) - Supports keyboard navigation (Arrow keys, Enter, Escape)
- On mobile, a plain form submit is used instead (no autocomplete)
Adding a New Page¶
- Define the GraphQL ops in
src/lib/houdini/MyPage.ts(query store + any mutation stores) - Run
npx houdini generate(included innpm run check) - Create
src/routes/my-page/+page.tswithexport const load = async (event) => load_MyPage({ event, variables }) - Create
src/routes/my-page/+page.sveltewithlet { data } = $props()and read the store (data.MyPage) - Add any new i18n keys to
$lib/i18n/en.jsonandes.json
Note
All browser GraphQL goes through /api/graphql (the BFF forwarder). Never fetch() the backend directly from the browser. The one sanctioned exception is SearchAutocomplete querying Typesense with a scoped key (Decided #105). See bff-pattern.md.
Account Section Layout¶
All routes under /account/ share a persistent sidebar layout defined in src/routes/account/+layout.svelte. The shared +layout.server.ts fetches account details, employees, locations with addresses, and document types once — child pages inherit via $page.data.
Sidebar Navigation¶
The sidebar renders built-in nav items (Dashboard, Profile, Orders, Returns, Locations, Team, Addresses, Payment Methods, Store Credit, Claims) plus any items registered via the extension hook store.
Extension Hook for Nav Items¶
Extensions can add items to the account sidebar by importing and appending to the extraAccountNavItems array:
import { registerAccountNavItem } from '$lib/stores/accountNav';
registerAccountNavItem({ href: '/account/my-feature', label: 'My Feature', order: 150 });
Items are sorted by order (built-in items use 0-100; extensions should use 200+).
Profile & Password Change¶
The profile page (/account/profile) lets the account owner edit their first name, last name, and phone via the updateMyProfile mutation. A separate "Change Password" section calls changeMyPassword(currentPassword, newPassword), which verifies the current password, validates the new password against the registration_password_policy setting (min length, uppercase, lowercase, number, special character rules), then updates the hash. Client-side validation enforces minimum length and confirmation matching before the mutation fires.
Address Approval Flow¶
When account.address_approval_required is true (the default), addresses created via createMyAddress start with approval_status = "pending". Only addresses with approval_status = "approved" appear at checkout. The admin reviews pending addresses at /pending-changes.
License Document Management¶
Location detail pages (/account/locations/[id]) display documents linked to each location with expiration badges (expired = red, expiring soon = amber). Account holders can upload renewal documents, which enter a pending state for admin approval. Approved renewals supersede older documents of the same type and location.
Cart Bulk Operations¶
Three workflows let buyers act on many lines at once:
- Bulk select — checkboxes on each cart row + a sticky toolbar with
Delete,Save for later, andMove to list. UsesbulkRemoveCartLines(lineIds),bulkSaveCartLinesForLater(lineIds), andconvertListToCartmutations. The toolbar collapses on mobile into a sheet. - Quick Order (CSV / paste) — the cart page accepts a
SKU,Quantitypaste or a.csvupload. The UI callscartBulkLookupfirst to preview candidates (matches, ambiguities, MMOQ violations, packaging-shift hints) and only commits on confirm viabulkAddToCart. Partial successes surface a "X of Y added" notice with rejected-row details. - Save-for-later — moved lines persist
unit_package_idso when the buyer restores them the original packaging selection (case-of-24, etc.) returns intact (Decided #230).
AI Parse-to-Cart (Decided #232)¶
The cart UI has a "Type or photo" entry point that takes free-text ("12 bottles of strawberry e-juice, 3 disposables") or a photo of a handwritten order list. The text/image is sent to the cartAiParseToLookupItems(input: CartAIParseInput!) mutation, which returns a CartAIParseResult of line candidates with confidence scores plus an error taxonomy. The result feeds cartBulkLookup → bulkAddToCart so AI parse, package matching, and MMOQ preview all share one pipeline. The cart_ai_provider_priority Setting controls AI provider order.
MMOQ Guard (Decided #236)¶
Tracked products with per-customer caps surface a banner on PDP ("Limit: 12 per order, 24 in 30 days") and on cart violations. The cart drawer renders any mmoqViolations returned by the cart query with a clear "Reduce quantity" affordance. The 30-day window respects the channel timezone (channel timezone setting); a merged guest→registered cart re-aggregates after merge via the mmoq_basis_changed event.
Packaging UoM¶
PDP renders a package picker (Each / Pack-of-6 / Case-of-24) when the product has packages enabled. The selected package writes unit_package_id into the cart line; the cart and minicart display "12 (Pack-of-6, 72 eaches)". Reorder uses the original slug + multiplier and flags the line unit_changed when the ladder no longer supports the original selection (Decided #235) — the storefront shows a "Package changed — confirm" interstitial.
Location Switcher (Decided #167)¶
B2B accounts with multiple locations can switch the active location from the storefront header without a page reload. The switcher writes the chosen location_id to the session via setActiveLocation and the next cart write attaches to that location. Address-bound prices, inventory, and tax all recompute from the new active location.
Affiliates Portal¶
/affiliates/* is a full self-serve partner portal: apply, dashboard (commissions + clicks), commissions, payouts, referral-codes, and settings. The portal uses dedicated stores under $lib/houdini/Affiliates*.ts. The dashboard reads AWIN-sourced reports when the AWIN extension is installed (carrier-style integration).
CSP Split for CMS Embeds (Decided #177)¶
Strict directives (script-src, connect-src) stay locked down. The permissive directives (img-src, media-src, frame-src) accept a per-channel allowlist so merchants can embed YouTube, Vimeo, supplier photos, and Stripe Climate badges from CMS without re-deploying the storefront. Layout middleware emits the resolved CSP header on every response; the storefront does not generate inline <script> for CMS content.
Cookie Consent¶
The storefront shows a granular cookie banner on every channel by default (essential + analytics + marketing categories). Rejecting essential cookies skips the consent cookie entirely so the banner shows again on next visit. The privacy module covers the 7-audit-gap fixes from 2026-04 (commit c3ff97e).