diff --git a/Frontend/src/admin/store/adminStore.js b/Frontend/src/admin/store/adminStore.js index f2d7e661..df65476e 100644 --- a/Frontend/src/admin/store/adminStore.js +++ b/Frontend/src/admin/store/adminStore.js @@ -1,5 +1,6 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; +import { createPersistConfig } from '../../store/persistence'; const useAdminStore = create( persist( @@ -29,9 +30,9 @@ const useAdminStore = create( users: state.users.filter(u => u.id !== userId) })), }), - { + createPersistConfig('admin-settings', { name: 'admin-storage-settings', - } + }) ) ); diff --git a/Frontend/src/components/ErrorBoundary.jsx b/Frontend/src/components/ErrorBoundary.jsx new file mode 100644 index 00000000..05fb944e --- /dev/null +++ b/Frontend/src/components/ErrorBoundary.jsx @@ -0,0 +1,242 @@ +import React from 'react'; + +/** + * ErrorBoundary — catches unhandled React errors and shows a stylized fallback UI + * with a "copy diagnostic payload" button for rapid issue reporting. + */ +class ErrorBoundary extends React.Component { + constructor(props) { + super(props); + this.state = { + hasError: false, + error: null, + errorInfo: null, + copied: false, + }; + } + + static getDerivedStateFromError(error) { + return { hasError: true, error }; + } + + componentDidCatch(error, errorInfo) { + this.setState({ errorInfo }); + // Optionally log to external service + if (typeof this.props.onError === 'function') { + this.props.onError(error, errorInfo); + } + } + + /** + * Builds a structured diagnostic payload for debugging. + */ + buildDiagnosticPayload() { + const { error, errorInfo } = this.state; + return { + timestamp: new Date().toISOString(), + url: typeof window !== 'undefined' ? window.location.href : '', + userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : '', + error: error + ? { + message: error.message, + stack: error.stack?.split('\n').slice(0, 8).join('\n'), + name: error.name, + } + : null, + componentStack: errorInfo?.componentStack + ? errorInfo.componentStack.split('\n').slice(0, 8).join('\n') + : null, + }; + } + + handleCopyDiagnostics = async () => { + try { + const payload = JSON.stringify(this.buildDiagnosticPayload(), null, 2); + await navigator.clipboard.writeText(payload); + this.setState({ copied: true }); + setTimeout(() => this.setState({ copied: false }), 2000); + } catch { + // Fallback: select text in a textarea + const textarea = document.createElement('textarea'); + textarea.value = JSON.stringify(this.buildDiagnosticPayload(), null, 2); + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand('copy'); + document.body.removeChild(textarea); + this.setState({ copied: true }); + setTimeout(() => this.setState({ copied: false }), 2000); + } + }; + + handleReset = () => { + this.setState({ hasError: false, error: null, errorInfo: null, copied: false }); + }; + + render() { + if (this.state.hasError) { + // Allow custom fallback via prop + if (this.props.fallback) { + return this.props.fallback( + this.state.error, + this.handleReset, + () => this.buildDiagnosticPayload() + ); + } + + return ( +
+
+
+ ⚠️ +
+

+ {this.props.title || 'Something went wrong'} +

+

+ {this.props.message || + 'An unexpected error occurred. Our team has been notified.'} +

+ + {/* Error detail for dev — collapsible */} + {import.meta.env.DEV && ( +
+ + Technical Details + +
+                                    {this.state.error?.stack}
+                                
+
+ )} + +
+ + +
+
+
+ ); + } + + return this.props.children; + } +} + +const styles = { + container: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + minHeight: '60vh', + padding: '24px', + background: '#0f172a', + }, + card: { + background: '#1e293b', + borderRadius: '16px', + padding: '40px', + maxWidth: '520px', + width: '100%', + boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.5)', + border: '1px solid #334155', + }, + iconContainer: { + width: '64px', + height: '64px', + borderRadius: '50%', + background: '#450a0a', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + margin: '0 auto 20px', + }, + icon: { fontSize: '28px' }, + title: { + textAlign: 'center', + color: '#f1f5f9', + fontSize: '1.25rem', + marginBottom: '12px', + fontWeight: 600, + }, + message: { + textAlign: 'center', + color: '#94a3b8', + fontSize: '0.9rem', + lineHeight: 1.5, + marginBottom: '24px', + }, + details: { + background: '#0f172a', + borderRadius: '8px', + padding: '12px', + marginBottom: '20px', + }, + detailsSummary: { + color: '#64748b', + cursor: 'pointer', + fontSize: '0.8rem', + fontWeight: 500, + }, + pre: { + fontSize: '0.7rem', + color: '#94a3b8', + marginTop: '8px', + maxHeight: '150px', + overflowY: 'auto', + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + }, + actions: { + display: 'flex', + gap: '12px', + flexWrap: 'wrap', + }, + button: { + flex: 1, + padding: '10px 16px', + background: '#10b981', + color: '#fff', + border: 'none', + borderRadius: '8px', + cursor: 'pointer', + fontSize: '0.85rem', + fontWeight: 500, + transition: 'background 0.2s', + minWidth: '120px', + }, + secondaryButton: { + background: '#1f2937', + border: '1px solid #374151', + }, +}; + +export default ErrorBoundary; diff --git a/Frontend/src/main.jsx b/Frontend/src/main.jsx index b9a1a6de..baf9a00d 100644 --- a/Frontend/src/main.jsx +++ b/Frontend/src/main.jsx @@ -2,9 +2,12 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' import App from './App.jsx' +import ErrorBoundary from './components/ErrorBoundary' createRoot(document.getElementById('root')).render( - + + + , ) diff --git a/Frontend/src/store/adminStore.js b/Frontend/src/store/adminStore.js index fb0f2708..da95c049 100644 --- a/Frontend/src/store/adminStore.js +++ b/Frontend/src/store/adminStore.js @@ -1,5 +1,6 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; +import { createPersistConfig } from './persistence'; const useAdminStore = create( persist( @@ -17,9 +18,7 @@ const useAdminStore = create( adminProfile: { ...state.adminProfile, ...updates } })), }), - { - name: 'admin-storage', - } + createPersistConfig('admin') ) ); diff --git a/Frontend/src/store/authStore.js b/Frontend/src/store/authStore.js index 88fa03d6..207b7501 100644 --- a/Frontend/src/store/authStore.js +++ b/Frontend/src/store/authStore.js @@ -1,5 +1,6 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; +import { createPersistConfig } from './persistence'; import { supabase } from '../lib/supabaseClient'; import useTicketStore from './ticketStore'; @@ -289,14 +290,12 @@ const useAuthStore = create( }); } }), - { - name: 'auth-storage', + createPersistConfig('auth', { partialize: (state) => ({ - // We keep profile persisted for quick UI transitions, - // but session is handled by Supabase cookie/localStorage - profile: state.profile + user: state.user, + profile: state.profile, }), - } + }) ) ); diff --git a/Frontend/src/store/persistence.js b/Frontend/src/store/persistence.js new file mode 100644 index 00000000..f413fe6d --- /dev/null +++ b/Frontend/src/store/persistence.js @@ -0,0 +1,115 @@ +/** + * Centralized Zustand Persistence Configuration + * + * Single source of truth for all localStorage keys and persistence options. + * All stores should import from here instead of configuring persist() inline. + */ +const PERSISTENCE_KEYS = { + auth: 'auth-storage', + admin: 'admin-storage', + 'admin-settings': 'admin-storage-settings', + ticket: 'ticket-storage', + toast: 'toast-storage', +}; + +/** + * Creates a standardized persist config for zustand stores. + * Handles quota errors, migration, and key collisions in one place. + * + * @param {string} storeName - Key from PERSISTENCE_KEYS map + * @param {object} [overrides] - Optional overrides for specific stores + * @returns {object} Persist config object for zustand's persist() middleware + */ +export function createPersistConfig(storeName, overrides = {}) { + const storageKey = PERSISTENCE_KEYS[storeName]; + if (!storageKey) { + console.warn(`[Persistence] Unknown store name "${storeName}", falling back to key directly.`); + } + + return { + name: storageKey || storeName, + version: 1, + // Graceful degradation: if localStorage is full or unavailable, + // the store still works in-memory — the user just loses state on refresh. + partialize: (state) => { + // Only persist serializable data (exclude functions like actions) + const serializable = {}; + for (const [key, value] of Object.entries(state)) { + if (typeof value !== 'function') { + serializable[key] = value; + } + } + return serializable; + }, + merge: (persisted, current) => ({ + ...current, + ...persisted, + }), + onRehydrateStorage: () => (state) => { + if (state) { + console.log(`[Persistence] Store "${storeName}" rehydrated successfully.`); + } + }, + ...overrides, + // Always wrap storage with error handling + storage: { + getItem: (name) => { + try { + const value = localStorage.getItem(name); + return value ? JSON.parse(value) : null; + } catch (e) { + console.warn(`[Persistence] Failed to read "${name}":`, e.message); + return null; + } + }, + setItem: (name, value) => { + try { + localStorage.setItem(name, JSON.stringify(value)); + } catch (e) { + if (e instanceof DOMException && e.name === 'QuotaExceededError') { + console.warn(`[Persistence] localStorage quota exceeded for "${name}". Clearing oldest entries.`); + // Fallback: clear non-critical keys to free space + const critical = Object.values(PERSISTENCE_KEYS); + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (!critical.includes(key)) { + localStorage.removeItem(key); + } + } + // Retry once + try { + localStorage.setItem(name, JSON.stringify(value)); + } catch (_) { + console.error('[Persistence] Still cannot write after cleanup.'); + } + } else { + console.warn(`[Persistence] Failed to write "${name}":`, e.message); + } + } + }, + removeItem: (name) => { + try { + localStorage.removeItem(name); + } catch (e) { + console.warn(`[Persistence] Failed to remove "${name}":`, e.message); + } + }, + }, + }; +} + +/** + * Clears all non-critical localStorage entries. + * Useful for manual cleanup in admin panels or error recovery. + */ +export function clearNonCriticalStorage() { + const critical = Object.values(PERSISTENCE_KEYS); + for (let i = localStorage.length - 1; i >= 0; i--) { + const key = localStorage.key(i); + if (!critical.includes(key)) { + localStorage.removeItem(key); + } + } +} + +export default PERSISTENCE_KEYS; diff --git a/Frontend/src/store/ticketStore.js b/Frontend/src/store/ticketStore.js index 1b88dddf..0fca1c2c 100644 --- a/Frontend/src/store/ticketStore.js +++ b/Frontend/src/store/ticketStore.js @@ -1,5 +1,6 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; +import { createPersistConfig } from './persistence'; const useTicketStore = create( persist( @@ -87,7 +88,7 @@ const useTicketStore = create( clearTicket: () => set({ aiTicket: null, activeTicket: null, autoResolvedTickets: [] }), }), { - name: 'ticket-storage', // unique name for localStorage key + ...createPersistConfig('ticket'), } ) );