changes
This commit is contained in:
parent
c31f8d8cfe
commit
e5b3f649be
5
.gitignore
vendored
5
.gitignore
vendored
@ -34,6 +34,7 @@ yarn-error.log*
|
|||||||
# env files (can opt-in for committing if needed)
|
# env files (can opt-in for committing if needed)
|
||||||
.env*
|
.env*
|
||||||
.dev.vars
|
.dev.vars
|
||||||
|
.dev.vars.*
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
@ -50,3 +51,7 @@ next-env.d.ts
|
|||||||
.open-next/
|
.open-next/
|
||||||
.open-next 2/
|
.open-next 2/
|
||||||
.wrangler/
|
.wrangler/
|
||||||
|
|
||||||
|
# local editor / temp
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
|||||||
53
DEV_SETUP.md
53
DEV_SETUP.md
@ -61,3 +61,56 @@ Expected URL:
|
|||||||
|
|
||||||
- Keep file reads tightly scoped to explicit paths when requested.
|
- Keep file reads tightly scoped to explicit paths when requested.
|
||||||
- Avoid broad folder crawling unless asked.
|
- 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
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
|
```bash
|
||||||
npm run dev
|
bun install
|
||||||
# or
|
bun run d1:migrate
|
||||||
yarn dev
|
bun run build:worker
|
||||||
# or
|
bun run dev:worker
|
||||||
pnpm dev
|
|
||||||
# or
|
|
||||||
bun dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
- `WORKOS_CLIENT_ID`
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
- `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,6 +1,18 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getSession } from '@/lib/session';
|
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() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
@ -10,14 +22,22 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const achievements = await getAchievementsD1(session.user.id);
|
const achievements = await getAchievementsD1(session.user.id);
|
||||||
|
const seenGlobalBadges = new Set<string>();
|
||||||
|
|
||||||
return NextResponse.json(
|
const normalized = achievements
|
||||||
achievements.map((a) => ({
|
.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,
|
badgeId: a.badgeId,
|
||||||
unlockedAt: a.unlockedAt,
|
unlockedAt: a.unlockedAt,
|
||||||
substance: a.substance,
|
substance: GLOBAL_BADGE_IDS.has(a.badgeId) ? 'both' : a.substance,
|
||||||
}))
|
}));
|
||||||
);
|
|
||||||
|
return NextResponse.json(normalized);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching achievements:', error);
|
console.error('Error fetching achievements:', error);
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
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 });
|
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
|
// 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) {
|
if (existing) {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
badgeId: existing.badgeId,
|
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) {
|
if (!achievement) {
|
||||||
return NextResponse.json({ error: 'Failed to unlock achievement' }, { status: 500 });
|
return NextResponse.json({ error: 'Failed to unlock achievement' }, { status: 500 });
|
||||||
|
|||||||
@ -1,25 +1,28 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getSession } from '@/lib/session';
|
import { getSession } from '@/lib/session';
|
||||||
|
import { workos } from '@/lib/workos';
|
||||||
|
|
||||||
// Send password reset email to current user
|
export async function POST(request: NextRequest) {
|
||||||
// NOTE: WorkOS AuthKit handles password reset through the hosted UI flow
|
|
||||||
// Users can reset their password by clicking "Forgot password" on the login page
|
|
||||||
export async function POST() {
|
|
||||||
try {
|
try {
|
||||||
|
const body = await request.json().catch(() => ({})) as { email?: string };
|
||||||
const session = await getSession();
|
const session = await getSession();
|
||||||
if (!session?.user) {
|
|
||||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
const email = (body.email || session?.user?.email || '').trim().toLowerCase();
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
return NextResponse.json({ error: 'Email is required.' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// For WorkOS AuthKit, password reset is handled through the hosted UI
|
try {
|
||||||
// The user should be directed to re-authenticate via the login page
|
await workos.userManagement.createPasswordReset({ email });
|
||||||
// where they can click "Forgot password"
|
} catch (error: any) {
|
||||||
|
// Prevent account enumeration: we intentionally return success either way.
|
||||||
|
console.warn('Password reset initiation warning:', error?.message || error);
|
||||||
|
}
|
||||||
|
|
||||||
// Return a message directing the user to the login page
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'To reset your password, please log out and use the "Forgot Password" option on the login page.',
|
message: 'If an account exists for this email, a password reset link has been sent.'
|
||||||
redirectTo: '/login'
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Password reset error:', error);
|
console.error('Password reset error:', error);
|
||||||
|
|||||||
@ -85,6 +85,20 @@ function getNotificationData(timeStr: string) {
|
|||||||
return { title, message };
|
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) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const isVapidReady = ensureVapidConfig();
|
const isVapidReady = ensureVapidConfig();
|
||||||
@ -125,16 +139,20 @@ export async function GET(request: NextRequest) {
|
|||||||
let tag = '';
|
let tag = '';
|
||||||
|
|
||||||
if (user.frequency === 'hourly') {
|
if (user.frequency === 'hourly') {
|
||||||
const [currentH, currentM] = userTimeString.split(':');
|
const [currentH, currentM] = userTimeString.split(':').map(Number);
|
||||||
const [startH, startM] = (user.hourlyStart || '09:00').split(':');
|
|
||||||
const currentHourKey = `${userDateString}-${currentH}`; // YYYY-MM-DD-HH
|
const currentHourKey = `${userDateString}-${currentH}`; // YYYY-MM-DD-HH
|
||||||
|
|
||||||
// Check active hours
|
// Check active hours
|
||||||
const startStr = user.hourlyStart || '09:00';
|
const startStr = user.hourlyStart || '09:00';
|
||||||
const endStr = user.hourlyEnd || '21: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
|
const cadenceMinute = Number((user.reminderTime || startStr).split(':')[1] || '0');
|
||||||
if (userTimeString >= startStr && userTimeString <= endStr && currentM === startM) {
|
|
||||||
|
// Send once per hour at configured cadence minute while within active window
|
||||||
|
if (isWithinWindow(currentMinutes, startMinutes, endMinutes) && currentM === cadenceMinute) {
|
||||||
if (user.lastNotifiedDate !== currentHourKey) {
|
if (user.lastNotifiedDate !== currentHourKey) {
|
||||||
shouldSend = true;
|
shouldSend = true;
|
||||||
const { title: t, message } = getNotificationData(userTimeString);
|
const { title: t, message } = getNotificationData(userTimeString);
|
||||||
|
|||||||
@ -50,6 +50,20 @@ export async function POST(request: NextRequest) {
|
|||||||
};
|
};
|
||||||
const { enabled, reminderTime, frequency, hourlyStart, hourlyEnd, timezone } = body;
|
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(
|
const settings = await upsertReminderSettingsD1(
|
||||||
session.user.id,
|
session.user.id,
|
||||||
enabled ?? false,
|
enabled ?? false,
|
||||||
|
|||||||
@ -4,105 +4,111 @@
|
|||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: oklch(0.9789 0.0082 121.6272);
|
--background: oklch(0.9838 0.0035 247.8583);
|
||||||
--foreground: oklch(0 0 0);
|
--foreground: oklch(0.1284 0.0267 261.5937);
|
||||||
--card: oklch(1.0000 0 0);
|
--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: oklch(1.0000 0 0);
|
||||||
--popover-foreground: oklch(0 0 0);
|
--popover-foreground: oklch(0.1284 0.0267 261.5937);
|
||||||
--primary: oklch(0.5106 0.2301 276.9656);
|
--primary: oklch(0.4865 0.2423 291.8661);
|
||||||
--primary-foreground: oklch(1.0000 0 0);
|
--primary-foreground: oklch(0.9838 0.0035 247.8583);
|
||||||
--secondary: oklch(0.7038 0.1230 182.5025);
|
--secondary: oklch(0.9486 0.0085 303.5068);
|
||||||
--secondary-foreground: oklch(1.0000 0 0);
|
--secondary-foreground: oklch(0.3410 0.1625 292.9477);
|
||||||
--muted: oklch(0.9551 0 0);
|
--muted: oklch(0.9679 0.0027 264.5424);
|
||||||
--muted-foreground: oklch(0.3211 0 0);
|
--muted-foreground: oklch(0.5503 0.0235 264.3620);
|
||||||
--accent: oklch(0.7686 0.1647 70.0804);
|
--accent: oklch(0.9546 0.0227 303.2883);
|
||||||
--accent-foreground: oklch(0 0 0);
|
--accent-foreground: oklch(0.4865 0.2423 291.8661);
|
||||||
--destructive: oklch(0.6368 0.2078 25.3313);
|
--destructive: oklch(0.6356 0.2082 25.3782);
|
||||||
--destructive-foreground: oklch(1.0000 0 0);
|
--destructive-foreground: oklch(0.9838 0.0035 247.8583);
|
||||||
--border: oklch(0 0 0);
|
--border: oklch(0.9278 0.0058 264.5314);
|
||||||
--input: oklch(0.5555 0 0);
|
--input: oklch(0.9278 0.0058 264.5314);
|
||||||
--ring: oklch(0.7853 0.1041 274.7134);
|
--ring: oklch(0.4865 0.2423 291.8661);
|
||||||
--chart-1: oklch(0.5106 0.2301 276.9656);
|
--chart-1: oklch(0.4865 0.2423 291.8661);
|
||||||
--chart-2: oklch(0.7038 0.1230 182.5025);
|
--chart-2: oklch(0.7216 0.1282 217.8676);
|
||||||
--chart-3: oklch(0.7686 0.1647 70.0804);
|
--chart-3: oklch(0.6356 0.1398 156.1492);
|
||||||
--chart-4: oklch(0.6559 0.2118 354.3084);
|
--chart-4: oklch(0.6192 0.2037 312.7283);
|
||||||
--chart-5: oklch(0.7227 0.1920 149.5793);
|
--chart-5: oklch(0.6532 0.2114 353.9392);
|
||||||
--sidebar: oklch(0.9789 0.0082 121.6272);
|
--sidebar: oklch(1.0000 0 0);
|
||||||
--sidebar-foreground: oklch(0 0 0);
|
--sidebar-foreground: oklch(0.1284 0.0267 261.5937);
|
||||||
--sidebar-primary: oklch(0.5106 0.2301 276.9656);
|
--sidebar-primary: oklch(0.4865 0.2423 291.8661);
|
||||||
--sidebar-primary-foreground: oklch(1.0000 0 0);
|
--sidebar-primary-foreground: oklch(0.9838 0.0035 247.8583);
|
||||||
--sidebar-accent: oklch(0.7686 0.1647 70.0804);
|
--sidebar-accent: oklch(0.9486 0.0085 303.5068);
|
||||||
--sidebar-accent-foreground: oklch(0 0 0);
|
--sidebar-accent-foreground: oklch(0.4865 0.2423 291.8661);
|
||||||
--sidebar-border: oklch(0 0 0);
|
--sidebar-border: oklch(0.9278 0.0058 264.5314);
|
||||||
--sidebar-ring: oklch(0.7853 0.1041 274.7134);
|
--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;
|
--radius: 1rem;
|
||||||
--shadow-x: 0px;
|
--shadow-x: 0px;
|
||||||
--shadow-y: 0px;
|
--shadow-y: 8px;
|
||||||
--shadow-blur: 0px;
|
--shadow-blur: 30px;
|
||||||
--shadow-spread: 0px;
|
--shadow-spread: 0px;
|
||||||
--shadow-opacity: 0.05;
|
--shadow-opacity: 0.08;
|
||||||
--shadow-color: #1a1a1a;
|
--shadow-color: hsl(263, 70%, 50%);
|
||||||
--shadow-2xs: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.03);
|
--shadow-2xs: 0px 8px 30px 0px hsl(263 70% 50% / 0.04);
|
||||||
--shadow-xs: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.03);
|
--shadow-xs: 0px 8px 30px 0px hsl(263 70% 50% / 0.04);
|
||||||
--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-sm: 0px 8px 30px 0px hsl(263 70% 50% / 0.08), 0px 1px 2px -1px hsl(263 70% 50% / 0.08);
|
||||||
--shadow: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 1px 2px -1px hsl(0 0% 10.1961% / 0.05);
|
--shadow: 0px 8px 30px 0px hsl(263 70% 50% / 0.08), 0px 1px 2px -1px hsl(263 70% 50% / 0.08);
|
||||||
--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-md: 0px 8px 30px 0px hsl(263 70% 50% / 0.08), 0px 2px 4px -1px hsl(263 70% 50% / 0.08);
|
||||||
--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-lg: 0px 8px 30px 0px hsl(263 70% 50% / 0.08), 0px 4px 6px -1px hsl(263 70% 50% / 0.08);
|
||||||
--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-xl: 0px 8px 30px 0px hsl(263 70% 50% / 0.08), 0px 8px 10px -1px hsl(263 70% 50% / 0.08);
|
||||||
--shadow-2xl: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.13);
|
--shadow-2xl: 0px 8px 30px 0px hsl(263 70% 50% / 0.20);
|
||||||
--tracking-normal: normal;
|
--tracking-normal: -0.02em;
|
||||||
--spacing: 0.25rem;
|
--spacing: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: oklch(0 0 0);
|
--background: oklch(0.1091 0.0091 301.6956);
|
||||||
--foreground: oklch(1.0000 0 0);
|
--foreground: oklch(0.9838 0.0035 247.8583);
|
||||||
--card: oklch(0.2455 0.0217 257.2823);
|
--card: oklch(0.1376 0.0118 301.0607);
|
||||||
--card-foreground: oklch(1.0000 0 0);
|
--card-foreground: oklch(0.9838 0.0035 247.8583);
|
||||||
--popover: oklch(0.2455 0.0217 257.2823);
|
--popover: oklch(0.1486 0.0140 299.9811);
|
||||||
--popover-foreground: oklch(1.0000 0 0);
|
--popover-foreground: oklch(0.9838 0.0035 247.8583);
|
||||||
--primary: oklch(0.6801 0.1583 276.9349);
|
--primary: oklch(0.6083 0.2172 297.1153);
|
||||||
--primary-foreground: oklch(0 0 0);
|
--primary-foreground: oklch(0.1091 0.0091 301.6956);
|
||||||
--secondary: oklch(0.7845 0.1325 181.9120);
|
--secondary: oklch(0.2363 0.0582 299.6364);
|
||||||
--secondary-foreground: oklch(0 0 0);
|
--secondary-foreground: oklch(0.8266 0.0933 301.9462);
|
||||||
--muted: oklch(0.3211 0 0);
|
--muted: oklch(0.2217 0.0242 299.7054);
|
||||||
--muted-foreground: oklch(0.8452 0 0);
|
--muted-foreground: oklch(0.7497 0.0224 301.0128);
|
||||||
--accent: oklch(0.8790 0.1534 91.6054);
|
--accent: oklch(0.2255 0.0836 296.7401);
|
||||||
--accent-foreground: oklch(0 0 0);
|
--accent-foreground: oklch(0.6083 0.2172 297.1153);
|
||||||
--destructive: oklch(0.7106 0.1661 22.2162);
|
--destructive: oklch(0.6356 0.2082 25.3782);
|
||||||
--destructive-foreground: oklch(0 0 0);
|
--destructive-foreground: oklch(0.9838 0.0035 247.8583);
|
||||||
--border: oklch(0.4459 0 0);
|
--border: oklch(0.2505 0.0293 299.5707);
|
||||||
--input: oklch(1.0000 0 0);
|
--input: oklch(0.2505 0.0293 299.5707);
|
||||||
--ring: oklch(0.6801 0.1583 276.9349);
|
--ring: oklch(0.6083 0.2172 297.1153);
|
||||||
--chart-1: oklch(0.6801 0.1583 276.9349);
|
--chart-1: oklch(0.6083 0.2172 297.1153);
|
||||||
--chart-2: oklch(0.7845 0.1325 181.9120);
|
--chart-2: oklch(0.7741 0.1272 215.0981);
|
||||||
--chart-3: oklch(0.8790 0.1534 91.6054);
|
--chart-3: oklch(0.7801 0.1859 154.5892);
|
||||||
--chart-4: oklch(0.7253 0.1752 349.7607);
|
--chart-4: oklch(0.7001 0.1882 313.2907);
|
||||||
--chart-5: oklch(0.8003 0.1821 151.7110);
|
--chart-5: oklch(0.6888 0.2092 353.1317);
|
||||||
--sidebar: oklch(0 0 0);
|
--sidebar: oklch(0.1249 0.0104 301.6956);
|
||||||
--sidebar-foreground: oklch(1.0000 0 0);
|
--sidebar-foreground: oklch(0.9838 0.0035 247.8583);
|
||||||
--sidebar-primary: oklch(0.6801 0.1583 276.9349);
|
--sidebar-primary: oklch(0.6083 0.2172 297.1153);
|
||||||
--sidebar-primary-foreground: oklch(0 0 0);
|
--sidebar-primary-foreground: oklch(0.1091 0.0091 301.6956);
|
||||||
--sidebar-accent: oklch(0.8790 0.1534 91.6054);
|
--sidebar-accent: oklch(0.2096 0.0482 299.9505);
|
||||||
--sidebar-accent-foreground: oklch(0 0 0);
|
--sidebar-accent-foreground: oklch(0.6083 0.2172 297.1153);
|
||||||
--sidebar-border: oklch(1.0000 0 0);
|
--sidebar-border: oklch(0.2217 0.0242 299.7054);
|
||||||
--sidebar-ring: oklch(0.6801 0.1583 276.9349);
|
--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;
|
--radius: 1rem;
|
||||||
--shadow-x: 0px;
|
--shadow-x: 0px;
|
||||||
--shadow-y: 0px;
|
--shadow-y: 20px;
|
||||||
--shadow-blur: 0px;
|
--shadow-blur: 40px;
|
||||||
--shadow-spread: 0px;
|
--shadow-spread: -10px;
|
||||||
--shadow-opacity: 0.05;
|
--shadow-opacity: 0.6;
|
||||||
--shadow-color: #1a1a1a;
|
--shadow-color: hsl(0, 0%, 0%);
|
||||||
--shadow-2xs: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.03);
|
--shadow-2xs: 0px 20px 40px -10px hsl(0 0% 0% / 0.30);
|
||||||
--shadow-xs: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.03);
|
--shadow-xs: 0px 20px 40px -10px hsl(0 0% 0% / 0.30);
|
||||||
--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-sm: 0px 20px 40px -10px hsl(0 0% 0% / 0.60), 0px 1px 2px -11px hsl(0 0% 0% / 0.60);
|
||||||
--shadow: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 1px 2px -1px hsl(0 0% 10.1961% / 0.05);
|
--shadow: 0px 20px 40px -10px hsl(0 0% 0% / 0.60), 0px 1px 2px -11px hsl(0 0% 0% / 0.60);
|
||||||
--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-md: 0px 20px 40px -10px hsl(0 0% 0% / 0.60), 0px 2px 4px -11px hsl(0 0% 0% / 0.60);
|
||||||
--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-lg: 0px 20px 40px -10px hsl(0 0% 0% / 0.60), 0px 4px 6px -11px hsl(0 0% 0% / 0.60);
|
||||||
--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-xl: 0px 20px 40px -10px hsl(0 0% 0% / 0.60), 0px 8px 10px -11px hsl(0 0% 0% / 0.60);
|
||||||
--shadow-2xl: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.13);
|
--shadow-2xl: 0px 20px 40px -10px hsl(0 0% 0% / 1.50);
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
@ -139,9 +145,9 @@
|
|||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
|
||||||
--font-sans: 'DM Sans', sans-serif;
|
--font-sans: var(--font-sans);
|
||||||
--font-mono: 'Space Mono', monospace;
|
--font-mono: var(--font-mono);
|
||||||
--font-serif: 'DM Sans', sans-serif;
|
--font-serif: var(--font-serif);
|
||||||
|
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
@ -164,57 +170,19 @@
|
|||||||
--tracking-wider: calc(var(--tracking-normal) + 0.05em);
|
--tracking-wider: calc(var(--tracking-normal) + 0.05em);
|
||||||
--tracking-widest: calc(var(--tracking-normal) + 0.1em);
|
--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 {
|
@layer base {
|
||||||
html {
|
|
||||||
scroll-behavior: smooth;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply text-foreground;
|
@apply bg-background text-foreground;
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
letter-spacing: var(--tracking-normal);
|
letter-spacing: var(--tracking-normal);
|
||||||
background-color: transparent;
|
|
||||||
min-height: 100vh;
|
|
||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
overflow-x: hidden;
|
overflow-x: clip;
|
||||||
}
|
|
||||||
|
|
||||||
body::before {
|
|
||||||
content: '';
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: var(--bg-orbs), var(--bg-main);
|
|
||||||
background-size: cover;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: -50;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dark mode overrides */
|
|
||||||
.dark {
|
|
||||||
--bg-main: linear-gradient(135deg, #0f0f1a 0%, #1a1a2e 20%, #16213e 40%, #1a1a2e 60%, #0f0f1a 80%, #1a1a2e 100%);
|
|
||||||
--bg-orbs:
|
|
||||||
radial-gradient(ellipse at 15% 10%, rgba(99, 102, 241, 0.15) 0%, transparent 40%),
|
|
||||||
radial-gradient(ellipse at 85% 20%, rgba(168, 85, 247, 0.12) 0%, transparent 35%),
|
|
||||||
radial-gradient(ellipse at 50% 50%, rgba(45, 55, 72, 0.1) 0%, transparent 50%),
|
|
||||||
radial-gradient(ellipse at 20% 80%, rgba(34, 197, 94, 0.08) 0%, transparent 40%),
|
|
||||||
radial-gradient(ellipse at 80% 85%, rgba(239, 68, 68, 0.08) 0%, transparent 35%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Calendar styling - optimize cell size */
|
/* Calendar styling - optimize cell size */
|
||||||
@ -645,18 +613,21 @@
|
|||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.swipe-container {
|
.swipe-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
scroll-snap-type: x mandatory;
|
scroll-snap-type: x proximity;
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
|
touch-action: pan-x pinch-zoom;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
-ms-overflow-style: none;
|
-ms-overflow-style: none;
|
||||||
gap: 1.25rem;
|
gap: 1rem;
|
||||||
padding: 0.5rem 0 1.5rem;
|
padding: 0.25rem 0 0.5rem;
|
||||||
margin: 0 -1rem;
|
margin: 0 -1rem;
|
||||||
padding-left: 1.5rem;
|
padding-left: 1.5rem;
|
||||||
padding-right: 1.5rem;
|
padding-right: 1.5rem;
|
||||||
|
scroll-padding-inline: 1.5rem;
|
||||||
overscroll-behavior-x: contain;
|
overscroll-behavior-x: contain;
|
||||||
will-change: transform, scroll-position;
|
will-change: transform, scroll-position;
|
||||||
}
|
}
|
||||||
@ -666,10 +637,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.swipe-item {
|
.swipe-item {
|
||||||
flex: 0 0 calc(100vw - 3rem);
|
flex: 0 0 calc(100vw - 3.25rem);
|
||||||
width: calc(100vw - 3rem);
|
width: calc(100vw - 3.25rem);
|
||||||
scroll-snap-align: center;
|
scroll-snap-align: start;
|
||||||
height: fit-content;
|
scroll-snap-stop: always;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -722,4 +697,4 @@
|
|||||||
|
|
||||||
.animate-float {
|
.animate-float {
|
||||||
animation: float 6s ease-in-out infinite;
|
animation: float 6s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { ArrowLeft, Mail, Lock, LogOut, ExternalLink } from 'lucide-react';
|
import { ArrowLeft, Mail, Lock } from 'lucide-react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useTheme } from '@/lib/theme-context';
|
import { useTheme } from '@/lib/theme-context';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@ -11,10 +11,33 @@ import { cn } from '@/lib/utils';
|
|||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
|
const [isSendingReset, setIsSendingReset] = useState(false);
|
||||||
|
const [resetMessage, setResetMessage] = useState<string | null>(null);
|
||||||
|
const [resetError, setResetError] = useState<string | null>(null);
|
||||||
|
|
||||||
const handlePasswordReset = () => {
|
const handlePasswordReset = async () => {
|
||||||
// Log out and redirect to login page where they can use "Forgot Password"
|
setIsSendingReset(true);
|
||||||
window.location.href = '/api/auth/logout?redirect=/login';
|
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 (
|
return (
|
||||||
@ -64,18 +87,30 @@ export default function SettingsPage() {
|
|||||||
: "bg-white/5 border-white/10"
|
: "bg-white/5 border-white/10"
|
||||||
)}>
|
)}>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
To change your password, you'll be logged out and redirected to the login page.
|
Send a secure password reset link to your account email.
|
||||||
Use the <strong>"Forgot Password"</strong> option to receive a reset link via email.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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
|
<Button
|
||||||
onClick={handlePasswordReset}
|
onClick={handlePasswordReset}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full h-12 text-base font-semibold rounded-xl"
|
className="w-full h-12 text-base font-semibold rounded-xl"
|
||||||
|
disabled={isSendingReset}
|
||||||
>
|
>
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
<Lock className="mr-2 h-4 w-4" />
|
||||||
Log Out to Reset Password
|
{isSendingReset ? 'Sending Reset Link...' : 'Send Password Reset Link'}
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@ -1,12 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
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 { useTheme } from '@/lib/theme-context';
|
||||||
import {
|
import {
|
||||||
Trophy,
|
Trophy,
|
||||||
Lock,
|
Lock,
|
||||||
|
CheckCircle2,
|
||||||
Footprints,
|
Footprints,
|
||||||
Flame,
|
Flame,
|
||||||
Shield,
|
Shield,
|
||||||
@ -30,7 +31,6 @@ const iconMap: Record<string, React.ElementType> = {
|
|||||||
|
|
||||||
function AchievementsCardComponent({ achievements, substance }: AchievementsCardProps) {
|
function AchievementsCardComponent({ achievements, substance }: AchievementsCardProps) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const [hoveredBadge, setHoveredBadge] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const unlockedBadgeIds = useMemo(() => {
|
const unlockedBadgeIds = useMemo(() => {
|
||||||
return new Set(
|
return new Set(
|
||||||
@ -49,11 +49,9 @@ function AchievementsCardComponent({ achievements, substance }: AchievementsCard
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<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 }}
|
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">
|
<CardHeader className="relative z-10 pb-2">
|
||||||
<CardTitle className="flex items-center gap-2 text-white text-shadow-sm">
|
<CardTitle className="flex items-center gap-2 text-white text-shadow-sm">
|
||||||
<Trophy className="h-5 w-5 text-yellow-400" />
|
<Trophy className="h-5 w-5 text-yellow-400" />
|
||||||
@ -61,46 +59,20 @@ function AchievementsCardComponent({ achievements, substance }: AchievementsCard
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="relative z-10">
|
<CardContent className="relative z-10 flex flex-1 flex-col pb-6">
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 auto-rows-fr">
|
||||||
{BADGE_DEFINITIONS.map((badge) => {
|
{BADGE_DEFINITIONS.map((badge) => {
|
||||||
const isUnlocked = unlockedBadgeIds.has(badge.id);
|
const isUnlocked = unlockedBadgeIds.has(badge.id);
|
||||||
const Icon = iconMap[badge.icon] || Trophy;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={badge.id}
|
key={badge.id}
|
||||||
className={`relative p-3 rounded-xl text-center transition-all duration-300 cursor-pointer ${isUnlocked
|
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 hover:scale-105'
|
? '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'
|
: '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
|
<div
|
||||||
className={`mx-auto mb-1 p-2 rounded-full w-fit ${isUnlocked
|
className={`mx-auto mb-1 p-2 rounded-full w-fit ${isUnlocked
|
||||||
? 'bg-yellow-500/30 text-yellow-300'
|
? 'bg-yellow-500/30 text-yellow-300'
|
||||||
@ -109,18 +81,37 @@ function AchievementsCardComponent({ achievements, substance }: AchievementsCard
|
|||||||
>
|
>
|
||||||
<Icon className="h-5 w-5" />
|
<Icon className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
className={`text-xs font-medium ${isUnlocked ? 'text-white' : 'text-white/40'
|
className={`text-xs font-medium ${isUnlocked ? 'text-white' : 'text-white/40'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{badge.name}
|
{badge.name}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 text-center">
|
<div className="mt-auto pt-4 text-center">
|
||||||
<p className="text-sm text-white/70">
|
<p className="text-sm text-white/70">
|
||||||
{unlockedBadgeIds.size} of {BADGE_DEFINITIONS.length} badges unlocked
|
{unlockedBadgeIds.size} of {BADGE_DEFINITIONS.length} badges unlocked
|
||||||
</p>
|
</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()}`
|
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="relative z-10">
|
||||||
<div className="flex items-center justify-between mb-2 sm:mb-3">
|
<div className="flex items-center justify-between mb-2 sm:mb-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import {
|
|||||||
SavingsConfig,
|
SavingsConfig,
|
||||||
BADGE_DEFINITIONS,
|
BADGE_DEFINITIONS,
|
||||||
BadgeDefinition,
|
BadgeDefinition,
|
||||||
|
StorageRequestError,
|
||||||
} from '@/lib/storage';
|
} from '@/lib/storage';
|
||||||
import { UserHeader } from './UserHeader';
|
import { UserHeader } from './UserHeader';
|
||||||
import { SetupWizard } from './SetupWizard';
|
import { SetupWizard } from './SetupWizard';
|
||||||
@ -33,11 +34,12 @@ import { CelebrationAnimation } from './CelebrationAnimation';
|
|||||||
import { HealthTimelineCard } from './HealthTimelineCard';
|
import { HealthTimelineCard } from './HealthTimelineCard';
|
||||||
import { SavingsTrackerCard } from './SavingsTrackerCard';
|
import { SavingsTrackerCard } from './SavingsTrackerCard';
|
||||||
import { MoodTracker } from './MoodTracker';
|
import { MoodTracker } from './MoodTracker';
|
||||||
|
import { DailyInspirationCard } from './DailyInspirationCard';
|
||||||
import { ScrollWheelLogger } from './ScrollWheelLogger';
|
import { ScrollWheelLogger } from './ScrollWheelLogger';
|
||||||
import { UsageLoggerDropUp } from './UsageLoggerDropUp';
|
import { UsageLoggerDropUp } from './UsageLoggerDropUp';
|
||||||
import { VersionUpdateModal } from './VersionUpdateModal';
|
import { VersionUpdateModal } from './VersionUpdateModal';
|
||||||
import { Button } from '@/components/ui/button';
|
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 { useTheme } from '@/lib/theme-context';
|
||||||
import { getTodayString } from '@/lib/date-utils';
|
import { getTodayString } from '@/lib/date-utils';
|
||||||
|
|
||||||
@ -46,6 +48,18 @@ interface DashboardProps {
|
|||||||
user: User;
|
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) {
|
export function Dashboard({ user }: DashboardProps) {
|
||||||
const [preferences, setPreferences] = useState<UserPreferences | null>(null);
|
const [preferences, setPreferences] = useState<UserPreferences | null>(null);
|
||||||
const [usageData, setUsageData] = useState<UsageEntry[]>([]);
|
const [usageData, setUsageData] = useState<UsageEntry[]>([]);
|
||||||
@ -57,6 +71,7 @@ export function Dashboard({ user }: DashboardProps) {
|
|||||||
const [activeLoggingSubstance, setActiveLoggingSubstance] = useState<'nicotine' | 'weed' | null>(null);
|
const [activeLoggingSubstance, setActiveLoggingSubstance] = useState<'nicotine' | 'weed' | null>(null);
|
||||||
const [newBadge, setNewBadge] = useState<BadgeDefinition | null>(null);
|
const [newBadge, setNewBadge] = useState<BadgeDefinition | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
const [refreshKey, setRefreshKey] = useState(0);
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
const [currentPage, setCurrentPage] = useState(0);
|
const [currentPage, setCurrentPage] = useState(0);
|
||||||
const [modalOpenCount, setModalOpenCount] = useState(0);
|
const [modalOpenCount, setModalOpenCount] = useState(0);
|
||||||
@ -64,45 +79,92 @@ export function Dashboard({ user }: DashboardProps) {
|
|||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
|
|
||||||
const isModalOpen = modalOpenCount > 0 || showSetup || showCelebration;
|
const isModalOpen = modalOpenCount > 0 || showSetup || showCelebration;
|
||||||
const isNavHidden = isModalOpen || isSubstancePickerOpen || !!activeLoggingSubstance;
|
const totalPages = MOBILE_SLIDES.length;
|
||||||
|
|
||||||
const handleModalStateChange = useCallback((isOpen: boolean) => {
|
const handleModalStateChange = useCallback((isOpen: boolean) => {
|
||||||
setModalOpenCount(prev => isOpen ? prev + 1 : Math.max(0, prev - 1));
|
setModalOpenCount(prev => isOpen ? prev + 1 : Math.max(0, prev - 1));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleScroll = useCallback(() => {
|
const handleScroll = useCallback(() => {
|
||||||
if (!swipeContainerRef.current) return;
|
const container = swipeContainerRef.current;
|
||||||
const scrollLeft = swipeContainerRef.current.scrollLeft;
|
if (!container) return;
|
||||||
const width = swipeContainerRef.current.offsetWidth;
|
|
||||||
const page = Math.round(scrollLeft / width);
|
const slides = Array.from(container.querySelectorAll<HTMLElement>('.swipe-item'));
|
||||||
if (page !== currentPage) {
|
if (slides.length === 0) return;
|
||||||
setCurrentPage(page);
|
|
||||||
|
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]);
|
}, [currentPage]);
|
||||||
|
|
||||||
const scrollToPage = (pageIndex: number) => {
|
const scrollToPage = (pageIndex: number) => {
|
||||||
if (!swipeContainerRef.current) return;
|
const container = swipeContainerRef.current;
|
||||||
const width = swipeContainerRef.current.offsetWidth;
|
if (!container) return;
|
||||||
swipeContainerRef.current.scrollTo({
|
|
||||||
left: pageIndex * width,
|
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'
|
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 loadData = useCallback(async () => {
|
||||||
const [prefs, usage, achvs, savings] = await Promise.all([
|
try {
|
||||||
fetchPreferences(),
|
const [prefs, usage, achvs, savings] = await Promise.all([
|
||||||
fetchUsageData(),
|
fetchPreferences(),
|
||||||
fetchAchievements(),
|
fetchUsageData(),
|
||||||
fetchSavingsConfig(),
|
fetchAchievements(),
|
||||||
]);
|
fetchSavingsConfig(),
|
||||||
setPreferences(prefs);
|
]);
|
||||||
setUsageData(usage);
|
setPreferences(prefs);
|
||||||
setAchievements(achvs);
|
setUsageData(usage);
|
||||||
setSavingsConfig(savings);
|
setAchievements(achvs);
|
||||||
console.log('[Dashboard] Loaded prefs:', prefs);
|
setSavingsConfig(savings);
|
||||||
setRefreshKey(prev => prev + 1);
|
setLoadError(null);
|
||||||
return { prefs, usage, achvs };
|
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 (
|
const checkAndUnlockAchievements = useCallback(async (
|
||||||
@ -112,11 +174,47 @@ export function Dashboard({ user }: DashboardProps) {
|
|||||||
) => {
|
) => {
|
||||||
// Current unlocked set (local + server)
|
// Current unlocked set (local + server)
|
||||||
const unlockedIds = new Set(currentAchievements.map(a => `${a.badgeId}-${a.substance}`));
|
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[] = [];
|
const newUnlocked: Achievement[] = [];
|
||||||
let badgeToCelebrate: BadgeDefinition | null = null;
|
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) {
|
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) {
|
for (const substance of ['nicotine', 'weed'] as const) {
|
||||||
|
if (!hasUsageBySubstance[substance]) continue;
|
||||||
|
|
||||||
const key = `${badge.id}-${substance}`;
|
const key = `${badge.id}-${substance}`;
|
||||||
if (unlockedIds.has(key)) continue;
|
if (unlockedIds.has(key)) continue;
|
||||||
|
|
||||||
@ -154,22 +252,26 @@ export function Dashboard({ user }: DashboardProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
const { prefs, usage, achvs } = await loadData();
|
try {
|
||||||
|
const { prefs, usage, achvs } = await loadData();
|
||||||
|
|
||||||
if (!prefs.hasCompletedSetup) {
|
if (!prefs.hasCompletedSetup) {
|
||||||
setShowSetup(true);
|
setShowSetup(true);
|
||||||
} else {
|
} else {
|
||||||
// Check for achievements
|
// Check for achievements
|
||||||
await checkAndUnlockAchievements(usage, prefs, achvs);
|
await checkAndUnlockAchievements(usage, prefs, achvs);
|
||||||
|
|
||||||
// Check if running as PWA (home screen shortcut)
|
// Check if running as PWA (home screen shortcut)
|
||||||
// No longer automatically showing substance picker
|
// No longer automatically showing substance picker
|
||||||
if (shouldShowUsagePrompt()) {
|
if (shouldShowUsagePrompt()) {
|
||||||
markPromptShown();
|
markPromptShown();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Dashboard init error:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
init();
|
init();
|
||||||
@ -307,10 +409,39 @@ export function Dashboard({ user }: DashboardProps) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<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-4 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 && (
|
{preferences && (
|
||||||
<>
|
<>
|
||||||
{/* Floating Log Button - Simplified to toggle Picker */}
|
{/* 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
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
onClick={() => setIsSubstancePickerOpen(!isSubstancePickerOpen)}
|
onClick={() => setIsSubstancePickerOpen(!isSubstancePickerOpen)}
|
||||||
@ -335,36 +466,6 @@ export function Dashboard({ user }: DashboardProps) {
|
|||||||
|
|
||||||
{/* Dashboard Sections */}
|
{/* Dashboard Sections */}
|
||||||
<div className="space-y-6 sm:space-y-12 relative overflow-hidden">
|
<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>
|
|
||||||
|
|
||||||
{/* DESKTOP LAYOUT - Hidden on mobile */}
|
{/* DESKTOP LAYOUT - Hidden on mobile */}
|
||||||
<div className="hidden sm:block space-y-8">
|
<div className="hidden sm:block space-y-8">
|
||||||
{/* Row 1: Mood + Quit Plan */}
|
{/* Row 1: Mood + Quit Plan */}
|
||||||
@ -385,12 +486,6 @@ export function Dashboard({ user }: DashboardProps) {
|
|||||||
usageData={usageData}
|
usageData={usageData}
|
||||||
onDataUpdate={loadData}
|
onDataUpdate={loadData}
|
||||||
userId={user.id}
|
userId={user.id}
|
||||||
religion={preferences.religion}
|
|
||||||
onReligionUpdate={async (religion: 'christian' | 'secular') => {
|
|
||||||
const updatedPrefs = { ...preferences, religion };
|
|
||||||
setPreferences(updatedPrefs);
|
|
||||||
await savePreferencesAsync(updatedPrefs);
|
|
||||||
}}
|
|
||||||
preferences={preferences}
|
preferences={preferences}
|
||||||
onPreferencesUpdate={async (updatedPrefs: UserPreferences) => {
|
onPreferencesUpdate={async (updatedPrefs: UserPreferences) => {
|
||||||
await savePreferencesAsync(updatedPrefs);
|
await savePreferencesAsync(updatedPrefs);
|
||||||
@ -433,17 +528,48 @@ export function Dashboard({ user }: DashboardProps) {
|
|||||||
{/* MOBILE SWIPE LAYOUT - Hidden on desktop */}
|
{/* MOBILE SWIPE LAYOUT - Hidden on desktop */}
|
||||||
<div
|
<div
|
||||||
ref={swipeContainerRef}
|
ref={swipeContainerRef}
|
||||||
|
id="mobile-dashboard-slides"
|
||||||
onScroll={handleScroll}
|
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"
|
className="swipe-container sm:hidden"
|
||||||
|
tabIndex={0}
|
||||||
|
role="region"
|
||||||
|
aria-label="Mobile dashboard sections"
|
||||||
>
|
>
|
||||||
|
|
||||||
{/* SLIDE 1: Mindset (Mood & Personalized Plan) */}
|
{/* SLIDE 1: Mood */}
|
||||||
<div className="swipe-item space-y-4">
|
<div className="swipe-item space-y-3">
|
||||||
<div className="flex items-center justify-between mb-2 px-1">
|
<div className="flex items-center justify-between mb-2 px-1">
|
||||||
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Daily Mindset</h2>
|
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">How Are You Feeling</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="w-full max-w-[30rem] mx-auto space-y-3">
|
||||||
<MoodTracker />
|
<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
|
<UnifiedQuitPlanCard
|
||||||
preferences={preferences}
|
preferences={preferences}
|
||||||
usageData={usageData}
|
usageData={usageData}
|
||||||
@ -453,16 +579,31 @@ export function Dashboard({ user }: DashboardProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* SLIDE 2: Stats & Recovery (Side-by-side Stats + Health) */}
|
{/* SLIDE 3: Stats */}
|
||||||
<div className="swipe-item space-y-4">
|
<div className="swipe-item space-y-3">
|
||||||
<div className="flex items-center justify-between mb-2 px-1">
|
<div className="flex items-center justify-between mb-2 px-1">
|
||||||
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Usage & Recovery</h2>
|
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Usage Stats</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="grid grid-cols-1 gap-3 w-full max-w-[30rem] mx-auto">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<StatsCard
|
||||||
<StatsCard key={`stats-nicotine-${refreshKey}`} usageData={usageData} substance="nicotine" />
|
key={`stats-nicotine-${refreshKey}`}
|
||||||
<StatsCard key={`stats-weed-${refreshKey}`} usageData={usageData} substance="weed" />
|
usageData={usageData}
|
||||||
</div>
|
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
|
<HealthTimelineCard
|
||||||
key={`health-${refreshKey}`}
|
key={`health-${refreshKey}`}
|
||||||
usageData={usageData}
|
usageData={usageData}
|
||||||
@ -471,17 +612,26 @@ export function Dashboard({ user }: DashboardProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* SLIDE 3: Achievements & Money (Insights) */}
|
{/* SLIDE 5: Achievements */}
|
||||||
<div className="swipe-item space-y-4">
|
<div className="swipe-item space-y-3">
|
||||||
<div className="flex items-center justify-between mb-2 px-1">
|
<div className="flex items-center justify-between mb-2 px-1">
|
||||||
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Achievements & Savings</h2>
|
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Achievements</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="w-full max-w-[30rem] mx-auto">
|
||||||
<AchievementsCard
|
<AchievementsCard
|
||||||
key={`achievements-${refreshKey}`}
|
key={`achievements-${refreshKey}`}
|
||||||
achievements={achievements}
|
achievements={achievements}
|
||||||
substance={preferences.substance}
|
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
|
<SavingsTrackerCard
|
||||||
key={`savings-${refreshKey}`}
|
key={`savings-${refreshKey}`}
|
||||||
savingsConfig={savingsConfig}
|
savingsConfig={savingsConfig}
|
||||||
@ -493,31 +643,47 @@ export function Dashboard({ user }: DashboardProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* SLIDE 4: Calendar */}
|
{/* SLIDE 7: Calendar */}
|
||||||
<div id="calendar-section-mobile" className="swipe-item">
|
<div id="calendar-section-mobile" className="swipe-item">
|
||||||
<div className="flex items-center justify-between mb-2 px-1">
|
<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>
|
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Usage Calendar</h2>
|
||||||
</div>
|
</div>
|
||||||
<UsageCalendar
|
<div className="w-full max-w-[30rem] mx-auto">
|
||||||
key={refreshKey}
|
<UsageCalendar
|
||||||
usageData={usageData}
|
key={refreshKey}
|
||||||
onDataUpdate={loadData}
|
usageData={usageData}
|
||||||
userId={user.id}
|
onDataUpdate={loadData}
|
||||||
religion={preferences.religion}
|
userId={user.id}
|
||||||
onReligionUpdate={async (religion: 'christian' | 'secular') => {
|
preferences={preferences}
|
||||||
const updatedPrefs = { ...preferences, religion };
|
onPreferencesUpdate={async (updatedPrefs: UserPreferences) => {
|
||||||
setPreferences(updatedPrefs);
|
await savePreferencesAsync(updatedPrefs);
|
||||||
await savePreferencesAsync(updatedPrefs);
|
setPreferences(updatedPrefs);
|
||||||
}}
|
}}
|
||||||
preferences={preferences}
|
/>
|
||||||
onPreferencesUpdate={async (updatedPrefs: UserPreferences) => {
|
</div>
|
||||||
await savePreferencesAsync(updatedPrefs);
|
|
||||||
setPreferences(updatedPrefs);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="sm:hidden mt-1 mb-1 px-2">
|
||||||
|
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-2">
|
||||||
|
<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>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -311,11 +311,9 @@ function HealthTimelineCardComponent({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<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 }}
|
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">
|
<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`}>
|
<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" />
|
<Heart className="h-5 w-5 text-teal-500" />
|
||||||
|
|||||||
@ -10,13 +10,20 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Download, Share, Plus, MoreVertical, Smartphone } from 'lucide-react';
|
import { Download, Share, Plus, MoreVertical, Smartphone } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface BeforeInstallPromptEvent extends Event {
|
interface BeforeInstallPromptEvent extends Event {
|
||||||
prompt: () => Promise<void>;
|
prompt: () => Promise<void>;
|
||||||
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
|
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 [showInstructions, setShowInstructions] = useState(false);
|
||||||
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
|
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
|
||||||
const [isIOS, setIsIOS] = useState(false);
|
const [isIOS, setIsIOS] = useState(false);
|
||||||
@ -50,6 +57,7 @@ export function InstallAppButton() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleInstallClick = async () => {
|
const handleInstallClick = async () => {
|
||||||
|
onBeforeOpen?.();
|
||||||
if (deferredPrompt) {
|
if (deferredPrompt) {
|
||||||
// Use the native install prompt (Android/Chrome)
|
// Use the native install prompt (Android/Chrome)
|
||||||
await deferredPrompt.prompt();
|
await deferredPrompt.prompt();
|
||||||
@ -74,11 +82,21 @@ export function InstallAppButton() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleInstallClick}
|
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" />
|
<Smartphone className={cn('text-purple-400', compact ? 'h-5 w-5' : 'h-4 w-4')} />
|
||||||
<span className="hidden sm:inline">Add to Home Screen</span>
|
{!compact && (
|
||||||
<span className="sm:hidden">Install</span>
|
<>
|
||||||
|
<span className="hidden sm:inline">Add to Home Screen</span>
|
||||||
|
<span className="sm:hidden">Install</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Dialog open={showInstructions} onOpenChange={setShowInstructions}>
|
<Dialog open={showInstructions} onOpenChange={setShowInstructions}>
|
||||||
|
|||||||
@ -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"
|
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 }}
|
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">
|
<CardHeader className="relative z-10 pb-2">
|
||||||
<CardTitle className="flex items-center gap-2 text-white text-shadow-sm">
|
<CardTitle className="flex items-center gap-2 text-white text-shadow-sm">
|
||||||
<Bell className="h-5 w-5 text-indigo-400" />
|
<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"
|
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 }}
|
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">
|
<CardHeader className="relative z-10">
|
||||||
<CardTitle className="flex items-center gap-2 text-white text-shadow-sm">
|
<CardTitle className="flex items-center gap-2 text-white text-shadow-sm">
|
||||||
<DollarSign className="h-5 w-5 text-emerald-400" />
|
<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"
|
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 }}
|
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">
|
<CardHeader className="relative z-10 pb-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle className="flex items-center gap-2 text-white text-shadow-sm">
|
<CardTitle className="flex items-center gap-2 text-white text-shadow-sm">
|
||||||
|
|||||||
@ -10,7 +10,10 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
Shield,
|
Shield,
|
||||||
Heart,
|
Heart,
|
||||||
Calendar
|
Calendar,
|
||||||
|
Bell,
|
||||||
|
Moon,
|
||||||
|
Sun
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
@ -18,6 +21,7 @@ import { cn } from '@/lib/utils';
|
|||||||
import { useTheme } from '@/lib/theme-context';
|
import { useTheme } from '@/lib/theme-context';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
|
import { InstallAppButton } from './InstallAppButton';
|
||||||
|
|
||||||
interface SideMenuProps {
|
interface SideMenuProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@ -30,11 +34,12 @@ interface SideMenuProps {
|
|||||||
profilePictureUrl?: string | null;
|
profilePictureUrl?: string | null;
|
||||||
};
|
};
|
||||||
userName: 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 router = useRouter();
|
||||||
const { theme } = useTheme();
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
@ -72,10 +77,10 @@ export function SideMenu({ isOpen, onClose, user, userName }: SideMenuProps) {
|
|||||||
{/* Menu Content */}
|
{/* Menu Content */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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'
|
theme === 'light'
|
||||||
? "bg-white text-slate-900"
|
? "bg-white text-slate-900 border-l border-slate-100"
|
||||||
: "bg-slate-900 text-white border-r border-white/5"
|
: "bg-slate-900 text-white border-l border-white/5"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Header/Profile Info */}
|
{/* Header/Profile Info */}
|
||||||
@ -103,6 +108,59 @@ export function SideMenu({ isOpen, onClose, user, userName }: SideMenuProps) {
|
|||||||
|
|
||||||
{/* Navigation Links */}
|
{/* Navigation Links */}
|
||||||
<div className="flex-1 overflow-y-auto py-4 px-3 space-y-1">
|
<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
|
<MenuLink
|
||||||
icon={Home}
|
icon={Home}
|
||||||
label="Dashboard"
|
label="Dashboard"
|
||||||
@ -148,10 +206,10 @@ export function SideMenu({ isOpen, onClose, user, userName }: SideMenuProps) {
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
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" />
|
<LogOut className="absolute left-3 h-5 w-5" />
|
||||||
<span>Sign out</span>
|
<span className="text-center leading-none">Sign out</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -182,20 +240,20 @@ function MenuLink({ icon: Icon, label, onClick, color }: MenuLinkProps) {
|
|||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={cn(
|
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'
|
theme === 'light'
|
||||||
? "hover:bg-slate-100"
|
? "hover:bg-slate-100"
|
||||||
: "hover:bg-white/5"
|
: "hover:bg-white/5"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className={cn(
|
<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")
|
color ? colors[color] : (theme === 'light' ? "bg-slate-100" : "bg-white/5")
|
||||||
)}>
|
)}>
|
||||||
<Icon className="h-4 w-4" />
|
<Icon className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<span className="flex-1 text-left text-sm">{label}</span>
|
<span className="text-sm text-center leading-none">{label}</span>
|
||||||
<div className="w-1.5 h-1.5 rounded-full bg-primary opacity-0 group-hover:opacity-100 transition-opacity" />
|
<div className="absolute right-3 w-1.5 h-1.5 rounded-full bg-primary opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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`}
|
className={`backdrop-blur-xl border ${borderColor} shadow-xl drop-shadow-lg hover-lift transition-all duration-300 overflow-hidden relative`}
|
||||||
style={{ background: cardBackground }}
|
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">
|
<CardHeader className="pb-2 relative z-10">
|
||||||
<CardTitle className="flex items-center gap-2 text-white text-shadow-sm">
|
<CardTitle className="flex items-center gap-2 text-white text-shadow-sm">
|
||||||
<SubstanceIcon className={`h-5 w-5 ${iconColor}`} />
|
<SubstanceIcon className={`h-5 w-5 ${iconColor}`} />
|
||||||
|
|||||||
@ -17,12 +17,20 @@ interface SubstanceTrackingPageProps {
|
|||||||
export function SubstanceTrackingPage({ user, substance }: SubstanceTrackingPageProps) {
|
export function SubstanceTrackingPage({ user, substance }: SubstanceTrackingPageProps) {
|
||||||
const [usageData, setUsageData] = useState<UsageEntry[]>([]);
|
const [usageData, setUsageData] = useState<UsageEntry[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
const usage = await fetchUsageData();
|
try {
|
||||||
setUsageData(usage);
|
const usage = await fetchUsageData();
|
||||||
setIsLoading(false);
|
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(() => {
|
useEffect(() => {
|
||||||
@ -88,6 +96,12 @@ export function SubstanceTrackingPage({ user, substance }: SubstanceTrackingPage
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats and Graph */}
|
{/* 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="grid gap-6 md:grid-cols-2">
|
||||||
<div className="opacity-0 animate-fade-in-up delay-200">
|
<div className="opacity-0 animate-fade-in-up delay-200">
|
||||||
<StatsCard usageData={usageData} substance={substance} />
|
<StatsCard usageData={usageData} substance={substance} />
|
||||||
|
|||||||
@ -23,6 +23,8 @@ export function UnifiedLogin({ onSuccess }: UnifiedLoginProps) {
|
|||||||
const [pendingVerification, setPendingVerification] = useState(false);
|
const [pendingVerification, setPendingVerification] = useState(false);
|
||||||
const [verificationCode, setVerificationCode] = useState('');
|
const [verificationCode, setVerificationCode] = useState('');
|
||||||
const [userId, setUserId] = useState<string | null>(null);
|
const [userId, setUserId] = useState<string | null>(null);
|
||||||
|
const [isResettingPassword, setIsResettingPassword] = useState(false);
|
||||||
|
const [resetStatus, setResetStatus] = useState<string | null>(null);
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
email: '',
|
email: '',
|
||||||
@ -107,6 +109,39 @@ export function UnifiedLogin({ onSuccess }: UnifiedLoginProps) {
|
|||||||
const toggleMode = () => {
|
const toggleMode = () => {
|
||||||
setIsSignup(!isSignup);
|
setIsSignup(!isSignup);
|
||||||
setError(null);
|
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
|
// Checkbox Component to reuse
|
||||||
@ -276,16 +311,14 @@ export function UnifiedLogin({ onSuccess }: UnifiedLoginProps) {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label htmlFor="password">Password</Label>
|
<Label htmlFor="password">Password</Label>
|
||||||
{!isSignup && !pendingVerification && (
|
{!isSignup && !pendingVerification && (
|
||||||
<a
|
<button
|
||||||
href="#"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={handleForgotPassword}
|
||||||
e.preventDefault();
|
|
||||||
alert('Please use the Contact Support option or wait for upcoming full password reset features.');
|
|
||||||
}}
|
|
||||||
className="text-xs text-primary hover:underline"
|
className="text-xs text-primary hover:underline"
|
||||||
|
disabled={isResettingPassword}
|
||||||
>
|
>
|
||||||
Forgot password?
|
{isResettingPassword ? 'Sending reset link...' : 'Forgot password?'}
|
||||||
</a>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@ -324,6 +357,12 @@ export function UnifiedLogin({ onSuccess }: UnifiedLoginProps) {
|
|||||||
</div>
|
</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 />
|
<StayLoggedInCheckbox />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { QuitPlan, UsageEntry, UserPreferences } from '@/lib/storage';
|
import { QuitPlan, UsageEntry, UserPreferences } from '@/lib/storage';
|
||||||
import { Target, TrendingDown, ChevronDown, ChevronUp, Cigarette, Leaf, AlertTriangle, XCircle } from 'lucide-react';
|
import { TrendingDown, Cigarette, Leaf, AlertTriangle, XCircle } from 'lucide-react';
|
||||||
import { useTheme } from '@/lib/theme-context';
|
import { useTheme } from '@/lib/theme-context';
|
||||||
import { getTodayString } from '@/lib/date-utils';
|
import { getTodayString } from '@/lib/date-utils';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@ -15,8 +15,6 @@ interface SubstancePlanSectionProps {
|
|||||||
usageData: UsageEntry[];
|
usageData: UsageEntry[];
|
||||||
trackingStartDate: string | null;
|
trackingStartDate: string | null;
|
||||||
onGeneratePlan: () => void;
|
onGeneratePlan: () => void;
|
||||||
isExpanded: boolean;
|
|
||||||
onToggle: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function SubstancePlanSection({
|
function SubstancePlanSection({
|
||||||
@ -24,9 +22,7 @@ function SubstancePlanSection({
|
|||||||
plan,
|
plan,
|
||||||
usageData,
|
usageData,
|
||||||
trackingStartDate,
|
trackingStartDate,
|
||||||
onGeneratePlan,
|
onGeneratePlan
|
||||||
isExpanded,
|
|
||||||
onToggle
|
|
||||||
}: SubstancePlanSectionProps) {
|
}: SubstancePlanSectionProps) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
|
|
||||||
@ -76,6 +72,7 @@ function SubstancePlanSection({
|
|||||||
const isNicotine = substance === 'nicotine';
|
const isNicotine = substance === 'nicotine';
|
||||||
const Icon = isNicotine ? Cigarette : Leaf;
|
const Icon = isNicotine ? Cigarette : Leaf;
|
||||||
const label = isNicotine ? 'Nicotine' : 'Weed';
|
const label = isNicotine ? 'Nicotine' : 'Weed';
|
||||||
|
const unitLabel = isNicotine ? 'puffs' : 'hits';
|
||||||
|
|
||||||
// Base Colors
|
// Base Colors
|
||||||
const bgColor = isNicotine
|
const bgColor = isNicotine
|
||||||
@ -100,8 +97,7 @@ function SubstancePlanSection({
|
|||||||
<div className={cn("rounded-xl border transition-all duration-300 overflow-hidden mb-3", bgColor, borderColor)}>
|
<div className={cn("rounded-xl border transition-all duration-300 overflow-hidden mb-3", bgColor, borderColor)}>
|
||||||
{/* HEADER / SUMMARY ROW */}
|
{/* HEADER / SUMMARY ROW */}
|
||||||
<div
|
<div
|
||||||
onClick={onToggle}
|
className="flex items-center justify-between p-4"
|
||||||
className="flex items-center justify-between p-4 cursor-pointer hover:bg-black/5 active:bg-black/10 transition-colors"
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className={cn("p-2 rounded-lg", isNicotine ? "bg-yellow-500/20" : "bg-emerald-500/20")}>
|
<div className={cn("p-2 rounded-lg", isNicotine ? "bg-yellow-500/20" : "bg-emerald-500/20")}>
|
||||||
@ -122,54 +118,52 @@ function SubstancePlanSection({
|
|||||||
{todayUsage}{activePlan ? ` / ${currentTarget}` : ''}
|
{todayUsage}{activePlan ? ` / ${currentTarget}` : ''}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{isExpanded ? <ChevronUp className="h-5 w-5 opacity-30" /> : <ChevronDown className="h-5 w-5 opacity-30" />}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* EXPANDED CONTENT */}
|
{/* EXPANDED CONTENT */}
|
||||||
{isExpanded && (
|
<div className="px-4 pb-4">
|
||||||
<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 ? (
|
{!activePlan ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="bg-black/5 p-4 rounded-lg">
|
<div className="bg-black/5 p-4 rounded-lg">
|
||||||
<div className="flex justify-between items-center mb-2">
|
<div className="flex justify-between items-center mb-2">
|
||||||
<span className="text-xs font-medium">Weekly Baseline Progress</span>
|
<span className="text-xs font-medium">Weekly Baseline Progress</span>
|
||||||
<span className={cn("text-xs font-bold", accentColor)}>
|
<span className={cn("text-xs font-bold", accentColor)}>
|
||||||
{daysRemaining > 0 ? `${daysRemaining} days left` : 'Ready!'}
|
{daysRemaining > 0 ? `${daysRemaining} days left` : 'Ready!'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full bg-black/10 rounded-full h-2 overflow-hidden">
|
<div className="w-full bg-black/10 rounded-full h-2 overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className={cn("h-full transition-all duration-700", progressFill)}
|
className={cn("h-full transition-all duration-700", progressFill)}
|
||||||
style={{ width: `${Math.min(100, (uniqueDaysWithData / 7) * 100)}%` }}
|
style={{ width: `${Math.min(100, (uniqueDaysWithData / 7) * 100)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
</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>
|
||||||
) : (
|
|
||||||
<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={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 */}
|
{/* Active Plan Detail */}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-[10px] font-bold uppercase opacity-50 mb-1">Current Daily Limit</p>
|
<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={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>
|
</div>
|
||||||
|
|
||||||
{/* Progress Bar Detail */}
|
{/* Progress Bar Detail */}
|
||||||
@ -252,13 +246,12 @@ function SubstancePlanSection({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-black/5 p-3 rounded-lg flex justify-between items-center text-[10px] uppercase font-bold opacity-50">
|
<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>
|
<span>End: {new Date(activePlan.endDate).toLocaleDateString()}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -276,16 +269,8 @@ export function UnifiedQuitPlanCard({
|
|||||||
onGeneratePlan,
|
onGeneratePlan,
|
||||||
refreshKey
|
refreshKey
|
||||||
}: UnifiedQuitPlanCardProps) {
|
}: UnifiedQuitPlanCardProps) {
|
||||||
const [expandedSubstance, setExpandedSubstance] = useState<'nicotine' | 'weed' | 'none'>('nicotine');
|
|
||||||
|
|
||||||
if (!preferences) return null;
|
if (!preferences) return null;
|
||||||
|
|
||||||
// Determine which substances to show
|
|
||||||
const showNicotine = preferences.substance === 'nicotine' || usageData.some(e => e.substance === 'nicotine');
|
|
||||||
const showWeed = preferences.substance === 'weed' || usageData.some(e => e.substance === 'weed');
|
|
||||||
|
|
||||||
if (!showNicotine && !showWeed) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="backdrop-blur-2xl shadow-2xl border-white/10 overflow-hidden bg-white/5">
|
<Card className="backdrop-blur-2xl shadow-2xl border-white/10 overflow-hidden bg-white/5">
|
||||||
<CardHeader className="pb-1 pt-6 px-4 sm:px-6">
|
<CardHeader className="pb-1 pt-6 px-4 sm:px-6">
|
||||||
@ -295,39 +280,31 @@ export function UnifiedQuitPlanCard({
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-2 p-2 sm:p-4">
|
<CardContent className="pt-2 p-2 sm:p-4">
|
||||||
{showNicotine && (
|
<SubstancePlanSection
|
||||||
<SubstancePlanSection
|
substance="nicotine"
|
||||||
substance="nicotine"
|
plan={preferences.quitState?.nicotine?.plan || (preferences.substance === 'nicotine' ? preferences.quitPlan : null)}
|
||||||
isExpanded={expandedSubstance === 'nicotine'}
|
usageData={usageData}
|
||||||
onToggle={() => setExpandedSubstance(expandedSubstance === 'nicotine' ? 'none' : 'nicotine')}
|
trackingStartDate={
|
||||||
plan={preferences.quitState?.nicotine?.plan || (preferences.substance === 'nicotine' ? preferences.quitPlan : null)}
|
preferences.quitState?.nicotine?.startDate ||
|
||||||
usageData={usageData}
|
(preferences.substance === 'nicotine' ? preferences.trackingStartDate : null) ||
|
||||||
trackingStartDate={
|
usageData.filter(e => e.substance === 'nicotine').sort((a, b) => a.date.localeCompare(b.date))[0]?.date ||
|
||||||
preferences.quitState?.nicotine?.startDate ||
|
null
|
||||||
(preferences.substance === 'nicotine' ? preferences.trackingStartDate : null) ||
|
}
|
||||||
usageData.filter(e => e.substance === 'nicotine').sort((a, b) => a.date.localeCompare(b.date))[0]?.date ||
|
onGeneratePlan={() => onGeneratePlan('nicotine')}
|
||||||
null
|
/>
|
||||||
}
|
|
||||||
onGeneratePlan={() => onGeneratePlan('nicotine')}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showWeed && (
|
<SubstancePlanSection
|
||||||
<SubstancePlanSection
|
substance="weed"
|
||||||
substance="weed"
|
plan={preferences.quitState?.weed?.plan || (preferences.substance === 'weed' ? preferences.quitPlan : null)}
|
||||||
isExpanded={expandedSubstance === 'weed'}
|
usageData={usageData}
|
||||||
onToggle={() => setExpandedSubstance(expandedSubstance === 'weed' ? 'none' : 'weed')}
|
trackingStartDate={
|
||||||
plan={preferences.quitState?.weed?.plan || (preferences.substance === 'weed' ? preferences.quitPlan : null)}
|
preferences.quitState?.weed?.startDate ||
|
||||||
usageData={usageData}
|
(preferences.substance === 'weed' ? preferences.trackingStartDate : null) ||
|
||||||
trackingStartDate={
|
usageData.filter(e => e.substance === 'weed').sort((a, b) => a.date.localeCompare(b.date))[0]?.date ||
|
||||||
preferences.quitState?.weed?.startDate ||
|
null
|
||||||
(preferences.substance === 'weed' ? preferences.trackingStartDate : null) ||
|
}
|
||||||
usageData.filter(e => e.substance === 'weed').sort((a, b) => a.date.localeCompare(b.date))[0]?.date ||
|
onGeneratePlan={() => onGeneratePlan('weed')}
|
||||||
null
|
/>
|
||||||
}
|
|
||||||
onGeneratePlan={() => onGeneratePlan('weed')}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -16,7 +16,6 @@ import { UsageEntry, UserPreferences, setUsageForDateAsync, clearDayDataAsync }
|
|||||||
import { ChevronLeftIcon, ChevronRightIcon, Cigarette, Leaf, Sparkles } from 'lucide-react';
|
import { ChevronLeftIcon, ChevronRightIcon, Cigarette, Leaf, Sparkles } from 'lucide-react';
|
||||||
import { useTheme } from '@/lib/theme-context';
|
import { useTheme } from '@/lib/theme-context';
|
||||||
import { getLocalDateString, getTodayString } from '@/lib/date-utils';
|
import { getLocalDateString, getTodayString } from '@/lib/date-utils';
|
||||||
import { DailyInspirationCard } from './DailyInspirationCard';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
@ -25,13 +24,11 @@ interface UsageCalendarProps {
|
|||||||
usageData: UsageEntry[];
|
usageData: UsageEntry[];
|
||||||
onDataUpdate: () => void;
|
onDataUpdate: () => void;
|
||||||
userId: string;
|
userId: string;
|
||||||
religion?: 'christian' | 'secular' | null;
|
|
||||||
onReligionUpdate?: (religion: 'christian' | 'secular') => void;
|
|
||||||
preferences?: UserPreferences | null;
|
preferences?: UserPreferences | null;
|
||||||
onPreferencesUpdate?: (prefs: UserPreferences) => Promise<void>;
|
onPreferencesUpdate?: (prefs: UserPreferences) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function UsageCalendarComponent({ usageData, onDataUpdate, religion, onReligionUpdate, preferences, onPreferencesUpdate }: UsageCalendarProps) {
|
function UsageCalendarComponent({ usageData, onDataUpdate, preferences, onPreferencesUpdate }: UsageCalendarProps) {
|
||||||
const [selectedDate, setSelectedDate] = useState<Date | undefined>(undefined);
|
const [selectedDate, setSelectedDate] = useState<Date | undefined>(undefined);
|
||||||
const [editNicotineCount, setEditNicotineCount] = useState('');
|
const [editNicotineCount, setEditNicotineCount] = useState('');
|
||||||
const [editWeedCount, setEditWeedCount] = useState('');
|
const [editWeedCount, setEditWeedCount] = useState('');
|
||||||
@ -258,9 +255,9 @@ function UsageCalendarComponent({ usageData, onDataUpdate, religion, onReligionU
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col lg:flex-row gap-8 lg:gap-8 items-center lg:items-stretch justify-center max-w-6xl mx-auto">
|
<div className="max-w-4xl mx-auto">
|
||||||
{/* Calendar - Give it proper width on desktop */}
|
{/* Calendar */}
|
||||||
<div className="w-full lg:w-1/2 flex flex-col items-center">
|
<div className="w-full flex flex-col items-center">
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"rounded-2xl p-2 sm:p-4 border shadow-inner transition-all duration-500 w-full",
|
"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"
|
theme === 'light' ? "bg-slate-50/50 border-slate-200/60" : "bg-black/20 border-white/5"
|
||||||
@ -302,17 +299,6 @@ function UsageCalendarComponent({ usageData, onDataUpdate, religion, onReligionU
|
|||||||
/>
|
/>
|
||||||
</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 - Matching width on desktop */}
|
|
||||||
<div className="w-full lg:w-1/2 flex flex-col justify-center">
|
|
||||||
<DailyInspirationCard
|
|
||||||
initialReligion={religion}
|
|
||||||
onReligionChange={onReligionUpdate}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@ -25,13 +25,19 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { User } from '@/lib/session';
|
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 { useNotifications } from '@/hooks/useNotifications';
|
||||||
import { useRef, useEffect, useState } from 'react';
|
import { useRef, useEffect, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
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 { useTheme } from '@/lib/theme-context';
|
||||||
import { InstallAppButton } from './InstallAppButton';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { SideMenu } from './SideMenu';
|
import { SideMenu } from './SideMenu';
|
||||||
|
|
||||||
@ -131,7 +137,7 @@ export function UserHeader({ user, preferences, onModalStateChange }: UserHeader
|
|||||||
const [localTime, setLocalTime] = useState('09:00');
|
const [localTime, setLocalTime] = useState('09:00');
|
||||||
const [localFrequency, setLocalFrequency] = useState<'daily' | 'hourly'>('daily');
|
const [localFrequency, setLocalFrequency] = useState<'daily' | 'hourly'>('daily');
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { theme, toggleTheme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const { isSupported, permission, requestPermission } = useNotifications(reminderSettings);
|
const { isSupported, permission, requestPermission } = useNotifications(reminderSettings);
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
||||||
@ -222,26 +228,33 @@ export function UserHeader({ user, preferences, onModalStateChange }: UserHeader
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
const [prefs, reminders] = await Promise.all([
|
try {
|
||||||
preferences ? Promise.resolve(preferences) : fetchPreferences(),
|
const [prefs, reminders] = await Promise.all([
|
||||||
fetchReminderSettings(),
|
preferences ? Promise.resolve(preferences) : fetchPreferences(),
|
||||||
]);
|
fetchReminderSettings(),
|
||||||
|
]);
|
||||||
|
|
||||||
if (prefs) {
|
if (prefs) {
|
||||||
setUserName(prefs.userName);
|
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();
|
loadData();
|
||||||
}, [preferences]);
|
}, [preferences]);
|
||||||
@ -379,27 +392,9 @@ export function UserHeader({ user, preferences, onModalStateChange }: UserHeader
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="container mx-auto px-4 h-16 sm:h-20 flex items-center justify-between relative z-50">
|
<div className="container mx-auto px-4 h-16 sm:h-20 relative z-50 grid grid-cols-[1fr_auto_1fr] items-center">
|
||||||
{/* LEFT: User Profile / Side Menu Trigger */}
|
{/* LEFT: Spacer for balanced centered title */}
|
||||||
<div className="flex-1 flex justify-start">
|
<div className="flex-1" />
|
||||||
<button
|
|
||||||
onClick={() => setIsSideMenuOpen(true)}
|
|
||||||
className="group relative flex items-center gap-2 p-1.5 pr-3 rounded-full transition-all hover:bg-white/10 active:scale-95 border border-transparent hover:border-white/10"
|
|
||||||
>
|
|
||||||
<div className="relative">
|
|
||||||
<Avatar className="h-9 w-9 sm:h-10 sm:w-10 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">{initials}</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div className="absolute -bottom-1 -right-1 bg-slate-900 rounded-full p-0.5 shadow-sm border border-white/10">
|
|
||||||
<Menu className="h-3 w-3 text-white" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="hidden sm:block text-left">
|
|
||||||
<div className="text-[10px] font-bold uppercase tracking-widest opacity-50 text-white leading-none">Menu</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* CENTER: Title and Welcome Message */}
|
{/* CENTER: Title and Welcome Message */}
|
||||||
<div className="flex-[2] flex flex-col items-center justify-center text-center">
|
<div className="flex-[2] flex flex-col items-center justify-center text-center">
|
||||||
@ -422,33 +417,20 @@ export function UserHeader({ user, preferences, onModalStateChange }: UserHeader
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* RIGHT: Action Buttons */}
|
{/* RIGHT: User Profile / Side Menu Trigger */}
|
||||||
<div className="flex-1 flex items-center justify-end gap-1.5 sm:gap-3">
|
<div className="flex items-center justify-end justify-self-end">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowReminderDialog(true)}
|
onClick={() => setIsSideMenuOpen(true)}
|
||||||
className={cn(
|
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"
|
||||||
"p-2 sm:p-2.5 rounded-full transition-all duration-300 active:scale-90 shadow-sm",
|
aria-label="Open profile menu"
|
||||||
reminderSettings.enabled
|
|
||||||
? 'bg-indigo-500/15 text-indigo-400 border border-indigo-500/20'
|
|
||||||
: 'bg-white/5 border border-transparent text-white/50 hover:bg-white/10'
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{reminderSettings.enabled ? (
|
<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">
|
||||||
<BellRing className="h-4.5 w-4.5 sm:h-5 sm:w-5" />
|
<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>
|
||||||
<Bell className="h-4.5 w-4.5 sm:h-5 sm:w-5" />
|
</Avatar>
|
||||||
)}
|
<div className="absolute -bottom-1 -right-1 rounded-full p-0.5 shadow-sm border border-white/10 bg-slate-900/90">
|
||||||
</button>
|
<Menu className="h-3 w-3 text-white" />
|
||||||
<InstallAppButton />
|
</div>
|
||||||
<button
|
|
||||||
onClick={toggleTheme}
|
|
||||||
className="p-2 sm:p-2.5 rounded-full bg-white/5 border border-transparent hover:bg-white/10 transition-all active:scale-90"
|
|
||||||
>
|
|
||||||
{theme === 'dark' ? (
|
|
||||||
<Moon className="h-4.5 w-4.5 sm:h-5 sm:w-5 text-blue-300" />
|
|
||||||
) : (
|
|
||||||
<Sun className="h-4.5 w-4.5 sm:h-5 sm:w-5 text-yellow-500" />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -460,6 +442,7 @@ export function UserHeader({ user, preferences, onModalStateChange }: UserHeader
|
|||||||
onClose={() => setIsSideMenuOpen(false)}
|
onClose={() => setIsSideMenuOpen(false)}
|
||||||
user={user}
|
user={user}
|
||||||
userName={userName}
|
userName={userName}
|
||||||
|
onOpenNotifications={() => setShowReminderDialog(true)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Reminder Settings Dialog */}
|
{/* Reminder Settings Dialog */}
|
||||||
@ -606,9 +589,7 @@ export function UserHeader({ user, preferences, onModalStateChange }: UserHeader
|
|||||||
<HourlyTimePicker
|
<HourlyTimePicker
|
||||||
value={reminderSettings.hourlyStart || '09:00'}
|
value={reminderSettings.hourlyStart || '09:00'}
|
||||||
onChange={async (newTime) => {
|
onChange={async (newTime) => {
|
||||||
const [h, m] = newTime.split(':');
|
const newSettings = { ...reminderSettings, hourlyStart: newTime };
|
||||||
const end = (reminderSettings.hourlyEnd || '21:00').split(':');
|
|
||||||
const newSettings = { ...reminderSettings, hourlyStart: newTime, hourlyEnd: `${end[0]}:${m}` };
|
|
||||||
setReminderSettings(newSettings);
|
setReminderSettings(newSettings);
|
||||||
await saveReminderSettings(newSettings);
|
await saveReminderSettings(newSettings);
|
||||||
}}
|
}}
|
||||||
@ -620,9 +601,7 @@ export function UserHeader({ user, preferences, onModalStateChange }: UserHeader
|
|||||||
<HourlyTimePicker
|
<HourlyTimePicker
|
||||||
value={reminderSettings.hourlyEnd || '21:00'}
|
value={reminderSettings.hourlyEnd || '21:00'}
|
||||||
onChange={async (newTime) => {
|
onChange={async (newTime) => {
|
||||||
const [h, m] = newTime.split(':');
|
const newSettings = { ...reminderSettings, hourlyEnd: newTime };
|
||||||
const start = (reminderSettings.hourlyStart || '09:00').split(':');
|
|
||||||
const newSettings = { ...reminderSettings, hourlyEnd: newTime, hourlyStart: `${start[0]}:${m}` };
|
|
||||||
setReminderSettings(newSettings);
|
setReminderSettings(newSettings);
|
||||||
await saveReminderSettings(newSettings);
|
await saveReminderSettings(newSettings);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'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 { Cigarette, Leaf, Heart, Trophy, DollarSign, TrendingDown, CheckCircle, type LucideIcon } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
@ -169,6 +169,8 @@ const DEMO_SCREENS: DemoScreen[] = [
|
|||||||
|
|
||||||
// Phone mockup component
|
// Phone mockup component
|
||||||
function PhoneMockup({ activeScreen }: { activeScreen: number }) {
|
function PhoneMockup({ activeScreen }: { activeScreen: number }) {
|
||||||
|
const active = DEMO_SCREENS[activeScreen];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{/* Phone frame glow */}
|
{/* Phone frame glow */}
|
||||||
@ -183,25 +185,19 @@ function PhoneMockup({ activeScreen }: { activeScreen: number }) {
|
|||||||
<div className="absolute inset-0 p-4 pt-10">
|
<div className="absolute inset-0 p-4 pt-10">
|
||||||
{/* Screen header */}
|
{/* Screen header */}
|
||||||
<div className="text-center mb-4">
|
<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">
|
<p className="text-xs text-muted-foreground">
|
||||||
{DEMO_SCREENS[activeScreen].subtitle}
|
{active.subtitle}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Crossfade content */}
|
<div
|
||||||
<div className="relative h-[calc(100%-5rem)]">
|
id="demo-active-panel"
|
||||||
{DEMO_SCREENS.map((screen, index) => (
|
role="tabpanel"
|
||||||
<div
|
aria-labelledby={`demo-tab-${active.id}`}
|
||||||
key={screen.id}
|
className="h-[calc(100%-5rem)] transition-opacity duration-300 motion-reduce:transition-none"
|
||||||
className={cn(
|
>
|
||||||
"absolute inset-0 transition-opacity duration-500 motion-reduce:transition-none",
|
{active.content}
|
||||||
activeScreen === index ? "opacity-100 z-10" : "opacity-0 z-0"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{screen.content}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</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",
|
"p-6 sm:p-8 rounded-2xl transition-all duration-500 motion-reduce:transition-none",
|
||||||
isActive
|
isActive
|
||||||
? "bg-primary/10 border border-primary/30 scale-100 opacity-100"
|
? "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">
|
<div className="flex items-start gap-4">
|
||||||
@ -247,19 +243,48 @@ export function DemoSection() {
|
|||||||
const [activeScreen, setActiveScreen] = useState(0);
|
const [activeScreen, setActiveScreen] = useState(0);
|
||||||
const [isPaused, setIsPaused] = useState(false);
|
const [isPaused, setIsPaused] = useState(false);
|
||||||
const sectionRefs = useRef<(HTMLDivElement | null)[]>([]);
|
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(() => {
|
useEffect(() => {
|
||||||
if (isPaused) return;
|
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(() => {
|
const interval = setInterval(() => {
|
||||||
|
if (document.hidden) return;
|
||||||
setActiveScreen((prev) => (prev + 1) % DEMO_SCREENS.length);
|
setActiveScreen((prev) => (prev + 1) % DEMO_SCREENS.length);
|
||||||
}, 4000);
|
}, 4000);
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [isPaused]);
|
}, [isPaused]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (resumeTimerRef.current) {
|
||||||
|
window.clearTimeout(resumeTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Intersection Observer for desktop scroll spy
|
// Intersection Observer for desktop scroll spy
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const observers: IntersectionObserver[] = [];
|
const observers: IntersectionObserver[] = [];
|
||||||
@ -271,6 +296,7 @@ export function DemoSection() {
|
|||||||
(entries) => {
|
(entries) => {
|
||||||
entries.forEach((entry) => {
|
entries.forEach((entry) => {
|
||||||
if (entry.isIntersecting) {
|
if (entry.isIntersecting) {
|
||||||
|
if (!window.matchMedia('(min-width: 1024px)').matches) return;
|
||||||
setActiveScreen(index);
|
setActiveScreen(index);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -317,6 +343,7 @@ export function DemoSection() {
|
|||||||
<p className="text-muted-foreground max-w-2xl mx-auto">
|
<p className="text-muted-foreground max-w-2xl mx-auto">
|
||||||
A quick look at how QuitTraq helps you track progress and stay motivated.
|
A quick look at how QuitTraq helps you track progress and stay motivated.
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground/80 mt-3">Preview uses sample data for demonstration.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop: Split Sticky Scroll Layout */}
|
{/* Desktop: Split Sticky Scroll Layout */}
|
||||||
@ -333,9 +360,9 @@ export function DemoSection() {
|
|||||||
key={screen.id}
|
key={screen.id}
|
||||||
ref={(el) => { sectionRefs.current[index] = el; }}
|
ref={(el) => { sectionRefs.current[index] = el; }}
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-h-[70vh] flex items-center py-8",
|
"min-h-[52vh] flex items-center py-6",
|
||||||
index === 0 && "pt-0",
|
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} />
|
<FeatureCard screen={screen} isActive={activeScreen === index} />
|
||||||
@ -362,15 +389,29 @@ export function DemoSection() {
|
|||||||
<div className="w-full max-w-md text-center">
|
<div className="w-full max-w-md text-center">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Screen descriptions as buttons */}
|
{/* 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
|
<button
|
||||||
key={screen.id}
|
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(
|
className={cn(
|
||||||
"w-full text-left p-4 rounded-xl transition-all duration-300",
|
"w-full text-left p-4 rounded-xl transition-all duration-300",
|
||||||
activeScreen === index
|
activeScreen === index
|
||||||
? "bg-primary/10 border border-primary/30"
|
? "bg-primary/10 border border-primary/30"
|
||||||
: "hover:bg-white/5 border border-transparent"
|
: "hover:bg-card/60 border border-transparent"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<h4
|
<h4
|
||||||
@ -383,7 +424,8 @@ export function DemoSection() {
|
|||||||
</h4>
|
</h4>
|
||||||
<p className="text-sm text-muted-foreground">{screen.subtitle}</p>
|
<p className="text-sm text-muted-foreground">{screen.subtitle}</p>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dot indicators */}
|
{/* Dot indicators */}
|
||||||
@ -391,12 +433,13 @@ export function DemoSection() {
|
|||||||
{DEMO_SCREENS.map((_, index) => (
|
{DEMO_SCREENS.map((_, index) => (
|
||||||
<button
|
<button
|
||||||
key={index}
|
key={index}
|
||||||
onClick={() => setActiveScreen(index)}
|
type="button"
|
||||||
|
onClick={() => handleManualSelect(index)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-2 h-2 rounded-full transition-all",
|
"w-2 h-2 rounded-full transition-all",
|
||||||
activeScreen === index
|
activeScreen === index
|
||||||
? "w-6 bg-primary"
|
? "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}`}
|
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})`,
|
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">
|
<CardHeader className="relative z-10">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
{icons.map((icon, index) => (
|
{icons.map((icon, index) => (
|
||||||
|
|||||||
@ -221,6 +221,17 @@ export async function getAchievementD1(userId: string, badgeId: string, substanc
|
|||||||
return result;
|
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> {
|
export async function createAchievementD1(userId: string, badgeId: string, substance: string): Promise<AchievementRow | null> {
|
||||||
const db = getD1();
|
const db = getD1();
|
||||||
if (!db) return null;
|
if (!db) return null;
|
||||||
@ -232,7 +243,7 @@ export async function createAchievementD1(userId: string, badgeId: string, subst
|
|||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
await db.prepare(
|
await db.prepare(
|
||||||
`INSERT INTO Achievement (id, userId, badgeId, unlockedAt, substance)
|
`INSERT OR IGNORE INTO Achievement (id, userId, badgeId, unlockedAt, substance)
|
||||||
VALUES (?, ?, ?, ?, ?)`
|
VALUES (?, ?, ?, ?, ?)`
|
||||||
).bind(id, userId, badgeId, now, substance).run();
|
).bind(id, userId, badgeId, now, substance).run();
|
||||||
|
|
||||||
|
|||||||
@ -128,6 +128,22 @@ const defaultPreferences: UserPreferences = {
|
|||||||
religion: null,
|
religion: 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
|
// Cache for preferences and usage data to avoid excessive API calls
|
||||||
let preferencesCache: UserPreferences | null = null;
|
let preferencesCache: UserPreferences | null = null;
|
||||||
let usageDataCache: UsageEntry[] | null = null;
|
let usageDataCache: UsageEntry[] | null = null;
|
||||||
@ -160,15 +176,17 @@ export async function fetchPreferences(): Promise<UserPreferences> {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch('/api/preferences', { cache: 'no-store' });
|
const response = await fetch('/api/preferences', { cache: 'no-store' });
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.error('Failed to fetch preferences');
|
throw new StorageRequestError('Unable to load profile preferences.', response.status);
|
||||||
return defaultPreferences;
|
|
||||||
}
|
}
|
||||||
const data = await response.json() as UserPreferences;
|
const data = await response.json() as UserPreferences;
|
||||||
preferencesCache = data;
|
preferencesCache = data;
|
||||||
return data;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching preferences:', error);
|
console.error('Error fetching preferences:', error);
|
||||||
return defaultPreferences;
|
if (error instanceof StorageRequestError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new StorageRequestError('Unable to load profile preferences.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -195,15 +213,17 @@ export async function fetchUsageData(): Promise<UsageEntry[]> {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch('/api/usage', { cache: 'no-store' });
|
const response = await fetch('/api/usage', { cache: 'no-store' });
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.error('Failed to fetch usage data');
|
throw new StorageRequestError('Unable to load usage history.', response.status);
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
const data = await response.json() as UsageEntry[];
|
const data = await response.json() as UsageEntry[];
|
||||||
usageDataCache = data;
|
usageDataCache = data;
|
||||||
return data;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching usage data:', error);
|
console.error('Error fetching usage data:', error);
|
||||||
return [];
|
if (error instanceof StorageRequestError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new StorageRequestError('Unable to load usage history.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -306,13 +326,18 @@ export async function fetchReminderSettings(): Promise<ReminderSettings> {
|
|||||||
if (reminderSettingsCache) return reminderSettingsCache;
|
if (reminderSettingsCache) return reminderSettingsCache;
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/reminders');
|
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;
|
const data = await response.json() as ReminderSettings;
|
||||||
reminderSettingsCache = data;
|
reminderSettingsCache = data;
|
||||||
return data;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching reminder settings:', 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.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -332,7 +357,7 @@ export async function saveReminderSettings(settings: ReminderSettings): Promise<
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getReminderSettings(): ReminderSettings {
|
export function getReminderSettings(): ReminderSettings {
|
||||||
return reminderSettingsCache || { enabled: false, reminderTime: '09:00', frequency: 'daily' };
|
return reminderSettingsCache || defaultReminderSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ SAVINGS FUNCTIONS ============
|
// ============ SAVINGS FUNCTIONS ============
|
||||||
@ -489,6 +514,10 @@ export function checkBadgeEligibility(
|
|||||||
preferences: UserPreferences,
|
preferences: UserPreferences,
|
||||||
substance: 'nicotine' | 'weed'
|
substance: 'nicotine' | 'weed'
|
||||||
): boolean {
|
): boolean {
|
||||||
|
const hasLoggedUsageForSubstance = usageData.some(
|
||||||
|
(entry) => entry.substance === substance && entry.count > 0
|
||||||
|
);
|
||||||
|
|
||||||
// Pre-calculate common stats once O(n)
|
// Pre-calculate common stats once O(n)
|
||||||
const stats = (() => {
|
const stats = (() => {
|
||||||
const nicotineMap = new Map<string, number>();
|
const nicotineMap = new Map<string, number>();
|
||||||
@ -576,7 +605,7 @@ export function checkBadgeEligibility(
|
|||||||
};
|
};
|
||||||
|
|
||||||
switch (badgeId) {
|
switch (badgeId) {
|
||||||
case 'first_day': return stats.totalDays >= 1;
|
case 'first_day': return hasLoggedUsageForSubstance;
|
||||||
case 'streak_3': return streak >= 3;
|
case 'streak_3': return streak >= 3;
|
||||||
case 'streak_7': return stats.totalDays >= 7;
|
case 'streak_7': return stats.totalDays >= 7;
|
||||||
case 'fighter':
|
case 'fighter':
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user