Compare commits
161 commits
feat/g1-g4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| bf82ef3364 | |||
| 4d29ef89a0 | |||
| 756f5d6cfb | |||
| fbcfff47bd | |||
| 9dcd0fc854 | |||
| a69546977c | |||
| e989e6c558 | |||
| 0c88058a79 | |||
| b5f1e5540b | |||
| 082956adea | |||
| 1577303582 | |||
| 80390e7f13 | |||
| 3604f7314d | |||
| 99b5543eb9 | |||
| 2bd45bd4fd | |||
| 6a1aaa38a2 | |||
| 714909d7ee | |||
| 87e795c837 | |||
| 0f3e87b67a | |||
| 38bb5af01c | |||
| 5083961c6b | |||
| 14db731ed4 | |||
| 2608c7a146 | |||
| af0dad6922 | |||
| c523533531 | |||
| 2a450c7644 | |||
| 69caff5226 | |||
| a3d89ee37c | |||
| 8f141883bb | |||
| 1cbdd68756 | |||
| f5d21eea28 | |||
| 52a60a7cff | |||
| c5cb9570b2 | |||
| 68a911c6db | |||
| 6df914ddf9 | |||
| c1e02249d6 | |||
| ab937c4e9d | |||
| a89ab96a12 | |||
| 27b07a5cfc | |||
| 4e90064989 | |||
| 67f7c4836d | |||
| 7332bd1e8b | |||
| 05975b51a1 | |||
| 91c25b2c15 | |||
| 470df7fb9f | |||
| deaa1810d7 | |||
| cbbe8f24ac | |||
| 7a60132bb2 | |||
| 94d9b234f8 | |||
| e53c51f044 | |||
| 45f9d6261b | |||
| 2b534d4c43 | |||
| dad0611350 | |||
| 39b2787484 | |||
| e309c91309 | |||
| 261a9cbbcb | |||
| daf6b34281 | |||
| e5a59c5191 | |||
| 8be6bbe23f | |||
| 678cf65d70 | |||
| ee4bcc4498 | |||
| e7a332cacd | |||
| 093903162e | |||
| 3cfcbdc0ca | |||
| e99a874309 | |||
| cfb0f4b2eb | |||
| d8c9500949 | |||
| 27709dc851 | |||
| f953963b3b | |||
| a3a0ddf3c9 | |||
| 51e36633b9 | |||
| ccae6d85d2 | |||
| 9c2e7328ab | |||
| 52ec89f5a4 | |||
| ef30f27e9c | |||
| 309fd5aa29 | |||
| 774e8f29d4 | |||
| bdb5199d5f | |||
| 0a9def36bf | |||
| 90c8d13814 | |||
| 260187b0de | |||
| b64226cbd8 | |||
| 0c7f37fd12 | |||
| b6814579c6 | |||
| 942a03d99a | |||
| b0423dfea8 | |||
| 2b8312efef | |||
| 2a09c027fa | |||
| f1d4374609 | |||
| 31b4a9480a | |||
| 8a3da6a5a6 | |||
| e2a3e83638 | |||
| 4dcdc5a572 | |||
| 781dd8f1df | |||
| 6d7feca397 | |||
| b01e0596c1 | |||
| 0b67631fda | |||
| cff17a079d | |||
| 7816247073 | |||
| 5235e26cad | |||
| afae041208 | |||
| fa5e27bfd9 | |||
| f37e5bfad4 | |||
| 1ae0986767 | |||
| 2c016dbc8e | |||
| b91595316d | |||
| 6bdaade777 | |||
| 23a365309b | |||
| c2695435f2 | |||
| 23010e9d90 | |||
| bcc167d7c2 | |||
| ab25b7531d | |||
| 560761968b | |||
| 581fdb074d | |||
| 27125047bb | |||
| 66a765e75f | |||
| c24392f0a1 | |||
| 9f7ab870ba | |||
| 21f88459d7 | |||
| 3d7ff9adb5 | |||
| b7fb34fdde | |||
| 12be105af4 | |||
| 7fe60dc1af | |||
| acb9d66815 | |||
| d8bda20887 | |||
| ee3f8b4507 | |||
| 5fdb69679d | |||
| c732d2d7c2 | |||
| 5297ab76ba | |||
| 3db6fb2710 | |||
| a14d8e7043 | |||
| 164206c023 | |||
| 94b80fd862 | |||
| f806e4d3bd | |||
| fb402e9898 | |||
| 8598259fb1 | |||
| 3b62841bd4 | |||
| 3d0e6ed46c | |||
| 70ff02c930 | |||
| 8a75adb94e | |||
| 1994725101 | |||
| c4304615ec | |||
| 9b9b551463 | |||
| edd239fa69 | |||
| a3d3a140ed | |||
| e7d68c2fc6 | |||
| 5fe24b8c59 | |||
| acd2adde7e | |||
| 9c0afa2054 | |||
| fb45250a27 | |||
| 704faf070b | |||
| 0e047e110f | |||
| 96d28cbadc | |||
| 3ebb3055a5 | |||
| 93db148a65 | |||
| 5863c8c2e6 | |||
| 515e93aae1 | |||
| a75a543373 | |||
| 7189fc766c | |||
| 291eb4793b | |||
| 5ca655fbe6 |
|
|
@ -6,5 +6,11 @@ README.md
|
|||
.docker
|
||||
docker-compose.dev.yml
|
||||
docker/
|
||||
drizzle/
|
||||
*.log
|
||||
*.log
|
||||
dist
|
||||
|
||||
# NOTE: drizzle/ is intentionally NOT ignored — the migration SQL must be
|
||||
# in the build context so the Dockerfile can COPY it into the runner image.
|
||||
# Only the non-shipping sub-folders are excluded:
|
||||
drizzle/_archived_pre_baseline_2026-05-19
|
||||
drizzle/_introspected
|
||||
|
|
|
|||
11
.gitignore
vendored
|
|
@ -7,6 +7,9 @@
|
|||
/.next/
|
||||
/out/
|
||||
|
||||
# migrator build output (generated by scripts/build-migrator.mjs)
|
||||
/dist/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
|
|
@ -31,4 +34,10 @@ yarn-error.log*
|
|||
# docker
|
||||
.docker/
|
||||
data/
|
||||
drizzle/
|
||||
|
||||
# NOTE: drizzle/ is intentionally NOT ignored.
|
||||
# It holds migration SQL + meta snapshots — these are SOURCE CODE and
|
||||
# MUST be committed so the deploy pipeline can apply them on the server.
|
||||
# Only transient introspection scratch output is ignored:
|
||||
drizzle/_introspected/
|
||||
drizzle/_archived_pre_baseline_2026-05-19/
|
||||
494
CLAUDE.md
|
|
@ -12,10 +12,13 @@ pnpm start # Start production server
|
|||
# Note: If Turbopack fails, use: pnpm build -- --webpack
|
||||
|
||||
# Database (direct SQL or docker)
|
||||
docker-compose -f docker-compose.dev.yml exec db psql -U postgres -d tia
|
||||
docker-compose -f docker-compose.dev.yml exec db psql -U postgres -d tia_dev
|
||||
```
|
||||
|
||||
**Note:** Running from `/Users/manohar_air/MyProjects/Tia/tia` directory.
|
||||
**Rule:** ALWAYS run `pnpm build` locally BEFORE git commit/push.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
|
|
@ -23,10 +26,12 @@ docker-compose -f docker-compose.dev.yml exec db psql -U postgres -d tia
|
|||
|
||||
- **Framework:** Next.js 16 with App Router (src/app/)
|
||||
- **Database:** PostgreSQL 16 with pgvector + Drizzle ORM
|
||||
- **Auth:** Database sessions with httpOnly cookies
|
||||
- **Auth:** Database sessions with httpOnly cookies (`tia_session`)
|
||||
- **AI:** LiteLLM gateway → MiniMax model (minimax-2.7)
|
||||
- **Storage:** Cloudflare R2 for media uploads
|
||||
- **Email:** Resend (transactional emails)
|
||||
- **Styling:** Tailwind CSS v4
|
||||
- **Deploy:** Dokploy (Docker-based, auto-runs migrations on deploy)
|
||||
|
||||
### Project Structure
|
||||
|
||||
|
|
@ -34,289 +39,302 @@ docker-compose -f docker-compose.dev.yml exec db psql -U postgres -d tia
|
|||
src/
|
||||
├── app/ # Next.js App Router pages
|
||||
│ ├── api/ # API routes (auth, logs, ai, growth, etc.)
|
||||
│ ├── page.tsx # Home (Quick Log + AI card)
|
||||
│ ├── page.tsx # Home (Quick Log + AI card + baby card)
|
||||
│ ├── ai/ # AI chat page with sidebar
|
||||
│ ├── medical/ # Vaccination tracking
|
||||
│ ├── growth/ # Growth charts
|
||||
│ ├── memories/ # Photo gallery
|
||||
│ ├── menu/ # Navigation menu
|
||||
│ ├── onboarding/ # First-time setup
|
||||
│ ├── settings/ # Settings with theme picker
|
||||
│ ├── settings/ # Settings with theme picker, invite members
|
||||
│ ├── profile/ # Parent user profile (avatar, name, email)
|
||||
│ ├── login/ # User login (email + password)
|
||||
│ ├── admin/ # Admin panel
|
||||
│ └── admin-login/ # Admin login (separate)
|
||||
│ ├── admin/ # Admin panel (server component layout)
|
||||
│ └── admin-login/ # Admin login (separate, NOT under admin layout)
|
||||
├── ThemeProvider.tsx # Theme context (light/dark/system/time)
|
||||
├── FamilyProvider.tsx # Family/child context (resolves from session)
|
||||
drizzle/ # Database migrations
|
||||
docs/ # Design docs
|
||||
├── lib/
|
||||
│ ├── auth.ts # requireFamily(), requireOwnership()
|
||||
│ ├── email.ts # sendVerificationEmail(), sendFamilyInviteEmail()
|
||||
│ └── admin-auth.ts # requireAdmin() for API, verifyAdminSession() for server components
|
||||
drizzle/ # Database migrations (SQL files)
|
||||
drizzle/meta/_journal.json # Migration order — MUST update when adding new SQL files
|
||||
```
|
||||
|
||||
### Database
|
||||
---
|
||||
|
||||
- **Migrations:** SQL files in `drizzle/` (not using drizzle-kit push)
|
||||
- **Apply:** `psql` directly or via docker-compose exec
|
||||
- **RLS:** Row-level security for multi-family isolation
|
||||
## Database Migrations
|
||||
|
||||
### Data Models
|
||||
### How it works (Dokploy auto-deploy)
|
||||
- `Dockerfile CMD`: `node migrate.mjs && node server.js` — migrations run first, app boots after
|
||||
- `src/db/migrate.ts`: reads every SQL file in `drizzle/` and applies any not yet in `__drizzle_migrations`
|
||||
- `drizzle/meta/_journal.json`: ordered list of migration files — **must be updated** when adding a new SQL file
|
||||
|
||||
- **Family:** Parent account container
|
||||
- **Members:** Adults in family (mom, dad, etc.) via `family_members`
|
||||
- **Children:** Baby profiles with birth date
|
||||
- **Sessions:** Login sessions with httpOnly cookies
|
||||
- **Logs:** Feed, sleep, diaper entries with timestamps
|
||||
- **Vaccinations:** IAP schedule tracking
|
||||
- **Growth:** Weight/height/head measurements over time
|
||||
- **Memories:** Photos with R2 storage
|
||||
- **Chat Sessions:** User conversations with AI (chat_sessions, chat_messages)
|
||||
### Adding a new migration
|
||||
1. Create `drizzle/NNNN_description.sql` with your SQL (use `IF NOT EXISTS` / `ADD COLUMN IF NOT EXISTS`)
|
||||
2. Add an entry to `drizzle/meta/_journal.json` with the next `idx` and matching `tag`
|
||||
3. Push to git → Dokploy auto-applies on next deploy
|
||||
|
||||
### Growth Page Features
|
||||
> ⚠️ **CRITICAL — the `when` timestamp must be greater than every existing entry.**
|
||||
> Drizzle's migrator only applies a migration when its `when` (folderMillis) is **greater than the
|
||||
> max `created_at` already in `drizzle.__drizzle_migrations`**. It does NOT diff by hash/idx.
|
||||
> Migrations `0003`–`0010` were hand-added with 2025-era `when` values (1748…/1749…) that are
|
||||
> SMALLER than the 2026 baseline (0000–0002 ≈ 1779539431897), so drizzle **silently skipped all of
|
||||
> them** — they only ever got applied via the debug-migration hot-apply endpoint.
|
||||
> For any new migration: set `when` to a **current `Date.now()`** (must be > 1779539431897). If you
|
||||
> ever need drizzle to re-apply a skipped one, its SQL must be idempotent (`IF NOT EXISTS`); note
|
||||
> `0003_circles.sql` is NOT idempotent, so do not let drizzle re-run it.
|
||||
|
||||
The growth page tracks child's physical development with WHO standards.
|
||||
### Hot-fix: apply migration without waiting for redeploy
|
||||
Use the **debug-migration endpoint** (same pattern as the Circles implementation):
|
||||
|
||||
**Components:**
|
||||
- `formatAge(birthDate, measurementDate?)` - Shows age as "1y 4mo" or "8mo"
|
||||
- Latest Reading card with velocity and percentile
|
||||
- WHO Standards collapsible card (shows icons ⚖️📏⭕ when collapsed)
|
||||
- Growth chart (collapsible inside WHO card)
|
||||
- History (collapsible with scroll for many entries)
|
||||
- Goals stored in localStorage (`tia_growth_goal_${childId}`)
|
||||
|
||||
**State:**
|
||||
- `showAdd` - Toggle add form (only shows if latest exists)
|
||||
- `showGoals` - Toggle goals card
|
||||
- `showWhoStandards` - Toggle WHO card expand/collapse
|
||||
- `showChart` - Toggle chart inside WHO card
|
||||
- `showHistory` - Toggle history expand/collapse
|
||||
|
||||
**Color-coded percentiles:**
|
||||
- 🟢 Normal (15th-85th percentile)
|
||||
- 🟡 Watch (<15th or >85th)
|
||||
- 🔴 Alert (<3rd or >97th)
|
||||
|
||||
**Column order:** Goals → Latest → Add Form → WHO+Chart → History
|
||||
|
||||
### Key Patterns
|
||||
|
||||
**ThemeProvider:** Wrap app in ThemeProvider from layout.tsx. Use `useTheme()` hook in components.
|
||||
|
||||
```typescript
|
||||
import { useTheme } from "./ThemeProvider";
|
||||
const { theme, toggle, setMode } = useTheme();
|
||||
// theme: "light" | "dark"
|
||||
// mode: "light" | "dark" | "system" | "time"
|
||||
```
|
||||
POST /api/debug-migration
|
||||
Header: x-run-migration: yes
|
||||
```
|
||||
|
||||
**FamilyProvider:** Resolves family from database session on login.
|
||||
This runs SQL directly through the app's live DB connection. Steps use `IF NOT EXISTS` so safe to re-run.
|
||||
|
||||
To add a new hot-fix: add `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` to the `steps` array in
|
||||
`src/app/api/debug-migration/route.ts`, push, and immediately call the endpoint from Chrome:
|
||||
|
||||
```javascript
|
||||
// In browser console on tia.manohargupta.com
|
||||
const res = await fetch('/api/debug-migration', {
|
||||
method: 'POST',
|
||||
headers: { 'x-run-migration': 'yes' }
|
||||
});
|
||||
console.log(await res.json());
|
||||
```
|
||||
|
||||
The GET endpoint also shows which migrations have been applied and email config status.
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
### User auth (email + password)
|
||||
1. User visits `/login` with email + password
|
||||
2. `POST /api/auth/signin` verifies bcrypt hash, creates session in `sessions` table
|
||||
3. Session token stored in **httpOnly cookie** `tia_session` (NOT localStorage!)
|
||||
4. All data routes use `requireFamily()` from `@/lib/auth`
|
||||
|
||||
### Admin auth
|
||||
- Login at `/admin-login` (username: `admin`, password: from env)
|
||||
- Sets `tia_admin_session` httpOnly cookie
|
||||
- **Admin layout** (`src/app/admin/layout.tsx`) is a **server component** — calls `verifyAdminSession()` from `@/lib/admin-auth` and `redirect('/admin-login')` if not authed
|
||||
- **Admin sub-pages** (`families`, `users`, `analytics`, etc.) use `credentials: 'include'` on all fetches — NO localStorage tokens, NO Bearer headers
|
||||
- `AdminSidebar.tsx` is a separate `"use client"` component for the interactive sidebar
|
||||
|
||||
### FamilyProvider
|
||||
Resolves family from database session on login. Skip for admin routes.
|
||||
|
||||
```typescript
|
||||
import { useFamily } from "./FamilyProvider";
|
||||
const { familyId, familyName, child, children, tier, memberCount } = useFamily();
|
||||
// familyId: string | null
|
||||
// familyName: string | null (from session)
|
||||
// child: Child | null
|
||||
// children: Child[]
|
||||
// tier: "free" | "pro"
|
||||
// memberCount: number (from family_members table)
|
||||
const { familyId, familyName, child, children, tier, memberCount, updateChildImage } = useFamily();
|
||||
// updateChildImage(childId, imageUrl | null) — updates in-memory state after photo change
|
||||
```
|
||||
|
||||
**Offline Queue:** Uses localStorage (`tia_offline_queue`) for failed API calls, retries when online.
|
||||
---
|
||||
|
||||
**Session Validation:** All data API routes must use functions from `@/lib/auth`:
|
||||
## Photo Uploads (R2)
|
||||
|
||||
```typescript
|
||||
import { validateSession, requireFamily, requireOwnership } from "@/lib/auth";
|
||||
### Critical: CORS — never do a direct cross-origin PUT from the browser
|
||||
Direct `PUT` to R2 presigned URLs is cross-origin and **blocked by the browser**. Always proxy through the server.
|
||||
|
||||
// Require family for user data
|
||||
export async function GET(request: Request) {
|
||||
const auth = await requireFamily();
|
||||
if (!auth.success) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
### 3-step upload pattern (used everywhere)
|
||||
1. `POST /api/[init-endpoint]` with `{ contentType, filename }` → get `{ key, publicUrl }`
|
||||
2. `PUT /api/upload?key=...&contentType=...` — server proxies the file to R2
|
||||
3. `PATCH /api/[save-endpoint]` with `{ [imageField]: publicUrl }` → saves URL to DB + deletes old R2 object
|
||||
|
||||
const familyId = auth.session!.familyId!;
|
||||
// ... route logic
|
||||
}
|
||||
### Photo storage — two completely separate features
|
||||
|
||||
// Require ownership for specific resources
|
||||
const ownership = await requireOwnership(childId, "children", "Child");
|
||||
if (!ownership.success) {
|
||||
return NextResponse.json({ error: ownership.error }, { status: ownership.status });
|
||||
}
|
||||
```
|
||||
| | **Parent profile photo** | **Baby card photo** |
|
||||
|---|---|---|
|
||||
| Page | `/profile` | Homepage (`/`) |
|
||||
| DB column | `users.image` | `children.image_url` |
|
||||
| API | `POST/PATCH/DELETE /api/auth/avatar` | `POST/PATCH /api/children/[id]` |
|
||||
| R2 prefix | `avatars/{userId}/...` | `profiles/{childId}/...` |
|
||||
|
||||
**Chat Sessions:** Stored in localStorage (`tia_chat_sessions`) - shared between home page AI card and /ai page. Database tables: `chat_sessions`, `chat_messages`.
|
||||
### Orphan cleanup pattern
|
||||
Before overwriting an image URL in the DB, fetch the old URL, extract the R2 key, verify it's under a controlled prefix (`avatars/` or `profiles/`), then `DeleteObjectCommand`. Applied in both avatar and children routes.
|
||||
|
||||
**API Routes:** Return standard JSON `{ success: true, items: [...] }` format for lists.
|
||||
### users.image — NOT users.avatar_url
|
||||
The `users` table column is called **`image`** (not `avatar_url`). `avatar_url` belongs to `member_profiles`.
|
||||
Always use `u.image` in SQL queries against the `users` table.
|
||||
|
||||
**AI Integration:**
|
||||
---
|
||||
|
||||
- Route: `/api/ai` → LiteLLM (set via `LITELLM_BASE_URL` env var)
|
||||
- Model: `minimax-2.7`
|
||||
- See `/docs/debugging.md` for troubleshooting
|
||||
|
||||
## Authentication (Email + Password)
|
||||
|
||||
1. User visits `/login` with email + password (or signs up for new account)
|
||||
2. API `/api/auth/signin` verifies password hash and creates session in `sessions` table
|
||||
3. Session token stored in **httpOnly cookie** (NOT localStorage!)
|
||||
4. Password stored with simple hash in `users.password_hash`
|
||||
|
||||
### Tables Used
|
||||
|
||||
- **users:** User accounts (email, name)
|
||||
- **families:** Family accounts (name, tier, limits)
|
||||
- **family_members:** Links users to families (user_id, family_id, role)
|
||||
- **children:** Child profiles (name, birth_date, family_id)
|
||||
- **sessions:** Login sessions (session_token, user_id, expires)
|
||||
|
||||
### NEVER use localStorage for:
|
||||
- authentication tokens
|
||||
- family_id after login
|
||||
- Any data that should persist across devices
|
||||
|
||||
### localStorage Acceptable For:
|
||||
- Theme preference (user-specific display only)
|
||||
- Temporary cache (offline queue for retry)
|
||||
- Chat sessions local cache (synced from database)
|
||||
|
||||
## Admin Panel
|
||||
|
||||
Access at: `/admin-login` (username: `admin`, password: `admin123`)
|
||||
|
||||
### Pages
|
||||
|
||||
- `/admin` - Dashboard with clickable stat cards
|
||||
- `/admin/families` - Manage families (create, view/add/remove members, set tier)
|
||||
- `/admin/users` - Manage users (add to family, password status, delete)
|
||||
- `/admin/children` - Manage children
|
||||
- `/admin/revenue` - Revenue analytics
|
||||
- `/admin/analytics` - Feature usage
|
||||
- `/admin/support` - Support tickets
|
||||
- `/admin/settings` - Platform settings
|
||||
|
||||
## Data Storage Consistency
|
||||
|
||||
### RULE: All user data must persist to database, NOT localStorage
|
||||
|
||||
| Data Type | Storage | API Key | Persists After Refresh | Persists After Logout |
|
||||
|----------|---------|--------|------------------------|-------------------|
|
||||
| Children | Database | `/api/children` | ✅ Yes | ✅ Yes |
|
||||
| Activity Logs | Database | `/api/logs` | ✅ Yes | ✅ Yes |
|
||||
| Vaccinations | Database | `/api/vaccinations` | ✅ Yes | ✅ Yes |
|
||||
| Growth Records | Database | `/api/growth` | ✅ Yes | ✅ Yes |
|
||||
| User Profile | Database | `/api/auth/profile` | ✅ Yes | ✅ Yes |
|
||||
| Memories/Photos | Database + R2 | `/api/upload` | ✅ Yes | ✅ Yes |
|
||||
| Auth Session | Database + Cookie | `/api/auth/signin` | ✅ Yes | ✅ No |
|
||||
| Theme | localStorage | `tia_theme` | ✅ Yes | ✅ Yes |
|
||||
| Chat Sessions | Database | `/api/chat` | ✅ Yes | ✅ Yes |
|
||||
| Offline Queue | localStorage | `tia_offline_queue` | ✅ Yes | ❌ No |
|
||||
|
||||
## R2 Storage (Cloudflare)
|
||||
|
||||
### Setup
|
||||
|
||||
1. **Create bucket** in Cloudflare Dashboard → R2
|
||||
2. **Create API token** with "Object Read & Write" permissions
|
||||
3. **Enable Public Development URL** in bucket settings (gives pub-*.r2.dev URL)
|
||||
|
||||
### API Endpoint Format
|
||||
|
||||
The S3 API endpoint is: `https://<accountId>.r2.cloudflarestorage.com`
|
||||
|
||||
For your bucket named "tia":
|
||||
- Account ID: `e71f22a2f8614fb3ba6d9b28a264d8ce`
|
||||
- S3 Endpoint: `https://e71f22a2f8614fb3ba6d9b28a264d8ce.r2.cloudflarestorage.com`
|
||||
- Public URL: `https://pub-37a76fd657c94d1dbc521a109c087a11.r2.dev` (no bucket name in path!)
|
||||
|
||||
### Code Example
|
||||
|
||||
```typescript
|
||||
const client = new S3Client({
|
||||
region: "auto",
|
||||
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
|
||||
credentials: { accessKeyId, secretAccessKey },
|
||||
});
|
||||
|
||||
// List objects
|
||||
const command = new ListObjectsV2Command({ Bucket: "tia" });
|
||||
const res = await client.send(command);
|
||||
|
||||
// Get presigned upload URL
|
||||
const command = new PutObjectCommand({ Bucket: "tia", Key: key, ContentType });
|
||||
const url = await getSignedUrl(client, command, { expiresIn: 3600 });
|
||||
```
|
||||
|
||||
### Key Learnings
|
||||
|
||||
1. **S3 API endpoint** does NOT include bucket name: `https://...r2.cloudflarestorage.com`
|
||||
2. **Public URL** does NOT include bucket path: `https://pub-...r2.dev` (NOT `/tia/...`)
|
||||
3. **CORS** needs GET and PUT methods for uploads
|
||||
4. ListObjects needs bucket name in command, not endpoint
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Set in `.env.local` for development, or in Dokploy dashboard for production.
|
||||
|
||||
Required:
|
||||
|
||||
- `DATABASE_URL` - PostgreSQL connection (as `tia_app` role after H2.1)
|
||||
- `DATABASE_URL_SUPERUSER` - Superuser connection (for migrations only)
|
||||
- `LITELLM_BASE_URL` - AI gateway URL (e.g., https://llm.manohargupta.com)
|
||||
- `LITELLM_API_KEY` - AI API key
|
||||
- `R2_ACCOUNT_ID` - Cloudflare R2 account ID
|
||||
- `R2_ACCESS_KEY_ID` - R2 access key
|
||||
- `R2_SECRET_ACCESS_KEY` - R2 secret key
|
||||
- `R2_BUCKET_NAME` - R2 bucket name (e.g., "tia")
|
||||
- `R2_PUBLIC_URL` - Public R2 URL
|
||||
- `CRON_SECRET` - Secret for cron backup endpoint
|
||||
|
||||
### Security Patterns
|
||||
|
||||
All data API routes must validate sessions using `@/lib/auth`:
|
||||
## Key Patterns
|
||||
|
||||
### Session validation in API routes
|
||||
```typescript
|
||||
import { requireFamily, requireOwnership } from "@/lib/auth";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const auth = await requireFamily();
|
||||
if (!auth.success) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
// ... route logic
|
||||
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
const familyId = auth.session!.familyId!;
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Current Security Status (May 2026)
|
||||
|
||||
- **RLS (Row-Level Security):** DISABLED on family_members, children, and growth tables (app-level security via requireOwnership)
|
||||
- **App-level security:** All routes use `requireFamily()` and `requireOwnership()` checks
|
||||
- **This is secure because:** All API routes validate session before returning data
|
||||
- **To re-enable RLS later:** Add proper INSERT bypass policy, keep RLS for SELECT only
|
||||
|
||||
AI routes use medical guardrails from `@/lib/ai/medical-triggers`:
|
||||
|
||||
### ThemeProvider
|
||||
```typescript
|
||||
import { detectMedicalIntent } from "@/lib/ai/medical-triggers";
|
||||
|
||||
const intent = detectMedicalIntent(query);
|
||||
if (intent.isMedical) {
|
||||
// Redirect to pediatrician
|
||||
}
|
||||
import { useTheme } from "./ThemeProvider";
|
||||
const { theme, toggle, setMode } = useTheme();
|
||||
// theme: "light" | "dark" | mode: "light" | "dark" | "system" | "time"
|
||||
```
|
||||
|
||||
## Known Issues
|
||||
### Offline Queue
|
||||
Uses localStorage (`tia_offline_queue`) for failed API calls, retries when online.
|
||||
|
||||
### Chat Sessions
|
||||
Stored in localStorage (`tia_chat_sessions`) — shared between home page AI card and `/ai` page.
|
||||
Database tables: `chat_sessions`, `chat_messages`.
|
||||
|
||||
---
|
||||
|
||||
## Email (Resend)
|
||||
|
||||
Transactional emails via Resend. Functions in `src/lib/email.ts`:
|
||||
- `sendVerificationEmail(email, token, userId)` — account verification
|
||||
- `sendFamilyInviteEmail({ to, inviterName, familyName, token, role })` — family invite
|
||||
|
||||
Emails fall back to console log if `RESEND_API_KEY` is not set (dev mode).
|
||||
|
||||
---
|
||||
|
||||
## Family Invites
|
||||
|
||||
Flow:
|
||||
1. Settings → Invite → `POST /api/invites` → creates row in `family_invites`, sends email via Resend
|
||||
2. Invitee clicks `/invite/{token}` link → logs in/signs up
|
||||
3. `POST /api/invites/accept` → adds to `family_members`, **deletes** the invite row (single-use)
|
||||
4. `DELETE /api/invites/[id]` — cancel a pending invite
|
||||
|
||||
`family_invites` table columns: `id, family_id, email, role, token, expires_at, created_at, display_name, accepted_at`
|
||||
(Note: `display_name` and `accepted_at` were missing from the original schema — added in migration `0006`)
|
||||
|
||||
---
|
||||
|
||||
## Growth Page
|
||||
|
||||
- `formatAge(birthDate, measurementDate?)` — shows age as "1y 4mo" or "8mo"
|
||||
- Goals stored in localStorage (`tia_growth_goal_${childId}`)
|
||||
- Column order: Goals → Latest Reading → Add Form → WHO+Chart → History
|
||||
- Color-coded percentiles: 🟢 Normal (15–85th) · 🟡 Watch (<15 or >85) · 🔴 Alert (<3 or >97)
|
||||
|
||||
---
|
||||
|
||||
## Admin Panel
|
||||
|
||||
Access at `/admin-login`.
|
||||
|
||||
Pages: `/admin`, `/admin/families`, `/admin/users`, `/admin/children`, `/admin/revenue`, `/admin/analytics`, `/admin/support`, `/admin/settings`
|
||||
|
||||
Auth pattern: server component layout calls `verifyAdminSession()` → redirects to `/admin-login` if not authenticated. No client-side cookie checks.
|
||||
|
||||
---
|
||||
|
||||
## Monitoring & Alerting
|
||||
|
||||
Alerts go to Telegram (tiaBaby_Bot) via `src/lib/alert.ts` → `sendAlert(level, title, detail?, opts?)`. Best-effort, never throws.
|
||||
|
||||
| Signal | Where | How it fires |
|
||||
|--------|-------|--------------|
|
||||
| Site/health up-down | **Uptime Kuma** (external, in Dokploy) | Pings `GET /api/healthz` (public, 200/503). Kuma handles flip detection + recovery |
|
||||
| Backup fail / empty | `POST /api/cron/backup` | Alerts on exception, and warns if the gzipped dump < 1KB. Success is a silent confirmation |
|
||||
| Error spikes | `/api/cron/monitor` | Rising-edge: errors in last 1h > 5 **and** > 2× the prior hour. Stateless (no re-alert on flat rate) |
|
||||
| Internal health | `/api/cron/monitor` | DB unreachable, no migrations, missing integration env |
|
||||
| Visitor digest | `/api/cron/visitor-summary` | Polls Umami REST API (login → stats/metrics/active), posts digest. `?hours=N` window |
|
||||
|
||||
**Cron endpoints** all require the `x-cron-secret: $CRON_SECRET` header (same as backup). Schedule in Dokploy:
|
||||
- `monitor` — hourly
|
||||
- `visitor-summary` — daily (or `?hours=1` hourly during launch)
|
||||
- `backup` — daily (existing)
|
||||
|
||||
**Test the Telegram wiring:** `GET /api/cron/monitor?test=1` with the cron-secret header sends a test ping.
|
||||
|
||||
---
|
||||
|
||||
## Data Storage Rules
|
||||
|
||||
| Data Type | Storage | API |
|
||||
|----------|---------|-----|
|
||||
| Children | Database | `/api/children` |
|
||||
| Activity Logs | Database | `/api/logs` |
|
||||
| Vaccinations | Database | `/api/vaccinations` |
|
||||
| Growth Records | Database | `/api/growth` |
|
||||
| User Profile | Database | `/api/auth/profile` |
|
||||
| Parent Avatar | Database (`users.image`) + R2 | `/api/auth/avatar` |
|
||||
| Baby Photo | Database (`children.image_url`) + R2 | `/api/children/[id]` |
|
||||
| Memories/Photos | Database + R2 | `/api/upload` |
|
||||
| Auth Session | Database + Cookie | `/api/auth/signin` |
|
||||
| Theme | localStorage | `tia_theme` |
|
||||
| Growth Goals | localStorage | `tia_growth_goal_${childId}` |
|
||||
| Custom Folders | localStorage | `tia_custom_folders` |
|
||||
| Offline Queue | localStorage | `tia_offline_queue` |
|
||||
|
||||
**NEVER use localStorage for:** authentication tokens, family_id, or any data that should persist across devices.
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Set in `.env.local` for development, Dokploy dashboard for production.
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `DATABASE_URL` | ✅ | PostgreSQL connection |
|
||||
| `DATABASE_URL_SUPERUSER` | ✅ | Superuser connection (migrations only) |
|
||||
| `LITELLM_BASE_URL` | ✅ | AI gateway URL |
|
||||
| `LITELLM_API_KEY` | ✅ | AI API key |
|
||||
| `R2_ACCOUNT_ID` | ✅ | Cloudflare R2 account ID |
|
||||
| `R2_ACCESS_KEY_ID` | ✅ | R2 access key |
|
||||
| `R2_SECRET_ACCESS_KEY` | ✅ | R2 secret key |
|
||||
| `R2_BUCKET_NAME` | ✅ | R2 bucket name (e.g. "tia") |
|
||||
| `R2_PUBLIC_URL` | ✅ | Public R2 URL |
|
||||
| `RESEND_API_KEY` | ✅ | Resend API key for transactional email |
|
||||
| `EMAIL_FROM` | ✅ | Sender address (e.g. `Tia <tia@manohargupta.com>`) |
|
||||
| `NEXT_PUBLIC_APP_URL` | ✅ | Full app URL (e.g. `https://tia.manohargupta.com`) |
|
||||
| `CRON_SECRET` | ✅ | Secret for cron endpoints (backup, monitor, visitor-summary) — sent as `x-cron-secret` header |
|
||||
| `TELEGRAM_BOT_TOKEN` | ✅ | tiaBaby_Bot token from @BotFather — operational alerts |
|
||||
| `TELEGRAM_CHAT_ID` | ✅ | Chat/group/channel id alerts post to (see `src/lib/alert.ts` header for how to get it) |
|
||||
| `UMAMI_BASE_URL` | — | Umami instance (default `https://analytics.manohargupta.com`) |
|
||||
| `UMAMI_USERNAME` | ✅ | Umami login — for the visitor-summary cron |
|
||||
| `UMAMI_PASSWORD` | ✅ | Umami password |
|
||||
| `UMAMI_WEBSITE_ID` | — | Umami website id (default Tia's id) |
|
||||
|
||||
---
|
||||
|
||||
## R2 Storage (Cloudflare)
|
||||
|
||||
- Account ID: `e71f22a2f8614fb3ba6d9b28a264d8ce`
|
||||
- S3 Endpoint: `https://e71f22a2f8614fb3ba6d9b28a264d8ce.r2.cloudflarestorage.com`
|
||||
- Public URL: `https://pub-37a76fd657c94d1dbc521a109c087a11.r2.dev` (no bucket name in path!)
|
||||
|
||||
Key learnings:
|
||||
1. S3 API endpoint does NOT include bucket name
|
||||
2. Public URL does NOT include `/tia/` bucket path
|
||||
3. Direct browser PUT to presigned URL = CORS blocked → always proxy via `/api/upload`
|
||||
|
||||
---
|
||||
|
||||
## Known Issues & Fixes Applied
|
||||
|
||||
### Turbopack Parsing Issue
|
||||
Some files cause "Unterminated regexp literal" with Turbopack. Fix: `pnpm build -- --webpack`
|
||||
Avoid patterns like `name: "TT/Td"` in arrays (the `/` can be misread as regex).
|
||||
|
||||
Some files may cause "Unterminated regexp literal" errors when building with Turbopack. If build fails:
|
||||
### users.image vs users.avatar_url
|
||||
The `users` table uses `image` for the profile photo column. Using `avatar_url` in SQL will throw
|
||||
"column does not exist". `avatar_url` exists only on `member_profiles`.
|
||||
|
||||
1. Try building with Webpack instead:
|
||||
|
||||
```bash
|
||||
pnpm build -- --webpack
|
||||
```
|
||||
|
||||
2. The issue is in SWC parser - avoid patterns like `name: "TT/Td"` in arrays as the `/` can be interpreted as regex
|
||||
|
||||
3. Apply fixes one change at a time between builds - cumulative changes can confuse Turbopack's cache
|
||||
### family_invites missing columns
|
||||
Original schema was missing `display_name` and `accepted_at`. Added in `drizzle/0006_family_invites_missing_cols.sql`.
|
||||
Hot-fixed via `/api/debug-migration` POST.
|
||||
|
||||
### Admin auth (httpOnly cookie)
|
||||
`tia_admin_session` is httpOnly — `document.cookie` can never read it. Admin pages use a **server component layout**
|
||||
that calls `verifyAdminSession()` server-side. Client pages use `credentials: 'include'` on fetches; no Bearer tokens.
|
||||
|
|
|
|||
13
Dockerfile
|
|
@ -14,6 +14,10 @@ COPY --from=deps /app/node_modules ./node_modules
|
|||
COPY . .
|
||||
RUN corepack enable pnpm
|
||||
RUN pnpm run build
|
||||
# Bundle the standalone migration runner. This produces dist/migrate.mjs
|
||||
# with drizzle-orm + postgres inlined, so the runner stage needs no extra
|
||||
# node_modules. Runs here because the builder has full dependencies.
|
||||
RUN pnpm run db:build-migrator
|
||||
|
||||
# Stage 3: Production runner
|
||||
FROM node:22-alpine AS runner
|
||||
|
|
@ -24,8 +28,15 @@ RUN adduser --system --uid 1001 nextjs
|
|||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static .next/static
|
||||
# Migration runner + migration SQL. The migrator runs on container start
|
||||
# (see CMD) BEFORE the Next.js server boots — see drizzle/README.md.
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/dist/migrate.mjs ./migrate.mjs
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/drizzle ./drizzle
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
CMD ["node", "server.js"]
|
||||
# Apply pending migrations, THEN start the server. If migration fails the
|
||||
# process exits non-zero and the container crashes — a loud, safe failure
|
||||
# that prevents the app from serving against a half-migrated schema.
|
||||
CMD ["sh", "-c", "node migrate.mjs && node server.js"]
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
version: '3.8'
|
||||
|
||||
# Local development services for Tia (Postgres + Redis).
|
||||
# Postgres major version is pinned to match production.
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
# pg18 to match production (Hetzner prod runs Postgres 18).
|
||||
# Keeping dev on the same major version avoids dump/restore mismatches
|
||||
# and version-specific behaviour drift.
|
||||
image: pgvector/pgvector:pg18
|
||||
container_name: tia-postgres
|
||||
environment:
|
||||
POSTGRES_DB: tia_dev
|
||||
|
|
@ -11,7 +14,9 @@ services:
|
|||
ports:
|
||||
- "5433:5432"
|
||||
volumes:
|
||||
- ./data/postgres:/var/lib/postgresql/data
|
||||
# pg18 image convention: mount at /var/lib/postgresql (NOT .../data).
|
||||
# The image places data in a version-specific subdirectory under it.
|
||||
- ./data/postgres:/var/lib/postgresql
|
||||
- ./docker/init-db:/docker-entrypoint-initdb.d
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U tia -d tia_dev"]
|
||||
|
|
|
|||
430
drizzle/0000_baseline_prod_2026_05_19.sql
Normal file
|
|
@ -0,0 +1,430 @@
|
|||
CREATE TYPE "public"."child_sex" AS ENUM('male', 'female', 'other');--> statement-breakpoint
|
||||
CREATE TYPE "public"."child_stage" AS ENUM('newborn', 'infant', 'solids_start', 'toddler_early', 'toddler_late', 'preschool');--> statement-breakpoint
|
||||
CREATE TYPE "public"."member_role" AS ENUM('admin', 'caregiver', 'viewer');--> statement-breakpoint
|
||||
CREATE TYPE "public"."diaper_type" AS ENUM('wet', 'dirty', 'both', 'dry');--> statement-breakpoint
|
||||
CREATE TYPE "public"."feed_method" AS ENUM('bottle', 'breast_left', 'breast_right', 'breast_both', 'cup', 'spoon', 'finger', 'self');--> statement-breakpoint
|
||||
CREATE TYPE "public"."feed_type" AS ENUM('breast_milk', 'formula', 'solid', 'water', 'other');--> statement-breakpoint
|
||||
CREATE TYPE "public"."sleep_type" AS ENUM('nap', 'night');--> statement-breakpoint
|
||||
CREATE TABLE "admin_sessions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"admin_id" uuid NOT NULL,
|
||||
"session_token" text NOT NULL,
|
||||
"expires" timestamp with time zone NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now(),
|
||||
CONSTRAINT "admin_sessions_session_token_unique" UNIQUE("session_token")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "admins" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"username" varchar(50) NOT NULL,
|
||||
"password_hash" varchar(255) NOT NULL,
|
||||
"role" varchar(20) DEFAULT 'admin',
|
||||
"created_at" timestamp with time zone DEFAULT now(),
|
||||
"last_login" timestamp with time zone,
|
||||
CONSTRAINT "admins_username_unique" UNIQUE("username"),
|
||||
CONSTRAINT "admins_role_check" CHECK ((role)::text = ANY (ARRAY['super_admin','admin','support']))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "password_resets" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"token" text NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"used_at" timestamp with time zone,
|
||||
CONSTRAINT "password_resets_token_unique" UNIQUE("token")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "member_profiles" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"family_id" uuid NOT NULL,
|
||||
"slug" text NOT NULL,
|
||||
"display_name" text NOT NULL,
|
||||
"bio" text,
|
||||
"avatar_url" text,
|
||||
"is_public" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "member_profiles_user_id_unique" UNIQUE("user_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "product_clicks" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"product_id" uuid NOT NULL,
|
||||
"clicked_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"referrer" text,
|
||||
"ip_hash" text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "recommended_products" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"profile_id" uuid NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"description" text,
|
||||
"url" text NOT NULL,
|
||||
"image_url" text,
|
||||
"category" text DEFAULT 'general' NOT NULL,
|
||||
"display_order" integer DEFAULT 0 NOT NULL,
|
||||
"is_active" boolean DEFAULT true NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "ai_usage" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"family_id" uuid,
|
||||
"user_id" uuid,
|
||||
"intent" text,
|
||||
"model_used" text,
|
||||
"prompt_tokens" integer,
|
||||
"completion_tokens" integer,
|
||||
"total_tokens" integer,
|
||||
"cost_estimate_paise" numeric(10, 4),
|
||||
"duration_ms" integer,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "chat_messages" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"session_id" uuid NOT NULL,
|
||||
"role" varchar(20) NOT NULL,
|
||||
"content" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "chat_sessions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"child_id" uuid NOT NULL,
|
||||
"title" varchar(255) DEFAULT 'New conversation' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now(),
|
||||
"updated_at" timestamp with time zone DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "audit_log" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"family_id" uuid,
|
||||
"user_id" uuid,
|
||||
"action" varchar(50) NOT NULL,
|
||||
"resource_type" varchar(50),
|
||||
"resource_id" uuid,
|
||||
"ip_address" varchar(45),
|
||||
"user_agent" text,
|
||||
"metadata" jsonb DEFAULT '{}'::jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "log_corrections" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"family_id" uuid NOT NULL,
|
||||
"dose_id" uuid NOT NULL,
|
||||
"original_value" jsonb NOT NULL,
|
||||
"corrected_value" jsonb NOT NULL,
|
||||
"reason" text,
|
||||
"corrected_by" uuid,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "accounts" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"type" text NOT NULL,
|
||||
"provider" text NOT NULL,
|
||||
"provider_account_id" text NOT NULL,
|
||||
"refresh_token" text,
|
||||
"access_token" text,
|
||||
"expires_at" timestamp,
|
||||
"token_type" text,
|
||||
"scope" text,
|
||||
"id_token" text,
|
||||
"session_state" text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "sessions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"session_token" text NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"expires" timestamp NOT NULL,
|
||||
CONSTRAINT "sessions_session_token_unique" UNIQUE("session_token")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "users" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" text,
|
||||
"email" text NOT NULL,
|
||||
"email_verified" timestamp,
|
||||
"image" text,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
"password_hash" varchar(255),
|
||||
"password_updated_at" timestamp with time zone,
|
||||
CONSTRAINT "users_email_unique" UNIQUE("email")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "verification_tokens" (
|
||||
"identifier" text NOT NULL,
|
||||
"token" text NOT NULL,
|
||||
"expires" timestamp NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "children" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"family_id" uuid NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"birth_date" date NOT NULL,
|
||||
"sex" "child_sex",
|
||||
"stage" "child_stage" DEFAULT 'newborn' NOT NULL,
|
||||
"image_url" text,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "families" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"tier" varchar(20) DEFAULT 'free',
|
||||
"max_children" integer DEFAULT 1,
|
||||
"max_members" integer DEFAULT 2,
|
||||
"pediatrician_phone" text,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "family_invites" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"family_id" uuid NOT NULL,
|
||||
"email" text NOT NULL,
|
||||
"role" "member_role" DEFAULT 'viewer' NOT NULL,
|
||||
"token" text NOT NULL,
|
||||
"expires_at" timestamp NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "family_invites_token_unique" UNIQUE("token")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "family_members" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"family_id" uuid NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"role" "member_role" DEFAULT 'caregiver' NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "attachments" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"family_id" uuid NOT NULL,
|
||||
"log_entry_id" uuid,
|
||||
"r2_key" text NOT NULL,
|
||||
"r2_thumbnail_key" text,
|
||||
"mime_type" text,
|
||||
"size_bytes" integer,
|
||||
"uploaded_by" uuid,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "memories" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"family_id" uuid NOT NULL,
|
||||
"child_id" uuid,
|
||||
"title" text,
|
||||
"description" text,
|
||||
"taken_at" timestamp with time zone,
|
||||
"r2_key" text NOT NULL,
|
||||
"r2_thumbnail_key" text,
|
||||
"mime_type" text,
|
||||
"size_bytes" integer,
|
||||
"width" integer,
|
||||
"height" integer,
|
||||
"vision_caption" text,
|
||||
"vision_tags" text[],
|
||||
"vision_embedding" vector(1536),
|
||||
"is_private" boolean DEFAULT false NOT NULL,
|
||||
"processing_status" text DEFAULT 'uploading' NOT NULL,
|
||||
"uploaded_by" uuid,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "diapers_logs" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"child_id" uuid NOT NULL,
|
||||
"type" "diaper_type" NOT NULL,
|
||||
"notes" text,
|
||||
"logged_at" timestamp DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "feeds" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"child_id" uuid NOT NULL,
|
||||
"type" "feed_type" NOT NULL,
|
||||
"method" "feed_method",
|
||||
"amount_ml" real,
|
||||
"notes" text,
|
||||
"logged_at" timestamp DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "growth" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"child_id" uuid NOT NULL,
|
||||
"measured_at" timestamp NOT NULL,
|
||||
"weight_kg" real,
|
||||
"height_cm" real,
|
||||
"head_circumference_cm" real,
|
||||
"notes" text,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "medications" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"child_id" uuid NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"dosage" text,
|
||||
"frequency" text,
|
||||
"start_date" date NOT NULL,
|
||||
"end_date" date,
|
||||
"active" boolean DEFAULT true NOT NULL,
|
||||
"notes" text,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "milestone_achievements" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"child_id" uuid NOT NULL,
|
||||
"family_id" uuid NOT NULL,
|
||||
"milestone_key" text NOT NULL,
|
||||
"achieved_at" date NOT NULL,
|
||||
"notes" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "sleeps" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"child_id" uuid NOT NULL,
|
||||
"type" "sleep_type" NOT NULL,
|
||||
"started_at" timestamp,
|
||||
"ended_at" timestamp,
|
||||
"duration_minutes" integer,
|
||||
"notes" text,
|
||||
"logged_at" timestamp DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "vaccinations" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"child_id" uuid NOT NULL,
|
||||
"vaccine_name" text NOT NULL,
|
||||
"scheduled_date" date NOT NULL,
|
||||
"given_date" date,
|
||||
"status" text DEFAULT 'pending' NOT NULL,
|
||||
"provider" text,
|
||||
"lot_number" text,
|
||||
"notes" text,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "allergies" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"child_id" uuid NOT NULL,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"severity" varchar(50) DEFAULT 'mild',
|
||||
"notes" text,
|
||||
"created_at" timestamp with time zone DEFAULT now(),
|
||||
"updated_at" timestamp with time zone DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "doctor_visits" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"child_id" uuid NOT NULL,
|
||||
"doctor_name" varchar(255) NOT NULL,
|
||||
"reason" varchar(255),
|
||||
"visit_date" date NOT NULL,
|
||||
"notes" text,
|
||||
"created_at" timestamp with time zone DEFAULT now(),
|
||||
"updated_at" timestamp with time zone DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "illness_logs" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"child_id" uuid NOT NULL,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"start_date" date NOT NULL,
|
||||
"end_date" date,
|
||||
"notes" text,
|
||||
"created_at" timestamp with time zone DEFAULT now(),
|
||||
"updated_at" timestamp with time zone DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "medication_doses" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"medicine_id" uuid NOT NULL,
|
||||
"family_id" uuid NOT NULL,
|
||||
"administered_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"administered_by" uuid,
|
||||
"amount_given" text,
|
||||
"notes" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "medicines" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"child_id" uuid NOT NULL,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"dose" varchar(255),
|
||||
"notes" text,
|
||||
"reminder_time" varchar(10),
|
||||
"created_at" timestamp with time zone DEFAULT now(),
|
||||
"updated_at" timestamp with time zone DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "support_responses" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"ticket_id" uuid,
|
||||
"admin_id" uuid,
|
||||
"message" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "support_tickets" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"family_id" uuid,
|
||||
"user_id" uuid,
|
||||
"email" varchar(255) NOT NULL,
|
||||
"subject" varchar(255) NOT NULL,
|
||||
"description" text,
|
||||
"status" varchar(20) DEFAULT 'open',
|
||||
"priority" varchar(20) DEFAULT 'normal',
|
||||
"created_at" timestamp with time zone DEFAULT now(),
|
||||
"updated_at" timestamp with time zone DEFAULT now(),
|
||||
CONSTRAINT "support_tickets_status_check" CHECK ((status)::text = ANY (ARRAY['open','in_progress','resolved','closed'])),
|
||||
CONSTRAINT "support_tickets_priority_check" CHECK ((priority)::text = ANY (ARRAY['low','normal','high','urgent']))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "diapers_logs" ADD CONSTRAINT "diapers_logs_child_id_children_id_fk" FOREIGN KEY ("child_id") REFERENCES "public"."children"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "feeds" ADD CONSTRAINT "feeds_child_id_children_id_fk" FOREIGN KEY ("child_id") REFERENCES "public"."children"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "growth" ADD CONSTRAINT "growth_child_id_children_id_fk" FOREIGN KEY ("child_id") REFERENCES "public"."children"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "medications" ADD CONSTRAINT "medications_child_id_children_id_fk" FOREIGN KEY ("child_id") REFERENCES "public"."children"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sleeps" ADD CONSTRAINT "sleeps_child_id_children_id_fk" FOREIGN KEY ("child_id") REFERENCES "public"."children"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "vaccinations" ADD CONSTRAINT "vaccinations_child_id_children_id_fk" FOREIGN KEY ("child_id") REFERENCES "public"."children"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "member_profiles_slug_idx" ON "member_profiles" USING btree ("slug");--> statement-breakpoint
|
||||
CREATE INDEX "product_clicks_product_idx" ON "product_clicks" USING btree ("product_id","clicked_at");--> statement-breakpoint
|
||||
CREATE INDEX "recommended_products_profile_idx" ON "recommended_products" USING btree ("profile_id","display_order");--> statement-breakpoint
|
||||
CREATE INDEX "ai_usage_created_idx" ON "ai_usage" USING btree ("created_at");--> statement-breakpoint
|
||||
CREATE INDEX "ai_usage_family_idx" ON "ai_usage" USING btree ("family_id");--> statement-breakpoint
|
||||
CREATE INDEX "audit_family_idx" ON "audit_log" USING btree ("family_id","created_at");--> statement-breakpoint
|
||||
CREATE INDEX "idx_audit_log_action" ON "audit_log" USING btree ("action");--> statement-breakpoint
|
||||
CREATE INDEX "idx_audit_log_created" ON "audit_log" USING btree ("created_at");--> statement-breakpoint
|
||||
CREATE INDEX "idx_audit_log_family" ON "audit_log" USING btree ("family_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_audit_log_user" ON "audit_log" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "log_corrections_dose_idx" ON "log_corrections" USING btree ("dose_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "accounts_provider_idx" ON "accounts" USING btree ("provider","provider_account_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "verification_tokens_idx" ON "verification_tokens" USING btree ("identifier","token");--> statement-breakpoint
|
||||
CREATE INDEX "children_family_idx" ON "children" USING btree ("family_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "invite_token_idx" ON "family_invites" USING btree ("token");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "family_members_family_id_user_id_key" ON "family_members" USING btree ("family_id","user_id");--> statement-breakpoint
|
||||
CREATE INDEX "attachments_family_idx" ON "attachments" USING btree ("family_id");--> statement-breakpoint
|
||||
CREATE INDEX "memories_family_idx" ON "memories" USING btree ("family_id");--> statement-breakpoint
|
||||
CREATE INDEX "memories_child_idx" ON "memories" USING btree ("child_id");--> statement-breakpoint
|
||||
CREATE INDEX "memories_embedding_idx" ON "memories" USING ivfflat ("vision_embedding" vector_cosine_ops) WITH (lists=100);--> statement-breakpoint
|
||||
CREATE INDEX "milestone_child_idx" ON "milestone_achievements" USING btree ("child_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "milestone_achievements_child_milestone_unique" ON "milestone_achievements" USING btree ("child_id","milestone_key");--> statement-breakpoint
|
||||
CREATE INDEX "medication_doses_family_idx" ON "medication_doses" USING btree ("family_id");--> statement-breakpoint
|
||||
CREATE INDEX "medication_doses_medicine_idx" ON "medication_doses" USING btree ("medicine_id");
|
||||
39
drizzle/0001_wardrobe_tables.sql
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
CREATE TABLE "garment_wears" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"family_id" uuid NOT NULL,
|
||||
"garment_id" uuid NOT NULL,
|
||||
"worn_on" date NOT NULL,
|
||||
"memory_id" uuid,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "garments" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"family_id" uuid NOT NULL,
|
||||
"child_id" uuid NOT NULL,
|
||||
"name" text,
|
||||
"category" text NOT NULL,
|
||||
"size_label" text NOT NULL,
|
||||
"colors" text[] DEFAULT '{}',
|
||||
"seasons" text[] DEFAULT '{}',
|
||||
"occasion_tags" text[] DEFAULT '{}',
|
||||
"image_key" text NOT NULL,
|
||||
"thumb_key" text NOT NULL,
|
||||
"status" text DEFAULT 'active' NOT NULL,
|
||||
"acquired_via" text,
|
||||
"gift_from" text,
|
||||
"vision_metadata" jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "garment_wears" ADD CONSTRAINT "garment_wears_family_id_families_id_fk" FOREIGN KEY ("family_id") REFERENCES "public"."families"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "garment_wears" ADD CONSTRAINT "garment_wears_garment_id_garments_id_fk" FOREIGN KEY ("garment_id") REFERENCES "public"."garments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "garment_wears" ADD CONSTRAINT "garment_wears_memory_id_memories_id_fk" FOREIGN KEY ("memory_id") REFERENCES "public"."memories"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "garments" ADD CONSTRAINT "garments_family_id_families_id_fk" FOREIGN KEY ("family_id") REFERENCES "public"."families"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "garments" ADD CONSTRAINT "garments_child_id_children_id_fk" FOREIGN KEY ("child_id") REFERENCES "public"."children"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "garment_wears_garment_idx" ON "garment_wears" USING btree ("garment_id");--> statement-breakpoint
|
||||
CREATE INDEX "garment_wears_family_idx" ON "garment_wears" USING btree ("family_id");--> statement-breakpoint
|
||||
CREATE INDEX "garments_family_idx" ON "garments" USING btree ("family_id");--> statement-breakpoint
|
||||
CREATE INDEX "garments_child_idx" ON "garments" USING btree ("child_id");--> statement-breakpoint
|
||||
CREATE INDEX "garments_status_idx" ON "garments" USING btree ("status");
|
||||
14
drizzle/0002_outfits_table.sql
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
CREATE TABLE "outfits" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"family_id" uuid NOT NULL,
|
||||
"child_id" uuid NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"garment_ids" uuid[] DEFAULT '{}' NOT NULL,
|
||||
"occasion_tags" text[] DEFAULT '{}',
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "outfits" ADD CONSTRAINT "outfits_family_id_families_id_fk" FOREIGN KEY ("family_id") REFERENCES "public"."families"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "outfits" ADD CONSTRAINT "outfits_child_id_children_id_fk" FOREIGN KEY ("child_id") REFERENCES "public"."children"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "outfits_family_idx" ON "outfits" USING btree ("family_id");--> statement-breakpoint
|
||||
CREATE INDEX "outfits_child_idx" ON "outfits" USING btree ("child_id");
|
||||
94
drizzle/0003_circles.sql
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
-- C0: Circle multi-tenant social tables
|
||||
-- Security model: RLS enabled (deny-by-default at DB layer).
|
||||
-- App-level enforcement via requireFamily() + WHERE family_id checks
|
||||
-- mirrors the existing pattern used for all other tables in this codebase.
|
||||
|
||||
CREATE TABLE circles (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name text NOT NULL,
|
||||
created_by uuid NOT NULL REFERENCES families(id),
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE circle_members (
|
||||
circle_id uuid NOT NULL REFERENCES circles(id) ON DELETE CASCADE,
|
||||
family_id uuid NOT NULL REFERENCES families(id),
|
||||
role text NOT NULL DEFAULT 'member', -- admin | member
|
||||
joined_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (circle_id, family_id)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE circle_invites (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
circle_id uuid NOT NULL REFERENCES circles(id) ON DELETE CASCADE,
|
||||
token text NOT NULL UNIQUE, -- crypto-random, unguessable
|
||||
created_by uuid NOT NULL REFERENCES families(id),
|
||||
expires_at timestamptz NOT NULL,
|
||||
consumed_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE circle_posts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
circle_id uuid NOT NULL REFERENCES circles(id) ON DELETE CASCADE,
|
||||
author_family_id uuid NOT NULL REFERENCES families(id),
|
||||
body text,
|
||||
image_key text, -- R2 key under circle-posts/{id}/ prefix
|
||||
source_kind text, -- NULL | 'milestone' | 'memory'
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE circle_post_comments (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
post_id uuid NOT NULL REFERENCES circle_posts(id) ON DELETE CASCADE,
|
||||
author_family_id uuid NOT NULL REFERENCES families(id),
|
||||
body text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE circle_post_reactions (
|
||||
post_id uuid NOT NULL REFERENCES circle_posts(id) ON DELETE CASCADE,
|
||||
family_id uuid NOT NULL REFERENCES families(id),
|
||||
emoji text NOT NULL,
|
||||
PRIMARY KEY (post_id, family_id, emoji)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE post_reports (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
post_id uuid NOT NULL REFERENCES circle_posts(id) ON DELETE CASCADE,
|
||||
reported_by uuid NOT NULL REFERENCES families(id),
|
||||
reason text,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX circle_members_family_idx ON circle_members(family_id);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX circle_members_circle_idx ON circle_members(circle_id);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX circle_posts_circle_idx ON circle_posts(circle_id);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX circle_posts_author_idx ON circle_posts(author_family_id);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX circle_comments_post_idx ON circle_post_comments(post_id);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX circle_reactions_post_idx ON circle_post_reactions(post_id);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX circle_invites_token_idx ON circle_invites(token);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE circles ENABLE ROW LEVEL SECURITY;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE circle_members ENABLE ROW LEVEL SECURITY;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE circle_invites ENABLE ROW LEVEL SECURITY;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE circle_posts ENABLE ROW LEVEL SECURITY;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE circle_post_comments ENABLE ROW LEVEL SECURITY;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE circle_post_reactions ENABLE ROW LEVEL SECURITY;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE post_reports ENABLE ROW LEVEL SECURITY;
|
||||
--> statement-breakpoint
|
||||
GRANT ALL ON circles, circle_members, circle_invites, circle_posts,
|
||||
circle_post_comments, circle_post_reactions, post_reports
|
||||
TO tia_app;
|
||||
9
drizzle/0004_circle_invite_email.sql
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
-- Add email-based invite columns to circle_invites.
|
||||
-- invited_email: the address that was invited
|
||||
-- invited_family_id: set when the email matches an existing family (for in-app notification)
|
||||
|
||||
ALTER TABLE circle_invites ADD COLUMN IF NOT EXISTS invited_email text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE circle_invites ADD COLUMN IF NOT EXISTS invited_family_id uuid REFERENCES families(id);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS circle_invites_invited_family_idx ON circle_invites(invited_family_id) WHERE invited_family_id IS NOT NULL;
|
||||
21
drizzle/0005_email_verification.sql
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
-- Email verification tokens (single-use, 24 h).
|
||||
-- Mirrors the password_resets shape already in prod.
|
||||
-- Backfill grandfathers all existing users as verified so the Task C
|
||||
-- sign-in gate does not lock out accounts created before this migration.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS email_verifications (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token text NOT NULL UNIQUE,
|
||||
expires_at timestamptz NOT NULL,
|
||||
used_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS email_verifications_token_idx
|
||||
ON email_verifications(token);
|
||||
--> statement-breakpoint
|
||||
-- Grandfather every existing user as verified.
|
||||
UPDATE users SET email_verified = now() WHERE email_verified IS NULL;
|
||||
--> statement-breakpoint
|
||||
GRANT ALL ON email_verifications TO tia_app;
|
||||
3
drizzle/0006_family_invites_missing_cols.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
-- Add missing columns to family_invites that the invite flow requires
|
||||
ALTER TABLE family_invites ADD COLUMN IF NOT EXISTS display_name text;
|
||||
ALTER TABLE family_invites ADD COLUMN IF NOT EXISTS accepted_at timestamp;
|
||||
6
drizzle/0007_subscription_status.sql
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
-- Add subscription_status to families for payment-provider abstraction.
|
||||
-- The actual payment integration (Razorpay TBD) will only need to flip
|
||||
-- families.tier and set subscription_status; all quota/member enforcement
|
||||
-- reads from these two fields via isPaidFamily() in src/lib/quota.ts.
|
||||
ALTER TABLE families
|
||||
ADD COLUMN IF NOT EXISTS subscription_status varchar(20) DEFAULT NULL;
|
||||
2
drizzle/0008_pediatrician_name.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE families
|
||||
ADD COLUMN IF NOT EXISTS pediatrician_name text;
|
||||
38
drizzle/0009_notifications.sql
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
-- Notification system — persistent, DB-backed nudges and vaccine alerts
|
||||
-- Replaces the previous compute-only approach so read/unread syncs across devices.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
family_id UUID NOT NULL REFERENCES families(id) ON DELETE CASCADE,
|
||||
child_id UUID REFERENCES children(id) ON DELETE CASCADE,
|
||||
|
||||
-- Stable type codes:
|
||||
-- Vaccine alerts : "vaccine_BCG", "vaccine_OPV-0", etc.
|
||||
-- Daily nudges : "log_nudge", "memory_nudge"
|
||||
-- Weekly nudge : "garment_nudge"
|
||||
type VARCHAR(80) NOT NULL,
|
||||
|
||||
title TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
action_url TEXT,
|
||||
is_read BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
-- IST date this notification belongs to.
|
||||
-- Nudges: today's IST date → ensures one per day per type
|
||||
-- Vaccines: the vaccine due date → ensures one per vaccine (due date is fixed)
|
||||
-- Garment: Monday of current IST week → ensures one per week
|
||||
scheduled_for DATE NOT NULL,
|
||||
|
||||
-- Arbitrary extra data (e.g. { "vaccineName": "BCG" } for vaccine rows)
|
||||
metadata JSONB,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- One row per (family, child, type, date-slot) — prevents duplicate notifications
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS notifications_unique_slot
|
||||
ON notifications(family_id, child_id, type, scheduled_for);
|
||||
|
||||
-- Fast unread count + list query
|
||||
CREATE INDEX IF NOT EXISTS notifications_family_child_idx
|
||||
ON notifications(family_id, child_id, is_read, created_at DESC);
|
||||
22
drizzle/0010_error_events.sql
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
-- Error / crash tracking. Captures both client-side React errors (via the
|
||||
-- error boundaries that POST to /api/errors) and server-side failures (via
|
||||
-- logError() in src/lib/error-log.ts). Surfaced in the admin panel at
|
||||
-- /admin/errors so production bugs are visible instead of silent.
|
||||
CREATE TABLE IF NOT EXISTS error_events (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
level varchar(20) NOT NULL DEFAULT 'error', -- error | warn | fatal
|
||||
source varchar(20) NOT NULL DEFAULT 'client', -- client | server
|
||||
message text NOT NULL,
|
||||
stack text,
|
||||
url text, -- route / pathname where it happened
|
||||
digest varchar(120), -- Next.js error digest (server)
|
||||
user_id uuid,
|
||||
family_id uuid,
|
||||
user_agent text,
|
||||
metadata jsonb DEFAULT '{}',
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_error_events_created ON error_events (created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_error_events_source ON error_events (source);
|
||||
CREATE INDEX IF NOT EXISTS idx_error_events_message ON error_events (message);
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
-- Enable pgvector if not already
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
|
||||
-- Memories table (photos with vision metadata)
|
||||
CREATE TABLE IF NOT EXISTS memories (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
family_id UUID NOT NULL,
|
||||
child_id UUID,
|
||||
title TEXT,
|
||||
description TEXT,
|
||||
taken_at TIMESTAMPTZ,
|
||||
r2_key TEXT NOT NULL,
|
||||
r2_thumbnail_key TEXT,
|
||||
mime_type TEXT,
|
||||
size_bytes INTEGER,
|
||||
width INTEGER,
|
||||
height INTEGER,
|
||||
vision_caption TEXT,
|
||||
vision_tags TEXT[],
|
||||
vision_embedding VECTOR(1536),
|
||||
is_private BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
processing_status TEXT NOT NULL DEFAULT 'uploading'
|
||||
CHECK (processing_status IN ('uploading', 'processing', 'ready', 'failed')),
|
||||
uploaded_by UUID,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS memories_family_idx ON memories (family_id);
|
||||
CREATE INDEX IF NOT EXISTS memories_child_idx ON memories (child_id);
|
||||
|
||||
-- Attachments table (files linked to log entries, no vision)
|
||||
CREATE TABLE IF NOT EXISTS attachments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
family_id UUID NOT NULL,
|
||||
log_entry_id UUID,
|
||||
r2_key TEXT NOT NULL,
|
||||
r2_thumbnail_key TEXT,
|
||||
mime_type TEXT,
|
||||
size_bytes INTEGER,
|
||||
uploaded_by UUID,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS attachments_family_idx ON attachments (family_id);
|
||||
2
drizzle/0011_user_phone.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
-- Add optional phone number to users (collected at onboarding / profile).
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS phone text;
|
||||
56
drizzle/0012_billing.sql
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
-- Billing / Razorpay subscriptions.
|
||||
-- Idempotent: safe to re-run (used by debug-migration hot-apply too).
|
||||
|
||||
-- Subscription lifecycle enum (mirrors Razorpay states).
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE subscription_status_enum AS ENUM (
|
||||
'created','authenticated','active','pending',
|
||||
'halted','cancelled','completed','expired','paused'
|
||||
);
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
-- Plans: maps a Razorpay plan_id -> what it grants.
|
||||
CREATE TABLE IF NOT EXISTS subscription_plans (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
razorpay_plan_id text NOT NULL UNIQUE,
|
||||
name text NOT NULL,
|
||||
price_paise integer NOT NULL,
|
||||
storage_bytes bigint NOT NULL,
|
||||
member_limit integer NOT NULL,
|
||||
child_limit integer NOT NULL DEFAULT 3,
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- One subscription row per family (append on upgrade).
|
||||
CREATE TABLE IF NOT EXISTS family_subscriptions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
family_id uuid NOT NULL REFERENCES families(id) ON DELETE CASCADE,
|
||||
plan_id uuid NOT NULL REFERENCES subscription_plans(id),
|
||||
razorpay_subscription_id text NOT NULL UNIQUE,
|
||||
razorpay_customer_id text,
|
||||
status subscription_status_enum NOT NULL DEFAULT 'created',
|
||||
current_start timestamptz,
|
||||
current_end timestamptz,
|
||||
cancelled_at timestamptz,
|
||||
ended_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS family_subscriptions_family_idx
|
||||
ON family_subscriptions (family_id);
|
||||
|
||||
-- At most one LIVE (non-terminal) subscription per family.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS family_live_sub_idx
|
||||
ON family_subscriptions (family_id)
|
||||
WHERE status IN ('created','authenticated','active','pending','halted');
|
||||
|
||||
-- Append-only webhook log. razorpay_event_id = idempotency key.
|
||||
CREATE TABLE IF NOT EXISTS razorpay_webhook_events (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
razorpay_event_id text NOT NULL UNIQUE,
|
||||
event_type text NOT NULL,
|
||||
payload jsonb NOT NULL,
|
||||
received_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
-- G3.2: Medication dose log
|
||||
CREATE TABLE IF NOT EXISTS medication_doses (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
medicine_id UUID NOT NULL,
|
||||
family_id UUID NOT NULL,
|
||||
administered_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
administered_by UUID,
|
||||
amount_given TEXT,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS medication_doses_medicine_idx ON medication_doses (medicine_id);
|
||||
CREATE INDEX IF NOT EXISTS medication_doses_family_idx ON medication_doses (family_id);
|
||||
|
||||
-- G3.4: Log corrections (audit trail for edited doses)
|
||||
CREATE TABLE IF NOT EXISTS log_corrections (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
family_id UUID NOT NULL,
|
||||
dose_id UUID NOT NULL REFERENCES medication_doses(id) ON DELETE CASCADE,
|
||||
original_value JSONB NOT NULL,
|
||||
corrected_value JSONB NOT NULL,
|
||||
reason TEXT,
|
||||
corrected_by UUID,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS log_corrections_dose_idx ON log_corrections (dose_id);
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
CREATE TABLE IF NOT EXISTS ai_usage (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
family_id UUID,
|
||||
user_id UUID,
|
||||
intent TEXT, -- structured_query | memory_search | general_parenting | medical_redirect
|
||||
model_used TEXT,
|
||||
prompt_tokens INTEGER,
|
||||
completion_tokens INTEGER,
|
||||
total_tokens INTEGER,
|
||||
cost_estimate_paise NUMERIC(10,4), -- rough estimate for monitoring
|
||||
duration_ms INTEGER,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ai_usage_family_idx ON ai_usage (family_id);
|
||||
CREATE INDEX IF NOT EXISTS ai_usage_created_idx ON ai_usage (created_at DESC);
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
CREATE TABLE IF NOT EXISTS milestone_achievements (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
child_id UUID NOT NULL REFERENCES children(id) ON DELETE CASCADE,
|
||||
family_id UUID NOT NULL,
|
||||
milestone_key TEXT NOT NULL,
|
||||
achieved_at DATE NOT NULL,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(child_id, milestone_key)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS milestone_child_idx ON milestone_achievements (child_id);
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
CREATE TABLE IF NOT EXISTS member_profiles (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
|
||||
family_id UUID NOT NULL REFERENCES families(id) ON DELETE CASCADE,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL,
|
||||
bio TEXT,
|
||||
avatar_url TEXT,
|
||||
is_public BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS recommended_products (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
profile_id UUID NOT NULL REFERENCES member_profiles(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
url TEXT NOT NULL,
|
||||
image_url TEXT,
|
||||
category TEXT NOT NULL DEFAULT 'general',
|
||||
display_order INTEGER NOT NULL DEFAULT 0,
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS product_clicks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
product_id UUID NOT NULL REFERENCES recommended_products(id) ON DELETE CASCADE,
|
||||
clicked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
referrer TEXT,
|
||||
ip_hash TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS member_profiles_slug_idx ON member_profiles (slug);
|
||||
CREATE INDEX IF NOT EXISTS recommended_products_profile_idx ON recommended_products (profile_id, display_order);
|
||||
CREATE INDEX IF NOT EXISTS product_clicks_product_idx ON product_clicks (product_id, clicked_at DESC);
|
||||
55
drizzle/README.md
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
# Tia — Database Migrations
|
||||
|
||||
This folder is **source code** and is committed to git. It is consumed by the
|
||||
deploy pipeline (`pnpm db:migrate`, run on container start — see `Dockerfile`).
|
||||
|
||||
## Baseline reset — 2026-05-19
|
||||
|
||||
The project's first 16 migrations (`0000`–`0015`) plus a `manual/` folder were
|
||||
hand-rolled SQL applied directly via the Dokploy database terminal. They were
|
||||
**never** run through Drizzle's migrator, so:
|
||||
|
||||
- prod had no `__drizzle_migrations` tracking table;
|
||||
- the `drizzle/` folder was gitignored, so migration SQL never reached the server;
|
||||
- `schema.ts` had drifted well behind the real production schema.
|
||||
|
||||
To fix this we performed a **Path A baseline reset**:
|
||||
|
||||
1. `pg_dump` backup of prod taken and stored off-server.
|
||||
2. `drizzle-kit pull` introspected the live prod schema (35 tables).
|
||||
3. `src/db/schema/*.ts` was rewritten to match prod exactly.
|
||||
4. Legacy migrations were archived to `_archived_pre_baseline_2026-05-19/`
|
||||
(also retained in git history).
|
||||
5. A single fresh baseline — `0000_baseline_prod_2026_05_19.sql` — was generated
|
||||
and **verified column-for-column against the introspected prod schema**.
|
||||
6. Prod's `drizzle.__drizzle_migrations` table was created and seeded with one
|
||||
row marking `0000_baseline_prod_2026_05_19` as already applied, so the
|
||||
migrator treats prod as up-to-date and runs nothing on the next deploy.
|
||||
|
||||
## Normal workflow from here
|
||||
|
||||
```bash
|
||||
# 1. Edit src/db/schema/*.ts
|
||||
# 2. Generate a migration from the diff:
|
||||
pnpm db:generate # writes drizzle/000N_<name>.sql
|
||||
# 3. Review the generated SQL by eye.
|
||||
# 4. Apply locally against the dev DB:
|
||||
pnpm db:migrate
|
||||
# 5. Commit schema + migration together, then push.
|
||||
# Dokploy redeploys; the migrator applies it in prod on container start.
|
||||
```
|
||||
|
||||
## Hard rules
|
||||
|
||||
- **Never** edit a migration file after it has been pushed. Fix-forward with a
|
||||
new migration instead.
|
||||
- **Never** run schema-changing SQL directly against prod. It becomes drift.
|
||||
- The `drizzle/` folder must stay **out** of `.gitignore`.
|
||||
|
||||
## RLS policies
|
||||
|
||||
Five log tables (`feeds`, `diapers_logs`, `sleeps`, `vaccinations`, `growth`)
|
||||
plus `children` / `family_members` carry row-level-security policies in prod.
|
||||
These are **not** modelled in the `pgTable` definitions and are managed
|
||||
separately in the database. Drizzle migrations will not recreate them — keep
|
||||
that in mind if you ever rebuild the DB from scratch.
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
-- Create audit_log table
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID,
|
||||
family_id UUID,
|
||||
action TEXT NOT NULL,
|
||||
metadata JSONB,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Create index for queries
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_user ON audit_log(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_family ON audit_log(family_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC);
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
-- Create password_resets table
|
||||
CREATE TABLE IF NOT EXISTS password_resets (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
token TEXT UNIQUE NOT NULL,
|
||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
used_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
21
drizzle/manual/06-wardrobe-rls.sql
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
-- RLS for garments and garment_wears
|
||||
-- Run AFTER 0001_wardrobe_tables.sql has been applied
|
||||
-- Apply as superuser: psql $DATABASE_URL_SUPERUSER -f drizzle/manual/06-wardrobe-rls.sql
|
||||
|
||||
ALTER TABLE garments ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE garment_wears ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Both tables carry family_id directly, so we use the same direct comparison
|
||||
-- pattern as family_invites rather than the child_id subquery pattern.
|
||||
-- FOR ALL with USING also enforces WITH CHECK on INSERT (prevents cross-family writes).
|
||||
CREATE POLICY family_isolation ON garments
|
||||
FOR ALL USING (family_id = current_setting('app.current_family_id', true)::uuid);
|
||||
|
||||
CREATE POLICY family_isolation ON garment_wears
|
||||
FOR ALL USING (family_id = current_setting('app.current_family_id', true)::uuid);
|
||||
|
||||
-- W9: Saved outfits
|
||||
ALTER TABLE outfits ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY family_isolation ON outfits
|
||||
FOR ALL USING (family_id = current_setting('app.current_family_id', true)::uuid);
|
||||
2898
drizzle/meta/0000_snapshot.json
Normal file
3215
drizzle/meta/0001_snapshot.json
Normal file
3332
drizzle/meta/0002_snapshot.json
Normal file
97
drizzle/meta/_journal.json
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1779518962214,
|
||||
"tag": "0000_baseline_prod_2026_05_19",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1779538936553,
|
||||
"tag": "0001_wardrobe_tables",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1779539431897,
|
||||
"tag": "0002_outfits_table",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1748134800000,
|
||||
"tag": "0003_circles",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1748221200000,
|
||||
"tag": "0004_circle_invite_email",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1748307600000,
|
||||
"tag": "0005_email_verification",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1748394000000,
|
||||
"tag": "0006_family_invites_missing_cols",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1748480400000,
|
||||
"tag": "0007_subscription_status",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1748566800000,
|
||||
"tag": "0008_pediatrician_name",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "7",
|
||||
"when": 1748880000000,
|
||||
"tag": "0009_notifications",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1749139200000,
|
||||
"tag": "0010_error_events",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1780000000000,
|
||||
"tag": "0011_user_phone",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1780100000000,
|
||||
"tag": "0012_billing",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,4 +1,12 @@
|
|||
import type { NextConfig } from "next";
|
||||
import withSerwistInit from "@serwist/next";
|
||||
|
||||
const withSerwist = withSerwistInit({
|
||||
swSrc: "src/app/sw.ts",
|
||||
swDest: "public/sw.js",
|
||||
// Disable in dev so it never interferes with HMR / hot reload
|
||||
disable: process.env.NODE_ENV === "development",
|
||||
});
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
|
|
@ -14,10 +22,11 @@ const nextConfig: NextConfig = {
|
|||
{ key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" },
|
||||
{ key: "Content-Security-Policy", value:
|
||||
"default-src 'self'; " +
|
||||
"img-src 'self' data: https://*.r2.cloudflarestorage.com https://*.r2.dev; " +
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; " +
|
||||
"img-src 'self' data: https://*.r2.cloudflarestorage.com https://*.r2.dev https://*.razorpay.com; " +
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://checkout.razorpay.com; " +
|
||||
"style-src 'self' 'unsafe-inline'; " +
|
||||
"connect-src 'self' https://llm.manohargupta.com; " +
|
||||
"connect-src 'self' https://llm.manohargupta.com https://analytics.manohargupta.com https://*.razorpay.com https://lumberjack.razorpay.com; " +
|
||||
"frame-src 'self' https://*.razorpay.com https://api.razorpay.com; " +
|
||||
"font-src 'self' data:;"
|
||||
},
|
||||
],
|
||||
|
|
@ -26,4 +35,4 @@ const nextConfig: NextConfig = {
|
|||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
export default withSerwist(nextConfig);
|
||||
|
|
|
|||
20
package.json
|
|
@ -4,14 +4,23 @@
|
|||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start"
|
||||
"build": "next build --webpack",
|
||||
"start": "next start",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "tsx src/db/migrate.ts",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"db:pull": "drizzle-kit pull",
|
||||
"db:build-migrator": "node scripts/build-migrator.mjs",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth/drizzle-adapter": "^1.11.2",
|
||||
"@aws-sdk/client-s3": "^3.1045.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1045.0",
|
||||
"@react-pdf/renderer": "^4.5.1",
|
||||
"@serwist/next": "9.5.11",
|
||||
"arctic": "^3.7.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"chart.js": "^4.5.1",
|
||||
"date-fns": "^4.1.0",
|
||||
|
|
@ -20,7 +29,6 @@
|
|||
"nanoid": "^5.1.11",
|
||||
"next": "16.2.6",
|
||||
"next-auth": "5.0.0-beta.31",
|
||||
"next-pwa": "^5.6.0",
|
||||
"nodemailer": "^7.0.13",
|
||||
"openai": "^6.37.0",
|
||||
"postgres": "^3.4.9",
|
||||
|
|
@ -30,6 +38,7 @@
|
|||
"react-dom": "19.2.4",
|
||||
"recharts": "^3.8.1",
|
||||
"resend": "^6.12.3",
|
||||
"serwist": "9.5.11",
|
||||
"sharp": "^0.34.5",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
|
|
@ -40,9 +49,12 @@
|
|||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/sharp": "^0.32.0",
|
||||
"@vitest/coverage-v8": "^4.1.7",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"esbuild": "^0.25.12",
|
||||
"tailwindcss": "^4",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.1.7"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
4539
pnpm-lock.yaml
generated
|
|
@ -1,6 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<circle cx="16" cy="16" r="15" fill="#fb7185"/>
|
||||
<circle cx="12" cy="12" r="3" fill="white"/>
|
||||
<circle cx="20" cy="12" r="3" fill="white"/>
|
||||
<path d="M10 20 Q16 26 22 20" stroke="white" stroke-width="2" fill="none"/>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<text y=".9em" font-size="88">🌸</text>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 289 B After Width: | Height: | Size: 114 B |
BIN
public/icons/192.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
public/icons/512.png
Normal file
|
After Width: | Height: | Size: 9.7 KiB |
BIN
public/icons/apple-180.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
public/icons/maskable-512.png
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
BIN
public/images/family-illustration.jpg
Normal file
|
After Width: | Height: | Size: 223 KiB |
BIN
public/images/family-illustration.png
Normal file
|
After Width: | Height: | Size: 2 MiB |
BIN
public/images/memory-bath.jpg
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
public/images/memory-milestone.jpg
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
public/images/tia-portrait.jpg
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
public/images/tia-portrait.png
Normal file
|
After Width: | Height: | Size: 885 KiB |
|
|
@ -1,21 +0,0 @@
|
|||
{
|
||||
"name": "Tia - Baby Tracking",
|
||||
"short_name": "Tia",
|
||||
"description": "Your baby tracking companion",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#fdf2f2",
|
||||
"theme_color": "#fb7185",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
51
public/sw.js
1
public/umami.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
!function(){"use strict";(t=>{const{screen:{width:e,height:a},navigator:{language:r,doNotTrack:n,msDoNotTrack:i},location:o,document:c,history:s,top:u,doNotTrack:d}=t,{currentScript:_cs,referrer:f}=c;const l=_cs||c.querySelector('script[data-website-id]');if(!l)return;const{hostname:h,href:m,origin:p}=o,y=m.startsWith("data:")?void 0:t.localStorage,g="data-",b="true",$=l.getAttribute.bind(l),v=$(`${g}website-id`),w=$(`${g}host-url`),S=$(`${g}before-send`),k=$(`${g}tag`)||void 0,N="false"!==$(`${g}auto-track`),T=$(`${g}do-not-track`)===b,A=$(`${g}exclude-search`)===b,j=$(`${g}exclude-hash`)===b,x=$(`${g}domains`)||"",L=$(`${g}fetch-credentials`)||"omit",E=x.split(",").map(t=>t.trim()),K=`${(w||""||l.src.split("/").slice(0,-1).join("/")).replace(/\/$/,"")}/api/send`,O=`${e}x${a}`,U=/data-umami-event-([\w-_]+)/,_=`${g}umami-event`,D=300,P=t=>{if(!t)return t;try{const e=new URL(t,o.href);return A&&(e.search=""),j&&(e.hash=""),e.toString()}catch{return t}},R=()=>({website:v,screen:O,language:r,title:c.title,hostname:h,url:G,referrer:H,tag:k,id:F||void 0}),W=(t,e,a)=>{a&&(H=G,G=P(new URL(a,o.href).toString()),G!==H&&setTimeout(J,D))},B=()=>Q||!v||y?.getItem("umami.disabled")||x&&!E.includes(h)||T&&(()=>{const t=d||n||i;return 1===t||"1"===t||"yes"===t})(),C=async(e,a="event")=>{if(B())return;const r=t[S];if("function"==typeof r&&(e=await Promise.resolve(r(a,e))),e)try{const t=await fetch(K,{keepalive:!0,method:"POST",body:JSON.stringify({type:a,payload:e}),headers:{"Content-Type":"application/json",...void 0!==z&&{"x-umami-cache":z}},credentials:L}),r=await t.json();r&&(Q=!!r.disabled,z=r.cache)}catch(t){}},I=()=>{M||(M=!0,J(),(()=>{const t=(t,e,a)=>{const r=t[e];return(...e)=>(a.apply(null,e),r.apply(t,e))};s.pushState=t(s,"pushState",W),s.replaceState=t(s,"replaceState",W)})(),(()=>{const t=async t=>{const e=t.getAttribute(_);if(e){const a={};return t.getAttributeNames().forEach(e=>{const r=e.match(U);r&&(a[r[1]]=t.getAttribute(e))}),J(e,a)}};c.addEventListener("click",async e=>{const a=e.target,r=a.closest("a,button");if(!r)return t(a);const{href:n,target:i}=r;if(r.getAttribute(_)){if("BUTTON"===r.tagName)return t(r);if("A"===r.tagName&&n){const a="_blank"===i||e.ctrlKey||e.shiftKey||e.metaKey||e.button&&1===e.button;return a||e.preventDefault(),t(r).then(()=>{a||(("_top"===i?u.location:o).href=n)})}}},!0)})())},J=(t,e)=>C("string"==typeof t?{...R(),name:t,data:e}:"object"==typeof t?{...t}:"function"==typeof t?t(R()):R()),q=(t,e)=>("string"==typeof t&&(F=t),z="",C({...R(),data:"object"==typeof t?t:e},"identify"));t.umami||(t.umami={track:J,identify:q});let z,F,G=P(m),H=P(f.startsWith(p)?"":f),M=!1,Q=!1;N&&!B()&&("complete"===c.readyState?I():c.addEventListener("readystatechange",I,!0))})(window)}();
|
||||
30
scripts/build-migrator.mjs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/**
|
||||
* Bundles src/db/migrate.ts into a single self-contained dist/migrate.mjs.
|
||||
*
|
||||
* Why: the Next.js standalone production image does NOT keep drizzle-orm or
|
||||
* postgres as separate node_modules packages (Next traces them straight into
|
||||
* server.js). The migration runner is a separate entrypoint, so it needs its
|
||||
* own bundle with those deps inlined. esbuild does exactly that.
|
||||
*
|
||||
* esbuild is already available as a transitive dependency of drizzle-kit/tsx,
|
||||
* so this adds no new package to the project.
|
||||
*
|
||||
* Run via: pnpm db:build-migrator (invoked automatically inside the build).
|
||||
*/
|
||||
import { build } from "esbuild";
|
||||
|
||||
await build({
|
||||
entryPoints: ["src/db/migrate.ts"],
|
||||
bundle: true, // inline drizzle-orm + postgres into the output
|
||||
platform: "node",
|
||||
target: "node22",
|
||||
format: "esm",
|
||||
outfile: "dist/migrate.mjs",
|
||||
// postgres ships optional native bits; keep it bundled but let node resolve
|
||||
// built-ins normally. No externals — we want a fully standalone file.
|
||||
banner: {
|
||||
js: "// AUTO-GENERATED by scripts/build-migrator.mjs — do not edit.",
|
||||
},
|
||||
});
|
||||
|
||||
console.log("[build-migrator] dist/migrate.mjs written.");
|
||||
47
scripts/generate-icons.mjs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
// Generates PWA icon PNGs from an SVG template using sharp.
|
||||
// Run: node scripts/generate-icons.mjs
|
||||
import sharp from "sharp";
|
||||
import { writeFileSync } from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const outDir = path.join(__dirname, "../public/icons");
|
||||
|
||||
// Brand colors from the app
|
||||
const BG = "#fb7185"; // rose-400
|
||||
|
||||
// SVG template — rounded square with cherry blossom character.
|
||||
// For maskable: full bleed background, content within safe zone (inner 80%).
|
||||
function makeSvg(size, maskable = false) {
|
||||
const radius = maskable ? 0 : Math.round(size * 0.18);
|
||||
const fontSize = maskable ? Math.round(size * 0.48) : Math.round(size * 0.56);
|
||||
const y = maskable ? Math.round(size * 0.68) : Math.round(size * 0.72);
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
|
||||
<rect width="${size}" height="${size}" rx="${radius}" fill="${BG}"/>
|
||||
<text
|
||||
x="${size / 2}"
|
||||
y="${y}"
|
||||
font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
|
||||
font-weight="700"
|
||||
font-size="${fontSize}"
|
||||
text-anchor="middle"
|
||||
fill="white"
|
||||
dominant-baseline="auto"
|
||||
>T</text>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
const icons = [
|
||||
{ name: "192.png", size: 192, maskable: false },
|
||||
{ name: "512.png", size: 512, maskable: false },
|
||||
{ name: "maskable-512.png", size: 512, maskable: true },
|
||||
{ name: "apple-180.png", size: 180, maskable: false },
|
||||
];
|
||||
|
||||
for (const icon of icons) {
|
||||
const svg = Buffer.from(makeSvg(icon.size, icon.maskable));
|
||||
const outPath = path.join(outDir, icon.name);
|
||||
await sharp(svg).png().toFile(outPath);
|
||||
console.log(`✓ ${icon.name}`);
|
||||
}
|
||||
391
src/__tests__/quota.test.ts
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
/**
|
||||
* quota.test.ts — Unit tests for storage quota and member limit enforcement.
|
||||
*
|
||||
* Pure-function tests run without any DB. DB-bound function tests mock the
|
||||
* @/db module so no real database connection is required.
|
||||
*
|
||||
* Run: pnpm test
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import {
|
||||
isPaidFamily,
|
||||
wouldExceedQuota,
|
||||
isAtMemberLimit,
|
||||
formatBytes,
|
||||
FREE_STORAGE_LIMIT_BYTES,
|
||||
FREE_MEMBER_LIMIT,
|
||||
STORAGE_WARN_THRESHOLD,
|
||||
} from "@/lib/quota";
|
||||
|
||||
// ─── Pure function tests (no DB, no mocks needed) ─────────────────────────────
|
||||
|
||||
describe("isPaidFamily", () => {
|
||||
it("returns false for 'free'", () => {
|
||||
expect(isPaidFamily("free")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for null", () => {
|
||||
expect(isPaidFamily(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for undefined", () => {
|
||||
expect(isPaidFamily(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for 'pro'", () => {
|
||||
expect(isPaidFamily("pro")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for any non-free string", () => {
|
||||
expect(isPaidFamily("paid")).toBe(true);
|
||||
expect(isPaidFamily("enterprise")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("wouldExceedQuota", () => {
|
||||
const LIMIT = FREE_STORAGE_LIMIT_BYTES; // 1 GiB
|
||||
|
||||
it("allows upload when usage + declared <= limit", () => {
|
||||
expect(wouldExceedQuota(900_000_000, 100_000_000, LIMIT)).toBe(false); // exactly 1 GiB
|
||||
});
|
||||
|
||||
it("blocks upload when usage + declared > limit", () => {
|
||||
expect(wouldExceedQuota(900_000_000, 200_000_000, LIMIT)).toBe(true); // over by 100 MB
|
||||
});
|
||||
|
||||
it("blocks when already at limit with any positive declared size", () => {
|
||||
expect(wouldExceedQuota(LIMIT, 1, LIMIT)).toBe(true);
|
||||
});
|
||||
|
||||
it("allows zero-byte declared upload when under limit", () => {
|
||||
expect(wouldExceedQuota(500_000_000, 0, LIMIT)).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks zero-byte declared upload when already over limit (post-downgrade)", () => {
|
||||
// Family was on paid, accumulated > 1 GiB, then downgraded.
|
||||
// Even a 0-byte declared should report over quota.
|
||||
expect(wouldExceedQuota(LIMIT + 1, 0, LIMIT)).toBe(true);
|
||||
});
|
||||
|
||||
it("correctly handles large files within quota", () => {
|
||||
// 950 MB used, 50 MB upload → 1000 MB total, under 1 GiB (1024 MB)
|
||||
expect(wouldExceedQuota(950_000_000, 50_000_000, LIMIT)).toBe(false);
|
||||
});
|
||||
|
||||
it("correctly handles combined memories + attachments sum", () => {
|
||||
// SUM from memories (600 MB) + attachments (200 MB) = 800 MB, + 300 MB upload → over
|
||||
const memoriesBytes = 600_000_000;
|
||||
const attachmentsBytes = 200_000_000;
|
||||
const combined = memoriesBytes + attachmentsBytes;
|
||||
expect(wouldExceedQuota(combined, 300_000_000, LIMIT)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAtMemberLimit", () => {
|
||||
it("blocks at limit on free tier", () => {
|
||||
expect(isAtMemberLimit(2, FREE_MEMBER_LIMIT, false)).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks above limit on free tier", () => {
|
||||
expect(isAtMemberLimit(5, FREE_MEMBER_LIMIT, false)).toBe(true);
|
||||
});
|
||||
|
||||
it("allows below limit on free tier", () => {
|
||||
expect(isAtMemberLimit(1, FREE_MEMBER_LIMIT, false)).toBe(false);
|
||||
});
|
||||
|
||||
it("always allows on paid tier regardless of count", () => {
|
||||
expect(isAtMemberLimit(100, FREE_MEMBER_LIMIT, true)).toBe(false);
|
||||
});
|
||||
|
||||
it("freeze rule: paid → free downgrade with 4 members blocks new invites, not existing members", () => {
|
||||
// After downgrade, all 4 members still exist (roles unchanged — enforced elsewhere).
|
||||
// The limit check sees count=4, limit=2, paid=false → blocked for new invites.
|
||||
expect(isAtMemberLimit(4, 2, false)).toBe(true);
|
||||
// But if tier were still paid, it would be allowed:
|
||||
expect(isAtMemberLimit(4, 2, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatBytes", () => {
|
||||
it("formats bytes", () => {
|
||||
expect(formatBytes(512)).toBe("512 B");
|
||||
});
|
||||
|
||||
it("formats kilobytes", () => {
|
||||
expect(formatBytes(1024)).toBe("1.0 KB");
|
||||
});
|
||||
|
||||
it("formats megabytes", () => {
|
||||
expect(formatBytes(10 * 1024 * 1024)).toBe("10.0 MB");
|
||||
});
|
||||
|
||||
it("formats gigabytes", () => {
|
||||
expect(formatBytes(1_073_741_824)).toBe("1.00 GB");
|
||||
});
|
||||
|
||||
it("formats 0 bytes", () => {
|
||||
expect(formatBytes(0)).toBe("0 B");
|
||||
});
|
||||
});
|
||||
|
||||
describe("constants", () => {
|
||||
it("FREE_STORAGE_LIMIT_BYTES is exactly 1 GiB", () => {
|
||||
expect(FREE_STORAGE_LIMIT_BYTES).toBe(1_073_741_824);
|
||||
});
|
||||
|
||||
it("FREE_MEMBER_LIMIT is 2", () => {
|
||||
expect(FREE_MEMBER_LIMIT).toBe(2);
|
||||
});
|
||||
|
||||
it("STORAGE_WARN_THRESHOLD is 0.8", () => {
|
||||
expect(STORAGE_WARN_THRESHOLD).toBe(0.8);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── DB-bound function tests (mocked DB) ──────────────────────────────────────
|
||||
|
||||
// We mock the @/db module so tests don't need a real Postgres connection.
|
||||
vi.mock("@/db", () => ({
|
||||
sql: vi.fn(),
|
||||
}));
|
||||
|
||||
import { sql } from "@/db";
|
||||
import {
|
||||
getFamilyStorageUsage,
|
||||
checkStorageQuota,
|
||||
checkMemberLimit,
|
||||
getStorageInfo,
|
||||
} from "@/lib/quota";
|
||||
|
||||
const mockSql = sql as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
// Helper: make sql return specific results for tagged template literals.
|
||||
// The postgres.js `sql` is a tagged template function, not a regular function,
|
||||
// but vi.fn() captures the call regardless.
|
||||
function sqlReturns(...results: unknown[][]) {
|
||||
let callIndex = 0;
|
||||
mockSql.mockImplementation((..._args: unknown[]) => {
|
||||
return Promise.resolve(results[callIndex++ % results.length]);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("getFamilyStorageUsage", () => {
|
||||
it("sums memories and attachments correctly", async () => {
|
||||
// memories: 600 MB, attachments: 200 MB → 800 MB total
|
||||
sqlReturns(
|
||||
[{ bytes: "629145600" }], // memories: 600 MB
|
||||
[{ bytes: "209715200" }] // attachments: 200 MB
|
||||
);
|
||||
const usage = await getFamilyStorageUsage("family-a");
|
||||
expect(usage).toBe(629145600 + 209715200);
|
||||
});
|
||||
|
||||
it("returns 0 when both tables are empty", async () => {
|
||||
sqlReturns([{ bytes: "0" }], [{ bytes: "0" }]);
|
||||
expect(await getFamilyStorageUsage("family-empty")).toBe(0);
|
||||
});
|
||||
|
||||
it("tenant isolation: queried with the correct familyId", async () => {
|
||||
sqlReturns([{ bytes: "0" }], [{ bytes: "0" }]);
|
||||
await getFamilyStorageUsage("family-a");
|
||||
// Both SQL calls should have received family-a in their args
|
||||
const calls = mockSql.mock.calls;
|
||||
expect(calls).toHaveLength(2);
|
||||
// The familyId is passed as a tagged template argument; verify it appears
|
||||
const allArgs = calls.flatMap((c: unknown[]) => c);
|
||||
expect(allArgs).toContain("family-a");
|
||||
});
|
||||
|
||||
it("excludes uploading-status memories (counted by WHERE clause in SQL)", async () => {
|
||||
// The WHERE processing_status != 'uploading' is in the SQL template.
|
||||
// Here we verify the SUM result reflects only confirmed uploads.
|
||||
// Simulate: one confirmed memory (100 MB), pending stays excluded via SQL
|
||||
sqlReturns([{ bytes: "104857600" }], [{ bytes: "0" }]);
|
||||
const usage = await getFamilyStorageUsage("family-b");
|
||||
expect(usage).toBe(104857600);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkStorageQuota", () => {
|
||||
it("allows upload when family is under quota", async () => {
|
||||
// family: free tier, 500 MB used
|
||||
sqlReturns(
|
||||
[{ tier: "free" }], // families query
|
||||
[{ bytes: "524288000" }], // memories SUM (500 MB)
|
||||
[{ bytes: "0" }] // attachments SUM
|
||||
);
|
||||
const result = await checkStorageQuota("family-a", 100 * 1024 * 1024); // +100 MB
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks upload when declared size would push over quota", async () => {
|
||||
// family: free tier, 950 MB used; trying to upload 200 MB → would be 1150 MB > 1 GiB
|
||||
sqlReturns(
|
||||
[{ tier: "free" }],
|
||||
[{ bytes: "996147200" }], // 950 MB
|
||||
[{ bytes: "0" }]
|
||||
);
|
||||
const result = await checkStorageQuota("family-a", 200 * 1024 * 1024); // 200 MB
|
||||
expect(result.allowed).toBe(false);
|
||||
if (!result.allowed) {
|
||||
expect(result.reason).toBe("storage_quota_exceeded");
|
||||
expect(result.usedBytes).toBe(996147200);
|
||||
expect(result.limitBytes).toBe(FREE_STORAGE_LIMIT_BYTES);
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks upload when family is already over quota (post-downgrade)", async () => {
|
||||
// Paid family accumulated 2 GB, then downgraded to free.
|
||||
// Even a tiny upload (1 byte) should be blocked.
|
||||
sqlReturns(
|
||||
[{ tier: "free" }],
|
||||
[{ bytes: "2147483648" }], // 2 GB in memories
|
||||
[{ bytes: "0" }]
|
||||
);
|
||||
const result = await checkStorageQuota("family-a", 1);
|
||||
expect(result.allowed).toBe(false);
|
||||
});
|
||||
|
||||
it("allows upload for paid family regardless of usage", async () => {
|
||||
sqlReturns(
|
||||
[{ tier: "pro" }],
|
||||
[{ bytes: "10737418240" }], // 10 GB (way over free limit)
|
||||
[{ bytes: "0" }]
|
||||
);
|
||||
const result = await checkStorageQuota("family-paid", 500 * 1024 * 1024);
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks upload when declared=0 but family is over quota", async () => {
|
||||
sqlReturns(
|
||||
[{ tier: "free" }],
|
||||
[{ bytes: `${FREE_STORAGE_LIMIT_BYTES + 1}` }],
|
||||
[{ bytes: "0" }]
|
||||
);
|
||||
const result = await checkStorageQuota("family-a", 0);
|
||||
expect(result.allowed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkMemberLimit", () => {
|
||||
it("allows invite when count < limit on free tier", async () => {
|
||||
sqlReturns([{ count: "1", max_members: 2, tier: "free" }]);
|
||||
const result = await checkMemberLimit("family-a");
|
||||
expect(result.allowed).toBe(true);
|
||||
if (result.allowed) expect(result.currentCount).toBe(1);
|
||||
});
|
||||
|
||||
it("blocks invite when count = limit on free tier", async () => {
|
||||
sqlReturns([{ count: "2", max_members: 2, tier: "free" }]);
|
||||
const result = await checkMemberLimit("family-a");
|
||||
expect(result.allowed).toBe(false);
|
||||
if (!result.allowed) {
|
||||
expect(result.reason).toBe("member_limit_reached");
|
||||
expect(result.currentCount).toBe(2);
|
||||
expect(result.limit).toBe(2);
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks invite when count > limit (post-downgrade freeze)", async () => {
|
||||
// 4 members from paid tier, now free — new invites blocked, existing retained
|
||||
sqlReturns([{ count: "4", max_members: 2, tier: "free" }]);
|
||||
const result = await checkMemberLimit("family-downgraded");
|
||||
expect(result.allowed).toBe(false);
|
||||
if (!result.allowed) {
|
||||
expect(result.currentCount).toBe(4);
|
||||
expect(result.limit).toBe(2);
|
||||
}
|
||||
});
|
||||
|
||||
it("allows invite on paid tier regardless of count", async () => {
|
||||
sqlReturns([{ count: "50", max_members: 2, tier: "pro" }]);
|
||||
const result = await checkMemberLimit("family-paid");
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it("respects custom max_members from DB (configurable per family)", async () => {
|
||||
// Admin could set max_members=5 for a special promo plan
|
||||
sqlReturns([{ count: "4", max_members: 5, tier: "free" }]);
|
||||
const result = await checkMemberLimit("family-promo");
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getStorageInfo", () => {
|
||||
it("returns approaching=true near 80% usage", async () => {
|
||||
const nearLimit = Math.floor(FREE_STORAGE_LIMIT_BYTES * 0.85);
|
||||
sqlReturns(
|
||||
[{ tier: "free" }],
|
||||
[{ bytes: String(nearLimit) }],
|
||||
[{ bytes: "0" }]
|
||||
);
|
||||
const info = await getStorageInfo("family-a");
|
||||
expect(info.approaching).toBe(true);
|
||||
expect(info.exceeded).toBe(false);
|
||||
});
|
||||
|
||||
it("returns exceeded=true at 100%+ usage", async () => {
|
||||
sqlReturns(
|
||||
[{ tier: "free" }],
|
||||
[{ bytes: String(FREE_STORAGE_LIMIT_BYTES + 1000) }],
|
||||
[{ bytes: "0" }]
|
||||
);
|
||||
const info = await getStorageInfo("family-a");
|
||||
expect(info.exceeded).toBe(true);
|
||||
expect(info.approaching).toBe(false); // exceeded takes over
|
||||
});
|
||||
|
||||
it("returns approaching=false and exceeded=false for paid family", async () => {
|
||||
sqlReturns(
|
||||
[{ tier: "pro" }],
|
||||
[{ bytes: String(FREE_STORAGE_LIMIT_BYTES * 10) }],
|
||||
[{ bytes: "0" }]
|
||||
);
|
||||
const info = await getStorageInfo("family-paid");
|
||||
expect(info.approaching).toBe(false);
|
||||
expect(info.exceeded).toBe(false);
|
||||
expect(info.isPaid).toBe(true);
|
||||
});
|
||||
|
||||
it("over-quota family can still derive usage (read path unblocked)", async () => {
|
||||
// getStorageInfo is the read path for the UI meter — must work even over quota.
|
||||
sqlReturns(
|
||||
[{ tier: "free" }],
|
||||
[{ bytes: "2000000000" }],
|
||||
[{ bytes: "0" }]
|
||||
);
|
||||
const info = await getStorageInfo("family-over");
|
||||
expect(info.usedBytes).toBe(2_000_000_000);
|
||||
expect(info.exceeded).toBe(true);
|
||||
// The read itself succeeds — memories can still be read (enforced at route level)
|
||||
});
|
||||
});
|
||||
|
||||
// ─── RLS / tenant isolation ────────────────────────────────────────────────────
|
||||
|
||||
describe("tenant isolation", () => {
|
||||
it("does not mix usage across families", async () => {
|
||||
// Family A: 900 MB; Family B: 100 MB. Each call should see only its own usage.
|
||||
const familyAUsage = 943718400; // 900 MB
|
||||
const familyBUsage = 104857600; // 100 MB
|
||||
|
||||
mockSql
|
||||
.mockImplementationOnce(() => Promise.resolve([{ bytes: String(familyAUsage) }]))
|
||||
.mockImplementationOnce(() => Promise.resolve([{ bytes: "0" }]))
|
||||
.mockImplementationOnce(() => Promise.resolve([{ bytes: String(familyBUsage) }]))
|
||||
.mockImplementationOnce(() => Promise.resolve([{ bytes: "0" }]));
|
||||
|
||||
const usageA = await getFamilyStorageUsage("family-a");
|
||||
const usageB = await getFamilyStorageUsage("family-b");
|
||||
|
||||
expect(usageA).toBe(familyAUsage);
|
||||
expect(usageB).toBe(familyBUsage);
|
||||
expect(usageA).not.toBe(usageB);
|
||||
});
|
||||
});
|
||||
587
src/app/(app)/activity/page.tsx
Normal file
|
|
@ -0,0 +1,587 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useFamily } from "@/app/FamilyProvider";
|
||||
import { getGuideline, getAgeInMonths } from "@/lib/guidelines";
|
||||
import { api } from "@/lib/api";
|
||||
import { CalendarView } from "@/components/CalendarView";
|
||||
import { LogModal, type LogType as ModalLogType, type SmartDefault } from "@/components/LogModal";
|
||||
import type { Log, LogType } from "@/types";
|
||||
import { dateIST, todayIST, fmtTime, fmtDate, dayLabel } from "@/lib/date-ist";
|
||||
|
||||
type ViewMode = "timeline" | "calendar";
|
||||
|
||||
interface DayLogs {
|
||||
date: string;
|
||||
logs: Log[];
|
||||
}
|
||||
|
||||
function getIcon(type: LogType) {
|
||||
if (type === "feed") return "🍼";
|
||||
if (type === "sleep") return "😴";
|
||||
if (type === "diaper") return "🚼";
|
||||
return "📝";
|
||||
}
|
||||
|
||||
// dateStr is now an IST YYYY-MM-DD string (from dateIST())
|
||||
function formatDayLabel(dateStr: string): string {
|
||||
return dayLabel(dateStr);
|
||||
}
|
||||
|
||||
export default function ActivityPage() {
|
||||
const router = useRouter();
|
||||
const { child, childId: providerChildId } = useFamily();
|
||||
const [view, setView] = useState<ViewMode>("timeline");
|
||||
const [logs, setLogs] = useState<Log[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState<LogType | "all">("all");
|
||||
const [showSuggested, setShowSuggested] = useState(true);
|
||||
const [guideExpanded, setGuideExpanded] = useState(false);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [fabOpen, setFabOpen] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [modalType, setModalType] = useState<ModalLogType | null>(null);
|
||||
const [selectedLog, setSelectedLog] = useState<Log | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState(false);
|
||||
const [smartDefault, setSmartDefault] = useState<SmartDefault | null>(null);
|
||||
const [pendingDeleteId, setPendingDeleteId] = useState<{ id: string; type: string } | null>(null);
|
||||
const [daySheetDate, setDaySheetDate] = useState<{ date: string; type: LogType } | null>(null);
|
||||
const childId = providerChildId ?? "";
|
||||
|
||||
useEffect(() => {
|
||||
if (providerChildId) fetchLogs();
|
||||
}, [providerChildId]);
|
||||
|
||||
const fetchLogs = async () => {
|
||||
if (!childId) return;
|
||||
try {
|
||||
const data = await api.get<{ entries: Log[] }>(`/api/logs?childId=${childId}&limit=200`);
|
||||
setLogs(data.entries || []);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch logs:", err);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const generateHistory = async () => {
|
||||
if (!child) return;
|
||||
setMenuOpen(false);
|
||||
setGenerating(true);
|
||||
try {
|
||||
const data = await api.post<{ success: boolean }>("/api/history", { childId: child.id, birthDate: child.birthDate });
|
||||
if (data.success) fetchLogs();
|
||||
} catch (err) {
|
||||
console.error("Failed to generate history:", err);
|
||||
}
|
||||
setGenerating(false);
|
||||
};
|
||||
|
||||
const deleteLog = async (log: Log) => {
|
||||
try {
|
||||
await fetch(`/api/logs/${log.id}?type=${log.type}`, { method: "DELETE" });
|
||||
setSelectedLog(null);
|
||||
setDeleteConfirm(false);
|
||||
fetchLogs();
|
||||
} catch (err) {
|
||||
console.error("Failed to delete log:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (log: Log) => {
|
||||
// Store the old log to delete after the new one is saved
|
||||
setPendingDeleteId({ id: log.id, type: log.type });
|
||||
setSmartDefault({
|
||||
subType: log.subType ?? "",
|
||||
amountMl: log.amount ?? undefined,
|
||||
editTime: log.loggedAt, // pre-fills the time picker with the original log time
|
||||
} as SmartDefault);
|
||||
setModalType(log.type as ModalLogType);
|
||||
setSelectedLog(null);
|
||||
};
|
||||
|
||||
// Today's stats (computed from loaded logs — no extra fetch)
|
||||
const todayStr = todayIST();
|
||||
const todayLogs = logs.filter(l => dateIST(l.loggedAt) === todayStr);
|
||||
const todayCounts = {
|
||||
feed: todayLogs.filter(l => l.type === "feed").length,
|
||||
sleep: todayLogs.filter(l => l.type === "sleep").length,
|
||||
diaper: todayLogs.filter(l => l.type === "diaper").length,
|
||||
};
|
||||
|
||||
// Last 4 calendar days (computed from loaded logs — no extra fetch)
|
||||
const last4Days = Array.from({ length: 4 }, (_, i) => {
|
||||
const d = new Date(Date.now() - i * 86_400_000);
|
||||
const dateStr = dateIST(d);
|
||||
const dLogs = logs.filter(l => dateIST(l.loggedAt) === dateStr);
|
||||
return {
|
||||
date: dateStr,
|
||||
label: i === 0 ? "Today" : i === 1 ? "Yest." : fmtDate(d, { weekday: "short" }),
|
||||
feed: dLogs.filter(l => l.type === "feed").length,
|
||||
sleep: dLogs.filter(l => l.type === "sleep").length,
|
||||
diaper: dLogs.filter(l => l.type === "diaper").length,
|
||||
};
|
||||
});
|
||||
|
||||
const filteredLogs = filter === "all" ? logs : logs.filter(l => l.type === filter);
|
||||
|
||||
const groupedByDay = filteredLogs.reduce<DayLogs[]>((acc, log) => {
|
||||
const date = dateIST(log.loggedAt);
|
||||
const existing = acc.find(d => d.date === date);
|
||||
if (existing) existing.logs.push(log);
|
||||
else acc.push({ date, logs: [log] });
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const guide = child ? getGuideline(child.birthDate) : null;
|
||||
const ageMonths = child ? getAgeInMonths(child.birthDate) : 0;
|
||||
|
||||
const guideItems = guide ? [
|
||||
{ icon: "🍼", label: "Feeds/day", count: todayCounts.feed, target: guide.feeds.times, barClass: "bg-rose-400", textClass: "text-rose-600 dark:text-rose-400" },
|
||||
{ icon: "😴", label: "Sleep (hrs)", count: todayCounts.sleep, target: guide.sleep.totalHours, barClass: "bg-amber-400", textClass: "text-amber-600 dark:text-amber-400" },
|
||||
{ icon: "🚼", label: "Diapers", count: todayCounts.diaper, target: guide.diapers.count, barClass: "bg-blue-400", textClass: "text-blue-600 dark:text-blue-400" },
|
||||
] : [];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800">
|
||||
{/* Header */}
|
||||
<div className="p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-xl"
|
||||
>←</button>
|
||||
<h1 className="text-xl font-bold">Activity</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* ⋯ overflow menu */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setMenuOpen(o => !o)}
|
||||
className="p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-lg leading-none"
|
||||
>⋯</button>
|
||||
{menuOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setMenuOpen(false)} />
|
||||
<div className="absolute right-0 top-10 z-50 bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-100 dark:border-gray-700 min-w-[200px]">
|
||||
<button
|
||||
onClick={generateHistory}
|
||||
disabled={generating}
|
||||
className="w-full text-left px-4 py-3 text-sm hover:bg-gray-50 dark:hover:bg-gray-700 rounded-xl disabled:opacity-50"
|
||||
>
|
||||
{generating ? "Generating…" : "📋 Generate sample history"}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* View toggle */}
|
||||
<div className="flex bg-white dark:bg-gray-800 rounded-lg p-1">
|
||||
{(["timeline", "calendar"] as const).map(v => (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => setView(v)}
|
||||
className={`px-3 py-1 rounded-md text-sm transition-colors ${
|
||||
view === v ? "bg-rose-400 text-white" : "text-gray-600 dark:text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{v === "timeline" ? "📋" : "📅"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter pills */}
|
||||
<div className="px-4 mb-3 flex gap-2 overflow-x-auto scrollbar-hide pb-1">
|
||||
{([
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "feed", label: "🍼 Feed" },
|
||||
{ value: "sleep", label: "😴 Sleep" },
|
||||
{ value: "diaper", label: "🚼 Diaper" },
|
||||
] as { value: LogType | "all"; label: string }[]).map(f => (
|
||||
<button
|
||||
key={f.value}
|
||||
onClick={() => setFilter(f.value)}
|
||||
className={`px-4 py-2 rounded-full text-sm whitespace-nowrap flex-shrink-0 transition-colors ${
|
||||
filter === f.value ? "bg-rose-400 text-white" : "bg-white dark:bg-gray-800"
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!loading && (
|
||||
<>
|
||||
{/* Collapsible guidelines card — above strip */}
|
||||
{child && guide && showSuggested && (
|
||||
<div className="mx-4 mb-3">
|
||||
<button
|
||||
onClick={() => setGuideExpanded(v => !v)}
|
||||
className="w-full flex items-center justify-between px-4 py-2.5 bg-white dark:bg-gray-800 rounded-2xl shadow-sm"
|
||||
>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 text-left">
|
||||
📋 {child.name} · {ageMonths}mo ·
|
||||
🍼 {todayCounts.feed}/{guide.feeds.times} ·
|
||||
😴 {todayCounts.sleep}/{guide.sleep.totalHours}h ·
|
||||
🚼 {todayCounts.diaper}/{guide.diapers.count}
|
||||
</span>
|
||||
<span className={`text-gray-400 text-xs ml-2 flex-shrink-0 transition-transform ${guideExpanded ? "rotate-180" : ""}`}>▼</span>
|
||||
</button>
|
||||
|
||||
{guideExpanded && (
|
||||
<div className="mt-2 p-4 bg-gradient-to-r from-rose-100 to-amber-100 dark:from-rose-900/40 dark:to-amber-900/40 rounded-2xl">
|
||||
<div className="flex justify-end mb-2">
|
||||
<button onClick={() => setShowSuggested(false)} className="text-gray-400 text-sm">✕ Hide</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{guideItems.map(item => (
|
||||
<div key={item.label} className="text-center">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">{item.label}</div>
|
||||
<div className={`text-lg font-bold ${item.textClass}`}>
|
||||
{item.count}
|
||||
<span className="text-sm font-normal text-gray-400">/{item.target}</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5 mt-1">
|
||||
<div
|
||||
className={`${item.barClass} h-1.5 rounded-full transition-all`}
|
||||
style={{ width: `${Math.min(100, item.target > 0 ? (item.count / item.target) * 100 : 0)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 4-day overview strip — oldest → newest, each row independently tappable */}
|
||||
<div className="grid grid-cols-4 gap-2 px-4 mb-3">
|
||||
{[...last4Days].reverse().map(d => {
|
||||
const isToday = d.label === "Today";
|
||||
const rows: { type: LogType; icon: string; count: number }[] = [
|
||||
{ type: "feed", icon: "🍼", count: d.feed },
|
||||
{ type: "sleep", icon: "😴", count: d.sleep },
|
||||
{ type: "diaper", icon: "🚼", count: d.diaper },
|
||||
];
|
||||
return (
|
||||
<div
|
||||
key={d.label}
|
||||
className={`rounded-xl shadow-sm overflow-hidden ${
|
||||
isToday ? "bg-rose-400" : "bg-white dark:bg-gray-800"
|
||||
}`}
|
||||
>
|
||||
{/* Day label — non-interactive header */}
|
||||
<div className={`text-xs font-semibold text-center py-1.5 ${
|
||||
isToday ? "text-rose-100" : "text-gray-500 dark:text-gray-400"
|
||||
}`}>
|
||||
{d.label}
|
||||
</div>
|
||||
|
||||
{/* Each row is its own tap target */}
|
||||
{rows.map((row, idx) => (
|
||||
<button
|
||||
key={row.type}
|
||||
onClick={() => setDaySheetDate({ date: d.date, type: row.type })}
|
||||
className={`w-full flex items-center justify-center py-1.5 text-xs transition-all active:opacity-60 ${
|
||||
isToday ? "border-t border-rose-300" : "border-t border-gray-100 dark:border-gray-700"
|
||||
} ${
|
||||
isToday
|
||||
? "text-white hover:bg-white/20"
|
||||
: row.count === 0
|
||||
? "text-gray-300 dark:text-gray-600 hover:bg-rose-50 dark:hover:bg-gray-700"
|
||||
: "text-gray-700 dark:text-gray-200 hover:bg-rose-50 dark:hover:bg-gray-700"
|
||||
}`}
|
||||
>
|
||||
{row.icon}×{row.count}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-4 pb-24">
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-3">
|
||||
<div className="flex gap-3 text-3xl">
|
||||
{["🍼", "😴", "🚼"].map((e, i) => (
|
||||
<span key={i} className="animate-bounce" style={{ animationDelay: `${i * 120}ms` }}>{e}</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-sm text-gray-400">Loading activity…</p>
|
||||
</div>
|
||||
) : view === "calendar" ? (
|
||||
<CalendarView logs={logs} filter={filter} />
|
||||
) : groupedByDay.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center px-8">
|
||||
<span className="text-5xl mb-4">📋</span>
|
||||
<p className="font-semibold text-gray-600 dark:text-gray-300">
|
||||
No {filter !== "all" ? filter : ""} logs yet
|
||||
</p>
|
||||
<p className="text-sm text-gray-400 mt-1">Tap + to start logging</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{groupedByDay.map(day => {
|
||||
const label = formatDayLabel(day.date);
|
||||
const isToday = label === "Today";
|
||||
return (
|
||||
<div key={day.date}>
|
||||
<div className={`text-sm mb-2 ${isToday ? "text-rose-500 font-semibold" : "font-medium text-gray-500 dark:text-gray-400"}`}>
|
||||
{label}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{day.logs
|
||||
.sort((a, b) => new Date(b.loggedAt).getTime() - new Date(a.loggedAt).getTime())
|
||||
.map(log => (
|
||||
<button
|
||||
key={log.id}
|
||||
onClick={() => { setSelectedLog(log); setDeleteConfirm(false); }}
|
||||
className="group w-full flex items-center gap-3 p-3 bg-white dark:bg-gray-800 rounded-xl text-left transition-all active:scale-[0.98] hover:bg-rose-50/70 dark:hover:bg-gray-700/80 hover:shadow-sm"
|
||||
>
|
||||
<span className="text-2xl">{getIcon(log.type)}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium capitalize">{log.type}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 truncate">
|
||||
{[
|
||||
log.subType?.replace(/_/g, " "),
|
||||
log.amount ? `${log.amount}ml` : null,
|
||||
log.notes,
|
||||
].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
<span className="text-sm text-gray-400">
|
||||
{fmtTime(log.loggedAt)}
|
||||
</span>
|
||||
<span className="text-gray-300 dark:text-gray-600 group-hover:text-rose-400 transition-colors text-base leading-none">›</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* FAB */}
|
||||
<div className="fixed bottom-24 right-5 flex flex-col items-end gap-2 z-50">
|
||||
{fabOpen && (["feed", "sleep", "diaper"] as ModalLogType[]).map(t => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => { setFabOpen(false); setModalType(t); }}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-gray-800 rounded-full shadow-lg text-sm font-medium capitalize"
|
||||
>
|
||||
<span>{t === "feed" ? "🍼" : t === "sleep" ? "😴" : "🚼"}</span>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setFabOpen(o => !o)}
|
||||
className="w-14 h-14 bg-rose-400 text-white rounded-full shadow-lg flex items-center justify-center text-2xl transition-transform"
|
||||
style={{ transform: fabOpen ? "rotate(45deg)" : "rotate(0deg)" }}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{fabOpen && <div className="fixed inset-0 z-30" onClick={() => setFabOpen(false)} />}
|
||||
|
||||
{/* Log action sheet */}
|
||||
{selectedLog && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50"
|
||||
onClick={() => { setSelectedLog(null); setDeleteConfirm(false); }}
|
||||
/>
|
||||
<div className="fixed bottom-0 inset-x-0 z-50 bg-white dark:bg-gray-900 rounded-t-2xl p-4 pb-10 shadow-xl">
|
||||
{/* Summary row */}
|
||||
<div className="flex items-center gap-3 mb-4 p-3 bg-gray-50 dark:bg-gray-800 rounded-xl">
|
||||
<span className="text-2xl">{getIcon(selectedLog.type)}</span>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium capitalize">{selectedLog.type}</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{[
|
||||
selectedLog.subType?.replace(/_/g, " "),
|
||||
selectedLog.amount ? `${selectedLog.amount}ml` : null,
|
||||
fmtTime(selectedLog.loggedAt),
|
||||
].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!deleteConfirm ? (
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={() => handleEdit(selectedLog)}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 bg-gray-50 dark:bg-gray-800 rounded-xl text-left"
|
||||
>
|
||||
<span className="text-lg">✏️</span>
|
||||
<div>
|
||||
<div className="font-medium">Edit</div>
|
||||
<div className="text-xs text-gray-400">Pre-fills a new log with same values</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(true)}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 bg-red-50 dark:bg-red-900/20 text-red-500 rounded-xl text-left"
|
||||
>
|
||||
<span className="text-lg">🗑️</span>
|
||||
<span className="font-medium">Delete</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedLog(null)}
|
||||
className="w-full py-3 text-gray-400 text-sm text-center"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<p className="text-center text-sm text-gray-600 dark:text-gray-300 py-2">
|
||||
Delete this {selectedLog.type} log?
|
||||
</p>
|
||||
<button
|
||||
onClick={() => deleteLog(selectedLog)}
|
||||
className="w-full py-3 bg-red-500 text-white rounded-xl font-medium"
|
||||
>
|
||||
Yes, delete
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(false)}
|
||||
className="w-full py-3 text-gray-400 text-sm text-center"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Day-type detail sheet — tap a specific row (feed/sleep/diaper) on a day chip */}
|
||||
{daySheetDate && (() => {
|
||||
const { date: sheetDateStr, type: sheetType } = daySheetDate;
|
||||
const sheetLogs = logs
|
||||
.filter(l =>
|
||||
dateIST(l.loggedAt) === sheetDateStr &&
|
||||
l.type === sheetType
|
||||
)
|
||||
.sort((a, b) => new Date(b.loggedAt).getTime() - new Date(a.loggedAt).getTime());
|
||||
const sheetDayLabel = formatDayLabel(sheetDateStr);
|
||||
const sheetTypeIcon = getIcon(sheetType);
|
||||
const sheetTypeLabel = sheetType.charAt(0).toUpperCase() + sheetType.slice(1);
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50"
|
||||
onClick={() => setDaySheetDate(null)}
|
||||
/>
|
||||
<div className="fixed bottom-0 inset-x-0 z-50 bg-white dark:bg-gray-900 rounded-t-2xl shadow-xl flex flex-col max-h-[75vh]">
|
||||
{/* Sheet header */}
|
||||
<div className="flex items-center justify-between px-4 pt-4 pb-3 border-b border-gray-100 dark:border-gray-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl">{sheetTypeIcon}</span>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">
|
||||
{sheetTypeLabel} · {sheetDayLabel}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
{sheetLogs.length === 0
|
||||
? "Nothing logged — tap + to add"
|
||||
: `${sheetLogs.length} entr${sheetLogs.length === 1 ? "y" : "ies"} · tap to edit or delete`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setDaySheetDate(null)}
|
||||
className="p-2 text-gray-400 text-lg"
|
||||
>✕</button>
|
||||
</div>
|
||||
|
||||
{/* Log list */}
|
||||
<div className="overflow-y-auto flex-1 p-4 space-y-2">
|
||||
{sheetLogs.length === 0 ? (
|
||||
<div className="text-center py-10 text-gray-400">
|
||||
<p className="text-4xl mb-2">{sheetTypeIcon}</p>
|
||||
<p className="text-sm font-medium">No {sheetType} logged for {sheetDayLabel}</p>
|
||||
<p className="text-xs mt-1 text-gray-300">
|
||||
Tap + below to add one, or use ⋯ → Generate sample history
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
sheetLogs.map(log => (
|
||||
<button
|
||||
key={log.id}
|
||||
onClick={() => { setSelectedLog(log); setDeleteConfirm(false); setDaySheetDate(null); }}
|
||||
className="group w-full flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-800 rounded-xl text-left transition-all active:scale-[0.98] hover:bg-rose-50 dark:hover:bg-gray-700 hover:shadow-sm"
|
||||
>
|
||||
<span className="text-2xl">{sheetTypeIcon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium capitalize">{log.type}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 truncate">
|
||||
{[
|
||||
log.subType?.replace(/_/g, " "),
|
||||
log.amount ? `${log.amount}ml` : null,
|
||||
log.notes,
|
||||
].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<span className="text-sm text-gray-400">
|
||||
{fmtTime(log.loggedAt)}
|
||||
</span>
|
||||
<span className="text-gray-300 dark:text-gray-600 group-hover:text-rose-400 transition-colors text-lg">›</span>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Single add button for this specific type */}
|
||||
<div className="px-4 py-3 border-t border-gray-100 dark:border-gray-800">
|
||||
<button
|
||||
onClick={() => { setDaySheetDate(null); setModalType(sheetType as ModalLogType); }}
|
||||
className="w-full flex items-center justify-center gap-2 py-3 bg-rose-400 text-white rounded-xl font-medium"
|
||||
>
|
||||
<span>{sheetTypeIcon}</span>
|
||||
<span>+ Add {sheetTypeLabel}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
<LogModal
|
||||
type={modalType}
|
||||
childId={childId}
|
||||
onClose={() => { setModalType(null); setSmartDefault(null); setPendingDeleteId(null); }}
|
||||
onSaved={async () => {
|
||||
// If editing, delete the old log after the new one is saved
|
||||
if (pendingDeleteId) {
|
||||
try {
|
||||
await fetch(`/api/logs/${pendingDeleteId.id}?type=${pendingDeleteId.type}`, { method: "DELETE" });
|
||||
} catch (err) {
|
||||
console.error("Failed to delete old log during edit:", err);
|
||||
}
|
||||
setPendingDeleteId(null);
|
||||
}
|
||||
setSmartDefault(null);
|
||||
fetchLogs();
|
||||
}}
|
||||
smartDefault={smartDefault}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,22 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useFamily } from "../FamilyProvider";
|
||||
|
||||
interface AIChat {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface ChatSession {
|
||||
id: string;
|
||||
title: string;
|
||||
messages: AIChat[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
import { useFamily } from "@/app/FamilyProvider";
|
||||
import { Button, Input, ConfirmDialog } from "@/components/ui";
|
||||
import type { AIChat, ChatSession } from "@/types";
|
||||
import { fmtDate } from "@/lib/date-ist";
|
||||
|
||||
export default function AIChatPage() {
|
||||
const { childId } = useFamily();
|
||||
|
|
@ -63,8 +51,12 @@ export default function AIChatPage() {
|
|||
});
|
||||
const data = await res.json();
|
||||
if (data.session) {
|
||||
setSessions([data.session, ...sessions]);
|
||||
setCurrentSessionId(data.session.id);
|
||||
// POST /api/chat returns a session without a `messages` field — normalize it
|
||||
// to an empty array so the render path (currentSession.messages.length) never
|
||||
// hits `undefined.length`, which crashed the page on the first new chat.
|
||||
const newSession = { ...data.session, messages: [] };
|
||||
setSessions([newSession, ...sessions]);
|
||||
setCurrentSessionId(newSession.id);
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
@ -162,7 +154,7 @@ export default function AIChatPage() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 overflow-hidden">
|
||||
<div className="flex h-screen pb-16 bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 overflow-hidden">
|
||||
|
||||
{/* Sidebar overlay on mobile */}
|
||||
{sidebarOpen && (
|
||||
|
|
@ -182,7 +174,7 @@ export default function AIChatPage() {
|
|||
<a href="/menu" className="text-rose-500 dark:text-rose-400">←</a>
|
||||
<h1 className="font-bold dark:text-white">Chats</h1>
|
||||
</div>
|
||||
<button onClick={createNewSession} className="w-8 h-8 flex items-center justify-center bg-rose-400 text-white rounded-full text-lg leading-none">+</button>
|
||||
<Button size="sm" onClick={createNewSession} className="w-8 h-8 !p-0 rounded-full">+</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
{sessions.length === 0 ? (
|
||||
|
|
@ -199,7 +191,7 @@ export default function AIChatPage() {
|
|||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate dark:text-gray-100">{session.title}</div>
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500">{new Date(session.updatedAt).toLocaleDateString()}</div>
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500">{fmtDate(session.updatedAt)}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setDeleteConfirm(session.id); }}
|
||||
|
|
@ -237,16 +229,14 @@ export default function AIChatPage() {
|
|||
<div className="text-4xl">🤱</div>
|
||||
<p className="text-gray-500 dark:text-gray-400 font-medium">Ask anything about your baby</p>
|
||||
<p className="text-sm text-gray-400 dark:text-gray-500">Tap ☰ to see past chats, or just type below to start</p>
|
||||
<button onClick={createNewSession} className="mt-2 px-5 py-2 bg-rose-400 text-white rounded-full text-sm">
|
||||
New Chat
|
||||
</button>
|
||||
<Button onClick={createNewSession} className="mt-2 rounded-full">New Chat</Button>
|
||||
</div>
|
||||
) : currentSession.messages.length === 0 ? (
|
||||
) : (currentSession.messages?.length ?? 0) === 0 ? (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center gap-2">
|
||||
<p className="text-gray-400 dark:text-gray-500 text-sm">Type a question below to get started</p>
|
||||
</div>
|
||||
) : (
|
||||
currentSession.messages.map((msg, i) => (
|
||||
(currentSession.messages ?? []).map((msg, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
|
||||
|
|
@ -277,39 +267,35 @@ export default function AIChatPage() {
|
|||
|
||||
{/* Input */}
|
||||
<div className="p-4 border-t bg-white dark:bg-gray-800 flex-shrink-0">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={e => e.key === "Enter" && !e.shiftKey && handleSend()}
|
||||
placeholder="Ask about your baby..."
|
||||
className="flex-1 p-3 border dark:border-gray-600 rounded-xl dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 text-sm focus:outline-none focus:ring-2 focus:ring-rose-300"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={loading || !input.trim()}
|
||||
className={`px-4 py-2 text-white rounded-xl text-sm font-medium transition-colors ${loading || !input.trim() ? "bg-gray-200 dark:bg-gray-600 text-gray-400 cursor-not-allowed" : "bg-rose-400 hover:bg-rose-500"}`}
|
||||
>
|
||||
{loading ? "..." : "Send"}
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Input renders its own wrapper div, so flex-1 must go on a wrapper
|
||||
here — putting it on <Input> only hits the inner <input> (already
|
||||
w-full) and the wrapper stays content-width, leaving it narrow. */}
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={e => e.key === "Enter" && !e.shiftKey && handleSend()}
|
||||
placeholder="Ask about your baby..."
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleSend} disabled={loading || !input.trim()} loading={loading}>
|
||||
Send
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete confirm modal */}
|
||||
{deleteConfirm && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 p-6 rounded-2xl w-72 shadow-xl">
|
||||
<p className="mb-4 dark:text-white font-medium">Delete this conversation?</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-5">This can't be undone.</p>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => deleteSession(deleteConfirm)} className="flex-1 py-2 bg-red-500 text-white rounded-xl text-sm">Delete</button>
|
||||
<button onClick={() => setDeleteConfirm(null)} className="flex-1 py-2 bg-gray-100 dark:bg-gray-700 dark:text-white rounded-xl text-sm">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={!!deleteConfirm}
|
||||
onClose={() => setDeleteConfirm(null)}
|
||||
onConfirm={() => deleteConfirm && deleteSession(deleteConfirm)}
|
||||
title="Delete this conversation?"
|
||||
description="This can't be undone."
|
||||
confirmLabel="Delete"
|
||||
variant="danger"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
677
src/app/(app)/circle/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,677 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, use } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useFamily } from "@/app/FamilyProvider";
|
||||
import type { CirclePost, CircleComment, Circle } from "@/types";
|
||||
|
||||
const REACTIONS = ["❤️", "😂", "👍", "🙏", "😮"];
|
||||
|
||||
function timeAgo(iso: string) {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const m = Math.floor(diff / 60000);
|
||||
if (m < 1) return "just now";
|
||||
if (m < 60) return `${m}m`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h`;
|
||||
return `${Math.floor(h / 24)}d`;
|
||||
}
|
||||
|
||||
// ── Post card ─────────────────────────────────────────────────────────────────
|
||||
function PostCard({
|
||||
post, myFamilyId, circleId, isAdmin,
|
||||
onDeleted, onReact,
|
||||
}: {
|
||||
post: CirclePost;
|
||||
myFamilyId: string;
|
||||
circleId: string;
|
||||
isAdmin: boolean;
|
||||
onDeleted: (id: string) => void;
|
||||
onReact: (postId: string, emoji: string) => void;
|
||||
}) {
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
const [showComments, setShowComments] = useState(false);
|
||||
const [comments, setComments] = useState<CircleComment[]>([]);
|
||||
const [commentText, setCommentText] = useState("");
|
||||
const [posting, setPosting] = useState(false);
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const [reportSent, setReportSent] = useState(false);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState(false);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [editBody, setEditBody] = useState(post.body ?? "");
|
||||
const [savingEdit, setSavingEdit] = useState(false);
|
||||
const [lightbox, setLightbox] = useState(false);
|
||||
|
||||
const loadComments = async () => {
|
||||
const res = await fetch(`/api/circles/${circleId}/posts/${post.id}/comments`);
|
||||
const data = await res.json();
|
||||
setComments(data.comments ?? []);
|
||||
};
|
||||
|
||||
const toggleComments = () => {
|
||||
if (!showComments) loadComments();
|
||||
setShowComments(v => !v);
|
||||
};
|
||||
|
||||
const addComment = async () => {
|
||||
if (!commentText.trim()) return;
|
||||
setPosting(true);
|
||||
await fetch(`/api/circles/${circleId}/posts/${post.id}/comments`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ body: commentText.trim() }),
|
||||
});
|
||||
setCommentText("");
|
||||
loadComments();
|
||||
setPosting(false);
|
||||
};
|
||||
|
||||
const sendReport = async (reason: string) => {
|
||||
await fetch(`/api/circles/${circleId}/posts/${post.id}/report`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ reason }),
|
||||
});
|
||||
setReportSent(true);
|
||||
setShowMenu(false);
|
||||
};
|
||||
|
||||
const deletePost = async () => {
|
||||
await fetch(`/api/circles/${circleId}/posts/${post.id}`, { method: "DELETE" });
|
||||
onDeleted(post.id);
|
||||
};
|
||||
|
||||
const saveEdit = async () => {
|
||||
setSavingEdit(true);
|
||||
await fetch(`/api/circles/${circleId}/posts/${post.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ body: editBody }),
|
||||
});
|
||||
post.body = editBody; // optimistic local update
|
||||
setEditMode(false);
|
||||
setSavingEdit(false);
|
||||
};
|
||||
|
||||
const isOwn = post.authorFamilyId === myFamilyId;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Fullscreen image lightbox */}
|
||||
{lightbox && post.imageUrl && (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] bg-black/90 flex items-center justify-center p-4"
|
||||
onClick={() => setLightbox(false)}
|
||||
>
|
||||
<img
|
||||
src={post.imageUrl}
|
||||
alt="Full size"
|
||||
className="max-w-full max-h-full object-contain rounded-xl"
|
||||
/>
|
||||
<button className="absolute top-4 right-4 text-white text-2xl bg-black/40 rounded-full w-10 h-10 flex items-center justify-center">✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-sm">
|
||||
{/* Author row — tap to collapse/expand */}
|
||||
<div className="flex items-center justify-between px-4 pt-3 pb-2">
|
||||
<button
|
||||
onClick={() => setCollapsed(v => !v)}
|
||||
className="flex items-center gap-2 flex-1 min-w-0 text-left"
|
||||
>
|
||||
<div className="w-8 h-8 bg-rose-100 dark:bg-rose-900/40 rounded-full flex items-center justify-center text-sm flex-shrink-0">👨👩👧</div>
|
||||
{collapsed ? (
|
||||
/* Single-row collapsed: name · preview text · thumbnail · chevron */
|
||||
<>
|
||||
<span className="text-sm font-medium flex-shrink-0">{post.authorFamilyName}</span>
|
||||
{(post.body || post.imageUrl) && <span className="text-gray-300 text-xs flex-shrink-0">·</span>}
|
||||
{post.body && <span className="text-xs text-gray-400 truncate flex-1 min-w-0">{post.body}</span>}
|
||||
{!post.body && <span className="flex-1" />}
|
||||
{post.imageUrl && <img src={post.imageUrl} alt="" className="w-7 h-7 rounded-md object-cover flex-shrink-0" />}
|
||||
</>
|
||||
) : (
|
||||
/* Expanded: two-line author block */
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">{post.authorFamilyName}</p>
|
||||
<p className="text-xs text-gray-400">{timeAgo(post.createdAt)}{post.sourceKind ? ` · shared a ${post.sourceKind}` : ""}</p>
|
||||
</div>
|
||||
)}
|
||||
<span className="text-gray-300 text-xs flex-shrink-0 ml-1">{collapsed ? "▼" : "▲"}</span>
|
||||
</button>
|
||||
{/* ⋯ menu — rendered outside overflow:hidden so it doesn't get clipped */}
|
||||
<div className="relative">
|
||||
<button onClick={() => setShowMenu(v => !v)} className="p-2 text-gray-400 text-lg">⋯</button>
|
||||
{showMenu && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setShowMenu(false)} />
|
||||
<div className="absolute right-0 top-9 z-50 bg-white dark:bg-gray-900 rounded-xl shadow-xl border border-gray-100 dark:border-gray-700 min-w-[170px] py-1">
|
||||
{isOwn && (
|
||||
<button
|
||||
onClick={() => { setShowMenu(false); setCollapsed(false); setEditMode(true); setEditBody(post.body ?? ""); }}
|
||||
className="w-full text-left px-4 py-2.5 text-sm hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>✏️ Edit post</button>
|
||||
)}
|
||||
{(isOwn || isAdmin) && (
|
||||
<button
|
||||
onClick={() => { setShowMenu(false); setCollapsed(false); setDeleteConfirm(true); }}
|
||||
className="w-full text-left px-4 py-2.5 text-sm text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
>🗑️ Delete post</button>
|
||||
)}
|
||||
{!isOwn && !reportSent && (
|
||||
<button
|
||||
onClick={() => sendReport("inappropriate")}
|
||||
className="w-full text-left px-4 py-2.5 text-sm hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>🚩 Report post</button>
|
||||
)}
|
||||
{reportSent && <p className="px-4 py-2.5 text-xs text-gray-400">Reported — thank you</p>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Collapsed: no extra row needed — everything is in the author row */}
|
||||
|
||||
{/* Expanded content */}
|
||||
{!collapsed && <>
|
||||
|
||||
{/* Delete confirmation */}
|
||||
{deleteConfirm && (
|
||||
<div className="mx-4 mb-3 p-3 bg-red-50 dark:bg-red-900/20 rounded-xl space-y-2">
|
||||
<p className="text-sm text-red-600 font-medium">Delete this post?</p>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={deletePost} className="flex-1 py-1.5 bg-red-500 text-white rounded-lg text-sm">Delete</button>
|
||||
<button onClick={() => setDeleteConfirm(false)} className="flex-1 py-1.5 text-gray-400 text-sm">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body — inline edit or display */}
|
||||
{editMode ? (
|
||||
<div className="px-4 pb-3 space-y-2">
|
||||
<textarea
|
||||
value={editBody}
|
||||
onChange={e => setEditBody(e.target.value)}
|
||||
rows={3}
|
||||
autoFocus
|
||||
className="w-full px-3 py-2 bg-gray-50 dark:bg-gray-700 rounded-xl text-sm outline-none resize-none border border-rose-300"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={saveEdit}
|
||||
disabled={savingEdit}
|
||||
className="flex-1 py-1.5 bg-rose-400 text-white rounded-lg text-sm disabled:opacity-50"
|
||||
>{savingEdit ? "Saving…" : "Save"}</button>
|
||||
<button onClick={() => setEditMode(false)} className="flex-1 py-1.5 text-gray-400 text-sm">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
post.body && <p className="px-4 pb-3 text-sm leading-relaxed">{post.body}</p>
|
||||
)}
|
||||
|
||||
{/* Image — contain (no crop) + tap for fullscreen */}
|
||||
{post.imageUrl && (
|
||||
<div className="overflow-hidden rounded-none mx-0 mb-0">
|
||||
<img
|
||||
src={post.imageUrl}
|
||||
alt="Post"
|
||||
onClick={() => setLightbox(true)}
|
||||
className="w-full max-h-80 object-contain bg-gray-50 dark:bg-gray-900 cursor-pointer"
|
||||
/>
|
||||
<p className="text-center text-xs text-gray-400 py-1">Tap to view full size</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reactions */}
|
||||
<div className="px-4 py-2 flex items-center gap-1 flex-wrap border-t border-gray-50 dark:border-gray-700">
|
||||
{REACTIONS.map(emoji => {
|
||||
const r = post.reactions.find(x => x.emoji === emoji);
|
||||
return (
|
||||
<button
|
||||
key={emoji}
|
||||
onClick={() => onReact(post.id, emoji)}
|
||||
className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs transition-colors ${
|
||||
r?.includedMe
|
||||
? "bg-rose-100 dark:bg-rose-900/40 text-rose-600"
|
||||
: "bg-gray-50 dark:bg-gray-700 text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{emoji}{r?.count ? ` ${r.count}` : ""}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
onClick={toggleComments}
|
||||
className="ml-auto flex items-center gap-1 text-xs text-gray-400 px-2 py-1"
|
||||
>
|
||||
💬 {post.commentCount > 0 ? post.commentCount : ""} {showComments ? "▲" : "▼"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Comments */}
|
||||
{showComments && (
|
||||
<div className="border-t border-gray-50 dark:border-gray-700 px-4 py-3 space-y-3">
|
||||
{comments.map(c => (
|
||||
<div key={c.id} className="flex gap-2">
|
||||
<div className="w-7 h-7 bg-rose-50 dark:bg-rose-900/30 rounded-full flex items-center justify-center text-xs flex-shrink-0">👨👩👧</div>
|
||||
<div className="flex-1 bg-gray-50 dark:bg-gray-700 rounded-xl px-3 py-2">
|
||||
<p className="text-xs font-medium text-gray-600 dark:text-gray-300">{c.authorFamilyName}</p>
|
||||
<p className="text-sm">{c.body}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<input
|
||||
id="comment-input"
|
||||
name="comment-input"
|
||||
autoComplete="off"
|
||||
value={commentText}
|
||||
onChange={e => setCommentText(e.target.value)}
|
||||
onKeyDown={e => e.key === "Enter" && addComment()}
|
||||
placeholder="Add a comment…"
|
||||
className="flex-1 px-3 py-2 bg-gray-50 dark:bg-gray-700 rounded-xl text-sm outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={addComment}
|
||||
disabled={posting || !commentText.trim()}
|
||||
className="px-3 py-2 bg-rose-400 text-white rounded-xl text-sm disabled:opacity-40"
|
||||
>→</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>} {/* end !collapsed */}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Create Post Modal (C5 + C9 consent) ──────────────────────────────────────
|
||||
function CreatePostModal({
|
||||
circleId, circleName, memberCount,
|
||||
onClose, onPosted,
|
||||
}: {
|
||||
circleId: string;
|
||||
circleName: string;
|
||||
memberCount: number;
|
||||
onClose: () => void;
|
||||
onPosted: () => void;
|
||||
}) {
|
||||
const [body, setBody] = useState("");
|
||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
const [posting, setPosting] = useState(false);
|
||||
const [step, setStep] = useState<"compose" | "confirm">("compose");
|
||||
const [postError, setPostError] = useState<string | null>(null);
|
||||
|
||||
const pickImage = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (!f) return;
|
||||
setImageFile(f);
|
||||
setImagePreview(URL.createObjectURL(f));
|
||||
setPostError(null);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
setPosting(true);
|
||||
setPostError(null);
|
||||
try {
|
||||
let tmpKey: string | null = null;
|
||||
|
||||
if (imageFile) {
|
||||
// Upload via server (avoids CORS issues with direct R2 PUT)
|
||||
const fd = new FormData();
|
||||
fd.append("file", imageFile);
|
||||
const uploadRes = await fetch(`/api/circles/${circleId}/posts/upload`, {
|
||||
method: "POST",
|
||||
body: fd,
|
||||
});
|
||||
const uploadData = await uploadRes.json();
|
||||
if (!uploadRes.ok) {
|
||||
setPostError(uploadData.error ?? "Image upload failed");
|
||||
setPosting(false);
|
||||
return;
|
||||
}
|
||||
tmpKey = uploadData.tmpKey;
|
||||
}
|
||||
|
||||
const postRes = await fetch(`/api/circles/${circleId}/posts`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ postBody: body, tmpKey }),
|
||||
});
|
||||
const postData = await postRes.json();
|
||||
if (!postRes.ok) {
|
||||
setPostError(postData.error ?? "Failed to create post");
|
||||
setPosting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
onPosted();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setPostError(err instanceof Error ? err.message : "Something went wrong");
|
||||
}
|
||||
setPosting(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 bg-black/50 z-50" onClick={onClose} />
|
||||
<div className="fixed bottom-0 inset-x-0 z-50 bg-white dark:bg-gray-900 rounded-t-2xl p-4 pb-10 shadow-xl max-h-[85vh] flex flex-col">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold">{step === "confirm" ? "Confirm post" : "New post"}</h3>
|
||||
<button onClick={onClose} className="text-gray-400">✕</button>
|
||||
</div>
|
||||
|
||||
{step === "compose" ? (
|
||||
<>
|
||||
<textarea
|
||||
id="post-body"
|
||||
name="post-body"
|
||||
autoFocus
|
||||
value={body}
|
||||
onChange={e => setBody(e.target.value)}
|
||||
placeholder="What's on your mind?"
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 bg-gray-50 dark:bg-gray-700 rounded-xl text-sm outline-none resize-none mb-3"
|
||||
/>
|
||||
{imagePreview && (
|
||||
<div className="relative mb-3">
|
||||
<img src={imagePreview} alt="Preview" className="w-full max-h-48 object-cover rounded-xl" />
|
||||
<button
|
||||
onClick={() => { setImageFile(null); setImagePreview(null); }}
|
||||
className="absolute top-2 right-2 w-6 h-6 bg-black/50 text-white rounded-full text-xs"
|
||||
>✕</button>
|
||||
</div>
|
||||
)}
|
||||
<label className="flex items-center gap-2 text-sm text-gray-500 cursor-pointer mb-4">
|
||||
<span className="text-xl">📷</span> Add photo
|
||||
<input id="post-image" name="post-image" type="file" accept="image/*" className="hidden" onChange={pickImage} />
|
||||
</label>
|
||||
<button
|
||||
onClick={() => setStep("confirm")}
|
||||
disabled={!body.trim() && !imageFile}
|
||||
className="w-full py-3 bg-rose-400 text-white rounded-xl font-medium disabled:opacity-40"
|
||||
>Continue →</button>
|
||||
</>
|
||||
) : (
|
||||
/* C9 — Explicit consent surface */
|
||||
<div className="flex-1 flex flex-col gap-4">
|
||||
<div className="p-4 bg-amber-50 dark:bg-amber-900/20 rounded-2xl border border-amber-200 dark:border-amber-700">
|
||||
<p className="text-sm font-semibold text-amber-800 dark:text-amber-200 mb-1">📣 Heads up</p>
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300">
|
||||
This will be visible to all <strong>{memberCount} {memberCount === 1 ? "family" : "families"}</strong> in{" "}
|
||||
<strong>{circleName}</strong>.{" "}
|
||||
{imageFile && "The photo you selected will also be shared. "}
|
||||
Once posted, other members can see it.
|
||||
</p>
|
||||
</div>
|
||||
{body && <p className="text-sm bg-gray-50 dark:bg-gray-700 rounded-xl px-3 py-2 italic text-gray-600 dark:text-gray-300">"{body}"</p>}
|
||||
{postError && (
|
||||
<p className="text-sm text-red-600 bg-red-50 dark:bg-red-900/20 rounded-xl px-3 py-2 mb-2">
|
||||
✗ {postError}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-3 mt-auto">
|
||||
<button onClick={() => setStep("compose")} className="flex-1 py-3 border border-gray-200 dark:border-gray-600 rounded-xl text-sm">← Edit</button>
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={posting}
|
||||
className="flex-1 py-3 bg-rose-400 text-white rounded-xl font-medium disabled:opacity-50"
|
||||
>{posting ? "Posting…" : "Post to Circle ✓"}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Circle feed page ──────────────────────────────────────────────────────────
|
||||
export default function CircleFeedPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id: circleId } = use(params);
|
||||
const router = useRouter();
|
||||
const { familyId } = useFamily();
|
||||
|
||||
const [circle, setCircle] = useState<Circle | null>(null);
|
||||
const [posts, setPosts] = useState<CirclePost[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [showMembers, setShowMembers] = useState(false);
|
||||
const [members, setMembers] = useState<{ familyId: string; familyName: string; role: string }[]>([]);
|
||||
const [myRole, setMyRole] = useState<"admin" | "member">("member");
|
||||
const [showInviteModal, setShowInviteModal] = useState(false);
|
||||
const [inviteEmail, setInviteEmail] = useState("");
|
||||
const [inviteSending, setInviteSending] = useState(false);
|
||||
const [inviteResult, setInviteResult] = useState<{ type: "success" | "error"; msg: string } | null>(null);
|
||||
|
||||
const fetchFeed = useCallback(async () => {
|
||||
try {
|
||||
const [circleRes, postsRes] = await Promise.all([
|
||||
fetch(`/api/circles/${circleId}`),
|
||||
fetch(`/api/circles/${circleId}/posts`),
|
||||
]);
|
||||
const circleData = await circleRes.json();
|
||||
const postsData = await postsRes.json();
|
||||
if (circleData.circle) {
|
||||
setCircle(circleData.circle);
|
||||
setMembers(circleData.members ?? []);
|
||||
setMyRole(circleData.circle.role ?? "member");
|
||||
}
|
||||
setPosts(postsData.posts ?? []);
|
||||
} catch { /* silent */ }
|
||||
setLoading(false);
|
||||
}, [circleId]);
|
||||
|
||||
useEffect(() => { fetchFeed(); }, [fetchFeed]);
|
||||
|
||||
const handleReact = async (postId: string, emoji: string) => {
|
||||
await fetch(`/api/circles/${circleId}/posts/${postId}/reactions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ emoji }),
|
||||
});
|
||||
// Refresh just this post's reactions
|
||||
fetchFeed();
|
||||
};
|
||||
|
||||
const handleInviteSend = async () => {
|
||||
if (!inviteEmail.trim()) return;
|
||||
setInviteSending(true);
|
||||
setInviteResult(null);
|
||||
try {
|
||||
const res = await fetch(`/api/circles/${circleId}/invite`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: inviteEmail.trim() }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
const msg = data.type === "in_app"
|
||||
? `Invite sent! ${inviteEmail} will see it on their Circles page.`
|
||||
: `Invite email sent to ${inviteEmail}!`;
|
||||
setInviteResult({ type: "success", msg });
|
||||
setInviteEmail("");
|
||||
} else {
|
||||
setInviteResult({ type: "error", msg: data.error ?? "Failed to send invite" });
|
||||
}
|
||||
} catch {
|
||||
setInviteResult({ type: "error", msg: "Something went wrong" });
|
||||
}
|
||||
setInviteSending(false);
|
||||
};
|
||||
|
||||
const handleLeave = async () => {
|
||||
if (!confirm("Leave this circle?")) return;
|
||||
const res = await fetch(`/api/circles/${circleId}/members`, { method: "DELETE" });
|
||||
const data = await res.json();
|
||||
if (data.success) router.push("/circle");
|
||||
else alert(data.error);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 flex items-center justify-center">
|
||||
<div className="flex gap-3 text-3xl">
|
||||
{["👨👩👧", "💬", "❤️"].map((e, i) => (
|
||||
<span key={i} className="animate-bounce" style={{ animationDelay: `${i * 120}ms` }}>{e}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!circle) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center text-gray-400">
|
||||
<div className="text-center">
|
||||
<p className="text-4xl mb-2">🔒</p>
|
||||
<p>Circle not found or you are not a member.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 pb-24">
|
||||
{/* Header */}
|
||||
<div className="p-4 flex items-center gap-3">
|
||||
<button onClick={() => router.push("/circle")} className="p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-xl">←</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-lg font-bold truncate">{circle.name}</h1>
|
||||
<p className="text-xs text-gray-400">{circle.memberCount} {circle.memberCount === 1 ? "family" : "families"}</p>
|
||||
</div>
|
||||
{/* Members panel toggle */}
|
||||
<button
|
||||
onClick={() => setShowMembers(v => !v)}
|
||||
className="p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-sm"
|
||||
>👥</button>
|
||||
{/* Admin: invite */}
|
||||
{myRole === "admin" && (
|
||||
<button
|
||||
onClick={() => { setShowInviteModal(true); setInviteResult(null); setInviteEmail(""); }}
|
||||
className="px-3 py-2 bg-rose-400 text-white rounded-xl text-sm font-medium"
|
||||
>+ Invite</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Members panel */}
|
||||
{showMembers && (
|
||||
<div className="mx-4 mb-4 bg-white dark:bg-gray-800 rounded-2xl shadow-sm p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="font-medium text-sm">Members</p>
|
||||
<button onClick={handleLeave} className="text-xs text-red-400">Leave circle</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{members.map(m => (
|
||||
<div key={m.familyId} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 bg-rose-100 dark:bg-rose-900/40 rounded-full flex items-center justify-center text-xs">👨👩👧</div>
|
||||
<span className="text-sm">{m.familyName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${m.role === "admin" ? "bg-rose-100 text-rose-600" : "bg-gray-100 text-gray-500"}`}>
|
||||
{m.role}
|
||||
</span>
|
||||
{myRole === "admin" && m.familyId !== familyId && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
await fetch(`/api/circles/${circleId}/members/${m.familyId}`, { method: "DELETE" });
|
||||
fetchFeed();
|
||||
}}
|
||||
className="text-xs text-red-400"
|
||||
>Remove</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Posts feed */}
|
||||
<div className="px-4 space-y-4">
|
||||
{posts.length === 0 ? (
|
||||
<div className="text-center py-16 px-8">
|
||||
<p className="text-4xl mb-3">💬</p>
|
||||
<p className="font-semibold text-gray-700 dark:text-gray-200">No posts yet</p>
|
||||
<p className="text-sm text-gray-400 mt-1">Be the first to share something with {circle.name}!</p>
|
||||
</div>
|
||||
) : (
|
||||
posts.map(p => (
|
||||
<PostCard
|
||||
key={p.id}
|
||||
post={p}
|
||||
myFamilyId={familyId ?? ""}
|
||||
circleId={circleId}
|
||||
isAdmin={myRole === "admin"}
|
||||
onDeleted={id => setPosts(prev => prev.filter(x => x.id !== id))}
|
||||
onReact={handleReact}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* FAB — create post */}
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="fixed bottom-24 right-5 w-14 h-14 bg-rose-400 text-white rounded-full shadow-lg flex items-center justify-center text-2xl z-50"
|
||||
>+</button>
|
||||
|
||||
{showCreate && circle && familyId && (
|
||||
<CreatePostModal
|
||||
circleId={circleId}
|
||||
circleName={circle.name}
|
||||
memberCount={circle.memberCount}
|
||||
onClose={() => setShowCreate(false)}
|
||||
onPosted={fetchFeed}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Invite by email modal */}
|
||||
{showInviteModal && (
|
||||
<>
|
||||
<div className="fixed inset-0 bg-black/50 z-50" onClick={() => setShowInviteModal(false)} />
|
||||
<div className="fixed bottom-0 inset-x-0 z-50 bg-white dark:bg-gray-900 rounded-t-2xl p-5 pb-10 shadow-xl">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold">Invite to {circle?.name}</h3>
|
||||
<button onClick={() => setShowInviteModal(false)} className="text-gray-400 text-xl">✕</button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Enter the email address of the person you want to invite.
|
||||
If they already have a Tia account they'll get a notification;
|
||||
otherwise we'll email them a link to sign up and join.
|
||||
</p>
|
||||
<input
|
||||
id="invite-email"
|
||||
name="invite-email"
|
||||
type="email"
|
||||
autoFocus
|
||||
value={inviteEmail}
|
||||
onChange={e => setInviteEmail(e.target.value)}
|
||||
onKeyDown={e => e.key === "Enter" && handleInviteSend()}
|
||||
placeholder="their@email.com"
|
||||
className="w-full px-4 py-3 bg-gray-50 dark:bg-gray-700 rounded-xl text-sm outline-none mb-3 border border-gray-200 dark:border-gray-600"
|
||||
/>
|
||||
{inviteResult && (
|
||||
<p className={`text-sm mb-3 px-3 py-2 rounded-xl ${
|
||||
inviteResult.type === "success"
|
||||
? "bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300"
|
||||
: "bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-300"
|
||||
}`}>
|
||||
{inviteResult.type === "success" ? "✓ " : "✗ "}{inviteResult.msg}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
onClick={handleInviteSend}
|
||||
disabled={inviteSending || !inviteEmail.trim()}
|
||||
className="w-full py-3 bg-rose-400 text-white rounded-xl font-medium disabled:opacity-40"
|
||||
>
|
||||
{inviteSending ? "Sending…" : "Send Invite"}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
124
src/app/(app)/circle/join/[token]/page.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, use } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useFamily } from "@/app/FamilyProvider";
|
||||
|
||||
type State = "loading" | "preview" | "joining" | "success" | "error";
|
||||
|
||||
export default function JoinCirclePage({ params }: { params: Promise<{ token: string }> }) {
|
||||
const { token } = use(params);
|
||||
const router = useRouter();
|
||||
const { familyId, loading: authLoading } = useFamily();
|
||||
|
||||
const [state, setState] = useState<State>("loading");
|
||||
const [circleName, setCircleName] = useState("");
|
||||
const [memberCount, setMemberCount] = useState(0);
|
||||
const [errorMsg, setErrorMsg] = useState("");
|
||||
|
||||
// Preview the circle info (no auth required)
|
||||
useEffect(() => {
|
||||
fetch(`/api/circle/join/${token}`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.error) { setErrorMsg(data.error); setState("error"); return; }
|
||||
setCircleName(data.circleName);
|
||||
setMemberCount(data.memberCount);
|
||||
setState("preview");
|
||||
})
|
||||
.catch(() => { setErrorMsg("Could not load this invite."); setState("error"); });
|
||||
}, [token]);
|
||||
|
||||
const joinCircle = async () => {
|
||||
if (!familyId) {
|
||||
// Not logged in — redirect to login with return URL
|
||||
router.push(`/login?next=/circle/join/${token}`);
|
||||
return;
|
||||
}
|
||||
setState("joining");
|
||||
try {
|
||||
const res = await fetch(`/api/circle/join/${token}`, { method: "POST" });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setState("success");
|
||||
setTimeout(() => router.push(`/circle/${data.circleId}`), 1500);
|
||||
} else {
|
||||
setErrorMsg(data.error ?? "Could not join circle.");
|
||||
setState("error");
|
||||
}
|
||||
} catch {
|
||||
setErrorMsg("Something went wrong. Please try again.");
|
||||
setState("error");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 flex items-center justify-center p-6">
|
||||
<div className="w-full max-w-sm bg-white dark:bg-gray-800 rounded-3xl shadow-xl p-6 text-center">
|
||||
|
||||
{(state === "loading" || authLoading) && (
|
||||
<>
|
||||
<div className="flex justify-center gap-2 text-3xl mb-4">
|
||||
{["👨👩👧", "❤️", "👶"].map((e, i) => (
|
||||
<span key={i} className="animate-bounce" style={{ animationDelay: `${i * 120}ms` }}>{e}</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-gray-500">Loading invite…</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === "preview" && !authLoading && (
|
||||
<>
|
||||
<div className="w-16 h-16 bg-rose-100 dark:bg-rose-900/40 rounded-full flex items-center justify-center text-3xl mx-auto mb-4">
|
||||
👨👩👧👦
|
||||
</div>
|
||||
<h1 className="text-xl font-bold mb-1">You're invited!</h1>
|
||||
<p className="text-gray-500 text-sm mb-4">
|
||||
Join <strong className="text-gray-800 dark:text-white">{circleName}</strong>
|
||||
<br />
|
||||
<span className="text-xs">{memberCount} {memberCount === 1 ? "family" : "families"} already inside</span>
|
||||
</p>
|
||||
{!familyId && (
|
||||
<p className="text-xs text-amber-600 bg-amber-50 rounded-xl px-3 py-2 mb-4">
|
||||
You'll need to log in to Tia first.
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
onClick={joinCircle}
|
||||
className="w-full py-3 bg-rose-400 text-white rounded-xl font-semibold"
|
||||
>
|
||||
{familyId ? `Join ${circleName}` : "Log in to Join"}
|
||||
</button>
|
||||
<button onClick={() => router.push("/home")} className="mt-3 text-xs text-gray-400">
|
||||
Not now
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === "joining" && (
|
||||
<>
|
||||
<div className="w-12 h-12 border-4 border-rose-400 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-500">Joining circle…</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === "success" && (
|
||||
<>
|
||||
<div className="text-5xl mb-4">🎉</div>
|
||||
<h1 className="text-xl font-bold mb-1">You're in!</h1>
|
||||
<p className="text-gray-500 text-sm">Welcome to <strong>{circleName}</strong>. Taking you there…</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === "error" && (
|
||||
<>
|
||||
<div className="text-5xl mb-4">😕</div>
|
||||
<h1 className="text-lg font-bold mb-2 text-gray-800 dark:text-white">Invite issue</h1>
|
||||
<p className="text-gray-500 text-sm mb-5">{errorMsg}</p>
|
||||
<button onClick={() => router.push("/home")} className="w-full py-3 bg-gray-100 dark:bg-gray-700 rounded-xl text-sm">Go to Home</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
196
src/app/(app)/circle/page.tsx
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { useFamily } from "@/app/FamilyProvider";
|
||||
import type { Circle } from "@/types";
|
||||
|
||||
type PendingInvite = {
|
||||
id: string;
|
||||
token: string;
|
||||
circleId: string;
|
||||
circleName: string;
|
||||
inviterName: string;
|
||||
memberCount: number;
|
||||
expiresAt: string;
|
||||
};
|
||||
|
||||
export default function CirclePage() {
|
||||
const router = useRouter();
|
||||
const { familyId } = useFamily();
|
||||
const [circles, setCircles] = useState<Circle[]>([]);
|
||||
const [pendingInvites, setPendingInvites] = useState<PendingInvite[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [joiningId, setJoiningId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (familyId) { fetchCircles(); fetchPendingInvites(); }
|
||||
}, [familyId]);
|
||||
|
||||
const fetchCircles = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/circles");
|
||||
const data = await res.json();
|
||||
setCircles(data.circles ?? []);
|
||||
} catch { /* silent */ }
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const fetchPendingInvites = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/circles/invites");
|
||||
const data = await res.json();
|
||||
setPendingInvites(data.invites ?? []);
|
||||
} catch { /* silent */ }
|
||||
};
|
||||
|
||||
const createCircle = async () => {
|
||||
if (!newName.trim()) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
const res = await fetch("/api/circles", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: newName.trim() }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setNewName("");
|
||||
setShowCreate(false);
|
||||
router.push(`/circle/${data.circle.id}`);
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
setCreating(false);
|
||||
};
|
||||
|
||||
const acceptInvite = async (invite: PendingInvite) => {
|
||||
setJoiningId(invite.id);
|
||||
try {
|
||||
const res = await fetch(`/api/circle/join/${invite.token}`, { method: "POST" });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
router.push(`/circle/${data.circleId}`);
|
||||
} else {
|
||||
alert(data.error ?? "Could not join circle");
|
||||
setPendingInvites(prev => prev.filter(i => i.id !== invite.id));
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
setJoiningId(null);
|
||||
};
|
||||
|
||||
const declineInvite = (inviteId: string) => {
|
||||
setPendingInvites(prev => prev.filter(i => i.id !== inviteId));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 pb-24">
|
||||
{/* Header */}
|
||||
<div className="p-4 flex items-center gap-3">
|
||||
<button onClick={() => router.back()} className="p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-xl">←</button>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-xl font-bold">My Circles</h1>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Private groups with trusted families</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreate(v => !v)}
|
||||
className="px-4 py-2 bg-rose-400 text-white rounded-xl text-sm font-medium"
|
||||
>+ New</button>
|
||||
</div>
|
||||
|
||||
{/* Create circle form */}
|
||||
{showCreate && (
|
||||
<div className="mx-4 mb-4 p-4 bg-white dark:bg-gray-800 rounded-2xl shadow-sm space-y-3">
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-200">Create a new Circle</p>
|
||||
<input
|
||||
id="circle-name"
|
||||
name="circle-name"
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
onKeyDown={e => e.key === "Enter" && createCircle()}
|
||||
placeholder="e.g. NCB Mamas 2024"
|
||||
className="w-full px-3 py-2 bg-gray-50 dark:bg-gray-700 rounded-xl text-sm outline-none border border-gray-200 dark:border-gray-600 focus:border-rose-400"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={createCircle}
|
||||
disabled={creating || !newName.trim()}
|
||||
className="flex-1 py-2 bg-rose-400 text-white rounded-xl text-sm font-medium disabled:opacity-50"
|
||||
>{creating ? "Creating…" : "Create Circle"}</button>
|
||||
<button onClick={() => setShowCreate(false)} className="px-4 py-2 text-gray-400 text-sm">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pending invites */}
|
||||
{pendingInvites.length > 0 && (
|
||||
<div className="px-4 mb-4 space-y-2">
|
||||
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide px-1 mb-1">Pending Invites</p>
|
||||
{pendingInvites.map(inv => (
|
||||
<div key={inv.id} className="bg-rose-50 dark:bg-rose-900/20 border border-rose-200 dark:border-rose-700 rounded-2xl p-4 flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-rose-100 dark:bg-rose-900/40 rounded-full flex items-center justify-center text-xl flex-shrink-0">
|
||||
👨👩👧👦
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold truncate">{inv.circleName}</p>
|
||||
<p className="text-xs text-gray-500">{inv.inviterName} invited you · {inv.memberCount} {inv.memberCount === 1 ? "family" : "families"}</p>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => acceptInvite(inv)}
|
||||
disabled={joiningId === inv.id}
|
||||
className="px-3 py-1.5 bg-rose-400 text-white rounded-xl text-xs font-medium disabled:opacity-50"
|
||||
>{joiningId === inv.id ? "…" : "Join"}</button>
|
||||
<button
|
||||
onClick={() => declineInvite(inv.id)}
|
||||
className="px-3 py-1.5 bg-gray-100 dark:bg-gray-700 text-gray-500 rounded-xl text-xs"
|
||||
>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* List */}
|
||||
<div className="px-4 space-y-3">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<div className="flex gap-2 text-3xl">
|
||||
{["👨👩👧", "💬", "❤️"].map((e, i) => (
|
||||
<span key={i} className="animate-bounce" style={{ animationDelay: `${i * 120}ms` }}>{e}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : circles.length === 0 && pendingInvites.length === 0 ? (
|
||||
<div className="text-center py-16 px-8">
|
||||
<p className="text-4xl mb-3">👨👩👧👦</p>
|
||||
<p className="font-semibold text-gray-700 dark:text-gray-200">No circles yet</p>
|
||||
<p className="text-sm text-gray-400 mt-1">Create one and invite trusted families to share milestones privately.</p>
|
||||
</div>
|
||||
) : (
|
||||
circles.map(c => (
|
||||
<Link
|
||||
key={c.id}
|
||||
href={`/circle/${c.id}`}
|
||||
className="flex items-center gap-4 p-4 bg-white dark:bg-gray-800 rounded-2xl shadow-sm"
|
||||
>
|
||||
<div className="w-12 h-12 bg-rose-100 dark:bg-rose-900/40 rounded-full flex items-center justify-center text-2xl flex-shrink-0">
|
||||
👨👩👧
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-semibold truncate">{c.name}</div>
|
||||
<div className="text-xs text-gray-400">{c.memberCount} {c.memberCount === 1 ? "family" : "families"} · {c.role === "admin" ? "Admin" : "Member"}</div>
|
||||
</div>
|
||||
<span className="text-gray-400">›</span>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ import {
|
|||
EmptyState, LoadingShimmer, ConfirmDialog, WashiTape,
|
||||
Badge, Avatar, Tabs, TabPanel,
|
||||
} from "@/components/ui";
|
||||
import { useTheme } from "../../ThemeProvider";
|
||||
import { useTheme } from "@/app/ThemeProvider";
|
||||
|
||||
export default function DevComponentsPage() {
|
||||
const { theme, toggle } = useTheme();
|
||||
48
src/app/(app)/error.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
// Error boundary for the authenticated (app) segment. Catches render/runtime
|
||||
// errors in any /(app) page (home, ai, growth, …), shows a friendly recovery
|
||||
// UI, and reports the crash to /api/errors for the admin error tracker.
|
||||
export default function AppError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
try {
|
||||
fetch("/api/errors", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
message: error?.message || "Unknown app error",
|
||||
stack: error?.stack,
|
||||
digest: error?.digest,
|
||||
url: typeof window !== "undefined" ? window.location.pathname : undefined,
|
||||
level: "error",
|
||||
metadata: { boundary: "app" },
|
||||
}),
|
||||
keepalive: true,
|
||||
}).catch(() => {});
|
||||
} catch {}
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center gap-4 p-6 text-center bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800">
|
||||
<div className="text-5xl">😵</div>
|
||||
<h1 className="text-xl font-bold dark:text-white">Something went wrong</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400 max-w-sm text-sm">
|
||||
This screen hit an unexpected error. It's been reported automatically.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => reset()}
|
||||
className="bg-rose-400 text-white px-5 py-2.5 rounded-xl font-semibold"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,7 +2,8 @@
|
|||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useFamily } from "../FamilyProvider";
|
||||
import { useFamily } from "@/app/FamilyProvider";
|
||||
import { ChildLimitBanner } from "@/components/StorageMeter";
|
||||
|
||||
interface Child {
|
||||
id: string;
|
||||
|
|
@ -24,6 +25,7 @@ export default function FamilyPage() {
|
|||
const [newName, setNewName] = useState("");
|
||||
const [newDob, setNewDob] = useState("");
|
||||
const [newSex, setNewSex] = useState("male");
|
||||
const [childLimit, setChildLimit] = useState<{ currentCount: number; limit: number } | null>(null);
|
||||
|
||||
// Load children from FamilyProvider or fetch
|
||||
useEffect(() => {
|
||||
|
|
@ -35,6 +37,18 @@ export default function FamilyPage() {
|
|||
}
|
||||
}, [childrenFromProvider, familyId]);
|
||||
|
||||
// Sync limit state whenever children list changes
|
||||
useEffect(() => {
|
||||
if (!familyId) return;
|
||||
fetch("/api/family")
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
const maxChildren = d.family?.max_children ?? 1;
|
||||
setChildLimit({ currentCount: children.length, limit: maxChildren });
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [familyId, children.length]);
|
||||
|
||||
const fetchOwnChildren = async () => {
|
||||
if (!familyId) return;
|
||||
try {
|
||||
|
|
@ -85,12 +99,19 @@ export default function FamilyPage() {
|
|||
setShowAdd(false);
|
||||
setNewName("");
|
||||
setNewDob("");
|
||||
} else if (data.reason === "child_limit_reached") {
|
||||
setShowAdd(false);
|
||||
setChildLimit({ currentCount: data.currentCount, limit: data.limit });
|
||||
} else {
|
||||
alert(data.error);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to add:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const atChildLimit = childLimit !== null && childLimit.currentCount >= childLimit.limit;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800">
|
||||
<div className="p-4 flex items-center gap-4">
|
||||
|
|
@ -99,8 +120,13 @@ export default function FamilyPage() {
|
|||
</div>
|
||||
|
||||
<div className="px-4 space-y-4">
|
||||
{/* Child limit banner */}
|
||||
{atChildLimit && childLimit && (
|
||||
<ChildLimitBanner currentCount={childLimit.currentCount} limit={childLimit.limit} />
|
||||
)}
|
||||
|
||||
{/* Add Child Form */}
|
||||
{showAdd && (
|
||||
{showAdd && !atChildLimit && (
|
||||
<div className="p-4 bg-white dark:bg-gray-800 rounded-xl space-y-3">
|
||||
<input
|
||||
type="text"
|
||||
|
|
@ -200,7 +226,7 @@ export default function FamilyPage() {
|
|||
)}
|
||||
|
||||
{/* Add Button */}
|
||||
{!showAdd && children.length > 0 && (
|
||||
{!showAdd && children.length > 0 && !atChildLimit && (
|
||||
<button
|
||||
onClick={() => setShowAdd(true)}
|
||||
className="w-full p-4 border-2 border-dashed border-gray-300 rounded-xl text-gray-500"
|
||||
|
|
@ -1,8 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useFamily } from "../FamilyProvider";
|
||||
import { WHO_BOY_WEIGHT, WHO_GIRL_WEIGHT, getAgeInMonthsFromBirth, getPercentile, type GrowthStandard } from "@/lib/growth-standards";
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { useFamily } from "@/app/FamilyProvider";
|
||||
import { Button, Card, Input, ConfirmDialog } from "@/components/ui";
|
||||
import { WHO_BOY_WEIGHT, WHO_GIRL_WEIGHT, getAgeInMonthsFromBirth, getPercentile } from "@/lib/growth-standards";
|
||||
import { formatAge } from "@/lib/formatting";
|
||||
import { fmtDate } from "@/lib/date-ist";
|
||||
import { trackGrowthLogged } from "@/lib/analytics";
|
||||
import type { GrowthRecord, Goal } from "@/types";
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
|
|
@ -15,59 +21,23 @@ import {
|
|||
Filler,
|
||||
} from "chart.js";
|
||||
import { Line } from "react-chartjs-2";
|
||||
import { useTheme } from "../ThemeProvider";
|
||||
import { useTheme } from "@/app/ThemeProvider";
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler
|
||||
);
|
||||
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, Filler);
|
||||
|
||||
interface GrowthRecord {
|
||||
id: string;
|
||||
child_id: string;
|
||||
measured_at: string;
|
||||
weight_kg: number | null;
|
||||
height_cm: number | null;
|
||||
head_circumference_cm: number | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
interface Goal {
|
||||
weightKg?: number;
|
||||
heightCm?: number;
|
||||
targetDate?: string;
|
||||
}
|
||||
|
||||
function formatAge(birthDate: string, measurementDate?: string): string {
|
||||
const birth = new Date(birthDate);
|
||||
const now = measurementDate ? new Date(measurementDate) : new Date();
|
||||
const years = Math.floor((now.getTime() - birth.getTime()) / (1000 * 60 * 60 * 24 * 365));
|
||||
const months = Math.floor(((now.getTime() - birth.getTime()) % (1000 * 60 * 60 * 24 * 365)) / (1000 * 60 * 60 * 24 * 30));
|
||||
|
||||
if (years > 0 && months > 0) {
|
||||
return `${years}y ${months}mo`;
|
||||
} else if (years > 0) {
|
||||
return `${years}y`;
|
||||
} else if (months > 0) {
|
||||
return `${months}mo`;
|
||||
} else {
|
||||
return "Newborn";
|
||||
}
|
||||
interface Percentiles { p3: number; p15: number; p50: number; p85: number; p97: number }
|
||||
interface WhoStandard {
|
||||
weight: Percentiles;
|
||||
height: Percentiles;
|
||||
headCircumference: Percentiles;
|
||||
}
|
||||
|
||||
export default function GrowthPage() {
|
||||
const { childId, child, familyId } = useFamily();
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === "dark";
|
||||
useTheme(); // keep theme context alive for dark mode CSS
|
||||
|
||||
const [growthData, setGrowthData] = useState<GrowthRecord[]>([]);
|
||||
const [whoStandard, setWhoStandard] = useState<any>(null);
|
||||
const [whoStandard, setWhoStandard] = useState<WhoStandard | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [showGoals, setShowGoals] = useState(false);
|
||||
|
|
@ -83,6 +53,7 @@ export default function GrowthPage() {
|
|||
const [chartMetric, setChartMetric] = useState<"weight" | "height" | "head">("weight");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||
const [showWhoStandards, setShowWhoStandards] = useState(true);
|
||||
const [showChart, setShowChart] = useState(true);
|
||||
const [showHistory, setShowHistory] = useState(true);
|
||||
|
|
@ -146,10 +117,11 @@ export default function GrowthPage() {
|
|||
setSaveError(err.error || "Failed to save");
|
||||
return;
|
||||
}
|
||||
trackGrowthLogged();
|
||||
resetForm();
|
||||
fetchGrowthData();
|
||||
} catch (e: any) {
|
||||
setSaveError(e.message || "Failed to save");
|
||||
} catch (e) {
|
||||
setSaveError(e instanceof Error ? e.message : "Failed to save");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
|
@ -186,8 +158,8 @@ export default function GrowthPage() {
|
|||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm("Delete this record?")) return;
|
||||
await fetch(`/api/growth?id=${id}`, { method: "DELETE" });
|
||||
setConfirmDeleteId(null);
|
||||
fetchGrowthData();
|
||||
};
|
||||
|
||||
|
|
@ -221,7 +193,7 @@ export default function GrowthPage() {
|
|||
|
||||
const headers = ["Date", "Weight (kg)", "Height (cm)", "Head (cm)", "Notes"];
|
||||
const rows = growthData.map(r => [
|
||||
new Date(r.measured_at).toLocaleDateString(),
|
||||
fmtDate(r.measured_at),
|
||||
r.weight_kg || "",
|
||||
r.height_cm || "",
|
||||
r.head_circumference_cm || "",
|
||||
|
|
@ -310,7 +282,7 @@ export default function GrowthPage() {
|
|||
// WHO 85th percentile
|
||||
{
|
||||
label: "85th",
|
||||
data: labels.map(() => whoStandard?.[whoKey]?.p85),
|
||||
data: labels.map(() => whoStandard?.[whoKey]?.p85 ?? null),
|
||||
borderColor: "#fbbf24",
|
||||
borderDash: [5, 5],
|
||||
fill: false,
|
||||
|
|
@ -320,7 +292,7 @@ export default function GrowthPage() {
|
|||
// WHO 50th percentile
|
||||
{
|
||||
label: "50th",
|
||||
data: labels.map(() => whoStandard?.[whoKey]?.p50),
|
||||
data: labels.map(() => whoStandard?.[whoKey]?.p50 ?? null),
|
||||
borderColor: "#22c55e",
|
||||
borderDash: [5, 5],
|
||||
fill: false,
|
||||
|
|
@ -330,21 +302,21 @@ export default function GrowthPage() {
|
|||
// WHO 15th percentile
|
||||
{
|
||||
label: "15th",
|
||||
data: labels.map(() => whoStandard?.[whoKey]?.p15),
|
||||
data: labels.map(() => whoStandard?.[whoKey]?.p15 ?? null),
|
||||
borderColor: "#fbbf24",
|
||||
borderDash: [5, 5],
|
||||
fill: false,
|
||||
pointRadius: 0,
|
||||
tension: 0.4,
|
||||
},
|
||||
// WHO 3rd-97th band
|
||||
{
|
||||
label: "3rd-97th",
|
||||
data: labels.map(() => [whoStandard?.[whoKey]?.p3, whoStandard?.[whoKey]?.p97]),
|
||||
borderColor: "transparent",
|
||||
backgroundColor: "rgba(34, 197, 94, 0.1)",
|
||||
fill: "+1",
|
||||
label: "3rd",
|
||||
data: labels.map(() => whoStandard?.[whoKey]?.p3 ?? null),
|
||||
borderColor: "#fbbf24",
|
||||
borderDash: [5, 5],
|
||||
fill: false,
|
||||
pointRadius: 0,
|
||||
tension: 0.4,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -369,71 +341,92 @@ export default function GrowthPage() {
|
|||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 pb-20">
|
||||
<div className="p-4 flex justify-between items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<a href="/menu" className="p-2">←</a>
|
||||
<h1 className="text-xl font-bold">Growth 📈</h1>
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 z-10 bg-white/80 dark:bg-gray-900/80 backdrop-blur-sm border-b border-gray-100 dark:border-gray-800 px-4 py-3 flex justify-between items-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/menu" className="text-gray-500 dark:text-gray-400 p-1">←</Link>
|
||||
<h1 className="text-sm font-semibold dark:text-white">Growth 📈</h1>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={exportCSV} className="p-2 text-sm bg-gray-200 dark:bg-gray-700 rounded-lg" title="Export CSV">
|
||||
📥
|
||||
</button>
|
||||
<button onClick={() => setShowGoals(!showGoals)} className="p-2 text-sm bg-gray-200 dark:bg-gray-700 rounded-lg" title="Set goals">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
title="Set goals"
|
||||
onClick={() => { setShowGoals(g => !g); setShowAdd(false); setEditingId(null); }}
|
||||
className={`p-2 rounded-lg text-sm transition-colors ${showGoals ? "bg-amber-100 dark:bg-amber-900 text-amber-600" : "text-gray-400 hover:text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700"}`}
|
||||
>
|
||||
🎯
|
||||
</button>
|
||||
<button onClick={() => { setShowAdd(!showAdd); setEditingId(null); setWeight(""); setHeight(""); setHeadCircumference(""); setMeasuredAt(new Date().toISOString().split("T")[0]); }} className="p-2 bg-rose-400 text-white rounded-lg">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowAdd(a => !a);
|
||||
setShowGoals(false);
|
||||
setEditingId(null);
|
||||
setWeight(""); setHeight(""); setHeadCircumference("");
|
||||
setMeasuredAt(new Date().toISOString().split("T")[0]);
|
||||
}}
|
||||
>
|
||||
+ Add
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Goals Card */}
|
||||
{showGoals && (
|
||||
<div className="mx-4 mb-4 p-4 bg-white dark:bg-gray-800 rounded-xl">
|
||||
<h3 className="font-semibold mb-3">Set Growth Goals</h3>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-sm text-gray-500">Target Weight (kg)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
placeholder="e.g., 10"
|
||||
value={goal.weightKg || ""}
|
||||
onChange={e => setGoal({ ...goal, weightKg: parseFloat(e.target.value) || undefined })}
|
||||
className="w-full p-2 border rounded-lg dark:bg-gray-700"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-gray-500">Target Height (cm)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
placeholder="e.g., 80"
|
||||
value={goal.heightCm || ""}
|
||||
onChange={e => setGoal({ ...goal, heightCm: parseFloat(e.target.value) || undefined })}
|
||||
className="w-full p-2 border rounded-lg dark:bg-gray-700"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={saveGoal} className="flex-1 p-2 bg-rose-400 text-white rounded-lg">
|
||||
Save Goal
|
||||
</button>
|
||||
<button onClick={() => setShowGoals(false)} className="flex-1 p-2 bg-gray-200 dark:bg-gray-700 rounded-lg">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
<div className="mx-4 mt-4 mb-2 p-4 bg-white dark:bg-gray-800 rounded-2xl shadow-sm space-y-3">
|
||||
<h3 className="font-semibold text-sm dark:text-white">🎯 Growth Goals</h3>
|
||||
<Input label="Target Weight (kg)" type="number" step="0.1" placeholder="e.g., 10"
|
||||
value={goal.weightKg || ""}
|
||||
onChange={e => setGoal({ ...goal, weightKg: parseFloat(e.target.value) || undefined })}
|
||||
/>
|
||||
<Input label="Target Height (cm)" type="number" step="0.1" placeholder="e.g., 80"
|
||||
value={goal.heightCm || ""}
|
||||
onChange={e => setGoal({ ...goal, heightCm: parseFloat(e.target.value) || undefined })}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button fullWidth onClick={saveGoal}>Save Goal</Button>
|
||||
<Button variant="secondary" fullWidth onClick={() => setShowGoals(false)}>Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add / Edit Measurement — always at the top, right below the header */}
|
||||
{showAdd && (
|
||||
<div className="mx-4 mt-4 mb-2 p-4 bg-white dark:bg-gray-800 rounded-2xl shadow-sm space-y-3">
|
||||
<h3 className="font-semibold text-sm dark:text-white">{editingId ? "✏️ Edit Record" : "📏 New Measurement"}</h3>
|
||||
{saveError && (
|
||||
<div className="p-2 bg-red-50 dark:bg-red-900/40 text-red-600 dark:text-red-300 rounded-lg text-xs">{saveError}</div>
|
||||
)}
|
||||
<Input type="date" value={measuredAt} onChange={e => setMeasuredAt(e.target.value)} />
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Input type="number" step="0.01" placeholder="Weight kg" value={weight} onChange={e => setWeight(e.target.value)} />
|
||||
<Input type="number" step="0.1" placeholder="Height cm" value={height} onChange={e => setHeight(e.target.value)} />
|
||||
<Input type="number" step="0.1" placeholder="Head cm" value={headCircumference} onChange={e => setHeadCircumference(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{editingId ? (
|
||||
<>
|
||||
<Button fullWidth loading={saving} onClick={() => handleEdit(editingId)}>Update</Button>
|
||||
<Button variant="secondary" fullWidth onClick={resetForm}>Cancel</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button fullWidth loading={saving} onClick={() => handleAdd()}>Save</Button>
|
||||
<Button variant="secondary" fullWidth onClick={resetForm}>Cancel</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Latest Reading Card */}
|
||||
{latest && (
|
||||
<div className="mx-4 mb-4 p-4 bg-gradient-to-r from-rose-50 to-pink-50 dark:from-rose-900 dark:to-pink-900 rounded-xl hover:shadow-lg transition-shadow cursor-pointer">
|
||||
<div className="mx-4 mt-4 mb-2 p-4 bg-gradient-to-r from-rose-50 to-pink-50 dark:from-rose-900/40 dark:to-pink-900/40 rounded-2xl shadow-sm">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<div>
|
||||
<div className="font-semibold text-rose-600 dark:text-rose-300">Latest Reading</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{new Date(latest.measured_at).toLocaleDateString()} ({child ? formatAge(child.birthDate, latest.measured_at) : ""})
|
||||
{fmtDate(latest.measured_at)} ({child ? formatAge(child.birthDate, latest.measured_at) : ""})
|
||||
</div>
|
||||
</div>
|
||||
{velocity && (
|
||||
|
|
@ -492,7 +485,7 @@ export default function GrowthPage() {
|
|||
|
||||
{/* WHO Standards + Chart Card - Collapsible */}
|
||||
{child && standard && (
|
||||
<div className="mx-4 mb-4 bg-white dark:bg-gray-800 rounded-xl overflow-hidden">
|
||||
<div className="mx-4 mt-4 mb-2 bg-white dark:bg-gray-800 rounded-2xl shadow-sm overflow-hidden">
|
||||
{/* WHO Standards Header - Clickable */}
|
||||
<button
|
||||
onClick={() => setShowWhoStandards(!showWhoStandards)}
|
||||
|
|
@ -614,66 +607,6 @@ export default function GrowthPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Measurement - Same pattern as Goals */}
|
||||
{showAdd && latest && (
|
||||
<div className="mx-4 mb-4 p-4 bg-white dark:bg-gray-800 rounded-xl space-y-3">
|
||||
<h3 className="font-semibold">{editingId ? "Edit Record" : "Add New Measurement"}</h3>
|
||||
{saveError && (
|
||||
<div className="p-2 bg-red-100 dark:bg-red-900 text-red-600 rounded-lg text-sm">{saveError}</div>
|
||||
)}
|
||||
<input
|
||||
type="date"
|
||||
value={measuredAt}
|
||||
onChange={e => setMeasuredAt(e.target.value)}
|
||||
className="w-full p-3 border rounded-xl dark:bg-gray-700"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="Weight (kg)"
|
||||
value={weight}
|
||||
onChange={e => setWeight(e.target.value)}
|
||||
className="w-full p-3 border rounded-xl dark:bg-gray-700"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
placeholder="Height (cm)"
|
||||
value={height}
|
||||
onChange={e => setHeight(e.target.value)}
|
||||
className="w-full p-3 border rounded-xl dark:bg-gray-700"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
placeholder="Head circumference (cm)"
|
||||
value={headCircumference}
|
||||
onChange={e => setHeadCircumference(e.target.value)}
|
||||
className="w-full p-3 border rounded-xl dark:bg-gray-700"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
{editingId ? (
|
||||
<>
|
||||
<button onClick={() => handleEdit(editingId)} disabled={saving} className="flex-1 p-3 bg-rose-400 text-white rounded-xl disabled:opacity-50">
|
||||
{saving ? "Saving..." : "Update"}
|
||||
</button>
|
||||
<button onClick={resetForm} className="flex-1 p-3 bg-gray-200 dark:bg-gray-700 rounded-xl">
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button onClick={() => handleAdd()} disabled={saving} className="flex-1 p-3 bg-rose-400 text-white rounded-xl disabled:opacity-50">
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
<button onClick={() => { setShowAdd(false); setWeight(""); setHeight(""); setHeadCircumference(""); }} className="flex-1 p-3 bg-gray-200 dark:bg-gray-700 rounded-xl">
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{loading ? (
|
||||
|
|
@ -681,19 +614,19 @@ export default function GrowthPage() {
|
|||
<p>Loading...</p>
|
||||
</div>
|
||||
) : growthData.length === 0 ? (
|
||||
<div className="mx-4 p-8 bg-white dark:bg-gray-800 rounded-xl text-center">
|
||||
<div className="text-4xl mb-4">📏</div>
|
||||
<h3 className="text-lg font-semibold mb-2">Track {child?.name}'s Growth</h3>
|
||||
<p className="text-gray-500 mb-4">
|
||||
Start logging weight, height, and head measurements to see how {child?.name} is growing compared to WHO standards.
|
||||
<div className="mx-4 mt-4 mb-4 p-6 bg-white dark:bg-gray-800 rounded-2xl shadow-sm text-center">
|
||||
<div className="text-4xl mb-3">📏</div>
|
||||
<h3 className="text-base font-semibold mb-1 dark:text-white">Track {child?.name}'s Growth</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400 mb-4 text-sm">
|
||||
Log weight, height, and head measurements to see how {child?.name} compares to WHO standards.
|
||||
</p>
|
||||
<button onClick={() => setShowAdd(true)} className="p-3 bg-rose-400 text-white rounded-xl">
|
||||
<Button onClick={() => { setShowAdd(true); setShowGoals(false); window.scrollTo({ top: 0, behavior: "smooth" }); }}>
|
||||
Add First Measurement
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
/* History List - Collapsible */
|
||||
<div className="mx-4 mb-4 bg-white dark:bg-gray-800 rounded-xl overflow-hidden">
|
||||
<div className="mx-4 mt-4 mb-4 bg-white dark:bg-gray-800 rounded-2xl shadow-sm overflow-hidden">
|
||||
<button
|
||||
onClick={() => setShowHistory(!showHistory)}
|
||||
className="w-full p-4 flex justify-between items-center hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
|
|
@ -707,7 +640,7 @@ export default function GrowthPage() {
|
|||
<div key={i} className="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg hover:shadow-md transition-shadow flex justify-between items-center">
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{new Date(record.measured_at).toLocaleDateString()} ({child ? formatAge(child.birthDate, record.measured_at) : ""})
|
||||
{fmtDate(record.measured_at)} ({child ? formatAge(child.birthDate, record.measured_at) : ""})
|
||||
</div>
|
||||
<div className="flex gap-2 mt-1">
|
||||
{record.weight_kg && (
|
||||
|
|
@ -730,7 +663,7 @@ export default function GrowthPage() {
|
|||
✏️
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(record.id)}
|
||||
onClick={() => setConfirmDeleteId(record.id)}
|
||||
className="p-2 text-sm text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900 rounded-lg transition-colors"
|
||||
title="Delete"
|
||||
>
|
||||
|
|
@ -743,6 +676,16 @@ export default function GrowthPage() {
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!confirmDeleteId}
|
||||
onClose={() => setConfirmDeleteId(null)}
|
||||
onConfirm={() => confirmDeleteId && handleDelete(confirmDeleteId)}
|
||||
title="Delete this record?"
|
||||
description="This measurement will be permanently removed."
|
||||
confirmLabel="Delete"
|
||||
variant="danger"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
581
src/app/(app)/home/page.tsx
Normal file
|
|
@ -0,0 +1,581 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import { useTheme } from "@/app/ThemeProvider";
|
||||
import { useFamily } from "@/app/FamilyProvider";
|
||||
import { useStageCheck, type BabyStage } from "@/hooks/useStageCheck";
|
||||
import { LogModal, type LogType } from "@/components/LogModal";
|
||||
import { getOfflineQueue, processOfflineQueue } from "@/lib/offline-queue";
|
||||
import { calculateAge, formatTimeAgo } from "@/lib/formatting";
|
||||
import { hourIST, isTodayIST, fmtTime } from "@/lib/date-ist";
|
||||
import type { Log, AIChat, ChatSession } from "@/types";
|
||||
|
||||
/** Some Android cameras return file.type = "" — detect from extension as fallback. */
|
||||
function resolveContentType(file: File): string {
|
||||
if (file.type && file.type !== "application/octet-stream") return file.type;
|
||||
const ext = file.name.split(".").pop()?.toLowerCase() ?? "";
|
||||
const map: Record<string, string> = {
|
||||
jpg: "image/jpeg", jpeg: "image/jpeg",
|
||||
png: "image/png", webp: "image/webp",
|
||||
heic: "image/heic", heif: "image/heic",
|
||||
};
|
||||
return map[ext] || "image/jpeg";
|
||||
}
|
||||
|
||||
async function getSessions(cid: string): Promise<ChatSession[]> {
|
||||
try {
|
||||
const res = await fetch(`/api/chat?childId=${cid}`);
|
||||
const data = await res.json();
|
||||
return data.sessions || [];
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
async function createSession(cid: string): Promise<ChatSession | null> {
|
||||
try {
|
||||
const res = await fetch("/api/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ childId: cid, title: "New conversation" }),
|
||||
});
|
||||
const data = await res.json();
|
||||
return data.session || null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
|
||||
function getGreeting() {
|
||||
const hour = hourIST();
|
||||
if (hour < 12) return "Good morning";
|
||||
if (hour < 18) return "Good afternoon";
|
||||
return "Good evening";
|
||||
}
|
||||
|
||||
function TodaySummary({ logs }: { logs: Log[] }) {
|
||||
const today = logs.filter(l => isTodayIST(l.loggedAt));
|
||||
const counts = {
|
||||
feed: today.filter(l => l.type === "feed").length,
|
||||
diaper: today.filter(l => l.type === "diaper").length,
|
||||
sleep: today.filter(l => l.type === "sleep").length,
|
||||
};
|
||||
const lastFeed = today.find(l => l.type === "feed");
|
||||
const lastDiaper = today.find(l => l.type === "diaper");
|
||||
const lastSleep = today.find(l => l.type === "sleep");
|
||||
|
||||
if (!lastFeed && !lastDiaper && !lastSleep) return null;
|
||||
|
||||
return (
|
||||
<div className="mx-4 mb-3 bg-white dark:bg-gray-800 rounded-2xl shadow-sm overflow-hidden">
|
||||
<div className="grid grid-cols-3 divide-x divide-gray-100 dark:divide-gray-700">
|
||||
{[
|
||||
{ icon: "🍼", label: "Feeds", count: counts.feed, last: lastFeed?.loggedAt },
|
||||
{ icon: "🚼", label: "Diapers", count: counts.diaper, last: lastDiaper?.loggedAt },
|
||||
{ icon: "😴", label: "Sleep", count: counts.sleep, last: lastSleep?.loggedAt },
|
||||
].map(item => (
|
||||
<div key={item.label} className="flex flex-col items-center py-2 px-1">
|
||||
<span className="text-xl mb-0.5">{item.icon}</span>
|
||||
<span className="text-xl font-bold text-gray-800 dark:text-white leading-tight">{item.count}</span>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">{item.label}</span>
|
||||
{item.last && (
|
||||
<span className="text-xs text-rose-400 mt-0.5">{formatTimeAgo(item.last)}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const AI_CHIP_FALLBACK = ["How much should baby eat?", "Sleep schedule tips?", "Development milestones?"];
|
||||
|
||||
export default function HomePage() {
|
||||
const [modalType, setModalType] = useState<"feed" | "diaper" | "sleep" | null>(null);
|
||||
const [aiOpen, setAiOpen] = useState(false);
|
||||
const [aiInput, setAiInput] = useState("");
|
||||
const [aiChats, setAiChats] = useState<AIChat[]>([]);
|
||||
const [aiLoading, setAiLoading] = useState(false);
|
||||
const [homeSessionId, setHomeSessionId] = useState<string | null>(null);
|
||||
const [pendingCount, setPendingCount] = useState(0);
|
||||
const [recentLogs, setRecentLogs] = useState<Log[]>([]);
|
||||
const [logsLoading, setLogsLoading] = useState(true);
|
||||
const [vaccineReminders, setVaccineReminders] = useState<any[]>([]);
|
||||
const [showPhoneNudge, setShowPhoneNudge] = useState(false);
|
||||
const [aiChips, setAiChips] = useState<string[]>([]);
|
||||
const [uploadingPhoto, setUploadingPhoto] = useState(false);
|
||||
const [photoError, setPhotoError] = useState(false);
|
||||
const [showPhotoMenu, setShowPhotoMenu] = useState(false);
|
||||
const photoInputRef = useRef<HTMLInputElement>(null);
|
||||
const { theme, toggle: toggleTheme } = useTheme();
|
||||
const { childId, child, familyId, loading, tier, updateChildImage } = useFamily();
|
||||
const stage = useStageCheck(child?.birthDate ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!childId) return;
|
||||
getSessions(childId).then(sessions => {
|
||||
if (sessions.length > 0) {
|
||||
setAiChats(sessions[0].messages);
|
||||
}
|
||||
});
|
||||
}, [childId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!childId) return;
|
||||
const queue = getOfflineQueue();
|
||||
setPendingCount(queue.length);
|
||||
const handleOnline = () => processOfflineQueue();
|
||||
window.addEventListener("online", handleOnline);
|
||||
fetch(`/api/notifications?childId=${childId}`)
|
||||
.then(res => res.json())
|
||||
// Only vaccine notifications belong in the "Vaccine Reminder" banner —
|
||||
// the API also returns log/memory/garment nudges.
|
||||
.then(data => setVaccineReminders(
|
||||
(data.notifications || []).filter((n: { type?: string }) => n.type?.startsWith("vaccine_"))
|
||||
))
|
||||
.catch(console.error);
|
||||
return () => window.removeEventListener("online", handleOnline);
|
||||
}, [childId]);
|
||||
|
||||
// One-time nudge for existing users who have no phone number on file.
|
||||
// Dismissable; remembered in localStorage so it never nags repeatedly.
|
||||
useEffect(() => {
|
||||
if (!childId) return;
|
||||
if (localStorage.getItem("tia_phone_nudge_dismissed") === "1") return;
|
||||
fetch("/api/auth/profile")
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.user && !data.user.phone) setShowPhoneNudge(true);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [childId]);
|
||||
|
||||
const dismissPhoneNudge = () => {
|
||||
setShowPhoneNudge(false);
|
||||
localStorage.setItem("tia_phone_nudge_dismissed", "1");
|
||||
};
|
||||
|
||||
const fetchRecentLogs = async () => {
|
||||
if (!childId) return;
|
||||
try {
|
||||
const res = await fetch(`/api/logs?childId=${childId}&limit=50`);
|
||||
const data = await res.json();
|
||||
setRecentLogs(data.entries || []);
|
||||
} catch {}
|
||||
setLogsLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!childId) return;
|
||||
setLogsLoading(true);
|
||||
fetchRecentLogs();
|
||||
}, [childId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!childId || !child) return;
|
||||
const age = calculateAge(child.birthDate);
|
||||
fetch("/api/ai", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
childId,
|
||||
messages: [{
|
||||
role: "user",
|
||||
content: `Generate exactly 3 short question chips (max 7 words each) a parent might want to ask right now about their baby ${child.name}, age ${age}. Reply ONLY with a JSON array of 3 strings, no other text.`,
|
||||
}],
|
||||
}),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
const chips = JSON.parse(d.reply);
|
||||
if (Array.isArray(chips) && chips.length > 0) setAiChips(chips.slice(0, 3));
|
||||
else setAiChips(AI_CHIP_FALLBACK);
|
||||
})
|
||||
.catch(() => setAiChips(AI_CHIP_FALLBACK));
|
||||
}, [childId, child?.id]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center gap-4 bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800">
|
||||
<div className="flex gap-3 text-4xl">
|
||||
{["🍼", "😴", "🚼", "👶"].map((e, i) => (
|
||||
<span key={i} className="animate-bounce" style={{ animationDelay: `${i * 120}ms` }}>{e}</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-sm text-gray-400">Loading Tia…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!familyId) {
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.href = "/login";
|
||||
}
|
||||
return <div className="min-h-screen flex items-center justify-center">Redirecting...</div>;
|
||||
}
|
||||
|
||||
if (!childId) {
|
||||
return <div className="min-h-screen flex items-center justify-center">No child found. Add a child in Family settings.</div>;
|
||||
}
|
||||
|
||||
const toggleDarkMode = () => {
|
||||
toggleTheme();
|
||||
};
|
||||
|
||||
const handlePhotoChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file || !childId) return;
|
||||
const contentType = resolveContentType(file);
|
||||
setUploadingPhoto(true);
|
||||
setShowPhotoMenu(false);
|
||||
|
||||
try {
|
||||
const initData = await fetch(`/api/children/${childId}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ contentType, filename: file.name }),
|
||||
}).then(r => r.json());
|
||||
if (!initData.key) throw new Error(initData.error || "Upload failed");
|
||||
const { key, publicUrl } = initData;
|
||||
|
||||
const putRes = await fetch(`/api/upload?${new URLSearchParams({ key, contentType })}`, {
|
||||
method: "PUT", body: file, headers: { "Content-Type": contentType },
|
||||
});
|
||||
if (!putRes.ok) throw new Error(`Upload failed (${putRes.status})`);
|
||||
|
||||
await fetch(`/api/children/${childId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ imageUrl: publicUrl }),
|
||||
});
|
||||
|
||||
setPhotoError(false);
|
||||
updateChildImage(childId, `/api/img?key=${encodeURIComponent(key)}`);
|
||||
} catch (err) {
|
||||
console.error("Photo upload failed:", err);
|
||||
} finally {
|
||||
setUploadingPhoto(false);
|
||||
if (photoInputRef.current) photoInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemovePhoto = async () => {
|
||||
setShowPhotoMenu(false);
|
||||
if (!childId) return;
|
||||
setUploadingPhoto(true);
|
||||
try {
|
||||
await fetch(`/api/children/${childId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ imageUrl: null }),
|
||||
});
|
||||
updateChildImage(childId, null);
|
||||
} catch (err) {
|
||||
console.error("Remove photo failed:", err);
|
||||
alert("Failed to remove photo.");
|
||||
}
|
||||
setUploadingPhoto(false);
|
||||
};
|
||||
|
||||
const handleAiChat = async (question?: string) => {
|
||||
const q = question || aiInput;
|
||||
if (!q.trim() || aiLoading) return;
|
||||
setAiLoading(true);
|
||||
setAiOpen(true);
|
||||
|
||||
const inputVal = q.trim();
|
||||
if (!question) setAiInput("");
|
||||
|
||||
let sessionId = homeSessionId;
|
||||
if (!sessionId) {
|
||||
const newSession = await createSession(childId);
|
||||
if (!newSession) { setAiLoading(false); return; }
|
||||
sessionId = newSession.id;
|
||||
setHomeSessionId(sessionId);
|
||||
setAiChats([]);
|
||||
}
|
||||
|
||||
const userMsg: AIChat = { id: "tmp-" + Date.now(), role: "user", content: inputVal, createdAt: new Date().toISOString() };
|
||||
setAiChats(prev => [...prev, userMsg]);
|
||||
|
||||
try {
|
||||
await fetch("/api/chat", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sessionId, role: "user", content: inputVal }),
|
||||
});
|
||||
|
||||
const res = await fetch("/api/ai", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ messages: [{ role: "user", content: inputVal }] }),
|
||||
});
|
||||
const data = await res.json();
|
||||
const reply = data.reply || "Sorry, I'm having trouble connecting. Please try again.";
|
||||
|
||||
await fetch("/api/chat", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sessionId, role: "assistant", content: reply }),
|
||||
});
|
||||
|
||||
setAiChats(prev => [...prev, { id: "ai-" + Date.now(), role: "assistant", content: reply, createdAt: new Date().toISOString() }]);
|
||||
} catch (err) {
|
||||
console.error("AI chat error:", err);
|
||||
}
|
||||
setAiLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 pb-24">
|
||||
|
||||
<div className="p-4 flex justify-between items-center">
|
||||
<button className="p-2"><Link href="/menu"><svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" /></svg></Link></button>
|
||||
<div className="flex items-center gap-1">
|
||||
{tier === "pro" ? (
|
||||
<Link
|
||||
href="/settings#upgrade"
|
||||
title="Tia Premium"
|
||||
className="p-2 text-amber-500 hover:text-amber-600"
|
||||
>
|
||||
✨
|
||||
</Link>
|
||||
) : (
|
||||
<Link
|
||||
href="/settings#upgrade"
|
||||
title="Upgrade to Premium"
|
||||
className="relative p-2 text-gray-400 hover:text-rose-500 transition-colors"
|
||||
>
|
||||
👑
|
||||
{/* Pulsing dot hints there's something to explore — non-intrusive */}
|
||||
<span className="absolute top-1 right-1 w-2 h-2 bg-rose-500 rounded-full animate-pulse" />
|
||||
</Link>
|
||||
)}
|
||||
<Link href="/medical/emergency" className="p-2 text-red-500 hover:text-red-600" title="Emergency Guide">🆘</Link>
|
||||
<button onClick={toggleDarkMode} className="p-2">{theme === "dark" ? "☀️" : "🌙"}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 pb-4">
|
||||
<h1 className="text-2xl font-bold">{getGreeting()} 👋</h1>
|
||||
<p className="text-gray-600 dark:text-gray-300">How is {child?.name || "your baby"} doing today?</p>
|
||||
</div>
|
||||
|
||||
<div className="mx-4 mb-4 p-4 bg-white dark:bg-gray-800 rounded-2xl shadow-md flex items-center gap-4">
|
||||
{/* Avatar — tap to change/remove photo */}
|
||||
<div className="relative flex-shrink-0">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (child?.imageUrl) setShowPhotoMenu(m => !m);
|
||||
else photoInputRef.current?.click();
|
||||
}}
|
||||
disabled={uploadingPhoto}
|
||||
className="relative group"
|
||||
title={child?.imageUrl ? "Photo options" : "Add photo"}
|
||||
>
|
||||
{child?.imageUrl && !photoError
|
||||
? <img src={child.imageUrl} alt={child?.name} className="w-16 h-16 rounded-full object-cover" onError={() => setPhotoError(true)} />
|
||||
: <div className="w-16 h-16 bg-rose-100 dark:bg-rose-900 rounded-full flex items-center justify-center text-2xl">👶</div>
|
||||
}
|
||||
{/* Camera / upload overlay */}
|
||||
<div className={`absolute inset-0 rounded-full flex items-center justify-center transition-opacity ${
|
||||
uploadingPhoto ? "bg-black/40 opacity-100" : "bg-black/0 opacity-0 group-hover:opacity-100 group-active:opacity-100"
|
||||
}`}>
|
||||
{uploadingPhoto
|
||||
? <div className="w-6 h-6 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
: <span className="text-white text-lg">📷</span>
|
||||
}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Photo options menu */}
|
||||
{showPhotoMenu && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setShowPhotoMenu(false)} />
|
||||
<div className="absolute left-0 top-[68px] z-50 bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-100 dark:border-gray-700 overflow-hidden w-44">
|
||||
<button
|
||||
onClick={() => { setShowPhotoMenu(false); photoInputRef.current?.click(); }}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
<span>📷</span> Change photo
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRemovePhoto}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2.5 text-sm text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 border-t border-gray-100 dark:border-gray-700"
|
||||
>
|
||||
<span>🗑️</span> Remove photo
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
ref={photoInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/jpg,image/png,image/webp,image/heic"
|
||||
className="hidden"
|
||||
onChange={handlePhotoChange}
|
||||
/>
|
||||
|
||||
{/* Name + age — tap to go to growth */}
|
||||
<Link href="/growth" className="flex-1 flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<div className="text-lg font-semibold">{child?.name || "Baby"}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">{calculateAge(child?.birthDate || "")}</div>
|
||||
</div>
|
||||
<div className="text-2xl text-gray-400">→</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{pendingCount > 0 && (
|
||||
<button
|
||||
onClick={() => { processOfflineQueue(); setPendingCount(0); }}
|
||||
className="mx-4 mb-3 w-[calc(100%-2rem)] bg-amber-100 text-amber-800 px-4 py-2 rounded-xl flex items-center justify-between"
|
||||
>
|
||||
<span>⚠️ {pendingCount} pending log{pendingCount > 1 ? "s" : ""}</span>
|
||||
<span className="text-sm font-medium">Retry now →</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{vaccineReminders.length > 0 && (
|
||||
<div className="mx-4 mb-4 bg-red-50 border border-red-200 px-4 py-3 rounded-xl">
|
||||
<Link href="/medical" className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-semibold text-red-700">💊 Vaccine Reminder</div>
|
||||
<div className="text-sm text-red-600">
|
||||
{vaccineReminders[0].message}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-red-400">→</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showPhoneNudge && (
|
||||
<div className="mx-4 mb-4 bg-rose-50 dark:bg-rose-900/20 border border-rose-200 dark:border-rose-800/40 px-4 py-3 rounded-xl flex items-center gap-3">
|
||||
<span className="text-xl flex-shrink-0">📱</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-semibold text-rose-700 dark:text-rose-300 text-sm">Add your phone number</div>
|
||||
<div className="text-xs text-rose-600 dark:text-rose-400">Get important reminders & updates about your baby</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/profile"
|
||||
className="flex-shrink-0 text-xs font-semibold text-white bg-rose-400 px-3 py-1.5 rounded-lg active:scale-95 transition-transform"
|
||||
>
|
||||
Add
|
||||
</Link>
|
||||
<button
|
||||
onClick={dismissPhoneNudge}
|
||||
className="flex-shrink-0 text-rose-300 dark:text-rose-500 text-lg leading-none px-1"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TodaySummary logs={recentLogs} />
|
||||
|
||||
{stage && (() => {
|
||||
const h = hourIST();
|
||||
type Suggestion = { label: string; type: "feed" | "sleep" | "diaper" };
|
||||
const matrix: Record<BabyStage, Suggestion> =
|
||||
h >= 5 && h < 9 ? { newborn: { label: "Feed", type: "feed" }, infant: { label: "Feed", type: "feed" }, sitter: { label: "Feed", type: "feed" }, crawler: { label: "Feed", type: "feed" }, toddler: { label: "Feed", type: "feed" }, walker: { label: "Feed", type: "feed" } } :
|
||||
h >= 9 && h < 12 ? { newborn: { label: "Feed", type: "feed" }, infant: { label: "Feed + Diaper", type: "feed" }, sitter: { label: "Solids", type: "feed" }, crawler: { label: "Solids", type: "feed" }, toddler: { label: "Solids", type: "feed" }, walker: { label: "Solids", type: "feed" } } :
|
||||
h >= 12 && h < 15 ? { newborn: { label: "Nap time", type: "sleep" }, infant: { label: "Nap time", type: "sleep" }, sitter: { label: "Nap time", type: "sleep" }, crawler: { label: "Nap time", type: "sleep" }, toddler: { label: "Nap time", type: "sleep" }, walker: { label: "Rest time", type: "sleep" } } :
|
||||
h >= 15 && h < 18 ? { newborn: { label: "Feed", type: "feed" }, infant: { label: "Feed", type: "feed" }, sitter: { label: "Snack time", type: "feed" }, crawler: { label: "Snack time", type: "feed" }, toddler: { label: "Snack time", type: "feed" }, walker: { label: "Snack time", type: "feed" } } :
|
||||
h >= 18 && h < 21 ? { newborn: { label: "Evening feed", type: "feed" }, infant: { label: "Evening feed", type: "feed" }, sitter: { label: "Dinner feed", type: "feed" }, crawler: { label: "Dinner feed", type: "feed" }, toddler: { label: "Dinner feed", type: "feed" }, walker: { label: "Dinner feed", type: "feed" } } :
|
||||
{ newborn: { label: "Night feed", type: "feed" }, infant: { label: "Night feed", type: "feed" }, sitter: { label: "Bedtime", type: "sleep" }, crawler: { label: "Bedtime", type: "sleep" }, toddler: { label: "Bedtime", type: "sleep" }, walker: { label: "Bedtime", type: "sleep" } };
|
||||
const s = matrix[stage.stage];
|
||||
return (
|
||||
<div className="mx-4 mb-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-2xl px-4 py-3 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-amber-600 dark:text-amber-400 uppercase tracking-wide">Suggested now</p>
|
||||
<p className="text-base font-bold text-amber-900 dark:text-amber-100">{s.label}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setModalType(s.type)}
|
||||
className="px-4 py-2 bg-amber-400 text-white rounded-xl text-sm font-semibold"
|
||||
>
|
||||
Log it →
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
<div className="px-4 mb-4">
|
||||
<h2 className="font-semibold mb-3 ml-1">Quick Log</h2>
|
||||
<div className="flex gap-3 overflow-x-auto scrollbar-hide pb-1 -mx-4 px-4">
|
||||
<button onClick={() => setModalType("feed")} className="flex-shrink-0 flex flex-col items-center p-4 bg-white dark:bg-gray-800 rounded-xl shadow-sm w-[72px]"><span className="text-3xl">🍼</span><span className="text-sm mt-1">Feed</span></button>
|
||||
<button onClick={() => setModalType("sleep")} className="flex-shrink-0 flex flex-col items-center p-4 bg-white dark:bg-gray-800 rounded-xl shadow-sm w-[72px]"><span className="text-3xl">😴</span><span className="text-sm mt-1">Sleep</span></button>
|
||||
<button onClick={() => setModalType("diaper")} className="flex-shrink-0 flex flex-col items-center p-4 bg-white dark:bg-gray-800 rounded-xl shadow-sm w-[72px]"><span className="text-3xl">🚼</span><span className="text-sm mt-1">Diaper</span></button>
|
||||
<Link href="/medical" className="flex-shrink-0 flex flex-col items-center p-4 bg-white dark:bg-gray-800 rounded-xl shadow-sm w-[72px]"><span className="text-3xl">💊</span><span className="text-sm mt-1">Medical</span></Link>
|
||||
<Link href="/wardrobe/add" className="flex-shrink-0 flex flex-col items-center p-4 bg-white dark:bg-gray-800 rounded-xl shadow-sm w-[72px]"><span className="text-3xl">👗</span><span className="text-sm mt-1">Wardrobe</span></Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h2 className="font-semibold ml-1">Ask AI</h2>
|
||||
<Link href="/ai" className="text-rose-500 text-sm">View all chats →</Link>
|
||||
</div>
|
||||
<div className="p-4 bg-white dark:bg-gray-800 rounded-2xl shadow-md">
|
||||
<div className="flex gap-2 mb-3">
|
||||
<input type="text" value={aiInput} onChange={e => setAiInput(e.target.value)} onKeyDown={e => e.key === "Enter" && handleAiChat()} placeholder="Ask anything..." className="flex-1 p-2 border dark:border-gray-600 rounded-xl text-sm bg-white dark:bg-gray-700 dark:text-white dark:placeholder-gray-400" disabled={aiLoading} />
|
||||
<button onClick={() => handleAiChat()} disabled={aiLoading || !aiInput.trim()} className="px-4 bg-rose-400 text-white rounded-xl text-sm">{aiLoading ? "..." : "Ask"}</button>
|
||||
</div>
|
||||
<div className="flex gap-2 overflow-x-auto scrollbar-hide">
|
||||
{(aiChips.length > 0 ? aiChips : AI_CHIP_FALLBACK).map((q, i) => (
|
||||
<button key={i} onClick={() => handleAiChat(q)} className="flex-shrink-0 px-3 py-1.5 bg-rose-50 dark:bg-gray-700 text-rose-600 dark:text-rose-400 rounded-full text-sm whitespace-nowrap">{q}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 mt-4">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h2 className="font-semibold ml-1">Recent Activity</h2>
|
||||
<Link href="/activity" className="text-rose-500 text-sm">See all →</Link>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{logsLoading ? <p className="text-gray-400 text-sm">Loading...</p> : recentLogs.length === 0 ? <p className="text-gray-400 text-sm">No logs yet today</p> : recentLogs.slice(0, 3).map(log => (
|
||||
<div key={log.id} className="flex items-center justify-between p-3 bg-white dark:bg-gray-800 rounded-xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl">{log.type === "feed" && "🍼"}{log.type === "sleep" && "😴"}{log.type === "diaper" && "🚼"}</span>
|
||||
<div><div className="font-medium capitalize">{log.type}</div><div className="text-xs text-gray-500 dark:text-gray-400">{fmtTime(log.loggedAt)}</div></div>
|
||||
</div>
|
||||
{log.amount && <span className="text-sm text-gray-500 dark:text-gray-400">{log.amount}ml</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LogModal
|
||||
type={modalType}
|
||||
childId={childId}
|
||||
onClose={() => setModalType(null)}
|
||||
onSaved={fetchRecentLogs}
|
||||
smartDefault={modalType ? (() => {
|
||||
const last = recentLogs.find(l => l.type === modalType);
|
||||
return last?.subType ? { subType: last.subType, amountMl: last.amount ?? undefined } : null;
|
||||
})() : null}
|
||||
/>
|
||||
|
||||
{aiOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => { setAiOpen(false); setHomeSessionId(null); }}>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 w-full max-w-sm mx-4 max-h-[80vh] flex flex-col" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex justify-between items-center mb-3"><h2 className="font-bold">Ask AI</h2><button onClick={() => { setAiOpen(false); setHomeSessionId(null); }}>✕</button></div>
|
||||
<div className="flex-1 overflow-y-auto space-y-3 mb-3 min-h-[250px]">
|
||||
{aiChats.map((chat) => (<div key={chat.id} className={`max-w-[85%] p-3 rounded-xl text-sm ${chat.role === "user" ? "ml-auto bg-rose-400 text-white" : "bg-rose-50 dark:bg-gray-700"}`}>{chat.content}</div>))}
|
||||
{aiLoading && <div className="bg-rose-50 dark:bg-gray-700 p-3 rounded-xl text-sm animate-pulse">Thinking...</div>}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input type="text" value={aiInput} onChange={e => setAiInput(e.target.value)} onKeyDown={e => e.key === "Enter" && handleAiChat()} placeholder="Ask a question..." className="flex-1 p-2 border dark:border-gray-600 rounded-xl text-sm dark:bg-gray-700 dark:text-white dark:placeholder-gray-400" disabled={aiLoading} />
|
||||
<button onClick={() => handleAiChat()} disabled={aiLoading || !aiInput.trim()} className="px-3 bg-rose-400 text-white rounded-xl">➤</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
src/app/(app)/layout.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import type { Metadata } from "next";
|
||||
import { ThemeProvider } from "@/app/ThemeProvider";
|
||||
import { FamilyProvider } from "@/app/FamilyProvider";
|
||||
import { PageTransition } from "@/components/PageTransition";
|
||||
import { BottomNav } from "@/components/BottomNav";
|
||||
import { InstallPrompt } from "@/components/InstallPrompt";
|
||||
|
||||
// Private, authenticated app — never index these pages.
|
||||
export const metadata: Metadata = {
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function AppLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<FamilyProvider>
|
||||
<PageTransition>{children}</PageTransition>
|
||||
<BottomNav />
|
||||
<InstallPrompt />
|
||||
</FamilyProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -17,10 +17,12 @@ const RULE_META: Record<string, { icon: string; title: string; color: string }>
|
|||
|
||||
export default function EmergencyPage() {
|
||||
const [phone, setPhone] = useState<string | null>(null);
|
||||
const [name, setName] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/family").then(r => r.json()).then(d => {
|
||||
setPhone(d.family?.pediatrician_phone || null);
|
||||
setName(d.family?.pediatrician_name || null);
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
|
|
@ -29,7 +31,7 @@ export default function EmergencyPage() {
|
|||
{/* Header */}
|
||||
<div className="sticky top-0 bg-red-500 text-white z-10">
|
||||
<div className="flex items-center gap-3 p-4">
|
||||
<Link href="/medical" className="text-white/80 hover:text-white text-xl">←</Link>
|
||||
<Link href="/home" className="text-white/80 hover:text-white text-xl">←</Link>
|
||||
<div>
|
||||
<h1 className="font-bold text-lg">Emergency Guide</h1>
|
||||
<p className="text-red-100 text-xs">When to call the doctor immediately</p>
|
||||
|
|
@ -38,20 +40,25 @@ export default function EmergencyPage() {
|
|||
</div>
|
||||
|
||||
{/* Call button */}
|
||||
<div className="p-4">
|
||||
<div className="p-4 space-y-1">
|
||||
{phone ? (
|
||||
<a
|
||||
href={`tel:${phone}`}
|
||||
className="w-full flex items-center justify-center gap-3 bg-red-500 hover:bg-red-600 text-white rounded-2xl p-4 text-lg font-bold shadow-md shadow-red-200 dark:shadow-red-900/30 transition-colors"
|
||||
>
|
||||
📞 Call Pediatrician Now
|
||||
</a>
|
||||
<>
|
||||
{name && (
|
||||
<p className="text-center text-sm font-medium text-gray-600 dark:text-gray-300 mb-2">{name}</p>
|
||||
)}
|
||||
<a
|
||||
href={`tel:${phone}`}
|
||||
className="w-full flex items-center justify-center gap-3 bg-red-500 hover:bg-red-600 text-white rounded-2xl p-4 text-lg font-bold shadow-md shadow-red-200 dark:shadow-red-900/30 transition-colors"
|
||||
>
|
||||
📞 Call Pediatrician Now
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<Link
|
||||
href="/settings"
|
||||
className="w-full flex items-center justify-center gap-2 bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded-2xl p-4 font-medium"
|
||||
>
|
||||
+ Add pediatrician phone in Settings
|
||||
+ Add pediatrician info in Settings
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
46
src/app/(app)/medical/page.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useFamily } from "@/app/FamilyProvider";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { TabBar } from "@/components/TabBar";
|
||||
import { VaccineTab } from "@/components/medical/VaccineTab";
|
||||
import { MedicineTab } from "@/components/medical/MedicineTab";
|
||||
import { AllergyTab } from "@/components/medical/AllergyTab";
|
||||
import { VisitTab } from "@/components/medical/VisitTab";
|
||||
import { IllnessTab } from "@/components/medical/IllnessTab";
|
||||
|
||||
type MedTab = "vaccinations" | "medicine" | "allergies" | "visits" | "illness";
|
||||
|
||||
const TABS = [
|
||||
{ value: "vaccinations", label: "Vaccines" },
|
||||
{ value: "medicine", label: "Medicine" },
|
||||
{ value: "allergies", label: "Allergies" },
|
||||
{ value: "visits", label: "Doctor Visit" },
|
||||
{ value: "illness", label: "Illness" },
|
||||
];
|
||||
|
||||
export default function MedicalPage() {
|
||||
const { childId, child } = useFamily();
|
||||
const [tab, setTab] = useState<MedTab>("vaccinations");
|
||||
|
||||
if (!childId || !child) {
|
||||
return <div className="p-8 text-center text-gray-400">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 pb-20">
|
||||
<PageHeader title="Medical" />
|
||||
<div className="px-4 mb-4">
|
||||
<TabBar tabs={TABS} value={tab} onChange={v => setTab(v as MedTab)} />
|
||||
</div>
|
||||
<div className="px-4">
|
||||
{tab === "vaccinations" && <VaccineTab childId={childId} birthDate={child.birthDate} />}
|
||||
{tab === "medicine" && <MedicineTab childId={childId} />}
|
||||
{tab === "allergies" && <AllergyTab childId={childId} />}
|
||||
{tab === "visits" && <VisitTab childId={childId} />}
|
||||
{tab === "illness" && <IllnessTab childId={childId} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
673
src/app/(app)/memories/page.tsx
Normal file
|
|
@ -0,0 +1,673 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { useFamily } from "@/app/FamilyProvider";
|
||||
import { Button, ConfirmDialog, Modal } from "@/components/ui";
|
||||
import { StorageMeter, StorageQuotaBanner } from "@/components/StorageMeter";
|
||||
import { formatBytes } from "@/lib/format-bytes";
|
||||
import { trackMemoryAdded } from "@/lib/analytics";
|
||||
|
||||
/** Some Android cameras return file.type = "" — detect from extension as fallback. */
|
||||
function resolveContentType(file: File): string {
|
||||
if (file.type && file.type !== "application/octet-stream") return file.type;
|
||||
const ext = file.name.split(".").pop()?.toLowerCase() ?? "";
|
||||
const map: Record<string, string> = {
|
||||
jpg: "image/jpeg", jpeg: "image/jpeg",
|
||||
png: "image/png", webp: "image/webp",
|
||||
heic: "image/heic", heif: "image/heic",
|
||||
gif: "image/gif",
|
||||
};
|
||||
return map[ext] || "image/jpeg";
|
||||
}
|
||||
|
||||
const PRESET_FOLDERS = [
|
||||
{ id: "", label: "All", emoji: "🌟" },
|
||||
{ id: "first-steps", label: "First Steps", emoji: "👣" },
|
||||
{ id: "bath-time", label: "Bath Time", emoji: "🛁" },
|
||||
{ id: "feeding", label: "Feeding", emoji: "🍼" },
|
||||
{ id: "family", label: "Family", emoji: "👨👩👧" },
|
||||
{ id: "milestones", label: "Milestones", emoji: "🏆" },
|
||||
{ id: "birthday", label: "Birthday", emoji: "🎂" },
|
||||
{ id: "outings", label: "Outings", emoji: "🌳" },
|
||||
{ id: "smiles", label: "Smiles", emoji: "😊" },
|
||||
];
|
||||
|
||||
const EMOJI_OPTIONS = ["📁","🌈","⭐","🎵","🏖","🎈","🐣","💛","🌸","🦋","🐾","🎀","🌙","🏠","🎠"];
|
||||
|
||||
interface Folder { id: string; label: string; emoji: string; }
|
||||
|
||||
interface Memory {
|
||||
id: string;
|
||||
key: string;
|
||||
url: string;
|
||||
thumbnailUrl: string | null;
|
||||
sizeBytes: number | null;
|
||||
mimeType: string | null;
|
||||
title: string | null;
|
||||
description: string | null;
|
||||
takenAt: string | null;
|
||||
visionCaption: string | null;
|
||||
visionTags: string[] | null;
|
||||
isPrivate: boolean;
|
||||
processingStatus: "uploading" | "processing" | "ready" | "failed";
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export default function MemoriesPage() {
|
||||
const { childId } = useFamily();
|
||||
const [memories, setMemories] = useState<Memory[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<Memory | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [activeFolder, setActiveFolder] = useState("");
|
||||
const [showFolderPicker, setShowFolderPicker] = useState(false);
|
||||
const [pendingFolder, setPendingFolder] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<Memory[] | null>(null);
|
||||
const [searching, setSearching] = useState(false);
|
||||
|
||||
// Custom folders
|
||||
const [customFolders, setCustomFolders] = useState<Folder[]>([]);
|
||||
const [showNewFolder, setShowNewFolder] = useState(false);
|
||||
const [newFolderName, setNewFolderName] = useState("");
|
||||
const [newFolderEmoji, setNewFolderEmoji] = useState("📁");
|
||||
|
||||
// Quota state — fetched on mount, refreshed after any 402 response
|
||||
const [quotaExceeded, setQuotaExceeded] = useState(false);
|
||||
const [quotaBanner, setQuotaBanner] = useState<{ usedBytes: number; limitBytes: number } | null>(null);
|
||||
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const loaderRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem("tia_custom_folders");
|
||||
if (saved) setCustomFolders(JSON.parse(saved));
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
// Fetch storage quota on mount so we can disable the FAB before the user
|
||||
// tries to upload and hits a 402 (better UX than a failed upload).
|
||||
useEffect(() => {
|
||||
fetch("/api/storage-usage")
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (d.exceeded) {
|
||||
setQuotaExceeded(true);
|
||||
setQuotaBanner({ usedBytes: d.usedBytes, limitBytes: d.limitBytes });
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const allFolders: Folder[] = [...PRESET_FOLDERS, ...customFolders];
|
||||
|
||||
const handleCreateFolder = () => {
|
||||
const trimmed = newFolderName.trim();
|
||||
if (!trimmed) return;
|
||||
const id = trimmed.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "") || `folder-${Date.now()}`;
|
||||
if (allFolders.some(f => f.id === id)) { alert("A folder with this name already exists."); return; }
|
||||
const folder: Folder = { id, label: trimmed, emoji: newFolderEmoji };
|
||||
const updated = [...customFolders, folder];
|
||||
setCustomFolders(updated);
|
||||
localStorage.setItem("tia_custom_folders", JSON.stringify(updated));
|
||||
setNewFolderName("");
|
||||
setNewFolderEmoji("📁");
|
||||
setShowNewFolder(false);
|
||||
};
|
||||
|
||||
const fetchMemories = useCallback(async (cursor?: string) => {
|
||||
if (!childId) return;
|
||||
try {
|
||||
const params = new URLSearchParams({ childId, limit: "60" });
|
||||
if (cursor) params.set("cursor", cursor);
|
||||
const res = await fetch(`/api/memories?${params}`);
|
||||
const data = await res.json();
|
||||
if (cursor) setMemories(prev => [...prev, ...(data.items || [])]);
|
||||
else setMemories(data.items || []);
|
||||
setNextCursor(data.nextCursor || null);
|
||||
} catch (err) { console.error("Failed to fetch memories:", err); }
|
||||
}, [childId]);
|
||||
|
||||
useEffect(() => { if (childId) fetchMemories(); }, [childId, fetchMemories]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loaderRef.current) return;
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
if (entries[0].isIntersecting && nextCursor && !loadingMore) {
|
||||
setLoadingMore(true);
|
||||
fetchMemories(nextCursor).finally(() => setLoadingMore(false));
|
||||
}
|
||||
});
|
||||
observer.observe(loaderRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, [nextCursor, loadingMore, fetchMemories]);
|
||||
|
||||
const handleUploadClick = () => setShowFolderPicker(true);
|
||||
|
||||
const handleFolderChosen = (folderId: string) => {
|
||||
setPendingFolder(folderId);
|
||||
setShowFolderPicker(false);
|
||||
fileRef.current?.click();
|
||||
};
|
||||
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file || !childId) return;
|
||||
|
||||
const contentType = resolveContentType(file);
|
||||
setUploading(true);
|
||||
setUploadError(null);
|
||||
|
||||
try {
|
||||
// Step 1: reserve slot + quota gate
|
||||
const initRes = await fetch("/api/upload", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ filename: file.name, contentType, childId, sizeBytes: file.size }),
|
||||
});
|
||||
const initData = await initRes.json();
|
||||
if (!initRes.ok) {
|
||||
if (initRes.status === 402 && initData.reason === "storage_quota_exceeded") {
|
||||
setQuotaExceeded(true);
|
||||
setQuotaBanner({ usedBytes: initData.usedBytes ?? 0, limitBytes: initData.limitBytes ?? 0 });
|
||||
setUploadError("Storage quota exceeded");
|
||||
} else {
|
||||
setUploadError(initData.error || `Upload failed (${initRes.status})`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const { key, memoryId } = initData;
|
||||
|
||||
// Step 2: binary upload via proxy
|
||||
const putRes = await fetch(`/api/upload?${new URLSearchParams({ key, contentType })}`, {
|
||||
method: "PUT", body: file, headers: { "Content-Type": contentType },
|
||||
});
|
||||
if (!putRes.ok) { setUploadError(`Upload failed (${putRes.status})`); return; }
|
||||
|
||||
// Assign folder if chosen
|
||||
if (pendingFolder) {
|
||||
await fetch(`/api/memories/${memoryId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ description: pendingFolder }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// Step 3: confirm
|
||||
const confirmRes = await fetch(`/api/memories/${memoryId}/confirm`, { method: "POST" });
|
||||
if (!confirmRes.ok) {
|
||||
const cd = await confirmRes.json().catch(() => ({})) as { reason?: string; usedBytes?: number; limitBytes?: number; error?: string };
|
||||
if (confirmRes.status === 402) {
|
||||
setQuotaExceeded(true);
|
||||
fetch("/api/storage-usage").then(r => r.json())
|
||||
.then(d => setQuotaBanner({ usedBytes: d.usedBytes, limitBytes: d.limitBytes }))
|
||||
.catch(() => {});
|
||||
setUploadError("Storage quota exceeded");
|
||||
} else {
|
||||
setUploadError(cd.error || `Failed to confirm upload (${confirmRes.status})`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
trackMemoryAdded();
|
||||
// Optimistic grid update
|
||||
const proxyUrl = `/api/img?key=${encodeURIComponent(key)}`;
|
||||
setMemories(prev => [{
|
||||
id: memoryId, key, url: proxyUrl, thumbnailUrl: null,
|
||||
sizeBytes: file.size, mimeType: contentType, title: null,
|
||||
description: pendingFolder || null, takenAt: null,
|
||||
visionCaption: null, visionTags: null, isPrivate: false,
|
||||
processingStatus: "processing", createdAt: new Date().toISOString(),
|
||||
}, ...prev]);
|
||||
} catch (err) {
|
||||
setUploadError(err instanceof Error ? err.message : "Upload failed");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
setPendingFolder("");
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await fetch(`/api/memories/${id}`, { method: "DELETE" });
|
||||
setMemories(prev => prev.filter(m => m.id !== id));
|
||||
if (selected?.id === id) setSelected(null);
|
||||
setDeleteTarget(null);
|
||||
};
|
||||
|
||||
const handleSearch = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!query.trim()) { setSearchResults(null); return; }
|
||||
setSearching(true);
|
||||
try {
|
||||
const res = await fetch("/api/memories/search", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query: query.trim(), childId }),
|
||||
});
|
||||
const data = await res.json();
|
||||
setSearchResults(data.items || []);
|
||||
} catch { setSearchResults([]); }
|
||||
setSearching(false);
|
||||
};
|
||||
|
||||
const baseList = searchResults !== null ? searchResults : memories;
|
||||
const displayMemories = (activeFolder
|
||||
? baseList.filter(m => m.description === activeFolder)
|
||||
: baseList
|
||||
).filter(m => m.url || m.thumbnailUrl);
|
||||
|
||||
const folderCounts = memories.reduce<Record<string, number>>((acc, m) => {
|
||||
if (m.description) acc[m.description] = (acc[m.description] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 z-10 bg-white dark:bg-gray-900 border-b border-gray-100 dark:border-gray-800 px-4 py-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/menu" className="text-gray-500 dark:text-gray-400 p-1">←</Link>
|
||||
<h1 className="text-xs font-semibold dark:text-white">Memories 📸</h1>
|
||||
</div>
|
||||
</div>
|
||||
{/* Storage meter — only visible when approaching or exceeded */}
|
||||
<StorageMeter compact className="px-1" />
|
||||
</div>
|
||||
|
||||
{/* Storage quota banner — shown when upload is blocked */}
|
||||
{quotaBanner && (
|
||||
<div className="px-4 pt-3">
|
||||
<StorageQuotaBanner
|
||||
usedBytes={quotaBanner.usedBytes}
|
||||
limitBytes={quotaBanner.limitBytes}
|
||||
usedFormatted={formatBytes(quotaBanner.usedBytes)}
|
||||
limitFormatted={formatBytes(quotaBanner.limitBytes)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search */}
|
||||
<form onSubmit={handleSearch} className="px-4 pt-3 pb-1 flex gap-2">
|
||||
<input
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder="Search photos..."
|
||||
className="flex-1 px-3 py-2 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm dark:text-white dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-rose-300"
|
||||
/>
|
||||
<Button type="submit" size="sm" loading={searching}>Search</Button>
|
||||
{searchResults !== null && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => { setSearchResults(null); setQuery(""); }}>Clear</Button>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{/* Folder pills */}
|
||||
<div className="px-4 py-2 flex gap-2 overflow-x-auto scrollbar-hide">
|
||||
{allFolders.map(folder => {
|
||||
const count = folder.id === "" ? memories.length : (folderCounts[folder.id] || 0);
|
||||
const isActive = activeFolder === folder.id;
|
||||
return (
|
||||
<button
|
||||
key={folder.id}
|
||||
onClick={() => setActiveFolder(folder.id)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm whitespace-nowrap flex-shrink-0 transition-colors ${
|
||||
isActive
|
||||
? "bg-rose-400 text-white"
|
||||
: "bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 border border-gray-200 dark:border-gray-700"
|
||||
}`}
|
||||
>
|
||||
<span>{folder.emoji}</span>
|
||||
<span>{folder.label}</span>
|
||||
{count > 0 && (
|
||||
<span className={`text-xs ${isActive ? "text-white/80" : "text-gray-400"}`}>{count}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{/* New folder button */}
|
||||
<button
|
||||
onClick={() => setShowNewFolder(true)}
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-full text-sm whitespace-nowrap flex-shrink-0 bg-white dark:bg-gray-800 text-gray-400 dark:text-gray-500 border border-dashed border-gray-300 dark:border-gray-600 hover:border-rose-300 hover:text-rose-400 transition-colors"
|
||||
>
|
||||
<span>+</span>
|
||||
<span>New</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Gallery grid */}
|
||||
<div className="px-3 pb-24">
|
||||
{displayMemories.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center gap-3">
|
||||
<span className="text-6xl">📷</span>
|
||||
<p className="font-medium text-gray-500 dark:text-gray-400">
|
||||
{searchResults !== null ? "No photos found" : activeFolder ? "No photos in this folder yet" : "No memories yet"}
|
||||
</p>
|
||||
<p className="text-sm text-gray-400 dark:text-gray-500">
|
||||
{searchResults !== null ? "Try a different search term." : "Tap + to add your first photo."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-4 gap-1 pt-1">
|
||||
{displayMemories.map(mem => (
|
||||
<MemoryTile
|
||||
key={mem.id}
|
||||
memory={mem}
|
||||
folder={allFolders.find(f => f.id === mem.description) ?? null}
|
||||
onClick={() => setSelected(mem)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div ref={loaderRef} className="py-4 text-center text-sm text-gray-400">
|
||||
{loadingMore && "Loading more..."}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload error banner */}
|
||||
{uploadError && !uploading && (
|
||||
<div className="fixed top-4 inset-x-4 z-50 bg-red-500 text-white rounded-xl px-4 py-3 flex items-center justify-between shadow-lg">
|
||||
<span className="text-sm">{uploadError}</span>
|
||||
<button onClick={() => setUploadError(null)} className="ml-3 text-white/80 hover:text-white text-lg leading-none">✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload FAB — disabled when storage quota is exceeded */}
|
||||
<div className="fixed bottom-24 right-6 z-50">
|
||||
<button
|
||||
onClick={quotaExceeded ? undefined : handleUploadClick}
|
||||
disabled={uploading || quotaExceeded}
|
||||
title={quotaExceeded ? "Storage full — delete some memories or upgrade to upload more" : undefined}
|
||||
className={`w-14 h-14 rounded-full shadow-xl flex items-center justify-center text-white text-2xl font-light transition-colors ${
|
||||
uploading || quotaExceeded ? "bg-gray-400 cursor-not-allowed" : "bg-rose-400 hover:bg-rose-500 active:bg-rose-600"
|
||||
}`}
|
||||
>
|
||||
{uploading ? "…" : quotaExceeded ? "⊘" : "+"}
|
||||
</button>
|
||||
<input ref={fileRef} type="file" accept="image/*" onChange={handleUpload} className="hidden" disabled={uploading || quotaExceeded} />
|
||||
</div>
|
||||
|
||||
{/* Folder picker modal */}
|
||||
<Modal open={showFolderPicker} onClose={() => setShowFolderPicker(false)} title="Add to folder" maxWidth="sm">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{allFolders.map(folder => (
|
||||
<button
|
||||
key={folder.id}
|
||||
onClick={() => handleFolderChosen(folder.id)}
|
||||
className="flex flex-col items-center gap-1 p-3 rounded-xl bg-gray-50 dark:bg-gray-700 hover:bg-rose-50 dark:hover:bg-rose-900/30 transition-colors"
|
||||
>
|
||||
<span className="text-2xl">{folder.emoji}</span>
|
||||
<span className="text-xs text-center text-gray-600 dark:text-gray-300 font-medium leading-tight">{folder.label}</span>
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => { setShowFolderPicker(false); setShowNewFolder(true); }}
|
||||
className="flex flex-col items-center gap-1 p-3 rounded-xl border-2 border-dashed border-gray-200 dark:border-gray-600 hover:border-rose-300 dark:hover:border-rose-700 text-gray-400 hover:text-rose-400 transition-colors"
|
||||
>
|
||||
<span className="text-2xl">➕</span>
|
||||
<span className="text-xs font-medium">New folder</span>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 text-center mt-3">Tap "All" to upload without a folder</p>
|
||||
</Modal>
|
||||
|
||||
{/* New folder modal */}
|
||||
<Modal open={showNewFolder} onClose={() => { setShowNewFolder(false); setNewFolderName(""); setNewFolderEmoji("📁"); }} title="Create folder" maxWidth="sm">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">Choose an emoji</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{EMOJI_OPTIONS.map(em => (
|
||||
<button
|
||||
key={em}
|
||||
onClick={() => setNewFolderEmoji(em)}
|
||||
className={`text-xl p-1.5 rounded-lg transition-colors ${
|
||||
newFolderEmoji === em
|
||||
? "bg-rose-100 dark:bg-rose-900 ring-2 ring-rose-400"
|
||||
: "bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600"
|
||||
}`}
|
||||
>
|
||||
{em}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Folder name</p>
|
||||
<input
|
||||
autoFocus
|
||||
value={newFolderName}
|
||||
onChange={e => setNewFolderName(e.target.value)}
|
||||
onKeyDown={e => e.key === "Enter" && handleCreateFolder()}
|
||||
placeholder="e.g. Park days"
|
||||
maxLength={30}
|
||||
className="w-full px-3 py-2 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm dark:text-white focus:outline-none focus:ring-2 focus:ring-rose-300"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button variant="secondary" fullWidth onClick={() => { setShowNewFolder(false); setNewFolderName(""); setNewFolderEmoji("📁"); }}>Cancel</Button>
|
||||
<Button variant="primary" fullWidth onClick={handleCreateFolder} disabled={!newFolderName.trim()}>
|
||||
{newFolderEmoji} Create
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Fullscreen viewer */}
|
||||
{selected && (
|
||||
<MemoryViewer
|
||||
memory={selected}
|
||||
allFolders={allFolders}
|
||||
onClose={() => setSelected(null)}
|
||||
onDelete={() => setDeleteTarget(selected.id)}
|
||||
onUpdate={updated => {
|
||||
setMemories(prev => prev.map(m => m.id === updated.id ? { ...m, ...updated } : m));
|
||||
setSelected(prev => prev?.id === updated.id ? { ...prev, ...updated } : prev);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
onConfirm={() => deleteTarget && handleDelete(deleteTarget)}
|
||||
title="Delete this photo?"
|
||||
description="This removes the photo permanently from your memories."
|
||||
confirmLabel="Delete"
|
||||
variant="danger"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Grid tile ────────────────────────────────────────────────────────────────
|
||||
|
||||
function MemoryTile({ memory, folder, onClick }: { memory: Memory; folder: Folder | null; onClick: () => void }) {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [imgError, setImgError] = useState(false);
|
||||
const src = memory.thumbnailUrl || memory.url;
|
||||
|
||||
if (imgError) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<button
|
||||
className="relative w-full aspect-square rounded-xl overflow-hidden group bg-gray-100 dark:bg-gray-800 shadow-sm ring-1 ring-black/5 dark:ring-white/5"
|
||||
onClick={onClick}
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
alt={memory.title || "Memory"}
|
||||
className={`w-full h-full object-cover transition-all duration-300 group-hover:scale-110 ${loaded ? "opacity-100" : "opacity-0"}`}
|
||||
onLoad={() => setLoaded(true)}
|
||||
onError={() => setImgError(true)}
|
||||
loading="lazy"
|
||||
/>
|
||||
{!loaded && <div className="absolute inset-0 bg-gray-200 dark:bg-gray-700 animate-pulse rounded-xl" />}
|
||||
{/* hover overlay */}
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/25 transition-colors duration-200" />
|
||||
{/* hover expand icon */}
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||
<span className="text-white text-lg drop-shadow">⤢</span>
|
||||
</div>
|
||||
{memory.processingStatus === "processing" && (
|
||||
<span className="absolute top-1 left-1 text-[8px] bg-black/60 text-white px-1.5 py-0.5 rounded-full leading-tight">
|
||||
⏳
|
||||
</span>
|
||||
)}
|
||||
{memory.isPrivate && <span className="absolute top-1 right-1 text-[9px]">🔒</span>}
|
||||
</button>
|
||||
{/* Folder label */}
|
||||
{folder && folder.id && (
|
||||
<p className="text-[9px] text-center text-gray-400 dark:text-gray-500 truncate leading-tight px-0.5">
|
||||
{folder.emoji} {folder.label}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Fullscreen viewer ────────────────────────────────────────────────────────
|
||||
|
||||
function MemoryViewer({ memory, allFolders, onClose, onDelete, onUpdate }: {
|
||||
memory: Memory;
|
||||
allFolders: Folder[];
|
||||
onClose: () => void;
|
||||
onDelete: () => void;
|
||||
onUpdate: (updated: Partial<Memory> & { id: string }) => void;
|
||||
}) {
|
||||
const [isPrivate, setIsPrivate] = useState(memory.isPrivate);
|
||||
const [toggling, setToggling] = useState(false);
|
||||
const [showChrome, setShowChrome] = useState(true);
|
||||
const [zoomed, setZoomed] = useState(false);
|
||||
const [folderEditing, setFolderEditing] = useState(false);
|
||||
|
||||
const togglePrivate = async () => {
|
||||
setToggling(true);
|
||||
const newVal = !isPrivate;
|
||||
try {
|
||||
await fetch(`/api/memories/${memory.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ isPrivate: newVal }),
|
||||
});
|
||||
setIsPrivate(newVal);
|
||||
onUpdate({ id: memory.id, isPrivate: newVal, ...(newVal ? { visionCaption: null, visionTags: null } : {}) });
|
||||
} finally { setToggling(false); }
|
||||
};
|
||||
|
||||
const assignFolder = async (folderId: string) => {
|
||||
try {
|
||||
await fetch(`/api/memories/${memory.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ description: folderId || null }),
|
||||
});
|
||||
onUpdate({ id: memory.id, description: folderId || null });
|
||||
} catch (err) { console.error("Failed to assign folder:", err); }
|
||||
setFolderEditing(false);
|
||||
};
|
||||
|
||||
const currentFolder = allFolders.find(f => f.id === memory.description);
|
||||
|
||||
const handleImageTap = () => {
|
||||
if (zoomed) {
|
||||
setZoomed(false);
|
||||
} else {
|
||||
setShowChrome(s => !s);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black z-50 flex flex-col" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||
{/* Top bar */}
|
||||
<div className={`flex items-center justify-between px-4 py-3 flex-shrink-0 bg-gradient-to-b from-black/70 to-transparent transition-opacity duration-200 ${showChrome ? "opacity-100" : "opacity-0 pointer-events-none"}`}>
|
||||
<button onClick={onClose} className="text-white/90 hover:text-white p-1 text-lg">✕</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setFolderEditing(true)}
|
||||
className="text-sm text-white/70 hover:text-white flex items-center gap-1"
|
||||
>
|
||||
{currentFolder && currentFolder.id ? `${currentFolder.emoji} ${currentFolder.label}` : "📁 Folder"}
|
||||
</button>
|
||||
<button
|
||||
onClick={togglePrivate}
|
||||
disabled={toggling}
|
||||
className={`text-sm px-3 py-1 rounded-full transition-colors ${
|
||||
isPrivate ? "bg-gray-700 text-gray-200" : "bg-gray-800 text-gray-400 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{toggling ? "…" : isPrivate ? "🔒" : "🌐"}
|
||||
</button>
|
||||
<button onClick={onDelete} className="text-red-400 hover:text-red-300 p-1">🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image area */}
|
||||
<div
|
||||
className="flex-1 flex items-center justify-center min-h-0 overflow-hidden"
|
||||
onClick={handleImageTap}
|
||||
>
|
||||
<img
|
||||
src={memory.url}
|
||||
alt={memory.title || "Memory"}
|
||||
style={{ transform: zoomed ? "scale(2)" : "scale(1)", transition: "transform 0.25s ease-out" }}
|
||||
className={`max-w-full max-h-full object-contain ${zoomed ? "cursor-zoom-out" : "cursor-zoom-in"}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Zoom hint */}
|
||||
{showChrome && !zoomed && (
|
||||
<div className="absolute bottom-20 left-1/2 -translate-x-1/2 text-white/40 text-xs pointer-events-none">
|
||||
tap to hide UI · tap again to zoom
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Caption/tags */}
|
||||
{showChrome && !zoomed && (memory.visionCaption || (memory.visionTags && memory.visionTags.length > 0)) && (
|
||||
<div className="px-4 py-3 bg-gradient-to-t from-black/80 to-transparent flex-shrink-0">
|
||||
{memory.visionCaption && (
|
||||
<p className="text-white/90 text-sm leading-relaxed mb-2">{memory.visionCaption}</p>
|
||||
)}
|
||||
{memory.visionTags && memory.visionTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{memory.visionTags.map((tag, i) => (
|
||||
<span key={i} className="text-xs bg-white/10 text-white/70 px-2 py-0.5 rounded-full">#{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Folder assignment bottom sheet */}
|
||||
{folderEditing && (
|
||||
<div className="absolute inset-0 bg-black/70 flex items-end z-10" onClick={() => setFolderEditing(false)}>
|
||||
<div className="w-full bg-white dark:bg-gray-900 rounded-t-2xl p-4 max-h-[70vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
||||
<h3 className="font-semibold text-center mb-3 dark:text-white">Move to folder</h3>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{allFolders.map(folder => (
|
||||
<button
|
||||
key={folder.id}
|
||||
onClick={() => assignFolder(folder.id)}
|
||||
className={`flex flex-col items-center gap-1 p-2 rounded-xl transition-colors ${
|
||||
memory.description === folder.id
|
||||
? "bg-rose-100 dark:bg-rose-900 ring-2 ring-rose-400"
|
||||
: "bg-gray-100 dark:bg-gray-800"
|
||||
}`}
|
||||
>
|
||||
<span className="text-xl">{folder.emoji}</span>
|
||||
<span className="text-xs text-center text-gray-600 dark:text-gray-300 leading-tight">{folder.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -9,13 +9,15 @@ export default function MenuPage() {
|
|||
const [signingOut, setSigningOut] = useState(false);
|
||||
|
||||
const menuItems = [
|
||||
{ icon: "🏠", label: "Home", href: "/" },
|
||||
{ icon: "📊", label: "Activity", href: "/activity" },
|
||||
{ icon: "📈", label: "Growth", href: "/growth" },
|
||||
{ icon: "💊", label: "Medical", href: "/medical" },
|
||||
{ icon: "🏡", label: "Home", href: "/home" },
|
||||
{ icon: "📝", label: "Activity", href: "/activity" },
|
||||
{ icon: "🌿", label: "Growth", href: "/growth" },
|
||||
{ icon: "🩺", label: "Medical", href: "/medical" },
|
||||
{ icon: "📸", label: "Memories", href: "/memories" },
|
||||
{ icon: "🤖", label: "AI Chat", href: "/ai" },
|
||||
{ icon: "🔮", label: "AI Chat", href: "/ai" },
|
||||
{ icon: "🌟", label: "Milestones", href: "/milestones" },
|
||||
{ icon: "🧺", label: "Wardrobe", href: "/wardrobe" },
|
||||
{ icon: "💞", label: "Circle", href: "/circle" },
|
||||
];
|
||||
|
||||
const handleSignOut = async () => {
|
||||
|
|
@ -23,6 +25,12 @@ export default function MenuPage() {
|
|||
setSigningOut(true);
|
||||
try {
|
||||
await fetch("/api/auth/signout", { method: "POST" });
|
||||
// Purge SW caches so a shared device can't see the previous user's
|
||||
// cached baby photos or shell state after login.
|
||||
if ("caches" in window) {
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys.map((k) => caches.delete(k)));
|
||||
}
|
||||
router.push("/login");
|
||||
} catch (err) {
|
||||
console.error("Sign out failed:", err);
|
||||
|
|
@ -31,7 +39,7 @@ export default function MenuPage() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800">
|
||||
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 pb-28">
|
||||
{/* Header */}
|
||||
<div className="p-4 flex justify-between items-center">
|
||||
<h1 className="text-xl font-bold">Menu</h1>
|
||||
|
|
@ -53,7 +61,7 @@ export default function MenuPage() {
|
|||
</div>
|
||||
|
||||
{/* Bottom Section - Settings and Sign Out */}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4 space-y-2">
|
||||
<div className="px-4 mt-4 space-y-2">
|
||||
<Link
|
||||
href="/settings"
|
||||
className="flex items-center gap-4 p-4 bg-white dark:bg-gray-800 rounded-xl"
|
||||
|
|
@ -3,6 +3,8 @@ import { useEffect, useState, useMemo } from "react";
|
|||
import { useFamily } from "@/app/FamilyProvider";
|
||||
import { useStageCheck } from "@/hooks/useStageCheck";
|
||||
import { MILESTONES, type MilestoneDef } from "@/lib/milestones";
|
||||
import { Button, Input } from "@/components/ui";
|
||||
import { fmtDate } from "@/lib/date-ist";
|
||||
|
||||
type Category = "all" | "social" | "motor" | "language" | "cognitive";
|
||||
type Filter = "all" | "achieved" | "upcoming";
|
||||
|
|
@ -29,7 +31,6 @@ export default function MilestonesPage() {
|
|||
const [filter, setFilter] = useState<Filter>("all");
|
||||
const [category, setCategory] = useState<Category>("all");
|
||||
|
||||
// date picker state per milestone key
|
||||
const [pendingKey, setPendingKey] = useState<string | null>(null);
|
||||
const [pendingDate, setPendingDate] = useState<string>(
|
||||
new Date().toISOString().slice(0, 10)
|
||||
|
|
@ -46,7 +47,6 @@ export default function MilestonesPage() {
|
|||
|
||||
async function toggleMilestone(m: MilestoneWithStatus) {
|
||||
if (m.achieved) {
|
||||
// un-mark
|
||||
await fetch(`/api/milestones/${m.key}?childId=${child!.id}`, { method: "DELETE" });
|
||||
setItems(prev => prev.map(x => x.key === m.key ? { ...x, achieved: false, achievedAt: null } : x));
|
||||
if (pendingKey === m.key) setPendingKey(null);
|
||||
|
|
@ -84,76 +84,83 @@ export default function MilestonesPage() {
|
|||
}, [items, stage]);
|
||||
|
||||
if (!child) return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 flex items-center justify-center">
|
||||
<p className="text-gray-500">Select a child to view milestones.</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 pb-20">
|
||||
<div className="max-w-2xl mx-auto px-4 py-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 pb-20">
|
||||
{/* Header */}
|
||||
<div className="p-4 flex justify-between items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<a href="/menu" className="p-2">←</a>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Milestones</h1>
|
||||
<p className="text-sm text-gray-500">{child.name}</p>
|
||||
</div>
|
||||
{stage && (
|
||||
<span className="text-sm font-medium px-3 py-1 rounded-full bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300">
|
||||
{stage.emoji} {stage.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 mb-4 shadow-sm">
|
||||
<div className="flex items-center justify-between text-sm mb-2">
|
||||
<span className="font-medium text-gray-700 dark:text-gray-300">{achievedCount} of {items.length} milestones</span>
|
||||
<span className="text-gray-400">{Math.round(achievedCount / Math.max(items.length, 1) * 100)}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-pink-400 to-purple-400 rounded-full transition-all duration-500"
|
||||
style={{ width: `${achievedCount / Math.max(items.length, 1) * 100}%` }}
|
||||
/>
|
||||
<h1 className="text-xl font-bold">Milestones 🌱</h1>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{child.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
{stage && (
|
||||
<span className="text-xs font-medium px-3 py-1.5 rounded-full bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 shadow-sm">
|
||||
{stage.emoji} {stage.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter tabs */}
|
||||
<div className="flex gap-2 mb-3 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{(["all", "achieved", "upcoming"] as Filter[]).map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-4 py-1.5 rounded-full text-sm font-medium whitespace-nowrap transition-colors ${
|
||||
filter === f
|
||||
? "bg-purple-500 text-white"
|
||||
: "bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 border dark:border-gray-700"
|
||||
}`}
|
||||
>
|
||||
{f.charAt(0).toUpperCase() + f.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
{/* Progress bar */}
|
||||
<div className="mx-4 mb-4 p-4 bg-white dark:bg-gray-800 rounded-xl shadow-sm">
|
||||
<div className="flex items-center justify-between text-sm mb-2">
|
||||
<span className="font-medium text-gray-700 dark:text-gray-300">
|
||||
{achievedCount} of {items.length} milestones
|
||||
</span>
|
||||
<span className="text-gray-400">
|
||||
{Math.round(achievedCount / Math.max(items.length, 1) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Category chips */}
|
||||
<div className="flex gap-2 mb-5 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{(["all", "social", "motor", "language", "cognitive"] as Category[]).map(c => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setCategory(c)}
|
||||
className={`px-3 py-1 rounded-full text-xs font-medium whitespace-nowrap transition-colors ${
|
||||
category === c
|
||||
? "bg-gray-800 dark:bg-gray-200 text-white dark:text-gray-900"
|
||||
: "bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 border dark:border-gray-700"
|
||||
}`}
|
||||
>
|
||||
{c.charAt(0).toUpperCase() + c.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
<div className="h-2 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-rose-400 to-pink-400 rounded-full transition-all duration-500"
|
||||
style={{ width: `${achievedCount / Math.max(items.length, 1) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Milestone grid */}
|
||||
{/* Filter tabs */}
|
||||
<div className="px-4 mb-3 flex gap-2 overflow-x-auto">
|
||||
{(["all", "achieved", "upcoming"] as Filter[]).map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-4 py-2 rounded-full text-sm whitespace-nowrap transition-colors ${
|
||||
filter === f
|
||||
? "bg-rose-400 text-white"
|
||||
: "bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{f.charAt(0).toUpperCase() + f.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Category chips */}
|
||||
<div className="px-4 mb-4 flex gap-2 overflow-x-auto">
|
||||
{(["all", "social", "motor", "language", "cognitive"] as Category[]).map(c => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setCategory(c)}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-medium whitespace-nowrap transition-colors ${
|
||||
category === c
|
||||
? "bg-rose-400 text-white"
|
||||
: "bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{c.charAt(0).toUpperCase() + c.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Milestone grid */}
|
||||
<div className="px-4">
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
|
|
@ -161,7 +168,10 @@ export default function MilestonesPage() {
|
|||
))}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="text-center text-gray-400 py-12">No milestones match this filter.</p>
|
||||
<div className="text-center py-20 text-gray-400">
|
||||
<div className="text-5xl mb-3">🌱</div>
|
||||
<p>No milestones match this filter.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{filtered.map(m => (
|
||||
|
|
@ -185,7 +195,7 @@ export default function MilestonesPage() {
|
|||
<p className="text-xs text-gray-400 mt-1">{m.ageRangeLabel}</p>
|
||||
{m.achieved && m.achievedAt && (
|
||||
<p className="text-xs text-green-600 dark:text-green-400 mt-1">
|
||||
{new Date(m.achievedAt).toLocaleDateString("en-IN", { day: "numeric", month: "short" })}
|
||||
{fmtDate(m.achievedAt, { day: "numeric", month: "short" })}
|
||||
</p>
|
||||
)}
|
||||
<span className={`inline-block text-xs px-1.5 py-0.5 rounded-full mt-2 ${CATEGORY_COLORS[m.category]}`}>
|
||||
|
|
@ -195,28 +205,22 @@ export default function MilestonesPage() {
|
|||
|
||||
{/* Inline date picker */}
|
||||
{pendingKey === m.key && (
|
||||
<div className="mt-2 bg-white dark:bg-gray-800 rounded-xl p-3 shadow-md border dark:border-gray-700">
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 mb-2">When did this happen?</p>
|
||||
<input
|
||||
<div className="mt-2 bg-white dark:bg-gray-800 rounded-xl p-3 shadow-md border border-gray-100 dark:border-gray-700">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2">When did this happen?</p>
|
||||
<Input
|
||||
type="date"
|
||||
value={pendingDate}
|
||||
max={new Date().toISOString().slice(0, 10)}
|
||||
onChange={e => setPendingDate(e.target.value)}
|
||||
className="w-full text-sm border dark:border-gray-600 rounded-lg px-2 py-1.5 dark:bg-gray-700 dark:text-white mb-2"
|
||||
className="mb-2"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => confirmAchieved(m.key)}
|
||||
className="flex-1 bg-green-500 text-white rounded-lg py-1.5 text-xs font-medium"
|
||||
>
|
||||
<Button size="sm" fullWidth onClick={() => confirmAchieved(m.key)}>
|
||||
Mark achieved
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPendingKey(null)}
|
||||
className="flex-1 border dark:border-gray-600 rounded-lg py-1.5 text-xs text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" fullWidth onClick={() => setPendingKey(null)}>
|
||||
Cancel
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
186
src/app/(app)/notifications/page.tsx
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useFamily } from "@/app/FamilyProvider";
|
||||
import { fmtDate } from "@/lib/date-ist";
|
||||
|
||||
interface AppNotification {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
message: string;
|
||||
actionUrl?: string;
|
||||
isRead: boolean;
|
||||
scheduledFor?: string;
|
||||
createdAt: string;
|
||||
metadata?: { vaccineName?: string; dueDate?: string };
|
||||
}
|
||||
|
||||
function notifIcon(type: string, metadata?: { dueDate?: string }): string {
|
||||
if (type.startsWith("vaccine_")) {
|
||||
const isOverdue = metadata?.dueDate && metadata.dueDate < new Date().toISOString().slice(0, 10);
|
||||
return isOverdue ? "🚨" : "💉";
|
||||
}
|
||||
if (type === "log_nudge") return "🍼";
|
||||
if (type === "memory_nudge") return "📸";
|
||||
if (type === "garment_nudge") return "👚";
|
||||
return "🔔";
|
||||
}
|
||||
|
||||
function notifColor(type: string): string {
|
||||
if (type.startsWith("vaccine_")) return "bg-red-50 dark:bg-red-900/20 border-red-100 dark:border-red-800/40";
|
||||
if (type === "log_nudge") return "bg-amber-50 dark:bg-amber-900/20 border-amber-100 dark:border-amber-800/40";
|
||||
if (type === "memory_nudge") return "bg-purple-50 dark:bg-purple-900/20 border-purple-100 dark:border-purple-800/40";
|
||||
if (type === "garment_nudge") return "bg-pink-50 dark:bg-pink-900/20 border-pink-100 dark:border-pink-800/40";
|
||||
return "bg-white dark:bg-gray-800";
|
||||
}
|
||||
|
||||
export default function NotificationsPage() {
|
||||
const router = useRouter();
|
||||
const { childId } = useFamily();
|
||||
const [items, setItems] = useState<AppNotification[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [markingAll, setMarkingAll] = useState(false);
|
||||
|
||||
const fetchNotifications = useCallback(async () => {
|
||||
if (!childId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/notifications?childId=${childId}`);
|
||||
const data = await res.json();
|
||||
setItems(data.notifications || []);
|
||||
} catch { /* swallow — show empty state */ }
|
||||
setLoading(false);
|
||||
}, [childId]);
|
||||
|
||||
useEffect(() => { fetchNotifications(); }, [fetchNotifications]);
|
||||
|
||||
const markRead = async (notif: AppNotification) => {
|
||||
if (notif.isRead) {
|
||||
// Navigate immediately if already read
|
||||
if (notif.actionUrl) router.push(notif.actionUrl);
|
||||
return;
|
||||
}
|
||||
// Optimistic update
|
||||
setItems(prev => prev.map(n => n.id === notif.id ? { ...n, isRead: true } : n));
|
||||
try {
|
||||
await fetch(`/api/notifications/${notif.id}`, { method: "PATCH" });
|
||||
} catch { /* no-op — optimistic state is fine */ }
|
||||
if (notif.actionUrl) router.push(notif.actionUrl);
|
||||
};
|
||||
|
||||
const markAllRead = async () => {
|
||||
if (!childId) return;
|
||||
setMarkingAll(true);
|
||||
setItems(prev => prev.map(n => ({ ...n, isRead: true })));
|
||||
try {
|
||||
await fetch("/api/notifications", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ childId }),
|
||||
});
|
||||
} catch { /* optimistic update already done */ }
|
||||
setMarkingAll(false);
|
||||
};
|
||||
|
||||
const unreadCount = items.filter(n => !n.isRead).length;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800">
|
||||
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 z-20 bg-rose-50/80 dark:bg-gray-900/80 backdrop-blur-sm px-4 py-3 flex items-center gap-3 border-b border-rose-100 dark:border-gray-700">
|
||||
<button onClick={() => router.back()} className="p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-xl">←</button>
|
||||
<h1 className="text-xl font-bold flex-1">Notifications</h1>
|
||||
{unreadCount > 0 && (
|
||||
<span className="px-2.5 py-0.5 bg-rose-400 text-white text-xs font-bold rounded-full">
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={markAllRead}
|
||||
disabled={markingAll}
|
||||
className="text-sm text-rose-500 font-medium disabled:opacity-50"
|
||||
>
|
||||
{markingAll ? "Marking…" : "Mark all read"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Section hint */}
|
||||
{!loading && items.length > 0 && (
|
||||
<p className="px-4 pt-3 pb-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
Tap a notification to go to the relevant page
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* List */}
|
||||
<div className="px-4 py-2 space-y-2 pb-24">
|
||||
{loading ? (
|
||||
<div className="text-center py-20 text-gray-400">
|
||||
<div className="text-4xl mb-3 animate-pulse">🔔</div>
|
||||
<p className="text-sm">Loading…</p>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="text-center py-20 text-gray-400">
|
||||
<div className="text-6xl mb-4">🔔</div>
|
||||
<p className="font-semibold text-gray-600 dark:text-gray-300">All caught up!</p>
|
||||
<p className="text-sm mt-1 text-gray-400">No pending reminders right now</p>
|
||||
</div>
|
||||
) : (
|
||||
items.map(notif => (
|
||||
<button
|
||||
key={notif.id}
|
||||
onClick={() => markRead(notif)}
|
||||
className={`w-full text-left rounded-2xl border transition-all active:scale-[0.98] ${
|
||||
notif.isRead
|
||||
? "bg-gray-50 dark:bg-gray-800/40 border-gray-100 dark:border-gray-700/40 opacity-60"
|
||||
: `${notifColor(notif.type)} border shadow-sm`
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3 p-4">
|
||||
<span className="text-2xl mt-0.5 flex-shrink-0">{notifIcon(notif.type, notif.metadata)}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`font-semibold text-sm ${notif.isRead ? "text-gray-400 dark:text-gray-500" : "text-gray-900 dark:text-white"}`}>
|
||||
{notif.title}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5 leading-snug">
|
||||
{notif.message}
|
||||
</div>
|
||||
{notif.metadata?.dueDate && (
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
Due: {fmtDate(notif.metadata.dueDate + "T00:00:00Z", { day: "numeric", month: "short", year: "numeric" })}
|
||||
</div>
|
||||
)}
|
||||
{notif.actionUrl && !notif.isRead && (
|
||||
<div className="text-xs text-rose-400 dark:text-rose-500 mt-1.5 font-medium">
|
||||
Tap to open →
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!notif.isRead && (
|
||||
<div className="w-2.5 h-2.5 bg-rose-400 rounded-full mt-1.5 flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
{!loading && items.length > 0 && (
|
||||
<div className="px-4 pb-8">
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 justify-center text-xs text-gray-400">
|
||||
<span>🚨 Vaccine overdue</span>
|
||||
<span>💉 Due today</span>
|
||||
<span>🍼 Log reminder</span>
|
||||
<span>📸 Memory nudge</span>
|
||||
<span>👚 Wardrobe nudge</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button, Card, Input, Select } from "@/components/ui";
|
||||
|
||||
const IAP_SCHEDULE = [
|
||||
{ name: "BCG", weeks: 0, milestone: "At Birth" },
|
||||
|
|
@ -54,6 +55,7 @@ export default function OnboardingPage() {
|
|||
const [form, setForm] = useState({
|
||||
familyName: "",
|
||||
memberName: "",
|
||||
phone: "",
|
||||
childName: "",
|
||||
birthDate: "",
|
||||
sex: "" as "male" | "female" | "other",
|
||||
|
|
@ -67,7 +69,7 @@ export default function OnboardingPage() {
|
|||
if (!data.authenticated) {
|
||||
router.push("/login");
|
||||
} else if (data.familyId) {
|
||||
router.push("/");
|
||||
router.push("/home");
|
||||
}
|
||||
setCheckingAuth(false);
|
||||
}
|
||||
|
|
@ -126,7 +128,7 @@ export default function OnboardingPage() {
|
|||
console.error(e);
|
||||
}
|
||||
setSaving(false);
|
||||
router.push("/");
|
||||
router.push("/home");
|
||||
};
|
||||
|
||||
if (checkingAuth) {
|
||||
|
|
@ -155,38 +157,24 @@ export default function OnboardingPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-3xl shadow-lg p-6 space-y-4">
|
||||
<Card className="space-y-4" padding="lg">
|
||||
{/* Step 1 — Family info */}
|
||||
{step === 1 && (
|
||||
<>
|
||||
<h2 className="font-semibold text-gray-800">Your family</h2>
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium">Family Name</span>
|
||||
<input
|
||||
type="text"
|
||||
value={form.familyName}
|
||||
onChange={(e) => setForm({ ...form, familyName: e.target.value })}
|
||||
placeholder="The Gupta Family"
|
||||
className="w-full p-3 border rounded-xl mt-1"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium">Your Name</span>
|
||||
<input
|
||||
type="text"
|
||||
value={form.memberName}
|
||||
onChange={(e) => setForm({ ...form, memberName: e.target.value })}
|
||||
placeholder="Mama"
|
||||
className="w-full p-3 border rounded-xl mt-1"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
onClick={() => setStep(2)}
|
||||
disabled={!form.familyName || !form.memberName}
|
||||
className="w-full p-3 bg-rose-400 text-white rounded-xl font-medium disabled:opacity-50"
|
||||
>
|
||||
<Input label="Family Name" type="text" value={form.familyName}
|
||||
onChange={(e) => setForm({ ...form, familyName: e.target.value })}
|
||||
placeholder="The Gupta Family" />
|
||||
<Input label="Your Name" type="text" value={form.memberName}
|
||||
onChange={(e) => setForm({ ...form, memberName: e.target.value })}
|
||||
placeholder="Mama" />
|
||||
<Input label="Phone Number (optional)" type="tel" value={form.phone}
|
||||
onChange={(e) => setForm({ ...form, phone: e.target.value })}
|
||||
placeholder="+91 98765 43210" />
|
||||
<p className="text-xs text-gray-400 -mt-2">For important reminders & updates about your baby</p>
|
||||
<Button fullWidth size="lg" onClick={() => setStep(2)} disabled={!form.familyName || !form.memberName}>
|
||||
Next →
|
||||
</button>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
@ -194,49 +182,35 @@ export default function OnboardingPage() {
|
|||
{step === 2 && (
|
||||
<>
|
||||
<h2 className="font-semibold text-gray-800">Your baby</h2>
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium">Baby's Name</span>
|
||||
<input
|
||||
type="text"
|
||||
value={form.childName}
|
||||
onChange={(e) => setForm({ ...form, childName: e.target.value })}
|
||||
placeholder="Tia"
|
||||
className="w-full p-3 border rounded-xl mt-1"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium">Birth Date</span>
|
||||
<input
|
||||
type="date"
|
||||
value={form.birthDate}
|
||||
onChange={(e) => setForm({ ...form, birthDate: e.target.value })}
|
||||
className="w-full p-3 border rounded-xl mt-1"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium">Sex</span>
|
||||
<Input label="Baby's Name" type="text" value={form.childName}
|
||||
onChange={(e) => setForm({ ...form, childName: e.target.value })}
|
||||
placeholder="Tia" />
|
||||
<Input label="Birth Date" type="date" value={form.birthDate}
|
||||
onChange={(e) => setForm({ ...form, birthDate: e.target.value })} />
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-700">Sex</span>
|
||||
<div className="flex gap-2 mt-1">
|
||||
{(["male", "female", "other"] as const).map((s) => (
|
||||
<button
|
||||
<Button
|
||||
key={s}
|
||||
type="button"
|
||||
fullWidth
|
||||
variant={form.sex === s ? "primary" : "secondary"}
|
||||
onClick={() => setForm({ ...form, sex: s })}
|
||||
className={`flex-1 p-3 rounded-xl border capitalize ${form.sex === s ? "bg-rose-400 text-white border-rose-400" : "bg-white"}`}
|
||||
className="capitalize"
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={() => setStep(1)} className="flex-1 p-3 border rounded-xl font-medium">Back</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!form.childName || !form.birthDate || !form.sex || loading}
|
||||
className="flex-1 p-3 bg-rose-400 text-white rounded-xl font-medium disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Creating..." : "Next →"}
|
||||
</button>
|
||||
<Button variant="secondary" fullWidth onClick={() => setStep(1)}>Back</Button>
|
||||
<Button fullWidth loading={loading}
|
||||
disabled={!form.childName || !form.birthDate || !form.sex}
|
||||
onClick={handleSubmit}>
|
||||
Next →
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -285,11 +259,8 @@ export default function OnboardingPage() {
|
|||
</button>
|
||||
</div>
|
||||
{s.given && (
|
||||
<input
|
||||
type="date"
|
||||
value={s.date}
|
||||
<Input type="date" value={s.date} className="mt-2"
|
||||
onChange={e => setVaccStates(prev => ({ ...prev, [v.name]: { ...s, date: e.target.value } }))}
|
||||
className="mt-2 w-full p-1.5 border rounded-lg text-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -302,23 +273,12 @@ export default function OnboardingPage() {
|
|||
)}
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
onClick={() => router.push("/")}
|
||||
className="flex-1 p-3 border rounded-xl text-sm text-gray-500"
|
||||
>
|
||||
Skip for now
|
||||
</button>
|
||||
<button
|
||||
onClick={handleVaccSave}
|
||||
disabled={saving}
|
||||
className="flex-1 p-3 bg-rose-400 text-white rounded-xl font-medium disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Saving..." : "Save & Go Home"}
|
||||
</button>
|
||||
<Button variant="ghost" fullWidth onClick={() => router.push("/home")}>Skip for now</Button>
|
||||
<Button fullWidth loading={saving} onClick={handleVaccSave}>Save & Go Home</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
244
src/app/(app)/profile/page.tsx
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
/** Some Android cameras return file.type = "" — detect from extension as fallback. */
|
||||
function resolveContentType(file: File): string {
|
||||
if (file.type && file.type !== "application/octet-stream") return file.type;
|
||||
const ext = file.name.split(".").pop()?.toLowerCase() ?? "";
|
||||
const map: Record<string, string> = {
|
||||
jpg: "image/jpeg", jpeg: "image/jpeg",
|
||||
png: "image/png", webp: "image/webp",
|
||||
heic: "image/heic", heif: "image/heic",
|
||||
};
|
||||
return map[ext] || "image/jpeg";
|
||||
}
|
||||
|
||||
export default function ProfilePage() {
|
||||
const router = useRouter();
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [phone, setPhone] = useState("");
|
||||
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveMsg, setSaveMsg] = useState("");
|
||||
const [avatarError, setAvatarError] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/auth/profile")
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.user) {
|
||||
setName(data.user.name || "");
|
||||
setEmail(data.user.email || "");
|
||||
setPhone(data.user.phone || "");
|
||||
setAvatarUrl(data.user.avatarUrl || null);
|
||||
}
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handlePhotoChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const contentType = resolveContentType(file);
|
||||
setUploading(true);
|
||||
setSaveMsg("");
|
||||
|
||||
try {
|
||||
// Step 1: reserve upload slot
|
||||
const initRes = await fetch("/api/auth/avatar", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ contentType, filename: file.name }),
|
||||
});
|
||||
const initData = await initRes.json();
|
||||
if (!initRes.ok) throw new Error(initData.error || `Upload failed (${initRes.status})`);
|
||||
const { key, publicUrl: r2Url } = initData;
|
||||
|
||||
// Step 2: upload binary via proxy
|
||||
const putRes = await fetch(`/api/upload?${new URLSearchParams({ key, contentType })}`, {
|
||||
method: "PUT",
|
||||
body: file,
|
||||
headers: { "Content-Type": contentType },
|
||||
});
|
||||
if (!putRes.ok) throw new Error(`Upload failed (${putRes.status})`);
|
||||
|
||||
// Step 3: save R2 URL to DB
|
||||
const patchRes = await fetch("/api/auth/avatar", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ avatarUrl: r2Url }),
|
||||
});
|
||||
if (!patchRes.ok) throw new Error("Failed to save photo");
|
||||
|
||||
setAvatarError(false);
|
||||
setAvatarUrl(`/api/img?key=${encodeURIComponent(key)}`);
|
||||
} catch (err) {
|
||||
setSaveMsg(err instanceof Error ? err.message : "Upload failed");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemovePhoto = async () => {
|
||||
setSaveMsg("");
|
||||
try {
|
||||
const res = await fetch("/api/auth/avatar", { method: "DELETE" });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Failed to remove photo");
|
||||
setAvatarUrl(null);
|
||||
setAvatarError(false);
|
||||
} catch (err) {
|
||||
setSaveMsg(err instanceof Error ? err.message : "Failed to remove photo");
|
||||
}
|
||||
};
|
||||
|
||||
const saveProfile = async () => {
|
||||
if (!name.trim()) { setSaveMsg("Please enter your name"); return; }
|
||||
setSaving(true);
|
||||
setSaveMsg("");
|
||||
try {
|
||||
const res = await fetch("/api/auth/profile", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, phone }),
|
||||
});
|
||||
const data = await res.json();
|
||||
setSaveMsg(data.success ? "Saved!" : data.error || "Save failed");
|
||||
} catch {
|
||||
setSaveMsg("Failed to save");
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const initials = name.split(" ").map(w => w[0]).join("").toUpperCase().slice(0, 2);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800">
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 z-10 bg-white/80 dark:bg-gray-900/80 backdrop-blur-sm border-b border-gray-100 dark:border-gray-800 px-4 py-3 flex items-center gap-3">
|
||||
<button onClick={() => router.back()} className="text-gray-500 dark:text-gray-400 p-1">←</button>
|
||||
<h1 className="text-sm font-semibold dark:text-white">My Profile</h1>
|
||||
</div>
|
||||
|
||||
<div className="px-4 pb-24 space-y-4">
|
||||
{/* Avatar */}
|
||||
<div className="flex flex-col items-center pt-8 pb-2">
|
||||
<div className="relative mb-3">
|
||||
{avatarUrl && !avatarError ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={name}
|
||||
className="w-24 h-24 rounded-full object-cover ring-4 ring-white dark:ring-gray-800 shadow-md"
|
||||
onError={() => setAvatarError(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-24 h-24 rounded-full bg-gradient-to-br from-rose-300 to-amber-300 flex items-center justify-center text-white text-2xl font-bold shadow-md ring-4 ring-white dark:ring-gray-800">
|
||||
{initials || "👤"}
|
||||
</div>
|
||||
)}
|
||||
{uploading && (
|
||||
<div className="absolute inset-0 rounded-full bg-black/40 flex items-center justify-center">
|
||||
<div className="w-6 h-6 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="text-sm font-medium text-rose-500 dark:text-rose-400 disabled:opacity-50"
|
||||
>
|
||||
{uploading ? "Uploading…" : "Change Photo"}
|
||||
</button>
|
||||
{avatarUrl && !uploading && (
|
||||
<button onClick={handleRemovePhoto} className="text-xs text-gray-400 mt-0.5 hover:text-red-400 transition-colors">
|
||||
Remove photo
|
||||
</button>
|
||||
)}
|
||||
{!avatarUrl && !uploading && (
|
||||
<p className="text-xs text-gray-400 mt-0.5">JPEG, PNG, WebP or HEIC</p>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/jpg,image/png,image/webp,image/heic"
|
||||
onChange={handlePhotoChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-gray-400 text-sm">Loading…</div>
|
||||
) : (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-sm p-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="w-full px-3 py-2.5 bg-gray-50 dark:bg-gray-700 rounded-xl border border-gray-200 dark:border-gray-600 text-sm dark:text-white focus:outline-none focus:ring-2 focus:ring-rose-300"
|
||||
placeholder="Your name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Phone Number</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={phone}
|
||||
onChange={e => setPhone(e.target.value)}
|
||||
className="w-full px-3 py-2.5 bg-gray-50 dark:bg-gray-700 rounded-xl border border-gray-200 dark:border-gray-600 text-sm dark:text-white focus:outline-none focus:ring-2 focus:ring-rose-300"
|
||||
placeholder="+91 98765 43210"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">For important reminders & updates (optional)</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
disabled
|
||||
className="w-full px-3 py-2.5 bg-gray-100 dark:bg-gray-700/50 rounded-xl border border-gray-200 dark:border-gray-600 text-sm text-gray-400 dark:text-gray-500 cursor-not-allowed"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">Email cannot be changed</p>
|
||||
</div>
|
||||
|
||||
{saveMsg && (
|
||||
<p className={`text-xs text-center font-medium ${saveMsg === "Saved!" ? "text-green-600 dark:text-green-400" : "text-red-500"}`}>
|
||||
{saveMsg}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={saveProfile}
|
||||
disabled={saving || !name.trim()}
|
||||
className="w-full py-3 bg-rose-400 text-white rounded-xl font-medium text-sm disabled:opacity-50 active:scale-95 transition-transform"
|
||||
>
|
||||
{saving ? "Saving…" : "Save Changes"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Account info */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-sm p-4">
|
||||
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Account</p>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300">{email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
516
src/app/(app)/settings/page.tsx
Normal file
|
|
@ -0,0 +1,516 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTheme } from "@/app/ThemeProvider";
|
||||
import { useFamily } from "@/app/FamilyProvider";
|
||||
import { Button, Card, Input, Select, Badge } from "@/components/ui";
|
||||
import { StorageMeter, MemberLimitBanner } from "@/components/StorageMeter";
|
||||
import { UpgradeButton } from "@/components/UpgradeButton";
|
||||
import { fmtDate } from "@/lib/date-ist";
|
||||
|
||||
interface Member {
|
||||
id: string;
|
||||
userId: string;
|
||||
role: string;
|
||||
displayName: string;
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface Invite {
|
||||
id: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
role: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const router = useRouter();
|
||||
const { theme, mode, setMode } = useTheme();
|
||||
const { tier, memberCount, familyId, children, familyName: providerFamilyName, childId, child } = useFamily();
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [themeOpen, setThemeOpen] = useState(false);
|
||||
const [inviteOpen, setInviteOpen] = useState(false);
|
||||
const [familyOpen, setFamilyOpen] = useState(true);
|
||||
const [members, setMembers] = useState<Member[]>([]);
|
||||
const [invites, setInvites] = useState<Invite[]>([]);
|
||||
const [inviteEmail, setInviteEmail] = useState("");
|
||||
const [inviteRole, setInviteRole] = useState("caregiver");
|
||||
const [inviteLoading, setInviteLoading] = useState(false);
|
||||
const [pedPhone, setPedPhone] = useState("");
|
||||
const [pedName, setPedName] = useState("");
|
||||
const [pedSaving, setPedSaving] = useState(false);
|
||||
const [pedEditing, setPedEditing] = useState(false);
|
||||
const [pedSaved, setPedSaved] = useState(false);
|
||||
const [pedError, setPedError] = useState("");
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
const [cancelMsg, setCancelMsg] = useState("");
|
||||
|
||||
const handleCancelSubscription = async () => {
|
||||
if (!window.confirm("Cancel your subscription? You'll keep premium until the end of your current billing period.")) return;
|
||||
setCancelling(true);
|
||||
try {
|
||||
const res = await fetch("/api/subscriptions/cancel", { method: "POST" });
|
||||
const data = await res.json();
|
||||
setCancelMsg(res.ok ? data.message : (data.error || "Could not cancel."));
|
||||
} catch {
|
||||
setCancelMsg("Could not reach the server. Try again.");
|
||||
}
|
||||
setCancelling(false);
|
||||
};
|
||||
|
||||
// Check if can invite more members (client-side pre-check; server enforces)
|
||||
const canInvite = tier === "pro" || memberCount < 2;
|
||||
|
||||
// Family name from provider or fallback
|
||||
const familyName = providerFamilyName || "My Family";
|
||||
|
||||
const themeOptions = [
|
||||
{ value: "light", label: "Light" },
|
||||
{ value: "dark", label: "Dark" },
|
||||
{ value: "system", label: "System" },
|
||||
{ value: "time", label: "Time of Day" },
|
||||
] as const;
|
||||
|
||||
useEffect(() => {
|
||||
if (familyId) {
|
||||
fetchMembers();
|
||||
fetchInvites();
|
||||
fetch("/api/family").then(r => r.json()).then(d => {
|
||||
setPedPhone(d.family?.pediatrician_phone || "");
|
||||
setPedName(d.family?.pediatrician_name || "");
|
||||
// If no data yet, open in edit mode so user can fill it in
|
||||
if (!d.family?.pediatrician_phone && !d.family?.pediatrician_name) setPedEditing(true);
|
||||
}).catch(() => {});
|
||||
}
|
||||
}, [familyId]);
|
||||
|
||||
const exportGrowthCSV = async () => {
|
||||
if (!childId) return;
|
||||
setExporting(true);
|
||||
try {
|
||||
const res = await fetch(`/api/growth?childId=${childId}`);
|
||||
const data = await res.json();
|
||||
const records = data.growth || [];
|
||||
if (records.length === 0) { alert("No growth records to export."); return; }
|
||||
const headers = ["Date", "Weight (kg)", "Height (cm)", "Head (cm)", "Notes"];
|
||||
const rows = records.map((r: { measured_at: string; weight_kg: number | null; height_cm: number | null; head_circumference_cm: number | null; notes: string | null }) => [
|
||||
fmtDate(r.measured_at),
|
||||
r.weight_kg ?? "",
|
||||
r.height_cm ?? "",
|
||||
r.head_circumference_cm ?? "",
|
||||
r.notes ?? "",
|
||||
]);
|
||||
const csv = [headers, ...rows].map(row => row.join(",")).join("\n");
|
||||
const blob = new Blob([csv], { type: "text/csv" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${child?.name || "child"}_growth_${new Date().toISOString().split("T")[0]}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch { alert("Export failed. Please try again."); }
|
||||
setExporting(false);
|
||||
};
|
||||
|
||||
const savePedInfo = async () => {
|
||||
setPedSaving(true);
|
||||
setPedError("");
|
||||
try {
|
||||
const res = await fetch("/api/family", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ pediatricianPhone: pedPhone, pediatricianName: pedName }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setPedError(data.error || "Failed to save. Please try again.");
|
||||
} else {
|
||||
setPedEditing(false);
|
||||
setPedSaved(true);
|
||||
setTimeout(() => setPedSaved(false), 3000);
|
||||
}
|
||||
} catch {
|
||||
setPedError("Network error. Please try again.");
|
||||
}
|
||||
setPedSaving(false);
|
||||
};
|
||||
|
||||
const fetchMembers = async () => {
|
||||
if (!familyId) return;
|
||||
try {
|
||||
const res = await fetch(`/api/family/members?familyId=${familyId}`);
|
||||
const data = await res.json();
|
||||
setMembers(data.members || []);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch members:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchInvites = async () => {
|
||||
if (!familyId) return;
|
||||
try {
|
||||
const res = await fetch(`/api/invites?familyId=${familyId}`);
|
||||
const data = await res.json();
|
||||
setInvites(data.invites || []);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch invites:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteInvite = async (inviteId: string) => {
|
||||
try {
|
||||
await fetch(`/api/invites/${inviteId}`, { method: "DELETE" });
|
||||
setInvites(prev => prev.filter(i => i.id !== inviteId));
|
||||
} catch (err) {
|
||||
console.error("Failed to delete invite:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const sendInvite = async () => {
|
||||
if (!inviteEmail || !familyId) return;
|
||||
setInviteLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/invites", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
familyId: familyId,
|
||||
email: inviteEmail,
|
||||
role: inviteRole,
|
||||
displayName: inviteEmail.split("@")[0],
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setInviteEmail("");
|
||||
fetchInvites();
|
||||
} else if (data.reason === "member_limit_reached") {
|
||||
// Trigger re-render of the limit banner with fresh data
|
||||
fetchMembers();
|
||||
} else {
|
||||
alert(data.error);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to send invite:", err);
|
||||
}
|
||||
setInviteLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800">
|
||||
<div className="p-4 flex items-center gap-4">
|
||||
<button onClick={() => router.back()} className="p-2">←</button>
|
||||
<h1 className="text-xl font-bold">Settings</h1>
|
||||
</div>
|
||||
|
||||
<div className="px-4 space-y-3">
|
||||
{/* Plan / Upgrade — anchor target for all "upgrade" CTAs across the app */}
|
||||
<div id="upgrade" className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm scroll-mt-20">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<p className="font-semibold text-gray-900 dark:text-white">
|
||||
{tier === "pro" ? "✨ Tia Premium" : "Your Plan"}
|
||||
</p>
|
||||
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${tier === "pro" ? "bg-green-100 text-green-700" : "bg-rose-100 text-rose-700"}`}>
|
||||
{tier === "pro" ? "Premium" : "Free"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{tier === "pro" ? (
|
||||
<>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3">
|
||||
50 GB storage · up to 6 members · up to 3 baby profiles
|
||||
</p>
|
||||
{cancelMsg ? (
|
||||
<p className="text-sm text-amber-600 dark:text-amber-400">{cancelMsg}</p>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleCancelSubscription}
|
||||
disabled={cancelling}
|
||||
className="text-sm text-gray-400 hover:text-red-500 disabled:opacity-50 underline"
|
||||
>
|
||||
{cancelling ? "Cancelling…" : "Cancel subscription"}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3">
|
||||
Upgrade for <strong>50 GB</strong> media storage, up to <strong>6 family members</strong>,
|
||||
and <strong>3 baby profiles</strong>. ₹199/month, cancel anytime.
|
||||
</p>
|
||||
<UpgradeButton />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* My Profile Page */}
|
||||
<a href="/settings/profile"
|
||||
className="flex items-center justify-between bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm mb-3">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 dark:text-white">My Profile Page</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Create your public product recommendation page</p>
|
||||
</div>
|
||||
<span className="text-gray-400">→</span>
|
||||
</a>
|
||||
|
||||
{/* Notifications */}
|
||||
<Link href="/notifications" className="flex items-center justify-between p-4 bg-white dark:bg-gray-800 rounded-xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl">🔔</span>
|
||||
<div className="font-medium">Notifications</div>
|
||||
</div>
|
||||
<span className="text-gray-400">→</span>
|
||||
</Link>
|
||||
|
||||
{/* Profile */}
|
||||
<Link href="/profile" className="flex items-center justify-between p-4 bg-white dark:bg-gray-800 rounded-xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl">👤</span>
|
||||
<div className="font-medium">Profile</div>
|
||||
</div>
|
||||
<span className="text-gray-400">→</span>
|
||||
</Link>
|
||||
|
||||
{/* Family - Single consolidated section */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl overflow-hidden">
|
||||
<button
|
||||
onClick={() => setFamilyOpen(!familyOpen)}
|
||||
className="w-full flex items-center justify-between p-4"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl">🏠</span>
|
||||
<div className="font-medium">Family</div>
|
||||
</div>
|
||||
<span className={`text-gray-400 transition-transform ${familyOpen ? "rotate-180" : ""}`}>▼</span>
|
||||
</button>
|
||||
|
||||
{familyOpen && (
|
||||
<div className="px-4 pb-4 space-y-4">
|
||||
{/* Family Info */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-lg">{familyName}</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{tier === "pro" ? "Pro Plan" : <span className="bg-rose-100 text-rose-700 px-2 py-0.5 rounded-full text-xs">Free Plan</span>} · {members.length} member{members.length !== 1 ? "s" : ""} · {children?.length || 0} child{children?.length !== 1 ? "ren" : ""}
|
||||
</div>
|
||||
</div>
|
||||
{tier === "free" && (
|
||||
<a href="#upgrade">
|
||||
<Button size="sm">Upgrade</Button>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Family Members */}
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-500 mb-2">Family Members</div>
|
||||
{members.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{members.map((member) => (
|
||||
<div key={member.id} className="flex items-center justify-between p-2 bg-gray-50 dark:bg-gray-700 rounded">
|
||||
<div>
|
||||
<div className="font-medium text-sm">{member.name || member.email}</div>
|
||||
<div className="text-xs text-gray-400">{member.email}</div>
|
||||
</div>
|
||||
<Badge variant={member.role === "admin" ? "rose" : "default"}>{member.role}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-400 text-sm">No family members</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Storage usage meter */}
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-500 mb-2">Storage</div>
|
||||
<StorageMeter />
|
||||
</div>
|
||||
|
||||
{/* Manage Children */}
|
||||
<Link href="/family" className="flex items-center justify-between p-3 border border-dashed border-gray-300 dark:border-gray-600 rounded-lg">
|
||||
<span className="text-sm">Manage Children</span>
|
||||
<span className="text-gray-400">→</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Invite Members */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl overflow-hidden">
|
||||
<button
|
||||
onClick={() => setInviteOpen(!inviteOpen)}
|
||||
className="w-full flex items-center justify-between p-4"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl">✉️</span>
|
||||
<div className="font-medium">Invite Members</div>
|
||||
{tier === "free" && (
|
||||
<span className="text-xs px-2 py-0.5 bg-rose-100 text-rose-600 rounded-full">{memberCount}/2</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={`text-gray-400 transition-transform ${inviteOpen ? "rotate-180" : ""}`}>▼</span>
|
||||
</button>
|
||||
|
||||
{inviteOpen && (
|
||||
<div className="px-4 pb-4">
|
||||
{/* Member limit banner */}
|
||||
{tier === "free" && !canInvite && (
|
||||
<div className="mb-3">
|
||||
<MemberLimitBanner currentCount={memberCount} limit={2} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pending invites */}
|
||||
{invites.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="text-sm text-gray-500 mb-2">Pending Invites</div>
|
||||
{invites.map((invite) => (
|
||||
<div key={invite.id} className="flex justify-between items-center p-2 bg-gray-50 dark:bg-gray-700 rounded text-sm mb-1">
|
||||
<div>
|
||||
<div className="font-medium text-gray-800 dark:text-gray-100">{invite.email}</div>
|
||||
<div className="text-xs text-gray-400">Pending · expires {fmtDate(invite.expiresAt)}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => deleteInvite(invite.id)}
|
||||
className="text-xs text-red-400 hover:text-red-600 dark:hover:text-red-300 px-2 py-1 rounded hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
||||
title="Cancel invite"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add invite form */}
|
||||
{canInvite && (
|
||||
<div className="space-y-2">
|
||||
<Input type="email" value={inviteEmail} onChange={(e) => setInviteEmail(e.target.value)} placeholder="Email address" />
|
||||
<Select value={inviteRole} onChange={(e) => setInviteRole(e.target.value)}>
|
||||
<option value="caregiver">Caregiver</option>
|
||||
<option value="viewer">Viewer (read-only)</option>
|
||||
</Select>
|
||||
<Button fullWidth loading={inviteLoading} disabled={!inviteEmail} onClick={sendInvite}>
|
||||
Send Invite
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Theme */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl overflow-hidden">
|
||||
<button
|
||||
onClick={() => setThemeOpen(!themeOpen)}
|
||||
className="w-full flex items-center justify-between p-4"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl">{theme === "dark" ? "🌙" : "☀️"}</span>
|
||||
<div className="font-medium">Theme</div>
|
||||
</div>
|
||||
<span className={`text-gray-400 transition-transform ${themeOpen ? "rotate-180" : ""}`}>▼</span>
|
||||
</button>
|
||||
|
||||
{themeOpen && (
|
||||
<div className="px-4 pb-4">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{themeOptions.map((opt) => (
|
||||
<Button
|
||||
key={opt.value}
|
||||
onClick={() => setMode(opt.value)}
|
||||
variant={mode === opt.value ? "primary" : "secondary"}
|
||||
>
|
||||
{opt.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pediatrician */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">🏥</span>
|
||||
<div className="font-medium dark:text-white">Pediatrician</div>
|
||||
</div>
|
||||
{!pedEditing && (pedName || pedPhone) && (
|
||||
<button
|
||||
onClick={() => { setPedEditing(true); setPedSaved(false); setPedError(""); }}
|
||||
className="text-sm text-rose-500 dark:text-rose-400 font-medium"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Display mode */}
|
||||
{!pedEditing && (pedName || pedPhone) ? (
|
||||
<div className="space-y-0.5">
|
||||
{pedName && <div className="text-sm font-medium text-gray-800 dark:text-gray-100">{pedName}</div>}
|
||||
{pedPhone && <div className="text-sm text-gray-500 dark:text-gray-400">{pedPhone}</div>}
|
||||
{pedSaved && <div className="text-xs text-green-600 dark:text-green-400">Saved!</div>}
|
||||
</div>
|
||||
) : (
|
||||
/* Edit / first-fill mode */
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">Shown on the emergency guide and in AI medical redirects.</p>
|
||||
<Input
|
||||
type="text"
|
||||
value={pedName}
|
||||
onChange={e => setPedName(e.target.value)}
|
||||
placeholder="Dr. Priya Sharma"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Input type="tel" value={pedPhone} onChange={e => setPedPhone(e.target.value)} placeholder="+91 98765 43210" className="flex-1" />
|
||||
<Button size="sm" loading={pedSaving} onClick={savePedInfo}>Save</Button>
|
||||
</div>
|
||||
{pedEditing && (pedName || pedPhone) && (
|
||||
<button
|
||||
onClick={() => { setPedEditing(false); setPedError(""); }}
|
||||
className="text-xs text-gray-400 dark:text-gray-500"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
{pedError && <p className="text-xs text-red-500">{pedError}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Link href="/medical/emergency" className="text-xs text-rose-500 dark:text-rose-400">
|
||||
View Emergency Guide →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Export Data */}
|
||||
<button
|
||||
onClick={exportGrowthCSV}
|
||||
disabled={exporting || !childId}
|
||||
className="w-full flex items-center justify-between p-4 bg-white dark:bg-gray-800 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl">📥</span>
|
||||
<div>
|
||||
<div className="font-medium text-left">Export Growth Data</div>
|
||||
<div className="text-xs text-gray-400 text-left">Download {child?.name ? `${child.name}'s` : "growth"} records as CSV</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-gray-400 text-sm">{exporting ? "…" : "→"}</span>
|
||||
</button>
|
||||
|
||||
{/* App Version */}
|
||||
<div className="p-4 bg-white dark:bg-gray-800 rounded-xl mt-4">
|
||||
<div className="font-medium">App Version</div>
|
||||
<div className="text-sm text-gray-500">Tia v1.0.0</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
427
src/app/(app)/settings/profile/page.tsx
Normal file
|
|
@ -0,0 +1,427 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface Profile {
|
||||
id: string;
|
||||
slug: string;
|
||||
display_name: string;
|
||||
bio: string | null;
|
||||
avatar_url: string | null;
|
||||
is_public: boolean;
|
||||
}
|
||||
|
||||
interface Product {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
url: string;
|
||||
image_url: string | null;
|
||||
category: string;
|
||||
display_order: number;
|
||||
click_count: number;
|
||||
}
|
||||
|
||||
const CATEGORIES = ["general", "feeding", "sleep", "play", "clothing"] as const;
|
||||
const CATEGORY_EMOJI: Record<string, string> = { feeding: "🍼", sleep: "💤", play: "🎮", clothing: "👗", general: "🛍️" };
|
||||
|
||||
export default function ProfileSettingsPage() {
|
||||
const router = useRouter();
|
||||
const [profile, setProfile] = useState<Profile | null>(null);
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveMsg, setSaveMsg] = useState("");
|
||||
const [profileExpanded, setProfileExpanded] = useState(true);
|
||||
const [shareProductId, setShareProductId] = useState<string | null>(null);
|
||||
const [profileShareOpen, setProfileShareOpen] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const [slug, setSlug] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [bio, setBio] = useState("");
|
||||
const [isPublic, setIsPublic] = useState(false);
|
||||
|
||||
const [showAddProduct, setShowAddProduct] = useState(false);
|
||||
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
|
||||
const [pTitle, setPTitle] = useState("");
|
||||
const [pUrl, setPUrl] = useState("");
|
||||
const [pDesc, setPDesc] = useState("");
|
||||
const [pImageUrl, setPImageUrl] = useState("");
|
||||
const [pCategory, setPCategory] = useState("general");
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
fetch("/api/profile").then(r => r.json()),
|
||||
fetch("/api/profile/products").then(r => r.json()),
|
||||
]).then(([pd, prods]) => {
|
||||
if (pd.profile) {
|
||||
setProfile(pd.profile);
|
||||
setSlug(pd.profile.slug);
|
||||
setDisplayName(pd.profile.display_name);
|
||||
setBio(pd.profile.bio ?? "");
|
||||
setIsPublic(pd.profile.is_public);
|
||||
setProfileExpanded(false); // already set up — start collapsed
|
||||
}
|
||||
setProducts(prods.items || []);
|
||||
setLoading(false);
|
||||
}).catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const profileUrl = typeof window !== "undefined" && slug ? `${window.location.origin}/m/${slug}` : "";
|
||||
|
||||
async function saveProfile() {
|
||||
setSaving(true);
|
||||
setSaveMsg("");
|
||||
const r = await fetch("/api/profile", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ slug, display_name: displayName, bio: bio || null, is_public: isPublic }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (r.ok) {
|
||||
setProfile(d.profile);
|
||||
setSaveMsg("Saved!");
|
||||
setProfileExpanded(false); // collapse after save
|
||||
} else {
|
||||
setSaveMsg(d.error || "Save failed");
|
||||
}
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
async function copyLink(url: string) {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
function shareViaWhatsApp(url: string, text: string) {
|
||||
const msg = encodeURIComponent(`${text} ${url}`);
|
||||
window.open(`https://wa.me/?text=${msg}`, "_blank");
|
||||
}
|
||||
|
||||
async function shareNative(title: string, url: string) {
|
||||
if (navigator.share) {
|
||||
await navigator.share({ title, url });
|
||||
}
|
||||
}
|
||||
|
||||
function resetProductForm() {
|
||||
setPTitle(""); setPUrl(""); setPDesc(""); setPImageUrl(""); setPCategory("general");
|
||||
setShowAddProduct(false); setEditingProduct(null);
|
||||
}
|
||||
|
||||
async function saveProduct() {
|
||||
const body = { title: pTitle, url: pUrl, description: pDesc || null, image_url: pImageUrl || null, category: pCategory };
|
||||
if (editingProduct) {
|
||||
await fetch(`/api/profile/products/${editingProduct.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} else {
|
||||
await fetch("/api/profile/products", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...body, display_order: products.length }),
|
||||
});
|
||||
}
|
||||
const r = await fetch("/api/profile/products");
|
||||
const d = await r.json();
|
||||
setProducts(d.items || []);
|
||||
resetProductForm();
|
||||
}
|
||||
|
||||
async function deleteProduct(id: string) {
|
||||
if (!confirm("Remove this product?")) return;
|
||||
await fetch(`/api/profile/products/${id}`, { method: "DELETE" });
|
||||
setProducts(p => p.filter(x => x.id !== id));
|
||||
}
|
||||
|
||||
async function moveProduct(id: string, dir: "up" | "down") {
|
||||
const idx = products.findIndex(p => p.id === id);
|
||||
if ((dir === "up" && idx === 0) || (dir === "down" && idx === products.length - 1)) return;
|
||||
const swapIdx = dir === "up" ? idx - 1 : idx + 1;
|
||||
await fetch(`/api/profile/products/${id}`, {
|
||||
method: "PATCH", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ display_order: products[swapIdx].display_order }),
|
||||
});
|
||||
await fetch(`/api/profile/products/${products[swapIdx].id}`, {
|
||||
method: "PATCH", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ display_order: products[idx].display_order }),
|
||||
});
|
||||
const r = await fetch("/api/profile/products");
|
||||
const d = await r.json();
|
||||
setProducts(d.items || []);
|
||||
}
|
||||
|
||||
function startEdit(p: Product) {
|
||||
setEditingProduct(p); setPTitle(p.title); setPUrl(p.url);
|
||||
setPDesc(p.description ?? ""); setPImageUrl(p.image_url ?? ""); setPCategory(p.category);
|
||||
setShowAddProduct(true);
|
||||
}
|
||||
|
||||
const slugValid = /^[a-z0-9-]{3,40}$/.test(slug);
|
||||
const baseUrl = typeof window !== "undefined" ? window.location.origin : "";
|
||||
|
||||
const inputClass = "w-full border border-gray-200 dark:border-gray-600 rounded-xl px-3 py-2.5 text-sm bg-white dark:bg-gray-700 dark:text-white focus:outline-none focus:ring-2 focus:ring-rose-300";
|
||||
|
||||
if (loading) return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center gap-4 bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800">
|
||||
<div className="flex gap-3 text-4xl">
|
||||
{["🍼", "😴", "🚼", "👶"].map((e, i) => (
|
||||
<span key={i} className="animate-bounce" style={{ animationDelay: `${i * 120}ms` }}>{e}</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-sm text-gray-400">Loading profile…</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 pb-24">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 p-4">
|
||||
<button onClick={() => router.back()} className="p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-xl">←</button>
|
||||
<h1 className="text-xl font-bold">My Profile Page</h1>
|
||||
</div>
|
||||
|
||||
<div className="px-4 space-y-4">
|
||||
{/* Profile section — collapsible */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-sm">
|
||||
{/* Always-visible header row — single tappable button + share action */}
|
||||
<div className="flex items-center gap-2 px-4 py-3">
|
||||
{/* Main expand/collapse — takes all remaining space */}
|
||||
<button
|
||||
onClick={() => setProfileExpanded(v => !v)}
|
||||
className="flex items-center gap-3 flex-1 text-left min-w-0"
|
||||
>
|
||||
<span className="text-xl">👤</span>
|
||||
<div className="min-w-0">
|
||||
<p className="font-semibold text-gray-800 dark:text-white text-sm truncate">
|
||||
{displayName || "Set up your profile"}
|
||||
</p>
|
||||
{!profileExpanded && slug && (
|
||||
<p className="text-xs text-gray-400 truncate">{baseUrl}/m/{slug}</p>
|
||||
)}
|
||||
</div>
|
||||
<span className={`text-gray-400 text-sm transition-transform flex-shrink-0 ${profileExpanded ? "rotate-180" : ""}`}>▼</span>
|
||||
</button>
|
||||
|
||||
{/* Profile page share — floats outside the expand button to avoid triggering expand */}
|
||||
{slug && slugValid && (
|
||||
<div className="relative flex-shrink-0">
|
||||
<button
|
||||
onClick={() => setProfileShareOpen(v => !v)}
|
||||
className="p-2 rounded-xl bg-rose-50 dark:bg-rose-900/20 text-rose-500 text-sm"
|
||||
title="Share your profile page"
|
||||
>
|
||||
↗
|
||||
</button>
|
||||
{profileShareOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-30" onClick={() => setProfileShareOpen(false)} />
|
||||
<div className="absolute right-0 top-10 z-40 bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-100 dark:border-gray-700 p-3 w-52 space-y-1">
|
||||
<p className="text-xs text-gray-400 px-2 pb-1">Share your profile page</p>
|
||||
<button onClick={() => { copyLink(profileUrl); setProfileShareOpen(false); }}
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700 text-sm text-gray-700 dark:text-gray-200">
|
||||
<span className="text-lg">{copied ? "✅" : "📋"}</span>
|
||||
{copied ? "Copied!" : "Copy link"}
|
||||
</button>
|
||||
<button onClick={() => { shareViaWhatsApp(profileUrl, "Check out my baby product recommendations!"); setProfileShareOpen(false); }}
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700 text-sm text-gray-700 dark:text-gray-200">
|
||||
<span className="text-lg">💬</span>
|
||||
WhatsApp
|
||||
</button>
|
||||
{typeof navigator !== "undefined" && "share" in navigator && (
|
||||
<button onClick={() => { shareNative(`${displayName}'s baby picks`, profileUrl); setProfileShareOpen(false); }}
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700 text-sm text-gray-700 dark:text-gray-200">
|
||||
<span className="text-lg">📤</span>
|
||||
More options
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expanded form */}
|
||||
{profileExpanded && (
|
||||
<div className="px-4 pb-4 space-y-3 border-t border-gray-100 dark:border-gray-700 pt-3">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-500 dark:text-gray-400 mb-1">Display Name</label>
|
||||
<input
|
||||
value={displayName}
|
||||
onChange={e => setDisplayName(e.target.value)}
|
||||
className={inputClass}
|
||||
placeholder="Priya Sharma"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-500 dark:text-gray-400 mb-1">Your Page URL</label>
|
||||
<div className="flex items-center gap-1 border border-gray-200 dark:border-gray-600 rounded-xl px-3 py-2.5 text-sm bg-white dark:bg-gray-700 focus-within:ring-2 focus-within:ring-rose-300">
|
||||
<span className="text-gray-400 text-xs whitespace-nowrap">{baseUrl}/m/</span>
|
||||
<input
|
||||
value={slug}
|
||||
onChange={e => setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""))}
|
||||
className="flex-1 bg-transparent outline-none dark:text-white text-sm"
|
||||
placeholder="priya-sharma"
|
||||
/>
|
||||
</div>
|
||||
{slug && !slugValid && (
|
||||
<p className="text-xs text-red-500 mt-1">3–40 chars, lowercase letters, numbers, hyphens only</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-500 dark:text-gray-400 mb-1">
|
||||
Bio <span className="text-gray-400">({bio.length}/200)</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={bio}
|
||||
onChange={e => setBio(e.target.value.slice(0, 200))}
|
||||
rows={3}
|
||||
className={`${inputClass} resize-none`}
|
||||
placeholder="New mama sharing what works for us 💕"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<div
|
||||
onClick={() => setIsPublic(v => !v)}
|
||||
className={`w-10 h-6 rounded-full transition-colors relative cursor-pointer ${isPublic ? "bg-rose-400" : "bg-gray-300 dark:bg-gray-600"}`}
|
||||
>
|
||||
<div className={`absolute top-1 w-4 h-4 bg-white rounded-full shadow transition-transform ${isPublic ? "translate-x-5" : "translate-x-1"}`} />
|
||||
</div>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Make my page public</span>
|
||||
</label>
|
||||
|
||||
<button
|
||||
onClick={saveProfile}
|
||||
disabled={saving || !displayName || !slugValid}
|
||||
className="w-full bg-rose-400 text-white rounded-xl py-2.5 font-medium text-sm disabled:opacity-50 active:scale-95 transition-transform"
|
||||
>
|
||||
{saving ? "Saving…" : "Save Profile"}
|
||||
</button>
|
||||
|
||||
{saveMsg && (
|
||||
<p className={`text-xs text-center ${saveMsg.startsWith("Saved") ? "text-green-600 dark:text-green-400" : "text-red-500"}`}>
|
||||
{saveMsg}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Products section */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-semibold text-gray-800 dark:text-white">Product Recommendations</h2>
|
||||
<button
|
||||
onClick={() => { resetProductForm(); setShowAddProduct(true); }}
|
||||
className="text-sm text-rose-500 font-medium px-2.5 py-1.5"
|
||||
>
|
||||
+ Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Add/Edit form */}
|
||||
{showAddProduct && (
|
||||
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-xl p-4 mb-4 space-y-2">
|
||||
<input value={pTitle} onChange={e => setPTitle(e.target.value)}
|
||||
placeholder="Product title *" className={inputClass} />
|
||||
<input value={pUrl} onChange={e => setPUrl(e.target.value)}
|
||||
placeholder="Product URL *" className={inputClass} />
|
||||
<textarea value={pDesc} onChange={e => setPDesc(e.target.value)} rows={2}
|
||||
placeholder="Description (optional)" className={`${inputClass} resize-none`} />
|
||||
<input value={pImageUrl} onChange={e => setPImageUrl(e.target.value)}
|
||||
placeholder="Image URL (optional)" className={inputClass} />
|
||||
<select value={pCategory} onChange={e => setPCategory(e.target.value)}
|
||||
className={inputClass}>
|
||||
{CATEGORIES.map(c => <option key={c} value={c}>{CATEGORY_EMOJI[c]} {c.charAt(0).toUpperCase() + c.slice(1)}</option>)}
|
||||
</select>
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button onClick={saveProduct} disabled={!pTitle || !pUrl}
|
||||
className="flex-1 bg-rose-400 text-white rounded-xl py-2.5 text-sm font-medium disabled:opacity-50">
|
||||
{editingProduct ? "Update" : "Add Product"}
|
||||
</button>
|
||||
<button onClick={resetProductForm}
|
||||
className="flex-1 border border-gray-200 dark:border-gray-600 rounded-xl py-2.5 text-sm text-gray-600 dark:text-gray-300">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{products.length === 0 && !showAddProduct && (
|
||||
<div className="text-center py-8">
|
||||
<span className="text-4xl">🛍️</span>
|
||||
<p className="text-sm text-gray-400 mt-2">No products yet.</p>
|
||||
<p className="text-xs text-gray-400">Add your first recommendation!</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{products.map((p, i) => (
|
||||
<div key={p.id} className="rounded-xl bg-gray-50 dark:bg-gray-700/50 overflow-visible">
|
||||
<div className="flex items-center gap-3 p-3">
|
||||
<div className="text-2xl">{CATEGORY_EMOJI[p.category] || "🛍️"}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-800 dark:text-white truncate">{p.title}</p>
|
||||
<p className="text-xs text-gray-400">{p.category} · {p.click_count} clicks</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => moveProduct(p.id, "up")} disabled={i === 0}
|
||||
className="text-gray-400 disabled:opacity-30 text-xs px-1.5 py-1">↑</button>
|
||||
<button onClick={() => moveProduct(p.id, "down")} disabled={i === products.length - 1}
|
||||
className="text-gray-400 disabled:opacity-30 text-xs px-1.5 py-1">↓</button>
|
||||
<button onClick={() => startEdit(p)} className="text-rose-400 text-xs px-1.5 py-1">Edit</button>
|
||||
{/* Per-product share */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShareProductId(id => id === p.id ? null : p.id)}
|
||||
className="text-rose-400 text-xs px-1.5 py-1"
|
||||
title="Share this product"
|
||||
>
|
||||
↗
|
||||
</button>
|
||||
{shareProductId === p.id && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-30" onClick={() => setShareProductId(null)} />
|
||||
<div className="absolute right-0 bottom-8 z-40 bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-100 dark:border-gray-700 p-3 w-52 space-y-1">
|
||||
<p className="text-xs text-gray-400 px-2 pb-1 truncate">{p.title}</p>
|
||||
<button onClick={() => { copyLink(p.url); setShareProductId(null); }}
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700 text-sm text-gray-700 dark:text-gray-200">
|
||||
<span className="text-lg">{copied ? "✅" : "📋"}</span>
|
||||
{copied ? "Copied!" : "Copy link"}
|
||||
</button>
|
||||
<button onClick={() => { shareViaWhatsApp(p.url, `Found this for our baby — ${p.title}:`); setShareProductId(null); }}
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700 text-sm text-gray-700 dark:text-gray-200">
|
||||
<span className="text-lg">💬</span>
|
||||
WhatsApp
|
||||
</button>
|
||||
{typeof navigator !== "undefined" && "share" in navigator && (
|
||||
<button onClick={() => { shareNative(p.title, p.url); setShareProductId(null); }}
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700 text-sm text-gray-700 dark:text-gray-200">
|
||||
<span className="text-lg">📤</span>
|
||||
More options
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={() => deleteProduct(p.id)} className="text-gray-400 text-xs px-1.5 py-1">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
411
src/app/(app)/wardrobe/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,411 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { Button } from "@/components/ui";
|
||||
import { GARMENT_CATEGORIES, GARMENT_SIZE_ORDER } from "@/db/schema/wardrobe";
|
||||
|
||||
interface Garment {
|
||||
id: string;
|
||||
name: string | null;
|
||||
category: string;
|
||||
sizeLabel: string;
|
||||
colors: string[];
|
||||
seasons: string[];
|
||||
occasionTags: string[];
|
||||
imageKey: string;
|
||||
thumbKey: string;
|
||||
imageUrl: string;
|
||||
thumbUrl: string;
|
||||
status: string;
|
||||
acquiredVia: string | null;
|
||||
giftFrom: string | null;
|
||||
visionMetadata: unknown;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface Wear {
|
||||
id: string;
|
||||
worn_on: string;
|
||||
memory_id: string | null;
|
||||
}
|
||||
|
||||
const STATUS_FLOW = ["active", "stored", "outgrown", "donated"] as const;
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
active: "bg-green-100 text-green-700",
|
||||
stored: "bg-blue-100 text-blue-700",
|
||||
outgrown: "bg-amber-100 text-amber-700",
|
||||
donated: "bg-gray-100 text-gray-600",
|
||||
};
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
onesie: "Onesie", top: "Top", bottom: "Bottom", dress: "Dress",
|
||||
outerwear: "Jacket", sleepwear: "Sleepwear", accessory: "Accessory",
|
||||
};
|
||||
|
||||
function Chip({ label, color }: { label: string; color?: string }) {
|
||||
return (
|
||||
<span className={`px-2.5 py-1 rounded-full text-xs font-medium ${color || "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300"}`}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GarmentDetailPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const id = params.id as string;
|
||||
|
||||
const [garment, setGarment] = useState<Garment | null>(null);
|
||||
const [wears, setWears] = useState<Wear[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [loggingWear, setLoggingWear] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// Edit state
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editCategory, setEditCategory] = useState("");
|
||||
const [editSize, setEditSize] = useState("");
|
||||
const [editStatus, setEditStatus] = useState("");
|
||||
const [editColors, setEditColors] = useState<string[]>([]);
|
||||
const [editSeasons, setEditSeasons] = useState<string[]>([]);
|
||||
const [editOccasions, setEditOccasions] = useState<string[]>([]);
|
||||
const [editColorInput, setEditColorInput] = useState("");
|
||||
const [editAcquiredVia, setEditAcquiredVia] = useState<string | null>(null);
|
||||
const [editGiftFrom, setEditGiftFrom] = useState("");
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/garments/${id}`);
|
||||
if (!res.ok) { router.replace("/wardrobe"); return; }
|
||||
const data = await res.json();
|
||||
setGarment(data.item);
|
||||
setWears(data.wears || []);
|
||||
} catch {
|
||||
router.replace("/wardrobe");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, [id]);
|
||||
|
||||
const startEdit = () => {
|
||||
if (!garment) return;
|
||||
setEditName(garment.name || "");
|
||||
setEditCategory(garment.category);
|
||||
setEditSize(garment.sizeLabel);
|
||||
setEditStatus(garment.status);
|
||||
setEditColors([...garment.colors]);
|
||||
setEditSeasons([...garment.seasons]);
|
||||
setEditOccasions([...garment.occasionTags]);
|
||||
setEditAcquiredVia(garment.acquiredVia);
|
||||
setEditGiftFrom(garment.giftFrom || "");
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const saveEdit = async () => {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch(`/api/garments/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: editName || null,
|
||||
category: editCategory,
|
||||
sizeLabel: editSize,
|
||||
status: editStatus,
|
||||
colors: editColors,
|
||||
seasons: editSeasons,
|
||||
occasionTags: editOccasions,
|
||||
acquiredVia: editAcquiredVia,
|
||||
giftFrom: editGiftFrom || null,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).error);
|
||||
const data = await res.json();
|
||||
setGarment(data.item);
|
||||
setEditing(false);
|
||||
} catch (err) {
|
||||
setError(`Save failed: ${err}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const logWear = async () => {
|
||||
setLoggingWear(true);
|
||||
try {
|
||||
const res = await fetch(`/api/garments/${id}/wear`, { method: "POST" });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setWears(prev => [data.wear, ...prev]);
|
||||
}
|
||||
} catch {}
|
||||
setLoggingWear(false);
|
||||
};
|
||||
|
||||
const updateStatus = async (newStatus: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/garments/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: newStatus }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setGarment(data.item);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const toggleArray = (arr: string[], val: string) =>
|
||||
arr.includes(val) ? arr.filter(x => x !== val) : [...arr, val];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 flex items-center justify-center">
|
||||
<div className="flex gap-1">
|
||||
{[0, 150, 300].map(d => (
|
||||
<span key={d} className="w-2.5 h-2.5 bg-rose-400 rounded-full animate-bounce" style={{ animationDelay: `${d}ms` }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!garment) return null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 pb-24">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 p-4">
|
||||
<button onClick={() => router.back()} className="p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-xl">←</button>
|
||||
<h1 className="text-lg font-bold truncate">{garment.name || CATEGORY_LABELS[garment.category] || garment.category}</h1>
|
||||
<button
|
||||
onClick={editing ? () => setEditing(false) : startEdit}
|
||||
className="ml-auto text-sm px-3 py-1.5 bg-white dark:bg-gray-800 rounded-xl shadow-sm"
|
||||
>
|
||||
{editing ? "Cancel" : "Edit"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mx-4 mb-3 p-3 bg-red-50 rounded-xl text-sm text-red-700">{error}</div>
|
||||
)}
|
||||
|
||||
{!editing ? (
|
||||
/* ─── View mode ─── */
|
||||
<div className="mx-4 space-y-3">
|
||||
{/* Full-res image */}
|
||||
<div className="rounded-3xl overflow-hidden shadow-lg">
|
||||
<img src={garment.imageUrl} alt={garment.name || garment.category} className="w-full object-contain max-h-80 bg-white dark:bg-gray-800" />
|
||||
</div>
|
||||
|
||||
{/* Status + quick actions */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-semibold ${STATUS_COLORS[garment.status] || "bg-gray-100"}`}>
|
||||
{garment.status}
|
||||
</span>
|
||||
<Button size="sm" loading={loggingWear} onClick={logWear} variant="secondary">
|
||||
👕 Worn today
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{STATUS_FLOW.filter(s => s !== garment.status).map(s => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => updateStatus(s)}
|
||||
className="text-xs px-2.5 py-1 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded-full hover:bg-gray-200 dark:hover:bg-gray-600"
|
||||
>
|
||||
Move to {s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<p className="text-xs text-gray-400">Category</p>
|
||||
<p className="font-medium text-sm">{CATEGORY_LABELS[garment.category] || garment.category}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-400">Size</p>
|
||||
<p className="font-medium text-sm">{garment.sizeLabel}</p>
|
||||
</div>
|
||||
{garment.acquiredVia && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-400">Acquired via</p>
|
||||
<p className="font-medium text-sm">{garment.acquiredVia}</p>
|
||||
</div>
|
||||
)}
|
||||
{garment.giftFrom && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-400">Gift from</p>
|
||||
<p className="font-medium text-sm">💝 {garment.giftFrom}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{garment.colors.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 mb-1.5">Colors</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{garment.colors.map(c => <Chip key={c} label={c} color="bg-pink-50 text-pink-700 dark:bg-pink-900/20 dark:text-pink-300" />)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{garment.seasons.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 mb-1.5">Seasons</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{garment.seasons.map(s => <Chip key={s} label={s} color="bg-orange-50 text-orange-700 dark:bg-orange-900/20 dark:text-orange-300" />)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{garment.occasionTags.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 mb-1.5">Occasions</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{garment.occasionTags.map(t => <Chip key={t} label={t} color="bg-purple-50 text-purple-700 dark:bg-purple-900/20 dark:text-purple-300" />)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Wear history */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
|
||||
<p className="font-semibold text-sm mb-3">
|
||||
Wear history
|
||||
<span className="ml-2 text-xs font-normal text-gray-400">({wears.length} times)</span>
|
||||
</p>
|
||||
{wears.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">Not worn yet — tap "Worn today" to start tracking</p>
|
||||
) : (
|
||||
<div className="space-y-1 max-h-40 overflow-y-auto">
|
||||
{wears.map(w => (
|
||||
<div key={w.id} className="flex items-center text-sm text-gray-600 dark:text-gray-300 gap-2">
|
||||
<span className="text-gray-300">📅</span>
|
||||
{new Date(w.worn_on).toLocaleDateString("en-IN", { day: "numeric", month: "short", year: "numeric" })}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* ─── Edit mode ─── */
|
||||
<div className="mx-4 space-y-3">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
|
||||
<label className="text-xs text-gray-500">Name</label>
|
||||
<input
|
||||
value={editName}
|
||||
onChange={e => setEditName(e.target.value)}
|
||||
className="mt-1 w-full px-3 py-2 rounded-xl bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 text-sm focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category chips */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
|
||||
<p className="text-xs text-gray-500 mb-2">Category</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{GARMENT_CATEGORIES.map(c => (
|
||||
<button key={c} type="button" onClick={() => setEditCategory(c)}
|
||||
className={`px-3 py-1.5 rounded-full text-sm font-medium ${editCategory === c ? "bg-rose-400 text-white" : "bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300"}`}
|
||||
>
|
||||
{CATEGORY_LABELS[c] || c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Size chips */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
|
||||
<p className="text-xs text-gray-500 mb-2">Size</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{GARMENT_SIZE_ORDER.map(s => (
|
||||
<button key={s} type="button" onClick={() => setEditSize(s)}
|
||||
className={`px-3 py-1.5 rounded-full text-sm font-medium ${editSize === s ? "bg-rose-400 text-white" : "bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300"}`}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
|
||||
<p className="text-xs text-gray-500 mb-2">Status</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{STATUS_FLOW.map(s => (
|
||||
<button key={s} type="button" onClick={() => setEditStatus(s)}
|
||||
className={`px-3 py-1.5 rounded-full text-sm font-medium ${editStatus === s ? "bg-rose-400 text-white" : "bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300"}`}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Colors */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
|
||||
<p className="text-xs text-gray-500 mb-2">Colors</p>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{editColors.map(c => (
|
||||
<button key={c} type="button" onClick={() => setEditColors(editColors.filter(x => x !== c))}
|
||||
className="px-2.5 py-1 rounded-full text-xs bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300"
|
||||
>
|
||||
{c} ×
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input value={editColorInput} onChange={e => setEditColorInput(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === "Enter") { const c = editColorInput.trim().toLowerCase(); if (c && !editColors.includes(c)) setEditColors([...editColors, c]); setEditColorInput(""); }}}
|
||||
placeholder="Add color…" className="flex-1 px-3 py-1.5 rounded-xl bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 text-sm focus:outline-none" />
|
||||
<button type="button" onClick={() => { const c = editColorInput.trim().toLowerCase(); if (c && !editColors.includes(c)) setEditColors([...editColors, c]); setEditColorInput(""); }}
|
||||
className="px-3 py-1.5 bg-rose-400 text-white rounded-xl text-sm">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Seasons */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
|
||||
<p className="text-xs text-gray-500 mb-2">Seasons</p>
|
||||
<div className="flex gap-2">
|
||||
{["summer", "monsoon", "winter"].map(s => (
|
||||
<button key={s} type="button" onClick={() => setEditSeasons(toggleArray(editSeasons, s))}
|
||||
className={`px-3 py-1.5 rounded-full text-sm font-medium ${editSeasons.includes(s) ? "bg-rose-400 text-white" : "bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300"}`}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Occasions */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
|
||||
<p className="text-xs text-gray-500 mb-2">Occasions</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{["everyday", "daycare", "festive", "photoshoot"].map(o => (
|
||||
<button key={o} type="button" onClick={() => setEditOccasions(toggleArray(editOccasions, o))}
|
||||
className={`px-3 py-1.5 rounded-full text-sm font-medium ${editOccasions.includes(o) ? "bg-rose-400 text-white" : "bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300"}`}
|
||||
>
|
||||
{o}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button fullWidth onClick={saveEdit} loading={saving} size="lg">Save Changes</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
495
src/app/(app)/wardrobe/add/page.tsx
Normal file
|
|
@ -0,0 +1,495 @@
|
|||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import { useFamily } from "@/app/FamilyProvider";
|
||||
import { Button } from "@/components/ui";
|
||||
import { trackGarmentAdded } from "@/lib/analytics";
|
||||
import { GARMENT_CATEGORIES, GARMENT_SIZE_ORDER } from "@/db/schema/wardrobe";
|
||||
|
||||
type Step = "capture" | "form";
|
||||
|
||||
interface VisionResult {
|
||||
name: string;
|
||||
category: string;
|
||||
colors: string[];
|
||||
seasons: string[];
|
||||
occasion_tags: string[];
|
||||
}
|
||||
|
||||
const SEASON_OPTIONS = ["summer", "monsoon", "winter"];
|
||||
const OCCASION_OPTIONS = ["everyday", "daycare", "festive", "photoshoot"];
|
||||
const ACQUIRED_OPTIONS = ["bought", "gift", "handmedown"];
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
onesie: "Onesie", top: "Top", bottom: "Bottom", dress: "Dress",
|
||||
outerwear: "Jacket", sleepwear: "Sleepwear", accessory: "Accessory",
|
||||
};
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
onesie: "bg-pink-100 text-pink-700", top: "bg-blue-100 text-blue-700",
|
||||
bottom: "bg-indigo-100 text-indigo-700", dress: "bg-purple-100 text-purple-700",
|
||||
outerwear: "bg-teal-100 text-teal-700", sleepwear: "bg-amber-100 text-amber-700",
|
||||
accessory: "bg-rose-100 text-rose-700",
|
||||
};
|
||||
|
||||
function ChipRow<T extends string>({
|
||||
label, options, selected, onChange, required, optionLabel,
|
||||
}: {
|
||||
label: string;
|
||||
options: readonly T[];
|
||||
selected: T[];
|
||||
onChange: (v: T[]) => void;
|
||||
required?: boolean;
|
||||
optionLabel?: (v: T) => string;
|
||||
}) {
|
||||
const toggle = (v: T) => {
|
||||
if (selected.includes(v)) onChange(selected.filter(x => x !== v));
|
||||
else onChange([...selected, v]);
|
||||
};
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-semibold text-gray-600 dark:text-gray-300 mb-2">
|
||||
{label}{required && <span className="text-red-500 ml-0.5">*</span>}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{options.map(o => (
|
||||
<button
|
||||
key={o}
|
||||
type="button"
|
||||
onClick={() => toggle(o)}
|
||||
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-all ${
|
||||
selected.includes(o)
|
||||
? "bg-rose-400 text-white shadow-sm scale-105"
|
||||
: "bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
{optionLabel ? optionLabel(o) : o}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SingleChipRow<T extends string>({
|
||||
label, options, selected, onChange, required, optionLabel,
|
||||
}: {
|
||||
label: string;
|
||||
options: readonly T[];
|
||||
selected: T | null;
|
||||
onChange: (v: T) => void;
|
||||
required?: boolean;
|
||||
optionLabel?: (v: T) => string;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-semibold text-gray-600 dark:text-gray-300 mb-2">
|
||||
{label}{required && <span className="text-red-500 ml-0.5">*</span>}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{options.map(o => (
|
||||
<button
|
||||
key={o}
|
||||
type="button"
|
||||
onClick={() => onChange(o)}
|
||||
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-all ${
|
||||
selected === o
|
||||
? "bg-rose-400 text-white shadow-sm scale-105"
|
||||
: "bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
{optionLabel ? optionLabel(o) : o}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AddGarmentPage() {
|
||||
const { childId } = useFamily();
|
||||
const router = useRouter();
|
||||
const fileRef = useRef<HTMLInputElement>(null); // gallery picker
|
||||
const cameraRef = useRef<HTMLInputElement>(null); // camera capture
|
||||
|
||||
const [step, setStep] = useState<Step>("capture");
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
const [thumbUrl, setThumbUrl] = useState<string | null>(null);
|
||||
const [imageKey, setImageKey] = useState("");
|
||||
const [thumbKey, setThumbKey] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [uploading, setUploading] = useState(false); // photo still uploading to R2
|
||||
const [visionPending, setVisionPending] = useState(false); // AI analysis running in bg
|
||||
|
||||
// Form fields
|
||||
const [name, setName] = useState("");
|
||||
const [category, setCategory] = useState<string | null>(null);
|
||||
const [sizeLabel, setSizeLabel] = useState<string | null>(null);
|
||||
const [colors, setColors] = useState<string[]>([]);
|
||||
const [colorInput, setColorInput] = useState("");
|
||||
const [seasons, setSeasons] = useState<string[]>([]);
|
||||
const [occasionTags, setOccasionTags] = useState<string[]>([]);
|
||||
const [acquiredVia, setAcquiredVia] = useState<string | null>(null);
|
||||
const [giftFrom, setGiftFrom] = useState("");
|
||||
const [visionMetadata, setVisionMetadata] = useState<VisionResult | null>(null);
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setError("");
|
||||
|
||||
// 1. Show local preview immediately and jump straight to the form.
|
||||
// The user can start filling in size/occasions while the upload runs.
|
||||
const reader = new FileReader();
|
||||
reader.onload = ev => setPreview(ev.target?.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
setStep("form");
|
||||
setUploading(true);
|
||||
setVisionPending(false);
|
||||
|
||||
// 2. Upload to R2
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
let ik = "", tk = "", tu = "";
|
||||
try {
|
||||
const uploadRes = await fetch("/api/garments/upload", { method: "POST", body: form });
|
||||
if (!uploadRes.ok) throw new Error((await uploadRes.json()).error);
|
||||
const uploadData = await uploadRes.json();
|
||||
ik = uploadData.imageKey;
|
||||
tk = uploadData.thumbKey;
|
||||
tu = uploadData.thumbUrl;
|
||||
setImageKey(ik);
|
||||
setThumbKey(tk);
|
||||
setThumbUrl(tu);
|
||||
} catch (err) {
|
||||
setError(`Upload failed: ${err}`);
|
||||
setStep("capture");
|
||||
setUploading(false);
|
||||
return;
|
||||
}
|
||||
setUploading(false);
|
||||
|
||||
// 3. Fire vision AI in the background — do NOT await, form stays interactive.
|
||||
setVisionPending(true);
|
||||
fetch("/api/garments/tag", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ imageKey: ik }),
|
||||
})
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then((vision: VisionResult | null) => {
|
||||
if (vision) {
|
||||
setVisionMetadata(vision);
|
||||
// Only pre-fill fields the user hasn't touched yet
|
||||
setName(n => n || vision.name || "");
|
||||
setCategory(c => c || vision.category || null);
|
||||
setColors(c => c.length ? c : (vision.colors || []));
|
||||
setSeasons(s => s.length ? s : (vision.seasons || []));
|
||||
setOccasionTags(t => t.length ? t : (vision.occasion_tags || []));
|
||||
}
|
||||
})
|
||||
.catch(() => {/* vision failure is non-fatal */})
|
||||
.finally(() => setVisionPending(false));
|
||||
};
|
||||
|
||||
const addColor = () => {
|
||||
const c = colorInput.trim().toLowerCase();
|
||||
if (c && !colors.includes(c)) setColors([...colors, c]);
|
||||
setColorInput("");
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!childId || !category || !sizeLabel) return;
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch("/api/garments", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
childId,
|
||||
name: name || null,
|
||||
category,
|
||||
sizeLabel,
|
||||
colors,
|
||||
seasons,
|
||||
occasionTags,
|
||||
imageKey,
|
||||
thumbKey,
|
||||
acquiredVia: acquiredVia || null,
|
||||
giftFrom: giftFrom || null,
|
||||
visionMetadata,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).error);
|
||||
trackGarmentAdded();
|
||||
handleAddAnother();
|
||||
} catch (err) {
|
||||
setError(`Save failed: ${err}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddAnother = () => {
|
||||
setStep("capture");
|
||||
setPreview(null);
|
||||
setThumbUrl(null);
|
||||
setImageKey("");
|
||||
setThumbKey("");
|
||||
setName("");
|
||||
setCategory(null);
|
||||
setSizeLabel(null);
|
||||
setColors([]);
|
||||
setColorInput("");
|
||||
setSeasons([]);
|
||||
setOccasionTags([]);
|
||||
setAcquiredVia(null);
|
||||
setGiftFrom("");
|
||||
setVisionMetadata(null);
|
||||
setError("");
|
||||
setUploading(false);
|
||||
setVisionPending(false);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
if (cameraRef.current) cameraRef.current.value = "";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-pink-50 via-purple-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 pb-24">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 p-4">
|
||||
<button onClick={() => router.back()} className="p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-xl">←</button>
|
||||
<h1 className="text-xl font-bold">Add Garment</h1>
|
||||
{step === "form" && visionPending && (
|
||||
<span className="ml-auto text-xs text-amber-500 bg-amber-50 dark:bg-amber-900/20 px-2 py-1 rounded-full shadow-sm animate-pulse">
|
||||
🔍 AI tagging…
|
||||
</span>
|
||||
)}
|
||||
{step === "form" && !visionPending && visionMetadata && (
|
||||
<span className="ml-auto text-xs text-gray-400 bg-white dark:bg-gray-800 px-2 py-1 rounded-full shadow-sm">
|
||||
✨ AI pre-filled
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mx-4 mb-3 p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700 rounded-xl text-sm text-red-700 dark:text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 1: Capture */}
|
||||
{step === "capture" && (
|
||||
<div className="mx-4 mt-4">
|
||||
<div className="aspect-square rounded-3xl border-4 border-dashed border-rose-200 dark:border-gray-600 flex flex-col items-center justify-center gap-4 bg-white dark:bg-gray-800">
|
||||
<span className="text-6xl">👚</span>
|
||||
<div className="text-center px-4">
|
||||
<p className="font-semibold text-gray-700 dark:text-gray-200">Add a garment photo</p>
|
||||
<p className="text-sm text-gray-400 mt-1">Tip: flat-lay on a light surface for best AI tagging</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => cameraRef.current?.click()}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-rose-400 text-white rounded-full text-sm font-medium shadow-sm active:scale-95 transition-transform"
|
||||
>
|
||||
📷 Camera
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 rounded-full text-sm font-medium shadow-sm active:scale-95 transition-transform"
|
||||
>
|
||||
🖼 Gallery
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Gallery input — no capture attribute, Android shows full media picker */}
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
{/* Camera input — capture="environment" opens rear camera directly */}
|
||||
<input
|
||||
ref={cameraRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Form (shown immediately after photo selected) */}
|
||||
{step === "form" && (
|
||||
<div className="mx-4 mt-2 space-y-2">
|
||||
{/* Thumbnail — shows upload spinner until R2 upload is done */}
|
||||
<div className="flex gap-4 items-start mb-4">
|
||||
{(thumbUrl || preview) && (
|
||||
<div className="relative w-24 h-24 rounded-2xl overflow-hidden shadow-md flex-shrink-0">
|
||||
<img src={thumbUrl || preview!} alt="garment" className="w-full h-full object-cover" />
|
||||
{uploading && (
|
||||
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
|
||||
<div className="w-6 h-6 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<label className="text-sm font-semibold text-gray-600 dark:text-gray-300">Name</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder="e.g. Striped blue onesie"
|
||||
className="mt-1 w-full px-3 py-2 rounded-xl bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 text-sm focus:outline-none focus:ring-2 focus:ring-rose-300"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
|
||||
<SingleChipRow
|
||||
label="Category"
|
||||
options={GARMENT_CATEGORIES}
|
||||
selected={category as typeof GARMENT_CATEGORIES[number] | null}
|
||||
onChange={v => setCategory(v)}
|
||||
required
|
||||
optionLabel={v => CATEGORY_LABELS[v] || v}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Size — required, not pre-filled */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
|
||||
<SingleChipRow
|
||||
label="Size"
|
||||
options={GARMENT_SIZE_ORDER}
|
||||
selected={sizeLabel as typeof GARMENT_SIZE_ORDER[number] | null}
|
||||
onChange={v => setSizeLabel(v)}
|
||||
required
|
||||
/>
|
||||
{!sizeLabel && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400 mt-2">
|
||||
⚠ Size must be set manually — vision cannot guess it
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Colors */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
|
||||
<p className="text-sm font-semibold text-gray-600 dark:text-gray-300 mb-2">Colors</p>
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{colors.map(c => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => setColors(colors.filter(x => x !== c))}
|
||||
className="px-3 py-1 rounded-full text-sm bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300 flex items-center gap-1"
|
||||
>
|
||||
{c} ×
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={colorInput}
|
||||
onChange={e => setColorInput(e.target.value)}
|
||||
onKeyDown={e => e.key === "Enter" && addColor()}
|
||||
placeholder="Add color…"
|
||||
className="flex-1 px-3 py-1.5 rounded-xl bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 text-sm focus:outline-none"
|
||||
/>
|
||||
<button type="button" onClick={addColor} className="px-3 py-1.5 bg-rose-400 text-white rounded-xl text-sm">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Seasons */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
|
||||
<ChipRow
|
||||
label="Seasons"
|
||||
options={SEASON_OPTIONS as readonly string[]}
|
||||
selected={seasons}
|
||||
onChange={setSeasons}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Occasions */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
|
||||
<ChipRow
|
||||
label="Occasions"
|
||||
options={OCCASION_OPTIONS as readonly string[]}
|
||||
selected={occasionTags}
|
||||
onChange={setOccasionTags}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Optional fields */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
|
||||
<p className="text-sm font-semibold text-gray-600 dark:text-gray-300 mb-3">Optional</p>
|
||||
<div className="mb-3">
|
||||
<p className="text-xs text-gray-500 mb-1">Acquired via</p>
|
||||
<div className="flex gap-2">
|
||||
{ACQUIRED_OPTIONS.map(o => (
|
||||
<button
|
||||
key={o}
|
||||
type="button"
|
||||
onClick={() => setAcquiredVia(acquiredVia === o ? null : o)}
|
||||
className={`px-3 py-1 rounded-full text-xs font-medium transition-all ${
|
||||
acquiredVia === o
|
||||
? "bg-indigo-400 text-white"
|
||||
: "bg-gray-100 dark:bg-gray-700 text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{o}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{acquiredVia === "gift" && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-1">Gift from</p>
|
||||
<input
|
||||
value={giftFrom}
|
||||
onChange={e => setGiftFrom(e.target.value)}
|
||||
placeholder="e.g. Nani"
|
||||
className="w-full px-3 py-2 rounded-xl bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 text-sm focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="pt-2 space-y-3">
|
||||
<Button
|
||||
fullWidth
|
||||
onClick={handleSave}
|
||||
loading={saving}
|
||||
disabled={!category || !sizeLabel || !childId || uploading}
|
||||
size="lg"
|
||||
>
|
||||
{uploading ? "Uploading photo…" : "Save Garment"}
|
||||
</Button>
|
||||
{uploading && (
|
||||
<p className="text-center text-sm text-gray-400">
|
||||
Photo uploading — you can pick size & tags while you wait
|
||||
</p>
|
||||
)}
|
||||
{!uploading && !sizeLabel && (
|
||||
<p className="text-center text-sm text-amber-600 dark:text-amber-400">
|
||||
Please select a size to save
|
||||
</p>
|
||||
)}
|
||||
<Button fullWidth variant="secondary" onClick={handleAddAnother} size="lg">
|
||||
+ Add Another
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
198
src/app/(app)/wardrobe/outfit/page.tsx
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useFamily } from "@/app/FamilyProvider";
|
||||
import { Button } from "@/components/ui";
|
||||
|
||||
interface OutfitItem {
|
||||
id: string;
|
||||
name: string | null;
|
||||
category: string;
|
||||
sizeLabel: string;
|
||||
thumbUrl: string;
|
||||
imageUrl: string;
|
||||
colors: string[];
|
||||
}
|
||||
|
||||
interface OutfitSuggestion {
|
||||
label: string;
|
||||
items: OutfitItem[];
|
||||
}
|
||||
|
||||
interface OutfitResponse {
|
||||
weatherBasis: string;
|
||||
weather: { tempC: number; season: string };
|
||||
outfits: OutfitSuggestion[];
|
||||
}
|
||||
|
||||
const OCCASION_OPTS = ["", "everyday", "daycare", "festive", "photoshoot"] as const;
|
||||
const SEASON_ICON: Record<string, string> = {
|
||||
summer: "☀️", monsoon: "🌧️", winter: "❄️",
|
||||
};
|
||||
|
||||
export default function OutfitSuggestionPage() {
|
||||
const { childId } = useFamily();
|
||||
const router = useRouter();
|
||||
|
||||
const [occasion, setOccasion] = useState("");
|
||||
const [result, setResult] = useState<OutfitResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [savedOutfitName, setSavedOutfitName] = useState<Record<number, string>>({});
|
||||
const [savedIdx, setSavedIdx] = useState<Set<number>>(new Set());
|
||||
|
||||
const fetchSuggestions = async () => {
|
||||
if (!childId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams({ childId });
|
||||
if (occasion) params.set("occasion", occasion);
|
||||
const res = await fetch(`/api/garments/outfit?${params}`);
|
||||
const data = await res.json();
|
||||
setResult(data);
|
||||
} catch {}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => { fetchSuggestions(); }, [childId]);
|
||||
|
||||
const saveOutfit = async (idx: number, outfit: OutfitSuggestion) => {
|
||||
const name = savedOutfitName[idx]?.trim() || outfit.label;
|
||||
if (!childId) return;
|
||||
try {
|
||||
const res = await fetch("/api/garments/outfits", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
childId,
|
||||
name,
|
||||
garmentIds: outfit.items.map(i => i.id),
|
||||
occasionTags: occasion ? [occasion] : [],
|
||||
}),
|
||||
});
|
||||
if (res.ok) setSavedIdx(prev => new Set([...prev, idx]));
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-yellow-50 via-orange-50 to-pink-50 dark:from-gray-900 dark:to-gray-800 pb-24">
|
||||
<div className="flex items-center gap-3 p-4">
|
||||
<button onClick={() => router.back()} className="p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-xl">←</button>
|
||||
<h1 className="text-xl font-bold">✨ Today's Outfit</h1>
|
||||
<button
|
||||
onClick={fetchSuggestions}
|
||||
className="ml-auto p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-lg"
|
||||
title="Refresh"
|
||||
>
|
||||
🔄
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Weather basis */}
|
||||
{result && (
|
||||
<div className="mx-4 mb-4 p-3 bg-white dark:bg-gray-800 rounded-2xl shadow-sm flex items-center gap-3">
|
||||
<span className="text-2xl">{SEASON_ICON[result.weather.season] || "🌤️"}</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-700 dark:text-gray-200">{result.weatherBasis}</p>
|
||||
<p className="text-xs text-gray-400">Suggestions filtered for {result.weather.season} garments</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Occasion filter */}
|
||||
<div className="px-4 mb-4 flex gap-2 overflow-x-auto scrollbar-hide">
|
||||
{OCCASION_OPTS.map(o => (
|
||||
<button
|
||||
key={o || "all"}
|
||||
onClick={() => { setOccasion(o); }}
|
||||
className={`px-3 py-1.5 rounded-full text-sm font-medium whitespace-nowrap flex-shrink-0 transition-all ${
|
||||
occasion === o ? "bg-rose-400 text-white" : "bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
{o || "Any occasion"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-3">
|
||||
<div className="flex gap-1">
|
||||
{[0, 150, 300].map(d => (
|
||||
<span key={d} className="w-2.5 h-2.5 bg-rose-400 rounded-full animate-bounce" style={{ animationDelay: `${d}ms` }} />
|
||||
))}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">Fetching weather + building outfits…</p>
|
||||
</div>
|
||||
) : result && result.outfits.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 px-8 text-center">
|
||||
<span className="text-5xl mb-4">🌂</span>
|
||||
<p className="font-semibold text-gray-600 dark:text-gray-300">No outfits for today's weather</p>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
Add more {result.weather.season} garments to your wardrobe
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.push("/wardrobe/add")}
|
||||
className="mt-4 px-5 py-2 bg-rose-400 text-white rounded-xl text-sm font-medium"
|
||||
>
|
||||
+ Add Garment
|
||||
</button>
|
||||
</div>
|
||||
) : result ? (
|
||||
<div className="mx-4 space-y-4">
|
||||
{result.outfits.map((outfit, idx) => (
|
||||
<div key={idx} className="bg-white dark:bg-gray-800 rounded-2xl shadow-sm overflow-hidden">
|
||||
<div className="px-4 pt-3 pb-2 flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">
|
||||
Outfit {idx + 1} — {outfit.label}
|
||||
</span>
|
||||
{outfit.items[0]?.colors?.length > 0 && (
|
||||
<span className="text-xs text-gray-400">{outfit.items.flatMap(i => i.colors).slice(0, 3).join(", ")}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={`flex gap-3 px-4 pb-3 ${outfit.items.length === 1 ? "justify-center" : ""}`}>
|
||||
{outfit.items.map(item => (
|
||||
<div key={item.id} className="flex flex-col items-center gap-1.5">
|
||||
<div className="w-28 h-28 rounded-xl overflow-hidden bg-gray-50">
|
||||
<img src={item.thumbUrl} alt={item.name || item.category} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
<p className="text-xs text-center text-gray-600 dark:text-gray-400 max-w-24 truncate">
|
||||
{item.name || item.category}
|
||||
</p>
|
||||
<span className="text-xs bg-gray-100 dark:bg-gray-700 text-gray-500 px-1.5 py-0.5 rounded-full">
|
||||
{item.sizeLabel}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Save outfit */}
|
||||
{savedIdx.has(idx) ? (
|
||||
<div className="px-4 pb-3 text-sm text-green-600 dark:text-green-400">✓ Saved to wardrobe</div>
|
||||
) : (
|
||||
<div className="px-4 pb-3 flex gap-2">
|
||||
<input
|
||||
value={savedOutfitName[idx] ?? ""}
|
||||
onChange={e => setSavedOutfitName(prev => ({ ...prev, [idx]: e.target.value }))}
|
||||
placeholder={outfit.label}
|
||||
className="flex-1 px-3 py-1.5 rounded-xl bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 text-sm focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveOutfit(idx, outfit)}
|
||||
className="px-3 py-1.5 bg-indigo-400 text-white rounded-xl text-sm font-medium"
|
||||
>
|
||||
💾 Save
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button fullWidth variant="secondary" onClick={fetchSuggestions} size="lg">
|
||||
🔄 Suggest again
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
164
src/app/(app)/wardrobe/packing/page.tsx
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useFamily } from "@/app/FamilyProvider";
|
||||
import { Button } from "@/components/ui";
|
||||
|
||||
interface PackingItem {
|
||||
id: string;
|
||||
name: string;
|
||||
sizeLabel: string;
|
||||
thumbUrl: string;
|
||||
checked: boolean;
|
||||
}
|
||||
|
||||
type PackingGroups = Record<string, PackingItem[]>;
|
||||
|
||||
const SEASON_OPTS = ["summer", "monsoon", "winter"] as const;
|
||||
const OCCASION_OPTS = ["everyday", "daycare", "festive", "photoshoot"] as const;
|
||||
const CATEGORY_EMOJI: Record<string, string> = {
|
||||
onesie: "👶", top: "👕", bottom: "👖", dress: "👗",
|
||||
outerwear: "🧥", sleepwear: "😴", accessory: "🎀",
|
||||
};
|
||||
|
||||
export default function PackingListPage() {
|
||||
const { childId } = useFamily();
|
||||
const router = useRouter();
|
||||
|
||||
const [season, setSeason] = useState<string>("");
|
||||
const [occasion, setOccasion] = useState<string>("");
|
||||
const [groups, setGroups] = useState<PackingGroups | null>(null);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const generate = async () => {
|
||||
if (!childId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams({ childId });
|
||||
if (season) params.set("season", season);
|
||||
if (occasion) params.set("occasion", occasion);
|
||||
const res = await fetch(`/api/garments/packing?${params}`);
|
||||
const data = await res.json();
|
||||
setGroups(data.groups || {});
|
||||
setTotal(data.total || 0);
|
||||
} catch {}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const toggleItem = (cat: string, itemId: string) => {
|
||||
if (!groups) return;
|
||||
setGroups(prev => ({
|
||||
...prev!,
|
||||
[cat]: prev![cat].map(i => i.id === itemId ? { ...i, checked: !i.checked } : i),
|
||||
}));
|
||||
};
|
||||
|
||||
const checkedCount = groups
|
||||
? Object.values(groups).flat().filter(i => i.checked).length
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-sky-50 via-purple-50 to-pink-50 dark:from-gray-900 dark:to-gray-800 pb-24">
|
||||
<div className="flex items-center gap-3 p-4">
|
||||
<button onClick={() => router.back()} className="p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-xl">←</button>
|
||||
<h1 className="text-xl font-bold">🧳 Packing List</h1>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="mx-4 bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm mb-4 space-y-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-600 dark:text-gray-300 mb-2">Season</p>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={() => setSeason("")}
|
||||
className={`px-3 py-1.5 rounded-full text-sm font-medium ${!season ? "bg-rose-400 text-white" : "bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300"}`}
|
||||
>
|
||||
Any
|
||||
</button>
|
||||
{SEASON_OPTS.map(s => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setSeason(season === s ? "" : s)}
|
||||
className={`px-3 py-1.5 rounded-full text-sm font-medium ${season === s ? "bg-rose-400 text-white" : "bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300"}`}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-600 dark:text-gray-300 mb-2">Occasion</p>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={() => setOccasion("")}
|
||||
className={`px-3 py-1.5 rounded-full text-sm font-medium ${!occasion ? "bg-rose-400 text-white" : "bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300"}`}
|
||||
>
|
||||
Any
|
||||
</button>
|
||||
{OCCASION_OPTS.map(o => (
|
||||
<button
|
||||
key={o}
|
||||
onClick={() => setOccasion(occasion === o ? "" : o)}
|
||||
className={`px-3 py-1.5 rounded-full text-sm font-medium ${occasion === o ? "bg-rose-400 text-white" : "bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300"}`}
|
||||
>
|
||||
{o}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button fullWidth onClick={generate} loading={loading} size="lg">
|
||||
Generate List
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{groups && (
|
||||
<>
|
||||
<div className="mx-4 mb-3 flex items-center justify-between">
|
||||
<p className="text-sm text-gray-500">{total} garments found · {checkedCount} packed</p>
|
||||
{total === 0 && (
|
||||
<p className="text-sm text-amber-600">No matching garments — try broader filters</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mx-4 space-y-3">
|
||||
{Object.entries(groups).map(([cat, items]) => (
|
||||
<div key={cat} className="bg-white dark:bg-gray-800 rounded-2xl shadow-sm overflow-hidden">
|
||||
<div className="px-4 py-2.5 bg-gray-50 dark:bg-gray-700 border-b border-gray-100 dark:border-gray-600 flex items-center gap-2">
|
||||
<span className="text-lg">{CATEGORY_EMOJI[cat] || "👗"}</span>
|
||||
<span className="font-semibold text-sm capitalize">{cat}</span>
|
||||
<span className="ml-auto text-xs text-gray-400">{items.length}</span>
|
||||
</div>
|
||||
{items.map(item => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => toggleItem(cat, item.id)}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 border-b border-gray-50 dark:border-gray-700 last:border-0 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<div className={`w-5 h-5 rounded-full border-2 flex items-center justify-center flex-shrink-0 transition-all ${
|
||||
item.checked ? "border-rose-400 bg-rose-400" : "border-gray-300 dark:border-gray-500"
|
||||
}`}>
|
||||
{item.checked && <span className="text-white text-xs">✓</span>}
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-xl overflow-hidden flex-shrink-0">
|
||||
<img src={item.thumbUrl} alt={item.name} className="w-full h-full object-cover" loading="lazy" />
|
||||
</div>
|
||||
<div className="flex-1 text-left">
|
||||
<p className={`text-sm font-medium ${item.checked ? "line-through text-gray-400" : "text-gray-700 dark:text-gray-200"}`}>
|
||||
{item.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">{item.sizeLabel}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
248
src/app/(app)/wardrobe/page.tsx
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useFamily } from "@/app/FamilyProvider";
|
||||
import { GARMENT_CATEGORIES, GARMENT_SIZE_ORDER } from "@/db/schema/wardrobe";
|
||||
|
||||
interface Garment {
|
||||
id: string;
|
||||
name: string | null;
|
||||
category: string;
|
||||
sizeLabel: string;
|
||||
thumbUrl: string;
|
||||
status: string;
|
||||
seasons: string[];
|
||||
occasionTags: string[];
|
||||
colors: string[];
|
||||
}
|
||||
|
||||
interface OutgrowthNudge {
|
||||
candidates: { id: string; sizeLabel: string }[];
|
||||
currentSizeLabel: string | null;
|
||||
}
|
||||
|
||||
const STATUS_OPTS = [
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "stored", label: "Stored" },
|
||||
{ value: "outgrown", label: "Outgrown" },
|
||||
{ value: "donated", label: "Donated" },
|
||||
];
|
||||
|
||||
const CATEGORY_EMOJI: Record<string, string> = {
|
||||
onesie: "👶", top: "👕", bottom: "👖", dress: "👗",
|
||||
outerwear: "🧥", sleepwear: "😴", accessory: "🎀",
|
||||
};
|
||||
|
||||
export default function WardrobePage() {
|
||||
const { childId } = useFamily();
|
||||
const router = useRouter();
|
||||
|
||||
const [garments, setGarments] = useState<Garment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [status, setStatus] = useState("active");
|
||||
const [filterCategory, setFilterCategory] = useState("");
|
||||
const [filterSize, setFilterSize] = useState("");
|
||||
const [filterSeason, setFilterSeason] = useState("");
|
||||
const [nudge, setNudge] = useState<OutgrowthNudge | null>(null);
|
||||
const [nudgeDismissed, setNudgeDismissed] = useState(false);
|
||||
|
||||
const fetchGarments = useCallback(async () => {
|
||||
if (!childId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams({ childId, status });
|
||||
if (filterCategory) params.set("category", filterCategory);
|
||||
if (filterSize) params.set("sizeLabel", filterSize);
|
||||
if (filterSeason) params.set("season", filterSeason);
|
||||
const res = await fetch(`/api/garments?${params}`);
|
||||
const data = await res.json();
|
||||
setGarments(data.items || []);
|
||||
} catch {
|
||||
setGarments([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [childId, status, filterCategory, filterSize, filterSeason]);
|
||||
|
||||
const fetchNudge = useCallback(async () => {
|
||||
if (!childId || nudgeDismissed) return;
|
||||
try {
|
||||
const res = await fetch(`/api/garments/outgrowth?childId=${childId}`);
|
||||
const data = await res.json();
|
||||
if (data.candidates?.length > 0) setNudge(data);
|
||||
} catch {}
|
||||
}, [childId, nudgeDismissed]);
|
||||
|
||||
useEffect(() => { fetchGarments(); }, [fetchGarments]);
|
||||
useEffect(() => { fetchNudge(); }, [fetchNudge]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-pink-50 via-purple-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 pb-24">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 p-4">
|
||||
<button onClick={() => router.back()} className="p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-xl">←</button>
|
||||
<h1 className="text-xl font-bold">Wardrobe 👗</h1>
|
||||
<div className="ml-auto flex gap-2">
|
||||
<Link href="/wardrobe/outfit" className="p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-lg" title="Today's outfit">✨</Link>
|
||||
<Link href="/wardrobe/packing" className="p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-lg" title="Packing list">🧳</Link>
|
||||
<Link href="/wardrobe/saved-outfits" className="p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-lg" title="Saved outfits">💾</Link>
|
||||
<Link href="/wardrobe/add" className="px-3 py-2 rounded-xl bg-rose-400 text-white text-sm font-medium shadow-sm">+ Add</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Outgrowth nudge */}
|
||||
{nudge && !nudgeDismissed && (
|
||||
<div className="mx-4 mb-3 p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-2xl flex items-start gap-3">
|
||||
<span className="text-2xl mt-0.5">📦</span>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-semibold text-amber-800 dark:text-amber-200">
|
||||
{nudge.candidates.length} item{nudge.candidates.length !== 1 ? "s" : ""} not worn in 45+ days and may be outgrown
|
||||
</p>
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400 mt-0.5">
|
||||
Current size: <strong>{nudge.currentSizeLabel}</strong>
|
||||
</p>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button
|
||||
onClick={() => { setFilterSize(nudge.candidates[0].sizeLabel); setNudgeDismissed(true); }}
|
||||
className="text-xs px-2 py-1 bg-amber-400 text-white rounded-lg"
|
||||
>
|
||||
View items
|
||||
</button>
|
||||
<button onClick={() => setNudgeDismissed(true)} className="text-xs px-2 py-1 text-amber-600 dark:text-amber-400">
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="px-4 space-y-2 mb-4">
|
||||
{/* Status row */}
|
||||
<div className="flex gap-2 overflow-x-auto scrollbar-hide pb-1">
|
||||
{STATUS_OPTS.map(s => (
|
||||
<button
|
||||
key={s.value}
|
||||
onClick={() => setStatus(s.value)}
|
||||
className={`px-3 py-1.5 rounded-full text-sm font-medium whitespace-nowrap flex-shrink-0 transition-all ${
|
||||
status === s.value
|
||||
? "bg-rose-400 text-white"
|
||||
: "bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Category row */}
|
||||
<div className="flex gap-2 overflow-x-auto scrollbar-hide pb-1">
|
||||
<button
|
||||
onClick={() => setFilterCategory("")}
|
||||
className={`px-3 py-1 rounded-full text-sm whitespace-nowrap flex-shrink-0 transition-all ${
|
||||
!filterCategory ? "bg-indigo-400 text-white" : "bg-white dark:bg-gray-800 text-gray-500"
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{GARMENT_CATEGORIES.map(c => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setFilterCategory(filterCategory === c ? "" : c)}
|
||||
className={`px-3 py-1 rounded-full text-sm whitespace-nowrap flex-shrink-0 transition-all ${
|
||||
filterCategory === c ? "bg-indigo-400 text-white" : "bg-white dark:bg-gray-800 text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{CATEGORY_EMOJI[c]} {c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Size row */}
|
||||
<div className="flex gap-2 overflow-x-auto scrollbar-hide pb-1">
|
||||
<button
|
||||
onClick={() => setFilterSize("")}
|
||||
className={`px-3 py-1 rounded-full text-xs whitespace-nowrap flex-shrink-0 transition-all ${
|
||||
!filterSize ? "bg-teal-400 text-white" : "bg-white dark:bg-gray-800 text-gray-500"
|
||||
}`}
|
||||
>
|
||||
Any size
|
||||
</button>
|
||||
{GARMENT_SIZE_ORDER.map(s => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setFilterSize(filterSize === s ? "" : s)}
|
||||
className={`px-3 py-1 rounded-full text-xs whitespace-nowrap flex-shrink-0 transition-all ${
|
||||
filterSize === s ? "bg-teal-400 text-white" : "bg-white dark:bg-gray-800 text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Season row */}
|
||||
<div className="flex gap-2 pb-1">
|
||||
{["", "summer", "monsoon", "winter"].map(s => (
|
||||
<button
|
||||
key={s || "all"}
|
||||
onClick={() => setFilterSeason(s)}
|
||||
className={`px-3 py-1 rounded-full text-xs whitespace-nowrap transition-all ${
|
||||
filterSeason === s ? "bg-orange-400 text-white" : "bg-white dark:bg-gray-800 text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{s || "All seasons"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-3 gap-3 px-4">
|
||||
{[...Array(9)].map((_, i) => (
|
||||
<div key={i} className="aspect-square rounded-2xl bg-white dark:bg-gray-800 animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : garments.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 px-8 text-center">
|
||||
<span className="text-5xl mb-4">👚</span>
|
||||
<p className="font-semibold text-gray-600 dark:text-gray-300">No garments yet</p>
|
||||
<p className="text-sm text-gray-400 mt-1">Add your first garment to build the wardrobe catalogue</p>
|
||||
<Link href="/wardrobe/add" className="mt-4 px-5 py-2 bg-rose-400 text-white rounded-xl text-sm font-medium">
|
||||
+ Add Garment
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-3 px-4">
|
||||
{garments.map(g => (
|
||||
<Link
|
||||
key={g.id}
|
||||
href={`/wardrobe/${g.id}`}
|
||||
className="flex flex-col bg-white dark:bg-gray-800 rounded-2xl overflow-hidden shadow-sm hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="aspect-square relative">
|
||||
<img
|
||||
src={g.thumbUrl}
|
||||
alt={g.name || g.category}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span className="absolute top-1 right-1 text-xs bg-white/90 dark:bg-gray-900/90 px-1.5 py-0.5 rounded-full font-medium text-gray-700 dark:text-gray-200">
|
||||
{g.sizeLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-2 py-1.5">
|
||||
<p className="text-xs font-medium text-gray-700 dark:text-gray-200 truncate">
|
||||
{g.name || `${CATEGORY_EMOJI[g.category] || ""} ${g.category}`}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
146
src/app/(app)/wardrobe/saved-outfits/page.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { useFamily } from "@/app/FamilyProvider";
|
||||
|
||||
interface SavedOutfit {
|
||||
id: string;
|
||||
name: string;
|
||||
garment_ids: string[];
|
||||
occasion_tags: string[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface GarmentThumb {
|
||||
id: string;
|
||||
thumbUrl: string;
|
||||
name: string | null;
|
||||
category: string;
|
||||
}
|
||||
|
||||
export default function SavedOutfitsPage() {
|
||||
const { childId } = useFamily();
|
||||
const router = useRouter();
|
||||
|
||||
const [outfits, setOutfits] = useState<SavedOutfit[]>([]);
|
||||
const [thumbs, setThumbs] = useState<Record<string, GarmentThumb>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!childId) return;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/garments/outfits?childId=${childId}`);
|
||||
const data = await res.json();
|
||||
setOutfits(data.items || []);
|
||||
|
||||
// Fetch thumbnails for garment IDs referenced by outfits
|
||||
const allIds = [...new Set((data.items || []).flatMap((o: SavedOutfit) => o.garment_ids))] as string[];
|
||||
if (allIds.length > 0) {
|
||||
const garmentRes = await fetch(`/api/garments?childId=${childId}&status=active`);
|
||||
const garmentData = await garmentRes.json();
|
||||
const map: Record<string, GarmentThumb> = {};
|
||||
for (const g of garmentData.items || []) {
|
||||
map[g.id] = { id: g.id, thumbUrl: g.thumbUrl, name: g.name, category: g.category };
|
||||
}
|
||||
setThumbs(map);
|
||||
}
|
||||
} catch {}
|
||||
setLoading(false);
|
||||
})();
|
||||
}, [childId]);
|
||||
|
||||
const deleteOutfit = async (id: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/garments/outfits/${id}`, { method: "DELETE" });
|
||||
if (res.ok) setOutfits(prev => prev.filter(o => o.id !== id));
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-indigo-50 via-purple-50 to-pink-50 dark:from-gray-900 dark:to-gray-800 pb-24">
|
||||
<div className="flex items-center gap-3 p-4">
|
||||
<button onClick={() => router.back()} className="p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-xl">←</button>
|
||||
<h1 className="text-xl font-bold">💾 Saved Outfits</h1>
|
||||
<Link href="/wardrobe/outfit" className="ml-auto text-sm px-3 py-1.5 bg-rose-400 text-white rounded-xl font-medium shadow-sm">
|
||||
+ New
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<div className="flex gap-1">
|
||||
{[0, 150, 300].map(d => (
|
||||
<span key={d} className="w-2.5 h-2.5 bg-rose-400 rounded-full animate-bounce" style={{ animationDelay: `${d}ms` }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : outfits.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 px-8 text-center">
|
||||
<span className="text-5xl mb-4">👗</span>
|
||||
<p className="font-semibold text-gray-600 dark:text-gray-300">No saved outfits yet</p>
|
||||
<p className="text-sm text-gray-400 mt-1">Save combinations from the outfit suggestion screen</p>
|
||||
<Link href="/wardrobe/outfit" className="mt-4 px-5 py-2 bg-rose-400 text-white rounded-xl text-sm font-medium">
|
||||
Get suggestions
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mx-4 space-y-3">
|
||||
{outfits.map(outfit => (
|
||||
<div key={outfit.id} className="bg-white dark:bg-gray-800 rounded-2xl shadow-sm p-4">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<p className="font-semibold text-gray-700 dark:text-gray-200">{outfit.name}</p>
|
||||
{outfit.occasion_tags?.length > 0 && (
|
||||
<div className="flex gap-1 mt-1">
|
||||
{outfit.occasion_tags.map(t => (
|
||||
<span key={t} className="text-xs px-2 py-0.5 bg-purple-100 text-purple-700 dark:bg-purple-900/20 dark:text-purple-300 rounded-full">
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => deleteOutfit(outfit.id)}
|
||||
className="text-gray-300 hover:text-red-400 text-lg p-1 transition-colors"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{outfit.garment_ids.slice(0, 4).map(gid => {
|
||||
const g = thumbs[gid];
|
||||
if (!g) return (
|
||||
<div key={gid} className="w-16 h-16 rounded-xl bg-gray-100 dark:bg-gray-700 flex items-center justify-center text-gray-300 text-xl">
|
||||
👗
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div key={gid} className="flex flex-col items-center gap-1">
|
||||
<div className="w-16 h-16 rounded-xl overflow-hidden">
|
||||
<img src={g.thumbUrl} alt={g.name || g.category} className="w-full h-full object-cover" loading="lazy" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{outfit.garment_ids.length > 4 && (
|
||||
<div className="w-16 h-16 rounded-xl bg-gray-100 dark:bg-gray-700 flex items-center justify-center text-sm text-gray-500">
|
||||
+{outfit.garment_ids.length - 4}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-400 mt-2">
|
||||
{new Date(outfit.created_at).toLocaleDateString("en-IN", { day: "numeric", month: "short", year: "numeric" })}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
568
src/app/(marketing)/about/page.tsx
Normal file
|
|
@ -0,0 +1,568 @@
|
|||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { AboutScrollReveal } from "@/components/marketing/AboutScrollReveal";
|
||||
|
||||
// Fonts (Fraunces, Newsreader, JetBrains Mono) are now loaded globally
|
||||
// in (marketing)/layout.tsx and available via CSS variables.
|
||||
|
||||
// ── Page-level styles (design tokens + letter layout) ─────────────
|
||||
const CSS = `
|
||||
.tia-letter {
|
||||
--paper: #f7f1e6;
|
||||
--surface: #fbf6ea;
|
||||
--ink: #1f1b16;
|
||||
--ink-soft: #5a5048;
|
||||
--ink-faint: #8a7e6f;
|
||||
--rule: #ddd2bc;
|
||||
--rule-soft: #e8ded0;
|
||||
--accent: #f43f5e; /* rose-500 — site primary */
|
||||
--accent-2: #e11d48; /* rose-600 — emphasis */
|
||||
--fd: var(--font-fraunces, Georgia, serif);
|
||||
--fb: var(--font-newsreader, Georgia, serif);
|
||||
--fl: var(--font-jetbrains, ui-monospace, monospace);
|
||||
--fh: var(--font-caveat, cursive);
|
||||
--dw: 400;
|
||||
--ds: -0.02em;
|
||||
--r: 18px;
|
||||
--measure: 39rem;
|
||||
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-family: var(--fb);
|
||||
font-size: 19px;
|
||||
line-height: 1.75;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* subtle paper grain */
|
||||
.tia-letter::after {
|
||||
content: '';
|
||||
position: fixed; inset: 0; pointer-events: none; z-index: 1;
|
||||
background-image: repeating-linear-gradient(0deg,rgba(31,27,22,.016) 0 1px,transparent 1px 3px);
|
||||
}
|
||||
|
||||
/* ── hero ── */
|
||||
.tia-hero {
|
||||
padding: clamp(52px,10vw,104px) clamp(20px,5vw,56px) clamp(8px,2vw,24px);
|
||||
text-align: center;
|
||||
position: relative; z-index: 2;
|
||||
background: linear-gradient(135deg, #fff1f2 0%, #fffbeb 50%, #fff1f2 100%);
|
||||
}
|
||||
.tia-eyebrow {
|
||||
display: inline-flex; align-items: center; gap: 10px;
|
||||
font-family: var(--fl); font-size: 11px; letter-spacing: .22em;
|
||||
text-transform: uppercase; color: var(--accent); margin: 0 0 22px;
|
||||
}
|
||||
.tia-eyebrow::before,.tia-eyebrow::after {
|
||||
content: ''; width: 24px; height: 1px; background: var(--accent);
|
||||
}
|
||||
.tia-hero h1 {
|
||||
font-family: var(--fd); font-weight: var(--dw);
|
||||
font-size: clamp(2.7rem,7vw,4.6rem); line-height: 1.02;
|
||||
letter-spacing: var(--ds); margin: 0; color: var(--ink);
|
||||
font-style: italic; text-wrap: balance;
|
||||
}
|
||||
.tia-hero h1 .hand {
|
||||
font-family: var(--fh); color: var(--accent-2);
|
||||
font-weight: 600; font-size: 1.04em; font-style: normal;
|
||||
}
|
||||
|
||||
/* ── letter body ── */
|
||||
.tia-body {
|
||||
position: relative; z-index: 2;
|
||||
max-width: 1120px; margin: 0 auto;
|
||||
padding: clamp(48px,9vw,96px) clamp(20px,5vw,56px) 0;
|
||||
}
|
||||
.tia-col {
|
||||
max-width: var(--measure); margin: 0 auto; display: flow-root;
|
||||
}
|
||||
.tia-col p { margin: 0 0 1em; }
|
||||
.tia-col p:last-child { margin-bottom: 0; }
|
||||
.tia-col strong { font-weight: 600; color: var(--ink); }
|
||||
.tia-col em { font-style: italic; }
|
||||
|
||||
/* drop cap */
|
||||
.tia-dropcap::first-letter {
|
||||
font-family: var(--fd); font-weight: 600; float: left;
|
||||
font-size: 4.6em; line-height: .78; padding: 8px 14px 0 0;
|
||||
color: var(--accent-2); font-style: italic;
|
||||
}
|
||||
|
||||
/* display highlight line */
|
||||
.tia-hl {
|
||||
font-family: var(--fd); font-weight: var(--dw);
|
||||
font-size: clamp(1.4rem,2.8vw,1.78rem); line-height: 1.28;
|
||||
letter-spacing: var(--ds); color: var(--accent-2);
|
||||
margin: 1em 0 0; font-style: italic;
|
||||
}
|
||||
|
||||
/* pull quotes */
|
||||
.tia-pull {
|
||||
max-width: var(--measure); margin: 2em auto;
|
||||
font-family: var(--fd); font-weight: var(--dw);
|
||||
font-size: clamp(1.7rem,3.6vw,2.5rem); line-height: 1.16;
|
||||
letter-spacing: var(--ds); color: var(--ink);
|
||||
text-wrap: balance; padding-left: 28px;
|
||||
border-left: 2px solid var(--accent); font-style: italic;
|
||||
}
|
||||
.tia-pull.accent { color: var(--accent-2); }
|
||||
.tia-pull.center {
|
||||
text-align: center; padding-left: 0; border-left: 0;
|
||||
border-top: 1px solid var(--rule); border-bottom: 1px solid var(--rule);
|
||||
padding: 1.1em 0;
|
||||
}
|
||||
|
||||
/* quiet sub-headings */
|
||||
.tia-subhead {
|
||||
max-width: var(--measure); margin: 2.4em auto .85em;
|
||||
padding-top: 1em; border-top: 1px solid var(--rule-soft);
|
||||
font-family: var(--fd); font-weight: var(--dw);
|
||||
font-size: clamp(1.45rem,3vw,1.95rem); line-height: 1.14;
|
||||
letter-spacing: var(--ds); color: var(--ink); font-style: italic;
|
||||
}
|
||||
|
||||
/* floated photo frame */
|
||||
.tia-float {
|
||||
float: right; width: 250px; margin: 4px 0 18px 34px;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.tia-float { float: none; width: min(290px,80vw); margin: 0 auto 22px; display: block; }
|
||||
}
|
||||
.tia-frame {
|
||||
position: relative; background: var(--surface);
|
||||
padding: 15px 15px 18px; border: 1px solid var(--rule);
|
||||
box-shadow: 0 22px 50px -20px rgba(31,27,22,.34), 0 2px 6px rgba(31,27,22,.08);
|
||||
transform: rotate(-1.6deg);
|
||||
}
|
||||
.tia-frame img { display: block; width: 100%; height: auto; }
|
||||
.tia-tape {
|
||||
position: absolute; top: -12px; left: 50%;
|
||||
transform: translateX(-50%) rotate(-3deg);
|
||||
width: 120px; height: 26px;
|
||||
background: rgba(201,138,43,.6); opacity: .8;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.1);
|
||||
}
|
||||
.tia-float figcaption {
|
||||
font-family: var(--fh); text-align: center;
|
||||
margin-top: 10px; font-size: 1.2rem; color: var(--ink-soft);
|
||||
}
|
||||
|
||||
/* callout card */
|
||||
.tia-callout {
|
||||
max-width: var(--measure); margin: 2.4em auto;
|
||||
background: var(--surface); border: 1px solid var(--rule);
|
||||
border-radius: var(--r); padding: clamp(24px,4vw,38px);
|
||||
position: relative;
|
||||
}
|
||||
.tia-callout::before {
|
||||
content: '“'; position: absolute; top: -.18em; left: .22em;
|
||||
font-family: var(--fd); font-style: italic; font-size: 5rem;
|
||||
color: rgba(201,138,43,.4); line-height: 1;
|
||||
}
|
||||
.tia-callout p {
|
||||
font-size: 1.12rem; color: var(--ink-soft);
|
||||
margin: 0 0 .8em; padding-left: 1.6em;
|
||||
}
|
||||
.tia-callout p:last-child { margin-bottom: 0; }
|
||||
.tia-callout .place {
|
||||
color: var(--accent); font-weight: 600;
|
||||
font-family: var(--fh); font-size: 1.3em;
|
||||
}
|
||||
|
||||
/* creed list — same cream paper as rest of letter */
|
||||
.tia-creed {
|
||||
list-style: none; max-width: var(--measure);
|
||||
margin: 1.7em auto; padding: 0;
|
||||
border-top: 1px solid var(--rule);
|
||||
}
|
||||
.tia-creed li {
|
||||
display: flex; align-items: baseline; gap: 16px;
|
||||
padding: 14px 2px; border-bottom: 1px solid var(--rule-soft);
|
||||
}
|
||||
.tia-creed .n {
|
||||
font-family: var(--fl); font-size: 11px; letter-spacing: .12em;
|
||||
color: var(--accent); min-width: 24px;
|
||||
}
|
||||
.tia-creed .t {
|
||||
font-family: var(--fb); font-size: 1.12rem; line-height: 1.5; color: var(--ink);
|
||||
}
|
||||
/* ── closing / signature ── */
|
||||
.tia-closing {
|
||||
max-width: var(--measure); margin: 2.6em auto 0; text-align: center;
|
||||
font-family: var(--fd); font-weight: var(--dw);
|
||||
font-size: clamp(1.5rem,3.2vw,2.1rem); line-height: 1.22;
|
||||
letter-spacing: var(--ds); color: var(--accent-2); font-style: italic;
|
||||
}
|
||||
.tia-divider {
|
||||
max-width: var(--measure); margin: 2.6em auto; text-align: center;
|
||||
line-height: 1; color: var(--accent); font-size: 1.1rem;
|
||||
}
|
||||
.tia-sign { max-width: var(--measure); margin: 0 auto; }
|
||||
.tia-sign .thanks { color: var(--ink-soft); margin: 0 0 1.4em; }
|
||||
.tia-sign .welcome {
|
||||
font-family: var(--fd); font-weight: var(--dw);
|
||||
font-size: clamp(1.5rem,3vw,2rem); letter-spacing: var(--ds);
|
||||
margin: 0 0 .2em; color: var(--ink);
|
||||
}
|
||||
.tia-sign .warmth { color: var(--ink-faint); margin: 0 0 2em; }
|
||||
.tia-sign-row { display: flex; align-items: center; gap: 22px; }
|
||||
.tia-sign-row .names {
|
||||
font-family: var(--fh); font-size: 2.1rem; line-height: 1; color: var(--ink); margin: 0;
|
||||
}
|
||||
.tia-sign-row .role {
|
||||
font-family: var(--fl); font-size: 11px; letter-spacing: .12em;
|
||||
text-transform: uppercase; color: var(--ink-faint); margin: 8px 0 0;
|
||||
}
|
||||
|
||||
/* P.S. box */
|
||||
.tia-ps {
|
||||
max-width: var(--measure); margin: 2.6em auto 0; display: flow-root;
|
||||
background: rgba(244,63,94,.05); border: 1px solid var(--rule);
|
||||
border-radius: var(--r); padding: 24px 26px;
|
||||
font-style: italic; color: var(--ink-soft); font-size: 1.04rem; line-height: 1.62;
|
||||
}
|
||||
.tia-ps-photo {
|
||||
float: left; width: 78px; height: 78px; border-radius: 50%;
|
||||
object-fit: cover; object-position: center 15%;
|
||||
margin: 2px 20px 8px 0;
|
||||
border: 2px solid var(--rule);
|
||||
shape-outside: circle(52%);
|
||||
box-shadow: 0 6px 16px -8px rgba(31,27,22,.4);
|
||||
}
|
||||
.tia-ps b {
|
||||
font-style: normal; font-family: var(--fl); font-size: 11px;
|
||||
letter-spacing: .14em; text-transform: uppercase;
|
||||
color: var(--accent); margin-right: 6px; font-weight: 500;
|
||||
}
|
||||
|
||||
/* CTA */
|
||||
.tia-cta {
|
||||
text-align: center; max-width: var(--measure);
|
||||
margin: clamp(56px,9vw,96px) auto 0;
|
||||
padding-bottom: clamp(56px,9vw,96px);
|
||||
}
|
||||
.tia-cta .ck {
|
||||
font-family: var(--fl); font-size: 11px; letter-spacing: .2em;
|
||||
text-transform: uppercase; color: var(--ink-faint); margin: 0 0 18px;
|
||||
}
|
||||
.tia-cta h3 {
|
||||
font-family: var(--fd); font-weight: var(--dw);
|
||||
font-size: clamp(1.7rem,4vw,2.4rem); letter-spacing: var(--ds);
|
||||
margin: 0 0 28px; color: var(--ink); font-style: italic; text-wrap: balance;
|
||||
}
|
||||
.tia-cta .big {
|
||||
display: inline-flex; align-items: center; gap: 12px;
|
||||
background: #f43f5e; color: #ffffff; border: 0;
|
||||
font-family: var(--fb); font-weight: 500; font-size: 1.05rem;
|
||||
padding: 16px 32px; border-radius: var(--r); text-decoration: none;
|
||||
box-shadow: 0 12px 30px -12px rgba(244,63,94,.4);
|
||||
transition: transform .2s, background .2s;
|
||||
}
|
||||
.tia-cta .big:hover { transform: translateY(-2px); background: #e11d48; }
|
||||
.tia-cta .fine { font-size: .85rem; color: var(--ink-faint); margin: 16px 0 0; }
|
||||
|
||||
/* text helpers */
|
||||
.tia-soft { color: var(--ink-soft); }
|
||||
.tia-strong { font-weight: 600; color: var(--ink); }
|
||||
|
||||
/* scroll reveal */
|
||||
.tia-reveal {
|
||||
transition: opacity .8s cubic-bezier(.2,.7,.2,1), transform .8s cubic-bezier(.2,.7,.2,1);
|
||||
}
|
||||
.tia-reveal.tia-pre { opacity: 0; transform: translateY(18px); }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tia-reveal.tia-pre { opacity: 1 !important; transform: none !important; }
|
||||
}
|
||||
`;
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "About Tia",
|
||||
description: "A letter to every new parent — the story of why Tia was built, named after our daughter, and made for families like yours.",
|
||||
alternates: { canonical: "/about" },
|
||||
openGraph: {
|
||||
type: "website",
|
||||
url: "/about",
|
||||
title: "About Tia — A Letter to Every New Parent",
|
||||
description: "Built by parents. Inspired by our daughter. Made for families.",
|
||||
},
|
||||
};
|
||||
|
||||
const CREED = [
|
||||
"Our vaccination schedule follows the IAP chart.",
|
||||
"We don't sell your data.",
|
||||
"We don't run ads.",
|
||||
"We don't build engagement loops.",
|
||||
"We don't treat your child as a product.",
|
||||
"We build to protect memories, not compete for attention.",
|
||||
];
|
||||
|
||||
export default function AboutPage() {
|
||||
return (
|
||||
<div>
|
||||
{/* Page-specific design system — inline so it's scoped and SSR-safe */}
|
||||
{/* eslint-disable-next-line react/no-danger */}
|
||||
<style dangerouslySetInnerHTML={{ __html: CSS }} />
|
||||
|
||||
{/* Scroll reveal — client-only behaviour, renders nothing to DOM */}
|
||||
<AboutScrollReveal />
|
||||
|
||||
<div className="tia-letter">
|
||||
|
||||
{/* ── HERO: title only — suspense preserved ── */}
|
||||
<header className="tia-hero">
|
||||
<p className="tia-eyebrow tia-reveal">Our story</p>
|
||||
<h1 className="tia-reveal">
|
||||
A letter to every<br />
|
||||
<span className="hand">new parent</span>
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
{/* ══ LETTER BODY ══════════════════════════════════════════ */}
|
||||
<article className="tia-body" id="story">
|
||||
|
||||
{/* Opening — drop cap + floated photo */}
|
||||
<div className="tia-col">
|
||||
<p className="tia-dropcap tia-reveal">
|
||||
When we held our daughter for the first time, we felt a rush of love we never knew
|
||||
we were capable of — and alongside it, a fear we hadn't expected.
|
||||
</p>
|
||||
|
||||
{/* Family illustration — floats right, text wraps */}
|
||||
<figure className="tia-float tia-reveal">
|
||||
<div className="tia-frame">
|
||||
<span className="tia-tape" />
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src="/images/family-illustration.jpg" alt="Yashika and Manohar holding baby Tia" />
|
||||
</div>
|
||||
<figcaption>the three of us — 2026</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="tia-hl tia-reveal">Everything felt fragile. Everything felt new.</p>
|
||||
|
||||
<p className="tia-reveal">
|
||||
In those early days, we wanted to capture every yawn, every gurgle, every tiny
|
||||
hand curling around our fingers. But the apps we tried felt cold and clinical — as
|
||||
though they had been built by people who had never stayed awake at 3 a.m.
|
||||
wondering whether their baby's cough sounded different from yesterday.
|
||||
</p>
|
||||
<p className="tia-reveal">
|
||||
When our daughter, Tia, was born, we made a silent promise: we would remember
|
||||
everything.
|
||||
</p>
|
||||
<p className="tia-strong tia-reveal">We meant it completely.</p>
|
||||
<p className="tia-reveal">
|
||||
And within weeks, details were already slipping away — not the big milestones, but
|
||||
the small things. The exact weight on day five. The way she stretched when she woke
|
||||
up. The moment she first looked at us as though she knew who we were.
|
||||
</p>
|
||||
<p className="tia-soft tia-reveal">
|
||||
Those tiny details felt irreplaceable, and yet they were already beginning to fade.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="tia-pull accent tia-reveal">
|
||||
That realisation became the seed for everything Tia is today.
|
||||
</p>
|
||||
|
||||
{/* ── Why we named it ── */}
|
||||
<h2 className="tia-subhead tia-reveal">Why we named the app Tia</h2>
|
||||
<div className="tia-col">
|
||||
<p className="tia-reveal">
|
||||
We named this app after our daughter, Tia. Not because we wanted a monument to
|
||||
her. Not because we wanted her name attached to a product.
|
||||
</p>
|
||||
<p className="tia-reveal">
|
||||
We named it after her because the love we have for our daughter is the same care
|
||||
we bring to building this app every single day.
|
||||
</p>
|
||||
<p className="tia-reveal">
|
||||
When we choose privacy over profit, patience over speed, and thoughtful design
|
||||
over endless notifications, we are building Tia the way we hope to raise our
|
||||
daughter — with care, responsibility, and respect.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="tia-pull accent tia-reveal">
|
||||
This app is not a trophy. It is not a brand badge.
|
||||
</p>
|
||||
|
||||
<div className="tia-col">
|
||||
<p className="tia-reveal">
|
||||
It is simply an extension of what matters most to us. We built Tia with the belief
|
||||
that the earliest years of a child's life deserve extraordinary care. Every
|
||||
decision we make comes from the same understanding:{" "}
|
||||
<strong>small things matter.</strong>
|
||||
</p>
|
||||
<p className="tia-strong tia-reveal">
|
||||
Because one day, those small things become the memories we treasure most.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Modern families ── */}
|
||||
<h2 className="tia-subhead tia-reveal">A note on modern families</h2>
|
||||
<div className="tia-col">
|
||||
<p className="tia-reveal">
|
||||
Like many new parents today, we are raising our daughter in a nuclear family. And
|
||||
with that comes a particular kind of loneliness that few people talk about.
|
||||
</p>
|
||||
<p className="tia-reveal">
|
||||
The grandparents who once lived down the street may now live in another city. The
|
||||
relatives whose advice once arrived over a cup of tea now appear through video
|
||||
calls. The village that helped raise children for generations often feels farther
|
||||
away than ever. The hands that could help are now often on the other side of a
|
||||
screen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="tia-pull center accent tia-reveal">
|
||||
Tia was born from that reality, too.
|
||||
</p>
|
||||
|
||||
<div className="tia-callout tia-reveal">
|
||||
<p>
|
||||
Tia is built so that{" "}
|
||||
<span className="place">Nani in Lucknow</span> can see today's moments
|
||||
without you handing over your phone.
|
||||
</p>
|
||||
<p>
|
||||
So that{" "}
|
||||
<span className="place">Dadu in Jaipur</span> can be reminded of the vaccination
|
||||
schedule without you sending a WhatsApp message at 11 PM.
|
||||
</p>
|
||||
<p>
|
||||
So that the aunties — even when they are in another city — can still feel like a
|
||||
part of the story.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="tia-col">
|
||||
<p className="tia-strong tia-reveal">
|
||||
Because raising a child was never meant to be done alone.
|
||||
</p>
|
||||
<p className="tia-reveal">
|
||||
In a small way, we hope Tia helps bring that village back — digitally, gently,
|
||||
and with respect for the way modern Indian families actually live.
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{/* ══ WHAT WE BELIEVE — same cream paper, continuous letter ═ */}
|
||||
<article className="tia-body" id="believe">
|
||||
<h2 className="tia-subhead tia-reveal">What we believe</h2>
|
||||
|
||||
<p className="tia-pull accent tia-reveal">
|
||||
The first years of your child's life are not a metric to optimise.
|
||||
They are irreplaceable.
|
||||
</p>
|
||||
|
||||
<div className="tia-col">
|
||||
<p className="tia-reveal">
|
||||
No app can give you back a missed moment. No notification can replace being
|
||||
present when your baby discovers their hands, says a first word, or falls asleep
|
||||
on your shoulder.
|
||||
</p>
|
||||
<p className="tia-strong tia-reveal">
|
||||
Your job is to be present. Ours is to make the rest a little easier.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul className="tia-creed tia-reveal">
|
||||
{CREED.map((item, i) => (
|
||||
<li key={i}>
|
||||
<span className="n">0{i + 1}</span>
|
||||
<span className="t">{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="tia-col">
|
||||
<p className="tia-strong tia-reveal">
|
||||
Every choice we make is guided by the same principle: your family's story
|
||||
belongs to your family.
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{/* ══ TO THE PARENT + SIGNATURE ════════════════════════════ */}
|
||||
<article className="tia-body">
|
||||
<h2 className="tia-subhead tia-reveal">To the parent reading this</h2>
|
||||
<div className="tia-col">
|
||||
<p className="tia-reveal">
|
||||
You are in the middle of something extraordinary.{" "}
|
||||
<em>Exhausting. Overwhelming. Beautiful.</em>
|
||||
</p>
|
||||
<p className="tia-reveal">
|
||||
You will never have this week again. Or this month. Or this exact version of your
|
||||
child — so small, so curious, and so completely dependent on you.
|
||||
</p>
|
||||
<p className="tia-reveal">Tia is not here to track your baby.</p>
|
||||
</div>
|
||||
|
||||
<p className="tia-pull accent tia-reveal">
|
||||
Tia is here to help you remember your baby — and to hand that memory to them
|
||||
someday, complete, authentic, and full of love.
|
||||
</p>
|
||||
|
||||
<div className="tia-col">
|
||||
<p className="tia-reveal">
|
||||
And one day, when your child asks,{" "}
|
||||
<em>“What was I like when I was little?”</em> — we hope you'll
|
||||
have an answer richer than memory alone.
|
||||
</p>
|
||||
<p className="tia-soft tia-reveal">
|
||||
Photos. Stories. Milestones. Tiny moments that would otherwise have faded with
|
||||
time.
|
||||
</p>
|
||||
<p className="tia-strong tia-reveal">
|
||||
A digital heirloom, built with love and preserved with care.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Final reveal — authorship */}
|
||||
<p className="tia-closing tia-reveal">
|
||||
Built by parents. Inspired by our daughter. Made for families like yours.
|
||||
</p>
|
||||
<div className="tia-divider tia-reveal">✦</div>
|
||||
|
||||
{/* Signature */}
|
||||
<div className="tia-sign tia-reveal">
|
||||
<p className="thanks">Thank you for trusting us with your family's story.</p>
|
||||
<p className="welcome">Welcome to the Tia family.</p>
|
||||
<p className="warmth">With warmth,</p>
|
||||
<div className="tia-sign-row">
|
||||
<div>
|
||||
<p className="names">Yashika & Manohar</p>
|
||||
<p className="role">Co-founders, Tia · Gurugram</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* P.S. — Tia's portrait floated left */}
|
||||
<div className="tia-ps tia-reveal">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
className="tia-ps-photo"
|
||||
src="/images/tia-portrait.png"
|
||||
alt="Baby Tia"
|
||||
/>
|
||||
<b>P.S.</b>{" "}
|
||||
…and Tia — currently eight months old, trying very hard to eat her own feet, and
|
||||
having absolutely no idea that we named this app after her. One day she'll
|
||||
find out. We can't wait to see her face.
|
||||
</div>
|
||||
|
||||
{/* CTA */}
|
||||
<div className="tia-cta tia-reveal" id="join">
|
||||
<p className="ck">Join the families already building their heirloom</p>
|
||||
<h3>Start your child's story today.</h3>
|
||||
<Link className="big" href="/login">
|
||||
Get started — it's free →
|
||||
</Link>
|
||||
<p className="fine">Free during early access. No credit card.</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
src/app/(marketing)/blog/[slug]/opengraph-image.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { ImageResponse } from "next/og";
|
||||
import { getPost, POSTS } from "../posts";
|
||||
|
||||
export const alt = "Tia Blog";
|
||||
export const size = { width: 1200, height: 630 };
|
||||
export const contentType = "image/png";
|
||||
|
||||
// Pre-render an OG image for every known post.
|
||||
export function generateStaticParams() {
|
||||
return POSTS.map((p) => ({ slug: p.slug }));
|
||||
}
|
||||
|
||||
export default async function BlogOgImage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const post = getPost(slug);
|
||||
|
||||
const title = post?.title ?? "The Tia Blog";
|
||||
const category = post?.category ?? "Journal";
|
||||
const emoji = post?.emoji ?? "🌸";
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(135deg, #fdf2f2 0%, #fef3c7 50%, #fdf2f2 100%)",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "space-between",
|
||||
fontFamily: "sans-serif",
|
||||
padding: 80,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
|
||||
<span style={{ fontSize: 40 }}>🌸</span>
|
||||
<span style={{ fontSize: 34, fontWeight: 800, color: "#111827" }}>
|
||||
Tia
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
marginLeft: 12,
|
||||
fontSize: 22,
|
||||
fontWeight: 600,
|
||||
color: "#f43f5e",
|
||||
background: "#ffe4e6",
|
||||
padding: "6px 18px",
|
||||
borderRadius: 999,
|
||||
}}
|
||||
>
|
||||
{category}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column" }}>
|
||||
<div style={{ fontSize: 64, marginBottom: 16 }}>{emoji}</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 56,
|
||||
fontWeight: 800,
|
||||
color: "#111827",
|
||||
lineHeight: 1.15,
|
||||
maxWidth: 1000,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 24, color: "#6b7280" }}>
|
||||
tia.manohargupta.com/blog
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{ ...size }
|
||||
);
|
||||
}
|
||||
334
src/app/(marketing)/blog/[slug]/page.tsx
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { POSTS, getPost, formatDate } from "../posts";
|
||||
import { Breadcrumb } from "@/components/marketing/Breadcrumb";
|
||||
import {
|
||||
blogPostingSchema,
|
||||
breadcrumbSchema,
|
||||
jsonLdScript,
|
||||
} from "@/lib/seo";
|
||||
|
||||
// Turn a heading string into a URL-safe anchor ID
|
||||
function slugifyHeading(heading: string): string {
|
||||
return heading
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return POSTS.map((p) => ({ slug: p.slug }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const post = getPost(slug);
|
||||
if (!post) return {};
|
||||
const url = `/blog/${post.slug}`;
|
||||
return {
|
||||
title: post.title,
|
||||
description: post.excerpt,
|
||||
alternates: { canonical: url },
|
||||
openGraph: {
|
||||
type: "article",
|
||||
url,
|
||||
siteName: "Tia",
|
||||
locale: "en_IN",
|
||||
title: post.title,
|
||||
description: post.excerpt,
|
||||
publishedTime: post.date,
|
||||
modifiedTime: post.date,
|
||||
authors: [post.author],
|
||||
section: post.category,
|
||||
// Uses the per-post opengraph-image.tsx in this segment.
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: post.title,
|
||||
description: post.excerpt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function BlogPostPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const post = getPost(slug);
|
||||
if (!post) notFound();
|
||||
|
||||
// Extract headings for TOC
|
||||
const headings = post.sections.filter((s) => s.heading).map((s) => ({
|
||||
text: s.heading!,
|
||||
id: slugifyHeading(s.heading!),
|
||||
}));
|
||||
|
||||
// Other posts (exclude current)
|
||||
const otherPosts = POSTS.filter((p) => p.slug !== slug).slice(0, 3);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
{/* Structured data — BlogPosting + breadcrumb trail */}
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={jsonLdScript([
|
||||
blogPostingSchema(post),
|
||||
breadcrumbSchema([
|
||||
{ name: "Home", path: "/" },
|
||||
{ name: "Blog", path: "/blog" },
|
||||
{ name: post.title, path: `/blog/${post.slug}` },
|
||||
]),
|
||||
])}
|
||||
/>
|
||||
{/* Post hero / header */}
|
||||
<div className="bg-gradient-to-br from-rose-50 to-pink-50 border-b border-rose-100">
|
||||
<div className="max-w-6xl mx-auto px-5 pt-10 pb-10">
|
||||
<div className="max-w-2xl">
|
||||
<div className="flex flex-wrap items-center gap-2 mb-5">
|
||||
<span className={`text-xs font-semibold px-2.5 py-0.5 rounded-full ${post.categoryColor}`}>
|
||||
{post.category}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">{formatDate(post.date)}</span>
|
||||
<span className="text-xs text-gray-400">·</span>
|
||||
<span className="text-xs text-gray-400">{post.readTime}</span>
|
||||
</div>
|
||||
|
||||
<h1 className="font-fraunces italic text-3xl sm:text-4xl font-bold text-gray-900 leading-tight mb-4">
|
||||
{post.emoji} {post.title}
|
||||
</h1>
|
||||
<p className="font-newsreader text-lg text-gray-500 leading-relaxed">{post.excerpt}</p>
|
||||
|
||||
<div className="mt-5 text-sm text-gray-400">
|
||||
By <span className="text-gray-600 font-medium">{post.author}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Breadcrumb — between hero and content */}
|
||||
<div className="max-w-6xl mx-auto px-5 pt-5 pb-1">
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{ label: "Home", href: "/" },
|
||||
{ label: "Blog", href: "/blog" },
|
||||
{ label: post.title },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 3-column body */}
|
||||
<div className="max-w-6xl mx-auto px-5 py-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[220px_minmax(0,1fr)_200px] gap-10">
|
||||
|
||||
{/* ── LEFT SIDEBAR: Table of Contents ── */}
|
||||
<aside className="hidden lg:block">
|
||||
<div className="sticky top-24">
|
||||
<p className="font-jetbrains text-xs font-bold text-gray-400 uppercase tracking-widest mb-4">In this article</p>
|
||||
{headings.length > 0 ? (
|
||||
<ol className="flex flex-col gap-2">
|
||||
{headings.map((h, i) => (
|
||||
<li key={i}>
|
||||
<a
|
||||
href={`#${h.id}`}
|
||||
className="group flex items-start gap-2 text-sm text-gray-500 hover:text-rose-600 transition-colors duration-150"
|
||||
>
|
||||
<span className="shrink-0 mt-0.5 w-4 h-4 rounded-full bg-rose-100 text-rose-500 text-[10px] flex items-center justify-center font-bold group-hover:bg-rose-200 transition-colors duration-150">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="leading-snug">{h.text}</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
) : (
|
||||
<p className="text-xs text-gray-400">No sections</p>
|
||||
)}
|
||||
|
||||
<div className="mt-8 pt-6 border-t border-gray-100">
|
||||
<Link
|
||||
href="/blog"
|
||||
className="flex items-center gap-1.5 text-sm text-rose-500 hover:text-rose-700 font-medium transition-colors"
|
||||
>
|
||||
← All articles
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* ── CENTER: Article body ── */}
|
||||
<article className="min-w-0">
|
||||
<div className="space-y-10">
|
||||
{post.sections.map((section, i) => (
|
||||
<div key={i}>
|
||||
{section.heading && (
|
||||
<h2
|
||||
id={slugifyHeading(section.heading)}
|
||||
className="font-fraunces text-xl font-bold text-gray-900 mb-4 mt-2 scroll-mt-28"
|
||||
>
|
||||
{section.heading}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
{section.paragraphs?.map((p, j) => (
|
||||
<p key={j} className="font-newsreader text-gray-600 leading-relaxed mb-4">
|
||||
{p}
|
||||
</p>
|
||||
))}
|
||||
|
||||
{section.table && (
|
||||
<div className="overflow-x-auto mb-4 rounded-xl border border-gray-100">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-rose-50">
|
||||
{section.table.headers.map((h, j) => (
|
||||
<th
|
||||
key={j}
|
||||
className="text-left px-4 py-3 font-semibold text-gray-700 border-b border-rose-100"
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{section.table.rows.map((row, j) => (
|
||||
<tr key={j} className="border-b border-gray-50 last:border-0 hover:bg-gray-50">
|
||||
{row.map((cell, k) => (
|
||||
<td key={k} className="px-4 py-3 text-gray-600">
|
||||
{cell}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section.list && (
|
||||
<ul className="space-y-2.5 mb-4">
|
||||
{section.list.map((item, j) => (
|
||||
<li key={j} className="flex items-start gap-3 text-sm text-gray-600">
|
||||
<span className="mt-1.5 w-1.5 h-1.5 rounded-full bg-rose-400 shrink-0" />
|
||||
<span>
|
||||
{item.label && (
|
||||
<span className="font-semibold text-gray-800">{item.label}: </span>
|
||||
)}
|
||||
{item.text}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{section.callout && (
|
||||
<div className="bg-rose-50 border border-rose-100 rounded-xl p-5 flex gap-3 items-start mb-4">
|
||||
<span className="text-xl shrink-0 mt-0.5">{section.callout.emoji}</span>
|
||||
<p className="text-sm text-rose-800 leading-relaxed">{section.callout.text}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Article footer CTA */}
|
||||
<div className="mt-14 pt-10 border-t border-gray-100">
|
||||
<div className="bg-gradient-to-br from-rose-50 to-pink-50 rounded-2xl p-8 text-center border border-rose-100">
|
||||
<div className="text-3xl mb-3">🌸</div>
|
||||
<h3 className="font-fraunces text-xl font-bold text-gray-900 mb-2">Try Tia — free during early access</h3>
|
||||
<p className="text-gray-500 text-sm mb-6 max-w-sm mx-auto">
|
||||
Log feeds, track vaccinations, and build a digital heirloom for your child. Built for Indian families.
|
||||
</p>
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-flex items-center gap-2 bg-rose-500 hover:bg-rose-600 text-white text-sm font-semibold px-7 py-3 rounded-full transition-colors duration-200"
|
||||
>
|
||||
Get started →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Back link — visible on mobile too */}
|
||||
<div className="mt-8 flex items-center justify-between">
|
||||
<Link
|
||||
href="/blog"
|
||||
className="inline-flex items-center gap-1.5 text-sm font-medium text-rose-600 hover:text-rose-700 transition-colors"
|
||||
>
|
||||
← All articles
|
||||
</Link>
|
||||
<span className="text-xs text-gray-400">
|
||||
{formatDate(post.date)} · {post.readTime}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{/* ── RIGHT SIDEBAR: More articles + categories ── */}
|
||||
<aside className="hidden lg:block">
|
||||
<div className="sticky top-24 space-y-8">
|
||||
|
||||
{/* More articles */}
|
||||
<div>
|
||||
<p className="font-jetbrains text-xs font-bold text-gray-400 uppercase tracking-widest mb-4">More articles</p>
|
||||
<div className="flex flex-col gap-4">
|
||||
{otherPosts.map((p) => (
|
||||
<Link
|
||||
key={p.slug}
|
||||
href={`/blog/${p.slug}`}
|
||||
className="group flex items-start gap-2.5"
|
||||
>
|
||||
<span className="text-lg shrink-0 mt-0.5">{p.emoji}</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-gray-700 group-hover:text-rose-600 leading-snug font-medium transition-colors duration-150 line-clamp-2">
|
||||
{p.title}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">{p.readTime}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-100" />
|
||||
|
||||
{/* Category badge */}
|
||||
<div>
|
||||
<p className="font-jetbrains text-xs font-bold text-gray-400 uppercase tracking-widest mb-3">Filed under</p>
|
||||
<span className={`text-xs font-semibold px-2.5 py-1 rounded-full ${post.categoryColor}`}>
|
||||
{post.category}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-100" />
|
||||
|
||||
{/* CTA */}
|
||||
<div className="bg-rose-50 rounded-xl p-5 border border-rose-100 text-center">
|
||||
<div className="text-2xl mb-2">🌸</div>
|
||||
<p className="text-sm font-semibold text-gray-800 mb-1">Free early access</p>
|
||||
<p className="text-xs text-gray-500 mb-4 leading-relaxed">
|
||||
Built for Indian families. No ads, no data selling.
|
||||
</p>
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-block w-full text-center bg-rose-500 hover:bg-rose-600 text-white text-xs font-semibold px-4 py-2.5 rounded-full transition-colors duration-200"
|
||||
>
|
||||
Get started →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
221
src/app/(marketing)/blog/page.tsx
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { POSTS, formatDate } from "./posts";
|
||||
import { Breadcrumb } from "@/components/marketing/Breadcrumb";
|
||||
import {
|
||||
SITE_NAME,
|
||||
absoluteUrl,
|
||||
blogPostingSchema,
|
||||
breadcrumbSchema,
|
||||
jsonLdScript,
|
||||
} from "@/lib/seo";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Blog — Baby Feeding, Health & Vaccination Guides",
|
||||
description:
|
||||
"Guides on baby feeding, health milestones, vaccination schedules, and how to make the most of Tia — written for Indian families.",
|
||||
alternates: { canonical: "/blog" },
|
||||
openGraph: {
|
||||
type: "website",
|
||||
url: "/blog",
|
||||
siteName: SITE_NAME,
|
||||
locale: "en_IN",
|
||||
title: `Blog | ${SITE_NAME}`,
|
||||
description:
|
||||
"Guides on baby feeding, health milestones, vaccination schedules, and how to make the most of Tia — written for Indian families.",
|
||||
},
|
||||
};
|
||||
|
||||
// Blog (with each post) + breadcrumb structured data.
|
||||
const blogSchema = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Blog",
|
||||
"@id": `${absoluteUrl("/blog")}#blog`,
|
||||
name: `${SITE_NAME} Blog`,
|
||||
url: absoluteUrl("/blog"),
|
||||
inLanguage: "en-IN",
|
||||
blogPost: POSTS.map((p) => blogPostingSchema(p)),
|
||||
};
|
||||
|
||||
const blogBreadcrumb = breadcrumbSchema([
|
||||
{ name: "Home", path: "/" },
|
||||
{ name: "Blog", path: "/blog" },
|
||||
]);
|
||||
|
||||
// Derive unique categories + counts from posts
|
||||
const CATEGORIES = Object.values(
|
||||
POSTS.reduce<Record<string, { name: string; color: string; count: number }>>((acc, p) => {
|
||||
if (!acc[p.category]) {
|
||||
acc[p.category] = { name: p.category, color: p.categoryColor, count: 0 };
|
||||
}
|
||||
acc[p.category].count++;
|
||||
return acc;
|
||||
}, {})
|
||||
);
|
||||
|
||||
export default function BlogPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={jsonLdScript([blogSchema, blogBreadcrumb])}
|
||||
/>
|
||||
{/* Page header */}
|
||||
<div className="bg-gradient-to-br from-rose-50 to-pink-50 border-b border-rose-100">
|
||||
<div className="max-w-6xl mx-auto px-5 py-12 text-center">
|
||||
<div className="font-jetbrains mb-2 text-sm font-medium text-rose-500 uppercase tracking-widest">Journal</div>
|
||||
<h1 className="font-fraunces italic text-4xl font-bold text-gray-900 mb-3 leading-tight">The Tia Blog</h1>
|
||||
<p className="text-base text-gray-500 leading-relaxed max-w-xl mx-auto">
|
||||
Practical guides on baby feeding, health, vaccination schedules, and getting the most out of Tia — for Indian families.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Breadcrumb — sits right above the content grid */}
|
||||
<div className="max-w-6xl mx-auto px-5 pt-5 pb-1">
|
||||
<Breadcrumb items={[{ label: "Home", href: "/" }, { label: "Blog" }]} />
|
||||
</div>
|
||||
|
||||
{/* 3-column layout */}
|
||||
<div className="max-w-6xl mx-auto px-5 py-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[220px_minmax(0,1fr)_200px] gap-10">
|
||||
|
||||
{/* ── LEFT SIDEBAR: timeline ── */}
|
||||
<aside className="hidden lg:block">
|
||||
<div className="sticky top-24">
|
||||
<p className="font-jetbrains text-xs font-bold text-gray-400 uppercase tracking-widest mb-5">All posts</p>
|
||||
<ol className="relative border-l-2 border-rose-100 pl-4 space-y-5">
|
||||
{POSTS.map((p) => (
|
||||
<li key={p.slug} className="group relative">
|
||||
{/* Timeline dot */}
|
||||
<span className="absolute -left-[21px] top-1.5 w-2.5 h-2.5 rounded-full bg-rose-200 border-2 border-white group-hover:bg-rose-400 transition-colors duration-150" />
|
||||
|
||||
<p className="text-xs text-gray-400 mb-0.5">
|
||||
{new Date(p.date).toLocaleDateString("en-IN", { day: "numeric", month: "short", year: "numeric" })}
|
||||
</p>
|
||||
<Link
|
||||
href={`/blog/${p.slug}`}
|
||||
className="text-sm font-medium text-gray-700 hover:text-rose-600 transition-colors duration-150 leading-snug line-clamp-2 block"
|
||||
>
|
||||
{p.title}
|
||||
</Link>
|
||||
<span className={`mt-1.5 text-xs px-2 py-0.5 rounded-full inline-block ${p.categoryColor}`}>
|
||||
{p.category}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* ── CENTER: post cards ── */}
|
||||
<main>
|
||||
<div className="flex flex-col gap-6">
|
||||
{POSTS.map((post) => (
|
||||
<article
|
||||
key={post.slug}
|
||||
className="group border border-gray-100 rounded-2xl p-6 hover:border-rose-200 hover:shadow-md transition-all duration-200 bg-white"
|
||||
>
|
||||
<Link href={`/blog/${post.slug}`} className="block">
|
||||
<div className="flex items-start gap-5">
|
||||
{/* Emoji icon */}
|
||||
<div className="hidden sm:flex shrink-0 w-14 h-14 rounded-xl bg-rose-50 items-center justify-center text-2xl border border-rose-100 group-hover:bg-rose-100 transition-colors duration-200">
|
||||
{post.emoji}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Meta */}
|
||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||
<span className={`text-xs font-semibold px-2.5 py-0.5 rounded-full ${post.categoryColor}`}>
|
||||
{post.category}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">{formatDate(post.date)}</span>
|
||||
<span className="text-xs text-gray-400">·</span>
|
||||
<span className="text-xs text-gray-400">{post.readTime}</span>
|
||||
</div>
|
||||
|
||||
<h2 className="font-fraunces text-lg font-bold text-gray-900 leading-snug mb-2 group-hover:text-rose-700 transition-colors duration-150">
|
||||
{post.title}
|
||||
</h2>
|
||||
<p className="text-gray-500 text-sm leading-relaxed line-clamp-2">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 text-sm font-semibold text-rose-500 group-hover:text-rose-600 transition-colors duration-150">
|
||||
Read article →
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* ── RIGHT SIDEBAR: categories + CTA ── */}
|
||||
<aside className="hidden lg:block">
|
||||
<div className="sticky top-24 space-y-8">
|
||||
|
||||
{/* Categories */}
|
||||
<div>
|
||||
<p className="font-jetbrains text-xs font-bold text-gray-400 uppercase tracking-widest mb-4">Browse by topic</p>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{CATEGORIES.map((cat) => (
|
||||
<div key={cat.name} className="flex items-center justify-between">
|
||||
<span className={`text-xs font-semibold px-2.5 py-1 rounded-full ${cat.color}`}>
|
||||
{cat.name}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 font-medium">{cat.count} {cat.count === 1 ? "post" : "posts"}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="border-t border-gray-100" />
|
||||
|
||||
{/* Quick links */}
|
||||
<div>
|
||||
<p className="font-jetbrains text-xs font-bold text-gray-400 uppercase tracking-widest mb-4">Quick reads</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
{POSTS.slice(0, 3).map((p) => (
|
||||
<Link
|
||||
key={p.slug}
|
||||
href={`/blog/${p.slug}`}
|
||||
className="group/ql flex items-start gap-2"
|
||||
>
|
||||
<span className="text-base mt-0.5 shrink-0">{p.emoji}</span>
|
||||
<span className="text-xs text-gray-600 group-hover/ql:text-rose-600 leading-snug transition-colors duration-150 line-clamp-2">
|
||||
{p.title}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="border-t border-gray-100" />
|
||||
|
||||
{/* CTA */}
|
||||
<div className="bg-rose-50 rounded-xl p-5 border border-rose-100 text-center">
|
||||
<div className="text-2xl mb-2">🌸</div>
|
||||
<p className="text-sm font-semibold text-gray-800 mb-1">Free early access</p>
|
||||
<p className="text-xs text-gray-500 mb-4 leading-relaxed">
|
||||
Built for Indian families. No ads, no data selling.
|
||||
</p>
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-block w-full text-center bg-rose-500 hover:bg-rose-600 text-white text-xs font-semibold px-4 py-2.5 rounded-full transition-colors duration-200"
|
||||
>
|
||||
Get started →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
406
src/app/(marketing)/blog/posts.ts
Normal file
|
|
@ -0,0 +1,406 @@
|
|||
export interface BlogSection {
|
||||
heading?: string;
|
||||
paragraphs?: string[];
|
||||
list?: { label?: string; text: string }[];
|
||||
callout?: { emoji: string; text: string };
|
||||
table?: { headers: string[]; rows: string[][] };
|
||||
}
|
||||
|
||||
export interface BlogPost {
|
||||
slug: string;
|
||||
title: string;
|
||||
excerpt: string;
|
||||
date: string;
|
||||
author: string;
|
||||
category: string;
|
||||
categoryColor: string;
|
||||
readTime: string;
|
||||
emoji: string;
|
||||
sections: BlogSection[];
|
||||
}
|
||||
|
||||
export const POSTS: BlogPost[] = [
|
||||
{
|
||||
slug: "baby-feed-cycle-by-age",
|
||||
title: "Baby Feed Cycle by Age: A Complete Guide for Indian Parents",
|
||||
excerpt:
|
||||
"From colostrum in the first hour to family meals at twelve months — a stage-by-stage breakdown of what, when, and how much to feed your baby in the first year.",
|
||||
date: "2026-05-15",
|
||||
author: "Tia Team",
|
||||
category: "Feeding",
|
||||
categoryColor: "bg-amber-100 text-amber-700",
|
||||
readTime: "7 min read",
|
||||
emoji: "🍼",
|
||||
sections: [
|
||||
{
|
||||
paragraphs: [
|
||||
"Feeding a newborn is one of the most frequent tasks you'll perform as a new parent — and one of the most confusing. Every baby is different, and advice can vary wildly between your paediatrician, your mother-in-law, and the internet. This guide cuts through the noise with a clear, age-by-age breakdown based on IAP (Indian Academy of Pediatrics) recommendations.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "0–4 Weeks: Demand Feeding Around the Clock",
|
||||
paragraphs: [
|
||||
"Newborns have stomachs roughly the size of a marble. They can hold only a few millilitres at a time, which is exactly why they feed so frequently — 8 to 12 times in 24 hours is completely normal.",
|
||||
"Breast milk is the gold standard. The IAP strongly recommends exclusive breastfeeding for the first six months. In the first few days, your body produces colostrum — a thick, yellowish fluid packed with antibodies. It's low in volume but high in everything your baby needs right now.",
|
||||
],
|
||||
callout: {
|
||||
emoji: "📌",
|
||||
text: "Tip: Wake your baby to feed if more than 4 hours have passed in the first two weeks. After that, feed on demand.",
|
||||
},
|
||||
list: [
|
||||
{ label: "Frequency", text: "Every 2–3 hours (8–12 feeds/day)" },
|
||||
{ label: "Duration", text: "10–20 minutes per breast" },
|
||||
{ label: "Signs of hunger", text: "Rooting, sucking fist, turning head side-to-side" },
|
||||
{ label: "Signs of fullness", text: "Releasing the nipple, relaxed hands, sleepy" },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "1–3 Months: Settling into a Rhythm",
|
||||
paragraphs: [
|
||||
"By six weeks, most babies start stretching their feeds slightly — you might see 3–4 hour gaps during the day. Night feeds are still very much on the table; expecting a full night's sleep at this age is unrealistic.",
|
||||
"If you're formula feeding, your baby will typically take 90–120 ml per feed, about 6–8 times a day. Always follow the formula preparation instructions exactly — over- or under-diluting causes real harm.",
|
||||
],
|
||||
list: [
|
||||
{ label: "Breast milk", text: "On demand, roughly every 3 hours" },
|
||||
{ label: "Formula", text: "90–120 ml, 6–8 times/day" },
|
||||
{ label: "Night feeds", text: "Still expected — 1 to 3 per night is normal" },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "4–6 Months: The Solid-Food Window Opens",
|
||||
paragraphs: [
|
||||
"The IAP recommends starting complementary foods at exactly 6 months — not earlier. Some babies show interest slightly before, but introducing solids too early increases the risk of allergies, digestive issues, and a reduction in breast milk supply.",
|
||||
"Signs your baby is ready: can sit with support, shows interest in your food, has lost the tongue-thrust reflex that pushes food back out.",
|
||||
],
|
||||
callout: {
|
||||
emoji: "⚠️",
|
||||
text: "Do NOT start solids before 6 months. Contrary to popular belief in India, rice water, dal water, or diluted cow's milk are not appropriate first foods and can cause nutritional deficiencies.",
|
||||
},
|
||||
list: [
|
||||
{ label: "Milk feeds", text: "Breast milk / formula remains primary nutrition" },
|
||||
{ label: "Start solids at 6m", text: "Begin with single-ingredient purées — rice, moong dal, banana, sweet potato" },
|
||||
{ label: "Frequency", text: "1 meal/day alongside regular milk feeds" },
|
||||
{ label: "Texture", text: "Smooth purée, no lumps, no salt, no sugar, no honey" },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "6–9 Months: Building Food Variety",
|
||||
paragraphs: [
|
||||
"Now the real exploration begins. Introduce one new food every 3–4 days so you can spot allergic reactions. Offer vegetables before fruit — early exposure to bitter tastes builds acceptance for a wider range of foods.",
|
||||
"Move from purées to mashed textures. Khichdi, suji halwa (without sugar), soft-cooked vegetables, and curd are excellent first Indian foods. Avoid salt, sugar, honey, and whole cow's milk as a drink (though it's fine in cooking).",
|
||||
],
|
||||
list: [
|
||||
{ label: "Meals", text: "2–3 times/day + breast milk / formula" },
|
||||
{ label: "Portion", text: "2–4 tablespoons per meal, increasing gradually" },
|
||||
{ label: "Texture", text: "Mashed, soft lumps" },
|
||||
{ label: "Good first foods", text: "Khichdi, ragi porridge, soft dal, banana, curd" },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "9–12 Months: Approaching Family Food",
|
||||
paragraphs: [
|
||||
"By nine months, many babies can manage soft finger foods and are curious about what's on your plate. Encourage self-feeding — yes, it's messy, but it builds motor skills and a healthy relationship with food.",
|
||||
"Breast milk or formula continues alongside 3 meals and 1–2 snacks. The goal is not to replace milk yet, but to steadily increase the variety and texture of solids. By twelve months, your baby should be eating a modified version of most family foods — just without the salt, spice, and whole nuts.",
|
||||
],
|
||||
list: [
|
||||
{ label: "Meals", text: "3 meals + 1–2 snacks" },
|
||||
{ label: "Milk", text: "Breast milk or follow-on formula, 3–4 times/day" },
|
||||
{ label: "Finger foods", text: "Soft chapati pieces, banana chunks, steamed carrot, paneer cubes" },
|
||||
{ label: "Avoid", text: "Honey, whole nuts, added salt, added sugar, cow's milk as a drink" },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "How Tia Helps You Track All of This",
|
||||
paragraphs: [
|
||||
"Logging every feed manually in a notebook is exhausting. Tia's Quick Log lets you record a feed in under three seconds — tap the type, confirm the time, done. Over days and weeks, Tia shows you patterns: when hunger peaks, how night feeding is trending, whether solid intake is growing.",
|
||||
"And if you ever have a question — \"Is three feeds at night normal at 8 months?\" or \"My baby rejected every vegetable I tried, what do I do?\" — Ask Tia is there. It answers parenting and feeding questions thoughtfully, and will always tell you when something needs a real paediatrician instead of an app.",
|
||||
],
|
||||
callout: {
|
||||
emoji: "🌸",
|
||||
text: "Join Tia's early access — free during this period — and start building your baby's complete feeding history today.",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "baby-health-warning-signs-first-year",
|
||||
title: "Your Baby's Health in Year One: Signs That Need Immediate Attention",
|
||||
excerpt:
|
||||
"What's normal newborn behaviour, what's a watch-and-wait situation, and what needs a call to the paediatrician right now — a parent's guide to the first twelve months.",
|
||||
date: "2026-05-08",
|
||||
author: "Tia Team",
|
||||
category: "Health",
|
||||
categoryColor: "bg-rose-100 text-rose-700",
|
||||
readTime: "8 min read",
|
||||
emoji: "🩺",
|
||||
sections: [
|
||||
{
|
||||
paragraphs: [
|
||||
"Nothing creates anxiety quite like a sick infant — especially in the first weeks when everything feels fragile and uncertain. This guide is not a replacement for your paediatrician. It's a map: the kind of knowledge that helps you stay calm, act quickly when it counts, and avoid unnecessary panic the rest of the time.",
|
||||
"The IAP recommends a schedule of well-baby visits in the first year. Make sure you attend all of them — they're not just for vaccinations. Your paediatrician uses these visits to track growth, development, and to answer the questions that pile up between appointments.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Fever: The Most Common Alarm",
|
||||
paragraphs: [
|
||||
"A fever is not a disease — it's a sign that your baby's immune system is working. But the response to fever depends entirely on how old your baby is.",
|
||||
],
|
||||
table: {
|
||||
headers: ["Age", "Temperature Threshold", "What to Do"],
|
||||
rows: [
|
||||
["Under 3 months", "38°C (100.4°F) or above", "Emergency — call your doctor immediately or go to hospital"],
|
||||
["3–6 months", "38.5°C (101.3°F) or above", "Call your paediatrician same day"],
|
||||
["6–12 months", "39°C (102.2°F) or above, or lasting 2+ days", "Call your paediatrician"],
|
||||
],
|
||||
},
|
||||
callout: {
|
||||
emoji: "⚠️",
|
||||
text: "In newborns under 3 months, never wait and watch with a fever. A temperature of 38°C or above is a medical emergency regardless of how well your baby looks.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Dehydration: Harder to Spot Than You Think",
|
||||
paragraphs: [
|
||||
"Babies lose fluids quickly during illness. Dehydration can turn serious within hours, especially in summer or if your baby has diarrhoea and vomiting simultaneously.",
|
||||
],
|
||||
list: [
|
||||
{ label: "Early signs", text: "Fewer wet nappies (fewer than 4 in 24 hours), dry mouth, no tears when crying" },
|
||||
{ label: "Moderate signs", text: "Sunken fontanelle (soft spot on head), sunken eyes, skin that tents when pinched" },
|
||||
{ label: "Severe signs", text: "No urine for 8+ hours, very sunken fontanelle, extreme lethargy — go to hospital" },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Breathing: When to Act Immediately",
|
||||
paragraphs: [
|
||||
"Occasional sneezes, snuffles, and irregular breathing during sleep are normal in newborns. What's not normal:",
|
||||
],
|
||||
list: [
|
||||
{ text: "Nostrils flaring with every breath" },
|
||||
{ text: "Skin pulling in between ribs or below the chest (retractions)" },
|
||||
{ text: "Breathing rate above 60 breaths per minute (for infants under 2 months)" },
|
||||
{ text: "A grunt with every breath" },
|
||||
{ text: "Blue tinge around the lips or fingernails (cyanosis) — call emergency services immediately" },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Jaundice in Newborns",
|
||||
paragraphs: [
|
||||
"A yellowish tint to the skin and whites of the eyes is jaundice — caused by bilirubin accumulating in the blood. Mild jaundice is extremely common (over 60% of newborns) and usually resolves on its own within 2 weeks.",
|
||||
"What needs attention: jaundice appearing in the first 24 hours of life, jaundice lasting more than 3 weeks, or a baby who is very yellow, difficult to wake, or feeding poorly alongside jaundice.",
|
||||
],
|
||||
callout: {
|
||||
emoji: "☀️",
|
||||
text: "Traditional sunbathing advice for jaundice is outdated. Modern guidance from the IAP recommends phototherapy in a clinical setting for significant jaundice — home sunlight is not sufficient and UV exposure carries its own risks.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Growth: The Bigger Picture",
|
||||
paragraphs: [
|
||||
"Babies should roughly double their birth weight by 5 months and triple it by 12 months. A single weight measurement tells you little — it's the trend that matters. Tia's growth chart plots your baby's weight, height, and head circumference against the IAP reference curves, flagging percentiles that warrant a conversation with your doctor.",
|
||||
"Slow weight gain can have many causes — a latch problem, illness, or occasionally something that needs investigation. Don't try to self-diagnose from a chart; bring the data to your paediatrician.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "The IAP Vaccination Schedule",
|
||||
paragraphs: [
|
||||
"Vaccines are one of the most powerful tools we have for protecting your baby in the first year. The IAP schedule includes vaccines at birth, 6 weeks, 10 weeks, 14 weeks, 6 months, 9 months, and 12 months — missing or delaying them leaves a real gap in protection.",
|
||||
"Tia tracks the complete IAP schedule and sends you alerts on Telegram before each due date. You can mark vaccines as given and keep a permanent record that travels with your family.",
|
||||
],
|
||||
callout: {
|
||||
emoji: "💉",
|
||||
text: "Never skip or delay vaccines based on mild illness or a mild fever. The IAP guidance: vaccination is safe with mild illness. Significant fever or acute illness is the only reason to postpone.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "A Rule of Thumb for New Parents",
|
||||
paragraphs: [
|
||||
"When in doubt, call your paediatrician. There is no such thing as a silly question in the first year. A good paediatrician would rather you ring once too often than miss something important.",
|
||||
"Tia's Ask Tia feature is designed to answer parenting and logistics questions — sleep patterns, feeding concerns, developmental milestones. For any symptom question — fever, rash, breathing, pain — Ask Tia will always direct you to your paediatrician rather than try to diagnose. We built it that way on purpose.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "getting-started-with-tia",
|
||||
title: "Getting Started with Tia: Your First Week",
|
||||
excerpt:
|
||||
"A friendly walkthrough for new families — from creating your account to logging your first feed, setting up IAP vaccination alerts, and inviting your partner.",
|
||||
date: "2026-04-28",
|
||||
author: "Tia Team",
|
||||
category: "App Guide",
|
||||
categoryColor: "bg-violet-100 text-violet-700",
|
||||
readTime: "5 min read",
|
||||
emoji: "🌸",
|
||||
sections: [
|
||||
{
|
||||
paragraphs: [
|
||||
"Tia is designed to get out of your way. You shouldn't be spending more than a few seconds on a log when you have a baby in your arms. This guide takes you through the key things to set up in your first week — after that, the app is mostly just quick taps.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Step 1: Creating Your Family",
|
||||
paragraphs: [
|
||||
"Sign in with Google — that's it, no password to create or remember. On your first login you'll be prompted to set up your family: give it a name (most families use their last name or a nickname), and add your baby's name, date of birth, and a photo if you like.",
|
||||
"Tia supports multiple children, so if you have more than one baby, you can add them all and switch between their records instantly.",
|
||||
],
|
||||
callout: {
|
||||
emoji: "🔒",
|
||||
text: "Your family data is stored privately. We use row-level security — your records are invisible to other families and to us. We don't sell your data and we don't run ads.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Step 2: Logging Your First Entry",
|
||||
paragraphs: [
|
||||
"The Quick Log button is the first thing you see on the home screen. Tap it, pick the activity type (Feed · Sleep · Diaper · Medicine · Note), fill in the details, and tap Save. That's a log.",
|
||||
"For feeds, you can record breast milk (left, right, or both), formula (amount in ml), or solids. For sleep, tap Start when your baby goes down and End when they wake — or log it after the fact. Tia calculates the duration automatically.",
|
||||
],
|
||||
list: [
|
||||
{ label: "Feed", text: "Breastfeed (side + duration) or formula (amount ml) or solids" },
|
||||
{ label: "Sleep", text: "Live timer or manual start/end time" },
|
||||
{ label: "Diaper", text: "Wet, dirty, or both — with optional notes" },
|
||||
{ label: "Medicine", text: "Name, dose, time — and Tia tracks the next due window" },
|
||||
{ label: "Note", text: "Free-form text for anything that doesn't fit a category" },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Step 3: Setting Up Vaccination Alerts",
|
||||
paragraphs: [
|
||||
"Go to Medical → Vaccinations. Tia pre-loads the complete IAP vaccination schedule based on your baby's date of birth. You'll see every upcoming vaccine with its due date and a description.",
|
||||
"To get Telegram reminders before each due date, tap 'Set up Telegram alerts' and follow the two-step flow: start a chat with our Telegram bot and send the code it gives you. That's it — you'll get a message before each upcoming vaccine so you can book the appointment in time.",
|
||||
],
|
||||
callout: {
|
||||
emoji: "📲",
|
||||
text: "Telegram alerts are completely optional. You can also just check the Vaccinations page manually — it shows upcoming, due, and completed vaccines in a clear timeline.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Step 4: Inviting Your Partner or a Caregiver",
|
||||
paragraphs: [
|
||||
"Go to Settings → Family Members → Invite. Enter the email address of the person you're inviting and choose their role:",
|
||||
],
|
||||
list: [
|
||||
{ label: "Admin", text: "Full access — can add/edit/delete all records and manage family settings" },
|
||||
{ label: "Caregiver", text: "Can log and view all records, but can't change family settings" },
|
||||
{ label: "Viewer", text: "Read-only — great for grandparents who want to follow along" },
|
||||
],
|
||||
},
|
||||
{
|
||||
paragraphs: [
|
||||
"They'll receive an email with a link. Once they sign in, they're added to your family and can see all your shared records instantly — on their own phone, their own login, no passwords to share.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Step 5: Ask Tia — Your Parenting Companion",
|
||||
paragraphs: [
|
||||
"The AI chat (tap the ✨ icon in the bottom nav) is trained specifically for parenting questions — sleep schedules, feeding concerns, developmental milestones, age-appropriate activities. It knows your baby's age and recent logs, so you can ask things like 'Is she sleeping enough for her age?' and get a contextual answer.",
|
||||
"What Ask Tia won't do: diagnose symptoms, recommend medications, or tell you whether a fever is serious. For any health concern, it will always direct you to your paediatrician. We designed it this way deliberately — an app that plays doctor is dangerous. We'd rather be honest about our limits.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Step 6: Building Memories",
|
||||
paragraphs: [
|
||||
"The Memories section is where Tia becomes something more than a tracker. Upload photos and videos, write captions, and they're organised automatically by your baby's age. A 3-month photo, a first-smile video, a first-steps moment — all in one place, privately stored, and yours to export whenever you want.",
|
||||
"This is the archive your child will one day be able to look back through. Take two minutes every few days to add something. You'll be glad you did.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "telegram-vaccination-alerts-feature",
|
||||
title: "Introducing Telegram Vaccination Alerts: Never Miss a Jab",
|
||||
excerpt:
|
||||
"We've connected Tia's IAP vaccination schedule to Telegram so you get a reminder before every due date — even when you haven't opened the app in weeks.",
|
||||
date: "2026-04-15",
|
||||
author: "Tia Team",
|
||||
category: "Feature",
|
||||
categoryColor: "bg-sky-100 text-sky-700",
|
||||
readTime: "4 min read",
|
||||
emoji: "💉",
|
||||
sections: [
|
||||
{
|
||||
paragraphs: [
|
||||
"Missed vaccinations are one of the most common and preventable gaps in infant health care. It's not neglect — it's the chaos of early parenthood. Between feeds, sleep deprivation, and everything else, a vaccine due date in three weeks feels like a lifetime away. Until suddenly it's overdue.",
|
||||
"We built Telegram alerts to solve this problem simply and reliably.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Why Telegram?",
|
||||
paragraphs: [
|
||||
"We looked at several notification options: email, push notifications, SMS. Each had real problems for this use case.",
|
||||
"Email often gets buried. Push notifications only work if you have the app installed and haven't muted it. SMS costs money and has formatting limitations.",
|
||||
"Telegram messages arrive reliably, render beautifully on every phone, and — critically — don't require an active app install to receive. If you've set up the connection, you'll get the reminder even if you've uninstalled Tia, switched phones, or just haven't opened the app in a month. That reliability is the whole point.",
|
||||
],
|
||||
callout: {
|
||||
emoji: "🔒",
|
||||
text: "We only use your Telegram connection to send vaccination reminders. No marketing, no promotions, no third-party sharing. You can disconnect at any time from Settings.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "What the IAP Schedule Covers",
|
||||
paragraphs: [
|
||||
"The Indian Academy of Pediatrics (IAP) releases an updated vaccination schedule each year. Tia's current schedule covers all vaccines from birth through 12 months, with plans to extend to 5 years.",
|
||||
],
|
||||
list: [
|
||||
{ label: "Birth", text: "BCG, Hepatitis B (1st dose), OPV-0" },
|
||||
{ label: "6 weeks", text: "DTwP/DTaP, IPV, Hepatitis B (2nd), Hib, Rotavirus, PCV (1st)" },
|
||||
{ label: "10 weeks", text: "DTwP/DTaP, IPV, Hib, Rotavirus, PCV (2nd)" },
|
||||
{ label: "14 weeks", text: "DTwP/DTaP, IPV, Hib, Rotavirus, PCV (3rd)" },
|
||||
{ label: "6 months", text: "Hepatitis B (3rd), OPV" },
|
||||
{ label: "9 months", text: "MMR (1st), OPV" },
|
||||
{ label: "12 months", text: "Hepatitis A (1st), PCV booster, Varicella (1st)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
paragraphs: [
|
||||
"Tia calculates exact due dates based on your baby's date of birth and sends you a Telegram reminder 7 days before each vaccine is due. You can mark vaccines as given directly from the app, and the record is permanently saved to your family's history.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "How to Set It Up",
|
||||
paragraphs: [
|
||||
"It takes about two minutes:",
|
||||
],
|
||||
list: [
|
||||
{ label: "1", text: "Open Tia → Medical → Vaccinations" },
|
||||
{ label: "2", text: "Tap 'Set up Telegram alerts'" },
|
||||
{ label: "3", text: "Open Telegram and start a chat with @TiaBabyBot" },
|
||||
{ label: "4", text: "Send the 6-digit code shown in the app" },
|
||||
{ label: "5", text: "Done — you'll get a confirmation message immediately" },
|
||||
],
|
||||
callout: {
|
||||
emoji: "✅",
|
||||
text: "Already missed a vaccine? No problem. Tia shows overdue vaccines clearly and lets you log catch-up doses with the actual date administered.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "What's Coming Next",
|
||||
paragraphs: [
|
||||
"Telegram alerts are just the beginning. We're working on:",
|
||||
],
|
||||
list: [
|
||||
{ text: "Alerts for medicines — automatic reminders when the next dose window opens" },
|
||||
{ text: "Growth check reminders — a nudge every 4 weeks to log a new measurement" },
|
||||
{ text: "Monthly milestone prompts — age-appropriate development questions to log" },
|
||||
{ text: "WhatsApp as an alternative notification channel" },
|
||||
],
|
||||
},
|
||||
{
|
||||
paragraphs: [
|
||||
"Early access families shape what we build next. If there's a reminder you'd like that isn't listed here, use the feedback button in Settings — we read every message.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function getPost(slug: string): BlogPost | undefined {
|
||||
return POSTS.find((p) => p.slug === slug);
|
||||
}
|
||||
|
||||
export function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString("en-IN", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
130
src/app/(marketing)/layout.tsx
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Fraunces, Newsreader, JetBrains_Mono } from "next/font/google";
|
||||
import { MarketingNav } from "@/components/marketing/MarketingNav";
|
||||
|
||||
// ── Editorial fonts — loaded once for all marketing pages ─────────
|
||||
const fraunces = Fraunces({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-fraunces",
|
||||
weight: ["300", "400", "500", "600"],
|
||||
style: ["normal", "italic"],
|
||||
display: "swap",
|
||||
});
|
||||
const newsreader = Newsreader({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-newsreader",
|
||||
weight: ["300", "400", "500", "600"],
|
||||
style: ["normal", "italic"],
|
||||
display: "swap",
|
||||
});
|
||||
const jetbrainsMono = JetBrains_Mono({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-jetbrains",
|
||||
weight: ["400", "500"],
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: "Tia — Your baby's digital heirloom",
|
||||
template: "%s | Tia",
|
||||
},
|
||||
description:
|
||||
"Tia is a digital heirloom for your baby — not just a tracker. Log daily moments, track the IAP vaccination schedule, and build a living archive your child will one day inherit.",
|
||||
openGraph: {
|
||||
type: "website",
|
||||
siteName: "Tia",
|
||||
title: "Tia — Your baby's digital heirloom",
|
||||
description:
|
||||
"Log every feed, milestone, and memory. Built for Indian families. Privacy-first. Free during early access.",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "Tia — Your baby's digital heirloom",
|
||||
description:
|
||||
"Log every feed, milestone, and memory. Built for Indian families. Privacy-first. Free during early access.",
|
||||
},
|
||||
};
|
||||
|
||||
export default function MarketingLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={`${fraunces.variable} ${newsreader.variable} ${jetbrainsMono.variable}`}>
|
||||
<MarketingNav />
|
||||
|
||||
<main>{children}</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-gray-50 border-t border-gray-100 mt-20">
|
||||
<div className="max-w-5xl mx-auto px-5 py-12">
|
||||
<div className="flex flex-col lg:flex-row lg:justify-between gap-10 mb-8">
|
||||
|
||||
{/* Brand block */}
|
||||
<div className="lg:max-w-xs">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-2xl">🌸</span>
|
||||
<span className="text-xl font-bold text-gray-900" style={{ fontFamily: "var(--font-caveat)" }}>
|
||||
Tia
|
||||
</span>
|
||||
</div>
|
||||
<p className="font-newsreader text-sm text-gray-500 leading-relaxed">
|
||||
A digital heirloom for your baby.<br />Every moment, preserved — privately.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Three-column link group */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-8 lg:gap-14 text-sm">
|
||||
|
||||
{/* Company */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="font-fraunces font-semibold text-gray-700 mb-1">Company</p>
|
||||
<Link href="/about" className="font-fraunces font-normal text-gray-500 hover:text-rose-600 transition-colors duration-150">About</Link>
|
||||
<Link href="/blog" className="font-fraunces font-normal text-gray-500 hover:text-rose-600 transition-colors duration-150">Blog</Link>
|
||||
<Link href="/partners" className="font-fraunces font-normal text-gray-500 hover:text-rose-600 transition-colors duration-150">Partners</Link>
|
||||
</div>
|
||||
|
||||
{/* Legal */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="font-fraunces font-semibold text-gray-700 mb-1">Legal</p>
|
||||
<Link href="/pricing" className="font-fraunces font-normal text-gray-500 hover:text-rose-600 transition-colors duration-150">Pricing</Link>
|
||||
<Link href="/privacy" className="font-fraunces font-normal text-gray-500 hover:text-rose-600 transition-colors duration-150">Privacy Policy</Link>
|
||||
<Link href="/terms" className="font-fraunces font-normal text-gray-500 hover:text-rose-600 transition-colors duration-150">Terms of Service</Link>
|
||||
</div>
|
||||
|
||||
{/* Contact */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="font-fraunces font-semibold text-gray-700 mb-1">Contact</p>
|
||||
<a
|
||||
href="tel:+919554881799"
|
||||
className="font-fraunces font-normal text-gray-500 hover:text-rose-600 transition-colors duration-150"
|
||||
>
|
||||
+91 95548 81799
|
||||
</a>
|
||||
<a
|
||||
href="mailto:hello@tia.baby"
|
||||
className="font-fraunces font-normal text-gray-500 hover:text-rose-600 transition-colors duration-150"
|
||||
>
|
||||
hello@tia.baby
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom bar — different shade */}
|
||||
<div className="bg-gray-100 border-t border-gray-200">
|
||||
<div className="max-w-5xl mx-auto px-5 py-4 grid grid-cols-1 sm:grid-cols-3 items-center gap-1 text-xs text-gray-500 text-center sm:text-left">
|
||||
<p>© {new Date().getFullYear()} Tia.</p>
|
||||
<p className="sm:text-center">We don't sell your data — we preserve it.</p>
|
||||
<p className="sm:text-right">Built with <span className="inline-block transition-transform duration-300 hover:scale-150 cursor-default select-none">❤️</span> in India.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
src/app/(marketing)/opengraph-image.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { ImageResponse } from "next/og";
|
||||
|
||||
export const alt = "Tia — Your baby's digital heirloom";
|
||||
export const size = { width: 1200, height: 630 };
|
||||
export const contentType = "image/png";
|
||||
|
||||
export default function OgImage() {
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
background: "linear-gradient(135deg, #fdf2f2 0%, #fef3c7 50%, #fdf2f2 100%)",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontFamily: "sans-serif",
|
||||
padding: 80,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 72, marginBottom: 24 }}>🌸</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: 60,
|
||||
fontWeight: 800,
|
||||
color: "#111827",
|
||||
textAlign: "center",
|
||||
lineHeight: 1.2,
|
||||
marginBottom: 20,
|
||||
}}
|
||||
>
|
||||
Tia
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: 28,
|
||||
color: "#f43f5e",
|
||||
fontWeight: 600,
|
||||
textAlign: "center",
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
Your baby's digital heirloom
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: 22,
|
||||
color: "#6b7280",
|
||||
textAlign: "center",
|
||||
maxWidth: 800,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
Log every feed, milestone, and memory.
|
||||
Built for Indian families. Privacy-first. Free during early access.
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{ ...size }
|
||||
);
|
||||
}
|
||||
433
src/app/(marketing)/page.tsx
Normal file
|
|
@ -0,0 +1,433 @@
|
|||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { PhoneMockup } from "@/components/marketing/PhoneMockup";
|
||||
import {
|
||||
jsonLdScript,
|
||||
organizationSchema,
|
||||
websiteSchema,
|
||||
softwareApplicationSchema,
|
||||
} from "@/lib/seo";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Tia — Baby Tracker & Digital Heirloom for Indian Families",
|
||||
description:
|
||||
"Tia is a digital heirloom for your baby — not just a tracker. Log daily moments, track the IAP vaccination schedule with Telegram alerts, and build a living archive your child will one day inherit. Free during early access.",
|
||||
keywords: [
|
||||
"baby tracker",
|
||||
"baby tracker app India",
|
||||
"IAP vaccination schedule",
|
||||
"baby vaccination tracker",
|
||||
"newborn feed tracker",
|
||||
"baby milestone tracker",
|
||||
"digital baby book",
|
||||
"parenting app India",
|
||||
"baby memory keeper",
|
||||
],
|
||||
alternates: { canonical: "/" },
|
||||
openGraph: {
|
||||
type: "website",
|
||||
url: "/",
|
||||
siteName: "Tia",
|
||||
locale: "en_IN",
|
||||
title: "Tia — Baby Tracker & Digital Heirloom for Indian Families",
|
||||
description:
|
||||
"Log every feed, milestone, and memory. Track the IAP vaccination schedule with Telegram alerts. Built for Indian families. Privacy-first. Free during early access.",
|
||||
},
|
||||
};
|
||||
|
||||
// ── Google G icon (reusable) ────────────────────────────────────
|
||||
function GoogleG({ size = 20 }: { size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" className="flex-shrink-0">
|
||||
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
|
||||
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
|
||||
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z" fill="#FBBC05"/>
|
||||
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Section: Hero ───────────────────────────────────────────────
|
||||
function Hero() {
|
||||
return (
|
||||
<section className="relative overflow-hidden bg-gradient-to-br from-rose-50 via-amber-50 to-rose-50 pt-12 pb-20 px-5">
|
||||
<div className="max-w-5xl mx-auto grid grid-cols-1 lg:grid-cols-[1.1fr_0.9fr] gap-10 items-center">
|
||||
|
||||
{/* LEFT: copy */}
|
||||
<div className="text-center lg:text-left max-w-xl mx-auto lg:mx-0">
|
||||
<div className="inline-flex items-center gap-2 bg-rose-100 text-rose-700 text-xs font-semibold px-3 py-1.5 rounded-full mb-6">
|
||||
<span>✨</span> Free during early access
|
||||
</div>
|
||||
|
||||
<h1 className="font-fraunces italic text-4xl sm:text-5xl font-bold text-gray-900 leading-tight mb-5">
|
||||
Your baby's story,{" "}
|
||||
<span className="text-rose-500" style={{ fontFamily: "var(--font-caveat)", fontSize: "1.1em" }}>
|
||||
preserved for a lifetime.
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p className="font-newsreader text-xl text-gray-800 leading-relaxed mb-8 max-w-xl mx-auto lg:mx-0">
|
||||
Tia is a digital heirloom — not just a tracker. Every feed, every first
|
||||
word, every vaccination, archived in one private place your child will
|
||||
one day look back on.
|
||||
</p>
|
||||
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-flex items-center gap-2.5 bg-white hover:bg-rose-50 border border-gray-200 hover:border-rose-300 text-gray-700 font-semibold px-7 py-3.5 rounded-full text-base transition-all duration-200 shadow-md hover:shadow-xl hover:shadow-rose-200/60 hover:-translate-y-0.5 hover:scale-[1.03] active:scale-95 active:translate-y-0"
|
||||
>
|
||||
<GoogleG />
|
||||
Continue with Google
|
||||
</Link>
|
||||
|
||||
<p className="mt-4 text-xs text-gray-400">No credit card. No setup fee. Your data is yours.</p>
|
||||
</div>
|
||||
|
||||
{/* RIGHT: animated phone mockup */}
|
||||
<div className="flex justify-center lg:justify-end">
|
||||
<PhoneMockup />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Decorative blobs */}
|
||||
<div className="absolute -top-20 -right-20 w-64 h-64 bg-rose-100 rounded-full opacity-40 blur-3xl pointer-events-none" />
|
||||
<div className="absolute -bottom-16 -left-16 w-48 h-48 bg-amber-100 rounded-full opacity-50 blur-2xl pointer-events-none" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Section: The Problem ────────────────────────────────────────
|
||||
function TheProblem() {
|
||||
return (
|
||||
<section className="py-20 px-5 bg-white">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<p className="font-jetbrains text-xs font-semibold text-rose-500 uppercase tracking-widest mb-4">The 3am reality</p>
|
||||
|
||||
<h2 className="font-fraunces text-3xl font-bold text-gray-900 mb-6 leading-tight">
|
||||
You're awake at 3am.<br />
|
||||
When did she last feed?
|
||||
</h2>
|
||||
|
||||
<div className="font-newsreader space-y-4 text-gray-600 leading-relaxed">
|
||||
<p>
|
||||
You scroll back through WhatsApp messages to your mother-in-law. You check a
|
||||
sticky note on the refrigerator. You open a notes app you downloaded last week
|
||||
and another one you downloaded the week before that.
|
||||
</p>
|
||||
<p>
|
||||
Every precious detail — the exact weight at the six-week checkup, the tiny smile
|
||||
at eleven weeks, the first solid feed — is scattered across six apps, two
|
||||
notebooks, and a dozen photographs that may or may not be backed up.
|
||||
</p>
|
||||
<p>
|
||||
And somewhere underneath the exhaustion, a quieter fear: <em>I'm going to forget this.</em>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{[
|
||||
{ icon: "📱", text: "Notes scattered across 4+ apps" },
|
||||
{ icon: "😰", text: "Fear of losing precious moments" },
|
||||
{ icon: "🌙", text: "No answers at 3am" },
|
||||
].map(item => (
|
||||
<div key={item.text} className="flex items-start gap-3 bg-rose-50 rounded-xl p-4">
|
||||
<span className="text-2xl flex-shrink-0">{item.icon}</span>
|
||||
<p className="text-sm text-gray-700 font-medium">{item.text}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Section: Features ──────────────────────────────────────────
|
||||
const FEATURES = [
|
||||
{
|
||||
icon: "🍼",
|
||||
title: "Log in 5 seconds",
|
||||
body: "Feed, sleep, diaper — one tap. Tia timestamps everything automatically. At 3am, you should be sleeping, not typing.",
|
||||
example: "\"Fed 90ml at 2:47am\" → logged and archived forever.",
|
||||
},
|
||||
{
|
||||
icon: "💉",
|
||||
title: "IAP vaccination schedule",
|
||||
body: "Tia tracks your baby's vaccination schedule against the Indian Academy of Pediatrics (IAP) recommended schedule. Reminders arrive via Telegram — the one app you actually check.",
|
||||
example: "BCG, OPV, Hepatitis B — all tracked. Telegram alert: \"Pentavalent 2 due in 3 days.\"",
|
||||
},
|
||||
{
|
||||
icon: "🔮",
|
||||
title: "Ask Tia",
|
||||
body: "Ask Tia parenting logistics questions — feeding windows, sleep patterns, when to introduce solids. For anything medical, Tia defers to your pediatrician. That restraint is intentional — it's a trust feature.",
|
||||
example: "\"Is 90ml normal at 6 weeks?\" → Tia gives context, then: \"Your pediatrician can confirm this for your baby specifically.\"",
|
||||
},
|
||||
{
|
||||
icon: "📚",
|
||||
title: "The heirloom archive",
|
||||
body: "Every log, photo, milestone, and memory becomes part of a permanent, private archive. Not a feed. A complete record — searchable, exportable, and theirs to keep.",
|
||||
example: "\"Show me everything from her first month\" → every feed, every photo, every note.",
|
||||
},
|
||||
{
|
||||
icon: "👨👩👧",
|
||||
title: "Family circle",
|
||||
body: "Role-based access so everyone in your baby's life can be as involved as they should be. Grandparents get view-only access. The nanny gets caregiver access. You stay the admin.",
|
||||
example: "Nani in Jaipur sees today's photos in real time. The daai logs the afternoon feed.",
|
||||
},
|
||||
{
|
||||
icon: "📈",
|
||||
title: "Growth tracking",
|
||||
body: "Weight, length, and head circumference plotted on clear charts. See how your baby is growing over time and carry the full history into every pediatric visit.",
|
||||
example: "\"She's been in the 60th percentile for weight since month two.\"",
|
||||
},
|
||||
];
|
||||
|
||||
function Features() {
|
||||
return (
|
||||
<section className="py-20 px-5 bg-gray-50">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<p className="font-jetbrains text-xs font-semibold text-rose-500 uppercase tracking-widest mb-4 text-center">What Tia does</p>
|
||||
<h2 className="font-fraunces text-3xl font-bold text-gray-900 text-center mb-12">
|
||||
Everything in one private place.
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{FEATURES.map(f => (
|
||||
<div
|
||||
key={f.title}
|
||||
className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100 hover:border-rose-200 hover:shadow-md transition-all duration-200 group flex flex-col"
|
||||
>
|
||||
<span className="text-3xl mb-4 transition-transform duration-200 group-hover:scale-110 block">
|
||||
{f.icon}
|
||||
</span>
|
||||
<h3 className="font-fraunces font-bold text-gray-900 text-lg mb-2">{f.title}</h3>
|
||||
<p
|
||||
className="text-gray-600 text-sm leading-relaxed mb-4 flex-1"
|
||||
dangerouslySetInnerHTML={{ __html: f.body }}
|
||||
/>
|
||||
<div className="bg-rose-50 rounded-lg px-3 py-2 text-xs text-rose-700 italic mt-auto">
|
||||
{f.example}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Section: Founder Story ─────────────────────────────────────
|
||||
function FounderStory() {
|
||||
return (
|
||||
<section className="py-20 px-5 bg-white">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<p className="font-jetbrains text-xs font-semibold text-rose-500 uppercase tracking-widest mb-4">Why Tia exists</p>
|
||||
|
||||
<h2 className="font-fraunces text-3xl font-bold text-gray-900 mb-3 leading-tight">
|
||||
TIA began with a promise.
|
||||
</h2>
|
||||
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-2xl p-6 sm:p-8 mt-6">
|
||||
<div className="font-newsreader text-gray-700 leading-relaxed space-y-4 text-sm sm:text-base">
|
||||
<p>
|
||||
When our daughter, Tia, was born, we wanted to remember everything — the tiny
|
||||
stretches, the sleepy smiles, the moments that felt too precious to forget. But we
|
||||
quickly discovered how easily those details fade, even when you're trying your
|
||||
best to hold on to them.
|
||||
</p>
|
||||
|
||||
<p className="font-medium text-gray-800">
|
||||
We built TIA to help families preserve those memories.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Named after our daughter, Tia, the app reflects the values that guide us as parents:
|
||||
care, patience, trust, privacy, and respect. We believe your family's memories
|
||||
belong to your family — not advertisers, algorithms, or engagement loops. We believe
|
||||
technology should support family life, not compete for attention.
|
||||
</p>
|
||||
|
||||
<p className="font-medium text-rose-700">
|
||||
TIA isn't here to track your child. It's here to help you remember them.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
As parents raising a child in a nuclear family, we also understood how much
|
||||
grandparents and loved ones want to stay connected. That's why TIA makes it
|
||||
easy to share precious moments with family while keeping parents in control.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
One day, when your child asks,{" "}
|
||||
<em>"What was I like when I was little?"</em> — we hope TIA helps you
|
||||
answer with photos, stories, milestones, and moments preserved with love.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 pt-6 border-t border-amber-200 text-center">
|
||||
<p className="text-sm font-semibold text-amber-800 tracking-wide">
|
||||
Built by parents. Inspired by our daughter. Made for families.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Section: The Heirloom Vision ───────────────────────────────
|
||||
function HeirloomVision() {
|
||||
return (
|
||||
<section className="py-20 px-5 bg-gradient-to-br from-rose-50 to-amber-50">
|
||||
<div className="max-w-2xl mx-auto text-center">
|
||||
<p className="font-jetbrains text-xs font-semibold text-rose-500 uppercase tracking-widest mb-4">The heirloom vision</p>
|
||||
|
||||
<h2 className="font-fraunces text-3xl font-bold text-gray-900 mb-6 leading-tight">
|
||||
One day, your child will be able to{" "}
|
||||
<span className="text-rose-500" style={{ fontFamily: "var(--font-caveat)", fontSize: "1.1em" }}>
|
||||
read their own story.
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
<p className="font-newsreader text-gray-600 leading-relaxed mb-8 max-w-xl mx-auto">
|
||||
Everything you log today is a letter to your future child. The 2:47am feed.
|
||||
The first solid. The doctor visit you worried about for a week. The photo from
|
||||
the moment you realised she could recognise your voice.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-left">
|
||||
{[
|
||||
{ icon: "📖", title: "A complete record", desc: "Not highlights. Everything — because you can't know yet which moment will matter most." },
|
||||
{ icon: "🔒", title: "Private and permanent", desc: "No public feed. No algorithm. Your family's archive, locked to your family." },
|
||||
{ icon: "💾", title: "Fully exportable", desc: "Your data is yours. Export everything at any time. The heirloom is portable." },
|
||||
].map(item => (
|
||||
<div key={item.title} className="bg-white/80 rounded-2xl p-5 border border-rose-100 hover:border-rose-300 hover:shadow-sm transition-all duration-200">
|
||||
<span className="text-2xl mb-3 block">{item.icon}</span>
|
||||
<h3 className="font-fraunces font-bold text-gray-900 mb-1">{item.title}</h3>
|
||||
<p className="text-sm text-gray-600 leading-relaxed">{item.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Section: Privacy & Trust ───────────────────────────────────
|
||||
function Privacy() {
|
||||
return (
|
||||
<section className="py-20 px-5 bg-white">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<p className="font-jetbrains text-xs font-semibold text-rose-500 uppercase tracking-widest mb-4">Privacy & trust</p>
|
||||
|
||||
<h2 className="font-fraunces text-3xl font-bold text-gray-900 mb-4">
|
||||
We don't sell your data —<br />
|
||||
<span className="text-rose-500">we preserve it.</span>
|
||||
</h2>
|
||||
|
||||
<p className="font-newsreader text-gray-600 leading-relaxed mb-8">
|
||||
Tia is a baby-tracking app. Your child's records are not the product.
|
||||
They are the point.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ icon: "🏛️", title: "Row-Level Security", desc: "Your family's data is isolated at the database level. No other user — no other family — can reach your records." },
|
||||
{ icon: "🚫", title: "No ads. No data resale.", desc: "We do not sell, share, or monetise your data with third parties. Ever." },
|
||||
{ icon: "📤", title: "Full export at any time", desc: "Export everything: logs, photos, milestones, vaccinations. Your data leaves with you whenever you want." },
|
||||
{ icon: "🔐", title: "Google Sign-In only", desc: "We don't store passwords. Authentication is handled by Google — the same account you already trust for everything else." },
|
||||
].map(item => (
|
||||
<div key={item.title} className="flex items-start gap-4 p-4 bg-gray-50 hover:bg-rose-50 rounded-xl transition-colors duration-150">
|
||||
<span className="text-xl flex-shrink-0 mt-0.5">{item.icon}</span>
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900 text-sm">{item.title}</p>
|
||||
<p className="text-sm text-gray-600 leading-relaxed mt-0.5">{item.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Section: Early Access ──────────────────────────────────────
|
||||
function EarlyAccess() {
|
||||
return (
|
||||
<section className="py-16 px-5 bg-gray-50 border-y border-gray-100">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<p className="font-jetbrains text-xs font-semibold text-rose-500 uppercase tracking-widest mb-4 text-center">Private early access</p>
|
||||
<h2 className="font-fraunces text-2xl font-bold text-gray-900 text-center mb-6">
|
||||
Built by a parent, being tested by parents.
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{[
|
||||
{ icon: "🌱", title: "Free during early access", desc: "Core features — activity logging and the heirloom archive — are free while Tia is in early access. Early families keep their terms for life." },
|
||||
{ icon: "🇮🇳", title: "India-native from day one", desc: "IAP vaccination schedule. Telegram alerts. Indian naming conventions. Built for how Indian families actually live." },
|
||||
].map(item => (
|
||||
<div key={item.title} className="bg-white rounded-2xl p-5 border border-gray-100 shadow-sm">
|
||||
<span className="text-2xl mb-3 block">{item.icon}</span>
|
||||
<h3 className="font-fraunces font-bold text-gray-900 mb-2">{item.title}</h3>
|
||||
<p className="text-sm text-gray-600 leading-relaxed">{item.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Section: Final CTA ─────────────────────────────────────────
|
||||
function FinalCTA() {
|
||||
return (
|
||||
<section className="py-24 px-5 bg-gradient-to-br from-rose-500 to-rose-600 text-white text-center">
|
||||
<div className="max-w-xl mx-auto">
|
||||
<h2 className="font-fraunces text-3xl sm:text-4xl font-bold mb-4 leading-tight">
|
||||
Start preserving your<br />child's story today.
|
||||
</h2>
|
||||
|
||||
<p className="text-rose-100 mb-8 leading-relaxed">
|
||||
Free during early access. No credit card. Your data is yours — always.
|
||||
</p>
|
||||
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-flex items-center gap-2.5 bg-white text-rose-600 font-bold px-8 py-4 rounded-full text-base hover:bg-rose-50 hover:shadow-xl active:scale-95 transition-all duration-200 shadow-lg"
|
||||
>
|
||||
<GoogleG />
|
||||
Continue with Google
|
||||
</Link>
|
||||
|
||||
<p className="mt-4 text-xs text-rose-200">
|
||||
Invite-only early access — join from a shared link or reach out directly.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Page ───────────────────────────────────────────────────────
|
||||
export default function MarketingHomePage() {
|
||||
return (
|
||||
<>
|
||||
{/* Structured data — Organization, WebSite, and SoftwareApplication */}
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={jsonLdScript([
|
||||
organizationSchema(),
|
||||
websiteSchema(),
|
||||
softwareApplicationSchema(),
|
||||
])}
|
||||
/>
|
||||
<Hero />
|
||||
<TheProblem />
|
||||
<Features />
|
||||
<FounderStory />
|
||||
<HeirloomVision />
|
||||
<Privacy />
|
||||
<EarlyAccess />
|
||||
<FinalCTA />
|
||||
</>
|
||||
);
|
||||
}
|
||||
59
src/app/(marketing)/partners/page.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Partners",
|
||||
description: "Clinics, pediatricians, and organisations partnering with Tia to support Indian families.",
|
||||
alternates: { canonical: "/partners" },
|
||||
openGraph: {
|
||||
type: "website",
|
||||
url: "/partners",
|
||||
title: "Partners | Tia",
|
||||
description: "Clinics, pediatricians, and organisations partnering with Tia to support Indian families.",
|
||||
},
|
||||
};
|
||||
|
||||
export default function PartnersPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<div className="max-w-2xl mx-auto px-5 py-20">
|
||||
<div className="font-jetbrains mb-3 text-sm font-medium text-rose-500 uppercase tracking-widest">Work with us</div>
|
||||
<h1 className="font-fraunces text-4xl font-bold text-gray-900 mb-6 leading-tight">Partners</h1>
|
||||
|
||||
<div className="text-gray-600 leading-relaxed space-y-5 mb-10">
|
||||
<p>
|
||||
We're exploring partnerships with pediatric clinics, child-care organisations, and
|
||||
technology providers who share our belief that parents deserve great tools — without
|
||||
surveillance.
|
||||
</p>
|
||||
<p>
|
||||
If you run a clinic, a parenting community, or a product that serves Indian families, we'd
|
||||
love to talk.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-rose-50 rounded-2xl p-8 border border-rose-100">
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-3">Get in touch</h2>
|
||||
<p className="text-gray-500 text-sm mb-5">
|
||||
Send us a note — we respond to every message personally.
|
||||
</p>
|
||||
<a
|
||||
href="mailto:hello@tia.baby"
|
||||
className="inline-flex items-center gap-2 bg-rose-500 hover:bg-rose-600 text-white text-sm font-semibold px-6 py-3 rounded-full transition-colors duration-200"
|
||||
>
|
||||
hello@tia.baby
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 pt-8 border-t border-gray-100">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 text-sm font-medium text-rose-600 hover:text-rose-700 transition-colors"
|
||||
>
|
||||
← Back to home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
src/app/(marketing)/pricing/page.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Pricing",
|
||||
description: "Founder pricing — early families keep their terms for life. Free during early access.",
|
||||
alternates: { canonical: "/pricing" },
|
||||
openGraph: {
|
||||
type: "website",
|
||||
url: "/pricing",
|
||||
title: "Pricing | Tia",
|
||||
description: "Founder pricing — early families keep their terms for life. Free during early access.",
|
||||
},
|
||||
};
|
||||
|
||||
export default function PricingPage() {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto px-4 py-20">
|
||||
<p className="font-jetbrains text-xs font-semibold text-rose-500 uppercase tracking-widest mb-4 text-center">Pricing</p>
|
||||
|
||||
<h1 className="font-fraunces text-3xl sm:text-4xl font-bold text-gray-900 text-center mb-4">
|
||||
Founder pricing.
|
||||
</h1>
|
||||
<p className="text-gray-500 text-center mb-12 max-w-md mx-auto">
|
||||
Early families keep their terms for life.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-gray-900">Free — always</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">The core heirloom experience</p>
|
||||
</div>
|
||||
<span className="bg-green-100 text-green-700 text-xs font-semibold px-3 py-1 rounded-full">Included</span>
|
||||
</div>
|
||||
<ul className="space-y-2 text-sm text-gray-700">
|
||||
{[
|
||||
"Activity logging — feed, sleep, diaper, and more",
|
||||
"The heirloom archive — every log, searchable forever",
|
||||
"Milestone tracking",
|
||||
"IAP vaccination schedule",
|
||||
"Telegram alerts for upcoming vaccinations",
|
||||
"Ask Tia (parenting logistics AI)",
|
||||
"Family circle with role-based access",
|
||||
"Growth tracking",
|
||||
"Full data export — the archive is yours",
|
||||
].map(item => (
|
||||
<li key={item} className="flex items-start gap-2">
|
||||
<span className="text-rose-400 flex-shrink-0 mt-0.5">✓</span>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-gray-900">Media storage</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">Photos and video in the archive</p>
|
||||
</div>
|
||||
<span className="bg-amber-100 text-amber-700 text-xs font-semibold px-3 py-1 rounded-full">Paid / metered</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 leading-relaxed">
|
||||
Storing photos and video at scale has a real infrastructure cost. Media storage is
|
||||
the one thing we charge for — metered, so you only pay for what you use.
|
||||
Pricing details will be shared before the early-access period ends.
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-3">
|
||||
Founder families joining during early access will be the first to know — and will
|
||||
keep whatever terms we agree on, for life.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 text-center">
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-flex items-center gap-2 bg-rose-500 hover:bg-rose-600 text-white font-semibold px-7 py-3.5 rounded-full text-base transition-colors"
|
||||
>
|
||||
Continue with Google — it's free
|
||||
</Link>
|
||||
<p className="text-xs text-gray-400 mt-3">No credit card required.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
87
src/app/(marketing)/privacy/page.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Privacy Policy",
|
||||
description: "How Tia handles your family's data. We don't sell it — we preserve it.",
|
||||
alternates: { canonical: "/privacy" },
|
||||
openGraph: {
|
||||
type: "website",
|
||||
url: "/privacy",
|
||||
title: "Privacy Policy | Tia",
|
||||
description: "How Tia handles your family's data. We don't sell it — we preserve it.",
|
||||
},
|
||||
};
|
||||
|
||||
export default function PrivacyPage() {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto px-4 py-20">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Privacy Policy</h1>
|
||||
<p className="text-sm text-gray-400 mb-10">Last updated: May 2026</p>
|
||||
|
||||
<div className="prose prose-gray max-w-none space-y-8 text-gray-700 leading-relaxed">
|
||||
<section>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-3">The short version</h2>
|
||||
<p>
|
||||
We don't sell your data. We don't show you ads. We don't share your records
|
||||
with third parties. Your family's data exists in Tia for one purpose: to
|
||||
be preserved for you and your child.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-3">What we collect</h2>
|
||||
<ul className="list-disc pl-5 space-y-2 text-sm">
|
||||
<li>Your name and email address, via Google Sign-In.</li>
|
||||
<li>Activity logs you enter (feeds, sleep, diapers, medical records, milestones).</li>
|
||||
<li>Photos and media you upload to the heirloom archive.</li>
|
||||
<li>Device and session information for security purposes.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-3">How we protect it</h2>
|
||||
<p>
|
||||
Your family's data is isolated at the database level using Row-Level Security (RLS).
|
||||
No other user — no other family — can access your records. We use HTTPS everywhere.
|
||||
Sessions are managed with secure, httpOnly cookies.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-3">Who we share it with</h2>
|
||||
<p>
|
||||
We use the following third-party services to operate Tia. We do not sell your data
|
||||
to any third party, ever.
|
||||
</p>
|
||||
<ul className="list-disc pl-5 space-y-2 text-sm mt-3">
|
||||
<li><strong>Google</strong> — authentication only.</li>
|
||||
<li><strong>Cloudflare R2</strong> — media storage (photos, video).</li>
|
||||
<li><strong>Telegram</strong> — vaccination alerts, if you opt in.</li>
|
||||
<li><strong>Resend</strong> — transactional email (invites, verification).</li>
|
||||
<li><strong>Plausible Analytics</strong> — privacy-preserving, cookie-free analytics on our marketing pages only. No tracking inside the app.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-3">Your rights</h2>
|
||||
<ul className="list-disc pl-5 space-y-2 text-sm">
|
||||
<li><strong>Export:</strong> You can export all your data at any time from Settings. The heirloom is portable and yours.</li>
|
||||
<li><strong>Deletion:</strong> You can delete your account and all associated data. Contact us at <a href="mailto:tia@manohargupta.com" className="text-rose-500 hover:underline">tia@manohargupta.com</a>.</li>
|
||||
<li><strong>Correction:</strong> You can edit or delete any log or record within the app.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-3">Contact</h2>
|
||||
<p>
|
||||
Questions about this policy? Email us at{" "}
|
||||
<a href="mailto:tia@manohargupta.com" className="text-rose-500 hover:underline">
|
||||
tia@manohargupta.com
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
94
src/app/(marketing)/terms/page.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Terms of Service",
|
||||
description: "Terms of service for Tia — the baby tracker and digital heirloom for Indian families.",
|
||||
alternates: { canonical: "/terms" },
|
||||
openGraph: {
|
||||
type: "website",
|
||||
url: "/terms",
|
||||
title: "Terms of Service | Tia",
|
||||
description: "Terms of service for Tia — the baby tracker and digital heirloom for Indian families.",
|
||||
},
|
||||
};
|
||||
|
||||
export default function TermsPage() {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto px-4 py-20">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Terms of Service</h1>
|
||||
<p className="text-sm text-gray-400 mb-10">Last updated: May 2026</p>
|
||||
|
||||
<div className="space-y-8 text-gray-700 leading-relaxed">
|
||||
<section>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-3">Acceptance</h2>
|
||||
<p>
|
||||
By using Tia, you agree to these terms. If you don't agree, please don't use
|
||||
the service.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-3">What Tia is</h2>
|
||||
<p>
|
||||
Tia is a baby-tracking and heirloom-archiving application for families. It is
|
||||
not a medical service. Tia does not provide medical advice, diagnosis, or
|
||||
treatment. For any health concerns about your child, consult your pediatrician.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-3">Your account</h2>
|
||||
<p>
|
||||
You are responsible for maintaining the security of your account. You must
|
||||
be at least 18 years old to create an account. Family members you invite
|
||||
are governed by these same terms.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-3">Your data</h2>
|
||||
<p>
|
||||
You own your data. We do not claim any ownership over the records, photos,
|
||||
or content you create in Tia. You can export and delete your data at any time.
|
||||
See our <a href="/privacy" className="text-rose-500 hover:underline">Privacy Policy</a> for details.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-3">Acceptable use</h2>
|
||||
<p>
|
||||
You agree not to use Tia to store illegal content, harm others, or interfere
|
||||
with the service. We may terminate accounts that violate these terms.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-3">Service availability</h2>
|
||||
<p>
|
||||
Tia is provided as-is during early access. We make no guarantees of uptime
|
||||
or availability, though we work hard to keep the service running reliably.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-3">Changes to these terms</h2>
|
||||
<p>
|
||||
We may update these terms. We will notify you by email before any material
|
||||
changes take effect.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-3">Contact</h2>
|
||||
<p>
|
||||
Questions? Email{" "}
|
||||
<a href="mailto:tia@manohargupta.com" className="text-rose-500 hover:underline">
|
||||
tia@manohargupta.com
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,13 +3,7 @@
|
|||
import { useState, useEffect, createContext, useContext } from "react";
|
||||
import { ReactNode } from "react";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
|
||||
interface Child {
|
||||
id: string;
|
||||
name: string;
|
||||
birthDate: string;
|
||||
sex: string;
|
||||
}
|
||||
import type { Child } from "@/types";
|
||||
|
||||
interface FamilyContextType {
|
||||
familyId: string | null;
|
||||
|
|
@ -20,6 +14,7 @@ interface FamilyContextType {
|
|||
loading: boolean;
|
||||
tier: "free" | "pro";
|
||||
memberCount: number;
|
||||
updateChildImage: (childId: string, imageUrl: string | null) => void;
|
||||
}
|
||||
|
||||
const FamilyContext = createContext<FamilyContextType>({
|
||||
|
|
@ -31,6 +26,7 @@ const FamilyContext = createContext<FamilyContextType>({
|
|||
loading: true,
|
||||
tier: "free",
|
||||
memberCount: 2,
|
||||
updateChildImage: () => {},
|
||||
});
|
||||
|
||||
export function useFamily() {
|
||||
|
|
@ -87,11 +83,12 @@ export function FamilyProvider({ children: providerChildren }: { children: React
|
|||
}
|
||||
|
||||
if (data.children?.length > 0) {
|
||||
const childList = data.children.map((c: any) => ({
|
||||
const childList: Child[] = data.children.map((c: Child) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
birthDate: c.birthDate,
|
||||
sex: c.sex,
|
||||
imageUrl: c.imageUrl ?? null,
|
||||
}));
|
||||
|
||||
setChildren(childList);
|
||||
|
|
@ -126,6 +123,11 @@ export function FamilyProvider({ children: providerChildren }: { children: React
|
|||
fetchFamilyData();
|
||||
}, [router]);
|
||||
|
||||
const updateChildImage = (cId: string, imageUrl: string | null) => {
|
||||
setChildren(prev => prev.map(c => c.id === cId ? { ...c, imageUrl } : c));
|
||||
setChild(prev => prev?.id === cId ? { ...prev, imageUrl } : prev);
|
||||
};
|
||||
|
||||
return (
|
||||
<FamilyContext.Provider
|
||||
value={{
|
||||
|
|
@ -137,6 +139,7 @@ export function FamilyProvider({ children: providerChildren }: { children: React
|
|||
loading,
|
||||
tier,
|
||||
memberCount,
|
||||
updateChildImage,
|
||||
}}
|
||||
>
|
||||
{providerChildren}
|
||||
|
|
|
|||
|
|
@ -1,244 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { useFamily } from "../FamilyProvider";
|
||||
|
||||
type ViewMode = "timeline" | "calendar";
|
||||
type LogType = "feed" | "sleep" | "diaper";
|
||||
|
||||
interface Log {
|
||||
id: string;
|
||||
type: LogType;
|
||||
subType?: string;
|
||||
amount?: number;
|
||||
notes?: string;
|
||||
loggedAt: string;
|
||||
}
|
||||
|
||||
interface Child {
|
||||
id: string;
|
||||
name: string;
|
||||
birthDate: string;
|
||||
}
|
||||
|
||||
import { getGuideline, getAgeInMonths, guidelines } from "@/lib/guidelines";
|
||||
|
||||
interface DayLogs {
|
||||
date: string;
|
||||
logs: Log[];
|
||||
}
|
||||
|
||||
export default function ActivityPage() {
|
||||
const { child, childId: providerChildId, familyId } = useFamily();
|
||||
const [view, setView] = useState<ViewMode>("timeline");
|
||||
const [logs, setLogs] = useState<Log[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState<LogType | "all">("all");
|
||||
const [showSuggested, setShowSuggested] = useState(true);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const childId = providerChildId || "default";
|
||||
|
||||
useEffect(() => {
|
||||
if (providerChildId) {
|
||||
fetchLogs();
|
||||
}
|
||||
}, [providerChildId]);
|
||||
|
||||
const fetchLogs = async () => {
|
||||
if (!childId) return;
|
||||
try {
|
||||
const res = await fetch(`/api/logs?childId=${childId}&limit=100`);
|
||||
const data = await res.json();
|
||||
setLogs(data.entries || []);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch:", err);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const generateHistory = async () => {
|
||||
if (!child) return;
|
||||
setGenerating(true);
|
||||
try {
|
||||
const res = await fetch("/api/history", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ childId: child.id, birthDate: child.birthDate }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
fetchLogs();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to generate:", err);
|
||||
}
|
||||
setGenerating(false);
|
||||
};
|
||||
|
||||
const filteredLogs = filter === "all" ? logs : logs.filter((l) => l.type === filter);
|
||||
|
||||
const groupedByDay = filteredLogs.reduce((acc: DayLogs[], log) => {
|
||||
const date = new Date(log.loggedAt).toDateString();
|
||||
const existing = acc.find((d) => d.date === date);
|
||||
if (existing) {
|
||||
existing.logs.push(log);
|
||||
} else {
|
||||
acc.push({ date, logs: [log] });
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const getIcon = (type: LogType) => {
|
||||
switch (type) {
|
||||
case "feed": return "🍼";
|
||||
case "sleep": return "😴";
|
||||
case "diaper": return "👶";
|
||||
default: return "📝";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800">
|
||||
{/* Header */}
|
||||
<div className="p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/menu" className="p-2">←</Link>
|
||||
<h1 className="text-xl font-bold">Activity</h1>
|
||||
{child && logs.length === 0 && (
|
||||
<button
|
||||
onClick={generateHistory}
|
||||
disabled={generating}
|
||||
className="text-xs px-2 py-1 bg-rose-400 text-white rounded-full"
|
||||
>
|
||||
{generating ? "..." : "Generate History"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* View Toggle */}
|
||||
<div className="flex bg-white dark:bg-gray-800 rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setView("timeline")}
|
||||
className={`px-3 py-1 rounded-md text-sm ${view === "timeline" ? "bg-rose-400 text-white" : ""}`}
|
||||
>
|
||||
Timeline
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView("calendar")}
|
||||
className={`px-3 py-1 rounded-md text-sm ${view === "calendar" ? "bg-rose-400 text-white" : ""}`}
|
||||
>
|
||||
Calendar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
<div className="px-4 mb-4 flex gap-2 overflow-x-auto">
|
||||
{(["all", "feed", "sleep", "diaper"] as const).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-4 py-2 rounded-full text-sm whitespace-nowrap ${
|
||||
filter === f
|
||||
? "bg-rose-400 text-white"
|
||||
: "bg-white dark:bg-gray-800"
|
||||
}`}
|
||||
>
|
||||
{f === "all" ? "All" : f.charAt(0).toUpperCase() + f.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Guidelines Card */}
|
||||
{child && showSuggested && (
|
||||
<div className="px-4 mb-4">
|
||||
{(() => {
|
||||
const guide = getGuideline(child.birthDate);
|
||||
const ageMonths = getAgeInMonths(child.birthDate);
|
||||
return (
|
||||
<div className="p-4 bg-gradient-to-r from-rose-100 to-amber-100 dark:from-rose-900 dark:to-amber-900 rounded-xl">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="font-medium text-rose-800 dark:text-rose-200">
|
||||
{child.name} · {ageMonths} months old
|
||||
</div>
|
||||
<button onClick={() => setShowSuggested(false)} className="text-gray-400">✕</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-sm">
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-rose-600 dark:text-rose-400">{guide.feeds.times}</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400">feeds/day</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-amber-600 dark:text-amber-400">{guide.sleep.totalHours}h</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400">sleep/day</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-blue-600 dark:text-blue-400">{guide.diapers.count}</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400">diapers/day</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-4 pb-20">
|
||||
{loading ? (
|
||||
<div className="text-center py-20 text-gray-400">Loading...</div>
|
||||
) : view === "timeline" ? (
|
||||
/* Timeline View */
|
||||
groupedByDay.length === 0 ? (
|
||||
<div className="text-center py-20 text-gray-400">
|
||||
<div className="text-6xl mb-4">📊</div>
|
||||
<p>No activity yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{groupedByDay.map((day) => (
|
||||
<div key={day.date}>
|
||||
<div className="text-sm font-medium text-gray-500 mb-2">
|
||||
{new Date(day.date).toLocaleDateString("en-US", {
|
||||
weekday: "long",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{day.logs
|
||||
.sort((a, b) => new Date(b.loggedAt).getTime() - new Date(a.loggedAt).getTime())
|
||||
.map((log) => (
|
||||
<div key={log.id} className="flex items-center gap-3 p-3 bg-white dark:bg-gray-800 rounded-xl">
|
||||
<span className="text-2xl">{getIcon(log.type)}</span>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium capitalize">{log.type}</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{log.subType && `${log.subType} · `}
|
||||
{log.amount && `${log.amount}ml`}
|
||||
{log.notes && ` · ${log.notes}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">
|
||||
{new Date(log.loggedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
/* Calendar View */
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-4">
|
||||
<div className="text-center text-gray-400 py-20">
|
||||
<div className="text-6xl mb-4">📅</div>
|
||||
<p>Calendar view coming soon</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button, Input } from "@/components/ui";
|
||||
|
||||
export default function AdminLoginPage() {
|
||||
const router = useRouter();
|
||||
|
|
@ -34,16 +35,13 @@ export default function AdminLoginPage() {
|
|||
const data = await res.json();
|
||||
|
||||
if (res.ok && data.success) {
|
||||
// Verify session server-side before redirect
|
||||
const sessionRes = await fetch("/api/admin/auth");
|
||||
const sessionData = await sessionRes.json();
|
||||
if (sessionData.authenticated) {
|
||||
router.push("/admin");
|
||||
}
|
||||
if (sessionData.authenticated) router.push("/admin");
|
||||
} else {
|
||||
setError(data.error || "Invalid credentials");
|
||||
}
|
||||
} catch (err) {
|
||||
} catch {
|
||||
setError("Login failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
|
@ -56,39 +54,30 @@ export default function AdminLoginPage() {
|
|||
<h1 className="text-2xl font-bold text-center mb-6 text-white">Admin Login</h1>
|
||||
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-300">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full p-3 bg-gray-700 border border-gray-600 rounded-xl text-white"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label="Username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
className="bg-gray-700 border-gray-600 text-white"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-300">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full p-3 bg-gray-700 border border-gray-600 rounded-xl text-white"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
error={error || undefined}
|
||||
className="bg-gray-700 border-gray-600 text-white"
|
||||
/>
|
||||
|
||||
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-3 bg-rose-500 text-white rounded-xl font-medium disabled:opacity-50"
|
||||
>
|
||||
{loading ? "..." : "Login"}
|
||||
</button>
|
||||
<Button type="submit" fullWidth size="lg" loading={loading}>
|
||||
Login
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,11 +12,18 @@ interface NavItem {
|
|||
|
||||
const navItems: NavItem[] = [
|
||||
{ name: "Dashboard", href: "/admin", icon: "📊" },
|
||||
{ name: "Health", href: "/admin/health", icon: "❤️🩹" },
|
||||
{ name: "Errors", href: "/admin/errors", icon: "🐞" },
|
||||
{ name: "Activity", href: "/admin/activity", icon: "🔍" },
|
||||
{ name: "Audit Log", href: "/admin/audit", icon: "📜" },
|
||||
{ name: "Families", href: "/admin/families", icon: "🏠" },
|
||||
{ name: "Users", href: "/admin/users", icon: "👥" },
|
||||
{ name: "Children", href: "/admin/children", icon: "👶" },
|
||||
{ name: "Revenue", href: "/admin/revenue", icon: "💰" },
|
||||
{ name: "Subscriptions", href: "/admin/subscriptions", icon: "💳" },
|
||||
{ name: "Storage", href: "/admin/storage", icon: "💾" },
|
||||
{ name: "Analytics", href: "/admin/analytics", icon: "📈" },
|
||||
{ name: "AI Usage", href: "/admin/ai", icon: "🤖" },
|
||||
{ name: "Support", href: "/admin/support", icon: "🎫" },
|
||||
{ name: "Settings", href: "/admin/settings", icon: "⚙️" },
|
||||
];
|
||||
|
|
|
|||
263
src/app/admin/activity/page.tsx
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, Badge } from "@/components/ui";
|
||||
|
||||
interface ActivityStats {
|
||||
activeSessions: number;
|
||||
loginsToday: number;
|
||||
failedToday: number;
|
||||
signupsWeek: number;
|
||||
}
|
||||
|
||||
interface Event {
|
||||
id: string;
|
||||
action: string;
|
||||
email: string;
|
||||
userName: string | null;
|
||||
familyName: string | null;
|
||||
ipAddress: string | null;
|
||||
userAgent: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface ActivityData {
|
||||
stats: ActivityStats;
|
||||
loginsByDay: { date: string; count: number }[];
|
||||
failedByDay: { date: string; count: number }[];
|
||||
events: Event[];
|
||||
}
|
||||
|
||||
const ACTION_CONFIG: Record<string, { label: string; color: string; badge: "rose" | "warning" | "default" }> = {
|
||||
login: { label: "Login", color: "text-emerald-400", badge: "default" },
|
||||
login_failed: { label: "Failed Login", color: "text-rose-400", badge: "rose" },
|
||||
signup: { label: "Signup", color: "text-blue-400", badge: "default" },
|
||||
logout: { label: "Logout", color: "text-gray-400", badge: "default" },
|
||||
};
|
||||
|
||||
function timeAgo(iso: string) {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return "just now";
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
const days = Math.floor(hrs / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
function parseDevice(ua: string | null) {
|
||||
if (!ua) return "Unknown";
|
||||
if (ua.includes("iPhone") || ua.includes("Android")) return "Mobile";
|
||||
if (ua.includes("iPad")) return "Tablet";
|
||||
return "Desktop";
|
||||
}
|
||||
|
||||
export default function AdminActivity() {
|
||||
const [data, setData] = useState<ActivityData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState("all");
|
||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchActivity();
|
||||
}, [filter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return;
|
||||
const id = setInterval(fetchActivity, 15000);
|
||||
return () => clearInterval(id);
|
||||
}, [autoRefresh, filter]);
|
||||
|
||||
const fetchActivity = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/activity?action=${filter}&limit=100`, { credentials: "include" });
|
||||
const json = await res.json();
|
||||
setData(json);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch activity:", err);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
if (loading || !data) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-8 bg-gray-700 rounded w-48" />
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{[1, 2, 3, 4].map(i => <div key={i} className="h-24 bg-gray-800 rounded-xl" />)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { stats, loginsByDay, failedByDay, events } = data;
|
||||
const maxLogins = Math.max(...loginsByDay.map(d => d.count), 1);
|
||||
const maxFailed = Math.max(...failedByDay.map(d => d.count), 1);
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Activity Monitor</h1>
|
||||
<p className="text-gray-400">Real-time login events and session tracking</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${autoRefresh ? "bg-emerald-400 animate-pulse" : "bg-gray-600"}`} />
|
||||
<button
|
||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||
className="text-sm text-gray-400 hover:text-white"
|
||||
>
|
||||
{autoRefresh ? "Live" : "Paused"}
|
||||
</button>
|
||||
</div>
|
||||
<Button size="sm" variant="secondary" onClick={fetchActivity}>Refresh</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stat Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-gray-800 p-5 rounded-xl border border-gray-700">
|
||||
<div className="text-3xl font-bold text-emerald-400">{stats.activeSessions}</div>
|
||||
<div className="text-gray-400 text-sm mt-1">Active Sessions</div>
|
||||
<div className="text-xs text-gray-600 mt-1">Currently online</div>
|
||||
</div>
|
||||
<div className="bg-gray-800 p-5 rounded-xl border border-gray-700">
|
||||
<div className="text-3xl font-bold text-blue-400">{stats.loginsToday}</div>
|
||||
<div className="text-gray-400 text-sm mt-1">Logins Today</div>
|
||||
<div className="text-xs text-gray-600 mt-1">Last 24 hours</div>
|
||||
</div>
|
||||
<div className="bg-gray-800 p-5 rounded-xl border border-gray-700">
|
||||
<div className={`text-3xl font-bold ${stats.failedToday > 0 ? "text-rose-400" : "text-gray-400"}`}>
|
||||
{stats.failedToday}
|
||||
</div>
|
||||
<div className="text-gray-400 text-sm mt-1">Failed Attempts</div>
|
||||
<div className="text-xs text-gray-600 mt-1">Last 24 hours</div>
|
||||
</div>
|
||||
<div className="bg-gray-800 p-5 rounded-xl border border-gray-700">
|
||||
<div className="text-3xl font-bold text-amber-400">{stats.signupsWeek}</div>
|
||||
<div className="text-gray-400 text-sm mt-1">New Signups</div>
|
||||
<div className="text-xs text-gray-600 mt-1">Last 7 days</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-gray-800 p-6 rounded-xl">
|
||||
<h3 className="text-sm font-semibold text-gray-400 mb-4">LOGIN ACTIVITY — LAST 30 DAYS</h3>
|
||||
<div className="h-32 flex items-end gap-1">
|
||||
{loginsByDay.slice(-30).map((d, i) => (
|
||||
<div key={i} className="flex-1 flex flex-col items-center gap-1">
|
||||
<div
|
||||
className="w-full bg-emerald-500 rounded-t opacity-80"
|
||||
style={{ height: `${(d.count / maxLogins) * 100}%`, minHeight: d.count > 0 ? "3px" : "0" }}
|
||||
/>
|
||||
<div className="text-[7px] text-gray-600">{d.date?.slice(5) || ""}</div>
|
||||
</div>
|
||||
))}
|
||||
{loginsByDay.length === 0 && (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-600 text-sm">
|
||||
No login data yet
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-800 p-6 rounded-xl">
|
||||
<h3 className="text-sm font-semibold text-gray-400 mb-4">FAILED LOGINS — LAST 30 DAYS</h3>
|
||||
<div className="h-32 flex items-end gap-1">
|
||||
{failedByDay.slice(-30).map((d, i) => (
|
||||
<div key={i} className="flex-1 flex flex-col items-center gap-1">
|
||||
<div
|
||||
className="w-full bg-rose-500 rounded-t opacity-80"
|
||||
style={{ height: `${(d.count / maxFailed) * 100}%`, minHeight: d.count > 0 ? "3px" : "0" }}
|
||||
/>
|
||||
<div className="text-[7px] text-gray-600">{d.date?.slice(5) || ""}</div>
|
||||
</div>
|
||||
))}
|
||||
{failedByDay.length === 0 && (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-600 text-sm">
|
||||
No failed logins
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Events Table */}
|
||||
<div className="bg-gray-800 rounded-xl overflow-hidden">
|
||||
{/* Filter tabs */}
|
||||
<div className="flex gap-1 p-3 border-b border-gray-700 bg-gray-900">
|
||||
{[
|
||||
{ key: "all", label: "All Events" },
|
||||
{ key: "login", label: "Logins" },
|
||||
{ key: "login_failed", label: "Failures" },
|
||||
{ key: "signup", label: "Signups" },
|
||||
].map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setFilter(key)}
|
||||
className={`px-3 py-1.5 rounded text-sm font-medium transition-colors ${
|
||||
filter === key
|
||||
? "bg-rose-500 text-white"
|
||||
: "text-gray-400 hover:text-white hover:bg-gray-700"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
<div className="ml-auto text-xs text-gray-600 flex items-center">
|
||||
{events.length} events
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-medium text-gray-400">TIME</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-medium text-gray-400">EVENT</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-medium text-gray-400">USER</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-medium text-gray-400">FAMILY</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-medium text-gray-400">IP</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-medium text-gray-400">DEVICE</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-700">
|
||||
{events.map((event) => {
|
||||
const config = ACTION_CONFIG[event.action] || { label: event.action, color: "text-gray-400", badge: "default" as const };
|
||||
return (
|
||||
<tr key={event.id} className={`hover:bg-gray-750 ${event.action === "login_failed" ? "bg-rose-500/5" : ""}`}>
|
||||
<td className="px-4 py-2.5 text-gray-500 whitespace-nowrap">
|
||||
{timeAgo(event.createdAt)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span className={`font-medium ${config.color}`}>{config.label}</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="font-medium truncate max-w-[180px]">{event.email}</div>
|
||||
{event.userName && event.userName !== event.email && (
|
||||
<div className="text-xs text-gray-500">{event.userName}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-400">{event.familyName || "—"}</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 font-mono text-xs">{event.ipAddress || "—"}</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 text-xs">{parseDevice(event.userAgent)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{events.length === 0 && (
|
||||
<div className="p-12 text-center text-gray-600">
|
||||
No events recorded yet
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
147
src/app/admin/ai/page.tsx
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface Summary { totalCalls: number; totalTokens: number; totalCostPaise: number; familiesUsingAI: number; avgMs: number; p95Ms: number; redirects: number }
|
||||
interface IntentRow { intent: string; count: number; avgMs: number; p95Ms: number; tokens: number; costPaise: number }
|
||||
interface DayRow { date: string; count: number; costPaise: number }
|
||||
interface SlowRow { id: string; intent: string; durationMs: number; model: string; createdAt: string; familyId: string | null }
|
||||
interface Data { days: number; summary: Summary; byIntent: IntentRow[]; byDay: DayRow[]; slowest: SlowRow[]; error?: string }
|
||||
|
||||
const EMPTY: Data = { days: 30, summary: { totalCalls: 0, totalTokens: 0, totalCostPaise: 0, familiesUsingAI: 0, avgMs: 0, p95Ms: 0, redirects: 0 }, byIntent: [], byDay: [], slowest: [] };
|
||||
const rupees = (paise: number) => `₹${(paise / 100).toFixed(2)}`;
|
||||
|
||||
export default function AdminAI() {
|
||||
const [data, setData] = useState<Data>(EMPTY);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [days, setDays] = useState(30);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
fetch(`/api/admin/ai?days=${days}`, { credentials: "include" })
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
setData({
|
||||
days: d?.days || days,
|
||||
summary: { ...EMPTY.summary, ...(d?.summary || {}) },
|
||||
byIntent: Array.isArray(d?.byIntent) ? d.byIntent : [],
|
||||
byDay: Array.isArray(d?.byDay) ? d.byDay : [],
|
||||
slowest: Array.isArray(d?.slowest) ? d.slowest : [],
|
||||
error: d?.error,
|
||||
});
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
}, [days]);
|
||||
|
||||
const { summary, byIntent, byDay, slowest } = data;
|
||||
const maxDay = Math.max(...byDay.map(d => d.count), 1);
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex justify-between items-start flex-wrap gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">AI Observability</h1>
|
||||
<p className="text-gray-400">Latency, cost, and intent breakdown over the last {days} days</p>
|
||||
</div>
|
||||
<select value={days} onChange={e => setDays(Number(e.target.value))} className="bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-sm">
|
||||
<option value={7}>Last 7 days</option>
|
||||
<option value={30}>Last 30 days</option>
|
||||
<option value={90}>Last 90 days</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{data.error && <div className="bg-rose-500/10 text-rose-400 text-sm p-3 rounded-lg">Feed error: {data.error}</div>}
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Card label="API Calls" value={summary.totalCalls} color="text-blue-400" />
|
||||
<Card label="Avg latency" value={`${summary.avgMs}ms`} color={summary.avgMs > 4000 ? "text-rose-400" : "text-emerald-400"} />
|
||||
<Card label="p95 latency" value={`${summary.p95Ms}ms`} color={summary.p95Ms > 8000 ? "text-rose-400" : "text-amber-400"} />
|
||||
<Card label="Cost" value={rupees(summary.totalCostPaise)} color="text-amber-400" />
|
||||
<Card label="Total tokens" value={summary.totalTokens.toLocaleString()} color="text-purple-400" />
|
||||
<Card label="Families using AI" value={summary.familiesUsingAI} color="text-emerald-400" />
|
||||
<Card label="Medical redirects" value={summary.redirects} color={summary.redirects > 0 ? "text-rose-400" : "text-gray-500"} />
|
||||
<Card label="Avg cost / call" value={summary.totalCalls ? rupees(summary.totalCostPaise / summary.totalCalls) : "₹0.00"} color="text-gray-300" />
|
||||
</div>
|
||||
|
||||
{loading ? <div className="text-gray-400">Loading…</div> : (
|
||||
<>
|
||||
<div className="bg-gray-800 p-6 rounded-xl">
|
||||
<h3 className="text-lg font-semibold mb-4">Calls per day</h3>
|
||||
<div className="h-32 flex items-end gap-1">
|
||||
{byDay.length === 0 ? <div className="w-full text-center text-gray-500 text-sm self-center">No AI calls in this window</div> :
|
||||
byDay.slice(-30).map((d, i) => (
|
||||
<div key={i} title={`${d.date}: ${d.count} calls · ${rupees(d.costPaise)}`} className="flex-1 group">
|
||||
<div className="w-full bg-blue-500 group-hover:bg-blue-400 rounded-t" style={{ height: `${Math.max((d.count / maxDay) * 100, d.count > 0 ? 4 : 0)}%` }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-800 rounded-xl overflow-hidden">
|
||||
<h3 className="text-lg font-semibold p-4 pb-3">By intent</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-700"><tr>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium">Intent</th>
|
||||
<th className="px-4 py-2 text-right text-sm font-medium">Calls</th>
|
||||
<th className="px-4 py-2 text-right text-sm font-medium">Avg ms</th>
|
||||
<th className="px-4 py-2 text-right text-sm font-medium">p95 ms</th>
|
||||
<th className="px-4 py-2 text-right text-sm font-medium">Tokens</th>
|
||||
<th className="px-4 py-2 text-right text-sm font-medium">Cost</th>
|
||||
</tr></thead>
|
||||
<tbody className="divide-y divide-gray-700">
|
||||
{byIntent.length === 0 ? <tr><td colSpan={6} className="px-4 py-6 text-center text-gray-500">No data</td></tr> :
|
||||
byIntent.map(r => (
|
||||
<tr key={r.intent} className="hover:bg-gray-750">
|
||||
<td className="px-4 py-2 text-sm font-medium">{r.intent}</td>
|
||||
<td className="px-4 py-2 text-sm text-right">{r.count}</td>
|
||||
<td className="px-4 py-2 text-sm text-right text-gray-300">{r.avgMs}</td>
|
||||
<td className={`px-4 py-2 text-sm text-right ${r.p95Ms > 8000 ? "text-rose-400" : "text-gray-300"}`}>{r.p95Ms}</td>
|
||||
<td className="px-4 py-2 text-sm text-right text-gray-400">{r.tokens.toLocaleString()}</td>
|
||||
<td className="px-4 py-2 text-sm text-right text-amber-400">{rupees(r.costPaise)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-800 rounded-xl overflow-hidden">
|
||||
<h3 className="text-lg font-semibold p-4 pb-3">Slowest calls</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-700"><tr>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium">When</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium">Intent</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium">Model</th>
|
||||
<th className="px-4 py-2 text-right text-sm font-medium">Duration</th>
|
||||
</tr></thead>
|
||||
<tbody className="divide-y divide-gray-700">
|
||||
{slowest.length === 0 ? <tr><td colSpan={4} className="px-4 py-6 text-center text-gray-500">No data</td></tr> :
|
||||
slowest.map(r => (
|
||||
<tr key={r.id} className="hover:bg-gray-750">
|
||||
<td className="px-4 py-2 text-sm text-gray-400 whitespace-nowrap">{new Date(r.createdAt).toLocaleString()}</td>
|
||||
<td className="px-4 py-2 text-sm">{r.intent}</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-400">{r.model}</td>
|
||||
<td className={`px-4 py-2 text-sm text-right font-medium ${r.durationMs > 8000 ? "text-rose-400" : "text-amber-400"}`}>{r.durationMs}ms</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({ label, value, color }: { label: string; value: string | number; color: string }) {
|
||||
return (
|
||||
<div className="bg-gray-800 p-4 rounded-xl">
|
||||
<div className={`text-2xl font-bold ${color}`}>{value}</div>
|
||||
<div className="text-sm text-gray-300 mt-0.5">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,130 +2,334 @@
|
|||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface EngagementStats {
|
||||
totalLogs: number;
|
||||
totalMedicines: number;
|
||||
totalVaccinations: number;
|
||||
totalGrowth: number;
|
||||
totalMemories: number;
|
||||
totalChatSessions: number;
|
||||
logsByDay: { date: string; count: number }[];
|
||||
topFeatures: { name: string; count: number }[];
|
||||
interface FeatureAdoption {
|
||||
name: string;
|
||||
count: number;
|
||||
pct: number;
|
||||
}
|
||||
|
||||
interface FamilyEngagement {
|
||||
id: string;
|
||||
name: string;
|
||||
tier: string;
|
||||
createdAt: string | null;
|
||||
lastActivity: string | null;
|
||||
activeStatus: "7d" | "30d" | "inactive" | "never";
|
||||
feedCount: number;
|
||||
diaperCount: number;
|
||||
sleepCount: number;
|
||||
vaccinationCount: number;
|
||||
growthCount: number;
|
||||
memoryCount: number;
|
||||
chatCount: number;
|
||||
totalLogs: number;
|
||||
}
|
||||
|
||||
interface EngagementData {
|
||||
totalFamilies: number;
|
||||
featureAdoption: FeatureAdoption[];
|
||||
families: FamilyEngagement[];
|
||||
activitySummary: { active7d: number; active30d: number; neverActive: number; total: number };
|
||||
aiUsage: { totalCalls: number; totalTokens: number; totalCostPaise: number; familiesUsingAI: number };
|
||||
logsByDay: { date: string; count: number }[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
type Tab = "overview" | "families" | "ai";
|
||||
type SortField = "lastActivity" | "totalLogs" | "name";
|
||||
type ActivityFilter = "all" | "7d" | "30d" | "inactive" | "never";
|
||||
|
||||
export default function AdminAnalytics() {
|
||||
const [stats, setStats] = useState<EngagementStats | null>(null);
|
||||
const [data, setData] = useState<EngagementData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [tab, setTab] = useState<Tab>("overview");
|
||||
const [sortField, setSortField] = useState<SortField>("lastActivity");
|
||||
const [sortAsc, setSortAsc] = useState(false);
|
||||
const [activityFilter, setActivityFilter] = useState<ActivityFilter>("all");
|
||||
|
||||
useEffect(() => {
|
||||
fetchAnalytics();
|
||||
fetch("/api/admin/engagement", { credentials: "include" })
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
// Normalize into the full EngagementData shape so a malformed/error
|
||||
// response can never crash the render (e.g. [...data.families]).
|
||||
setData({
|
||||
totalFamilies: Number(d?.totalFamilies) || 0,
|
||||
featureAdoption: Array.isArray(d?.featureAdoption) ? d.featureAdoption : [],
|
||||
families: Array.isArray(d?.families) ? d.families : [],
|
||||
activitySummary: { active7d: 0, active30d: 0, neverActive: 0, total: 0, ...(d?.activitySummary || {}) },
|
||||
aiUsage: { totalCalls: 0, totalTokens: 0, totalCostPaise: 0, familiesUsingAI: 0, ...(d?.aiUsage || {}) },
|
||||
logsByDay: Array.isArray(d?.logsByDay) ? d.logsByDay : [],
|
||||
error: typeof d?.error === "string" ? d.error : undefined,
|
||||
});
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const fetchAnalytics = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/admin/analytics", { credentials: "include" });
|
||||
const data = await res.json();
|
||||
setStats(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch analytics:", err);
|
||||
}
|
||||
setLoading(false);
|
||||
if (loading) return <div className="p-6 text-white">Loading...</div>;
|
||||
if (!data) return <div className="p-6 text-red-400">Failed to load analytics</div>;
|
||||
|
||||
const handleSort = (field: SortField) => {
|
||||
if (sortField === field) setSortAsc(a => !a);
|
||||
else { setSortField(field); setSortAsc(false); }
|
||||
};
|
||||
|
||||
if (loading || !stats) {
|
||||
return <div className="p-6 text-white">Loading...</div>;
|
||||
}
|
||||
const sortedFamilies = [...data.families]
|
||||
.filter(f => activityFilter === "all" || f.activeStatus === activityFilter)
|
||||
.sort((a, b) => {
|
||||
let diff = 0;
|
||||
if (sortField === "lastActivity") {
|
||||
const ta = a.lastActivity ? new Date(a.lastActivity).getTime() : 0;
|
||||
const tb = b.lastActivity ? new Date(b.lastActivity).getTime() : 0;
|
||||
diff = tb - ta;
|
||||
} else if (sortField === "totalLogs") {
|
||||
diff = b.totalLogs - a.totalLogs;
|
||||
} else {
|
||||
diff = a.name.localeCompare(b.name);
|
||||
}
|
||||
return sortAsc ? -diff : diff;
|
||||
});
|
||||
|
||||
const maxLog = Math.max(...data.logsByDay.map(d => d.count), 1);
|
||||
const { aiUsage, activitySummary } = data;
|
||||
const costINR = (aiUsage.totalCostPaise / 100).toFixed(2);
|
||||
const costPerFamily = aiUsage.familiesUsingAI > 0
|
||||
? (aiUsage.totalCostPaise / 100 / aiUsage.familiesUsingAI).toFixed(2)
|
||||
: "0.00";
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Analytics</h1>
|
||||
<p className="text-gray-400">Feature usage and engagement</p>
|
||||
</div>
|
||||
|
||||
{/* Usage Overview */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
<StatCard label="Activity Logs" value={stats.totalLogs} icon="📝" />
|
||||
<StatCard label="Medicines" value={stats.totalMedicines} icon="💊" />
|
||||
<StatCard label="Vaccinations" value={stats.totalVaccinations} icon="💉" />
|
||||
<StatCard label="Growth" value={stats.totalGrowth} icon="📏" />
|
||||
<StatCard label="Memories" value={stats.totalMemories} icon="📸" />
|
||||
<StatCard label="Chat Sessions" value={stats.totalChatSessions} icon="💬" />
|
||||
</div>
|
||||
|
||||
{/* Activity Chart */}
|
||||
<div className="bg-gray-800 p-6 rounded-xl">
|
||||
<h3 className="text-lg font-semibold mb-4">Daily Activity (Last 30 days)</h3>
|
||||
<div className="h-48 flex items-end gap-1">
|
||||
{stats.logsByDay.slice(-30).map((d, i) => (
|
||||
<div key={i} className="flex-1 flex flex-col items-center gap-1">
|
||||
<div
|
||||
className="w-full bg-rose-500 rounded-t"
|
||||
style={{
|
||||
height: `${Math.min((d.count / Math.max(...stats.logsByDay.map(l => l.count), 1)) * 100, 100)}%`,
|
||||
minHeight: d.count > 0 ? "4px" : "0"
|
||||
}}
|
||||
/>
|
||||
<div className="text-[8px] text-gray-500">{d.date?.slice(5) || ""}</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Analytics</h1>
|
||||
<p className="text-gray-400">User journey and feature engagement</p>
|
||||
</div>
|
||||
<div className="flex gap-1 bg-gray-800 p-1 rounded-lg">
|
||||
{(["overview", "families", "ai"] as Tab[]).map(t => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={`px-3 py-1.5 rounded-md text-sm font-medium capitalize transition-colors ${tab === t ? "bg-gray-600 text-white" : "text-gray-400 hover:text-white"}`}
|
||||
>
|
||||
{t === "ai" ? "AI Usage" : t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{stats.logsByDay.length === 0 && (
|
||||
<div className="h-48 flex items-center justify-center text-gray-500">
|
||||
No activity data yet
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Feature Breakdown */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-gray-800 p-6 rounded-xl">
|
||||
<h3 className="text-lg font-semibold mb-4">Top Features</h3>
|
||||
<div className="space-y-3">
|
||||
{stats.topFeatures.map((feature, i) => (
|
||||
<div key={i} className="flex justify-between items-center">
|
||||
<span>{feature.name}</span>
|
||||
<span className="font-bold text-rose-400">{feature.count}</span>
|
||||
</div>
|
||||
{data.error && (
|
||||
<div className="bg-rose-500/10 border border-rose-500/30 text-rose-300 text-sm p-3 rounded-lg">
|
||||
<span className="font-semibold">Some analytics queries failed:</span>
|
||||
<span className="ml-1 font-mono break-words">{data.error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Activity Summary Cards — always visible */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<SummaryCard label="Active 7d" value={activitySummary.active7d} color="text-emerald-400" sub={`of ${activitySummary.total}`} />
|
||||
<SummaryCard label="Active 30d" value={activitySummary.active30d} color="text-blue-400" sub={`of ${activitySummary.total}`} />
|
||||
<SummaryCard label="Inactive" value={activitySummary.total - activitySummary.active7d - activitySummary.active30d - activitySummary.neverActive} color="text-amber-400" sub="no activity >30d" />
|
||||
<SummaryCard label="Never Active" value={activitySummary.neverActive} color="text-gray-500" sub="0 logs ever" />
|
||||
</div>
|
||||
|
||||
{tab === "overview" && (
|
||||
<>
|
||||
{/* Feature Adoption Funnel */}
|
||||
<div className="bg-gray-800 p-6 rounded-xl">
|
||||
<h3 className="text-lg font-semibold mb-5">Feature Adoption</h3>
|
||||
<div className="space-y-3">
|
||||
{data.featureAdoption.map(f => (
|
||||
<div key={f.name} className="flex items-center gap-4">
|
||||
<div className="w-32 text-sm text-gray-300 shrink-0">{f.name}</div>
|
||||
<div className="flex-1 bg-gray-700 rounded-full h-5 relative overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-rose-500 rounded-full transition-all"
|
||||
style={{ width: `${f.pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-24 text-right text-sm">
|
||||
<span className="font-bold text-white">{f.pct}%</span>
|
||||
<span className="text-gray-500 ml-1">({f.count})</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{data.featureAdoption.length === 0 && (
|
||||
<div className="text-gray-500 text-sm">No data yet</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Daily Activity Chart */}
|
||||
<div className="bg-gray-800 p-6 rounded-xl">
|
||||
<h3 className="text-lg font-semibold mb-4">Daily Activity — Last 30 Days</h3>
|
||||
<div className="h-40 flex items-end gap-1">
|
||||
{data.logsByDay.slice(-30).map((d, i) => (
|
||||
<div key={i} title={`${d.date}: ${d.count}`} className="flex-1 flex flex-col items-center gap-1 group">
|
||||
<div
|
||||
className="w-full bg-rose-500 rounded-t group-hover:bg-rose-400 transition-colors"
|
||||
style={{
|
||||
height: `${Math.min((d.count / maxLog) * 100, 100)}%`,
|
||||
minHeight: d.count > 0 ? "4px" : "0"
|
||||
}}
|
||||
/>
|
||||
<div className="text-[8px] text-gray-500">{d.date?.slice(5) || ""}</div>
|
||||
</div>
|
||||
))}
|
||||
{data.logsByDay.length === 0 && (
|
||||
<div className="w-full text-center text-gray-500 text-sm self-center">No activity in the last 30 days</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "families" && (
|
||||
<div className="bg-gray-800 rounded-xl overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-gray-700 flex gap-2 flex-wrap">
|
||||
{(["all", "7d", "30d", "inactive", "never"] as ActivityFilter[]).map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setActivityFilter(f)}
|
||||
className={`px-3 py-1 rounded-lg text-sm transition-colors ${activityFilter === f ? "bg-rose-500 text-white" : "bg-gray-700 text-gray-300 hover:bg-gray-600"}`}
|
||||
>
|
||||
{f === "all" ? "All" : f === "7d" ? "Active 7d" : f === "30d" ? "Active 30d" : f === "inactive" ? "Inactive >30d" : "Never Active"}
|
||||
{f !== "all" && (
|
||||
<span className="ml-1.5 text-xs opacity-70">
|
||||
{data.families.filter(x => x.activeStatus === f).length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{stats.topFeatures.length === 0 && (
|
||||
<div className="text-gray-500">No data yet</div>
|
||||
<span className="text-gray-500 text-sm self-center ml-auto">{sortedFamilies.length} families</span>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">Family</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">Tier</th>
|
||||
<SortHeader label="Last Active" field="lastActivity" current={sortField} asc={sortAsc} onSort={handleSort} />
|
||||
<SortHeader label="Total Logs" field="totalLogs" current={sortField} asc={sortAsc} onSort={handleSort} />
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">Features Used</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">AI Chats</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-700">
|
||||
{sortedFamilies.map(f => (
|
||||
<tr key={f.id} className="hover:bg-gray-750">
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium">{f.name}</div>
|
||||
<div className="text-xs text-gray-500">{f.createdAt?.slice(0, 10)}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`text-xs px-2 py-0.5 rounded font-medium ${f.tier === "pro" ? "bg-rose-500/20 text-rose-400" : "bg-gray-700 text-gray-400"}`}>
|
||||
{f.tier}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{f.lastActivity ? (
|
||||
<span className={f.activeStatus === "7d" ? "text-emerald-400" : f.activeStatus === "30d" ? "text-blue-400" : "text-gray-500"}>
|
||||
{f.lastActivity.slice(0, 10)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-600">Never</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm font-medium">{f.totalLogs}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{f.feedCount > 0 && <FeatureBadge emoji="🍼" label="Feed" />}
|
||||
{f.sleepCount > 0 && <FeatureBadge emoji="💤" label="Sleep" />}
|
||||
{f.diaperCount > 0 && <FeatureBadge emoji="🚼" label="Diaper" />}
|
||||
{f.vaccinationCount > 0 && <FeatureBadge emoji="💉" label="Vacc" />}
|
||||
{f.growthCount > 0 && <FeatureBadge emoji="📏" label="Growth" />}
|
||||
{f.memoryCount > 0 && <FeatureBadge emoji="📸" label="Memory" />}
|
||||
{f.chatCount > 0 && <FeatureBadge emoji="💬" label="AI" />}
|
||||
{f.totalLogs === 0 && f.memoryCount === 0 && f.chatCount === 0 && (
|
||||
<span className="text-gray-600 text-xs">None</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-300">{f.chatCount || "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{sortedFamilies.length === 0 && (
|
||||
<div className="p-8 text-center text-gray-500">No families match this filter</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-gray-800 p-6 rounded-xl">
|
||||
<h3 className="text-lg font-semibold mb-4">Engagement Summary</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-400">Avg Logs per Family</span>
|
||||
{tab === "ai" && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<SummaryCard label="API Calls (30d)" value={aiUsage.totalCalls} color="text-blue-400" />
|
||||
<SummaryCard label="Total Tokens" value={aiUsage.totalTokens.toLocaleString()} color="text-purple-400" />
|
||||
<SummaryCard label="Cost (30d)" value={`₹${costINR}`} color="text-amber-400" />
|
||||
<SummaryCard label="Families Using AI" value={aiUsage.familiesUsingAI} color="text-emerald-400" />
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-800 p-6 rounded-xl space-y-3">
|
||||
<h3 className="text-lg font-semibold">AI Cost Breakdown</h3>
|
||||
<div className="flex justify-between text-sm py-2 border-b border-gray-700">
|
||||
<span className="text-gray-400">Total cost (30 days)</span>
|
||||
<span className="font-bold">₹{costINR}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm py-2 border-b border-gray-700">
|
||||
<span className="text-gray-400">Avg cost per AI-using family</span>
|
||||
<span className="font-bold">₹{costPerFamily}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm py-2 border-b border-gray-700">
|
||||
<span className="text-gray-400">Total tokens used</span>
|
||||
<span className="font-bold">{aiUsage.totalTokens.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm py-2">
|
||||
<span className="text-gray-400">Families using AI</span>
|
||||
<span className="font-bold">
|
||||
{stats.totalLogs > 0 ? (stats.totalLogs / 1).toFixed(1) : "0"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-400">Avg Children per Family</span>
|
||||
<span className="font-bold">1.0</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-400">Chat Adoption</span>
|
||||
<span className="font-bold text-emerald-400">
|
||||
{stats.totalChatSessions > 0 ? "Active" : "None"}
|
||||
{aiUsage.familiesUsingAI} / {data.totalFamilies}
|
||||
<span className="text-gray-500 ml-1 text-xs">
|
||||
({data.totalFamilies > 0 ? Math.round(aiUsage.familiesUsingAI / data.totalFamilies * 100) : 0}%)
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value, icon }: { label: string; value: number; icon: string }) {
|
||||
function SummaryCard({ label, value, color, sub }: { label: string; value: string | number; color: string; sub?: string }) {
|
||||
return (
|
||||
<div className="bg-gray-800 p-4 rounded-xl">
|
||||
<div className="text-2xl mb-1">{icon}</div>
|
||||
<div className="text-xl font-bold">{value}</div>
|
||||
<div className="text-xs text-gray-400">{label}</div>
|
||||
<div className={`text-2xl font-bold ${color}`}>{value}</div>
|
||||
<div className="text-sm text-gray-300 mt-0.5">{label}</div>
|
||||
{sub && <div className="text-xs text-gray-500 mt-0.5">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function FeatureBadge({ emoji, label }: { emoji: string; label: string }) {
|
||||
return (
|
||||
<span title={label} className="text-xs bg-gray-700 px-1.5 py-0.5 rounded">
|
||||
{emoji}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SortHeader({ label, field, current, asc, onSort }: {
|
||||
label: string; field: SortField; current: SortField; asc: boolean; onSort: (f: SortField) => void;
|
||||
}) {
|
||||
return (
|
||||
<th
|
||||
className="px-4 py-3 text-left text-sm font-medium cursor-pointer hover:text-white select-none"
|
||||
onClick={() => onSort(field)}
|
||||
>
|
||||
{label} {current === field ? (asc ? "↑" : "↓") : "↕"}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
134
src/app/admin/audit/page.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
interface AuditRow {
|
||||
id: string;
|
||||
action: string;
|
||||
resource_type: string | null;
|
||||
resource_id: string | null;
|
||||
ip_address: string | null;
|
||||
user_agent: string | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
created_at: string;
|
||||
user_email: string | null;
|
||||
user_name: string | null;
|
||||
family_name: string | null;
|
||||
}
|
||||
interface Data { events: AuditRow[]; actions: string[]; resourceTypes: string[]; error?: string }
|
||||
|
||||
const WINDOWS = [
|
||||
{ label: "24h", hours: 24 },
|
||||
{ label: "7d", hours: 168 },
|
||||
{ label: "30d", hours: 720 },
|
||||
{ label: "90d", hours: 2160 },
|
||||
];
|
||||
|
||||
export default function AdminAudit() {
|
||||
const [data, setData] = useState<Data | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [action, setAction] = useState("");
|
||||
const [resourceType, setResourceType] = useState("");
|
||||
const [hours, setHours] = useState(168);
|
||||
const [q, setQ] = useState("");
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams({ sinceHours: String(hours) });
|
||||
if (action) params.set("action", action);
|
||||
if (resourceType) params.set("resourceType", resourceType);
|
||||
if (q.trim()) params.set("q", q.trim());
|
||||
fetch(`/api/admin/audit?${params}`, { credentials: "include" })
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
setData({
|
||||
events: Array.isArray(d?.events) ? d.events : [],
|
||||
actions: Array.isArray(d?.actions) ? d.actions : [],
|
||||
resourceTypes: Array.isArray(d?.resourceTypes) ? d.resourceTypes : [],
|
||||
error: d?.error,
|
||||
});
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
}, [hours, action, resourceType, q]);
|
||||
|
||||
useEffect(() => { load(); }, [hours, action, resourceType]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex justify-between items-start flex-wrap gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Audit Log</h1>
|
||||
<p className="text-gray-400">Every recorded action across the platform</p>
|
||||
</div>
|
||||
<button onClick={load} className="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded-lg text-sm">↻ Refresh</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 flex-wrap items-center">
|
||||
<select value={action} onChange={e => setAction(e.target.value)} className="bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-sm">
|
||||
<option value="">All actions</option>
|
||||
{data?.actions.map(a => <option key={a} value={a}>{a}</option>)}
|
||||
</select>
|
||||
<select value={resourceType} onChange={e => setResourceType(e.target.value)} className="bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-sm">
|
||||
<option value="">All resources</option>
|
||||
{data?.resourceTypes.map(r => <option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
<div className="flex gap-1 bg-gray-800 p-1 rounded-lg">
|
||||
{WINDOWS.map(w => (
|
||||
<button key={w.hours} onClick={() => setHours(w.hours)} className={`px-3 py-1.5 rounded-md text-sm ${hours === w.hours ? "bg-gray-600" : "text-gray-400 hover:text-white"}`}>{w.label}</button>
|
||||
))}
|
||||
</div>
|
||||
<form onSubmit={e => { e.preventDefault(); load(); }} className="flex-1 min-w-[180px]">
|
||||
<input value={q} onChange={e => setQ(e.target.value)} placeholder="Search user / family / action…" className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-sm" />
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{data?.error && <div className="bg-rose-500/10 text-rose-400 text-sm p-3 rounded-lg">Feed error: {data.error}</div>}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-gray-400">Loading…</div>
|
||||
) : (
|
||||
<div className="bg-gray-800 rounded-xl overflow-hidden">
|
||||
{(data?.events.length ?? 0) === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">No audit events match these filters</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-700">
|
||||
{data!.events.map(e => {
|
||||
const hasMeta = e.metadata && Object.keys(e.metadata).length > 0;
|
||||
return (
|
||||
<div key={e.id} className="p-4 hover:bg-gray-750">
|
||||
<div className="flex items-start gap-3 cursor-pointer" onClick={() => hasMeta && setExpanded(expanded === e.id ? null : e.id)}>
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-gray-700 text-gray-200 font-medium whitespace-nowrap">{e.action}</span>
|
||||
{e.resource_type && <span className="text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-400">{e.resource_type}</span>}
|
||||
<div className="flex-1 min-w-0 text-xs text-gray-500 flex gap-2 flex-wrap">
|
||||
{(e.user_email || e.user_name) && <span>👤 {e.user_name || e.user_email}</span>}
|
||||
{e.family_name && <span>🏠 {e.family_name}</span>}
|
||||
{e.ip_address && <span>🌐 {e.ip_address}</span>}
|
||||
<span>🕒 {timeAgo(e.created_at)}</span>
|
||||
</div>
|
||||
{hasMeta && <span className="text-gray-500 text-xs">{expanded === e.id ? "▲" : "▼"}</span>}
|
||||
</div>
|
||||
{hasMeta && expanded === e.id && (
|
||||
<pre className="mt-3 bg-gray-900 p-3 rounded-lg text-xs text-gray-400 overflow-x-auto whitespace-pre-wrap break-words">{JSON.stringify(e.metadata, null, 2)}</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function timeAgo(iso: string) {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const m = Math.floor(diff / 60000);
|
||||
if (m < 1) return "just now";
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
return `${Math.floor(h / 24)}d ago`;
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, Input } from "@/components/ui";
|
||||
|
||||
interface Child {
|
||||
id: string;
|
||||
|
|
@ -15,11 +16,19 @@ export default function AdminChildren() {
|
|||
const [children, setChildren] = useState<Child[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchChildren();
|
||||
}, []);
|
||||
|
||||
const showMessage = (msg: string, type: "success" | "error") => {
|
||||
if (type === "success") { setSuccess(msg); setTimeout(() => setSuccess(null), 3000); }
|
||||
else { setError(msg); setTimeout(() => setError(null), 5000); }
|
||||
};
|
||||
|
||||
const fetchChildren = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/admin/children", { credentials: "include" });
|
||||
|
|
@ -31,9 +40,48 @@ export default function AdminChildren() {
|
|||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleDeleteChild = async (child: Child) => {
|
||||
// Fetch counts for the confirmation message
|
||||
let confirmMsg = `Delete ${child.name}? This will permanently delete all their logs, growth records, vaccinations, and associated data. This cannot be undone.`;
|
||||
try {
|
||||
const res = await fetch(`/api/admin/children/${child.id}`, { credentials: "include" });
|
||||
if (res.ok) {
|
||||
const counts = await res.json();
|
||||
const total =
|
||||
Number(counts.feed_count || 0) +
|
||||
Number(counts.diaper_count || 0) +
|
||||
Number(counts.sleep_count || 0);
|
||||
const parts: string[] = [];
|
||||
if (total > 0) parts.push(`${total} activity logs`);
|
||||
if (Number(counts.growth_count || 0) > 0) parts.push(`${counts.growth_count} growth records`);
|
||||
if (Number(counts.vaccination_count || 0) > 0) parts.push(`${counts.vaccination_count} vaccination records`);
|
||||
if (parts.length > 0) {
|
||||
confirmMsg = `Delete ${child.name}? This will permanently delete ${parts.join(", ")}, and all associated data. This cannot be undone.`;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (!window.confirm(confirmMsg)) return;
|
||||
|
||||
setDeletingId(child.id);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/children/${child.id}`, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Delete failed");
|
||||
showMessage(`${child.name} and all data deleted`, "success");
|
||||
setChildren(prev => prev.filter(c => c.id !== child.id));
|
||||
} catch (err: any) {
|
||||
showMessage(err.message, "error");
|
||||
}
|
||||
setDeletingId(null);
|
||||
};
|
||||
|
||||
const filteredChildren = children.filter((c) =>
|
||||
c.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
c.familyName.toLowerCase().includes(search.toLowerCase())
|
||||
(c.familyName || "").toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
const exportCSV = () => {
|
||||
|
|
@ -48,28 +96,35 @@ export default function AdminChildren() {
|
|||
a.click();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-6 text-white">Loading...</div>;
|
||||
}
|
||||
if (loading) return <div className="p-6 text-white">Loading...</div>;
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-500/15 border border-red-500/30 text-red-400 px-4 py-3 rounded-xl text-sm flex justify-between items-center">
|
||||
<span>⚠ {error}</span>
|
||||
<button onClick={() => setError(null)} className="ml-4 opacity-60 hover:opacity-100">✕</button>
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<div className="bg-emerald-500/15 border border-emerald-500/30 text-emerald-400 px-4 py-3 rounded-xl text-sm">
|
||||
✓ {success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Children</h1>
|
||||
<p className="text-gray-400">{children.length} total children</p>
|
||||
</div>
|
||||
<button onClick={exportCSV} className="px-4 py-2 bg-emerald-600 hover:bg-emerald-700 rounded-lg">
|
||||
Export CSV
|
||||
</button>
|
||||
<Button variant="secondary" onClick={exportCSV}>Export CSV</Button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search children..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
|
||||
/>
|
||||
|
||||
<div className="bg-gray-800 rounded-xl overflow-hidden">
|
||||
|
|
@ -80,6 +135,7 @@ export default function AdminChildren() {
|
|||
<th className="px-4 py-3 text-left text-sm font-medium">Birth Date</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">Age</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">Family</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-700">
|
||||
|
|
@ -91,6 +147,17 @@ export default function AdminChildren() {
|
|||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-400">{child.age}</td>
|
||||
<td className="px-4 py-3">{child.familyName}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
loading={deletingId === child.id}
|
||||
disabled={!!deletingId}
|
||||
onClick={() => handleDeleteChild(child)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
|
@ -101,4 +168,4 @@ export default function AdminChildren() {
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
192
src/app/admin/errors/page.tsx
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
interface ErrorRow {
|
||||
id: string;
|
||||
level: string;
|
||||
source: string;
|
||||
message: string;
|
||||
stack: string | null;
|
||||
url: string | null;
|
||||
digest: string | null;
|
||||
user_email: string | null;
|
||||
family_name: string | null;
|
||||
user_agent: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
interface GroupRow { message: string; source: string; count: number; last_seen: string; first_seen: string }
|
||||
interface Stats { last24h: number; last7d: number; client7d: number; server7d: number }
|
||||
interface Data { events: ErrorRow[]; grouped: GroupRow[]; stats: Stats; error?: string }
|
||||
|
||||
const WINDOWS = [
|
||||
{ label: "24h", hours: 24 },
|
||||
{ label: "7d", hours: 168 },
|
||||
{ label: "30d", hours: 720 },
|
||||
];
|
||||
|
||||
export default function AdminErrors() {
|
||||
const [data, setData] = useState<Data | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [tab, setTab] = useState<"recent" | "grouped">("recent");
|
||||
const [source, setSource] = useState<"" | "client" | "server">("");
|
||||
const [hours, setHours] = useState(168);
|
||||
const [q, setQ] = useState("");
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams({ sinceHours: String(hours) });
|
||||
if (source) params.set("source", source);
|
||||
if (q.trim()) params.set("q", q.trim());
|
||||
fetch(`/api/admin/errors?${params}`, { credentials: "include" })
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
setData({
|
||||
events: Array.isArray(d?.events) ? d.events : [],
|
||||
grouped: Array.isArray(d?.grouped) ? d.grouped : [],
|
||||
stats: d?.stats || { last24h: 0, last7d: 0, client7d: 0, server7d: 0 },
|
||||
error: d?.error,
|
||||
});
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
}, [hours, source, q]);
|
||||
|
||||
useEffect(() => { load(); }, [hours, source]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const stats = data?.stats || { last24h: 0, last7d: 0, client7d: 0, server7d: 0 };
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex justify-between items-start flex-wrap gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Errors & Crashes</h1>
|
||||
<p className="text-gray-400">Client & server errors captured automatically</p>
|
||||
</div>
|
||||
<button onClick={load} className="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded-lg text-sm">↻ Refresh</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Card label="Last 24h" value={stats.last24h} color={stats.last24h > 0 ? "text-rose-400" : "text-emerald-400"} />
|
||||
<Card label="Last 7d" value={stats.last7d} color="text-amber-400" />
|
||||
<Card label="Client (7d)" value={stats.client7d} color="text-blue-400" />
|
||||
<Card label="Server (7d)" value={stats.server7d} color="text-purple-400" />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 flex-wrap items-center">
|
||||
<div className="flex gap-1 bg-gray-800 p-1 rounded-lg">
|
||||
{(["recent", "grouped"] as const).map(t => (
|
||||
<button key={t} onClick={() => setTab(t)} className={`px-3 py-1.5 rounded-md text-sm font-medium capitalize ${tab === t ? "bg-gray-600" : "text-gray-400 hover:text-white"}`}>{t}</button>
|
||||
))}
|
||||
</div>
|
||||
<select value={source} onChange={e => setSource(e.target.value as "" | "client" | "server")} className="bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-sm">
|
||||
<option value="">All sources</option>
|
||||
<option value="client">Client</option>
|
||||
<option value="server">Server</option>
|
||||
</select>
|
||||
<div className="flex gap-1 bg-gray-800 p-1 rounded-lg">
|
||||
{WINDOWS.map(w => (
|
||||
<button key={w.hours} onClick={() => setHours(w.hours)} className={`px-3 py-1.5 rounded-md text-sm ${hours === w.hours ? "bg-gray-600" : "text-gray-400 hover:text-white"}`}>{w.label}</button>
|
||||
))}
|
||||
</div>
|
||||
<form onSubmit={e => { e.preventDefault(); load(); }} className="flex-1 min-w-[180px]">
|
||||
<input value={q} onChange={e => setQ(e.target.value)} placeholder="Search message…" className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-sm" />
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{data?.error && <div className="bg-rose-500/10 text-rose-400 text-sm p-3 rounded-lg">Feed error: {data.error}</div>}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-gray-400">Loading…</div>
|
||||
) : tab === "recent" ? (
|
||||
<div className="bg-gray-800 rounded-xl overflow-hidden">
|
||||
{(data?.events.length ?? 0) === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">🎉 No errors in this window</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-700">
|
||||
{data!.events.map(e => (
|
||||
<div key={e.id} className="p-4 hover:bg-gray-750">
|
||||
<div className="flex items-start gap-3 cursor-pointer" onClick={() => setExpanded(expanded === e.id ? null : e.id)}>
|
||||
<LevelBadge level={e.level} />
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${e.source === "client" ? "bg-blue-500/20 text-blue-400" : "bg-purple-500/20 text-purple-400"}`}>{e.source}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium break-words">{e.message}</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5 flex gap-2 flex-wrap">
|
||||
{e.url && <span>📍 {e.url}</span>}
|
||||
{e.user_email && <span>👤 {e.user_email}</span>}
|
||||
{e.family_name && <span>🏠 {e.family_name}</span>}
|
||||
<span>🕒 {timeAgo(e.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-gray-500 text-xs">{expanded === e.id ? "▲" : "▼"}</span>
|
||||
</div>
|
||||
{expanded === e.id && (
|
||||
<pre className="mt-3 bg-gray-900 p-3 rounded-lg text-xs text-gray-400 overflow-x-auto whitespace-pre-wrap break-words max-h-72">
|
||||
{e.stack || "(no stack trace)"}
|
||||
{e.user_agent ? `\n\nUser-Agent: ${e.user_agent}` : ""}
|
||||
{e.digest ? `\nDigest: ${e.digest}` : ""}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-gray-800 rounded-xl overflow-hidden">
|
||||
{(data?.grouped.length ?? 0) === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">No errors to group</div>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-700"><tr>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">Count</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">Source</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">Message</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">Last seen</th>
|
||||
</tr></thead>
|
||||
<tbody className="divide-y divide-gray-700">
|
||||
{data!.grouped.map((g, i) => (
|
||||
<tr key={i} className="hover:bg-gray-750">
|
||||
<td className="px-4 py-3"><span className="bg-rose-500/20 text-rose-400 px-2 py-0.5 rounded font-bold text-sm">{g.count}</span></td>
|
||||
<td className="px-4 py-3 text-sm text-gray-400">{g.source}</td>
|
||||
<td className="px-4 py-3 text-sm break-words">{g.message}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500 whitespace-nowrap">{timeAgo(g.last_seen)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({ label, value, color }: { label: string; value: number; color: string }) {
|
||||
return (
|
||||
<div className="bg-gray-800 p-4 rounded-xl">
|
||||
<div className={`text-2xl font-bold ${color}`}>{value}</div>
|
||||
<div className="text-sm text-gray-300 mt-0.5">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LevelBadge({ level }: { level: string }) {
|
||||
const map: Record<string, string> = {
|
||||
fatal: "bg-rose-600/30 text-rose-300",
|
||||
error: "bg-rose-500/20 text-rose-400",
|
||||
warn: "bg-amber-500/20 text-amber-400",
|
||||
};
|
||||
return <span className={`text-xs px-1.5 py-0.5 rounded font-medium ${map[level] || "bg-gray-700 text-gray-400"}`}>{level}</span>;
|
||||
}
|
||||
|
||||
function timeAgo(iso: string) {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const m = Math.floor(diff / 60000);
|
||||
if (m < 1) return "just now";
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
return `${Math.floor(h / 24)}d ago`;
|
||||
}
|
||||