Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
4 changes: 3 additions & 1 deletion src/app/components/gamification/AchievementsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { motion } from "framer-motion";
import { Lock, CheckCircle } from "lucide-react";
import { useGamificationStore } from "@/app/stores/useGamificationStore";
import { useUIStore } from "@/app/stores/useUIStore";
import { Card, CardHeader, CardTitle, CardContent } from "../ui/Card";

interface AchievementsPanelProps {
Expand All @@ -11,7 +12,8 @@ interface AchievementsPanelProps {

export function AchievementsPanel({ className }: AchievementsPanelProps) {
const achievements = useGamificationStore((state) => state.achievements);
const animationsEnabled = useGamificationStore((state) => state.animationsEnabled);
const reducedMotion = useUIStore((state) => state.reducedMotion);
const animationsEnabled = !reducedMotion;

const unlockedCount = achievements.filter((a) => a.unlockedAt).length;
const totalCount = achievements.length;
Expand Down
70 changes: 21 additions & 49 deletions src/app/components/gamification/GamificationSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"use client";

import { Volume2, VolumeX, Sparkles, SparklesIcon } from "lucide-react";
import { Volume2, VolumeX, Sparkles, Accessibility } from "lucide-react";
import { useGamificationStore } from "@/app/stores/useGamificationStore";
import { useUIStore } from "@/app/stores/useUIStore";
import { useSoundEffect } from "@/app/utils/soundManager";
import { Card, CardHeader, CardTitle, CardContent } from "../ui/Card";
import { useEffect } from "react";
Expand All @@ -11,45 +12,34 @@ interface GamificationSettingsProps {
}

export function GamificationSettings({ className }: GamificationSettingsProps) {
const soundEnabled = useGamificationStore((state) => state.soundEnabled);
const animationsEnabled = useGamificationStore((state) => state.animationsEnabled);
const soundVolume = useGamificationStore((state) => state.soundVolume);
const toggleSound = useGamificationStore((state) => state.toggleSound);
const toggleAnimations = useGamificationStore((state) => state.toggleAnimations);
const setSoundVolume = useGamificationStore((state) => state.setSoundVolume);
const soundEnabled = useUIStore((state) => state.soundEnabled);
const reducedMotion = useUIStore((state) => state.reducedMotion);
const setSoundEnabled = useUIStore((state) => state.setSoundEnabled);
const setReducedMotion = useUIStore((state) => state.setReducedMotion);

const sound = useSoundEffect();

// Sync sound manager with store
useEffect(() => {
sound.setEnabled(soundEnabled);
sound.setVolume(soundVolume);
}, [soundEnabled, soundVolume, sound]);
}, [soundEnabled, sound]);

const handleToggleSound = () => {
toggleSound();
if (!soundEnabled) {
const nextState = !soundEnabled;
setSoundEnabled(nextState);
if (nextState) {
// Play a test sound when enabling
setTimeout(() => sound.play("success"), 100);
}
};

const handleToggleAnimations = () => {
toggleAnimations();
if (soundEnabled) {
const handleToggleReducedMotion = () => {
setReducedMotion(!reducedMotion);
if (soundEnabled && reducedMotion) {
sound.play("click");
}
};

const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newVolume = parseFloat(e.target.value);
setSoundVolume(newVolume);
// Play a test sound at the new volume
if (soundEnabled) {
sound.play("xpGain");
}
};

return (
<Card className={className}>
<CardHeader>
Expand Down Expand Up @@ -88,48 +78,30 @@ export function GamificationSettings({ className }: GamificationSettingsProps) {
</button>
</div>

{/* Volume slider */}
{soundEnabled && (
<div className="space-y-2">
<label className="text-sm font-medium text-gray-900 dark:text-zinc-100">
Volume: {Math.round(soundVolume * 100)}%
</label>
<input
type="range"
min="0"
max="1"
step="0.1"
value={soundVolume}
onChange={handleVolumeChange}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-zinc-700 accent-blue-600"
/>
</div>
)}

{/* Animations toggle */}
{/* Reduced Motion toggle */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Sparkles
<Accessibility
className={`h-5 w-5 ${
animationsEnabled ? "text-purple-600 dark:text-purple-400" : "text-gray-400"
reducedMotion ? "text-purple-600 dark:text-purple-400" : "text-gray-400"
}`}
/>
<div>
<p className="text-sm font-medium text-gray-900 dark:text-zinc-100">Animations</p>
<p className="text-sm font-medium text-gray-900 dark:text-zinc-100">Reduced Motion</p>
<p className="text-xs text-gray-600 dark:text-zinc-400">
Enable micro-animations and effects
Disable or tone down non-essential animations
</p>
</div>
</div>
<button
onClick={handleToggleAnimations}
onClick={handleToggleReducedMotion}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
animationsEnabled ? "bg-purple-600" : "bg-gray-300 dark:bg-zinc-700"
reducedMotion ? "bg-purple-600" : "bg-gray-300 dark:bg-zinc-700"
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
animationsEnabled ? "translate-x-6" : "translate-x-1"
reducedMotion ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
Expand Down
4 changes: 3 additions & 1 deletion src/app/components/gamification/KingdomProgressWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { motion } from "framer-motion";
import { Crown, TrendingUp } from "lucide-react";
import { useGamificationStore, getNextLevelInfo } from "@/app/stores/useGamificationStore";
import { useUIStore } from "@/app/stores/useUIStore";
import { Card } from "../ui/Card";

interface KingdomProgressWidgetProps {
Expand All @@ -14,7 +15,8 @@ export function KingdomProgressWidget({ className, compact = false }: KingdomPro
const level = useGamificationStore((state) => state.level);
const xp = useGamificationStore((state) => state.xp);
const kingdomTitle = useGamificationStore((state) => state.kingdomTitle);
const animationsEnabled = useGamificationStore((state) => state.animationsEnabled);
const reducedMotion = useUIStore((state) => state.reducedMotion);
const animationsEnabled = !reducedMotion;

const { nextLevel, xpToNext, progress } = getNextLevelInfo(xp);
const isMaxLevel = nextLevel === level;
Expand Down
6 changes: 4 additions & 2 deletions src/app/components/gamification/LevelUpModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useEffect, useRef } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { X, Crown, Sparkles, Gift } from "lucide-react";
import { useGamificationStore } from "@/app/stores/useGamificationStore";
import { useUIStore } from "@/app/stores/useUIStore";
import { useSoundEffect } from "@/app/utils/soundManager";
import { Button } from "../ui/Button";
import { useModalFocusTrap } from "../../hooks/useModalFocusTrap";
Expand All @@ -12,8 +13,9 @@ export function LevelUpModal() {
const showModal = useGamificationStore((state) => state.showLevelUpModal);
const pendingLevelUp = useGamificationStore((state) => state.pendingLevelUp);
const dismissLevelUp = useGamificationStore((state) => state.dismissLevelUp);
const soundEnabled = useGamificationStore((state) => state.soundEnabled);
const animationsEnabled = useGamificationStore((state) => state.animationsEnabled);
const soundEnabled = useUIStore((state) => state.soundEnabled);
const reducedMotion = useUIStore((state) => state.reducedMotion);
const animationsEnabled = !reducedMotion;
const modalRef = useRef<HTMLDivElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);

Expand Down
12 changes: 7 additions & 5 deletions src/app/components/gamification/XPGainAnimation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { motion, AnimatePresence } from "framer-motion";
import { Sparkles } from "lucide-react";
import { useEffect, useState } from "react";
import { useUIStore } from "@/app/stores/useUIStore";

interface XPGainAnimationProps {
amount: number;
Expand All @@ -18,6 +19,7 @@ export function XPGainAnimation({
position = "top",
}: XPGainAnimationProps) {
const [isVisible, setIsVisible] = useState(show);
const reducedMotion = useUIStore((state) => state.reducedMotion);

useEffect(() => {
if (show) {
Expand All @@ -40,16 +42,16 @@ export function XPGainAnimation({
<AnimatePresence>
{isVisible && (
<motion.div
initial={{ opacity: 0, scale: 0.5, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.8, y: -20 }}
initial={reducedMotion ? { opacity: 0 } : { opacity: 0, scale: 0.5, y: 20 }}
animate={reducedMotion ? { opacity: 1 } : { opacity: 1, scale: 1, y: 0 }}
exit={reducedMotion ? { opacity: 0 } : { opacity: 0, scale: 0.8, y: -20 }}
transition={{ duration: 0.4, ease: "easeOut" }}
className={`fixed left-1/2 ${positionClasses[position]} z-50 -translate-x-1/2`}
>
<div className="flex items-center gap-2 rounded-full bg-gradient-to-r from-purple-600 to-blue-600 px-6 py-3 shadow-lg">
<motion.div
animate={{ rotate: [0, 360] }}
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
animate={reducedMotion ? {} : { rotate: [0, 360] }}
transition={reducedMotion ? {} : { duration: 1, repeat: Infinity, ease: "linear" }}
>
<Sparkles size={20} className="text-yellow-300" />
</motion.div>
Expand Down
36 changes: 0 additions & 36 deletions src/app/stores/useGamificationStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,6 @@ interface GamificationState {
kingdomTitle: KingdomLevel;
achievements: Achievement[];

// Settings
soundEnabled: boolean;
animationsEnabled: boolean;
soundVolume: number; // 0-1

// UI state
showLevelUpModal: boolean;
pendingLevelUp: LevelUpReward | null;
Expand All @@ -64,11 +59,6 @@ interface GamificationActions {
unlockAchievement: (achievementId: string) => void;
updateAchievementProgress: (achievementId: string, progress: number) => void;

// Settings
toggleSound: () => void;
toggleAnimations: () => void;
setSoundVolume: (volume: number) => void;

// Reset
resetGamification: () => void;
}
Expand Down Expand Up @@ -191,9 +181,6 @@ const initialState: GamificationState = {
xp: 0,
kingdomTitle: "Peasant",
achievements: INITIAL_ACHIEVEMENTS,
soundEnabled: true,
animationsEnabled: true,
soundVolume: 0.5,
showLevelUpModal: false,
pendingLevelUp: null,
recentXPGain: null,
Expand Down Expand Up @@ -297,26 +284,6 @@ export const useGamificationStore = create<GamificationStore>()(
);
},

toggleSound: () => {
set(
(state) => ({ soundEnabled: !state.soundEnabled }),
false,
"gamification/toggleSound",
);
},

toggleAnimations: () => {
set(
(state) => ({ animationsEnabled: !state.animationsEnabled }),
false,
"gamification/toggleAnimations",
);
},

setSoundVolume: (volume) => {
set({ soundVolume: Math.max(0, Math.min(1, volume)) }, false, "gamification/setVolume");
},

resetGamification: () => {
set({ ...initialState }, false, "gamification/reset");
},
Expand All @@ -335,9 +302,6 @@ export const selectLevel = (state: GamificationStore) => state.level;
export const selectXP = (state: GamificationStore) => state.xp;
export const selectKingdomTitle = (state: GamificationStore) => state.kingdomTitle;
export const selectAchievements = (state: GamificationStore) => state.achievements;
export const selectSoundEnabled = (state: GamificationStore) => state.soundEnabled;
export const selectAnimationsEnabled = (state: GamificationStore) => state.animationsEnabled;
export const selectSoundVolume = (state: GamificationStore) => state.soundVolume;
export const selectShowLevelUpModal = (state: GamificationStore) => state.showLevelUpModal;
export const selectPendingLevelUp = (state: GamificationStore) => state.pendingLevelUp;

Expand Down
54 changes: 48 additions & 6 deletions src/app/stores/useUIStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@
* - Toast notification queue
* - Global (page-level) loading overlay
*
* Design decision: no persistence — UI state should always start fresh.
* Design decision: most UI state starts fresh, but settings (sound/motion) are persisted.
*/

import { create } from "zustand";
import { devtools } from "zustand/middleware";
import { devtools, persist } from "zustand/middleware";

// ─── Toast types ──────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -54,6 +54,9 @@ interface UIState {
/** True while a global loading spinner should be shown */
isGlobalLoading: boolean;
globalLoadingMessage: string | null;
/** Global settings for accessibility and comfort */
soundEnabled: boolean;
reducedMotion: boolean;
}

interface UIActions {
Expand Down Expand Up @@ -82,6 +85,11 @@ interface UIActions {

showGlobalLoading: (message?: string) => void;
hideGlobalLoading: () => void;

// ── Settings ──────────────────────────────────────────────────────────────

setSoundEnabled: (enabled: boolean) => void;
setReducedMotion: (reduced: boolean) => void;
}

export type UIStore = UIState & UIActions;
Expand All @@ -103,19 +111,29 @@ const defaultModals = Object.fromEntries(ALL_MODALS.map((id) => [id, { isOpen: f

// ─── Initial state ────────────────────────────────────────────────────────────

const getInitialReducedMotion = () => {
if (typeof window !== "undefined") {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
return false;
};

const initialState: UIState = {
modals: defaultModals,
toasts: [],
isGlobalLoading: false,
globalLoadingMessage: null,
soundEnabled: true,
reducedMotion: getInitialReducedMotion(),
};

// ─── Store ────────────────────────────────────────────────────────────────────

export const useUIStore = create<UIStore>()(
devtools(
(set) => ({
...initialState,
persist(
(set) => ({
...initialState,

// ── Modals ──────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -181,14 +199,38 @@ export const useUIStore = create<UIStore>()(

hideGlobalLoading: () =>
set({ isGlobalLoading: false, globalLoadingMessage: null }, false, "ui/hideGlobalLoading"),

// ── Settings ─────────────────────────────────────────────────────────────

setSoundEnabled: (enabled) =>
set({ soundEnabled: enabled }, false, "ui/setSoundEnabled"),

setReducedMotion: (reduced) =>
set({ reducedMotion: reduced }, false, "ui/setReducedMotion"),
}),
{ name: "UIStore" },
{
name: "ui-store",
partialize: (state) => ({
soundEnabled: state.soundEnabled,
reducedMotion: state.reducedMotion,
}),
}
),
);
{ name: "UIStore" }
));

if (typeof window !== "undefined") {
const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
mediaQuery.addEventListener("change", (e) => {
useUIStore.getState().setReducedMotion(e.matches);
});
}

// ─── Selectors ────────────────────────────────────────────────────────────────

export const selectModal = (id: ModalId) => (state: UIStore) => state.modals[id];
export const selectToasts = (state: UIStore) => state.toasts;
export const selectIsGlobalLoading = (state: UIStore) => state.isGlobalLoading;
export const selectGlobalLoadingMessage = (state: UIStore) => state.globalLoadingMessage;
export const selectSoundEnabled = (state: UIStore) => state.soundEnabled;
export const selectReducedMotion = (state: UIStore) => state.reducedMotion;
Loading