Compare commits

..

No commits in common. "main" and "feat/landingpage" have entirely different histories.

59 changed files with 986 additions and 25018 deletions

6
.gitignore vendored
View File

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

View File

@ -1,116 +0,0 @@
# 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,77 +1,36 @@
# QuitTraq (Stop Smoking Website v2)
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 is a Next.js + Cloudflare Workers app for tracking nicotine/marijuana usage, recovery progress, achievements, reminders, and savings.
## Getting Started
## 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.
First, run the development server:
```bash
bun install
bun run d1:migrate
bun run build:worker
bun run dev:worker
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Local URL: `http://localhost:3000`
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
## Environment Variables
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
Create local env files:
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.
- `.env.local` (Next.js runtime)
- `.dev.vars` (Wrangler runtime)
## Learn More
Required keys:
To learn more about Next.js, take a look at the following resources:
- `WORKOS_CLIENT_ID`
- `WORKOS_API_KEY`
- `WORKOS_REDIRECT_URI`
- `SESSION_SECRET`
- `DATABASE_URL`
- [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.
Optional keys for push notifications and cron:
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
- `NEXT_PUBLIC_VAPID_PUBLIC_KEY`
- `VAPID_PRIVATE_KEY`
- `VAPID_SUBJECT`
- `CRON_SECRET`
## Deploy on Vercel
## Deployment
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.
```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.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View File

@ -31,7 +31,6 @@
"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

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

22092
package-lock.json generated

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 --port 3000",
"dev:worker": "wrangler dev",
"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,10 +40,9 @@
"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",
"yaml": "^2.8.2"
"styled-jsx": "^5.1.6"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250121.0",
@ -67,4 +66,4 @@
"sharp",
"unrs-resolver"
]
}
}

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 364 KiB

After

Width:  |  Height:  |  Size: 413 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 364 KiB

After

Width:  |  Height:  |  Size: 413 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

View File

@ -1,18 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { getSession } from '@/lib/session';
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']);
import { getAchievementsD1, getAchievementD1, createAchievementD1 } from '@/lib/d1';
export async function GET() {
try {
@ -22,22 +10,14 @@ export async function GET() {
}
const achievements = await getAchievementsD1(session.user.id);
const seenGlobalBadges = new Set<string>();
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) => ({
return NextResponse.json(
achievements.map((a) => ({
badgeId: a.badgeId,
unlockedAt: a.unlockedAt,
substance: GLOBAL_BADGE_IDS.has(a.badgeId) ? 'both' : a.substance,
}));
return NextResponse.json(normalized);
substance: a.substance,
}))
);
} catch (error) {
console.error('Error fetching achievements:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
@ -58,22 +38,8 @@ 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 = isGlobalBadge
? await getAchievementByBadgeD1(session.user.id, badgeId)
: await getAchievementD1(session.user.id, badgeId, targetSubstance);
const existing = await getAchievementD1(session.user.id, badgeId, substance);
if (existing) {
return NextResponse.json({
badgeId: existing.badgeId,
@ -83,7 +49,7 @@ export async function POST(request: NextRequest) {
});
}
const achievement = await createAchievementD1(session.user.id, badgeId, targetSubstance);
const achievement = await createAchievementD1(session.user.id, badgeId, substance);
if (!achievement) {
return NextResponse.json({ error: 'Failed to unlock achievement' }, { status: 500 });

View File

@ -1,90 +0,0 @@
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,52 +1,26 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAuthorizationUrl, getAuthKitUrl, OAuthProvider } from '@/lib/workos';
import { getAuthorizationUrl } from '@/lib/workos';
import { cookies } from 'next/headers';
const VALID_PROVIDERS = ['GoogleOAuth', 'AppleOAuth', 'GitHubOAuth'];
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const provider = searchParams.get('provider') as OAuthProvider | 'authkit' | null;
const stayLoggedIn = searchParams.get('stayLoggedIn') === 'true';
const searchParams = request.nextUrl.searchParams;
const provider = searchParams.get('provider') as 'GoogleOAuth' | 'AppleOAuth';
const stayLoggedIn = searchParams.get('stayLoggedIn') === 'true';
// 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 });
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: '/',
});
const authUrl = getAuthorizationUrl(provider);
return NextResponse.redirect(authUrl);
}

View File

@ -1,33 +0,0 @@
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

@ -1,69 +0,0 @@
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, deletePushSubscriptionD1 } from '@/lib/d1';
import { getUsersForRemindersD1, updateLastNotifiedD1 } from '@/lib/d1';
// Configure web-push - Helper called inside handler to ensure env is ready
function ensureVapidConfig() {
@ -85,20 +85,6 @@ 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();
@ -139,20 +125,16 @@ export async function GET(request: NextRequest) {
let tag = '';
if (user.frequency === 'hourly') {
const [currentH, currentM] = userTimeString.split(':').map(Number);
const [currentH, currentM] = userTimeString.split(':');
const [startH, startM] = (user.hourlyStart || '09:00').split(':');
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);
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) {
// Only send if we are in the time window AND the current minute matches the start minute
if (userTimeString >= startStr && userTimeString <= endStr && currentM === startM) {
if (user.lastNotifiedDate !== currentHourKey) {
shouldSend = true;
const { title: t, message } = getNotificationData(userTimeString);
@ -225,21 +207,8 @@ export async function GET(request: NextRequest) {
} catch (err) {
console.error(`Failed to process user ${user.userId}:`, err);
// 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) });
}
processed.push({ userId: user.userId, status: 'error', error: String(err) });
// If 410 Gone, we should delete subscription, but for now just log
}
}

View File

@ -101,18 +101,10 @@ 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 || !['amazing', 'good', 'neutral', 'bad', 'terrible'].includes(mood)) {
if (!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,10 +20,6 @@ export async function GET() {
quitPlan: null,
userName: null,
userAge: null,
religion: null,
lastNicotineUsageTime: null,
lastWeedUsageTime: null,
lastSeenReleaseNotesVersion: null,
});
}
@ -47,7 +43,6 @@ export async function GET() {
religion: preferences.religion,
lastNicotineUsageTime: preferences.lastNicotineUsageTime,
lastWeedUsageTime: preferences.lastWeedUsageTime,
lastSeenReleaseNotesVersion: preferences.lastSeenReleaseNotesVersion,
});
} catch (error) {
console.error('Error fetching preferences:', error);
@ -74,38 +69,8 @@ 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)
@ -122,7 +87,6 @@ export async function POST(request: NextRequest) {
religion: body.religion,
lastNicotineUsageTime: body.lastNicotineUsageTime,
lastWeedUsageTime: body.lastWeedUsageTime,
lastSeenReleaseNotesVersion: body.lastSeenReleaseNotesVersion,
});
if (!preferences) {
@ -149,7 +113,6 @@ 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,20 +50,6 @@ 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,17 +43,6 @@ 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);
@ -86,17 +75,6 @@ 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,111 +4,105 @@
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(0.9838 0.0035 247.8583);
--foreground: oklch(0.1284 0.0267 261.5937);
--background: oklch(0.9789 0.0082 121.6272);
--foreground: oklch(0 0 0);
--card: oklch(1.0000 0 0);
--card-foreground: oklch(0.1284 0.0267 261.5937);
--card-foreground: oklch(0 0 0);
--popover: oklch(1.0000 0 0);
--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;
--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);
--radius: 1rem;
--shadow-x: 0px;
--shadow-y: 8px;
--shadow-blur: 30px;
--shadow-y: 0px;
--shadow-blur: 0px;
--shadow-spread: 0px;
--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;
--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;
--spacing: 0.25rem;
}
.dark {
--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;
--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);
--radius: 1rem;
--shadow-x: 0px;
--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);
--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);
}
@theme inline {
@ -145,9 +139,9 @@
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--font-serif: var(--font-serif);
--font-sans: 'DM Sans', sans-serif;
--font-mono: 'Space Mono', monospace;
--font-serif: 'DM Sans', sans-serif;
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
@ -170,19 +164,57 @@
--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 bg-background text-foreground;
@apply text-foreground;
font-family: var(--font-sans);
letter-spacing: var(--tracking-normal);
background-color: transparent;
min-height: 100vh;
min-height: 100dvh;
overflow-x: clip;
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%);
}
/* Calendar styling - optimize cell size */
@ -613,21 +645,18 @@
@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: 1rem;
padding: 0.25rem 0 0.5rem;
gap: 1.25rem;
padding: 0.5rem 0 1.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;
}
@ -637,14 +666,10 @@
}
.swipe-item {
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;
flex: 0 0 calc(100vw - 3rem);
width: calc(100vw - 3rem);
scroll-snap-align: center;
height: fit-content;
}
}
@ -697,4 +722,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?v=2",
apple: "/icons/apple-touch-icon.png",
},
};

View File

@ -1,33 +1,120 @@
'use client';
import { useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { UnifiedLogin } from '@/components/UnifiedLogin';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
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-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 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>
<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>
{/* 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>
</div>
</div>
);

View File

@ -1,6 +0,0 @@
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('/home');
redirect('/login');
}
return <Dashboard user={user} />;

View File

@ -1,147 +0,0 @@
'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,13 +1,12 @@
'use client';
import React, { useMemo } from 'react';
import React, { useMemo, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Achievement, BADGE_DEFINITIONS } from '@/lib/storage';
import { Achievement, BADGE_DEFINITIONS, BadgeDefinition } from '@/lib/storage';
import { useTheme } from '@/lib/theme-context';
import {
Trophy,
Lock,
CheckCircle2,
Footprints,
Flame,
Shield,
@ -31,6 +30,7 @@ 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,9 +49,11 @@ 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 h-full min-h-[62dvh] sm:min-h-0 flex flex-col`}
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-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" />
@ -59,20 +61,46 @@ function AchievementsCardComponent({ achievements, substance }: AchievementsCard
</CardTitle>
</CardHeader>
<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">
<CardContent className="relative z-10">
<div className="grid grid-cols-3 gap-3">
{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 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'
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'
: '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'
@ -81,37 +109,18 @@ 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-auto pt-4 text-center">
<div className="mt-4 text-center">
<p className="text-sm text-white/70">
{unlockedBadgeIds.size} of {BADGE_DEFINITIONS.length} badges unlocked
</p>

View File

@ -138,6 +138,8 @@ 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,7 +22,6 @@ import {
SavingsConfig,
BADGE_DEFINITIONS,
BadgeDefinition,
StorageRequestError,
} from '@/lib/storage';
import { UserHeader } from './UserHeader';
import { SetupWizard } from './SetupWizard';
@ -34,12 +33,10 @@ import { CelebrationAnimation } from './CelebrationAnimation';
import { HealthTimelineCard } from './HealthTimelineCard';
import { SavingsTrackerCard } from './SavingsTrackerCard';
import { MoodTracker } from './MoodTracker';
import { DailyInspirationCard } from './DailyInspirationCard';
import { ScrollWheelLogger } from './ScrollWheelLogger';
import { UsageLoggerDropUp } from './UsageLoggerDropUp';
import { VersionUpdateModal } from './VersionUpdateModal';
import { Button } from '@/components/ui/button';
import { PlusCircle, X } from 'lucide-react';
import { PlusCircle, ChevronLeft, ChevronRight, X } from 'lucide-react';
import { useTheme } from '@/lib/theme-context';
import { getTodayString } from '@/lib/date-utils';
@ -48,18 +45,6 @@ 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[]>([]);
@ -71,7 +56,6 @@ 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);
@ -79,92 +63,45 @@ export function Dashboard({ user }: DashboardProps) {
const { theme } = useTheme();
const isModalOpen = modalOpenCount > 0 || showSetup || showCelebration;
const totalPages = MOBILE_SLIDES.length;
const isNavHidden = isModalOpen || isSubstancePickerOpen || !!activeLoggingSubstance;
const handleModalStateChange = useCallback((isOpen: boolean) => {
setModalOpenCount(prev => isOpen ? prev + 1 : Math.max(0, prev - 1));
}, []);
const handleScroll = useCallback(() => {
const container = swipeContainerRef.current;
if (!container) return;
const slides = Array.from(container.querySelectorAll<HTMLElement>('.swipe-item'));
if (slides.length === 0) return;
let nearestIndex = 0;
let nearestDistance = Number.POSITIVE_INFINITY;
slides.forEach((slide, index) => {
const distance = Math.abs(container.scrollLeft - slide.offsetLeft);
if (distance < nearestDistance) {
nearestDistance = distance;
nearestIndex = index;
}
});
if (nearestIndex !== currentPage) {
setCurrentPage(nearestIndex);
if (!swipeContainerRef.current) return;
const scrollLeft = swipeContainerRef.current.scrollLeft;
const width = swipeContainerRef.current.offsetWidth;
const page = Math.round(scrollLeft / width);
if (page !== currentPage) {
setCurrentPage(page);
}
}, [currentPage]);
const scrollToPage = (pageIndex: number) => {
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,
if (!swipeContainerRef.current) return;
const width = swipeContainerRef.current.offsetWidth;
swipeContainerRef.current.scrollTo({
left: pageIndex * width,
behavior: 'smooth'
});
};
useEffect(() => {
if (typeof window === 'undefined') return;
const savedPage = Number(localStorage.getItem('quittraq_mobile_dashboard_page'));
if (Number.isNaN(savedPage) || savedPage < 0 || savedPage >= totalPages) return;
setCurrentPage(savedPage);
const timeout = window.setTimeout(() => {
scrollToPage(savedPage);
}, 0);
return () => window.clearTimeout(timeout);
}, [totalPages]);
useEffect(() => {
if (typeof window === 'undefined') return;
localStorage.setItem('quittraq_mobile_dashboard_page', String(currentPage));
}, [currentPage]);
const loadData = useCallback(async () => {
try {
const [prefs, usage, achvs, savings] = await Promise.all([
fetchPreferences(),
fetchUsageData(),
fetchAchievements(),
fetchSavingsConfig(),
]);
setPreferences(prefs);
setUsageData(usage);
setAchievements(achvs);
setSavingsConfig(savings);
setLoadError(null);
console.log('[Dashboard] Loaded prefs:', prefs);
setRefreshKey(prev => prev + 1);
return { prefs, usage, achvs };
} catch (error) {
const message = error instanceof StorageRequestError
? error.message
: 'Unable to sync your dashboard right now. Please try again.';
setLoadError(message);
throw error;
}
const [prefs, usage, achvs, savings] = await Promise.all([
fetchPreferences(),
fetchUsageData(),
fetchAchievements(),
fetchSavingsConfig(),
]);
setPreferences(prefs);
setUsageData(usage);
setAchievements(achvs);
setSavingsConfig(savings);
console.log('[Dashboard] Loaded prefs:', prefs);
setRefreshKey(prev => prev + 1);
return { prefs, usage, achvs };
}, []);
const checkAndUnlockAchievements = useCallback(async (
@ -174,47 +111,11 @@ 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;
@ -252,26 +153,22 @@ export function Dashboard({ user }: DashboardProps) {
useEffect(() => {
const init = async () => {
try {
const { prefs, usage, achvs } = await loadData();
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();
@ -320,17 +217,8 @@ export function Dashboard({ user }: DashboardProps) {
...preferences,
[substance === 'nicotine' ? 'lastNicotineUsageTime' : 'lastWeedUsageTime']: now,
};
// Force specific fields to be present to avoid partial update issues
// This ensures that even if preferences is stale, we explicitly set the usage time
const payload: UserPreferences = {
...latestPrefs,
lastNicotineUsageTime: substance === 'nicotine' ? now : (latestPrefs.lastNicotineUsageTime ?? null),
lastWeedUsageTime: substance === 'weed' ? now : (latestPrefs.lastWeedUsageTime ?? null),
};
await savePreferencesAsync(payload);
setPreferences(payload);
await savePreferencesAsync(latestPrefs);
setPreferences(latestPrefs);
}
setActiveLoggingSubstance(null);
@ -408,40 +296,11 @@ export function Dashboard({ user }: DashboardProps) {
onModalStateChange={handleModalStateChange}
/>
<main className="container mx-auto px-4 py-4 sm:py-8 pb-28 sm:pb-8 max-w-full">
{loadError && (
<div className={`mb-4 rounded-xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm ${theme === 'light' ? 'text-amber-800' : 'text-amber-100'}`}>
<div className="flex items-center justify-between gap-3">
<span>{loadError}</span>
<Button
variant="outline"
size="sm"
className="h-8"
onClick={async () => {
setIsLoading(true);
try {
await loadData();
} finally {
setIsLoading(false);
}
}}
>
Retry
</Button>
</div>
</div>
)}
{!preferences && !isLoading && (
<div className="rounded-2xl border border-border bg-card/70 p-6 text-center">
<p className="text-sm text-muted-foreground">Your dashboard data is unavailable right now.</p>
</div>
)}
<main className="container mx-auto px-4 py-4 sm:py-8 pb-4 sm:pb-8 max-w-full">
{preferences && (
<>
{/* Floating Log Button - Simplified to toggle Picker */}
<div className={`fixed bottom-[6.5rem] sm:bottom-6 left-1/2 -translate-x-1/2 sm:left-auto sm:translate-x-0 sm:right-6 z-40 transition-all duration-300 ${isModalOpen ? 'opacity-0 scale-90 pointer-events-none' : 'opacity-100 scale-100'} sm:block`}>
<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`}>
<Button
size="lg"
onClick={() => setIsSubstancePickerOpen(!isSubstancePickerOpen)}
@ -466,153 +325,70 @@ export function Dashboard({ user }: DashboardProps) {
{/* Dashboard Sections */}
<div className="space-y-6 sm:space-y-12 relative overflow-hidden">
{/* DESKTOP LAYOUT - Hidden on mobile */}
<div className="hidden sm:block space-y-8">
{/* Row 1: Mood + Quit Plan */}
<div className="grid grid-cols-2 gap-6">
<MoodTracker />
<UnifiedQuitPlanCard
preferences={preferences}
usageData={usageData}
onGeneratePlan={handleGeneratePlan}
refreshKey={refreshKey}
variant="desktop"
/>
</div>
{/* Row 2: Calendar/Quote */}
<div id="calendar-section">
<UsageCalendar
key={refreshKey}
usageData={usageData}
onDataUpdate={loadData}
userId={user.id}
religion={preferences.religion}
onReligionUpdate={async (religion: 'christian' | 'secular') => {
const updatedPrefs = { ...preferences, religion };
setPreferences(updatedPrefs);
await savePreferencesAsync(updatedPrefs);
{/* 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)'
}}
showInspirationPanel
preferences={preferences}
onPreferencesUpdate={async (updatedPrefs: UserPreferences) => {
await savePreferencesAsync(updatedPrefs);
setPreferences(updatedPrefs);
>
<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)'
}}
/>
</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>
>
<ChevronRight className="h-8 w-8 text-primary group-hover:scale-110" />
</button>
)}
</div>
{/* MOBILE SWIPE LAYOUT - Hidden on desktop */}
{/* SECTION: Mobile Swipe Ecosystem */}
<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"
className="swipe-container sm:space-y-12 sm:block"
>
{/* 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>
{/* 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="w-full max-w-[30rem] mx-auto space-y-3">
<div className="space-y-4 sm:grid sm:grid-cols-2 sm:gap-6 sm:space-y-0">
<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">
{/* Unified Quit Plan Placard */}
<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>
{/* 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="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">
<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}
@ -621,26 +397,17 @@ export function Dashboard({ user }: DashboardProps) {
</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>
{/* 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="w-full max-w-[30rem] mx-auto">
<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}
/>
</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}
@ -652,47 +419,31 @@ export function Dashboard({ user }: DashboardProps) {
</div>
</div>
{/* SLIDE 7: Calendar */}
<div id="calendar-section-mobile" className="swipe-item">
<div className="flex items-center justify-between mb-2 px-1">
{/* 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>
<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>
<UsageCalendar
key={refreshKey}
usageData={usageData}
onDataUpdate={loadData}
userId={user.id}
religion={preferences.religion}
onReligionUpdate={async (religion: 'christian' | 'secular') => {
const updatedPrefs = { ...preferences, religion };
setPreferences(updatedPrefs);
await savePreferencesAsync(updatedPrefs);
}}
preferences={preferences}
onPreferencesUpdate={async (updatedPrefs: UserPreferences) => {
await savePreferencesAsync(updatedPrefs);
setPreferences(updatedPrefs);
}}
/>
</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>
</>
)}
@ -717,19 +468,6 @@ 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,7 +18,6 @@ import {
Cigarette,
Leaf
} from 'lucide-react';
import { getTodayString, getLocalDateString } from '@/lib/date-utils';
interface HealthTimelineCardProps {
usageData: UsageEntry[];
@ -226,57 +225,29 @@ function HealthTimelineCardComponent({
// Calculate last usage timestamps only when data changes
const lastUsageTimes = useMemo(() => {
const getTimestamp = (substance: 'nicotine' | 'weed') => {
let lastTime = 0;
// 1. Check for stored timestamp
// 1. Check for stored timestamp first
const stored = substance === 'nicotine' ? preferences?.lastNicotineUsageTime : preferences?.lastWeedUsageTime;
if (stored) {
lastTime = new Date(stored).getTime();
}
if (stored) return new Date(stored).getTime();
// 2. Check usage data (usually more up-to-date for "just logged")
// 2. Fallback to usage data
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);
const entryTime = d.getTime();
// Take the more recent of the two
lastTime = Math.max(lastTime, entryTime);
return d.getTime();
}
// 3. Fallback to start date if no usage found
if (lastTime === 0 && preferences?.trackingStartDate) {
// 3. Fallback to start date
if (preferences?.trackingStartDate) {
const d = new Date(preferences.trackingStartDate);
d.setHours(0, 0, 0, 0);
lastTime = d.getTime();
return d.getTime();
}
return lastTime || null;
return null;
};
return {
@ -311,9 +282,11 @@ 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-[min(calc(58dvh+13rem),43rem)] sm: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-[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" />
@ -329,7 +302,6 @@ function HealthTimelineCardComponent({
<TimelineColumn substance="nicotine" minutesFree={nicotineMinutes} theme={theme} />
<TimelineColumn substance="weed" minutesFree={weedMinutes} theme={theme} />
</div>
</CardContent>
</Card>
);

View File

@ -10,20 +10,13 @@ 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' }>;
}
interface InstallAppButtonProps {
compact?: boolean;
className?: string;
onBeforeOpen?: () => void;
}
export function InstallAppButton({ compact = false, className, onBeforeOpen }: InstallAppButtonProps) {
export function InstallAppButton() {
const [showInstructions, setShowInstructions] = useState(false);
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
const [isIOS, setIsIOS] = useState(false);
@ -57,7 +50,6 @@ export function InstallAppButton({ compact = false, className, onBeforeOpen }: I
}, []);
const handleInstallClick = async () => {
onBeforeOpen?.();
if (deferredPrompt) {
// Use the native install prompt (Android/Chrome)
await deferredPrompt.prompt();
@ -82,21 +74,11 @@ export function InstallAppButton({ compact = false, className, onBeforeOpen }: I
variant="outline"
size="sm"
onClick={handleInstallClick}
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"
className="gap-2 border-purple-500/30 hover:bg-purple-500/10 hover:border-purple-500/50"
>
<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>
</>
)}
<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>
</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 h-full",
"overflow-hidden transition-all duration-700 ease-in-out backdrop-blur-xl border shadow-xl",
"bg-gradient-to-br",
gradientClass
)}>

View File

@ -84,6 +84,8 @@ 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,6 +81,8 @@ 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" />
@ -125,6 +127,8 @@ 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,10 +10,7 @@ import {
Settings,
Shield,
Heart,
Calendar,
Bell,
Moon,
Sun
Calendar
} from 'lucide-react';
import React from 'react';
import { useRouter } from 'next/navigation';
@ -21,7 +18,6 @@ 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;
@ -34,12 +30,11 @@ interface SideMenuProps {
profilePictureUrl?: string | null;
};
userName: string | null;
onOpenNotifications: () => void;
}
export function SideMenu({ isOpen, onClose, user, userName, onOpenNotifications }: SideMenuProps) {
export function SideMenu({ isOpen, onClose, user, userName }: SideMenuProps) {
const router = useRouter();
const { theme, toggleTheme } = useTheme();
const { theme } = useTheme();
if (!isOpen) return null;
@ -77,10 +72,10 @@ export function SideMenu({ isOpen, onClose, user, userName, onOpenNotifications
{/* Menu Content */}
<div
className={cn(
"relative ml-auto w-72 h-full flex flex-col shadow-2xl transition-all animate-in slide-in-from-right duration-300",
"relative w-72 h-full flex flex-col shadow-2xl transition-all animate-in slide-in-from-left duration-300",
theme === 'light'
? "bg-white text-slate-900 border-l border-slate-100"
: "bg-slate-900 text-white border-l border-white/5"
? "bg-white text-slate-900"
: "bg-slate-900 text-white border-r border-white/5"
)}
>
{/* Header/Profile Info */}
@ -108,59 +103,6 @@ export function SideMenu({ isOpen, onClose, user, userName, onOpenNotifications
{/* 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"
@ -198,18 +140,12 @@ export function SideMenu({ isOpen, onClose, user, userName, onOpenNotifications
"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="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"
className="w-full flex items-center gap-3 px-3 py-3 rounded-xl text-red-500 hover:bg-red-500/10 transition-colors font-medium mt-1"
>
<LogOut className="absolute left-3 h-5 w-5" />
<span className="text-center leading-none">Sign out</span>
<LogOut className="h-5 w-5" />
<span>Sign out</span>
</button>
</div>
</div>
@ -240,20 +176,20 @@ function MenuLink({ icon: Icon, label, onClick, color }: MenuLinkProps) {
<button
onClick={onClick}
className={cn(
"relative w-full flex items-center justify-center px-3 py-3 rounded-xl transition-all font-medium group",
"w-full flex items-center gap-3 px-3 py-3 rounded-xl transition-all font-medium group",
theme === 'light'
? "hover:bg-slate-100"
: "hover:bg-white/5"
)}
>
<div className={cn(
"absolute left-3 p-2 rounded-lg transition-colors",
"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="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" />
<span className="flex-1 text-left text-sm">{label}</span>
<div className="w-1.5 h-1.5 rounded-full bg-primary opacity-0 group-hover:opacity-100 transition-opacity" />
</button>
);
}

View File

@ -21,7 +21,7 @@ const smokingAids = [
color: 'text-sky-400',
themeColor: 'sky',
gradient: 'from-sky-500/20 to-indigo-500/20',
borderColor: 'border-white/10',
borderColor: 'border-sky-500/30',
},
{
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-white/10',
borderColor: 'border-rose-500/30',
},
{
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-white/10',
borderColor: 'border-amber-500/30',
},
{
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-white/10',
borderColor: 'border-emerald-500/30',
},
];
@ -101,9 +101,10 @@ export function SmokingAidsContent() {
style={{ animationDelay: `${index * 150}ms` }}
>
<Card
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"
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"
style={{
background: cardBackground
background: cardBackground,
borderColor: `rgba(var(--${item.themeColor}-500), 0.3)`
}}
>
{/* Specific card glow on hover */}

View File

@ -92,6 +92,7 @@ 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,20 +17,12 @@ 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 () => {
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);
}
const usage = await fetchUsageData();
setUsageData(usage);
setIsLoading(false);
}, []);
useEffect(() => {
@ -86,22 +78,23 @@ 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'}`}>
0 {unitLabel} recorded, amazing job so far!
Great job, nothing yet!
</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, but don't stress.
{todayCount} {todayCount === 1 ? (substance === 'nicotine' ? 'puff' : 'hit') : unitLabel} recorded, you got this!
</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>
)}
{/* 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 */}
<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

@ -1,398 +0,0 @@
'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, { useEffect, useMemo, useState } from 'react';
import React, { useState, useMemo } 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 { TrendingDown, ChevronDown, ChevronUp, Cigarette, Leaf, AlertTriangle, XCircle } from 'lucide-react';
import { Target, TrendingDown, ChevronDown, ChevronUp, Cigarette, Leaf } 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,7 +76,6 @@ 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
@ -102,10 +101,7 @@ function SubstancePlanSection({
{/* HEADER / SUMMARY ROW */}
<div
onClick={onToggle}
className={cn(
"flex items-center justify-between p-4",
onToggle && "cursor-pointer hover:bg-black/5 active:bg-black/10 transition-colors"
)}
className="flex items-center justify-between p-4 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")}>
@ -126,110 +122,70 @@ function SubstancePlanSection({
{todayUsage}{activePlan ? ` / ${currentTarget}` : ''}
</span>
</div>
{onToggle && (isExpanded ? <ChevronUp className="h-5 w-5 opacity-30" /> : <ChevronDown className="h-5 w-5 opacity-30" />)}
{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>
{!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>
</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>
</div>
{isUnlocked ? (
<div className="text-center space-y-3">
<p className="text-sm">
Baseline established: <strong className={accentColor}>{currentAverage} {unitLabel}/day</strong>
{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>
<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">
)}
</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">{unitLabel} allowed today</p>
<p className="text-xs opacity-50 mt-1">puffs allowed today</p>
</div>
{/* Progress Bar Detail */}
<div className="space-y-1.5 pb-5">
<div className="space-y-1.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="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="w-full bg-black/10 rounded-full h-3 overflow-hidden">
<div
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>
className={cn("h-full transition-all duration-500", progressColor)}
style={{ width: `${Math.min(100, usagePercent)}%` }}
/>
</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) => {
@ -256,11 +212,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} {unitLabel}/day</span>
<span>Start: {activePlan.baselineAverage}/day</span>
<span>End: {new Date(activePlan.endDate).toLocaleDateString()}</span>
</div>
</div>
)}
</div>
)}
</div>
)}
</div>
@ -272,59 +228,38 @@ interface UnifiedQuitPlanCardProps {
usageData: UsageEntry[];
onGeneratePlan: (substance: 'nicotine' | 'weed') => void;
refreshKey: number;
variant?: 'desktop' | 'mobile';
}
export function UnifiedQuitPlanCard({
preferences,
usageData,
onGeneratePlan,
refreshKey,
variant = 'mobile'
refreshKey
}: UnifiedQuitPlanCardProps) {
const [expandedSubstance, setExpandedSubstance] = useState<'nicotine' | 'weed' | 'none'>('nicotine');
if (!preferences) return null;
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]);
// Determine which substances to show
const showNicotine = preferences.substance === 'nicotine' || usageData.some(e => e.substance === 'nicotine');
const showWeed = preferences.substance === 'weed' || usageData.some(e => e.substance === 'weed');
if (!showNicotine && !showWeed) return null;
return (
<Card
className={cn(
"backdrop-blur-2xl shadow-2xl border-white/10 overflow-hidden bg-white/5",
!isDesktopVariant && "max-h-[calc(100dvh-13.5rem)]"
)}
>
<Card className="backdrop-blur-2xl shadow-2xl border-white/10 overflow-hidden bg-white/5">
<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={cn("pt-2 p-2 sm:p-4", !isDesktopVariant && "overflow-y-auto overscroll-contain pr-1")}>
<CardContent className="pt-2 p-2 sm:p-4">
{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={
@ -333,8 +268,6 @@ 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')}
/>
)}
@ -342,6 +275,8 @@ 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={
@ -350,8 +285,6 @@ 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,12 +27,11 @@ 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, showInspirationPanel = false, preferences, onPreferencesUpdate }: UsageCalendarProps) {
function UsageCalendarComponent({ usageData, onDataUpdate, religion, onReligionUpdate, preferences, onPreferencesUpdate }: UsageCalendarProps) {
const [selectedDate, setSelectedDate] = useState<Date | undefined>(undefined);
const [editNicotineCount, setEditNicotineCount] = useState('');
const [editWeedCount, setEditWeedCount] = useState('');
@ -259,67 +258,60 @@ function UsageCalendarComponent({ usageData, onDataUpdate, religion, onReligionU
</div>
</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 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>
</div>
{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>
</>
)}
{/* 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}
/>
</div>
</div>

View File

@ -122,11 +122,9 @@ 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 === 'increasing' ? 'Usage Rising' : trend === 'decreasing' ? 'Dropping' : 'Stable'}
</p>
<p className="text-2xl font-bold capitalize">{trend}</p>
<p className="text-sm text-muted-foreground">
{trend === 'decreasing' ? 'Great progress!' : trend === 'increasing' ? 'Time to refocus' : 'Holding steady'}
{trend === 'decreasing' ? 'Great progress!' : trend === 'increasing' ? 'Stay strong!' : 'Holding steady'}
</p>
</div>
</div>

View File

@ -25,19 +25,13 @@ 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,
defaultReminderSettings,
} from '@/lib/storage';
import { fetchPreferences, fetchReminderSettings, saveReminderSettings, ReminderSettings, UserPreferences } from '@/lib/storage';
import { useNotifications } from '@/hooks/useNotifications';
import { useRef, useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Bell, BellOff, BellRing, Menu, Sparkles } from 'lucide-react';
import { Cigarette, Leaf, LogOut, Home, ChevronDown, Sun, Moon, Bell, BellOff, BellRing, Menu, Sparkles, Link as LinkIcon } from 'lucide-react';
import { useTheme } from '@/lib/theme-context';
import { InstallAppButton } from './InstallAppButton';
import { cn } from '@/lib/utils';
import { SideMenu } from './SideMenu';
@ -137,107 +131,8 @@ 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 } = useTheme();
const { theme, toggleTheme } = 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) {
@ -271,33 +166,26 @@ export function UserHeader({ user, preferences, onModalStateChange }: UserHeader
useEffect(() => {
const loadData = async () => {
try {
const [prefs, reminders] = await Promise.all([
preferences ? Promise.resolve(preferences) : fetchPreferences(),
fetchReminderSettings(),
]);
const [prefs, reminders] = await Promise.all([
preferences ? Promise.resolve(preferences) : fetchPreferences(),
fetchReminderSettings(),
]);
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);
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');
};
loadData();
}, [preferences]);
@ -331,400 +219,321 @@ export function UserHeader({ user, preferences, onModalStateChange }: UserHeader
return (
<>
<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)'
<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)'
}}>
{/* 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>
{/* 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]" />
</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" />
<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>
{/* 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",
"bg-gradient-to-br from-[#a78bfa] to-[#f472b6]"
theme === 'light'
? 'bg-gradient-to-br from-[#4f46e5] to-[#7c3aed]'
: 'bg-gradient-to-br from-[#a78bfa] to-[#f472b6]'
)}
onClick={() => handleNavigate('/')}
style={{
filter: 'drop-shadow(0 0 10px rgba(167, 139, 250, 0.3))'
filter: theme === 'dark' ? 'drop-shadow(0 0 10px rgba(167, 139, 250, 0.3))' : 'none'
}}
>
QuitTraq
</h1>
{userName && (
<p className="text-[10px] sm:text-xs font-medium text-white/60 tracking-wide mt-0.5 whitespace-nowrap overflow-hidden">
<p className="text-[10px] sm:text-xs font-medium text-foreground/60 tracking-wide mt-0.5 whitespace-nowrap overflow-hidden">
Welcome {userName}, you got this!
</p>
)}
</div>
{/* RIGHT: User Profile / Side Menu Trigger */}
<div className="flex items-center justify-end justify-self-end">
{/* RIGHT: Action Buttons */}
<div className="flex-1 flex items-center justify-end gap-1.5 sm:gap-3">
<button
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"
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'
)}
>
<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>
{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" />
)}
</button>
</div>
</div>
</header>
{/* Side Menu Integration */}
<SideMenu
isOpen={isSideMenuOpen}
onClose={() => setIsSideMenuOpen(false)}
user={user}
userName={userName}
onOpenNotifications={() => setShowReminderDialog(true)}
/>
{/* Side Menu Integration */}
<SideMenu
isOpen={isSideMenuOpen}
onClose={() => setIsSideMenuOpen(false)}
user={user}
userName={userName}
/>
{/* 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>
<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 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-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 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>
</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.");
}}
<button
onClick={handleToggleReminders}
disabled={!isSupported || (permission === 'denied' && !reminderSettings.enabled)}
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]'
"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"
)}
>
<Bell className="mr-2 h-4 w-4 group-hover:animate-bounce" />
{permission === 'granted' ? 'Permissions Verified' : 'Enable Push Alerts'}
</Button>
<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>
)}
{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>
{/* 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.");
}}
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>
</header>
</>
);
}

View File

@ -1,162 +0,0 @@
'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 KeyboardEvent, type ReactNode } from 'react';
import { useState, useEffect, useRef, type ReactNode } from 'react';
import { Cigarette, Leaf, Heart, Trophy, DollarSign, TrendingDown, CheckCircle, type LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
@ -169,8 +169,6 @@ const DEMO_SCREENS: DemoScreen[] = [
// Phone mockup component
function PhoneMockup({ activeScreen }: { activeScreen: number }) {
const active = DEMO_SCREENS[activeScreen];
return (
<div className="relative">
{/* Phone frame glow */}
@ -185,19 +183,25 @@ 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">{active.title}</h3>
<h3 className="text-lg font-bold">{DEMO_SCREENS[activeScreen].title}</h3>
<p className="text-xs text-muted-foreground">
{active.subtitle}
{DEMO_SCREENS[activeScreen].subtitle}
</p>
</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}
{/* 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>
</div>
@ -218,7 +222,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-card/60 border border-border scale-[0.98] opacity-70"
: "bg-white/5 border border-white/10 scale-[0.98] opacity-60"
)}
>
<div className="flex items-start gap-4">
@ -243,48 +247,19 @@ export function DemoSection() {
const [activeScreen, setActiveScreen] = useState(0);
const [isPaused, setIsPaused] = useState(false);
const sectionRefs = useRef<(HTMLDivElement | null)[]>([]);
const resumeTimerRef = useRef<number | null>(null);
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
// Timer-based rotation for mobile
useEffect(() => {
if (isPaused) return;
const mobileMedia = window.matchMedia('(max-width: 1023px)');
const reducedMotionMedia = window.matchMedia('(prefers-reduced-motion: reduce)');
if (!mobileMedia.matches || reducedMotionMedia.matches) {
return;
}
// Only run timer on mobile (will be hidden on lg+)
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[] = [];
@ -296,7 +271,6 @@ export function DemoSection() {
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
if (!window.matchMedia('(min-width: 1024px)').matches) return;
setActiveScreen(index);
}
});
@ -343,7 +317,6 @@ 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 */}
@ -360,9 +333,9 @@ export function DemoSection() {
key={screen.id}
ref={(el) => { sectionRefs.current[index] = el; }}
className={cn(
"min-h-[52vh] flex items-center py-6",
"min-h-[70vh] flex items-center py-8",
index === 0 && "pt-0",
index === DEMO_SCREENS.length - 1 && "min-h-[40vh]"
index === DEMO_SCREENS.length - 1 && "min-h-[50vh]"
)}
>
<FeatureCard screen={screen} isActive={activeScreen === index} />
@ -389,29 +362,15 @@ export function DemoSection() {
<div className="w-full max-w-md text-center">
<div className="space-y-4">
{/* Screen descriptions as buttons */}
<div role="tablist" aria-label="QuitTraq demo screens" className="space-y-4">
{DEMO_SCREENS.map((screen, index) => (
{DEMO_SCREENS.map((screen, index) => (
<button
key={screen.id}
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);
}}
onClick={() => setActiveScreen(index)}
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-card/60 border border-transparent"
: "hover:bg-white/5 border border-transparent"
)}
>
<h4
@ -424,8 +383,7 @@ export function DemoSection() {
</h4>
<p className="text-sm text-muted-foreground">{screen.subtitle}</p>
</button>
))}
</div>
))}
</div>
{/* Dot indicators */}
@ -433,13 +391,12 @@ export function DemoSection() {
{DEMO_SCREENS.map((_, index) => (
<button
key={index}
type="button"
onClick={() => handleManualSelect(index)}
onClick={() => setActiveScreen(index)}
className={cn(
"w-2 h-2 rounded-full transition-all",
activeScreen === index
? "w-6 bg-primary"
: "bg-muted-foreground/30 hover:bg-muted-foreground/60"
: "bg-white/20 hover:bg-white/40"
)}
aria-label={`Go to screen ${index + 1}`}
/>

View File

@ -52,6 +52,9 @@ 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-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]",
"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",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm hover:shadow-md",
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"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",
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
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 shadow-sm",
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
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",
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",
},
},
defaultVariants: {

View File

@ -38,7 +38,6 @@ export interface UserPreferencesRow {
religion: string | null;
lastNicotineUsageTime: string | null;
lastWeedUsageTime: string | null;
lastSeenReleaseNotesVersion: string | null;
quitPlanJson: string | null;
createdAt: string;
updatedAt: string;
@ -75,38 +74,22 @@ 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); }
// 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.lastNicotineUsageTime !== undefined) { updates.push('lastNicotineUsageTime = ?'); values.push(data.lastNicotineUsageTime); }
if (data.lastWeedUsageTime !== undefined) { updates.push('lastWeedUsageTime = ?'); values.push(data.lastWeedUsageTime); }
if (data.quitPlanJson !== undefined) { updates.push('quitPlanJson = ?'); values.push(data.quitPlanJson); }
updates.push('updatedAt = ?');
values.push(now);
values.push(userId);
if (updates.length > 1) { // At least updatedAt is always there
await db.prepare(
`UPDATE UserPreferences SET ${updates.join(', ')} WHERE userId = ?`
).bind(...values).run();
}
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, lastSeenReleaseNotesVersion, quitPlanJson, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
`INSERT INTO UserPreferences (id, userId, substance, trackingStartDate, hasCompletedSetup, dailyGoal, userName, userAge, religion, lastNicotineUsageTime, lastWeedUsageTime, quitPlanJson, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).bind(
id,
userId,
@ -119,7 +102,6 @@ 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
@ -227,17 +209,6 @@ 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;
@ -249,7 +220,7 @@ export async function createAchievementD1(userId: string, badgeId: string, subst
const now = new Date().toISOString();
await db.prepare(
`INSERT OR IGNORE INTO Achievement (id, userId, badgeId, unlockedAt, substance)
`INSERT INTO Achievement (id, userId, badgeId, unlockedAt, substance)
VALUES (?, ?, ?, ?, ?)`
).bind(id, userId, badgeId, now, substance).run();
@ -456,15 +427,6 @@ 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,43 +16,6 @@ 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();
@ -63,20 +26,8 @@ export async function getSession(): Promise<Session | null> {
}
try {
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 JSON.parse(sessionCookie.value);
} catch {
return null;
}
}
@ -85,11 +36,7 @@ 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
const payload = JSON.stringify(session);
const payloadBase64 = btoa(payload);
const signature = await sign(payload, SESSION_SECRET);
cookieStore.set(SESSION_COOKIE_NAME, `${payloadBase64}.${signature}`, {
cookieStore.set(SESSION_COOKIE_NAME, JSON.stringify(session), {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',

View File

@ -27,7 +27,6 @@ export interface UserPreferences {
religion: 'christian' | 'secular' | null;
lastNicotineUsageTime?: string | null;
lastWeedUsageTime?: string | null;
lastSeenReleaseNotesVersion?: string | null;
}
export interface QuitPlan {
@ -127,25 +126,8 @@ 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;
@ -178,17 +160,15 @@ export async function fetchPreferences(): Promise<UserPreferences> {
try {
const response = await fetch('/api/preferences', { cache: 'no-store' });
if (!response.ok) {
throw new StorageRequestError('Unable to load profile preferences.', response.status);
console.error('Failed to fetch preferences');
return defaultPreferences;
}
const data = await response.json() as UserPreferences;
preferencesCache = data;
return data;
} catch (error) {
console.error('Error fetching preferences:', error);
if (error instanceof StorageRequestError) {
throw error;
}
throw new StorageRequestError('Unable to load profile preferences.');
return defaultPreferences;
}
}
@ -201,9 +181,6 @@ 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);
@ -215,17 +192,15 @@ export async function fetchUsageData(): Promise<UsageEntry[]> {
try {
const response = await fetch('/api/usage', { cache: 'no-store' });
if (!response.ok) {
throw new StorageRequestError('Unable to load usage history.', response.status);
console.error('Failed to fetch usage data');
return [];
}
const data = await response.json() as UsageEntry[];
usageDataCache = data;
return data;
} catch (error) {
console.error('Error fetching usage data:', error);
if (error instanceof StorageRequestError) {
throw error;
}
throw new StorageRequestError('Unable to load usage history.');
return [];
}
}
@ -328,18 +303,13 @@ export async function fetchReminderSettings(): Promise<ReminderSettings> {
if (reminderSettingsCache) return reminderSettingsCache;
try {
const response = await fetch('/api/reminders');
if (!response.ok) {
throw new StorageRequestError('Unable to load reminder settings.', response.status);
}
if (!response.ok) return { enabled: false, reminderTime: '09:00', frequency: 'daily' };
const data = await response.json() as ReminderSettings;
reminderSettingsCache = data;
return data;
} catch (error) {
console.error('Error fetching reminder settings:', error);
if (error instanceof StorageRequestError) {
throw error;
}
throw new StorageRequestError('Unable to load reminder settings.');
return { enabled: false, reminderTime: '09:00', frequency: 'daily' };
}
}
@ -359,7 +329,7 @@ export async function saveReminderSettings(settings: ReminderSettings): Promise<
}
export function getReminderSettings(): ReminderSettings {
return reminderSettingsCache || defaultReminderSettings;
return reminderSettingsCache || { enabled: false, reminderTime: '09:00', frequency: 'daily' };
}
// ============ SAVINGS FUNCTIONS ============
@ -516,10 +486,6 @@ 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>();
@ -607,7 +573,7 @@ export function checkBadgeEligibility(
};
switch (badgeId) {
case 'first_day': return hasLoggedUsageForSubstance;
case 'first_day': return stats.totalDays >= 1;
case 'streak_3': return streak >= 3;
case 'streak_7': return stats.totalDays >= 7;
case 'fighter':

View File

@ -4,25 +4,10 @@ export const workos = new WorkOS(process.env.WORKOS_API_KEY!);
export const clientId = process.env.WORKOS_CLIENT_ID!;
export type OAuthProvider = 'GoogleOAuth' | 'AppleOAuth' | 'GitHubOAuth';
export function getAuthorizationUrl(provider: OAuthProvider) {
export function getAuthorizationUrl(provider: 'GoogleOAuth' | 'AppleOAuth') {
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()}`;
}