742 lines
27 KiB
TypeScript
742 lines
27 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
|
import React 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,
|
|
StorageRequestError,
|
|
} from '@/lib/storage';
|
|
import { UserHeader } from './UserHeader';
|
|
import { SetupWizard } from './SetupWizard';
|
|
import { UsageCalendar } from './UsageCalendar';
|
|
import { StatsCard } from './StatsCard';
|
|
import { UnifiedQuitPlanCard } from './UnifiedQuitPlanCard';
|
|
import { AchievementsCard } from './AchievementsCard';
|
|
import { CelebrationAnimation } from './CelebrationAnimation';
|
|
import { HealthTimelineCard } from './HealthTimelineCard';
|
|
import { SavingsTrackerCard } from './SavingsTrackerCard';
|
|
import { MoodTracker } from './MoodTracker';
|
|
import { DailyInspirationCard } from './DailyInspirationCard';
|
|
import { ScrollWheelLogger } from './ScrollWheelLogger';
|
|
import { UsageLoggerDropUp } from './UsageLoggerDropUp';
|
|
import { VersionUpdateModal } from './VersionUpdateModal';
|
|
import { Button } from '@/components/ui/button';
|
|
import { PlusCircle, X } from 'lucide-react';
|
|
import { useTheme } from '@/lib/theme-context';
|
|
import { getTodayString } from '@/lib/date-utils';
|
|
|
|
|
|
interface DashboardProps {
|
|
user: User;
|
|
}
|
|
|
|
const MOBILE_SLIDES = [
|
|
{ id: 'mood', label: 'How Are You Feeling' },
|
|
{ id: 'plan', label: 'Quit Journey Plan' },
|
|
{ id: 'stats', label: 'Usage Stats' },
|
|
{ id: 'recovery', label: 'Health Recovery' },
|
|
{ id: 'achievements', label: 'Achievements' },
|
|
{ id: 'savings', label: 'Savings' },
|
|
{ id: 'calendar', label: 'Usage Calendar' },
|
|
];
|
|
|
|
const GLOBAL_BADGE_IDS = new Set(['first_day']);
|
|
|
|
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 [showCelebration, setShowCelebration] = useState(false);
|
|
const [isSubstancePickerOpen, setIsSubstancePickerOpen] = useState(false);
|
|
const [activeLoggingSubstance, setActiveLoggingSubstance] = useState<'nicotine' | 'weed' | null>(null);
|
|
const [newBadge, setNewBadge] = useState<BadgeDefinition | null>(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [loadError, setLoadError] = useState<string | null>(null);
|
|
const [refreshKey, setRefreshKey] = useState(0);
|
|
const [currentPage, setCurrentPage] = useState(0);
|
|
const [modalOpenCount, setModalOpenCount] = useState(0);
|
|
const swipeContainerRef = useRef<HTMLDivElement>(null);
|
|
const { theme } = useTheme();
|
|
|
|
const isModalOpen = modalOpenCount > 0 || showSetup || showCelebration;
|
|
const totalPages = MOBILE_SLIDES.length;
|
|
|
|
const handleModalStateChange = useCallback((isOpen: boolean) => {
|
|
setModalOpenCount(prev => isOpen ? prev + 1 : Math.max(0, prev - 1));
|
|
}, []);
|
|
|
|
const handleScroll = useCallback(() => {
|
|
const container = swipeContainerRef.current;
|
|
if (!container) return;
|
|
|
|
const slides = Array.from(container.querySelectorAll<HTMLElement>('.swipe-item'));
|
|
if (slides.length === 0) return;
|
|
|
|
let nearestIndex = 0;
|
|
let nearestDistance = Number.POSITIVE_INFINITY;
|
|
|
|
slides.forEach((slide, index) => {
|
|
const distance = Math.abs(container.scrollLeft - slide.offsetLeft);
|
|
if (distance < nearestDistance) {
|
|
nearestDistance = distance;
|
|
nearestIndex = index;
|
|
}
|
|
});
|
|
|
|
if (nearestIndex !== currentPage) {
|
|
setCurrentPage(nearestIndex);
|
|
}
|
|
}, [currentPage]);
|
|
|
|
const scrollToPage = (pageIndex: number) => {
|
|
const container = swipeContainerRef.current;
|
|
if (!container) return;
|
|
|
|
const slides = Array.from(container.querySelectorAll<HTMLElement>('.swipe-item'));
|
|
if (slides.length === 0) return;
|
|
|
|
const boundedPage = Math.min(Math.max(pageIndex, 0), totalPages - 1);
|
|
const targetSlide = slides[boundedPage];
|
|
|
|
container.scrollTo({
|
|
left: targetSlide?.offsetLeft ?? 0,
|
|
behavior: 'smooth'
|
|
});
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (typeof window === 'undefined') return;
|
|
const savedPage = Number(localStorage.getItem('quittraq_mobile_dashboard_page'));
|
|
if (Number.isNaN(savedPage) || savedPage < 0 || savedPage >= totalPages) return;
|
|
setCurrentPage(savedPage);
|
|
|
|
const timeout = window.setTimeout(() => {
|
|
scrollToPage(savedPage);
|
|
}, 0);
|
|
|
|
return () => window.clearTimeout(timeout);
|
|
}, [totalPages]);
|
|
|
|
useEffect(() => {
|
|
if (typeof window === 'undefined') return;
|
|
localStorage.setItem('quittraq_mobile_dashboard_page', String(currentPage));
|
|
}, [currentPage]);
|
|
|
|
const loadData = useCallback(async () => {
|
|
try {
|
|
const [prefs, usage, achvs, savings] = await Promise.all([
|
|
fetchPreferences(),
|
|
fetchUsageData(),
|
|
fetchAchievements(),
|
|
fetchSavingsConfig(),
|
|
]);
|
|
setPreferences(prefs);
|
|
setUsageData(usage);
|
|
setAchievements(achvs);
|
|
setSavingsConfig(savings);
|
|
setLoadError(null);
|
|
console.log('[Dashboard] Loaded prefs:', prefs);
|
|
setRefreshKey(prev => prev + 1);
|
|
return { prefs, usage, achvs };
|
|
} catch (error) {
|
|
const message = error instanceof StorageRequestError
|
|
? error.message
|
|
: 'Unable to sync your dashboard right now. Please try again.';
|
|
setLoadError(message);
|
|
throw error;
|
|
}
|
|
}, []);
|
|
|
|
const checkAndUnlockAchievements = useCallback(async (
|
|
usage: UsageEntry[],
|
|
prefs: UserPreferences,
|
|
currentAchievements: Achievement[]
|
|
) => {
|
|
// Current unlocked set (local + server)
|
|
const unlockedIds = new Set(currentAchievements.map(a => `${a.badgeId}-${a.substance}`));
|
|
const unlockedGlobalBadges = new Set(
|
|
currentAchievements
|
|
.filter((a) => GLOBAL_BADGE_IDS.has(a.badgeId))
|
|
.map((a) => a.badgeId)
|
|
);
|
|
const newUnlocked: Achievement[] = [];
|
|
let badgeToCelebrate: BadgeDefinition | null = null;
|
|
|
|
const hasUsageBySubstance = {
|
|
nicotine: usage.some((entry) => entry.substance === 'nicotine' && entry.count > 0),
|
|
weed: usage.some((entry) => entry.substance === 'weed' && entry.count > 0),
|
|
};
|
|
|
|
for (const badge of BADGE_DEFINITIONS) {
|
|
if (GLOBAL_BADGE_IDS.has(badge.id)) {
|
|
if (unlockedGlobalBadges.has(badge.id)) continue;
|
|
|
|
const isEligible = checkBadgeEligibility(badge.id, usage, prefs, 'nicotine')
|
|
|| checkBadgeEligibility(badge.id, usage, prefs, 'weed');
|
|
|
|
if (!isEligible) continue;
|
|
|
|
try {
|
|
const result = await unlockAchievement(badge.id, 'both');
|
|
if (result.isNew && result.achievement) {
|
|
newUnlocked.push(result.achievement);
|
|
unlockedGlobalBadges.add(badge.id);
|
|
if (!badgeToCelebrate) {
|
|
badgeToCelebrate = badge;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('Error unlocking global achievement:', e);
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
for (const substance of ['nicotine', 'weed'] as const) {
|
|
if (!hasUsageBySubstance[substance]) continue;
|
|
|
|
const key = `${badge.id}-${substance}`;
|
|
if (unlockedIds.has(key)) continue;
|
|
|
|
const isEligible = checkBadgeEligibility(badge.id, usage, prefs, substance);
|
|
if (isEligible) {
|
|
try {
|
|
const result = await unlockAchievement(badge.id, substance);
|
|
if (result.isNew && result.achievement) {
|
|
newUnlocked.push(result.achievement);
|
|
// Prioritize celebrating the first one found
|
|
if (!badgeToCelebrate) {
|
|
badgeToCelebrate = badge;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('Error unlocking achievement:', e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (newUnlocked.length > 0) {
|
|
// Update local state with ALL new achievements
|
|
setAchievements(prev => [...prev, ...newUnlocked]);
|
|
|
|
// Show celebration for determining badge
|
|
if (badgeToCelebrate) {
|
|
setNewBadge(badgeToCelebrate);
|
|
setShowCelebration(true);
|
|
}
|
|
}
|
|
|
|
return newUnlocked.length > 0;
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const init = async () => {
|
|
try {
|
|
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)
|
|
// No longer automatically showing substance picker
|
|
if (shouldShowUsagePrompt()) {
|
|
markPromptShown();
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Dashboard init error:', error);
|
|
} finally {
|
|
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);
|
|
setIsSubstancePickerOpen(true);
|
|
setRefreshKey(prev => prev + 1);
|
|
};
|
|
|
|
const handleUsageSubmit = async (count: number, substance: 'nicotine' | 'weed') => {
|
|
if (!preferences) {
|
|
setIsSubstancePickerOpen(false);
|
|
setActiveLoggingSubstance(null);
|
|
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,
|
|
};
|
|
|
|
// Force specific fields to be present to avoid partial update issues
|
|
// This ensures that even if preferences is stale, we explicitly set the usage time
|
|
const payload: UserPreferences = {
|
|
...latestPrefs,
|
|
lastNicotineUsageTime: substance === 'nicotine' ? now : (latestPrefs.lastNicotineUsageTime ?? null),
|
|
lastWeedUsageTime: substance === 'weed' ? now : (latestPrefs.lastWeedUsageTime ?? null),
|
|
};
|
|
|
|
await savePreferencesAsync(payload);
|
|
setPreferences(payload);
|
|
}
|
|
|
|
setActiveLoggingSubstance(null);
|
|
setIsSubstancePickerOpen(false);
|
|
// Reload data and force calendar refresh
|
|
const usage = await fetchUsageData();
|
|
setUsageData(usage);
|
|
setRefreshKey(prev => prev + 1);
|
|
|
|
// Check for new achievements metrics FIRST
|
|
await checkAndUnlockAchievements(usage, latestPrefs, achievements);
|
|
|
|
// Force a fresh fetch of all data to ensure UI sync
|
|
const freshAchievements = await fetchAchievements();
|
|
setAchievements(freshAchievements);
|
|
|
|
// THEN refresh UI components
|
|
setRefreshKey(prev => prev + 1);
|
|
};
|
|
|
|
const handleGeneratePlan = async (targetSubstance: 'nicotine' | 'weed') => {
|
|
if (!preferences) return;
|
|
|
|
const plan = generateQuitPlan(targetSubstance);
|
|
|
|
// Construct new state
|
|
const currentQuitState = preferences.quitState || {
|
|
nicotine: { plan: null, startDate: null },
|
|
weed: { plan: null, startDate: null }
|
|
};
|
|
|
|
const updatedQuitState = {
|
|
...currentQuitState,
|
|
[targetSubstance]: {
|
|
plan,
|
|
startDate: currentQuitState[targetSubstance].startDate || (preferences.substance === targetSubstance ? preferences.trackingStartDate : null) || getTodayString()
|
|
}
|
|
};
|
|
|
|
const updatedPrefs = {
|
|
...preferences,
|
|
quitState: updatedQuitState
|
|
};
|
|
|
|
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}
|
|
onModalStateChange={handleModalStateChange}
|
|
/>
|
|
|
|
<main className="container mx-auto px-4 py-4 sm:py-8 pb-28 sm:pb-8 max-w-full">
|
|
{loadError && (
|
|
<div className={`mb-4 rounded-xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm ${theme === 'light' ? 'text-amber-800' : 'text-amber-100'}`}>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<span>{loadError}</span>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-8"
|
|
onClick={async () => {
|
|
setIsLoading(true);
|
|
try {
|
|
await loadData();
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}}
|
|
>
|
|
Retry
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{!preferences && !isLoading && (
|
|
<div className="rounded-2xl border border-border bg-card/70 p-6 text-center">
|
|
<p className="text-sm text-muted-foreground">Your dashboard data is unavailable right now.</p>
|
|
</div>
|
|
)}
|
|
|
|
{preferences && (
|
|
<>
|
|
{/* Floating Log Button - Simplified to toggle Picker */}
|
|
<div className={`fixed bottom-[6.5rem] sm:bottom-6 left-1/2 -translate-x-1/2 sm:left-auto sm:translate-x-0 sm:right-6 z-40 transition-all duration-300 ${isModalOpen ? 'opacity-0 scale-90 pointer-events-none' : 'opacity-100 scale-100'} sm:block`}>
|
|
<Button
|
|
size="lg"
|
|
onClick={() => setIsSubstancePickerOpen(!isSubstancePickerOpen)}
|
|
className={`h-14 px-6 sm:h-16 sm:px-8 text-base sm:text-lg rounded-full shadow-xl transition-all duration-300 hover:scale-105 active:scale-95 flex items-center gap-2 ${isSubstancePickerOpen
|
|
? 'bg-white/10 text-white backdrop-blur-xl border border-white/20'
|
|
: 'bg-gradient-to-r from-primary to-primary/80 hover:from-primary/90 hover:to-primary/70 text-white border-none'
|
|
}`}
|
|
>
|
|
{isSubstancePickerOpen ? (
|
|
<>
|
|
<X className="h-5 w-5 sm:h-6 sm:w-6" />
|
|
Cancel
|
|
</>
|
|
) : (
|
|
<>
|
|
<PlusCircle className="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">
|
|
{/* DESKTOP LAYOUT - Hidden on mobile */}
|
|
<div className="hidden sm:block space-y-8">
|
|
{/* Row 1: Mood + Quit Plan */}
|
|
<div className="grid grid-cols-2 gap-6">
|
|
<MoodTracker />
|
|
<UnifiedQuitPlanCard
|
|
preferences={preferences}
|
|
usageData={usageData}
|
|
onGeneratePlan={handleGeneratePlan}
|
|
refreshKey={refreshKey}
|
|
variant="desktop"
|
|
/>
|
|
</div>
|
|
|
|
{/* Row 2: Calendar/Quote */}
|
|
<div id="calendar-section">
|
|
<UsageCalendar
|
|
key={refreshKey}
|
|
usageData={usageData}
|
|
onDataUpdate={loadData}
|
|
userId={user.id}
|
|
religion={preferences.religion}
|
|
onReligionUpdate={async (religion: 'christian' | 'secular') => {
|
|
const updatedPrefs = { ...preferences, religion };
|
|
setPreferences(updatedPrefs);
|
|
await savePreferencesAsync(updatedPrefs);
|
|
}}
|
|
showInspirationPanel
|
|
preferences={preferences}
|
|
onPreferencesUpdate={async (updatedPrefs: UserPreferences) => {
|
|
await savePreferencesAsync(updatedPrefs);
|
|
setPreferences(updatedPrefs);
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* Row 3: Achievements + Health Recovery */}
|
|
<div className="grid grid-cols-2 gap-6">
|
|
<AchievementsCard
|
|
key={`achievements-${refreshKey}`}
|
|
achievements={achievements}
|
|
substance={preferences.substance}
|
|
/>
|
|
<HealthTimelineCard
|
|
key={`health-${refreshKey}`}
|
|
usageData={usageData}
|
|
preferences={preferences}
|
|
/>
|
|
</div>
|
|
|
|
{/* Row 4: Savings + Stats */}
|
|
<div className="grid grid-cols-2 gap-6">
|
|
<SavingsTrackerCard
|
|
key={`savings-${refreshKey}`}
|
|
savingsConfig={savingsConfig}
|
|
usageData={usageData}
|
|
trackingStartDate={preferences.trackingStartDate}
|
|
onSavingsConfigChange={handleSavingsConfigChange}
|
|
onModalStateChange={handleModalStateChange}
|
|
/>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<StatsCard key={`stats-nicotine-${refreshKey}`} usageData={usageData} substance="nicotine" />
|
|
<StatsCard key={`stats-weed-${refreshKey}`} usageData={usageData} substance="weed" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* MOBILE SWIPE LAYOUT - Hidden on desktop */}
|
|
<div
|
|
ref={swipeContainerRef}
|
|
id="mobile-dashboard-slides"
|
|
onScroll={handleScroll}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'ArrowRight') {
|
|
event.preventDefault();
|
|
scrollToPage(currentPage + 1);
|
|
}
|
|
if (event.key === 'ArrowLeft') {
|
|
event.preventDefault();
|
|
scrollToPage(currentPage - 1);
|
|
}
|
|
}}
|
|
className="swipe-container sm:hidden"
|
|
tabIndex={0}
|
|
role="region"
|
|
aria-label="Mobile dashboard sections"
|
|
>
|
|
|
|
{/* SLIDE 1: Mood */}
|
|
<div className="swipe-item space-y-3">
|
|
<div className="flex items-center justify-between mb-2 px-1">
|
|
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">How Are You Feeling</h2>
|
|
</div>
|
|
<div className="w-full max-w-[30rem] mx-auto space-y-3">
|
|
<MoodTracker />
|
|
<DailyInspirationCard
|
|
initialReligion={preferences.religion}
|
|
onReligionChange={async (religion) => {
|
|
const updatedPrefs = { ...preferences, religion };
|
|
setPreferences(updatedPrefs);
|
|
await savePreferencesAsync(updatedPrefs);
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* SLIDE 2: Quit Plan */}
|
|
<div className="swipe-item space-y-3">
|
|
<div className="flex items-center justify-between mb-2 px-1">
|
|
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Quit Journey Plan</h2>
|
|
</div>
|
|
<div className="w-full max-w-[30rem] mx-auto">
|
|
<UnifiedQuitPlanCard
|
|
preferences={preferences}
|
|
usageData={usageData}
|
|
onGeneratePlan={handleGeneratePlan}
|
|
refreshKey={refreshKey}
|
|
variant="mobile"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* SLIDE 3: Stats */}
|
|
<div className="swipe-item space-y-3">
|
|
<div className="flex items-center justify-between mb-2 px-1">
|
|
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Usage Stats</h2>
|
|
</div>
|
|
<div className="grid grid-cols-1 gap-3 w-full max-w-[30rem] mx-auto">
|
|
<StatsCard
|
|
key={`stats-nicotine-${refreshKey}`}
|
|
usageData={usageData}
|
|
substance="nicotine"
|
|
/>
|
|
<StatsCard
|
|
key={`stats-weed-${refreshKey}`}
|
|
usageData={usageData}
|
|
substance="weed"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* SLIDE 4: Recovery */}
|
|
<div className="swipe-item space-y-3">
|
|
<div className="flex items-center justify-between mb-2 px-1">
|
|
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Health Recovery</h2>
|
|
</div>
|
|
<div className="w-full max-w-[30rem] mx-auto">
|
|
<HealthTimelineCard
|
|
key={`health-${refreshKey}`}
|
|
usageData={usageData}
|
|
preferences={preferences}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* SLIDE 5: Achievements */}
|
|
<div className="swipe-item space-y-3">
|
|
<div className="flex items-center justify-between mb-2 px-1">
|
|
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Achievements</h2>
|
|
</div>
|
|
<div className="w-full max-w-[30rem] mx-auto">
|
|
<AchievementsCard
|
|
key={`achievements-${refreshKey}`}
|
|
achievements={achievements}
|
|
substance={preferences.substance}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* SLIDE 6: Savings */}
|
|
<div className="swipe-item space-y-3">
|
|
<div className="flex items-center justify-between mb-2 px-1">
|
|
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Savings</h2>
|
|
</div>
|
|
<div className="w-full max-w-[30rem] mx-auto">
|
|
<SavingsTrackerCard
|
|
key={`savings-${refreshKey}`}
|
|
savingsConfig={savingsConfig}
|
|
usageData={usageData}
|
|
trackingStartDate={preferences.trackingStartDate}
|
|
onSavingsConfigChange={handleSavingsConfigChange}
|
|
onModalStateChange={handleModalStateChange}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* SLIDE 7: Calendar */}
|
|
<div id="calendar-section-mobile" className="swipe-item">
|
|
<div className="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>
|
|
<div className="w-full max-w-[30rem] mx-auto">
|
|
<UsageCalendar
|
|
key={refreshKey}
|
|
usageData={usageData}
|
|
onDataUpdate={loadData}
|
|
userId={user.id}
|
|
preferences={preferences}
|
|
onPreferencesUpdate={async (updatedPrefs: UserPreferences) => {
|
|
await savePreferencesAsync(updatedPrefs);
|
|
setPreferences(updatedPrefs);
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div className="sm:hidden fixed bottom-[calc(env(safe-area-inset-bottom)+0.75rem)] left-1/2 -translate-x-1/2 z-30 w-full px-3 pointer-events-none">
|
|
<div className="mx-auto max-w-sm rounded-xl border border-white/10 bg-black/25 backdrop-blur-xl px-3 py-2 pointer-events-auto">
|
|
<div className="text-[10px] uppercase tracking-[0.2em] opacity-60 text-center mb-2">
|
|
{MOBILE_SLIDES[currentPage]?.label}
|
|
</div>
|
|
<div className="flex items-center justify-center gap-2">
|
|
{MOBILE_SLIDES.map((slide, index) => (
|
|
<button
|
|
key={slide.id}
|
|
type="button"
|
|
onClick={() => scrollToPage(index)}
|
|
aria-label={`Go to ${slide.label}`}
|
|
aria-current={currentPage === index ? 'page' : undefined}
|
|
className={`h-2 rounded-full transition-all ${currentPage === index ? 'w-8 bg-primary' : 'w-2 bg-white/30'}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</main>
|
|
|
|
<SetupWizard open={showSetup} onComplete={handleSetupComplete} />
|
|
|
|
<UsageLoggerDropUp
|
|
isOpen={isSubstancePickerOpen}
|
|
onSelect={(substance) => {
|
|
setActiveLoggingSubstance(substance);
|
|
setIsSubstancePickerOpen(false);
|
|
}}
|
|
onClose={() => setIsSubstancePickerOpen(false)}
|
|
/>
|
|
|
|
{activeLoggingSubstance && (
|
|
<ScrollWheelLogger
|
|
substance={activeLoggingSubstance}
|
|
onSubmit={handleUsageSubmit}
|
|
onCancel={() => setActiveLoggingSubstance(null)}
|
|
/>
|
|
)}
|
|
|
|
<VersionUpdateModal
|
|
preferences={preferences}
|
|
onAcknowledge={async (version) => {
|
|
if (!preferences) return;
|
|
const nextPreferences = {
|
|
...preferences,
|
|
lastSeenReleaseNotesVersion: version,
|
|
};
|
|
setPreferences(nextPreferences);
|
|
await savePreferencesAsync(nextPreferences);
|
|
}}
|
|
/>
|
|
|
|
{showCelebration && newBadge && (
|
|
<CelebrationAnimation
|
|
badge={newBadge}
|
|
onComplete={handleCelebrationComplete}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|