diff --git a/src/components/AppErrorBoundary.tsx b/src/components/AppErrorBoundary.tsx
new file mode 100644
index 0000000..50b21ff
--- /dev/null
+++ b/src/components/AppErrorBoundary.tsx
@@ -0,0 +1,69 @@
+import { Component, type ErrorInfo, type ReactNode } from 'react';
+
+type AppErrorBoundaryProps = {
+ children: ReactNode;
+};
+
+type AppErrorBoundaryState =
+ | { kind: 'ok' }
+ | { error: Error; kind: 'error' };
+
+function clearLocalStorageKidA() {
+ const keysToRemove: string[] = [];
+
+ for (let i = 0; i < window.localStorage.length; i++) {
+ const key = window.localStorage.key(i);
+
+ if (key?.startsWith('kid-a:')) {
+ keysToRemove.push(key);
+ }
+ }
+
+ keysToRemove.forEach((key) => window.localStorage.removeItem(key));
+ window.location.reload();
+}
+
+export class AppErrorBoundary extends Component<
+ AppErrorBoundaryProps,
+ AppErrorBoundaryState
+> {
+ constructor(props: AppErrorBoundaryProps) {
+ super(props);
+ this.state = { kind: 'ok' };
+ }
+
+ static getDerivedStateFromError(error: Error): AppErrorBoundaryState {
+ return { error, kind: 'error' };
+ }
+
+ override componentDidCatch(error: Error, info: ErrorInfo) {
+ console.error('AppErrorBoundary caught an error', error, info.componentStack);
+ }
+
+ override render() {
+ if (this.state.kind === 'error') {
+ return (
+
+
+
Something went wrong
+
+ The app failed to load. This can be caused by corrupted local data.
+ Clearing local storage will reset your saved friends and session data
+ but will not affect event progress.
+
+
{this.state.error.message}
+
+ Clear local data and reload
+
+
+
+ );
+ }
+
+ return this.props.children;
+ }
+}
diff --git a/src/contexts/LocalDataLayerContext.tsx b/src/contexts/LocalDataLayerContext.tsx
index 8a76832..7e9f7c3 100644
--- a/src/contexts/LocalDataLayerContext.tsx
+++ b/src/contexts/LocalDataLayerContext.tsx
@@ -7,6 +7,7 @@ import {
type PropsWithChildren,
} from 'react';
import type { Kid } from '../data/data-model';
+import { useI18n } from '../i18n/I18nProvider';
type LocalDataLayerContextValue = {
getFriendIds: () => string[];
@@ -18,6 +19,11 @@ type LocalDataLayerContextValue = {
const friendsStorageKey = 'kid-a:local:friends';
const friendKidsStorageKey = 'kid-a:local:friend-kids';
+type StorageReadResult =
+ | { data: T; kind: 'ok' }
+ | { kind: 'error'; message: string }
+ | { kind: 'empty' };
+
const LocalDataLayerContext = createContext<
LocalDataLayerContextValue | undefined
>(undefined);
@@ -28,27 +34,28 @@ function dedupeFriendIds(friendIds: string[]) {
);
}
-function readStoredFriends(): string[] {
+function readStoredFriends(): StorageReadResult {
const storedFriends = window.localStorage.getItem(friendsStorageKey);
if (!storedFriends) {
- return [];
+ return { kind: 'empty' };
}
try {
const parsedFriends: unknown = JSON.parse(storedFriends);
if (Array.isArray(parsedFriends)) {
- return dedupeFriendIds(
- parsedFriends.filter((friendId) => typeof friendId === 'string'),
- );
+ return {
+ data: dedupeFriendIds(
+ parsedFriends.filter((friendId) => typeof friendId === 'string'),
+ ),
+ kind: 'ok',
+ };
}
- console.warn('Ignoring invalid local friends data.');
- return [];
- } catch (error) {
- console.warn('Ignoring unreadable local friends data.', error);
- return [];
+ return { kind: 'error', message: 'Invalid local friends data' };
+ } catch {
+ return { kind: 'error', message: 'Unreadable local friends data' };
}
}
@@ -68,37 +75,39 @@ function isStoredKid(value: unknown): value is Kid {
);
}
-function readStoredFriendKids(): Record {
+function readStoredFriendKids(): StorageReadResult> {
const storedFriendKids = window.localStorage.getItem(friendKidsStorageKey);
if (!storedFriendKids) {
- return {};
+ return { kind: 'empty' };
}
try {
const parsedFriendKids: unknown = JSON.parse(storedFriendKids);
if (Array.isArray(parsedFriendKids)) {
- return Object.fromEntries(
- parsedFriendKids
- .filter(isStoredKid)
- .map((kid) => [kid.id, kid]),
- );
+ return {
+ data: Object.fromEntries(
+ parsedFriendKids.filter(isStoredKid).map((kid) => [kid.id, kid]),
+ ),
+ kind: 'ok',
+ };
}
if (parsedFriendKids && typeof parsedFriendKids === 'object') {
- return Object.fromEntries(
- Object.values(parsedFriendKids)
- .filter(isStoredKid)
- .map((kid) => [kid.id, kid]),
- );
+ return {
+ data: Object.fromEntries(
+ Object.values(parsedFriendKids)
+ .filter(isStoredKid)
+ .map((kid) => [kid.id, kid]),
+ ),
+ kind: 'ok',
+ };
}
- console.warn('Ignoring invalid local friend kids data.');
- return {};
- } catch (error) {
- console.warn('Ignoring unreadable local friend kids data.', error);
- return {};
+ return { kind: 'error', message: 'Invalid local friend kids data' };
+ } catch {
+ return { kind: 'error', message: 'Unreadable local friend kids data' };
}
}
@@ -109,10 +118,92 @@ function writeStoredFriendKids(friendKidsById: Record) {
);
}
+function clearLocalStorageKidA() {
+ const keysToRemove: string[] = [];
+
+ for (let i = 0; i < window.localStorage.length; i++) {
+ const key = window.localStorage.key(i);
+
+ if (key?.startsWith('kid-a:')) {
+ keysToRemove.push(key);
+ }
+ }
+
+ keysToRemove.forEach((key) => window.localStorage.removeItem(key));
+}
+
+type StorageError = { messages: string[] };
+
+function collectStorageErrors(): StorageError | null {
+ const friendsResult = readStoredFriends();
+ const friendKidsResult = readStoredFriendKids();
+ const errorMessages: string[] = [];
+
+ if (friendsResult.kind === 'error') {
+ errorMessages.push(friendsResult.message);
+ }
+
+ if (friendKidsResult.kind === 'error') {
+ errorMessages.push(friendKidsResult.message);
+ }
+
+ return errorMessages.length > 0 ? { messages: errorMessages } : null;
+}
+
+function StorageErrorBanner({
+ error,
+ onDismiss,
+}: {
+ error: StorageError;
+ onDismiss: () => void;
+}) {
+ const { t } = useI18n();
+
+ function handleClear() {
+ clearLocalStorageKidA();
+ onDismiss();
+ }
+
+ return (
+
+
{t('storage.error.notice')}
+
+
+ {t('storage.error.clear')}
+
+
+ {t('storage.error.dismiss')}
+
+
+
+ );
+}
+
export function LocalDataLayerProvider({ children }: PropsWithChildren) {
- const [friendIds, setFriendIds] = useState(() => readStoredFriends());
+ const [friendIds, setFriendIds] = useState(() => {
+ const result = readStoredFriends();
+ return result.kind === 'ok' ? result.data : [];
+ });
const [friendKidsById, setFriendKidsById] = useState>(
- () => readStoredFriendKids(),
+ () => {
+ const result = readStoredFriendKids();
+ return result.kind === 'ok' ? result.data : {};
+ },
+ );
+ const [storageError, setStorageError] = useState(() =>
+ collectStorageErrors(),
);
useEffect(() => {
@@ -126,11 +217,13 @@ export function LocalDataLayerProvider({ children }: PropsWithChildren) {
useEffect(() => {
const readFriendsFromAnotherTab = (event: StorageEvent) => {
if (event.key === friendsStorageKey) {
- setFriendIds(readStoredFriends());
+ const result = readStoredFriends();
+ setFriendIds(result.kind === 'ok' ? result.data : []);
}
if (event.key === friendKidsStorageKey) {
- setFriendKidsById(readStoredFriendKids());
+ const result = readStoredFriendKids();
+ setFriendKidsById(result.kind === 'ok' ? result.data : {});
}
};
@@ -175,6 +268,12 @@ export function LocalDataLayerProvider({ children }: PropsWithChildren) {
return (
+ {storageError && (
+ setStorageError(null)}
+ />
+ )}
{children}
);
diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts
index 1393cb5..dc2e889 100644
--- a/src/i18n/messages.ts
+++ b/src/i18n/messages.ts
@@ -229,6 +229,9 @@ export const messages = {
'user.idLabel': 'ID',
'user.nameLabel': 'Name',
'user.logout': 'Log out',
+ 'storage.error.notice': 'Some local data could not be read and was reset.',
+ 'storage.error.clear': 'Clear local data',
+ 'storage.error.dismiss': 'Dismiss',
},
es: {
'app.titlePrefix': 'Bienvenida a',
@@ -453,6 +456,9 @@ export const messages = {
'user.idLabel': 'ID',
'user.nameLabel': 'Nombre',
'user.logout': 'Cerrar sesión',
+ 'storage.error.notice': 'Algunos datos locales no se pudieron leer y se han restablecido.',
+ 'storage.error.clear': 'Limpiar datos locales',
+ 'storage.error.dismiss': 'Ignorar',
},
} as const;
diff --git a/src/main.tsx b/src/main.tsx
index 0bcc2a4..c9a60d0 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -2,6 +2,7 @@ import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
+import { AppErrorBoundary } from './components/AppErrorBoundary';
import { DataLayerProvider } from './contexts/DataLayerContext';
import { LocalDataLayerProvider } from './contexts/LocalDataLayerContext';
import { I18nProvider } from './i18n/I18nProvider';
@@ -12,14 +13,16 @@ initializeMagicLinkSession();
createRoot(document.getElementById('root')!).render(
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
,
);
diff --git a/src/styles.css b/src/styles.css
index 990da4f..0971f28 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -2563,3 +2563,129 @@ h2 {
page-break-inside: avoid;
}
}
+
+/* Storage error banner */
+.storage-error-banner {
+ position: fixed;
+ top: 16px;
+ left: 50%;
+ z-index: 1000;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ align-items: center;
+ justify-content: space-between;
+ width: min(600px, calc(100% - 32px));
+ padding: 12px 16px;
+ border: 1px solid #c05621;
+ border-radius: 12px;
+ background: #fff7ed;
+ box-shadow: 0 4px 16px rgb(0 0 0 / 12%);
+ color: #7c2d12;
+ transform: translateX(-50%);
+}
+
+.storage-error-text {
+ margin: 0;
+ flex: 1;
+ min-width: 0;
+ font-size: 0.875rem;
+}
+
+.storage-error-actions {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ flex-shrink: 0;
+}
+
+.storage-error-clear {
+ padding: 6px 12px;
+ border: 1px solid #c05621;
+ border-radius: 999px;
+ background: #c05621;
+ color: #fff;
+ font-size: 0.8125rem;
+ font-weight: 700;
+ cursor: pointer;
+ white-space: nowrap;
+}
+
+.storage-error-clear:hover {
+ background: #9a3412;
+ border-color: #9a3412;
+}
+
+.storage-error-dismiss {
+ padding: 6px 10px;
+ border: none;
+ background: transparent;
+ color: #9a3412;
+ font-size: 0.8125rem;
+ cursor: pointer;
+ text-decoration: underline;
+}
+
+.storage-error-dismiss:hover {
+ color: #7c2d12;
+}
+
+/* App crash screen */
+.app-crash-screen {
+ display: flex;
+ min-height: 100vh;
+ align-items: center;
+ justify-content: center;
+ padding: 24px;
+ background: #eef5df;
+}
+
+.app-crash-card {
+ width: min(480px, 100%);
+ padding: clamp(24px, 5vw, 48px);
+ border: 1px solid #8c6b3f;
+ border-radius: 24px;
+ background: linear-gradient(135deg, #fbf7ec 0%, #dfecc2 100%);
+ box-shadow: 0 16px 48px rgb(61 85 35 / 16%);
+}
+
+.app-crash-title {
+ margin: 0 0 12px;
+ font-size: 1.5rem;
+ font-weight: 800;
+ color: #7c2d12;
+}
+
+.app-crash-message {
+ margin: 0 0 12px;
+ font-size: 0.9375rem;
+ line-height: 1.5;
+ color: #4a3520;
+}
+
+.app-crash-error {
+ margin: 0 0 20px;
+ padding: 10px 14px;
+ border: 1px solid #e5c89b;
+ border-radius: 8px;
+ background: #fef3e2;
+ font-family: ui-monospace, monospace;
+ font-size: 0.8125rem;
+ color: #7c2d12;
+ word-break: break-word;
+}
+
+.app-crash-btn {
+ padding: 10px 20px;
+ border: none;
+ border-radius: 999px;
+ background: #c05621;
+ color: #fff;
+ font-size: 0.9375rem;
+ font-weight: 700;
+ cursor: pointer;
+}
+
+.app-crash-btn:hover {
+ background: #9a3412;
+}