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'),
}
)
);