Compare commits
31 Commits
feat/landi
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95e60594e8 | ||
|
|
e8f47993a9 | ||
|
|
e5b3f649be | ||
|
|
c31f8d8cfe | ||
|
|
80917efa8f | ||
|
|
730ed1a76a | ||
|
|
371c1e4618 | ||
|
|
d957f7525f | ||
|
|
805508a413 | ||
|
|
7ee0aff52f | ||
|
|
711b5d838a | ||
|
|
4e8fe2a91c | ||
|
|
42841f665c | ||
|
|
834524bece | ||
|
|
d166e92f8c | ||
|
|
3c3f803a1c | ||
|
|
5f87c79b58 | ||
|
|
c3f9a4fc9a | ||
|
|
2eb8d25a06 | ||
|
|
242b098292 | ||
| bd68c1bbed | |||
|
|
b46c220027 | ||
|
|
5795fd0468 | ||
|
|
485f7a1e32 | ||
|
|
60ca78b9d8 | ||
|
|
27854f8a10 | ||
|
|
468f02fbc8 | ||
|
|
31addc066e | ||
|
|
95cfdabe36 | ||
|
|
d6b191a201 | ||
|
|
2a0f162ea3 |
6
.gitignore
vendored
@ -33,6 +33,8 @@ yarn-error.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
.dev.vars
|
||||
.dev.vars.*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
@ -49,3 +51,7 @@ next-env.d.ts
|
||||
.open-next/
|
||||
.open-next 2/
|
||||
.wrangler/
|
||||
|
||||
# local editor / temp
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
116
DEV_SETUP.md
Normal file
@ -0,0 +1,116 @@
|
||||
# Dev Setup Notes (Local)
|
||||
|
||||
This file captures the local development setup used in this session so future work can resume quickly.
|
||||
|
||||
## Project Location
|
||||
|
||||
- Main working copy: `/Users/averyfelts/Desktop/DevSmokeWebsite`
|
||||
- Old typo folder exists but unused: `/Users/averyfelts/Desktop/DevSmokWebsite`
|
||||
|
||||
## Runtime Choice
|
||||
|
||||
- Use Cloudflare Worker dev flow (not plain `next dev`) for D1-backed features.
|
||||
- Dev server is pinned to port `3000` to avoid conflicts with Signet tooling.
|
||||
|
||||
## Commands
|
||||
|
||||
From `/Users/averyfelts/Desktop/DevSmokeWebsite`:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun run d1:migrate
|
||||
bun run build:worker
|
||||
bun run dev:worker
|
||||
```
|
||||
|
||||
Expected URL:
|
||||
|
||||
- `http://localhost:3000`
|
||||
|
||||
## Key Local Changes Made
|
||||
|
||||
1. `package.json`
|
||||
- Updated script:
|
||||
- `dev:worker` from `wrangler dev` to `wrangler dev --port 3000`
|
||||
|
||||
2. `.gitignore`
|
||||
- Added `.dev.vars` so local worker env secrets are not tracked.
|
||||
|
||||
3. `src/app/logo-black.png/route.ts`
|
||||
- Added compatibility route to stop `GET /logo-black.png 404`.
|
||||
- Route redirects `/logo-black.png` to `/icons/icon-192.png`.
|
||||
|
||||
## Env File Notes
|
||||
|
||||
- Local app env file: `.env.local`
|
||||
- Local wrangler env file: `.dev.vars`
|
||||
- Both are intended for local development.
|
||||
- Keep secrets out of git.
|
||||
|
||||
## Cloudflare/D1
|
||||
|
||||
- Wrangler auth verified for this machine.
|
||||
- Local D1 migrations were applied via `bun run d1:migrate`.
|
||||
|
||||
## Known Warnings
|
||||
|
||||
- Wrangler/OpenNext may print duplicate object key warnings from generated `.open-next` output.
|
||||
- These warnings did not block startup in this session.
|
||||
|
||||
## Session Preferences to Preserve
|
||||
|
||||
- Keep file reads tightly scoped to explicit paths when requested.
|
||||
- Avoid broad folder crawling unless asked.
|
||||
|
||||
## Latest Session Changes
|
||||
|
||||
### Product and UX
|
||||
|
||||
- Reworked mobile dashboard into dedicated swipe pages:
|
||||
- Mood (+ Daily Inspiration)
|
||||
- Quit Journey Plan
|
||||
- Usage Stats
|
||||
- Health Recovery
|
||||
- Achievements
|
||||
- Savings
|
||||
- Usage Calendar
|
||||
- Removed mobile side arrow buttons; swipe + dots are now primary navigation.
|
||||
- Moved floating `Log Usage` button above the mobile swipe indicator and centered it on mobile.
|
||||
- Updated profile menu to top-right and moved quick actions (notifications/install/theme) into menu.
|
||||
- Expanded Quit Journey Plan sections (no dropdown collapse), showing both nicotine and weed plans.
|
||||
- Replaced old theme token set with updated light/dark OKLCH theme variables in `src/app/globals.css`.
|
||||
|
||||
### Reliability and Auth
|
||||
|
||||
- Added explicit dashboard/substance data load error handling and retry surface.
|
||||
- Fixed hourly reminders edge cases for overnight windows.
|
||||
- Added reminder API input validation for time/frequency values.
|
||||
- Implemented real password reset initiation via WorkOS endpoint and UI wiring in login/settings.
|
||||
|
||||
### Achievements
|
||||
|
||||
- Added inline unlock guidance text under each achievement tile.
|
||||
- Added completed/locked status treatment directly in the tile.
|
||||
- Added unlock safeguards to avoid duplicate global `first_day` unlock behavior:
|
||||
- `first_day` now normalizes to `substance: both`
|
||||
- dedupe normalization in achievements API GET
|
||||
- API validation for badge/substance input
|
||||
- `INSERT OR IGNORE` safety in D1 write path
|
||||
|
||||
## Validation Commands Used
|
||||
|
||||
```bash
|
||||
bun run build
|
||||
bun run build:worker
|
||||
bun run dev:worker
|
||||
```
|
||||
|
||||
## Quick Troubleshooting
|
||||
|
||||
- If UI changes do not appear in worker mode:
|
||||
1. `bun run build:worker`
|
||||
2. `bun run dev:worker`
|
||||
3. hard refresh browser (`Cmd + Shift + R`)
|
||||
|
||||
- If mobile swipe feels off:
|
||||
- swipe behavior is controlled in `src/app/globals.css` under `.swipe-container` and `.swipe-item`.
|
||||
83
README.md
@ -1,36 +1,77 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
# QuitTraq (Stop Smoking Website v2)
|
||||
|
||||
## Getting Started
|
||||
QuitTraq is a Next.js + Cloudflare Workers app for tracking nicotine/marijuana usage, recovery progress, achievements, reminders, and savings.
|
||||
|
||||
First, run the development server:
|
||||
## Tech Stack
|
||||
|
||||
- Next.js 16 (App Router), React 19, TypeScript
|
||||
- Cloudflare Workers via OpenNext
|
||||
- Cloudflare D1 (SQLite)
|
||||
- WorkOS AuthKit + WorkOS User Management
|
||||
- Tailwind CSS v4
|
||||
|
||||
## Local Development
|
||||
|
||||
This app should be run in Worker mode for realistic auth + D1 behavior.
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
bun install
|
||||
bun run d1:migrate
|
||||
bun run build:worker
|
||||
bun run dev:worker
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
Local URL: `http://localhost:3000`
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
## Environment Variables
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
Create local env files:
|
||||
|
||||
## Learn More
|
||||
- `.env.local` (Next.js runtime)
|
||||
- `.dev.vars` (Wrangler runtime)
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
Required keys:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
- `WORKOS_CLIENT_ID`
|
||||
- `WORKOS_API_KEY`
|
||||
- `WORKOS_REDIRECT_URI`
|
||||
- `SESSION_SECRET`
|
||||
- `DATABASE_URL`
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
Optional keys for push notifications and cron:
|
||||
|
||||
## Deploy on Vercel
|
||||
- `NEXT_PUBLIC_VAPID_PUBLIC_KEY`
|
||||
- `VAPID_PRIVATE_KEY`
|
||||
- `VAPID_SUBJECT`
|
||||
- `CRON_SECRET`
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
## Deployment
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
```bash
|
||||
bun run deploy:app
|
||||
bun run deploy:cron
|
||||
```
|
||||
|
||||
Or run both:
|
||||
|
||||
```bash
|
||||
bun run deploy
|
||||
```
|
||||
|
||||
## Database Migrations
|
||||
|
||||
```bash
|
||||
# local D1
|
||||
bun run d1:migrate
|
||||
|
||||
# remote D1
|
||||
bun run d1:migrate:prod
|
||||
```
|
||||
|
||||
## Recent Product/UX Updates
|
||||
|
||||
- Mobile dashboard uses swipe-first pages with improved snap behavior.
|
||||
- Profile menu moved to top-right; quick actions are grouped in-menu.
|
||||
- Daily Inspiration moved under Mood on mobile.
|
||||
- Achievements cards now show unlock guidance inline.
|
||||
- Achievements unlock flow hardened to avoid duplicate global first-step unlocks.
|
||||
|
||||
1
bun.lock
@ -31,6 +31,7 @@
|
||||
"styled-jsx": "^5.1.6",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"web-push": "^3.6.7",
|
||||
"yaml": "^2.8.2",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20250121.0",
|
||||
|
||||
1
migrations/0008_add_release_notes_version.sql
Normal file
@ -0,0 +1 @@
|
||||
ALTER TABLE UserPreferences ADD COLUMN lastSeenReleaseNotesVersion TEXT;
|
||||
22092
package-lock.json
generated
Normal file
@ -9,7 +9,7 @@
|
||||
"lint": "eslint",
|
||||
"postinstall": "prisma generate",
|
||||
"build:worker": "opennextjs-cloudflare build",
|
||||
"dev:worker": "wrangler dev",
|
||||
"dev:worker": "wrangler dev --port 3000",
|
||||
"deploy:app": "bun run build:worker && wrangler deploy",
|
||||
"deploy:cron": "cd cron-worker && wrangler deploy --config wrangler.toml",
|
||||
"deploy": "bun run deploy:app && bun run deploy:cron",
|
||||
@ -40,9 +40,10 @@
|
||||
"react-day-picker": "^9.13.0",
|
||||
"react-dom": "19.2.3",
|
||||
"recharts": "^3.7.0",
|
||||
"styled-jsx": "^5.1.6",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"web-push": "^3.6.7",
|
||||
"styled-jsx": "^5.1.6"
|
||||
"yaml": "^2.8.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20250121.0",
|
||||
@ -66,4 +67,4 @@
|
||||
"sharp",
|
||||
"unrs-resolver"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ CREATE TABLE "UserPreferences" (
|
||||
"religion" TEXT,
|
||||
"lastNicotineUsageTime" TEXT,
|
||||
"lastWeedUsageTime" TEXT,
|
||||
"lastSeenReleaseNotesVersion" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
"quitPlanJson" TEXT
|
||||
@ -79,4 +80,3 @@ CREATE UNIQUE INDEX "ReminderSettings_userId_key" ON "ReminderSettings"("userId"
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "SavingsConfig_userId_key" ON "SavingsConfig"("userId");
|
||||
|
||||
|
||||
@ -24,6 +24,7 @@ model UserPreferences {
|
||||
religion String?
|
||||
lastNicotineUsageTime String?
|
||||
lastWeedUsageTime String?
|
||||
lastSeenReleaseNotesVersion String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
|
||||
BIN
public/icons/apple-touch-icon-copy.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 413 KiB After Width: | Height: | Size: 364 KiB |
BIN
public/icons/icon-192-copy.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
public/icons/icon-192.png
Normal file
|
After Width: | Height: | Size: 364 KiB |
BIN
public/icons/icon-512-copy.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 413 KiB After Width: | Height: | Size: 364 KiB |
@ -9,13 +9,13 @@
|
||||
"orientation": "portrait-primary",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"src": "/icons/icon-192.png?v=2",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"src": "/icons/icon-512.png?v=2",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
|
||||
BIN
public/videos/smoke-poster.jpg
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
public/videos/smoke.mp4
Normal file
@ -1,6 +1,18 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getSession } from '@/lib/session';
|
||||
import { getAchievementsD1, getAchievementD1, createAchievementD1 } from '@/lib/d1';
|
||||
import { getAchievementsD1, getAchievementD1, getAchievementByBadgeD1, createAchievementD1 } from '@/lib/d1';
|
||||
|
||||
const VALID_BADGE_IDS = new Set([
|
||||
'first_day',
|
||||
'streak_3',
|
||||
'streak_7',
|
||||
'fighter',
|
||||
'one_month',
|
||||
'goal_crusher',
|
||||
]);
|
||||
|
||||
const VALID_SUBSTANCES = new Set(['nicotine', 'weed', 'both']);
|
||||
const GLOBAL_BADGE_IDS = new Set(['first_day']);
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
@ -10,14 +22,22 @@ export async function GET() {
|
||||
}
|
||||
|
||||
const achievements = await getAchievementsD1(session.user.id);
|
||||
const seenGlobalBadges = new Set<string>();
|
||||
|
||||
return NextResponse.json(
|
||||
achievements.map((a) => ({
|
||||
const normalized = achievements
|
||||
.filter((a) => {
|
||||
if (!GLOBAL_BADGE_IDS.has(a.badgeId)) return true;
|
||||
if (seenGlobalBadges.has(a.badgeId)) return false;
|
||||
seenGlobalBadges.add(a.badgeId);
|
||||
return true;
|
||||
})
|
||||
.map((a) => ({
|
||||
badgeId: a.badgeId,
|
||||
unlockedAt: a.unlockedAt,
|
||||
substance: a.substance,
|
||||
}))
|
||||
);
|
||||
substance: GLOBAL_BADGE_IDS.has(a.badgeId) ? 'both' : a.substance,
|
||||
}));
|
||||
|
||||
return NextResponse.json(normalized);
|
||||
} catch (error) {
|
||||
console.error('Error fetching achievements:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
@ -38,8 +58,22 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Missing badgeId or substance' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!VALID_BADGE_IDS.has(badgeId)) {
|
||||
return NextResponse.json({ error: 'Invalid badgeId' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!VALID_SUBSTANCES.has(substance)) {
|
||||
return NextResponse.json({ error: 'Invalid substance' }, { status: 400 });
|
||||
}
|
||||
|
||||
const isGlobalBadge = GLOBAL_BADGE_IDS.has(badgeId);
|
||||
const targetSubstance = isGlobalBadge ? 'both' : substance;
|
||||
|
||||
// Check if already exists
|
||||
const existing = await getAchievementD1(session.user.id, badgeId, substance);
|
||||
const existing = isGlobalBadge
|
||||
? await getAchievementByBadgeD1(session.user.id, badgeId)
|
||||
: await getAchievementD1(session.user.id, badgeId, targetSubstance);
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json({
|
||||
badgeId: existing.badgeId,
|
||||
@ -49,7 +83,7 @@ export async function POST(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
const achievement = await createAchievementD1(session.user.id, badgeId, substance);
|
||||
const achievement = await createAchievementD1(session.user.id, badgeId, targetSubstance);
|
||||
|
||||
if (!achievement) {
|
||||
return NextResponse.json({ error: 'Failed to unlock achievement' }, { status: 500 });
|
||||
|
||||
90
src/app/api/auth/email/route.ts
Normal file
@ -0,0 +1,90 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { workos, clientId } from '@/lib/workos';
|
||||
import { setSession } from '@/lib/session';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json() as {
|
||||
email?: string;
|
||||
password?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
type?: 'signup' | 'login';
|
||||
stayLoggedIn?: boolean;
|
||||
};
|
||||
const { email, password, firstName, lastName, type, stayLoggedIn } = body;
|
||||
|
||||
if (!email || !password) {
|
||||
return NextResponse.json({ error: 'Email and password are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Handle Signup
|
||||
if (type === 'signup') {
|
||||
try {
|
||||
const newUser = await workos.userManagement.createUser({
|
||||
email,
|
||||
password,
|
||||
firstName,
|
||||
lastName,
|
||||
emailVerified: false,
|
||||
});
|
||||
|
||||
// Return early for signup to allow verification
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
pendingVerification: true,
|
||||
userId: newUser.id,
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
// Handle user already exists
|
||||
if (error.code === 'user_already_exists' ||
|
||||
(error.code === 'user_creation_error' && error.errors?.some((e: any) => e.code === 'email_not_available'))) {
|
||||
return NextResponse.json({ error: 'A user with this email already exists. Please sign in.' }, { status: 409 });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticate (Login)
|
||||
try {
|
||||
const response = await workos.userManagement.authenticateWithPassword({
|
||||
email,
|
||||
password,
|
||||
clientId,
|
||||
});
|
||||
|
||||
const user = response.user;
|
||||
|
||||
// Create session
|
||||
await setSession({
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
profilePictureUrl: user.profilePictureUrl,
|
||||
},
|
||||
accessToken: response.accessToken, // Note: SDK returns camelCase
|
||||
refreshToken: response.refreshToken,
|
||||
stayLoggedIn: !!stayLoggedIn,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, user });
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Authentication error:', error);
|
||||
|
||||
// Map WorkOS errors to user-friendly messages
|
||||
if (error.code === 'invalid_credentials' || error.message?.includes('Invalid credentials')) {
|
||||
return NextResponse.json({ error: 'Invalid email or password.' }, { status: 401 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: error.message || 'Authentication failed' }, { status: 400 });
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Login API error:', error);
|
||||
return NextResponse.json({ error: 'An internal error occurred.' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@ -1,26 +1,52 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAuthorizationUrl } from '@/lib/workos';
|
||||
import { getAuthorizationUrl, getAuthKitUrl, OAuthProvider } from '@/lib/workos';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const VALID_PROVIDERS = ['GoogleOAuth', 'AppleOAuth', 'GitHubOAuth'];
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const provider = searchParams.get('provider') as 'GoogleOAuth' | 'AppleOAuth';
|
||||
const stayLoggedIn = searchParams.get('stayLoggedIn') === 'true';
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const provider = searchParams.get('provider') as OAuthProvider | 'authkit' | null;
|
||||
const stayLoggedIn = searchParams.get('stayLoggedIn') === 'true';
|
||||
|
||||
if (!provider || !['GoogleOAuth', 'AppleOAuth'].includes(provider)) {
|
||||
return NextResponse.json({ error: 'Invalid provider' }, { status: 400 });
|
||||
// Store the stay logged in preference in a cookie
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set('stay_logged_in', stayLoggedIn.toString(), {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 10, // 10 minutes for the auth flow
|
||||
path: '/',
|
||||
});
|
||||
|
||||
// If no provider or 'authkit', redirect to hosted AuthKit (email/password, phone)
|
||||
if (!provider || provider === 'authkit') {
|
||||
const authUrl = getAuthKitUrl();
|
||||
console.log('AuthKit URL generated:', authUrl);
|
||||
|
||||
if (!authUrl || typeof authUrl !== 'string') {
|
||||
return NextResponse.json({
|
||||
error: 'Failed to generate AuthKit URL',
|
||||
debug: { authUrl, hasClientId: !!process.env.WORKOS_CLIENT_ID, hasRedirectUri: !!process.env.WORKOS_REDIRECT_URI }
|
||||
}, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.redirect(authUrl);
|
||||
}
|
||||
|
||||
// Validate OAuth provider
|
||||
if (!VALID_PROVIDERS.includes(provider)) {
|
||||
return NextResponse.json({ error: 'Invalid provider' }, { status: 400 });
|
||||
}
|
||||
|
||||
const authUrl = getAuthorizationUrl(provider);
|
||||
return NextResponse.redirect(authUrl);
|
||||
} catch (error) {
|
||||
console.error('Login route error:', error);
|
||||
return NextResponse.json({
|
||||
error: 'Authentication error',
|
||||
message: String(error)
|
||||
}, { status: 500 });
|
||||
}
|
||||
|
||||
// Store the stay logged in preference in a cookie
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set('stay_logged_in', stayLoggedIn.toString(), {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 10, // 10 minutes for the auth flow
|
||||
path: '/',
|
||||
});
|
||||
|
||||
const authUrl = getAuthorizationUrl(provider);
|
||||
return NextResponse.redirect(authUrl);
|
||||
}
|
||||
|
||||
33
src/app/api/auth/reset-password/route.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getSession } from '@/lib/session';
|
||||
import { workos } from '@/lib/workos';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json().catch(() => ({})) as { email?: string };
|
||||
const session = await getSession();
|
||||
|
||||
const email = (body.email || session?.user?.email || '').trim().toLowerCase();
|
||||
|
||||
if (!email) {
|
||||
return NextResponse.json({ error: 'Email is required.' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
await workos.userManagement.createPasswordReset({ email });
|
||||
} catch (error: any) {
|
||||
// Prevent account enumeration: we intentionally return success either way.
|
||||
console.warn('Password reset initiation warning:', error?.message || error);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'If an account exists for this email, a password reset link has been sent.'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Password reset error:', error);
|
||||
return NextResponse.json({
|
||||
error: 'An error occurred. Please try again.'
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
69
src/app/api/auth/verify/route.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { workos, clientId } from '@/lib/workos';
|
||||
import { setSession } from '@/lib/session';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json() as {
|
||||
userId?: string;
|
||||
code?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
stayLoggedIn?: boolean;
|
||||
};
|
||||
const { userId, code, email, password, stayLoggedIn } = body;
|
||||
|
||||
if (!userId || !code || !email || !password) {
|
||||
return NextResponse.json({ error: 'Missing verification details' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 1. Verify the email with the code
|
||||
try {
|
||||
await workos.userManagement.verifyEmail({
|
||||
code,
|
||||
userId,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Verification error:', error);
|
||||
return NextResponse.json({
|
||||
error: error.message || 'Invalid verification code'
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// 2. If verification successful, authenticate the user to create a session
|
||||
try {
|
||||
const response = await workos.userManagement.authenticateWithPassword({
|
||||
email,
|
||||
password,
|
||||
clientId,
|
||||
});
|
||||
|
||||
const user = response.user;
|
||||
|
||||
await setSession({
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
profilePictureUrl: user.profilePictureUrl,
|
||||
},
|
||||
accessToken: response.accessToken,
|
||||
refreshToken: response.refreshToken,
|
||||
stayLoggedIn: !!stayLoggedIn,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, user });
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Post-verification auth error:', error);
|
||||
return NextResponse.json({
|
||||
error: 'Verification successful, but login failed. Please try logging in.'
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Verify API error:', error);
|
||||
return NextResponse.json({ error: 'An internal error occurred.' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import webPush from 'web-push';
|
||||
import { getUsersForRemindersD1, updateLastNotifiedD1 } from '@/lib/d1';
|
||||
import { getUsersForRemindersD1, updateLastNotifiedD1, deletePushSubscriptionD1 } from '@/lib/d1';
|
||||
|
||||
// Configure web-push - Helper called inside handler to ensure env is ready
|
||||
function ensureVapidConfig() {
|
||||
@ -85,6 +85,20 @@ function getNotificationData(timeStr: string) {
|
||||
return { title, message };
|
||||
}
|
||||
|
||||
function toMinutes(timeStr: string) {
|
||||
const [hours, minutes] = timeStr.split(':').map(Number);
|
||||
return (hours * 60) + minutes;
|
||||
}
|
||||
|
||||
function isWithinWindow(current: number, start: number, end: number) {
|
||||
if (start <= end) {
|
||||
return current >= start && current <= end;
|
||||
}
|
||||
|
||||
// Overnight window (e.g. 22:00 -> 06:00)
|
||||
return current >= start || current <= end;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const isVapidReady = ensureVapidConfig();
|
||||
@ -125,16 +139,20 @@ export async function GET(request: NextRequest) {
|
||||
let tag = '';
|
||||
|
||||
if (user.frequency === 'hourly') {
|
||||
const [currentH, currentM] = userTimeString.split(':');
|
||||
const [startH, startM] = (user.hourlyStart || '09:00').split(':');
|
||||
const [currentH, currentM] = userTimeString.split(':').map(Number);
|
||||
const currentHourKey = `${userDateString}-${currentH}`; // YYYY-MM-DD-HH
|
||||
|
||||
// Check active hours
|
||||
const startStr = user.hourlyStart || '09:00';
|
||||
const endStr = user.hourlyEnd || '21:00';
|
||||
const startMinutes = toMinutes(startStr);
|
||||
const endMinutes = toMinutes(endStr);
|
||||
const currentMinutes = toMinutes(userTimeString);
|
||||
|
||||
// Only send if we are in the time window AND the current minute matches the start minute
|
||||
if (userTimeString >= startStr && userTimeString <= endStr && currentM === startM) {
|
||||
const cadenceMinute = Number((user.reminderTime || startStr).split(':')[1] || '0');
|
||||
|
||||
// Send once per hour at configured cadence minute while within active window
|
||||
if (isWithinWindow(currentMinutes, startMinutes, endMinutes) && currentM === cadenceMinute) {
|
||||
if (user.lastNotifiedDate !== currentHourKey) {
|
||||
shouldSend = true;
|
||||
const { title: t, message } = getNotificationData(userTimeString);
|
||||
@ -207,8 +225,21 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
} catch (err) {
|
||||
console.error(`Failed to process user ${user.userId}:`, err);
|
||||
processed.push({ userId: user.userId, status: 'error', error: String(err) });
|
||||
// If 410 Gone, we should delete subscription, but for now just log
|
||||
|
||||
// Check for 410 Gone - subscription expired, delete from DB
|
||||
const errorStr = String(err);
|
||||
if (errorStr.includes('410') || errorStr.includes('Gone') || errorStr.includes('expired')) {
|
||||
console.log(`Subscription expired for user ${user.userId}, cleaning up...`);
|
||||
try {
|
||||
await deletePushSubscriptionD1(user.userId);
|
||||
processed.push({ userId: user.userId, status: 'expired_cleaned', error: 'Subscription expired and removed' });
|
||||
} catch (deleteErr) {
|
||||
console.error(`Failed to delete expired subscription for ${user.userId}:`, deleteErr);
|
||||
processed.push({ userId: user.userId, status: 'error', error: String(err) });
|
||||
}
|
||||
} else {
|
||||
processed.push({ userId: user.userId, status: 'error', error: String(err) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -101,10 +101,18 @@ export async function POST(request: NextRequest) {
|
||||
const body = await request.json() as { mood: 'amazing' | 'good' | 'neutral' | 'bad' | 'terrible'; score: number; comment?: string; date?: string };
|
||||
const { mood, score, comment, date } = body;
|
||||
|
||||
if (!mood) {
|
||||
if (!mood || !['amazing', 'good', 'neutral', 'bad', 'terrible'].includes(mood)) {
|
||||
return NextResponse.json({ error: 'Invalid mood' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (comment && (typeof comment !== 'string' || comment.length > 500)) {
|
||||
return NextResponse.json({ error: 'Comment too long' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (date && !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
return NextResponse.json({ error: 'Invalid date format' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate score is between 0 and 100
|
||||
const finalScore = Math.max(0, Math.min(100, score ?? 50));
|
||||
|
||||
|
||||
@ -20,6 +20,10 @@ export async function GET() {
|
||||
quitPlan: null,
|
||||
userName: null,
|
||||
userAge: null,
|
||||
religion: null,
|
||||
lastNicotineUsageTime: null,
|
||||
lastWeedUsageTime: null,
|
||||
lastSeenReleaseNotesVersion: null,
|
||||
});
|
||||
}
|
||||
|
||||
@ -43,6 +47,7 @@ export async function GET() {
|
||||
religion: preferences.religion,
|
||||
lastNicotineUsageTime: preferences.lastNicotineUsageTime,
|
||||
lastWeedUsageTime: preferences.lastWeedUsageTime,
|
||||
lastSeenReleaseNotesVersion: preferences.lastSeenReleaseNotesVersion,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching preferences:', error);
|
||||
@ -69,8 +74,38 @@ export async function POST(request: NextRequest) {
|
||||
religion?: string;
|
||||
lastNicotineUsageTime?: string;
|
||||
lastWeedUsageTime?: string;
|
||||
lastSeenReleaseNotesVersion?: string;
|
||||
};
|
||||
|
||||
// Validation & Normalization
|
||||
if (body.substance && !['nicotine', 'weed'].includes(body.substance)) {
|
||||
return NextResponse.json({ error: 'Invalid substance' }, { status: 400 });
|
||||
}
|
||||
if (body.trackingStartDate && !/^\d{4}-\d{2}-\d{2}$/.test(body.trackingStartDate)) {
|
||||
return NextResponse.json({ error: 'Invalid trackingStartDate format' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Loose type checking for numbers (allow strings that parse to numbers)
|
||||
const dailyGoal = Number(body.dailyGoal);
|
||||
if (body.dailyGoal !== undefined && body.dailyGoal !== null && (isNaN(dailyGoal) || dailyGoal < 0)) {
|
||||
return NextResponse.json({ error: 'Invalid dailyGoal' }, { status: 400 });
|
||||
}
|
||||
|
||||
const userAge = Number(body.userAge);
|
||||
if (body.userAge !== undefined && body.userAge !== null && (isNaN(userAge) || userAge < 0 || userAge > 120)) {
|
||||
return NextResponse.json({ error: 'Invalid userAge' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (body.religion && !['christian', 'secular'].includes(body.religion)) {
|
||||
return NextResponse.json({ error: 'Invalid religion' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (body.lastSeenReleaseNotesVersion !== undefined && body.lastSeenReleaseNotesVersion !== null) {
|
||||
if (typeof body.lastSeenReleaseNotesVersion !== 'string' || body.lastSeenReleaseNotesVersion.length > 32) {
|
||||
return NextResponse.json({ error: 'Invalid lastSeenReleaseNotesVersion' }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// If quitState is provided in body, save it to quitPlanJson
|
||||
const quitPlanJson = body.quitState
|
||||
? JSON.stringify(body.quitState)
|
||||
@ -87,6 +122,7 @@ export async function POST(request: NextRequest) {
|
||||
religion: body.religion,
|
||||
lastNicotineUsageTime: body.lastNicotineUsageTime,
|
||||
lastWeedUsageTime: body.lastWeedUsageTime,
|
||||
lastSeenReleaseNotesVersion: body.lastSeenReleaseNotesVersion,
|
||||
});
|
||||
|
||||
if (!preferences) {
|
||||
@ -113,6 +149,7 @@ export async function POST(request: NextRequest) {
|
||||
religion: preferences.religion,
|
||||
lastNicotineUsageTime: preferences.lastNicotineUsageTime,
|
||||
lastWeedUsageTime: preferences.lastWeedUsageTime,
|
||||
lastSeenReleaseNotesVersion: preferences.lastSeenReleaseNotesVersion,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error saving preferences:', error);
|
||||
|
||||
@ -50,6 +50,20 @@ export async function POST(request: NextRequest) {
|
||||
};
|
||||
const { enabled, reminderTime, frequency, hourlyStart, hourlyEnd, timezone } = body;
|
||||
|
||||
const timeRegex = /^\d{2}:\d{2}$/;
|
||||
if (reminderTime && !timeRegex.test(reminderTime)) {
|
||||
return NextResponse.json({ error: 'Invalid reminderTime format' }, { status: 400 });
|
||||
}
|
||||
if (hourlyStart && !timeRegex.test(hourlyStart)) {
|
||||
return NextResponse.json({ error: 'Invalid hourlyStart format' }, { status: 400 });
|
||||
}
|
||||
if (hourlyEnd && !timeRegex.test(hourlyEnd)) {
|
||||
return NextResponse.json({ error: 'Invalid hourlyEnd format' }, { status: 400 });
|
||||
}
|
||||
if (frequency && !['daily', 'hourly'].includes(frequency)) {
|
||||
return NextResponse.json({ error: 'Invalid frequency' }, { status: 400 });
|
||||
}
|
||||
|
||||
const settings = await upsertReminderSettingsD1(
|
||||
session.user.id,
|
||||
enabled ?? false,
|
||||
|
||||
@ -43,6 +43,17 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validation
|
||||
if (typeof count !== 'number' || count < 0 || !Number.isInteger(count)) {
|
||||
return NextResponse.json({ error: 'Invalid count' }, { status: 400 });
|
||||
}
|
||||
if (!['nicotine', 'weed'].includes(substance)) {
|
||||
return NextResponse.json({ error: 'Invalid substance' }, { status: 400 });
|
||||
}
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
return NextResponse.json({ error: 'Invalid date format' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Add to existing count
|
||||
const entry = await upsertUsageEntryD1(session.user.id, date, count, substance, true);
|
||||
|
||||
@ -75,6 +86,17 @@ export async function PUT(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validation
|
||||
if (typeof count !== 'number' || count < 0 || !Number.isInteger(count)) {
|
||||
return NextResponse.json({ error: 'Invalid count' }, { status: 400 });
|
||||
}
|
||||
if (!['nicotine', 'weed'].includes(substance)) {
|
||||
return NextResponse.json({ error: 'Invalid substance' }, { status: 400 });
|
||||
}
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
return NextResponse.json({ error: 'Invalid date format' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Set the exact count (replace, not add)
|
||||
const entry = await upsertUsageEntryD1(session.user.id, date, count, substance, false);
|
||||
|
||||
|
||||
@ -4,105 +4,111 @@
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--background: oklch(0.9789 0.0082 121.6272);
|
||||
--foreground: oklch(0 0 0);
|
||||
--background: oklch(0.9838 0.0035 247.8583);
|
||||
--foreground: oklch(0.1284 0.0267 261.5937);
|
||||
--card: oklch(1.0000 0 0);
|
||||
--card-foreground: oklch(0 0 0);
|
||||
--card-foreground: oklch(0.1284 0.0267 261.5937);
|
||||
--popover: oklch(1.0000 0 0);
|
||||
--popover-foreground: oklch(0 0 0);
|
||||
--primary: oklch(0.5106 0.2301 276.9656);
|
||||
--primary-foreground: oklch(1.0000 0 0);
|
||||
--secondary: oklch(0.7038 0.1230 182.5025);
|
||||
--secondary-foreground: oklch(1.0000 0 0);
|
||||
--muted: oklch(0.9551 0 0);
|
||||
--muted-foreground: oklch(0.3211 0 0);
|
||||
--accent: oklch(0.7686 0.1647 70.0804);
|
||||
--accent-foreground: oklch(0 0 0);
|
||||
--destructive: oklch(0.6368 0.2078 25.3313);
|
||||
--destructive-foreground: oklch(1.0000 0 0);
|
||||
--border: oklch(0 0 0);
|
||||
--input: oklch(0.5555 0 0);
|
||||
--ring: oklch(0.7853 0.1041 274.7134);
|
||||
--chart-1: oklch(0.5106 0.2301 276.9656);
|
||||
--chart-2: oklch(0.7038 0.1230 182.5025);
|
||||
--chart-3: oklch(0.7686 0.1647 70.0804);
|
||||
--chart-4: oklch(0.6559 0.2118 354.3084);
|
||||
--chart-5: oklch(0.7227 0.1920 149.5793);
|
||||
--sidebar: oklch(0.9789 0.0082 121.6272);
|
||||
--sidebar-foreground: oklch(0 0 0);
|
||||
--sidebar-primary: oklch(0.5106 0.2301 276.9656);
|
||||
--sidebar-primary-foreground: oklch(1.0000 0 0);
|
||||
--sidebar-accent: oklch(0.7686 0.1647 70.0804);
|
||||
--sidebar-accent-foreground: oklch(0 0 0);
|
||||
--sidebar-border: oklch(0 0 0);
|
||||
--sidebar-ring: oklch(0.7853 0.1041 274.7134);
|
||||
--popover-foreground: oklch(0.1284 0.0267 261.5937);
|
||||
--primary: oklch(0.4865 0.2423 291.8661);
|
||||
--primary-foreground: oklch(0.9838 0.0035 247.8583);
|
||||
--secondary: oklch(0.9486 0.0085 303.5068);
|
||||
--secondary-foreground: oklch(0.3410 0.1625 292.9477);
|
||||
--muted: oklch(0.9679 0.0027 264.5424);
|
||||
--muted-foreground: oklch(0.5503 0.0235 264.3620);
|
||||
--accent: oklch(0.9546 0.0227 303.2883);
|
||||
--accent-foreground: oklch(0.4865 0.2423 291.8661);
|
||||
--destructive: oklch(0.6356 0.2082 25.3782);
|
||||
--destructive-foreground: oklch(0.9838 0.0035 247.8583);
|
||||
--border: oklch(0.9278 0.0058 264.5314);
|
||||
--input: oklch(0.9278 0.0058 264.5314);
|
||||
--ring: oklch(0.4865 0.2423 291.8661);
|
||||
--chart-1: oklch(0.4865 0.2423 291.8661);
|
||||
--chart-2: oklch(0.7216 0.1282 217.8676);
|
||||
--chart-3: oklch(0.6356 0.1398 156.1492);
|
||||
--chart-4: oklch(0.6192 0.2037 312.7283);
|
||||
--chart-5: oklch(0.6532 0.2114 353.9392);
|
||||
--sidebar: oklch(1.0000 0 0);
|
||||
--sidebar-foreground: oklch(0.1284 0.0267 261.5937);
|
||||
--sidebar-primary: oklch(0.4865 0.2423 291.8661);
|
||||
--sidebar-primary-foreground: oklch(0.9838 0.0035 247.8583);
|
||||
--sidebar-accent: oklch(0.9486 0.0085 303.5068);
|
||||
--sidebar-accent-foreground: oklch(0.4865 0.2423 291.8661);
|
||||
--sidebar-border: oklch(0.9278 0.0058 264.5314);
|
||||
--sidebar-ring: oklch(0.4865 0.2423 291.8661);
|
||||
--font-sans: Poppins, ui-sans-serif, sans-serif, system-ui;
|
||||
--font-serif: Georgia, serif;
|
||||
--font-mono: JetBrains Mono, monospace;
|
||||
--radius: 1rem;
|
||||
--shadow-x: 0px;
|
||||
--shadow-y: 0px;
|
||||
--shadow-blur: 0px;
|
||||
--shadow-y: 8px;
|
||||
--shadow-blur: 30px;
|
||||
--shadow-spread: 0px;
|
||||
--shadow-opacity: 0.05;
|
||||
--shadow-color: #1a1a1a;
|
||||
--shadow-2xs: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.03);
|
||||
--shadow-xs: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.03);
|
||||
--shadow-sm: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 1px 2px -1px hsl(0 0% 10.1961% / 0.05);
|
||||
--shadow: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 1px 2px -1px hsl(0 0% 10.1961% / 0.05);
|
||||
--shadow-md: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 2px 4px -1px hsl(0 0% 10.1961% / 0.05);
|
||||
--shadow-lg: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 4px 6px -1px hsl(0 0% 10.1961% / 0.05);
|
||||
--shadow-xl: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 8px 10px -1px hsl(0 0% 10.1961% / 0.05);
|
||||
--shadow-2xl: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.13);
|
||||
--tracking-normal: normal;
|
||||
--shadow-opacity: 0.08;
|
||||
--shadow-color: hsl(263, 70%, 50%);
|
||||
--shadow-2xs: 0px 8px 30px 0px hsl(263 70% 50% / 0.04);
|
||||
--shadow-xs: 0px 8px 30px 0px hsl(263 70% 50% / 0.04);
|
||||
--shadow-sm: 0px 8px 30px 0px hsl(263 70% 50% / 0.08), 0px 1px 2px -1px hsl(263 70% 50% / 0.08);
|
||||
--shadow: 0px 8px 30px 0px hsl(263 70% 50% / 0.08), 0px 1px 2px -1px hsl(263 70% 50% / 0.08);
|
||||
--shadow-md: 0px 8px 30px 0px hsl(263 70% 50% / 0.08), 0px 2px 4px -1px hsl(263 70% 50% / 0.08);
|
||||
--shadow-lg: 0px 8px 30px 0px hsl(263 70% 50% / 0.08), 0px 4px 6px -1px hsl(263 70% 50% / 0.08);
|
||||
--shadow-xl: 0px 8px 30px 0px hsl(263 70% 50% / 0.08), 0px 8px 10px -1px hsl(263 70% 50% / 0.08);
|
||||
--shadow-2xl: 0px 8px 30px 0px hsl(263 70% 50% / 0.20);
|
||||
--tracking-normal: -0.02em;
|
||||
--spacing: 0.25rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0 0 0);
|
||||
--foreground: oklch(1.0000 0 0);
|
||||
--card: oklch(0.2455 0.0217 257.2823);
|
||||
--card-foreground: oklch(1.0000 0 0);
|
||||
--popover: oklch(0.2455 0.0217 257.2823);
|
||||
--popover-foreground: oklch(1.0000 0 0);
|
||||
--primary: oklch(0.6801 0.1583 276.9349);
|
||||
--primary-foreground: oklch(0 0 0);
|
||||
--secondary: oklch(0.7845 0.1325 181.9120);
|
||||
--secondary-foreground: oklch(0 0 0);
|
||||
--muted: oklch(0.3211 0 0);
|
||||
--muted-foreground: oklch(0.8452 0 0);
|
||||
--accent: oklch(0.8790 0.1534 91.6054);
|
||||
--accent-foreground: oklch(0 0 0);
|
||||
--destructive: oklch(0.7106 0.1661 22.2162);
|
||||
--destructive-foreground: oklch(0 0 0);
|
||||
--border: oklch(0.4459 0 0);
|
||||
--input: oklch(1.0000 0 0);
|
||||
--ring: oklch(0.6801 0.1583 276.9349);
|
||||
--chart-1: oklch(0.6801 0.1583 276.9349);
|
||||
--chart-2: oklch(0.7845 0.1325 181.9120);
|
||||
--chart-3: oklch(0.8790 0.1534 91.6054);
|
||||
--chart-4: oklch(0.7253 0.1752 349.7607);
|
||||
--chart-5: oklch(0.8003 0.1821 151.7110);
|
||||
--sidebar: oklch(0 0 0);
|
||||
--sidebar-foreground: oklch(1.0000 0 0);
|
||||
--sidebar-primary: oklch(0.6801 0.1583 276.9349);
|
||||
--sidebar-primary-foreground: oklch(0 0 0);
|
||||
--sidebar-accent: oklch(0.8790 0.1534 91.6054);
|
||||
--sidebar-accent-foreground: oklch(0 0 0);
|
||||
--sidebar-border: oklch(1.0000 0 0);
|
||||
--sidebar-ring: oklch(0.6801 0.1583 276.9349);
|
||||
--background: oklch(0.1091 0.0091 301.6956);
|
||||
--foreground: oklch(0.9838 0.0035 247.8583);
|
||||
--card: oklch(0.1376 0.0118 301.0607);
|
||||
--card-foreground: oklch(0.9838 0.0035 247.8583);
|
||||
--popover: oklch(0.1486 0.0140 299.9811);
|
||||
--popover-foreground: oklch(0.9838 0.0035 247.8583);
|
||||
--primary: oklch(0.6083 0.2172 297.1153);
|
||||
--primary-foreground: oklch(0.1091 0.0091 301.6956);
|
||||
--secondary: oklch(0.2363 0.0582 299.6364);
|
||||
--secondary-foreground: oklch(0.8266 0.0933 301.9462);
|
||||
--muted: oklch(0.2217 0.0242 299.7054);
|
||||
--muted-foreground: oklch(0.7497 0.0224 301.0128);
|
||||
--accent: oklch(0.2255 0.0836 296.7401);
|
||||
--accent-foreground: oklch(0.6083 0.2172 297.1153);
|
||||
--destructive: oklch(0.6356 0.2082 25.3782);
|
||||
--destructive-foreground: oklch(0.9838 0.0035 247.8583);
|
||||
--border: oklch(0.2505 0.0293 299.5707);
|
||||
--input: oklch(0.2505 0.0293 299.5707);
|
||||
--ring: oklch(0.6083 0.2172 297.1153);
|
||||
--chart-1: oklch(0.6083 0.2172 297.1153);
|
||||
--chart-2: oklch(0.7741 0.1272 215.0981);
|
||||
--chart-3: oklch(0.7801 0.1859 154.5892);
|
||||
--chart-4: oklch(0.7001 0.1882 313.2907);
|
||||
--chart-5: oklch(0.6888 0.2092 353.1317);
|
||||
--sidebar: oklch(0.1249 0.0104 301.6956);
|
||||
--sidebar-foreground: oklch(0.9838 0.0035 247.8583);
|
||||
--sidebar-primary: oklch(0.6083 0.2172 297.1153);
|
||||
--sidebar-primary-foreground: oklch(0.1091 0.0091 301.6956);
|
||||
--sidebar-accent: oklch(0.2096 0.0482 299.9505);
|
||||
--sidebar-accent-foreground: oklch(0.6083 0.2172 297.1153);
|
||||
--sidebar-border: oklch(0.2217 0.0242 299.7054);
|
||||
--sidebar-ring: oklch(0.6083 0.2172 297.1153);
|
||||
--font-sans: Poppins, ui-sans-serif, sans-serif, system-ui;
|
||||
--font-serif: Georgia, serif;
|
||||
--font-mono: JetBrains Mono, monospace;
|
||||
--radius: 1rem;
|
||||
--shadow-x: 0px;
|
||||
--shadow-y: 0px;
|
||||
--shadow-blur: 0px;
|
||||
--shadow-spread: 0px;
|
||||
--shadow-opacity: 0.05;
|
||||
--shadow-color: #1a1a1a;
|
||||
--shadow-2xs: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.03);
|
||||
--shadow-xs: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.03);
|
||||
--shadow-sm: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 1px 2px -1px hsl(0 0% 10.1961% / 0.05);
|
||||
--shadow: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 1px 2px -1px hsl(0 0% 10.1961% / 0.05);
|
||||
--shadow-md: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 2px 4px -1px hsl(0 0% 10.1961% / 0.05);
|
||||
--shadow-lg: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 4px 6px -1px hsl(0 0% 10.1961% / 0.05);
|
||||
--shadow-xl: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 8px 10px -1px hsl(0 0% 10.1961% / 0.05);
|
||||
--shadow-2xl: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.13);
|
||||
--shadow-y: 20px;
|
||||
--shadow-blur: 40px;
|
||||
--shadow-spread: -10px;
|
||||
--shadow-opacity: 0.6;
|
||||
--shadow-color: hsl(0, 0%, 0%);
|
||||
--shadow-2xs: 0px 20px 40px -10px hsl(0 0% 0% / 0.30);
|
||||
--shadow-xs: 0px 20px 40px -10px hsl(0 0% 0% / 0.30);
|
||||
--shadow-sm: 0px 20px 40px -10px hsl(0 0% 0% / 0.60), 0px 1px 2px -11px hsl(0 0% 0% / 0.60);
|
||||
--shadow: 0px 20px 40px -10px hsl(0 0% 0% / 0.60), 0px 1px 2px -11px hsl(0 0% 0% / 0.60);
|
||||
--shadow-md: 0px 20px 40px -10px hsl(0 0% 0% / 0.60), 0px 2px 4px -11px hsl(0 0% 0% / 0.60);
|
||||
--shadow-lg: 0px 20px 40px -10px hsl(0 0% 0% / 0.60), 0px 4px 6px -11px hsl(0 0% 0% / 0.60);
|
||||
--shadow-xl: 0px 20px 40px -10px hsl(0 0% 0% / 0.60), 0px 8px 10px -11px hsl(0 0% 0% / 0.60);
|
||||
--shadow-2xl: 0px 20px 40px -10px hsl(0 0% 0% / 1.50);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@ -139,9 +145,9 @@
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
|
||||
--font-sans: 'DM Sans', sans-serif;
|
||||
--font-mono: 'Space Mono', monospace;
|
||||
--font-serif: 'DM Sans', sans-serif;
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-mono);
|
||||
--font-serif: var(--font-serif);
|
||||
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
@ -164,57 +170,19 @@
|
||||
--tracking-wider: calc(var(--tracking-normal) + 0.05em);
|
||||
--tracking-widest: calc(var(--tracking-normal) + 0.1em);
|
||||
|
||||
/* Background gradients */
|
||||
--bg-main: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 20%, #bbf7d0 40%, #dcfce7 60%, #f0fdf4 80%, #dcfce7 100%);
|
||||
--bg-orbs:
|
||||
radial-gradient(ellipse at 15% 10%, rgba(99, 102, 241, 0.1) 0%, transparent 40%),
|
||||
radial-gradient(ellipse at 85% 20%, rgba(168, 85, 247, 0.08) 0%, transparent 35%),
|
||||
radial-gradient(ellipse at 50% 50%, rgba(59, 130, 246, 0.06) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at 20% 80%, rgba(34, 197, 94, 0.05) 0%, transparent 40%),
|
||||
radial-gradient(ellipse at 80% 85%, rgba(239, 68, 68, 0.05) 0%, transparent 35%);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply text-foreground;
|
||||
@apply bg-background text-foreground;
|
||||
font-family: var(--font-sans);
|
||||
letter-spacing: var(--tracking-normal);
|
||||
background-color: transparent;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: var(--bg-orbs), var(--bg-main);
|
||||
background-size: cover;
|
||||
pointer-events: none;
|
||||
z-index: -50;
|
||||
}
|
||||
|
||||
/* Dark mode overrides */
|
||||
.dark {
|
||||
--bg-main: linear-gradient(135deg, #0f0f1a 0%, #1a1a2e 20%, #16213e 40%, #1a1a2e 60%, #0f0f1a 80%, #1a1a2e 100%);
|
||||
--bg-orbs:
|
||||
radial-gradient(ellipse at 15% 10%, rgba(99, 102, 241, 0.15) 0%, transparent 40%),
|
||||
radial-gradient(ellipse at 85% 20%, rgba(168, 85, 247, 0.12) 0%, transparent 35%),
|
||||
radial-gradient(ellipse at 50% 50%, rgba(45, 55, 72, 0.1) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at 20% 80%, rgba(34, 197, 94, 0.08) 0%, transparent 40%),
|
||||
radial-gradient(ellipse at 80% 85%, rgba(239, 68, 68, 0.08) 0%, transparent 35%);
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
/* Calendar styling - optimize cell size */
|
||||
@ -645,18 +613,21 @@
|
||||
@media (max-width: 640px) {
|
||||
.swipe-container {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scroll-snap-type: x mandatory;
|
||||
scroll-behavior: smooth;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
touch-action: pan-x pan-y pinch-zoom;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
gap: 1.25rem;
|
||||
padding: 0.5rem 0 1.5rem;
|
||||
gap: 1rem;
|
||||
padding: 0.25rem 0 0.5rem;
|
||||
margin: 0 -1rem;
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
scroll-padding-inline: 1.5rem;
|
||||
overscroll-behavior-x: contain;
|
||||
will-change: transform, scroll-position;
|
||||
}
|
||||
@ -666,10 +637,14 @@
|
||||
}
|
||||
|
||||
.swipe-item {
|
||||
flex: 0 0 calc(100vw - 3rem);
|
||||
width: calc(100vw - 3rem);
|
||||
scroll-snap-align: center;
|
||||
height: fit-content;
|
||||
flex: 0 0 calc(100vw - 3.25rem);
|
||||
width: calc(100vw - 3.25rem);
|
||||
scroll-snap-align: start;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
padding-bottom: calc(env(safe-area-inset-bottom) + 7.25rem);
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
@ -722,4 +697,4 @@
|
||||
|
||||
.animate-float {
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,7 +12,7 @@ export const metadata: Metadata = {
|
||||
title: "QuitTraq",
|
||||
},
|
||||
icons: {
|
||||
apple: "/icons/apple-touch-icon.png",
|
||||
apple: "/icons/apple-touch-icon.png?v=2",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -1,120 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { UnifiedLogin } from '@/components/UnifiedLogin';
|
||||
|
||||
export default function LoginPage() {
|
||||
const [stayLoggedIn, setStayLoggedIn] = useState(false);
|
||||
|
||||
const handleLogin = (provider: 'GoogleOAuth' | 'AppleOAuth') => {
|
||||
window.location.href = `/api/auth/login?provider=${provider}&stayLoggedIn=${stayLoggedIn}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-950 dark:to-slate-900 overflow-hidden relative">
|
||||
{/* Background Orbs */}
|
||||
<div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-primary/10 rounded-full blur-[120px] pointer-events-none" />
|
||||
<div className="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] bg-purple-500/10 rounded-full blur-[120px] pointer-events-none" />
|
||||
|
||||
<div className="w-full max-w-5xl grid lg:grid-cols-2 gap-8 items-center relative z-10">
|
||||
{/* Left Side: Video Demo */}
|
||||
<div className="hidden lg:block relative group">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 to-purple-500/20 rounded-[2.5rem] blur-2xl group-hover:blur-3xl transition-all duration-500 opacity-50" />
|
||||
<div className="relative aspect-[9/16] max-h-[80vh] mx-auto rounded-[2rem] overflow-hidden border border-white/10 shadow-2xl shadow-black/20">
|
||||
<video
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
className="w-full h-full object-cover"
|
||||
>
|
||||
<source src="/demo-video.mp4" type="video/mp4" />
|
||||
</video>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent pointer-events-none" />
|
||||
<div className="absolute bottom-8 left-8 right-8 text-white">
|
||||
<p className="text-sm font-bold uppercase tracking-widest opacity-70 mb-2">Live Preview</p>
|
||||
<h3 className="text-xl font-bold">Experience the journey</h3>
|
||||
<div className="w-full max-w-md mx-auto relative z-10">
|
||||
{/* Login Form */}
|
||||
<Card className="bg-white/70 dark:bg-slate-900/70 backdrop-blur-xl border-white/20 dark:border-white/10 shadow-2xl rounded-[2rem] overflow-hidden">
|
||||
<CardHeader className="text-center pt-8 pb-4">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-primary to-purple-600 rounded-2xl mx-auto mb-6 flex items-center justify-center shadow-lg shadow-primary/20 rotate-3 group-hover:rotate-0 transition-transform">
|
||||
<span className="text-2xl font-black text-white">Q</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Side: Login Form */}
|
||||
<div className="w-full max-w-md mx-auto">
|
||||
<Card className="bg-white/70 dark:bg-slate-900/70 backdrop-blur-xl border-white/20 dark:border-white/10 shadow-2xl rounded-[2rem] overflow-hidden">
|
||||
<CardHeader className="text-center pt-8 pb-4">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-primary to-purple-600 rounded-2xl mx-auto mb-6 flex items-center justify-center shadow-lg shadow-primary/20 rotate-3 group-hover:rotate-0 transition-transform">
|
||||
<span className="text-2xl font-black text-white">Q</span>
|
||||
</div>
|
||||
<CardTitle className="text-4xl font-black tracking-tight bg-gradient-to-br from-slate-900 to-slate-700 dark:from-white dark:to-slate-400 bg-clip-text text-transparent">
|
||||
QuitTraq
|
||||
</CardTitle>
|
||||
<CardDescription className="text-base font-medium mt-2">
|
||||
Your companion to a smoke-free life
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6 pb-8">
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full h-14 text-base font-bold rounded-xl border-slate-200 dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800/50 hover:scale-[1.02] transition-all shadow-sm"
|
||||
onClick={() => handleLogin('GoogleOAuth')}
|
||||
>
|
||||
<svg className="mr-3 h-5 w-5" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
Continue with Google
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full h-14 text-base font-bold rounded-xl border-slate-200 dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800/50 hover:scale-[1.02] transition-all shadow-sm"
|
||||
onClick={() => handleLogin('AppleOAuth')}
|
||||
>
|
||||
<svg className="mr-3 h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" />
|
||||
</svg>
|
||||
Continue with Apple
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3 bg-slate-50 dark:bg-slate-800/50 p-3 rounded-xl border border-slate-100 dark:border-slate-700/50">
|
||||
<Checkbox
|
||||
id="stayLoggedIn"
|
||||
checked={stayLoggedIn}
|
||||
onCheckedChange={(checked) => setStayLoggedIn(checked === true)}
|
||||
className="w-5 h-5 rounded-md border-slate-300 dark:border-slate-700"
|
||||
/>
|
||||
<Label htmlFor="stayLoggedIn" className="text-sm font-medium cursor-pointer select-none opacity-80">
|
||||
Keep me logged in on this device
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<p className="text-center text-[10px] uppercase font-bold tracking-widest text-slate-400 dark:text-slate-500 leading-relaxed max-w-[200px] mx-auto">
|
||||
By continuing, you agree to our Terms & Privacy Policy
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<CardTitle className="text-4xl font-black tracking-tight bg-gradient-to-br from-slate-900 to-slate-700 dark:from-white dark:to-slate-400 bg-clip-text text-transparent">
|
||||
QuitTraq
|
||||
</CardTitle>
|
||||
<CardDescription className="text-base font-medium mt-2">
|
||||
Your companion to a smoke-free life
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pb-8">
|
||||
<UnifiedLogin />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
6
src/app/logo-black.png/route.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const target = new URL('/icons/icon-192.png', request.url);
|
||||
return NextResponse.redirect(target, 307);
|
||||
}
|
||||
@ -6,7 +6,7 @@ export default async function Home() {
|
||||
const user = await getUser();
|
||||
|
||||
if (!user) {
|
||||
redirect('/login');
|
||||
redirect('/home');
|
||||
}
|
||||
|
||||
return <Dashboard user={user} />;
|
||||
|
||||
147
src/app/settings/page.tsx
Normal file
@ -0,0 +1,147 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ArrowLeft, Mail, Lock } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useTheme } from '@/lib/theme-context';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const router = useRouter();
|
||||
const { theme } = useTheme();
|
||||
const [isSendingReset, setIsSendingReset] = useState(false);
|
||||
const [resetMessage, setResetMessage] = useState<string | null>(null);
|
||||
const [resetError, setResetError] = useState<string | null>(null);
|
||||
|
||||
const handlePasswordReset = async () => {
|
||||
setIsSendingReset(true);
|
||||
setResetMessage(null);
|
||||
setResetError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/reset-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
const data = await response.json() as { message?: string; error?: string };
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to start password reset.');
|
||||
}
|
||||
|
||||
setResetMessage(data.message || 'Reset instructions sent to your email.');
|
||||
} catch (error: any) {
|
||||
setResetError(error.message || 'Failed to start password reset.');
|
||||
} finally {
|
||||
setIsSendingReset(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen pb-24">
|
||||
{/* Header */}
|
||||
<div className={cn(
|
||||
"sticky top-0 z-40 backdrop-blur-xl border-b",
|
||||
theme === 'light'
|
||||
? "bg-white/80 border-slate-200"
|
||||
: "bg-slate-950/80 border-white/10"
|
||||
)}>
|
||||
<div className="container mx-auto px-4 h-16 flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => router.back()}
|
||||
className="rounded-full"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="text-xl font-bold">Account Settings</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="container mx-auto px-4 py-8 max-w-2xl space-y-6">
|
||||
{/* Password Reset Card */}
|
||||
<Card className={cn(
|
||||
"overflow-hidden",
|
||||
theme === 'light'
|
||||
? "bg-white border-slate-200"
|
||||
: "bg-slate-900/50 border-white/10"
|
||||
)}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Lock className="h-5 w-5 text-amber-500" />
|
||||
Change Password
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Reset your password through our secure authentication provider.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className={cn(
|
||||
"p-4 rounded-xl border",
|
||||
theme === 'light'
|
||||
? "bg-slate-50 border-slate-200"
|
||||
: "bg-white/5 border-white/10"
|
||||
)}>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Send a secure password reset link to your account email.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{resetMessage && (
|
||||
<div className="rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-700 dark:text-emerald-300">
|
||||
{resetMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resetError && (
|
||||
<div className="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-700 dark:text-red-300">
|
||||
{resetError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handlePasswordReset}
|
||||
variant="outline"
|
||||
className="w-full h-12 text-base font-semibold rounded-xl"
|
||||
disabled={isSendingReset}
|
||||
>
|
||||
<Lock className="mr-2 h-4 w-4" />
|
||||
{isSendingReset ? 'Sending Reset Link...' : 'Send Password Reset Link'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Email Info Card */}
|
||||
<Card className={cn(
|
||||
"overflow-hidden",
|
||||
theme === 'light'
|
||||
? "bg-white border-slate-200"
|
||||
: "bg-slate-900/50 border-white/10"
|
||||
)}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Mail className="h-5 w-5 text-blue-500" />
|
||||
Email & Account
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Your authentication is managed securely through WorkOS.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className={cn(
|
||||
"text-sm",
|
||||
theme === 'light' ? "text-slate-500" : "text-white/50"
|
||||
)}>
|
||||
To change your email address or manage connected accounts (Google, Apple, GitHub),
|
||||
contact support or create a new account with your preferred email.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,12 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Achievement, BADGE_DEFINITIONS, BadgeDefinition } from '@/lib/storage';
|
||||
import { Achievement, BADGE_DEFINITIONS } from '@/lib/storage';
|
||||
import { useTheme } from '@/lib/theme-context';
|
||||
import {
|
||||
Trophy,
|
||||
Lock,
|
||||
CheckCircle2,
|
||||
Footprints,
|
||||
Flame,
|
||||
Shield,
|
||||
@ -30,7 +31,6 @@ const iconMap: Record<string, React.ElementType> = {
|
||||
|
||||
function AchievementsCardComponent({ achievements, substance }: AchievementsCardProps) {
|
||||
const { theme } = useTheme();
|
||||
const [hoveredBadge, setHoveredBadge] = useState<string | null>(null);
|
||||
|
||||
const unlockedBadgeIds = useMemo(() => {
|
||||
return new Set(
|
||||
@ -49,11 +49,9 @@ function AchievementsCardComponent({ achievements, substance }: AchievementsCard
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={`backdrop-blur-xl border ${borderColor} shadow-xl drop-shadow-lg hover-lift transition-all duration-300 overflow-hidden relative`}
|
||||
className={`backdrop-blur-xl border ${borderColor} shadow-xl drop-shadow-lg hover-lift transition-all duration-300 overflow-hidden relative h-full min-h-[62dvh] sm:min-h-0 flex flex-col`}
|
||||
style={{ background: cardBackground }}
|
||||
>
|
||||
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-br from-yellow-500/10 to-transparent rounded-full -translate-y-1/2 translate-x-1/2 pointer-events-none" />
|
||||
|
||||
<CardHeader className="relative z-10 pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-white text-shadow-sm">
|
||||
<Trophy className="h-5 w-5 text-yellow-400" />
|
||||
@ -61,46 +59,20 @@ function AchievementsCardComponent({ achievements, substance }: AchievementsCard
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="relative z-10">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<CardContent className="relative z-10 flex flex-1 flex-col pb-6">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 auto-rows-fr">
|
||||
{BADGE_DEFINITIONS.map((badge) => {
|
||||
const isUnlocked = unlockedBadgeIds.has(badge.id);
|
||||
const Icon = iconMap[badge.icon] || Trophy;
|
||||
const unlockedAchievement = achievements.find(
|
||||
(a) =>
|
||||
a.badgeId === badge.id &&
|
||||
(a.substance === substance || a.substance === 'both')
|
||||
);
|
||||
const isHovered = hoveredBadge === badge.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={badge.id}
|
||||
className={`relative p-3 rounded-xl text-center transition-all duration-300 cursor-pointer ${isUnlocked
|
||||
? 'bg-gradient-to-br from-yellow-500/30 to-amber-600/20 border border-yellow-500/50 hover:scale-105'
|
||||
className={`relative p-3 rounded-xl text-center transition-all duration-300 h-full flex flex-col justify-between ${isUnlocked
|
||||
? 'bg-gradient-to-br from-yellow-500/30 to-amber-600/20 border border-yellow-500/50'
|
||||
: 'bg-white/5 border border-white/10 hover:bg-white/10 hover:border-white/20'
|
||||
}`}
|
||||
onMouseEnter={() => setHoveredBadge(badge.id)}
|
||||
onMouseLeave={() => setHoveredBadge(null)}
|
||||
>
|
||||
{/* Hover tooltip */}
|
||||
{isHovered && (
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 z-20 w-48 p-2 bg-gray-900/95 border border-white/20 rounded-lg shadow-xl backdrop-blur-sm">
|
||||
<p className="text-xs text-white font-medium mb-1">{badge.name}</p>
|
||||
<p className="text-[10px] text-white/70">
|
||||
{isUnlocked
|
||||
? `Unlocked: ${new Date(unlockedAchievement!.unlockedAt).toLocaleDateString()}`
|
||||
: badge.howToUnlock}
|
||||
</p>
|
||||
<div className="absolute top-full left-1/2 -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-900/95" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isUnlocked && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/20 rounded-xl pointer-events-none">
|
||||
<Lock className="h-4 w-4 text-white/40" />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`mx-auto mb-1 p-2 rounded-full w-fit ${isUnlocked
|
||||
? 'bg-yellow-500/30 text-yellow-300'
|
||||
@ -109,18 +81,37 @@ function AchievementsCardComponent({ achievements, substance }: AchievementsCard
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
|
||||
<p
|
||||
className={`text-xs font-medium ${isUnlocked ? 'text-white' : 'text-white/40'
|
||||
}`}
|
||||
>
|
||||
{badge.name}
|
||||
</p>
|
||||
|
||||
<p className={`mt-1.5 text-[10px] leading-tight ${isUnlocked ? 'text-white/35 line-through' : 'text-white/75'}`}>
|
||||
{badge.howToUnlock}
|
||||
</p>
|
||||
|
||||
<div className="mt-2 flex items-center justify-center gap-1.5">
|
||||
{isUnlocked ? (
|
||||
<>
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-yellow-300/90" />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wide text-white/55">Completed</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Lock className="h-3.5 w-3.5 text-white/45" />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wide text-white/55">Locked</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<div className="mt-auto pt-4 text-center">
|
||||
<p className="text-sm text-white/70">
|
||||
{unlockedBadgeIds.size} of {BADGE_DEFINITIONS.length} badges unlocked
|
||||
</p>
|
||||
|
||||
@ -138,8 +138,6 @@ export function DailyInspirationCard({ initialReligion, onReligionChange }: Dail
|
||||
boxShadow: `inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 4px 20px ${getShadowColor()}`
|
||||
}}
|
||||
>
|
||||
<div className="absolute top-0 right-0 w-24 h-24 sm:w-32 sm:h-32 bg-gradient-to-br from-white/10 to-transparent rounded-full -translate-y-1/2 translate-x-1/2" />
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-center justify-between mb-2 sm:mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@ -22,6 +22,7 @@ import {
|
||||
SavingsConfig,
|
||||
BADGE_DEFINITIONS,
|
||||
BadgeDefinition,
|
||||
StorageRequestError,
|
||||
} from '@/lib/storage';
|
||||
import { UserHeader } from './UserHeader';
|
||||
import { SetupWizard } from './SetupWizard';
|
||||
@ -33,10 +34,12 @@ import { CelebrationAnimation } from './CelebrationAnimation';
|
||||
import { HealthTimelineCard } from './HealthTimelineCard';
|
||||
import { SavingsTrackerCard } from './SavingsTrackerCard';
|
||||
import { MoodTracker } from './MoodTracker';
|
||||
import { DailyInspirationCard } from './DailyInspirationCard';
|
||||
import { ScrollWheelLogger } from './ScrollWheelLogger';
|
||||
import { UsageLoggerDropUp } from './UsageLoggerDropUp';
|
||||
import { VersionUpdateModal } from './VersionUpdateModal';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PlusCircle, ChevronLeft, ChevronRight, X } from 'lucide-react';
|
||||
import { PlusCircle, X } from 'lucide-react';
|
||||
import { useTheme } from '@/lib/theme-context';
|
||||
import { getTodayString } from '@/lib/date-utils';
|
||||
|
||||
@ -45,6 +48,18 @@ interface DashboardProps {
|
||||
user: User;
|
||||
}
|
||||
|
||||
const MOBILE_SLIDES = [
|
||||
{ id: 'mood', label: 'How Are You Feeling' },
|
||||
{ id: 'plan', label: 'Quit Journey Plan' },
|
||||
{ id: 'stats', label: 'Usage Stats' },
|
||||
{ id: 'recovery', label: 'Health Recovery' },
|
||||
{ id: 'achievements', label: 'Achievements' },
|
||||
{ id: 'savings', label: 'Savings' },
|
||||
{ id: 'calendar', label: 'Usage Calendar' },
|
||||
];
|
||||
|
||||
const GLOBAL_BADGE_IDS = new Set(['first_day']);
|
||||
|
||||
export function Dashboard({ user }: DashboardProps) {
|
||||
const [preferences, setPreferences] = useState<UserPreferences | null>(null);
|
||||
const [usageData, setUsageData] = useState<UsageEntry[]>([]);
|
||||
@ -56,6 +71,7 @@ export function Dashboard({ user }: DashboardProps) {
|
||||
const [activeLoggingSubstance, setActiveLoggingSubstance] = useState<'nicotine' | 'weed' | null>(null);
|
||||
const [newBadge, setNewBadge] = useState<BadgeDefinition | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
const [modalOpenCount, setModalOpenCount] = useState(0);
|
||||
@ -63,45 +79,92 @@ export function Dashboard({ user }: DashboardProps) {
|
||||
const { theme } = useTheme();
|
||||
|
||||
const isModalOpen = modalOpenCount > 0 || showSetup || showCelebration;
|
||||
const isNavHidden = isModalOpen || isSubstancePickerOpen || !!activeLoggingSubstance;
|
||||
const totalPages = MOBILE_SLIDES.length;
|
||||
|
||||
const handleModalStateChange = useCallback((isOpen: boolean) => {
|
||||
setModalOpenCount(prev => isOpen ? prev + 1 : Math.max(0, prev - 1));
|
||||
}, []);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!swipeContainerRef.current) return;
|
||||
const scrollLeft = swipeContainerRef.current.scrollLeft;
|
||||
const width = swipeContainerRef.current.offsetWidth;
|
||||
const page = Math.round(scrollLeft / width);
|
||||
if (page !== currentPage) {
|
||||
setCurrentPage(page);
|
||||
const container = swipeContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const slides = Array.from(container.querySelectorAll<HTMLElement>('.swipe-item'));
|
||||
if (slides.length === 0) return;
|
||||
|
||||
let nearestIndex = 0;
|
||||
let nearestDistance = Number.POSITIVE_INFINITY;
|
||||
|
||||
slides.forEach((slide, index) => {
|
||||
const distance = Math.abs(container.scrollLeft - slide.offsetLeft);
|
||||
if (distance < nearestDistance) {
|
||||
nearestDistance = distance;
|
||||
nearestIndex = index;
|
||||
}
|
||||
});
|
||||
|
||||
if (nearestIndex !== currentPage) {
|
||||
setCurrentPage(nearestIndex);
|
||||
}
|
||||
}, [currentPage]);
|
||||
|
||||
const scrollToPage = (pageIndex: number) => {
|
||||
if (!swipeContainerRef.current) return;
|
||||
const width = swipeContainerRef.current.offsetWidth;
|
||||
swipeContainerRef.current.scrollTo({
|
||||
left: pageIndex * width,
|
||||
const container = swipeContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const slides = Array.from(container.querySelectorAll<HTMLElement>('.swipe-item'));
|
||||
if (slides.length === 0) return;
|
||||
|
||||
const boundedPage = Math.min(Math.max(pageIndex, 0), totalPages - 1);
|
||||
const targetSlide = slides[boundedPage];
|
||||
|
||||
container.scrollTo({
|
||||
left: targetSlide?.offsetLeft ?? 0,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const savedPage = Number(localStorage.getItem('quittraq_mobile_dashboard_page'));
|
||||
if (Number.isNaN(savedPage) || savedPage < 0 || savedPage >= totalPages) return;
|
||||
setCurrentPage(savedPage);
|
||||
|
||||
const timeout = window.setTimeout(() => {
|
||||
scrollToPage(savedPage);
|
||||
}, 0);
|
||||
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [totalPages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.setItem('quittraq_mobile_dashboard_page', String(currentPage));
|
||||
}, [currentPage]);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
const [prefs, usage, achvs, savings] = await Promise.all([
|
||||
fetchPreferences(),
|
||||
fetchUsageData(),
|
||||
fetchAchievements(),
|
||||
fetchSavingsConfig(),
|
||||
]);
|
||||
setPreferences(prefs);
|
||||
setUsageData(usage);
|
||||
setAchievements(achvs);
|
||||
setSavingsConfig(savings);
|
||||
console.log('[Dashboard] Loaded prefs:', prefs);
|
||||
setRefreshKey(prev => prev + 1);
|
||||
return { prefs, usage, achvs };
|
||||
try {
|
||||
const [prefs, usage, achvs, savings] = await Promise.all([
|
||||
fetchPreferences(),
|
||||
fetchUsageData(),
|
||||
fetchAchievements(),
|
||||
fetchSavingsConfig(),
|
||||
]);
|
||||
setPreferences(prefs);
|
||||
setUsageData(usage);
|
||||
setAchievements(achvs);
|
||||
setSavingsConfig(savings);
|
||||
setLoadError(null);
|
||||
console.log('[Dashboard] Loaded prefs:', prefs);
|
||||
setRefreshKey(prev => prev + 1);
|
||||
return { prefs, usage, achvs };
|
||||
} catch (error) {
|
||||
const message = error instanceof StorageRequestError
|
||||
? error.message
|
||||
: 'Unable to sync your dashboard right now. Please try again.';
|
||||
setLoadError(message);
|
||||
throw error;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const checkAndUnlockAchievements = useCallback(async (
|
||||
@ -111,11 +174,47 @@ export function Dashboard({ user }: DashboardProps) {
|
||||
) => {
|
||||
// Current unlocked set (local + server)
|
||||
const unlockedIds = new Set(currentAchievements.map(a => `${a.badgeId}-${a.substance}`));
|
||||
const unlockedGlobalBadges = new Set(
|
||||
currentAchievements
|
||||
.filter((a) => GLOBAL_BADGE_IDS.has(a.badgeId))
|
||||
.map((a) => a.badgeId)
|
||||
);
|
||||
const newUnlocked: Achievement[] = [];
|
||||
let badgeToCelebrate: BadgeDefinition | null = null;
|
||||
|
||||
const hasUsageBySubstance = {
|
||||
nicotine: usage.some((entry) => entry.substance === 'nicotine' && entry.count > 0),
|
||||
weed: usage.some((entry) => entry.substance === 'weed' && entry.count > 0),
|
||||
};
|
||||
|
||||
for (const badge of BADGE_DEFINITIONS) {
|
||||
if (GLOBAL_BADGE_IDS.has(badge.id)) {
|
||||
if (unlockedGlobalBadges.has(badge.id)) continue;
|
||||
|
||||
const isEligible = checkBadgeEligibility(badge.id, usage, prefs, 'nicotine')
|
||||
|| checkBadgeEligibility(badge.id, usage, prefs, 'weed');
|
||||
|
||||
if (!isEligible) continue;
|
||||
|
||||
try {
|
||||
const result = await unlockAchievement(badge.id, 'both');
|
||||
if (result.isNew && result.achievement) {
|
||||
newUnlocked.push(result.achievement);
|
||||
unlockedGlobalBadges.add(badge.id);
|
||||
if (!badgeToCelebrate) {
|
||||
badgeToCelebrate = badge;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error unlocking global achievement:', e);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const substance of ['nicotine', 'weed'] as const) {
|
||||
if (!hasUsageBySubstance[substance]) continue;
|
||||
|
||||
const key = `${badge.id}-${substance}`;
|
||||
if (unlockedIds.has(key)) continue;
|
||||
|
||||
@ -153,22 +252,26 @@ export function Dashboard({ user }: DashboardProps) {
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
const { prefs, usage, achvs } = await loadData();
|
||||
try {
|
||||
const { prefs, usage, achvs } = await loadData();
|
||||
|
||||
if (!prefs.hasCompletedSetup) {
|
||||
setShowSetup(true);
|
||||
} else {
|
||||
// Check for achievements
|
||||
await checkAndUnlockAchievements(usage, prefs, achvs);
|
||||
if (!prefs.hasCompletedSetup) {
|
||||
setShowSetup(true);
|
||||
} else {
|
||||
// Check for achievements
|
||||
await checkAndUnlockAchievements(usage, prefs, achvs);
|
||||
|
||||
// Check if running as PWA (home screen shortcut)
|
||||
// No longer automatically showing substance picker
|
||||
if (shouldShowUsagePrompt()) {
|
||||
markPromptShown();
|
||||
// Check if running as PWA (home screen shortcut)
|
||||
// No longer automatically showing substance picker
|
||||
if (shouldShowUsagePrompt()) {
|
||||
markPromptShown();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Dashboard init error:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
init();
|
||||
@ -217,8 +320,17 @@ export function Dashboard({ user }: DashboardProps) {
|
||||
...preferences,
|
||||
[substance === 'nicotine' ? 'lastNicotineUsageTime' : 'lastWeedUsageTime']: now,
|
||||
};
|
||||
await savePreferencesAsync(latestPrefs);
|
||||
setPreferences(latestPrefs);
|
||||
|
||||
// Force specific fields to be present to avoid partial update issues
|
||||
// This ensures that even if preferences is stale, we explicitly set the usage time
|
||||
const payload: UserPreferences = {
|
||||
...latestPrefs,
|
||||
lastNicotineUsageTime: substance === 'nicotine' ? now : (latestPrefs.lastNicotineUsageTime ?? null),
|
||||
lastWeedUsageTime: substance === 'weed' ? now : (latestPrefs.lastWeedUsageTime ?? null),
|
||||
};
|
||||
|
||||
await savePreferencesAsync(payload);
|
||||
setPreferences(payload);
|
||||
}
|
||||
|
||||
setActiveLoggingSubstance(null);
|
||||
@ -296,11 +408,40 @@ export function Dashboard({ user }: DashboardProps) {
|
||||
onModalStateChange={handleModalStateChange}
|
||||
/>
|
||||
|
||||
<main className="container mx-auto px-4 py-4 sm:py-8 pb-4 sm:pb-8 max-w-full">
|
||||
<main className="container mx-auto px-4 py-4 sm:py-8 pb-28 sm:pb-8 max-w-full">
|
||||
{loadError && (
|
||||
<div className={`mb-4 rounded-xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm ${theme === 'light' ? 'text-amber-800' : 'text-amber-100'}`}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>{loadError}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await loadData();
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!preferences && !isLoading && (
|
||||
<div className="rounded-2xl border border-border bg-card/70 p-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">Your dashboard data is unavailable right now.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preferences && (
|
||||
<>
|
||||
{/* Floating Log Button - Simplified to toggle Picker */}
|
||||
<div className={`fixed bottom-6 right-6 z-40 transition-all duration-300 ${isModalOpen ? 'opacity-0 scale-90 pointer-events-none' : 'opacity-100 scale-100'} sm:block`}>
|
||||
<div className={`fixed bottom-[6.5rem] sm:bottom-6 left-1/2 -translate-x-1/2 sm:left-auto sm:translate-x-0 sm:right-6 z-40 transition-all duration-300 ${isModalOpen ? 'opacity-0 scale-90 pointer-events-none' : 'opacity-100 scale-100'} sm:block`}>
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={() => setIsSubstancePickerOpen(!isSubstancePickerOpen)}
|
||||
@ -325,105 +466,22 @@ export function Dashboard({ user }: DashboardProps) {
|
||||
|
||||
{/* Dashboard Sections */}
|
||||
<div className="space-y-6 sm:space-y-12 relative overflow-hidden">
|
||||
{/* Mobile Navigation Buttons - LARGE */}
|
||||
<div className="sm:hidden">
|
||||
{currentPage > 0 && !isNavHidden && (
|
||||
<button
|
||||
onClick={() => scrollToPage(currentPage - 1)}
|
||||
className="fixed left-3 top-[55%] -translate-y-1/2 z-40 p-5 rounded-full glass border border-white/20 shadow-2xl active:scale-90 transition-all duration-300 group"
|
||||
style={{
|
||||
background: theme === 'light' ? 'rgba(255, 255, 255, 0.85)' : 'rgba(0, 0, 0, 0.6)',
|
||||
backdropFilter: 'blur(16px)',
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.2)'
|
||||
}}
|
||||
>
|
||||
<ChevronLeft className="h-8 w-8 text-primary group-hover:scale-110" />
|
||||
</button>
|
||||
)}
|
||||
{currentPage < 3 && !isNavHidden && (
|
||||
<button
|
||||
onClick={() => scrollToPage(currentPage + 1)}
|
||||
className="fixed right-3 top-[55%] -translate-y-1/2 z-40 p-5 rounded-full glass border border-white/20 shadow-2xl active:scale-90 transition-all duration-300 group"
|
||||
style={{
|
||||
background: theme === 'light' ? 'rgba(255, 255, 255, 0.85)' : 'rgba(0, 0, 0, 0.6)',
|
||||
backdropFilter: 'blur(16px)',
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.2)'
|
||||
}}
|
||||
>
|
||||
<ChevronRight className="h-8 w-8 text-primary group-hover:scale-110" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* SECTION: Mobile Swipe Ecosystem */}
|
||||
<div
|
||||
ref={swipeContainerRef}
|
||||
onScroll={handleScroll}
|
||||
className="swipe-container sm:space-y-12 sm:block"
|
||||
>
|
||||
|
||||
{/* SLIDE 1: Mindset (Mood & Personalized Plan) */}
|
||||
<div className="swipe-item space-y-4">
|
||||
<div className="sm:hidden flex items-center justify-between mb-2 px-1">
|
||||
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Daily Mindset</h2>
|
||||
</div>
|
||||
<div className="space-y-4 sm:grid sm:grid-cols-2 sm:gap-6 sm:space-y-0">
|
||||
<MoodTracker />
|
||||
{/* Unified Quit Plan Placard */}
|
||||
<UnifiedQuitPlanCard
|
||||
preferences={preferences}
|
||||
usageData={usageData}
|
||||
onGeneratePlan={handleGeneratePlan}
|
||||
refreshKey={refreshKey}
|
||||
/>
|
||||
</div>
|
||||
{/* DESKTOP LAYOUT - Hidden on mobile */}
|
||||
<div className="hidden sm:block space-y-8">
|
||||
{/* Row 1: Mood + Quit Plan */}
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<MoodTracker />
|
||||
<UnifiedQuitPlanCard
|
||||
preferences={preferences}
|
||||
usageData={usageData}
|
||||
onGeneratePlan={handleGeneratePlan}
|
||||
refreshKey={refreshKey}
|
||||
variant="desktop"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* SLIDE 2: Stats & Recovery (Side-by-side Stats + Health) */}
|
||||
<div className="swipe-item space-y-4">
|
||||
<div className="sm:hidden flex items-center justify-between mb-2 px-1">
|
||||
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Usage & Recovery</h2>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3 sm:gap-6">
|
||||
<StatsCard key={`stats-nicotine-${refreshKey}`} usageData={usageData} substance="nicotine" />
|
||||
<StatsCard key={`stats-weed-${refreshKey}`} usageData={usageData} substance="weed" />
|
||||
</div>
|
||||
<HealthTimelineCard
|
||||
key={`health-${refreshKey}`}
|
||||
usageData={usageData}
|
||||
preferences={preferences}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SLIDE 3: Achievements & Money (Insights) */}
|
||||
<div className="swipe-item space-y-4">
|
||||
<div className="sm:hidden flex items-center justify-between mb-2 px-1">
|
||||
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Achievements & Savings</h2>
|
||||
</div>
|
||||
<div className="space-y-4 sm:grid sm:grid-cols-2 sm:gap-6 sm:space-y-0">
|
||||
<AchievementsCard
|
||||
key={`achievements-${refreshKey}`}
|
||||
achievements={achievements}
|
||||
substance={preferences.substance}
|
||||
/>
|
||||
<SavingsTrackerCard
|
||||
key={`savings-${refreshKey}`}
|
||||
savingsConfig={savingsConfig}
|
||||
usageData={usageData}
|
||||
trackingStartDate={preferences.trackingStartDate}
|
||||
onSavingsConfigChange={handleSavingsConfigChange}
|
||||
onModalStateChange={handleModalStateChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SLIDE 4: Calendar */}
|
||||
<div id="calendar-section" className="swipe-item">
|
||||
<div className="sm:hidden flex items-center justify-between mb-2 px-1">
|
||||
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Usage Calendar</h2>
|
||||
</div>
|
||||
{/* Row 2: Calendar/Quote */}
|
||||
<div id="calendar-section">
|
||||
<UsageCalendar
|
||||
key={refreshKey}
|
||||
usageData={usageData}
|
||||
@ -435,6 +493,7 @@ export function Dashboard({ user }: DashboardProps) {
|
||||
setPreferences(updatedPrefs);
|
||||
await savePreferencesAsync(updatedPrefs);
|
||||
}}
|
||||
showInspirationPanel
|
||||
preferences={preferences}
|
||||
onPreferencesUpdate={async (updatedPrefs: UserPreferences) => {
|
||||
await savePreferencesAsync(updatedPrefs);
|
||||
@ -443,6 +502,196 @@ export function Dashboard({ user }: DashboardProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Row 3: Achievements + Health Recovery */}
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<AchievementsCard
|
||||
key={`achievements-${refreshKey}`}
|
||||
achievements={achievements}
|
||||
substance={preferences.substance}
|
||||
/>
|
||||
<HealthTimelineCard
|
||||
key={`health-${refreshKey}`}
|
||||
usageData={usageData}
|
||||
preferences={preferences}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Row 4: Savings + Stats */}
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<SavingsTrackerCard
|
||||
key={`savings-${refreshKey}`}
|
||||
savingsConfig={savingsConfig}
|
||||
usageData={usageData}
|
||||
trackingStartDate={preferences.trackingStartDate}
|
||||
onSavingsConfigChange={handleSavingsConfigChange}
|
||||
onModalStateChange={handleModalStateChange}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<StatsCard key={`stats-nicotine-${refreshKey}`} usageData={usageData} substance="nicotine" />
|
||||
<StatsCard key={`stats-weed-${refreshKey}`} usageData={usageData} substance="weed" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MOBILE SWIPE LAYOUT - Hidden on desktop */}
|
||||
<div
|
||||
ref={swipeContainerRef}
|
||||
id="mobile-dashboard-slides"
|
||||
onScroll={handleScroll}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
scrollToPage(currentPage + 1);
|
||||
}
|
||||
if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
scrollToPage(currentPage - 1);
|
||||
}
|
||||
}}
|
||||
className="swipe-container sm:hidden"
|
||||
tabIndex={0}
|
||||
role="region"
|
||||
aria-label="Mobile dashboard sections"
|
||||
>
|
||||
|
||||
{/* SLIDE 1: Mood */}
|
||||
<div className="swipe-item space-y-3">
|
||||
<div className="flex items-center justify-between mb-2 px-1">
|
||||
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">How Are You Feeling</h2>
|
||||
</div>
|
||||
<div className="w-full max-w-[30rem] mx-auto space-y-3">
|
||||
<MoodTracker />
|
||||
<DailyInspirationCard
|
||||
initialReligion={preferences.religion}
|
||||
onReligionChange={async (religion) => {
|
||||
const updatedPrefs = { ...preferences, religion };
|
||||
setPreferences(updatedPrefs);
|
||||
await savePreferencesAsync(updatedPrefs);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SLIDE 2: Quit Plan */}
|
||||
<div className="swipe-item space-y-3">
|
||||
<div className="flex items-center justify-between mb-2 px-1">
|
||||
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Quit Journey Plan</h2>
|
||||
</div>
|
||||
<div className="w-full max-w-[30rem] mx-auto">
|
||||
<UnifiedQuitPlanCard
|
||||
preferences={preferences}
|
||||
usageData={usageData}
|
||||
onGeneratePlan={handleGeneratePlan}
|
||||
refreshKey={refreshKey}
|
||||
variant="mobile"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SLIDE 3: Stats */}
|
||||
<div className="swipe-item space-y-3">
|
||||
<div className="flex items-center justify-between mb-2 px-1">
|
||||
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Usage Stats</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 w-full max-w-[30rem] mx-auto">
|
||||
<StatsCard
|
||||
key={`stats-nicotine-${refreshKey}`}
|
||||
usageData={usageData}
|
||||
substance="nicotine"
|
||||
/>
|
||||
<StatsCard
|
||||
key={`stats-weed-${refreshKey}`}
|
||||
usageData={usageData}
|
||||
substance="weed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SLIDE 4: Recovery */}
|
||||
<div className="swipe-item space-y-3">
|
||||
<div className="flex items-center justify-between mb-2 px-1">
|
||||
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Health Recovery</h2>
|
||||
</div>
|
||||
<div className="w-full max-w-[30rem] mx-auto">
|
||||
<HealthTimelineCard
|
||||
key={`health-${refreshKey}`}
|
||||
usageData={usageData}
|
||||
preferences={preferences}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SLIDE 5: Achievements */}
|
||||
<div className="swipe-item space-y-3">
|
||||
<div className="flex items-center justify-between mb-2 px-1">
|
||||
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Achievements</h2>
|
||||
</div>
|
||||
<div className="w-full max-w-[30rem] mx-auto">
|
||||
<AchievementsCard
|
||||
key={`achievements-${refreshKey}`}
|
||||
achievements={achievements}
|
||||
substance={preferences.substance}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SLIDE 6: Savings */}
|
||||
<div className="swipe-item space-y-3">
|
||||
<div className="flex items-center justify-between mb-2 px-1">
|
||||
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Savings</h2>
|
||||
</div>
|
||||
<div className="w-full max-w-[30rem] mx-auto">
|
||||
<SavingsTrackerCard
|
||||
key={`savings-${refreshKey}`}
|
||||
savingsConfig={savingsConfig}
|
||||
usageData={usageData}
|
||||
trackingStartDate={preferences.trackingStartDate}
|
||||
onSavingsConfigChange={handleSavingsConfigChange}
|
||||
onModalStateChange={handleModalStateChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SLIDE 7: Calendar */}
|
||||
<div id="calendar-section-mobile" className="swipe-item">
|
||||
<div className="flex items-center justify-between mb-2 px-1">
|
||||
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Usage Calendar</h2>
|
||||
</div>
|
||||
<div className="w-full max-w-[30rem] mx-auto">
|
||||
<UsageCalendar
|
||||
key={refreshKey}
|
||||
usageData={usageData}
|
||||
onDataUpdate={loadData}
|
||||
userId={user.id}
|
||||
preferences={preferences}
|
||||
onPreferencesUpdate={async (updatedPrefs: UserPreferences) => {
|
||||
await savePreferencesAsync(updatedPrefs);
|
||||
setPreferences(updatedPrefs);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="sm:hidden fixed bottom-[calc(env(safe-area-inset-bottom)+0.75rem)] left-1/2 -translate-x-1/2 z-30 w-full px-3 pointer-events-none">
|
||||
<div className="mx-auto max-w-sm rounded-xl border border-white/10 bg-black/25 backdrop-blur-xl px-3 py-2 pointer-events-auto">
|
||||
<div className="text-[10px] uppercase tracking-[0.2em] opacity-60 text-center mb-2">
|
||||
{MOBILE_SLIDES[currentPage]?.label}
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{MOBILE_SLIDES.map((slide, index) => (
|
||||
<button
|
||||
key={slide.id}
|
||||
type="button"
|
||||
onClick={() => scrollToPage(index)}
|
||||
aria-label={`Go to ${slide.label}`}
|
||||
aria-current={currentPage === index ? 'page' : undefined}
|
||||
className={`h-2 rounded-full transition-all ${currentPage === index ? 'w-8 bg-primary' : 'w-2 bg-white/30'}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@ -468,6 +717,19 @@ export function Dashboard({ user }: DashboardProps) {
|
||||
/>
|
||||
)}
|
||||
|
||||
<VersionUpdateModal
|
||||
preferences={preferences}
|
||||
onAcknowledge={async (version) => {
|
||||
if (!preferences) return;
|
||||
const nextPreferences = {
|
||||
...preferences,
|
||||
lastSeenReleaseNotesVersion: version,
|
||||
};
|
||||
setPreferences(nextPreferences);
|
||||
await savePreferencesAsync(nextPreferences);
|
||||
}}
|
||||
/>
|
||||
|
||||
{showCelebration && newBadge && (
|
||||
<CelebrationAnimation
|
||||
badge={newBadge}
|
||||
|
||||
@ -18,6 +18,7 @@ import {
|
||||
Cigarette,
|
||||
Leaf
|
||||
} from 'lucide-react';
|
||||
import { getTodayString, getLocalDateString } from '@/lib/date-utils';
|
||||
|
||||
interface HealthTimelineCardProps {
|
||||
usageData: UsageEntry[];
|
||||
@ -225,29 +226,57 @@ function HealthTimelineCardComponent({
|
||||
// Calculate last usage timestamps only when data changes
|
||||
const lastUsageTimes = useMemo(() => {
|
||||
const getTimestamp = (substance: 'nicotine' | 'weed') => {
|
||||
// 1. Check for stored timestamp first
|
||||
const stored = substance === 'nicotine' ? preferences?.lastNicotineUsageTime : preferences?.lastWeedUsageTime;
|
||||
if (stored) return new Date(stored).getTime();
|
||||
let lastTime = 0;
|
||||
|
||||
// 2. Fallback to usage data
|
||||
// 1. Check for stored timestamp
|
||||
const stored = substance === 'nicotine' ? preferences?.lastNicotineUsageTime : preferences?.lastWeedUsageTime;
|
||||
if (stored) {
|
||||
lastTime = new Date(stored).getTime();
|
||||
}
|
||||
|
||||
// 2. Check usage data (usually more up-to-date for "just logged")
|
||||
const lastEntry = usageData
|
||||
.filter(e => e.substance === substance && e.count > 0)
|
||||
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())[0];
|
||||
|
||||
if (lastEntry) {
|
||||
const todayStr = getTodayString();
|
||||
|
||||
// Calculate local midnight for today
|
||||
const now = new Date();
|
||||
const localMidnight = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
||||
|
||||
// If usage recorded today
|
||||
if (lastEntry.date === todayStr) {
|
||||
// Check if the stored timestamp belongs to today (is after midnight)
|
||||
if (lastTime >= localMidnight) {
|
||||
return lastTime;
|
||||
}
|
||||
|
||||
// Fallback: If we have usage "Today" but no valid timestamp,
|
||||
// we must assume it just happened or we missed the timestamp.
|
||||
// Returning Date.now() resets the timer to 0.
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
const d = new Date(lastEntry.date);
|
||||
d.setHours(23, 59, 59, 999);
|
||||
return d.getTime();
|
||||
const entryTime = d.getTime();
|
||||
|
||||
// Take the more recent of the two
|
||||
lastTime = Math.max(lastTime, entryTime);
|
||||
}
|
||||
|
||||
// 3. Fallback to start date
|
||||
if (preferences?.trackingStartDate) {
|
||||
|
||||
|
||||
// 3. Fallback to start date if no usage found
|
||||
if (lastTime === 0 && preferences?.trackingStartDate) {
|
||||
const d = new Date(preferences.trackingStartDate);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d.getTime();
|
||||
lastTime = d.getTime();
|
||||
}
|
||||
|
||||
return null;
|
||||
return lastTime || null;
|
||||
};
|
||||
|
||||
return {
|
||||
@ -282,11 +311,9 @@ function HealthTimelineCardComponent({
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="backdrop-blur-xl border border-teal-500/40 shadow-xl drop-shadow-lg hover-lift transition-all duration-300 overflow-hidden relative h-[500px] flex flex-col"
|
||||
className="backdrop-blur-xl border border-teal-500/40 shadow-xl drop-shadow-lg hover-lift transition-all duration-300 overflow-hidden relative h-[min(calc(58dvh+13rem),43rem)] sm:h-[500px] flex flex-col"
|
||||
style={{ background: cardBackground }}
|
||||
>
|
||||
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-br from-teal-500/10 to-transparent rounded-full -translate-y-1/2 translate-x-1/2 pointer-events-none" />
|
||||
|
||||
<CardHeader className="relative z-10 pb-4 shrink-0">
|
||||
<CardTitle className={`flex items-center gap-2 ${theme === 'light' ? 'text-teal-900' : 'text-white'} text-shadow-sm`}>
|
||||
<Heart className="h-5 w-5 text-teal-500" />
|
||||
@ -302,6 +329,7 @@ function HealthTimelineCardComponent({
|
||||
<TimelineColumn substance="nicotine" minutesFree={nicotineMinutes} theme={theme} />
|
||||
<TimelineColumn substance="weed" minutesFree={weedMinutes} theme={theme} />
|
||||
</div>
|
||||
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@ -10,13 +10,20 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Download, Share, Plus, MoreVertical, Smartphone } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
prompt: () => Promise<void>;
|
||||
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
|
||||
}
|
||||
|
||||
export function InstallAppButton() {
|
||||
interface InstallAppButtonProps {
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
onBeforeOpen?: () => void;
|
||||
}
|
||||
|
||||
export function InstallAppButton({ compact = false, className, onBeforeOpen }: InstallAppButtonProps) {
|
||||
const [showInstructions, setShowInstructions] = useState(false);
|
||||
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
|
||||
const [isIOS, setIsIOS] = useState(false);
|
||||
@ -50,6 +57,7 @@ export function InstallAppButton() {
|
||||
}, []);
|
||||
|
||||
const handleInstallClick = async () => {
|
||||
onBeforeOpen?.();
|
||||
if (deferredPrompt) {
|
||||
// Use the native install prompt (Android/Chrome)
|
||||
await deferredPrompt.prompt();
|
||||
@ -74,11 +82,21 @@ export function InstallAppButton() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleInstallClick}
|
||||
className="gap-2 border-purple-500/30 hover:bg-purple-500/10 hover:border-purple-500/50"
|
||||
className={cn(
|
||||
compact
|
||||
? 'h-12 w-12 p-0 border-purple-500/30 hover:bg-purple-500/10 hover:border-purple-500/50 flex items-center justify-center'
|
||||
: 'gap-2 border-purple-500/30 hover:bg-purple-500/10 hover:border-purple-500/50',
|
||||
className
|
||||
)}
|
||||
aria-label="Install app"
|
||||
>
|
||||
<Smartphone className="h-4 w-4 text-purple-400" />
|
||||
<span className="hidden sm:inline">Add to Home Screen</span>
|
||||
<span className="sm:hidden">Install</span>
|
||||
<Smartphone className={cn('text-purple-400', compact ? 'h-5 w-5' : 'h-4 w-4')} />
|
||||
{!compact && (
|
||||
<>
|
||||
<span className="hidden sm:inline">Add to Home Screen</span>
|
||||
<span className="sm:hidden">Install</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Dialog open={showInstructions} onOpenChange={setShowInstructions}>
|
||||
|
||||
@ -200,7 +200,7 @@ function MoodTrackerComponent() {
|
||||
|
||||
return (
|
||||
<Card className={cn(
|
||||
"overflow-hidden transition-all duration-700 ease-in-out backdrop-blur-xl border shadow-xl",
|
||||
"overflow-hidden transition-all duration-700 ease-in-out backdrop-blur-xl border shadow-xl h-full",
|
||||
"bg-gradient-to-br",
|
||||
gradientClass
|
||||
)}>
|
||||
|
||||
@ -84,8 +84,6 @@ export function ReminderSettingsCard({
|
||||
className="backdrop-blur-xl border border-indigo-500/40 shadow-xl drop-shadow-lg hover-lift transition-all duration-300 overflow-hidden relative"
|
||||
style={{ background: cardBackground }}
|
||||
>
|
||||
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-br from-indigo-500/10 to-transparent rounded-full -translate-y-1/2 translate-x-1/2 pointer-events-none" />
|
||||
|
||||
<CardHeader className="relative z-10 pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-white text-shadow-sm">
|
||||
<Bell className="h-5 w-5 text-indigo-400" />
|
||||
|
||||
@ -81,8 +81,6 @@ function SavingsTrackerCardComponent({
|
||||
className="backdrop-blur-xl border border-emerald-500/40 shadow-xl drop-shadow-lg hover-lift transition-all duration-300 overflow-hidden relative"
|
||||
style={{ background: cardBackground }}
|
||||
>
|
||||
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-br from-emerald-500/10 to-transparent rounded-full -translate-y-1/2 translate-x-1/2 pointer-events-none" />
|
||||
|
||||
<CardHeader className="relative z-10">
|
||||
<CardTitle className="flex items-center gap-2 text-white text-shadow-sm">
|
||||
<DollarSign className="h-5 w-5 text-emerald-400" />
|
||||
@ -127,8 +125,6 @@ function SavingsTrackerCardComponent({
|
||||
className="backdrop-blur-xl border border-emerald-500/40 shadow-xl drop-shadow-lg hover-lift transition-all duration-300 overflow-hidden relative"
|
||||
style={{ background: cardBackground }}
|
||||
>
|
||||
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-br from-emerald-500/10 to-transparent rounded-full -translate-y-1/2 translate-x-1/2 pointer-events-none" />
|
||||
|
||||
<CardHeader className="relative z-10 pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2 text-white text-shadow-sm">
|
||||
|
||||
@ -10,7 +10,10 @@ import {
|
||||
Settings,
|
||||
Shield,
|
||||
Heart,
|
||||
Calendar
|
||||
Calendar,
|
||||
Bell,
|
||||
Moon,
|
||||
Sun
|
||||
} from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
@ -18,6 +21,7 @@ import { cn } from '@/lib/utils';
|
||||
import { useTheme } from '@/lib/theme-context';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { InstallAppButton } from './InstallAppButton';
|
||||
|
||||
interface SideMenuProps {
|
||||
isOpen: boolean;
|
||||
@ -30,11 +34,12 @@ interface SideMenuProps {
|
||||
profilePictureUrl?: string | null;
|
||||
};
|
||||
userName: string | null;
|
||||
onOpenNotifications: () => void;
|
||||
}
|
||||
|
||||
export function SideMenu({ isOpen, onClose, user, userName }: SideMenuProps) {
|
||||
export function SideMenu({ isOpen, onClose, user, userName, onOpenNotifications }: SideMenuProps) {
|
||||
const router = useRouter();
|
||||
const { theme } = useTheme();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
@ -72,10 +77,10 @@ export function SideMenu({ isOpen, onClose, user, userName }: SideMenuProps) {
|
||||
{/* Menu Content */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-72 h-full flex flex-col shadow-2xl transition-all animate-in slide-in-from-left duration-300",
|
||||
"relative ml-auto w-72 h-full flex flex-col shadow-2xl transition-all animate-in slide-in-from-right duration-300",
|
||||
theme === 'light'
|
||||
? "bg-white text-slate-900"
|
||||
: "bg-slate-900 text-white border-r border-white/5"
|
||||
? "bg-white text-slate-900 border-l border-slate-100"
|
||||
: "bg-slate-900 text-white border-l border-white/5"
|
||||
)}
|
||||
>
|
||||
{/* Header/Profile Info */}
|
||||
@ -103,6 +108,59 @@ export function SideMenu({ isOpen, onClose, user, userName }: SideMenuProps) {
|
||||
|
||||
{/* Navigation Links */}
|
||||
<div className="flex-1 overflow-y-auto py-4 px-3 space-y-1">
|
||||
<div className="px-3 pb-2">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest opacity-30">Quick Actions</span>
|
||||
</div>
|
||||
<div className="px-3 pb-3">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onOpenNotifications();
|
||||
}}
|
||||
className={cn(
|
||||
"h-12 w-12 rounded-xl border transition-all active:scale-95 flex items-center justify-center",
|
||||
theme === 'light'
|
||||
? "bg-slate-50 border-slate-200 hover:bg-slate-100"
|
||||
: "bg-white/5 border-white/10 hover:bg-white/10"
|
||||
)}
|
||||
aria-label="Open notification settings"
|
||||
>
|
||||
<Bell className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<InstallAppButton
|
||||
compact
|
||||
onBeforeOpen={onClose}
|
||||
className={cn(
|
||||
theme === 'light'
|
||||
? 'bg-slate-50 border-slate-200 hover:bg-slate-100'
|
||||
: 'bg-white/5 border-white/10 hover:bg-white/10'
|
||||
)}
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className={cn(
|
||||
"h-12 w-12 rounded-xl border transition-all active:scale-95 flex items-center justify-center",
|
||||
theme === 'light'
|
||||
? "bg-slate-50 border-slate-200 hover:bg-slate-100"
|
||||
: "bg-white/5 border-white/10 hover:bg-white/10"
|
||||
)}
|
||||
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
<Moon className="h-5 w-5 text-blue-300" />
|
||||
) : (
|
||||
<Sun className="h-5 w-5 text-amber-500" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="my-2 px-3">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest opacity-30">Main</span>
|
||||
</div>
|
||||
<MenuLink
|
||||
icon={Home}
|
||||
label="Dashboard"
|
||||
@ -140,12 +198,18 @@ export function SideMenu({ isOpen, onClose, user, userName }: SideMenuProps) {
|
||||
"p-4 border-t",
|
||||
theme === 'light' ? "border-slate-100" : "border-white/5"
|
||||
)}>
|
||||
<MenuLink
|
||||
icon={Settings}
|
||||
label="Account Settings"
|
||||
onClick={() => handleNavigate('/settings')}
|
||||
color="blue"
|
||||
/>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center gap-3 px-3 py-3 rounded-xl text-red-500 hover:bg-red-500/10 transition-colors font-medium mt-1"
|
||||
className="relative w-full flex items-center justify-center px-3 py-3 rounded-xl text-red-500 hover:bg-red-500/10 transition-colors font-medium mt-1"
|
||||
>
|
||||
<LogOut className="h-5 w-5" />
|
||||
<span>Sign out</span>
|
||||
<LogOut className="absolute left-3 h-5 w-5" />
|
||||
<span className="text-center leading-none">Sign out</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -176,20 +240,20 @@ function MenuLink({ icon: Icon, label, onClick, color }: MenuLinkProps) {
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 px-3 py-3 rounded-xl transition-all font-medium group",
|
||||
"relative w-full flex items-center justify-center px-3 py-3 rounded-xl transition-all font-medium group",
|
||||
theme === 'light'
|
||||
? "hover:bg-slate-100"
|
||||
: "hover:bg-white/5"
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
"p-2 rounded-lg transition-colors",
|
||||
"absolute left-3 p-2 rounded-lg transition-colors",
|
||||
color ? colors[color] : (theme === 'light' ? "bg-slate-100" : "bg-white/5")
|
||||
)}>
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<span className="flex-1 text-left text-sm">{label}</span>
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-primary opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<span className="text-sm text-center leading-none">{label}</span>
|
||||
<div className="absolute right-3 w-1.5 h-1.5 rounded-full bg-primary opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@ -21,7 +21,7 @@ const smokingAids = [
|
||||
color: 'text-sky-400',
|
||||
themeColor: 'sky',
|
||||
gradient: 'from-sky-500/20 to-indigo-500/20',
|
||||
borderColor: 'border-sky-500/30',
|
||||
borderColor: 'border-white/10',
|
||||
},
|
||||
{
|
||||
id: 'nicotine-lozenge',
|
||||
@ -36,7 +36,7 @@ const smokingAids = [
|
||||
color: 'text-rose-400',
|
||||
themeColor: 'rose',
|
||||
gradient: 'from-rose-500/20 to-pink-500/20',
|
||||
borderColor: 'border-rose-500/30',
|
||||
borderColor: 'border-white/10',
|
||||
},
|
||||
{
|
||||
id: 'recovery-complex',
|
||||
@ -51,7 +51,7 @@ const smokingAids = [
|
||||
color: 'text-amber-400',
|
||||
themeColor: 'amber',
|
||||
gradient: 'from-amber-500/20 to-orange-500/20',
|
||||
borderColor: 'border-amber-500/30',
|
||||
borderColor: 'border-white/10',
|
||||
},
|
||||
{
|
||||
id: 'mullein-tea',
|
||||
@ -66,7 +66,7 @@ const smokingAids = [
|
||||
color: 'text-emerald-400',
|
||||
themeColor: 'emerald',
|
||||
gradient: 'from-emerald-500/20 to-teal-500/20',
|
||||
borderColor: 'border-emerald-500/30',
|
||||
borderColor: 'border-white/10',
|
||||
},
|
||||
];
|
||||
|
||||
@ -101,10 +101,9 @@ export function SmokingAidsContent() {
|
||||
style={{ animationDelay: `${index * 150}ms` }}
|
||||
>
|
||||
<Card
|
||||
className="h-full flex flex-col overflow-hidden border-border/40 transition-all duration-500 hover:shadow-[0_20px_50px_rgba(0,0,0,0.2)] dark:hover:shadow-[0_20px_50px_rgba(0,0,0,0.4)] hover:-translate-y-2 backdrop-blur-xl relative group"
|
||||
className="h-full flex flex-col overflow-hidden border-0 !p-0 !bg-transparent transition-all duration-500 hover:shadow-[0_20px_50px_rgba(0,0,0,0.2)] dark:hover:shadow-[0_20px_50px_rgba(0,0,0,0.4)] hover:-translate-y-2 backdrop-blur-xl relative group rounded-2xl"
|
||||
style={{
|
||||
background: cardBackground,
|
||||
borderColor: `rgba(var(--${item.themeColor}-500), 0.3)`
|
||||
background: cardBackground
|
||||
}}
|
||||
>
|
||||
{/* Specific card glow on hover */}
|
||||
|
||||
@ -92,7 +92,6 @@ function StatsCardComponent({ usageData, substance }: StatsCardProps) {
|
||||
className={`backdrop-blur-xl border ${borderColor} shadow-xl drop-shadow-lg hover-lift transition-all duration-300 overflow-hidden relative`}
|
||||
style={{ background: cardBackground }}
|
||||
>
|
||||
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-br from-white/5 to-transparent rounded-full -translate-y-1/2 translate-x-1/2 pointer-events-none" />
|
||||
<CardHeader className="pb-2 relative z-10">
|
||||
<CardTitle className="flex items-center gap-2 text-white text-shadow-sm">
|
||||
<SubstanceIcon className={`h-5 w-5 ${iconColor}`} />
|
||||
|
||||
@ -17,12 +17,20 @@ interface SubstanceTrackingPageProps {
|
||||
export function SubstanceTrackingPage({ user, substance }: SubstanceTrackingPageProps) {
|
||||
const [usageData, setUsageData] = useState<UsageEntry[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const { theme } = useTheme();
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
const usage = await fetchUsageData();
|
||||
setUsageData(usage);
|
||||
setIsLoading(false);
|
||||
try {
|
||||
const usage = await fetchUsageData();
|
||||
setUsageData(usage);
|
||||
setLoadError(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to load substance usage data:', error);
|
||||
setLoadError('Unable to load tracking data right now.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@ -78,23 +86,22 @@ export function SubstanceTrackingPage({ user, substance }: SubstanceTrackingPage
|
||||
<div className="mb-6 sm:mb-8 text-center opacity-0 animate-fade-in delay-100">
|
||||
{todayCount === 0 ? (
|
||||
<p className={`text-xl sm:text-2xl font-medium ${theme === 'light' ? 'text-green-600' : 'text-green-400'}`}>
|
||||
Great job, nothing yet!
|
||||
0 {unitLabel} recorded, amazing job so far!
|
||||
</p>
|
||||
) : (
|
||||
<p className={`text-xl sm:text-2xl font-medium ${theme === 'light' ? 'text-gray-900' : 'text-white'}`}>
|
||||
{todayCount} {todayCount === 1 ? (substance === 'nicotine' ? 'puff' : 'hit') : unitLabel} recorded, you got this!
|
||||
{todayCount} {todayCount === 1 ? (substance === 'nicotine' ? 'puff' : 'hit') : unitLabel} recorded, but don't stress.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Inspirational Message */}
|
||||
<div className="mb-6 sm:mb-8 text-center opacity-0 animate-fade-in delay-200">
|
||||
<p className={`text-lg sm:text-xl font-light italic ${theme === 'light' ? 'text-gray-500' : 'text-white/60'}`}>
|
||||
"One day at a time..."
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats and Graph */}
|
||||
{loadError && (
|
||||
<div className="mb-6 rounded-xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-700 dark:text-amber-200">
|
||||
{loadError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="opacity-0 animate-fade-in-up delay-200">
|
||||
<StatsCard usageData={usageData} substance={substance} />
|
||||
|
||||
398
src/components/UnifiedLogin.tsx
Normal file
@ -0,0 +1,398 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Mail, Lock, Loader2, AlertCircle, ArrowRight } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
|
||||
interface UnifiedLoginProps {
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function UnifiedLogin({ onSuccess }: UnifiedLoginProps) {
|
||||
const router = useRouter();
|
||||
const [isSignup, setIsSignup] = useState(false);
|
||||
const [showEmailForm, setShowEmailForm] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [stayLoggedIn, setStayLoggedIn] = useState(false);
|
||||
|
||||
const [pendingVerification, setPendingVerification] = useState(false);
|
||||
const [verificationCode, setVerificationCode] = useState('');
|
||||
const [userId, setUserId] = useState<string | null>(null);
|
||||
const [isResettingPassword, setIsResettingPassword] = useState(false);
|
||||
const [resetStatus, setResetStatus] = useState<string | null>(null);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
password: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// If we are pending verification, submit the code
|
||||
if (pendingVerification) {
|
||||
const res = await fetch('/api/auth/verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
userId,
|
||||
code: verificationCode,
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
stayLoggedIn,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json() as { error?: string };
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || 'Verification failed');
|
||||
}
|
||||
|
||||
// Success
|
||||
if (onSuccess) onSuccess();
|
||||
router.push('/home');
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal login / signup initiation
|
||||
const res = await fetch('/api/auth/email', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...formData,
|
||||
type: isSignup ? 'signup' : 'login',
|
||||
stayLoggedIn,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json() as { error?: string, success?: boolean, pendingVerification?: boolean, userId?: string };
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || 'Authentication failed');
|
||||
}
|
||||
|
||||
// Check if we need verification
|
||||
if (data.pendingVerification) {
|
||||
setPendingVerification(true);
|
||||
setUserId(data.userId || null);
|
||||
setLoading(false); // Stop loading so user can enter code
|
||||
return;
|
||||
}
|
||||
|
||||
// Success (Direct login)
|
||||
if (onSuccess) onSuccess();
|
||||
router.push('/home');
|
||||
router.refresh();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOAuthLogin = (provider: string) => {
|
||||
window.location.href = `/api/auth/login?provider=${provider}&stayLoggedIn=${stayLoggedIn}`;
|
||||
};
|
||||
|
||||
const toggleMode = () => {
|
||||
setIsSignup(!isSignup);
|
||||
setError(null);
|
||||
setResetStatus(null);
|
||||
};
|
||||
|
||||
const handleForgotPassword = async () => {
|
||||
const email = formData.email.trim();
|
||||
if (!email) {
|
||||
setError('Enter your email first, then request password reset.');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setResetStatus(null);
|
||||
setIsResettingPassword(true);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/reset-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
|
||||
const data = await res.json() as { message?: string; error?: string };
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || 'Could not start password reset.');
|
||||
}
|
||||
|
||||
setResetStatus(data.message || 'Password reset instructions sent if the account exists.');
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Could not start password reset.');
|
||||
} finally {
|
||||
setIsResettingPassword(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Checkbox Component to reuse
|
||||
const StayLoggedInCheckbox = () => (
|
||||
<div className="flex items-center space-x-3 bg-slate-50 dark:bg-slate-800/50 p-3 rounded-xl border border-slate-100 dark:border-slate-700/50">
|
||||
<Checkbox
|
||||
id="stayLoggedIn"
|
||||
checked={stayLoggedIn}
|
||||
onCheckedChange={(checked) => setStayLoggedIn(checked === true)}
|
||||
className="w-5 h-5 rounded-md border-slate-300 dark:border-slate-700"
|
||||
/>
|
||||
<Label htmlFor="stayLoggedIn" className="text-sm font-medium cursor-pointer select-none opacity-80">
|
||||
Keep me logged in on this device
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!showEmailForm) {
|
||||
return (
|
||||
<div className="space-y-3 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full h-14 text-base font-bold rounded-xl border-slate-200 dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800/50 hover:scale-[1.02] transition-all shadow-sm"
|
||||
onClick={() => handleOAuthLogin('GoogleOAuth')}
|
||||
>
|
||||
<svg className="mr-3 h-5 w-5" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
Continue with Google
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full h-14 text-base font-bold rounded-xl border-slate-200 dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800/50 hover:scale-[1.02] transition-all shadow-sm"
|
||||
onClick={() => handleOAuthLogin('AppleOAuth')}
|
||||
>
|
||||
<svg className="mr-3 h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" />
|
||||
</svg>
|
||||
Continue with Apple
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full h-14 text-base font-bold rounded-xl border-slate-200 dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800/50 hover:scale-[1.02] transition-all shadow-sm"
|
||||
onClick={() => handleOAuthLogin('GitHubOAuth')}
|
||||
>
|
||||
<svg className="mr-3 h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||
</svg>
|
||||
Continue with GitHub
|
||||
</Button>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="relative py-2">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-slate-200 dark:border-slate-700" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-white/80 dark:bg-slate-900 px-3 text-slate-400 font-bold tracking-widest backdrop-blur-sm">or</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full h-14 text-base font-bold rounded-xl border-slate-200 dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800/50 hover:scale-[1.02] transition-all shadow-sm"
|
||||
onClick={() => setShowEmailForm(true)}
|
||||
>
|
||||
<Mail className="mr-3 h-5 w-5" />
|
||||
Continue with Email
|
||||
</Button>
|
||||
|
||||
<StayLoggedInCheckbox />
|
||||
|
||||
<div className="pt-2">
|
||||
<p className="text-center text-[10px] uppercase font-bold tracking-widest text-slate-400 dark:text-slate-500 leading-relaxed max-w-[200px] mx-auto">
|
||||
By continuing, you agree to our Terms & Privacy Policy
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 animate-in fade-in slide-in-from-right-4 duration-300">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-bold text-slate-900 dark:text-white">
|
||||
{isSignup ? 'Create Account' : 'Welcome Back'}
|
||||
</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowEmailForm(false)}
|
||||
className="text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-500/30 rounded-lg flex items-center gap-3 text-red-600 dark:text-red-400 text-sm">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSignup && (
|
||||
<div className="grid grid-cols-2 gap-3 animate-in fade-in slide-in-from-top-2 duration-300">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="firstName">First Name</Label>
|
||||
<Input
|
||||
id="firstName"
|
||||
placeholder="John"
|
||||
value={formData.firstName}
|
||||
onChange={(e) => setFormData({ ...formData, firstName: e.target.value })}
|
||||
required={isSignup}
|
||||
className="bg-slate-50 dark:bg-slate-800/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lastName">Last Name</Label>
|
||||
<Input
|
||||
id="lastName"
|
||||
placeholder="Doe"
|
||||
value={formData.lastName}
|
||||
onChange={(e) => setFormData({ ...formData, lastName: e.target.value })}
|
||||
required={isSignup}
|
||||
className="bg-slate-50 dark:bg-slate-800/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email Address</Label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="name@example.com"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
required
|
||||
className="pl-10 bg-slate-50 dark:bg-slate-800/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
{!isSignup && !pendingVerification && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleForgotPassword}
|
||||
className="text-xs text-primary hover:underline"
|
||||
disabled={isResettingPassword}
|
||||
>
|
||||
{isResettingPassword ? 'Sending reset link...' : 'Forgot password?'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
required
|
||||
minLength={8}
|
||||
disabled={pendingVerification}
|
||||
className="pl-10 bg-slate-50 dark:bg-slate-800/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pendingVerification && (
|
||||
<div className="space-y-2 animate-in fade-in slide-in-from-top-2 duration-300">
|
||||
<Label htmlFor="verificationCode" className="text-primary font-bold">Verification Code</Label>
|
||||
<Input
|
||||
id="verificationCode"
|
||||
placeholder="Enter the code sent to your email"
|
||||
value={verificationCode}
|
||||
onChange={(e) => setVerificationCode(e.target.value)}
|
||||
required
|
||||
className="bg-primary/5 border-primary/20 text-lg tracking-widest text-center font-mono placeholder:font-sans placeholder:text-sm placeholder:tracking-normal"
|
||||
/>
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400 font-medium flex items-start gap-1.5 p-2 bg-amber-50 dark:bg-amber-900/20 rounded-lg border border-amber-100 dark:border-amber-800/50">
|
||||
<AlertCircle className="w-3.5 h-3.5 mt-0.5 shrink-0" />
|
||||
<span>
|
||||
Please check your spam/junk folder if you don't see the email in your inbox.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resetStatus && (
|
||||
<div className="rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-600 dark:text-emerald-300">
|
||||
{resetStatus}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<StayLoggedInCheckbox />
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full h-12 text-base font-bold rounded-xl shadow-lg shadow-primary/20"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{isSignup ? 'Creating Account...' : 'Signing In...'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{pendingVerification ? 'Verify & Sign In' : (isSignup ? 'Create Account' : 'Sign In')}
|
||||
<ArrowRight className="ml-2 h-4 w-4 group-hover:translate-x-1 transition-transform" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleMode}
|
||||
className="text-sm text-slate-500 dark:text-slate-400 hover:text-primary dark:hover:text-primary transition-colors font-medium"
|
||||
>
|
||||
{isSignup ? 'Already have an account? Sign in' : "Don't have an account? Sign up"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,10 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { QuitPlan, UsageEntry, UserPreferences } from '@/lib/storage';
|
||||
import { Target, TrendingDown, ChevronDown, ChevronUp, Cigarette, Leaf } from 'lucide-react';
|
||||
import { TrendingDown, ChevronDown, ChevronUp, Cigarette, Leaf, AlertTriangle, XCircle } from 'lucide-react';
|
||||
import { useTheme } from '@/lib/theme-context';
|
||||
import { getTodayString } from '@/lib/date-utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
@ -16,7 +16,7 @@ interface SubstancePlanSectionProps {
|
||||
trackingStartDate: string | null;
|
||||
onGeneratePlan: () => void;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
onToggle?: () => void;
|
||||
}
|
||||
|
||||
function SubstancePlanSection({
|
||||
@ -76,6 +76,7 @@ function SubstancePlanSection({
|
||||
const isNicotine = substance === 'nicotine';
|
||||
const Icon = isNicotine ? Cigarette : Leaf;
|
||||
const label = isNicotine ? 'Nicotine' : 'Weed';
|
||||
const unitLabel = isNicotine ? 'puffs' : 'hits';
|
||||
|
||||
// Base Colors
|
||||
const bgColor = isNicotine
|
||||
@ -101,7 +102,10 @@ function SubstancePlanSection({
|
||||
{/* HEADER / SUMMARY ROW */}
|
||||
<div
|
||||
onClick={onToggle}
|
||||
className="flex items-center justify-between p-4 cursor-pointer hover:bg-black/5 active:bg-black/10 transition-colors"
|
||||
className={cn(
|
||||
"flex items-center justify-between p-4",
|
||||
onToggle && "cursor-pointer hover:bg-black/5 active:bg-black/10 transition-colors"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn("p-2 rounded-lg", isNicotine ? "bg-yellow-500/20" : "bg-emerald-500/20")}>
|
||||
@ -122,70 +126,110 @@ function SubstancePlanSection({
|
||||
{todayUsage}{activePlan ? ` / ${currentTarget}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
{isExpanded ? <ChevronUp className="h-5 w-5 opacity-30" /> : <ChevronDown className="h-5 w-5 opacity-30" />}
|
||||
{onToggle && (isExpanded ? <ChevronUp className="h-5 w-5 opacity-30" /> : <ChevronDown className="h-5 w-5 opacity-30" />)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* EXPANDED CONTENT */}
|
||||
{isExpanded && (
|
||||
<div className="px-4 pb-4 animate-in slide-in-from-top-2 duration-200">
|
||||
<div className="h-px w-full bg-border mb-4 opacity-30" />
|
||||
<div className="h-px w-full bg-border mb-4 opacity-30" />
|
||||
|
||||
{!activePlan ? (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-black/5 p-4 rounded-lg">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-xs font-medium">Weekly Baseline Progress</span>
|
||||
<span className={cn("text-xs font-bold", accentColor)}>
|
||||
{daysRemaining > 0 ? `${daysRemaining} days left` : 'Ready!'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-black/10 rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
className={cn("h-full transition-all duration-700", progressFill)}
|
||||
style={{ width: `${Math.min(100, (uniqueDaysWithData / 7) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
{!activePlan ? (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-black/5 p-4 rounded-lg">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-xs font-medium">Weekly Baseline Progress</span>
|
||||
<span className={cn("text-xs font-bold", accentColor)}>
|
||||
{daysRemaining > 0 ? `${daysRemaining} days left` : 'Ready!'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-black/10 rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
className={cn("h-full transition-all duration-700", progressFill)}
|
||||
style={{ width: `${Math.min(100, (uniqueDaysWithData / 7) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isUnlocked ? (
|
||||
<div className="text-center space-y-3">
|
||||
<p className="text-sm">
|
||||
Baseline established: <strong className={accentColor}>{currentAverage} puffs/day</strong>
|
||||
</p>
|
||||
<Button onClick={(e) => { e.stopPropagation(); onGeneratePlan(); }} size="sm" className={cn("w-full h-10 font-bold", progressFill, "text-white hover:opacity-90")}>
|
||||
Generate Plan
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-center opacity-70 italic">
|
||||
Keep logging for {daysRemaining} more days to calculate your personalized reduction plan.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
|
||||
{isUnlocked ? (
|
||||
<div className="text-center space-y-3">
|
||||
<p className="text-sm">
|
||||
Baseline established: <strong className={accentColor}>{currentAverage} {unitLabel}/day</strong>
|
||||
</p>
|
||||
<Button onClick={(e) => { e.stopPropagation(); onGeneratePlan(); }} size="sm" className={cn("w-full h-10 font-bold", progressFill, "text-white hover:opacity-90")}>
|
||||
Generate Plan
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-center opacity-70 italic">
|
||||
Keep logging for {daysRemaining} more days to calculate your personalized reduction plan.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Active Plan Detail */}
|
||||
<div className="text-center">
|
||||
<p className="text-[10px] font-bold uppercase opacity-50 mb-1">Current Daily Limit</p>
|
||||
<p className={cn("text-4xl font-black", accentColor)}>{currentTarget}</p>
|
||||
<p className="text-xs opacity-50 mt-1">puffs allowed today</p>
|
||||
<p className="text-xs opacity-50 mt-1">{unitLabel} allowed today</p>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar Detail */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="space-y-1.5 pb-5">
|
||||
<div className="flex justify-between text-[10px] uppercase font-bold opacity-60">
|
||||
<span>Usage Progress</span>
|
||||
<span>{Math.round(usagePercent)}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-black/10 rounded-full h-3 overflow-hidden">
|
||||
<div className="relative">
|
||||
<div className="w-full bg-black/10 rounded-full h-3 overflow-hidden">
|
||||
<div
|
||||
className={cn("h-full transition-all duration-500", progressColor)}
|
||||
style={{ width: `${Math.min(100, usagePercent)}%` }}
|
||||
/>
|
||||
</div>
|
||||
{/* Positioned puff count indicator */}
|
||||
<div
|
||||
className={cn("h-full transition-all duration-500", progressColor)}
|
||||
style={{ width: `${Math.min(100, usagePercent)}%` }}
|
||||
/>
|
||||
className="absolute top-full mt-1 transition-all duration-500"
|
||||
style={{
|
||||
left: `${Math.min(100, usagePercent)}%`,
|
||||
transform: usagePercent > 10 ? 'translateX(-100%)' : 'translateX(0)'
|
||||
}}
|
||||
>
|
||||
<span className={cn(
|
||||
"text-xs font-bold whitespace-nowrap",
|
||||
todayUsage >= currentTarget ? "text-red-500" : accentColor
|
||||
)}>
|
||||
{todayUsage} {isNicotine ? 'puffs' : 'hits'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Warning/Exceeded Status Messages */}
|
||||
{todayUsage >= currentTarget && currentTarget > 0 && (
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-red-500/20 border border-red-500/30">
|
||||
<XCircle className="h-5 w-5 text-red-500 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-bold text-red-500">Daily limit exceeded</p>
|
||||
<p className="text-xs opacity-70">You've gone over today's goal. No worries — try again tomorrow!</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{todayUsage < currentTarget && currentTarget - todayUsage <= 5 && currentTarget > 0 && (
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-orange-500/20 border border-orange-500/30">
|
||||
<AlertTriangle className="h-5 w-5 text-orange-500 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-bold text-orange-500">Approaching your limit</p>
|
||||
<p className="text-xs opacity-70">
|
||||
Only <strong>{currentTarget - todayUsage}</strong> {isNicotine ? 'puffs' : 'hits'} remaining today. Pace yourself!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Weekly Matrix */}
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{activePlan.weeklyTargets.map((target, idx) => {
|
||||
@ -212,11 +256,11 @@ function SubstancePlanSection({
|
||||
</div>
|
||||
|
||||
<div className="bg-black/5 p-3 rounded-lg flex justify-between items-center text-[10px] uppercase font-bold opacity-50">
|
||||
<span>Start: {activePlan.baselineAverage}/day</span>
|
||||
<span>Start: {activePlan.baselineAverage} {unitLabel}/day</span>
|
||||
<span>End: {new Date(activePlan.endDate).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -228,38 +272,59 @@ interface UnifiedQuitPlanCardProps {
|
||||
usageData: UsageEntry[];
|
||||
onGeneratePlan: (substance: 'nicotine' | 'weed') => void;
|
||||
refreshKey: number;
|
||||
variant?: 'desktop' | 'mobile';
|
||||
}
|
||||
|
||||
export function UnifiedQuitPlanCard({
|
||||
preferences,
|
||||
usageData,
|
||||
onGeneratePlan,
|
||||
refreshKey
|
||||
refreshKey,
|
||||
variant = 'mobile'
|
||||
}: UnifiedQuitPlanCardProps) {
|
||||
const [expandedSubstance, setExpandedSubstance] = useState<'nicotine' | 'weed' | 'none'>('nicotine');
|
||||
|
||||
if (!preferences) return null;
|
||||
|
||||
// Determine which substances to show
|
||||
const showNicotine = preferences.substance === 'nicotine' || usageData.some(e => e.substance === 'nicotine');
|
||||
const showWeed = preferences.substance === 'weed' || usageData.some(e => e.substance === 'weed');
|
||||
const isDesktopVariant = variant === 'desktop';
|
||||
|
||||
const showNicotine = isDesktopVariant
|
||||
? (preferences.substance === 'nicotine' || usageData.some(e => e.substance === 'nicotine'))
|
||||
: true;
|
||||
const showWeed = isDesktopVariant
|
||||
? (preferences.substance === 'weed' || usageData.some(e => e.substance === 'weed'))
|
||||
: true;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDesktopVariant) return;
|
||||
if (expandedSubstance === 'none') return;
|
||||
if (expandedSubstance === 'nicotine' && !showNicotine) {
|
||||
setExpandedSubstance(showWeed ? 'weed' : 'none');
|
||||
}
|
||||
if (expandedSubstance === 'weed' && !showWeed) {
|
||||
setExpandedSubstance(showNicotine ? 'nicotine' : 'none');
|
||||
}
|
||||
}, [expandedSubstance, isDesktopVariant, showNicotine, showWeed]);
|
||||
|
||||
if (!showNicotine && !showWeed) return null;
|
||||
|
||||
return (
|
||||
<Card className="backdrop-blur-2xl shadow-2xl border-white/10 overflow-hidden bg-white/5">
|
||||
<Card
|
||||
className={cn(
|
||||
"backdrop-blur-2xl shadow-2xl border-white/10 overflow-hidden bg-white/5",
|
||||
!isDesktopVariant && "max-h-[calc(100dvh-13.5rem)]"
|
||||
)}
|
||||
>
|
||||
<CardHeader className="pb-1 pt-6 px-4 sm:px-6">
|
||||
<CardTitle className="flex items-center gap-2 text-sm sm:text-base font-black uppercase tracking-widest opacity-60">
|
||||
<TrendingDown className="h-4 w-4 text-primary" />
|
||||
Quit Journey Plan
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-2 p-2 sm:p-4">
|
||||
<CardContent className={cn("pt-2 p-2 sm:p-4", !isDesktopVariant && "overflow-y-auto overscroll-contain pr-1")}>
|
||||
{showNicotine && (
|
||||
<SubstancePlanSection
|
||||
substance="nicotine"
|
||||
isExpanded={expandedSubstance === 'nicotine'}
|
||||
onToggle={() => setExpandedSubstance(expandedSubstance === 'nicotine' ? 'none' : 'nicotine')}
|
||||
plan={preferences.quitState?.nicotine?.plan || (preferences.substance === 'nicotine' ? preferences.quitPlan : null)}
|
||||
usageData={usageData}
|
||||
trackingStartDate={
|
||||
@ -268,6 +333,8 @@ export function UnifiedQuitPlanCard({
|
||||
usageData.filter(e => e.substance === 'nicotine').sort((a, b) => a.date.localeCompare(b.date))[0]?.date ||
|
||||
null
|
||||
}
|
||||
isExpanded={isDesktopVariant ? expandedSubstance === 'nicotine' : true}
|
||||
onToggle={isDesktopVariant ? () => setExpandedSubstance(expandedSubstance === 'nicotine' ? 'none' : 'nicotine') : undefined}
|
||||
onGeneratePlan={() => onGeneratePlan('nicotine')}
|
||||
/>
|
||||
)}
|
||||
@ -275,8 +342,6 @@ export function UnifiedQuitPlanCard({
|
||||
{showWeed && (
|
||||
<SubstancePlanSection
|
||||
substance="weed"
|
||||
isExpanded={expandedSubstance === 'weed'}
|
||||
onToggle={() => setExpandedSubstance(expandedSubstance === 'weed' ? 'none' : 'weed')}
|
||||
plan={preferences.quitState?.weed?.plan || (preferences.substance === 'weed' ? preferences.quitPlan : null)}
|
||||
usageData={usageData}
|
||||
trackingStartDate={
|
||||
@ -285,6 +350,8 @@ export function UnifiedQuitPlanCard({
|
||||
usageData.filter(e => e.substance === 'weed').sort((a, b) => a.date.localeCompare(b.date))[0]?.date ||
|
||||
null
|
||||
}
|
||||
isExpanded={isDesktopVariant ? expandedSubstance === 'weed' : true}
|
||||
onToggle={isDesktopVariant ? () => setExpandedSubstance(expandedSubstance === 'weed' ? 'none' : 'weed') : undefined}
|
||||
onGeneratePlan={() => onGeneratePlan('weed')}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -27,11 +27,12 @@ interface UsageCalendarProps {
|
||||
userId: string;
|
||||
religion?: 'christian' | 'secular' | null;
|
||||
onReligionUpdate?: (religion: 'christian' | 'secular') => void;
|
||||
showInspirationPanel?: boolean;
|
||||
preferences?: UserPreferences | null;
|
||||
onPreferencesUpdate?: (prefs: UserPreferences) => Promise<void>;
|
||||
}
|
||||
|
||||
function UsageCalendarComponent({ usageData, onDataUpdate, religion, onReligionUpdate, preferences, onPreferencesUpdate }: UsageCalendarProps) {
|
||||
function UsageCalendarComponent({ usageData, onDataUpdate, religion, onReligionUpdate, showInspirationPanel = false, preferences, onPreferencesUpdate }: UsageCalendarProps) {
|
||||
const [selectedDate, setSelectedDate] = useState<Date | undefined>(undefined);
|
||||
const [editNicotineCount, setEditNicotineCount] = useState('');
|
||||
const [editWeedCount, setEditWeedCount] = useState('');
|
||||
@ -258,60 +259,67 @@ function UsageCalendarComponent({ usageData, onDataUpdate, religion, onReligionU
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-8 lg:gap-16 items-center lg:items-stretch justify-center max-w-6xl mx-auto">
|
||||
{/* Calendar - Focused Container */}
|
||||
<div className="w-full lg:w-auto flex flex-col items-center">
|
||||
<div className={cn(
|
||||
"rounded-2xl p-2 sm:p-4 border shadow-inner transition-all duration-500",
|
||||
theme === 'light' ? "bg-slate-50/50 border-slate-200/60" : "bg-black/20 border-white/5"
|
||||
)}>
|
||||
<DayPicker
|
||||
mode="single"
|
||||
selected={selectedDate}
|
||||
onSelect={handleDateSelect}
|
||||
className={cn(
|
||||
"p-0 sm:p-2",
|
||||
theme === 'light' ? "text-slate-900" : "text-white"
|
||||
)}
|
||||
showOutsideDays={false}
|
||||
components={{
|
||||
DayButton: (props) => (
|
||||
<CustomDayButton
|
||||
{...props}
|
||||
className={cn(
|
||||
props.className,
|
||||
"aspect-square rounded-full flex items-center justify-center p-0"
|
||||
)}
|
||||
/>
|
||||
),
|
||||
Chevron: ({ orientation }) => (
|
||||
<div className={cn(
|
||||
"p-1.5 rounded-full border transition-all duration-200",
|
||||
theme === 'light'
|
||||
? "bg-white border-slate-200 text-slate-600 hover:bg-slate-100 hover:scale-110 shadow-sm"
|
||||
: "bg-white/5 border-white/10 text-white/70 hover:bg-white/10 hover:scale-110"
|
||||
)}>
|
||||
{orientation === 'left' ? (
|
||||
<ChevronLeftIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<div className={cn("mx-auto", showInspirationPanel ? "max-w-6xl" : "max-w-4xl")}>
|
||||
<div className={cn(
|
||||
showInspirationPanel
|
||||
? "flex flex-col lg:flex-row gap-8 lg:gap-8 items-center lg:items-stretch justify-center"
|
||||
: "w-full flex flex-col items-center"
|
||||
)}>
|
||||
{/* Calendar */}
|
||||
<div className={cn("w-full flex flex-col items-center", showInspirationPanel && "lg:w-1/2")}>
|
||||
<div className={cn(
|
||||
"rounded-2xl p-2 sm:p-4 border shadow-inner transition-all duration-500 w-full",
|
||||
theme === 'light' ? "bg-slate-50/50 border-slate-200/60" : "bg-black/20 border-white/5"
|
||||
)}>
|
||||
<DayPicker
|
||||
mode="single"
|
||||
selected={selectedDate}
|
||||
onSelect={handleDateSelect}
|
||||
className={cn(
|
||||
"p-0 sm:p-2 w-full [&_.rdp-month]:w-full [&_.rdp-table]:w-full",
|
||||
theme === 'light' ? "text-slate-900" : "text-white"
|
||||
)}
|
||||
showOutsideDays={false}
|
||||
components={{
|
||||
DayButton: (props) => (
|
||||
<CustomDayButton
|
||||
{...props}
|
||||
className={cn(
|
||||
props.className,
|
||||
"aspect-square rounded-full flex items-center justify-center p-0"
|
||||
)}
|
||||
/>
|
||||
),
|
||||
Chevron: ({ orientation }) => (
|
||||
<div className={cn(
|
||||
"p-1.5 rounded-full border transition-all duration-200",
|
||||
theme === 'light'
|
||||
? "bg-white border-slate-200 text-slate-600 hover:bg-slate-100 hover:scale-110 shadow-sm"
|
||||
: "bg-white/5 border-white/10 text-white/70 hover:bg-white/10 hover:scale-110"
|
||||
)}>
|
||||
{orientation === 'left' ? (
|
||||
<ChevronLeftIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop Vertical Divider */}
|
||||
<div className="hidden lg:block w-px self-stretch bg-gradient-to-b from-transparent via-white/10 to-transparent" />
|
||||
|
||||
{/* Daily Inspiration - Centered vertically on desktop */}
|
||||
<div className="flex-1 w-full max-w-2xl flex flex-col justify-center">
|
||||
<DailyInspirationCard
|
||||
initialReligion={religion}
|
||||
onReligionChange={onReligionUpdate}
|
||||
/>
|
||||
{showInspirationPanel && (
|
||||
<>
|
||||
<div className="hidden lg:block w-px self-stretch bg-gradient-to-b from-transparent via-white/10 to-transparent" />
|
||||
<div className="w-full lg:w-1/2 flex flex-col justify-center">
|
||||
<DailyInspirationCard
|
||||
initialReligion={religion}
|
||||
onReligionChange={onReligionUpdate}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -122,9 +122,11 @@ export function UsageTrendGraph({ usageData, substance }: UsageTrendGraphProps)
|
||||
<p className="text-sm text-muted-foreground">Daily Average</p>
|
||||
</div>
|
||||
<div className="bg-muted/50 p-3 rounded-lg text-center hover:bg-muted/70 transition-all duration-200 hover:scale-[1.02]">
|
||||
<p className="text-2xl font-bold capitalize">{trend}</p>
|
||||
<p className="text-2xl font-bold capitalize">
|
||||
{trend === 'increasing' ? 'Usage Rising' : trend === 'decreasing' ? 'Dropping' : 'Stable'}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{trend === 'decreasing' ? 'Great progress!' : trend === 'increasing' ? 'Stay strong!' : 'Holding steady'}
|
||||
{trend === 'decreasing' ? 'Great progress!' : trend === 'increasing' ? 'Time to refocus' : 'Holding steady'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -25,13 +25,19 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { User } from '@/lib/session';
|
||||
import { fetchPreferences, fetchReminderSettings, saveReminderSettings, ReminderSettings, UserPreferences } from '@/lib/storage';
|
||||
import {
|
||||
fetchPreferences,
|
||||
fetchReminderSettings,
|
||||
saveReminderSettings,
|
||||
ReminderSettings,
|
||||
UserPreferences,
|
||||
defaultReminderSettings,
|
||||
} from '@/lib/storage';
|
||||
import { useNotifications } from '@/hooks/useNotifications';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRef, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Cigarette, Leaf, LogOut, Home, ChevronDown, Sun, Moon, Bell, BellOff, BellRing, Menu, Sparkles, Link as LinkIcon } from 'lucide-react';
|
||||
import { Bell, BellOff, BellRing, Menu, Sparkles } from 'lucide-react';
|
||||
import { useTheme } from '@/lib/theme-context';
|
||||
import { InstallAppButton } from './InstallAppButton';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SideMenu } from './SideMenu';
|
||||
|
||||
@ -131,8 +137,107 @@ export function UserHeader({ user, preferences, onModalStateChange }: UserHeader
|
||||
const [localTime, setLocalTime] = useState('09:00');
|
||||
const [localFrequency, setLocalFrequency] = useState<'daily' | 'hourly'>('daily');
|
||||
const router = useRouter();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { theme } = useTheme();
|
||||
const { isSupported, permission, requestPermission } = useNotifications(reminderSettings);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const videoRetryTimeoutRef = useRef<number | null>(null);
|
||||
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
||||
const [isVideoPlaying, setIsVideoPlaying] = useState(false);
|
||||
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [isPWA, setIsPWA] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Detect Mobile/PWA
|
||||
const checkMobile = () => {
|
||||
const userAgent = typeof window.navigator === "undefined" ? "" : navigator.userAgent;
|
||||
const mobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(userAgent);
|
||||
setIsMobile(mobile);
|
||||
|
||||
// Detect PWA (standalone mode)
|
||||
const isStandalone = window.matchMedia('(display-mode: standalone)').matches || (window.navigator as any).standalone;
|
||||
setIsPWA(!!isStandalone);
|
||||
};
|
||||
checkMobile();
|
||||
}, []);
|
||||
|
||||
const clearVideoRetryTimeout = () => {
|
||||
if (videoRetryTimeoutRef.current !== null) {
|
||||
window.clearTimeout(videoRetryTimeoutRef.current);
|
||||
videoRetryTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleVideoRetry = () => {
|
||||
clearVideoRetryTimeout();
|
||||
videoRetryTimeoutRef.current = window.setTimeout(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
if (video.readyState < 2) {
|
||||
video.load();
|
||||
}
|
||||
|
||||
video.play().catch(() => {
|
||||
// Ignore autoplay retries that still fail.
|
||||
});
|
||||
}, 900);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearVideoRetryTimeout();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Force play background video
|
||||
useEffect(() => {
|
||||
if (videoRef.current) {
|
||||
const video = videoRef.current;
|
||||
|
||||
// Optimization: For Desktop, we prioritize quality logic if we had multiple sources.
|
||||
// For now, we ensure robust playback for both.
|
||||
|
||||
// PWA/Mobile specific optimizations
|
||||
if (isMobile || isPWA) {
|
||||
// Ensure strictly muted/inline for iOS policy
|
||||
video.muted = true;
|
||||
video.defaultMuted = true;
|
||||
video.playsInline = true;
|
||||
video.setAttribute('playsinline', 'true'); // Explicit attribute for some older browsers
|
||||
video.setAttribute('webkit-playsinline', 'true');
|
||||
video.preload = 'metadata';
|
||||
} else {
|
||||
video.preload = 'auto';
|
||||
}
|
||||
|
||||
video.loop = true;
|
||||
|
||||
// If already ready, set loaded immediately
|
||||
if (video.readyState >= 2) {
|
||||
setIsVideoLoaded(true);
|
||||
}
|
||||
|
||||
// Try playing
|
||||
const playPromise = video.play();
|
||||
if (playPromise !== undefined) {
|
||||
playPromise.catch((err) => {
|
||||
console.warn("Autoplay failed, user interaction might be needed:", err);
|
||||
if (isMobile || isPWA) {
|
||||
scheduleVideoRetry();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (isMobile || isPWA) {
|
||||
scheduleVideoRetry();
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearVideoRetryTimeout();
|
||||
};
|
||||
}, [isMobile, isPWA]);
|
||||
|
||||
useEffect(() => {
|
||||
if (onModalStateChange) {
|
||||
@ -166,26 +271,33 @@ export function UserHeader({ user, preferences, onModalStateChange }: UserHeader
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
const [prefs, reminders] = await Promise.all([
|
||||
preferences ? Promise.resolve(preferences) : fetchPreferences(),
|
||||
fetchReminderSettings(),
|
||||
]);
|
||||
try {
|
||||
const [prefs, reminders] = await Promise.all([
|
||||
preferences ? Promise.resolve(preferences) : fetchPreferences(),
|
||||
fetchReminderSettings(),
|
||||
]);
|
||||
|
||||
if (prefs) {
|
||||
setUserName(prefs.userName);
|
||||
if (prefs) {
|
||||
setUserName(prefs.userName);
|
||||
}
|
||||
|
||||
const detectedTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
let settingsToUse = reminders;
|
||||
|
||||
if (reminders.timezone !== detectedTimezone) {
|
||||
settingsToUse = { ...reminders, timezone: detectedTimezone };
|
||||
await saveReminderSettings(settingsToUse);
|
||||
}
|
||||
|
||||
setReminderSettings(settingsToUse);
|
||||
setLocalTime(settingsToUse.reminderTime);
|
||||
setLocalFrequency(settingsToUse.frequency || 'daily');
|
||||
} catch (error) {
|
||||
console.error('Failed to load header data:', error);
|
||||
setReminderSettings(defaultReminderSettings);
|
||||
setLocalTime(defaultReminderSettings.reminderTime);
|
||||
setLocalFrequency(defaultReminderSettings.frequency);
|
||||
}
|
||||
|
||||
const detectedTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
let settingsToUse = reminders;
|
||||
|
||||
if (reminders.timezone !== detectedTimezone) {
|
||||
settingsToUse = { ...reminders, timezone: detectedTimezone };
|
||||
await saveReminderSettings(settingsToUse);
|
||||
}
|
||||
|
||||
setReminderSettings(settingsToUse);
|
||||
setLocalTime(settingsToUse.reminderTime);
|
||||
setLocalFrequency(settingsToUse.frequency || 'daily');
|
||||
};
|
||||
loadData();
|
||||
}, [preferences]);
|
||||
@ -219,321 +331,400 @@ export function UserHeader({ user, preferences, onModalStateChange }: UserHeader
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="sticky top-0 z-50 transition-colors duration-300 relative" style={{
|
||||
background: theme === 'light'
|
||||
? 'rgba(255, 255, 255, 0.92)'
|
||||
: 'rgba(10, 10, 20, 0.94)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
WebkitBackdropFilter: 'blur(20px)',
|
||||
borderBottom: theme === 'light' ? '1px solid rgba(0,0,0,0.08)' : '1px solid rgba(255,255,255,0.08)'
|
||||
<header className="sticky top-0 z-50 relative overflow-hidden h-16 sm:h-20" style={{
|
||||
borderBottom: '1px solid rgba(255,255,255,0.08)'
|
||||
}}>
|
||||
{/* Cloudy/Foggy effect overlay with ultra-wide, organic feathering */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none select-none overflow-hidden invert dark:invert-0 transition-opacity duration-700"
|
||||
style={{
|
||||
maskImage: 'radial-gradient(ellipse 95% 140% at 50% 50%, black 0%, rgba(0,0,0,0.4) 60%, transparent 100%)',
|
||||
WebkitMaskImage: 'radial-gradient(ellipse 95% 140% at 50% 50%, black 0%, rgba(0,0,0,0.4) 60%, transparent 100%)',
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 fog-layer-1 opacity-[0.25] dark:opacity-[0.2]" />
|
||||
<div className="absolute inset-0 fog-layer-2 opacity-[0.18] dark:opacity-[0.14]" />
|
||||
{/* Background Layers */}
|
||||
<div className="absolute inset-0 z-[-1] pointer-events-none overflow-hidden">
|
||||
<style dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
/* Absolute nuclear suppression of all native iOS media HUDs */
|
||||
video::-webkit-media-controls {
|
||||
display: none !important;
|
||||
opacity: 0 !important;
|
||||
-webkit-appearance: none !important;
|
||||
}
|
||||
video::-webkit-media-controls-start-playback-button {
|
||||
display: none !important;
|
||||
opacity: 0 !important;
|
||||
-webkit-appearance: none !important;
|
||||
}
|
||||
video::-webkit-media-controls-panel {
|
||||
display: none !important;
|
||||
opacity: 0 !important;
|
||||
-webkit-appearance: none !important;
|
||||
}
|
||||
video::-webkit-media-controls-play-button {
|
||||
display: none !important;
|
||||
opacity: 0 !important;
|
||||
-webkit-appearance: none !important;
|
||||
}
|
||||
/* Extra safety for PWA/Safari specifics */
|
||||
*::-webkit-media-controls-start-playback-button {
|
||||
display: none !important;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
` }} />
|
||||
|
||||
{/* Base Background Layer - Simple semi-transparent base */}
|
||||
<div
|
||||
className="absolute inset-0 transition-colors duration-500"
|
||||
style={{
|
||||
background: 'rgba(10, 10, 20, 0.5)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Wrapper for background image/video to allow click-to-play */}
|
||||
<div
|
||||
className="absolute inset-0 w-full h-full pointer-events-auto"
|
||||
onClick={() => {
|
||||
if (videoRef.current && videoRef.current.paused) {
|
||||
videoRef.current.play().catch((err) => {
|
||||
console.warn("User interaction play failed:", err);
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Static Poster Image Layer (Safe Fallback) */}
|
||||
<img
|
||||
src="/videos/smoke-poster.jpg"
|
||||
alt=""
|
||||
className={cn(
|
||||
"absolute inset-0 w-full h-full object-cover transition-opacity duration-1000",
|
||||
isMobile ? "scale-105" : "scale-110", // Reduce scale on mobile
|
||||
isVideoPlaying ? "opacity-0" : "opacity-100" // Hide poster completely when playing for better perf, or keep low opacity
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Smoke Video background - Only visible when MOVING */}
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
{...({
|
||||
"webkit-playsinline": "true",
|
||||
"x-webkit-airplay": "deny",
|
||||
"disableRemotePlayback": true
|
||||
} as any)}
|
||||
preload={isMobile || isPWA ? 'metadata' : 'auto'}
|
||||
controls={false}
|
||||
disablePictureInPicture
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
onLoadedData={() => setIsVideoLoaded(true)}
|
||||
onCanPlay={() => {
|
||||
setIsVideoLoaded(true);
|
||||
if (videoRef.current?.paused) {
|
||||
videoRef.current.play().catch(() => {
|
||||
if (isMobile || isPWA) {
|
||||
scheduleVideoRetry();
|
||||
}
|
||||
});
|
||||
}
|
||||
}}
|
||||
onPlaying={() => {
|
||||
if (videoRef.current && videoRef.current.currentTime > 0.06) {
|
||||
setIsVideoPlaying(true);
|
||||
}
|
||||
}}
|
||||
onTimeUpdate={() => {
|
||||
if (!isVideoPlaying && videoRef.current && videoRef.current.currentTime > 0.06) {
|
||||
setIsVideoPlaying(true);
|
||||
}
|
||||
}}
|
||||
onWaiting={() => {
|
||||
setIsVideoPlaying(false);
|
||||
if (isMobile || isPWA) {
|
||||
scheduleVideoRetry();
|
||||
}
|
||||
}}
|
||||
onStalled={() => {
|
||||
setIsVideoPlaying(false);
|
||||
if (isMobile || isPWA) {
|
||||
scheduleVideoRetry();
|
||||
}
|
||||
}}
|
||||
onPause={() => {
|
||||
setIsVideoPlaying(false);
|
||||
}}
|
||||
onError={() => {
|
||||
setIsVideoPlaying(false);
|
||||
}}
|
||||
className={cn(
|
||||
"absolute inset-0 w-full h-full object-cover transition-opacity duration-[1500ms] ease-in-out",
|
||||
isMobile ? "scale-105" : "scale-110",
|
||||
isVideoPlaying
|
||||
? "opacity-50" // Simple consistent transparency
|
||||
: "opacity-[0.01]"
|
||||
)}
|
||||
>
|
||||
<source src="/videos/smoke.mp4" type="video/mp4" />
|
||||
</video>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="container mx-auto px-4 h-16 sm:h-20 flex items-center justify-between relative z-50">
|
||||
{/* LEFT: User Profile / Side Menu Trigger */}
|
||||
<div className="flex-1 flex justify-start">
|
||||
<button
|
||||
onClick={() => setIsSideMenuOpen(true)}
|
||||
className="group relative flex items-center gap-2 p-1.5 pr-3 rounded-full transition-all hover:bg-black/5 dark:hover:bg-white/5 active:scale-95 border border-transparent hover:border-primary/10"
|
||||
>
|
||||
<div className="relative">
|
||||
<Avatar className="h-9 w-9 sm:h-10 sm:w-10 ring-2 ring-primary/20 transition-all group-hover:ring-primary/50 shadow-sm">
|
||||
<AvatarImage src={user.profilePictureUrl ?? undefined} alt={userName || 'User'} />
|
||||
<AvatarFallback className="bg-gradient-to-br from-indigo-500 to-purple-500 text-white font-bold">{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="absolute -bottom-1 -right-1 bg-white dark:bg-slate-900 rounded-full p-0.5 shadow-sm border border-border">
|
||||
<Menu className="h-3 w-3 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden sm:block text-left">
|
||||
<div className="text-[10px] font-bold uppercase tracking-widest opacity-50 leading-none">Menu</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div className="container mx-auto px-4 h-16 sm:h-20 relative z-50 grid grid-cols-[1fr_auto_1fr] items-center">
|
||||
{/* LEFT: Spacer for balanced centered title */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* CENTER: Title and Welcome Message */}
|
||||
<div className="flex-[2] flex flex-col items-center justify-center text-center">
|
||||
<h1
|
||||
className={cn(
|
||||
"text-xl sm:text-2xl font-bold cursor-pointer transition-all duration-300 hover:scale-105 tracking-tight leading-tight bg-clip-text text-transparent w-fit mx-auto",
|
||||
theme === 'light'
|
||||
? 'bg-gradient-to-br from-[#4f46e5] to-[#7c3aed]'
|
||||
: 'bg-gradient-to-br from-[#a78bfa] to-[#f472b6]'
|
||||
"bg-gradient-to-br from-[#a78bfa] to-[#f472b6]"
|
||||
)}
|
||||
onClick={() => handleNavigate('/')}
|
||||
style={{
|
||||
filter: theme === 'dark' ? 'drop-shadow(0 0 10px rgba(167, 139, 250, 0.3))' : 'none'
|
||||
filter: 'drop-shadow(0 0 10px rgba(167, 139, 250, 0.3))'
|
||||
}}
|
||||
>
|
||||
QuitTraq
|
||||
</h1>
|
||||
{userName && (
|
||||
<p className="text-[10px] sm:text-xs font-medium text-foreground/60 tracking-wide mt-0.5 whitespace-nowrap overflow-hidden">
|
||||
<p className="text-[10px] sm:text-xs font-medium text-white/60 tracking-wide mt-0.5 whitespace-nowrap overflow-hidden">
|
||||
Welcome {userName}, you got this!
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* RIGHT: Action Buttons */}
|
||||
<div className="flex-1 flex items-center justify-end gap-1.5 sm:gap-3">
|
||||
{/* RIGHT: User Profile / Side Menu Trigger */}
|
||||
<div className="flex items-center justify-end justify-self-end">
|
||||
<button
|
||||
onClick={() => setShowReminderDialog(true)}
|
||||
className={cn(
|
||||
"p-2 sm:p-2.5 rounded-full transition-all duration-300 active:scale-90 shadow-sm",
|
||||
reminderSettings.enabled
|
||||
? 'bg-indigo-500/15 text-indigo-400 border border-indigo-500/20'
|
||||
: 'bg-muted border border-transparent text-muted-foreground'
|
||||
)}
|
||||
onClick={() => setIsSideMenuOpen(true)}
|
||||
className="group relative flex h-10 w-10 sm:h-11 sm:w-11 items-center justify-center rounded-full transition-all hover:bg-white/10 active:scale-95 border border-white/15 hover:border-white/30"
|
||||
aria-label="Open profile menu"
|
||||
>
|
||||
{reminderSettings.enabled ? (
|
||||
<BellRing className="h-4.5 w-4.5 sm:h-5 sm:w-5" />
|
||||
) : (
|
||||
<Bell className="h-4.5 w-4.5 sm:h-5 sm:w-5" />
|
||||
)}
|
||||
</button>
|
||||
<InstallAppButton />
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-2 sm:p-2.5 rounded-full bg-muted border border-transparent hover:bg-muted/80 transition-all active:scale-90"
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
<Moon className="h-4.5 w-4.5 sm:h-5 sm:w-5 text-blue-300" />
|
||||
) : (
|
||||
<Sun className="h-4.5 w-4.5 sm:h-5 sm:w-5 text-yellow-500" />
|
||||
)}
|
||||
<Avatar className="h-8 w-8 sm:h-9 sm:w-9 ring-2 ring-white/20 transition-all group-hover:ring-white/50 shadow-sm">
|
||||
<AvatarImage src={user.profilePictureUrl ?? undefined} alt={userName || 'User'} />
|
||||
<AvatarFallback className="bg-gradient-to-br from-indigo-500 to-purple-500 text-white font-bold text-xs sm:text-sm">{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="absolute -bottom-1 -right-1 rounded-full p-0.5 shadow-sm border border-white/10 bg-slate-900/90">
|
||||
<Menu className="h-3 w-3 text-white" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Side Menu Integration */}
|
||||
<SideMenu
|
||||
isOpen={isSideMenuOpen}
|
||||
onClose={() => setIsSideMenuOpen(false)}
|
||||
user={user}
|
||||
userName={userName}
|
||||
/>
|
||||
{/* Side Menu Integration */}
|
||||
<SideMenu
|
||||
isOpen={isSideMenuOpen}
|
||||
onClose={() => setIsSideMenuOpen(false)}
|
||||
user={user}
|
||||
userName={userName}
|
||||
onOpenNotifications={() => setShowReminderDialog(true)}
|
||||
/>
|
||||
|
||||
{/* Reminder Settings Dialog */}
|
||||
<Dialog open={showReminderDialog} onOpenChange={setShowReminderDialog}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 font-bold tracking-tight">
|
||||
<Bell className="h-5 w-5 text-indigo-400" />
|
||||
Notification Settings
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{/* Reminder Settings Dialog */}
|
||||
<Dialog open={showReminderDialog} onOpenChange={setShowReminderDialog}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 font-bold tracking-tight">
|
||||
<Bell className="h-5 w-5 text-indigo-400" />
|
||||
Notification Settings
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4 px-1">
|
||||
{/* Enable/Disable Toggle */}
|
||||
<div className={cn(
|
||||
"flex items-center justify-between p-4 rounded-2xl border transition-all",
|
||||
theme === 'light' ? "bg-slate-50 border-slate-100" : "bg-white/5 border-white/5"
|
||||
)}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn(
|
||||
"p-2.5 rounded-xl",
|
||||
reminderSettings.enabled ? "bg-indigo-500/20 text-indigo-400" : "bg-slate-500/20 text-slate-400"
|
||||
)}>
|
||||
{reminderSettings.enabled ? <BellRing className="h-5 w-5" /> : <BellOff className="h-5 w-5" />}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-bold">
|
||||
{reminderSettings.enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
<span className="text-[10px] opacity-60">
|
||||
{reminderSettings.enabled ? 'Reminders active' : 'Turn on to get alerts'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-4 py-4 px-1">
|
||||
{/* Enable/Disable Toggle */}
|
||||
<div className={cn(
|
||||
"flex items-center justify-between p-4 rounded-2xl border transition-all",
|
||||
theme === 'light' ? "bg-slate-50 border-slate-100" : "bg-white/5 border-white/5"
|
||||
)}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn(
|
||||
"p-2.5 rounded-xl",
|
||||
reminderSettings.enabled ? "bg-indigo-500/20 text-indigo-400" : "bg-slate-500/20 text-slate-400"
|
||||
)}>
|
||||
{reminderSettings.enabled ? <BellRing className="h-5 w-5" /> : <BellOff className="h-5 w-5" />}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-bold">
|
||||
{reminderSettings.enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
<span className="text-[10px] opacity-60">
|
||||
{reminderSettings.enabled ? 'Reminders active' : 'Turn on to get alerts'}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleToggleReminders}
|
||||
disabled={!isSupported || (permission === 'denied' && !reminderSettings.enabled)}
|
||||
className={cn(
|
||||
"relative w-12 h-6 rounded-full transition-all duration-300 shadow-inner",
|
||||
reminderSettings.enabled ? "bg-indigo-500" : "bg-slate-400/30",
|
||||
(!isSupported || (permission === 'denied' && !reminderSettings.enabled)) && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
"absolute top-1 w-4 h-4 rounded-full bg-white shadow-md transition-all duration-300",
|
||||
reminderSettings.enabled ? "left-7" : "left-1"
|
||||
)} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleToggleReminders}
|
||||
disabled={!isSupported || (permission === 'denied' && !reminderSettings.enabled)}
|
||||
className={cn(
|
||||
"relative w-12 h-6 rounded-full transition-all duration-300 shadow-inner",
|
||||
reminderSettings.enabled ? "bg-indigo-500" : "bg-slate-400/30",
|
||||
(!isSupported || (permission === 'denied' && !reminderSettings.enabled)) && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
"absolute top-1 w-4 h-4 rounded-full bg-white shadow-md transition-all duration-300",
|
||||
reminderSettings.enabled ? "left-7" : "left-1"
|
||||
)} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Frequency Selection */}
|
||||
{reminderSettings.enabled && (
|
||||
<div className="space-y-3">
|
||||
<div className="text-[10px] font-bold uppercase tracking-widest opacity-40 px-1">Reminder Frequency</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
onClick={() => handleFrequencyChange('daily')}
|
||||
className={cn(
|
||||
"p-4 rounded-2xl border text-sm font-bold transition-all flex flex-col items-center gap-1",
|
||||
localFrequency === 'daily'
|
||||
? 'bg-indigo-500 border-indigo-500 text-white shadow-lg shadow-indigo-500/25 scale-[1.02]'
|
||||
: 'bg-background border-border hover:border-indigo-500/50'
|
||||
)}
|
||||
>
|
||||
<span>Daily</span>
|
||||
<span className="text-[10px] font-normal opacity-70">Once a day</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleFrequencyChange('hourly')}
|
||||
className={cn(
|
||||
"p-4 rounded-2xl border text-sm font-bold transition-all flex flex-col items-center gap-1",
|
||||
localFrequency === 'hourly'
|
||||
? 'bg-indigo-500 border-indigo-500 text-white shadow-lg shadow-indigo-500/25 scale-[1.02]'
|
||||
: 'bg-background border-border hover:border-indigo-500/50'
|
||||
)}
|
||||
>
|
||||
<span>Hourly</span>
|
||||
<span className="text-[10px] font-normal opacity-70">Window alerts</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Time Picker (Only for Daily) */}
|
||||
{reminderSettings.enabled && localFrequency === 'daily' && (
|
||||
<div className="space-y-3 animate-in fade-in slide-in-from-top-2 duration-300">
|
||||
<div className="text-[10px] font-bold uppercase tracking-widest opacity-40 px-1">Preferred Time</div>
|
||||
<div className="flex gap-2 p-1 bg-muted/30 rounded-2xl border border-border/50">
|
||||
<div className="flex-1">
|
||||
<Select
|
||||
value={hourString}
|
||||
onValueChange={(val) => updateTime(val, minuteString, currentAmpm)}
|
||||
>
|
||||
<SelectTrigger className="border-none bg-transparent shadow-none hover:bg-white/5 h-12 rounded-xl">
|
||||
<SelectValue placeholder="Hour" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl">
|
||||
{hoursOptions.map((h) => (
|
||||
<SelectItem key={h} value={h} className="rounded-lg">{h}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<Select
|
||||
value={minuteString}
|
||||
onValueChange={(val) => updateTime(hourString, val, currentAmpm)}
|
||||
>
|
||||
<SelectTrigger className="border-none bg-transparent shadow-none hover:bg-white/5 h-12 rounded-xl">
|
||||
<SelectValue placeholder="Min" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl">
|
||||
{minutesOptions.map((m) => (
|
||||
<SelectItem key={m} value={m} className="rounded-lg">{m}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="w-24 px-1">
|
||||
<Select
|
||||
value={currentAmpm}
|
||||
onValueChange={(val) => updateTime(hourString, minuteString, val)}
|
||||
>
|
||||
<SelectTrigger className="border-none bg-indigo-500/10 text-indigo-400 font-bold shadow-none hover:bg-indigo-500/20 h-10 mt-1 rounded-lg">
|
||||
<SelectValue placeholder="AM/PM" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl">
|
||||
<SelectItem value="AM" className="rounded-lg">AM</SelectItem>
|
||||
<SelectItem value="PM" className="rounded-lg">PM</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hourly Alerts Window */}
|
||||
{reminderSettings.enabled && localFrequency === 'hourly' && (
|
||||
<div className="space-y-4 animate-in fade-in slide-in-from-top-2 duration-300">
|
||||
<div className="space-y-2">
|
||||
<div className="text-[10px] font-bold uppercase tracking-widest opacity-40 px-1">Start Time</div>
|
||||
<HourlyTimePicker
|
||||
value={reminderSettings.hourlyStart || '09:00'}
|
||||
onChange={async (newTime) => {
|
||||
const [h, m] = newTime.split(':');
|
||||
const end = (reminderSettings.hourlyEnd || '21:00').split(':');
|
||||
const newSettings = { ...reminderSettings, hourlyStart: newTime, hourlyEnd: `${end[0]}:${m}` };
|
||||
setReminderSettings(newSettings);
|
||||
await saveReminderSettings(newSettings);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-[10px] font-bold uppercase tracking-widest opacity-40 px-1">End Time</div>
|
||||
<HourlyTimePicker
|
||||
value={reminderSettings.hourlyEnd || '21:00'}
|
||||
onChange={async (newTime) => {
|
||||
const [h, m] = newTime.split(':');
|
||||
const start = (reminderSettings.hourlyStart || '09:00').split(':');
|
||||
const newSettings = { ...reminderSettings, hourlyEnd: newTime, hourlyStart: `${start[0]}:${m}` };
|
||||
setReminderSettings(newSettings);
|
||||
await saveReminderSettings(newSettings);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 p-3 bg-indigo-500/5 rounded-2xl border border-indigo-500/10">
|
||||
<Sparkles className="h-4 w-4 text-indigo-400" />
|
||||
<p className="text-[10px] text-indigo-400 uppercase font-black tracking-widest">Reminders every 60 minutes</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notification Permission Sync */}
|
||||
{reminderSettings.enabled && isSupported && (
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
onClick={async () => {
|
||||
const result = await requestPermission();
|
||||
if (result === 'granted') {
|
||||
try {
|
||||
const res = await fetch('/api/notifications/test', { method: 'POST' });
|
||||
if (res.ok) alert("Success! Push notifications active.");
|
||||
} catch (err) { console.error(err); }
|
||||
} else alert("Please enable notifications in settings.");
|
||||
}}
|
||||
{/* Frequency Selection */}
|
||||
{reminderSettings.enabled && (
|
||||
<div className="space-y-3">
|
||||
<div className="text-[10px] font-bold uppercase tracking-widest opacity-40 px-1">Reminder Frequency</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
onClick={() => handleFrequencyChange('daily')}
|
||||
className={cn(
|
||||
"w-full h-12 rounded-2xl font-bold transition-all shadow-md group",
|
||||
permission === 'granted'
|
||||
? 'bg-emerald-500/10 text-emerald-500 hover:bg-emerald-500/20'
|
||||
: 'bg-emerald-600 text-white hover:bg-emerald-500 hover:scale-[1.02]'
|
||||
"p-4 rounded-2xl border text-sm font-bold transition-all flex flex-col items-center gap-1",
|
||||
localFrequency === 'daily'
|
||||
? 'bg-indigo-500 border-indigo-500 text-white shadow-lg shadow-indigo-500/25 scale-[1.02]'
|
||||
: 'bg-background border-border hover:border-indigo-500/50'
|
||||
)}
|
||||
>
|
||||
<Bell className="mr-2 h-4 w-4 group-hover:animate-bounce" />
|
||||
{permission === 'granted' ? 'Permissions Verified' : 'Enable Push Alerts'}
|
||||
</Button>
|
||||
<span>Daily</span>
|
||||
<span className="text-[10px] font-normal opacity-70">Once a day</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleFrequencyChange('hourly')}
|
||||
className={cn(
|
||||
"p-4 rounded-2xl border text-sm font-bold transition-all flex flex-col items-center gap-1",
|
||||
localFrequency === 'hourly'
|
||||
? 'bg-indigo-500 border-indigo-500 text-white shadow-lg shadow-indigo-500/25 scale-[1.02]'
|
||||
: 'bg-background border-border hover:border-indigo-500/50'
|
||||
)}
|
||||
>
|
||||
<span>Hourly</span>
|
||||
<span className="text-[10px] font-normal opacity-70">Window alerts</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{permission === 'denied' && (
|
||||
<div className="p-4 bg-red-500/10 border border-red-500/20 rounded-2xl">
|
||||
<p className="text-xs text-red-500 text-center font-medium leading-relaxed">
|
||||
Browser notifications are currently blocked. To get reminders, please update your site settings.
|
||||
</p>
|
||||
{/* Time Picker (Only for Daily) */}
|
||||
{reminderSettings.enabled && localFrequency === 'daily' && (
|
||||
<div className="space-y-3 animate-in fade-in slide-in-from-top-2 duration-300">
|
||||
<div className="text-[10px] font-bold uppercase tracking-widest opacity-40 px-1">Preferred Time</div>
|
||||
<div className="flex gap-2 p-1 bg-muted/30 rounded-2xl border border-border/50">
|
||||
<div className="flex-1">
|
||||
<Select
|
||||
value={hourString}
|
||||
onValueChange={(val) => updateTime(val, minuteString, currentAmpm)}
|
||||
>
|
||||
<SelectTrigger className="border-none bg-transparent shadow-none hover:bg-white/5 h-12 rounded-xl">
|
||||
<SelectValue placeholder="Hour" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl">
|
||||
{hoursOptions.map((h) => (
|
||||
<SelectItem key={h} value={h} className="rounded-lg">{h}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<Select
|
||||
value={minuteString}
|
||||
onValueChange={(val) => updateTime(hourString, val, currentAmpm)}
|
||||
>
|
||||
<SelectTrigger className="border-none bg-transparent shadow-none hover:bg-white/5 h-12 rounded-xl">
|
||||
<SelectValue placeholder="Min" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl">
|
||||
{minutesOptions.map((m) => (
|
||||
<SelectItem key={m} value={m} className="rounded-lg">{m}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="w-24 px-1">
|
||||
<Select
|
||||
value={currentAmpm}
|
||||
onValueChange={(val) => updateTime(hourString, minuteString, val)}
|
||||
>
|
||||
<SelectTrigger className="border-none bg-indigo-500/10 text-indigo-400 font-bold shadow-none hover:bg-indigo-500/20 h-10 mt-1 rounded-lg">
|
||||
<SelectValue placeholder="AM/PM" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl">
|
||||
<SelectItem value="AM" className="rounded-lg">AM</SelectItem>
|
||||
<SelectItem value="PM" className="rounded-lg">PM</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</header>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hourly Alerts Window */}
|
||||
{reminderSettings.enabled && localFrequency === 'hourly' && (
|
||||
<div className="space-y-4 animate-in fade-in slide-in-from-top-2 duration-300">
|
||||
<div className="space-y-2">
|
||||
<div className="text-[10px] font-bold uppercase tracking-widest opacity-40 px-1">Start Time</div>
|
||||
<HourlyTimePicker
|
||||
value={reminderSettings.hourlyStart || '09:00'}
|
||||
onChange={async (newTime) => {
|
||||
const newSettings = { ...reminderSettings, hourlyStart: newTime };
|
||||
setReminderSettings(newSettings);
|
||||
await saveReminderSettings(newSettings);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-[10px] font-bold uppercase tracking-widest opacity-40 px-1">End Time</div>
|
||||
<HourlyTimePicker
|
||||
value={reminderSettings.hourlyEnd || '21:00'}
|
||||
onChange={async (newTime) => {
|
||||
const newSettings = { ...reminderSettings, hourlyEnd: newTime };
|
||||
setReminderSettings(newSettings);
|
||||
await saveReminderSettings(newSettings);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 p-3 bg-indigo-500/5 rounded-2xl border border-indigo-500/10">
|
||||
<Sparkles className="h-4 w-4 text-indigo-400" />
|
||||
<p className="text-[10px] text-indigo-400 uppercase font-black tracking-widest">Reminders every 60 minutes</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notification Permission Sync */}
|
||||
{reminderSettings.enabled && isSupported && (
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
onClick={async () => {
|
||||
const result = await requestPermission();
|
||||
if (result === 'granted') {
|
||||
try {
|
||||
const res = await fetch('/api/notifications/test', { method: 'POST' });
|
||||
if (res.ok) alert("Success! Push notifications active.");
|
||||
} catch (err) { console.error(err); }
|
||||
} else alert("Please enable notifications in settings.");
|
||||
}}
|
||||
className={cn(
|
||||
"w-full h-12 rounded-2xl font-bold transition-all shadow-md group",
|
||||
permission === 'granted'
|
||||
? 'bg-emerald-500/10 text-emerald-500 hover:bg-emerald-500/20'
|
||||
: 'bg-emerald-600 text-white hover:bg-emerald-500 hover:scale-[1.02]'
|
||||
)}
|
||||
>
|
||||
<Bell className="mr-2 h-4 w-4 group-hover:animate-bounce" />
|
||||
{permission === 'granted' ? 'Permissions Verified' : 'Enable Push Alerts'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{permission === 'denied' && (
|
||||
<div className="p-4 bg-red-500/10 border border-red-500/20 rounded-2xl">
|
||||
<p className="text-xs text-red-500 text-center font-medium leading-relaxed">
|
||||
Browser notifications are currently blocked. To get reminders, please update your site settings.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
162
src/components/VersionUpdateModal.tsx
Normal file
@ -0,0 +1,162 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Sparkles, Shield, Bell, Smartphone, Trophy, Rocket, Scale, Heart } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTheme } from '@/lib/theme-context';
|
||||
import type { UserPreferences } from '@/lib/storage';
|
||||
|
||||
const RELEASE_VERSION = '1.1';
|
||||
|
||||
interface VersionUpdateModalProps {
|
||||
preferences: UserPreferences | null;
|
||||
onAcknowledge: (version: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function VersionUpdateModal({ preferences, onAcknowledge }: VersionUpdateModalProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const { theme } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
if (!preferences) return;
|
||||
const hasSeenUpdate = preferences.lastSeenReleaseNotesVersion === RELEASE_VERSION;
|
||||
setIsOpen(!hasSeenUpdate);
|
||||
}, [preferences]);
|
||||
|
||||
const handleClose = async () => {
|
||||
if (isSaving || !preferences) return;
|
||||
setIsSaving(true);
|
||||
await onAcknowledge(RELEASE_VERSION);
|
||||
setIsOpen(false);
|
||||
setIsSaving(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => { if (!open) void handleClose(); }}>
|
||||
<DialogContent className={cn(
|
||||
"sm:max-w-xl max-h-[85vh] overflow-y-auto border-0 shadow-2xl p-0 gap-0 rounded-3xl",
|
||||
theme === 'light' ? 'bg-white' : 'bg-[#1a1b26]'
|
||||
)}>
|
||||
|
||||
{/* Header Section with Gradient Background */}
|
||||
<div className="relative overflow-hidden bg-gradient-to-br from-indigo-600 to-purple-700 p-8 text-white">
|
||||
<div className="absolute top-0 right-0 -mr-16 -mt-16 w-64 h-64 bg-white/10 rounded-full blur-3xl" />
|
||||
<div className="relative z-10 flex flex-col items-center text-center space-y-4">
|
||||
<div className="p-3 bg-white/20 backdrop-blur-md rounded-2xl shadow-inner">
|
||||
<Rocket className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<DialogTitle className="text-3xl font-bold tracking-tight">Version 1.1 is Live!</DialogTitle>
|
||||
<DialogDescription className="text-white/80 text-base font-medium">
|
||||
Desktop is restored, mobile is cleaner, and swipe flow is smoother.
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Section */}
|
||||
<div className="p-6 space-y-6">
|
||||
<h3 className="text-sm font-bold uppercase tracking-widest opacity-50 px-2">What's New</h3>
|
||||
|
||||
<div className="grid gap-4">
|
||||
|
||||
{/* Security */}
|
||||
<div className={cn("flex gap-4 p-4 rounded-2xl border", theme === 'light' ? 'bg-slate-50 border-slate-100' : 'bg-white/5 border-white/5')}>
|
||||
<div className="p-2.5 bg-emerald-500/10 rounded-xl h-fit">
|
||||
<Shield className="w-5 h-5 text-emerald-500" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h4 className="font-bold text-sm">Desktop Layout Restored</h4>
|
||||
<p className="text-xs opacity-70 leading-relaxed">
|
||||
Restored the desktop dashboard structure, including better section balance and the quote/calendar pairing.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PWA Icon & Look */}
|
||||
<div className={cn("flex gap-4 p-4 rounded-2xl border", theme === 'light' ? 'bg-indigo-50 border-indigo-100' : 'bg-indigo-500/10 border-indigo-500/10')}>
|
||||
<div className="p-2.5 bg-indigo-500/10 rounded-xl h-fit">
|
||||
<Sparkles className="w-5 h-5 text-indigo-500" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-bold text-sm">Mobile Navigation Polish</h4>
|
||||
<p className="text-xs opacity-70 leading-relaxed">
|
||||
Swipe cards now move more naturally, and the mobile section indicator stays visible at the bottom.
|
||||
</p>
|
||||
<div className={cn("text-[10px] p-2 rounded-lg border", theme === 'light' ? 'bg-yellow-50 border-yellow-200 text-yellow-800' : 'bg-yellow-500/10 border-yellow-500/20 text-yellow-200')}>
|
||||
<strong>Note:</strong> The pager/footer now stays on-screen so you can always jump between mobile sections.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notifications & Input */}
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
<div className={cn("p-4 rounded-2xl border space-y-2", theme === 'light' ? 'bg-slate-50 border-slate-100' : 'bg-white/5 border-white/5')}>
|
||||
<Bell className="w-5 h-5 text-amber-500 mb-1" />
|
||||
<h4 className="font-bold text-sm">One-Time Account Release Notes</h4>
|
||||
<p className="text-[10px] opacity-70 leading-relaxed">
|
||||
This message now saves per account and will not appear again after you close it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={cn("p-4 rounded-2xl border space-y-2", theme === 'light' ? 'bg-slate-50 border-slate-100' : 'bg-white/5 border-white/5')}>
|
||||
<Smartphone className="w-5 h-5 text-blue-500 mb-1" />
|
||||
<h4 className="font-bold text-sm">Swipe Feel Improvements</h4>
|
||||
<p className="text-[10px] opacity-70 leading-relaxed">
|
||||
Reduced sticky snapping so swiping between sections feels lighter and more direct.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tracking Improvements */}
|
||||
<div className={cn("flex gap-4 p-4 rounded-2xl border", theme === 'light' ? 'bg-slate-50 border-slate-100' : 'bg-white/5 border-white/5')}>
|
||||
<div className="p-2.5 bg-indigo-500/10 rounded-xl h-fit">
|
||||
<Scale className="w-5 h-5 text-indigo-500" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h4 className="font-bold text-sm">Tracking + Visibility Fixes</h4>
|
||||
<p className="text-xs opacity-70 leading-relaxed">
|
||||
Section navigation, card flow, and bottom spacing were tuned so key controls stay reachable on mobile.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Goals & Moods */}
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
<div className={cn("p-4 rounded-2xl border space-y-2", theme === 'light' ? 'bg-slate-50 border-slate-100' : 'bg-white/5 border-white/5')}>
|
||||
<Trophy className="w-5 h-5 text-yellow-500 mb-1" />
|
||||
<h4 className="font-bold text-sm">Achievements Reliability</h4>
|
||||
<p className="text-[10px] opacity-70 leading-relaxed">
|
||||
Improved unlock behavior to prevent duplicate first-step unlock confusion.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={cn("p-4 rounded-2xl border space-y-2", theme === 'light' ? 'bg-slate-50 border-slate-100' : 'bg-white/5 border-white/5')}>
|
||||
<Heart className="w-5 h-5 text-rose-500 mb-1" />
|
||||
<h4 className="font-bold text-sm">Overall Experience Cleanup</h4>
|
||||
<p className="text-[10px] opacity-70 leading-relaxed">
|
||||
Better spacing, cleaner transitions, and more predictable behavior across desktop and mobile.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className={cn("p-6 pt-2", theme === 'light' ? 'bg-slate-50/50' : 'bg-black/20')}>
|
||||
<Button
|
||||
className="w-full h-12 rounded-xl text-base font-bold bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-700 hover:to-purple-700 text-white shadow-lg active:scale-95 transition-all"
|
||||
onClick={() => void handleClose()}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? 'Saving...' : "Let's Go!"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef, type ReactNode } from 'react';
|
||||
import { useState, useEffect, useRef, type KeyboardEvent, type ReactNode } from 'react';
|
||||
import { Cigarette, Leaf, Heart, Trophy, DollarSign, TrendingDown, CheckCircle, type LucideIcon } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@ -169,6 +169,8 @@ const DEMO_SCREENS: DemoScreen[] = [
|
||||
|
||||
// Phone mockup component
|
||||
function PhoneMockup({ activeScreen }: { activeScreen: number }) {
|
||||
const active = DEMO_SCREENS[activeScreen];
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Phone frame glow */}
|
||||
@ -183,25 +185,19 @@ function PhoneMockup({ activeScreen }: { activeScreen: number }) {
|
||||
<div className="absolute inset-0 p-4 pt-10">
|
||||
{/* Screen header */}
|
||||
<div className="text-center mb-4">
|
||||
<h3 className="text-lg font-bold">{DEMO_SCREENS[activeScreen].title}</h3>
|
||||
<h3 className="text-lg font-bold">{active.title}</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{DEMO_SCREENS[activeScreen].subtitle}
|
||||
{active.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Crossfade content */}
|
||||
<div className="relative h-[calc(100%-5rem)]">
|
||||
{DEMO_SCREENS.map((screen, index) => (
|
||||
<div
|
||||
key={screen.id}
|
||||
className={cn(
|
||||
"absolute inset-0 transition-opacity duration-500 motion-reduce:transition-none",
|
||||
activeScreen === index ? "opacity-100 z-10" : "opacity-0 z-0"
|
||||
)}
|
||||
>
|
||||
{screen.content}
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
id="demo-active-panel"
|
||||
role="tabpanel"
|
||||
aria-labelledby={`demo-tab-${active.id}`}
|
||||
className="h-[calc(100%-5rem)] transition-opacity duration-300 motion-reduce:transition-none"
|
||||
>
|
||||
{active.content}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -222,7 +218,7 @@ function FeatureCard({ screen, isActive }: { screen: DemoScreen; isActive: boole
|
||||
"p-6 sm:p-8 rounded-2xl transition-all duration-500 motion-reduce:transition-none",
|
||||
isActive
|
||||
? "bg-primary/10 border border-primary/30 scale-100 opacity-100"
|
||||
: "bg-white/5 border border-white/10 scale-[0.98] opacity-60"
|
||||
: "bg-card/60 border border-border scale-[0.98] opacity-70"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
@ -247,19 +243,48 @@ export function DemoSection() {
|
||||
const [activeScreen, setActiveScreen] = useState(0);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const sectionRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const resumeTimerRef = useRef<number | null>(null);
|
||||
|
||||
// Timer-based rotation for mobile
|
||||
const handleManualSelect = (index: number) => {
|
||||
setActiveScreen(index);
|
||||
setIsPaused(true);
|
||||
|
||||
if (resumeTimerRef.current) {
|
||||
window.clearTimeout(resumeTimerRef.current);
|
||||
}
|
||||
|
||||
resumeTimerRef.current = window.setTimeout(() => {
|
||||
setIsPaused(false);
|
||||
}, 8000);
|
||||
};
|
||||
|
||||
// Timer-based rotation for mobile only
|
||||
useEffect(() => {
|
||||
if (isPaused) return;
|
||||
|
||||
// Only run timer on mobile (will be hidden on lg+)
|
||||
const mobileMedia = window.matchMedia('(max-width: 1023px)');
|
||||
const reducedMotionMedia = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
|
||||
if (!mobileMedia.matches || reducedMotionMedia.matches) {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (document.hidden) return;
|
||||
setActiveScreen((prev) => (prev + 1) % DEMO_SCREENS.length);
|
||||
}, 4000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isPaused]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (resumeTimerRef.current) {
|
||||
window.clearTimeout(resumeTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Intersection Observer for desktop scroll spy
|
||||
useEffect(() => {
|
||||
const observers: IntersectionObserver[] = [];
|
||||
@ -271,6 +296,7 @@ export function DemoSection() {
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
if (!window.matchMedia('(min-width: 1024px)').matches) return;
|
||||
setActiveScreen(index);
|
||||
}
|
||||
});
|
||||
@ -317,6 +343,7 @@ export function DemoSection() {
|
||||
<p className="text-muted-foreground max-w-2xl mx-auto">
|
||||
A quick look at how QuitTraq helps you track progress and stay motivated.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/80 mt-3">Preview uses sample data for demonstration.</p>
|
||||
</div>
|
||||
|
||||
{/* Desktop: Split Sticky Scroll Layout */}
|
||||
@ -333,9 +360,9 @@ export function DemoSection() {
|
||||
key={screen.id}
|
||||
ref={(el) => { sectionRefs.current[index] = el; }}
|
||||
className={cn(
|
||||
"min-h-[70vh] flex items-center py-8",
|
||||
"min-h-[52vh] flex items-center py-6",
|
||||
index === 0 && "pt-0",
|
||||
index === DEMO_SCREENS.length - 1 && "min-h-[50vh]"
|
||||
index === DEMO_SCREENS.length - 1 && "min-h-[40vh]"
|
||||
)}
|
||||
>
|
||||
<FeatureCard screen={screen} isActive={activeScreen === index} />
|
||||
@ -362,15 +389,29 @@ export function DemoSection() {
|
||||
<div className="w-full max-w-md text-center">
|
||||
<div className="space-y-4">
|
||||
{/* Screen descriptions as buttons */}
|
||||
{DEMO_SCREENS.map((screen, index) => (
|
||||
<div role="tablist" aria-label="QuitTraq demo screens" className="space-y-4">
|
||||
{DEMO_SCREENS.map((screen, index) => (
|
||||
<button
|
||||
key={screen.id}
|
||||
onClick={() => setActiveScreen(index)}
|
||||
id={`demo-tab-${screen.id}`}
|
||||
role="tab"
|
||||
type="button"
|
||||
aria-selected={activeScreen === index}
|
||||
aria-controls="demo-active-panel"
|
||||
tabIndex={activeScreen === index ? 0 : -1}
|
||||
onClick={() => handleManualSelect(index)}
|
||||
onKeyDown={(event: KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
|
||||
event.preventDefault();
|
||||
const direction = event.key === 'ArrowDown' ? 1 : -1;
|
||||
const nextIndex = (index + direction + DEMO_SCREENS.length) % DEMO_SCREENS.length;
|
||||
handleManualSelect(nextIndex);
|
||||
}}
|
||||
className={cn(
|
||||
"w-full text-left p-4 rounded-xl transition-all duration-300",
|
||||
activeScreen === index
|
||||
? "bg-primary/10 border border-primary/30"
|
||||
: "hover:bg-white/5 border border-transparent"
|
||||
: "hover:bg-card/60 border border-transparent"
|
||||
)}
|
||||
>
|
||||
<h4
|
||||
@ -383,7 +424,8 @@ export function DemoSection() {
|
||||
</h4>
|
||||
<p className="text-sm text-muted-foreground">{screen.subtitle}</p>
|
||||
</button>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dot indicators */}
|
||||
@ -391,12 +433,13 @@ export function DemoSection() {
|
||||
{DEMO_SCREENS.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setActiveScreen(index)}
|
||||
type="button"
|
||||
onClick={() => handleManualSelect(index)}
|
||||
className={cn(
|
||||
"w-2 h-2 rounded-full transition-all",
|
||||
activeScreen === index
|
||||
? "w-6 bg-primary"
|
||||
: "bg-white/20 hover:bg-white/40"
|
||||
: "bg-muted-foreground/30 hover:bg-muted-foreground/60"
|
||||
)}
|
||||
aria-label={`Go to screen ${index + 1}`}
|
||||
/>
|
||||
|
||||
@ -52,9 +52,6 @@ export function FeatureCard({ title, description, icons, gradient, delay = 0 }:
|
||||
background: `linear-gradient(135deg, ${gradient})`,
|
||||
}}
|
||||
>
|
||||
{/* Decorative gradient orb */}
|
||||
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-br from-white/10 to-transparent rounded-full -translate-y-1/2 translate-x-1/2 pointer-events-none group-hover:scale-110 transition-transform duration-500" />
|
||||
|
||||
<CardHeader className="relative z-10">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
{icons.map((icon, index) => (
|
||||
|
||||
@ -5,30 +5,30 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all duration-200 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive active:scale-[0.97]",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm hover:shadow-md",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 shadow-sm",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-sm",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
default: "h-12 px-5 py-2 has-[>svg]:px-4 text-base",
|
||||
xs: "h-8 gap-1 rounded-md px-3 text-xs has-[>svg]:px-2 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
sm: "h-10 rounded-md gap-1.5 px-4 has-[>svg]:px-3",
|
||||
lg: "h-14 rounded-xl px-8 text-lg has-[>svg]:px-6",
|
||||
icon: "size-12 rounded-xl",
|
||||
"icon-xs": "size-8 rounded-lg [&_svg:not([class*='size-'])]:size-4",
|
||||
"icon-sm": "size-10 rounded-lg",
|
||||
"icon-lg": "size-14 rounded-2xl",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@ -38,6 +38,7 @@ export interface UserPreferencesRow {
|
||||
religion: string | null;
|
||||
lastNicotineUsageTime: string | null;
|
||||
lastWeedUsageTime: string | null;
|
||||
lastSeenReleaseNotesVersion: string | null;
|
||||
quitPlanJson: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@ -74,22 +75,38 @@ export async function upsertPreferencesD1(userId: string, data: Partial<UserPref
|
||||
if (data.userName !== undefined) { updates.push('userName = ?'); values.push(data.userName); }
|
||||
if (data.userAge !== undefined) { updates.push('userAge = ?'); values.push(data.userAge); }
|
||||
if (data.religion !== undefined) { updates.push('religion = ?'); values.push(data.religion); }
|
||||
if (data.lastNicotineUsageTime !== undefined) { updates.push('lastNicotineUsageTime = ?'); values.push(data.lastNicotineUsageTime); }
|
||||
if (data.lastWeedUsageTime !== undefined) { updates.push('lastWeedUsageTime = ?'); values.push(data.lastWeedUsageTime); }
|
||||
|
||||
// Explicit checks for usage times
|
||||
if (data.lastNicotineUsageTime !== undefined) {
|
||||
updates.push('lastNicotineUsageTime = ?');
|
||||
values.push(data.lastNicotineUsageTime);
|
||||
}
|
||||
if (data.lastWeedUsageTime !== undefined) {
|
||||
updates.push('lastWeedUsageTime = ?');
|
||||
values.push(data.lastWeedUsageTime);
|
||||
}
|
||||
if (data.lastSeenReleaseNotesVersion !== undefined) {
|
||||
updates.push('lastSeenReleaseNotesVersion = ?');
|
||||
values.push(data.lastSeenReleaseNotesVersion);
|
||||
}
|
||||
|
||||
if (data.quitPlanJson !== undefined) { updates.push('quitPlanJson = ?'); values.push(data.quitPlanJson); }
|
||||
|
||||
updates.push('updatedAt = ?');
|
||||
values.push(now);
|
||||
values.push(userId);
|
||||
|
||||
await db.prepare(
|
||||
`UPDATE UserPreferences SET ${updates.join(', ')} WHERE userId = ?`
|
||||
).bind(...values).run();
|
||||
if (updates.length > 1) { // At least updatedAt is always there
|
||||
await db.prepare(
|
||||
`UPDATE UserPreferences SET ${updates.join(', ')} WHERE userId = ?`
|
||||
).bind(...values).run();
|
||||
}
|
||||
|
||||
} else {
|
||||
// Insert
|
||||
await db.prepare(
|
||||
`INSERT INTO UserPreferences (id, userId, substance, trackingStartDate, hasCompletedSetup, dailyGoal, userName, userAge, religion, lastNicotineUsageTime, lastWeedUsageTime, quitPlanJson, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
`INSERT INTO UserPreferences (id, userId, substance, trackingStartDate, hasCompletedSetup, dailyGoal, userName, userAge, religion, lastNicotineUsageTime, lastWeedUsageTime, lastSeenReleaseNotesVersion, quitPlanJson, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).bind(
|
||||
id,
|
||||
userId,
|
||||
@ -102,6 +119,7 @@ export async function upsertPreferencesD1(userId: string, data: Partial<UserPref
|
||||
data.religion || null,
|
||||
data.lastNicotineUsageTime || null,
|
||||
data.lastWeedUsageTime || null,
|
||||
data.lastSeenReleaseNotesVersion || null,
|
||||
data.quitPlanJson || null,
|
||||
now,
|
||||
now
|
||||
@ -209,6 +227,17 @@ export async function getAchievementD1(userId: string, badgeId: string, substanc
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getAchievementByBadgeD1(userId: string, badgeId: string): Promise<AchievementRow | null> {
|
||||
const db = getD1();
|
||||
if (!db) return null;
|
||||
|
||||
const result = await db.prepare(
|
||||
'SELECT * FROM Achievement WHERE userId = ? AND badgeId = ? ORDER BY CASE WHEN substance = ? THEN 0 ELSE 1 END, unlockedAt DESC LIMIT 1'
|
||||
).bind(userId, badgeId, 'both').first<AchievementRow>();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function createAchievementD1(userId: string, badgeId: string, substance: string): Promise<AchievementRow | null> {
|
||||
const db = getD1();
|
||||
if (!db) return null;
|
||||
@ -220,7 +249,7 @@ export async function createAchievementD1(userId: string, badgeId: string, subst
|
||||
const now = new Date().toISOString();
|
||||
|
||||
await db.prepare(
|
||||
`INSERT INTO Achievement (id, userId, badgeId, unlockedAt, substance)
|
||||
`INSERT OR IGNORE INTO Achievement (id, userId, badgeId, unlockedAt, substance)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
).bind(id, userId, badgeId, now, substance).run();
|
||||
|
||||
@ -427,6 +456,15 @@ export async function upsertPushSubscriptionD1(
|
||||
return getPushSubscriptionD1(userId);
|
||||
}
|
||||
|
||||
export async function deletePushSubscriptionD1(userId: string): Promise<void> {
|
||||
const db = getD1();
|
||||
if (!db) return;
|
||||
|
||||
await db.prepare(
|
||||
'DELETE FROM PushSubscriptions WHERE userId = ?'
|
||||
).bind(userId).run();
|
||||
}
|
||||
|
||||
// ============ MOOD TRACKER ============
|
||||
|
||||
export interface MoodEntryRow {
|
||||
|
||||
@ -16,6 +16,43 @@ export interface Session {
|
||||
}
|
||||
|
||||
const SESSION_COOKIE_NAME = 'quit_smoking_session';
|
||||
const SESSION_SECRET = process.env.SESSION_SECRET || 'fallback-secret-for-dev-only';
|
||||
|
||||
// Helper to sign the session
|
||||
async function sign(data: string, secret: string): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const keyData = encoder.encode(secret);
|
||||
const dataData = encoder.encode(data);
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyData,
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
|
||||
const signature = await crypto.subtle.sign('HMAC', key, dataData);
|
||||
return btoa(String.fromCharCode(...new Uint8Array(signature)));
|
||||
}
|
||||
|
||||
// Helper to verify the session
|
||||
async function verify(data: string, signature: string, secret: string): Promise<boolean> {
|
||||
const encoder = new TextEncoder();
|
||||
const keyData = encoder.encode(secret);
|
||||
const dataData = encoder.encode(data);
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyData,
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['verify']
|
||||
);
|
||||
|
||||
const sigBytes = Uint8Array.from(atob(signature), (c) => c.charCodeAt(0));
|
||||
return await crypto.subtle.verify('HMAC', key, sigBytes, dataData);
|
||||
}
|
||||
|
||||
export async function getSession(): Promise<Session | null> {
|
||||
const cookieStore = await cookies();
|
||||
@ -26,8 +63,20 @@ export async function getSession(): Promise<Session | null> {
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(sessionCookie.value);
|
||||
} catch {
|
||||
const [payloadBase64, signature] = sessionCookie.value.split('.');
|
||||
if (!payloadBase64 || !signature) return null;
|
||||
|
||||
const payload = atob(payloadBase64);
|
||||
const isValid = await verify(payload, signature, SESSION_SECRET);
|
||||
|
||||
if (!isValid) {
|
||||
console.warn('Invalid session signature detected');
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.parse(payload);
|
||||
} catch (error) {
|
||||
console.error('Error parsing session:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -36,7 +85,11 @@ export async function setSession(session: Session): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
const maxAge = session.stayLoggedIn ? 60 * 60 * 24 * 30 : 60 * 60 * 24; // 30 days if stay logged in, else 1 day
|
||||
|
||||
cookieStore.set(SESSION_COOKIE_NAME, JSON.stringify(session), {
|
||||
const payload = JSON.stringify(session);
|
||||
const payloadBase64 = btoa(payload);
|
||||
const signature = await sign(payload, SESSION_SECRET);
|
||||
|
||||
cookieStore.set(SESSION_COOKIE_NAME, `${payloadBase64}.${signature}`, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
|
||||
@ -27,6 +27,7 @@ export interface UserPreferences {
|
||||
religion: 'christian' | 'secular' | null;
|
||||
lastNicotineUsageTime?: string | null;
|
||||
lastWeedUsageTime?: string | null;
|
||||
lastSeenReleaseNotesVersion?: string | null;
|
||||
}
|
||||
|
||||
export interface QuitPlan {
|
||||
@ -126,8 +127,25 @@ const defaultPreferences: UserPreferences = {
|
||||
userName: null,
|
||||
userAge: null,
|
||||
religion: null,
|
||||
lastSeenReleaseNotesVersion: null,
|
||||
};
|
||||
|
||||
export const defaultReminderSettings: ReminderSettings = {
|
||||
enabled: false,
|
||||
reminderTime: '09:00',
|
||||
frequency: 'daily',
|
||||
};
|
||||
|
||||
export class StorageRequestError extends Error {
|
||||
status?: number;
|
||||
|
||||
constructor(message: string, status?: number) {
|
||||
super(message);
|
||||
this.name = 'StorageRequestError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
// Cache for preferences and usage data to avoid excessive API calls
|
||||
let preferencesCache: UserPreferences | null = null;
|
||||
let usageDataCache: UsageEntry[] | null = null;
|
||||
@ -160,15 +178,17 @@ export async function fetchPreferences(): Promise<UserPreferences> {
|
||||
try {
|
||||
const response = await fetch('/api/preferences', { cache: 'no-store' });
|
||||
if (!response.ok) {
|
||||
console.error('Failed to fetch preferences');
|
||||
return defaultPreferences;
|
||||
throw new StorageRequestError('Unable to load profile preferences.', response.status);
|
||||
}
|
||||
const data = await response.json() as UserPreferences;
|
||||
preferencesCache = data;
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching preferences:', error);
|
||||
return defaultPreferences;
|
||||
if (error instanceof StorageRequestError) {
|
||||
throw error;
|
||||
}
|
||||
throw new StorageRequestError('Unable to load profile preferences.');
|
||||
}
|
||||
}
|
||||
|
||||
@ -181,6 +201,9 @@ export async function savePreferencesAsync(preferences: UserPreferences): Promis
|
||||
});
|
||||
if (response.ok) {
|
||||
preferencesCache = preferences;
|
||||
} else {
|
||||
const err = await response.json();
|
||||
console.error('[Storage] Error saving preferences:', response.status, err);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving preferences:', error);
|
||||
@ -192,15 +215,17 @@ export async function fetchUsageData(): Promise<UsageEntry[]> {
|
||||
try {
|
||||
const response = await fetch('/api/usage', { cache: 'no-store' });
|
||||
if (!response.ok) {
|
||||
console.error('Failed to fetch usage data');
|
||||
return [];
|
||||
throw new StorageRequestError('Unable to load usage history.', response.status);
|
||||
}
|
||||
const data = await response.json() as UsageEntry[];
|
||||
usageDataCache = data;
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching usage data:', error);
|
||||
return [];
|
||||
if (error instanceof StorageRequestError) {
|
||||
throw error;
|
||||
}
|
||||
throw new StorageRequestError('Unable to load usage history.');
|
||||
}
|
||||
}
|
||||
|
||||
@ -303,13 +328,18 @@ export async function fetchReminderSettings(): Promise<ReminderSettings> {
|
||||
if (reminderSettingsCache) return reminderSettingsCache;
|
||||
try {
|
||||
const response = await fetch('/api/reminders');
|
||||
if (!response.ok) return { enabled: false, reminderTime: '09:00', frequency: 'daily' };
|
||||
if (!response.ok) {
|
||||
throw new StorageRequestError('Unable to load reminder settings.', response.status);
|
||||
}
|
||||
const data = await response.json() as ReminderSettings;
|
||||
reminderSettingsCache = data;
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching reminder settings:', error);
|
||||
return { enabled: false, reminderTime: '09:00', frequency: 'daily' };
|
||||
if (error instanceof StorageRequestError) {
|
||||
throw error;
|
||||
}
|
||||
throw new StorageRequestError('Unable to load reminder settings.');
|
||||
}
|
||||
}
|
||||
|
||||
@ -329,7 +359,7 @@ export async function saveReminderSettings(settings: ReminderSettings): Promise<
|
||||
}
|
||||
|
||||
export function getReminderSettings(): ReminderSettings {
|
||||
return reminderSettingsCache || { enabled: false, reminderTime: '09:00', frequency: 'daily' };
|
||||
return reminderSettingsCache || defaultReminderSettings;
|
||||
}
|
||||
|
||||
// ============ SAVINGS FUNCTIONS ============
|
||||
@ -486,6 +516,10 @@ export function checkBadgeEligibility(
|
||||
preferences: UserPreferences,
|
||||
substance: 'nicotine' | 'weed'
|
||||
): boolean {
|
||||
const hasLoggedUsageForSubstance = usageData.some(
|
||||
(entry) => entry.substance === substance && entry.count > 0
|
||||
);
|
||||
|
||||
// Pre-calculate common stats once O(n)
|
||||
const stats = (() => {
|
||||
const nicotineMap = new Map<string, number>();
|
||||
@ -573,7 +607,7 @@ export function checkBadgeEligibility(
|
||||
};
|
||||
|
||||
switch (badgeId) {
|
||||
case 'first_day': return stats.totalDays >= 1;
|
||||
case 'first_day': return hasLoggedUsageForSubstance;
|
||||
case 'streak_3': return streak >= 3;
|
||||
case 'streak_7': return stats.totalDays >= 7;
|
||||
case 'fighter':
|
||||
|
||||
@ -4,10 +4,25 @@ export const workos = new WorkOS(process.env.WORKOS_API_KEY!);
|
||||
|
||||
export const clientId = process.env.WORKOS_CLIENT_ID!;
|
||||
|
||||
export function getAuthorizationUrl(provider: 'GoogleOAuth' | 'AppleOAuth') {
|
||||
export type OAuthProvider = 'GoogleOAuth' | 'AppleOAuth' | 'GitHubOAuth';
|
||||
|
||||
export function getAuthorizationUrl(provider: OAuthProvider) {
|
||||
return workos.userManagement.getAuthorizationUrl({
|
||||
provider,
|
||||
clientId,
|
||||
redirectUri: process.env.WORKOS_REDIRECT_URI!,
|
||||
});
|
||||
}
|
||||
|
||||
// Get AuthKit hosted login URL (for email/password and phone)
|
||||
// Note: We manually construct this URL with provider=authkit
|
||||
export function getAuthKitUrl() {
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
redirect_uri: process.env.WORKOS_REDIRECT_URI!,
|
||||
response_type: 'code',
|
||||
provider: 'authkit',
|
||||
});
|
||||
|
||||
return `https://api.workos.com/user_management/authorize?${params.toString()}`;
|
||||
}
|
||||
|
||||