| export type ThemeMode = 'dark' | 'light'; |
| export type InterfaceLanguage = 'english' | 'spanish' | 'french' | 'german'; |
| export type FontSizePreference = 'small' | 'medium' | 'large'; |
|
|
| export type DeliveryAddress = { |
| fullName: string; |
| phone: string; |
| street: string; |
| city: string; |
| state: string; |
| pincode: string; |
| country: string; |
| }; |
|
|
| export type UserPreferences = { |
| themeMode: ThemeMode; |
| language: InterfaceLanguage; |
| fontSize: FontSizePreference; |
| emailNotifications: boolean; |
| pushNotifications: boolean; |
| soundEnabled: boolean; |
| autoSave: boolean; |
| twoFactorEnabled: boolean; |
| twoFactorCode: string; |
| dataAnalytics: boolean; |
| address: DeliveryAddress; |
| updatedAt: string; |
| }; |
|
|
| type ImportedPreferencesPayload = Partial<UserPreferences> & { |
| version?: number; |
| exportedAt?: string; |
| }; |
|
|
| export type AnalyticsEvent = { |
| type: string; |
| createdAt: string; |
| payload?: Record<string, string | number | boolean | null>; |
| }; |
|
|
| const PREFERENCES_STORAGE_PREFIX = 'ryp_preferences'; |
| const ANALYTICS_STORAGE_PREFIX = 'ryp_analytics'; |
| const LEGACY_ADDRESS_KEY = 'cq_delivery_address'; |
| const LEGACY_KEYS = { |
| emailNotifications: 'emailNotifications', |
| pushNotifications: 'pushNotifications', |
| soundEnabled: 'soundEnabled', |
| autoSave: 'autoSave', |
| twoFactorEnabled: 'twoFactorEnabled', |
| dataAnalytics: 'dataAnalytics', |
| language: 'language', |
| fontSize: 'fontSize', |
| themeMode: 'theme_mode', |
| } as const; |
|
|
| export const SETTINGS_EXPORT_VERSION = 1; |
| export const EMPTY_ADDRESS: DeliveryAddress = { |
| fullName: '', |
| phone: '', |
| street: '', |
| city: '', |
| state: '', |
| pincode: '', |
| country: 'India', |
| }; |
|
|
| export function createDefaultPreferences(themeMode: ThemeMode = 'dark'): UserPreferences { |
| return { |
| themeMode, |
| language: 'english', |
| fontSize: 'medium', |
| emailNotifications: true, |
| pushNotifications: true, |
| soundEnabled: true, |
| autoSave: true, |
| twoFactorEnabled: false, |
| twoFactorCode: '', |
| dataAnalytics: true, |
| address: { ...EMPTY_ADDRESS }, |
| updatedAt: new Date().toISOString(), |
| }; |
| } |
|
|
| function preferencesStorageKey(userId: string) { |
| return `${PREFERENCES_STORAGE_PREFIX}_${userId}`; |
| } |
|
|
| function analyticsStorageKey(userId: string) { |
| return `${ANALYTICS_STORAGE_PREFIX}_${userId}`; |
| } |
|
|
| export function userScopedStorageKey(scope: string, userId: string) { |
| return `ryp_${scope}_${userId}`; |
| } |
|
|
| function isPlainObject(value: unknown): value is Record<string, unknown> { |
| return Boolean(value) && typeof value === 'object' && !Array.isArray(value); |
| } |
|
|
| function normalizeBoolean(value: unknown, fallback: boolean) { |
| return typeof value === 'boolean' ? value : fallback; |
| } |
|
|
| function normalizeThemeMode(value: unknown, fallback: ThemeMode): ThemeMode { |
| return value === 'light' || value === 'dark' ? value : fallback; |
| } |
|
|
| function normalizeLanguage(value: unknown, fallback: InterfaceLanguage): InterfaceLanguage { |
| return value === 'english' || value === 'spanish' || value === 'french' || value === 'german' |
| ? value |
| : fallback; |
| } |
|
|
| function normalizeFontSize(value: unknown, fallback: FontSizePreference): FontSizePreference { |
| return value === 'small' || value === 'medium' || value === 'large' ? value : fallback; |
| } |
|
|
| export function normalizeAddress(value: unknown): DeliveryAddress { |
| if (!isPlainObject(value)) { |
| return { ...EMPTY_ADDRESS }; |
| } |
|
|
| return { |
| fullName: typeof value.fullName === 'string' ? value.fullName : '', |
| phone: typeof value.phone === 'string' ? value.phone : '', |
| street: typeof value.street === 'string' ? value.street : '', |
| city: typeof value.city === 'string' ? value.city : '', |
| state: typeof value.state === 'string' ? value.state : '', |
| pincode: typeof value.pincode === 'string' ? value.pincode : '', |
| country: typeof value.country === 'string' && value.country.trim() ? value.country : EMPTY_ADDRESS.country, |
| }; |
| } |
|
|
| export function normalizePreferences( |
| value: unknown, |
| fallbackThemeMode: ThemeMode = 'dark', |
| ): UserPreferences { |
| const defaults = createDefaultPreferences(fallbackThemeMode); |
| if (!isPlainObject(value)) { |
| return defaults; |
| } |
|
|
| const twoFactorEnabled = normalizeBoolean(value.twoFactorEnabled, defaults.twoFactorEnabled); |
| const twoFactorCode = typeof value.twoFactorCode === 'string' ? value.twoFactorCode.trim() : ''; |
|
|
| return { |
| themeMode: normalizeThemeMode(value.themeMode, defaults.themeMode), |
| language: normalizeLanguage(value.language, defaults.language), |
| fontSize: normalizeFontSize(value.fontSize, defaults.fontSize), |
| emailNotifications: normalizeBoolean(value.emailNotifications, defaults.emailNotifications), |
| pushNotifications: normalizeBoolean(value.pushNotifications, defaults.pushNotifications), |
| soundEnabled: normalizeBoolean(value.soundEnabled, defaults.soundEnabled), |
| autoSave: normalizeBoolean(value.autoSave, defaults.autoSave), |
| twoFactorEnabled: twoFactorEnabled && twoFactorCode.length >= 6, |
| twoFactorCode, |
| dataAnalytics: normalizeBoolean(value.dataAnalytics, defaults.dataAnalytics), |
| address: normalizeAddress(value.address), |
| updatedAt: typeof value.updatedAt === 'string' && value.updatedAt.trim() |
| ? value.updatedAt |
| : defaults.updatedAt, |
| }; |
| } |
|
|
| function readJson<T>(key: string): T | null { |
| if (typeof window === 'undefined') { |
| return null; |
| } |
|
|
| const raw = window.localStorage.getItem(key); |
| if (!raw) { |
| return null; |
| } |
|
|
| try { |
| return JSON.parse(raw) as T; |
| } catch { |
| return null; |
| } |
| } |
|
|
| function writeJson(key: string, value: unknown) { |
| if (typeof window === 'undefined') { |
| return; |
| } |
|
|
| window.localStorage.setItem(key, JSON.stringify(value)); |
| } |
|
|
| function removeValue(key: string) { |
| if (typeof window === 'undefined') { |
| return; |
| } |
|
|
| window.localStorage.removeItem(key); |
| } |
|
|
| function readLegacyPreferences(themeMode: ThemeMode): UserPreferences { |
| if (typeof window === 'undefined') { |
| return createDefaultPreferences(themeMode); |
| } |
|
|
| const defaults = createDefaultPreferences(themeMode); |
| const legacyAddress = normalizeAddress(readJson<DeliveryAddress>(LEGACY_ADDRESS_KEY)); |
|
|
| return { |
| ...defaults, |
| themeMode: normalizeThemeMode(window.localStorage.getItem(LEGACY_KEYS.themeMode), defaults.themeMode), |
| language: normalizeLanguage(window.localStorage.getItem(LEGACY_KEYS.language), defaults.language), |
| fontSize: normalizeFontSize(window.localStorage.getItem(LEGACY_KEYS.fontSize), defaults.fontSize), |
| emailNotifications: window.localStorage.getItem(LEGACY_KEYS.emailNotifications) !== 'false', |
| pushNotifications: window.localStorage.getItem(LEGACY_KEYS.pushNotifications) !== 'false', |
| soundEnabled: window.localStorage.getItem(LEGACY_KEYS.soundEnabled) !== 'false', |
| autoSave: window.localStorage.getItem(LEGACY_KEYS.autoSave) !== 'false', |
| twoFactorEnabled: window.localStorage.getItem(LEGACY_KEYS.twoFactorEnabled) === 'true', |
| dataAnalytics: window.localStorage.getItem(LEGACY_KEYS.dataAnalytics) !== 'false', |
| address: legacyAddress, |
| }; |
| } |
|
|
| function clearLegacySettings() { |
| if (typeof window === 'undefined') { |
| return; |
| } |
|
|
| Object.values(LEGACY_KEYS).forEach((key) => { |
| window.localStorage.removeItem(key); |
| }); |
| window.localStorage.removeItem(LEGACY_ADDRESS_KEY); |
| } |
|
|
| export function loadUserPreferences(userId: string, fallbackThemeMode: ThemeMode = 'dark') { |
| const stored = readJson<ImportedPreferencesPayload>(preferencesStorageKey(userId)); |
| if (stored) { |
| return normalizePreferences(stored, fallbackThemeMode); |
| } |
|
|
| const migrated = readLegacyPreferences(fallbackThemeMode); |
| saveUserPreferences(userId, migrated); |
| clearLegacySettings(); |
| return migrated; |
| } |
|
|
| export function saveUserPreferences(userId: string, preferences: UserPreferences) { |
| const normalized = normalizePreferences(preferences, preferences.themeMode); |
| normalized.updatedAt = new Date().toISOString(); |
| writeJson(preferencesStorageKey(userId), normalized); |
| return normalized; |
| } |
|
|
| export function resetUserPreferences(userId: string, themeMode: ThemeMode = 'dark') { |
| const defaults = createDefaultPreferences(themeMode); |
| writeJson(preferencesStorageKey(userId), defaults); |
| return defaults; |
| } |
|
|
| export function exportUserPreferences(preferences: UserPreferences) { |
| return { |
| version: SETTINGS_EXPORT_VERSION, |
| exportedAt: new Date().toISOString(), |
| ...preferences, |
| }; |
| } |
|
|
| export function parseImportedPreferences( |
| rawText: string, |
| fallbackThemeMode: ThemeMode = 'dark', |
| ) { |
| const parsed = JSON.parse(rawText) as ImportedPreferencesPayload; |
| return normalizePreferences(parsed, fallbackThemeMode); |
| } |
|
|
| export function createTwoFactorCode() { |
| return String(Math.floor(100000 + Math.random() * 900000)); |
| } |
|
|
| export function fontSizeToRootRem(fontSize: FontSizePreference) { |
| if (fontSize === 'small') { |
| return '14px'; |
| } |
| if (fontSize === 'large') { |
| return '18px'; |
| } |
| return '16px'; |
| } |
|
|
| export function getCodeEditorFontSize(fontSize: FontSizePreference) { |
| if (fontSize === 'small') { |
| return 13; |
| } |
| if (fontSize === 'large') { |
| return 17; |
| } |
| return 15; |
| } |
|
|
| export function trackAnalyticsEvent( |
| userId: string, |
| enabled: boolean, |
| type: string, |
| payload?: Record<string, string | number | boolean | null>, |
| ) { |
| if (!enabled || typeof window === 'undefined') { |
| return; |
| } |
|
|
| const current = readJson<AnalyticsEvent[]>(analyticsStorageKey(userId)) ?? []; |
| const next = [ |
| { |
| type, |
| payload, |
| createdAt: new Date().toISOString(), |
| }, |
| ...current, |
| ].slice(0, 50); |
|
|
| writeJson(analyticsStorageKey(userId), next); |
| } |
|
|
| export function loadAnalyticsEvents(userId: string) { |
| return readJson<AnalyticsEvent[]>(analyticsStorageKey(userId)) ?? []; |
| } |
|
|
| export function clearAnalyticsEvents(userId: string) { |
| removeValue(analyticsStorageKey(userId)); |
| } |
|
|
| export function saveUserScopedJson(scope: string, userId: string, value: unknown) { |
| writeJson(userScopedStorageKey(scope, userId), value); |
| } |
|
|
| export function loadUserScopedJson<T>(scope: string, userId: string) { |
| return readJson<T>(userScopedStorageKey(scope, userId)); |
| } |
|
|
| export function removeUserScopedJson(scope: string, userId: string) { |
| removeValue(userScopedStorageKey(scope, userId)); |
| } |
|
|
| export async function requestBrowserNotificationPermission() { |
| if (typeof window === 'undefined' || !('Notification' in window)) { |
| return 'unsupported' as const; |
| } |
|
|
| if (Notification.permission === 'granted') { |
| return 'granted' as const; |
| } |
|
|
| if (Notification.permission === 'denied') { |
| return 'denied' as const; |
| } |
|
|
| return Notification.requestPermission(); |
| } |
|
|
| export function sendBrowserNotification( |
| enabled: boolean, |
| title: string, |
| options?: NotificationOptions, |
| ) { |
| if ( |
| !enabled || |
| typeof window === 'undefined' || |
| !('Notification' in window) || |
| Notification.permission !== 'granted' |
| ) { |
| return; |
| } |
|
|
| try { |
| const notification = new Notification(title, options); |
| window.setTimeout(() => notification.close(), 5000); |
| } catch { |
| |
| } |
| } |
|
|
| export function playUiSound(enabled: boolean, variant: 'success' | 'error' | 'soft' = 'soft') { |
| if (!enabled || typeof window === 'undefined') { |
| return; |
| } |
|
|
| const AudioCtor = window.AudioContext || (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; |
| if (!AudioCtor) { |
| return; |
| } |
|
|
| const context = new AudioCtor(); |
| const oscillator = context.createOscillator(); |
| const gain = context.createGain(); |
| const now = context.currentTime; |
|
|
| oscillator.type = variant === 'error' ? 'sawtooth' : 'sine'; |
| oscillator.frequency.setValueAtTime(variant === 'error' ? 220 : variant === 'success' ? 660 : 440, now); |
| gain.gain.setValueAtTime(0.0001, now); |
| gain.gain.exponentialRampToValueAtTime(0.05, now + 0.02); |
| gain.gain.exponentialRampToValueAtTime(0.0001, now + (variant === 'soft' ? 0.12 : 0.18)); |
|
|
| oscillator.connect(gain); |
| gain.connect(context.destination); |
| oscillator.start(now); |
| oscillator.stop(now + (variant === 'soft' ? 0.12 : 0.18)); |
| window.setTimeout(() => { |
| void context.close().catch(() => undefined); |
| }, variant === 'soft' ? 180 : 260); |
| } |
|
|