import type { TryOnHistoryItem } from '../types'; /** * The shopper's past try-ons, kept in IndexedDB in their own browser. * * Nothing here is ever sent anywhere. Renders are megabytes each, which is * why they cannot live in localStorage next to the saved photo, and storing * them on our side would turn shopper photos into a category of personal data * we deliberately do not hold. * * Consequence to be honest about: the gallery is per browser, so it does not * follow the shopper from phone to laptop. */ const DB_NAME = 'aisthetix-tryons'; const DB_VERSION = 1; const STORE_NAME = 'tryons'; const CREATED_AT_INDEX = 'createdAt'; /** Roughly two months of a heavy shopper, and a few hundred MB at worst. */ export const MAX_TRY_ONS = 24; export function isGalleryAvailable(): boolean { try { return typeof indexedDB !== 'undefined' && indexedDB !== null; } catch { return false; } } let connection: Promise | null = null; /** * Resolves null instead of rejecting on every failure path. Storage is a * nice-to-have for a storefront widget: a browser that refuses it must lose * the gallery, not the try-on. */ function openDatabase(): Promise { if (!isGalleryAvailable()) return Promise.resolve(null); if (!connection) { connection = new Promise((resolve) => { let request: IDBOpenDBRequest; try { request = indexedDB.open(DB_NAME, DB_VERSION); } catch (e) { console.warn('[tryOnStore] Could not open the gallery database:', e); resolve(null); return; } request.onupgradeneeded = () => { const db = request.result; if (!db.objectStoreNames.contains(STORE_NAME)) { const store = db.createObjectStore(STORE_NAME, { keyPath: 'id' }); // ISO timestamps sort chronologically as strings, so the index // doubles as the gallery's newest-first order. store.createIndex(CREATED_AT_INDEX, 'createdAt'); } }; request.onsuccess = () => resolve(request.result); request.onerror = () => { console.warn('[tryOnStore] Gallery database unavailable:', request.error); resolve(null); }; request.onblocked = () => resolve(null); }); } return connection; } type TransactionWork = (store: IDBObjectStore, done: (value: T) => void) => void; /** * Runs `work` in one transaction and resolves with whatever it reports, or * with `fallback` if anything at all goes wrong. */ async function inTransaction( mode: IDBTransactionMode, fallback: T, work: TransactionWork ): Promise { const db = await openDatabase(); if (!db) return fallback; return new Promise((resolve) => { let result = fallback; try { const transaction = db.transaction(STORE_NAME, mode); transaction.oncomplete = () => resolve(result); transaction.onerror = () => resolve(fallback); transaction.onabort = () => resolve(fallback); work(transaction.objectStore(STORE_NAME), (value) => { result = value; }); } catch (e) { console.warn('[tryOnStore] Gallery transaction failed:', e); resolve(fallback); } }); } function createId(): string { return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; } /** Newest first. */ export function listTryOns(): Promise { return inTransaction('readonly', [], (store, done) => { const items: TryOnHistoryItem[] = []; const request = store.index(CREATED_AT_INDEX).openCursor(null, 'prev'); request.onsuccess = () => { const cursor = request.result; if (cursor) { items.push(cursor.value as TryOnHistoryItem); cursor.continue(); return; } done(items); }; }); } export function countTryOns(): Promise { return inTransaction('readonly', 0, (store, done) => { const request = store.count(); request.onsuccess = () => done(request.result); }); } export async function saveTryOn( input: Omit ): Promise { const item: TryOnHistoryItem = { ...input, id: createId() }; const saved = await inTransaction('readwrite', false, (store, done) => { store.put(item); done(true); }); if (!saved) return null; await pruneOverflow(); return item; } export function deleteTryOn(id: string): Promise { return inTransaction('readwrite', undefined, (store) => { store.delete(id); }); } export function clearTryOns(): Promise { return inTransaction('readwrite', undefined, (store) => { store.clear(); }); } /** Deletes the oldest entries until the gallery is back within MAX_TRY_ONS. */ function pruneOverflow(): Promise { return inTransaction('readwrite', undefined, (store) => { const countRequest = store.count(); countRequest.onsuccess = () => { let excess = countRequest.result - MAX_TRY_ONS; if (excess <= 0) return; const cursorRequest = store.index(CREATED_AT_INDEX).openCursor(null, 'next'); cursorRequest.onsuccess = () => { const cursor = cursorRequest.result; if (!cursor || excess <= 0) return; cursor.delete(); excess -= 1; cursor.continue(); }; }; }); }