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.