diff --git a/.gitignore b/.gitignore index 29a25b9..3bd0fa7 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* .dev.vars +.dev.vars.* # vercel .vercel @@ -50,3 +51,7 @@ next-env.d.ts .open-next/ .open-next 2/ .wrangler/ + +# local editor / temp +.idea/ +.vscode/ diff --git a/DEV_SETUP.md b/DEV_SETUP.md index f1de88b..2b800ca 100644 --- a/DEV_SETUP.md +++ b/DEV_SETUP.md @@ -61,3 +61,56 @@ Expected URL: - Keep file reads tightly scoped to explicit paths when requested. - Avoid broad folder crawling unless asked. + +## Latest Session Changes + +### Product and UX + +- Reworked mobile dashboard into dedicated swipe pages: + - Mood (+ Daily Inspiration) + - Quit Journey Plan + - Usage Stats + - Health Recovery + - Achievements + - Savings + - Usage Calendar +- Removed mobile side arrow buttons; swipe + dots are now primary navigation. +- Moved floating `Log Usage` button above the mobile swipe indicator and centered it on mobile. +- Updated profile menu to top-right and moved quick actions (notifications/install/theme) into menu. +- Expanded Quit Journey Plan sections (no dropdown collapse), showing both nicotine and weed plans. +- Replaced old theme token set with updated light/dark OKLCH theme variables in `src/app/globals.css`. + +### Reliability and Auth + +- Added explicit dashboard/substance data load error handling and retry surface. +- Fixed hourly reminders edge cases for overnight windows. +- Added reminder API input validation for time/frequency values. +- Implemented real password reset initiation via WorkOS endpoint and UI wiring in login/settings. + +### Achievements + +- Added inline unlock guidance text under each achievement tile. +- Added completed/locked status treatment directly in the tile. +- Added unlock safeguards to avoid duplicate global `first_day` unlock behavior: + - `first_day` now normalizes to `substance: both` + - dedupe normalization in achievements API GET + - API validation for badge/substance input + - `INSERT OR IGNORE` safety in D1 write path + +## Validation Commands Used + +```bash +bun run build +bun run build:worker +bun run dev:worker +``` + +## Quick Troubleshooting + +- If UI changes do not appear in worker mode: + 1. `bun run build:worker` + 2. `bun run dev:worker` + 3. hard refresh browser (`Cmd + Shift + R`) + +- If mobile swipe feels off: + - swipe behavior is controlled in `src/app/globals.css` under `.swipe-container` and `.swipe-item`. diff --git a/README.md b/README.md index e215bc4..9d851fc 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,77 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# QuitTraq (Stop Smoking Website v2) -## Getting Started +QuitTraq is a Next.js + Cloudflare Workers app for tracking nicotine/marijuana usage, recovery progress, achievements, reminders, and savings. -First, run the development server: +## Tech Stack + +- Next.js 16 (App Router), React 19, TypeScript +- Cloudflare Workers via OpenNext +- Cloudflare D1 (SQLite) +- WorkOS AuthKit + WorkOS User Management +- Tailwind CSS v4 + +## Local Development + +This app should be run in Worker mode for realistic auth + D1 behavior. ```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev +bun install +bun run d1:migrate +bun run build:worker +bun run dev:worker ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +Local URL: `http://localhost:3000` -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +## Environment Variables -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +Create local env files: -## Learn More +- `.env.local` (Next.js runtime) +- `.dev.vars` (Wrangler runtime) -To learn more about Next.js, take a look at the following resources: +Required keys: -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +- `WORKOS_CLIENT_ID` +- `WORKOS_API_KEY` +- `WORKOS_REDIRECT_URI` +- `SESSION_SECRET` +- `DATABASE_URL` -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +Optional keys for push notifications and cron: -## Deploy on Vercel +- `NEXT_PUBLIC_VAPID_PUBLIC_KEY` +- `VAPID_PRIVATE_KEY` +- `VAPID_SUBJECT` +- `CRON_SECRET` -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +## Deployment -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +```bash +bun run deploy:app +bun run deploy:cron +``` + +Or run both: + +```bash +bun run deploy +``` + +## Database Migrations + +```bash +# local D1 +bun run d1:migrate + +# remote D1 +bun run d1:migrate:prod +``` + +## Recent Product/UX Updates + +- Mobile dashboard uses swipe-first pages with improved snap behavior. +- Profile menu moved to top-right; quick actions are grouped in-menu. +- Daily Inspiration moved under Mood on mobile. +- Achievements cards now show unlock guidance inline. +- Achievements unlock flow hardened to avoid duplicate global first-step unlocks. diff --git a/src/app/api/achievements/route.ts b/src/app/api/achievements/route.ts index 856f9a1..15c64f0 100644 --- a/src/app/api/achievements/route.ts +++ b/src/app/api/achievements/route.ts @@ -1,6 +1,18 @@ import { NextRequest, NextResponse } from 'next/server'; import { getSession } from '@/lib/session'; -import { getAchievementsD1, getAchievementD1, createAchievementD1 } from '@/lib/d1'; +import { getAchievementsD1, getAchievementD1, getAchievementByBadgeD1, createAchievementD1 } from '@/lib/d1'; + +const VALID_BADGE_IDS = new Set([ + 'first_day', + 'streak_3', + 'streak_7', + 'fighter', + 'one_month', + 'goal_crusher', +]); + +const VALID_SUBSTANCES = new Set(['nicotine', 'weed', 'both']); +const GLOBAL_BADGE_IDS = new Set(['first_day']); export async function GET() { try { @@ -10,14 +22,22 @@ export async function GET() { } const achievements = await getAchievementsD1(session.user.id); + const seenGlobalBadges = new Set(); - return NextResponse.json( - achievements.map((a) => ({ + const normalized = achievements + .filter((a) => { + if (!GLOBAL_BADGE_IDS.has(a.badgeId)) return true; + if (seenGlobalBadges.has(a.badgeId)) return false; + seenGlobalBadges.add(a.badgeId); + return true; + }) + .map((a) => ({ badgeId: a.badgeId, unlockedAt: a.unlockedAt, - substance: a.substance, - })) - ); + substance: GLOBAL_BADGE_IDS.has(a.badgeId) ? 'both' : a.substance, + })); + + return NextResponse.json(normalized); } catch (error) { console.error('Error fetching achievements:', error); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); @@ -38,8 +58,22 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Missing badgeId or substance' }, { status: 400 }); } + if (!VALID_BADGE_IDS.has(badgeId)) { + return NextResponse.json({ error: 'Invalid badgeId' }, { status: 400 }); + } + + if (!VALID_SUBSTANCES.has(substance)) { + return NextResponse.json({ error: 'Invalid substance' }, { status: 400 }); + } + + const isGlobalBadge = GLOBAL_BADGE_IDS.has(badgeId); + const targetSubstance = isGlobalBadge ? 'both' : substance; + // Check if already exists - const existing = await getAchievementD1(session.user.id, badgeId, substance); + const existing = isGlobalBadge + ? await getAchievementByBadgeD1(session.user.id, badgeId) + : await getAchievementD1(session.user.id, badgeId, targetSubstance); + if (existing) { return NextResponse.json({ badgeId: existing.badgeId, @@ -49,7 +83,7 @@ export async function POST(request: NextRequest) { }); } - const achievement = await createAchievementD1(session.user.id, badgeId, substance); + const achievement = await createAchievementD1(session.user.id, badgeId, targetSubstance); if (!achievement) { return NextResponse.json({ error: 'Failed to unlock achievement' }, { status: 500 }); diff --git a/src/app/api/auth/reset-password/route.ts b/src/app/api/auth/reset-password/route.ts index 4acf46f..20f8ad4 100644 --- a/src/app/api/auth/reset-password/route.ts +++ b/src/app/api/auth/reset-password/route.ts @@ -1,25 +1,28 @@ -import { NextResponse } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; import { getSession } from '@/lib/session'; +import { workos } from '@/lib/workos'; -// Send password reset email to current user -// NOTE: WorkOS AuthKit handles password reset through the hosted UI flow -// Users can reset their password by clicking "Forgot password" on the login page -export async function POST() { +export async function POST(request: NextRequest) { try { + const body = await request.json().catch(() => ({})) as { email?: string }; const session = await getSession(); - if (!session?.user) { - return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + + const email = (body.email || session?.user?.email || '').trim().toLowerCase(); + + if (!email) { + return NextResponse.json({ error: 'Email is required.' }, { status: 400 }); } - // For WorkOS AuthKit, password reset is handled through the hosted UI - // The user should be directed to re-authenticate via the login page - // where they can click "Forgot password" + try { + await workos.userManagement.createPasswordReset({ email }); + } catch (error: any) { + // Prevent account enumeration: we intentionally return success either way. + console.warn('Password reset initiation warning:', error?.message || error); + } - // Return a message directing the user to the login page return NextResponse.json({ success: true, - message: 'To reset your password, please log out and use the "Forgot Password" option on the login page.', - redirectTo: '/login' + message: 'If an account exists for this email, a password reset link has been sent.' }); } catch (error) { console.error('Password reset error:', error); diff --git a/src/app/api/cron/reminders/route.ts b/src/app/api/cron/reminders/route.ts index 3ceb843..d2b71de 100644 --- a/src/app/api/cron/reminders/route.ts +++ b/src/app/api/cron/reminders/route.ts @@ -85,6 +85,20 @@ function getNotificationData(timeStr: string) { return { title, message }; } +function toMinutes(timeStr: string) { + const [hours, minutes] = timeStr.split(':').map(Number); + return (hours * 60) + minutes; +} + +function isWithinWindow(current: number, start: number, end: number) { + if (start <= end) { + return current >= start && current <= end; + } + + // Overnight window (e.g. 22:00 -> 06:00) + return current >= start || current <= end; +} + export async function GET(request: NextRequest) { try { const isVapidReady = ensureVapidConfig(); @@ -125,16 +139,20 @@ export async function GET(request: NextRequest) { let tag = ''; if (user.frequency === 'hourly') { - const [currentH, currentM] = userTimeString.split(':'); - const [startH, startM] = (user.hourlyStart || '09:00').split(':'); + const [currentH, currentM] = userTimeString.split(':').map(Number); const currentHourKey = `${userDateString}-${currentH}`; // YYYY-MM-DD-HH // Check active hours const startStr = user.hourlyStart || '09:00'; const endStr = user.hourlyEnd || '21:00'; + const startMinutes = toMinutes(startStr); + const endMinutes = toMinutes(endStr); + const currentMinutes = toMinutes(userTimeString); - // Only send if we are in the time window AND the current minute matches the start minute - if (userTimeString >= startStr && userTimeString <= endStr && currentM === startM) { + const cadenceMinute = Number((user.reminderTime || startStr).split(':')[1] || '0'); + + // Send once per hour at configured cadence minute while within active window + if (isWithinWindow(currentMinutes, startMinutes, endMinutes) && currentM === cadenceMinute) { if (user.lastNotifiedDate !== currentHourKey) { shouldSend = true; const { title: t, message } = getNotificationData(userTimeString); diff --git a/src/app/api/reminders/route.ts b/src/app/api/reminders/route.ts index 21f316b..3bca1d2 100644 --- a/src/app/api/reminders/route.ts +++ b/src/app/api/reminders/route.ts @@ -50,6 +50,20 @@ export async function POST(request: NextRequest) { }; const { enabled, reminderTime, frequency, hourlyStart, hourlyEnd, timezone } = body; + const timeRegex = /^\d{2}:\d{2}$/; + if (reminderTime && !timeRegex.test(reminderTime)) { + return NextResponse.json({ error: 'Invalid reminderTime format' }, { status: 400 }); + } + if (hourlyStart && !timeRegex.test(hourlyStart)) { + return NextResponse.json({ error: 'Invalid hourlyStart format' }, { status: 400 }); + } + if (hourlyEnd && !timeRegex.test(hourlyEnd)) { + return NextResponse.json({ error: 'Invalid hourlyEnd format' }, { status: 400 }); + } + if (frequency && !['daily', 'hourly'].includes(frequency)) { + return NextResponse.json({ error: 'Invalid frequency' }, { status: 400 }); + } + const settings = await upsertReminderSettingsD1( session.user.id, enabled ?? false, diff --git a/src/app/globals.css b/src/app/globals.css index 2109cb9..288ec83 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -4,105 +4,111 @@ @custom-variant dark (&:is(.dark *)); :root { - --background: oklch(0.9789 0.0082 121.6272); - --foreground: oklch(0 0 0); + --background: oklch(0.9838 0.0035 247.8583); + --foreground: oklch(0.1284 0.0267 261.5937); --card: oklch(1.0000 0 0); - --card-foreground: oklch(0 0 0); + --card-foreground: oklch(0.1284 0.0267 261.5937); --popover: oklch(1.0000 0 0); - --popover-foreground: oklch(0 0 0); - --primary: oklch(0.5106 0.2301 276.9656); - --primary-foreground: oklch(1.0000 0 0); - --secondary: oklch(0.7038 0.1230 182.5025); - --secondary-foreground: oklch(1.0000 0 0); - --muted: oklch(0.9551 0 0); - --muted-foreground: oklch(0.3211 0 0); - --accent: oklch(0.7686 0.1647 70.0804); - --accent-foreground: oklch(0 0 0); - --destructive: oklch(0.6368 0.2078 25.3313); - --destructive-foreground: oklch(1.0000 0 0); - --border: oklch(0 0 0); - --input: oklch(0.5555 0 0); - --ring: oklch(0.7853 0.1041 274.7134); - --chart-1: oklch(0.5106 0.2301 276.9656); - --chart-2: oklch(0.7038 0.1230 182.5025); - --chart-3: oklch(0.7686 0.1647 70.0804); - --chart-4: oklch(0.6559 0.2118 354.3084); - --chart-5: oklch(0.7227 0.1920 149.5793); - --sidebar: oklch(0.9789 0.0082 121.6272); - --sidebar-foreground: oklch(0 0 0); - --sidebar-primary: oklch(0.5106 0.2301 276.9656); - --sidebar-primary-foreground: oklch(1.0000 0 0); - --sidebar-accent: oklch(0.7686 0.1647 70.0804); - --sidebar-accent-foreground: oklch(0 0 0); - --sidebar-border: oklch(0 0 0); - --sidebar-ring: oklch(0.7853 0.1041 274.7134); + --popover-foreground: oklch(0.1284 0.0267 261.5937); + --primary: oklch(0.4865 0.2423 291.8661); + --primary-foreground: oklch(0.9838 0.0035 247.8583); + --secondary: oklch(0.9486 0.0085 303.5068); + --secondary-foreground: oklch(0.3410 0.1625 292.9477); + --muted: oklch(0.9679 0.0027 264.5424); + --muted-foreground: oklch(0.5503 0.0235 264.3620); + --accent: oklch(0.9546 0.0227 303.2883); + --accent-foreground: oklch(0.4865 0.2423 291.8661); + --destructive: oklch(0.6356 0.2082 25.3782); + --destructive-foreground: oklch(0.9838 0.0035 247.8583); + --border: oklch(0.9278 0.0058 264.5314); + --input: oklch(0.9278 0.0058 264.5314); + --ring: oklch(0.4865 0.2423 291.8661); + --chart-1: oklch(0.4865 0.2423 291.8661); + --chart-2: oklch(0.7216 0.1282 217.8676); + --chart-3: oklch(0.6356 0.1398 156.1492); + --chart-4: oklch(0.6192 0.2037 312.7283); + --chart-5: oklch(0.6532 0.2114 353.9392); + --sidebar: oklch(1.0000 0 0); + --sidebar-foreground: oklch(0.1284 0.0267 261.5937); + --sidebar-primary: oklch(0.4865 0.2423 291.8661); + --sidebar-primary-foreground: oklch(0.9838 0.0035 247.8583); + --sidebar-accent: oklch(0.9486 0.0085 303.5068); + --sidebar-accent-foreground: oklch(0.4865 0.2423 291.8661); + --sidebar-border: oklch(0.9278 0.0058 264.5314); + --sidebar-ring: oklch(0.4865 0.2423 291.8661); + --font-sans: Poppins, ui-sans-serif, sans-serif, system-ui; + --font-serif: Georgia, serif; + --font-mono: JetBrains Mono, monospace; --radius: 1rem; --shadow-x: 0px; - --shadow-y: 0px; - --shadow-blur: 0px; + --shadow-y: 8px; + --shadow-blur: 30px; --shadow-spread: 0px; - --shadow-opacity: 0.05; - --shadow-color: #1a1a1a; - --shadow-2xs: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.03); - --shadow-xs: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.03); - --shadow-sm: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 1px 2px -1px hsl(0 0% 10.1961% / 0.05); - --shadow: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 1px 2px -1px hsl(0 0% 10.1961% / 0.05); - --shadow-md: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 2px 4px -1px hsl(0 0% 10.1961% / 0.05); - --shadow-lg: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 4px 6px -1px hsl(0 0% 10.1961% / 0.05); - --shadow-xl: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 8px 10px -1px hsl(0 0% 10.1961% / 0.05); - --shadow-2xl: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.13); - --tracking-normal: normal; + --shadow-opacity: 0.08; + --shadow-color: hsl(263, 70%, 50%); + --shadow-2xs: 0px 8px 30px 0px hsl(263 70% 50% / 0.04); + --shadow-xs: 0px 8px 30px 0px hsl(263 70% 50% / 0.04); + --shadow-sm: 0px 8px 30px 0px hsl(263 70% 50% / 0.08), 0px 1px 2px -1px hsl(263 70% 50% / 0.08); + --shadow: 0px 8px 30px 0px hsl(263 70% 50% / 0.08), 0px 1px 2px -1px hsl(263 70% 50% / 0.08); + --shadow-md: 0px 8px 30px 0px hsl(263 70% 50% / 0.08), 0px 2px 4px -1px hsl(263 70% 50% / 0.08); + --shadow-lg: 0px 8px 30px 0px hsl(263 70% 50% / 0.08), 0px 4px 6px -1px hsl(263 70% 50% / 0.08); + --shadow-xl: 0px 8px 30px 0px hsl(263 70% 50% / 0.08), 0px 8px 10px -1px hsl(263 70% 50% / 0.08); + --shadow-2xl: 0px 8px 30px 0px hsl(263 70% 50% / 0.20); + --tracking-normal: -0.02em; --spacing: 0.25rem; } .dark { - --background: oklch(0 0 0); - --foreground: oklch(1.0000 0 0); - --card: oklch(0.2455 0.0217 257.2823); - --card-foreground: oklch(1.0000 0 0); - --popover: oklch(0.2455 0.0217 257.2823); - --popover-foreground: oklch(1.0000 0 0); - --primary: oklch(0.6801 0.1583 276.9349); - --primary-foreground: oklch(0 0 0); - --secondary: oklch(0.7845 0.1325 181.9120); - --secondary-foreground: oklch(0 0 0); - --muted: oklch(0.3211 0 0); - --muted-foreground: oklch(0.8452 0 0); - --accent: oklch(0.8790 0.1534 91.6054); - --accent-foreground: oklch(0 0 0); - --destructive: oklch(0.7106 0.1661 22.2162); - --destructive-foreground: oklch(0 0 0); - --border: oklch(0.4459 0 0); - --input: oklch(1.0000 0 0); - --ring: oklch(0.6801 0.1583 276.9349); - --chart-1: oklch(0.6801 0.1583 276.9349); - --chart-2: oklch(0.7845 0.1325 181.9120); - --chart-3: oklch(0.8790 0.1534 91.6054); - --chart-4: oklch(0.7253 0.1752 349.7607); - --chart-5: oklch(0.8003 0.1821 151.7110); - --sidebar: oklch(0 0 0); - --sidebar-foreground: oklch(1.0000 0 0); - --sidebar-primary: oklch(0.6801 0.1583 276.9349); - --sidebar-primary-foreground: oklch(0 0 0); - --sidebar-accent: oklch(0.8790 0.1534 91.6054); - --sidebar-accent-foreground: oklch(0 0 0); - --sidebar-border: oklch(1.0000 0 0); - --sidebar-ring: oklch(0.6801 0.1583 276.9349); + --background: oklch(0.1091 0.0091 301.6956); + --foreground: oklch(0.9838 0.0035 247.8583); + --card: oklch(0.1376 0.0118 301.0607); + --card-foreground: oklch(0.9838 0.0035 247.8583); + --popover: oklch(0.1486 0.0140 299.9811); + --popover-foreground: oklch(0.9838 0.0035 247.8583); + --primary: oklch(0.6083 0.2172 297.1153); + --primary-foreground: oklch(0.1091 0.0091 301.6956); + --secondary: oklch(0.2363 0.0582 299.6364); + --secondary-foreground: oklch(0.8266 0.0933 301.9462); + --muted: oklch(0.2217 0.0242 299.7054); + --muted-foreground: oklch(0.7497 0.0224 301.0128); + --accent: oklch(0.2255 0.0836 296.7401); + --accent-foreground: oklch(0.6083 0.2172 297.1153); + --destructive: oklch(0.6356 0.2082 25.3782); + --destructive-foreground: oklch(0.9838 0.0035 247.8583); + --border: oklch(0.2505 0.0293 299.5707); + --input: oklch(0.2505 0.0293 299.5707); + --ring: oklch(0.6083 0.2172 297.1153); + --chart-1: oklch(0.6083 0.2172 297.1153); + --chart-2: oklch(0.7741 0.1272 215.0981); + --chart-3: oklch(0.7801 0.1859 154.5892); + --chart-4: oklch(0.7001 0.1882 313.2907); + --chart-5: oklch(0.6888 0.2092 353.1317); + --sidebar: oklch(0.1249 0.0104 301.6956); + --sidebar-foreground: oklch(0.9838 0.0035 247.8583); + --sidebar-primary: oklch(0.6083 0.2172 297.1153); + --sidebar-primary-foreground: oklch(0.1091 0.0091 301.6956); + --sidebar-accent: oklch(0.2096 0.0482 299.9505); + --sidebar-accent-foreground: oklch(0.6083 0.2172 297.1153); + --sidebar-border: oklch(0.2217 0.0242 299.7054); + --sidebar-ring: oklch(0.6083 0.2172 297.1153); + --font-sans: Poppins, ui-sans-serif, sans-serif, system-ui; + --font-serif: Georgia, serif; + --font-mono: JetBrains Mono, monospace; --radius: 1rem; --shadow-x: 0px; - --shadow-y: 0px; - --shadow-blur: 0px; - --shadow-spread: 0px; - --shadow-opacity: 0.05; - --shadow-color: #1a1a1a; - --shadow-2xs: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.03); - --shadow-xs: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.03); - --shadow-sm: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 1px 2px -1px hsl(0 0% 10.1961% / 0.05); - --shadow: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 1px 2px -1px hsl(0 0% 10.1961% / 0.05); - --shadow-md: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 2px 4px -1px hsl(0 0% 10.1961% / 0.05); - --shadow-lg: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 4px 6px -1px hsl(0 0% 10.1961% / 0.05); - --shadow-xl: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 8px 10px -1px hsl(0 0% 10.1961% / 0.05); - --shadow-2xl: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.13); + --shadow-y: 20px; + --shadow-blur: 40px; + --shadow-spread: -10px; + --shadow-opacity: 0.6; + --shadow-color: hsl(0, 0%, 0%); + --shadow-2xs: 0px 20px 40px -10px hsl(0 0% 0% / 0.30); + --shadow-xs: 0px 20px 40px -10px hsl(0 0% 0% / 0.30); + --shadow-sm: 0px 20px 40px -10px hsl(0 0% 0% / 0.60), 0px 1px 2px -11px hsl(0 0% 0% / 0.60); + --shadow: 0px 20px 40px -10px hsl(0 0% 0% / 0.60), 0px 1px 2px -11px hsl(0 0% 0% / 0.60); + --shadow-md: 0px 20px 40px -10px hsl(0 0% 0% / 0.60), 0px 2px 4px -11px hsl(0 0% 0% / 0.60); + --shadow-lg: 0px 20px 40px -10px hsl(0 0% 0% / 0.60), 0px 4px 6px -11px hsl(0 0% 0% / 0.60); + --shadow-xl: 0px 20px 40px -10px hsl(0 0% 0% / 0.60), 0px 8px 10px -11px hsl(0 0% 0% / 0.60); + --shadow-2xl: 0px 20px 40px -10px hsl(0 0% 0% / 1.50); } @theme inline { @@ -139,9 +145,9 @@ --color-sidebar-border: var(--sidebar-border); --color-sidebar-ring: var(--sidebar-ring); - --font-sans: 'DM Sans', sans-serif; - --font-mono: 'Space Mono', monospace; - --font-serif: 'DM Sans', sans-serif; + --font-sans: var(--font-sans); + --font-mono: var(--font-mono); + --font-serif: var(--font-serif); --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); @@ -164,57 +170,19 @@ --tracking-wider: calc(var(--tracking-normal) + 0.05em); --tracking-widest: calc(var(--tracking-normal) + 0.1em); - /* Background gradients */ - --bg-main: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 20%, #bbf7d0 40%, #dcfce7 60%, #f0fdf4 80%, #dcfce7 100%); - --bg-orbs: - radial-gradient(ellipse at 15% 10%, rgba(99, 102, 241, 0.1) 0%, transparent 40%), - radial-gradient(ellipse at 85% 20%, rgba(168, 85, 247, 0.08) 0%, transparent 35%), - radial-gradient(ellipse at 50% 50%, rgba(59, 130, 246, 0.06) 0%, transparent 50%), - radial-gradient(ellipse at 20% 80%, rgba(34, 197, 94, 0.05) 0%, transparent 40%), - radial-gradient(ellipse at 80% 85%, rgba(239, 68, 68, 0.05) 0%, transparent 35%); } @layer base { - html { - scroll-behavior: smooth; - } - * { @apply border-border outline-ring/50; } body { - @apply text-foreground; + @apply bg-background text-foreground; font-family: var(--font-sans); letter-spacing: var(--tracking-normal); - background-color: transparent; - min-height: 100vh; min-height: 100dvh; - overflow-x: hidden; - } - - body::before { - content: ''; - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: var(--bg-orbs), var(--bg-main); - background-size: cover; - pointer-events: none; - z-index: -50; - } - - /* Dark mode overrides */ - .dark { - --bg-main: linear-gradient(135deg, #0f0f1a 0%, #1a1a2e 20%, #16213e 40%, #1a1a2e 60%, #0f0f1a 80%, #1a1a2e 100%); - --bg-orbs: - radial-gradient(ellipse at 15% 10%, rgba(99, 102, 241, 0.15) 0%, transparent 40%), - radial-gradient(ellipse at 85% 20%, rgba(168, 85, 247, 0.12) 0%, transparent 35%), - radial-gradient(ellipse at 50% 50%, rgba(45, 55, 72, 0.1) 0%, transparent 50%), - radial-gradient(ellipse at 20% 80%, rgba(34, 197, 94, 0.08) 0%, transparent 40%), - radial-gradient(ellipse at 80% 85%, rgba(239, 68, 68, 0.08) 0%, transparent 35%); + overflow-x: clip; } /* Calendar styling - optimize cell size */ @@ -645,18 +613,21 @@ @media (max-width: 640px) { .swipe-container { display: flex; + align-items: flex-start; overflow-x: auto; overflow-y: hidden; - scroll-snap-type: x mandatory; + scroll-snap-type: x proximity; scroll-behavior: smooth; -webkit-overflow-scrolling: touch; + touch-action: pan-x pinch-zoom; scrollbar-width: none; -ms-overflow-style: none; - gap: 1.25rem; - padding: 0.5rem 0 1.5rem; + gap: 1rem; + padding: 0.25rem 0 0.5rem; margin: 0 -1rem; padding-left: 1.5rem; padding-right: 1.5rem; + scroll-padding-inline: 1.5rem; overscroll-behavior-x: contain; will-change: transform, scroll-position; } @@ -666,10 +637,14 @@ } .swipe-item { - flex: 0 0 calc(100vw - 3rem); - width: calc(100vw - 3rem); - scroll-snap-align: center; - height: fit-content; + flex: 0 0 calc(100vw - 3.25rem); + width: calc(100vw - 3.25rem); + scroll-snap-align: start; + scroll-snap-stop: always; + display: flex; + flex-direction: column; + justify-content: flex-start; + overflow: visible; } } @@ -722,4 +697,4 @@ .animate-float { animation: float 6s ease-in-out infinite; -} \ No newline at end of file +} diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 6f55d61..9f73efb 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -3,7 +3,7 @@ import { useState } from 'react'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; -import { ArrowLeft, Mail, Lock, LogOut, ExternalLink } from 'lucide-react'; +import { ArrowLeft, Mail, Lock } from 'lucide-react'; import { useRouter } from 'next/navigation'; import { useTheme } from '@/lib/theme-context'; import { cn } from '@/lib/utils'; @@ -11,10 +11,33 @@ import { cn } from '@/lib/utils'; export default function SettingsPage() { const router = useRouter(); const { theme } = useTheme(); + const [isSendingReset, setIsSendingReset] = useState(false); + const [resetMessage, setResetMessage] = useState(null); + const [resetError, setResetError] = useState(null); - const handlePasswordReset = () => { - // Log out and redirect to login page where they can use "Forgot Password" - window.location.href = '/api/auth/logout?redirect=/login'; + const handlePasswordReset = async () => { + setIsSendingReset(true); + setResetMessage(null); + setResetError(null); + + try { + const response = await fetch('/api/auth/reset-password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + + const data = await response.json() as { message?: string; error?: string }; + + if (!response.ok) { + throw new Error(data.error || 'Failed to start password reset.'); + } + + setResetMessage(data.message || 'Reset instructions sent to your email.'); + } catch (error: any) { + setResetError(error.message || 'Failed to start password reset.'); + } finally { + setIsSendingReset(false); + } }; return ( @@ -64,18 +87,30 @@ export default function SettingsPage() { : "bg-white/5 border-white/10" )}>

- To change your password, you'll be logged out and redirected to the login page. - Use the "Forgot Password" option to receive a reset link via email. + Send a secure password reset link to your account email.

+ {resetMessage && ( +
+ {resetMessage} +
+ )} + + {resetError && ( +
+ {resetError} +
+ )} + diff --git a/src/components/AchievementsCard.tsx b/src/components/AchievementsCard.tsx index 91dfee5..56f5328 100644 --- a/src/components/AchievementsCard.tsx +++ b/src/components/AchievementsCard.tsx @@ -1,12 +1,13 @@ 'use client'; -import React, { useMemo, useState } from 'react'; +import React, { useMemo } from 'react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Achievement, BADGE_DEFINITIONS, BadgeDefinition } from '@/lib/storage'; +import { Achievement, BADGE_DEFINITIONS } from '@/lib/storage'; import { useTheme } from '@/lib/theme-context'; import { Trophy, Lock, + CheckCircle2, Footprints, Flame, Shield, @@ -30,7 +31,6 @@ const iconMap: Record = { function AchievementsCardComponent({ achievements, substance }: AchievementsCardProps) { const { theme } = useTheme(); - const [hoveredBadge, setHoveredBadge] = useState(null); const unlockedBadgeIds = useMemo(() => { return new Set( @@ -49,11 +49,9 @@ function AchievementsCardComponent({ achievements, substance }: AchievementsCard return ( -
- @@ -61,46 +59,20 @@ function AchievementsCardComponent({ achievements, substance }: AchievementsCard - -
+ +
{BADGE_DEFINITIONS.map((badge) => { const isUnlocked = unlockedBadgeIds.has(badge.id); const Icon = iconMap[badge.icon] || Trophy; - const unlockedAchievement = achievements.find( - (a) => - a.badgeId === badge.id && - (a.substance === substance || a.substance === 'both') - ); - const isHovered = hoveredBadge === badge.id; return (
setHoveredBadge(badge.id)} - onMouseLeave={() => setHoveredBadge(null)} > - {/* Hover tooltip */} - {isHovered && ( -
-

{badge.name}

-

- {isUnlocked - ? `Unlocked: ${new Date(unlockedAchievement!.unlockedAt).toLocaleDateString()}` - : badge.howToUnlock} -

-
-
- )} - - {!isUnlocked && ( -
- -
- )}
+

{badge.name}

+ +

+ {badge.howToUnlock} +

+ +
+ {isUnlocked ? ( + <> + + Completed + + ) : ( + <> + + Locked + + )} +
); })}
-
+

{unlockedBadgeIds.size} of {BADGE_DEFINITIONS.length} badges unlocked

diff --git a/src/components/DailyInspirationCard.tsx b/src/components/DailyInspirationCard.tsx index 1273781..ae218e0 100644 --- a/src/components/DailyInspirationCard.tsx +++ b/src/components/DailyInspirationCard.tsx @@ -138,8 +138,6 @@ export function DailyInspirationCard({ initialReligion, onReligionChange }: Dail boxShadow: `inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 4px 20px ${getShadowColor()}` }} > -
-
diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index a99f089..465e89e 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -22,6 +22,7 @@ import { SavingsConfig, BADGE_DEFINITIONS, BadgeDefinition, + StorageRequestError, } from '@/lib/storage'; import { UserHeader } from './UserHeader'; import { SetupWizard } from './SetupWizard'; @@ -33,11 +34,12 @@ 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, ChevronLeft, ChevronRight, X } from 'lucide-react'; +import { PlusCircle, X } from 'lucide-react'; import { useTheme } from '@/lib/theme-context'; import { getTodayString } from '@/lib/date-utils'; @@ -46,6 +48,18 @@ 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(null); const [usageData, setUsageData] = useState([]); @@ -57,6 +71,7 @@ export function Dashboard({ user }: DashboardProps) { const [activeLoggingSubstance, setActiveLoggingSubstance] = useState<'nicotine' | 'weed' | null>(null); const [newBadge, setNewBadge] = useState(null); const [isLoading, setIsLoading] = useState(true); + const [loadError, setLoadError] = useState(null); const [refreshKey, setRefreshKey] = useState(0); const [currentPage, setCurrentPage] = useState(0); const [modalOpenCount, setModalOpenCount] = useState(0); @@ -64,45 +79,92 @@ export function Dashboard({ user }: DashboardProps) { const { theme } = useTheme(); const isModalOpen = modalOpenCount > 0 || showSetup || showCelebration; - const isNavHidden = isModalOpen || isSubstancePickerOpen || !!activeLoggingSubstance; + const totalPages = MOBILE_SLIDES.length; const handleModalStateChange = useCallback((isOpen: boolean) => { setModalOpenCount(prev => isOpen ? prev + 1 : Math.max(0, prev - 1)); }, []); 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); + const container = swipeContainerRef.current; + if (!container) return; + + const slides = Array.from(container.querySelectorAll('.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) => { - if (!swipeContainerRef.current) return; - const width = swipeContainerRef.current.offsetWidth; - swipeContainerRef.current.scrollTo({ - left: pageIndex * width, + const container = swipeContainerRef.current; + if (!container) return; + + const slides = Array.from(container.querySelectorAll('.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 () => { - 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 }; + 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 ( @@ -112,11 +174,47 @@ export function Dashboard({ user }: DashboardProps) { ) => { // 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; @@ -154,22 +252,26 @@ export function Dashboard({ user }: DashboardProps) { useEffect(() => { const init = async () => { - const { prefs, usage, achvs } = await loadData(); + try { + const { prefs, usage, achvs } = await loadData(); - if (!prefs.hasCompletedSetup) { - setShowSetup(true); - } else { - // Check for achievements - await checkAndUnlockAchievements(usage, prefs, achvs); + 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(); + // 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); } - - setIsLoading(false); }; init(); @@ -307,10 +409,39 @@ export function Dashboard({ user }: DashboardProps) { />
+ {loadError && ( +
+
+ {loadError} + +
+
+ )} + + {!preferences && !isLoading && ( +
+

Your dashboard data is unavailable right now.

+
+ )} + {preferences && ( <> {/* Floating Log Button - Simplified to toggle Picker */} -
+
- )} - {currentPage < 3 && !isNavHidden && ( - - )} -
- {/* DESKTOP LAYOUT - Hidden on mobile */}
{/* Row 1: Mood + Quit Plan */} @@ -385,12 +486,6 @@ export function Dashboard({ user }: DashboardProps) { usageData={usageData} onDataUpdate={loadData} userId={user.id} - religion={preferences.religion} - onReligionUpdate={async (religion: 'christian' | 'secular') => { - const updatedPrefs = { ...preferences, religion }; - setPreferences(updatedPrefs); - await savePreferencesAsync(updatedPrefs); - }} preferences={preferences} onPreferencesUpdate={async (updatedPrefs: UserPreferences) => { await savePreferencesAsync(updatedPrefs); @@ -433,17 +528,48 @@ export function Dashboard({ user }: DashboardProps) { {/* MOBILE SWIPE LAYOUT - Hidden on desktop */}
{ + 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: Mindset (Mood & Personalized Plan) */} -
+ {/* SLIDE 1: Mood */} +
-

Daily Mindset

+

How Are You Feeling

-
+
+ { + const updatedPrefs = { ...preferences, religion }; + setPreferences(updatedPrefs); + await savePreferencesAsync(updatedPrefs); + }} + /> +
+
+ + {/* SLIDE 2: Quit Plan */} +
+
+

Quit Journey Plan

+
+
- {/* SLIDE 2: Stats & Recovery (Side-by-side Stats + Health) */} -
+ {/* SLIDE 3: Stats */} +
-

Usage & Recovery

+

Usage Stats

-
-
- - -
+
+ + +
+
+ + {/* SLIDE 4: Recovery */} +
+
+

Health Recovery

+
+
- {/* SLIDE 3: Achievements & Money (Insights) */} -
+ {/* SLIDE 5: Achievements */} +
-

Achievements & Savings

+

Achievements

-
+
+
+
+ + {/* SLIDE 6: Savings */} +
+
+

Savings

+
+
- {/* SLIDE 4: Calendar */} + {/* SLIDE 7: Calendar */}

Usage Calendar

- { - const updatedPrefs = { ...preferences, religion }; - setPreferences(updatedPrefs); - await savePreferencesAsync(updatedPrefs); - }} - preferences={preferences} - onPreferencesUpdate={async (updatedPrefs: UserPreferences) => { - await savePreferencesAsync(updatedPrefs); - setPreferences(updatedPrefs); - }} - /> +
+ { + await savePreferencesAsync(updatedPrefs); + setPreferences(updatedPrefs); + }} + /> +
+ +
+
+
+ {MOBILE_SLIDES[currentPage]?.label} +
+
+ {MOBILE_SLIDES.map((slide, index) => ( +
+
+
)} diff --git a/src/components/HealthTimelineCard.tsx b/src/components/HealthTimelineCard.tsx index 6c604ef..b4b135f 100644 --- a/src/components/HealthTimelineCard.tsx +++ b/src/components/HealthTimelineCard.tsx @@ -311,11 +311,9 @@ function HealthTimelineCardComponent({ return ( -
- diff --git a/src/components/InstallAppButton.tsx b/src/components/InstallAppButton.tsx index d6dcec7..8d433f6 100644 --- a/src/components/InstallAppButton.tsx +++ b/src/components/InstallAppButton.tsx @@ -10,13 +10,20 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { Download, Share, Plus, MoreVertical, Smartphone } from 'lucide-react'; +import { cn } from '@/lib/utils'; interface BeforeInstallPromptEvent extends Event { prompt: () => Promise; userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>; } -export function InstallAppButton() { +interface InstallAppButtonProps { + compact?: boolean; + className?: string; + onBeforeOpen?: () => void; +} + +export function InstallAppButton({ compact = false, className, onBeforeOpen }: InstallAppButtonProps) { const [showInstructions, setShowInstructions] = useState(false); const [deferredPrompt, setDeferredPrompt] = useState(null); const [isIOS, setIsIOS] = useState(false); @@ -50,6 +57,7 @@ export function InstallAppButton() { }, []); const handleInstallClick = async () => { + onBeforeOpen?.(); if (deferredPrompt) { // Use the native install prompt (Android/Chrome) await deferredPrompt.prompt(); @@ -74,11 +82,21 @@ export function InstallAppButton() { variant="outline" size="sm" onClick={handleInstallClick} - className="gap-2 border-purple-500/30 hover:bg-purple-500/10 hover:border-purple-500/50" + className={cn( + compact + ? 'h-12 w-12 p-0 border-purple-500/30 hover:bg-purple-500/10 hover:border-purple-500/50 flex items-center justify-center' + : 'gap-2 border-purple-500/30 hover:bg-purple-500/10 hover:border-purple-500/50', + className + )} + aria-label="Install app" > - - Add to Home Screen - Install + + {!compact && ( + <> + Add to Home Screen + Install + + )} diff --git a/src/components/ReminderSettingsCard.tsx b/src/components/ReminderSettingsCard.tsx index 4b7a100..8bedb5a 100644 --- a/src/components/ReminderSettingsCard.tsx +++ b/src/components/ReminderSettingsCard.tsx @@ -84,8 +84,6 @@ export function ReminderSettingsCard({ className="backdrop-blur-xl border border-indigo-500/40 shadow-xl drop-shadow-lg hover-lift transition-all duration-300 overflow-hidden relative" style={{ background: cardBackground }} > -
- diff --git a/src/components/SavingsTrackerCard.tsx b/src/components/SavingsTrackerCard.tsx index c10bf52..7188ec0 100644 --- a/src/components/SavingsTrackerCard.tsx +++ b/src/components/SavingsTrackerCard.tsx @@ -81,8 +81,6 @@ function SavingsTrackerCardComponent({ className="backdrop-blur-xl border border-emerald-500/40 shadow-xl drop-shadow-lg hover-lift transition-all duration-300 overflow-hidden relative" style={{ background: cardBackground }} > -
- @@ -127,8 +125,6 @@ function SavingsTrackerCardComponent({ className="backdrop-blur-xl border border-emerald-500/40 shadow-xl drop-shadow-lg hover-lift transition-all duration-300 overflow-hidden relative" style={{ background: cardBackground }} > -
-
diff --git a/src/components/SideMenu.tsx b/src/components/SideMenu.tsx index a018550..a4d9745 100644 --- a/src/components/SideMenu.tsx +++ b/src/components/SideMenu.tsx @@ -10,7 +10,10 @@ import { Settings, Shield, Heart, - Calendar + Calendar, + Bell, + Moon, + Sun } from 'lucide-react'; import React from 'react'; import { useRouter } from 'next/navigation'; @@ -18,6 +21,7 @@ import { cn } from '@/lib/utils'; import { useTheme } from '@/lib/theme-context'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { createPortal } from 'react-dom'; +import { InstallAppButton } from './InstallAppButton'; interface SideMenuProps { isOpen: boolean; @@ -30,11 +34,12 @@ interface SideMenuProps { profilePictureUrl?: string | null; }; userName: string | null; + onOpenNotifications: () => void; } -export function SideMenu({ isOpen, onClose, user, userName }: SideMenuProps) { +export function SideMenu({ isOpen, onClose, user, userName, onOpenNotifications }: SideMenuProps) { const router = useRouter(); - const { theme } = useTheme(); + const { theme, toggleTheme } = useTheme(); if (!isOpen) return null; @@ -72,10 +77,10 @@ export function SideMenu({ isOpen, onClose, user, userName }: SideMenuProps) { {/* Menu Content */}
{/* Header/Profile Info */} @@ -103,6 +108,59 @@ export function SideMenu({ isOpen, onClose, user, userName }: SideMenuProps) { {/* Navigation Links */}
+
+ Quick Actions +
+
+
+ + + + + +
+
+ +
+ Main +
@@ -182,20 +240,20 @@ function MenuLink({ icon: Icon, label, onClick, color }: MenuLinkProps) { ); } diff --git a/src/components/StatsCard.tsx b/src/components/StatsCard.tsx index faea2ac..4e21b71 100644 --- a/src/components/StatsCard.tsx +++ b/src/components/StatsCard.tsx @@ -92,7 +92,6 @@ function StatsCardComponent({ usageData, substance }: StatsCardProps) { className={`backdrop-blur-xl border ${borderColor} shadow-xl drop-shadow-lg hover-lift transition-all duration-300 overflow-hidden relative`} style={{ background: cardBackground }} > -
diff --git a/src/components/SubstanceTrackingPage.tsx b/src/components/SubstanceTrackingPage.tsx index c89ed01..8e498d5 100644 --- a/src/components/SubstanceTrackingPage.tsx +++ b/src/components/SubstanceTrackingPage.tsx @@ -17,12 +17,20 @@ interface SubstanceTrackingPageProps { export function SubstanceTrackingPage({ user, substance }: SubstanceTrackingPageProps) { const [usageData, setUsageData] = useState([]); const [isLoading, setIsLoading] = useState(true); + const [loadError, setLoadError] = useState(null); const { theme } = useTheme(); const loadData = useCallback(async () => { - const usage = await fetchUsageData(); - setUsageData(usage); - setIsLoading(false); + try { + const usage = await fetchUsageData(); + setUsageData(usage); + setLoadError(null); + } catch (error) { + console.error('Failed to load substance usage data:', error); + setLoadError('Unable to load tracking data right now.'); + } finally { + setIsLoading(false); + } }, []); useEffect(() => { @@ -88,6 +96,12 @@ export function SubstanceTrackingPage({ user, substance }: SubstanceTrackingPage
{/* Stats and Graph */} + {loadError && ( +
+ {loadError} +
+ )} +
diff --git a/src/components/UnifiedLogin.tsx b/src/components/UnifiedLogin.tsx index 9879248..5c2521f 100644 --- a/src/components/UnifiedLogin.tsx +++ b/src/components/UnifiedLogin.tsx @@ -23,6 +23,8 @@ export function UnifiedLogin({ onSuccess }: UnifiedLoginProps) { const [pendingVerification, setPendingVerification] = useState(false); const [verificationCode, setVerificationCode] = useState(''); const [userId, setUserId] = useState(null); + const [isResettingPassword, setIsResettingPassword] = useState(false); + const [resetStatus, setResetStatus] = useState(null); const [formData, setFormData] = useState({ email: '', @@ -107,6 +109,39 @@ export function UnifiedLogin({ onSuccess }: UnifiedLoginProps) { const toggleMode = () => { setIsSignup(!isSignup); setError(null); + setResetStatus(null); + }; + + const handleForgotPassword = async () => { + const email = formData.email.trim(); + if (!email) { + setError('Enter your email first, then request password reset.'); + return; + } + + setError(null); + setResetStatus(null); + setIsResettingPassword(true); + + try { + const res = await fetch('/api/auth/reset-password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }), + }); + + const data = await res.json() as { message?: string; error?: string }; + + if (!res.ok) { + throw new Error(data.error || 'Could not start password reset.'); + } + + setResetStatus(data.message || 'Password reset instructions sent if the account exists.'); + } catch (err: any) { + setError(err.message || 'Could not start password reset.'); + } finally { + setIsResettingPassword(false); + } }; // Checkbox Component to reuse @@ -276,16 +311,14 @@ export function UnifiedLogin({ onSuccess }: UnifiedLoginProps) {
{!isSignup && !pendingVerification && ( - { - e.preventDefault(); - alert('Please use the Contact Support option or wait for upcoming full password reset features.'); - }} + )}
@@ -324,6 +357,12 @@ export function UnifiedLogin({ onSuccess }: UnifiedLoginProps) {
)} + {resetStatus && ( +
+ {resetStatus} +
+ )} + -
- ) : ( -

- Keep logging for {daysRemaining} more days to calculate your personalized reduction plan. -

- )}
- ) : ( -
+ + {isUnlocked ? ( +
+

+ Baseline established: {currentAverage} {unitLabel}/day +

+ +
+ ) : ( +

+ Keep logging for {daysRemaining} more days to calculate your personalized reduction plan. +

+ )} +
+ ) : ( +
{/* Active Plan Detail */}

Current Daily Limit

{currentTarget}

-

puffs allowed today

+

{unitLabel} allowed today

{/* Progress Bar Detail */} @@ -252,13 +246,12 @@ function SubstancePlanSection({
- Start: {activePlan.baselineAverage}/day + Start: {activePlan.baselineAverage} {unitLabel}/day End: {new Date(activePlan.endDate).toLocaleDateString()}
-
- )} -
- )} +
+ )} +
); } @@ -276,16 +269,8 @@ export function UnifiedQuitPlanCard({ onGeneratePlan, refreshKey }: UnifiedQuitPlanCardProps) { - const [expandedSubstance, setExpandedSubstance] = useState<'nicotine' | 'weed' | 'none'>('nicotine'); - if (!preferences) return null; - // Determine which substances to show - const showNicotine = preferences.substance === 'nicotine' || usageData.some(e => e.substance === 'nicotine'); - const showWeed = preferences.substance === 'weed' || usageData.some(e => e.substance === 'weed'); - - if (!showNicotine && !showWeed) return null; - return ( @@ -295,39 +280,31 @@ export function UnifiedQuitPlanCard({ - {showNicotine && ( - setExpandedSubstance(expandedSubstance === 'nicotine' ? 'none' : 'nicotine')} - plan={preferences.quitState?.nicotine?.plan || (preferences.substance === 'nicotine' ? preferences.quitPlan : null)} - usageData={usageData} - trackingStartDate={ - preferences.quitState?.nicotine?.startDate || - (preferences.substance === 'nicotine' ? preferences.trackingStartDate : null) || - usageData.filter(e => e.substance === 'nicotine').sort((a, b) => a.date.localeCompare(b.date))[0]?.date || - null - } - onGeneratePlan={() => onGeneratePlan('nicotine')} - /> - )} + e.substance === 'nicotine').sort((a, b) => a.date.localeCompare(b.date))[0]?.date || + null + } + onGeneratePlan={() => onGeneratePlan('nicotine')} + /> - {showWeed && ( - setExpandedSubstance(expandedSubstance === 'weed' ? 'none' : 'weed')} - plan={preferences.quitState?.weed?.plan || (preferences.substance === 'weed' ? preferences.quitPlan : null)} - usageData={usageData} - trackingStartDate={ - preferences.quitState?.weed?.startDate || - (preferences.substance === 'weed' ? preferences.trackingStartDate : null) || - usageData.filter(e => e.substance === 'weed').sort((a, b) => a.date.localeCompare(b.date))[0]?.date || - null - } - onGeneratePlan={() => onGeneratePlan('weed')} - /> - )} + e.substance === 'weed').sort((a, b) => a.date.localeCompare(b.date))[0]?.date || + null + } + onGeneratePlan={() => onGeneratePlan('weed')} + /> ); diff --git a/src/components/UsageCalendar.tsx b/src/components/UsageCalendar.tsx index 713abcf..50f2101 100644 --- a/src/components/UsageCalendar.tsx +++ b/src/components/UsageCalendar.tsx @@ -16,7 +16,6 @@ import { UsageEntry, UserPreferences, setUsageForDateAsync, clearDayDataAsync } import { ChevronLeftIcon, ChevronRightIcon, Cigarette, Leaf, Sparkles } from 'lucide-react'; import { useTheme } from '@/lib/theme-context'; import { getLocalDateString, getTodayString } from '@/lib/date-utils'; -import { DailyInspirationCard } from './DailyInspirationCard'; import { cn } from '@/lib/utils'; import React from 'react'; @@ -25,13 +24,11 @@ interface UsageCalendarProps { usageData: UsageEntry[]; onDataUpdate: () => void; userId: string; - religion?: 'christian' | 'secular' | null; - onReligionUpdate?: (religion: 'christian' | 'secular') => void; preferences?: UserPreferences | null; onPreferencesUpdate?: (prefs: UserPreferences) => Promise; } -function UsageCalendarComponent({ usageData, onDataUpdate, religion, onReligionUpdate, preferences, onPreferencesUpdate }: UsageCalendarProps) { +function UsageCalendarComponent({ usageData, onDataUpdate, preferences, onPreferencesUpdate }: UsageCalendarProps) { const [selectedDate, setSelectedDate] = useState(undefined); const [editNicotineCount, setEditNicotineCount] = useState(''); const [editWeedCount, setEditWeedCount] = useState(''); @@ -258,9 +255,9 @@ function UsageCalendarComponent({ usageData, onDataUpdate, religion, onReligionU
-
- {/* Calendar - Give it proper width on desktop */} -
+
+ {/* Calendar */} +
- - {/* Desktop Vertical Divider */} -
- - {/* Daily Inspiration - Matching width on desktop */} -
- -
diff --git a/src/components/UserHeader.tsx b/src/components/UserHeader.tsx index 40b5521..006f6b3 100644 --- a/src/components/UserHeader.tsx +++ b/src/components/UserHeader.tsx @@ -25,13 +25,19 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Button } from '@/components/ui/button'; import { User } from '@/lib/session'; -import { fetchPreferences, fetchReminderSettings, saveReminderSettings, ReminderSettings, UserPreferences } from '@/lib/storage'; +import { + fetchPreferences, + fetchReminderSettings, + saveReminderSettings, + ReminderSettings, + UserPreferences, + defaultReminderSettings, +} from '@/lib/storage'; import { useNotifications } from '@/hooks/useNotifications'; import { useRef, useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; -import { Cigarette, Leaf, LogOut, Home, ChevronDown, Sun, Moon, Bell, BellOff, BellRing, Menu, Sparkles, Link as LinkIcon } from 'lucide-react'; +import { Bell, BellOff, BellRing, Menu, Sparkles } from 'lucide-react'; import { useTheme } from '@/lib/theme-context'; -import { InstallAppButton } from './InstallAppButton'; import { cn } from '@/lib/utils'; import { SideMenu } from './SideMenu'; @@ -131,7 +137,7 @@ export function UserHeader({ user, preferences, onModalStateChange }: UserHeader const [localTime, setLocalTime] = useState('09:00'); const [localFrequency, setLocalFrequency] = useState<'daily' | 'hourly'>('daily'); const router = useRouter(); - const { theme, toggleTheme } = useTheme(); + const { theme } = useTheme(); const { isSupported, permission, requestPermission } = useNotifications(reminderSettings); const videoRef = useRef(null); const [isVideoLoaded, setIsVideoLoaded] = useState(false); @@ -222,26 +228,33 @@ export function UserHeader({ user, preferences, onModalStateChange }: UserHeader useEffect(() => { const loadData = async () => { - const [prefs, reminders] = await Promise.all([ - preferences ? Promise.resolve(preferences) : fetchPreferences(), - fetchReminderSettings(), - ]); + try { + const [prefs, reminders] = await Promise.all([ + preferences ? Promise.resolve(preferences) : fetchPreferences(), + fetchReminderSettings(), + ]); - if (prefs) { - setUserName(prefs.userName); + if (prefs) { + setUserName(prefs.userName); + } + + const detectedTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + let settingsToUse = reminders; + + if (reminders.timezone !== detectedTimezone) { + settingsToUse = { ...reminders, timezone: detectedTimezone }; + await saveReminderSettings(settingsToUse); + } + + setReminderSettings(settingsToUse); + setLocalTime(settingsToUse.reminderTime); + setLocalFrequency(settingsToUse.frequency || 'daily'); + } catch (error) { + console.error('Failed to load header data:', error); + setReminderSettings(defaultReminderSettings); + setLocalTime(defaultReminderSettings.reminderTime); + setLocalFrequency(defaultReminderSettings.frequency); } - - const detectedTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; - let settingsToUse = reminders; - - if (reminders.timezone !== detectedTimezone) { - settingsToUse = { ...reminders, timezone: detectedTimezone }; - await saveReminderSettings(settingsToUse); - } - - setReminderSettings(settingsToUse); - setLocalTime(settingsToUse.reminderTime); - setLocalFrequency(settingsToUse.frequency || 'daily'); }; loadData(); }, [preferences]); @@ -379,27 +392,9 @@ export function UserHeader({ user, preferences, onModalStateChange }: UserHeader
-
- {/* LEFT: User Profile / Side Menu Trigger */} -
- -
+
+ {/* LEFT: Spacer for balanced centered title */} +
{/* CENTER: Title and Welcome Message */}
@@ -422,33 +417,20 @@ export function UserHeader({ user, preferences, onModalStateChange }: UserHeader )}
- {/* RIGHT: Action Buttons */} -
+ {/* RIGHT: User Profile / Side Menu Trigger */} +
- -
@@ -460,6 +442,7 @@ export function UserHeader({ user, preferences, onModalStateChange }: UserHeader onClose={() => setIsSideMenuOpen(false)} user={user} userName={userName} + onOpenNotifications={() => setShowReminderDialog(true)} /> {/* Reminder Settings Dialog */} @@ -606,9 +589,7 @@ export function UserHeader({ user, preferences, onModalStateChange }: UserHeader { - const [h, m] = newTime.split(':'); - const end = (reminderSettings.hourlyEnd || '21:00').split(':'); - const newSettings = { ...reminderSettings, hourlyStart: newTime, hourlyEnd: `${end[0]}:${m}` }; + const newSettings = { ...reminderSettings, hourlyStart: newTime }; setReminderSettings(newSettings); await saveReminderSettings(newSettings); }} @@ -620,9 +601,7 @@ export function UserHeader({ user, preferences, onModalStateChange }: UserHeader { - const [h, m] = newTime.split(':'); - const start = (reminderSettings.hourlyStart || '09:00').split(':'); - const newSettings = { ...reminderSettings, hourlyEnd: newTime, hourlyStart: `${start[0]}:${m}` }; + const newSettings = { ...reminderSettings, hourlyEnd: newTime }; setReminderSettings(newSettings); await saveReminderSettings(newSettings); }} diff --git a/src/components/landing/DemoSection.tsx b/src/components/landing/DemoSection.tsx index be24214..116fb2b 100644 --- a/src/components/landing/DemoSection.tsx +++ b/src/components/landing/DemoSection.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect, useRef, type ReactNode } from 'react'; +import { useState, useEffect, useRef, type KeyboardEvent, type ReactNode } from 'react'; import { Cigarette, Leaf, Heart, Trophy, DollarSign, TrendingDown, CheckCircle, type LucideIcon } from 'lucide-react'; import { cn } from '@/lib/utils'; @@ -169,6 +169,8 @@ const DEMO_SCREENS: DemoScreen[] = [ // Phone mockup component function PhoneMockup({ activeScreen }: { activeScreen: number }) { + const active = DEMO_SCREENS[activeScreen]; + return (
{/* Phone frame glow */} @@ -183,25 +185,19 @@ function PhoneMockup({ activeScreen }: { activeScreen: number }) {
{/* Screen header */}
-

{DEMO_SCREENS[activeScreen].title}

+

{active.title}

- {DEMO_SCREENS[activeScreen].subtitle} + {active.subtitle}

- {/* Crossfade content */} -
- {DEMO_SCREENS.map((screen, index) => ( -
- {screen.content} -
- ))} +
+ {active.content}
@@ -222,7 +218,7 @@ function FeatureCard({ screen, isActive }: { screen: DemoScreen; isActive: boole "p-6 sm:p-8 rounded-2xl transition-all duration-500 motion-reduce:transition-none", isActive ? "bg-primary/10 border border-primary/30 scale-100 opacity-100" - : "bg-white/5 border border-white/10 scale-[0.98] opacity-60" + : "bg-card/60 border border-border scale-[0.98] opacity-70" )} >
@@ -247,19 +243,48 @@ export function DemoSection() { const [activeScreen, setActiveScreen] = useState(0); const [isPaused, setIsPaused] = useState(false); const sectionRefs = useRef<(HTMLDivElement | null)[]>([]); + const resumeTimerRef = useRef(null); - // Timer-based rotation for mobile + const handleManualSelect = (index: number) => { + setActiveScreen(index); + setIsPaused(true); + + if (resumeTimerRef.current) { + window.clearTimeout(resumeTimerRef.current); + } + + resumeTimerRef.current = window.setTimeout(() => { + setIsPaused(false); + }, 8000); + }; + + // Timer-based rotation for mobile only useEffect(() => { if (isPaused) return; - // Only run timer on mobile (will be hidden on lg+) + const mobileMedia = window.matchMedia('(max-width: 1023px)'); + const reducedMotionMedia = window.matchMedia('(prefers-reduced-motion: reduce)'); + + if (!mobileMedia.matches || reducedMotionMedia.matches) { + return; + } + const interval = setInterval(() => { + if (document.hidden) return; setActiveScreen((prev) => (prev + 1) % DEMO_SCREENS.length); }, 4000); return () => clearInterval(interval); }, [isPaused]); + useEffect(() => { + return () => { + if (resumeTimerRef.current) { + window.clearTimeout(resumeTimerRef.current); + } + }; + }, []); + // Intersection Observer for desktop scroll spy useEffect(() => { const observers: IntersectionObserver[] = []; @@ -271,6 +296,7 @@ export function DemoSection() { (entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { + if (!window.matchMedia('(min-width: 1024px)').matches) return; setActiveScreen(index); } }); @@ -317,6 +343,7 @@ export function DemoSection() {

A quick look at how QuitTraq helps you track progress and stay motivated.

+

Preview uses sample data for demonstration.

{/* Desktop: Split Sticky Scroll Layout */} @@ -333,9 +360,9 @@ export function DemoSection() { key={screen.id} ref={(el) => { sectionRefs.current[index] = el; }} className={cn( - "min-h-[70vh] flex items-center py-8", + "min-h-[52vh] flex items-center py-6", index === 0 && "pt-0", - index === DEMO_SCREENS.length - 1 && "min-h-[50vh]" + index === DEMO_SCREENS.length - 1 && "min-h-[40vh]" )} > @@ -362,15 +389,29 @@ export function DemoSection() {
{/* Screen descriptions as buttons */} - {DEMO_SCREENS.map((screen, index) => ( +
+ {DEMO_SCREENS.map((screen, index) => ( - ))} + ))} +
{/* Dot indicators */} @@ -391,12 +433,13 @@ export function DemoSection() { {DEMO_SCREENS.map((_, index) => (