UI Overhaul: Side Menu, Centered Brand Header, and Swipe Ecosystem for Placards

This commit is contained in:
Avery Felts 2026-01-28 00:57:40 -07:00
parent 680281ce6c
commit f5363fea7c
8 changed files with 565 additions and 3021 deletions

File diff suppressed because one or more lines are too long

View File

@ -1,29 +0,0 @@
// open-next.config.ts
var config = {
default: {
override: {
wrapper: "cloudflare-node",
converter: "edge",
proxyExternalRequest: "fetch",
incrementalCache: "dummy",
tagCache: "dummy",
queue: "dummy"
}
},
edgeExternals: ["node:crypto"],
middleware: {
external: true,
override: {
wrapper: "cloudflare-edge",
converter: "edge",
proxyExternalRequest: "fetch",
incrementalCache: "dummy",
tagCache: "dummy",
queue: "dummy"
}
}
};
var open_next_config_default = config;
export {
open_next_config_default as default
};

50
.open-next 2/worker 8.js Normal file
View File

@ -0,0 +1,50 @@
//@ts-expect-error: Will be resolved by wrangler build
import { handleImageRequest } from "./cloudflare/images.js";
//@ts-expect-error: Will be resolved by wrangler build
import { runWithCloudflareRequestContext } from "./cloudflare/init.js";
//@ts-expect-error: Will be resolved by wrangler build
import { maybeGetSkewProtectionResponse } from "./cloudflare/skew-protection.js";
// @ts-expect-error: Will be resolved by wrangler build
import { handler as middlewareHandler } from "./middleware/handler.mjs";
//@ts-expect-error: Will be resolved by wrangler build
export { DOQueueHandler } from "./.build/durable-objects/queue.js";
//@ts-expect-error: Will be resolved by wrangler build
export { DOShardedTagCache } from "./.build/durable-objects/sharded-tag-cache.js";
//@ts-expect-error: Will be resolved by wrangler build
export { BucketCachePurge } from "./.build/durable-objects/bucket-cache-purge.js";
export default {
async fetch(request, env, ctx) {
return runWithCloudflareRequestContext(request, env, ctx, async () => {
const response = maybeGetSkewProtectionResponse(request);
if (response) {
return response;
}
const url = new URL(request.url);
// Serve images in development.
// Note: "/cdn-cgi/image/..." requests do not reach production workers.
if (url.pathname.startsWith("/cdn-cgi/image/")) {
const m = url.pathname.match(/\/cdn-cgi\/image\/.+?\/(?<url>.+)$/);
if (m === null) {
return new Response("Not Found!", { status: 404 });
}
const imageUrl = m.groups.url;
return imageUrl.match(/^https?:\/\//)
? fetch(imageUrl, { cf: { cacheEverything: true } })
: env.ASSETS?.fetch(new URL(`/${imageUrl}`, url));
}
// Fallback for the Next default image loader.
if (url.pathname ===
`${globalThis.__NEXT_BASE_PATH__}/_next/image${globalThis.__TRAILING_SLASH__ ? "/" : ""}`) {
return await handleImageRequest(url, request.headers, env);
}
// - `Request`s are handled by the Next server
const reqOrResp = await middlewareHandler(request, env, ctx);
if (reqOrResp instanceof Response) {
return reqOrResp;
}
// @ts-expect-error: resolved by wrangler build
const { handler } = await import("./server-functions/default/handler.mjs");
return handler(reqOrResp, env, ctx, request.signal);
});
},
};

50
.open-next 2/worker 9.js Normal file
View File

@ -0,0 +1,50 @@
//@ts-expect-error: Will be resolved by wrangler build
import { handleImageRequest } from "./cloudflare/images.js";
//@ts-expect-error: Will be resolved by wrangler build
import { runWithCloudflareRequestContext } from "./cloudflare/init.js";
//@ts-expect-error: Will be resolved by wrangler build
import { maybeGetSkewProtectionResponse } from "./cloudflare/skew-protection.js";
// @ts-expect-error: Will be resolved by wrangler build
import { handler as middlewareHandler } from "./middleware/handler.mjs";
//@ts-expect-error: Will be resolved by wrangler build
export { DOQueueHandler } from "./.build/durable-objects/queue.js";
//@ts-expect-error: Will be resolved by wrangler build
export { DOShardedTagCache } from "./.build/durable-objects/sharded-tag-cache.js";
//@ts-expect-error: Will be resolved by wrangler build
export { BucketCachePurge } from "./.build/durable-objects/bucket-cache-purge.js";
export default {
async fetch(request, env, ctx) {
return runWithCloudflareRequestContext(request, env, ctx, async () => {
const response = maybeGetSkewProtectionResponse(request);
if (response) {
return response;
}
const url = new URL(request.url);
// Serve images in development.
// Note: "/cdn-cgi/image/..." requests do not reach production workers.
if (url.pathname.startsWith("/cdn-cgi/image/")) {
const m = url.pathname.match(/\/cdn-cgi\/image\/.+?\/(?<url>.+)$/);
if (m === null) {
return new Response("Not Found!", { status: 404 });
}
const imageUrl = m.groups.url;
return imageUrl.match(/^https?:\/\//)
? fetch(imageUrl, { cf: { cacheEverything: true } })
: env.ASSETS?.fetch(new URL(`/${imageUrl}`, url));
}
// Fallback for the Next default image loader.
if (url.pathname ===
`${globalThis.__NEXT_BASE_PATH__}/_next/image${globalThis.__TRAILING_SLASH__ ? "/" : ""}`) {
return await handleImageRequest(url, request.headers, env);
}
// - `Request`s are handled by the Next server
const reqOrResp = await middlewareHandler(request, env, ctx);
if (reqOrResp instanceof Response) {
return reqOrResp;
}
// @ts-expect-error: resolved by wrangler build
const { handler } = await import("./server-functions/default/handler.mjs");
return handler(reqOrResp, env, ctx, request.signal);
});
},
};

View File

@ -628,4 +628,30 @@
animation-delay: -15s; animation-delay: -15s;
opacity: 0.7; opacity: 0.7;
filter: blur(5px); filter: blur(5px);
} }
/* Swipe ecosystem for mobile placards */
@media (max-width: 640px) {
.swipe-container {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
-ms-overflow-style: none;
gap: 1.25rem;
padding: 1rem 0 2.5rem;
margin: 0 -1rem;
padding-left: 1.5rem;
padding-right: 1.5rem;
}
.swipe-container::-webkit-scrollbar {
display: none;
}
.swipe-item {
flex: 0 0 calc(100vw - 3rem);
scroll-snap-align: center;
}
}

View File

@ -234,70 +234,89 @@ export function Dashboard({ user }: DashboardProps) {
</Button> </Button>
</div> </div>
<div className="grid gap-6 md:grid-cols-2"> {/* Dashboard Sections */}
<div className="space-y-6"> <div className="space-y-10 sm:space-y-12">
<div className="opacity-0 animate-fade-in-up">
<UsageCalendar {/* SECTION: Daily Snapshot (Horizontal Swipe on Mobile) */}
key={refreshKey} <section>
usageData={usageData} <div className="flex items-center justify-between mb-4 px-1">
onDataUpdate={loadData} <h2 className="text-xs font-bold uppercase tracking-[0.2em] opacity-50">Daily Snapshot</h2>
userId={user.id} <div className="sm:hidden flex items-center gap-1.5 opacity-30">
religion={preferences.religion} <div className="w-1 h-1 rounded-full bg-primary" />
onReligionUpdate={async (religion) => { <div className="w-1 h-1 rounded-full bg-primary/50" />
const updatedPrefs = { ...preferences, religion }; <div className="w-1 h-1 rounded-full bg-primary/50" />
setPreferences(updatedPrefs); </div>
await savePreferencesAsync(updatedPrefs);
}}
preferences={preferences}
onPreferencesUpdate={async (updatedPrefs) => {
await savePreferencesAsync(updatedPrefs);
setPreferences(updatedPrefs);
}}
/>
</div> </div>
<div className="opacity-0 animate-fade-in-up delay-100">
<MoodTracker /> <div className="swipe-container sm:grid sm:grid-cols-2 lg:grid-cols-3 sm:gap-6 sm:px-0 sm:margin-0">
<div className="swipe-item sm:block opacity-0 animate-fade-in-up">
<StatsCard key={`stats-combined-${refreshKey}`} usageData={usageData} substance={preferences.substance} />
</div>
<div className="swipe-item sm:block opacity-0 animate-fade-in-up delay-75">
<MoodTracker />
</div>
<div className="swipe-item sm:block opacity-0 animate-fade-in-up delay-150 sm:col-span-2 lg:col-span-1">
<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>
<div className="opacity-0 animate-fade-in-up delay-200"> </section>
<QuitPlanCard
key={`quit-plan-${refreshKey}`} {/* SECTION: Your Journey */}
plan={preferences.quitPlan} <section>
onGeneratePlan={handleGeneratePlan} <div className="flex items-center justify-between mb-4 px-1">
usageData={usageData} <h2 className="text-xs font-bold uppercase tracking-[0.2em] opacity-50">Your Journey</h2>
/>
</div> </div>
<div className="opacity-0 animate-fade-in-up delay-400">
<HealthTimelineCard <div className="swipe-container sm:grid sm:grid-cols-2 lg:grid-cols-4 sm:gap-6 sm:px-0">
key={`health-${refreshKey}`} <div className="swipe-item sm:block opacity-0 animate-fade-in-up delay-200">
usageData={usageData} <SavingsTrackerCard
preferences={preferences} key={`savings-${refreshKey}`}
/> savingsConfig={savingsConfig}
usageData={usageData}
trackingStartDate={preferences.trackingStartDate}
onSavingsConfigChange={handleSavingsConfigChange}
/>
</div>
<div className="swipe-item sm:block opacity-0 animate-fade-in-up delay-300">
<HealthTimelineCard
key={`health-${refreshKey}`}
usageData={usageData}
preferences={preferences}
/>
</div>
<div className="swipe-item sm:block opacity-0 animate-fade-in-up delay-400">
<AchievementsCard
key={`achievements-${refreshKey}`}
achievements={achievements}
substance={preferences.substance}
/>
</div>
<div className="swipe-item sm:block opacity-0 animate-fade-in-up delay-500">
<QuitPlanCard
key={`quit-plan-${refreshKey}`}
plan={preferences.quitPlan}
onGeneratePlan={handleGeneratePlan}
usageData={usageData}
/>
</div>
</div> </div>
</div> </section>
<div className="space-y-6">
<div className="opacity-0 animate-slide-in-right delay-100">
<StatsCard key={`stats-nicotine-${refreshKey}`} usageData={usageData} substance="nicotine" />
</div>
<div className="opacity-0 animate-slide-in-right delay-300">
<StatsCard key={`stats-weed-${refreshKey}`} usageData={usageData} substance="weed" />
</div>
<div className="opacity-0 animate-slide-in-right delay-400">
<AchievementsCard
key={`achievements-${refreshKey}`}
achievements={achievements}
substance={preferences.substance}
/>
</div>
<div className="opacity-0 animate-slide-in-right delay-500">
<SavingsTrackerCard
key={`savings-${refreshKey}`}
savingsConfig={savingsConfig}
usageData={usageData}
trackingStartDate={preferences.trackingStartDate}
onSavingsConfigChange={handleSavingsConfigChange}
/>
</div>
</div>
</div> </div>
</> </>
)} )}

200
src/components/SideMenu.tsx Normal file
View File

@ -0,0 +1,200 @@
'use client';
import {
Cigarette,
Leaf,
LogOut,
Home,
Sparkles,
X,
Settings,
Shield,
Heart,
Calendar
} from 'lucide-react';
import { useRouter } from 'next/navigation';
import { cn } from '@/lib/utils';
import { useTheme } from '@/lib/theme-context';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
interface SideMenuProps {
isOpen: boolean;
onClose: () => void;
user: {
id: string;
email: string;
firstName?: string | null;
lastName?: string | null;
profilePictureUrl?: string | null;
};
userName: string | null;
}
export function SideMenu({ isOpen, onClose, user, userName }: SideMenuProps) {
const router = useRouter();
const { theme } = useTheme();
if (!isOpen) return null;
const handleNavigate = (path: string) => {
router.push(path);
onClose();
};
const handleLogout = () => {
window.location.href = '/api/auth/logout';
};
const initials = [user.firstName?.[0], user.lastName?.[0]]
.filter(Boolean)
.join('')
.toUpperCase() || user.email[0].toUpperCase();
return (
<div className="fixed inset-0 z-[100] flex">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/40 backdrop-blur-sm transition-none"
onClick={onClose}
/>
{/* Menu Content */}
<div
className={cn(
"relative w-72 h-full flex flex-col shadow-2xl transition-none",
theme === 'light'
? "bg-white text-slate-900"
: "bg-slate-900 text-white border-r border-white/5"
)}
>
{/* Header/Profile Info */}
<div className={cn(
"p-6 border-b",
theme === 'light' ? "border-slate-100 bg-slate-50/50" : "border-white/5 bg-white/5"
)}>
<div className="flex items-center justify-between mb-4">
<Avatar className="h-12 w-12 ring-2 ring-primary/20">
<AvatarImage src={user.profilePictureUrl ?? undefined} alt={userName || 'User'} />
<AvatarFallback className="bg-primary/20 text-primary text-lg">{initials}</AvatarFallback>
</Avatar>
<button
onClick={onClose}
className="p-2 rounded-full hover:bg-slate-200/50 dark:hover:bg-white/10 transition-colors"
>
<X className="h-5 w-5 opacity-60" />
</button>
</div>
<div className="space-y-1">
<h3 className="font-bold text-lg leading-tight">{userName || 'User'}</h3>
<p className="text-xs opacity-50 truncate">{user.email}</p>
</div>
</div>
{/* Navigation Links */}
<div className="flex-1 overflow-y-auto py-4 px-3 space-y-1">
<MenuLink
icon={Home}
label="Dashboard"
onClick={() => handleNavigate('/')}
/>
<div className="my-2 px-3">
<span className="text-[10px] font-bold uppercase tracking-widest opacity-30">Tracking</span>
</div>
<MenuLink
icon={Cigarette}
label="Track Nicotine"
onClick={() => handleNavigate('/track/nicotine')}
color="rose"
/>
<MenuLink
icon={Leaf}
label="Track Marijuana"
onClick={() => handleNavigate('/track/marijuana')}
color="emerald"
/>
<div className="my-2 px-3 pt-2">
<span className="text-[10px] font-bold uppercase tracking-widest opacity-30">Resources</span>
</div>
<MenuLink
icon={Sparkles}
label="Smoking Aids"
onClick={() => handleNavigate('/smoking-aids')}
color="purple"
/>
<MenuLink
icon={Heart}
label="Health Status"
onClick={onClose}
color="blue"
/>
<MenuLink
icon={Calendar}
label="History"
onClick={onClose}
color="amber"
/>
</div>
{/* Footer Actions */}
<div className={cn(
"p-4 border-t",
theme === 'light' ? "border-slate-100" : "border-white/5"
)}>
<MenuLink
icon={Settings}
label="Settings"
onClick={onClose}
/>
<button
onClick={handleLogout}
className="w-full flex items-center gap-3 px-3 py-3 rounded-xl text-red-500 hover:bg-red-500/10 transition-colors font-medium mt-1"
>
<LogOut className="h-5 w-5" />
<span>Sign out</span>
</button>
</div>
</div>
</div>
);
}
interface MenuLinkProps {
icon: any;
label: string;
onClick: () => void;
color?: 'rose' | 'emerald' | 'purple' | 'blue' | 'amber';
}
function MenuLink({ icon: Icon, label, onClick, color }: MenuLinkProps) {
const { theme } = useTheme();
const colors = {
rose: "text-rose-500 bg-rose-500/5",
emerald: "text-emerald-500 bg-emerald-500/5",
purple: "text-purple-500 bg-purple-500/5",
blue: "text-blue-500 bg-blue-500/5",
amber: "text-amber-500 bg-amber-500/5",
};
return (
<button
onClick={onClick}
className={cn(
"w-full flex items-center gap-3 px-3 py-3 rounded-xl transition-all font-medium group",
theme === 'light'
? "hover:bg-slate-100"
: "hover:bg-white/5"
)}
>
<div className={cn(
"p-2 rounded-lg transition-colors",
color ? colors[color] : (theme === 'light' ? "bg-slate-100" : "bg-white/5")
)}>
<Icon className="h-4 w-4" />
</div>
<span className="flex-1 text-left text-sm">{label}</span>
<div className="w-1.5 h-1.5 rounded-full bg-primary opacity-0 group-hover:opacity-100 transition-opacity" />
</button>
);
}

View File

@ -121,10 +121,13 @@ function HourlyTimePicker({ value, onChange }: HourlyTimePickerProps) {
); );
} }
import { SideMenu } from './SideMenu';
export function UserHeader({ user, preferences }: UserHeaderProps) { export function UserHeader({ user, preferences }: UserHeaderProps) {
const [userName, setUserName] = useState<string | null>(null); const [userName, setUserName] = useState<string | null>(null);
const [reminderSettings, setReminderSettings] = useState<ReminderSettings>({ enabled: false, reminderTime: '09:00', frequency: 'daily' }); const [reminderSettings, setReminderSettings] = useState<ReminderSettings>({ enabled: false, reminderTime: '09:00', frequency: 'daily' });
const [showReminderDialog, setShowReminderDialog] = useState(false); const [showReminderDialog, setShowReminderDialog] = useState(false);
const [isSideMenuOpen, setIsSideMenuOpen] = useState(false);
const [localTime, setLocalTime] = useState('09:00'); const [localTime, setLocalTime] = useState('09:00');
const [localFrequency, setLocalFrequency] = useState<'daily' | 'hourly'>('daily'); const [localFrequency, setLocalFrequency] = useState<'daily' | 'hourly'>('daily');
const router = useRouter(); const router = useRouter();
@ -157,8 +160,6 @@ export function UserHeader({ user, preferences }: UserHeaderProps) {
useEffect(() => { useEffect(() => {
const loadData = async () => { const loadData = async () => {
// If preferences passed from parent, use them. Otherwise fetch.
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
const [prefs, reminders] = await Promise.all([ const [prefs, reminders] = await Promise.all([
preferences ? Promise.resolve(preferences) : fetchPreferences(), preferences ? Promise.resolve(preferences) : fetchPreferences(),
fetchReminderSettings(), fetchReminderSettings(),
@ -171,7 +172,6 @@ export function UserHeader({ user, preferences }: UserHeaderProps) {
const detectedTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; const detectedTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
let settingsToUse = reminders; let settingsToUse = reminders;
// If timezone is missing or different, update it
if (reminders.timezone !== detectedTimezone) { if (reminders.timezone !== detectedTimezone) {
settingsToUse = { ...reminders, timezone: detectedTimezone }; settingsToUse = { ...reminders, timezone: detectedTimezone };
await saveReminderSettings(settingsToUse); await saveReminderSettings(settingsToUse);
@ -194,8 +194,6 @@ export function UserHeader({ user, preferences }: UserHeaderProps) {
await saveReminderSettings(newSettings); await saveReminderSettings(newSettings);
}; };
const handleFrequencyChange = async (newFrequency: 'daily' | 'hourly') => { const handleFrequencyChange = async (newFrequency: 'daily' | 'hourly') => {
setLocalFrequency(newFrequency); setLocalFrequency(newFrequency);
const newSettings = { ...reminderSettings, frequency: newFrequency }; const newSettings = { ...reminderSettings, frequency: newFrequency };
@ -208,60 +206,48 @@ export function UserHeader({ user, preferences }: UserHeaderProps) {
.join('') .join('')
.toUpperCase() || user.email[0].toUpperCase(); .toUpperCase() || user.email[0].toUpperCase();
const handleLogout = () => {
window.location.href = '/api/auth/logout';
};
const handleNavigate = (path: string) => { const handleNavigate = (path: string) => {
router.push(path); router.push(path);
setIsSideMenuOpen(false);
}; };
return ( return (
<> <>
<header className="sticky top-0 z-50 border-b border-border/10 transition-colors duration-300 relative overflow-hidden" style={{ <header className="sticky top-0 z-50 transition-colors duration-300 relative" style={{
background: theme === 'light' background: theme === 'light'
? 'rgba(255, 255, 255, 0.8)' ? 'rgba(255, 255, 255, 0.85)'
: 'linear-gradient(135deg, rgba(10, 10, 20, 0.98) 0%, rgba(20, 30, 60, 0.95) 50%, rgba(15, 25, 50, 0.98) 100%)', : 'rgba(10, 10, 20, 0.95)',
backdropFilter: 'blur(10px)', backdropFilter: 'blur(12px)',
borderBottom: theme === 'light' ? '1px solid rgba(0,0,0,0.05)' : '1px solid rgba(255,255,255,0.05)'
}}> }}>
{/* Cloudy/Foggy effect overlay */} {/* Cloudy/Foggy effect overlay */}
<div className="absolute inset-0 pointer-events-none select-none"> <div className="absolute inset-0 pointer-events-none select-none overflow-hidden">
<div className="absolute -top-10 -left-10 w-64 h-64 bg-neutral-200/40 rounded-full blur-3xl animate-float" style={{ animationDuration: '15s', animationDelay: '0s' }} /> <div className="absolute inset-0 fog-layer-1 opacity-20" />
<div className="absolute top-1/2 left-1/3 w-96 h-32 bg-indigo-500/10 rounded-full blur-3xl animate-float" style={{ animationDuration: '20s', animationDelay: '-5s' }} /> <div className="absolute inset-0 fog-layer-2 opacity-15" />
<div className="absolute -bottom-10 right-0 w-80 h-80 bg-stone-200/20 rounded-full blur-3xl animate-float" style={{ animationDuration: '18s', animationDelay: '-2s' }} />
{/* Subtle moving fog layers - CSS procedural animation */}
<div
className={cn(
"absolute inset-0 z-10 opacity-20 pointer-events-none transition-all duration-1000",
theme === 'dark' ? "mix-blend-screen" : "mix-blend-multiply"
)}
style={{ filter: theme === 'dark' ? 'invert(1)' : 'none' }}
>
<div className="absolute inset-0 fog-layer-1" />
<div className="absolute inset-0 fog-layer-2" />
</div>
</div> </div>
{/* Edge blur overlay - fades content into header */} <div className="container mx-auto px-4 h-16 sm:h-20 flex items-center justify-between relative z-50">
<div {/* LEFT: User Profile / Side Menu Trigger */}
className="absolute left-0 right-0 pointer-events-none z-40" <div className="flex-1 flex justify-start">
style={{ <button
bottom: '-40px', onClick={() => setIsSideMenuOpen(true)}
height: '40px', className="group relative flex items-center gap-2 p-1 rounded-full transition-all hover:bg-black/5 dark:hover:bg-white/5 active:scale-95"
background: theme === 'light' >
? 'linear-gradient(to bottom, rgba(255, 255, 255, 0.9) 0%, rgba(255, 255, 255, 0.5) 50%, transparent 100%)' <Avatar className="h-9 w-9 sm:h-10 sm:w-10 ring-2 ring-primary/20 transition-all group-hover:ring-primary/40">
: 'linear-gradient(to bottom, rgba(10, 10, 20, 0.95) 0%, rgba(10, 10, 20, 0.5) 50%, transparent 100%)', <AvatarImage src={user.profilePictureUrl ?? undefined} alt={userName || 'User'} />
backdropFilter: 'blur(4px)', <AvatarFallback className="bg-primary/20 text-primary text-xs font-bold">{initials}</AvatarFallback>
WebkitBackdropFilter: 'blur(4px)', </Avatar>
maskImage: 'linear-gradient(to bottom, black, transparent)', <div className="hidden lg:block text-left mr-2">
WebkitMaskImage: 'linear-gradient(to bottom, black, transparent)', <div className="text-[10px] font-bold uppercase tracking-widest opacity-40 leading-none mb-1">Menu</div>
}} <div className="h-0.5 w-4 bg-primary/40 rounded-full" />
/> </div>
<div className="container mx-auto px-4 py-3 sm:py-4 flex items-center justify-between relative z-50"> </button>
<div className="flex items-center gap-4 sm:gap-8"> </div>
{/* CENTER: Title and Welcome Message */}
<div className="flex-[2] flex flex-col items-center justify-center text-center">
<h1 <h1
className="text-2xl sm:text-3xl font-bold cursor-pointer transition-all duration-300 hover:scale-105 tracking-tight" className="text-xl sm:text-2xl font-bold cursor-pointer transition-all duration-300 hover:scale-105 tracking-tight leading-tight"
onClick={() => handleNavigate('/')} onClick={() => handleNavigate('/')}
style={{ style={{
background: theme === 'light' background: theme === 'light'
@ -270,174 +256,133 @@ export function UserHeader({ user, preferences }: UserHeaderProps) {
WebkitBackgroundClip: 'text', WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent', WebkitTextFillColor: 'transparent',
backgroundClip: 'text', backgroundClip: 'text',
filter: theme === 'light' filter: theme === 'dark' ? 'drop-shadow(0 0 10px rgba(167, 139, 250, 0.3))' : 'none'
? 'none'
: 'drop-shadow(0 0 10px rgba(167, 139, 250, 0.4))'
}} }}
> >
QuitTraq QuitTraq
</h1> </h1>
{userName && ( {userName && (
<p className="text-foreground/90 text-lg hidden sm:block ml-4"> <p className="text-[10px] sm:text-xs font-medium text-foreground/60 tracking-wide mt-0.5 whitespace-nowrap overflow-hidden">
Welcome {userName}, you got this! Welcome {userName}, you got this!
</p> </p>
)} )}
</div> </div>
<div className="flex items-center gap-2 sm:gap-3"> {/* RIGHT: Action Buttons */}
<div className="flex-1 flex items-center justify-end gap-1.5 sm:gap-3">
<button <button
onClick={() => setShowReminderDialog(true)} onClick={() => setShowReminderDialog(true)}
className={`p-2.5 sm:p-2 rounded-full transition-all duration-300 focus:outline-none focus:ring-2 focus:ring-white/30 hover:scale-110 active:scale-95 ${reminderSettings.enabled className={cn(
? 'bg-indigo-500/30 hover:bg-indigo-500/40' "p-2 sm:p-2.5 rounded-full transition-all duration-300 active:scale-90 shadow-sm",
: 'bg-muted hover:bg-muted/80' reminderSettings.enabled
}`} ? 'bg-indigo-500/15 text-indigo-400 border border-indigo-500/20'
aria-label="Reminder settings" : 'bg-muted border border-transparent text-muted-foreground'
title={reminderSettings.enabled ? `Reminders on (${reminderSettings.frequency})` : 'Reminders off'} )}
> >
{reminderSettings.enabled ? ( {reminderSettings.enabled ? (
<BellRing className="h-5 w-5 text-indigo-300 transition-transform duration-300" /> <BellRing className="h-4.5 w-4.5 sm:h-5 sm:w-5" />
) : ( ) : (
<Bell className="h-5 w-5 text-muted-foreground transition-transform duration-300" /> <Bell className="h-4.5 w-4.5 sm:h-5 sm:w-5" />
)} )}
</button> </button>
<InstallAppButton /> <InstallAppButton />
<button <button
onClick={toggleTheme} onClick={toggleTheme}
className="p-2.5 sm:p-2 rounded-full bg-muted hover:bg-muted/80 transition-all duration-300 focus:outline-none focus:ring-2 focus:ring-primary/30 hover:scale-110 active:scale-95" className="p-2 sm:p-2.5 rounded-full bg-muted border border-transparent hover:bg-muted/80 transition-all active:scale-90"
aria-label="Toggle theme"
> >
{theme === 'dark' ? ( {theme === 'dark' ? (
<Moon className="h-5 w-5 text-blue-300 transition-transform duration-300" /> <Moon className="h-4.5 w-4.5 sm:h-5 sm:w-5 text-blue-300" />
) : ( ) : (
<Sun className="h-5 w-5 text-yellow-400 transition-transform duration-300" /> <Sun className="h-4.5 w-4.5 sm:h-5 sm:w-5 text-yellow-500" />
)} )}
</button> </button>
{/* Main Navigation Menu */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="p-2.5 sm:p-2 rounded-full bg-muted hover:bg-muted/80 transition-all duration-300 focus:outline-none focus:ring-2 focus:ring-primary/30 hover:scale-110 active:scale-95"
aria-label="Open menu"
>
<Menu className="h-5 w-5 text-muted-foreground" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={8}>
<DropdownMenuItem onClick={() => handleNavigate('/')}>
<Home className="mr-3 h-4 w-4 text-muted-foreground" />
<span>Dashboard</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => handleNavigate('/track/nicotine')}>
<Cigarette className="mr-3 h-4 w-4 text-red-400" />
<span>Track Nicotine Usage</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleNavigate('/track/marijuana')}>
<Leaf className="mr-3 h-4 w-4 text-green-400" />
<span>Track Marijuana Usage</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => handleNavigate('/smoking-aids')}>
<Sparkles className="mr-3 h-4 w-4 text-purple-400" />
<span>Smoking Aids</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-2 px-3 py-2 rounded-full bg-muted hover:bg-muted/80 transition-all focus:outline-none focus:ring-2 focus:ring-primary/30">
<Avatar className="h-8 w-8 ring-2 ring-primary/30">
<AvatarImage src={user.profilePictureUrl ?? undefined} alt={userName || 'User'} />
<AvatarFallback className="bg-primary/20 text-primary text-sm">{initials}</AvatarFallback>
</Avatar>
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={8}>
<DropdownMenuItem onClick={handleLogout} className="text-red-400 hover:text-red-300">
<LogOut className="mr-3 h-4 w-4" />
<span>Sign out</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
</div> </div>
{userName && (
<div className="sm:hidden container mx-auto px-4 pb-2 relative z-50"> {/* Side Menu Integration */}
<p className="text-muted-foreground text-sm"> <SideMenu
Welcome {userName}, you got this! isOpen={isSideMenuOpen}
</p> onClose={() => setIsSideMenuOpen(false)}
</div> user={user}
)} userName={userName}
/>
{/* Reminder Settings Dialog */} {/* Reminder Settings Dialog */}
<Dialog open={showReminderDialog} onOpenChange={setShowReminderDialog}> <Dialog open={showReminderDialog} onOpenChange={setShowReminderDialog}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2 font-bold tracking-tight">
<Bell className="h-5 w-5 text-indigo-400" /> <Bell className="h-5 w-5 text-indigo-400" />
Notification Settings Notification Settings
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4"> <div className="space-y-4 py-4 px-1">
{/* Enable/Disable Toggle */} {/* Enable/Disable Toggle */}
<div className="flex items-center justify-between p-3 bg-muted rounded-lg"> <div className={cn(
<div className="flex items-center gap-2"> "flex items-center justify-between p-4 rounded-2xl border transition-all",
{reminderSettings.enabled ? ( theme === 'light' ? "bg-slate-50 border-slate-100" : "bg-white/5 border-white/5"
<BellRing className="h-4 w-4 text-indigo-400" /> )}>
) : ( <div className="flex items-center gap-3">
<BellOff className="h-4 w-4 text-muted-foreground" /> <div className={cn(
)} "p-2.5 rounded-xl",
reminderSettings.enabled ? "bg-indigo-500/20 text-indigo-400" : "bg-slate-500/20 text-slate-400"
)}>
{reminderSettings.enabled ? <BellRing className="h-5 w-5" /> : <BellOff className="h-5 w-5" />}
</div>
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-sm font-medium"> <span className="text-sm font-bold">
{reminderSettings.enabled ? 'Notifications On' : 'Notifications Off'} {reminderSettings.enabled ? 'Enabled' : 'Disabled'}
</span> </span>
<span className="text-xs text-muted-foreground"> <span className="text-[10px] opacity-60">
{reminderSettings.enabled ? 'You will be notified to log usage' : 'Turn on to get reminders'} {reminderSettings.enabled ? 'Reminders active' : 'Turn on to get alerts'}
</span> </span>
</div> </div>
</div> </div>
<button <button
onClick={handleToggleReminders} onClick={handleToggleReminders}
disabled={!isSupported || (permission === 'denied' && !reminderSettings.enabled)} disabled={!isSupported || (permission === 'denied' && !reminderSettings.enabled)}
className={`relative w-12 h-6 rounded-full transition-all duration-300 ${reminderSettings.enabled ? 'bg-indigo-500' : 'bg-muted-foreground/30' className={cn(
} ${!isSupported || (permission === 'denied' && !reminderSettings.enabled) ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`} "relative w-12 h-6 rounded-full transition-all duration-300 shadow-inner",
reminderSettings.enabled ? "bg-indigo-500" : "bg-slate-400/30",
(!isSupported || (permission === 'denied' && !reminderSettings.enabled)) && "opacity-50 cursor-not-allowed"
)}
> >
<div <div className={cn(
className={`absolute top-1 w-4 h-4 rounded-full bg-white transition-all duration-300 ${reminderSettings.enabled ? 'left-7' : 'left-1' "absolute top-1 w-4 h-4 rounded-full bg-white shadow-md transition-all duration-300",
}`} reminderSettings.enabled ? "left-7" : "left-1"
/> )} />
</button> </button>
</div> </div>
{/* Frequency Selection */} {/* Frequency Selection */}
{reminderSettings.enabled && ( {reminderSettings.enabled && (
<div className="space-y-3"> <div className="space-y-3">
<Label className="text-sm font-medium">Frequency</Label> <div className="text-[10px] font-bold uppercase tracking-widest opacity-40 px-1">Reminder Frequency</div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<button <button
onClick={() => handleFrequencyChange('daily')} onClick={() => handleFrequencyChange('daily')}
className={`p-3 rounded-lg border text-sm font-medium transition-all ${localFrequency === 'daily' className={cn(
? 'bg-indigo-500/10 border-indigo-500/50 text-indigo-400' "p-4 rounded-2xl border text-sm font-bold transition-all flex flex-col items-center gap-1",
: 'bg-background border-border hover:border-border/80' localFrequency === 'daily'
}`} ? 'bg-indigo-500 border-indigo-500 text-white shadow-lg shadow-indigo-500/25 scale-[1.02]'
: 'bg-background border-border hover:border-indigo-500/50'
)}
> >
Daily <span>Daily</span>
<span className="text-[10px] font-normal opacity-70">Once a day</span>
</button> </button>
<button <button
onClick={() => handleFrequencyChange('hourly')} onClick={() => handleFrequencyChange('hourly')}
className={`p-3 rounded-lg border text-sm font-medium transition-all ${localFrequency === 'hourly' className={cn(
? 'bg-indigo-500/10 border-indigo-500/50 text-indigo-400' "p-4 rounded-2xl border text-sm font-bold transition-all flex flex-col items-center gap-1",
: 'bg-background border-border hover:border-border/80' localFrequency === 'hourly'
}`} ? 'bg-indigo-500 border-indigo-500 text-white shadow-lg shadow-indigo-500/25 scale-[1.02]'
: 'bg-background border-border hover:border-indigo-500/50'
)}
> >
Hourly <span>Hourly</span>
<span className="text-[10px] font-normal opacity-70">Window alerts</span>
</button> </button>
</div> </div>
</div> </div>
@ -445,184 +390,127 @@ export function UserHeader({ user, preferences }: UserHeaderProps) {
{/* Time Picker (Only for Daily) */} {/* Time Picker (Only for Daily) */}
{reminderSettings.enabled && localFrequency === 'daily' && ( {reminderSettings.enabled && localFrequency === 'daily' && (
<div className="space-y-2"> <div className="space-y-3 animate-in fade-in slide-in-from-top-2 duration-300">
<Label className="text-sm"> <div className="text-[10px] font-bold uppercase tracking-widest opacity-40 px-1">Preferred Time</div>
Reminder Time <div className="flex gap-2 p-1 bg-muted/30 rounded-2xl border border-border/50">
</Label>
<div className="flex gap-2">
{/* Hour Select */}
<div className="flex-1"> <div className="flex-1">
<Select <Select
value={hourString} value={hourString}
onValueChange={(val) => updateTime(val, minuteString, currentAmpm)} onValueChange={(val) => updateTime(val, minuteString, currentAmpm)}
> >
<SelectTrigger className="w-full"> <SelectTrigger className="border-none bg-transparent shadow-none hover:bg-white/5 h-12 rounded-xl">
<SelectValue placeholder="Hour" /> <SelectValue placeholder="Hour" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent className="rounded-xl">
{hoursOptions.map((h) => ( {hoursOptions.map((h) => (
<SelectItem key={h} value={h}> <SelectItem key={h} value={h} className="rounded-lg">{h}</SelectItem>
{h}
</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
{/* Minute Select */}
<div className="flex-1"> <div className="flex-1">
<Select <Select
value={minuteString} value={minuteString}
onValueChange={(val) => updateTime(hourString, val, currentAmpm)} onValueChange={(val) => updateTime(hourString, val, currentAmpm)}
> >
<SelectTrigger className="w-full"> <SelectTrigger className="border-none bg-transparent shadow-none hover:bg-white/5 h-12 rounded-xl">
<SelectValue placeholder="Min" /> <SelectValue placeholder="Min" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent className="rounded-xl">
{minutesOptions.map((m) => ( {minutesOptions.map((m) => (
<SelectItem key={m} value={m}> <SelectItem key={m} value={m} className="rounded-lg">{m}</SelectItem>
{m}
</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
{/* AM/PM Select */} <div className="w-24 px-1">
<div className="w-24">
<Select <Select
value={currentAmpm} value={currentAmpm}
onValueChange={(val) => updateTime(hourString, minuteString, val)} onValueChange={(val) => updateTime(hourString, minuteString, val)}
> >
<SelectTrigger className="w-full"> <SelectTrigger className="border-none bg-indigo-500/10 text-indigo-400 font-bold shadow-none hover:bg-indigo-500/20 h-10 mt-1 rounded-lg">
<SelectValue placeholder="AM/PM" /> <SelectValue placeholder="AM/PM" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent className="rounded-xl">
<SelectItem value="AM">AM</SelectItem> <SelectItem value="AM" className="rounded-lg">AM</SelectItem>
<SelectItem value="PM">PM</SelectItem> <SelectItem value="PM" className="rounded-lg">PM</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
</div> </div>
<p className="text-xs text-muted-foreground">
You&apos;ll receive a reminder at this time each day
</p>
</div> </div>
)} )}
{/* Hourly Time Pickers */} {/* Hourly Alerts Window */}
{reminderSettings.enabled && localFrequency === 'hourly' && ( {reminderSettings.enabled && localFrequency === 'hourly' && (
<div className="space-y-4"> <div className="space-y-4 animate-in fade-in slide-in-from-top-2 duration-300">
{/* Start Time */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm flex items-center justify-between"> <div className="text-[10px] font-bold uppercase tracking-widest opacity-40 px-1">Start Time</div>
Start Time <HourlyTimePicker
<span className="flex items-center gap-1 text-[10px] text-indigo-400 font-normal"> value={reminderSettings.hourlyStart || '09:00'}
<LinkIcon className="w-3 h-3" /> onChange={async (newTime) => {
Minute Link const [h, m] = newTime.split(':');
</span> const end = (reminderSettings.hourlyEnd || '21:00').split(':');
</Label> const newSettings = { ...reminderSettings, hourlyStart: newTime, hourlyEnd: `${end[0]}:${m}` };
<div className="flex gap-2"> setReminderSettings(newSettings);
<HourlyTimePicker await saveReminderSettings(newSettings);
value={reminderSettings.hourlyStart || '09:00'} }}
onChange={async (newTime) => { />
const [h, m] = newTime.split(':');
const end = (reminderSettings.hourlyEnd || '21:00').split(':');
const newEnd = `${end[0]}:${m}`;
const newSettings = {
...reminderSettings,
hourlyStart: newTime,
hourlyEnd: newEnd
};
setReminderSettings(newSettings);
await saveReminderSettings(newSettings);
}}
/>
</div>
</div> </div>
{/* End Time */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm flex items-center justify-between"> <div className="text-[10px] font-bold uppercase tracking-widest opacity-40 px-1">End Time</div>
End Time <HourlyTimePicker
<span className="text-[10px] text-indigo-400/70 font-normal">Minutes synced with Start</span> value={reminderSettings.hourlyEnd || '21:00'}
</Label> onChange={async (newTime) => {
<div className="flex gap-2"> const [h, m] = newTime.split(':');
<HourlyTimePicker const start = (reminderSettings.hourlyStart || '09:00').split(':');
value={reminderSettings.hourlyEnd || '21:00'} const newSettings = { ...reminderSettings, hourlyEnd: newTime, hourlyStart: `${start[0]}:${m}` };
onChange={async (newTime) => { setReminderSettings(newSettings);
const [h, m] = newTime.split(':'); await saveReminderSettings(newSettings);
const start = (reminderSettings.hourlyStart || '09:00').split(':'); }}
const newStart = `${start[0]}:${m}`; />
const newSettings = {
...reminderSettings,
hourlyEnd: newTime,
hourlyStart: newStart
};
setReminderSettings(newSettings);
await saveReminderSettings(newSettings);
}}
/>
</div>
</div> </div>
<p className="text-xs text-muted-foreground flex items-center gap-2"> <div className="flex items-center gap-2 p-3 bg-indigo-500/5 rounded-2xl border border-indigo-500/10">
<Sparkles className="w-3 h-3 text-indigo-400" /> <Sparkles className="h-4 w-4 text-indigo-400" />
You&apos;ll receive reminders every hour between these times. <p className="text-[10px] text-indigo-400 uppercase font-black tracking-widest">Reminders every 60 minutes</p>
</p> </div>
</div> </div>
)} )}
{/* Push Permission / Re-Sync Button */} {/* Notification Permission Sync */}
{reminderSettings.enabled && isSupported && ( {reminderSettings.enabled && isSupported && (
<div className="pt-2 border-t border-border/50 space-y-2"> <div className="pt-2">
<button <Button
onClick={async () => { onClick={async () => {
// 1. Request/Refresh Permission & Subscription
const result = await requestPermission(); const result = await requestPermission();
// 2. If granted, try to send a test notification immediately
if (result === 'granted') { if (result === 'granted') {
try { try {
const res = await fetch('/api/notifications/test', { method: 'POST' }); const res = await fetch('/api/notifications/test', { method: 'POST' });
if (!res.ok) { if (res.ok) alert("Success! Push notifications active.");
const errData = await res.json() as { error?: string }; } catch (err) { console.error(err); }
throw new Error(errData.error || `Server error ${res.status}`); } else alert("Please enable notifications in settings.");
}
alert("Success! Push notifications are now active.");
} catch (err) {
console.error(err);
// @ts-expect-error - err is unknown
alert(`Error: ${err.message}`);
}
} else {
alert("Please enable notifications in your browser settings.");
}
}} }}
className={`w-full py-3 text-sm font-semibold rounded-lg transition-colors flex items-center justify-center gap-2 shadow-sm ${permission === 'granted' className={cn(
? 'text-emerald-600 bg-emerald-50 hover:bg-emerald-100 border border-emerald-200' "w-full h-12 rounded-2xl font-bold transition-all shadow-md group",
: 'text-white bg-emerald-600 hover:bg-emerald-500' permission === 'granted'
}`} ? 'bg-emerald-500/10 text-emerald-500 hover:bg-emerald-500/20'
: 'bg-emerald-600 text-white hover:bg-emerald-500 hover:scale-[1.02]'
)}
> >
<Bell className="w-4 h-4" /> <Bell className="mr-2 h-4 w-4 group-hover:animate-bounce" />
Enable Push {permission === 'granted' ? 'Permissions Verified' : 'Enable Push Alerts'}
</button> </Button>
<p className="text-[10px] text-muted-foreground text-center">
{permission === 'granted'
? 'Tap if you are not receiving alerts'
: 'Required for background alerts'}
</p>
</div> </div>
)} )}
{/* Denied Message */}
{permission === 'denied' && ( {permission === 'denied' && (
<div className="p-3 bg-red-500/10 border border-red-500/30 rounded-lg"> <div className="p-4 bg-red-500/10 border border-red-500/20 rounded-2xl">
<p className="text-xs text-red-400"> <p className="text-xs text-red-500 text-center font-medium leading-relaxed">
Notifications are blocked. Please enable them in your browser settings to receive reminders. Browser notifications are currently blocked. To get reminders, please update your site settings.
</p> </p>
</div> </div>
)} )}