Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/data/catalog.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* iBamboo catalog helpers.
* Live assortment SoT is Flash Catalog (see useFlashCatalog).
* Static merge below is emergency fallback when flash is unreachable.
* Static house + BSR catalog is the bulk shelf. Flash layers on top when live
* (see useFlashCatalog merge) until Flash publish depth can own full assortment.
*/
export type { Category, Product, ProductSpec } from './types'
export { products as curatedProducts } from './products'
Expand Down
31 changes: 18 additions & 13 deletions src/hooks/useFlashCatalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,26 +26,31 @@ function finalizePool(list: Product[]): Product[] {
return out
}

/**
* Merge flash over static house catalog.
* Flash alone is still too thin (~12 SKUs) to own the full shop; until Flash
* publish depth is production-grade, keep static assortment and layer flash on top.
*/
function mergeFlashOverStatic(flash: Product[]): Product[] {
return finalizePool([...flash, ...shopProducts])
}

export type FlashCatalogState = {
/**
* Active shop assortment.
* When source === 'flash', this is flash-only (control plane SoT).
* When source === 'static', emergency fallback (static BSR/curated).
*/
/** Full shop list: flash picks first, then full static catalog */
products: Product[]
flashOnly: Product[]
payload: FlashCatalogPayload | null
loading: boolean
source: 'flash' | 'static'
source: 'flash' | 'static' | 'merged'
error?: string
generatedAt?: string
weekOf?: string
catalogBaseUrl: string
}

/**
* Load site assortment from Flash Catalog (source of truth).
* Static catalog is emergency fallback only when flash is empty/unreachable.
* Load flash catalog and merge onto the static house/BSR assortment.
* Static is the bulk shelf; flash is live control-plane overlay (not a wipe).
*/
export function useFlashCatalog(siteId = 'ibamboo'): FlashCatalogState {
const catalogBaseUrl = flashCatalogBaseUrl()
Expand Down Expand Up @@ -89,27 +94,27 @@ export function useFlashCatalog(siteId = 'ibamboo'): FlashCatalogState {

return useMemo(() => {
const flashOnly = finalizePool(mapFlashProducts(payload))
const staticPool = finalizePool(shopProducts)
if (flashOnly.length > 0) {
return {
// Assortment SoT: flash products only — do not merge static shelves
products: flashOnly,
products: mergeFlashOverStatic(flashOnly),
flashOnly,
payload,
loading,
source: 'flash' as const,
source: 'merged' as const,
error: undefined,
generatedAt: payload?.generatedAt,
weekOf: payload?.weekOf,
catalogBaseUrl,
}
}
return {
products: finalizePool(shopProducts),
products: staticPool,
flashOnly: [],
payload: null,
loading,
source: 'static' as const,
error: error || (loading ? undefined : 'Using static emergency catalog'),
error: error || (loading ? undefined : undefined),
weekOf: undefined,
generatedAt: undefined,
catalogBaseUrl,
Expand Down
16 changes: 8 additions & 8 deletions src/pages/Shop.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,8 @@ export function Shop() {
</h1>
<p className="text-ink-soft mt-3 max-w-xl text-lg font-light leading-relaxed">
{limited
? 'Assortment from the live flash catalog control plane — filters and schedule managed there; refresh anytime without a site redeploy.'
: 'Browse by room of the house, then buy on Amazon with secure checkout. Assortment is driven by the flash catalog.'}
? 'This week’s limited options from the house edit and live flash refresh.'
: 'Browse by room of the house, then buy on Amazon with secure checkout.'}
</p>

<div className="mt-6 rounded-2xl border border-line bg-card px-5 py-4 flex flex-wrap items-center gap-3">
Expand All @@ -161,17 +161,17 @@ export function Shop() {
<p className="font-bold uppercase tracking-wide text-[11px] text-ink">
{flash.loading
? 'Loading assortment…'
: flash.source === 'flash'
? 'Live assortment'
: 'Emergency static catalog'}
: flash.source === 'merged'
? 'Full collection · live flash layered'
: 'Full collection'}
</p>
<p className="mt-0.5">
{flash.loading ? '…' : `${pool.length} products`}
{!flash.loading && flash.flashOnly.length
? ` · ${flash.flashOnly.length} live flash`
: ''}
{!flash.loading && drop.weekOf ? ` · week of ${drop.weekOf}` : ''}
{!flash.loading && until ? ` · rotates ${until}` : ''}
{!flash.loading && flash.error && flash.source === 'static'
? ` · ${flash.error}`
: ''}
</p>
</div>
</div>
Expand Down
25 changes: 19 additions & 6 deletions tests/flash-catalog.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,24 @@ describe('flash catalog Phase A contract', () => {
)
})

it('flash-only SoT: empty flash means fallback, non-empty means no static merge needed', () => {
// Document the contract used by useFlashCatalog
const flashOnly = [{ id: 'flash-A' }, { id: 'flash-B' }]
const products = flashOnly.length > 0 ? flashOnly : ['static']
assert.deepEqual(products, flashOnly)
assert.notEqual(products[0], 'static')
it('merges flash over static without wiping the house catalog', () => {
const flashOnly = [{ id: 'flash-A', asin: 'A' }, { id: 'flash-B', asin: 'B' }]
const staticPool = [
{ id: 'static-1', asin: 'A' }, // duplicate ASIN → flash wins
{ id: 'static-2', asin: 'C' },
{ id: 'static-3', asin: 'D' },
]
const seen = new Set()
const products = []
for (const p of [...flashOnly, ...staticPool]) {
if (seen.has(p.asin)) continue
seen.add(p.asin)
products.push(p)
}
assert.equal(products.length, 4)
assert.equal(products[0].id, 'flash-A')
assert.ok(products.some((p) => p.id === 'static-2'))
assert.ok(products.some((p) => p.id === 'static-3'))
})
})

Loading