Compare commits

...

31 Commits

Author SHA1 Message Date
Avery Felts
95e60594e8 fix mobile pwa header video startup and slide overflow 2026-02-24 02:40:36 -07:00
Avery Felts
e8f47993a9 fix desktop dashboard regressions and one-time v1.1 release notes 2026-02-24 02:31:04 -07:00
Avery Felts
e5b3f649be changes 2026-02-24 02:03:27 -07:00
Avery Felts
c31f8d8cfe Document local Cloudflare dev workflow and standardize local port
Adds a reusable DEV_SETUP guide, prevents accidental secret commits with .dev.vars ignore, sets worker dev to port 3000 to avoid local tool conflicts, and includes a compatibility route for /logo-black.png.
2026-02-24 00:26:24 -07:00
Avery Felts
80917efa8f Fix auth error handling and revert debug UI 2026-02-02 01:10:36 -07:00
Avery Felts
730ed1a76a feat: add headless email auth, account settings, and push notification cleanup 2026-02-02 00:15:31 -07:00
Avery Felts
371c1e4618 Restructure desktop dashboard layout
- Separate desktop and mobile layouts completely
- Desktop: Mood+Plan, Calendar, Achievements+Health, Savings+Stats
- Mobile: Keep original 4-slide swipe layout unchanged
2026-02-01 22:41:33 -07:00
Avery Felts
d957f7525f Add GitHub/Email/Phone auth, fix smoking aids borders, improve desktop layout
- Add GitHub OAuth, email, and phone login buttons to login page
- Update workos.ts with GitHubOAuth provider and getAuthKitUrl function
- Update /api/auth/login route to support new auth methods
- Remove colored borders from SmokingAidsContent cards
- Fix calendar/quote desktop layout with balanced 50/50 widths
- Add h-full to MoodTracker for proper height alignment
- Add compressed icon copies for PWA
2026-02-01 22:28:57 -07:00
Avery Felts
805508a413 Refactor UserHeader for transparent background and simplified styling 2026-02-01 12:30:26 -07:00
Avery Felts
7ee0aff52f Fix usage timestamp persistence and clean up debug code 2026-02-01 12:09:42 -07:00
Avery Felts
711b5d838a feat: v1.0 polish - new icon, optimized video, better buttons, and updated messaging
- Replaced app icon with new cleaner design and added PWA cache busting
- Optimized background video for better mobile performance (reduced blur/opacity)
- Increased button touch targets for better mobile accessibility
- Updated Dashboard messages and graph labels to be more supportive/less celebratory of usage
- Updated VersionUpdateModal to include fresh look announcement
2026-02-01 02:31:58 -07:00
Avery Felts
4e8fe2a91c feat: security hardening and v1.0 update modal
- Implemented HMAC-signed session cookies for enhanced security
- Added robust input validation for usage, preferences, and mood APIs
- Added VersionUpdateModal to announce v1.0 features
- Integrated update modal into Dashboard
2026-02-01 01:55:53 -07:00
Avery Felts
42841f665c UI: Remove live preview from login page and center login card 2026-01-31 20:12:35 -07:00
Avery Felts
834524bece Routing: Default route now points to /home landing page 2026-01-31 20:10:19 -07:00
Avery Felts
d166e92f8c UI: Hardware-aware video reveal with click-to-play bridge for iPhone XR 2026-01-31 19:59:43 -07:00
Avery Felts
3c3f803a1c UI: Defeat iOS play HUD with Hybrid Poster and nuclear CSS suppression 2026-01-31 19:56:36 -07:00
Avery Felts
5f87c79b58 UI: Final surgical fix for iPhone XR - 480p Baseline 3.0, CSS filter removal, and structure repair 2026-01-31 19:54:10 -07:00
Avery Felts
c3f9a4fc9a UI: Final legacy hardware fix - 720p optimization and leninent reveal logic for iPhone XR 2026-01-31 19:50:52 -07:00
Avery Felts
2eb8d25a06 UI: Ensure video poster visibility on mobile and fix header blackout 2026-01-31 19:49:55 -07:00
Avery Felts
242b098292 UI: Robust fix for iOS PWA play HUD - Ghosting logic and comprehensive media control suppression 2026-01-31 19:47:15 -07:00
bd68c1bbed Merge pull request 'feat/landingpage' (#1) from feat/landingpage into main
Reviewed-on: #1
2026-02-01 02:43:25 +00:00
Avery Felts
b46c220027 UI: Final surgical fix for iOS PWA play button - baseline encoding & CSS HUD override 2026-01-31 19:43:03 -07:00
Avery Felts
5795fd0468 UI: Critical fix for PWA/Safari video playback - programmatic initialization and 30fps optimization 2026-01-31 19:39:39 -07:00
Avery Felts
485f7a1e32 UI: Final fix for video delay - aggressive keyframes, poster frame, and smooth fade-in 2026-01-31 19:34:47 -07:00
Avery Felts
60ca78b9d8 UI: Refactor header layers for instant video visibility and fix layering issues 2026-01-31 19:30:07 -07:00
Avery Felts
27854f8a10 UI: Final fix for 'stuck' video - add React force-play and re-encode for speed 2026-01-31 19:25:11 -07:00
Avery Felts
468f02fbc8 UI: Trim first 3s of smoke video to bypass static/stuck segment 2026-01-31 19:22:01 -07:00
Avery Felts
31addc066e UI: Re-convert video to 8-bit yuv420p for instant web playback 2026-01-31 19:19:15 -07:00
Avery Felts
95cfdabe36 UI: Focus smoke video on bottom crop and optimize for faster start 2026-01-31 19:16:27 -07:00
Avery Felts
d6b191a201 UI: Remove legacy cloudy SVG mask to reveal video background 2026-01-31 19:13:50 -07:00
Avery Felts
2a0f162ea3 UI: Add cinematic smoke video background to header 2026-01-31 19:10:10 -07:00
59 changed files with 25048 additions and 1016 deletions

6
.gitignore vendored
View File

@ -33,6 +33,8 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
.dev.vars
.dev.vars.*
# vercel
.vercel
@ -49,3 +51,7 @@ next-env.d.ts
.open-next/
.open-next 2/
.wrangler/
# local editor / temp
.idea/
.vscode/

116
DEV_SETUP.md Normal file
View File

@ -0,0 +1,116 @@
# Dev Setup Notes (Local)
This file captures the local development setup used in this session so future work can resume quickly.
## Project Location
- Main working copy: `/Users/averyfelts/Desktop/DevSmokeWebsite`
- Old typo folder exists but unused: `/Users/averyfelts/Desktop/DevSmokWebsite`
## Runtime Choice
- Use Cloudflare Worker dev flow (not plain `next dev`) for D1-backed features.
- Dev server is pinned to port `3000` to avoid conflicts with Signet tooling.
## Commands
From `/Users/averyfelts/Desktop/DevSmokeWebsite`:
```bash
bun install
bun run d1:migrate
bun run build:worker
bun run dev:worker
```
Expected URL:
- `http://localhost:3000`
## Key Local Changes Made
1. `package.json`
- Updated script:
- `dev:worker` from `wrangler dev` to `wrangler dev --port 3000`
2. `.gitignore`
- Added `.dev.vars` so local worker env secrets are not tracked.
3. `src/app/logo-black.png/route.ts`
- Added compatibility route to stop `GET /logo-black.png 404`.
- Route redirects `/logo-black.png` to `/icons/icon-192.png`.
## Env File Notes
- Local app env file: `.env.local`
- Local wrangler env file: `.dev.vars`
- Both are intended for local development.
- Keep secrets out of git.
## Cloudflare/D1
- Wrangler auth verified for this machine.
- Local D1 migrations were applied via `bun run d1:migrate`.
## Known Warnings
- Wrangler/OpenNext may print duplicate object key warnings from generated `.open-next` output.
- These warnings did not block startup in this session.
## Session Preferences to Preserve
- 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`.

View File

@ -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.

View File

@ -31,6 +31,7 @@
"styled-jsx": "^5.1.6",
"tailwind-merge": "^3.4.0",
"web-push": "^3.6.7",
"yaml": "^2.8.2",
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250121.0",

View File

@ -0,0 +1 @@
ALTER TABLE UserPreferences ADD COLUMN lastSeenReleaseNotesVersion TEXT;

22092
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -9,7 +9,7 @@
"lint": "eslint",
"postinstall": "prisma generate",
"build:worker": "opennextjs-cloudflare build",
"dev:worker": "wrangler dev",
"dev:worker": "wrangler dev --port 3000",
"deploy:app": "bun run build:worker && wrangler deploy",
"deploy:cron": "cd cron-worker && wrangler deploy --config wrangler.toml",
"deploy": "bun run deploy:app && bun run deploy:cron",
@ -40,9 +40,10 @@
"react-day-picker": "^9.13.0",
"react-dom": "19.2.3",
"recharts": "^3.7.0",
"styled-jsx": "^5.1.6",
"tailwind-merge": "^3.4.0",
"web-push": "^3.6.7",
"styled-jsx": "^5.1.6"
"yaml": "^2.8.2"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250121.0",
@ -66,4 +67,4 @@
"sharp",
"unrs-resolver"
]
}
}

View File

@ -11,6 +11,7 @@ CREATE TABLE "UserPreferences" (
"religion" TEXT,
"lastNicotineUsageTime" TEXT,
"lastWeedUsageTime" TEXT,
"lastSeenReleaseNotesVersion" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"quitPlanJson" TEXT
@ -79,4 +80,3 @@ CREATE UNIQUE INDEX "ReminderSettings_userId_key" ON "ReminderSettings"("userId"
-- CreateIndex
CREATE UNIQUE INDEX "SavingsConfig_userId_key" ON "SavingsConfig"("userId");

View File

@ -24,6 +24,7 @@ model UserPreferences {
religion String?
lastNicotineUsageTime String?
lastWeedUsageTime String?
lastSeenReleaseNotesVersion String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 413 KiB

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

BIN
public/icons/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 413 KiB

After

Width:  |  Height:  |  Size: 364 KiB

View File

@ -9,13 +9,13 @@
"orientation": "portrait-primary",
"icons": [
{
"src": "/icons/icon-192.png",
"src": "/icons/icon-192.png?v=2",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-512.png",
"src": "/icons/icon-512.png?v=2",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

BIN
public/videos/smoke.mp4 Normal file

Binary file not shown.

View File

@ -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<string>();
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 });

View File

@ -0,0 +1,90 @@
import { NextRequest, NextResponse } from 'next/server';
import { workos, clientId } from '@/lib/workos';
import { setSession } from '@/lib/session';
export async function POST(request: NextRequest) {
try {
const body = await request.json() as {
email?: string;
password?: string;
firstName?: string;
lastName?: string;
type?: 'signup' | 'login';
stayLoggedIn?: boolean;
};
const { email, password, firstName, lastName, type, stayLoggedIn } = body;
if (!email || !password) {
return NextResponse.json({ error: 'Email and password are required' }, { status: 400 });
}
// Handle Signup
if (type === 'signup') {
try {
const newUser = await workos.userManagement.createUser({
email,
password,
firstName,
lastName,
emailVerified: false,
});
// Return early for signup to allow verification
return NextResponse.json({
success: true,
pendingVerification: true,
userId: newUser.id,
});
} catch (error: any) {
// Handle user already exists
if (error.code === 'user_already_exists' ||
(error.code === 'user_creation_error' && error.errors?.some((e: any) => e.code === 'email_not_available'))) {
return NextResponse.json({ error: 'A user with this email already exists. Please sign in.' }, { status: 409 });
}
throw error;
}
}
// Authenticate (Login)
try {
const response = await workos.userManagement.authenticateWithPassword({
email,
password,
clientId,
});
const user = response.user;
// Create session
await setSession({
user: {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
profilePictureUrl: user.profilePictureUrl,
},
accessToken: response.accessToken, // Note: SDK returns camelCase
refreshToken: response.refreshToken,
stayLoggedIn: !!stayLoggedIn,
});
return NextResponse.json({ success: true, user });
} catch (error: any) {
console.error('Authentication error:', error);
// Map WorkOS errors to user-friendly messages
if (error.code === 'invalid_credentials' || error.message?.includes('Invalid credentials')) {
return NextResponse.json({ error: 'Invalid email or password.' }, { status: 401 });
}
return NextResponse.json({ error: error.message || 'Authentication failed' }, { status: 400 });
}
} catch (error: any) {
console.error('Login API error:', error);
return NextResponse.json({ error: 'An internal error occurred.' }, { status: 500 });
}
}

View File

@ -1,26 +1,52 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAuthorizationUrl } from '@/lib/workos';
import { getAuthorizationUrl, getAuthKitUrl, OAuthProvider } from '@/lib/workos';
import { cookies } from 'next/headers';
const VALID_PROVIDERS = ['GoogleOAuth', 'AppleOAuth', 'GitHubOAuth'];
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const provider = searchParams.get('provider') as 'GoogleOAuth' | 'AppleOAuth';
const stayLoggedIn = searchParams.get('stayLoggedIn') === 'true';
try {
const searchParams = request.nextUrl.searchParams;
const provider = searchParams.get('provider') as OAuthProvider | 'authkit' | null;
const stayLoggedIn = searchParams.get('stayLoggedIn') === 'true';
if (!provider || !['GoogleOAuth', 'AppleOAuth'].includes(provider)) {
return NextResponse.json({ error: 'Invalid provider' }, { status: 400 });
// Store the stay logged in preference in a cookie
const cookieStore = await cookies();
cookieStore.set('stay_logged_in', stayLoggedIn.toString(), {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 10, // 10 minutes for the auth flow
path: '/',
});
// If no provider or 'authkit', redirect to hosted AuthKit (email/password, phone)
if (!provider || provider === 'authkit') {
const authUrl = getAuthKitUrl();
console.log('AuthKit URL generated:', authUrl);
if (!authUrl || typeof authUrl !== 'string') {
return NextResponse.json({
error: 'Failed to generate AuthKit URL',
debug: { authUrl, hasClientId: !!process.env.WORKOS_CLIENT_ID, hasRedirectUri: !!process.env.WORKOS_REDIRECT_URI }
}, { status: 500 });
}
return NextResponse.redirect(authUrl);
}
// Validate OAuth provider
if (!VALID_PROVIDERS.includes(provider)) {
return NextResponse.json({ error: 'Invalid provider' }, { status: 400 });
}
const authUrl = getAuthorizationUrl(provider);
return NextResponse.redirect(authUrl);
} catch (error) {
console.error('Login route error:', error);
return NextResponse.json({
error: 'Authentication error',
message: String(error)
}, { status: 500 });
}
// Store the stay logged in preference in a cookie
const cookieStore = await cookies();
cookieStore.set('stay_logged_in', stayLoggedIn.toString(), {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 10, // 10 minutes for the auth flow
path: '/',
});
const authUrl = getAuthorizationUrl(provider);
return NextResponse.redirect(authUrl);
}

View File

@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from 'next/server';
import { getSession } from '@/lib/session';
import { workos } from '@/lib/workos';
export async function POST(request: NextRequest) {
try {
const body = await request.json().catch(() => ({})) as { email?: string };
const session = await getSession();
const email = (body.email || session?.user?.email || '').trim().toLowerCase();
if (!email) {
return NextResponse.json({ error: 'Email is required.' }, { status: 400 });
}
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 NextResponse.json({
success: true,
message: 'If an account exists for this email, a password reset link has been sent.'
});
} catch (error) {
console.error('Password reset error:', error);
return NextResponse.json({
error: 'An error occurred. Please try again.'
}, { status: 500 });
}
}

View File

@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from 'next/server';
import { workos, clientId } from '@/lib/workos';
import { setSession } from '@/lib/session';
export async function POST(request: NextRequest) {
try {
const body = await request.json() as {
userId?: string;
code?: string;
email?: string;
password?: string;
stayLoggedIn?: boolean;
};
const { userId, code, email, password, stayLoggedIn } = body;
if (!userId || !code || !email || !password) {
return NextResponse.json({ error: 'Missing verification details' }, { status: 400 });
}
// 1. Verify the email with the code
try {
await workos.userManagement.verifyEmail({
code,
userId,
});
} catch (error: any) {
console.error('Verification error:', error);
return NextResponse.json({
error: error.message || 'Invalid verification code'
}, { status: 400 });
}
// 2. If verification successful, authenticate the user to create a session
try {
const response = await workos.userManagement.authenticateWithPassword({
email,
password,
clientId,
});
const user = response.user;
await setSession({
user: {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
profilePictureUrl: user.profilePictureUrl,
},
accessToken: response.accessToken,
refreshToken: response.refreshToken,
stayLoggedIn: !!stayLoggedIn,
});
return NextResponse.json({ success: true, user });
} catch (error: any) {
console.error('Post-verification auth error:', error);
return NextResponse.json({
error: 'Verification successful, but login failed. Please try logging in.'
}, { status: 400 });
}
} catch (error: any) {
console.error('Verify API error:', error);
return NextResponse.json({ error: 'An internal error occurred.' }, { status: 500 });
}
}

View File

@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import webPush from 'web-push';
import { getUsersForRemindersD1, updateLastNotifiedD1 } from '@/lib/d1';
import { getUsersForRemindersD1, updateLastNotifiedD1, deletePushSubscriptionD1 } from '@/lib/d1';
// Configure web-push - Helper called inside handler to ensure env is ready
function ensureVapidConfig() {
@ -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);
@ -207,8 +225,21 @@ export async function GET(request: NextRequest) {
} catch (err) {
console.error(`Failed to process user ${user.userId}:`, err);
processed.push({ userId: user.userId, status: 'error', error: String(err) });
// If 410 Gone, we should delete subscription, but for now just log
// Check for 410 Gone - subscription expired, delete from DB
const errorStr = String(err);
if (errorStr.includes('410') || errorStr.includes('Gone') || errorStr.includes('expired')) {
console.log(`Subscription expired for user ${user.userId}, cleaning up...`);
try {
await deletePushSubscriptionD1(user.userId);
processed.push({ userId: user.userId, status: 'expired_cleaned', error: 'Subscription expired and removed' });
} catch (deleteErr) {
console.error(`Failed to delete expired subscription for ${user.userId}:`, deleteErr);
processed.push({ userId: user.userId, status: 'error', error: String(err) });
}
} else {
processed.push({ userId: user.userId, status: 'error', error: String(err) });
}
}
}

View File

@ -101,10 +101,18 @@ export async function POST(request: NextRequest) {
const body = await request.json() as { mood: 'amazing' | 'good' | 'neutral' | 'bad' | 'terrible'; score: number; comment?: string; date?: string };
const { mood, score, comment, date } = body;
if (!mood) {
if (!mood || !['amazing', 'good', 'neutral', 'bad', 'terrible'].includes(mood)) {
return NextResponse.json({ error: 'Invalid mood' }, { status: 400 });
}
if (comment && (typeof comment !== 'string' || comment.length > 500)) {
return NextResponse.json({ error: 'Comment too long' }, { status: 400 });
}
if (date && !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
return NextResponse.json({ error: 'Invalid date format' }, { status: 400 });
}
// Validate score is between 0 and 100
const finalScore = Math.max(0, Math.min(100, score ?? 50));

View File

@ -20,6 +20,10 @@ export async function GET() {
quitPlan: null,
userName: null,
userAge: null,
religion: null,
lastNicotineUsageTime: null,
lastWeedUsageTime: null,
lastSeenReleaseNotesVersion: null,
});
}
@ -43,6 +47,7 @@ export async function GET() {
religion: preferences.religion,
lastNicotineUsageTime: preferences.lastNicotineUsageTime,
lastWeedUsageTime: preferences.lastWeedUsageTime,
lastSeenReleaseNotesVersion: preferences.lastSeenReleaseNotesVersion,
});
} catch (error) {
console.error('Error fetching preferences:', error);
@ -69,8 +74,38 @@ export async function POST(request: NextRequest) {
religion?: string;
lastNicotineUsageTime?: string;
lastWeedUsageTime?: string;
lastSeenReleaseNotesVersion?: string;
};
// Validation & Normalization
if (body.substance && !['nicotine', 'weed'].includes(body.substance)) {
return NextResponse.json({ error: 'Invalid substance' }, { status: 400 });
}
if (body.trackingStartDate && !/^\d{4}-\d{2}-\d{2}$/.test(body.trackingStartDate)) {
return NextResponse.json({ error: 'Invalid trackingStartDate format' }, { status: 400 });
}
// Loose type checking for numbers (allow strings that parse to numbers)
const dailyGoal = Number(body.dailyGoal);
if (body.dailyGoal !== undefined && body.dailyGoal !== null && (isNaN(dailyGoal) || dailyGoal < 0)) {
return NextResponse.json({ error: 'Invalid dailyGoal' }, { status: 400 });
}
const userAge = Number(body.userAge);
if (body.userAge !== undefined && body.userAge !== null && (isNaN(userAge) || userAge < 0 || userAge > 120)) {
return NextResponse.json({ error: 'Invalid userAge' }, { status: 400 });
}
if (body.religion && !['christian', 'secular'].includes(body.religion)) {
return NextResponse.json({ error: 'Invalid religion' }, { status: 400 });
}
if (body.lastSeenReleaseNotesVersion !== undefined && body.lastSeenReleaseNotesVersion !== null) {
if (typeof body.lastSeenReleaseNotesVersion !== 'string' || body.lastSeenReleaseNotesVersion.length > 32) {
return NextResponse.json({ error: 'Invalid lastSeenReleaseNotesVersion' }, { status: 400 });
}
}
// If quitState is provided in body, save it to quitPlanJson
const quitPlanJson = body.quitState
? JSON.stringify(body.quitState)
@ -87,6 +122,7 @@ export async function POST(request: NextRequest) {
religion: body.religion,
lastNicotineUsageTime: body.lastNicotineUsageTime,
lastWeedUsageTime: body.lastWeedUsageTime,
lastSeenReleaseNotesVersion: body.lastSeenReleaseNotesVersion,
});
if (!preferences) {
@ -113,6 +149,7 @@ export async function POST(request: NextRequest) {
religion: preferences.religion,
lastNicotineUsageTime: preferences.lastNicotineUsageTime,
lastWeedUsageTime: preferences.lastWeedUsageTime,
lastSeenReleaseNotesVersion: preferences.lastSeenReleaseNotesVersion,
});
} catch (error) {
console.error('Error saving preferences:', error);

View File

@ -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,

View File

@ -43,6 +43,17 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
// Validation
if (typeof count !== 'number' || count < 0 || !Number.isInteger(count)) {
return NextResponse.json({ error: 'Invalid count' }, { status: 400 });
}
if (!['nicotine', 'weed'].includes(substance)) {
return NextResponse.json({ error: 'Invalid substance' }, { status: 400 });
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
return NextResponse.json({ error: 'Invalid date format' }, { status: 400 });
}
// Add to existing count
const entry = await upsertUsageEntryD1(session.user.id, date, count, substance, true);
@ -75,6 +86,17 @@ export async function PUT(request: NextRequest) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
// Validation
if (typeof count !== 'number' || count < 0 || !Number.isInteger(count)) {
return NextResponse.json({ error: 'Invalid count' }, { status: 400 });
}
if (!['nicotine', 'weed'].includes(substance)) {
return NextResponse.json({ error: 'Invalid substance' }, { status: 400 });
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
return NextResponse.json({ error: 'Invalid date format' }, { status: 400 });
}
// Set the exact count (replace, not add)
const entry = await upsertUsageEntryD1(session.user.id, date, count, substance, false);

View File

@ -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-behavior: smooth;
-webkit-overflow-scrolling: touch;
touch-action: pan-x pan-y 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;
display: flex;
flex-direction: column;
justify-content: flex-start;
padding-bottom: calc(env(safe-area-inset-bottom) + 7.25rem);
overflow: visible;
}
}
@ -722,4 +697,4 @@
.animate-float {
animation: float 6s ease-in-out infinite;
}
}

View File

@ -12,7 +12,7 @@ export const metadata: Metadata = {
title: "QuitTraq",
},
icons: {
apple: "/icons/apple-touch-icon.png",
apple: "/icons/apple-touch-icon.png?v=2",
},
};

View File

@ -1,120 +1,33 @@
'use client';
import { useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { UnifiedLogin } from '@/components/UnifiedLogin';
export default function LoginPage() {
const [stayLoggedIn, setStayLoggedIn] = useState(false);
const handleLogin = (provider: 'GoogleOAuth' | 'AppleOAuth') => {
window.location.href = `/api/auth/login?provider=${provider}&stayLoggedIn=${stayLoggedIn}`;
};
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-950 dark:to-slate-900 overflow-hidden relative">
{/* Background Orbs */}
<div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-primary/10 rounded-full blur-[120px] pointer-events-none" />
<div className="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] bg-purple-500/10 rounded-full blur-[120px] pointer-events-none" />
<div className="w-full max-w-5xl grid lg:grid-cols-2 gap-8 items-center relative z-10">
{/* Left Side: Video Demo */}
<div className="hidden lg:block relative group">
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 to-purple-500/20 rounded-[2.5rem] blur-2xl group-hover:blur-3xl transition-all duration-500 opacity-50" />
<div className="relative aspect-[9/16] max-h-[80vh] mx-auto rounded-[2rem] overflow-hidden border border-white/10 shadow-2xl shadow-black/20">
<video
autoPlay
loop
muted
playsInline
className="w-full h-full object-cover"
>
<source src="/demo-video.mp4" type="video/mp4" />
</video>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent pointer-events-none" />
<div className="absolute bottom-8 left-8 right-8 text-white">
<p className="text-sm font-bold uppercase tracking-widest opacity-70 mb-2">Live Preview</p>
<h3 className="text-xl font-bold">Experience the journey</h3>
<div className="w-full max-w-md mx-auto relative z-10">
{/* Login Form */}
<Card className="bg-white/70 dark:bg-slate-900/70 backdrop-blur-xl border-white/20 dark:border-white/10 shadow-2xl rounded-[2rem] overflow-hidden">
<CardHeader className="text-center pt-8 pb-4">
<div className="w-16 h-16 bg-gradient-to-br from-primary to-purple-600 rounded-2xl mx-auto mb-6 flex items-center justify-center shadow-lg shadow-primary/20 rotate-3 group-hover:rotate-0 transition-transform">
<span className="text-2xl font-black text-white">Q</span>
</div>
</div>
</div>
{/* Right Side: Login Form */}
<div className="w-full max-w-md mx-auto">
<Card className="bg-white/70 dark:bg-slate-900/70 backdrop-blur-xl border-white/20 dark:border-white/10 shadow-2xl rounded-[2rem] overflow-hidden">
<CardHeader className="text-center pt-8 pb-4">
<div className="w-16 h-16 bg-gradient-to-br from-primary to-purple-600 rounded-2xl mx-auto mb-6 flex items-center justify-center shadow-lg shadow-primary/20 rotate-3 group-hover:rotate-0 transition-transform">
<span className="text-2xl font-black text-white">Q</span>
</div>
<CardTitle className="text-4xl font-black tracking-tight bg-gradient-to-br from-slate-900 to-slate-700 dark:from-white dark:to-slate-400 bg-clip-text text-transparent">
QuitTraq
</CardTitle>
<CardDescription className="text-base font-medium mt-2">
Your companion to a smoke-free life
</CardDescription>
</CardHeader>
<CardContent className="space-y-6 pb-8">
<div className="space-y-3">
<Button
variant="outline"
className="w-full h-14 text-base font-bold rounded-xl border-slate-200 dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800/50 hover:scale-[1.02] transition-all shadow-sm"
onClick={() => handleLogin('GoogleOAuth')}
>
<svg className="mr-3 h-5 w-5" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="currentColor"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
Continue with Google
</Button>
<Button
variant="outline"
className="w-full h-14 text-base font-bold rounded-xl border-slate-200 dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800/50 hover:scale-[1.02] transition-all shadow-sm"
onClick={() => handleLogin('AppleOAuth')}
>
<svg className="mr-3 h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" />
</svg>
Continue with Apple
</Button>
</div>
<div className="flex items-center space-x-3 bg-slate-50 dark:bg-slate-800/50 p-3 rounded-xl border border-slate-100 dark:border-slate-700/50">
<Checkbox
id="stayLoggedIn"
checked={stayLoggedIn}
onCheckedChange={(checked) => setStayLoggedIn(checked === true)}
className="w-5 h-5 rounded-md border-slate-300 dark:border-slate-700"
/>
<Label htmlFor="stayLoggedIn" className="text-sm font-medium cursor-pointer select-none opacity-80">
Keep me logged in on this device
</Label>
</div>
<div className="pt-2">
<p className="text-center text-[10px] uppercase font-bold tracking-widest text-slate-400 dark:text-slate-500 leading-relaxed max-w-[200px] mx-auto">
By continuing, you agree to our Terms & Privacy Policy
</p>
</div>
</CardContent>
</Card>
</div>
<CardTitle className="text-4xl font-black tracking-tight bg-gradient-to-br from-slate-900 to-slate-700 dark:from-white dark:to-slate-400 bg-clip-text text-transparent">
QuitTraq
</CardTitle>
<CardDescription className="text-base font-medium mt-2">
Your companion to a smoke-free life
</CardDescription>
</CardHeader>
<CardContent className="pb-8">
<UnifiedLogin />
</CardContent>
</Card>
</div>
</div>
);

View File

@ -0,0 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
const target = new URL('/icons/icon-192.png', request.url);
return NextResponse.redirect(target, 307);
}

View File

@ -6,7 +6,7 @@ export default async function Home() {
const user = await getUser();
if (!user) {
redirect('/login');
redirect('/home');
}
return <Dashboard user={user} />;

147
src/app/settings/page.tsx Normal file
View File

@ -0,0 +1,147 @@
'use client';
import { useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { ArrowLeft, Mail, Lock } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useTheme } from '@/lib/theme-context';
import { cn } from '@/lib/utils';
export default function SettingsPage() {
const router = useRouter();
const { theme } = useTheme();
const [isSendingReset, setIsSendingReset] = useState(false);
const [resetMessage, setResetMessage] = useState<string | null>(null);
const [resetError, setResetError] = useState<string | null>(null);
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 (
<div className="min-h-screen pb-24">
{/* Header */}
<div className={cn(
"sticky top-0 z-40 backdrop-blur-xl border-b",
theme === 'light'
? "bg-white/80 border-slate-200"
: "bg-slate-950/80 border-white/10"
)}>
<div className="container mx-auto px-4 h-16 flex items-center gap-4">
<Button
variant="ghost"
size="icon"
onClick={() => router.back()}
className="rounded-full"
>
<ArrowLeft className="h-5 w-5" />
</Button>
<h1 className="text-xl font-bold">Account Settings</h1>
</div>
</div>
<main className="container mx-auto px-4 py-8 max-w-2xl space-y-6">
{/* Password Reset Card */}
<Card className={cn(
"overflow-hidden",
theme === 'light'
? "bg-white border-slate-200"
: "bg-slate-900/50 border-white/10"
)}>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Lock className="h-5 w-5 text-amber-500" />
Change Password
</CardTitle>
<CardDescription>
Reset your password through our secure authentication provider.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className={cn(
"p-4 rounded-xl border",
theme === 'light'
? "bg-slate-50 border-slate-200"
: "bg-white/5 border-white/10"
)}>
<p className="text-sm text-muted-foreground">
Send a secure password reset link to your account email.
</p>
</div>
{resetMessage && (
<div className="rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-700 dark:text-emerald-300">
{resetMessage}
</div>
)}
{resetError && (
<div className="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-700 dark:text-red-300">
{resetError}
</div>
)}
<Button
onClick={handlePasswordReset}
variant="outline"
className="w-full h-12 text-base font-semibold rounded-xl"
disabled={isSendingReset}
>
<Lock className="mr-2 h-4 w-4" />
{isSendingReset ? 'Sending Reset Link...' : 'Send Password Reset Link'}
</Button>
</CardContent>
</Card>
{/* Email Info Card */}
<Card className={cn(
"overflow-hidden",
theme === 'light'
? "bg-white border-slate-200"
: "bg-slate-900/50 border-white/10"
)}>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Mail className="h-5 w-5 text-blue-500" />
Email & Account
</CardTitle>
<CardDescription>
Your authentication is managed securely through WorkOS.
</CardDescription>
</CardHeader>
<CardContent>
<p className={cn(
"text-sm",
theme === 'light' ? "text-slate-500" : "text-white/50"
)}>
To change your email address or manage connected accounts (Google, Apple, GitHub),
contact support or create a new account with your preferred email.
</p>
</CardContent>
</Card>
</main>
</div>
);
}

View File

@ -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<string, React.ElementType> = {
function AchievementsCardComponent({ achievements, substance }: AchievementsCardProps) {
const { theme } = useTheme();
const [hoveredBadge, setHoveredBadge] = useState<string | null>(null);
const unlockedBadgeIds = useMemo(() => {
return new Set(
@ -49,11 +49,9 @@ function AchievementsCardComponent({ achievements, substance }: AchievementsCard
return (
<Card
className={`backdrop-blur-xl border ${borderColor} shadow-xl drop-shadow-lg hover-lift transition-all duration-300 overflow-hidden relative`}
className={`backdrop-blur-xl border ${borderColor} shadow-xl drop-shadow-lg hover-lift transition-all duration-300 overflow-hidden relative h-full min-h-[62dvh] sm:min-h-0 flex flex-col`}
style={{ background: cardBackground }}
>
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-br from-yellow-500/10 to-transparent rounded-full -translate-y-1/2 translate-x-1/2 pointer-events-none" />
<CardHeader className="relative z-10 pb-2">
<CardTitle className="flex items-center gap-2 text-white text-shadow-sm">
<Trophy className="h-5 w-5 text-yellow-400" />
@ -61,46 +59,20 @@ function AchievementsCardComponent({ achievements, substance }: AchievementsCard
</CardTitle>
</CardHeader>
<CardContent className="relative z-10">
<div className="grid grid-cols-3 gap-3">
<CardContent className="relative z-10 flex flex-1 flex-col pb-6">
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 auto-rows-fr">
{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 (
<div
key={badge.id}
className={`relative p-3 rounded-xl text-center transition-all duration-300 cursor-pointer ${isUnlocked
? 'bg-gradient-to-br from-yellow-500/30 to-amber-600/20 border border-yellow-500/50 hover:scale-105'
className={`relative p-3 rounded-xl text-center transition-all duration-300 h-full flex flex-col justify-between ${isUnlocked
? 'bg-gradient-to-br from-yellow-500/30 to-amber-600/20 border border-yellow-500/50'
: 'bg-white/5 border border-white/10 hover:bg-white/10 hover:border-white/20'
}`}
onMouseEnter={() => setHoveredBadge(badge.id)}
onMouseLeave={() => setHoveredBadge(null)}
>
{/* Hover tooltip */}
{isHovered && (
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 z-20 w-48 p-2 bg-gray-900/95 border border-white/20 rounded-lg shadow-xl backdrop-blur-sm">
<p className="text-xs text-white font-medium mb-1">{badge.name}</p>
<p className="text-[10px] text-white/70">
{isUnlocked
? `Unlocked: ${new Date(unlockedAchievement!.unlockedAt).toLocaleDateString()}`
: badge.howToUnlock}
</p>
<div className="absolute top-full left-1/2 -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-900/95" />
</div>
)}
{!isUnlocked && (
<div className="absolute inset-0 flex items-center justify-center bg-black/20 rounded-xl pointer-events-none">
<Lock className="h-4 w-4 text-white/40" />
</div>
)}
<div
className={`mx-auto mb-1 p-2 rounded-full w-fit ${isUnlocked
? 'bg-yellow-500/30 text-yellow-300'
@ -109,18 +81,37 @@ function AchievementsCardComponent({ achievements, substance }: AchievementsCard
>
<Icon className="h-5 w-5" />
</div>
<p
className={`text-xs font-medium ${isUnlocked ? 'text-white' : 'text-white/40'
}`}
>
{badge.name}
</p>
<p className={`mt-1.5 text-[10px] leading-tight ${isUnlocked ? 'text-white/35 line-through' : 'text-white/75'}`}>
{badge.howToUnlock}
</p>
<div className="mt-2 flex items-center justify-center gap-1.5">
{isUnlocked ? (
<>
<CheckCircle2 className="h-3.5 w-3.5 text-yellow-300/90" />
<span className="text-[10px] font-semibold uppercase tracking-wide text-white/55">Completed</span>
</>
) : (
<>
<Lock className="h-3.5 w-3.5 text-white/45" />
<span className="text-[10px] font-semibold uppercase tracking-wide text-white/55">Locked</span>
</>
)}
</div>
</div>
);
})}
</div>
<div className="mt-4 text-center">
<div className="mt-auto pt-4 text-center">
<p className="text-sm text-white/70">
{unlockedBadgeIds.size} of {BADGE_DEFINITIONS.length} badges unlocked
</p>

View File

@ -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()}`
}}
>
<div className="absolute top-0 right-0 w-24 h-24 sm:w-32 sm:h-32 bg-gradient-to-br from-white/10 to-transparent rounded-full -translate-y-1/2 translate-x-1/2" />
<div className="relative z-10">
<div className="flex items-center justify-between mb-2 sm:mb-3">
<div className="flex items-center gap-2">

View File

@ -22,6 +22,7 @@ import {
SavingsConfig,
BADGE_DEFINITIONS,
BadgeDefinition,
StorageRequestError,
} from '@/lib/storage';
import { UserHeader } from './UserHeader';
import { SetupWizard } from './SetupWizard';
@ -33,10 +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';
@ -45,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<UserPreferences | null>(null);
const [usageData, setUsageData] = useState<UsageEntry[]>([]);
@ -56,6 +71,7 @@ export function Dashboard({ user }: DashboardProps) {
const [activeLoggingSubstance, setActiveLoggingSubstance] = useState<'nicotine' | 'weed' | null>(null);
const [newBadge, setNewBadge] = useState<BadgeDefinition | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
const [currentPage, setCurrentPage] = useState(0);
const [modalOpenCount, setModalOpenCount] = useState(0);
@ -63,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<HTMLElement>('.swipe-item'));
if (slides.length === 0) return;
let nearestIndex = 0;
let nearestDistance = Number.POSITIVE_INFINITY;
slides.forEach((slide, index) => {
const distance = Math.abs(container.scrollLeft - slide.offsetLeft);
if (distance < nearestDistance) {
nearestDistance = distance;
nearestIndex = index;
}
});
if (nearestIndex !== currentPage) {
setCurrentPage(nearestIndex);
}
}, [currentPage]);
const scrollToPage = (pageIndex: number) => {
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<HTMLElement>('.swipe-item'));
if (slides.length === 0) return;
const boundedPage = Math.min(Math.max(pageIndex, 0), totalPages - 1);
const targetSlide = slides[boundedPage];
container.scrollTo({
left: targetSlide?.offsetLeft ?? 0,
behavior: 'smooth'
});
};
useEffect(() => {
if (typeof window === 'undefined') return;
const savedPage = Number(localStorage.getItem('quittraq_mobile_dashboard_page'));
if (Number.isNaN(savedPage) || savedPage < 0 || savedPage >= totalPages) return;
setCurrentPage(savedPage);
const timeout = window.setTimeout(() => {
scrollToPage(savedPage);
}, 0);
return () => window.clearTimeout(timeout);
}, [totalPages]);
useEffect(() => {
if (typeof window === 'undefined') return;
localStorage.setItem('quittraq_mobile_dashboard_page', String(currentPage));
}, [currentPage]);
const loadData = useCallback(async () => {
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 (
@ -111,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;
@ -153,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();
@ -217,8 +320,17 @@ export function Dashboard({ user }: DashboardProps) {
...preferences,
[substance === 'nicotine' ? 'lastNicotineUsageTime' : 'lastWeedUsageTime']: now,
};
await savePreferencesAsync(latestPrefs);
setPreferences(latestPrefs);
// Force specific fields to be present to avoid partial update issues
// This ensures that even if preferences is stale, we explicitly set the usage time
const payload: UserPreferences = {
...latestPrefs,
lastNicotineUsageTime: substance === 'nicotine' ? now : (latestPrefs.lastNicotineUsageTime ?? null),
lastWeedUsageTime: substance === 'weed' ? now : (latestPrefs.lastWeedUsageTime ?? null),
};
await savePreferencesAsync(payload);
setPreferences(payload);
}
setActiveLoggingSubstance(null);
@ -296,11 +408,40 @@ export function Dashboard({ user }: DashboardProps) {
onModalStateChange={handleModalStateChange}
/>
<main className="container mx-auto px-4 py-4 sm:py-8 pb-4 sm:pb-8 max-w-full">
<main className="container mx-auto px-4 py-4 sm:py-8 pb-28 sm:pb-8 max-w-full">
{loadError && (
<div className={`mb-4 rounded-xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm ${theme === 'light' ? 'text-amber-800' : 'text-amber-100'}`}>
<div className="flex items-center justify-between gap-3">
<span>{loadError}</span>
<Button
variant="outline"
size="sm"
className="h-8"
onClick={async () => {
setIsLoading(true);
try {
await loadData();
} finally {
setIsLoading(false);
}
}}
>
Retry
</Button>
</div>
</div>
)}
{!preferences && !isLoading && (
<div className="rounded-2xl border border-border bg-card/70 p-6 text-center">
<p className="text-sm text-muted-foreground">Your dashboard data is unavailable right now.</p>
</div>
)}
{preferences && (
<>
{/* Floating Log Button - Simplified to toggle Picker */}
<div className={`fixed bottom-6 right-6 z-40 transition-all duration-300 ${isModalOpen ? 'opacity-0 scale-90 pointer-events-none' : 'opacity-100 scale-100'} sm:block`}>
<div className={`fixed bottom-[6.5rem] sm:bottom-6 left-1/2 -translate-x-1/2 sm:left-auto sm:translate-x-0 sm:right-6 z-40 transition-all duration-300 ${isModalOpen ? 'opacity-0 scale-90 pointer-events-none' : 'opacity-100 scale-100'} sm:block`}>
<Button
size="lg"
onClick={() => setIsSubstancePickerOpen(!isSubstancePickerOpen)}
@ -325,105 +466,22 @@ export function Dashboard({ user }: DashboardProps) {
{/* Dashboard Sections */}
<div className="space-y-6 sm:space-y-12 relative overflow-hidden">
{/* Mobile Navigation Buttons - LARGE */}
<div className="sm:hidden">
{currentPage > 0 && !isNavHidden && (
<button
onClick={() => scrollToPage(currentPage - 1)}
className="fixed left-3 top-[55%] -translate-y-1/2 z-40 p-5 rounded-full glass border border-white/20 shadow-2xl active:scale-90 transition-all duration-300 group"
style={{
background: theme === 'light' ? 'rgba(255, 255, 255, 0.85)' : 'rgba(0, 0, 0, 0.6)',
backdropFilter: 'blur(16px)',
boxShadow: '0 8px 32px rgba(0,0,0,0.2)'
}}
>
<ChevronLeft className="h-8 w-8 text-primary group-hover:scale-110" />
</button>
)}
{currentPage < 3 && !isNavHidden && (
<button
onClick={() => scrollToPage(currentPage + 1)}
className="fixed right-3 top-[55%] -translate-y-1/2 z-40 p-5 rounded-full glass border border-white/20 shadow-2xl active:scale-90 transition-all duration-300 group"
style={{
background: theme === 'light' ? 'rgba(255, 255, 255, 0.85)' : 'rgba(0, 0, 0, 0.6)',
backdropFilter: 'blur(16px)',
boxShadow: '0 8px 32px rgba(0,0,0,0.2)'
}}
>
<ChevronRight className="h-8 w-8 text-primary group-hover:scale-110" />
</button>
)}
</div>
{/* SECTION: Mobile Swipe Ecosystem */}
<div
ref={swipeContainerRef}
onScroll={handleScroll}
className="swipe-container sm:space-y-12 sm:block"
>
{/* SLIDE 1: Mindset (Mood & Personalized Plan) */}
<div className="swipe-item space-y-4">
<div className="sm:hidden flex items-center justify-between mb-2 px-1">
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Daily Mindset</h2>
</div>
<div className="space-y-4 sm:grid sm:grid-cols-2 sm:gap-6 sm:space-y-0">
<MoodTracker />
{/* Unified Quit Plan Placard */}
<UnifiedQuitPlanCard
preferences={preferences}
usageData={usageData}
onGeneratePlan={handleGeneratePlan}
refreshKey={refreshKey}
/>
</div>
{/* DESKTOP LAYOUT - Hidden on mobile */}
<div className="hidden sm:block space-y-8">
{/* Row 1: Mood + Quit Plan */}
<div className="grid grid-cols-2 gap-6">
<MoodTracker />
<UnifiedQuitPlanCard
preferences={preferences}
usageData={usageData}
onGeneratePlan={handleGeneratePlan}
refreshKey={refreshKey}
variant="desktop"
/>
</div>
{/* SLIDE 2: Stats & Recovery (Side-by-side Stats + Health) */}
<div className="swipe-item space-y-4">
<div className="sm:hidden flex items-center justify-between mb-2 px-1">
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Usage & Recovery</h2>
</div>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3 sm:gap-6">
<StatsCard key={`stats-nicotine-${refreshKey}`} usageData={usageData} substance="nicotine" />
<StatsCard key={`stats-weed-${refreshKey}`} usageData={usageData} substance="weed" />
</div>
<HealthTimelineCard
key={`health-${refreshKey}`}
usageData={usageData}
preferences={preferences}
/>
</div>
</div>
{/* SLIDE 3: Achievements & Money (Insights) */}
<div className="swipe-item space-y-4">
<div className="sm:hidden flex items-center justify-between mb-2 px-1">
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Achievements & Savings</h2>
</div>
<div className="space-y-4 sm:grid sm:grid-cols-2 sm:gap-6 sm:space-y-0">
<AchievementsCard
key={`achievements-${refreshKey}`}
achievements={achievements}
substance={preferences.substance}
/>
<SavingsTrackerCard
key={`savings-${refreshKey}`}
savingsConfig={savingsConfig}
usageData={usageData}
trackingStartDate={preferences.trackingStartDate}
onSavingsConfigChange={handleSavingsConfigChange}
onModalStateChange={handleModalStateChange}
/>
</div>
</div>
{/* SLIDE 4: Calendar */}
<div id="calendar-section" className="swipe-item">
<div className="sm:hidden flex items-center justify-between mb-2 px-1">
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Usage Calendar</h2>
</div>
{/* Row 2: Calendar/Quote */}
<div id="calendar-section">
<UsageCalendar
key={refreshKey}
usageData={usageData}
@ -435,6 +493,7 @@ export function Dashboard({ user }: DashboardProps) {
setPreferences(updatedPrefs);
await savePreferencesAsync(updatedPrefs);
}}
showInspirationPanel
preferences={preferences}
onPreferencesUpdate={async (updatedPrefs: UserPreferences) => {
await savePreferencesAsync(updatedPrefs);
@ -443,6 +502,196 @@ export function Dashboard({ user }: DashboardProps) {
/>
</div>
{/* Row 3: Achievements + Health Recovery */}
<div className="grid grid-cols-2 gap-6">
<AchievementsCard
key={`achievements-${refreshKey}`}
achievements={achievements}
substance={preferences.substance}
/>
<HealthTimelineCard
key={`health-${refreshKey}`}
usageData={usageData}
preferences={preferences}
/>
</div>
{/* Row 4: Savings + Stats */}
<div className="grid grid-cols-2 gap-6">
<SavingsTrackerCard
key={`savings-${refreshKey}`}
savingsConfig={savingsConfig}
usageData={usageData}
trackingStartDate={preferences.trackingStartDate}
onSavingsConfigChange={handleSavingsConfigChange}
onModalStateChange={handleModalStateChange}
/>
<div className="grid grid-cols-2 gap-4">
<StatsCard key={`stats-nicotine-${refreshKey}`} usageData={usageData} substance="nicotine" />
<StatsCard key={`stats-weed-${refreshKey}`} usageData={usageData} substance="weed" />
</div>
</div>
</div>
{/* MOBILE SWIPE LAYOUT - Hidden on desktop */}
<div
ref={swipeContainerRef}
id="mobile-dashboard-slides"
onScroll={handleScroll}
onKeyDown={(event) => {
if (event.key === 'ArrowRight') {
event.preventDefault();
scrollToPage(currentPage + 1);
}
if (event.key === 'ArrowLeft') {
event.preventDefault();
scrollToPage(currentPage - 1);
}
}}
className="swipe-container sm:hidden"
tabIndex={0}
role="region"
aria-label="Mobile dashboard sections"
>
{/* SLIDE 1: Mood */}
<div className="swipe-item space-y-3">
<div className="flex items-center justify-between mb-2 px-1">
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">How Are You Feeling</h2>
</div>
<div className="w-full max-w-[30rem] mx-auto space-y-3">
<MoodTracker />
<DailyInspirationCard
initialReligion={preferences.religion}
onReligionChange={async (religion) => {
const updatedPrefs = { ...preferences, religion };
setPreferences(updatedPrefs);
await savePreferencesAsync(updatedPrefs);
}}
/>
</div>
</div>
{/* SLIDE 2: Quit Plan */}
<div className="swipe-item space-y-3">
<div className="flex items-center justify-between mb-2 px-1">
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Quit Journey Plan</h2>
</div>
<div className="w-full max-w-[30rem] mx-auto">
<UnifiedQuitPlanCard
preferences={preferences}
usageData={usageData}
onGeneratePlan={handleGeneratePlan}
refreshKey={refreshKey}
variant="mobile"
/>
</div>
</div>
{/* SLIDE 3: Stats */}
<div className="swipe-item space-y-3">
<div className="flex items-center justify-between mb-2 px-1">
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Usage Stats</h2>
</div>
<div className="grid grid-cols-1 gap-3 w-full max-w-[30rem] mx-auto">
<StatsCard
key={`stats-nicotine-${refreshKey}`}
usageData={usageData}
substance="nicotine"
/>
<StatsCard
key={`stats-weed-${refreshKey}`}
usageData={usageData}
substance="weed"
/>
</div>
</div>
{/* SLIDE 4: Recovery */}
<div className="swipe-item space-y-3">
<div className="flex items-center justify-between mb-2 px-1">
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Health Recovery</h2>
</div>
<div className="w-full max-w-[30rem] mx-auto">
<HealthTimelineCard
key={`health-${refreshKey}`}
usageData={usageData}
preferences={preferences}
/>
</div>
</div>
{/* SLIDE 5: Achievements */}
<div className="swipe-item space-y-3">
<div className="flex items-center justify-between mb-2 px-1">
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Achievements</h2>
</div>
<div className="w-full max-w-[30rem] mx-auto">
<AchievementsCard
key={`achievements-${refreshKey}`}
achievements={achievements}
substance={preferences.substance}
/>
</div>
</div>
{/* SLIDE 6: Savings */}
<div className="swipe-item space-y-3">
<div className="flex items-center justify-between mb-2 px-1">
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Savings</h2>
</div>
<div className="w-full max-w-[30rem] mx-auto">
<SavingsTrackerCard
key={`savings-${refreshKey}`}
savingsConfig={savingsConfig}
usageData={usageData}
trackingStartDate={preferences.trackingStartDate}
onSavingsConfigChange={handleSavingsConfigChange}
onModalStateChange={handleModalStateChange}
/>
</div>
</div>
{/* SLIDE 7: Calendar */}
<div id="calendar-section-mobile" className="swipe-item">
<div className="flex items-center justify-between mb-2 px-1">
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Usage Calendar</h2>
</div>
<div className="w-full max-w-[30rem] mx-auto">
<UsageCalendar
key={refreshKey}
usageData={usageData}
onDataUpdate={loadData}
userId={user.id}
preferences={preferences}
onPreferencesUpdate={async (updatedPrefs: UserPreferences) => {
await savePreferencesAsync(updatedPrefs);
setPreferences(updatedPrefs);
}}
/>
</div>
</div>
</div>
<div className="sm:hidden fixed bottom-[calc(env(safe-area-inset-bottom)+0.75rem)] left-1/2 -translate-x-1/2 z-30 w-full px-3 pointer-events-none">
<div className="mx-auto max-w-sm rounded-xl border border-white/10 bg-black/25 backdrop-blur-xl px-3 py-2 pointer-events-auto">
<div className="text-[10px] uppercase tracking-[0.2em] opacity-60 text-center mb-2">
{MOBILE_SLIDES[currentPage]?.label}
</div>
<div className="flex items-center justify-center gap-2">
{MOBILE_SLIDES.map((slide, index) => (
<button
key={slide.id}
type="button"
onClick={() => scrollToPage(index)}
aria-label={`Go to ${slide.label}`}
aria-current={currentPage === index ? 'page' : undefined}
className={`h-2 rounded-full transition-all ${currentPage === index ? 'w-8 bg-primary' : 'w-2 bg-white/30'}`}
/>
))}
</div>
</div>
</div>
</div>
</>
@ -468,6 +717,19 @@ export function Dashboard({ user }: DashboardProps) {
/>
)}
<VersionUpdateModal
preferences={preferences}
onAcknowledge={async (version) => {
if (!preferences) return;
const nextPreferences = {
...preferences,
lastSeenReleaseNotesVersion: version,
};
setPreferences(nextPreferences);
await savePreferencesAsync(nextPreferences);
}}
/>
{showCelebration && newBadge && (
<CelebrationAnimation
badge={newBadge}

View File

@ -18,6 +18,7 @@ import {
Cigarette,
Leaf
} from 'lucide-react';
import { getTodayString, getLocalDateString } from '@/lib/date-utils';
interface HealthTimelineCardProps {
usageData: UsageEntry[];
@ -225,29 +226,57 @@ function HealthTimelineCardComponent({
// Calculate last usage timestamps only when data changes
const lastUsageTimes = useMemo(() => {
const getTimestamp = (substance: 'nicotine' | 'weed') => {
// 1. Check for stored timestamp first
const stored = substance === 'nicotine' ? preferences?.lastNicotineUsageTime : preferences?.lastWeedUsageTime;
if (stored) return new Date(stored).getTime();
let lastTime = 0;
// 2. Fallback to usage data
// 1. Check for stored timestamp
const stored = substance === 'nicotine' ? preferences?.lastNicotineUsageTime : preferences?.lastWeedUsageTime;
if (stored) {
lastTime = new Date(stored).getTime();
}
// 2. Check usage data (usually more up-to-date for "just logged")
const lastEntry = usageData
.filter(e => e.substance === substance && e.count > 0)
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())[0];
if (lastEntry) {
const todayStr = getTodayString();
// Calculate local midnight for today
const now = new Date();
const localMidnight = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
// If usage recorded today
if (lastEntry.date === todayStr) {
// Check if the stored timestamp belongs to today (is after midnight)
if (lastTime >= localMidnight) {
return lastTime;
}
// Fallback: If we have usage "Today" but no valid timestamp,
// we must assume it just happened or we missed the timestamp.
// Returning Date.now() resets the timer to 0.
return Date.now();
}
const d = new Date(lastEntry.date);
d.setHours(23, 59, 59, 999);
return d.getTime();
const entryTime = d.getTime();
// Take the more recent of the two
lastTime = Math.max(lastTime, entryTime);
}
// 3. Fallback to start date
if (preferences?.trackingStartDate) {
// 3. Fallback to start date if no usage found
if (lastTime === 0 && preferences?.trackingStartDate) {
const d = new Date(preferences.trackingStartDate);
d.setHours(0, 0, 0, 0);
return d.getTime();
lastTime = d.getTime();
}
return null;
return lastTime || null;
};
return {
@ -282,11 +311,9 @@ function HealthTimelineCardComponent({
return (
<Card
className="backdrop-blur-xl border border-teal-500/40 shadow-xl drop-shadow-lg hover-lift transition-all duration-300 overflow-hidden relative h-[500px] flex flex-col"
className="backdrop-blur-xl border border-teal-500/40 shadow-xl drop-shadow-lg hover-lift transition-all duration-300 overflow-hidden relative h-[min(calc(58dvh+13rem),43rem)] sm:h-[500px] flex flex-col"
style={{ background: cardBackground }}
>
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-br from-teal-500/10 to-transparent rounded-full -translate-y-1/2 translate-x-1/2 pointer-events-none" />
<CardHeader className="relative z-10 pb-4 shrink-0">
<CardTitle className={`flex items-center gap-2 ${theme === 'light' ? 'text-teal-900' : 'text-white'} text-shadow-sm`}>
<Heart className="h-5 w-5 text-teal-500" />
@ -302,6 +329,7 @@ function HealthTimelineCardComponent({
<TimelineColumn substance="nicotine" minutesFree={nicotineMinutes} theme={theme} />
<TimelineColumn substance="weed" minutesFree={weedMinutes} theme={theme} />
</div>
</CardContent>
</Card>
);

View File

@ -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<void>;
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<BeforeInstallPromptEvent | null>(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"
>
<Smartphone className="h-4 w-4 text-purple-400" />
<span className="hidden sm:inline">Add to Home Screen</span>
<span className="sm:hidden">Install</span>
<Smartphone className={cn('text-purple-400', compact ? 'h-5 w-5' : 'h-4 w-4')} />
{!compact && (
<>
<span className="hidden sm:inline">Add to Home Screen</span>
<span className="sm:hidden">Install</span>
</>
)}
</Button>
<Dialog open={showInstructions} onOpenChange={setShowInstructions}>

View File

@ -200,7 +200,7 @@ function MoodTrackerComponent() {
return (
<Card className={cn(
"overflow-hidden transition-all duration-700 ease-in-out backdrop-blur-xl border shadow-xl",
"overflow-hidden transition-all duration-700 ease-in-out backdrop-blur-xl border shadow-xl h-full",
"bg-gradient-to-br",
gradientClass
)}>

View File

@ -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 }}
>
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-br from-indigo-500/10 to-transparent rounded-full -translate-y-1/2 translate-x-1/2 pointer-events-none" />
<CardHeader className="relative z-10 pb-2">
<CardTitle className="flex items-center gap-2 text-white text-shadow-sm">
<Bell className="h-5 w-5 text-indigo-400" />

View File

@ -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 }}
>
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-br from-emerald-500/10 to-transparent rounded-full -translate-y-1/2 translate-x-1/2 pointer-events-none" />
<CardHeader className="relative z-10">
<CardTitle className="flex items-center gap-2 text-white text-shadow-sm">
<DollarSign className="h-5 w-5 text-emerald-400" />
@ -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 }}
>
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-br from-emerald-500/10 to-transparent rounded-full -translate-y-1/2 translate-x-1/2 pointer-events-none" />
<CardHeader className="relative z-10 pb-2">
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2 text-white text-shadow-sm">

View File

@ -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 */}
<div
className={cn(
"relative w-72 h-full flex flex-col shadow-2xl transition-all animate-in slide-in-from-left duration-300",
"relative ml-auto w-72 h-full flex flex-col shadow-2xl transition-all animate-in slide-in-from-right duration-300",
theme === 'light'
? "bg-white text-slate-900"
: "bg-slate-900 text-white border-r border-white/5"
? "bg-white text-slate-900 border-l border-slate-100"
: "bg-slate-900 text-white border-l border-white/5"
)}
>
{/* Header/Profile Info */}
@ -103,6 +108,59 @@ export function SideMenu({ isOpen, onClose, user, userName }: SideMenuProps) {
{/* Navigation Links */}
<div className="flex-1 overflow-y-auto py-4 px-3 space-y-1">
<div className="px-3 pb-2">
<span className="text-[10px] font-bold uppercase tracking-widest opacity-30">Quick Actions</span>
</div>
<div className="px-3 pb-3">
<div className="flex items-center justify-center gap-3">
<button
onClick={() => {
onClose();
onOpenNotifications();
}}
className={cn(
"h-12 w-12 rounded-xl border transition-all active:scale-95 flex items-center justify-center",
theme === 'light'
? "bg-slate-50 border-slate-200 hover:bg-slate-100"
: "bg-white/5 border-white/10 hover:bg-white/10"
)}
aria-label="Open notification settings"
>
<Bell className="h-5 w-5" />
</button>
<InstallAppButton
compact
onBeforeOpen={onClose}
className={cn(
theme === 'light'
? 'bg-slate-50 border-slate-200 hover:bg-slate-100'
: 'bg-white/5 border-white/10 hover:bg-white/10'
)}
/>
<button
onClick={toggleTheme}
className={cn(
"h-12 w-12 rounded-xl border transition-all active:scale-95 flex items-center justify-center",
theme === 'light'
? "bg-slate-50 border-slate-200 hover:bg-slate-100"
: "bg-white/5 border-white/10 hover:bg-white/10"
)}
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
>
{theme === 'dark' ? (
<Moon className="h-5 w-5 text-blue-300" />
) : (
<Sun className="h-5 w-5 text-amber-500" />
)}
</button>
</div>
</div>
<div className="my-2 px-3">
<span className="text-[10px] font-bold uppercase tracking-widest opacity-30">Main</span>
</div>
<MenuLink
icon={Home}
label="Dashboard"
@ -140,12 +198,18 @@ export function SideMenu({ isOpen, onClose, user, userName }: SideMenuProps) {
"p-4 border-t",
theme === 'light' ? "border-slate-100" : "border-white/5"
)}>
<MenuLink
icon={Settings}
label="Account Settings"
onClick={() => handleNavigate('/settings')}
color="blue"
/>
<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"
className="relative w-full flex items-center justify-center 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>
<LogOut className="absolute left-3 h-5 w-5" />
<span className="text-center leading-none">Sign out</span>
</button>
</div>
</div>
@ -176,20 +240,20 @@ function MenuLink({ icon: Icon, label, onClick, color }: MenuLinkProps) {
<button
onClick={onClick}
className={cn(
"w-full flex items-center gap-3 px-3 py-3 rounded-xl transition-all font-medium group",
"relative w-full flex items-center justify-center 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",
"absolute left-3 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" />
<span className="text-sm text-center leading-none">{label}</span>
<div className="absolute right-3 w-1.5 h-1.5 rounded-full bg-primary opacity-0 group-hover:opacity-100 transition-opacity" />
</button>
);
}

View File

@ -21,7 +21,7 @@ const smokingAids = [
color: 'text-sky-400',
themeColor: 'sky',
gradient: 'from-sky-500/20 to-indigo-500/20',
borderColor: 'border-sky-500/30',
borderColor: 'border-white/10',
},
{
id: 'nicotine-lozenge',
@ -36,7 +36,7 @@ const smokingAids = [
color: 'text-rose-400',
themeColor: 'rose',
gradient: 'from-rose-500/20 to-pink-500/20',
borderColor: 'border-rose-500/30',
borderColor: 'border-white/10',
},
{
id: 'recovery-complex',
@ -51,7 +51,7 @@ const smokingAids = [
color: 'text-amber-400',
themeColor: 'amber',
gradient: 'from-amber-500/20 to-orange-500/20',
borderColor: 'border-amber-500/30',
borderColor: 'border-white/10',
},
{
id: 'mullein-tea',
@ -66,7 +66,7 @@ const smokingAids = [
color: 'text-emerald-400',
themeColor: 'emerald',
gradient: 'from-emerald-500/20 to-teal-500/20',
borderColor: 'border-emerald-500/30',
borderColor: 'border-white/10',
},
];
@ -101,10 +101,9 @@ export function SmokingAidsContent() {
style={{ animationDelay: `${index * 150}ms` }}
>
<Card
className="h-full flex flex-col overflow-hidden border-border/40 transition-all duration-500 hover:shadow-[0_20px_50px_rgba(0,0,0,0.2)] dark:hover:shadow-[0_20px_50px_rgba(0,0,0,0.4)] hover:-translate-y-2 backdrop-blur-xl relative group"
className="h-full flex flex-col overflow-hidden border-0 !p-0 !bg-transparent transition-all duration-500 hover:shadow-[0_20px_50px_rgba(0,0,0,0.2)] dark:hover:shadow-[0_20px_50px_rgba(0,0,0,0.4)] hover:-translate-y-2 backdrop-blur-xl relative group rounded-2xl"
style={{
background: cardBackground,
borderColor: `rgba(var(--${item.themeColor}-500), 0.3)`
background: cardBackground
}}
>
{/* Specific card glow on hover */}

View File

@ -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 }}
>
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-br from-white/5 to-transparent rounded-full -translate-y-1/2 translate-x-1/2 pointer-events-none" />
<CardHeader className="pb-2 relative z-10">
<CardTitle className="flex items-center gap-2 text-white text-shadow-sm">
<SubstanceIcon className={`h-5 w-5 ${iconColor}`} />

View File

@ -17,12 +17,20 @@ interface SubstanceTrackingPageProps {
export function SubstanceTrackingPage({ user, substance }: SubstanceTrackingPageProps) {
const [usageData, setUsageData] = useState<UsageEntry[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(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(() => {
@ -78,23 +86,22 @@ export function SubstanceTrackingPage({ user, substance }: SubstanceTrackingPage
<div className="mb-6 sm:mb-8 text-center opacity-0 animate-fade-in delay-100">
{todayCount === 0 ? (
<p className={`text-xl sm:text-2xl font-medium ${theme === 'light' ? 'text-green-600' : 'text-green-400'}`}>
Great job, nothing yet!
0 {unitLabel} recorded, amazing job so far!
</p>
) : (
<p className={`text-xl sm:text-2xl font-medium ${theme === 'light' ? 'text-gray-900' : 'text-white'}`}>
{todayCount} {todayCount === 1 ? (substance === 'nicotine' ? 'puff' : 'hit') : unitLabel} recorded, you got this!
{todayCount} {todayCount === 1 ? (substance === 'nicotine' ? 'puff' : 'hit') : unitLabel} recorded, but don't stress.
</p>
)}
</div>
{/* Inspirational Message */}
<div className="mb-6 sm:mb-8 text-center opacity-0 animate-fade-in delay-200">
<p className={`text-lg sm:text-xl font-light italic ${theme === 'light' ? 'text-gray-500' : 'text-white/60'}`}>
&quot;One day at a time...&quot;
</p>
</div>
{/* Stats and Graph */}
{loadError && (
<div className="mb-6 rounded-xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-700 dark:text-amber-200">
{loadError}
</div>
)}
<div className="grid gap-6 md:grid-cols-2">
<div className="opacity-0 animate-fade-in-up delay-200">
<StatsCard usageData={usageData} substance={substance} />

View File

@ -0,0 +1,398 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Mail, Lock, Loader2, AlertCircle, ArrowRight } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { Checkbox } from '@/components/ui/checkbox';
interface UnifiedLoginProps {
onSuccess?: () => void;
}
export function UnifiedLogin({ onSuccess }: UnifiedLoginProps) {
const router = useRouter();
const [isSignup, setIsSignup] = useState(false);
const [showEmailForm, setShowEmailForm] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [stayLoggedIn, setStayLoggedIn] = useState(false);
const [pendingVerification, setPendingVerification] = useState(false);
const [verificationCode, setVerificationCode] = useState('');
const [userId, setUserId] = useState<string | null>(null);
const [isResettingPassword, setIsResettingPassword] = useState(false);
const [resetStatus, setResetStatus] = useState<string | null>(null);
const [formData, setFormData] = useState({
email: '',
password: '',
firstName: '',
lastName: '',
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
try {
// If we are pending verification, submit the code
if (pendingVerification) {
const res = await fetch('/api/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId,
code: verificationCode,
email: formData.email,
password: formData.password,
stayLoggedIn,
}),
});
const data = await res.json() as { error?: string };
if (!res.ok) {
throw new Error(data.error || 'Verification failed');
}
// Success
if (onSuccess) onSuccess();
router.push('/home');
router.refresh();
return;
}
// Normal login / signup initiation
const res = await fetch('/api/auth/email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...formData,
type: isSignup ? 'signup' : 'login',
stayLoggedIn,
}),
});
const data = await res.json() as { error?: string, success?: boolean, pendingVerification?: boolean, userId?: string };
if (!res.ok) {
throw new Error(data.error || 'Authentication failed');
}
// Check if we need verification
if (data.pendingVerification) {
setPendingVerification(true);
setUserId(data.userId || null);
setLoading(false); // Stop loading so user can enter code
return;
}
// Success (Direct login)
if (onSuccess) onSuccess();
router.push('/home');
router.refresh();
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
const handleOAuthLogin = (provider: string) => {
window.location.href = `/api/auth/login?provider=${provider}&stayLoggedIn=${stayLoggedIn}`;
};
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
const StayLoggedInCheckbox = () => (
<div className="flex items-center space-x-3 bg-slate-50 dark:bg-slate-800/50 p-3 rounded-xl border border-slate-100 dark:border-slate-700/50">
<Checkbox
id="stayLoggedIn"
checked={stayLoggedIn}
onCheckedChange={(checked) => setStayLoggedIn(checked === true)}
className="w-5 h-5 rounded-md border-slate-300 dark:border-slate-700"
/>
<Label htmlFor="stayLoggedIn" className="text-sm font-medium cursor-pointer select-none opacity-80">
Keep me logged in on this device
</Label>
</div>
);
if (!showEmailForm) {
return (
<div className="space-y-3 animate-in fade-in slide-in-from-bottom-4 duration-500">
<Button
variant="outline"
className="w-full h-14 text-base font-bold rounded-xl border-slate-200 dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800/50 hover:scale-[1.02] transition-all shadow-sm"
onClick={() => handleOAuthLogin('GoogleOAuth')}
>
<svg className="mr-3 h-5 w-5" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="currentColor"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
Continue with Google
</Button>
<Button
variant="outline"
className="w-full h-14 text-base font-bold rounded-xl border-slate-200 dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800/50 hover:scale-[1.02] transition-all shadow-sm"
onClick={() => handleOAuthLogin('AppleOAuth')}
>
<svg className="mr-3 h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" />
</svg>
Continue with Apple
</Button>
<Button
variant="outline"
className="w-full h-14 text-base font-bold rounded-xl border-slate-200 dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800/50 hover:scale-[1.02] transition-all shadow-sm"
onClick={() => handleOAuthLogin('GitHubOAuth')}
>
<svg className="mr-3 h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
Continue with GitHub
</Button>
{/* Divider */}
<div className="relative py-2">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-slate-200 dark:border-slate-700" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white/80 dark:bg-slate-900 px-3 text-slate-400 font-bold tracking-widest backdrop-blur-sm">or</span>
</div>
</div>
<Button
variant="outline"
className="w-full h-14 text-base font-bold rounded-xl border-slate-200 dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800/50 hover:scale-[1.02] transition-all shadow-sm"
onClick={() => setShowEmailForm(true)}
>
<Mail className="mr-3 h-5 w-5" />
Continue with Email
</Button>
<StayLoggedInCheckbox />
<div className="pt-2">
<p className="text-center text-[10px] uppercase font-bold tracking-widest text-slate-400 dark:text-slate-500 leading-relaxed max-w-[200px] mx-auto">
By continuing, you agree to our Terms & Privacy Policy
</p>
</div>
</div>
);
}
return (
<div className="space-y-4 animate-in fade-in slide-in-from-right-4 duration-300">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold text-slate-900 dark:text-white">
{isSignup ? 'Create Account' : 'Welcome Back'}
</h2>
<Button
variant="ghost"
size="sm"
onClick={() => setShowEmailForm(false)}
className="text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
>
Back
</Button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-500/30 rounded-lg flex items-center gap-3 text-red-600 dark:text-red-400 text-sm">
<AlertCircle className="h-4 w-4 shrink-0" />
{error}
</div>
)}
{isSignup && (
<div className="grid grid-cols-2 gap-3 animate-in fade-in slide-in-from-top-2 duration-300">
<div className="space-y-2">
<Label htmlFor="firstName">First Name</Label>
<Input
id="firstName"
placeholder="John"
value={formData.firstName}
onChange={(e) => setFormData({ ...formData, firstName: e.target.value })}
required={isSignup}
className="bg-slate-50 dark:bg-slate-800/50"
/>
</div>
<div className="space-y-2">
<Label htmlFor="lastName">Last Name</Label>
<Input
id="lastName"
placeholder="Doe"
value={formData.lastName}
onChange={(e) => setFormData({ ...formData, lastName: e.target.value })}
required={isSignup}
className="bg-slate-50 dark:bg-slate-800/50"
/>
</div>
</div>
)}
<div className="space-y-2">
<Label htmlFor="email">Email Address</Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
<Input
id="email"
type="email"
placeholder="name@example.com"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
required
className="pl-10 bg-slate-50 dark:bg-slate-800/50"
/>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label>
{!isSignup && !pendingVerification && (
<button
type="button"
onClick={handleForgotPassword}
className="text-xs text-primary hover:underline"
disabled={isResettingPassword}
>
{isResettingPassword ? 'Sending reset link...' : 'Forgot password?'}
</button>
)}
</div>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
<Input
id="password"
type="password"
placeholder="••••••••"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
required
minLength={8}
disabled={pendingVerification}
className="pl-10 bg-slate-50 dark:bg-slate-800/50"
/>
</div>
</div>
{pendingVerification && (
<div className="space-y-2 animate-in fade-in slide-in-from-top-2 duration-300">
<Label htmlFor="verificationCode" className="text-primary font-bold">Verification Code</Label>
<Input
id="verificationCode"
placeholder="Enter the code sent to your email"
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value)}
required
className="bg-primary/5 border-primary/20 text-lg tracking-widest text-center font-mono placeholder:font-sans placeholder:text-sm placeholder:tracking-normal"
/>
<p className="text-xs text-amber-600 dark:text-amber-400 font-medium flex items-start gap-1.5 p-2 bg-amber-50 dark:bg-amber-900/20 rounded-lg border border-amber-100 dark:border-amber-800/50">
<AlertCircle className="w-3.5 h-3.5 mt-0.5 shrink-0" />
<span>
Please check your spam/junk folder if you don't see the email in your inbox.
</span>
</p>
</div>
)}
{resetStatus && (
<div className="rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-600 dark:text-emerald-300">
{resetStatus}
</div>
)}
<StayLoggedInCheckbox />
<Button
type="submit"
disabled={loading}
className="w-full h-12 text-base font-bold rounded-xl shadow-lg shadow-primary/20"
>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{isSignup ? 'Creating Account...' : 'Signing In...'}
</>
) : (
<>
{pendingVerification ? 'Verify & Sign In' : (isSignup ? 'Create Account' : 'Sign In')}
<ArrowRight className="ml-2 h-4 w-4 group-hover:translate-x-1 transition-transform" />
</>
)}
</Button>
</form>
<div className="text-center">
<button
type="button"
onClick={toggleMode}
className="text-sm text-slate-500 dark:text-slate-400 hover:text-primary dark:hover:text-primary transition-colors font-medium"
>
{isSignup ? 'Already have an account? Sign in' : "Don't have an account? Sign up"}
</button>
</div>
</div>
);
}

View File

@ -1,10 +1,10 @@
'use client';
import React, { useState, useMemo } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { QuitPlan, UsageEntry, UserPreferences } from '@/lib/storage';
import { Target, TrendingDown, ChevronDown, ChevronUp, Cigarette, Leaf } from 'lucide-react';
import { TrendingDown, ChevronDown, ChevronUp, Cigarette, Leaf, AlertTriangle, XCircle } from 'lucide-react';
import { useTheme } from '@/lib/theme-context';
import { getTodayString } from '@/lib/date-utils';
import { cn } from '@/lib/utils';
@ -16,7 +16,7 @@ interface SubstancePlanSectionProps {
trackingStartDate: string | null;
onGeneratePlan: () => void;
isExpanded: boolean;
onToggle: () => void;
onToggle?: () => void;
}
function SubstancePlanSection({
@ -76,6 +76,7 @@ function SubstancePlanSection({
const isNicotine = substance === 'nicotine';
const Icon = isNicotine ? Cigarette : Leaf;
const label = isNicotine ? 'Nicotine' : 'Weed';
const unitLabel = isNicotine ? 'puffs' : 'hits';
// Base Colors
const bgColor = isNicotine
@ -101,7 +102,10 @@ function SubstancePlanSection({
{/* HEADER / SUMMARY ROW */}
<div
onClick={onToggle}
className="flex items-center justify-between p-4 cursor-pointer hover:bg-black/5 active:bg-black/10 transition-colors"
className={cn(
"flex items-center justify-between p-4",
onToggle && "cursor-pointer hover:bg-black/5 active:bg-black/10 transition-colors"
)}
>
<div className="flex items-center gap-3">
<div className={cn("p-2 rounded-lg", isNicotine ? "bg-yellow-500/20" : "bg-emerald-500/20")}>
@ -122,70 +126,110 @@ function SubstancePlanSection({
{todayUsage}{activePlan ? ` / ${currentTarget}` : ''}
</span>
</div>
{isExpanded ? <ChevronUp className="h-5 w-5 opacity-30" /> : <ChevronDown className="h-5 w-5 opacity-30" />}
{onToggle && (isExpanded ? <ChevronUp className="h-5 w-5 opacity-30" /> : <ChevronDown className="h-5 w-5 opacity-30" />)}
</div>
</div>
{/* EXPANDED CONTENT */}
{isExpanded && (
<div className="px-4 pb-4 animate-in slide-in-from-top-2 duration-200">
<div className="h-px w-full bg-border mb-4 opacity-30" />
<div className="h-px w-full bg-border mb-4 opacity-30" />
{!activePlan ? (
<div className="space-y-4">
<div className="bg-black/5 p-4 rounded-lg">
<div className="flex justify-between items-center mb-2">
<span className="text-xs font-medium">Weekly Baseline Progress</span>
<span className={cn("text-xs font-bold", accentColor)}>
{daysRemaining > 0 ? `${daysRemaining} days left` : 'Ready!'}
</span>
</div>
<div className="w-full bg-black/10 rounded-full h-2 overflow-hidden">
<div
className={cn("h-full transition-all duration-700", progressFill)}
style={{ width: `${Math.min(100, (uniqueDaysWithData / 7) * 100)}%` }}
/>
</div>
{!activePlan ? (
<div className="space-y-4">
<div className="bg-black/5 p-4 rounded-lg">
<div className="flex justify-between items-center mb-2">
<span className="text-xs font-medium">Weekly Baseline Progress</span>
<span className={cn("text-xs font-bold", accentColor)}>
{daysRemaining > 0 ? `${daysRemaining} days left` : 'Ready!'}
</span>
</div>
<div className="w-full bg-black/10 rounded-full h-2 overflow-hidden">
<div
className={cn("h-full transition-all duration-700", progressFill)}
style={{ width: `${Math.min(100, (uniqueDaysWithData / 7) * 100)}%` }}
/>
</div>
{isUnlocked ? (
<div className="text-center space-y-3">
<p className="text-sm">
Baseline established: <strong className={accentColor}>{currentAverage} puffs/day</strong>
</p>
<Button onClick={(e) => { e.stopPropagation(); onGeneratePlan(); }} size="sm" className={cn("w-full h-10 font-bold", progressFill, "text-white hover:opacity-90")}>
Generate Plan
</Button>
</div>
) : (
<p className="text-xs text-center opacity-70 italic">
Keep logging for {daysRemaining} more days to calculate your personalized reduction plan.
</p>
)}
</div>
) : (
<div className="space-y-6">
{isUnlocked ? (
<div className="text-center space-y-3">
<p className="text-sm">
Baseline established: <strong className={accentColor}>{currentAverage} {unitLabel}/day</strong>
</p>
<Button onClick={(e) => { e.stopPropagation(); onGeneratePlan(); }} size="sm" className={cn("w-full h-10 font-bold", progressFill, "text-white hover:opacity-90")}>
Generate Plan
</Button>
</div>
) : (
<p className="text-xs text-center opacity-70 italic">
Keep logging for {daysRemaining} more days to calculate your personalized reduction plan.
</p>
)}
</div>
) : (
<div className="space-y-6">
{/* Active Plan Detail */}
<div className="text-center">
<p className="text-[10px] font-bold uppercase opacity-50 mb-1">Current Daily Limit</p>
<p className={cn("text-4xl font-black", accentColor)}>{currentTarget}</p>
<p className="text-xs opacity-50 mt-1">puffs allowed today</p>
<p className="text-xs opacity-50 mt-1">{unitLabel} allowed today</p>
</div>
{/* Progress Bar Detail */}
<div className="space-y-1.5">
<div className="space-y-1.5 pb-5">
<div className="flex justify-between text-[10px] uppercase font-bold opacity-60">
<span>Usage Progress</span>
<span>{Math.round(usagePercent)}%</span>
</div>
<div className="w-full bg-black/10 rounded-full h-3 overflow-hidden">
<div className="relative">
<div className="w-full bg-black/10 rounded-full h-3 overflow-hidden">
<div
className={cn("h-full transition-all duration-500", progressColor)}
style={{ width: `${Math.min(100, usagePercent)}%` }}
/>
</div>
{/* Positioned puff count indicator */}
<div
className={cn("h-full transition-all duration-500", progressColor)}
style={{ width: `${Math.min(100, usagePercent)}%` }}
/>
className="absolute top-full mt-1 transition-all duration-500"
style={{
left: `${Math.min(100, usagePercent)}%`,
transform: usagePercent > 10 ? 'translateX(-100%)' : 'translateX(0)'
}}
>
<span className={cn(
"text-xs font-bold whitespace-nowrap",
todayUsage >= currentTarget ? "text-red-500" : accentColor
)}>
{todayUsage} {isNicotine ? 'puffs' : 'hits'}
</span>
</div>
</div>
</div>
{/* Warning/Exceeded Status Messages */}
{todayUsage >= currentTarget && currentTarget > 0 && (
<div className="flex items-center gap-3 p-3 rounded-lg bg-red-500/20 border border-red-500/30">
<XCircle className="h-5 w-5 text-red-500 shrink-0" />
<div>
<p className="text-sm font-bold text-red-500">Daily limit exceeded</p>
<p className="text-xs opacity-70">You've gone over today's goal. No worries try again tomorrow!</p>
</div>
</div>
)}
{todayUsage < currentTarget && currentTarget - todayUsage <= 5 && currentTarget > 0 && (
<div className="flex items-center gap-3 p-3 rounded-lg bg-orange-500/20 border border-orange-500/30">
<AlertTriangle className="h-5 w-5 text-orange-500 shrink-0" />
<div>
<p className="text-sm font-bold text-orange-500">Approaching your limit</p>
<p className="text-xs opacity-70">
Only <strong>{currentTarget - todayUsage}</strong> {isNicotine ? 'puffs' : 'hits'} remaining today. Pace yourself!
</p>
</div>
</div>
)}
{/* Weekly Matrix */}
<div className="grid grid-cols-4 gap-2">
{activePlan.weeklyTargets.map((target, idx) => {
@ -212,11 +256,11 @@ function SubstancePlanSection({
</div>
<div className="bg-black/5 p-3 rounded-lg flex justify-between items-center text-[10px] uppercase font-bold opacity-50">
<span>Start: {activePlan.baselineAverage}/day</span>
<span>Start: {activePlan.baselineAverage} {unitLabel}/day</span>
<span>End: {new Date(activePlan.endDate).toLocaleDateString()}</span>
</div>
</div>
)}
</div>
)}
</div>
)}
</div>
@ -228,38 +272,59 @@ interface UnifiedQuitPlanCardProps {
usageData: UsageEntry[];
onGeneratePlan: (substance: 'nicotine' | 'weed') => void;
refreshKey: number;
variant?: 'desktop' | 'mobile';
}
export function UnifiedQuitPlanCard({
preferences,
usageData,
onGeneratePlan,
refreshKey
refreshKey,
variant = 'mobile'
}: 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');
const isDesktopVariant = variant === 'desktop';
const showNicotine = isDesktopVariant
? (preferences.substance === 'nicotine' || usageData.some(e => e.substance === 'nicotine'))
: true;
const showWeed = isDesktopVariant
? (preferences.substance === 'weed' || usageData.some(e => e.substance === 'weed'))
: true;
useEffect(() => {
if (!isDesktopVariant) return;
if (expandedSubstance === 'none') return;
if (expandedSubstance === 'nicotine' && !showNicotine) {
setExpandedSubstance(showWeed ? 'weed' : 'none');
}
if (expandedSubstance === 'weed' && !showWeed) {
setExpandedSubstance(showNicotine ? 'nicotine' : 'none');
}
}, [expandedSubstance, isDesktopVariant, showNicotine, showWeed]);
if (!showNicotine && !showWeed) return null;
return (
<Card className="backdrop-blur-2xl shadow-2xl border-white/10 overflow-hidden bg-white/5">
<Card
className={cn(
"backdrop-blur-2xl shadow-2xl border-white/10 overflow-hidden bg-white/5",
!isDesktopVariant && "max-h-[calc(100dvh-13.5rem)]"
)}
>
<CardHeader className="pb-1 pt-6 px-4 sm:px-6">
<CardTitle className="flex items-center gap-2 text-sm sm:text-base font-black uppercase tracking-widest opacity-60">
<TrendingDown className="h-4 w-4 text-primary" />
Quit Journey Plan
</CardTitle>
</CardHeader>
<CardContent className="pt-2 p-2 sm:p-4">
<CardContent className={cn("pt-2 p-2 sm:p-4", !isDesktopVariant && "overflow-y-auto overscroll-contain pr-1")}>
{showNicotine && (
<SubstancePlanSection
substance="nicotine"
isExpanded={expandedSubstance === 'nicotine'}
onToggle={() => setExpandedSubstance(expandedSubstance === 'nicotine' ? 'none' : 'nicotine')}
plan={preferences.quitState?.nicotine?.plan || (preferences.substance === 'nicotine' ? preferences.quitPlan : null)}
usageData={usageData}
trackingStartDate={
@ -268,6 +333,8 @@ export function UnifiedQuitPlanCard({
usageData.filter(e => e.substance === 'nicotine').sort((a, b) => a.date.localeCompare(b.date))[0]?.date ||
null
}
isExpanded={isDesktopVariant ? expandedSubstance === 'nicotine' : true}
onToggle={isDesktopVariant ? () => setExpandedSubstance(expandedSubstance === 'nicotine' ? 'none' : 'nicotine') : undefined}
onGeneratePlan={() => onGeneratePlan('nicotine')}
/>
)}
@ -275,8 +342,6 @@ export function UnifiedQuitPlanCard({
{showWeed && (
<SubstancePlanSection
substance="weed"
isExpanded={expandedSubstance === 'weed'}
onToggle={() => setExpandedSubstance(expandedSubstance === 'weed' ? 'none' : 'weed')}
plan={preferences.quitState?.weed?.plan || (preferences.substance === 'weed' ? preferences.quitPlan : null)}
usageData={usageData}
trackingStartDate={
@ -285,6 +350,8 @@ export function UnifiedQuitPlanCard({
usageData.filter(e => e.substance === 'weed').sort((a, b) => a.date.localeCompare(b.date))[0]?.date ||
null
}
isExpanded={isDesktopVariant ? expandedSubstance === 'weed' : true}
onToggle={isDesktopVariant ? () => setExpandedSubstance(expandedSubstance === 'weed' ? 'none' : 'weed') : undefined}
onGeneratePlan={() => onGeneratePlan('weed')}
/>
)}

View File

@ -27,11 +27,12 @@ interface UsageCalendarProps {
userId: string;
religion?: 'christian' | 'secular' | null;
onReligionUpdate?: (religion: 'christian' | 'secular') => void;
showInspirationPanel?: boolean;
preferences?: UserPreferences | null;
onPreferencesUpdate?: (prefs: UserPreferences) => Promise<void>;
}
function UsageCalendarComponent({ usageData, onDataUpdate, religion, onReligionUpdate, preferences, onPreferencesUpdate }: UsageCalendarProps) {
function UsageCalendarComponent({ usageData, onDataUpdate, religion, onReligionUpdate, showInspirationPanel = false, preferences, onPreferencesUpdate }: UsageCalendarProps) {
const [selectedDate, setSelectedDate] = useState<Date | undefined>(undefined);
const [editNicotineCount, setEditNicotineCount] = useState('');
const [editWeedCount, setEditWeedCount] = useState('');
@ -258,60 +259,67 @@ function UsageCalendarComponent({ usageData, onDataUpdate, religion, onReligionU
</div>
</div>
<div className="flex flex-col lg:flex-row gap-8 lg:gap-16 items-center lg:items-stretch justify-center max-w-6xl mx-auto">
{/* Calendar - Focused Container */}
<div className="w-full lg:w-auto flex flex-col items-center">
<div className={cn(
"rounded-2xl p-2 sm:p-4 border shadow-inner transition-all duration-500",
theme === 'light' ? "bg-slate-50/50 border-slate-200/60" : "bg-black/20 border-white/5"
)}>
<DayPicker
mode="single"
selected={selectedDate}
onSelect={handleDateSelect}
className={cn(
"p-0 sm:p-2",
theme === 'light' ? "text-slate-900" : "text-white"
)}
showOutsideDays={false}
components={{
DayButton: (props) => (
<CustomDayButton
{...props}
className={cn(
props.className,
"aspect-square rounded-full flex items-center justify-center p-0"
)}
/>
),
Chevron: ({ orientation }) => (
<div className={cn(
"p-1.5 rounded-full border transition-all duration-200",
theme === 'light'
? "bg-white border-slate-200 text-slate-600 hover:bg-slate-100 hover:scale-110 shadow-sm"
: "bg-white/5 border-white/10 text-white/70 hover:bg-white/10 hover:scale-110"
)}>
{orientation === 'left' ? (
<ChevronLeftIcon className="h-4 w-4" />
) : (
<ChevronRightIcon className="h-4 w-4" />
)}
</div>
),
}}
/>
<div className={cn("mx-auto", showInspirationPanel ? "max-w-6xl" : "max-w-4xl")}>
<div className={cn(
showInspirationPanel
? "flex flex-col lg:flex-row gap-8 lg:gap-8 items-center lg:items-stretch justify-center"
: "w-full flex flex-col items-center"
)}>
{/* Calendar */}
<div className={cn("w-full flex flex-col items-center", showInspirationPanel && "lg:w-1/2")}>
<div className={cn(
"rounded-2xl p-2 sm:p-4 border shadow-inner transition-all duration-500 w-full",
theme === 'light' ? "bg-slate-50/50 border-slate-200/60" : "bg-black/20 border-white/5"
)}>
<DayPicker
mode="single"
selected={selectedDate}
onSelect={handleDateSelect}
className={cn(
"p-0 sm:p-2 w-full [&_.rdp-month]:w-full [&_.rdp-table]:w-full",
theme === 'light' ? "text-slate-900" : "text-white"
)}
showOutsideDays={false}
components={{
DayButton: (props) => (
<CustomDayButton
{...props}
className={cn(
props.className,
"aspect-square rounded-full flex items-center justify-center p-0"
)}
/>
),
Chevron: ({ orientation }) => (
<div className={cn(
"p-1.5 rounded-full border transition-all duration-200",
theme === 'light'
? "bg-white border-slate-200 text-slate-600 hover:bg-slate-100 hover:scale-110 shadow-sm"
: "bg-white/5 border-white/10 text-white/70 hover:bg-white/10 hover:scale-110"
)}>
{orientation === 'left' ? (
<ChevronLeftIcon className="h-4 w-4" />
) : (
<ChevronRightIcon className="h-4 w-4" />
)}
</div>
),
}}
/>
</div>
</div>
</div>
{/* Desktop Vertical Divider */}
<div className="hidden lg:block w-px self-stretch bg-gradient-to-b from-transparent via-white/10 to-transparent" />
{/* Daily Inspiration - Centered vertically on desktop */}
<div className="flex-1 w-full max-w-2xl flex flex-col justify-center">
<DailyInspirationCard
initialReligion={religion}
onReligionChange={onReligionUpdate}
/>
{showInspirationPanel && (
<>
<div className="hidden lg:block w-px self-stretch bg-gradient-to-b from-transparent via-white/10 to-transparent" />
<div className="w-full lg:w-1/2 flex flex-col justify-center">
<DailyInspirationCard
initialReligion={religion}
onReligionChange={onReligionUpdate}
/>
</div>
</>
)}
</div>
</div>

View File

@ -122,9 +122,11 @@ export function UsageTrendGraph({ usageData, substance }: UsageTrendGraphProps)
<p className="text-sm text-muted-foreground">Daily Average</p>
</div>
<div className="bg-muted/50 p-3 rounded-lg text-center hover:bg-muted/70 transition-all duration-200 hover:scale-[1.02]">
<p className="text-2xl font-bold capitalize">{trend}</p>
<p className="text-2xl font-bold capitalize">
{trend === 'increasing' ? 'Usage Rising' : trend === 'decreasing' ? 'Dropping' : 'Stable'}
</p>
<p className="text-sm text-muted-foreground">
{trend === 'decreasing' ? 'Great progress!' : trend === 'increasing' ? 'Stay strong!' : 'Holding steady'}
{trend === 'decreasing' ? 'Great progress!' : trend === 'increasing' ? 'Time to refocus' : 'Holding steady'}
</p>
</div>
</div>

View File

@ -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 { useEffect, useState } from 'react';
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,8 +137,107 @@ 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<HTMLVideoElement>(null);
const videoRetryTimeoutRef = useRef<number | null>(null);
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
const [isVideoPlaying, setIsVideoPlaying] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const [isPWA, setIsPWA] = useState(false);
useEffect(() => {
// Detect Mobile/PWA
const checkMobile = () => {
const userAgent = typeof window.navigator === "undefined" ? "" : navigator.userAgent;
const mobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(userAgent);
setIsMobile(mobile);
// Detect PWA (standalone mode)
const isStandalone = window.matchMedia('(display-mode: standalone)').matches || (window.navigator as any).standalone;
setIsPWA(!!isStandalone);
};
checkMobile();
}, []);
const clearVideoRetryTimeout = () => {
if (videoRetryTimeoutRef.current !== null) {
window.clearTimeout(videoRetryTimeoutRef.current);
videoRetryTimeoutRef.current = null;
}
};
const scheduleVideoRetry = () => {
clearVideoRetryTimeout();
videoRetryTimeoutRef.current = window.setTimeout(() => {
const video = videoRef.current;
if (!video) return;
if (video.readyState < 2) {
video.load();
}
video.play().catch(() => {
// Ignore autoplay retries that still fail.
});
}, 900);
};
useEffect(() => {
return () => {
clearVideoRetryTimeout();
};
}, []);
// Force play background video
useEffect(() => {
if (videoRef.current) {
const video = videoRef.current;
// Optimization: For Desktop, we prioritize quality logic if we had multiple sources.
// For now, we ensure robust playback for both.
// PWA/Mobile specific optimizations
if (isMobile || isPWA) {
// Ensure strictly muted/inline for iOS policy
video.muted = true;
video.defaultMuted = true;
video.playsInline = true;
video.setAttribute('playsinline', 'true'); // Explicit attribute for some older browsers
video.setAttribute('webkit-playsinline', 'true');
video.preload = 'metadata';
} else {
video.preload = 'auto';
}
video.loop = true;
// If already ready, set loaded immediately
if (video.readyState >= 2) {
setIsVideoLoaded(true);
}
// Try playing
const playPromise = video.play();
if (playPromise !== undefined) {
playPromise.catch((err) => {
console.warn("Autoplay failed, user interaction might be needed:", err);
if (isMobile || isPWA) {
scheduleVideoRetry();
}
});
}
if (isMobile || isPWA) {
scheduleVideoRetry();
}
}
return () => {
clearVideoRetryTimeout();
};
}, [isMobile, isPWA]);
useEffect(() => {
if (onModalStateChange) {
@ -166,26 +271,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]);
@ -219,321 +331,400 @@ export function UserHeader({ user, preferences, onModalStateChange }: UserHeader
return (
<>
<header className="sticky top-0 z-50 transition-colors duration-300 relative" style={{
background: theme === 'light'
? 'rgba(255, 255, 255, 0.92)'
: 'rgba(10, 10, 20, 0.94)',
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
borderBottom: theme === 'light' ? '1px solid rgba(0,0,0,0.08)' : '1px solid rgba(255,255,255,0.08)'
<header className="sticky top-0 z-50 relative overflow-hidden h-16 sm:h-20" style={{
borderBottom: '1px solid rgba(255,255,255,0.08)'
}}>
{/* Cloudy/Foggy effect overlay with ultra-wide, organic feathering */}
<div
className="absolute inset-0 pointer-events-none select-none overflow-hidden invert dark:invert-0 transition-opacity duration-700"
style={{
maskImage: 'radial-gradient(ellipse 95% 140% at 50% 50%, black 0%, rgba(0,0,0,0.4) 60%, transparent 100%)',
WebkitMaskImage: 'radial-gradient(ellipse 95% 140% at 50% 50%, black 0%, rgba(0,0,0,0.4) 60%, transparent 100%)',
}}
>
<div className="absolute inset-0 fog-layer-1 opacity-[0.25] dark:opacity-[0.2]" />
<div className="absolute inset-0 fog-layer-2 opacity-[0.18] dark:opacity-[0.14]" />
{/* Background Layers */}
<div className="absolute inset-0 z-[-1] pointer-events-none overflow-hidden">
<style dangerouslySetInnerHTML={{
__html: `
/* Absolute nuclear suppression of all native iOS media HUDs */
video::-webkit-media-controls {
display: none !important;
opacity: 0 !important;
-webkit-appearance: none !important;
}
video::-webkit-media-controls-start-playback-button {
display: none !important;
opacity: 0 !important;
-webkit-appearance: none !important;
}
video::-webkit-media-controls-panel {
display: none !important;
opacity: 0 !important;
-webkit-appearance: none !important;
}
video::-webkit-media-controls-play-button {
display: none !important;
opacity: 0 !important;
-webkit-appearance: none !important;
}
/* Extra safety for PWA/Safari specifics */
*::-webkit-media-controls-start-playback-button {
display: none !important;
-webkit-appearance: none;
}
` }} />
{/* Base Background Layer - Simple semi-transparent base */}
<div
className="absolute inset-0 transition-colors duration-500"
style={{
background: 'rgba(10, 10, 20, 0.5)',
}}
/>
{/* Wrapper for background image/video to allow click-to-play */}
<div
className="absolute inset-0 w-full h-full pointer-events-auto"
onClick={() => {
if (videoRef.current && videoRef.current.paused) {
videoRef.current.play().catch((err) => {
console.warn("User interaction play failed:", err);
});
}
}}
>
{/* Static Poster Image Layer (Safe Fallback) */}
<img
src="/videos/smoke-poster.jpg"
alt=""
className={cn(
"absolute inset-0 w-full h-full object-cover transition-opacity duration-1000",
isMobile ? "scale-105" : "scale-110", // Reduce scale on mobile
isVideoPlaying ? "opacity-0" : "opacity-100" // Hide poster completely when playing for better perf, or keep low opacity
)}
aria-hidden="true"
/>
{/* Smoke Video background - Only visible when MOVING */}
<video
ref={videoRef}
autoPlay
loop
muted
playsInline
{...({
"webkit-playsinline": "true",
"x-webkit-airplay": "deny",
"disableRemotePlayback": true
} as any)}
preload={isMobile || isPWA ? 'metadata' : 'auto'}
controls={false}
disablePictureInPicture
onContextMenu={(e) => e.preventDefault()}
onLoadedData={() => setIsVideoLoaded(true)}
onCanPlay={() => {
setIsVideoLoaded(true);
if (videoRef.current?.paused) {
videoRef.current.play().catch(() => {
if (isMobile || isPWA) {
scheduleVideoRetry();
}
});
}
}}
onPlaying={() => {
if (videoRef.current && videoRef.current.currentTime > 0.06) {
setIsVideoPlaying(true);
}
}}
onTimeUpdate={() => {
if (!isVideoPlaying && videoRef.current && videoRef.current.currentTime > 0.06) {
setIsVideoPlaying(true);
}
}}
onWaiting={() => {
setIsVideoPlaying(false);
if (isMobile || isPWA) {
scheduleVideoRetry();
}
}}
onStalled={() => {
setIsVideoPlaying(false);
if (isMobile || isPWA) {
scheduleVideoRetry();
}
}}
onPause={() => {
setIsVideoPlaying(false);
}}
onError={() => {
setIsVideoPlaying(false);
}}
className={cn(
"absolute inset-0 w-full h-full object-cover transition-opacity duration-[1500ms] ease-in-out",
isMobile ? "scale-105" : "scale-110",
isVideoPlaying
? "opacity-50" // Simple consistent transparency
: "opacity-[0.01]"
)}
>
<source src="/videos/smoke.mp4" type="video/mp4" />
</video>
</div>
</div>
<div className="container mx-auto px-4 h-16 sm:h-20 flex items-center justify-between relative z-50">
{/* LEFT: User Profile / Side Menu Trigger */}
<div className="flex-1 flex justify-start">
<button
onClick={() => setIsSideMenuOpen(true)}
className="group relative flex items-center gap-2 p-1.5 pr-3 rounded-full transition-all hover:bg-black/5 dark:hover:bg-white/5 active:scale-95 border border-transparent hover:border-primary/10"
>
<div className="relative">
<Avatar className="h-9 w-9 sm:h-10 sm:w-10 ring-2 ring-primary/20 transition-all group-hover:ring-primary/50 shadow-sm">
<AvatarImage src={user.profilePictureUrl ?? undefined} alt={userName || 'User'} />
<AvatarFallback className="bg-gradient-to-br from-indigo-500 to-purple-500 text-white font-bold">{initials}</AvatarFallback>
</Avatar>
<div className="absolute -bottom-1 -right-1 bg-white dark:bg-slate-900 rounded-full p-0.5 shadow-sm border border-border">
<Menu className="h-3 w-3 text-primary" />
</div>
</div>
<div className="hidden sm:block text-left">
<div className="text-[10px] font-bold uppercase tracking-widest opacity-50 leading-none">Menu</div>
</div>
</button>
</div>
<div className="container mx-auto px-4 h-16 sm:h-20 relative z-50 grid grid-cols-[1fr_auto_1fr] items-center">
{/* LEFT: Spacer for balanced centered title */}
<div className="flex-1" />
{/* CENTER: Title and Welcome Message */}
<div className="flex-[2] flex flex-col items-center justify-center text-center">
<h1
className={cn(
"text-xl sm:text-2xl font-bold cursor-pointer transition-all duration-300 hover:scale-105 tracking-tight leading-tight bg-clip-text text-transparent w-fit mx-auto",
theme === 'light'
? 'bg-gradient-to-br from-[#4f46e5] to-[#7c3aed]'
: 'bg-gradient-to-br from-[#a78bfa] to-[#f472b6]'
"bg-gradient-to-br from-[#a78bfa] to-[#f472b6]"
)}
onClick={() => handleNavigate('/')}
style={{
filter: theme === 'dark' ? 'drop-shadow(0 0 10px rgba(167, 139, 250, 0.3))' : 'none'
filter: 'drop-shadow(0 0 10px rgba(167, 139, 250, 0.3))'
}}
>
QuitTraq
</h1>
{userName && (
<p className="text-[10px] sm:text-xs font-medium text-foreground/60 tracking-wide mt-0.5 whitespace-nowrap overflow-hidden">
<p className="text-[10px] sm:text-xs font-medium text-white/60 tracking-wide mt-0.5 whitespace-nowrap overflow-hidden">
Welcome {userName}, you got this!
</p>
)}
</div>
{/* RIGHT: Action Buttons */}
<div className="flex-1 flex items-center justify-end gap-1.5 sm:gap-3">
{/* RIGHT: User Profile / Side Menu Trigger */}
<div className="flex items-center justify-end justify-self-end">
<button
onClick={() => setShowReminderDialog(true)}
className={cn(
"p-2 sm:p-2.5 rounded-full transition-all duration-300 active:scale-90 shadow-sm",
reminderSettings.enabled
? 'bg-indigo-500/15 text-indigo-400 border border-indigo-500/20'
: 'bg-muted border border-transparent text-muted-foreground'
)}
onClick={() => setIsSideMenuOpen(true)}
className="group relative flex h-10 w-10 sm:h-11 sm:w-11 items-center justify-center rounded-full transition-all hover:bg-white/10 active:scale-95 border border-white/15 hover:border-white/30"
aria-label="Open profile menu"
>
{reminderSettings.enabled ? (
<BellRing className="h-4.5 w-4.5 sm:h-5 sm:w-5" />
) : (
<Bell className="h-4.5 w-4.5 sm:h-5 sm:w-5" />
)}
</button>
<InstallAppButton />
<button
onClick={toggleTheme}
className="p-2 sm:p-2.5 rounded-full bg-muted border border-transparent hover:bg-muted/80 transition-all active:scale-90"
>
{theme === 'dark' ? (
<Moon className="h-4.5 w-4.5 sm:h-5 sm:w-5 text-blue-300" />
) : (
<Sun className="h-4.5 w-4.5 sm:h-5 sm:w-5 text-yellow-500" />
)}
<Avatar className="h-8 w-8 sm:h-9 sm:w-9 ring-2 ring-white/20 transition-all group-hover:ring-white/50 shadow-sm">
<AvatarImage src={user.profilePictureUrl ?? undefined} alt={userName || 'User'} />
<AvatarFallback className="bg-gradient-to-br from-indigo-500 to-purple-500 text-white font-bold text-xs sm:text-sm">{initials}</AvatarFallback>
</Avatar>
<div className="absolute -bottom-1 -right-1 rounded-full p-0.5 shadow-sm border border-white/10 bg-slate-900/90">
<Menu className="h-3 w-3 text-white" />
</div>
</button>
</div>
</div>
</header>
{/* Side Menu Integration */}
<SideMenu
isOpen={isSideMenuOpen}
onClose={() => setIsSideMenuOpen(false)}
user={user}
userName={userName}
/>
{/* Side Menu Integration */}
<SideMenu
isOpen={isSideMenuOpen}
onClose={() => setIsSideMenuOpen(false)}
user={user}
userName={userName}
onOpenNotifications={() => setShowReminderDialog(true)}
/>
{/* Reminder Settings Dialog */}
<Dialog open={showReminderDialog} onOpenChange={setShowReminderDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 font-bold tracking-tight">
<Bell className="h-5 w-5 text-indigo-400" />
Notification Settings
</DialogTitle>
</DialogHeader>
{/* Reminder Settings Dialog */}
<Dialog open={showReminderDialog} onOpenChange={setShowReminderDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 font-bold tracking-tight">
<Bell className="h-5 w-5 text-indigo-400" />
Notification Settings
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4 px-1">
{/* Enable/Disable Toggle */}
<div className={cn(
"flex items-center justify-between p-4 rounded-2xl border transition-all",
theme === 'light' ? "bg-slate-50 border-slate-100" : "bg-white/5 border-white/5"
)}>
<div className="flex items-center gap-3">
<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">
<span className="text-sm font-bold">
{reminderSettings.enabled ? 'Enabled' : 'Disabled'}
</span>
<span className="text-[10px] opacity-60">
{reminderSettings.enabled ? 'Reminders active' : 'Turn on to get alerts'}
</span>
</div>
<div className="space-y-4 py-4 px-1">
{/* Enable/Disable Toggle */}
<div className={cn(
"flex items-center justify-between p-4 rounded-2xl border transition-all",
theme === 'light' ? "bg-slate-50 border-slate-100" : "bg-white/5 border-white/5"
)}>
<div className="flex items-center gap-3">
<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">
<span className="text-sm font-bold">
{reminderSettings.enabled ? 'Enabled' : 'Disabled'}
</span>
<span className="text-[10px] opacity-60">
{reminderSettings.enabled ? 'Reminders active' : 'Turn on to get alerts'}
</span>
</div>
<button
onClick={handleToggleReminders}
disabled={!isSupported || (permission === 'denied' && !reminderSettings.enabled)}
className={cn(
"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 className={cn(
"absolute top-1 w-4 h-4 rounded-full bg-white shadow-md transition-all duration-300",
reminderSettings.enabled ? "left-7" : "left-1"
)} />
</button>
</div>
<button
onClick={handleToggleReminders}
disabled={!isSupported || (permission === 'denied' && !reminderSettings.enabled)}
className={cn(
"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 className={cn(
"absolute top-1 w-4 h-4 rounded-full bg-white shadow-md transition-all duration-300",
reminderSettings.enabled ? "left-7" : "left-1"
)} />
</button>
</div>
{/* Frequency Selection */}
{reminderSettings.enabled && (
<div className="space-y-3">
<div className="text-[10px] font-bold uppercase tracking-widest opacity-40 px-1">Reminder Frequency</div>
<div className="grid grid-cols-2 gap-3">
<button
onClick={() => handleFrequencyChange('daily')}
className={cn(
"p-4 rounded-2xl border text-sm font-bold transition-all flex flex-col items-center gap-1",
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'
)}
>
<span>Daily</span>
<span className="text-[10px] font-normal opacity-70">Once a day</span>
</button>
<button
onClick={() => handleFrequencyChange('hourly')}
className={cn(
"p-4 rounded-2xl border text-sm font-bold transition-all flex flex-col items-center gap-1",
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'
)}
>
<span>Hourly</span>
<span className="text-[10px] font-normal opacity-70">Window alerts</span>
</button>
</div>
</div>
)}
{/* Time Picker (Only for Daily) */}
{reminderSettings.enabled && localFrequency === 'daily' && (
<div className="space-y-3 animate-in fade-in slide-in-from-top-2 duration-300">
<div className="text-[10px] font-bold uppercase tracking-widest opacity-40 px-1">Preferred Time</div>
<div className="flex gap-2 p-1 bg-muted/30 rounded-2xl border border-border/50">
<div className="flex-1">
<Select
value={hourString}
onValueChange={(val) => updateTime(val, minuteString, currentAmpm)}
>
<SelectTrigger className="border-none bg-transparent shadow-none hover:bg-white/5 h-12 rounded-xl">
<SelectValue placeholder="Hour" />
</SelectTrigger>
<SelectContent className="rounded-xl">
{hoursOptions.map((h) => (
<SelectItem key={h} value={h} className="rounded-lg">{h}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex-1">
<Select
value={minuteString}
onValueChange={(val) => updateTime(hourString, val, currentAmpm)}
>
<SelectTrigger className="border-none bg-transparent shadow-none hover:bg-white/5 h-12 rounded-xl">
<SelectValue placeholder="Min" />
</SelectTrigger>
<SelectContent className="rounded-xl">
{minutesOptions.map((m) => (
<SelectItem key={m} value={m} className="rounded-lg">{m}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="w-24 px-1">
<Select
value={currentAmpm}
onValueChange={(val) => updateTime(hourString, minuteString, val)}
>
<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" />
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="AM" className="rounded-lg">AM</SelectItem>
<SelectItem value="PM" className="rounded-lg">PM</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
)}
{/* Hourly Alerts Window */}
{reminderSettings.enabled && localFrequency === 'hourly' && (
<div className="space-y-4 animate-in fade-in slide-in-from-top-2 duration-300">
<div className="space-y-2">
<div className="text-[10px] font-bold uppercase tracking-widest opacity-40 px-1">Start Time</div>
<HourlyTimePicker
value={reminderSettings.hourlyStart || '09:00'}
onChange={async (newTime) => {
const [h, m] = newTime.split(':');
const end = (reminderSettings.hourlyEnd || '21:00').split(':');
const newSettings = { ...reminderSettings, hourlyStart: newTime, hourlyEnd: `${end[0]}:${m}` };
setReminderSettings(newSettings);
await saveReminderSettings(newSettings);
}}
/>
</div>
<div className="space-y-2">
<div className="text-[10px] font-bold uppercase tracking-widest opacity-40 px-1">End Time</div>
<HourlyTimePicker
value={reminderSettings.hourlyEnd || '21:00'}
onChange={async (newTime) => {
const [h, m] = newTime.split(':');
const start = (reminderSettings.hourlyStart || '09:00').split(':');
const newSettings = { ...reminderSettings, hourlyEnd: newTime, hourlyStart: `${start[0]}:${m}` };
setReminderSettings(newSettings);
await saveReminderSettings(newSettings);
}}
/>
</div>
<div className="flex items-center gap-2 p-3 bg-indigo-500/5 rounded-2xl border border-indigo-500/10">
<Sparkles className="h-4 w-4 text-indigo-400" />
<p className="text-[10px] text-indigo-400 uppercase font-black tracking-widest">Reminders every 60 minutes</p>
</div>
</div>
)}
{/* Notification Permission Sync */}
{reminderSettings.enabled && isSupported && (
<div className="pt-2">
<Button
onClick={async () => {
const result = await requestPermission();
if (result === 'granted') {
try {
const res = await fetch('/api/notifications/test', { method: 'POST' });
if (res.ok) alert("Success! Push notifications active.");
} catch (err) { console.error(err); }
} else alert("Please enable notifications in settings.");
}}
{/* Frequency Selection */}
{reminderSettings.enabled && (
<div className="space-y-3">
<div className="text-[10px] font-bold uppercase tracking-widest opacity-40 px-1">Reminder Frequency</div>
<div className="grid grid-cols-2 gap-3">
<button
onClick={() => handleFrequencyChange('daily')}
className={cn(
"w-full h-12 rounded-2xl font-bold transition-all shadow-md group",
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]'
"p-4 rounded-2xl border text-sm font-bold transition-all flex flex-col items-center gap-1",
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'
)}
>
<Bell className="mr-2 h-4 w-4 group-hover:animate-bounce" />
{permission === 'granted' ? 'Permissions Verified' : 'Enable Push Alerts'}
</Button>
<span>Daily</span>
<span className="text-[10px] font-normal opacity-70">Once a day</span>
</button>
<button
onClick={() => handleFrequencyChange('hourly')}
className={cn(
"p-4 rounded-2xl border text-sm font-bold transition-all flex flex-col items-center gap-1",
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'
)}
>
<span>Hourly</span>
<span className="text-[10px] font-normal opacity-70">Window alerts</span>
</button>
</div>
)}
</div>
)}
{permission === 'denied' && (
<div className="p-4 bg-red-500/10 border border-red-500/20 rounded-2xl">
<p className="text-xs text-red-500 text-center font-medium leading-relaxed">
Browser notifications are currently blocked. To get reminders, please update your site settings.
</p>
{/* Time Picker (Only for Daily) */}
{reminderSettings.enabled && localFrequency === 'daily' && (
<div className="space-y-3 animate-in fade-in slide-in-from-top-2 duration-300">
<div className="text-[10px] font-bold uppercase tracking-widest opacity-40 px-1">Preferred Time</div>
<div className="flex gap-2 p-1 bg-muted/30 rounded-2xl border border-border/50">
<div className="flex-1">
<Select
value={hourString}
onValueChange={(val) => updateTime(val, minuteString, currentAmpm)}
>
<SelectTrigger className="border-none bg-transparent shadow-none hover:bg-white/5 h-12 rounded-xl">
<SelectValue placeholder="Hour" />
</SelectTrigger>
<SelectContent className="rounded-xl">
{hoursOptions.map((h) => (
<SelectItem key={h} value={h} className="rounded-lg">{h}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex-1">
<Select
value={minuteString}
onValueChange={(val) => updateTime(hourString, val, currentAmpm)}
>
<SelectTrigger className="border-none bg-transparent shadow-none hover:bg-white/5 h-12 rounded-xl">
<SelectValue placeholder="Min" />
</SelectTrigger>
<SelectContent className="rounded-xl">
{minutesOptions.map((m) => (
<SelectItem key={m} value={m} className="rounded-lg">{m}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="w-24 px-1">
<Select
value={currentAmpm}
onValueChange={(val) => updateTime(hourString, minuteString, val)}
>
<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" />
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="AM" className="rounded-lg">AM</SelectItem>
<SelectItem value="PM" className="rounded-lg">PM</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
</div>
</DialogContent>
</Dialog>
</header>
</div>
)}
{/* Hourly Alerts Window */}
{reminderSettings.enabled && localFrequency === 'hourly' && (
<div className="space-y-4 animate-in fade-in slide-in-from-top-2 duration-300">
<div className="space-y-2">
<div className="text-[10px] font-bold uppercase tracking-widest opacity-40 px-1">Start Time</div>
<HourlyTimePicker
value={reminderSettings.hourlyStart || '09:00'}
onChange={async (newTime) => {
const newSettings = { ...reminderSettings, hourlyStart: newTime };
setReminderSettings(newSettings);
await saveReminderSettings(newSettings);
}}
/>
</div>
<div className="space-y-2">
<div className="text-[10px] font-bold uppercase tracking-widest opacity-40 px-1">End Time</div>
<HourlyTimePicker
value={reminderSettings.hourlyEnd || '21:00'}
onChange={async (newTime) => {
const newSettings = { ...reminderSettings, hourlyEnd: newTime };
setReminderSettings(newSettings);
await saveReminderSettings(newSettings);
}}
/>
</div>
<div className="flex items-center gap-2 p-3 bg-indigo-500/5 rounded-2xl border border-indigo-500/10">
<Sparkles className="h-4 w-4 text-indigo-400" />
<p className="text-[10px] text-indigo-400 uppercase font-black tracking-widest">Reminders every 60 minutes</p>
</div>
</div>
)}
{/* Notification Permission Sync */}
{reminderSettings.enabled && isSupported && (
<div className="pt-2">
<Button
onClick={async () => {
const result = await requestPermission();
if (result === 'granted') {
try {
const res = await fetch('/api/notifications/test', { method: 'POST' });
if (res.ok) alert("Success! Push notifications active.");
} catch (err) { console.error(err); }
} else alert("Please enable notifications in settings.");
}}
className={cn(
"w-full h-12 rounded-2xl font-bold transition-all shadow-md group",
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="mr-2 h-4 w-4 group-hover:animate-bounce" />
{permission === 'granted' ? 'Permissions Verified' : 'Enable Push Alerts'}
</Button>
</div>
)}
{permission === 'denied' && (
<div className="p-4 bg-red-500/10 border border-red-500/20 rounded-2xl">
<p className="text-xs text-red-500 text-center font-medium leading-relaxed">
Browser notifications are currently blocked. To get reminders, please update your site settings.
</p>
</div>
)}
</div>
</DialogContent>
</Dialog>
</>
);
}

View File

@ -0,0 +1,162 @@
'use client';
import { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Sparkles, Shield, Bell, Smartphone, Trophy, Rocket, Scale, Heart } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useTheme } from '@/lib/theme-context';
import type { UserPreferences } from '@/lib/storage';
const RELEASE_VERSION = '1.1';
interface VersionUpdateModalProps {
preferences: UserPreferences | null;
onAcknowledge: (version: string) => Promise<void>;
}
export function VersionUpdateModal({ preferences, onAcknowledge }: VersionUpdateModalProps) {
const [isOpen, setIsOpen] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const { theme } = useTheme();
useEffect(() => {
if (!preferences) return;
const hasSeenUpdate = preferences.lastSeenReleaseNotesVersion === RELEASE_VERSION;
setIsOpen(!hasSeenUpdate);
}, [preferences]);
const handleClose = async () => {
if (isSaving || !preferences) return;
setIsSaving(true);
await onAcknowledge(RELEASE_VERSION);
setIsOpen(false);
setIsSaving(false);
};
return (
<Dialog open={isOpen} onOpenChange={(open) => { if (!open) void handleClose(); }}>
<DialogContent className={cn(
"sm:max-w-xl max-h-[85vh] overflow-y-auto border-0 shadow-2xl p-0 gap-0 rounded-3xl",
theme === 'light' ? 'bg-white' : 'bg-[#1a1b26]'
)}>
{/* Header Section with Gradient Background */}
<div className="relative overflow-hidden bg-gradient-to-br from-indigo-600 to-purple-700 p-8 text-white">
<div className="absolute top-0 right-0 -mr-16 -mt-16 w-64 h-64 bg-white/10 rounded-full blur-3xl" />
<div className="relative z-10 flex flex-col items-center text-center space-y-4">
<div className="p-3 bg-white/20 backdrop-blur-md rounded-2xl shadow-inner">
<Rocket className="w-10 h-10 text-white" />
</div>
<div className="space-y-1">
<DialogTitle className="text-3xl font-bold tracking-tight">Version 1.1 is Live!</DialogTitle>
<DialogDescription className="text-white/80 text-base font-medium">
Desktop is restored, mobile is cleaner, and swipe flow is smoother.
</DialogDescription>
</div>
</div>
</div>
{/* Content Section */}
<div className="p-6 space-y-6">
<h3 className="text-sm font-bold uppercase tracking-widest opacity-50 px-2">What's New</h3>
<div className="grid gap-4">
{/* Security */}
<div className={cn("flex gap-4 p-4 rounded-2xl border", theme === 'light' ? 'bg-slate-50 border-slate-100' : 'bg-white/5 border-white/5')}>
<div className="p-2.5 bg-emerald-500/10 rounded-xl h-fit">
<Shield className="w-5 h-5 text-emerald-500" />
</div>
<div className="space-y-1">
<h4 className="font-bold text-sm">Desktop Layout Restored</h4>
<p className="text-xs opacity-70 leading-relaxed">
Restored the desktop dashboard structure, including better section balance and the quote/calendar pairing.
</p>
</div>
</div>
{/* PWA Icon & Look */}
<div className={cn("flex gap-4 p-4 rounded-2xl border", theme === 'light' ? 'bg-indigo-50 border-indigo-100' : 'bg-indigo-500/10 border-indigo-500/10')}>
<div className="p-2.5 bg-indigo-500/10 rounded-xl h-fit">
<Sparkles className="w-5 h-5 text-indigo-500" />
</div>
<div className="space-y-2">
<h4 className="font-bold text-sm">Mobile Navigation Polish</h4>
<p className="text-xs opacity-70 leading-relaxed">
Swipe cards now move more naturally, and the mobile section indicator stays visible at the bottom.
</p>
<div className={cn("text-[10px] p-2 rounded-lg border", theme === 'light' ? 'bg-yellow-50 border-yellow-200 text-yellow-800' : 'bg-yellow-500/10 border-yellow-500/20 text-yellow-200')}>
<strong>Note:</strong> The pager/footer now stays on-screen so you can always jump between mobile sections.
</div>
</div>
</div>
{/* Notifications & Input */}
<div className="grid sm:grid-cols-2 gap-4">
<div className={cn("p-4 rounded-2xl border space-y-2", theme === 'light' ? 'bg-slate-50 border-slate-100' : 'bg-white/5 border-white/5')}>
<Bell className="w-5 h-5 text-amber-500 mb-1" />
<h4 className="font-bold text-sm">One-Time Account Release Notes</h4>
<p className="text-[10px] opacity-70 leading-relaxed">
This message now saves per account and will not appear again after you close it.
</p>
</div>
<div className={cn("p-4 rounded-2xl border space-y-2", theme === 'light' ? 'bg-slate-50 border-slate-100' : 'bg-white/5 border-white/5')}>
<Smartphone className="w-5 h-5 text-blue-500 mb-1" />
<h4 className="font-bold text-sm">Swipe Feel Improvements</h4>
<p className="text-[10px] opacity-70 leading-relaxed">
Reduced sticky snapping so swiping between sections feels lighter and more direct.
</p>
</div>
</div>
{/* Tracking Improvements */}
<div className={cn("flex gap-4 p-4 rounded-2xl border", theme === 'light' ? 'bg-slate-50 border-slate-100' : 'bg-white/5 border-white/5')}>
<div className="p-2.5 bg-indigo-500/10 rounded-xl h-fit">
<Scale className="w-5 h-5 text-indigo-500" />
</div>
<div className="space-y-1">
<h4 className="font-bold text-sm">Tracking + Visibility Fixes</h4>
<p className="text-xs opacity-70 leading-relaxed">
Section navigation, card flow, and bottom spacing were tuned so key controls stay reachable on mobile.
</p>
</div>
</div>
{/* Goals & Moods */}
<div className="grid sm:grid-cols-2 gap-4">
<div className={cn("p-4 rounded-2xl border space-y-2", theme === 'light' ? 'bg-slate-50 border-slate-100' : 'bg-white/5 border-white/5')}>
<Trophy className="w-5 h-5 text-yellow-500 mb-1" />
<h4 className="font-bold text-sm">Achievements Reliability</h4>
<p className="text-[10px] opacity-70 leading-relaxed">
Improved unlock behavior to prevent duplicate first-step unlock confusion.
</p>
</div>
<div className={cn("p-4 rounded-2xl border space-y-2", theme === 'light' ? 'bg-slate-50 border-slate-100' : 'bg-white/5 border-white/5')}>
<Heart className="w-5 h-5 text-rose-500 mb-1" />
<h4 className="font-bold text-sm">Overall Experience Cleanup</h4>
<p className="text-[10px] opacity-70 leading-relaxed">
Better spacing, cleaner transitions, and more predictable behavior across desktop and mobile.
</p>
</div>
</div>
</div>
</div>
{/* Footer */}
<div className={cn("p-6 pt-2", theme === 'light' ? 'bg-slate-50/50' : 'bg-black/20')}>
<Button
className="w-full h-12 rounded-xl text-base font-bold bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-700 hover:to-purple-700 text-white shadow-lg active:scale-95 transition-all"
onClick={() => void handleClose()}
disabled={isSaving}
>
{isSaving ? 'Saving...' : "Let's Go!"}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -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 (
<div className="relative">
{/* Phone frame glow */}
@ -183,25 +185,19 @@ function PhoneMockup({ activeScreen }: { activeScreen: number }) {
<div className="absolute inset-0 p-4 pt-10">
{/* Screen header */}
<div className="text-center mb-4">
<h3 className="text-lg font-bold">{DEMO_SCREENS[activeScreen].title}</h3>
<h3 className="text-lg font-bold">{active.title}</h3>
<p className="text-xs text-muted-foreground">
{DEMO_SCREENS[activeScreen].subtitle}
{active.subtitle}
</p>
</div>
{/* Crossfade content */}
<div className="relative h-[calc(100%-5rem)]">
{DEMO_SCREENS.map((screen, index) => (
<div
key={screen.id}
className={cn(
"absolute inset-0 transition-opacity duration-500 motion-reduce:transition-none",
activeScreen === index ? "opacity-100 z-10" : "opacity-0 z-0"
)}
>
{screen.content}
</div>
))}
<div
id="demo-active-panel"
role="tabpanel"
aria-labelledby={`demo-tab-${active.id}`}
className="h-[calc(100%-5rem)] transition-opacity duration-300 motion-reduce:transition-none"
>
{active.content}
</div>
</div>
@ -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"
)}
>
<div className="flex items-start gap-4">
@ -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<number | null>(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() {
<p className="text-muted-foreground max-w-2xl mx-auto">
A quick look at how QuitTraq helps you track progress and stay motivated.
</p>
<p className="text-xs text-muted-foreground/80 mt-3">Preview uses sample data for demonstration.</p>
</div>
{/* 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]"
)}
>
<FeatureCard screen={screen} isActive={activeScreen === index} />
@ -362,15 +389,29 @@ export function DemoSection() {
<div className="w-full max-w-md text-center">
<div className="space-y-4">
{/* Screen descriptions as buttons */}
{DEMO_SCREENS.map((screen, index) => (
<div role="tablist" aria-label="QuitTraq demo screens" className="space-y-4">
{DEMO_SCREENS.map((screen, index) => (
<button
key={screen.id}
onClick={() => setActiveScreen(index)}
id={`demo-tab-${screen.id}`}
role="tab"
type="button"
aria-selected={activeScreen === index}
aria-controls="demo-active-panel"
tabIndex={activeScreen === index ? 0 : -1}
onClick={() => handleManualSelect(index)}
onKeyDown={(event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
event.preventDefault();
const direction = event.key === 'ArrowDown' ? 1 : -1;
const nextIndex = (index + direction + DEMO_SCREENS.length) % DEMO_SCREENS.length;
handleManualSelect(nextIndex);
}}
className={cn(
"w-full text-left p-4 rounded-xl transition-all duration-300",
activeScreen === index
? "bg-primary/10 border border-primary/30"
: "hover:bg-white/5 border border-transparent"
: "hover:bg-card/60 border border-transparent"
)}
>
<h4
@ -383,7 +424,8 @@ export function DemoSection() {
</h4>
<p className="text-sm text-muted-foreground">{screen.subtitle}</p>
</button>
))}
))}
</div>
</div>
{/* Dot indicators */}
@ -391,12 +433,13 @@ export function DemoSection() {
{DEMO_SCREENS.map((_, index) => (
<button
key={index}
onClick={() => setActiveScreen(index)}
type="button"
onClick={() => handleManualSelect(index)}
className={cn(
"w-2 h-2 rounded-full transition-all",
activeScreen === index
? "w-6 bg-primary"
: "bg-white/20 hover:bg-white/40"
: "bg-muted-foreground/30 hover:bg-muted-foreground/60"
)}
aria-label={`Go to screen ${index + 1}`}
/>

View File

@ -52,9 +52,6 @@ export function FeatureCard({ title, description, icons, gradient, delay = 0 }:
background: `linear-gradient(135deg, ${gradient})`,
}}
>
{/* Decorative gradient orb */}
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-br from-white/10 to-transparent rounded-full -translate-y-1/2 translate-x-1/2 pointer-events-none group-hover:scale-110 transition-transform duration-500" />
<CardHeader className="relative z-10">
<div className="flex items-center gap-2 mb-3">
{icons.map((icon, index) => (

View File

@ -5,30 +5,30 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all duration-200 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive active:scale-[0.97]",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
default: "bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm hover:shadow-md",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 shadow-sm",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
"bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-sm",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
default: "h-12 px-5 py-2 has-[>svg]:px-4 text-base",
xs: "h-8 gap-1 rounded-md px-3 text-xs has-[>svg]:px-2 [&_svg:not([class*='size-'])]:size-3.5",
sm: "h-10 rounded-md gap-1.5 px-4 has-[>svg]:px-3",
lg: "h-14 rounded-xl px-8 text-lg has-[>svg]:px-6",
icon: "size-12 rounded-xl",
"icon-xs": "size-8 rounded-lg [&_svg:not([class*='size-'])]:size-4",
"icon-sm": "size-10 rounded-lg",
"icon-lg": "size-14 rounded-2xl",
},
},
defaultVariants: {

View File

@ -38,6 +38,7 @@ export interface UserPreferencesRow {
religion: string | null;
lastNicotineUsageTime: string | null;
lastWeedUsageTime: string | null;
lastSeenReleaseNotesVersion: string | null;
quitPlanJson: string | null;
createdAt: string;
updatedAt: string;
@ -74,22 +75,38 @@ export async function upsertPreferencesD1(userId: string, data: Partial<UserPref
if (data.userName !== undefined) { updates.push('userName = ?'); values.push(data.userName); }
if (data.userAge !== undefined) { updates.push('userAge = ?'); values.push(data.userAge); }
if (data.religion !== undefined) { updates.push('religion = ?'); values.push(data.religion); }
if (data.lastNicotineUsageTime !== undefined) { updates.push('lastNicotineUsageTime = ?'); values.push(data.lastNicotineUsageTime); }
if (data.lastWeedUsageTime !== undefined) { updates.push('lastWeedUsageTime = ?'); values.push(data.lastWeedUsageTime); }
// Explicit checks for usage times
if (data.lastNicotineUsageTime !== undefined) {
updates.push('lastNicotineUsageTime = ?');
values.push(data.lastNicotineUsageTime);
}
if (data.lastWeedUsageTime !== undefined) {
updates.push('lastWeedUsageTime = ?');
values.push(data.lastWeedUsageTime);
}
if (data.lastSeenReleaseNotesVersion !== undefined) {
updates.push('lastSeenReleaseNotesVersion = ?');
values.push(data.lastSeenReleaseNotesVersion);
}
if (data.quitPlanJson !== undefined) { updates.push('quitPlanJson = ?'); values.push(data.quitPlanJson); }
updates.push('updatedAt = ?');
values.push(now);
values.push(userId);
await db.prepare(
`UPDATE UserPreferences SET ${updates.join(', ')} WHERE userId = ?`
).bind(...values).run();
if (updates.length > 1) { // At least updatedAt is always there
await db.prepare(
`UPDATE UserPreferences SET ${updates.join(', ')} WHERE userId = ?`
).bind(...values).run();
}
} else {
// Insert
await db.prepare(
`INSERT INTO UserPreferences (id, userId, substance, trackingStartDate, hasCompletedSetup, dailyGoal, userName, userAge, religion, lastNicotineUsageTime, lastWeedUsageTime, quitPlanJson, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
`INSERT INTO UserPreferences (id, userId, substance, trackingStartDate, hasCompletedSetup, dailyGoal, userName, userAge, religion, lastNicotineUsageTime, lastWeedUsageTime, lastSeenReleaseNotesVersion, quitPlanJson, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).bind(
id,
userId,
@ -102,6 +119,7 @@ export async function upsertPreferencesD1(userId: string, data: Partial<UserPref
data.religion || null,
data.lastNicotineUsageTime || null,
data.lastWeedUsageTime || null,
data.lastSeenReleaseNotesVersion || null,
data.quitPlanJson || null,
now,
now
@ -209,6 +227,17 @@ export async function getAchievementD1(userId: string, badgeId: string, substanc
return result;
}
export async function getAchievementByBadgeD1(userId: string, badgeId: string): Promise<AchievementRow | null> {
const db = getD1();
if (!db) return null;
const result = await db.prepare(
'SELECT * FROM Achievement WHERE userId = ? AND badgeId = ? ORDER BY CASE WHEN substance = ? THEN 0 ELSE 1 END, unlockedAt DESC LIMIT 1'
).bind(userId, badgeId, 'both').first<AchievementRow>();
return result;
}
export async function createAchievementD1(userId: string, badgeId: string, substance: string): Promise<AchievementRow | null> {
const db = getD1();
if (!db) return null;
@ -220,7 +249,7 @@ export async function createAchievementD1(userId: string, badgeId: string, subst
const now = new Date().toISOString();
await db.prepare(
`INSERT INTO Achievement (id, userId, badgeId, unlockedAt, substance)
`INSERT OR IGNORE INTO Achievement (id, userId, badgeId, unlockedAt, substance)
VALUES (?, ?, ?, ?, ?)`
).bind(id, userId, badgeId, now, substance).run();
@ -427,6 +456,15 @@ export async function upsertPushSubscriptionD1(
return getPushSubscriptionD1(userId);
}
export async function deletePushSubscriptionD1(userId: string): Promise<void> {
const db = getD1();
if (!db) return;
await db.prepare(
'DELETE FROM PushSubscriptions WHERE userId = ?'
).bind(userId).run();
}
// ============ MOOD TRACKER ============
export interface MoodEntryRow {

View File

@ -16,6 +16,43 @@ export interface Session {
}
const SESSION_COOKIE_NAME = 'quit_smoking_session';
const SESSION_SECRET = process.env.SESSION_SECRET || 'fallback-secret-for-dev-only';
// Helper to sign the session
async function sign(data: string, secret: string): Promise<string> {
const encoder = new TextEncoder();
const keyData = encoder.encode(secret);
const dataData = encoder.encode(data);
const key = await crypto.subtle.importKey(
'raw',
keyData,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = await crypto.subtle.sign('HMAC', key, dataData);
return btoa(String.fromCharCode(...new Uint8Array(signature)));
}
// Helper to verify the session
async function verify(data: string, signature: string, secret: string): Promise<boolean> {
const encoder = new TextEncoder();
const keyData = encoder.encode(secret);
const dataData = encoder.encode(data);
const key = await crypto.subtle.importKey(
'raw',
keyData,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
);
const sigBytes = Uint8Array.from(atob(signature), (c) => c.charCodeAt(0));
return await crypto.subtle.verify('HMAC', key, sigBytes, dataData);
}
export async function getSession(): Promise<Session | null> {
const cookieStore = await cookies();
@ -26,8 +63,20 @@ export async function getSession(): Promise<Session | null> {
}
try {
return JSON.parse(sessionCookie.value);
} catch {
const [payloadBase64, signature] = sessionCookie.value.split('.');
if (!payloadBase64 || !signature) return null;
const payload = atob(payloadBase64);
const isValid = await verify(payload, signature, SESSION_SECRET);
if (!isValid) {
console.warn('Invalid session signature detected');
return null;
}
return JSON.parse(payload);
} catch (error) {
console.error('Error parsing session:', error);
return null;
}
}
@ -36,7 +85,11 @@ export async function setSession(session: Session): Promise<void> {
const cookieStore = await cookies();
const maxAge = session.stayLoggedIn ? 60 * 60 * 24 * 30 : 60 * 60 * 24; // 30 days if stay logged in, else 1 day
cookieStore.set(SESSION_COOKIE_NAME, JSON.stringify(session), {
const payload = JSON.stringify(session);
const payloadBase64 = btoa(payload);
const signature = await sign(payload, SESSION_SECRET);
cookieStore.set(SESSION_COOKIE_NAME, `${payloadBase64}.${signature}`, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',

View File

@ -27,6 +27,7 @@ export interface UserPreferences {
religion: 'christian' | 'secular' | null;
lastNicotineUsageTime?: string | null;
lastWeedUsageTime?: string | null;
lastSeenReleaseNotesVersion?: string | null;
}
export interface QuitPlan {
@ -126,8 +127,25 @@ const defaultPreferences: UserPreferences = {
userName: null,
userAge: null,
religion: null,
lastSeenReleaseNotesVersion: null,
};
export const defaultReminderSettings: ReminderSettings = {
enabled: false,
reminderTime: '09:00',
frequency: 'daily',
};
export class StorageRequestError extends Error {
status?: number;
constructor(message: string, status?: number) {
super(message);
this.name = 'StorageRequestError';
this.status = status;
}
}
// Cache for preferences and usage data to avoid excessive API calls
let preferencesCache: UserPreferences | null = null;
let usageDataCache: UsageEntry[] | null = null;
@ -160,15 +178,17 @@ export async function fetchPreferences(): Promise<UserPreferences> {
try {
const response = await fetch('/api/preferences', { cache: 'no-store' });
if (!response.ok) {
console.error('Failed to fetch preferences');
return defaultPreferences;
throw new StorageRequestError('Unable to load profile preferences.', response.status);
}
const data = await response.json() as UserPreferences;
preferencesCache = data;
return data;
} catch (error) {
console.error('Error fetching preferences:', error);
return defaultPreferences;
if (error instanceof StorageRequestError) {
throw error;
}
throw new StorageRequestError('Unable to load profile preferences.');
}
}
@ -181,6 +201,9 @@ export async function savePreferencesAsync(preferences: UserPreferences): Promis
});
if (response.ok) {
preferencesCache = preferences;
} else {
const err = await response.json();
console.error('[Storage] Error saving preferences:', response.status, err);
}
} catch (error) {
console.error('Error saving preferences:', error);
@ -192,15 +215,17 @@ export async function fetchUsageData(): Promise<UsageEntry[]> {
try {
const response = await fetch('/api/usage', { cache: 'no-store' });
if (!response.ok) {
console.error('Failed to fetch usage data');
return [];
throw new StorageRequestError('Unable to load usage history.', response.status);
}
const data = await response.json() as UsageEntry[];
usageDataCache = data;
return data;
} catch (error) {
console.error('Error fetching usage data:', error);
return [];
if (error instanceof StorageRequestError) {
throw error;
}
throw new StorageRequestError('Unable to load usage history.');
}
}
@ -303,13 +328,18 @@ export async function fetchReminderSettings(): Promise<ReminderSettings> {
if (reminderSettingsCache) return reminderSettingsCache;
try {
const response = await fetch('/api/reminders');
if (!response.ok) return { enabled: false, reminderTime: '09:00', frequency: 'daily' };
if (!response.ok) {
throw new StorageRequestError('Unable to load reminder settings.', response.status);
}
const data = await response.json() as ReminderSettings;
reminderSettingsCache = data;
return data;
} catch (error) {
console.error('Error fetching reminder settings:', error);
return { enabled: false, reminderTime: '09:00', frequency: 'daily' };
if (error instanceof StorageRequestError) {
throw error;
}
throw new StorageRequestError('Unable to load reminder settings.');
}
}
@ -329,7 +359,7 @@ export async function saveReminderSettings(settings: ReminderSettings): Promise<
}
export function getReminderSettings(): ReminderSettings {
return reminderSettingsCache || { enabled: false, reminderTime: '09:00', frequency: 'daily' };
return reminderSettingsCache || defaultReminderSettings;
}
// ============ SAVINGS FUNCTIONS ============
@ -486,6 +516,10 @@ export function checkBadgeEligibility(
preferences: UserPreferences,
substance: 'nicotine' | 'weed'
): boolean {
const hasLoggedUsageForSubstance = usageData.some(
(entry) => entry.substance === substance && entry.count > 0
);
// Pre-calculate common stats once O(n)
const stats = (() => {
const nicotineMap = new Map<string, number>();
@ -573,7 +607,7 @@ export function checkBadgeEligibility(
};
switch (badgeId) {
case 'first_day': return stats.totalDays >= 1;
case 'first_day': return hasLoggedUsageForSubstance;
case 'streak_3': return streak >= 3;
case 'streak_7': return stats.totalDays >= 7;
case 'fighter':

View File

@ -4,10 +4,25 @@ export const workos = new WorkOS(process.env.WORKOS_API_KEY!);
export const clientId = process.env.WORKOS_CLIENT_ID!;
export function getAuthorizationUrl(provider: 'GoogleOAuth' | 'AppleOAuth') {
export type OAuthProvider = 'GoogleOAuth' | 'AppleOAuth' | 'GitHubOAuth';
export function getAuthorizationUrl(provider: OAuthProvider) {
return workos.userManagement.getAuthorizationUrl({
provider,
clientId,
redirectUri: process.env.WORKOS_REDIRECT_URI!,
});
}
// Get AuthKit hosted login URL (for email/password and phone)
// Note: We manually construct this URL with provider=authkit
export function getAuthKitUrl() {
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: process.env.WORKOS_REDIRECT_URI!,
response_type: 'code',
provider: 'authkit',
});
return `https://api.workos.com/user_management/authorize?${params.toString()}`;
}