diff --git a/apps/web/modules/onboarding/store/onboarding-storage.ts b/apps/web/modules/onboarding/store/onboarding-storage.ts new file mode 100644 index 0000000000..807b779847 --- /dev/null +++ b/apps/web/modules/onboarding/store/onboarding-storage.ts @@ -0,0 +1,132 @@ +import { createJSONStorage, type StateStorage } from "zustand/middleware"; + +/** + * IndexedDB storage adapter for onboarding store. + * Supports much larger storage limits than localStorage (typically 50% of available disk space), + * making it suitable for storing large base64-encoded images (logos, banners, etc.). + * Falls back to localStorage if IndexedDB is not available. + */ +const DB_NAME = "cal-onboarding-idb"; +const DB_VERSION = 1; +const STORE_NAME = "keyval"; + +// Cache database connection to avoid opening/closing repeatedly +let dbPromise: Promise | null = null; + +function getDB(): Promise { + if (dbPromise) { + return dbPromise; + } + + dbPromise = new Promise((resolve, reject) => { + if (typeof window === "undefined" || !window.indexedDB) { + reject(new Error("IndexedDB is not available")); + return; + } + + const request = window.indexedDB.open(DB_NAME, DB_VERSION); + + request.onerror = () => { + dbPromise = null; + reject(request.error); + }; + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME); + } + }; + + request.onsuccess = () => { + const db = request.result; + // Reset promise on close so we can reconnect if needed + db.onclose = () => { + dbPromise = null; + }; + resolve(db); + }; + }); + + return dbPromise; +} + +async function getItem(key: string): Promise { + try { + const db = await getDB(); + return new Promise((resolve, reject) => { + const transaction = db.transaction([STORE_NAME], "readonly"); + const store = transaction.objectStore(STORE_NAME); + const request = store.get(key); + + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result ?? null); + }); + } catch { + // Fallback to localStorage if IndexedDB fails + try { + return window.localStorage.getItem(key); + } catch { + return null; + } + } +} + +async function setItem(key: string, value: string): Promise { + try { + const db = await getDB(); + return new Promise((resolve, reject) => { + const transaction = db.transaction([STORE_NAME], "readwrite"); + const store = transaction.objectStore(STORE_NAME); + const request = store.put(value, key); + + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(); + }); + } catch { + // Fallback to localStorage if IndexedDB fails (will throw if quota exceeded) + try { + window.localStorage.setItem(key, value); + } catch { + // Storage quota exceeded even in fallback - not much we can do + } + } +} + +async function removeItem(key: string): Promise { + try { + const db = await getDB(); + return new Promise((resolve, reject) => { + const transaction = db.transaction([STORE_NAME], "readwrite"); + const store = transaction.objectStore(STORE_NAME); + const request = store.delete(key); + + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(); + }); + } catch { + // Fallback to localStorage if IndexedDB fails + try { + window.localStorage.removeItem(key); + } catch { + // Ignore errors on remove + } + } +} + +/** + * IndexedDB storage adapter that implements StateStorage interface. + * This provides much larger storage capacity than localStorage, making it suitable for + * storing large base64-encoded images (logos, banners, etc.). + */ +const indexedDBStateStorage: StateStorage = { + getItem, + setItem, + removeItem, +}; + +/** + * IndexedDB storage adapter that implements the PersistStorage interface for zustand persist. + * Uses createJSONStorage to handle JSON serialization/deserialization automatically. + */ +export const onboardingIndexedDBStorage = createJSONStorage(() => indexedDBStateStorage); diff --git a/apps/web/modules/onboarding/store/onboarding-store.ts b/apps/web/modules/onboarding/store/onboarding-store.ts index 46a2d38d90..2da6bb0236 100644 --- a/apps/web/modules/onboarding/store/onboarding-store.ts +++ b/apps/web/modules/onboarding/store/onboarding-store.ts @@ -1,6 +1,8 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; +import { onboardingIndexedDBStorage } from "./onboarding-storage"; + export type PlanType = "personal" | "team" | "organization"; export type InviteRole = "MEMBER" | "ADMIN"; @@ -160,7 +162,8 @@ export const useOnboardingStore = create()( resetOnboarding: () => set(initialState), }), { - name: "cal-onboarding-storage", // localStorage key + name: "cal-onboarding-storage", // Storage key + storage: onboardingIndexedDBStorage, // Use IndexedDB instead of localStorage for larger capacity // Optional: Only persist certain fields partialize: (state) => ({ selectedPlan: state.selectedPlan,