406 lines
15 KiB
TypeScript
406 lines
15 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
import { User } from '@/lib/session';
|
|
import {
|
|
fetchPreferences,
|
|
fetchUsageData,
|
|
savePreferencesAsync,
|
|
saveUsageEntryAsync,
|
|
shouldShowUsagePrompt,
|
|
markPromptShown,
|
|
generateQuitPlan,
|
|
fetchAchievements,
|
|
fetchSavingsConfig,
|
|
saveSavingsConfig,
|
|
unlockAchievement,
|
|
checkBadgeEligibility,
|
|
UserPreferences,
|
|
UsageEntry,
|
|
Achievement,
|
|
SavingsConfig,
|
|
BADGE_DEFINITIONS,
|
|
BadgeDefinition,
|
|
} from '@/lib/storage';
|
|
import { UserHeader } from './UserHeader';
|
|
import { SetupWizard } from './SetupWizard';
|
|
import { UsagePromptDialog } from './UsagePromptDialog';
|
|
import { UsageCalendar } from './UsageCalendar';
|
|
import { StatsCard } from './StatsCard';
|
|
import { QuitPlanCard } from './QuitPlanCard';
|
|
import { AchievementsCard } from './AchievementsCard';
|
|
import { CelebrationAnimation } from './CelebrationAnimation';
|
|
import { HealthTimelineCard } from './HealthTimelineCard';
|
|
import { SavingsTrackerCard } from './SavingsTrackerCard';
|
|
import { MoodTracker } from './MoodTracker';
|
|
import { Button } from '@/components/ui/button';
|
|
import { PlusCircle, ChevronLeft, ChevronRight } from 'lucide-react';
|
|
import { useTheme } from '@/lib/theme-context';
|
|
import { getTodayString } from '@/lib/date-utils';
|
|
|
|
interface DashboardProps {
|
|
user: User;
|
|
}
|
|
|
|
export function Dashboard({ user }: DashboardProps) {
|
|
const [preferences, setPreferences] = useState<UserPreferences | null>(null);
|
|
const [usageData, setUsageData] = useState<UsageEntry[]>([]);
|
|
const [achievements, setAchievements] = useState<Achievement[]>([]);
|
|
const [savingsConfig, setSavingsConfig] = useState<SavingsConfig | null>(null);
|
|
const [showSetup, setShowSetup] = useState(false);
|
|
const [showUsagePrompt, setShowUsagePrompt] = useState(false);
|
|
const [showCelebration, setShowCelebration] = useState(false);
|
|
const [newBadge, setNewBadge] = useState<BadgeDefinition | null>(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [refreshKey, setRefreshKey] = useState(0);
|
|
const [currentPage, setCurrentPage] = useState(0);
|
|
const swipeContainerRef = useRef<HTMLDivElement>(null);
|
|
const { theme } = useTheme();
|
|
|
|
const handleScroll = useCallback(() => {
|
|
if (!swipeContainerRef.current) return;
|
|
const scrollLeft = swipeContainerRef.current.scrollLeft;
|
|
const width = swipeContainerRef.current.offsetWidth;
|
|
const page = Math.round(scrollLeft / width);
|
|
if (page !== currentPage) {
|
|
setCurrentPage(page);
|
|
}
|
|
}, [currentPage]);
|
|
|
|
const scrollToPage = (pageIndex: number) => {
|
|
if (!swipeContainerRef.current) return;
|
|
const width = swipeContainerRef.current.offsetWidth;
|
|
swipeContainerRef.current.scrollTo({
|
|
left: pageIndex * width,
|
|
behavior: 'smooth'
|
|
});
|
|
};
|
|
|
|
const loadData = useCallback(async () => {
|
|
const [prefs, usage, achvs, savings] = await Promise.all([
|
|
fetchPreferences(),
|
|
fetchUsageData(),
|
|
fetchAchievements(),
|
|
fetchSavingsConfig(),
|
|
]);
|
|
setPreferences(prefs);
|
|
setUsageData(usage);
|
|
setAchievements(achvs);
|
|
setSavingsConfig(savings);
|
|
console.log('[Dashboard] Loaded prefs:', prefs);
|
|
setRefreshKey(prev => prev + 1);
|
|
return { prefs, usage, achvs };
|
|
}, []);
|
|
|
|
const checkAndUnlockAchievements = useCallback(async (
|
|
usage: UsageEntry[],
|
|
prefs: UserPreferences,
|
|
currentAchievements: Achievement[]
|
|
) => {
|
|
const unlockedIds = new Set(currentAchievements.map(a => `${a.badgeId}-${a.substance}`));
|
|
|
|
for (const badge of BADGE_DEFINITIONS) {
|
|
for (const substance of ['nicotine', 'weed'] as const) {
|
|
const key = `${badge.id}-${substance}`;
|
|
if (unlockedIds.has(key)) continue;
|
|
|
|
const isEligible = checkBadgeEligibility(badge.id, usage, prefs, substance);
|
|
if (isEligible) {
|
|
const result = await unlockAchievement(badge.id, substance);
|
|
if (result.isNew && result.achievement) {
|
|
setNewBadge(badge);
|
|
setShowCelebration(true);
|
|
setAchievements(prev => [...prev, result.achievement!]);
|
|
return; // Only show one celebration at a time
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const init = async () => {
|
|
const { prefs, usage, achvs } = await loadData();
|
|
|
|
if (!prefs.hasCompletedSetup) {
|
|
setShowSetup(true);
|
|
} else {
|
|
// Check for achievements
|
|
await checkAndUnlockAchievements(usage, prefs, achvs);
|
|
|
|
// Check if running as PWA (home screen shortcut)
|
|
const isStandalone = window.matchMedia('(display-mode: standalone)').matches ||
|
|
(window.navigator as Navigator & { standalone?: boolean }).standalone === true;
|
|
|
|
// Always show usage prompt when accessed as PWA shortcut
|
|
if (isStandalone) {
|
|
setShowUsagePrompt(true);
|
|
} else if (shouldShowUsagePrompt()) {
|
|
setShowUsagePrompt(true);
|
|
markPromptShown();
|
|
}
|
|
}
|
|
|
|
setIsLoading(false);
|
|
};
|
|
|
|
init();
|
|
}, [loadData, checkAndUnlockAchievements]);
|
|
|
|
const handleSetupComplete = async (data: { substance: 'nicotine' | 'weed'; name: string; age: number; religion: 'christian' | 'secular' }) => {
|
|
const today = getTodayString();
|
|
const newPrefs: UserPreferences = {
|
|
substance: data.substance,
|
|
trackingStartDate: today,
|
|
hasCompletedSetup: true,
|
|
dailyGoal: null,
|
|
quitPlan: null,
|
|
userName: data.name,
|
|
userAge: data.age,
|
|
religion: data.religion,
|
|
};
|
|
await savePreferencesAsync(newPrefs);
|
|
setPreferences(newPrefs);
|
|
setShowSetup(false);
|
|
setShowUsagePrompt(true);
|
|
setRefreshKey(prev => prev + 1);
|
|
};
|
|
|
|
const handleUsageSubmit = async (count: number, substance: 'nicotine' | 'weed') => {
|
|
if (!preferences) {
|
|
setShowUsagePrompt(false);
|
|
return;
|
|
}
|
|
|
|
let latestPrefs = preferences;
|
|
|
|
if (count > 0) {
|
|
const today = getTodayString();
|
|
const now = new Date().toISOString();
|
|
|
|
await saveUsageEntryAsync({
|
|
date: today,
|
|
count,
|
|
substance,
|
|
});
|
|
|
|
// Update preferences with last usage time
|
|
latestPrefs = {
|
|
...preferences,
|
|
[substance === 'nicotine' ? 'lastNicotineUsageTime' : 'lastWeedUsageTime']: now,
|
|
};
|
|
await savePreferencesAsync(latestPrefs);
|
|
setPreferences(latestPrefs);
|
|
}
|
|
|
|
setShowUsagePrompt(false);
|
|
// Reload data and force calendar refresh
|
|
const usage = await fetchUsageData();
|
|
setUsageData(usage);
|
|
setRefreshKey(prev => prev + 1);
|
|
|
|
// Check for new achievements immediately
|
|
await checkAndUnlockAchievements(usage, latestPrefs, achievements);
|
|
};
|
|
|
|
const handleGeneratePlan = async () => {
|
|
if (!preferences) return;
|
|
|
|
const plan = generateQuitPlan(preferences.substance);
|
|
const updatedPrefs = {
|
|
...preferences,
|
|
quitPlan: plan,
|
|
};
|
|
await savePreferencesAsync(updatedPrefs);
|
|
setPreferences(updatedPrefs);
|
|
setRefreshKey(prev => prev + 1);
|
|
};
|
|
|
|
const handleSavingsConfigChange = async (config: SavingsConfig) => {
|
|
setSavingsConfig(config);
|
|
await saveSavingsConfig(config);
|
|
};
|
|
|
|
const handleCelebrationComplete = () => {
|
|
setShowCelebration(false);
|
|
setNewBadge(null);
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center">
|
|
<div className="animate-pulse text-lg text-white">Loading...</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
|
|
|
|
return (
|
|
<div className="min-h-screen">
|
|
<UserHeader user={user} preferences={preferences} />
|
|
|
|
<main className="container mx-auto px-4 py-4 sm:py-8 pb-4 sm:pb-8 max-w-full">
|
|
{preferences && (
|
|
<>
|
|
{/* Floating Log Button */}
|
|
<div className="fixed bottom-6 right-6 z-50 opacity-0 animate-scale-in delay-500 sm:block">
|
|
<Button
|
|
size="lg"
|
|
onClick={() => setShowUsagePrompt(true)}
|
|
className="h-14 px-6 sm:h-16 sm:px-8 text-base sm:text-lg rounded-full shadow-xl bg-gradient-to-r from-primary to-primary/80 hover:from-primary/90 hover:to-primary/70 drop-shadow-lg hover-lift transition-all duration-300 hover:scale-105 active:scale-95"
|
|
>
|
|
<PlusCircle className="mr-2 h-5 w-5 sm:h-6 sm:w-6" />
|
|
Log Usage
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Dashboard Sections */}
|
|
<div className="space-y-6 sm:space-y-12 relative overflow-hidden">
|
|
{/* Mobile Navigation Buttons - LARGE */}
|
|
<div className="sm:hidden">
|
|
{currentPage > 0 && (
|
|
<button
|
|
onClick={() => scrollToPage(currentPage - 1)}
|
|
className="fixed left-3 top-[55%] -translate-y-1/2 z-[60] p-5 rounded-full glass border border-white/20 shadow-2xl active:scale-90 transition-all duration-300 group"
|
|
style={{
|
|
background: theme === 'light' ? 'rgba(255, 255, 255, 0.85)' : 'rgba(0, 0, 0, 0.6)',
|
|
backdropFilter: 'blur(16px)',
|
|
boxShadow: '0 8px 32px rgba(0,0,0,0.2)'
|
|
}}
|
|
>
|
|
<ChevronLeft className="h-8 w-8 text-primary group-hover:scale-110" />
|
|
</button>
|
|
)}
|
|
{currentPage < 3 && (
|
|
<button
|
|
onClick={() => scrollToPage(currentPage + 1)}
|
|
className="fixed right-3 top-[55%] -translate-y-1/2 z-[60] p-5 rounded-full glass border border-white/20 shadow-2xl active:scale-90 transition-all duration-300 group"
|
|
style={{
|
|
background: theme === 'light' ? 'rgba(255, 255, 255, 0.85)' : 'rgba(0, 0, 0, 0.6)',
|
|
backdropFilter: 'blur(16px)',
|
|
boxShadow: '0 8px 32px rgba(0,0,0,0.2)'
|
|
}}
|
|
>
|
|
<ChevronRight className="h-8 w-8 text-primary group-hover:scale-110" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* SECTION: Mobile Swipe Ecosystem */}
|
|
<div
|
|
ref={swipeContainerRef}
|
|
onScroll={handleScroll}
|
|
className="swipe-container sm:space-y-12 sm:block"
|
|
>
|
|
|
|
{/* SLIDE 1: Mindset (Mood & Personalized Plan) */}
|
|
<div className="swipe-item space-y-4">
|
|
<div className="sm:hidden flex items-center justify-between mb-2 px-1">
|
|
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Daily Mindset</h2>
|
|
</div>
|
|
<div className="space-y-4 sm:grid sm:grid-cols-2 sm:gap-6 sm:space-y-0">
|
|
<MoodTracker />
|
|
<QuitPlanCard
|
|
key={`quit-plan-${refreshKey}`}
|
|
plan={preferences.quitPlan}
|
|
onGeneratePlan={handleGeneratePlan}
|
|
usageData={usageData}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* SLIDE 2: Stats & Recovery (Side-by-side Stats + Health) */}
|
|
<div className="swipe-item space-y-4">
|
|
<div className="sm:hidden flex items-center justify-between mb-2 px-1">
|
|
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Usage & Recovery</h2>
|
|
</div>
|
|
<div className="space-y-4">
|
|
<div className="grid grid-cols-2 gap-3 sm:gap-6">
|
|
<StatsCard key={`stats-nicotine-${refreshKey}`} usageData={usageData} substance="nicotine" />
|
|
<StatsCard key={`stats-weed-${refreshKey}`} usageData={usageData} substance="weed" />
|
|
</div>
|
|
<HealthTimelineCard
|
|
key={`health-${refreshKey}`}
|
|
usageData={usageData}
|
|
preferences={preferences}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* SLIDE 3: Achievements & Money (Insights) */}
|
|
<div className="swipe-item space-y-4">
|
|
<div className="sm:hidden flex items-center justify-between mb-2 px-1">
|
|
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Achievements & Savings</h2>
|
|
</div>
|
|
<div className="space-y-4 sm:grid sm:grid-cols-2 sm:gap-6 sm:space-y-0">
|
|
<AchievementsCard
|
|
key={`achievements-${refreshKey}`}
|
|
achievements={achievements}
|
|
substance={preferences.substance}
|
|
/>
|
|
<SavingsTrackerCard
|
|
key={`savings-${refreshKey}`}
|
|
savingsConfig={savingsConfig}
|
|
usageData={usageData}
|
|
trackingStartDate={preferences.trackingStartDate}
|
|
onSavingsConfigChange={handleSavingsConfigChange}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* SLIDE 4: Calendar */}
|
|
<div id="calendar-section" className="swipe-item">
|
|
<div className="sm:hidden flex items-center justify-between mb-2 px-1">
|
|
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Usage Calendar</h2>
|
|
</div>
|
|
<UsageCalendar
|
|
key={refreshKey}
|
|
usageData={usageData}
|
|
onDataUpdate={loadData}
|
|
userId={user.id}
|
|
religion={preferences.religion}
|
|
onReligionUpdate={async (religion) => {
|
|
const updatedPrefs = { ...preferences, religion };
|
|
setPreferences(updatedPrefs);
|
|
await savePreferencesAsync(updatedPrefs);
|
|
}}
|
|
preferences={preferences}
|
|
onPreferencesUpdate={async (updatedPrefs) => {
|
|
await savePreferencesAsync(updatedPrefs);
|
|
setPreferences(updatedPrefs);
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</main >
|
|
|
|
<SetupWizard open={showSetup} onComplete={handleSetupComplete} />
|
|
|
|
{
|
|
preferences && (
|
|
<UsagePromptDialog
|
|
open={showUsagePrompt}
|
|
onClose={() => setShowUsagePrompt(false)}
|
|
onSubmit={handleUsageSubmit}
|
|
userId={user.id}
|
|
/>
|
|
)
|
|
}
|
|
|
|
{
|
|
showCelebration && newBadge && (
|
|
<CelebrationAnimation
|
|
badge={newBadge}
|
|
onComplete={handleCelebrationComplete}
|
|
/>
|
|
)
|
|
}
|
|
</div >
|
|
);
|
|
}
|