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*
|
||||
.dev.vars
|
||||
.dev.vars.*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
@ -50,3 +51,7 @@ next-env.d.ts
|
||||
.open-next/
|
||||
.open-next 2/
|
||||
.wrangler/
|
||||
|
||||
# local editor / temp
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
53
DEV_SETUP.md
53
DEV_SETUP.md
@ -61,3 +61,56 @@ Expected URL:
|
||||
|
||||
- Keep file reads tightly scoped to explicit paths when requested.
|
||||
- Avoid broad folder crawling unless asked.
|
||||
|
||||
## Latest Session Changes
|
||||
|
||||
### Product and UX
|
||||
|
||||
- Reworked mobile dashboard into dedicated swipe pages:
|
||||
- Mood (+ Daily Inspiration)
|
||||
- Quit Journey Plan
|
||||
- Usage Stats
|
||||
- Health Recovery
|
||||
- Achievements
|
||||
- Savings
|
||||
- Usage Calendar
|
||||
- Removed mobile side arrow buttons; swipe + dots are now primary navigation.
|
||||
- Moved floating `Log Usage` button above the mobile swipe indicator and centered it on mobile.
|
||||
- Updated profile menu to top-right and moved quick actions (notifications/install/theme) into menu.
|
||||
- Expanded Quit Journey Plan sections (no dropdown collapse), showing both nicotine and weed plans.
|
||||
- Replaced old theme token set with updated light/dark OKLCH theme variables in `src/app/globals.css`.
|
||||
|
||||
### Reliability and Auth
|
||||
|
||||
- Added explicit dashboard/substance data load error handling and retry surface.
|
||||
- Fixed hourly reminders edge cases for overnight windows.
|
||||
- Added reminder API input validation for time/frequency values.
|
||||
- Implemented real password reset initiation via WorkOS endpoint and UI wiring in login/settings.
|
||||
|
||||
### Achievements
|
||||
|
||||
- Added inline unlock guidance text under each achievement tile.
|
||||
- Added completed/locked status treatment directly in the tile.
|
||||
- Added unlock safeguards to avoid duplicate global `first_day` unlock behavior:
|
||||
- `first_day` now normalizes to `substance: both`
|
||||
- dedupe normalization in achievements API GET
|
||||
- API validation for badge/substance input
|
||||
- `INSERT OR IGNORE` safety in D1 write path
|
||||
|
||||
## Validation Commands Used
|
||||
|
||||
```bash
|
||||
bun run build
|
||||
bun run build:worker
|
||||
bun run dev:worker
|
||||
```
|
||||
|
||||
## Quick Troubleshooting
|
||||
|
||||
- If UI changes do not appear in worker mode:
|
||||
1. `bun run build:worker`
|
||||
2. `bun run dev:worker`
|
||||
3. hard refresh browser (`Cmd + Shift + R`)
|
||||
|
||||
- If mobile swipe feels off:
|
||||
- swipe behavior is controlled in `src/app/globals.css` under `.swipe-container` and `.swipe-item`.
|
||||
|
||||
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
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
bun install
|
||||
bun run d1:migrate
|
||||
bun run build:worker
|
||||
bun run dev:worker
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
Local URL: `http://localhost:3000`
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
## Environment Variables
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
Create local env files:
|
||||
|
||||
## Learn More
|
||||
- `.env.local` (Next.js runtime)
|
||||
- `.dev.vars` (Wrangler runtime)
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
Required keys:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
- `WORKOS_CLIENT_ID`
|
||||
- `WORKOS_API_KEY`
|
||||
- `WORKOS_REDIRECT_URI`
|
||||
- `SESSION_SECRET`
|
||||
- `DATABASE_URL`
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
Optional keys for push notifications and cron:
|
||||
|
||||
## Deploy on Vercel
|
||||
- `NEXT_PUBLIC_VAPID_PUBLIC_KEY`
|
||||
- `VAPID_PRIVATE_KEY`
|
||||
- `VAPID_SUBJECT`
|
||||
- `CRON_SECRET`
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
## Deployment
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
```bash
|
||||
bun run deploy:app
|
||||
bun run deploy:cron
|
||||
```
|
||||
|
||||
Or run both:
|
||||
|
||||
```bash
|
||||
bun run deploy
|
||||
```
|
||||
|
||||
## Database Migrations
|
||||
|
||||
```bash
|
||||
# local D1
|
||||
bun run d1:migrate
|
||||
|
||||
# remote D1
|
||||
bun run d1:migrate:prod
|
||||
```
|
||||
|
||||
## Recent Product/UX Updates
|
||||
|
||||
- Mobile dashboard uses swipe-first pages with improved snap behavior.
|
||||
- Profile menu moved to top-right; quick actions are grouped in-menu.
|
||||
- Daily Inspiration moved under Mood on mobile.
|
||||
- Achievements cards now show unlock guidance inline.
|
||||
- Achievements unlock flow hardened to avoid duplicate global first-step unlocks.
|
||||
|
||||
@ -1,6 +1,18 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getSession } from '@/lib/session';
|
||||
import { getAchievementsD1, getAchievementD1, createAchievementD1 } from '@/lib/d1';
|
||||
import { getAchievementsD1, getAchievementD1, getAchievementByBadgeD1, createAchievementD1 } from '@/lib/d1';
|
||||
|
||||
const VALID_BADGE_IDS = new Set([
|
||||
'first_day',
|
||||
'streak_3',
|
||||
'streak_7',
|
||||
'fighter',
|
||||
'one_month',
|
||||
'goal_crusher',
|
||||
]);
|
||||
|
||||
const VALID_SUBSTANCES = new Set(['nicotine', 'weed', 'both']);
|
||||
const GLOBAL_BADGE_IDS = new Set(['first_day']);
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
@ -10,14 +22,22 @@ export async function GET() {
|
||||
}
|
||||
|
||||
const achievements = await getAchievementsD1(session.user.id);
|
||||
const seenGlobalBadges = new Set<string>();
|
||||
|
||||
return NextResponse.json(
|
||||
achievements.map((a) => ({
|
||||
const normalized = achievements
|
||||
.filter((a) => {
|
||||
if (!GLOBAL_BADGE_IDS.has(a.badgeId)) return true;
|
||||
if (seenGlobalBadges.has(a.badgeId)) return false;
|
||||
seenGlobalBadges.add(a.badgeId);
|
||||
return true;
|
||||
})
|
||||
.map((a) => ({
|
||||
badgeId: a.badgeId,
|
||||
unlockedAt: a.unlockedAt,
|
||||
substance: a.substance,
|
||||
}))
|
||||
);
|
||||
substance: GLOBAL_BADGE_IDS.has(a.badgeId) ? 'both' : a.substance,
|
||||
}));
|
||||
|
||||
return NextResponse.json(normalized);
|
||||
} catch (error) {
|
||||
console.error('Error fetching achievements:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
@ -38,8 +58,22 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Missing badgeId or substance' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!VALID_BADGE_IDS.has(badgeId)) {
|
||||
return NextResponse.json({ error: 'Invalid badgeId' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!VALID_SUBSTANCES.has(substance)) {
|
||||
return NextResponse.json({ error: 'Invalid substance' }, { status: 400 });
|
||||
}
|
||||
|
||||
const isGlobalBadge = GLOBAL_BADGE_IDS.has(badgeId);
|
||||
const targetSubstance = isGlobalBadge ? 'both' : substance;
|
||||
|
||||
// Check if already exists
|
||||
const existing = await getAchievementD1(session.user.id, badgeId, substance);
|
||||
const existing = isGlobalBadge
|
||||
? await getAchievementByBadgeD1(session.user.id, badgeId)
|
||||
: await getAchievementD1(session.user.id, badgeId, targetSubstance);
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json({
|
||||
badgeId: existing.badgeId,
|
||||
@ -49,7 +83,7 @@ export async function POST(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
const achievement = await createAchievementD1(session.user.id, badgeId, substance);
|
||||
const achievement = await createAchievementD1(session.user.id, badgeId, targetSubstance);
|
||||
|
||||
if (!achievement) {
|
||||
return NextResponse.json({ error: 'Failed to unlock achievement' }, { status: 500 });
|
||||
|
||||
@ -1,25 +1,28 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getSession } from '@/lib/session';
|
||||
import { workos } from '@/lib/workos';
|
||||
|
||||
// Send password reset email to current user
|
||||
// NOTE: WorkOS AuthKit handles password reset through the hosted UI flow
|
||||
// Users can reset their password by clicking "Forgot password" on the login page
|
||||
export async function POST() {
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json().catch(() => ({})) as { email?: string };
|
||||
const session = await getSession();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
|
||||
const email = (body.email || session?.user?.email || '').trim().toLowerCase();
|
||||
|
||||
if (!email) {
|
||||
return NextResponse.json({ error: 'Email is required.' }, { status: 400 });
|
||||
}
|
||||
|
||||
// For WorkOS AuthKit, password reset is handled through the hosted UI
|
||||
// The user should be directed to re-authenticate via the login page
|
||||
// where they can click "Forgot password"
|
||||
try {
|
||||
await workos.userManagement.createPasswordReset({ email });
|
||||
} catch (error: any) {
|
||||
// Prevent account enumeration: we intentionally return success either way.
|
||||
console.warn('Password reset initiation warning:', error?.message || error);
|
||||
}
|
||||
|
||||
// Return a message directing the user to the login page
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'To reset your password, please log out and use the "Forgot Password" option on the login page.',
|
||||
redirectTo: '/login'
|
||||
message: 'If an account exists for this email, a password reset link has been sent.'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Password reset error:', error);
|
||||
|
||||
@ -85,6 +85,20 @@ function getNotificationData(timeStr: string) {
|
||||
return { title, message };
|
||||
}
|
||||
|
||||
function toMinutes(timeStr: string) {
|
||||
const [hours, minutes] = timeStr.split(':').map(Number);
|
||||
return (hours * 60) + minutes;
|
||||
}
|
||||
|
||||
function isWithinWindow(current: number, start: number, end: number) {
|
||||
if (start <= end) {
|
||||
return current >= start && current <= end;
|
||||
}
|
||||
|
||||
// Overnight window (e.g. 22:00 -> 06:00)
|
||||
return current >= start || current <= end;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const isVapidReady = ensureVapidConfig();
|
||||
@ -125,16 +139,20 @@ export async function GET(request: NextRequest) {
|
||||
let tag = '';
|
||||
|
||||
if (user.frequency === 'hourly') {
|
||||
const [currentH, currentM] = userTimeString.split(':');
|
||||
const [startH, startM] = (user.hourlyStart || '09:00').split(':');
|
||||
const [currentH, currentM] = userTimeString.split(':').map(Number);
|
||||
const currentHourKey = `${userDateString}-${currentH}`; // YYYY-MM-DD-HH
|
||||
|
||||
// Check active hours
|
||||
const startStr = user.hourlyStart || '09:00';
|
||||
const endStr = user.hourlyEnd || '21:00';
|
||||
const startMinutes = toMinutes(startStr);
|
||||
const endMinutes = toMinutes(endStr);
|
||||
const currentMinutes = toMinutes(userTimeString);
|
||||
|
||||
// Only send if we are in the time window AND the current minute matches the start minute
|
||||
if (userTimeString >= startStr && userTimeString <= endStr && currentM === startM) {
|
||||
const cadenceMinute = Number((user.reminderTime || startStr).split(':')[1] || '0');
|
||||
|
||||
// Send once per hour at configured cadence minute while within active window
|
||||
if (isWithinWindow(currentMinutes, startMinutes, endMinutes) && currentM === cadenceMinute) {
|
||||
if (user.lastNotifiedDate !== currentHourKey) {
|
||||
shouldSend = true;
|
||||
const { title: t, message } = getNotificationData(userTimeString);
|
||||
|
||||
@ -50,6 +50,20 @@ export async function POST(request: NextRequest) {
|
||||
};
|
||||
const { enabled, reminderTime, frequency, hourlyStart, hourlyEnd, timezone } = body;
|
||||
|
||||
const timeRegex = /^\d{2}:\d{2}$/;
|
||||
if (reminderTime && !timeRegex.test(reminderTime)) {
|
||||
return NextResponse.json({ error: 'Invalid reminderTime format' }, { status: 400 });
|
||||
}
|
||||
if (hourlyStart && !timeRegex.test(hourlyStart)) {
|
||||
return NextResponse.json({ error: 'Invalid hourlyStart format' }, { status: 400 });
|
||||
}
|
||||
if (hourlyEnd && !timeRegex.test(hourlyEnd)) {
|
||||
return NextResponse.json({ error: 'Invalid hourlyEnd format' }, { status: 400 });
|
||||
}
|
||||
if (frequency && !['daily', 'hourly'].includes(frequency)) {
|
||||
return NextResponse.json({ error: 'Invalid frequency' }, { status: 400 });
|
||||
}
|
||||
|
||||
const settings = await upsertReminderSettingsD1(
|
||||
session.user.id,
|
||||
enabled ?? false,
|
||||
|
||||
@ -4,105 +4,111 @@
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--background: oklch(0.9789 0.0082 121.6272);
|
||||
--foreground: oklch(0 0 0);
|
||||
--background: oklch(0.9838 0.0035 247.8583);
|
||||
--foreground: oklch(0.1284 0.0267 261.5937);
|
||||
--card: oklch(1.0000 0 0);
|
||||
--card-foreground: oklch(0 0 0);
|
||||
--card-foreground: oklch(0.1284 0.0267 261.5937);
|
||||
--popover: oklch(1.0000 0 0);
|
||||
--popover-foreground: oklch(0 0 0);
|
||||
--primary: oklch(0.5106 0.2301 276.9656);
|
||||
--primary-foreground: oklch(1.0000 0 0);
|
||||
--secondary: oklch(0.7038 0.1230 182.5025);
|
||||
--secondary-foreground: oklch(1.0000 0 0);
|
||||
--muted: oklch(0.9551 0 0);
|
||||
--muted-foreground: oklch(0.3211 0 0);
|
||||
--accent: oklch(0.7686 0.1647 70.0804);
|
||||
--accent-foreground: oklch(0 0 0);
|
||||
--destructive: oklch(0.6368 0.2078 25.3313);
|
||||
--destructive-foreground: oklch(1.0000 0 0);
|
||||
--border: oklch(0 0 0);
|
||||
--input: oklch(0.5555 0 0);
|
||||
--ring: oklch(0.7853 0.1041 274.7134);
|
||||
--chart-1: oklch(0.5106 0.2301 276.9656);
|
||||
--chart-2: oklch(0.7038 0.1230 182.5025);
|
||||
--chart-3: oklch(0.7686 0.1647 70.0804);
|
||||
--chart-4: oklch(0.6559 0.2118 354.3084);
|
||||
--chart-5: oklch(0.7227 0.1920 149.5793);
|
||||
--sidebar: oklch(0.9789 0.0082 121.6272);
|
||||
--sidebar-foreground: oklch(0 0 0);
|
||||
--sidebar-primary: oklch(0.5106 0.2301 276.9656);
|
||||
--sidebar-primary-foreground: oklch(1.0000 0 0);
|
||||
--sidebar-accent: oklch(0.7686 0.1647 70.0804);
|
||||
--sidebar-accent-foreground: oklch(0 0 0);
|
||||
--sidebar-border: oklch(0 0 0);
|
||||
--sidebar-ring: oklch(0.7853 0.1041 274.7134);
|
||||
--popover-foreground: oklch(0.1284 0.0267 261.5937);
|
||||
--primary: oklch(0.4865 0.2423 291.8661);
|
||||
--primary-foreground: oklch(0.9838 0.0035 247.8583);
|
||||
--secondary: oklch(0.9486 0.0085 303.5068);
|
||||
--secondary-foreground: oklch(0.3410 0.1625 292.9477);
|
||||
--muted: oklch(0.9679 0.0027 264.5424);
|
||||
--muted-foreground: oklch(0.5503 0.0235 264.3620);
|
||||
--accent: oklch(0.9546 0.0227 303.2883);
|
||||
--accent-foreground: oklch(0.4865 0.2423 291.8661);
|
||||
--destructive: oklch(0.6356 0.2082 25.3782);
|
||||
--destructive-foreground: oklch(0.9838 0.0035 247.8583);
|
||||
--border: oklch(0.9278 0.0058 264.5314);
|
||||
--input: oklch(0.9278 0.0058 264.5314);
|
||||
--ring: oklch(0.4865 0.2423 291.8661);
|
||||
--chart-1: oklch(0.4865 0.2423 291.8661);
|
||||
--chart-2: oklch(0.7216 0.1282 217.8676);
|
||||
--chart-3: oklch(0.6356 0.1398 156.1492);
|
||||
--chart-4: oklch(0.6192 0.2037 312.7283);
|
||||
--chart-5: oklch(0.6532 0.2114 353.9392);
|
||||
--sidebar: oklch(1.0000 0 0);
|
||||
--sidebar-foreground: oklch(0.1284 0.0267 261.5937);
|
||||
--sidebar-primary: oklch(0.4865 0.2423 291.8661);
|
||||
--sidebar-primary-foreground: oklch(0.9838 0.0035 247.8583);
|
||||
--sidebar-accent: oklch(0.9486 0.0085 303.5068);
|
||||
--sidebar-accent-foreground: oklch(0.4865 0.2423 291.8661);
|
||||
--sidebar-border: oklch(0.9278 0.0058 264.5314);
|
||||
--sidebar-ring: oklch(0.4865 0.2423 291.8661);
|
||||
--font-sans: Poppins, ui-sans-serif, sans-serif, system-ui;
|
||||
--font-serif: Georgia, serif;
|
||||
--font-mono: JetBrains Mono, monospace;
|
||||
--radius: 1rem;
|
||||
--shadow-x: 0px;
|
||||
--shadow-y: 0px;
|
||||
--shadow-blur: 0px;
|
||||
--shadow-y: 8px;
|
||||
--shadow-blur: 30px;
|
||||
--shadow-spread: 0px;
|
||||
--shadow-opacity: 0.05;
|
||||
--shadow-color: #1a1a1a;
|
||||
--shadow-2xs: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.03);
|
||||
--shadow-xs: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.03);
|
||||
--shadow-sm: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 1px 2px -1px hsl(0 0% 10.1961% / 0.05);
|
||||
--shadow: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 1px 2px -1px hsl(0 0% 10.1961% / 0.05);
|
||||
--shadow-md: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 2px 4px -1px hsl(0 0% 10.1961% / 0.05);
|
||||
--shadow-lg: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 4px 6px -1px hsl(0 0% 10.1961% / 0.05);
|
||||
--shadow-xl: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 8px 10px -1px hsl(0 0% 10.1961% / 0.05);
|
||||
--shadow-2xl: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.13);
|
||||
--tracking-normal: normal;
|
||||
--shadow-opacity: 0.08;
|
||||
--shadow-color: hsl(263, 70%, 50%);
|
||||
--shadow-2xs: 0px 8px 30px 0px hsl(263 70% 50% / 0.04);
|
||||
--shadow-xs: 0px 8px 30px 0px hsl(263 70% 50% / 0.04);
|
||||
--shadow-sm: 0px 8px 30px 0px hsl(263 70% 50% / 0.08), 0px 1px 2px -1px hsl(263 70% 50% / 0.08);
|
||||
--shadow: 0px 8px 30px 0px hsl(263 70% 50% / 0.08), 0px 1px 2px -1px hsl(263 70% 50% / 0.08);
|
||||
--shadow-md: 0px 8px 30px 0px hsl(263 70% 50% / 0.08), 0px 2px 4px -1px hsl(263 70% 50% / 0.08);
|
||||
--shadow-lg: 0px 8px 30px 0px hsl(263 70% 50% / 0.08), 0px 4px 6px -1px hsl(263 70% 50% / 0.08);
|
||||
--shadow-xl: 0px 8px 30px 0px hsl(263 70% 50% / 0.08), 0px 8px 10px -1px hsl(263 70% 50% / 0.08);
|
||||
--shadow-2xl: 0px 8px 30px 0px hsl(263 70% 50% / 0.20);
|
||||
--tracking-normal: -0.02em;
|
||||
--spacing: 0.25rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0 0 0);
|
||||
--foreground: oklch(1.0000 0 0);
|
||||
--card: oklch(0.2455 0.0217 257.2823);
|
||||
--card-foreground: oklch(1.0000 0 0);
|
||||
--popover: oklch(0.2455 0.0217 257.2823);
|
||||
--popover-foreground: oklch(1.0000 0 0);
|
||||
--primary: oklch(0.6801 0.1583 276.9349);
|
||||
--primary-foreground: oklch(0 0 0);
|
||||
--secondary: oklch(0.7845 0.1325 181.9120);
|
||||
--secondary-foreground: oklch(0 0 0);
|
||||
--muted: oklch(0.3211 0 0);
|
||||
--muted-foreground: oklch(0.8452 0 0);
|
||||
--accent: oklch(0.8790 0.1534 91.6054);
|
||||
--accent-foreground: oklch(0 0 0);
|
||||
--destructive: oklch(0.7106 0.1661 22.2162);
|
||||
--destructive-foreground: oklch(0 0 0);
|
||||
--border: oklch(0.4459 0 0);
|
||||
--input: oklch(1.0000 0 0);
|
||||
--ring: oklch(0.6801 0.1583 276.9349);
|
||||
--chart-1: oklch(0.6801 0.1583 276.9349);
|
||||
--chart-2: oklch(0.7845 0.1325 181.9120);
|
||||
--chart-3: oklch(0.8790 0.1534 91.6054);
|
||||
--chart-4: oklch(0.7253 0.1752 349.7607);
|
||||
--chart-5: oklch(0.8003 0.1821 151.7110);
|
||||
--sidebar: oklch(0 0 0);
|
||||
--sidebar-foreground: oklch(1.0000 0 0);
|
||||
--sidebar-primary: oklch(0.6801 0.1583 276.9349);
|
||||
--sidebar-primary-foreground: oklch(0 0 0);
|
||||
--sidebar-accent: oklch(0.8790 0.1534 91.6054);
|
||||
--sidebar-accent-foreground: oklch(0 0 0);
|
||||
--sidebar-border: oklch(1.0000 0 0);
|
||||
--sidebar-ring: oklch(0.6801 0.1583 276.9349);
|
||||
--background: oklch(0.1091 0.0091 301.6956);
|
||||
--foreground: oklch(0.9838 0.0035 247.8583);
|
||||
--card: oklch(0.1376 0.0118 301.0607);
|
||||
--card-foreground: oklch(0.9838 0.0035 247.8583);
|
||||
--popover: oklch(0.1486 0.0140 299.9811);
|
||||
--popover-foreground: oklch(0.9838 0.0035 247.8583);
|
||||
--primary: oklch(0.6083 0.2172 297.1153);
|
||||
--primary-foreground: oklch(0.1091 0.0091 301.6956);
|
||||
--secondary: oklch(0.2363 0.0582 299.6364);
|
||||
--secondary-foreground: oklch(0.8266 0.0933 301.9462);
|
||||
--muted: oklch(0.2217 0.0242 299.7054);
|
||||
--muted-foreground: oklch(0.7497 0.0224 301.0128);
|
||||
--accent: oklch(0.2255 0.0836 296.7401);
|
||||
--accent-foreground: oklch(0.6083 0.2172 297.1153);
|
||||
--destructive: oklch(0.6356 0.2082 25.3782);
|
||||
--destructive-foreground: oklch(0.9838 0.0035 247.8583);
|
||||
--border: oklch(0.2505 0.0293 299.5707);
|
||||
--input: oklch(0.2505 0.0293 299.5707);
|
||||
--ring: oklch(0.6083 0.2172 297.1153);
|
||||
--chart-1: oklch(0.6083 0.2172 297.1153);
|
||||
--chart-2: oklch(0.7741 0.1272 215.0981);
|
||||
--chart-3: oklch(0.7801 0.1859 154.5892);
|
||||
--chart-4: oklch(0.7001 0.1882 313.2907);
|
||||
--chart-5: oklch(0.6888 0.2092 353.1317);
|
||||
--sidebar: oklch(0.1249 0.0104 301.6956);
|
||||
--sidebar-foreground: oklch(0.9838 0.0035 247.8583);
|
||||
--sidebar-primary: oklch(0.6083 0.2172 297.1153);
|
||||
--sidebar-primary-foreground: oklch(0.1091 0.0091 301.6956);
|
||||
--sidebar-accent: oklch(0.2096 0.0482 299.9505);
|
||||
--sidebar-accent-foreground: oklch(0.6083 0.2172 297.1153);
|
||||
--sidebar-border: oklch(0.2217 0.0242 299.7054);
|
||||
--sidebar-ring: oklch(0.6083 0.2172 297.1153);
|
||||
--font-sans: Poppins, ui-sans-serif, sans-serif, system-ui;
|
||||
--font-serif: Georgia, serif;
|
||||
--font-mono: JetBrains Mono, monospace;
|
||||
--radius: 1rem;
|
||||
--shadow-x: 0px;
|
||||
--shadow-y: 0px;
|
||||
--shadow-blur: 0px;
|
||||
--shadow-spread: 0px;
|
||||
--shadow-opacity: 0.05;
|
||||
--shadow-color: #1a1a1a;
|
||||
--shadow-2xs: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.03);
|
||||
--shadow-xs: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.03);
|
||||
--shadow-sm: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 1px 2px -1px hsl(0 0% 10.1961% / 0.05);
|
||||
--shadow: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 1px 2px -1px hsl(0 0% 10.1961% / 0.05);
|
||||
--shadow-md: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 2px 4px -1px hsl(0 0% 10.1961% / 0.05);
|
||||
--shadow-lg: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 4px 6px -1px hsl(0 0% 10.1961% / 0.05);
|
||||
--shadow-xl: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 8px 10px -1px hsl(0 0% 10.1961% / 0.05);
|
||||
--shadow-2xl: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.13);
|
||||
--shadow-y: 20px;
|
||||
--shadow-blur: 40px;
|
||||
--shadow-spread: -10px;
|
||||
--shadow-opacity: 0.6;
|
||||
--shadow-color: hsl(0, 0%, 0%);
|
||||
--shadow-2xs: 0px 20px 40px -10px hsl(0 0% 0% / 0.30);
|
||||
--shadow-xs: 0px 20px 40px -10px hsl(0 0% 0% / 0.30);
|
||||
--shadow-sm: 0px 20px 40px -10px hsl(0 0% 0% / 0.60), 0px 1px 2px -11px hsl(0 0% 0% / 0.60);
|
||||
--shadow: 0px 20px 40px -10px hsl(0 0% 0% / 0.60), 0px 1px 2px -11px hsl(0 0% 0% / 0.60);
|
||||
--shadow-md: 0px 20px 40px -10px hsl(0 0% 0% / 0.60), 0px 2px 4px -11px hsl(0 0% 0% / 0.60);
|
||||
--shadow-lg: 0px 20px 40px -10px hsl(0 0% 0% / 0.60), 0px 4px 6px -11px hsl(0 0% 0% / 0.60);
|
||||
--shadow-xl: 0px 20px 40px -10px hsl(0 0% 0% / 0.60), 0px 8px 10px -11px hsl(0 0% 0% / 0.60);
|
||||
--shadow-2xl: 0px 20px 40px -10px hsl(0 0% 0% / 1.50);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@ -139,9 +145,9 @@
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
|
||||
--font-sans: 'DM Sans', sans-serif;
|
||||
--font-mono: 'Space Mono', monospace;
|
||||
--font-serif: 'DM Sans', sans-serif;
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-mono);
|
||||
--font-serif: var(--font-serif);
|
||||
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
@ -164,57 +170,19 @@
|
||||
--tracking-wider: calc(var(--tracking-normal) + 0.05em);
|
||||
--tracking-widest: calc(var(--tracking-normal) + 0.1em);
|
||||
|
||||
/* Background gradients */
|
||||
--bg-main: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 20%, #bbf7d0 40%, #dcfce7 60%, #f0fdf4 80%, #dcfce7 100%);
|
||||
--bg-orbs:
|
||||
radial-gradient(ellipse at 15% 10%, rgba(99, 102, 241, 0.1) 0%, transparent 40%),
|
||||
radial-gradient(ellipse at 85% 20%, rgba(168, 85, 247, 0.08) 0%, transparent 35%),
|
||||
radial-gradient(ellipse at 50% 50%, rgba(59, 130, 246, 0.06) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at 20% 80%, rgba(34, 197, 94, 0.05) 0%, transparent 40%),
|
||||
radial-gradient(ellipse at 80% 85%, rgba(239, 68, 68, 0.05) 0%, transparent 35%);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply text-foreground;
|
||||
@apply bg-background text-foreground;
|
||||
font-family: var(--font-sans);
|
||||
letter-spacing: var(--tracking-normal);
|
||||
background-color: transparent;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: var(--bg-orbs), var(--bg-main);
|
||||
background-size: cover;
|
||||
pointer-events: none;
|
||||
z-index: -50;
|
||||
}
|
||||
|
||||
/* Dark mode overrides */
|
||||
.dark {
|
||||
--bg-main: linear-gradient(135deg, #0f0f1a 0%, #1a1a2e 20%, #16213e 40%, #1a1a2e 60%, #0f0f1a 80%, #1a1a2e 100%);
|
||||
--bg-orbs:
|
||||
radial-gradient(ellipse at 15% 10%, rgba(99, 102, 241, 0.15) 0%, transparent 40%),
|
||||
radial-gradient(ellipse at 85% 20%, rgba(168, 85, 247, 0.12) 0%, transparent 35%),
|
||||
radial-gradient(ellipse at 50% 50%, rgba(45, 55, 72, 0.1) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at 20% 80%, rgba(34, 197, 94, 0.08) 0%, transparent 40%),
|
||||
radial-gradient(ellipse at 80% 85%, rgba(239, 68, 68, 0.08) 0%, transparent 35%);
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
/* Calendar styling - optimize cell size */
|
||||
@ -645,18 +613,21 @@
|
||||
@media (max-width: 640px) {
|
||||
.swipe-container {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scroll-snap-type: x mandatory;
|
||||
scroll-snap-type: x proximity;
|
||||
scroll-behavior: smooth;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
touch-action: pan-x pinch-zoom;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
gap: 1.25rem;
|
||||
padding: 0.5rem 0 1.5rem;
|
||||
gap: 1rem;
|
||||
padding: 0.25rem 0 0.5rem;
|
||||
margin: 0 -1rem;
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
scroll-padding-inline: 1.5rem;
|
||||
overscroll-behavior-x: contain;
|
||||
will-change: transform, scroll-position;
|
||||
}
|
||||
@ -666,10 +637,14 @@
|
||||
}
|
||||
|
||||
.swipe-item {
|
||||
flex: 0 0 calc(100vw - 3rem);
|
||||
width: calc(100vw - 3rem);
|
||||
scroll-snap-align: center;
|
||||
height: fit-content;
|
||||
flex: 0 0 calc(100vw - 3.25rem);
|
||||
width: calc(100vw - 3.25rem);
|
||||
scroll-snap-align: start;
|
||||
scroll-snap-stop: always;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ArrowLeft, Mail, Lock, LogOut, ExternalLink } from 'lucide-react';
|
||||
import { ArrowLeft, Mail, Lock } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useTheme } from '@/lib/theme-context';
|
||||
import { cn } from '@/lib/utils';
|
||||
@ -11,10 +11,33 @@ import { cn } from '@/lib/utils';
|
||||
export default function SettingsPage() {
|
||||
const router = useRouter();
|
||||
const { theme } = useTheme();
|
||||
const [isSendingReset, setIsSendingReset] = useState(false);
|
||||
const [resetMessage, setResetMessage] = useState<string | null>(null);
|
||||
const [resetError, setResetError] = useState<string | null>(null);
|
||||
|
||||
const handlePasswordReset = () => {
|
||||
// Log out and redirect to login page where they can use "Forgot Password"
|
||||
window.location.href = '/api/auth/logout?redirect=/login';
|
||||
const handlePasswordReset = async () => {
|
||||
setIsSendingReset(true);
|
||||
setResetMessage(null);
|
||||
setResetError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/reset-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
const data = await response.json() as { message?: string; error?: string };
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to start password reset.');
|
||||
}
|
||||
|
||||
setResetMessage(data.message || 'Reset instructions sent to your email.');
|
||||
} catch (error: any) {
|
||||
setResetError(error.message || 'Failed to start password reset.');
|
||||
} finally {
|
||||
setIsSendingReset(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@ -64,18 +87,30 @@ export default function SettingsPage() {
|
||||
: "bg-white/5 border-white/10"
|
||||
)}>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
To change your password, you'll be logged out and redirected to the login page.
|
||||
Use the <strong>"Forgot Password"</strong> option to receive a reset link via email.
|
||||
Send a secure password reset link to your account email.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{resetMessage && (
|
||||
<div className="rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-700 dark:text-emerald-300">
|
||||
{resetMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resetError && (
|
||||
<div className="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-700 dark:text-red-300">
|
||||
{resetError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handlePasswordReset}
|
||||
variant="outline"
|
||||
className="w-full h-12 text-base font-semibold rounded-xl"
|
||||
disabled={isSendingReset}
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Log Out to Reset Password
|
||||
<Lock className="mr-2 h-4 w-4" />
|
||||
{isSendingReset ? 'Sending Reset Link...' : 'Send Password Reset Link'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Achievement, BADGE_DEFINITIONS, BadgeDefinition } from '@/lib/storage';
|
||||
import { Achievement, BADGE_DEFINITIONS } from '@/lib/storage';
|
||||
import { useTheme } from '@/lib/theme-context';
|
||||
import {
|
||||
Trophy,
|
||||
Lock,
|
||||
CheckCircle2,
|
||||
Footprints,
|
||||
Flame,
|
||||
Shield,
|
||||
@ -30,7 +31,6 @@ const iconMap: Record<string, React.ElementType> = {
|
||||
|
||||
function AchievementsCardComponent({ achievements, substance }: AchievementsCardProps) {
|
||||
const { theme } = useTheme();
|
||||
const [hoveredBadge, setHoveredBadge] = useState<string | null>(null);
|
||||
|
||||
const unlockedBadgeIds = useMemo(() => {
|
||||
return new Set(
|
||||
@ -49,11 +49,9 @@ function AchievementsCardComponent({ achievements, substance }: AchievementsCard
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={`backdrop-blur-xl border ${borderColor} shadow-xl drop-shadow-lg hover-lift transition-all duration-300 overflow-hidden relative`}
|
||||
className={`backdrop-blur-xl border ${borderColor} shadow-xl drop-shadow-lg hover-lift transition-all duration-300 overflow-hidden relative h-full min-h-[62dvh] sm:min-h-0 flex flex-col`}
|
||||
style={{ background: cardBackground }}
|
||||
>
|
||||
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-br from-yellow-500/10 to-transparent rounded-full -translate-y-1/2 translate-x-1/2 pointer-events-none" />
|
||||
|
||||
<CardHeader className="relative z-10 pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-white text-shadow-sm">
|
||||
<Trophy className="h-5 w-5 text-yellow-400" />
|
||||
@ -61,46 +59,20 @@ function AchievementsCardComponent({ achievements, substance }: AchievementsCard
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="relative z-10">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<CardContent className="relative z-10 flex flex-1 flex-col pb-6">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 auto-rows-fr">
|
||||
{BADGE_DEFINITIONS.map((badge) => {
|
||||
const isUnlocked = unlockedBadgeIds.has(badge.id);
|
||||
const Icon = iconMap[badge.icon] || Trophy;
|
||||
const unlockedAchievement = achievements.find(
|
||||
(a) =>
|
||||
a.badgeId === badge.id &&
|
||||
(a.substance === substance || a.substance === 'both')
|
||||
);
|
||||
const isHovered = hoveredBadge === badge.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={badge.id}
|
||||
className={`relative p-3 rounded-xl text-center transition-all duration-300 cursor-pointer ${isUnlocked
|
||||
? 'bg-gradient-to-br from-yellow-500/30 to-amber-600/20 border border-yellow-500/50 hover:scale-105'
|
||||
className={`relative p-3 rounded-xl text-center transition-all duration-300 h-full flex flex-col justify-between ${isUnlocked
|
||||
? 'bg-gradient-to-br from-yellow-500/30 to-amber-600/20 border border-yellow-500/50'
|
||||
: 'bg-white/5 border border-white/10 hover:bg-white/10 hover:border-white/20'
|
||||
}`}
|
||||
onMouseEnter={() => setHoveredBadge(badge.id)}
|
||||
onMouseLeave={() => setHoveredBadge(null)}
|
||||
>
|
||||
{/* Hover tooltip */}
|
||||
{isHovered && (
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 z-20 w-48 p-2 bg-gray-900/95 border border-white/20 rounded-lg shadow-xl backdrop-blur-sm">
|
||||
<p className="text-xs text-white font-medium mb-1">{badge.name}</p>
|
||||
<p className="text-[10px] text-white/70">
|
||||
{isUnlocked
|
||||
? `Unlocked: ${new Date(unlockedAchievement!.unlockedAt).toLocaleDateString()}`
|
||||
: badge.howToUnlock}
|
||||
</p>
|
||||
<div className="absolute top-full left-1/2 -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-900/95" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isUnlocked && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/20 rounded-xl pointer-events-none">
|
||||
<Lock className="h-4 w-4 text-white/40" />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`mx-auto mb-1 p-2 rounded-full w-fit ${isUnlocked
|
||||
? 'bg-yellow-500/30 text-yellow-300'
|
||||
@ -109,18 +81,37 @@ function AchievementsCardComponent({ achievements, substance }: AchievementsCard
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
|
||||
<p
|
||||
className={`text-xs font-medium ${isUnlocked ? 'text-white' : 'text-white/40'
|
||||
}`}
|
||||
>
|
||||
{badge.name}
|
||||
</p>
|
||||
|
||||
<p className={`mt-1.5 text-[10px] leading-tight ${isUnlocked ? 'text-white/35 line-through' : 'text-white/75'}`}>
|
||||
{badge.howToUnlock}
|
||||
</p>
|
||||
|
||||
<div className="mt-2 flex items-center justify-center gap-1.5">
|
||||
{isUnlocked ? (
|
||||
<>
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-yellow-300/90" />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wide text-white/55">Completed</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Lock className="h-3.5 w-3.5 text-white/45" />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wide text-white/55">Locked</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<div className="mt-auto pt-4 text-center">
|
||||
<p className="text-sm text-white/70">
|
||||
{unlockedBadgeIds.size} of {BADGE_DEFINITIONS.length} badges unlocked
|
||||
</p>
|
||||
|
||||
@ -138,8 +138,6 @@ export function DailyInspirationCard({ initialReligion, onReligionChange }: Dail
|
||||
boxShadow: `inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 4px 20px ${getShadowColor()}`
|
||||
}}
|
||||
>
|
||||
<div className="absolute top-0 right-0 w-24 h-24 sm:w-32 sm:h-32 bg-gradient-to-br from-white/10 to-transparent rounded-full -translate-y-1/2 translate-x-1/2" />
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-center justify-between mb-2 sm:mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@ -22,6 +22,7 @@ import {
|
||||
SavingsConfig,
|
||||
BADGE_DEFINITIONS,
|
||||
BadgeDefinition,
|
||||
StorageRequestError,
|
||||
} from '@/lib/storage';
|
||||
import { UserHeader } from './UserHeader';
|
||||
import { SetupWizard } from './SetupWizard';
|
||||
@ -33,11 +34,12 @@ import { CelebrationAnimation } from './CelebrationAnimation';
|
||||
import { HealthTimelineCard } from './HealthTimelineCard';
|
||||
import { SavingsTrackerCard } from './SavingsTrackerCard';
|
||||
import { MoodTracker } from './MoodTracker';
|
||||
import { DailyInspirationCard } from './DailyInspirationCard';
|
||||
import { ScrollWheelLogger } from './ScrollWheelLogger';
|
||||
import { UsageLoggerDropUp } from './UsageLoggerDropUp';
|
||||
import { VersionUpdateModal } from './VersionUpdateModal';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PlusCircle, ChevronLeft, ChevronRight, X } from 'lucide-react';
|
||||
import { PlusCircle, X } from 'lucide-react';
|
||||
import { useTheme } from '@/lib/theme-context';
|
||||
import { getTodayString } from '@/lib/date-utils';
|
||||
|
||||
@ -46,6 +48,18 @@ interface DashboardProps {
|
||||
user: User;
|
||||
}
|
||||
|
||||
const MOBILE_SLIDES = [
|
||||
{ id: 'mood', label: 'How Are You Feeling' },
|
||||
{ id: 'plan', label: 'Quit Journey Plan' },
|
||||
{ id: 'stats', label: 'Usage Stats' },
|
||||
{ id: 'recovery', label: 'Health Recovery' },
|
||||
{ id: 'achievements', label: 'Achievements' },
|
||||
{ id: 'savings', label: 'Savings' },
|
||||
{ id: 'calendar', label: 'Usage Calendar' },
|
||||
];
|
||||
|
||||
const GLOBAL_BADGE_IDS = new Set(['first_day']);
|
||||
|
||||
export function Dashboard({ user }: DashboardProps) {
|
||||
const [preferences, setPreferences] = useState<UserPreferences | null>(null);
|
||||
const [usageData, setUsageData] = useState<UsageEntry[]>([]);
|
||||
@ -57,6 +71,7 @@ export function Dashboard({ user }: DashboardProps) {
|
||||
const [activeLoggingSubstance, setActiveLoggingSubstance] = useState<'nicotine' | 'weed' | null>(null);
|
||||
const [newBadge, setNewBadge] = useState<BadgeDefinition | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
const [modalOpenCount, setModalOpenCount] = useState(0);
|
||||
@ -64,45 +79,92 @@ export function Dashboard({ user }: DashboardProps) {
|
||||
const { theme } = useTheme();
|
||||
|
||||
const isModalOpen = modalOpenCount > 0 || showSetup || showCelebration;
|
||||
const isNavHidden = isModalOpen || isSubstancePickerOpen || !!activeLoggingSubstance;
|
||||
const totalPages = MOBILE_SLIDES.length;
|
||||
|
||||
const handleModalStateChange = useCallback((isOpen: boolean) => {
|
||||
setModalOpenCount(prev => isOpen ? prev + 1 : Math.max(0, prev - 1));
|
||||
}, []);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!swipeContainerRef.current) return;
|
||||
const scrollLeft = swipeContainerRef.current.scrollLeft;
|
||||
const width = swipeContainerRef.current.offsetWidth;
|
||||
const page = Math.round(scrollLeft / width);
|
||||
if (page !== currentPage) {
|
||||
setCurrentPage(page);
|
||||
const container = swipeContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const slides = Array.from(container.querySelectorAll<HTMLElement>('.swipe-item'));
|
||||
if (slides.length === 0) return;
|
||||
|
||||
let nearestIndex = 0;
|
||||
let nearestDistance = Number.POSITIVE_INFINITY;
|
||||
|
||||
slides.forEach((slide, index) => {
|
||||
const distance = Math.abs(container.scrollLeft - slide.offsetLeft);
|
||||
if (distance < nearestDistance) {
|
||||
nearestDistance = distance;
|
||||
nearestIndex = index;
|
||||
}
|
||||
});
|
||||
|
||||
if (nearestIndex !== currentPage) {
|
||||
setCurrentPage(nearestIndex);
|
||||
}
|
||||
}, [currentPage]);
|
||||
|
||||
const scrollToPage = (pageIndex: number) => {
|
||||
if (!swipeContainerRef.current) return;
|
||||
const width = swipeContainerRef.current.offsetWidth;
|
||||
swipeContainerRef.current.scrollTo({
|
||||
left: pageIndex * width,
|
||||
const container = swipeContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const slides = Array.from(container.querySelectorAll<HTMLElement>('.swipe-item'));
|
||||
if (slides.length === 0) return;
|
||||
|
||||
const boundedPage = Math.min(Math.max(pageIndex, 0), totalPages - 1);
|
||||
const targetSlide = slides[boundedPage];
|
||||
|
||||
container.scrollTo({
|
||||
left: targetSlide?.offsetLeft ?? 0,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const savedPage = Number(localStorage.getItem('quittraq_mobile_dashboard_page'));
|
||||
if (Number.isNaN(savedPage) || savedPage < 0 || savedPage >= totalPages) return;
|
||||
setCurrentPage(savedPage);
|
||||
|
||||
const timeout = window.setTimeout(() => {
|
||||
scrollToPage(savedPage);
|
||||
}, 0);
|
||||
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [totalPages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.setItem('quittraq_mobile_dashboard_page', String(currentPage));
|
||||
}, [currentPage]);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
const [prefs, usage, achvs, savings] = await Promise.all([
|
||||
fetchPreferences(),
|
||||
fetchUsageData(),
|
||||
fetchAchievements(),
|
||||
fetchSavingsConfig(),
|
||||
]);
|
||||
setPreferences(prefs);
|
||||
setUsageData(usage);
|
||||
setAchievements(achvs);
|
||||
setSavingsConfig(savings);
|
||||
console.log('[Dashboard] Loaded prefs:', prefs);
|
||||
setRefreshKey(prev => prev + 1);
|
||||
return { prefs, usage, achvs };
|
||||
try {
|
||||
const [prefs, usage, achvs, savings] = await Promise.all([
|
||||
fetchPreferences(),
|
||||
fetchUsageData(),
|
||||
fetchAchievements(),
|
||||
fetchSavingsConfig(),
|
||||
]);
|
||||
setPreferences(prefs);
|
||||
setUsageData(usage);
|
||||
setAchievements(achvs);
|
||||
setSavingsConfig(savings);
|
||||
setLoadError(null);
|
||||
console.log('[Dashboard] Loaded prefs:', prefs);
|
||||
setRefreshKey(prev => prev + 1);
|
||||
return { prefs, usage, achvs };
|
||||
} catch (error) {
|
||||
const message = error instanceof StorageRequestError
|
||||
? error.message
|
||||
: 'Unable to sync your dashboard right now. Please try again.';
|
||||
setLoadError(message);
|
||||
throw error;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const checkAndUnlockAchievements = useCallback(async (
|
||||
@ -112,11 +174,47 @@ export function Dashboard({ user }: DashboardProps) {
|
||||
) => {
|
||||
// Current unlocked set (local + server)
|
||||
const unlockedIds = new Set(currentAchievements.map(a => `${a.badgeId}-${a.substance}`));
|
||||
const unlockedGlobalBadges = new Set(
|
||||
currentAchievements
|
||||
.filter((a) => GLOBAL_BADGE_IDS.has(a.badgeId))
|
||||
.map((a) => a.badgeId)
|
||||
);
|
||||
const newUnlocked: Achievement[] = [];
|
||||
let badgeToCelebrate: BadgeDefinition | null = null;
|
||||
|
||||
const hasUsageBySubstance = {
|
||||
nicotine: usage.some((entry) => entry.substance === 'nicotine' && entry.count > 0),
|
||||
weed: usage.some((entry) => entry.substance === 'weed' && entry.count > 0),
|
||||
};
|
||||
|
||||
for (const badge of BADGE_DEFINITIONS) {
|
||||
if (GLOBAL_BADGE_IDS.has(badge.id)) {
|
||||
if (unlockedGlobalBadges.has(badge.id)) continue;
|
||||
|
||||
const isEligible = checkBadgeEligibility(badge.id, usage, prefs, 'nicotine')
|
||||
|| checkBadgeEligibility(badge.id, usage, prefs, 'weed');
|
||||
|
||||
if (!isEligible) continue;
|
||||
|
||||
try {
|
||||
const result = await unlockAchievement(badge.id, 'both');
|
||||
if (result.isNew && result.achievement) {
|
||||
newUnlocked.push(result.achievement);
|
||||
unlockedGlobalBadges.add(badge.id);
|
||||
if (!badgeToCelebrate) {
|
||||
badgeToCelebrate = badge;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error unlocking global achievement:', e);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const substance of ['nicotine', 'weed'] as const) {
|
||||
if (!hasUsageBySubstance[substance]) continue;
|
||||
|
||||
const key = `${badge.id}-${substance}`;
|
||||
if (unlockedIds.has(key)) continue;
|
||||
|
||||
@ -154,22 +252,26 @@ export function Dashboard({ user }: DashboardProps) {
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
const { prefs, usage, achvs } = await loadData();
|
||||
try {
|
||||
const { prefs, usage, achvs } = await loadData();
|
||||
|
||||
if (!prefs.hasCompletedSetup) {
|
||||
setShowSetup(true);
|
||||
} else {
|
||||
// Check for achievements
|
||||
await checkAndUnlockAchievements(usage, prefs, achvs);
|
||||
if (!prefs.hasCompletedSetup) {
|
||||
setShowSetup(true);
|
||||
} else {
|
||||
// Check for achievements
|
||||
await checkAndUnlockAchievements(usage, prefs, achvs);
|
||||
|
||||
// Check if running as PWA (home screen shortcut)
|
||||
// No longer automatically showing substance picker
|
||||
if (shouldShowUsagePrompt()) {
|
||||
markPromptShown();
|
||||
// Check if running as PWA (home screen shortcut)
|
||||
// No longer automatically showing substance picker
|
||||
if (shouldShowUsagePrompt()) {
|
||||
markPromptShown();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Dashboard init error:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
init();
|
||||
@ -307,10 +409,39 @@ export function Dashboard({ user }: DashboardProps) {
|
||||
/>
|
||||
|
||||
<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 && (
|
||||
<>
|
||||
{/* Floating Log Button - Simplified to toggle Picker */}
|
||||
<div className={`fixed bottom-6 right-6 z-40 transition-all duration-300 ${isModalOpen ? 'opacity-0 scale-90 pointer-events-none' : 'opacity-100 scale-100'} sm:block`}>
|
||||
<div className={`fixed bottom-[6.5rem] sm:bottom-6 left-1/2 -translate-x-1/2 sm:left-auto sm:translate-x-0 sm:right-6 z-40 transition-all duration-300 ${isModalOpen ? 'opacity-0 scale-90 pointer-events-none' : 'opacity-100 scale-100'} sm:block`}>
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={() => setIsSubstancePickerOpen(!isSubstancePickerOpen)}
|
||||
@ -335,36 +466,6 @@ export function Dashboard({ user }: DashboardProps) {
|
||||
|
||||
{/* Dashboard Sections */}
|
||||
<div className="space-y-6 sm:space-y-12 relative overflow-hidden">
|
||||
{/* Mobile Navigation Buttons - LARGE */}
|
||||
<div className="sm:hidden">
|
||||
{currentPage > 0 && !isNavHidden && (
|
||||
<button
|
||||
onClick={() => scrollToPage(currentPage - 1)}
|
||||
className="fixed left-3 top-[55%] -translate-y-1/2 z-40 p-5 rounded-full glass border border-white/20 shadow-2xl active:scale-90 transition-all duration-300 group"
|
||||
style={{
|
||||
background: theme === 'light' ? 'rgba(255, 255, 255, 0.85)' : 'rgba(0, 0, 0, 0.6)',
|
||||
backdropFilter: 'blur(16px)',
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.2)'
|
||||
}}
|
||||
>
|
||||
<ChevronLeft className="h-8 w-8 text-primary group-hover:scale-110" />
|
||||
</button>
|
||||
)}
|
||||
{currentPage < 3 && !isNavHidden && (
|
||||
<button
|
||||
onClick={() => scrollToPage(currentPage + 1)}
|
||||
className="fixed right-3 top-[55%] -translate-y-1/2 z-40 p-5 rounded-full glass border border-white/20 shadow-2xl active:scale-90 transition-all duration-300 group"
|
||||
style={{
|
||||
background: theme === 'light' ? 'rgba(255, 255, 255, 0.85)' : 'rgba(0, 0, 0, 0.6)',
|
||||
backdropFilter: 'blur(16px)',
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.2)'
|
||||
}}
|
||||
>
|
||||
<ChevronRight className="h-8 w-8 text-primary group-hover:scale-110" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* DESKTOP LAYOUT - Hidden on mobile */}
|
||||
<div className="hidden sm:block space-y-8">
|
||||
{/* Row 1: Mood + Quit Plan */}
|
||||
@ -385,12 +486,6 @@ export function Dashboard({ user }: DashboardProps) {
|
||||
usageData={usageData}
|
||||
onDataUpdate={loadData}
|
||||
userId={user.id}
|
||||
religion={preferences.religion}
|
||||
onReligionUpdate={async (religion: 'christian' | 'secular') => {
|
||||
const updatedPrefs = { ...preferences, religion };
|
||||
setPreferences(updatedPrefs);
|
||||
await savePreferencesAsync(updatedPrefs);
|
||||
}}
|
||||
preferences={preferences}
|
||||
onPreferencesUpdate={async (updatedPrefs: UserPreferences) => {
|
||||
await savePreferencesAsync(updatedPrefs);
|
||||
@ -433,17 +528,48 @@ export function Dashboard({ user }: DashboardProps) {
|
||||
{/* MOBILE SWIPE LAYOUT - Hidden on desktop */}
|
||||
<div
|
||||
ref={swipeContainerRef}
|
||||
id="mobile-dashboard-slides"
|
||||
onScroll={handleScroll}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
scrollToPage(currentPage + 1);
|
||||
}
|
||||
if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
scrollToPage(currentPage - 1);
|
||||
}
|
||||
}}
|
||||
className="swipe-container sm:hidden"
|
||||
tabIndex={0}
|
||||
role="region"
|
||||
aria-label="Mobile dashboard sections"
|
||||
>
|
||||
|
||||
{/* SLIDE 1: Mindset (Mood & Personalized Plan) */}
|
||||
<div className="swipe-item space-y-4">
|
||||
{/* SLIDE 1: Mood */}
|
||||
<div className="swipe-item space-y-3">
|
||||
<div className="flex items-center justify-between mb-2 px-1">
|
||||
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Daily Mindset</h2>
|
||||
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">How Are You Feeling</h2>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="w-full max-w-[30rem] mx-auto space-y-3">
|
||||
<MoodTracker />
|
||||
<DailyInspirationCard
|
||||
initialReligion={preferences.religion}
|
||||
onReligionChange={async (religion) => {
|
||||
const updatedPrefs = { ...preferences, religion };
|
||||
setPreferences(updatedPrefs);
|
||||
await savePreferencesAsync(updatedPrefs);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SLIDE 2: Quit Plan */}
|
||||
<div className="swipe-item space-y-3">
|
||||
<div className="flex items-center justify-between mb-2 px-1">
|
||||
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Quit Journey Plan</h2>
|
||||
</div>
|
||||
<div className="w-full max-w-[30rem] mx-auto">
|
||||
<UnifiedQuitPlanCard
|
||||
preferences={preferences}
|
||||
usageData={usageData}
|
||||
@ -453,16 +579,31 @@ export function Dashboard({ user }: DashboardProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SLIDE 2: Stats & Recovery (Side-by-side Stats + Health) */}
|
||||
<div className="swipe-item space-y-4">
|
||||
{/* SLIDE 3: Stats */}
|
||||
<div className="swipe-item space-y-3">
|
||||
<div className="flex items-center justify-between mb-2 px-1">
|
||||
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Usage & Recovery</h2>
|
||||
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Usage Stats</h2>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<StatsCard key={`stats-nicotine-${refreshKey}`} usageData={usageData} substance="nicotine" />
|
||||
<StatsCard key={`stats-weed-${refreshKey}`} usageData={usageData} substance="weed" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 w-full max-w-[30rem] mx-auto">
|
||||
<StatsCard
|
||||
key={`stats-nicotine-${refreshKey}`}
|
||||
usageData={usageData}
|
||||
substance="nicotine"
|
||||
/>
|
||||
<StatsCard
|
||||
key={`stats-weed-${refreshKey}`}
|
||||
usageData={usageData}
|
||||
substance="weed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SLIDE 4: Recovery */}
|
||||
<div className="swipe-item space-y-3">
|
||||
<div className="flex items-center justify-between mb-2 px-1">
|
||||
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Health Recovery</h2>
|
||||
</div>
|
||||
<div className="w-full max-w-[30rem] mx-auto">
|
||||
<HealthTimelineCard
|
||||
key={`health-${refreshKey}`}
|
||||
usageData={usageData}
|
||||
@ -471,17 +612,26 @@ export function Dashboard({ user }: DashboardProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SLIDE 3: Achievements & Money (Insights) */}
|
||||
<div className="swipe-item space-y-4">
|
||||
{/* SLIDE 5: Achievements */}
|
||||
<div className="swipe-item space-y-3">
|
||||
<div className="flex items-center justify-between mb-2 px-1">
|
||||
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Achievements & Savings</h2>
|
||||
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Achievements</h2>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="w-full max-w-[30rem] mx-auto">
|
||||
<AchievementsCard
|
||||
key={`achievements-${refreshKey}`}
|
||||
achievements={achievements}
|
||||
substance={preferences.substance}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SLIDE 6: Savings */}
|
||||
<div className="swipe-item space-y-3">
|
||||
<div className="flex items-center justify-between mb-2 px-1">
|
||||
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Savings</h2>
|
||||
</div>
|
||||
<div className="w-full max-w-[30rem] mx-auto">
|
||||
<SavingsTrackerCard
|
||||
key={`savings-${refreshKey}`}
|
||||
savingsConfig={savingsConfig}
|
||||
@ -493,31 +643,47 @@ export function Dashboard({ user }: DashboardProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SLIDE 4: Calendar */}
|
||||
{/* SLIDE 7: Calendar */}
|
||||
<div id="calendar-section-mobile" className="swipe-item">
|
||||
<div className="flex items-center justify-between mb-2 px-1">
|
||||
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Usage Calendar</h2>
|
||||
</div>
|
||||
<UsageCalendar
|
||||
key={refreshKey}
|
||||
usageData={usageData}
|
||||
onDataUpdate={loadData}
|
||||
userId={user.id}
|
||||
religion={preferences.religion}
|
||||
onReligionUpdate={async (religion: 'christian' | 'secular') => {
|
||||
const updatedPrefs = { ...preferences, religion };
|
||||
setPreferences(updatedPrefs);
|
||||
await savePreferencesAsync(updatedPrefs);
|
||||
}}
|
||||
preferences={preferences}
|
||||
onPreferencesUpdate={async (updatedPrefs: UserPreferences) => {
|
||||
await savePreferencesAsync(updatedPrefs);
|
||||
setPreferences(updatedPrefs);
|
||||
}}
|
||||
/>
|
||||
<div className="w-full max-w-[30rem] mx-auto">
|
||||
<UsageCalendar
|
||||
key={refreshKey}
|
||||
usageData={usageData}
|
||||
onDataUpdate={loadData}
|
||||
userId={user.id}
|
||||
preferences={preferences}
|
||||
onPreferencesUpdate={async (updatedPrefs: UserPreferences) => {
|
||||
await savePreferencesAsync(updatedPrefs);
|
||||
setPreferences(updatedPrefs);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="sm:hidden 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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -311,11 +311,9 @@ function HealthTimelineCardComponent({
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="backdrop-blur-xl border border-teal-500/40 shadow-xl drop-shadow-lg hover-lift transition-all duration-300 overflow-hidden relative h-[500px] flex flex-col"
|
||||
className="backdrop-blur-xl border border-teal-500/40 shadow-xl drop-shadow-lg hover-lift transition-all duration-300 overflow-hidden relative h-[min(calc(58dvh+13rem),43rem)] sm:h-[500px] flex flex-col"
|
||||
style={{ background: cardBackground }}
|
||||
>
|
||||
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-br from-teal-500/10 to-transparent rounded-full -translate-y-1/2 translate-x-1/2 pointer-events-none" />
|
||||
|
||||
<CardHeader className="relative z-10 pb-4 shrink-0">
|
||||
<CardTitle className={`flex items-center gap-2 ${theme === 'light' ? 'text-teal-900' : 'text-white'} text-shadow-sm`}>
|
||||
<Heart className="h-5 w-5 text-teal-500" />
|
||||
|
||||
@ -10,13 +10,20 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Download, Share, Plus, MoreVertical, Smartphone } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
prompt: () => Promise<void>;
|
||||
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
|
||||
}
|
||||
|
||||
export function InstallAppButton() {
|
||||
interface InstallAppButtonProps {
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
onBeforeOpen?: () => void;
|
||||
}
|
||||
|
||||
export function InstallAppButton({ compact = false, className, onBeforeOpen }: InstallAppButtonProps) {
|
||||
const [showInstructions, setShowInstructions] = useState(false);
|
||||
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
|
||||
const [isIOS, setIsIOS] = useState(false);
|
||||
@ -50,6 +57,7 @@ export function InstallAppButton() {
|
||||
}, []);
|
||||
|
||||
const handleInstallClick = async () => {
|
||||
onBeforeOpen?.();
|
||||
if (deferredPrompt) {
|
||||
// Use the native install prompt (Android/Chrome)
|
||||
await deferredPrompt.prompt();
|
||||
@ -74,11 +82,21 @@ export function InstallAppButton() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleInstallClick}
|
||||
className="gap-2 border-purple-500/30 hover:bg-purple-500/10 hover:border-purple-500/50"
|
||||
className={cn(
|
||||
compact
|
||||
? 'h-12 w-12 p-0 border-purple-500/30 hover:bg-purple-500/10 hover:border-purple-500/50 flex items-center justify-center'
|
||||
: 'gap-2 border-purple-500/30 hover:bg-purple-500/10 hover:border-purple-500/50',
|
||||
className
|
||||
)}
|
||||
aria-label="Install app"
|
||||
>
|
||||
<Smartphone className="h-4 w-4 text-purple-400" />
|
||||
<span className="hidden sm:inline">Add to Home Screen</span>
|
||||
<span className="sm:hidden">Install</span>
|
||||
<Smartphone className={cn('text-purple-400', compact ? 'h-5 w-5' : 'h-4 w-4')} />
|
||||
{!compact && (
|
||||
<>
|
||||
<span className="hidden sm:inline">Add to Home Screen</span>
|
||||
<span className="sm:hidden">Install</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Dialog open={showInstructions} onOpenChange={setShowInstructions}>
|
||||
|
||||
@ -84,8 +84,6 @@ export function ReminderSettingsCard({
|
||||
className="backdrop-blur-xl border border-indigo-500/40 shadow-xl drop-shadow-lg hover-lift transition-all duration-300 overflow-hidden relative"
|
||||
style={{ background: cardBackground }}
|
||||
>
|
||||
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-br from-indigo-500/10 to-transparent rounded-full -translate-y-1/2 translate-x-1/2 pointer-events-none" />
|
||||
|
||||
<CardHeader className="relative z-10 pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-white text-shadow-sm">
|
||||
<Bell className="h-5 w-5 text-indigo-400" />
|
||||
|
||||
@ -81,8 +81,6 @@ function SavingsTrackerCardComponent({
|
||||
className="backdrop-blur-xl border border-emerald-500/40 shadow-xl drop-shadow-lg hover-lift transition-all duration-300 overflow-hidden relative"
|
||||
style={{ background: cardBackground }}
|
||||
>
|
||||
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-br from-emerald-500/10 to-transparent rounded-full -translate-y-1/2 translate-x-1/2 pointer-events-none" />
|
||||
|
||||
<CardHeader className="relative z-10">
|
||||
<CardTitle className="flex items-center gap-2 text-white text-shadow-sm">
|
||||
<DollarSign className="h-5 w-5 text-emerald-400" />
|
||||
@ -127,8 +125,6 @@ function SavingsTrackerCardComponent({
|
||||
className="backdrop-blur-xl border border-emerald-500/40 shadow-xl drop-shadow-lg hover-lift transition-all duration-300 overflow-hidden relative"
|
||||
style={{ background: cardBackground }}
|
||||
>
|
||||
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-br from-emerald-500/10 to-transparent rounded-full -translate-y-1/2 translate-x-1/2 pointer-events-none" />
|
||||
|
||||
<CardHeader className="relative z-10 pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2 text-white text-shadow-sm">
|
||||
|
||||
@ -10,7 +10,10 @@ import {
|
||||
Settings,
|
||||
Shield,
|
||||
Heart,
|
||||
Calendar
|
||||
Calendar,
|
||||
Bell,
|
||||
Moon,
|
||||
Sun
|
||||
} from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
@ -18,6 +21,7 @@ import { cn } from '@/lib/utils';
|
||||
import { useTheme } from '@/lib/theme-context';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { InstallAppButton } from './InstallAppButton';
|
||||
|
||||
interface SideMenuProps {
|
||||
isOpen: boolean;
|
||||
@ -30,11 +34,12 @@ interface SideMenuProps {
|
||||
profilePictureUrl?: string | null;
|
||||
};
|
||||
userName: string | null;
|
||||
onOpenNotifications: () => void;
|
||||
}
|
||||
|
||||
export function SideMenu({ isOpen, onClose, user, userName }: SideMenuProps) {
|
||||
export function SideMenu({ isOpen, onClose, user, userName, onOpenNotifications }: SideMenuProps) {
|
||||
const router = useRouter();
|
||||
const { theme } = useTheme();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
@ -72,10 +77,10 @@ export function SideMenu({ isOpen, onClose, user, userName }: SideMenuProps) {
|
||||
{/* Menu Content */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-72 h-full flex flex-col shadow-2xl transition-all animate-in slide-in-from-left duration-300",
|
||||
"relative ml-auto w-72 h-full flex flex-col shadow-2xl transition-all animate-in slide-in-from-right duration-300",
|
||||
theme === 'light'
|
||||
? "bg-white text-slate-900"
|
||||
: "bg-slate-900 text-white border-r border-white/5"
|
||||
? "bg-white text-slate-900 border-l border-slate-100"
|
||||
: "bg-slate-900 text-white border-l border-white/5"
|
||||
)}
|
||||
>
|
||||
{/* Header/Profile Info */}
|
||||
@ -103,6 +108,59 @@ export function SideMenu({ isOpen, onClose, user, userName }: SideMenuProps) {
|
||||
|
||||
{/* Navigation Links */}
|
||||
<div className="flex-1 overflow-y-auto py-4 px-3 space-y-1">
|
||||
<div className="px-3 pb-2">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest opacity-30">Quick Actions</span>
|
||||
</div>
|
||||
<div className="px-3 pb-3">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onOpenNotifications();
|
||||
}}
|
||||
className={cn(
|
||||
"h-12 w-12 rounded-xl border transition-all active:scale-95 flex items-center justify-center",
|
||||
theme === 'light'
|
||||
? "bg-slate-50 border-slate-200 hover:bg-slate-100"
|
||||
: "bg-white/5 border-white/10 hover:bg-white/10"
|
||||
)}
|
||||
aria-label="Open notification settings"
|
||||
>
|
||||
<Bell className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<InstallAppButton
|
||||
compact
|
||||
onBeforeOpen={onClose}
|
||||
className={cn(
|
||||
theme === 'light'
|
||||
? 'bg-slate-50 border-slate-200 hover:bg-slate-100'
|
||||
: 'bg-white/5 border-white/10 hover:bg-white/10'
|
||||
)}
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className={cn(
|
||||
"h-12 w-12 rounded-xl border transition-all active:scale-95 flex items-center justify-center",
|
||||
theme === 'light'
|
||||
? "bg-slate-50 border-slate-200 hover:bg-slate-100"
|
||||
: "bg-white/5 border-white/10 hover:bg-white/10"
|
||||
)}
|
||||
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
<Moon className="h-5 w-5 text-blue-300" />
|
||||
) : (
|
||||
<Sun className="h-5 w-5 text-amber-500" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="my-2 px-3">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest opacity-30">Main</span>
|
||||
</div>
|
||||
<MenuLink
|
||||
icon={Home}
|
||||
label="Dashboard"
|
||||
@ -148,10 +206,10 @@ export function SideMenu({ isOpen, onClose, user, userName }: SideMenuProps) {
|
||||
/>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center gap-3 px-3 py-3 rounded-xl text-red-500 hover:bg-red-500/10 transition-colors font-medium mt-1"
|
||||
className="relative w-full flex items-center justify-center px-3 py-3 rounded-xl text-red-500 hover:bg-red-500/10 transition-colors font-medium mt-1"
|
||||
>
|
||||
<LogOut className="h-5 w-5" />
|
||||
<span>Sign out</span>
|
||||
<LogOut className="absolute left-3 h-5 w-5" />
|
||||
<span className="text-center leading-none">Sign out</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -182,20 +240,20 @@ function MenuLink({ icon: Icon, label, onClick, color }: MenuLinkProps) {
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 px-3 py-3 rounded-xl transition-all font-medium group",
|
||||
"relative w-full flex items-center justify-center px-3 py-3 rounded-xl transition-all font-medium group",
|
||||
theme === 'light'
|
||||
? "hover:bg-slate-100"
|
||||
: "hover:bg-white/5"
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
"p-2 rounded-lg transition-colors",
|
||||
"absolute left-3 p-2 rounded-lg transition-colors",
|
||||
color ? colors[color] : (theme === 'light' ? "bg-slate-100" : "bg-white/5")
|
||||
)}>
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<span className="flex-1 text-left text-sm">{label}</span>
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-primary opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<span className="text-sm text-center leading-none">{label}</span>
|
||||
<div className="absolute right-3 w-1.5 h-1.5 rounded-full bg-primary opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@ -92,7 +92,6 @@ function StatsCardComponent({ usageData, substance }: StatsCardProps) {
|
||||
className={`backdrop-blur-xl border ${borderColor} shadow-xl drop-shadow-lg hover-lift transition-all duration-300 overflow-hidden relative`}
|
||||
style={{ background: cardBackground }}
|
||||
>
|
||||
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-br from-white/5 to-transparent rounded-full -translate-y-1/2 translate-x-1/2 pointer-events-none" />
|
||||
<CardHeader className="pb-2 relative z-10">
|
||||
<CardTitle className="flex items-center gap-2 text-white text-shadow-sm">
|
||||
<SubstanceIcon className={`h-5 w-5 ${iconColor}`} />
|
||||
|
||||
@ -17,12 +17,20 @@ interface SubstanceTrackingPageProps {
|
||||
export function SubstanceTrackingPage({ user, substance }: SubstanceTrackingPageProps) {
|
||||
const [usageData, setUsageData] = useState<UsageEntry[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const { theme } = useTheme();
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
const usage = await fetchUsageData();
|
||||
setUsageData(usage);
|
||||
setIsLoading(false);
|
||||
try {
|
||||
const usage = await fetchUsageData();
|
||||
setUsageData(usage);
|
||||
setLoadError(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to load substance usage data:', error);
|
||||
setLoadError('Unable to load tracking data right now.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@ -88,6 +96,12 @@ export function SubstanceTrackingPage({ user, substance }: SubstanceTrackingPage
|
||||
</div>
|
||||
|
||||
{/* Stats and Graph */}
|
||||
{loadError && (
|
||||
<div className="mb-6 rounded-xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-700 dark:text-amber-200">
|
||||
{loadError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="opacity-0 animate-fade-in-up delay-200">
|
||||
<StatsCard usageData={usageData} substance={substance} />
|
||||
|
||||
@ -23,6 +23,8 @@ export function UnifiedLogin({ onSuccess }: UnifiedLoginProps) {
|
||||
const [pendingVerification, setPendingVerification] = useState(false);
|
||||
const [verificationCode, setVerificationCode] = useState('');
|
||||
const [userId, setUserId] = useState<string | null>(null);
|
||||
const [isResettingPassword, setIsResettingPassword] = useState(false);
|
||||
const [resetStatus, setResetStatus] = useState<string | null>(null);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
@ -107,6 +109,39 @@ export function UnifiedLogin({ onSuccess }: UnifiedLoginProps) {
|
||||
const toggleMode = () => {
|
||||
setIsSignup(!isSignup);
|
||||
setError(null);
|
||||
setResetStatus(null);
|
||||
};
|
||||
|
||||
const handleForgotPassword = async () => {
|
||||
const email = formData.email.trim();
|
||||
if (!email) {
|
||||
setError('Enter your email first, then request password reset.');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setResetStatus(null);
|
||||
setIsResettingPassword(true);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/reset-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
|
||||
const data = await res.json() as { message?: string; error?: string };
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || 'Could not start password reset.');
|
||||
}
|
||||
|
||||
setResetStatus(data.message || 'Password reset instructions sent if the account exists.');
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Could not start password reset.');
|
||||
} finally {
|
||||
setIsResettingPassword(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Checkbox Component to reuse
|
||||
@ -276,16 +311,14 @@ export function UnifiedLogin({ onSuccess }: UnifiedLoginProps) {
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
{!isSignup && !pendingVerification && (
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
alert('Please use the Contact Support option or wait for upcoming full password reset features.');
|
||||
}}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleForgotPassword}
|
||||
className="text-xs text-primary hover:underline"
|
||||
disabled={isResettingPassword}
|
||||
>
|
||||
Forgot password?
|
||||
</a>
|
||||
{isResettingPassword ? 'Sending reset link...' : 'Forgot password?'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
@ -324,6 +357,12 @@ export function UnifiedLogin({ onSuccess }: UnifiedLoginProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resetStatus && (
|
||||
<div className="rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-600 dark:text-emerald-300">
|
||||
{resetStatus}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<StayLoggedInCheckbox />
|
||||
|
||||
<Button
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { QuitPlan, UsageEntry, UserPreferences } from '@/lib/storage';
|
||||
import { 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 { getTodayString } from '@/lib/date-utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
@ -15,8 +15,6 @@ interface SubstancePlanSectionProps {
|
||||
usageData: UsageEntry[];
|
||||
trackingStartDate: string | null;
|
||||
onGeneratePlan: () => void;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
function SubstancePlanSection({
|
||||
@ -24,9 +22,7 @@ function SubstancePlanSection({
|
||||
plan,
|
||||
usageData,
|
||||
trackingStartDate,
|
||||
onGeneratePlan,
|
||||
isExpanded,
|
||||
onToggle
|
||||
onGeneratePlan
|
||||
}: SubstancePlanSectionProps) {
|
||||
const { theme } = useTheme();
|
||||
|
||||
@ -76,6 +72,7 @@ function SubstancePlanSection({
|
||||
const isNicotine = substance === 'nicotine';
|
||||
const Icon = isNicotine ? Cigarette : Leaf;
|
||||
const label = isNicotine ? 'Nicotine' : 'Weed';
|
||||
const unitLabel = isNicotine ? 'puffs' : 'hits';
|
||||
|
||||
// Base Colors
|
||||
const bgColor = isNicotine
|
||||
@ -100,8 +97,7 @@ function SubstancePlanSection({
|
||||
<div className={cn("rounded-xl border transition-all duration-300 overflow-hidden mb-3", bgColor, borderColor)}>
|
||||
{/* HEADER / SUMMARY ROW */}
|
||||
<div
|
||||
onClick={onToggle}
|
||||
className="flex items-center justify-between p-4 cursor-pointer hover:bg-black/5 active:bg-black/10 transition-colors"
|
||||
className="flex items-center justify-between p-4"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<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}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
{isExpanded ? <ChevronUp className="h-5 w-5 opacity-30" /> : <ChevronDown className="h-5 w-5 opacity-30" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* EXPANDED CONTENT */}
|
||||
{isExpanded && (
|
||||
<div className="px-4 pb-4 animate-in slide-in-from-top-2 duration-200">
|
||||
<div className="h-px w-full bg-border mb-4 opacity-30" />
|
||||
<div className="px-4 pb-4">
|
||||
<div className="h-px w-full bg-border mb-4 opacity-30" />
|
||||
|
||||
{!activePlan ? (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-black/5 p-4 rounded-lg">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-xs font-medium">Weekly Baseline Progress</span>
|
||||
<span className={cn("text-xs font-bold", accentColor)}>
|
||||
{daysRemaining > 0 ? `${daysRemaining} days left` : 'Ready!'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-black/10 rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
className={cn("h-full transition-all duration-700", progressFill)}
|
||||
style={{ width: `${Math.min(100, (uniqueDaysWithData / 7) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
{!activePlan ? (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-black/5 p-4 rounded-lg">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-xs font-medium">Weekly Baseline Progress</span>
|
||||
<span className={cn("text-xs font-bold", accentColor)}>
|
||||
{daysRemaining > 0 ? `${daysRemaining} days left` : 'Ready!'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-black/10 rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
className={cn("h-full transition-all duration-700", progressFill)}
|
||||
style={{ width: `${Math.min(100, (uniqueDaysWithData / 7) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isUnlocked ? (
|
||||
<div className="text-center space-y-3">
|
||||
<p className="text-sm">
|
||||
Baseline established: <strong className={accentColor}>{currentAverage} puffs/day</strong>
|
||||
</p>
|
||||
<Button onClick={(e) => { e.stopPropagation(); onGeneratePlan(); }} size="sm" className={cn("w-full h-10 font-bold", progressFill, "text-white hover:opacity-90")}>
|
||||
Generate Plan
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-center opacity-70 italic">
|
||||
Keep logging for {daysRemaining} more days to calculate your personalized reduction plan.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
|
||||
{isUnlocked ? (
|
||||
<div className="text-center space-y-3">
|
||||
<p className="text-sm">
|
||||
Baseline established: <strong className={accentColor}>{currentAverage} {unitLabel}/day</strong>
|
||||
</p>
|
||||
<Button onClick={onGeneratePlan} size="sm" className={cn("w-full h-10 font-bold", progressFill, "text-white hover:opacity-90")}>
|
||||
Generate Plan
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-center opacity-70 italic">
|
||||
Keep logging for {daysRemaining} more days to calculate your personalized reduction plan.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Active Plan Detail */}
|
||||
<div className="text-center">
|
||||
<p className="text-[10px] font-bold uppercase opacity-50 mb-1">Current Daily Limit</p>
|
||||
<p className={cn("text-4xl font-black", accentColor)}>{currentTarget}</p>
|
||||
<p className="text-xs opacity-50 mt-1">puffs allowed today</p>
|
||||
<p className="text-xs opacity-50 mt-1">{unitLabel} allowed today</p>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar Detail */}
|
||||
@ -252,13 +246,12 @@ function SubstancePlanSection({
|
||||
</div>
|
||||
|
||||
<div className="bg-black/5 p-3 rounded-lg flex justify-between items-center text-[10px] uppercase font-bold opacity-50">
|
||||
<span>Start: {activePlan.baselineAverage}/day</span>
|
||||
<span>Start: {activePlan.baselineAverage} {unitLabel}/day</span>
|
||||
<span>End: {new Date(activePlan.endDate).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -276,16 +269,8 @@ export function UnifiedQuitPlanCard({
|
||||
onGeneratePlan,
|
||||
refreshKey
|
||||
}: UnifiedQuitPlanCardProps) {
|
||||
const [expandedSubstance, setExpandedSubstance] = useState<'nicotine' | 'weed' | 'none'>('nicotine');
|
||||
|
||||
if (!preferences) return null;
|
||||
|
||||
// Determine which substances to show
|
||||
const showNicotine = preferences.substance === 'nicotine' || usageData.some(e => e.substance === 'nicotine');
|
||||
const showWeed = preferences.substance === 'weed' || usageData.some(e => e.substance === 'weed');
|
||||
|
||||
if (!showNicotine && !showWeed) return null;
|
||||
|
||||
return (
|
||||
<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">
|
||||
@ -295,39 +280,31 @@ export function UnifiedQuitPlanCard({
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-2 p-2 sm:p-4">
|
||||
{showNicotine && (
|
||||
<SubstancePlanSection
|
||||
substance="nicotine"
|
||||
isExpanded={expandedSubstance === 'nicotine'}
|
||||
onToggle={() => setExpandedSubstance(expandedSubstance === 'nicotine' ? 'none' : 'nicotine')}
|
||||
plan={preferences.quitState?.nicotine?.plan || (preferences.substance === 'nicotine' ? preferences.quitPlan : null)}
|
||||
usageData={usageData}
|
||||
trackingStartDate={
|
||||
preferences.quitState?.nicotine?.startDate ||
|
||||
(preferences.substance === 'nicotine' ? preferences.trackingStartDate : null) ||
|
||||
usageData.filter(e => e.substance === 'nicotine').sort((a, b) => a.date.localeCompare(b.date))[0]?.date ||
|
||||
null
|
||||
}
|
||||
onGeneratePlan={() => onGeneratePlan('nicotine')}
|
||||
/>
|
||||
)}
|
||||
<SubstancePlanSection
|
||||
substance="nicotine"
|
||||
plan={preferences.quitState?.nicotine?.plan || (preferences.substance === 'nicotine' ? preferences.quitPlan : null)}
|
||||
usageData={usageData}
|
||||
trackingStartDate={
|
||||
preferences.quitState?.nicotine?.startDate ||
|
||||
(preferences.substance === 'nicotine' ? preferences.trackingStartDate : null) ||
|
||||
usageData.filter(e => e.substance === 'nicotine').sort((a, b) => a.date.localeCompare(b.date))[0]?.date ||
|
||||
null
|
||||
}
|
||||
onGeneratePlan={() => onGeneratePlan('nicotine')}
|
||||
/>
|
||||
|
||||
{showWeed && (
|
||||
<SubstancePlanSection
|
||||
substance="weed"
|
||||
isExpanded={expandedSubstance === 'weed'}
|
||||
onToggle={() => setExpandedSubstance(expandedSubstance === 'weed' ? 'none' : 'weed')}
|
||||
plan={preferences.quitState?.weed?.plan || (preferences.substance === 'weed' ? preferences.quitPlan : null)}
|
||||
usageData={usageData}
|
||||
trackingStartDate={
|
||||
preferences.quitState?.weed?.startDate ||
|
||||
(preferences.substance === 'weed' ? preferences.trackingStartDate : null) ||
|
||||
usageData.filter(e => e.substance === 'weed').sort((a, b) => a.date.localeCompare(b.date))[0]?.date ||
|
||||
null
|
||||
}
|
||||
onGeneratePlan={() => onGeneratePlan('weed')}
|
||||
/>
|
||||
)}
|
||||
<SubstancePlanSection
|
||||
substance="weed"
|
||||
plan={preferences.quitState?.weed?.plan || (preferences.substance === 'weed' ? preferences.quitPlan : null)}
|
||||
usageData={usageData}
|
||||
trackingStartDate={
|
||||
preferences.quitState?.weed?.startDate ||
|
||||
(preferences.substance === 'weed' ? preferences.trackingStartDate : null) ||
|
||||
usageData.filter(e => e.substance === 'weed').sort((a, b) => a.date.localeCompare(b.date))[0]?.date ||
|
||||
null
|
||||
}
|
||||
onGeneratePlan={() => onGeneratePlan('weed')}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@ -16,7 +16,6 @@ import { UsageEntry, UserPreferences, setUsageForDateAsync, clearDayDataAsync }
|
||||
import { ChevronLeftIcon, ChevronRightIcon, Cigarette, Leaf, Sparkles } from 'lucide-react';
|
||||
import { useTheme } from '@/lib/theme-context';
|
||||
import { getLocalDateString, getTodayString } from '@/lib/date-utils';
|
||||
import { DailyInspirationCard } from './DailyInspirationCard';
|
||||
import { cn } from '@/lib/utils';
|
||||
import React from 'react';
|
||||
|
||||
@ -25,13 +24,11 @@ interface UsageCalendarProps {
|
||||
usageData: UsageEntry[];
|
||||
onDataUpdate: () => void;
|
||||
userId: string;
|
||||
religion?: 'christian' | 'secular' | null;
|
||||
onReligionUpdate?: (religion: 'christian' | 'secular') => void;
|
||||
preferences?: UserPreferences | null;
|
||||
onPreferencesUpdate?: (prefs: UserPreferences) => Promise<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 [editNicotineCount, setEditNicotineCount] = useState('');
|
||||
const [editWeedCount, setEditWeedCount] = useState('');
|
||||
@ -258,9 +255,9 @@ function UsageCalendarComponent({ usageData, onDataUpdate, religion, onReligionU
|
||||
</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">
|
||||
{/* Calendar - Give it proper width on desktop */}
|
||||
<div className="w-full lg:w-1/2 flex flex-col items-center">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Calendar */}
|
||||
<div className="w-full flex flex-col items-center">
|
||||
<div className={cn(
|
||||
"rounded-2xl p-2 sm:p-4 border shadow-inner transition-all duration-500 w-full",
|
||||
theme === 'light' ? "bg-slate-50/50 border-slate-200/60" : "bg-black/20 border-white/5"
|
||||
@ -302,17 +299,6 @@ function UsageCalendarComponent({ usageData, onDataUpdate, religion, onReligionU
|
||||
/>
|
||||
</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>
|
||||
|
||||
</CardContent>
|
||||
|
||||
@ -25,13 +25,19 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { User } from '@/lib/session';
|
||||
import { fetchPreferences, fetchReminderSettings, saveReminderSettings, ReminderSettings, UserPreferences } from '@/lib/storage';
|
||||
import {
|
||||
fetchPreferences,
|
||||
fetchReminderSettings,
|
||||
saveReminderSettings,
|
||||
ReminderSettings,
|
||||
UserPreferences,
|
||||
defaultReminderSettings,
|
||||
} from '@/lib/storage';
|
||||
import { useNotifications } from '@/hooks/useNotifications';
|
||||
import { useRef, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Cigarette, Leaf, LogOut, Home, ChevronDown, Sun, Moon, Bell, BellOff, BellRing, Menu, Sparkles, Link as LinkIcon } from 'lucide-react';
|
||||
import { Bell, BellOff, BellRing, Menu, Sparkles } from 'lucide-react';
|
||||
import { useTheme } from '@/lib/theme-context';
|
||||
import { InstallAppButton } from './InstallAppButton';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SideMenu } from './SideMenu';
|
||||
|
||||
@ -131,7 +137,7 @@ export function UserHeader({ user, preferences, onModalStateChange }: UserHeader
|
||||
const [localTime, setLocalTime] = useState('09:00');
|
||||
const [localFrequency, setLocalFrequency] = useState<'daily' | 'hourly'>('daily');
|
||||
const router = useRouter();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { theme } = useTheme();
|
||||
const { isSupported, permission, requestPermission } = useNotifications(reminderSettings);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
||||
@ -222,26 +228,33 @@ export function UserHeader({ user, preferences, onModalStateChange }: UserHeader
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
const [prefs, reminders] = await Promise.all([
|
||||
preferences ? Promise.resolve(preferences) : fetchPreferences(),
|
||||
fetchReminderSettings(),
|
||||
]);
|
||||
try {
|
||||
const [prefs, reminders] = await Promise.all([
|
||||
preferences ? Promise.resolve(preferences) : fetchPreferences(),
|
||||
fetchReminderSettings(),
|
||||
]);
|
||||
|
||||
if (prefs) {
|
||||
setUserName(prefs.userName);
|
||||
if (prefs) {
|
||||
setUserName(prefs.userName);
|
||||
}
|
||||
|
||||
const detectedTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
let settingsToUse = reminders;
|
||||
|
||||
if (reminders.timezone !== detectedTimezone) {
|
||||
settingsToUse = { ...reminders, timezone: detectedTimezone };
|
||||
await saveReminderSettings(settingsToUse);
|
||||
}
|
||||
|
||||
setReminderSettings(settingsToUse);
|
||||
setLocalTime(settingsToUse.reminderTime);
|
||||
setLocalFrequency(settingsToUse.frequency || 'daily');
|
||||
} catch (error) {
|
||||
console.error('Failed to load header data:', error);
|
||||
setReminderSettings(defaultReminderSettings);
|
||||
setLocalTime(defaultReminderSettings.reminderTime);
|
||||
setLocalFrequency(defaultReminderSettings.frequency);
|
||||
}
|
||||
|
||||
const detectedTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
let settingsToUse = reminders;
|
||||
|
||||
if (reminders.timezone !== detectedTimezone) {
|
||||
settingsToUse = { ...reminders, timezone: detectedTimezone };
|
||||
await saveReminderSettings(settingsToUse);
|
||||
}
|
||||
|
||||
setReminderSettings(settingsToUse);
|
||||
setLocalTime(settingsToUse.reminderTime);
|
||||
setLocalFrequency(settingsToUse.frequency || 'daily');
|
||||
};
|
||||
loadData();
|
||||
}, [preferences]);
|
||||
@ -379,27 +392,9 @@ export function UserHeader({ user, preferences, onModalStateChange }: UserHeader
|
||||
|
||||
</div>
|
||||
|
||||
<div className="container mx-auto px-4 h-16 sm:h-20 flex items-center justify-between relative z-50">
|
||||
{/* LEFT: User Profile / Side Menu Trigger */}
|
||||
<div className="flex-1 flex justify-start">
|
||||
<button
|
||||
onClick={() => setIsSideMenuOpen(true)}
|
||||
className="group relative flex items-center gap-2 p-1.5 pr-3 rounded-full transition-all hover:bg-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>
|
||||
<div className="container mx-auto px-4 h-16 sm:h-20 relative z-50 grid grid-cols-[1fr_auto_1fr] items-center">
|
||||
{/* LEFT: Spacer for balanced centered title */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* CENTER: Title and Welcome Message */}
|
||||
<div className="flex-[2] flex flex-col items-center justify-center text-center">
|
||||
@ -422,33 +417,20 @@ export function UserHeader({ user, preferences, onModalStateChange }: UserHeader
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* RIGHT: Action Buttons */}
|
||||
<div className="flex-1 flex items-center justify-end gap-1.5 sm:gap-3">
|
||||
{/* RIGHT: User Profile / Side Menu Trigger */}
|
||||
<div className="flex items-center justify-end justify-self-end">
|
||||
<button
|
||||
onClick={() => setShowReminderDialog(true)}
|
||||
className={cn(
|
||||
"p-2 sm:p-2.5 rounded-full transition-all duration-300 active:scale-90 shadow-sm",
|
||||
reminderSettings.enabled
|
||||
? 'bg-indigo-500/15 text-indigo-400 border border-indigo-500/20'
|
||||
: 'bg-white/5 border border-transparent text-white/50 hover:bg-white/10'
|
||||
)}
|
||||
onClick={() => setIsSideMenuOpen(true)}
|
||||
className="group relative flex h-10 w-10 sm:h-11 sm:w-11 items-center justify-center rounded-full transition-all hover:bg-white/10 active:scale-95 border border-white/15 hover:border-white/30"
|
||||
aria-label="Open profile menu"
|
||||
>
|
||||
{reminderSettings.enabled ? (
|
||||
<BellRing className="h-4.5 w-4.5 sm:h-5 sm:w-5" />
|
||||
) : (
|
||||
<Bell className="h-4.5 w-4.5 sm:h-5 sm:w-5" />
|
||||
)}
|
||||
</button>
|
||||
<InstallAppButton />
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-2 sm:p-2.5 rounded-full bg-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" />
|
||||
)}
|
||||
<Avatar className="h-8 w-8 sm:h-9 sm:w-9 ring-2 ring-white/20 transition-all group-hover:ring-white/50 shadow-sm">
|
||||
<AvatarImage src={user.profilePictureUrl ?? undefined} alt={userName || 'User'} />
|
||||
<AvatarFallback className="bg-gradient-to-br from-indigo-500 to-purple-500 text-white font-bold text-xs sm:text-sm">{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="absolute -bottom-1 -right-1 rounded-full p-0.5 shadow-sm border border-white/10 bg-slate-900/90">
|
||||
<Menu className="h-3 w-3 text-white" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -460,6 +442,7 @@ export function UserHeader({ user, preferences, onModalStateChange }: UserHeader
|
||||
onClose={() => setIsSideMenuOpen(false)}
|
||||
user={user}
|
||||
userName={userName}
|
||||
onOpenNotifications={() => setShowReminderDialog(true)}
|
||||
/>
|
||||
|
||||
{/* Reminder Settings Dialog */}
|
||||
@ -606,9 +589,7 @@ export function UserHeader({ user, preferences, onModalStateChange }: UserHeader
|
||||
<HourlyTimePicker
|
||||
value={reminderSettings.hourlyStart || '09:00'}
|
||||
onChange={async (newTime) => {
|
||||
const [h, m] = newTime.split(':');
|
||||
const end = (reminderSettings.hourlyEnd || '21:00').split(':');
|
||||
const newSettings = { ...reminderSettings, hourlyStart: newTime, hourlyEnd: `${end[0]}:${m}` };
|
||||
const newSettings = { ...reminderSettings, hourlyStart: newTime };
|
||||
setReminderSettings(newSettings);
|
||||
await saveReminderSettings(newSettings);
|
||||
}}
|
||||
@ -620,9 +601,7 @@ export function UserHeader({ user, preferences, onModalStateChange }: UserHeader
|
||||
<HourlyTimePicker
|
||||
value={reminderSettings.hourlyEnd || '21:00'}
|
||||
onChange={async (newTime) => {
|
||||
const [h, m] = newTime.split(':');
|
||||
const start = (reminderSettings.hourlyStart || '09:00').split(':');
|
||||
const newSettings = { ...reminderSettings, hourlyEnd: newTime, hourlyStart: `${start[0]}:${m}` };
|
||||
const newSettings = { ...reminderSettings, hourlyEnd: newTime };
|
||||
setReminderSettings(newSettings);
|
||||
await saveReminderSettings(newSettings);
|
||||
}}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef, type ReactNode } from 'react';
|
||||
import { useState, useEffect, useRef, type KeyboardEvent, type ReactNode } from 'react';
|
||||
import { Cigarette, Leaf, Heart, Trophy, DollarSign, TrendingDown, CheckCircle, type LucideIcon } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@ -169,6 +169,8 @@ const DEMO_SCREENS: DemoScreen[] = [
|
||||
|
||||
// Phone mockup component
|
||||
function PhoneMockup({ activeScreen }: { activeScreen: number }) {
|
||||
const active = DEMO_SCREENS[activeScreen];
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Phone frame glow */}
|
||||
@ -183,25 +185,19 @@ function PhoneMockup({ activeScreen }: { activeScreen: number }) {
|
||||
<div className="absolute inset-0 p-4 pt-10">
|
||||
{/* Screen header */}
|
||||
<div className="text-center mb-4">
|
||||
<h3 className="text-lg font-bold">{DEMO_SCREENS[activeScreen].title}</h3>
|
||||
<h3 className="text-lg font-bold">{active.title}</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{DEMO_SCREENS[activeScreen].subtitle}
|
||||
{active.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Crossfade content */}
|
||||
<div className="relative h-[calc(100%-5rem)]">
|
||||
{DEMO_SCREENS.map((screen, index) => (
|
||||
<div
|
||||
key={screen.id}
|
||||
className={cn(
|
||||
"absolute inset-0 transition-opacity duration-500 motion-reduce:transition-none",
|
||||
activeScreen === index ? "opacity-100 z-10" : "opacity-0 z-0"
|
||||
)}
|
||||
>
|
||||
{screen.content}
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
id="demo-active-panel"
|
||||
role="tabpanel"
|
||||
aria-labelledby={`demo-tab-${active.id}`}
|
||||
className="h-[calc(100%-5rem)] transition-opacity duration-300 motion-reduce:transition-none"
|
||||
>
|
||||
{active.content}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -222,7 +218,7 @@ function FeatureCard({ screen, isActive }: { screen: DemoScreen; isActive: boole
|
||||
"p-6 sm:p-8 rounded-2xl transition-all duration-500 motion-reduce:transition-none",
|
||||
isActive
|
||||
? "bg-primary/10 border border-primary/30 scale-100 opacity-100"
|
||||
: "bg-white/5 border border-white/10 scale-[0.98] opacity-60"
|
||||
: "bg-card/60 border border-border scale-[0.98] opacity-70"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
@ -247,19 +243,48 @@ export function DemoSection() {
|
||||
const [activeScreen, setActiveScreen] = useState(0);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const sectionRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const resumeTimerRef = useRef<number | null>(null);
|
||||
|
||||
// Timer-based rotation for mobile
|
||||
const handleManualSelect = (index: number) => {
|
||||
setActiveScreen(index);
|
||||
setIsPaused(true);
|
||||
|
||||
if (resumeTimerRef.current) {
|
||||
window.clearTimeout(resumeTimerRef.current);
|
||||
}
|
||||
|
||||
resumeTimerRef.current = window.setTimeout(() => {
|
||||
setIsPaused(false);
|
||||
}, 8000);
|
||||
};
|
||||
|
||||
// Timer-based rotation for mobile only
|
||||
useEffect(() => {
|
||||
if (isPaused) return;
|
||||
|
||||
// Only run timer on mobile (will be hidden on lg+)
|
||||
const mobileMedia = window.matchMedia('(max-width: 1023px)');
|
||||
const reducedMotionMedia = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
|
||||
if (!mobileMedia.matches || reducedMotionMedia.matches) {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (document.hidden) return;
|
||||
setActiveScreen((prev) => (prev + 1) % DEMO_SCREENS.length);
|
||||
}, 4000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isPaused]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (resumeTimerRef.current) {
|
||||
window.clearTimeout(resumeTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Intersection Observer for desktop scroll spy
|
||||
useEffect(() => {
|
||||
const observers: IntersectionObserver[] = [];
|
||||
@ -271,6 +296,7 @@ export function DemoSection() {
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
if (!window.matchMedia('(min-width: 1024px)').matches) return;
|
||||
setActiveScreen(index);
|
||||
}
|
||||
});
|
||||
@ -317,6 +343,7 @@ export function DemoSection() {
|
||||
<p className="text-muted-foreground max-w-2xl mx-auto">
|
||||
A quick look at how QuitTraq helps you track progress and stay motivated.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/80 mt-3">Preview uses sample data for demonstration.</p>
|
||||
</div>
|
||||
|
||||
{/* Desktop: Split Sticky Scroll Layout */}
|
||||
@ -333,9 +360,9 @@ export function DemoSection() {
|
||||
key={screen.id}
|
||||
ref={(el) => { sectionRefs.current[index] = el; }}
|
||||
className={cn(
|
||||
"min-h-[70vh] flex items-center py-8",
|
||||
"min-h-[52vh] flex items-center py-6",
|
||||
index === 0 && "pt-0",
|
||||
index === DEMO_SCREENS.length - 1 && "min-h-[50vh]"
|
||||
index === DEMO_SCREENS.length - 1 && "min-h-[40vh]"
|
||||
)}
|
||||
>
|
||||
<FeatureCard screen={screen} isActive={activeScreen === index} />
|
||||
@ -362,15 +389,29 @@ export function DemoSection() {
|
||||
<div className="w-full max-w-md text-center">
|
||||
<div className="space-y-4">
|
||||
{/* Screen descriptions as buttons */}
|
||||
{DEMO_SCREENS.map((screen, index) => (
|
||||
<div role="tablist" aria-label="QuitTraq demo screens" className="space-y-4">
|
||||
{DEMO_SCREENS.map((screen, index) => (
|
||||
<button
|
||||
key={screen.id}
|
||||
onClick={() => setActiveScreen(index)}
|
||||
id={`demo-tab-${screen.id}`}
|
||||
role="tab"
|
||||
type="button"
|
||||
aria-selected={activeScreen === index}
|
||||
aria-controls="demo-active-panel"
|
||||
tabIndex={activeScreen === index ? 0 : -1}
|
||||
onClick={() => handleManualSelect(index)}
|
||||
onKeyDown={(event: KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
|
||||
event.preventDefault();
|
||||
const direction = event.key === 'ArrowDown' ? 1 : -1;
|
||||
const nextIndex = (index + direction + DEMO_SCREENS.length) % DEMO_SCREENS.length;
|
||||
handleManualSelect(nextIndex);
|
||||
}}
|
||||
className={cn(
|
||||
"w-full text-left p-4 rounded-xl transition-all duration-300",
|
||||
activeScreen === index
|
||||
? "bg-primary/10 border border-primary/30"
|
||||
: "hover:bg-white/5 border border-transparent"
|
||||
: "hover:bg-card/60 border border-transparent"
|
||||
)}
|
||||
>
|
||||
<h4
|
||||
@ -383,7 +424,8 @@ export function DemoSection() {
|
||||
</h4>
|
||||
<p className="text-sm text-muted-foreground">{screen.subtitle}</p>
|
||||
</button>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dot indicators */}
|
||||
@ -391,12 +433,13 @@ export function DemoSection() {
|
||||
{DEMO_SCREENS.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setActiveScreen(index)}
|
||||
type="button"
|
||||
onClick={() => handleManualSelect(index)}
|
||||
className={cn(
|
||||
"w-2 h-2 rounded-full transition-all",
|
||||
activeScreen === index
|
||||
? "w-6 bg-primary"
|
||||
: "bg-white/20 hover:bg-white/40"
|
||||
: "bg-muted-foreground/30 hover:bg-muted-foreground/60"
|
||||
)}
|
||||
aria-label={`Go to screen ${index + 1}`}
|
||||
/>
|
||||
|
||||
@ -52,9 +52,6 @@ export function FeatureCard({ title, description, icons, gradient, delay = 0 }:
|
||||
background: `linear-gradient(135deg, ${gradient})`,
|
||||
}}
|
||||
>
|
||||
{/* Decorative gradient orb */}
|
||||
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-br from-white/10 to-transparent rounded-full -translate-y-1/2 translate-x-1/2 pointer-events-none group-hover:scale-110 transition-transform duration-500" />
|
||||
|
||||
<CardHeader className="relative z-10">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
{icons.map((icon, index) => (
|
||||
|
||||
@ -221,6 +221,17 @@ export async function getAchievementD1(userId: string, badgeId: string, substanc
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getAchievementByBadgeD1(userId: string, badgeId: string): Promise<AchievementRow | null> {
|
||||
const db = getD1();
|
||||
if (!db) return null;
|
||||
|
||||
const result = await db.prepare(
|
||||
'SELECT * FROM Achievement WHERE userId = ? AND badgeId = ? ORDER BY CASE WHEN substance = ? THEN 0 ELSE 1 END, unlockedAt DESC LIMIT 1'
|
||||
).bind(userId, badgeId, 'both').first<AchievementRow>();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function createAchievementD1(userId: string, badgeId: string, substance: string): Promise<AchievementRow | null> {
|
||||
const db = getD1();
|
||||
if (!db) return null;
|
||||
@ -232,7 +243,7 @@ export async function createAchievementD1(userId: string, badgeId: string, subst
|
||||
const now = new Date().toISOString();
|
||||
|
||||
await db.prepare(
|
||||
`INSERT INTO Achievement (id, userId, badgeId, unlockedAt, substance)
|
||||
`INSERT OR IGNORE INTO Achievement (id, userId, badgeId, unlockedAt, substance)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
).bind(id, userId, badgeId, now, substance).run();
|
||||
|
||||
|
||||
@ -128,6 +128,22 @@ const defaultPreferences: UserPreferences = {
|
||||
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
|
||||
let preferencesCache: UserPreferences | null = null;
|
||||
let usageDataCache: UsageEntry[] | null = null;
|
||||
@ -160,15 +176,17 @@ export async function fetchPreferences(): Promise<UserPreferences> {
|
||||
try {
|
||||
const response = await fetch('/api/preferences', { cache: 'no-store' });
|
||||
if (!response.ok) {
|
||||
console.error('Failed to fetch preferences');
|
||||
return defaultPreferences;
|
||||
throw new StorageRequestError('Unable to load profile preferences.', response.status);
|
||||
}
|
||||
const data = await response.json() as UserPreferences;
|
||||
preferencesCache = data;
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching preferences:', error);
|
||||
return defaultPreferences;
|
||||
if (error instanceof StorageRequestError) {
|
||||
throw error;
|
||||
}
|
||||
throw new StorageRequestError('Unable to load profile preferences.');
|
||||
}
|
||||
}
|
||||
|
||||
@ -195,15 +213,17 @@ export async function fetchUsageData(): Promise<UsageEntry[]> {
|
||||
try {
|
||||
const response = await fetch('/api/usage', { cache: 'no-store' });
|
||||
if (!response.ok) {
|
||||
console.error('Failed to fetch usage data');
|
||||
return [];
|
||||
throw new StorageRequestError('Unable to load usage history.', response.status);
|
||||
}
|
||||
const data = await response.json() as UsageEntry[];
|
||||
usageDataCache = data;
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching usage data:', error);
|
||||
return [];
|
||||
if (error instanceof StorageRequestError) {
|
||||
throw error;
|
||||
}
|
||||
throw new StorageRequestError('Unable to load usage history.');
|
||||
}
|
||||
}
|
||||
|
||||
@ -306,13 +326,18 @@ export async function fetchReminderSettings(): Promise<ReminderSettings> {
|
||||
if (reminderSettingsCache) return reminderSettingsCache;
|
||||
try {
|
||||
const response = await fetch('/api/reminders');
|
||||
if (!response.ok) return { enabled: false, reminderTime: '09:00', frequency: 'daily' };
|
||||
if (!response.ok) {
|
||||
throw new StorageRequestError('Unable to load reminder settings.', response.status);
|
||||
}
|
||||
const data = await response.json() as ReminderSettings;
|
||||
reminderSettingsCache = data;
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching reminder settings:', error);
|
||||
return { enabled: false, reminderTime: '09:00', frequency: 'daily' };
|
||||
if (error instanceof StorageRequestError) {
|
||||
throw error;
|
||||
}
|
||||
throw new StorageRequestError('Unable to load reminder settings.');
|
||||
}
|
||||
}
|
||||
|
||||
@ -332,7 +357,7 @@ export async function saveReminderSettings(settings: ReminderSettings): Promise<
|
||||
}
|
||||
|
||||
export function getReminderSettings(): ReminderSettings {
|
||||
return reminderSettingsCache || { enabled: false, reminderTime: '09:00', frequency: 'daily' };
|
||||
return reminderSettingsCache || defaultReminderSettings;
|
||||
}
|
||||
|
||||
// ============ SAVINGS FUNCTIONS ============
|
||||
@ -489,6 +514,10 @@ export function checkBadgeEligibility(
|
||||
preferences: UserPreferences,
|
||||
substance: 'nicotine' | 'weed'
|
||||
): boolean {
|
||||
const hasLoggedUsageForSubstance = usageData.some(
|
||||
(entry) => entry.substance === substance && entry.count > 0
|
||||
);
|
||||
|
||||
// Pre-calculate common stats once O(n)
|
||||
const stats = (() => {
|
||||
const nicotineMap = new Map<string, number>();
|
||||
@ -576,7 +605,7 @@ export function checkBadgeEligibility(
|
||||
};
|
||||
|
||||
switch (badgeId) {
|
||||
case 'first_day': return stats.totalDays >= 1;
|
||||
case 'first_day': return hasLoggedUsageForSubstance;
|
||||
case 'streak_3': return streak >= 3;
|
||||
case 'streak_7': return stats.totalDays >= 7;
|
||||
case 'fighter':
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user