Skip to content

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 autocompleteDebounceMs from config (fallback 200ms if omitted)
  • Requires at least autocompleteMinChars characters (fallback 2)
  • Shows up to autocompleteMaxResults hits (fallback 6) with product name, brand, price, and thumbnail
  • Skips Typesense when autocompleteEnabled is false (plain GET /search only)
  • Supports keyboard navigation (Arrow keys, Enter, Escape)
  • On mobile, a plain form submit is used instead (no autocomplete)

Adding a New Page

  1. Define the GraphQL ops in src/lib/houdini/MyPage.ts (query store + any mutation stores)
  2. Run npx houdini generate (included in npm run check)
  3. Create src/routes/my-page/+page.ts with export const load = async (event) => load_MyPage({ event, variables })
  4. Create src/routes/my-page/+page.svelte with let { data } = $props() and read the store (data.MyPage)
  5. Add any new i18n keys to $lib/i18n/en.json and es.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.

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, and Move to list. Uses bulkRemoveCartLines(lineIds), bulkSaveCartLinesForLater(lineIds), and convertListToCart mutations. The toolbar collapses on mobile into a sheet.
  • Quick Order (CSV / paste) — the cart page accepts a SKU,Quantity paste or a .csv upload. The UI calls cartBulkLookup first to preview candidates (matches, ambiguities, MMOQ violations, packaging-shift hints) and only commits on confirm via bulkAddToCart. Partial successes surface a "X of Y added" notice with rejected-row details.
  • Save-for-later — moved lines persist unit_package_id so 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 cartBulkLookupbulkAddToCart 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.

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).