Developer Docs

Cross-Malet Product Comparison & Quick Actions

Two related Buyer-facing features that enhance product discovery across the platform:

  1. Compare Deck โ€” Pin up to 4 products from any Malet and evaluate them side-by-side in a unified modal.
  2. Card Quick Menu โ€” A universal kebab (โ‹ฎ) dropdown on every product and service card with contextual actions.

Both features are entirely client-side โ€” no backend changes required.


Architecture

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                        Root Layout                              โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”   โ”‚
โ”‚  โ”‚ ComparisonTray (fixed bottom, persistent across routes)  โ”‚   โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜   โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”   โ”‚
โ”‚  โ”‚ ComparisonModal (overlay, opens from tray)               โ”‚   โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜   โ”‚
โ”‚                                                                 โ”‚
โ”‚  Every Card Surface:                                            โ”‚
โ”‚  โ”œโ”€ ProductCard (catalog)         โ†’ CardQuickMenu               โ”‚
โ”‚  โ”œโ”€ ServiceCard (catalog)         โ†’ CardQuickMenu               โ”‚
โ”‚  โ”œโ”€ LobbyTrending (mosaic)        โ†’ CardQuickMenu               โ”‚
โ”‚  โ”œโ”€ Lobby inline cards            โ†’ CardQuickMenu               โ”‚
โ”‚  โ”œโ”€ FeaturedProducts (widget)     โ†’ CardQuickMenu               โ”‚
โ”‚  โ”œโ”€ SearchResultCard              โ†’ CardQuickMenu               โ”‚
โ”‚  โ””โ”€ Item Detail Page              โ†’ CompareButton (expanded)    โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
         โ”‚
         โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ comparisonStore (Svelte 5 Runes)   โ”‚
โ”‚ โ”€ items: ComparisonProduct[]       โ”‚
โ”‚ โ”€ isOpen: boolean                  โ”‚
โ”‚ โ”€ canAdd / canCompare (derived)    โ”‚
โ”‚ โ”€ localStorage persistence         โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
         โ”‚
         โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ comparisonUtils.ts                 โ”‚
โ”‚ โ”€ buildComparisonRows()            โ”‚
โ”‚ โ”€ formatComparisonValue()          โ”‚
โ”‚ โ”€ getComparisonDiffFlag()          โ”‚
โ”‚ โ”€ extractComparisonProduct()       โ”‚
โ”‚ โ”€ ComparisonProductSchema (Zod)    โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

No backend changes required. The comparison operates entirely on data already loaded by existing GET_ITEM_DETAILS, GET_MALET_PRODUCTS, and GET_PRODUCTS queries.


Card Quick Menu (`CardQuickMenu`)

A universal kebab (โ‹ฎ) dropdown that appears on hover over any product or service card. Provides contextual quick actions without navigating away from the current page.

Action Status Description
Add to Compare Deck โœ… Live Toggle product in/out of the comparison set. Shows "Full" badge when 4 items are pinned, โœ“ checkmark when active.
Add to List ๐Ÿ”œ Coming Soon Disabled with "SOON" badge. Will integrate with the future Buyer Lists/Wishlist feature.

Technical Details

  • Portal rendering: The dropdown is teleported to document.body via a Svelte use:portal action. This is necessary because product cards use CSS transform on hover (for the lift effect), which creates a new containing block that breaks position: fixed inside overflow: hidden containers.
  • Event isolation: All click events within the menu are stopped from propagating to parent <a> tags, preventing accidental navigation.
  • Keyboard support: Escape key closes the dropdown.
  • Click-outside dismissal: Global click listener with a 10ms delay to prevent the open-click from immediately closing the menu.

Integration Points

The CardQuickMenu is integrated into every card surface across the platform:

Surface Component Position
Malet Catalog ProductCard.svelte Top-left, hover reveal
Malet Catalog ServiceCard.svelte Top-left, hover reveal
Lobby โ€” Trending Mosaic LobbyTrending.svelte Top-right, hover reveal
Lobby โ€” Featured Products lobby/+page.svelte Top-left, hover reveal
Malet Storefront Widget FeaturedProducts.svelte Top-left, hover reveal
Search Results SearchResultCard.svelte Top-right, hover reveal
Item Detail Page CompareButton.svelte Inline (expanded)

Props

Prop Type Default Description
product ComparisonProduct | undefined undefined Product data for comparison. When undefined, only the "Add to List" option shows.
type 'product' | 'service' 'product' Card type context for future action differentiation.

Comparison Store API

`comparisonStore` (Singleton)

Property / Method Type Description
items ComparisonProduct[] Current comparison set (max 4)
isOpen boolean Whether the comparison modal is visible
canAdd boolean (derived) true if fewer than 4 items
canCompare boolean (derived) true if โ‰ฅ2 items
isEmpty boolean (derived) true if no items
count number (derived) Current item count
addProduct(p) โ†’ boolean Adds product, returns false if duplicate or full
removeProduct(id) โ†’ void Removes by ID, auto-closes modal if <2 remain
toggleProduct(p) โ†’ boolean Add or remove, returns true if added
hasProduct(id) โ†’ boolean Check if product is in comparison
clearAll() โ†’ void Remove all, close modal
openModal() โ†’ void Open modal (requires โ‰ฅ2 items)
closeModal() โ†’ void Close modal

Persistence: Items are synced to localStorage('ngwenya_compare_items') via a $effect.root() watcher. The $effect.root() wrapper is required because the store is a module-level singleton instantiated outside any component's reactive context.


Visual Components

`ComparisonTray`

Fixed-bottom bar showing current comparison set. Visible when โ‰ฅ1 product is pinned.

  • Mini product thumbnails with Malet handle
  • Empty slot placeholders (dashed borders)
  • "Clear All" button
  • "Compare Now" CTA (disabled with <2 items)
  • Auto-hides when comparison set is empty

`ComparisonModal`

Full-screen overlay with side-by-side comparison table.

  • Header: Product image, name, price, Malet link per column
  • Body: Attribute rows (Price, Compare-At, Status, SKU, Weight, Dimensions, Inventory, Tags, Variants). Rows with differing values are highlighted.
  • Footer: "View Details" link per product
  • Escape key and backdrop click close the modal

Utility Functions

`buildComparisonRows(products): ComparisonRow[]`

Generates normalized attribute rows from an array of products. Omits rows where all products have null values.

`formatComparisonValue(value, key): string`

Formats raw product data for display: cents โ†’ currency, dimensions โ†’ Lร—Wร—H unit, arrays โ†’ comma-separated, booleans โ†’ Yes/No, nulls โ†’ โ€”.

`getComparisonDiffFlag(row): boolean`

Returns true if any non-null values in the row differ โ€” used to highlight divergent attributes.

`extractComparisonProduct(product, maletHandle, maletName): ComparisonProduct`

Bridge function that extracts a ComparisonProduct from a raw ProductNode (as returned by GraphQL queries) plus Malet context.

`ComparisonProductSchema` (Zod)

Validates data before it enters the comparison store. Required fields: id, name, maletHandle, maletName, basePrice.


File Reference

File Purpose
src/stores/comparisonStore.svelte.ts Svelte 5 reactive state class with localStorage persistence
src/lib/utils/comparisonUtils.ts Pure utility functions and Zod schema
src/lib/components/catalog/CardQuickMenu.svelte Universal kebab dropdown with portal rendering
src/lib/components/catalog/CompareButton.svelte Toggle button for add/remove (item detail page)
src/lib/components/catalog/ComparisonTray.svelte Fixed-bottom comparison tray
src/lib/components/catalog/ComparisonModal.svelte Full comparison overlay modal
tests/comparisonUtils.test.ts 31 unit tests for utilities
tests/comparisonStore.test.ts 15 unit tests for store logic

Design Decisions

  1. Client-side only: No new GraphQL queries or mutations. Products loaded by existing queries are reused.
  2. Products only: Services excluded from comparison โ€” their attributes (duration, booking, location) don't suit tabular comparison. Services still get the quick menu for future "Add to List".
  3. Max 4 items: Standard comparison limit. Beyond 4, the comparison table becomes unwieldy on mobile.
  4. localStorage: Simple, session-scoped persistence. No server-side state needed.
  5. Diff highlighting: Rows where products differ get a subtle accent background to draw the Buyer's eye to distinguishing attributes.
  6. Portal rendering: The dropdown is teleported to document.body because card wrappers use overflow: hidden + transform on hover, which breaks position: fixed positioning.