Developer Docs

Wishlists & Collections โ€” Architecture Guide

The Wishlists & Collections system enables Visitors and Buyers to save Products, Services, and Malets into named, user-defined lists with per-item quantities and notes. It uses a dual-persistence architecture: local-first (localStorage + IndexedDB) for instant UI, with background GraphQL sync to the nodes subgraph when authenticated.

Architecture Overview

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                       UI Layer                           โ”‚
โ”‚                                                          โ”‚
โ”‚  CardQuickMenu.svelte  โ†โ†’  /wishlist (two-panel page)    โ”‚
โ”‚  (Save to List picker)     SideNav  โ†โ†’  UserMenu         โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                โ”‚                         โ”‚
                โ–ผ                         โ–ผ
        โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
        โ”‚        wishlistStore.svelte.ts            โ”‚
        โ”‚       (Svelte 5 runes โ€” $state)          โ”‚
        โ”‚  - items: ListItem[]                     โ”‚
        โ”‚  - lists: CustomList[]                   โ”‚
        โ”‚  - loaded / syncing flags                โ”‚
        โ”‚  - idMap: localโ†’backend ID resolution    โ”‚
        โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                โ”‚                โ”‚
        โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”Œโ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
        โ”‚  localStorage โ”‚  โ”‚  GraphQL (nodes)        โ”‚
        โ”‚  + IndexedDB  โ”‚  โ”‚  collections / items    โ”‚
        โ”‚  (instant)    โ”‚  โ”‚  (background sync)      โ”‚
        โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Persistence Strategy

The store uses a three-tier persistence model:

Tier Storage Speed Purpose
1. localStorage Sync Instant Page reload hydration โ€” read synchronously before first render
2. IndexedDB Async Fast Large data capacity, survives browser restarts
3. GraphQL Backend Network Variable Cross-device sync, authenticated users only

Write Path

Every mutation (add/remove/update) follows:

  1. Optimistic update โ€” $state arrays mutated immediately
  2. localStorage sync โ€” JSON.stringify written synchronously
  3. IndexedDB write โ€” idbSet (fire-and-forget async)
  4. Backend sync โ€” GraphQL mutation (fire-and-forget, errors logged to console)

Read Path (on load)

  1. localStorage read (synchronous โ€” instant hydration)
  2. IndexedDB read (async โ€” may override localStorage with fresher data)
  3. Backend fetch (async โ€” merges server collections into local state, if authenticated)

Components

`CardQuickMenu.svelte`

The primary entry point for saving items. Lives on product cards, service cards, and lobby tiles.

Key derived values:

let effectiveId = $derived(itemId || product?.id || '');
let effectiveName = $derived(itemName || product?.name || '');
let effectiveHandle = $derived(maletHandle || product?.maletHandle || '');
let effectiveSlug = $derived(product?.slug || '');

These derived values ensure the malet handle and slug are always resolved, even when individual props aren't passed โ€” falling back to the product (ComparisonProduct) object.

`/wishlist` Route

A two-panel management page. SSR is disabled (ssr = false) because the page reads from IndexedDB/localStorage.

Left sidebar: List names with item counts, create/rename/delete actions. Main area: Item grid/list with quantity steppers, inline notes, price totals, CSV export.

Item links resolve to /{maletHandle}/item/{slug || id}. If maletHandle is empty, falls back to /search?q={name}.

Navigation Entry Points

  • SideNav (primaryRoutes array) โ€” "My Lists" with actions/heart icon
  • UserMenu (avatar dropdown) โ€” "My Lists" button after "My Murchases"

Store: `wishlistStore.svelte.ts`

Types

interface ListItem {
  id: string;
  type: 'product' | 'service' | 'malet';
  name: string;
  handle?: string;
  slug?: string;
  imageUrl?: string;
  price?: number;
  currency?: string;
  maletHandle: string;
  maletName: string;
  addedAt: number;
  listId: string;
  quantity: number;
  note?: string;
}

interface CustomList {
  id: string;
  name: string;
  emoji: string;
  createdAt: number;
  description?: string;
  visibility: 'private' | 'unlisted' | 'public';
}

Actions

Action Signature Backend Sync
addToList (item, listId?) โ†’ void addCollectionItem
removeFromList (itemId, listId?) โ†’ void removeCollectionItem
toggleItem (item, listId?) โ†’ boolean Toggle add/remove
moveToList (itemId, from, to) โ†’ void โ€”
updateItemQuantity (itemId, qty, listId?) โ†’ void updateCollectionItem
updateItemNote (itemId, note, listId?) โ†’ void updateCollectionItem
createList (name, emoji?) โ†’ CustomList createCollection
renameList (id, name) โ†’ void updateCollection
deleteList (id) โ†’ void deleteCollection
exportListCSV (listId) โ†’ void โ€” (client-only)

ID Resolution

The store maintains an idMap: Map<string, string> that maps local IDs to backend IDs:

  • List IDs: col_<timestamp> โ†’ backend collection ID (mapped after createCollection response)
  • Item IDs: <entityId>:<listId> โ†’ backend CollectionItem ID (mapped after addCollectionItem response)

GraphQL API (`$lib/queries/collections.ts`)

Queries

Query Variables Description
collections first: Int Fetch authenticated user's collections
collectionItems collectionId: String!, first: Int Fetch items in a collection

Mutations

Mutation Input Description
createCollection CreateCollectionInput Create a new named collection
updateCollection UpdateCollectionInput Rename or change visibility
deleteCollection id: String! Delete + cascade item cleanup
addCollectionItem AddCollectionItemInput Add an entity to a collection
removeCollectionItem RemoveCollectionItemInput Remove an item by backend ID
updateCollectionItem UpdateCollectionItemInput Update quantity or note

All mutations are guarded by GqlAuthGuard and ownership checks in the backend resolver.

Migration

The store includes a migrateItems() function that handles legacy data:

  • collectionId โ†’ listId (field rename)
  • Missing quantity โ†’ defaults to 1
  • Missing maletHandle โ†’ falls back to handle or 'unknown'

Integration Guide

To add the "Save to List" action to a new surface:

  1. Import CardQuickMenu from $lib/components/catalog/CardQuickMenu.svelte
  2. Pass product metadata props: itemId, itemName, imageUrl, price, currency, maletHandle, maletName
  3. Optionally pass a full product (ComparisonProduct) object for slug and handle resolution
  4. The component handles all state management internally
<CardQuickMenu
  product={comparisonProduct}
  type="product"
  itemId={product.id}
  itemName={product.name}
  imageUrl={coverImage}
  price={product.basePrice}
  currency="USD"
  maletHandle={maletHandle}
  maletName={maletName}
/>