Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/components/GameLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { PetDisplay } from "./PetDisplay";
import { SettingsPanel } from "./SettingsPanel";
import { StatsBar } from "./StatsBar";
import { StatsPanel } from "./StatsPanel";
import { StorageBanner } from "./StorageBanner";
import { UpgradesSidebar } from "./UpgradesSidebar";

export function GameLayout() {
Expand Down Expand Up @@ -153,6 +154,7 @@ export function GameLayout() {
onClose={() => setSettingsOpen(false)}
/>
<CrtOverlay />
<StorageBanner />

{konamiVisible && (
<div
Expand Down
88 changes: 88 additions & 0 deletions src/components/StorageBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { Alert, Text } from "@mantine/core";
import { useCallback, useEffect, useState } from "react";
import {
getStorageAvailable,
type StorageError,
setStorageErrorHandler,
} from "../utils/safeStorage";

/**
* Persistent, non-intrusive banner that appears when:
* 1. localStorage is completely unavailable, OR
* 2. A QuotaExceededError occurs during play.
*
* Dismissable per-session, but reappears on quota errors.
*/
export function StorageBanner() {
const [storageUnavailable] = useState(() => !getStorageAvailable());
const [quotaError, setQuotaError] = useState(false);
const [dismissed, setDismissed] = useState(false);

const handleStorageError = useCallback((error: StorageError) => {
if (error.type === "quota-exceeded") {
setQuotaError(true);
setDismissed(false); // Re-show on new quota errors
}
}, []);

useEffect(() => {
setStorageErrorHandler(handleStorageError);
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Consider returning a cleanup function from this useEffect to unregister the handler on unmount:

useEffect(() => {
  setStorageErrorHandler(handleStorageError);
  return () => setStorageErrorHandler(null);
}, [handleStorageError]);

Non-blocking since this component lives at the root and never unmounts.

}, [handleStorageError]);

if (dismissed) return null;

if (storageUnavailable) {
return (
<Alert
color="orange"
variant="filled"
withCloseButton
onClose={() => setDismissed(true)}
style={{
position: "fixed",
bottom: 16,
left: "50%",
transform: "translateX(-50%)",
zIndex: 9999,
maxWidth: 480,
fontFamily: "monospace",
}}
data-testid="storage-banner"
>
<Text size="sm" ff="monospace">
localStorage is unavailable. Your progress will not be saved between
sessions. Use "Copy Save to Clipboard" in Settings to back up
manually.
</Text>
</Alert>
);
}

if (quotaError) {
return (
<Alert
color="red"
variant="light"
withCloseButton
onClose={() => setDismissed(true)}
style={{
position: "fixed",
bottom: 16,
left: "50%",
transform: "translateX(-50%)",
zIndex: 9999,
maxWidth: 480,
fontFamily: "monospace",
}}
data-testid="storage-banner"
>
<Text size="sm" ff="monospace">
Storage is full! Your latest progress may not be saved. Export your
save from Settings or clear other site data to free space.
</Text>
</Alert>
);
}

return null;
}
1 change: 1 addition & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ export { SettingsPanel } from "./SettingsPanel";
export { SpeechBubble } from "./SpeechBubble";
export { StatsBar } from "./StatsBar";
export { StatsPanel } from "./StatsPanel";
export { StorageBanner } from "./StorageBanner";
export { UpgradesSidebar } from "./UpgradesSidebar";
3 changes: 2 additions & 1 deletion src/store/dailyStore.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { getCurrentDateUTC } from "../engine/dailyObjectivesEngine";
import { safeStorage } from "../utils/safeStorage";

// ── State ─────────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -93,6 +94,6 @@ export const useDailyStore = create<DailyStore>()(
};
}),
}),
{ name: "glorp-daily" },
{ name: "glorp-daily", storage: safeStorage },
),
);
60 changes: 36 additions & 24 deletions src/store/gameStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
getUpgradeCost,
} from "../engine/upgradeEngine";
import { D, Decimal, toDecimal } from "../utils/decimal";
import { safeStorage } from "../utils/safeStorage";

export interface GameState {
trainingData: Decimal;
Expand Down Expand Up @@ -494,35 +495,46 @@ export const useGameStore = create<GameStore>()(
}),
{
name: "glorp-game-state",
storage: safeStorage,
merge: (persisted, current) => {
const saved = persisted as Partial<Record<string, unknown>> | undefined;
if (!saved) return current;
try {
const saved = persisted as
| Partial<Record<string, unknown>>
| undefined;
if (!saved) return current;

// Convert Decimal fields from persisted strings/numbers back to Decimal
const merged = { ...current, ...(saved as Partial<GameState>) };
for (const key of DECIMAL_KEYS) {
const val = saved[key];
if (val !== undefined) {
(merged as Record<string, unknown>)[key] = toDecimal(
val as string | number | null,
);
// Convert Decimal fields from persisted strings/numbers back to Decimal
const merged = { ...current, ...(saved as Partial<GameState>) };
for (const key of DECIMAL_KEYS) {
const val = saved[key];
if (val !== undefined) {
(merged as Record<string, unknown>)[key] = toDecimal(
val as string | number | null,
);
}
}
}

// Migrate old saves: convert wisdomTokens to spendable balance
if (saved.prestigeUpgrades === undefined) {
merged.prestigeUpgrades = {};
}
if (saved.prestigeTokenBalance === undefined) {
merged.prestigeTokenBalance = (saved.wisdomTokens as number) ?? 0;
}
if (saved.hasOpenedPrestigeShop === undefined) {
merged.hasOpenedPrestigeShop = false;
}
if (saved.runStart === undefined) {
merged.runStart = Date.now();
// Migrate old saves: convert wisdomTokens to spendable balance
if (saved.prestigeUpgrades === undefined) {
merged.prestigeUpgrades = {};
}
if (saved.prestigeTokenBalance === undefined) {
merged.prestigeTokenBalance = (saved.wisdomTokens as number) ?? 0;
}
if (saved.hasOpenedPrestigeShop === undefined) {
merged.hasOpenedPrestigeShop = false;
}
if (saved.runStart === undefined) {
merged.runStart = Date.now();
}
return merged;
} catch (e) {
console.error(
"[gameStore] Failed to merge persisted state, falling back to defaults:",
e,
);
return current;
}
return merged;
},
},
),
Expand Down
3 changes: 2 additions & 1 deletion src/store/settingsStore.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { safeStorage } from "../utils/safeStorage";

export type BuyMode = 1 | 10 | 100 | "max";

Expand Down Expand Up @@ -42,6 +43,6 @@ export const useSettingsStore = create<SettingsStore>()(
setNumberFormat: (numberFormat) => set({ numberFormat }),
setSoundEnabled: (soundEnabled) => set({ soundEnabled }),
}),
{ name: "glorp-settings" },
{ name: "glorp-settings", storage: safeStorage },
),
);
Loading
Loading