Compare commits
82 commits
feature/qu
...
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 |
128 changed files with 8431 additions and 881 deletions
39
CLAUDE.md
39
CLAUDE.md
|
|
@ -75,6 +75,16 @@ drizzle/meta/_journal.json # Migration order — MUST update when adding new SQL
|
||||||
2. Add an entry to `drizzle/meta/_journal.json` with the next `idx` and matching `tag`
|
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
|
3. Push to git → Dokploy auto-applies on next deploy
|
||||||
|
|
||||||
|
> ⚠️ **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.
|
||||||
|
|
||||||
### Hot-fix: apply migration without waiting for redeploy
|
### Hot-fix: apply migration without waiting for redeploy
|
||||||
Use the **debug-migration endpoint** (same pattern as the Circles implementation):
|
Use the **debug-migration endpoint** (same pattern as the Circles implementation):
|
||||||
|
|
||||||
|
|
@ -227,6 +237,27 @@ Auth pattern: server component layout calls `verifyAdminSession()` → redirects
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 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 Storage Rules
|
||||||
|
|
||||||
| Data Type | Storage | API |
|
| Data Type | Storage | API |
|
||||||
|
|
@ -267,7 +298,13 @@ Set in `.env.local` for development, Dokploy dashboard for production.
|
||||||
| `RESEND_API_KEY` | ✅ | Resend API key for transactional email |
|
| `RESEND_API_KEY` | ✅ | Resend API key for transactional email |
|
||||||
| `EMAIL_FROM` | ✅ | Sender address (e.g. `Tia <tia@manohargupta.com>`) |
|
| `EMAIL_FROM` | ✅ | Sender address (e.g. `Tia <tia@manohargupta.com>`) |
|
||||||
| `NEXT_PUBLIC_APP_URL` | ✅ | Full app URL (e.g. `https://tia.manohargupta.com`) |
|
| `NEXT_PUBLIC_APP_URL` | ✅ | Full app URL (e.g. `https://tia.manohargupta.com`) |
|
||||||
| `CRON_SECRET` | ✅ | Secret for cron backup endpoint |
|
| `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) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
2
drizzle/0008_pediatrician_name.sql
Normal file
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
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
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);
|
||||||
2
drizzle/0011_user_phone.sql
Normal file
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
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()
|
||||||
|
);
|
||||||
|
|
@ -57,6 +57,41 @@
|
||||||
"when": 1748480400000,
|
"when": 1748480400000,
|
||||||
"tag": "0007_subscription_status",
|
"tag": "0007_subscription_status",
|
||||||
"breakpoints": true
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -22,10 +22,11 @@ const nextConfig: NextConfig = {
|
||||||
{ key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" },
|
{ key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" },
|
||||||
{ key: "Content-Security-Policy", value:
|
{ key: "Content-Security-Policy", value:
|
||||||
"default-src 'self'; " +
|
"default-src 'self'; " +
|
||||||
"img-src 'self' data: https://*.r2.cloudflarestorage.com https://*.r2.dev; " +
|
"img-src 'self' data: https://*.r2.cloudflarestorage.com https://*.r2.dev https://*.razorpay.com; " +
|
||||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; " +
|
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://checkout.razorpay.com; " +
|
||||||
"style-src 'self' 'unsafe-inline'; " +
|
"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:;"
|
"font-src 'self' data:;"
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
BIN
public/images/family-illustration.jpg
Normal file
BIN
public/images/family-illustration.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 223 KiB |
BIN
public/images/family-illustration.png
Normal file
BIN
public/images/family-illustration.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2 MiB |
BIN
public/images/memory-bath.jpg
Normal file
BIN
public/images/memory-bath.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
public/images/memory-milestone.jpg
Normal file
BIN
public/images/memory-milestone.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
BIN
public/images/tia-portrait.jpg
Normal file
BIN
public/images/tia-portrait.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
BIN
public/images/tia-portrait.png
Normal file
BIN
public/images/tia-portrait.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 885 KiB |
File diff suppressed because one or more lines are too long
1
public/umami.js
Normal file
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)}();
|
||||||
|
|
@ -8,6 +8,7 @@ import { api } from "@/lib/api";
|
||||||
import { CalendarView } from "@/components/CalendarView";
|
import { CalendarView } from "@/components/CalendarView";
|
||||||
import { LogModal, type LogType as ModalLogType, type SmartDefault } from "@/components/LogModal";
|
import { LogModal, type LogType as ModalLogType, type SmartDefault } from "@/components/LogModal";
|
||||||
import type { Log, LogType } from "@/types";
|
import type { Log, LogType } from "@/types";
|
||||||
|
import { dateIST, todayIST, fmtTime, fmtDate, dayLabel } from "@/lib/date-ist";
|
||||||
|
|
||||||
type ViewMode = "timeline" | "calendar";
|
type ViewMode = "timeline" | "calendar";
|
||||||
|
|
||||||
|
|
@ -23,13 +24,9 @@ function getIcon(type: LogType) {
|
||||||
return "📝";
|
return "📝";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dateStr is now an IST YYYY-MM-DD string (from dateIST())
|
||||||
function formatDayLabel(dateStr: string): string {
|
function formatDayLabel(dateStr: string): string {
|
||||||
const d = new Date(dateStr);
|
return dayLabel(dateStr);
|
||||||
const today = new Date().toDateString();
|
|
||||||
const yesterday = new Date(Date.now() - 86_400_000).toDateString();
|
|
||||||
if (d.toDateString() === today) return "Today";
|
|
||||||
if (d.toDateString() === yesterday) return "Yesterday";
|
|
||||||
return d.toLocaleDateString("en-IN", { weekday: "short", month: "short", day: "numeric" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ActivityPage() {
|
export default function ActivityPage() {
|
||||||
|
|
@ -94,14 +91,18 @@ export default function ActivityPage() {
|
||||||
const handleEdit = (log: Log) => {
|
const handleEdit = (log: Log) => {
|
||||||
// Store the old log to delete after the new one is saved
|
// Store the old log to delete after the new one is saved
|
||||||
setPendingDeleteId({ id: log.id, type: log.type });
|
setPendingDeleteId({ id: log.id, type: log.type });
|
||||||
setSmartDefault({ subType: log.subType ?? "", amountMl: log.amount ?? undefined } as SmartDefault);
|
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);
|
setModalType(log.type as ModalLogType);
|
||||||
setSelectedLog(null);
|
setSelectedLog(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Today's stats (computed from loaded logs — no extra fetch)
|
// Today's stats (computed from loaded logs — no extra fetch)
|
||||||
const todayStr = new Date().toDateString();
|
const todayStr = todayIST();
|
||||||
const todayLogs = logs.filter(l => new Date(l.loggedAt).toDateString() === todayStr);
|
const todayLogs = logs.filter(l => dateIST(l.loggedAt) === todayStr);
|
||||||
const todayCounts = {
|
const todayCounts = {
|
||||||
feed: todayLogs.filter(l => l.type === "feed").length,
|
feed: todayLogs.filter(l => l.type === "feed").length,
|
||||||
sleep: todayLogs.filter(l => l.type === "sleep").length,
|
sleep: todayLogs.filter(l => l.type === "sleep").length,
|
||||||
|
|
@ -111,21 +112,21 @@ export default function ActivityPage() {
|
||||||
// Last 4 calendar days (computed from loaded logs — no extra fetch)
|
// Last 4 calendar days (computed from loaded logs — no extra fetch)
|
||||||
const last4Days = Array.from({ length: 4 }, (_, i) => {
|
const last4Days = Array.from({ length: 4 }, (_, i) => {
|
||||||
const d = new Date(Date.now() - i * 86_400_000);
|
const d = new Date(Date.now() - i * 86_400_000);
|
||||||
const dateStr = d.toDateString();
|
const dateStr = dateIST(d);
|
||||||
const dayLogs = logs.filter(l => new Date(l.loggedAt).toDateString() === dateStr);
|
const dLogs = logs.filter(l => dateIST(l.loggedAt) === dateStr);
|
||||||
return {
|
return {
|
||||||
date: dateStr,
|
date: dateStr,
|
||||||
label: i === 0 ? "Today" : i === 1 ? "Yest." : d.toLocaleDateString("en-IN", { weekday: "short" }),
|
label: i === 0 ? "Today" : i === 1 ? "Yest." : fmtDate(d, { weekday: "short" }),
|
||||||
feed: dayLogs.filter(l => l.type === "feed").length,
|
feed: dLogs.filter(l => l.type === "feed").length,
|
||||||
sleep: dayLogs.filter(l => l.type === "sleep").length,
|
sleep: dLogs.filter(l => l.type === "sleep").length,
|
||||||
diaper: dayLogs.filter(l => l.type === "diaper").length,
|
diaper: dLogs.filter(l => l.type === "diaper").length,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const filteredLogs = filter === "all" ? logs : logs.filter(l => l.type === filter);
|
const filteredLogs = filter === "all" ? logs : logs.filter(l => l.type === filter);
|
||||||
|
|
||||||
const groupedByDay = filteredLogs.reduce<DayLogs[]>((acc, log) => {
|
const groupedByDay = filteredLogs.reduce<DayLogs[]>((acc, log) => {
|
||||||
const date = new Date(log.loggedAt).toDateString();
|
const date = dateIST(log.loggedAt);
|
||||||
const existing = acc.find(d => d.date === date);
|
const existing = acc.find(d => d.date === date);
|
||||||
if (existing) existing.logs.push(log);
|
if (existing) existing.logs.push(log);
|
||||||
else acc.push({ date, logs: [log] });
|
else acc.push({ date, logs: [log] });
|
||||||
|
|
@ -359,7 +360,7 @@ export default function ActivityPage() {
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||||
<span className="text-sm text-gray-400">
|
<span className="text-sm text-gray-400">
|
||||||
{new Date(log.loggedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
{fmtTime(log.loggedAt)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-gray-300 dark:text-gray-600 group-hover:text-rose-400 transition-colors text-base leading-none">›</span>
|
<span className="text-gray-300 dark:text-gray-600 group-hover:text-rose-400 transition-colors text-base leading-none">›</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -374,7 +375,7 @@ export default function ActivityPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* FAB */}
|
{/* FAB */}
|
||||||
<div className="fixed bottom-20 right-5 flex flex-col items-end gap-2 z-40">
|
<div className="fixed bottom-24 right-5 flex flex-col items-end gap-2 z-50">
|
||||||
{fabOpen && (["feed", "sleep", "diaper"] as ModalLogType[]).map(t => (
|
{fabOpen && (["feed", "sleep", "diaper"] as ModalLogType[]).map(t => (
|
||||||
<button
|
<button
|
||||||
key={t}
|
key={t}
|
||||||
|
|
@ -413,7 +414,7 @@ export default function ActivityPage() {
|
||||||
{[
|
{[
|
||||||
selectedLog.subType?.replace(/_/g, " "),
|
selectedLog.subType?.replace(/_/g, " "),
|
||||||
selectedLog.amount ? `${selectedLog.amount}ml` : null,
|
selectedLog.amount ? `${selectedLog.amount}ml` : null,
|
||||||
new Date(selectedLog.loggedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
|
fmtTime(selectedLog.loggedAt),
|
||||||
].filter(Boolean).join(" · ")}
|
].filter(Boolean).join(" · ")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -473,7 +474,7 @@ export default function ActivityPage() {
|
||||||
const { date: sheetDateStr, type: sheetType } = daySheetDate;
|
const { date: sheetDateStr, type: sheetType } = daySheetDate;
|
||||||
const sheetLogs = logs
|
const sheetLogs = logs
|
||||||
.filter(l =>
|
.filter(l =>
|
||||||
new Date(l.loggedAt).toDateString() === sheetDateStr &&
|
dateIST(l.loggedAt) === sheetDateStr &&
|
||||||
l.type === sheetType
|
l.type === sheetType
|
||||||
)
|
)
|
||||||
.sort((a, b) => new Date(b.loggedAt).getTime() - new Date(a.loggedAt).getTime());
|
.sort((a, b) => new Date(b.loggedAt).getTime() - new Date(a.loggedAt).getTime());
|
||||||
|
|
@ -538,7 +539,7 @@ export default function ActivityPage() {
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
<span className="text-sm text-gray-400">
|
<span className="text-sm text-gray-400">
|
||||||
{new Date(log.loggedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
{fmtTime(log.loggedAt)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-gray-300 dark:text-gray-600 group-hover:text-rose-400 transition-colors text-lg">›</span>
|
<span className="text-gray-300 dark:text-gray-600 group-hover:text-rose-400 transition-colors text-lg">›</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
|
||||||
import { useFamily } from "@/app/FamilyProvider";
|
import { useFamily } from "@/app/FamilyProvider";
|
||||||
import { Button, Input, ConfirmDialog } from "@/components/ui";
|
import { Button, Input, ConfirmDialog } from "@/components/ui";
|
||||||
import type { AIChat, ChatSession } from "@/types";
|
import type { AIChat, ChatSession } from "@/types";
|
||||||
|
import { fmtDate } from "@/lib/date-ist";
|
||||||
|
|
||||||
export default function AIChatPage() {
|
export default function AIChatPage() {
|
||||||
const { childId } = useFamily();
|
const { childId } = useFamily();
|
||||||
|
|
@ -50,8 +51,12 @@ export default function AIChatPage() {
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.session) {
|
if (data.session) {
|
||||||
setSessions([data.session, ...sessions]);
|
// POST /api/chat returns a session without a `messages` field — normalize it
|
||||||
setCurrentSessionId(data.session.id);
|
// 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);
|
setSidebarOpen(false);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -149,7 +154,7 @@ export default function AIChatPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Sidebar overlay on mobile */}
|
||||||
{sidebarOpen && (
|
{sidebarOpen && (
|
||||||
|
|
@ -186,7 +191,7 @@ export default function AIChatPage() {
|
||||||
>
|
>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-sm font-medium truncate dark:text-gray-100">{session.title}</div>
|
<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>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); setDeleteConfirm(session.id); }}
|
onClick={(e) => { e.stopPropagation(); setDeleteConfirm(session.id); }}
|
||||||
|
|
@ -226,12 +231,12 @@ export default function AIChatPage() {
|
||||||
<p className="text-sm text-gray-400 dark:text-gray-500">Tap ☰ to see past chats, or just type below to start</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 rounded-full">New Chat</Button>
|
<Button onClick={createNewSession} className="mt-2 rounded-full">New Chat</Button>
|
||||||
</div>
|
</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">
|
<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>
|
<p className="text-gray-400 dark:text-gray-500 text-sm">Type a question below to get started</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
currentSession.messages.map((msg, i) => (
|
(currentSession.messages ?? []).map((msg, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
|
className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
|
||||||
|
|
@ -262,15 +267,19 @@ export default function AIChatPage() {
|
||||||
|
|
||||||
{/* Input */}
|
{/* Input */}
|
||||||
<div className="p-4 border-t bg-white dark:bg-gray-800 flex-shrink-0">
|
<div className="p-4 border-t bg-white dark:bg-gray-800 flex-shrink-0">
|
||||||
<div className="flex gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
{/* Input renders its own wrapper div, so flex-1 must go on a wrapper
|
||||||
value={input}
|
here — putting it on <Input> only hits the inner <input> (already
|
||||||
onChange={e => setInput(e.target.value)}
|
w-full) and the wrapper stays content-width, leaving it narrow. */}
|
||||||
onKeyDown={e => e.key === "Enter" && !e.shiftKey && handleSend()}
|
<div className="flex-1">
|
||||||
placeholder="Ask about your baby..."
|
<Input
|
||||||
disabled={loading}
|
value={input}
|
||||||
className="flex-1"
|
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}>
|
<Button onClick={handleSend} disabled={loading || !input.trim()} loading={loading}>
|
||||||
Send
|
Send
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -615,7 +615,7 @@ export default function CircleFeedPage({ params }: { params: Promise<{ id: strin
|
||||||
{/* FAB — create post */}
|
{/* FAB — create post */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowCreate(true)}
|
onClick={() => setShowCreate(true)}
|
||||||
className="fixed bottom-20 right-5 w-14 h-14 bg-rose-400 text-white rounded-full shadow-lg flex items-center justify-center text-2xl z-40"
|
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>
|
>+</button>
|
||||||
|
|
||||||
{showCreate && circle && familyId && (
|
{showCreate && circle && familyId && (
|
||||||
|
|
|
||||||
48
src/app/(app)/error.tsx
Normal file
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useFamily } from "@/app/FamilyProvider";
|
import { useFamily } from "@/app/FamilyProvider";
|
||||||
|
import { ChildLimitBanner } from "@/components/StorageMeter";
|
||||||
|
|
||||||
interface Child {
|
interface Child {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -24,6 +25,7 @@ export default function FamilyPage() {
|
||||||
const [newName, setNewName] = useState("");
|
const [newName, setNewName] = useState("");
|
||||||
const [newDob, setNewDob] = useState("");
|
const [newDob, setNewDob] = useState("");
|
||||||
const [newSex, setNewSex] = useState("male");
|
const [newSex, setNewSex] = useState("male");
|
||||||
|
const [childLimit, setChildLimit] = useState<{ currentCount: number; limit: number } | null>(null);
|
||||||
|
|
||||||
// Load children from FamilyProvider or fetch
|
// Load children from FamilyProvider or fetch
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -35,6 +37,18 @@ export default function FamilyPage() {
|
||||||
}
|
}
|
||||||
}, [childrenFromProvider, familyId]);
|
}, [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 () => {
|
const fetchOwnChildren = async () => {
|
||||||
if (!familyId) return;
|
if (!familyId) return;
|
||||||
try {
|
try {
|
||||||
|
|
@ -85,12 +99,19 @@ export default function FamilyPage() {
|
||||||
setShowAdd(false);
|
setShowAdd(false);
|
||||||
setNewName("");
|
setNewName("");
|
||||||
setNewDob("");
|
setNewDob("");
|
||||||
|
} else if (data.reason === "child_limit_reached") {
|
||||||
|
setShowAdd(false);
|
||||||
|
setChildLimit({ currentCount: data.currentCount, limit: data.limit });
|
||||||
|
} else {
|
||||||
|
alert(data.error);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to add:", err);
|
console.error("Failed to add:", err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const atChildLimit = childLimit !== null && childLimit.currentCount >= childLimit.limit;
|
||||||
|
|
||||||
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">
|
<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">
|
<div className="p-4 flex items-center gap-4">
|
||||||
|
|
@ -99,8 +120,13 @@ export default function FamilyPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-4 space-y-4">
|
<div className="px-4 space-y-4">
|
||||||
|
{/* Child limit banner */}
|
||||||
|
{atChildLimit && childLimit && (
|
||||||
|
<ChildLimitBanner currentCount={childLimit.currentCount} limit={childLimit.limit} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Add Child Form */}
|
{/* Add Child Form */}
|
||||||
{showAdd && (
|
{showAdd && !atChildLimit && (
|
||||||
<div className="p-4 bg-white dark:bg-gray-800 rounded-xl space-y-3">
|
<div className="p-4 bg-white dark:bg-gray-800 rounded-xl space-y-3">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -200,7 +226,7 @@ export default function FamilyPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Add Button */}
|
{/* Add Button */}
|
||||||
{!showAdd && children.length > 0 && (
|
{!showAdd && children.length > 0 && !atChildLimit && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAdd(true)}
|
onClick={() => setShowAdd(true)}
|
||||||
className="w-full p-4 border-2 border-dashed border-gray-300 rounded-xl text-gray-500"
|
className="w-full p-4 border-2 border-dashed border-gray-300 rounded-xl text-gray-500"
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import { useFamily } from "@/app/FamilyProvider";
|
||||||
import { Button, Card, Input, ConfirmDialog } from "@/components/ui";
|
import { Button, Card, Input, ConfirmDialog } from "@/components/ui";
|
||||||
import { WHO_BOY_WEIGHT, WHO_GIRL_WEIGHT, getAgeInMonthsFromBirth, getPercentile } from "@/lib/growth-standards";
|
import { WHO_BOY_WEIGHT, WHO_GIRL_WEIGHT, getAgeInMonthsFromBirth, getPercentile } from "@/lib/growth-standards";
|
||||||
import { formatAge } from "@/lib/formatting";
|
import { formatAge } from "@/lib/formatting";
|
||||||
|
import { fmtDate } from "@/lib/date-ist";
|
||||||
|
import { trackGrowthLogged } from "@/lib/analytics";
|
||||||
import type { GrowthRecord, Goal } from "@/types";
|
import type { GrowthRecord, Goal } from "@/types";
|
||||||
import {
|
import {
|
||||||
Chart as ChartJS,
|
Chart as ChartJS,
|
||||||
|
|
@ -115,6 +117,7 @@ export default function GrowthPage() {
|
||||||
setSaveError(err.error || "Failed to save");
|
setSaveError(err.error || "Failed to save");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
trackGrowthLogged();
|
||||||
resetForm();
|
resetForm();
|
||||||
fetchGrowthData();
|
fetchGrowthData();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -190,7 +193,7 @@ export default function GrowthPage() {
|
||||||
|
|
||||||
const headers = ["Date", "Weight (kg)", "Height (cm)", "Head (cm)", "Notes"];
|
const headers = ["Date", "Weight (kg)", "Height (cm)", "Head (cm)", "Notes"];
|
||||||
const rows = growthData.map(r => [
|
const rows = growthData.map(r => [
|
||||||
new Date(r.measured_at).toLocaleDateString(),
|
fmtDate(r.measured_at),
|
||||||
r.weight_kg || "",
|
r.weight_kg || "",
|
||||||
r.height_cm || "",
|
r.height_cm || "",
|
||||||
r.head_circumference_cm || "",
|
r.head_circumference_cm || "",
|
||||||
|
|
@ -423,7 +426,7 @@ export default function GrowthPage() {
|
||||||
<div>
|
<div>
|
||||||
<div className="font-semibold text-rose-600 dark:text-rose-300">Latest Reading</div>
|
<div className="font-semibold text-rose-600 dark:text-rose-300">Latest Reading</div>
|
||||||
<div className="text-sm text-gray-500">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{velocity && (
|
{velocity && (
|
||||||
|
|
@ -637,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 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>
|
||||||
<div className="text-sm text-gray-500">
|
<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>
|
||||||
<div className="flex gap-2 mt-1">
|
<div className="flex gap-2 mt-1">
|
||||||
{record.weight_kg && (
|
{record.weight_kg && (
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,21 @@ import { useStageCheck, type BabyStage } from "@/hooks/useStageCheck";
|
||||||
import { LogModal, type LogType } from "@/components/LogModal";
|
import { LogModal, type LogType } from "@/components/LogModal";
|
||||||
import { getOfflineQueue, processOfflineQueue } from "@/lib/offline-queue";
|
import { getOfflineQueue, processOfflineQueue } from "@/lib/offline-queue";
|
||||||
import { calculateAge, formatTimeAgo } from "@/lib/formatting";
|
import { calculateAge, formatTimeAgo } from "@/lib/formatting";
|
||||||
|
import { hourIST, isTodayIST, fmtTime } from "@/lib/date-ist";
|
||||||
import type { Log, AIChat, ChatSession } from "@/types";
|
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[]> {
|
async function getSessions(cid: string): Promise<ChatSession[]> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/chat?childId=${cid}`);
|
const res = await fetch(`/api/chat?childId=${cid}`);
|
||||||
|
|
@ -32,15 +45,14 @@ async function createSession(cid: string): Promise<ChatSession | null> {
|
||||||
|
|
||||||
|
|
||||||
function getGreeting() {
|
function getGreeting() {
|
||||||
const hour = new Date().getHours();
|
const hour = hourIST();
|
||||||
if (hour < 12) return "Good morning";
|
if (hour < 12) return "Good morning";
|
||||||
if (hour < 18) return "Good afternoon";
|
if (hour < 18) return "Good afternoon";
|
||||||
return "Good evening";
|
return "Good evening";
|
||||||
}
|
}
|
||||||
|
|
||||||
function TodaySummary({ logs }: { logs: Log[] }) {
|
function TodaySummary({ logs }: { logs: Log[] }) {
|
||||||
const todayStr = new Date().toDateString();
|
const today = logs.filter(l => isTodayIST(l.loggedAt));
|
||||||
const today = logs.filter(l => new Date(l.loggedAt).toDateString() === todayStr);
|
|
||||||
const counts = {
|
const counts = {
|
||||||
feed: today.filter(l => l.type === "feed").length,
|
feed: today.filter(l => l.type === "feed").length,
|
||||||
diaper: today.filter(l => l.type === "diaper").length,
|
diaper: today.filter(l => l.type === "diaper").length,
|
||||||
|
|
@ -87,12 +99,14 @@ export default function HomePage() {
|
||||||
const [recentLogs, setRecentLogs] = useState<Log[]>([]);
|
const [recentLogs, setRecentLogs] = useState<Log[]>([]);
|
||||||
const [logsLoading, setLogsLoading] = useState(true);
|
const [logsLoading, setLogsLoading] = useState(true);
|
||||||
const [vaccineReminders, setVaccineReminders] = useState<any[]>([]);
|
const [vaccineReminders, setVaccineReminders] = useState<any[]>([]);
|
||||||
|
const [showPhoneNudge, setShowPhoneNudge] = useState(false);
|
||||||
const [aiChips, setAiChips] = useState<string[]>([]);
|
const [aiChips, setAiChips] = useState<string[]>([]);
|
||||||
const [uploadingPhoto, setUploadingPhoto] = useState(false);
|
const [uploadingPhoto, setUploadingPhoto] = useState(false);
|
||||||
const [showPhotoMenu, setShowPhotoMenu] = useState(false);
|
const [photoError, setPhotoError] = useState(false);
|
||||||
|
const [showPhotoMenu, setShowPhotoMenu] = useState(false);
|
||||||
const photoInputRef = useRef<HTMLInputElement>(null);
|
const photoInputRef = useRef<HTMLInputElement>(null);
|
||||||
const { theme, toggle: toggleTheme } = useTheme();
|
const { theme, toggle: toggleTheme } = useTheme();
|
||||||
const { childId, child, familyId, loading, updateChildImage } = useFamily();
|
const { childId, child, familyId, loading, tier, updateChildImage } = useFamily();
|
||||||
const stage = useStageCheck(child?.birthDate ?? null);
|
const stage = useStageCheck(child?.birthDate ?? null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -112,11 +126,33 @@ export default function HomePage() {
|
||||||
window.addEventListener("online", handleOnline);
|
window.addEventListener("online", handleOnline);
|
||||||
fetch(`/api/notifications?childId=${childId}`)
|
fetch(`/api/notifications?childId=${childId}`)
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(data => setVaccineReminders(data.notifications || []))
|
// 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);
|
.catch(console.error);
|
||||||
return () => window.removeEventListener("online", handleOnline);
|
return () => window.removeEventListener("online", handleOnline);
|
||||||
}, [childId]);
|
}, [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 () => {
|
const fetchRecentLogs = async () => {
|
||||||
if (!childId) return;
|
if (!childId) return;
|
||||||
try {
|
try {
|
||||||
|
|
@ -187,41 +223,38 @@ export default function HomePage() {
|
||||||
const handlePhotoChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handlePhotoChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file || !childId) return;
|
if (!file || !childId) return;
|
||||||
|
const contentType = resolveContentType(file);
|
||||||
setUploadingPhoto(true);
|
setUploadingPhoto(true);
|
||||||
|
setShowPhotoMenu(false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Get R2 key + public URL from server
|
const initData = await fetch(`/api/children/${childId}`, {
|
||||||
const initRes = await fetch(`/api/children/${childId}`, {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ contentType: file.type, filename: file.name }),
|
body: JSON.stringify({ contentType, filename: file.name }),
|
||||||
});
|
}).then(r => r.json());
|
||||||
if (!initRes.ok) throw new Error("Failed to get upload URL");
|
if (!initData.key) throw new Error(initData.error || "Upload failed");
|
||||||
const { key, publicUrl } = await initRes.json();
|
const { key, publicUrl } = initData;
|
||||||
|
|
||||||
// 2. Upload via server proxy — avoids CORS on direct R2 PUT
|
const putRes = await fetch(`/api/upload?${new URLSearchParams({ key, contentType })}`, {
|
||||||
const putParams = new URLSearchParams({ key, contentType: file.type });
|
method: "PUT", body: file, headers: { "Content-Type": contentType },
|
||||||
const putRes = await fetch(`/api/upload?${putParams}`, {
|
|
||||||
method: "PUT",
|
|
||||||
body: file,
|
|
||||||
headers: { "Content-Type": file.type },
|
|
||||||
});
|
});
|
||||||
if (!putRes.ok) throw new Error("Upload failed");
|
if (!putRes.ok) throw new Error(`Upload failed (${putRes.status})`);
|
||||||
|
|
||||||
// 3. Save URL to DB
|
|
||||||
await fetch(`/api/children/${childId}`, {
|
await fetch(`/api/children/${childId}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ imageUrl: publicUrl }),
|
body: JSON.stringify({ imageUrl: publicUrl }),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 4. Update in-memory state immediately — no full reload needed
|
setPhotoError(false);
|
||||||
updateChildImage(childId, publicUrl);
|
updateChildImage(childId, `/api/img?key=${encodeURIComponent(key)}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Photo upload failed:", err);
|
console.error("Photo upload failed:", err);
|
||||||
alert("Photo upload failed. Please try again.");
|
} finally {
|
||||||
|
setUploadingPhoto(false);
|
||||||
|
if (photoInputRef.current) photoInputRef.current.value = "";
|
||||||
}
|
}
|
||||||
setUploadingPhoto(false);
|
|
||||||
if (photoInputRef.current) photoInputRef.current.value = "";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemovePhoto = async () => {
|
const handleRemovePhoto = async () => {
|
||||||
|
|
@ -293,9 +326,29 @@ export default function HomePage() {
|
||||||
|
|
||||||
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 pb-24">
|
<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">
|
|
||||||
|
<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>
|
<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">
|
<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>
|
<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>
|
<button onClick={toggleDarkMode} className="p-2">{theme === "dark" ? "☀️" : "🌙"}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -318,15 +371,18 @@ export default function HomePage() {
|
||||||
className="relative group"
|
className="relative group"
|
||||||
title={child?.imageUrl ? "Photo options" : "Add photo"}
|
title={child?.imageUrl ? "Photo options" : "Add photo"}
|
||||||
>
|
>
|
||||||
{child?.imageUrl
|
{child?.imageUrl && !photoError
|
||||||
? <img src={child.imageUrl} alt={child?.name} className="w-16 h-16 rounded-full object-cover" />
|
? <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>
|
: <div className="w-16 h-16 bg-rose-100 dark:bg-rose-900 rounded-full flex items-center justify-center text-2xl">👶</div>
|
||||||
}
|
}
|
||||||
{/* Camera overlay */}
|
{/* Camera / upload overlay */}
|
||||||
<div className={`absolute inset-0 rounded-full flex items-center justify-center transition-opacity ${
|
<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 ? "bg-black/40 opacity-100" : "bg-black/0 opacity-0 group-hover:opacity-100 group-active:opacity-100"
|
||||||
}`}>
|
}`}>
|
||||||
<span className="text-white text-lg">{uploadingPhoto ? "⏳" : "📷"}</span>
|
{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>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|
@ -387,9 +443,7 @@ export default function HomePage() {
|
||||||
<div>
|
<div>
|
||||||
<div className="font-semibold text-red-700">💊 Vaccine Reminder</div>
|
<div className="font-semibold text-red-700">💊 Vaccine Reminder</div>
|
||||||
<div className="text-sm text-red-600">
|
<div className="text-sm text-red-600">
|
||||||
{vaccineReminders[0].status === "overdue"
|
{vaccineReminders[0].message}
|
||||||
? `${vaccineReminders[0].message}`
|
|
||||||
: `${vaccineReminders[0].vaccineName} due today`}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-red-400">→</span>
|
<span className="text-red-400">→</span>
|
||||||
|
|
@ -397,10 +451,33 @@ export default function HomePage() {
|
||||||
</div>
|
</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} />
|
<TodaySummary logs={recentLogs} />
|
||||||
|
|
||||||
{stage && (() => {
|
{stage && (() => {
|
||||||
const h = new Date().getHours();
|
const h = hourIST();
|
||||||
type Suggestion = { label: string; type: "feed" | "sleep" | "diaper" };
|
type Suggestion = { label: string; type: "feed" | "sleep" | "diaper" };
|
||||||
const matrix: Record<BabyStage, Suggestion> =
|
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 >= 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" } } :
|
||||||
|
|
@ -465,7 +542,7 @@ export default function HomePage() {
|
||||||
<div key={log.id} className="flex items-center justify-between p-3 bg-white dark:bg-gray-800 rounded-xl">
|
<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">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-xl">{log.type === "feed" && "🍼"}{log.type === "sleep" && "😴"}{log.type === "diaper" && "🚼"}</span>
|
<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">{new Date(log.loggedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</div></div>
|
<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>
|
</div>
|
||||||
{log.amount && <span className="text-sm text-gray-500 dark:text-gray-400">{log.amount}ml</span>}
|
{log.amount && <span className="text-sm text-gray-500 dark:text-gray-400">{log.amount}ml</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,15 @@
|
||||||
|
import type { Metadata } from "next";
|
||||||
import { ThemeProvider } from "@/app/ThemeProvider";
|
import { ThemeProvider } from "@/app/ThemeProvider";
|
||||||
import { FamilyProvider } from "@/app/FamilyProvider";
|
import { FamilyProvider } from "@/app/FamilyProvider";
|
||||||
import { PageTransition } from "@/components/PageTransition";
|
import { PageTransition } from "@/components/PageTransition";
|
||||||
import { BottomNav } from "@/components/BottomNav";
|
import { BottomNav } from "@/components/BottomNav";
|
||||||
import { InstallPrompt } from "@/components/InstallPrompt";
|
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({
|
export default function AppLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,12 @@ const RULE_META: Record<string, { icon: string; title: string; color: string }>
|
||||||
|
|
||||||
export default function EmergencyPage() {
|
export default function EmergencyPage() {
|
||||||
const [phone, setPhone] = useState<string | null>(null);
|
const [phone, setPhone] = useState<string | null>(null);
|
||||||
|
const [name, setName] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/family").then(r => r.json()).then(d => {
|
fetch("/api/family").then(r => r.json()).then(d => {
|
||||||
setPhone(d.family?.pediatrician_phone || null);
|
setPhone(d.family?.pediatrician_phone || null);
|
||||||
|
setName(d.family?.pediatrician_name || null);
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -29,7 +31,7 @@ export default function EmergencyPage() {
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="sticky top-0 bg-red-500 text-white z-10">
|
<div className="sticky top-0 bg-red-500 text-white z-10">
|
||||||
<div className="flex items-center gap-3 p-4">
|
<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>
|
<div>
|
||||||
<h1 className="font-bold text-lg">Emergency Guide</h1>
|
<h1 className="font-bold text-lg">Emergency Guide</h1>
|
||||||
<p className="text-red-100 text-xs">When to call the doctor immediately</p>
|
<p className="text-red-100 text-xs">When to call the doctor immediately</p>
|
||||||
|
|
@ -38,20 +40,25 @@ export default function EmergencyPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Call button */}
|
{/* Call button */}
|
||||||
<div className="p-4">
|
<div className="p-4 space-y-1">
|
||||||
{phone ? (
|
{phone ? (
|
||||||
<a
|
<>
|
||||||
href={`tel:${phone}`}
|
{name && (
|
||||||
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"
|
<p className="text-center text-sm font-medium text-gray-600 dark:text-gray-300 mb-2">{name}</p>
|
||||||
>
|
)}
|
||||||
📞 Call Pediatrician Now
|
<a
|
||||||
</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
|
<Link
|
||||||
href="/settings"
|
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"
|
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>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,20 @@ import { useFamily } from "@/app/FamilyProvider";
|
||||||
import { Button, ConfirmDialog, Modal } from "@/components/ui";
|
import { Button, ConfirmDialog, Modal } from "@/components/ui";
|
||||||
import { StorageMeter, StorageQuotaBanner } from "@/components/StorageMeter";
|
import { StorageMeter, StorageQuotaBanner } from "@/components/StorageMeter";
|
||||||
import { formatBytes } from "@/lib/format-bytes";
|
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 = [
|
const PRESET_FOLDERS = [
|
||||||
{ id: "", label: "All", emoji: "🌟" },
|
{ id: "", label: "All", emoji: "🌟" },
|
||||||
|
|
@ -46,6 +60,7 @@ export default function MemoriesPage() {
|
||||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||||
const [selected, setSelected] = useState<Memory | null>(null);
|
const [selected, setSelected] = useState<Memory | null>(null);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||||
const [loadingMore, setLoadingMore] = useState(false);
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
const [activeFolder, setActiveFolder] = useState("");
|
const [activeFolder, setActiveFolder] = useState("");
|
||||||
|
|
@ -143,71 +158,74 @@ export default function MemoriesPage() {
|
||||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file || !childId) return;
|
if (!file || !childId) return;
|
||||||
|
|
||||||
|
const contentType = resolveContentType(file);
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
|
setUploadError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Step 1: Get presigned URL (quota gate runs here server-side)
|
// Step 1: reserve slot + quota gate
|
||||||
const initRes = await fetch("/api/upload", {
|
const initRes = await fetch("/api/upload", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ filename: file.name, contentType: file.type, childId, sizeBytes: file.size }),
|
body: JSON.stringify({ filename: file.name, contentType, childId, sizeBytes: file.size }),
|
||||||
});
|
});
|
||||||
const initData = await initRes.json();
|
const initData = await initRes.json();
|
||||||
|
|
||||||
if (!initRes.ok) {
|
if (!initRes.ok) {
|
||||||
if (initRes.status === 402 && initData.reason === "storage_quota_exceeded") {
|
if (initRes.status === 402 && initData.reason === "storage_quota_exceeded") {
|
||||||
setQuotaExceeded(true);
|
setQuotaExceeded(true);
|
||||||
setQuotaBanner({ usedBytes: initData.usedBytes, limitBytes: initData.limitBytes });
|
setQuotaBanner({ usedBytes: initData.usedBytes ?? 0, limitBytes: initData.limitBytes ?? 0 });
|
||||||
|
setUploadError("Storage quota exceeded");
|
||||||
} else {
|
} else {
|
||||||
alert("Error: " + (initData.error ?? "Upload failed"));
|
setUploadError(initData.error || `Upload failed (${initRes.status})`);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const { key, memoryId } = initData;
|
||||||
|
|
||||||
const { key, memoryId, publicUrl } = initData;
|
// Step 2: binary upload via proxy
|
||||||
|
const putRes = await fetch(`/api/upload?${new URLSearchParams({ key, contentType })}`, {
|
||||||
// Step 2: Upload file to R2 via proxy
|
method: "PUT", body: file, headers: { "Content-Type": contentType },
|
||||||
const putParams = new URLSearchParams({ key, contentType: file.type });
|
|
||||||
const putRes = await fetch(`/api/upload?${putParams}`, {
|
|
||||||
method: "PUT", body: file, headers: { "Content-Type": file.type },
|
|
||||||
});
|
});
|
||||||
if (!putRes.ok) {
|
if (!putRes.ok) { setUploadError(`Upload failed (${putRes.status})`); return; }
|
||||||
const putErr = await putRes.json().catch(() => ({})) as { error?: string };
|
|
||||||
throw new Error(putErr.error ?? `Upload failed (${putRes.status})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: Assign folder if chosen
|
// Assign folder if chosen
|
||||||
if (pendingFolder) {
|
if (pendingFolder) {
|
||||||
await fetch(`/api/memories/${memoryId}`, {
|
await fetch(`/api/memories/${memoryId}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ description: pendingFolder }),
|
body: JSON.stringify({ description: pendingFolder }),
|
||||||
});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Confirm — server reconciles actual R2 size against quota
|
// Step 3: confirm
|
||||||
const confirmRes = await fetch(`/api/memories/${memoryId}/confirm`, { method: "POST" });
|
const confirmRes = await fetch(`/api/memories/${memoryId}/confirm`, { method: "POST" });
|
||||||
if (!confirmRes.ok && confirmRes.status === 402) {
|
if (!confirmRes.ok) {
|
||||||
// Actual file size exceeded quota; server deleted the R2 object and
|
const cd = await confirmRes.json().catch(() => ({})) as { reason?: string; usedBytes?: number; limitBytes?: number; error?: string };
|
||||||
// marked the row failed. Refresh quota state and skip optimistic update.
|
if (confirmRes.status === 402) {
|
||||||
setQuotaExceeded(true);
|
setQuotaExceeded(true);
|
||||||
fetch("/api/storage-usage")
|
fetch("/api/storage-usage").then(r => r.json())
|
||||||
.then(r => r.json())
|
.then(d => setQuotaBanner({ usedBytes: d.usedBytes, limitBytes: d.limitBytes }))
|
||||||
.then(d => setQuotaBanner({ usedBytes: d.usedBytes, limitBytes: d.limitBytes }))
|
.catch(() => {});
|
||||||
.catch(() => {});
|
setUploadError("Storage quota exceeded");
|
||||||
|
} else {
|
||||||
|
setUploadError(cd.error || `Failed to confirm upload (${confirmRes.status})`);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 5: Optimistic update in UI
|
trackMemoryAdded();
|
||||||
const optimistic: Memory = {
|
// Optimistic grid update
|
||||||
id: memoryId, key, url: publicUrl, thumbnailUrl: null,
|
const proxyUrl = `/api/img?key=${encodeURIComponent(key)}`;
|
||||||
sizeBytes: file.size, mimeType: file.type, title: null,
|
setMemories(prev => [{
|
||||||
|
id: memoryId, key, url: proxyUrl, thumbnailUrl: null,
|
||||||
|
sizeBytes: file.size, mimeType: contentType, title: null,
|
||||||
description: pendingFolder || null, takenAt: null,
|
description: pendingFolder || null, takenAt: null,
|
||||||
visionCaption: null, visionTags: null, isPrivate: false,
|
visionCaption: null, visionTags: null, isPrivate: false,
|
||||||
processingStatus: "processing", createdAt: new Date().toISOString(),
|
processingStatus: "processing", createdAt: new Date().toISOString(),
|
||||||
};
|
}, ...prev]);
|
||||||
setMemories(prev => [optimistic, ...prev]);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert("Upload failed: " + err);
|
setUploadError(err instanceof Error ? err.message : "Upload failed");
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
setPendingFolder("");
|
setPendingFolder("");
|
||||||
|
|
@ -351,8 +369,16 @@ export default function MemoriesPage() {
|
||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Upload FAB — disabled when storage quota is exceeded */}
|
||||||
<div className="fixed bottom-6 right-6 z-20">
|
<div className="fixed bottom-24 right-6 z-50">
|
||||||
<button
|
<button
|
||||||
onClick={quotaExceeded ? undefined : handleUploadClick}
|
onClick={quotaExceeded ? undefined : handleUploadClick}
|
||||||
disabled={uploading || quotaExceeded}
|
disabled={uploading || quotaExceeded}
|
||||||
|
|
@ -490,9 +516,9 @@ function MemoryTile({ memory, folder, onClick }: { memory: Memory; folder: Folde
|
||||||
<span className="text-white text-lg drop-shadow">⤢</span>
|
<span className="text-white text-lg drop-shadow">⤢</span>
|
||||||
</div>
|
</div>
|
||||||
{memory.processingStatus === "processing" && (
|
{memory.processingStatus === "processing" && (
|
||||||
<div className="absolute inset-0 bg-black/40 flex items-center justify-center rounded-xl">
|
<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 className="text-white text-[9px] bg-black/50 px-1.5 py-0.5 rounded-full">Processing…</span>
|
⏳
|
||||||
</div>
|
</span>
|
||||||
)}
|
)}
|
||||||
{memory.isPrivate && <span className="absolute top-1 right-1 text-[9px]">🔒</span>}
|
{memory.isPrivate && <span className="absolute top-1 right-1 text-[9px]">🔒</span>}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ export default function MenuPage() {
|
||||||
const [signingOut, setSigningOut] = useState(false);
|
const [signingOut, setSigningOut] = useState(false);
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ icon: "🏡", label: "Home", href: "/" },
|
{ icon: "🏡", label: "Home", href: "/home" },
|
||||||
{ icon: "📝", label: "Activity", href: "/activity" },
|
{ icon: "📝", label: "Activity", href: "/activity" },
|
||||||
{ icon: "🌿", label: "Growth", href: "/growth" },
|
{ icon: "🌿", label: "Growth", href: "/growth" },
|
||||||
{ icon: "🩺", label: "Medical", href: "/medical" },
|
{ icon: "🩺", label: "Medical", href: "/medical" },
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useFamily } from "@/app/FamilyProvider";
|
||||||
import { useStageCheck } from "@/hooks/useStageCheck";
|
import { useStageCheck } from "@/hooks/useStageCheck";
|
||||||
import { MILESTONES, type MilestoneDef } from "@/lib/milestones";
|
import { MILESTONES, type MilestoneDef } from "@/lib/milestones";
|
||||||
import { Button, Input } from "@/components/ui";
|
import { Button, Input } from "@/components/ui";
|
||||||
|
import { fmtDate } from "@/lib/date-ist";
|
||||||
|
|
||||||
type Category = "all" | "social" | "motor" | "language" | "cognitive";
|
type Category = "all" | "social" | "motor" | "language" | "cognitive";
|
||||||
type Filter = "all" | "achieved" | "upcoming";
|
type Filter = "all" | "achieved" | "upcoming";
|
||||||
|
|
@ -194,7 +195,7 @@ export default function MilestonesPage() {
|
||||||
<p className="text-xs text-gray-400 mt-1">{m.ageRangeLabel}</p>
|
<p className="text-xs text-gray-400 mt-1">{m.ageRangeLabel}</p>
|
||||||
{m.achieved && m.achievedAt && (
|
{m.achieved && m.achievedAt && (
|
||||||
<p className="text-xs text-green-600 dark:text-green-400 mt-1">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
<span className={`inline-block text-xs px-1.5 py-0.5 rounded-full mt-2 ${CATEGORY_COLORS[m.category]}`}>
|
<span className={`inline-block text-xs px-1.5 py-0.5 rounded-full mt-2 ${CATEGORY_COLORS[m.category]}`}>
|
||||||
|
|
|
||||||
|
|
@ -1,58 +1,184 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useFamily } from "@/app/FamilyProvider";
|
||||||
|
import { fmtDate } from "@/lib/date-ist";
|
||||||
|
|
||||||
interface Notification {
|
interface AppNotification {
|
||||||
id: string;
|
id: string;
|
||||||
|
type: string;
|
||||||
title: string;
|
title: string;
|
||||||
message: string;
|
message: string;
|
||||||
time: string;
|
actionUrl?: string;
|
||||||
read: boolean;
|
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() {
|
export default function NotificationsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [notifications] = useState<Notification[]>([
|
const { childId } = useFamily();
|
||||||
{ id: "1", title: "Reminder", message: "Time to log today's feed", time: "2 hours ago", read: false },
|
const [items, setItems] = useState<AppNotification[]>([]);
|
||||||
{ id: "2", title: "Growth Update", message: "New growth data saved", time: "Yesterday", read: true },
|
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 (
|
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">
|
||||||
<div className="p-4 flex items-center gap-4">
|
|
||||||
<button onClick={() => router.back()} className="p-2">←</button>
|
{/* Header */}
|
||||||
<h1 className="text-xl font-bold">Notifications</h1>
|
<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>
|
</div>
|
||||||
|
|
||||||
<div className="px-4 space-y-2">
|
{/* Section hint */}
|
||||||
{notifications.length === 0 ? (
|
{!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-center py-20 text-gray-400">
|
||||||
<div className="text-6xl mb-4">🔔</div>
|
<div className="text-6xl mb-4">🔔</div>
|
||||||
<p>No notifications</p>
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
notifications.map((notif) => (
|
items.map(notif => (
|
||||||
<div
|
<button
|
||||||
key={notif.id}
|
key={notif.id}
|
||||||
className={`p-4 rounded-xl ${notif.read ? "bg-gray-50 dark:bg-gray-800" : "bg-white dark:bg-gray-700"}`}
|
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 justify-between">
|
<div className="flex items-start gap-3 p-4">
|
||||||
<div className="flex-1">
|
<span className="text-2xl mt-0.5 flex-shrink-0">{notifIcon(notif.type, notif.metadata)}</span>
|
||||||
<div className="font-medium">{notif.title}</div>
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-sm text-gray-500">{notif.message}</div>
|
<div className={`font-semibold text-sm ${notif.isRead ? "text-gray-400 dark:text-gray-500" : "text-gray-900 dark:text-white"}`}>
|
||||||
<div className="text-xs text-gray-400 mt-1">{notif.time}</div>
|
{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>
|
</div>
|
||||||
{!notif.read && <div className="w-2 h-2 bg-rose-400 rounded-full mt-2" />}
|
{!notif.isRead && (
|
||||||
|
<div className="w-2.5 h-2.5 bg-rose-400 rounded-full mt-1.5 flex-shrink-0" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{notifications.length > 0 && (
|
{/* Legend */}
|
||||||
<div className="px-4 mt-6">
|
{!loading && items.length > 0 && (
|
||||||
<button className="text-sm text-gray-500">Mark all as read</button>
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ export default function OnboardingPage() {
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
familyName: "",
|
familyName: "",
|
||||||
memberName: "",
|
memberName: "",
|
||||||
|
phone: "",
|
||||||
childName: "",
|
childName: "",
|
||||||
birthDate: "",
|
birthDate: "",
|
||||||
sex: "" as "male" | "female" | "other",
|
sex: "" as "male" | "female" | "other",
|
||||||
|
|
@ -167,6 +168,10 @@ export default function OnboardingPage() {
|
||||||
<Input label="Your Name" type="text" value={form.memberName}
|
<Input label="Your Name" type="text" value={form.memberName}
|
||||||
onChange={(e) => setForm({ ...form, memberName: e.target.value })}
|
onChange={(e) => setForm({ ...form, memberName: e.target.value })}
|
||||||
placeholder="Mama" />
|
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}>
|
<Button fullWidth size="lg" onClick={() => setStep(2)} disabled={!form.familyName || !form.memberName}>
|
||||||
Next →
|
Next →
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -3,27 +3,40 @@
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
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() {
|
export default function ProfilePage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const [userId, setUserId] = useState<string>("");
|
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
|
const [phone, setPhone] = useState("");
|
||||||
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
|
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [uploading, setUploading] = useState(false);
|
|
||||||
const [saveMsg, setSaveMsg] = useState("");
|
const [saveMsg, setSaveMsg] = useState("");
|
||||||
|
const [avatarError, setAvatarError] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/auth/profile")
|
fetch("/api/auth/profile")
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.user) {
|
if (data.user) {
|
||||||
setUserId(data.user.id || "");
|
|
||||||
setName(data.user.name || "");
|
setName(data.user.name || "");
|
||||||
setEmail(data.user.email || "");
|
setEmail(data.user.email || "");
|
||||||
|
setPhone(data.user.phone || "");
|
||||||
setAvatarUrl(data.user.avatarUrl || null);
|
setAvatarUrl(data.user.avatarUrl || null);
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
@ -35,44 +48,45 @@ export default function ProfilePage() {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
|
const contentType = resolveContentType(file);
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
setSaveMsg("");
|
setSaveMsg("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Step 1: get R2 key + presigned upload URL
|
// Step 1: reserve upload slot
|
||||||
const initRes = await fetch("/api/auth/avatar", {
|
const initRes = await fetch("/api/auth/avatar", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ contentType: file.type, filename: file.name }),
|
body: JSON.stringify({ contentType, filename: file.name }),
|
||||||
});
|
});
|
||||||
const initData = await initRes.json();
|
const initData = await initRes.json();
|
||||||
if (!initRes.ok) throw new Error(initData.error || "Upload init failed");
|
if (!initRes.ok) throw new Error(initData.error || `Upload failed (${initRes.status})`);
|
||||||
const { key, publicUrl: newPublicUrl } = initData;
|
const { key, publicUrl: r2Url } = initData;
|
||||||
|
|
||||||
// Step 2: proxy PUT through our server (avoids CORS on direct R2 PUT)
|
// Step 2: upload binary via proxy
|
||||||
const putParams = new URLSearchParams({ key, contentType: file.type });
|
const putRes = await fetch(`/api/upload?${new URLSearchParams({ key, contentType })}`, {
|
||||||
const putRes = await fetch(`/api/upload?${putParams}`, {
|
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: file,
|
body: file,
|
||||||
headers: { "Content-Type": file.type },
|
headers: { "Content-Type": contentType },
|
||||||
});
|
});
|
||||||
if (!putRes.ok) throw new Error("Upload to storage failed");
|
if (!putRes.ok) throw new Error(`Upload failed (${putRes.status})`);
|
||||||
|
|
||||||
// Step 3: save the new avatarUrl in DB (server cleans up old R2 object)
|
// Step 3: save R2 URL to DB
|
||||||
const patchRes = await fetch("/api/auth/avatar", {
|
const patchRes = await fetch("/api/auth/avatar", {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ avatarUrl: newPublicUrl }),
|
body: JSON.stringify({ avatarUrl: r2Url }),
|
||||||
});
|
});
|
||||||
const patchData = await patchRes.json();
|
if (!patchRes.ok) throw new Error("Failed to save photo");
|
||||||
if (!patchRes.ok) throw new Error(patchData.error || "Failed to save photo");
|
|
||||||
|
|
||||||
setAvatarUrl(newPublicUrl);
|
setAvatarError(false);
|
||||||
setSaveMsg("Photo updated!");
|
setAvatarUrl(`/api/img?key=${encodeURIComponent(key)}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setSaveMsg(err instanceof Error ? err.message : "Upload failed");
|
setSaveMsg(err instanceof Error ? err.message : "Upload failed");
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
if (fileRef.current) fileRef.current.value = "";
|
||||||
}
|
}
|
||||||
setUploading(false);
|
|
||||||
if (fileRef.current) fileRef.current.value = "";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemovePhoto = async () => {
|
const handleRemovePhoto = async () => {
|
||||||
|
|
@ -82,7 +96,7 @@ export default function ProfilePage() {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) throw new Error(data.error || "Failed to remove photo");
|
if (!res.ok) throw new Error(data.error || "Failed to remove photo");
|
||||||
setAvatarUrl(null);
|
setAvatarUrl(null);
|
||||||
setSaveMsg("Photo removed.");
|
setAvatarError(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setSaveMsg(err instanceof Error ? err.message : "Failed to remove photo");
|
setSaveMsg(err instanceof Error ? err.message : "Failed to remove photo");
|
||||||
}
|
}
|
||||||
|
|
@ -96,7 +110,7 @@ export default function ProfilePage() {
|
||||||
const res = await fetch("/api/auth/profile", {
|
const res = await fetch("/api/auth/profile", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ name }),
|
body: JSON.stringify({ name, phone }),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setSaveMsg(data.success ? "Saved!" : data.error || "Save failed");
|
setSaveMsg(data.success ? "Saved!" : data.error || "Save failed");
|
||||||
|
|
@ -106,12 +120,7 @@ export default function ProfilePage() {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const initials = name
|
const initials = name.split(" ").map(w => w[0]).join("").toUpperCase().slice(0, 2);
|
||||||
.split(" ")
|
|
||||||
.map(w => w[0])
|
|
||||||
.join("")
|
|
||||||
.toUpperCase()
|
|
||||||
.slice(0, 2);
|
|
||||||
|
|
||||||
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">
|
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800">
|
||||||
|
|
@ -123,20 +132,20 @@ export default function ProfilePage() {
|
||||||
|
|
||||||
<div className="px-4 pb-24 space-y-4">
|
<div className="px-4 pb-24 space-y-4">
|
||||||
{/* Avatar */}
|
{/* Avatar */}
|
||||||
<div className="flex flex-col items-center pt-8 pb-4">
|
<div className="flex flex-col items-center pt-8 pb-2">
|
||||||
<div className="relative mb-3">
|
<div className="relative mb-3">
|
||||||
{avatarUrl ? (
|
{avatarUrl && !avatarError ? (
|
||||||
<img
|
<img
|
||||||
src={avatarUrl}
|
src={avatarUrl}
|
||||||
alt={name}
|
alt={name}
|
||||||
className="w-24 h-24 rounded-full object-cover ring-4 ring-white dark:ring-gray-800 shadow-md"
|
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">
|
<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 || "👤"}
|
{initials || "👤"}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Upload spinner overlay */}
|
|
||||||
{uploading && (
|
{uploading && (
|
||||||
<div className="absolute inset-0 rounded-full bg-black/40 flex items-center justify-center">
|
<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 className="w-6 h-6 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||||
|
|
@ -152,19 +161,18 @@ export default function ProfilePage() {
|
||||||
{uploading ? "Uploading…" : "Change Photo"}
|
{uploading ? "Uploading…" : "Change Photo"}
|
||||||
</button>
|
</button>
|
||||||
{avatarUrl && !uploading && (
|
{avatarUrl && !uploading && (
|
||||||
<button
|
<button onClick={handleRemovePhoto} className="text-xs text-gray-400 mt-0.5 hover:text-red-400 transition-colors">
|
||||||
onClick={handleRemovePhoto}
|
|
||||||
className="text-xs text-gray-400 dark:text-gray-500 mt-0.5 hover:text-red-400 transition-colors"
|
|
||||||
>
|
|
||||||
Remove photo
|
Remove photo
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{!avatarUrl && <p className="text-xs text-gray-400 mt-0.5">JPEG, PNG or WebP · max 5 MB</p>}
|
{!avatarUrl && !uploading && (
|
||||||
|
<p className="text-xs text-gray-400 mt-0.5">JPEG, PNG, WebP or HEIC</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<input
|
<input
|
||||||
ref={fileRef}
|
ref={fileRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/jpeg,image/jpg,image/png,image/webp"
|
accept="image/jpeg,image/jpg,image/png,image/webp,image/heic"
|
||||||
onChange={handlePhotoChange}
|
onChange={handlePhotoChange}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
/>
|
/>
|
||||||
|
|
@ -186,6 +194,18 @@ export default function ProfilePage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Email</label>
|
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Email</label>
|
||||||
<input
|
<input
|
||||||
|
|
@ -198,7 +218,7 @@ export default function ProfilePage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{saveMsg && (
|
{saveMsg && (
|
||||||
<p className={`text-xs text-center font-medium ${saveMsg === "Saved!" || saveMsg === "Photo updated!" ? "text-green-600 dark:text-green-400" : "text-red-500"}`}>
|
<p className={`text-xs text-center font-medium ${saveMsg === "Saved!" ? "text-green-600 dark:text-green-400" : "text-red-500"}`}>
|
||||||
{saveMsg}
|
{saveMsg}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ import { useTheme } from "@/app/ThemeProvider";
|
||||||
import { useFamily } from "@/app/FamilyProvider";
|
import { useFamily } from "@/app/FamilyProvider";
|
||||||
import { Button, Card, Input, Select, Badge } from "@/components/ui";
|
import { Button, Card, Input, Select, Badge } from "@/components/ui";
|
||||||
import { StorageMeter, MemberLimitBanner } from "@/components/StorageMeter";
|
import { StorageMeter, MemberLimitBanner } from "@/components/StorageMeter";
|
||||||
|
import { UpgradeButton } from "@/components/UpgradeButton";
|
||||||
|
import { fmtDate } from "@/lib/date-ist";
|
||||||
|
|
||||||
interface Member {
|
interface Member {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -39,7 +41,26 @@ export default function SettingsPage() {
|
||||||
const [inviteRole, setInviteRole] = useState("caregiver");
|
const [inviteRole, setInviteRole] = useState("caregiver");
|
||||||
const [inviteLoading, setInviteLoading] = useState(false);
|
const [inviteLoading, setInviteLoading] = useState(false);
|
||||||
const [pedPhone, setPedPhone] = useState("");
|
const [pedPhone, setPedPhone] = useState("");
|
||||||
|
const [pedName, setPedName] = useState("");
|
||||||
const [pedSaving, setPedSaving] = useState(false);
|
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)
|
// Check if can invite more members (client-side pre-check; server enforces)
|
||||||
const canInvite = tier === "pro" || memberCount < 2;
|
const canInvite = tier === "pro" || memberCount < 2;
|
||||||
|
|
@ -59,7 +80,10 @@ export default function SettingsPage() {
|
||||||
fetchMembers();
|
fetchMembers();
|
||||||
fetchInvites();
|
fetchInvites();
|
||||||
fetch("/api/family").then(r => r.json()).then(d => {
|
fetch("/api/family").then(r => r.json()).then(d => {
|
||||||
if (d.family?.pediatrician_phone) setPedPhone(d.family.pediatrician_phone);
|
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(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
}, [familyId]);
|
}, [familyId]);
|
||||||
|
|
@ -74,7 +98,7 @@ export default function SettingsPage() {
|
||||||
if (records.length === 0) { alert("No growth records to export."); return; }
|
if (records.length === 0) { alert("No growth records to export."); return; }
|
||||||
const headers = ["Date", "Weight (kg)", "Height (cm)", "Head (cm)", "Notes"];
|
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 }) => [
|
const rows = records.map((r: { measured_at: string; weight_kg: number | null; height_cm: number | null; head_circumference_cm: number | null; notes: string | null }) => [
|
||||||
new Date(r.measured_at).toLocaleDateString(),
|
fmtDate(r.measured_at),
|
||||||
r.weight_kg ?? "",
|
r.weight_kg ?? "",
|
||||||
r.height_cm ?? "",
|
r.height_cm ?? "",
|
||||||
r.head_circumference_cm ?? "",
|
r.head_circumference_cm ?? "",
|
||||||
|
|
@ -92,13 +116,26 @@ export default function SettingsPage() {
|
||||||
setExporting(false);
|
setExporting(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const savePedPhone = async () => {
|
const savePedInfo = async () => {
|
||||||
setPedSaving(true);
|
setPedSaving(true);
|
||||||
await fetch("/api/family", {
|
setPedError("");
|
||||||
method: "PATCH",
|
try {
|
||||||
headers: { "Content-Type": "application/json" },
|
const res = await fetch("/api/family", {
|
||||||
body: JSON.stringify({ pediatricianPhone: pedPhone }),
|
method: "PATCH",
|
||||||
}).catch(() => {});
|
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);
|
setPedSaving(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -171,6 +208,45 @@ export default function SettingsPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-4 space-y-3">
|
<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 */}
|
{/* My Profile Page */}
|
||||||
<a href="/settings/profile"
|
<a href="/settings/profile"
|
||||||
className="flex items-center justify-between bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm mb-3">
|
className="flex items-center justify-between bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm mb-3">
|
||||||
|
|
@ -223,7 +299,9 @@ export default function SettingsPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{tier === "free" && (
|
{tier === "free" && (
|
||||||
<Button size="sm">Upgrade</Button>
|
<a href="#upgrade">
|
||||||
|
<Button size="sm">Upgrade</Button>
|
||||||
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -295,7 +373,7 @@ export default function SettingsPage() {
|
||||||
<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 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>
|
||||||
<div className="font-medium text-gray-800 dark:text-gray-100">{invite.email}</div>
|
<div className="font-medium text-gray-800 dark:text-gray-100">{invite.email}</div>
|
||||||
<div className="text-xs text-gray-400">Pending · expires {new Date(invite.expiresAt).toLocaleDateString()}</div>
|
<div className="text-xs text-gray-400">Pending · expires {fmtDate(invite.expiresAt)}</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => deleteInvite(invite.id)}
|
onClick={() => deleteInvite(invite.id)}
|
||||||
|
|
@ -356,17 +434,56 @@ export default function SettingsPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pediatrician Phone */}
|
{/* Pediatrician */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-4 space-y-2">
|
<div className="bg-white dark:bg-gray-800 rounded-xl p-4 space-y-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-xl">🏥</span>
|
<div className="flex items-center gap-2">
|
||||||
<div className="font-medium dark:text-white">Pediatrician Phone</div>
|
<span className="text-xl">🏥</span>
|
||||||
</div>
|
<div className="font-medium dark:text-white">Pediatrician</div>
|
||||||
<p className="text-xs text-gray-400 dark:text-gray-500">Shown on the emergency guide and in AI medical redirects.</p>
|
</div>
|
||||||
<div className="flex gap-2">
|
{!pedEditing && (pedName || pedPhone) && (
|
||||||
<Input type="tel" value={pedPhone} onChange={e => setPedPhone(e.target.value)} placeholder="+91 98765 43210" className="flex-1" />
|
<button
|
||||||
<Button size="sm" loading={pedSaving} onClick={savePedPhone}>Save</Button>
|
onClick={() => { setPedEditing(true); setPedSaved(false); setPedError(""); }}
|
||||||
|
className="text-sm text-rose-500 dark:text-rose-400 font-medium"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</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">
|
<Link href="/medical/emergency" className="text-xs text-rose-500 dark:text-rose-400">
|
||||||
View Emergency Guide →
|
View Emergency Guide →
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,10 @@ import { useRouter } from "next/navigation";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useFamily } from "@/app/FamilyProvider";
|
import { useFamily } from "@/app/FamilyProvider";
|
||||||
import { Button } from "@/components/ui";
|
import { Button } from "@/components/ui";
|
||||||
|
import { trackGarmentAdded } from "@/lib/analytics";
|
||||||
import { GARMENT_CATEGORIES, GARMENT_SIZE_ORDER } from "@/db/schema/wardrobe";
|
import { GARMENT_CATEGORIES, GARMENT_SIZE_ORDER } from "@/db/schema/wardrobe";
|
||||||
|
|
||||||
type Step = "capture" | "tagging" | "form";
|
type Step = "capture" | "form";
|
||||||
|
|
||||||
interface VisionResult {
|
interface VisionResult {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -110,7 +111,8 @@ function SingleChipRow<T extends string>({
|
||||||
export default function AddGarmentPage() {
|
export default function AddGarmentPage() {
|
||||||
const { childId } = useFamily();
|
const { childId } = useFamily();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null); // gallery picker
|
||||||
|
const cameraRef = useRef<HTMLInputElement>(null); // camera capture
|
||||||
|
|
||||||
const [step, setStep] = useState<Step>("capture");
|
const [step, setStep] = useState<Step>("capture");
|
||||||
const [preview, setPreview] = useState<string | null>(null);
|
const [preview, setPreview] = useState<string | null>(null);
|
||||||
|
|
@ -119,6 +121,8 @@ export default function AddGarmentPage() {
|
||||||
const [thumbKey, setThumbKey] = useState("");
|
const [thumbKey, setThumbKey] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [saving, setSaving] = useState(false);
|
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
|
// Form fields
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
|
|
@ -136,53 +140,59 @@ export default function AddGarmentPage() {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
setError("");
|
setError("");
|
||||||
setStep("tagging");
|
|
||||||
|
|
||||||
// Show local preview immediately
|
// 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();
|
const reader = new FileReader();
|
||||||
reader.onload = ev => setPreview(ev.target?.result as string);
|
reader.onload = ev => setPreview(ev.target?.result as string);
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
|
setStep("form");
|
||||||
|
setUploading(true);
|
||||||
|
setVisionPending(false);
|
||||||
|
|
||||||
// Upload to R2
|
// 2. Upload to R2
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append("file", file);
|
form.append("file", file);
|
||||||
let uploadRes;
|
let ik = "", tk = "", tu = "";
|
||||||
try {
|
try {
|
||||||
uploadRes = await fetch("/api/garments/upload", { method: "POST", body: form });
|
const uploadRes = await fetch("/api/garments/upload", { method: "POST", body: form });
|
||||||
if (!uploadRes.ok) throw new Error((await uploadRes.json()).error);
|
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) {
|
} catch (err) {
|
||||||
setError(`Upload failed: ${err}`);
|
setError(`Upload failed: ${err}`);
|
||||||
setStep("capture");
|
setStep("capture");
|
||||||
|
setUploading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { imageKey: ik, thumbKey: tk, thumbUrl: tu } = await uploadRes.json();
|
setUploading(false);
|
||||||
setImageKey(ik);
|
|
||||||
setThumbKey(tk);
|
|
||||||
setThumbUrl(tu);
|
|
||||||
|
|
||||||
// Vision tagging
|
// 3. Fire vision AI in the background — do NOT await, form stays interactive.
|
||||||
let vision: VisionResult = { name: "", category: "top", colors: [], seasons: [], occasion_tags: [] };
|
setVisionPending(true);
|
||||||
try {
|
fetch("/api/garments/tag", {
|
||||||
const tagRes = await fetch("/api/garments/tag", {
|
method: "POST",
|
||||||
method: "POST",
|
headers: { "Content-Type": "application/json" },
|
||||||
headers: { "Content-Type": "application/json" },
|
body: JSON.stringify({ imageKey: ik }),
|
||||||
body: JSON.stringify({ imageKey: ik }),
|
})
|
||||||
});
|
.then(r => r.ok ? r.json() : null)
|
||||||
if (tagRes.ok) {
|
.then((vision: VisionResult | null) => {
|
||||||
const tagData = await tagRes.json();
|
if (vision) {
|
||||||
vision = tagData;
|
setVisionMetadata(vision);
|
||||||
}
|
// Only pre-fill fields the user hasn't touched yet
|
||||||
} catch {
|
setName(n => n || vision.name || "");
|
||||||
// Vision failure is non-fatal — proceed with empty defaults
|
setCategory(c => c || vision.category || null);
|
||||||
}
|
setColors(c => c.length ? c : (vision.colors || []));
|
||||||
|
setSeasons(s => s.length ? s : (vision.seasons || []));
|
||||||
setVisionMetadata(vision);
|
setOccasionTags(t => t.length ? t : (vision.occasion_tags || []));
|
||||||
setName(vision.name || "");
|
}
|
||||||
setCategory(vision.category || null);
|
})
|
||||||
setColors(vision.colors || []);
|
.catch(() => {/* vision failure is non-fatal */})
|
||||||
setSeasons(vision.seasons || []);
|
.finally(() => setVisionPending(false));
|
||||||
setOccasionTags(vision.occasion_tags || []);
|
|
||||||
setStep("form");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const addColor = () => {
|
const addColor = () => {
|
||||||
|
|
@ -215,6 +225,7 @@ export default function AddGarmentPage() {
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error((await res.json()).error);
|
if (!res.ok) throw new Error((await res.json()).error);
|
||||||
|
trackGarmentAdded();
|
||||||
handleAddAnother();
|
handleAddAnother();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(`Save failed: ${err}`);
|
setError(`Save failed: ${err}`);
|
||||||
|
|
@ -240,7 +251,10 @@ export default function AddGarmentPage() {
|
||||||
setGiftFrom("");
|
setGiftFrom("");
|
||||||
setVisionMetadata(null);
|
setVisionMetadata(null);
|
||||||
setError("");
|
setError("");
|
||||||
if (fileRef.current) fileRef.current.value = "";
|
setUploading(false);
|
||||||
|
setVisionPending(false);
|
||||||
|
if (fileRef.current) fileRef.current.value = "";
|
||||||
|
if (cameraRef.current) cameraRef.current.value = "";
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -249,9 +263,14 @@ export default function AddGarmentPage() {
|
||||||
<div className="flex items-center gap-3 p-4">
|
<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>
|
<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>
|
<h1 className="text-xl font-bold">Add Garment</h1>
|
||||||
{step === "form" && (
|
{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">
|
<span className="ml-auto text-xs text-gray-400 bg-white dark:bg-gray-800 px-2 py-1 rounded-full shadow-sm">
|
||||||
✨ Vision pre-filled
|
✨ AI pre-filled
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -265,23 +284,42 @@ export default function AddGarmentPage() {
|
||||||
{/* Step 1: Capture */}
|
{/* Step 1: Capture */}
|
||||||
{step === "capture" && (
|
{step === "capture" && (
|
||||||
<div className="mx-4 mt-4">
|
<div className="mx-4 mt-4">
|
||||||
<div
|
<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">
|
||||||
onClick={() => fileRef.current?.click()}
|
|
||||||
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 cursor-pointer hover:border-rose-400 transition-colors bg-white dark:bg-gray-800"
|
|
||||||
>
|
|
||||||
<span className="text-6xl">👚</span>
|
<span className="text-6xl">👚</span>
|
||||||
<div className="text-center px-4">
|
<div className="text-center px-4">
|
||||||
<p className="font-semibold text-gray-700 dark:text-gray-200">Take or upload a photo</p>
|
<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 tagging</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>
|
||||||
<span className="px-4 py-2 bg-rose-400 text-white rounded-full text-sm font-medium">
|
|
||||||
📷 Add Photo
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
{/* Gallery input — no capture attribute, Android shows full media picker */}
|
||||||
<input
|
<input
|
||||||
ref={fileRef}
|
ref={fileRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
{/* Camera input — capture="environment" opens rear camera directly */}
|
||||||
|
<input
|
||||||
|
ref={cameraRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
capture="environment"
|
capture="environment"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
|
|
@ -289,34 +327,19 @@ export default function AddGarmentPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 2: Tagging spinner */}
|
{/* Step 2: Form (shown immediately after photo selected) */}
|
||||||
{step === "tagging" && (
|
|
||||||
<div className="mx-4 mt-4 flex flex-col items-center gap-6">
|
|
||||||
{preview && (
|
|
||||||
<div className="w-48 h-48 rounded-2xl overflow-hidden shadow-lg">
|
|
||||||
<img src={preview} alt="preview" className="w-full h-full object-cover" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="flex gap-1 justify-center mb-3">
|
|
||||||
{[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="font-semibold text-gray-700 dark:text-gray-200">Analysing garment…</p>
|
|
||||||
<p className="text-sm text-gray-400">Vision is detecting category & colors</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Step 3: Form */}
|
|
||||||
{step === "form" && (
|
{step === "form" && (
|
||||||
<div className="mx-4 mt-2 space-y-2">
|
<div className="mx-4 mt-2 space-y-2">
|
||||||
{/* Thumbnail */}
|
{/* Thumbnail — shows upload spinner until R2 upload is done */}
|
||||||
<div className="flex gap-4 items-start mb-4">
|
<div className="flex gap-4 items-start mb-4">
|
||||||
{(thumbUrl || preview) && (
|
{(thumbUrl || preview) && (
|
||||||
<div className="w-24 h-24 rounded-2xl overflow-hidden shadow-md flex-shrink-0">
|
<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" />
|
<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>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
|
|
@ -446,12 +469,17 @@ export default function AddGarmentPage() {
|
||||||
fullWidth
|
fullWidth
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
loading={saving}
|
loading={saving}
|
||||||
disabled={!category || !sizeLabel || !childId}
|
disabled={!category || !sizeLabel || !childId || uploading}
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
Save Garment
|
{uploading ? "Uploading photo…" : "Save Garment"}
|
||||||
</Button>
|
</Button>
|
||||||
{!sizeLabel && (
|
{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">
|
<p className="text-center text-sm text-amber-600 dark:text-amber-400">
|
||||||
Please select a size to save
|
Please select a size to save
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
568
src/app/(marketing)/about/page.tsx
Normal file
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
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
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
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
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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,29 @@
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { Fraunces, Newsreader, JetBrains_Mono } from "next/font/google";
|
||||||
import { MarketingNav } from "@/components/marketing/MarketingNav";
|
import { MarketingNav } from "@/components/marketing/MarketingNav";
|
||||||
import Script from "next/script";
|
|
||||||
|
// ── 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 = {
|
export const metadata: Metadata = {
|
||||||
title: {
|
title: {
|
||||||
|
|
@ -31,16 +53,7 @@ export default function MarketingLayout({
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<div className={`${fraunces.variable} ${newsreader.variable} ${jetbrainsMono.variable}`}>
|
||||||
{/* Privacy-respecting analytics — no cookies, no tracking */}
|
|
||||||
<Script
|
|
||||||
defer
|
|
||||||
data-domain={process.env.NEXT_PUBLIC_APP_URL?.replace("https://", "").replace("http://", "")}
|
|
||||||
src="https://plausible.io/js/script.js"
|
|
||||||
strategy="afterInteractive"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Scroll-reveal nav — appears after scrolling past hero */}
|
|
||||||
<MarketingNav />
|
<MarketingNav />
|
||||||
|
|
||||||
<main>{children}</main>
|
<main>{children}</main>
|
||||||
|
|
@ -48,38 +61,70 @@ export default function MarketingLayout({
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<footer className="bg-gray-50 border-t border-gray-100 mt-20">
|
<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="max-w-5xl mx-auto px-5 py-12">
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-start gap-8">
|
<div className="flex flex-col lg:flex-row lg:justify-between gap-10 mb-8">
|
||||||
<div>
|
|
||||||
|
{/* Brand block */}
|
||||||
|
<div className="lg:max-w-xs">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<span className="text-2xl">🌸</span>
|
<span className="text-2xl">🌸</span>
|
||||||
<span className="text-xl font-bold text-gray-900" style={{ fontFamily: "var(--font-caveat)" }}>
|
<span className="text-xl font-bold text-gray-900" style={{ fontFamily: "var(--font-caveat)" }}>
|
||||||
Tia
|
Tia
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-500 max-w-xs leading-relaxed">
|
<p className="font-newsreader text-sm text-gray-500 leading-relaxed">
|
||||||
A digital heirloom for your baby.<br />Every moment, preserved.
|
A digital heirloom for your baby.<br />Every moment, preserved — privately.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 text-sm">
|
{/* Three-column link group */}
|
||||||
<p className="font-semibold text-gray-700 mb-1">Links</p>
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-8 lg:gap-14 text-sm">
|
||||||
<Link href="/pricing" className="text-gray-500 hover:text-rose-600 transition-colors duration-150">Pricing</Link>
|
|
||||||
<Link href="/privacy" className="text-gray-500 hover:text-rose-600 transition-colors duration-150">Privacy Policy</Link>
|
{/* Company */}
|
||||||
<Link href="/terms" className="text-gray-500 hover:text-rose-600 transition-colors duration-150">Terms of Service</Link>
|
<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>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom bar — different shade */}
|
{/* Bottom bar — different shade */}
|
||||||
<div className="bg-gray-100 border-t border-gray-200">
|
<div className="bg-gray-100 border-t border-gray-200">
|
||||||
<div className="max-w-5xl mx-auto px-5 py-4 flex flex-col sm:flex-row items-center justify-between gap-2 text-xs text-gray-500">
|
<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">
|
||||||
<span>© {new Date().getFullYear()} Tia.</span>
|
<p>© {new Date().getFullYear()} Tia.</p>
|
||||||
<span>We don't sell your data — we preserve it.</span>
|
<p className="sm:text-center">We don't sell your data — we preserve it.</p>
|
||||||
<span>Built with ❤️ in India.</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,48 +1,93 @@
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { PhoneMockup } from "@/components/marketing/PhoneMockup";
|
||||||
|
import {
|
||||||
|
jsonLdScript,
|
||||||
|
organizationSchema,
|
||||||
|
websiteSchema,
|
||||||
|
softwareApplicationSchema,
|
||||||
|
} from "@/lib/seo";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Tia — Your baby's digital heirloom",
|
title: "Tia — Baby Tracker & Digital Heirloom for Indian Families",
|
||||||
description:
|
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.",
|
"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 ───────────────────────────────────────────────
|
// ── Section: Hero ───────────────────────────────────────────────
|
||||||
function Hero() {
|
function Hero() {
|
||||||
return (
|
return (
|
||||||
<section className="relative overflow-hidden bg-gradient-to-br from-rose-50 via-amber-50 to-rose-50 pt-16 pb-24 px-4">
|
<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-2xl mx-auto text-center">
|
<div className="max-w-5xl mx-auto grid grid-cols-1 lg:grid-cols-[1.1fr_0.9fr] gap-10 items-center">
|
||||||
<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
|
{/* 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>
|
</div>
|
||||||
|
|
||||||
<h1 className="text-4xl sm:text-5xl font-bold text-gray-900 leading-tight mb-5">
|
{/* RIGHT: animated phone mockup */}
|
||||||
Your baby's story,{" "}
|
<div className="flex justify-center lg:justify-end">
|
||||||
<span className="text-rose-500" style={{ fontFamily: "var(--font-caveat)", fontSize: "1.1em" }}>
|
<PhoneMockup />
|
||||||
preserved for a lifetime.
|
</div>
|
||||||
</span>
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<p className="text-xl font-light text-gray-500 leading-loose mb-8 max-w-xl mx-auto">
|
|
||||||
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-lg active:scale-95"
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5 flex-shrink-0" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
|
||||||
<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>
|
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
{/* Decorative blobs */}
|
{/* Decorative blobs */}
|
||||||
|
|
@ -55,16 +100,16 @@ function Hero() {
|
||||||
// ── Section: The Problem ────────────────────────────────────────
|
// ── Section: The Problem ────────────────────────────────────────
|
||||||
function TheProblem() {
|
function TheProblem() {
|
||||||
return (
|
return (
|
||||||
<section className="py-20 px-4 bg-white">
|
<section className="py-20 px-5 bg-white">
|
||||||
<div className="max-w-2xl mx-auto">
|
<div className="max-w-2xl mx-auto">
|
||||||
<p className="text-xs font-semibold text-rose-500 uppercase tracking-widest mb-4">The 3am reality</p>
|
<p className="font-jetbrains text-xs font-semibold text-rose-500 uppercase tracking-widest mb-4">The 3am reality</p>
|
||||||
|
|
||||||
<h2 className="text-3xl font-bold text-gray-900 mb-6 leading-tight">
|
<h2 className="font-fraunces text-3xl font-bold text-gray-900 mb-6 leading-tight">
|
||||||
You're awake at 3am.<br />
|
You're awake at 3am.<br />
|
||||||
When did she last feed?
|
When did she last feed?
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="space-y-4 text-gray-600 leading-relaxed">
|
<div className="font-newsreader space-y-4 text-gray-600 leading-relaxed">
|
||||||
<p>
|
<p>
|
||||||
You scroll back through WhatsApp messages to your mother-in-law. You check a
|
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
|
sticky note on the refrigerator. You open a notes app you downloaded last week
|
||||||
|
|
@ -114,19 +159,19 @@ const FEATURES = [
|
||||||
{
|
{
|
||||||
icon: "🔮",
|
icon: "🔮",
|
||||||
title: "Ask Tia",
|
title: "Ask Tia",
|
||||||
body: "Ask Tia parenting logistics questions — feeding windows, sleep patterns, when to introduce solids. For anything that sounds medical, Tia will always refer you to your pediatrician. That restraint is intentional. It's a trust feature, not a limitation.",
|
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.\"",
|
example: "\"Is 90ml normal at 6 weeks?\" → Tia gives context, then: \"Your pediatrician can confirm this for your baby specifically.\"",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: "📚",
|
icon: "📚",
|
||||||
title: "The heirloom archive",
|
title: "The heirloom archive",
|
||||||
body: "Every log, photo, milestone, and memory becomes part of a permanent, private archive. Not a feed. Not a highlights reel. A complete record of your child's earliest years — searchable, exportable, and theirs to keep.",
|
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.",
|
example: "\"Show me everything from her first month\" → every feed, every photo, every note.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: "👨👩👧",
|
icon: "👨👩👧",
|
||||||
title: "Family circle",
|
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 to milestones. The nanny gets caregiver access to log feeds. You stay the admin.",
|
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.",
|
example: "Nani in Jaipur sees today's photos in real time. The daai logs the afternoon feed.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -139,25 +184,29 @@ const FEATURES = [
|
||||||
|
|
||||||
function Features() {
|
function Features() {
|
||||||
return (
|
return (
|
||||||
<section className="py-20 px-4 bg-gray-50">
|
<section className="py-20 px-5 bg-gray-50">
|
||||||
<div className="max-w-2xl mx-auto">
|
<div className="max-w-5xl mx-auto">
|
||||||
<p className="text-xs font-semibold text-rose-500 uppercase tracking-widest mb-4 text-center">What Tia does</p>
|
<p className="font-jetbrains text-xs font-semibold text-rose-500 uppercase tracking-widest mb-4 text-center">What Tia does</p>
|
||||||
<h2 className="text-3xl font-bold text-gray-900 text-center mb-12">
|
<h2 className="font-fraunces text-3xl font-bold text-gray-900 text-center mb-12">
|
||||||
Everything in one private place.
|
Everything in one private place.
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{FEATURES.map(f => (
|
{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">
|
<div
|
||||||
<div className="flex items-start gap-4">
|
key={f.title}
|
||||||
<span className="text-3xl flex-shrink-0 transition-transform duration-200 group-hover:scale-110">{f.icon}</span>
|
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"
|
||||||
<div>
|
>
|
||||||
<h3 className="font-bold text-gray-900 text-lg mb-2">{f.title}</h3>
|
<span className="text-3xl mb-4 transition-transform duration-200 group-hover:scale-110 block">
|
||||||
<p className="text-gray-600 text-sm leading-relaxed mb-3" dangerouslySetInnerHTML={{ __html: f.body }} />
|
{f.icon}
|
||||||
<div className="bg-rose-50 rounded-lg px-3 py-2 text-xs text-rose-700 italic">
|
</span>
|
||||||
{f.example}
|
<h3 className="font-fraunces font-bold text-gray-900 text-lg mb-2">{f.title}</h3>
|
||||||
</div>
|
<p
|
||||||
</div>
|
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>
|
||||||
))}
|
))}
|
||||||
|
|
@ -170,49 +219,56 @@ function Features() {
|
||||||
// ── Section: Founder Story ─────────────────────────────────────
|
// ── Section: Founder Story ─────────────────────────────────────
|
||||||
function FounderStory() {
|
function FounderStory() {
|
||||||
return (
|
return (
|
||||||
<section className="py-20 px-4 bg-white">
|
<section className="py-20 px-5 bg-white">
|
||||||
<div className="max-w-2xl mx-auto">
|
<div className="max-w-2xl mx-auto">
|
||||||
<p className="text-xs font-semibold text-rose-500 uppercase tracking-widest mb-4">Why Tia exists</p>
|
<p className="font-jetbrains text-xs font-semibold text-rose-500 uppercase tracking-widest mb-4">Why Tia exists</p>
|
||||||
|
|
||||||
<div className="bg-amber-50 border border-amber-200 rounded-2xl p-6 sm:p-8">
|
<h2 className="font-fraunces text-3xl font-bold text-gray-900 mb-3 leading-tight">
|
||||||
{/*
|
TIA began with a promise.
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
</h2>
|
||||||
│ PLACEHOLDER — Manohar, this section is yours to write. │
|
|
||||||
│ │
|
<div className="bg-amber-50 border border-amber-200 rounded-2xl p-6 sm:p-8 mt-6">
|
||||||
│ Tell the story of your daughter. The specific 3am moment in │
|
<div className="font-newsreader text-gray-700 leading-relaxed space-y-4 text-sm sm:text-base">
|
||||||
│ Gurugram that made you start building. The fear that she'd │
|
<p>
|
||||||
│ grow up and you'd have nothing but blurry photos. The reason │
|
When our daughter, Tia, was born, we wanted to remember everything — the tiny
|
||||||
│ you want her to be able to read her own first chapter one day. │
|
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
|
||||||
│ Replace everything between these comment tags with your own │
|
best to hold on to them.
|
||||||
│ words. This section is the moat — it has to be in your voice. │
|
</p>
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
|
||||||
*/}
|
<p className="font-medium text-gray-800">
|
||||||
<div className="flex items-center gap-3 mb-5">
|
We built TIA to help families preserve those memories.
|
||||||
<div className="w-10 h-10 rounded-full bg-rose-200 flex items-center justify-center text-lg">👨💻</div>
|
</p>
|
||||||
<div>
|
|
||||||
<p className="font-semibold text-gray-900">Manohar Gupta</p>
|
<p>
|
||||||
<p className="text-xs text-gray-500">Founder, Tia · Gurugram</p>
|
Named after our daughter, Tia, the app reflects the values that guide us as parents:
|
||||||
</div>
|
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>
|
||||||
|
|
||||||
<blockquote className="text-gray-700 leading-relaxed space-y-4 italic text-sm sm:text-base">
|
<div className="mt-8 pt-6 border-t border-amber-200 text-center">
|
||||||
<p>
|
<p className="text-sm font-semibold text-amber-800 tracking-wide">
|
||||||
[PLACEHOLDER] My daughter was born and within two weeks I realised I was already
|
Built by parents. Inspired by our daughter. Made for families.
|
||||||
forgetting things. Not forgetting them completely — just losing the texture. The
|
|
||||||
exact weight on day five. The time of the first real smile. The way she sounded
|
|
||||||
when she figured out her own hands.
|
|
||||||
</p>
|
</p>
|
||||||
<p>
|
</div>
|
||||||
[PLACEHOLDER] I wanted to build something that would let her read her own first
|
|
||||||
chapter one day. Not a social media highlight reel. A real archive — private,
|
|
||||||
complete, and hers to keep. That's Tia.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
[PLACEHOLDER] — Replace these paragraphs with your story in your own words.
|
|
||||||
The specific 3am moment. The specific fear. Why Gurugram, why this daughter, why now.
|
|
||||||
</p>
|
|
||||||
</blockquote>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -222,18 +278,18 @@ function FounderStory() {
|
||||||
// ── Section: The Heirloom Vision ───────────────────────────────
|
// ── Section: The Heirloom Vision ───────────────────────────────
|
||||||
function HeirloomVision() {
|
function HeirloomVision() {
|
||||||
return (
|
return (
|
||||||
<section className="py-20 px-4 bg-gradient-to-br from-rose-50 to-amber-50">
|
<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">
|
<div className="max-w-2xl mx-auto text-center">
|
||||||
<p className="text-xs font-semibold text-rose-500 uppercase tracking-widest mb-4">The heirloom vision</p>
|
<p className="font-jetbrains text-xs font-semibold text-rose-500 uppercase tracking-widest mb-4">The heirloom vision</p>
|
||||||
|
|
||||||
<h2 className="text-3xl font-bold text-gray-900 mb-6 leading-tight">
|
<h2 className="font-fraunces text-3xl font-bold text-gray-900 mb-6 leading-tight">
|
||||||
One day, your child will be able to{" "}
|
One day, your child will be able to{" "}
|
||||||
<span className="text-rose-500" style={{ fontFamily: "var(--font-caveat)", fontSize: "1.1em" }}>
|
<span className="text-rose-500" style={{ fontFamily: "var(--font-caveat)", fontSize: "1.1em" }}>
|
||||||
read their own story.
|
read their own story.
|
||||||
</span>
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p className="text-gray-600 leading-relaxed mb-8 max-w-xl mx-auto">
|
<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.
|
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 first solid. The doctor visit you worried about for a week. The photo from
|
||||||
the moment you realised she could recognise your voice.
|
the moment you realised she could recognise your voice.
|
||||||
|
|
@ -241,26 +297,14 @@ function HeirloomVision() {
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-left">
|
<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: "📖",
|
{ icon: "🔒", title: "Private and permanent", desc: "No public feed. No algorithm. Your family's archive, locked to your family." },
|
||||||
title: "A complete record",
|
{ icon: "💾", title: "Fully exportable", desc: "Your data is yours. Export everything at any time. The heirloom is portable." },
|
||||||
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 => (
|
].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">
|
<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>
|
<span className="text-2xl mb-3 block">{item.icon}</span>
|
||||||
<h3 className="font-bold text-gray-900 mb-1">{item.title}</h3>
|
<h3 className="font-fraunces font-bold text-gray-900 mb-1">{item.title}</h3>
|
||||||
<p className="text-sm text-gray-600 leading-relaxed" dangerouslySetInnerHTML={{ __html: item.desc }} />
|
<p className="text-sm text-gray-600 leading-relaxed">{item.desc}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -272,48 +316,32 @@ function HeirloomVision() {
|
||||||
// ── Section: Privacy & Trust ───────────────────────────────────
|
// ── Section: Privacy & Trust ───────────────────────────────────
|
||||||
function Privacy() {
|
function Privacy() {
|
||||||
return (
|
return (
|
||||||
<section className="py-20 px-4 bg-white">
|
<section className="py-20 px-5 bg-white">
|
||||||
<div className="max-w-2xl mx-auto">
|
<div className="max-w-2xl mx-auto">
|
||||||
<p className="text-xs font-semibold text-rose-500 uppercase tracking-widest mb-4">Privacy & trust</p>
|
<p className="font-jetbrains text-xs font-semibold text-rose-500 uppercase tracking-widest mb-4">Privacy & trust</p>
|
||||||
|
|
||||||
<h2 className="text-3xl font-bold text-gray-900 mb-4">
|
<h2 className="font-fraunces text-3xl font-bold text-gray-900 mb-4">
|
||||||
We don't sell your data —<br />
|
We don't sell your data —<br />
|
||||||
<span className="text-rose-500">we preserve it.</span>
|
<span className="text-rose-500">we preserve it.</span>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p className="text-gray-600 leading-relaxed mb-8">
|
<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.
|
Tia is a baby-tracking app. Your child's records are not the product.
|
||||||
They are the point.
|
They are the point.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<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: "🏛️",
|
{ icon: "🚫", title: "No ads. No data resale.", desc: "We do not sell, share, or monetise your data with third parties. Ever." },
|
||||||
title: "Row-Level Security",
|
{ icon: "📤", title: "Full export at any time", desc: "Export everything: logs, photos, milestones, vaccinations. Your data leaves with you whenever you want." },
|
||||||
desc: "Your family's data is isolated at the database level. No other user — no other family — can reach your records.",
|
{ 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." },
|
||||||
},
|
|
||||||
{
|
|
||||||
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 => (
|
].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">
|
<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>
|
<span className="text-xl flex-shrink-0 mt-0.5">{item.icon}</span>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-gray-900 text-sm">{item.title}</p>
|
<p className="font-semibold text-gray-900 text-sm">{item.title}</p>
|
||||||
<p className="text-sm text-gray-600 leading-relaxed mt-0.5" dangerouslySetInnerHTML={{ __html: item.desc }} />
|
<p className="text-sm text-gray-600 leading-relaxed mt-0.5">{item.desc}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -326,30 +354,21 @@ function Privacy() {
|
||||||
// ── Section: Early Access ──────────────────────────────────────
|
// ── Section: Early Access ──────────────────────────────────────
|
||||||
function EarlyAccess() {
|
function EarlyAccess() {
|
||||||
return (
|
return (
|
||||||
<section className="py-16 px-4 bg-gray-50 border-y border-gray-100">
|
<section className="py-16 px-5 bg-gray-50 border-y border-gray-100">
|
||||||
<div className="max-w-2xl mx-auto">
|
<div className="max-w-2xl mx-auto">
|
||||||
<p className="text-xs font-semibold text-rose-500 uppercase tracking-widest mb-4 text-center">Private early access</p>
|
<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">
|
||||||
<h2 className="text-2xl font-bold text-gray-900 text-center mb-6">
|
|
||||||
Built by a parent, being tested by parents.
|
Built by a parent, being tested by parents.
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<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: "🌱",
|
{ icon: "🇮🇳", title: "India-native from day one", desc: "IAP vaccination schedule. Telegram alerts. Indian naming conventions. Built for how Indian families actually live." },
|
||||||
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 => (
|
].map(item => (
|
||||||
<div key={item.title} className="bg-white rounded-2xl p-5 border border-gray-100 shadow-sm">
|
<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>
|
<span className="text-2xl mb-3 block">{item.icon}</span>
|
||||||
<h3 className="font-bold text-gray-900 mb-2">{item.title}</h3>
|
<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>
|
<p className="text-sm text-gray-600 leading-relaxed">{item.desc}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -362,9 +381,9 @@ function EarlyAccess() {
|
||||||
// ── Section: Final CTA ─────────────────────────────────────────
|
// ── Section: Final CTA ─────────────────────────────────────────
|
||||||
function FinalCTA() {
|
function FinalCTA() {
|
||||||
return (
|
return (
|
||||||
<section className="py-24 px-4 bg-gradient-to-br from-rose-500 to-rose-600 text-white text-center">
|
<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">
|
<div className="max-w-xl mx-auto">
|
||||||
<h2 className="text-3xl sm:text-4xl font-bold mb-4 leading-tight">
|
<h2 className="font-fraunces text-3xl sm:text-4xl font-bold mb-4 leading-tight">
|
||||||
Start preserving your<br />child's story today.
|
Start preserving your<br />child's story today.
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
|
|
@ -376,12 +395,7 @@ function FinalCTA() {
|
||||||
href="/login"
|
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"
|
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"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
<GoogleG />
|
||||||
<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>
|
|
||||||
Continue with Google
|
Continue with Google
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
|
@ -397,6 +411,15 @@ function FinalCTA() {
|
||||||
export default function MarketingHomePage() {
|
export default function MarketingHomePage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{/* Structured data — Organization, WebSite, and SoftwareApplication */}
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={jsonLdScript([
|
||||||
|
organizationSchema(),
|
||||||
|
websiteSchema(),
|
||||||
|
softwareApplicationSchema(),
|
||||||
|
])}
|
||||||
|
/>
|
||||||
<Hero />
|
<Hero />
|
||||||
<TheProblem />
|
<TheProblem />
|
||||||
<Features />
|
<Features />
|
||||||
|
|
|
||||||
59
src/app/(marketing)/partners/page.tsx
Normal file
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,15 +3,22 @@ import Link from "next/link";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Pricing",
|
title: "Pricing",
|
||||||
description: "Founder pricing — early families keep their terms for life.",
|
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() {
|
export default function PricingPage() {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto px-4 py-20">
|
<div className="max-w-2xl mx-auto px-4 py-20">
|
||||||
<p className="text-xs font-semibold text-rose-500 uppercase tracking-widest mb-4 text-center">Pricing</p>
|
<p className="font-jetbrains text-xs font-semibold text-rose-500 uppercase tracking-widest mb-4 text-center">Pricing</p>
|
||||||
|
|
||||||
<h1 className="text-3xl sm:text-4xl font-bold text-gray-900 text-center mb-4">
|
<h1 className="font-fraunces text-3xl sm:text-4xl font-bold text-gray-900 text-center mb-4">
|
||||||
Founder pricing.
|
Founder pricing.
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-500 text-center mb-12 max-w-md mx-auto">
|
<p className="text-gray-500 text-center mb-12 max-w-md mx-auto">
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,13 @@ import type { Metadata } from "next";
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Privacy Policy",
|
title: "Privacy Policy",
|
||||||
description: "How Tia handles your family's data. We don't sell it — we preserve it.",
|
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() {
|
export default function PrivacyPage() {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,14 @@ import type { Metadata } from "next";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Terms of Service",
|
title: "Terms of Service",
|
||||||
description: "Terms of service for Tia.",
|
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() {
|
export default function TermsPage() {
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,18 @@ interface NavItem {
|
||||||
|
|
||||||
const navItems: NavItem[] = [
|
const navItems: NavItem[] = [
|
||||||
{ name: "Dashboard", href: "/admin", icon: "📊" },
|
{ name: "Dashboard", href: "/admin", icon: "📊" },
|
||||||
|
{ name: "Health", href: "/admin/health", icon: "❤️🩹" },
|
||||||
|
{ name: "Errors", href: "/admin/errors", icon: "🐞" },
|
||||||
{ name: "Activity", href: "/admin/activity", icon: "🔍" },
|
{ name: "Activity", href: "/admin/activity", icon: "🔍" },
|
||||||
|
{ name: "Audit Log", href: "/admin/audit", icon: "📜" },
|
||||||
{ name: "Families", href: "/admin/families", icon: "🏠" },
|
{ name: "Families", href: "/admin/families", icon: "🏠" },
|
||||||
{ name: "Users", href: "/admin/users", icon: "👥" },
|
{ name: "Users", href: "/admin/users", icon: "👥" },
|
||||||
{ name: "Children", href: "/admin/children", icon: "👶" },
|
{ name: "Children", href: "/admin/children", icon: "👶" },
|
||||||
{ name: "Revenue", href: "/admin/revenue", 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: "Analytics", href: "/admin/analytics", icon: "📈" },
|
||||||
|
{ name: "AI Usage", href: "/admin/ai", icon: "🤖" },
|
||||||
{ name: "Support", href: "/admin/support", icon: "🎫" },
|
{ name: "Support", href: "/admin/support", icon: "🎫" },
|
||||||
{ name: "Settings", href: "/admin/settings", icon: "⚙️" },
|
{ name: "Settings", href: "/admin/settings", icon: "⚙️" },
|
||||||
];
|
];
|
||||||
|
|
|
||||||
147
src/app/admin/ai/page.tsx
Normal file
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -32,6 +32,7 @@ interface EngagementData {
|
||||||
activitySummary: { active7d: number; active30d: number; neverActive: number; total: number };
|
activitySummary: { active7d: number; active30d: number; neverActive: number; total: number };
|
||||||
aiUsage: { totalCalls: number; totalTokens: number; totalCostPaise: number; familiesUsingAI: number };
|
aiUsage: { totalCalls: number; totalTokens: number; totalCostPaise: number; familiesUsingAI: number };
|
||||||
logsByDay: { date: string; count: number }[];
|
logsByDay: { date: string; count: number }[];
|
||||||
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tab = "overview" | "families" | "ai";
|
type Tab = "overview" | "families" | "ai";
|
||||||
|
|
@ -49,7 +50,20 @@ export default function AdminAnalytics() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/admin/engagement", { credentials: "include" })
|
fetch("/api/admin/engagement", { credentials: "include" })
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(d => { setData(d); setLoading(false); })
|
.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));
|
.catch(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -104,6 +118,13 @@ export default function AdminAnalytics() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 */}
|
{/* Activity Summary Cards — always visible */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<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 7d" value={activitySummary.active7d} color="text-emerald-400" sub={`of ${activitySummary.total}`} />
|
||||||
|
|
|
||||||
134
src/app/admin/audit/page.tsx
Normal file
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`;
|
||||||
|
}
|
||||||
192
src/app/admin/errors/page.tsx
Normal file
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`;
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,15 @@ interface Member {
|
||||||
displayName: string;
|
displayName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Subscription {
|
||||||
|
status: string;
|
||||||
|
planName: string | null;
|
||||||
|
pricePaise: number | null;
|
||||||
|
startedAt: string | null;
|
||||||
|
expiresAt: string | null;
|
||||||
|
cancelledAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface Family {
|
interface Family {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -23,6 +32,7 @@ interface Family {
|
||||||
logCount: number;
|
logCount: number;
|
||||||
memoryCount: number;
|
memoryCount: number;
|
||||||
members: Member[];
|
members: Member[];
|
||||||
|
subscription: Subscription | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminFamilies() {
|
export default function AdminFamilies() {
|
||||||
|
|
@ -160,12 +170,9 @@ export default function AdminFamilies() {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
body: JSON.stringify({
|
// Send only the tier — the server applies the real grant/revoke
|
||||||
familyId,
|
// (50GB / 6 members / 3 children for pro; free defaults otherwise).
|
||||||
tier: newTier,
|
body: JSON.stringify({ familyId, tier: newTier }),
|
||||||
maxChildren: newTier === "pro" ? 10 : 1,
|
|
||||||
maxMembers: newTier === "pro" ? 10 : 2,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) throw new Error(data.error || "Failed to update tier");
|
if (!res.ok) throw new Error(data.error || "Failed to update tier");
|
||||||
|
|
@ -313,6 +320,20 @@ export default function AdminFamilies() {
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<Badge variant={family.tier === "pro" ? "rose" : "default"}>{family.tier}</Badge>
|
<Badge variant={family.tier === "pro" ? "rose" : "default"}>{family.tier}</Badge>
|
||||||
|
{family.subscription && (
|
||||||
|
<div className="mt-1 text-[11px] leading-tight text-gray-500">
|
||||||
|
<div className="text-gray-400">{family.subscription.status}</div>
|
||||||
|
{family.subscription.startedAt && (
|
||||||
|
<div title="Subscribed since">since {family.subscription.startedAt.slice(0, 10)}</div>
|
||||||
|
)}
|
||||||
|
{family.subscription.expiresAt && (
|
||||||
|
<div title="Renews / expires" className={family.subscription.cancelledAt ? "text-amber-400" : ""}>
|
||||||
|
{family.subscription.cancelledAt ? "ends " : "renews "}
|
||||||
|
{family.subscription.expiresAt.slice(0, 10)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
68
src/app/admin/health/page.tsx
Normal file
68
src/app/admin/health/page.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
interface Check { name: string; status: "ok" | "warn" | "down"; detail: string }
|
||||||
|
interface Data { overall: "ok" | "warn" | "down"; checks: Check[]; checkedAt: string; error?: string }
|
||||||
|
|
||||||
|
const STATUS = {
|
||||||
|
ok: { dot: "bg-emerald-400", text: "text-emerald-400", label: "Healthy" },
|
||||||
|
warn: { dot: "bg-amber-400", text: "text-amber-400", label: "Degraded" },
|
||||||
|
down: { dot: "bg-rose-500", text: "text-rose-400", label: "Down" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdminHealth() {
|
||||||
|
const [data, setData] = useState<Data | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
fetch("/api/admin/health", { credentials: "include" })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => { setData(d); setLoading(false); })
|
||||||
|
.catch(() => { setData(null); setLoading(false); });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const overall = data?.overall || "warn";
|
||||||
|
|
||||||
|
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">System Health</h1>
|
||||||
|
<p className="text-gray-400">{data?.checkedAt ? `Checked ${new Date(data.checkedAt).toLocaleTimeString()}` : "Live status of core services"}</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="bg-gray-800 p-6 rounded-xl flex items-center gap-4">
|
||||||
|
<span className={`w-4 h-4 rounded-full ${STATUS[overall].dot} ${overall !== "ok" ? "animate-pulse" : ""}`} />
|
||||||
|
<div>
|
||||||
|
<div className={`text-xl font-bold ${STATUS[overall].text}`}>{STATUS[overall].label}</div>
|
||||||
|
<div className="text-sm text-gray-400">Overall system status</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-gray-400">Running checks…</div>
|
||||||
|
) : !data ? (
|
||||||
|
<div className="bg-rose-500/10 text-rose-400 p-4 rounded-xl">Failed to load health checks.</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-gray-800 rounded-xl divide-y divide-gray-700">
|
||||||
|
{data.checks.map(c => (
|
||||||
|
<div key={c.name} className="flex items-center gap-4 p-4">
|
||||||
|
<span className={`w-3 h-3 rounded-full flex-shrink-0 ${STATUS[c.status].dot}`} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-medium">{c.name}</div>
|
||||||
|
<div className="text-sm text-gray-400 break-words">{c.detail}</div>
|
||||||
|
</div>
|
||||||
|
<span className={`text-xs font-medium uppercase ${STATUS[c.status].text}`}>{c.status}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
|
import type { Metadata } from "next";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { verifyAdminSession } from "@/lib/admin-auth";
|
import { verifyAdminSession } from "@/lib/admin-auth";
|
||||||
import AdminSidebar from "./AdminSidebar";
|
import AdminSidebar from "./AdminSidebar";
|
||||||
|
|
||||||
|
// Admin panel — never index.
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
robots: { index: false, follow: false },
|
||||||
|
};
|
||||||
|
|
||||||
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
const auth = await verifyAdminSession();
|
const auth = await verifyAdminSession();
|
||||||
if (!auth.success) {
|
if (!auth.success) {
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ export default function AdminDashboard() {
|
||||||
<StatCard label="Families" value={overview.totalFamilies} icon="🏠" color="rose" href="/admin/families" />
|
<StatCard label="Families" value={overview.totalFamilies} icon="🏠" color="rose" href="/admin/families" />
|
||||||
<StatCard label="Users" value={overview.totalUsers} icon="👥" color="blue" href="/admin/users" />
|
<StatCard label="Users" value={overview.totalUsers} icon="👥" color="blue" href="/admin/users" />
|
||||||
<StatCard label="Children" value={overview.totalChildren} icon="👶" color="amber" href="/admin/children" />
|
<StatCard label="Children" value={overview.totalChildren} icon="👶" color="amber" href="/admin/children" />
|
||||||
<StatCard label="MRR" value={`$${overview.mrr.toFixed(2)}`} icon="💰" color="emerald" href="/admin/revenue" />
|
<StatCard label="MRR" value={`₹${(overview.mrr || 0).toLocaleString("en-IN")}`} icon="💰" color="emerald" href="/admin/revenue" />
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Active Sessions"
|
label="Active Sessions"
|
||||||
value={overview.activeSessions}
|
value={overview.activeSessions}
|
||||||
|
|
@ -105,7 +105,7 @@ export default function AdminDashboard() {
|
||||||
<h3 className="text-sm font-semibold text-gray-400 mb-4">REVENUE OVERVIEW</h3>
|
<h3 className="text-sm font-semibold text-gray-400 mb-4">REVENUE OVERVIEW</h3>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-2xl font-bold text-emerald-400">${overview.mrr.toFixed(2)}</div>
|
<div className="text-2xl font-bold text-emerald-400">₹{(overview.mrr || 0).toLocaleString("en-IN")}</div>
|
||||||
<div className="text-gray-400 text-sm">Monthly Recurring</div>
|
<div className="text-gray-400 text-sm">Monthly Recurring</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -117,7 +117,7 @@ export default function AdminDashboard() {
|
||||||
<div className="text-gray-400 text-sm">Free Families</div>
|
<div className="text-gray-400 text-sm">Free Families</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-2xl font-bold text-amber-400">${overview.avgRevenuePerUser}</div>
|
<div className="text-2xl font-bold text-amber-400">₹{(overview.avgRevenuePerUser || 0).toLocaleString("en-IN")}</div>
|
||||||
<div className="text-gray-400 text-sm">Avg per Family</div>
|
<div className="text-gray-400 text-sm">Avg per Family</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -5,98 +5,101 @@ import { useEffect, useState } from "react";
|
||||||
interface RevenueData {
|
interface RevenueData {
|
||||||
proFamilies: number;
|
proFamilies: number;
|
||||||
freeFamilies: number;
|
freeFamilies: number;
|
||||||
mrr: number;
|
mrr: number; // rupees
|
||||||
history: { month: string; revenue: number }[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const PRO_PRICE = 9.99;
|
interface TrendPoint { month: string; paise: number; charges: number }
|
||||||
|
|
||||||
|
const inr = (rupees: number) =>
|
||||||
|
"₹" + (Number(rupees) || 0).toLocaleString("en-IN", { maximumFractionDigits: 0 });
|
||||||
|
|
||||||
export default function AdminRevenue() {
|
export default function AdminRevenue() {
|
||||||
const [data, setData] = useState<RevenueData | null>(null);
|
const [data, setData] = useState<RevenueData | null>(null);
|
||||||
|
const [trend, setTrend] = useState<TrendPoint[]>([]);
|
||||||
|
const [churnRate, setChurnRate] = useState<number | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchRevenue();
|
Promise.all([
|
||||||
|
fetch("/api/admin/stats", { credentials: "include" }).then((r) => r.json()),
|
||||||
|
fetch("/api/admin/subscriptions", { credentials: "include" }).then((r) => r.json()).catch(() => null),
|
||||||
|
])
|
||||||
|
.then(([stats, subs]) => {
|
||||||
|
setData({
|
||||||
|
proFamilies: stats.overview?.proFamilies || 0,
|
||||||
|
freeFamilies: stats.overview?.freeFamilies || 0,
|
||||||
|
mrr: stats.overview?.mrr || 0,
|
||||||
|
});
|
||||||
|
if (subs) {
|
||||||
|
setTrend(subs.revenueTrend || []);
|
||||||
|
setChurnRate(subs.summary?.churnRate ?? null);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => console.error("Failed to fetch revenue:", err))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchRevenue = async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/admin/stats", { credentials: "include" });
|
|
||||||
const stats = await res.json();
|
|
||||||
setData({
|
|
||||||
proFamilies: stats.overview?.proFamilies || 0,
|
|
||||||
freeFamilies: stats.overview?.freeFamilies || 0,
|
|
||||||
mrr: stats.overview?.mrr || 0,
|
|
||||||
history: generateMonthlyHistory(stats.overview?.proFamilies || 0),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to fetch revenue:", err);
|
|
||||||
}
|
|
||||||
setLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const generateMonthlyHistory = (proCount: number): { month: string; revenue: number }[] => {
|
|
||||||
const months: { month: string; revenue: number }[] = [];
|
|
||||||
for (let i = 11; i >= 0; i--) {
|
|
||||||
const date = new Date();
|
|
||||||
date.setMonth(date.getMonth() - i);
|
|
||||||
months.push({
|
|
||||||
month: date.toLocaleDateString("en-US", { month: "short", year: "2-digit" }),
|
|
||||||
revenue: proCount > 0 ? proCount * PRO_PRICE : 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return months;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading || !data) {
|
if (loading || !data) {
|
||||||
return <div className="p-6 text-white">Loading...</div>;
|
return <div className="p-6 text-white">Loading...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const maxPaise = Math.max(1, ...trend.map((t) => t.paise));
|
||||||
|
|
||||||
|
// ARPU across paying families only (avoids a misleading blended number).
|
||||||
|
const arpu = data.proFamilies > 0 ? data.mrr / data.proFamilies : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Revenue</h1>
|
<h1 className="text-2xl font-bold">Revenue</h1>
|
||||||
<p className="text-gray-400">Subscription revenue analytics</p>
|
<p className="text-gray-400">Real subscription revenue, in ₹ (from active Razorpay subscriptions)</p>
|
||||||
<p className="text-sm text-rose-400">Pro: ${PRO_PRICE}/month per family</p>
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
For per-subscription detail, see the <a href="/admin/subscriptions" className="text-rose-400 underline">Subscriptions</a> page.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Key Metrics */}
|
{/* Key Metrics */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
<div className="bg-gray-800 p-6 rounded-xl">
|
<div className="bg-gray-800 p-6 rounded-xl">
|
||||||
<div className="text-3xl font-bold text-emerald-400">${data.mrr.toFixed(2)}</div>
|
<div className="text-3xl font-bold text-emerald-400">{inr(data.mrr)}</div>
|
||||||
<div className="text-gray-400 text-sm">Monthly Recurring Revenue</div>
|
<div className="text-gray-400 text-sm">Monthly Recurring Revenue</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-gray-800 p-6 rounded-xl">
|
<div className="bg-gray-800 p-6 rounded-xl">
|
||||||
<div className="text-3xl font-bold text-rose-400">${(data.mrr * 12).toFixed(2)}</div>
|
<div className="text-3xl font-bold text-rose-400">{inr(data.mrr * 12)}</div>
|
||||||
<div className="text-gray-400 text-sm">Annual Run Rate</div>
|
<div className="text-gray-400 text-sm">Annual Run Rate</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-gray-800 p-6 rounded-xl">
|
<div className="bg-gray-800 p-6 rounded-xl">
|
||||||
<div className="text-3xl font-bold text-rose-400">{data.proFamilies}</div>
|
<div className="text-3xl font-bold text-rose-400">{data.proFamilies}</div>
|
||||||
<div className="text-gray-400 text-sm">Pro Families</div>
|
<div className="text-gray-400 text-sm">Paying Families</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-gray-800 p-6 rounded-xl">
|
<div className="bg-gray-800 p-6 rounded-xl">
|
||||||
<div className="text-3xl font-bold text-gray-400">{data.freeFamilies}</div>
|
<div className={`text-3xl font-bold ${churnRate != null && churnRate > 10 ? "text-red-400" : "text-gray-400"}`}>
|
||||||
<div className="text-gray-400 text-sm">Free Families</div>
|
{churnRate != null ? `${churnRate}%` : "—"}
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-400 text-sm">Churn rate</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Revenue Chart */}
|
{/* Real monthly revenue trend (from subscription.charged events) */}
|
||||||
<div className="bg-gray-800 p-6 rounded-xl">
|
<div className="bg-gray-800 p-6 rounded-xl">
|
||||||
<h3 className="text-lg font-semibold mb-4">Monthly Revenue</h3>
|
<h3 className="text-lg font-semibold mb-1">Monthly Revenue (charged)</h3>
|
||||||
<div className="h-48 flex items-end gap-1">
|
<p className="text-xs text-gray-500 mb-4">Actual collections from Razorpay subscription.charged events</p>
|
||||||
{data.history.map((h, i) => (
|
{trend.length === 0 ? (
|
||||||
<div key={i} className="flex-1 flex flex-col items-center gap-1">
|
<p className="text-sm text-gray-500 py-8 text-center">No charges recorded yet</p>
|
||||||
<div
|
) : (
|
||||||
className="w-full bg-emerald-500 rounded-t"
|
<div className="h-48 flex items-end gap-2">
|
||||||
style={{
|
{trend.map((t) => (
|
||||||
height: data.mrr > 0 ? `${(h.revenue / (data.mrr * 1.2)) * 100}%` : "0%",
|
<div key={t.month} className="flex-1 flex flex-col items-center gap-1" title={`${inr(t.paise / 100)} · ${t.charges} charge(s)`}>
|
||||||
minHeight: h.revenue > 0 ? "4px" : "0"
|
<div className="text-[10px] text-gray-400">{inr(t.paise / 100)}</div>
|
||||||
}}
|
<div
|
||||||
/>
|
className="w-full bg-emerald-500 rounded-t"
|
||||||
<div className="text-[8px] text-gray-500">{h.month}</div>
|
style={{ height: `${(t.paise / maxPaise) * 100}%`, minHeight: t.paise > 0 ? "4px" : "0" }}
|
||||||
</div>
|
/>
|
||||||
))}
|
<div className="text-[9px] text-gray-500">{t.month.slice(5)}/{t.month.slice(2, 4)}</div>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Revenue Breakdown */}
|
{/* Revenue Breakdown */}
|
||||||
|
|
@ -105,37 +108,32 @@ export default function AdminRevenue() {
|
||||||
<h3 className="text-lg font-semibold mb-4">Revenue by Tier</h3>
|
<h3 className="text-lg font-semibold mb-4">Revenue by Tier</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-emerald-400">Pro</span>
|
<span className="text-emerald-400">Premium</span>
|
||||||
<span className="font-bold">${(data.proFamilies * PRO_PRICE).toFixed(2)}/mo</span>
|
<span className="font-bold">{inr(data.mrr)}/mo</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-gray-400">Free</span>
|
<span className="text-gray-400">Free</span>
|
||||||
<span className="font-bold">$0.00/mo</span>
|
<span className="font-bold">₹0/mo</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center border-t border-gray-700 pt-3">
|
||||||
|
<span className="text-gray-400">ARPU (paying)</span>
|
||||||
|
<span className="font-bold text-gray-300">{inr(arpu)}/mo</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-gray-800 p-6 rounded-xl">
|
<div className="bg-gray-800 p-6 rounded-xl">
|
||||||
<h3 className="text-lg font-semibold mb-4">Growth Potential</h3>
|
<h3 className="text-lg font-semibold mb-4">Growth Potential</h3>
|
||||||
|
<p className="text-xs text-gray-500 mb-3">If free families convert at premium price</p>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex justify-between items-center">
|
{[0.1, 0.25, 0.5].map((rate) => (
|
||||||
<span>If 10% convert</span>
|
<div key={rate} className="flex justify-between items-center">
|
||||||
<span className="font-bold text-amber-400">
|
<span>If {rate * 100}% convert</span>
|
||||||
${((data.freeFamilies * 0.1) * PRO_PRICE).toFixed(2)}/mo
|
<span className={`font-bold ${rate >= 0.5 ? "text-emerald-400" : "text-amber-400"}`}>
|
||||||
</span>
|
{inr(data.freeFamilies * rate * arpu)}/mo
|
||||||
</div>
|
</span>
|
||||||
<div className="flex justify-between items-center">
|
</div>
|
||||||
<span>If 25% convert</span>
|
))}
|
||||||
<span className="font-bold text-amber-400">
|
|
||||||
${((data.freeFamilies * 0.25) * PRO_PRICE).toFixed(2)}/mo
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span>If 50% convert</span>
|
|
||||||
<span className="font-bold text-emerald-400">
|
|
||||||
${((data.freeFamilies * 0.5) * PRO_PRICE).toFixed(2)}/mo
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
175
src/app/admin/storage/page.tsx
Normal file
175
src/app/admin/storage/page.tsx
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
interface FamilyRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
tier: string;
|
||||||
|
isPaid: boolean;
|
||||||
|
bytes: number;
|
||||||
|
memoryCount: number;
|
||||||
|
attachmentCount: number;
|
||||||
|
objectCount: number;
|
||||||
|
fraction: number;
|
||||||
|
overLimit: boolean;
|
||||||
|
}
|
||||||
|
interface Summary {
|
||||||
|
totalBytes: number;
|
||||||
|
totalObjects: number;
|
||||||
|
familyCount: number;
|
||||||
|
paidCount: number;
|
||||||
|
freeCount: number;
|
||||||
|
overLimitCount: number;
|
||||||
|
approachingCount: number;
|
||||||
|
estMonthlyCostUsd: number;
|
||||||
|
freeLimitBytes: number;
|
||||||
|
avgBytesPerFamily: number;
|
||||||
|
}
|
||||||
|
interface Data { summary: Summary; families: FamilyRow[]; byDay: { date: string; bytes: number; count: number }[]; error?: string }
|
||||||
|
|
||||||
|
const EMPTY: Summary = {
|
||||||
|
totalBytes: 0, totalObjects: 0, familyCount: 0, paidCount: 0, freeCount: 0,
|
||||||
|
overLimitCount: 0, approachingCount: 0, estMonthlyCostUsd: 0, freeLimitBytes: 1_073_741_824, avgBytesPerFamily: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function fmtBytes(n: number): string {
|
||||||
|
if (!n) return "0 B";
|
||||||
|
const u = ["B", "KB", "MB", "GB", "TB"];
|
||||||
|
const i = Math.min(Math.floor(Math.log(n) / Math.log(1024)), u.length - 1);
|
||||||
|
return `${(n / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1)} ${u[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminStorage() {
|
||||||
|
const [data, setData] = useState<Data>({ summary: EMPTY, families: [], byDay: [] });
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/admin/storage", { credentials: "include" })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => {
|
||||||
|
setData({
|
||||||
|
summary: { ...EMPTY, ...(d?.summary || {}) },
|
||||||
|
families: Array.isArray(d?.families) ? d.families : [],
|
||||||
|
byDay: Array.isArray(d?.byDay) ? d.byDay : [],
|
||||||
|
error: d?.error,
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { summary, families, byDay } = data;
|
||||||
|
const maxDayBytes = Math.max(...byDay.map(d => d.bytes), 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">Storage & Billing</h1>
|
||||||
|
<p className="text-gray-400">Per-family R2 storage usage — the basis for usage billing</p>
|
||||||
|
</div>
|
||||||
|
</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 queries failed:</span> <span className="font-mono break-words">{data.error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<Card label="Total Storage" value={fmtBytes(summary.totalBytes)} color="text-rose-400" sub={`${summary.totalObjects.toLocaleString()} objects`} />
|
||||||
|
<Card label="Est. R2 Cost / mo" value={`$${summary.estMonthlyCostUsd.toFixed(2)}`} color="text-amber-400" sub="@ $0.015/GB-mo" />
|
||||||
|
<Card label="Avg / Family" value={fmtBytes(summary.avgBytesPerFamily)} color="text-blue-400" sub={`${summary.familyCount} families`} />
|
||||||
|
<Card label="Over Free Limit" value={summary.overLimitCount} color={summary.overLimitCount > 0 ? "text-rose-400" : "text-emerald-400"} sub={`${summary.approachingCount} approaching`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Daily upload volume */}
|
||||||
|
<div className="bg-gray-800 p-6 rounded-xl">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">Uploads — last 30 days</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 uploads in the last 30 days</div>
|
||||||
|
) : byDay.slice(-30).map((d, i) => (
|
||||||
|
<div key={i} title={`${d.date}: ${fmtBytes(d.bytes)} · ${d.count} files`} className="flex-1 group">
|
||||||
|
<div className="w-full bg-rose-500 group-hover:bg-rose-400 rounded-t" style={{ height: `${Math.max((d.bytes / maxDayBytes) * 100, d.bytes > 0 ? 3 : 0)}%` }} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Per-family table */}
|
||||||
|
<div className="bg-gray-800 rounded-xl overflow-hidden">
|
||||||
|
<div className="px-4 py-3 border-b border-gray-700 flex justify-between items-center">
|
||||||
|
<h3 className="text-lg font-semibold">By family</h3>
|
||||||
|
<span className="text-sm text-gray-500">{summary.paidCount} paid · {summary.freeCount} free</span>
|
||||||
|
</div>
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-8 text-center text-gray-400">Loading…</div>
|
||||||
|
) : families.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-gray-500">No families</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>
|
||||||
|
<th className="px-4 py-3 text-right text-sm font-medium">Objects</th>
|
||||||
|
<th className="px-4 py-3 text-right text-sm font-medium">Storage</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium w-56">% of limit</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody className="divide-y divide-gray-700">
|
||||||
|
{families.map(f => {
|
||||||
|
const limit = f.isPaid ? 25 * 1024 * 1024 * 1024 : 1_073_741_824;
|
||||||
|
const fraction = f.bytes / limit;
|
||||||
|
const pct = Math.min(Math.round(fraction * 100), 999);
|
||||||
|
const isOver = fraction >= 1;
|
||||||
|
const barColor = isOver ? "bg-rose-500" : fraction >= 0.8 ? "bg-amber-500" : "bg-emerald-500";
|
||||||
|
const limitLabel = f.isPaid ? "25 GB" : "1 GiB";
|
||||||
|
return (
|
||||||
|
<tr key={f.id} className="hover:bg-gray-750">
|
||||||
|
<td className="px-4 py-3 font-medium">{f.name}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded font-medium ${f.isPaid ? "bg-rose-500/20 text-rose-400" : "bg-gray-700 text-gray-400"}`}>{f.tier}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right text-sm text-gray-300">
|
||||||
|
{f.objectCount.toLocaleString()}
|
||||||
|
<span className="text-gray-600 text-xs"> ({f.memoryCount}📸 {f.attachmentCount}📎)</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right text-sm font-medium">{fmtBytes(f.bytes)}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 bg-gray-700 rounded-full h-2 overflow-hidden">
|
||||||
|
<div className={`h-full ${barColor}`} style={{ width: `${Math.min(pct, 100)}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className={`text-xs w-24 text-right ${isOver ? "text-rose-400 font-bold" : "text-gray-400"}`}>
|
||||||
|
{pct}% of {limitLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-gray-600">
|
||||||
|
Usage = SUM(size_bytes) over memories + attachments. Limits: free = 1 GiB · paid = 25 GB.
|
||||||
|
Objects missing a recorded size aren't counted in bytes. R2 cost is storage only (egress is free on R2).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Card({ 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 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
321
src/app/admin/subscriptions/page.tsx
Normal file
321
src/app/admin/subscriptions/page.tsx
Normal file
|
|
@ -0,0 +1,321 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
|
||||||
|
interface Subscription {
|
||||||
|
id: string;
|
||||||
|
family_id: string;
|
||||||
|
family_name: string | null;
|
||||||
|
plan_name: string | null;
|
||||||
|
price_paise: number | null;
|
||||||
|
status: string;
|
||||||
|
razorpay_subscription_id: string;
|
||||||
|
razorpay_customer_id: string | null;
|
||||||
|
current_start: string | null;
|
||||||
|
current_end: string | null;
|
||||||
|
cancelled_at: string | null;
|
||||||
|
ended_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WebhookEvent {
|
||||||
|
razorpay_event_id: string;
|
||||||
|
event_type: string;
|
||||||
|
received_at: string;
|
||||||
|
sub_id: string | null;
|
||||||
|
sub_status: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Summary {
|
||||||
|
total: number;
|
||||||
|
byStatus: Record<string, number>;
|
||||||
|
mrrPaise: number;
|
||||||
|
arrPaise: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inr = (paise: number | null | undefined) =>
|
||||||
|
"₹" + ((Number(paise) || 0) / 100).toLocaleString("en-IN", { maximumFractionDigits: 0 });
|
||||||
|
|
||||||
|
const fmt = (iso: string | null) =>
|
||||||
|
iso
|
||||||
|
? new Date(iso).toLocaleString("en-IN", {
|
||||||
|
timeZone: "Asia/Kolkata",
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})
|
||||||
|
: "—";
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
active: "bg-emerald-500/15 text-emerald-400",
|
||||||
|
authenticated: "bg-emerald-500/15 text-emerald-400",
|
||||||
|
pending: "bg-amber-500/15 text-amber-400",
|
||||||
|
created: "bg-gray-500/15 text-gray-400",
|
||||||
|
halted: "bg-red-500/15 text-red-400",
|
||||||
|
cancelled: "bg-red-500/15 text-red-400",
|
||||||
|
completed: "bg-blue-500/15 text-blue-400",
|
||||||
|
expired: "bg-gray-500/15 text-gray-500",
|
||||||
|
paused: "bg-amber-500/15 text-amber-400",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdminSubscriptions() {
|
||||||
|
const [subs, setSubs] = useState<Subscription[]>([]);
|
||||||
|
const [events, setEvents] = useState<WebhookEvent[]>([]);
|
||||||
|
const [summary, setSummary] = useState<Summary | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [reconciling, setReconciling] = useState(false);
|
||||||
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/admin/subscriptions", { credentials: "include" });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.error) throw new Error(data.error);
|
||||||
|
setSubs(data.subscriptions || []);
|
||||||
|
setEvents(data.webhookEvents || []);
|
||||||
|
setSummary(data.summary || null);
|
||||||
|
} catch (e) {
|
||||||
|
setMsg(e instanceof Error ? e.message : "Failed to load");
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { fetchData(); }, [fetchData]);
|
||||||
|
|
||||||
|
const reconcile = async () => {
|
||||||
|
setReconciling(true);
|
||||||
|
setMsg(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/admin/subscriptions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ action: "reconcile" }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
const ok = (data.reconciled || []).filter((r: { ok?: boolean }) => r.ok).length;
|
||||||
|
const failed = (data.reconciled || []).filter((r: { ok?: boolean }) => r.ok === false).length;
|
||||||
|
setMsg(`Reconciled ${ok} OK${failed ? `, ${failed} failed` : ""}.`);
|
||||||
|
fetchData();
|
||||||
|
} catch (e) {
|
||||||
|
setMsg(e instanceof Error ? e.message : "Reconcile failed");
|
||||||
|
}
|
||||||
|
setReconciling(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelSub = async (subId: string, familyName: string | null) => {
|
||||||
|
if (!window.confirm(`Cancel subscription for ${familyName || "this family"}? They keep premium until the cycle ends.`)) return;
|
||||||
|
setMsg(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/admin/subscriptions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ action: "cancel", subscriptionId: subId }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
setMsg(res.ok ? (data.message || "Cancellation scheduled.") : (data.error || "Cancel failed"));
|
||||||
|
if (res.ok) fetchData();
|
||||||
|
} catch (e) {
|
||||||
|
setMsg(e instanceof Error ? e.message : "Cancel failed");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportCSV = () => {
|
||||||
|
const headers = ["Family", "Plan", "Status", "Started", "Renews/Expires", "Cancelled", "RazorpaySubId", "PricePaise"];
|
||||||
|
const rows = subs.map((s) => [
|
||||||
|
s.family_name || s.family_id,
|
||||||
|
s.plan_name || "",
|
||||||
|
s.status,
|
||||||
|
s.current_start || "",
|
||||||
|
s.current_end || "",
|
||||||
|
s.cancelled_at || "",
|
||||||
|
s.razorpay_subscription_id,
|
||||||
|
String(s.price_paise ?? ""),
|
||||||
|
]);
|
||||||
|
const csv = [headers, ...rows]
|
||||||
|
.map((r) => r.map((c) => `"${String(c ?? "").replace(/"/g, '""')}"`).join(","))
|
||||||
|
.join("\n");
|
||||||
|
const url = URL.createObjectURL(new Blob([csv], { type: "text/csv" }));
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `subscriptions-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||||
|
a.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Dunning: subs in 'pending' (a charge failed, Razorpay retrying) need attention.
|
||||||
|
const pendingSubs = subs.filter((s) => s.status === "pending");
|
||||||
|
|
||||||
|
if (loading) return <div className="p-6 text-white">Loading…</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Subscriptions</h1>
|
||||||
|
<p className="text-gray-400">Razorpay subscription state & webhook monitoring</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={exportCSV}
|
||||||
|
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-sm font-medium"
|
||||||
|
>
|
||||||
|
Export CSV
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={reconcile}
|
||||||
|
disabled={reconciling}
|
||||||
|
className="px-4 py-2 bg-rose-500 hover:bg-rose-600 disabled:opacity-50 rounded-lg text-sm font-medium"
|
||||||
|
title="Replay latest webhook events to re-sync entitlement"
|
||||||
|
>
|
||||||
|
{reconciling ? "Reconciling…" : "↻ Reconcile"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dunning — failing payments that need attention before they churn */}
|
||||||
|
{pendingSubs.length > 0 && (
|
||||||
|
<div className="bg-amber-500/10 border border-amber-500/30 rounded-xl p-4">
|
||||||
|
<div className="font-semibold text-amber-400 mb-2">
|
||||||
|
⚠ {pendingSubs.length} payment{pendingSubs.length > 1 ? "s" : ""} failing (grace period)
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-amber-300/80 mb-3">
|
||||||
|
A charge failed and Razorpay is retrying. Reach out before retries exhaust and they churn (halted).
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{pendingSubs.map((s) => (
|
||||||
|
<div key={s.id} className="flex items-center justify-between text-sm">
|
||||||
|
<span>{s.family_name || s.family_id.slice(0, 8)} · {s.plan_name || "—"}</span>
|
||||||
|
<span className="text-gray-400">renews {fmt(s.current_end)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{msg && (
|
||||||
|
<div className="bg-blue-500/15 border border-blue-500/30 text-blue-300 px-4 py-2 rounded-lg text-sm">
|
||||||
|
{msg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Summary cards */}
|
||||||
|
{summary && (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div className="bg-gray-800 p-5 rounded-xl">
|
||||||
|
<div className="text-2xl font-bold text-emerald-400">{inr(summary.mrrPaise)}</div>
|
||||||
|
<div className="text-gray-400 text-sm">MRR (active)</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-800 p-5 rounded-xl">
|
||||||
|
<div className="text-2xl font-bold text-rose-400">{inr(summary.arrPaise)}</div>
|
||||||
|
<div className="text-gray-400 text-sm">Annual run rate</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-800 p-5 rounded-xl">
|
||||||
|
<div className="text-2xl font-bold">{summary.byStatus.active || 0}</div>
|
||||||
|
<div className="text-gray-400 text-sm">Active</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-800 p-5 rounded-xl">
|
||||||
|
<div className="text-2xl font-bold text-gray-300">{summary.total}</div>
|
||||||
|
<div className="text-gray-400 text-sm">Total subscriptions</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status breakdown chips */}
|
||||||
|
{summary && Object.keys(summary.byStatus).length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{Object.entries(summary.byStatus).map(([status, count]) => (
|
||||||
|
<span key={status} className={`text-xs px-2.5 py-1 rounded-full ${STATUS_COLORS[status] || "bg-gray-700 text-gray-300"}`}>
|
||||||
|
{status}: {count}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Subscriptions table */}
|
||||||
|
<div className="bg-gray-800 rounded-xl overflow-hidden">
|
||||||
|
<div className="px-4 py-3 border-b border-gray-700 font-semibold">Subscriptions</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-700/50 text-gray-300">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2 text-left">Family</th>
|
||||||
|
<th className="px-4 py-2 text-left">Plan</th>
|
||||||
|
<th className="px-4 py-2 text-left">Status</th>
|
||||||
|
<th className="px-4 py-2 text-left">Started</th>
|
||||||
|
<th className="px-4 py-2 text-left">Renews / Expires</th>
|
||||||
|
<th className="px-4 py-2 text-left">Razorpay ID</th>
|
||||||
|
<th className="px-4 py-2 text-left">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-700">
|
||||||
|
{subs.map((s) => (
|
||||||
|
<tr key={s.id} className="hover:bg-gray-750">
|
||||||
|
<td className="px-4 py-2">{s.family_name || <span className="text-gray-600">{s.family_id.slice(0, 8)}…</span>}</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
{s.plan_name || "—"}
|
||||||
|
{s.price_paise ? <span className="text-gray-500"> · {inr(s.price_paise)}</span> : null}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_COLORS[s.status] || "bg-gray-700 text-gray-300"}`}>
|
||||||
|
{s.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-gray-400">{fmt(s.current_start)}</td>
|
||||||
|
<td className="px-4 py-2 text-gray-400">
|
||||||
|
{s.cancelled_at ? <span className="text-amber-400">cancels {fmt(s.current_end)}</span> : fmt(s.current_end)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-gray-500 font-mono text-xs">{s.razorpay_subscription_id}</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
{["active", "authenticated", "pending"].includes(s.status) && !s.cancelled_at ? (
|
||||||
|
<button
|
||||||
|
onClick={() => cancelSub(s.razorpay_subscription_id, s.family_name)}
|
||||||
|
className="text-xs text-red-400 hover:text-red-300 underline"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-600 text-xs">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{subs.length === 0 && <div className="p-8 text-center text-gray-500">No subscriptions yet</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Webhook events log */}
|
||||||
|
<div className="bg-gray-800 rounded-xl overflow-hidden">
|
||||||
|
<div className="px-4 py-3 border-b border-gray-700 font-semibold">Recent Webhook Events</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-700/50 text-gray-300">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2 text-left">Received</th>
|
||||||
|
<th className="px-4 py-2 text-left">Event</th>
|
||||||
|
<th className="px-4 py-2 text-left">Subscription</th>
|
||||||
|
<th className="px-4 py-2 text-left">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-700">
|
||||||
|
{events.map((e) => (
|
||||||
|
<tr key={e.razorpay_event_id} className="hover:bg-gray-750">
|
||||||
|
<td className="px-4 py-2 text-gray-400">{fmt(e.received_at)}</td>
|
||||||
|
<td className="px-4 py-2 font-mono text-xs">{e.event_type}</td>
|
||||||
|
<td className="px-4 py-2 text-gray-500 font-mono text-xs">{e.sub_id || "—"}</td>
|
||||||
|
<td className="px-4 py-2 text-gray-400">{e.sub_status || "—"}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{events.length === 0 && <div className="p-8 text-center text-gray-500">No webhook events received yet</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ interface User {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
phone?: string | null;
|
||||||
familyId: string;
|
familyId: string;
|
||||||
familyName: string;
|
familyName: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|
@ -122,13 +123,17 @@ export default function AdminUsers() {
|
||||||
|
|
||||||
const filteredUsers = users.filter((u) =>
|
const filteredUsers = users.filter((u) =>
|
||||||
u.email.toLowerCase().includes(search.toLowerCase()) ||
|
u.email.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
(u.name || "").toLowerCase().includes(search.toLowerCase())
|
(u.name || "").toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
(u.phone || "").includes(search)
|
||||||
);
|
);
|
||||||
|
|
||||||
const exportCSV = () => {
|
const exportCSV = () => {
|
||||||
const headers = ["Email", "Name", "Family", "Created"];
|
const headers = ["Email", "Name", "Phone", "Family", "Created"];
|
||||||
const rows = filteredUsers.map((u) => [u.email, u.name, u.familyName, u.createdAt]);
|
const rows = filteredUsers.map((u) => [u.email, u.name, u.phone || "", u.familyName, u.createdAt]);
|
||||||
const csv = [headers, ...rows].map((row) => row.join(",")).join("\n");
|
// Quote each cell so commas/empties don't shift columns
|
||||||
|
const csv = [headers, ...rows]
|
||||||
|
.map((row) => row.map((cell) => `"${String(cell ?? "").replace(/"/g, '""')}"`).join(","))
|
||||||
|
.join("\n");
|
||||||
const blob = new Blob([csv], { type: "text/csv" });
|
const blob = new Blob([csv], { type: "text/csv" });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
|
|
@ -206,7 +211,7 @@ export default function AdminUsers() {
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search by name or email…"
|
placeholder="Search by name, email or phone…"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -216,6 +221,7 @@ export default function AdminUsers() {
|
||||||
<thead className="bg-gray-700">
|
<thead className="bg-gray-700">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 text-left text-sm font-medium">User</th>
|
<th className="px-4 py-3 text-left text-sm font-medium">User</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium">Phone</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">Family</th>
|
||||||
<th className="px-4 py-3 text-left text-sm font-medium">Password</th>
|
<th className="px-4 py-3 text-left text-sm font-medium">Password</th>
|
||||||
<th className="px-4 py-3 text-left text-sm font-medium">Joined</th>
|
<th className="px-4 py-3 text-left text-sm font-medium">Joined</th>
|
||||||
|
|
@ -229,6 +235,11 @@ export default function AdminUsers() {
|
||||||
<div className="font-medium">{user.name || user.email}</div>
|
<div className="font-medium">{user.name || user.email}</div>
|
||||||
<div className="text-xs text-gray-500">{user.email}</div>
|
<div className="text-xs text-gray-500">{user.email}</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm">
|
||||||
|
{user.phone
|
||||||
|
? <a href={`tel:${user.phone}`} className="text-gray-300 hover:text-rose-400">{user.phone}</a>
|
||||||
|
: <span className="text-gray-600">—</span>}
|
||||||
|
</td>
|
||||||
<td className="px-4 py-3 text-gray-300">{user.familyName || <span className="text-gray-600">—</span>}</td>
|
<td className="px-4 py-3 text-gray-300">{user.familyName || <span className="text-gray-600">—</span>}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
101
src/app/api/admin/ai/route.ts
Normal file
101
src/app/api/admin/ai/route.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireAdmin } from "@/lib/admin-auth";
|
||||||
|
import { sql } from "@/db";
|
||||||
|
|
||||||
|
// AI observability feed over ai_usage: headline totals, per-intent latency &
|
||||||
|
// cost breakdown (incl. p95), daily trend, and the slowest recent calls.
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const auth = await requireAdmin(request);
|
||||||
|
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const days = Math.min(Math.max(parseInt(searchParams.get("days") || "30", 10) || 30, 1), 365);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const summaryRows = await sql`
|
||||||
|
SELECT
|
||||||
|
COUNT(*)::int AS total_calls,
|
||||||
|
COALESCE(SUM(total_tokens), 0)::int AS total_tokens,
|
||||||
|
COALESCE(SUM(cost_estimate_paise), 0)::numeric AS total_cost_paise,
|
||||||
|
COUNT(DISTINCT family_id)::int AS families_using_ai,
|
||||||
|
COALESCE(AVG(duration_ms), 0)::int AS avg_ms,
|
||||||
|
COALESCE(percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms), 0)::int AS p95_ms,
|
||||||
|
COUNT(*) FILTER (WHERE intent = 'medical_redirect')::int AS redirects
|
||||||
|
FROM ai_usage
|
||||||
|
WHERE created_at > NOW() - make_interval(days => ${days})
|
||||||
|
`;
|
||||||
|
|
||||||
|
const byIntent = await sql`
|
||||||
|
SELECT
|
||||||
|
COALESCE(intent, 'unknown') AS intent,
|
||||||
|
COUNT(*)::int AS count,
|
||||||
|
COALESCE(AVG(duration_ms), 0)::int AS avg_ms,
|
||||||
|
COALESCE(percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms), 0)::int AS p95_ms,
|
||||||
|
COALESCE(SUM(total_tokens), 0)::int AS tokens,
|
||||||
|
COALESCE(SUM(cost_estimate_paise), 0)::numeric AS cost_paise
|
||||||
|
FROM ai_usage
|
||||||
|
WHERE created_at > NOW() - make_interval(days => ${days})
|
||||||
|
GROUP BY intent
|
||||||
|
ORDER BY count DESC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const byDay = await sql`
|
||||||
|
SELECT DATE(created_at) AS date, COUNT(*)::int AS count,
|
||||||
|
COALESCE(SUM(cost_estimate_paise), 0)::numeric AS cost_paise
|
||||||
|
FROM ai_usage
|
||||||
|
WHERE created_at > NOW() - make_interval(days => ${days})
|
||||||
|
GROUP BY DATE(created_at)
|
||||||
|
ORDER BY date
|
||||||
|
`;
|
||||||
|
|
||||||
|
const slowest = await sql`
|
||||||
|
SELECT id, COALESCE(intent, 'unknown') AS intent, duration_ms, model_used, created_at, family_id
|
||||||
|
FROM ai_usage
|
||||||
|
WHERE created_at > NOW() - make_interval(days => ${days}) AND duration_ms IS NOT NULL
|
||||||
|
ORDER BY duration_ms DESC
|
||||||
|
LIMIT 10
|
||||||
|
`;
|
||||||
|
|
||||||
|
const s = summaryRows[0] || {};
|
||||||
|
return NextResponse.json({
|
||||||
|
days,
|
||||||
|
summary: {
|
||||||
|
totalCalls: Number(s.total_calls) || 0,
|
||||||
|
totalTokens: Number(s.total_tokens) || 0,
|
||||||
|
totalCostPaise: Number(s.total_cost_paise) || 0,
|
||||||
|
familiesUsingAI: Number(s.families_using_ai) || 0,
|
||||||
|
avgMs: Number(s.avg_ms) || 0,
|
||||||
|
p95Ms: Number(s.p95_ms) || 0,
|
||||||
|
redirects: Number(s.redirects) || 0,
|
||||||
|
},
|
||||||
|
byIntent: byIntent.map((r: Record<string, unknown>) => ({
|
||||||
|
intent: r.intent,
|
||||||
|
count: Number(r.count) || 0,
|
||||||
|
avgMs: Number(r.avg_ms) || 0,
|
||||||
|
p95Ms: Number(r.p95_ms) || 0,
|
||||||
|
tokens: Number(r.tokens) || 0,
|
||||||
|
costPaise: Number(r.cost_paise) || 0,
|
||||||
|
})),
|
||||||
|
byDay: byDay.map((r: Record<string, unknown>) => ({
|
||||||
|
date: r.date instanceof Date ? r.date.toISOString().split("T")[0] : String(r.date).split("T")[0],
|
||||||
|
count: Number(r.count) || 0,
|
||||||
|
costPaise: Number(r.cost_paise) || 0,
|
||||||
|
})),
|
||||||
|
slowest: slowest.map((r: Record<string, unknown>) => ({
|
||||||
|
id: r.id,
|
||||||
|
intent: r.intent,
|
||||||
|
durationMs: Number(r.duration_ms) || 0,
|
||||||
|
model: r.model_used || "—",
|
||||||
|
createdAt: r.created_at instanceof Date ? (r.created_at as Date).toISOString() : String(r.created_at),
|
||||||
|
familyId: r.family_id,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Admin AI feed error:", error);
|
||||||
|
return NextResponse.json({
|
||||||
|
days,
|
||||||
|
summary: { totalCalls: 0, totalTokens: 0, totalCostPaise: 0, familiesUsingAI: 0, avgMs: 0, p95Ms: 0, redirects: 0 },
|
||||||
|
byIntent: [], byDay: [], slowest: [], error: String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
57
src/app/api/admin/audit/route.ts
Normal file
57
src/app/api/admin/audit/route.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireAdmin } from "@/lib/admin-auth";
|
||||||
|
import { sql } from "@/db";
|
||||||
|
|
||||||
|
// Full audit-log viewer feed — every action (not just auth), filterable by
|
||||||
|
// action / resource type / family / user / free-text, with the joined user
|
||||||
|
// email and family name for context.
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const auth = await requireAdmin(request);
|
||||||
|
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const action = searchParams.get("action")?.trim();
|
||||||
|
const resourceType = searchParams.get("resourceType")?.trim();
|
||||||
|
const familyId = searchParams.get("familyId")?.trim();
|
||||||
|
const userId = searchParams.get("userId")?.trim();
|
||||||
|
const q = searchParams.get("q")?.trim();
|
||||||
|
const sinceHours = Math.min(Math.max(parseInt(searchParams.get("sinceHours") || "168", 10) || 168, 1), 24 * 90);
|
||||||
|
const limit = Math.min(Math.max(parseInt(searchParams.get("limit") || "100", 10) || 100, 1), 500);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const where: string[] = [`al.created_at > NOW() - make_interval(hours => $1)`];
|
||||||
|
const params: unknown[] = [sinceHours];
|
||||||
|
if (action) { params.push(action); where.push(`al.action = $${params.length}`); }
|
||||||
|
if (resourceType) { params.push(resourceType); where.push(`al.resource_type = $${params.length}`); }
|
||||||
|
if (familyId) { params.push(familyId); where.push(`al.family_id = $${params.length}::uuid`); }
|
||||||
|
if (userId) { params.push(userId); where.push(`al.user_id = $${params.length}::uuid`); }
|
||||||
|
if (q) { params.push(`%${q}%`); where.push(`(u.email ILIKE $${params.length} OR f.name ILIKE $${params.length} OR al.action ILIKE $${params.length})`); }
|
||||||
|
const whereSql = where.join(" AND ");
|
||||||
|
|
||||||
|
const events = await sql.unsafe(
|
||||||
|
`SELECT al.id, al.action, al.resource_type, al.resource_id, al.ip_address,
|
||||||
|
al.user_agent, al.metadata, al.created_at, al.user_id, al.family_id,
|
||||||
|
u.email AS user_email, u.name AS user_name, f.name AS family_name
|
||||||
|
FROM audit_log al
|
||||||
|
LEFT JOIN users u ON u.id = al.user_id
|
||||||
|
LEFT JOIN families f ON f.id = al.family_id
|
||||||
|
WHERE ${whereSql}
|
||||||
|
ORDER BY al.created_at DESC
|
||||||
|
LIMIT ${limit}`,
|
||||||
|
params as never[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Distinct action + resource-type values for the filter dropdowns.
|
||||||
|
const actions = await sql`SELECT DISTINCT action FROM audit_log ORDER BY action`;
|
||||||
|
const resourceTypes = await sql`SELECT DISTINCT resource_type FROM audit_log WHERE resource_type IS NOT NULL ORDER BY resource_type`;
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
events,
|
||||||
|
actions: actions.map(r => (r as { action: string }).action),
|
||||||
|
resourceTypes: resourceTypes.map(r => (r as { resource_type: string }).resource_type),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Admin audit feed error:", error);
|
||||||
|
return NextResponse.json({ events: [], actions: [], resourceTypes: [], error: String(error) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,15 +2,34 @@ import { NextResponse } from "next/server";
|
||||||
import { requireAdmin } from "@/lib/admin-auth";
|
import { requireAdmin } from "@/lib/admin-auth";
|
||||||
import { sql } from "@/db";
|
import { sql } from "@/db";
|
||||||
|
|
||||||
|
// Engagement / analytics feed for /admin/analytics.
|
||||||
|
//
|
||||||
|
// Each section runs in its own try/catch and pushes any failure into `errors`,
|
||||||
|
// so a single broken query degrades gracefully (partial data still renders)
|
||||||
|
// AND the real error message is surfaced on the page instead of a silent blank.
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const auth = await requireAdmin(request);
|
const auth = await requireAdmin(request);
|
||||||
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
|
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||||
|
|
||||||
|
const errors: string[] = [];
|
||||||
|
let total = 0;
|
||||||
|
let featureAdoption: { name: string; count: number; pct: number }[] = [];
|
||||||
|
let families: Array<Record<string, unknown>> = [];
|
||||||
|
let aiUsage = { totalCalls: 0, totalTokens: 0, totalCostPaise: 0, familiesUsingAI: 0 };
|
||||||
|
let logsByDay: { date: string; count: number }[] = [];
|
||||||
|
|
||||||
|
// 0. Total families (cheap, robust — used for denominators + summary total)
|
||||||
|
try {
|
||||||
|
const rows = await sql`SELECT COUNT(*)::int AS count FROM families`;
|
||||||
|
total = Number(rows[0]?.count) || 0;
|
||||||
|
} catch (e) {
|
||||||
|
errors.push(`families_count: ${String(e)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A. Feature adoption — % of families that have used each feature at least once
|
||||||
try {
|
try {
|
||||||
// A. Feature adoption — % of families that have used each feature at least once
|
|
||||||
const adoptionRows = await sql`
|
const adoptionRows = await sql`
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(DISTINCT f.id)::int as total_families,
|
|
||||||
COUNT(DISTINCT f.id) FILTER (WHERE fd.id IS NOT NULL)::int as families_feeding,
|
COUNT(DISTINCT f.id) FILTER (WHERE fd.id IS NOT NULL)::int as families_feeding,
|
||||||
COUNT(DISTINCT f.id) FILTER (WHERE dl.id IS NOT NULL)::int as families_diapers,
|
COUNT(DISTINCT f.id) FILTER (WHERE dl.id IS NOT NULL)::int as families_diapers,
|
||||||
COUNT(DISTINCT f.id) FILTER (WHERE sl.id IS NOT NULL)::int as families_sleeping,
|
COUNT(DISTINCT f.id) FILTER (WHERE sl.id IS NOT NULL)::int as families_sleeping,
|
||||||
|
|
@ -28,63 +47,78 @@ export async function GET(request: Request) {
|
||||||
LEFT JOIN LATERAL (SELECT id FROM memories WHERE family_id = f.id LIMIT 1) mem ON true
|
LEFT JOIN LATERAL (SELECT id FROM memories WHERE family_id = f.id LIMIT 1) mem ON true
|
||||||
LEFT JOIN LATERAL (SELECT id FROM chat_sessions WHERE child_id = c.id LIMIT 1) cs ON true
|
LEFT JOIN LATERAL (SELECT id FROM chat_sessions WHERE child_id = c.id LIMIT 1) cs ON true
|
||||||
`;
|
`;
|
||||||
|
const a = adoptionRows[0] || {};
|
||||||
|
const denom = total || 1;
|
||||||
|
const pct = (n: unknown) => Math.round((Number(n) || 0) / denom * 100);
|
||||||
|
featureAdoption = [
|
||||||
|
{ name: "Feed Logs", count: Number(a.families_feeding) || 0, pct: pct(a.families_feeding) },
|
||||||
|
{ name: "Diaper Logs", count: Number(a.families_diapers) || 0, pct: pct(a.families_diapers) },
|
||||||
|
{ name: "Sleep Logs", count: Number(a.families_sleeping) || 0, pct: pct(a.families_sleeping) },
|
||||||
|
{ name: "Vaccinations", count: Number(a.families_vaccinating) || 0, pct: pct(a.families_vaccinating) },
|
||||||
|
{ name: "Growth Tracking", count: Number(a.families_growth) || 0, pct: pct(a.families_growth) },
|
||||||
|
{ name: "Memories", count: Number(a.families_memories) || 0, pct: pct(a.families_memories) },
|
||||||
|
{ name: "AI Chat", count: Number(a.families_ai) || 0, pct: pct(a.families_ai) },
|
||||||
|
].sort((x, y) => y.pct - x.pct);
|
||||||
|
} catch (e) {
|
||||||
|
errors.push(`feature_adoption: ${String(e)}`);
|
||||||
|
}
|
||||||
|
|
||||||
const adoption = adoptionRows[0] || {};
|
// B. Per-family engagement table.
|
||||||
const total = Number(adoption.total_families) || 1;
|
//
|
||||||
|
// Pre-aggregate each log table to one row per child BEFORE joining — joining
|
||||||
const featureAdoption = [
|
// the raw tables together produces a cartesian product that times out in
|
||||||
{ name: "Feed Logs", count: Number(adoption.families_feeding) || 0, pct: Math.round((Number(adoption.families_feeding) || 0) / total * 100) },
|
// production. Memories are per-family. All MAX(*) timestamps are cast to
|
||||||
{ name: "Diaper Logs", count: Number(adoption.families_diapers) || 0, pct: Math.round((Number(adoption.families_diapers) || 0) / total * 100) },
|
// timestamptz so GREATEST() can't choke on mixed timestamp/timestamptz types.
|
||||||
{ name: "Sleep Logs", count: Number(adoption.families_sleeping) || 0, pct: Math.round((Number(adoption.families_sleeping) || 0) / total * 100) },
|
try {
|
||||||
{ name: "Vaccinations", count: Number(adoption.families_vaccinating) || 0, pct: Math.round((Number(adoption.families_vaccinating) || 0) / total * 100) },
|
|
||||||
{ name: "Growth Tracking", count: Number(adoption.families_growth) || 0, pct: Math.round((Number(adoption.families_growth) || 0) / total * 100) },
|
|
||||||
{ name: "Memories", count: Number(adoption.families_memories) || 0, pct: Math.round((Number(adoption.families_memories) || 0) / total * 100) },
|
|
||||||
{ name: "AI Chat", count: Number(adoption.families_ai) || 0, pct: Math.round((Number(adoption.families_ai) || 0) / total * 100) },
|
|
||||||
].sort((a, b) => b.pct - a.pct);
|
|
||||||
|
|
||||||
// B. Per-family engagement table
|
|
||||||
const familyRows = await sql`
|
const familyRows = await sql`
|
||||||
|
WITH feed_agg AS (SELECT child_id, COUNT(*)::int AS cnt, MAX(logged_at) AS last_at FROM feeds GROUP BY child_id),
|
||||||
|
diaper_agg AS (SELECT child_id, COUNT(*)::int AS cnt, MAX(logged_at) AS last_at FROM diapers_logs GROUP BY child_id),
|
||||||
|
sleep_agg AS (SELECT child_id, COUNT(*)::int AS cnt, MAX(started_at) AS last_at FROM sleeps GROUP BY child_id),
|
||||||
|
vacc_agg AS (SELECT child_id, COUNT(*)::int AS cnt FROM vaccinations GROUP BY child_id),
|
||||||
|
growth_agg AS (SELECT child_id, COUNT(*)::int AS cnt FROM growth GROUP BY child_id),
|
||||||
|
chat_agg AS (SELECT child_id, COUNT(*)::int AS cnt FROM chat_sessions GROUP BY child_id),
|
||||||
|
mem_agg AS (SELECT family_id, COUNT(*)::int AS cnt, MAX(created_at) AS last_at FROM memories GROUP BY family_id)
|
||||||
SELECT
|
SELECT
|
||||||
f.id,
|
f.id,
|
||||||
f.name,
|
f.name,
|
||||||
f.tier,
|
f.tier,
|
||||||
f.created_at,
|
f.created_at,
|
||||||
GREATEST(
|
GREATEST(
|
||||||
MAX(fd.logged_at),
|
MAX(fa.last_at)::timestamptz,
|
||||||
MAX(dl.logged_at),
|
MAX(da.last_at)::timestamptz,
|
||||||
MAX(sl.started_at),
|
MAX(sa.last_at)::timestamptz,
|
||||||
MAX(mem.created_at)
|
MAX(ma.last_at)::timestamptz
|
||||||
) as last_activity,
|
) as last_activity,
|
||||||
COUNT(DISTINCT fd.id)::int as feed_count,
|
COALESCE(SUM(fa.cnt), 0)::int as feed_count,
|
||||||
COUNT(DISTINCT dl.id)::int as diaper_count,
|
COALESCE(SUM(da.cnt), 0)::int as diaper_count,
|
||||||
COUNT(DISTINCT sl.id)::int as sleep_count,
|
COALESCE(SUM(sa.cnt), 0)::int as sleep_count,
|
||||||
COUNT(DISTINCT v.id)::int as vaccination_count,
|
COALESCE(SUM(va.cnt), 0)::int as vaccination_count,
|
||||||
COUNT(DISTINCT g.id)::int as growth_count,
|
COALESCE(SUM(ga.cnt), 0)::int as growth_count,
|
||||||
COUNT(DISTINCT mem.id)::int as memory_count,
|
COALESCE(MAX(ma.cnt), 0)::int as memory_count,
|
||||||
COUNT(DISTINCT cs.id)::int as chat_count
|
COALESCE(SUM(ca.cnt), 0)::int as chat_count
|
||||||
FROM families f
|
FROM families f
|
||||||
LEFT JOIN children c ON c.family_id = f.id
|
LEFT JOIN children c ON c.family_id = f.id
|
||||||
LEFT JOIN feeds fd ON fd.child_id = c.id
|
LEFT JOIN feed_agg fa ON fa.child_id = c.id
|
||||||
LEFT JOIN diapers_logs dl ON dl.child_id = c.id
|
LEFT JOIN diaper_agg da ON da.child_id = c.id
|
||||||
LEFT JOIN sleeps sl ON sl.child_id = c.id
|
LEFT JOIN sleep_agg sa ON sa.child_id = c.id
|
||||||
LEFT JOIN vaccinations v ON v.child_id = c.id
|
LEFT JOIN vacc_agg va ON va.child_id = c.id
|
||||||
LEFT JOIN growth g ON g.child_id = c.id
|
LEFT JOIN growth_agg ga ON ga.child_id = c.id
|
||||||
LEFT JOIN memories mem ON mem.family_id = f.id
|
LEFT JOIN chat_agg ca ON ca.child_id = c.id
|
||||||
LEFT JOIN chat_sessions cs ON cs.child_id = c.id
|
LEFT JOIN mem_agg ma ON ma.family_id = f.id
|
||||||
GROUP BY f.id, f.name, f.tier, f.created_at
|
GROUP BY f.id, f.name, f.tier, f.created_at
|
||||||
ORDER BY last_activity DESC NULLS LAST
|
ORDER BY last_activity DESC NULLS LAST
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const families = familyRows.map((f: any) => {
|
families = familyRows.map((f: Record<string, unknown>) => {
|
||||||
const lastActivity = f.last_activity ? new Date(f.last_activity).toISOString() : null;
|
const lastActivity = f.last_activity ? new Date(f.last_activity as string).toISOString() : null;
|
||||||
const msSince = lastActivity ? now - new Date(lastActivity).getTime() : Infinity;
|
const msSince = lastActivity ? now - new Date(lastActivity).getTime() : Infinity;
|
||||||
const activeStatus = !lastActivity ? "never" : msSince < 7 * 86400_000 ? "7d" : msSince < 30 * 86400_000 ? "30d" : "inactive";
|
const activeStatus = !lastActivity ? "never" : msSince < 7 * 86400_000 ? "7d" : msSince < 30 * 86400_000 ? "30d" : "inactive";
|
||||||
return {
|
return {
|
||||||
id: f.id,
|
id: f.id,
|
||||||
name: f.name,
|
name: f.name,
|
||||||
tier: f.tier || "free",
|
tier: f.tier || "free",
|
||||||
createdAt: f.created_at ? new Date(f.created_at).toISOString() : null,
|
createdAt: f.created_at ? new Date(f.created_at as string).toISOString() : null,
|
||||||
lastActivity,
|
lastActivity,
|
||||||
activeStatus,
|
activeStatus,
|
||||||
feedCount: Number(f.feed_count) || 0,
|
feedCount: Number(f.feed_count) || 0,
|
||||||
|
|
@ -95,38 +129,44 @@ export async function GET(request: Request) {
|
||||||
memoryCount: Number(f.memory_count) || 0,
|
memoryCount: Number(f.memory_count) || 0,
|
||||||
chatCount: Number(f.chat_count) || 0,
|
chatCount: Number(f.chat_count) || 0,
|
||||||
totalLogs:
|
totalLogs:
|
||||||
Number(f.feed_count) + Number(f.diaper_count) + Number(f.sleep_count) +
|
(Number(f.feed_count) || 0) + (Number(f.diaper_count) || 0) + (Number(f.sleep_count) || 0) +
|
||||||
Number(f.vaccination_count) + Number(f.growth_count),
|
(Number(f.vaccination_count) || 0) + (Number(f.growth_count) || 0),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
if (!total) total = families.length;
|
||||||
|
} catch (e) {
|
||||||
|
errors.push(`families: ${String(e)}`);
|
||||||
|
}
|
||||||
|
|
||||||
// C. Activity summary counts
|
// C. Activity summary counts (derived from families)
|
||||||
const active7d = families.filter(f => f.activeStatus === "7d").length;
|
const active7d = families.filter(f => f.activeStatus === "7d").length;
|
||||||
const active30d = families.filter(f => f.activeStatus === "30d").length;
|
const active30d = families.filter(f => f.activeStatus === "30d").length;
|
||||||
const neverActive = families.filter(f => f.activeStatus === "never").length;
|
const neverActive = families.filter(f => f.activeStatus === "never").length;
|
||||||
|
|
||||||
// D. AI usage last 30 days
|
// D. AI usage last 30 days
|
||||||
let aiUsage = { totalCalls: 0, totalTokens: 0, totalCostPaise: 0, familiesUsingAI: 0 };
|
try {
|
||||||
try {
|
const aiRows = await sql`
|
||||||
const aiRows = await sql`
|
SELECT
|
||||||
SELECT
|
COUNT(*)::int as total_calls,
|
||||||
COUNT(*)::int as total_calls,
|
COALESCE(SUM(total_tokens), 0)::int as total_tokens,
|
||||||
COALESCE(SUM(total_tokens), 0)::int as total_tokens,
|
COALESCE(SUM(cost_estimate_paise), 0)::numeric as total_cost_paise,
|
||||||
COALESCE(SUM(cost_estimate_paise), 0)::numeric as total_cost_paise,
|
COUNT(DISTINCT family_id)::int as families_using_ai
|
||||||
COUNT(DISTINCT family_id)::int as families_using_ai
|
FROM ai_usage
|
||||||
FROM ai_usage
|
WHERE created_at > NOW() - INTERVAL '30 days'
|
||||||
WHERE created_at > NOW() - INTERVAL '30 days'
|
`;
|
||||||
`;
|
const row = aiRows[0] || {};
|
||||||
const row = aiRows[0] || {};
|
aiUsage = {
|
||||||
aiUsage = {
|
totalCalls: Number(row.total_calls) || 0,
|
||||||
totalCalls: Number(row.total_calls) || 0,
|
totalTokens: Number(row.total_tokens) || 0,
|
||||||
totalTokens: Number(row.total_tokens) || 0,
|
totalCostPaise: Number(row.total_cost_paise) || 0,
|
||||||
totalCostPaise: Number(row.total_cost_paise) || 0,
|
familiesUsingAI: Number(row.families_using_ai) || 0,
|
||||||
familiesUsingAI: Number(row.families_using_ai) || 0,
|
};
|
||||||
};
|
} catch (e) {
|
||||||
} catch {}
|
errors.push(`ai_usage: ${String(e)}`);
|
||||||
|
}
|
||||||
|
|
||||||
// E. Daily activity for chart (last 30 days, all log types combined)
|
// E. Daily activity for chart (last 30 days, all log types combined)
|
||||||
|
try {
|
||||||
const dailyRows = await sql`
|
const dailyRows = await sql`
|
||||||
SELECT day::date as date, SUM(cnt)::int as count
|
SELECT day::date as date, SUM(cnt)::int as count
|
||||||
FROM (
|
FROM (
|
||||||
|
|
@ -138,25 +178,27 @@ export async function GET(request: Request) {
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT DATE(created_at) as day, COUNT(*) as cnt FROM memories WHERE created_at > NOW() - INTERVAL '30 days' GROUP BY day
|
SELECT DATE(created_at) as day, COUNT(*) as cnt FROM memories WHERE created_at > NOW() - INTERVAL '30 days' GROUP BY day
|
||||||
) sub
|
) sub
|
||||||
|
WHERE day IS NOT NULL
|
||||||
GROUP BY day
|
GROUP BY day
|
||||||
ORDER BY day
|
ORDER BY day
|
||||||
`;
|
`;
|
||||||
|
logsByDay = dailyRows.map((r: Record<string, unknown>) => ({
|
||||||
const logsByDay = dailyRows.map((r: any) => ({
|
|
||||||
date: r.date instanceof Date ? r.date.toISOString().split("T")[0] : String(r.date).split("T")[0],
|
date: r.date instanceof Date ? r.date.toISOString().split("T")[0] : String(r.date).split("T")[0],
|
||||||
count: Number(r.count),
|
count: Number(r.count) || 0,
|
||||||
}));
|
}));
|
||||||
|
} catch (e) {
|
||||||
return NextResponse.json({
|
errors.push(`logs_by_day: ${String(e)}`);
|
||||||
totalFamilies: total,
|
|
||||||
featureAdoption,
|
|
||||||
families,
|
|
||||||
activitySummary: { active7d, active30d, neverActive, total },
|
|
||||||
aiUsage,
|
|
||||||
logsByDay,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Admin engagement error:", error);
|
|
||||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (errors.length) console.error("Admin engagement partial errors:", errors);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
totalFamilies: total,
|
||||||
|
featureAdoption,
|
||||||
|
families,
|
||||||
|
activitySummary: { active7d, active30d, neverActive, total },
|
||||||
|
aiUsage,
|
||||||
|
logsByDay,
|
||||||
|
...(errors.length ? { error: errors.join(" | ") } : {}),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
71
src/app/api/admin/errors/route.ts
Normal file
71
src/app/api/admin/errors/route.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireAdmin } from "@/lib/admin-auth";
|
||||||
|
import { sql } from "@/db";
|
||||||
|
|
||||||
|
// Admin error tracker feed. Returns recent error events (filterable), a grouped
|
||||||
|
// "top errors" rollup, and headline counts.
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const auth = await requireAdmin(request);
|
||||||
|
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const source = searchParams.get("source"); // client | server | null
|
||||||
|
const level = searchParams.get("level"); // error | warn | fatal | null
|
||||||
|
const q = searchParams.get("q")?.trim();
|
||||||
|
const sinceHours = Math.min(Math.max(parseInt(searchParams.get("sinceHours") || "168", 10) || 168, 1), 24 * 90);
|
||||||
|
const limit = Math.min(Math.max(parseInt(searchParams.get("limit") || "100", 10) || 100, 1), 500);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Build dynamic WHERE with positional params.
|
||||||
|
const where: string[] = [`e.created_at > NOW() - make_interval(hours => $1)`];
|
||||||
|
const params: unknown[] = [sinceHours];
|
||||||
|
if (source === "client" || source === "server") { params.push(source); where.push(`e.source = $${params.length}`); }
|
||||||
|
if (level === "error" || level === "warn" || level === "fatal") { params.push(level); where.push(`e.level = $${params.length}`); }
|
||||||
|
if (q) { params.push(`%${q}%`); where.push(`e.message ILIKE $${params.length}`); }
|
||||||
|
const whereSql = where.join(" AND ");
|
||||||
|
|
||||||
|
const events = await sql.unsafe(
|
||||||
|
`SELECT e.id, e.level, e.source, e.message, e.stack, e.url, e.digest,
|
||||||
|
e.user_id, e.family_id, e.user_agent, e.created_at,
|
||||||
|
u.email AS user_email, f.name AS family_name
|
||||||
|
FROM error_events e
|
||||||
|
LEFT JOIN users u ON u.id = e.user_id
|
||||||
|
LEFT JOIN families f ON f.id = e.family_id
|
||||||
|
WHERE ${whereSql}
|
||||||
|
ORDER BY e.created_at DESC
|
||||||
|
LIMIT ${limit}`,
|
||||||
|
params as never[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const grouped = await sql.unsafe(
|
||||||
|
`SELECT message, source,
|
||||||
|
COUNT(*)::int AS count,
|
||||||
|
MAX(created_at) AS last_seen,
|
||||||
|
MIN(created_at) AS first_seen
|
||||||
|
FROM error_events e
|
||||||
|
WHERE ${whereSql}
|
||||||
|
GROUP BY message, source
|
||||||
|
ORDER BY count DESC
|
||||||
|
LIMIT 50`,
|
||||||
|
params as never[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const stats = await sql`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) FILTER (WHERE created_at > NOW() - INTERVAL '24 hours')::int AS last24h,
|
||||||
|
COUNT(*) FILTER (WHERE created_at > NOW() - INTERVAL '7 days')::int AS last7d,
|
||||||
|
COUNT(*) FILTER (WHERE source = 'client' AND created_at > NOW() - INTERVAL '7 days')::int AS client7d,
|
||||||
|
COUNT(*) FILTER (WHERE source = 'server' AND created_at > NOW() - INTERVAL '7 days')::int AS server7d
|
||||||
|
FROM error_events
|
||||||
|
`;
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
events,
|
||||||
|
grouped,
|
||||||
|
stats: stats[0] || { last24h: 0, last7d: 0, client7d: 0, server7d: 0 },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Admin errors feed error:", error);
|
||||||
|
return NextResponse.json({ events: [], grouped: [], stats: { last24h: 0, last7d: 0, client7d: 0, server7d: 0 }, error: String(error) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { sql } from "@/db";
|
import { sql } from "@/db";
|
||||||
import { requireAdmin } from "@/lib/admin-auth";
|
import { requireAdmin } from "@/lib/admin-auth";
|
||||||
|
import { grantPremium, revokeToFree } from "@/lib/billing/entitlements";
|
||||||
|
|
||||||
// GET all families with members
|
// GET all families with members
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
|
|
@ -54,6 +55,35 @@ export async function GET(request: Request) {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Subscription info per family (most recent sub). Wrapped in try/catch so
|
||||||
|
// the families page still works if the billing tables aren't present.
|
||||||
|
const subMap = new Map<string, any>();
|
||||||
|
if (familyIds.length > 0) {
|
||||||
|
try {
|
||||||
|
const subs = await sql`
|
||||||
|
SELECT DISTINCT ON (fs.family_id)
|
||||||
|
fs.family_id, fs.status, fs.current_start, fs.current_end,
|
||||||
|
fs.cancelled_at, p.name AS plan_name, p.price_paise
|
||||||
|
FROM family_subscriptions fs
|
||||||
|
LEFT JOIN subscription_plans p ON p.id = fs.plan_id
|
||||||
|
WHERE fs.family_id = ANY(${familyIds})
|
||||||
|
ORDER BY fs.family_id, fs.created_at DESC
|
||||||
|
`;
|
||||||
|
(subs || []).forEach((s: any) => {
|
||||||
|
subMap.set(s.family_id, {
|
||||||
|
status: s.status,
|
||||||
|
planName: s.plan_name,
|
||||||
|
pricePaise: s.price_paise ? Number(s.price_paise) : null,
|
||||||
|
startedAt: s.current_start ? new Date(s.current_start).toISOString() : null,
|
||||||
|
expiresAt: s.current_end ? new Date(s.current_end).toISOString() : null,
|
||||||
|
cancelledAt: s.cancelled_at ? new Date(s.cancelled_at).toISOString() : null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* billing tables not present yet */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
families: families.map((f: any) => ({
|
families: families.map((f: any) => ({
|
||||||
id: f.id,
|
id: f.id,
|
||||||
|
|
@ -67,6 +97,7 @@ export async function GET(request: Request) {
|
||||||
logCount: Number(f.log_count) || 0,
|
logCount: Number(f.log_count) || 0,
|
||||||
memoryCount: Number(f.memory_count) || 0,
|
memoryCount: Number(f.memory_count) || 0,
|
||||||
members: memberMap.get(f.id) || [],
|
members: memberMap.get(f.id) || [],
|
||||||
|
subscription: subMap.get(f.id) || null,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -88,14 +119,28 @@ export async function PATCH(request: Request) {
|
||||||
return NextResponse.json({ error: "familyId required" }, { status: 400 });
|
return NextResponse.json({ error: "familyId required" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
await sql`
|
// Tier change → apply the SAME grant/revoke logic the webhook uses, so a
|
||||||
UPDATE families
|
// manual/comp upgrade gets the real premium grant (50GB/6/3), not ad-hoc
|
||||||
SET tier = COALESCE(${tier}, tier),
|
// hardcoded limits. subscription_status records it was set by admin.
|
||||||
max_children = COALESCE(${maxChildren}, max_children),
|
if (tier === "pro") {
|
||||||
max_members = COALESCE(${maxMembers}, max_members)
|
await grantPremium(familyId, "admin_comp");
|
||||||
WHERE id = ${familyId}
|
} else if (tier === "free") {
|
||||||
`;
|
await revokeToFree(familyId, "admin_downgrade");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicit limit overrides (optional) still win — lets admin fine-tune.
|
||||||
|
if (maxChildren != null || maxMembers != null) {
|
||||||
|
await sql`
|
||||||
|
UPDATE families
|
||||||
|
SET max_children = COALESCE(${maxChildren ?? null}, max_children),
|
||||||
|
max_members = COALESCE(${maxMembers ?? null}, max_members),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = ${familyId}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If only limits were passed (no tier), nothing above ran the tier path —
|
||||||
|
// that's fine, the override block handled it.
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Admin families error:", error);
|
console.error("Admin families error:", error);
|
||||||
|
|
|
||||||
109
src/app/api/admin/health/route.ts
Normal file
109
src/app/api/admin/health/route.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireAdmin } from "@/lib/admin-auth";
|
||||||
|
import { sql } from "@/db";
|
||||||
|
|
||||||
|
type Check = { name: string; status: "ok" | "warn" | "down"; detail: string };
|
||||||
|
|
||||||
|
// System health snapshot: DB connectivity + latency, migration status, recent
|
||||||
|
// error volume, and which integrations are configured. Read-only and cheap —
|
||||||
|
// no external round-trips, just config presence + DB queries.
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const auth = await requireAdmin(request);
|
||||||
|
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||||
|
|
||||||
|
const checks: Check[] = [];
|
||||||
|
|
||||||
|
// 1. Database connectivity + latency
|
||||||
|
let dbOk = false;
|
||||||
|
try {
|
||||||
|
const t0 = Date.now();
|
||||||
|
await sql`SELECT 1`;
|
||||||
|
const ms = Date.now() - t0;
|
||||||
|
dbOk = true;
|
||||||
|
checks.push({ name: "Database", status: ms < 500 ? "ok" : "warn", detail: `Connected — ${ms}ms` });
|
||||||
|
} catch (e) {
|
||||||
|
checks.push({ name: "Database", status: "down", detail: String(e).slice(0, 200) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Migrations applied (drizzle stores its journal in drizzle.__drizzle_migrations)
|
||||||
|
if (dbOk) {
|
||||||
|
try {
|
||||||
|
const rows = await sql`
|
||||||
|
SELECT COUNT(*)::int AS count, MAX(created_at) AS latest
|
||||||
|
FROM drizzle.__drizzle_migrations
|
||||||
|
`;
|
||||||
|
const count = Number(rows[0]?.count) || 0;
|
||||||
|
const latest = rows[0]?.latest ? new Date(Number(rows[0].latest)).toISOString().split("T")[0] : "—";
|
||||||
|
checks.push({ name: "Migrations", status: count > 0 ? "ok" : "warn", detail: `${count} applied (latest ${latest})` });
|
||||||
|
} catch {
|
||||||
|
checks.push({ name: "Migrations", status: "warn", detail: "Could not read migration journal" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Recent error volume (from the error tracker)
|
||||||
|
let recentErrors = { last24h: 0, last1h: 0 };
|
||||||
|
if (dbOk) {
|
||||||
|
try {
|
||||||
|
const rows = await sql`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) FILTER (WHERE created_at > NOW() - INTERVAL '24 hours')::int AS last24h,
|
||||||
|
COUNT(*) FILTER (WHERE created_at > NOW() - INTERVAL '1 hour')::int AS last1h
|
||||||
|
FROM error_events
|
||||||
|
`;
|
||||||
|
recentErrors = { last24h: Number(rows[0]?.last24h) || 0, last1h: Number(rows[0]?.last1h) || 0 };
|
||||||
|
checks.push({
|
||||||
|
name: "Errors (24h)",
|
||||||
|
status: recentErrors.last1h > 5 ? "down" : recentErrors.last24h > 0 ? "warn" : "ok",
|
||||||
|
detail: `${recentErrors.last24h} in 24h · ${recentErrors.last1h} in last hour`,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
checks.push({ name: "Errors (24h)", status: "warn", detail: "error_events table unavailable" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Integration config presence (no live calls — just whether env is wired)
|
||||||
|
const configCheck = (name: string, present: boolean, missingHint: string): Check =>
|
||||||
|
({ name, status: present ? "ok" : "warn", detail: present ? "Configured" : missingHint });
|
||||||
|
|
||||||
|
checks.push(configCheck("AI Gateway", !!(process.env.LITELLM_BASE_URL && process.env.LITELLM_API_KEY), "LITELLM_BASE_URL / LITELLM_API_KEY not set"));
|
||||||
|
checks.push(configCheck("R2 Storage", !!(process.env.R2_ACCOUNT_ID && process.env.R2_ACCESS_KEY_ID && process.env.R2_BUCKET_NAME), "R2_* env vars incomplete"));
|
||||||
|
checks.push(configCheck("Email (Resend)", !!process.env.RESEND_API_KEY, "RESEND_API_KEY not set"));
|
||||||
|
checks.push(configCheck("Razorpay", !!(process.env.RAZORPAY_KEY_ID && process.env.RAZORPAY_KEY_SECRET && process.env.RAZORPAY_WEBHOOK_SECRET && process.env.RAZORPAY_PLAN_ID), "RAZORPAY_* env vars incomplete"));
|
||||||
|
|
||||||
|
// 5. Razorpay webhook freshness — if webhooks silently stop, entitlement
|
||||||
|
// (and revenue) quietly breaks. Only meaningful once subscriptions exist.
|
||||||
|
if (dbOk) {
|
||||||
|
try {
|
||||||
|
const rows = await sql`
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*) FROM family_subscriptions)::int AS sub_count,
|
||||||
|
(SELECT MAX(received_at) FROM razorpay_webhook_events) AS last_event
|
||||||
|
`;
|
||||||
|
const subCount = Number(rows[0]?.sub_count) || 0;
|
||||||
|
const lastEvent = rows[0]?.last_event ? new Date(rows[0].last_event as string) : null;
|
||||||
|
|
||||||
|
if (subCount === 0) {
|
||||||
|
checks.push({ name: "Razorpay Webhooks", status: "ok", detail: "No subscriptions yet" });
|
||||||
|
} else if (!lastEvent) {
|
||||||
|
checks.push({ name: "Razorpay Webhooks", status: "warn", detail: "Subscriptions exist but no webhook ever received" });
|
||||||
|
} else {
|
||||||
|
const hoursAgo = (Date.now() - lastEvent.getTime()) / 3_600_000;
|
||||||
|
const rel =
|
||||||
|
hoursAgo < 1 ? `${Math.round(hoursAgo * 60)}m ago`
|
||||||
|
: hoursAgo < 48 ? `${Math.round(hoursAgo)}h ago`
|
||||||
|
: `${Math.round(hoursAgo / 24)}d ago`;
|
||||||
|
// Subscriptions renew at least monthly, so >35 days of silence is suspect.
|
||||||
|
const status = hoursAgo > 35 * 24 ? "warn" : "ok";
|
||||||
|
checks.push({ name: "Razorpay Webhooks", status, detail: `Last event ${rel}` });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
checks.push({ name: "Razorpay Webhooks", status: "warn", detail: "Billing tables unavailable" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const overall: "ok" | "warn" | "down" = checks.some(c => c.status === "down")
|
||||||
|
? "down"
|
||||||
|
: checks.some(c => c.status === "warn") ? "warn" : "ok";
|
||||||
|
|
||||||
|
return NextResponse.json({ overall, checks, recentErrors, checkedAt: new Date().toISOString() });
|
||||||
|
}
|
||||||
121
src/app/api/admin/reconcile-subscriptions/route.ts
Normal file
121
src/app/api/admin/reconcile-subscriptions/route.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { sql } from "@/db";
|
||||||
|
import { requireAdmin } from "@/lib/admin-auth";
|
||||||
|
import { grantPremium, revokeToFree, ENTITLED_STATUSES, TERMINAL_STATUSES } from "@/lib/billing/entitlements";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/reconcile-subscriptions — admin recovery + diagnostic.
|
||||||
|
*
|
||||||
|
* For each family_subscription, looks at the most recent webhook event we
|
||||||
|
* logged for its Razorpay subscription id, derives the intended status, and
|
||||||
|
* (re)applies the grant/revoke + row update — catching and RETURNING any error.
|
||||||
|
*
|
||||||
|
* Why this exists: the webhook logs an event before processing it, so a
|
||||||
|
* processing error left the event logged-but-unprocessed and the idempotency
|
||||||
|
* guard blocked retries. This endpoint reprocesses those stuck events safely
|
||||||
|
* (all grant/revoke ops are idempotent) and surfaces the real error per sub.
|
||||||
|
*/
|
||||||
|
const GRANT_FROM_EVENT: Record<string, string> = {
|
||||||
|
"subscription.authenticated": "authenticated",
|
||||||
|
"subscription.activated": "active",
|
||||||
|
"subscription.charged": "active",
|
||||||
|
"subscription.resumed": "active",
|
||||||
|
"subscription.pending": "pending",
|
||||||
|
};
|
||||||
|
const REVOKE_FROM_EVENT: Record<string, string> = {
|
||||||
|
"subscription.halted": "halted",
|
||||||
|
"subscription.cancelled": "cancelled",
|
||||||
|
"subscription.completed": "completed",
|
||||||
|
"subscription.expired": "expired",
|
||||||
|
"subscription.paused": "paused",
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const auth = await requireAdmin(request);
|
||||||
|
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||||
|
|
||||||
|
const subs = await sql`
|
||||||
|
SELECT id, family_id, razorpay_subscription_id, status FROM family_subscriptions
|
||||||
|
`;
|
||||||
|
|
||||||
|
const results: unknown[] = [];
|
||||||
|
|
||||||
|
for (const s of subs as Record<string, unknown>[]) {
|
||||||
|
const subId = s.razorpay_subscription_id as string;
|
||||||
|
const rowId = s.id as string;
|
||||||
|
const familyId = s.family_id as string;
|
||||||
|
|
||||||
|
// Most recent webhook event we have for this subscription.
|
||||||
|
const ev = await sql`
|
||||||
|
SELECT event_type, payload FROM razorpay_webhook_events
|
||||||
|
WHERE payload->'payload'->'subscription'->'entity'->>'id' = ${subId}
|
||||||
|
ORDER BY received_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
const event = ev[0] as { event_type: string; payload: Record<string, unknown> } | undefined;
|
||||||
|
if (!event) {
|
||||||
|
results.push({ subId, action: "skipped", reason: "no webhook event logged yet" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventType = event.event_type;
|
||||||
|
const grant = GRANT_FROM_EVENT[eventType];
|
||||||
|
const revoke = REVOKE_FROM_EVENT[eventType];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Pull current_start/current_end from the stored payload.
|
||||||
|
const entity = ((event.payload as Record<string, unknown>)?.payload as Record<string, unknown>)
|
||||||
|
?.subscription as Record<string, unknown> | undefined;
|
||||||
|
const ent = (entity as { entity?: Record<string, unknown> })?.entity;
|
||||||
|
// ISO strings — postgres.js binds timestamps as strings, not Date objects.
|
||||||
|
const toISO = (v: unknown) => {
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) && n > 0 ? new Date(n * 1000).toISOString() : null;
|
||||||
|
};
|
||||||
|
const currentStart = toISO(ent?.current_start);
|
||||||
|
const currentEnd = toISO(ent?.current_end);
|
||||||
|
const customerId = (ent?.customer_id as string) ?? null;
|
||||||
|
|
||||||
|
if (grant) {
|
||||||
|
await sql`
|
||||||
|
UPDATE family_subscriptions SET
|
||||||
|
status = ${grant}::subscription_status_enum,
|
||||||
|
razorpay_customer_id = COALESCE(${customerId}, razorpay_customer_id),
|
||||||
|
current_start = COALESCE(${currentStart}, current_start),
|
||||||
|
current_end = COALESCE(${currentEnd}, current_end),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = ${rowId}
|
||||||
|
`;
|
||||||
|
await grantPremium(familyId, grant);
|
||||||
|
results.push({ subId, familyId, eventType, applied: "grant", status: grant, ok: true });
|
||||||
|
} else if (revoke) {
|
||||||
|
await sql`
|
||||||
|
UPDATE family_subscriptions SET
|
||||||
|
status = ${revoke}::subscription_status_enum,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = ${rowId}
|
||||||
|
`;
|
||||||
|
await revokeToFree(familyId, revoke);
|
||||||
|
results.push({ subId, familyId, eventType, applied: "revoke", status: revoke, ok: true });
|
||||||
|
} else {
|
||||||
|
results.push({ subId, eventType, action: "skipped", reason: "unhandled event type" });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
results.push({ subId, familyId, eventType, ok: false, error: String(e) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show resulting family tiers so we can confirm the grant landed.
|
||||||
|
const families = await sql`
|
||||||
|
SELECT id, tier, subscription_status, max_members, max_children
|
||||||
|
FROM families WHERE tier != 'free' OR subscription_status IS NOT NULL
|
||||||
|
`;
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
reconciled: results,
|
||||||
|
paidFamilies: families,
|
||||||
|
entitledStatuses: [...ENTITLED_STATUSES],
|
||||||
|
terminalStatuses: [...TERMINAL_STATUSES],
|
||||||
|
});
|
||||||
|
}
|
||||||
59
src/app/api/admin/seed-plan/route.ts
Normal file
59
src/app/api/admin/seed-plan/route.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { sql } from "@/db";
|
||||||
|
import { requireAdmin } from "@/lib/admin-auth";
|
||||||
|
import {
|
||||||
|
getRazorpayConfig,
|
||||||
|
PREMIUM_PLAN_NAME,
|
||||||
|
PREMIUM_PRICE_PAISE,
|
||||||
|
PREMIUM_STORAGE_BYTES,
|
||||||
|
PREMIUM_MEMBER_LIMIT,
|
||||||
|
PREMIUM_CHILD_LIMIT,
|
||||||
|
} from "@/lib/billing/config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/seed-plan — idempotent upsert of the Tia Premium plan row.
|
||||||
|
*
|
||||||
|
* Admin-only. Reads RAZORPAY_PLAN_ID + the premium grant constants and writes
|
||||||
|
* one subscription_plans row. Re-runnable: ON CONFLICT updates the grant values
|
||||||
|
* so you can tweak storage/member limits and re-seed without a DB shell.
|
||||||
|
*
|
||||||
|
* GET shows the current plan row(s) for verification.
|
||||||
|
*/
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const auth = await requireAdmin(request);
|
||||||
|
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||||
|
|
||||||
|
const rows = await sql`SELECT * FROM subscription_plans ORDER BY created_at DESC`;
|
||||||
|
return NextResponse.json({ plans: rows });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const auth = await requireAdmin(request);
|
||||||
|
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||||
|
|
||||||
|
let cfg;
|
||||||
|
try {
|
||||||
|
cfg = getRazorpayConfig();
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json({ error: String(e) }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await sql`
|
||||||
|
INSERT INTO subscription_plans
|
||||||
|
(razorpay_plan_id, name, price_paise, storage_bytes, member_limit, child_limit, is_active)
|
||||||
|
VALUES (
|
||||||
|
${cfg.planId}, ${PREMIUM_PLAN_NAME}, ${PREMIUM_PRICE_PAISE},
|
||||||
|
${PREMIUM_STORAGE_BYTES}, ${PREMIUM_MEMBER_LIMIT}, ${PREMIUM_CHILD_LIMIT}, true
|
||||||
|
)
|
||||||
|
ON CONFLICT (razorpay_plan_id) DO UPDATE SET
|
||||||
|
name = EXCLUDED.name,
|
||||||
|
price_paise = EXCLUDED.price_paise,
|
||||||
|
storage_bytes = EXCLUDED.storage_bytes,
|
||||||
|
member_limit = EXCLUDED.member_limit,
|
||||||
|
child_limit = EXCLUDED.child_limit,
|
||||||
|
is_active = true
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, plan: rows[0] });
|
||||||
|
}
|
||||||
|
|
@ -21,7 +21,23 @@ export async function GET(request: Request) {
|
||||||
const proFamilies = tierStats.find((t: any) => t.tier === "pro")?.count || 0;
|
const proFamilies = tierStats.find((t: any) => t.tier === "pro")?.count || 0;
|
||||||
const freeFamilies = tierStats.find((t: any) => t.tier === "free")?.count || 0;
|
const freeFamilies = tierStats.find((t: any) => t.tier === "free")?.count || 0;
|
||||||
const totalFamilies = familyCount[0]?.count || 0;
|
const totalFamilies = familyCount[0]?.count || 0;
|
||||||
const mrr = proFamilies * 9.99;
|
|
||||||
|
// Real MRR (₹) from active subscriptions — only entitled/recurring states.
|
||||||
|
// Sums the plan price (paise) of each family_subscriptions row that is
|
||||||
|
// currently active/authenticated/pending; falls back to 0 if no subs table.
|
||||||
|
let mrrPaise = 0;
|
||||||
|
try {
|
||||||
|
const mrrRow = await sql`
|
||||||
|
SELECT COALESCE(SUM(p.price_paise), 0)::bigint AS paise
|
||||||
|
FROM family_subscriptions fs
|
||||||
|
JOIN subscription_plans p ON p.id = fs.plan_id
|
||||||
|
WHERE fs.status IN ('active','authenticated','pending')
|
||||||
|
`;
|
||||||
|
mrrPaise = Number(mrrRow[0]?.paise) || 0;
|
||||||
|
} catch {
|
||||||
|
// subscriptions tables not present yet — leave MRR at 0
|
||||||
|
}
|
||||||
|
const mrr = mrrPaise / 100; // rupees
|
||||||
|
|
||||||
const [familiesByDay, usersByDay, childrenByAge, recentLogins, failedLogins] = await Promise.all([
|
const [familiesByDay, usersByDay, childrenByAge, recentLogins, failedLogins] = await Promise.all([
|
||||||
sql`
|
sql`
|
||||||
|
|
|
||||||
111
src/app/api/admin/storage/route.ts
Normal file
111
src/app/api/admin/storage/route.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireAdmin } from "@/lib/admin-auth";
|
||||||
|
import { sql } from "@/db";
|
||||||
|
|
||||||
|
// Storage / billing monitor. Usage is SUM(size_bytes) across memories +
|
||||||
|
// attachments per family — the SAME basis the quota system enforces
|
||||||
|
// (src/lib/quota.ts), so admin numbers match what users are charged on.
|
||||||
|
const FREE_LIMIT_BYTES = 1_073_741_824; // 1 GiB (matches FREE_STORAGE_LIMIT_BYTES)
|
||||||
|
const R2_USD_PER_GB_MONTH = 0.015; // Cloudflare R2 storage price
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const auth = await requireAdmin(request);
|
||||||
|
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||||
|
|
||||||
|
const errors: string[] = [];
|
||||||
|
let families: Array<Record<string, unknown>> = [];
|
||||||
|
let totalBytes = 0;
|
||||||
|
let totalObjects = 0;
|
||||||
|
let byDay: { date: string; bytes: number; count: number }[] = [];
|
||||||
|
|
||||||
|
// Per-family usage — aggregate each table to one row per family BEFORE joining
|
||||||
|
// (no cartesian blow-up), then sum. Sorted by heaviest consumer first.
|
||||||
|
try {
|
||||||
|
const rows = await sql`
|
||||||
|
WITH mem AS (
|
||||||
|
SELECT family_id, COALESCE(SUM(size_bytes::bigint), 0)::bigint AS bytes, COUNT(*)::int AS cnt
|
||||||
|
FROM memories GROUP BY family_id
|
||||||
|
),
|
||||||
|
att AS (
|
||||||
|
SELECT family_id, COALESCE(SUM(size_bytes::bigint), 0)::bigint AS bytes, COUNT(*)::int AS cnt
|
||||||
|
FROM attachments GROUP BY family_id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
f.id, f.name, f.tier,
|
||||||
|
(COALESCE(m.bytes, 0) + COALESCE(a.bytes, 0))::bigint AS total_bytes,
|
||||||
|
COALESCE(m.cnt, 0)::int AS memory_count,
|
||||||
|
COALESCE(a.cnt, 0)::int AS attachment_count
|
||||||
|
FROM families f
|
||||||
|
LEFT JOIN mem m ON m.family_id = f.id
|
||||||
|
LEFT JOIN att a ON a.family_id = f.id
|
||||||
|
ORDER BY total_bytes DESC
|
||||||
|
`;
|
||||||
|
families = rows.map((r: Record<string, unknown>) => {
|
||||||
|
const bytes = Number(r.total_bytes) || 0;
|
||||||
|
const isPaid = !!r.tier && r.tier !== "free";
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
tier: r.tier || "free",
|
||||||
|
isPaid,
|
||||||
|
bytes,
|
||||||
|
memoryCount: Number(r.memory_count) || 0,
|
||||||
|
attachmentCount: Number(r.attachment_count) || 0,
|
||||||
|
objectCount: (Number(r.memory_count) || 0) + (Number(r.attachment_count) || 0),
|
||||||
|
// fraction of the free limit (paid families have no limit)
|
||||||
|
fraction: isPaid ? 0 : bytes / FREE_LIMIT_BYTES,
|
||||||
|
overLimit: !isPaid && bytes > FREE_LIMIT_BYTES,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
totalBytes = families.reduce((s, f) => s + (f.bytes as number), 0);
|
||||||
|
totalObjects = families.reduce((s, f) => s + (f.objectCount as number), 0);
|
||||||
|
} catch (e) {
|
||||||
|
errors.push(`families: ${String(e)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Daily upload volume (bytes + objects), last 30 days
|
||||||
|
try {
|
||||||
|
const rows = await sql`
|
||||||
|
SELECT day::date AS date, SUM(bytes)::bigint AS bytes, SUM(cnt)::int AS count
|
||||||
|
FROM (
|
||||||
|
SELECT DATE(created_at) AS day, COALESCE(SUM(size_bytes::bigint), 0) AS bytes, COUNT(*) AS cnt
|
||||||
|
FROM memories WHERE created_at > NOW() - INTERVAL '30 days' GROUP BY day
|
||||||
|
UNION ALL
|
||||||
|
SELECT DATE(created_at) AS day, COALESCE(SUM(size_bytes::bigint), 0) AS bytes, COUNT(*) AS cnt
|
||||||
|
FROM attachments WHERE created_at > NOW() - INTERVAL '30 days' GROUP BY day
|
||||||
|
) sub
|
||||||
|
WHERE day IS NOT NULL
|
||||||
|
GROUP BY day ORDER BY day
|
||||||
|
`;
|
||||||
|
byDay = rows.map((r: Record<string, unknown>) => ({
|
||||||
|
date: r.date instanceof Date ? r.date.toISOString().split("T")[0] : String(r.date).split("T")[0],
|
||||||
|
bytes: Number(r.bytes) || 0,
|
||||||
|
count: Number(r.count) || 0,
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
errors.push(`by_day: ${String(e)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const paidCount = families.filter(f => f.isPaid).length;
|
||||||
|
const overLimitCount = families.filter(f => f.overLimit).length;
|
||||||
|
const approachingCount = families.filter(f => !f.isPaid && (f.fraction as number) >= 0.8 && !(f.overLimit as boolean)).length;
|
||||||
|
const estMonthlyCostUsd = (totalBytes / 1e9) * R2_USD_PER_GB_MONTH;
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
summary: {
|
||||||
|
totalBytes,
|
||||||
|
totalObjects,
|
||||||
|
familyCount: families.length,
|
||||||
|
paidCount,
|
||||||
|
freeCount: families.length - paidCount,
|
||||||
|
overLimitCount,
|
||||||
|
approachingCount,
|
||||||
|
estMonthlyCostUsd,
|
||||||
|
freeLimitBytes: FREE_LIMIT_BYTES,
|
||||||
|
avgBytesPerFamily: families.length ? Math.round(totalBytes / families.length) : 0,
|
||||||
|
},
|
||||||
|
families,
|
||||||
|
byDay,
|
||||||
|
...(errors.length ? { error: errors.join(" | ") } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
159
src/app/api/admin/subscriptions/route.ts
Normal file
159
src/app/api/admin/subscriptions/route.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { sql } from "@/db";
|
||||||
|
import { requireAdmin } from "@/lib/admin-auth";
|
||||||
|
import { getRazorpayConfig, razorpayAuthHeader, RAZORPAY_API_BASE } from "@/lib/billing/config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/subscriptions — subscription monitoring data:
|
||||||
|
* - subscriptions: every family_subscriptions row joined to family + plan
|
||||||
|
* - webhookEvents: recent razorpay_webhook_events (debugging delivery)
|
||||||
|
* - summary: counts by status + MRR/ARR in paise (active+authenticated+pending)
|
||||||
|
*/
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const auth = await requireAdmin(request);
|
||||||
|
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||||
|
|
||||||
|
const subscriptions = await sql`
|
||||||
|
SELECT
|
||||||
|
fs.id,
|
||||||
|
fs.family_id,
|
||||||
|
f.name AS family_name,
|
||||||
|
p.name AS plan_name,
|
||||||
|
p.price_paise,
|
||||||
|
fs.status,
|
||||||
|
fs.razorpay_subscription_id,
|
||||||
|
fs.razorpay_customer_id,
|
||||||
|
fs.current_start,
|
||||||
|
fs.current_end,
|
||||||
|
fs.cancelled_at,
|
||||||
|
fs.ended_at,
|
||||||
|
fs.created_at,
|
||||||
|
fs.updated_at
|
||||||
|
FROM family_subscriptions fs
|
||||||
|
LEFT JOIN families f ON f.id = fs.family_id
|
||||||
|
LEFT JOIN subscription_plans p ON p.id = fs.plan_id
|
||||||
|
ORDER BY fs.created_at DESC
|
||||||
|
LIMIT 200
|
||||||
|
`;
|
||||||
|
|
||||||
|
const webhookEvents = await sql`
|
||||||
|
SELECT
|
||||||
|
razorpay_event_id,
|
||||||
|
event_type,
|
||||||
|
received_at,
|
||||||
|
payload->'payload'->'subscription'->'entity'->>'id' AS sub_id,
|
||||||
|
payload->'payload'->'subscription'->'entity'->>'status' AS sub_status
|
||||||
|
FROM razorpay_webhook_events
|
||||||
|
ORDER BY received_at DESC
|
||||||
|
LIMIT 50
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Summary: status counts + MRR (only entitled/recurring-active statuses).
|
||||||
|
const byStatus: Record<string, number> = {};
|
||||||
|
let mrrPaise = 0;
|
||||||
|
for (const s of subscriptions as Record<string, unknown>[]) {
|
||||||
|
const status = s.status as string;
|
||||||
|
byStatus[status] = (byStatus[status] || 0) + 1;
|
||||||
|
if (status === "active" || status === "authenticated" || status === "pending") {
|
||||||
|
mrrPaise += Number(s.price_paise) || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Real monthly revenue trend from subscription.charged webhook events.
|
||||||
|
// Each charged event = one plan-price collection. We join back to the sub's
|
||||||
|
// plan to value it (price_paise), grouped by IST month.
|
||||||
|
let revenueTrend: { month: string; paise: number; charges: number }[] = [];
|
||||||
|
try {
|
||||||
|
const trend = await sql`
|
||||||
|
SELECT
|
||||||
|
to_char((e.received_at AT TIME ZONE 'Asia/Kolkata'), 'YYYY-MM') AS month,
|
||||||
|
COUNT(*)::int AS charges,
|
||||||
|
COALESCE(SUM(p.price_paise), 0)::bigint AS paise
|
||||||
|
FROM razorpay_webhook_events e
|
||||||
|
JOIN family_subscriptions fs
|
||||||
|
ON fs.razorpay_subscription_id = e.payload->'payload'->'subscription'->'entity'->>'id'
|
||||||
|
JOIN subscription_plans p ON p.id = fs.plan_id
|
||||||
|
WHERE e.event_type = 'subscription.charged'
|
||||||
|
AND e.received_at > NOW() - INTERVAL '12 months'
|
||||||
|
GROUP BY 1
|
||||||
|
ORDER BY 1
|
||||||
|
`;
|
||||||
|
revenueTrend = (trend as Record<string, unknown>[]).map((r) => ({
|
||||||
|
month: r.month as string,
|
||||||
|
paise: Number(r.paise) || 0,
|
||||||
|
charges: Number(r.charges) || 0,
|
||||||
|
}));
|
||||||
|
} catch { /* billing tables absent */ }
|
||||||
|
|
||||||
|
// Churn: cancelled+halted+expired ÷ all subs that ever became live.
|
||||||
|
const everLive = (subscriptions as Record<string, unknown>[]).filter(
|
||||||
|
(s) => s.status !== "created",
|
||||||
|
).length;
|
||||||
|
const churned = (subscriptions as Record<string, unknown>[]).filter((s) =>
|
||||||
|
["cancelled", "halted", "expired"].includes(s.status as string),
|
||||||
|
).length;
|
||||||
|
const churnRate = everLive > 0 ? Math.round((churned / everLive) * 1000) / 10 : 0;
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
subscriptions,
|
||||||
|
webhookEvents,
|
||||||
|
summary: {
|
||||||
|
total: subscriptions.length,
|
||||||
|
byStatus,
|
||||||
|
mrrPaise,
|
||||||
|
arrPaise: mrrPaise * 12,
|
||||||
|
churnRate, // %
|
||||||
|
churned,
|
||||||
|
everLive,
|
||||||
|
},
|
||||||
|
revenueTrend,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/subscriptions
|
||||||
|
* body { action: "reconcile" } — re-run entitlement sync
|
||||||
|
* body { action: "cancel", subscriptionId } — cancel a sub (admin, any family)
|
||||||
|
*/
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const auth = await requireAdmin(request);
|
||||||
|
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const action = body.action || "reconcile";
|
||||||
|
|
||||||
|
if (action === "cancel") {
|
||||||
|
const subId = body.subscriptionId as string | undefined;
|
||||||
|
if (!subId) return NextResponse.json({ error: "subscriptionId required" }, { status: 400 });
|
||||||
|
|
||||||
|
let cfg;
|
||||||
|
try {
|
||||||
|
cfg = getRazorpayConfig();
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json({ error: String(e) }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${RAZORPAY_API_BASE}/subscriptions/${subId}/cancel`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: razorpayAuthHeader(cfg), "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ cancel_at_cycle_end: 1 }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: data?.error?.description || "Cancel failed", razorpay: data },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// The subscription.cancelled webhook will sync state; this just initiates.
|
||||||
|
return NextResponse.json({ success: true, message: "Cancellation scheduled at cycle end." });
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json({ error: String(e) }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: reconcile. Delegate to the recovery endpoint.
|
||||||
|
const { POST: reconcilePOST } = await import("../reconcile-subscriptions/route");
|
||||||
|
return reconcilePOST(request);
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,7 @@ export async function GET(request: Request) {
|
||||||
u.id,
|
u.id,
|
||||||
u.email,
|
u.email,
|
||||||
u.name,
|
u.name,
|
||||||
|
u.phone,
|
||||||
u.password_hash,
|
u.password_hash,
|
||||||
fm.family_id,
|
fm.family_id,
|
||||||
fm.id as member_id,
|
fm.id as member_id,
|
||||||
|
|
@ -32,6 +33,7 @@ export async function GET(request: Request) {
|
||||||
id: u.id,
|
id: u.id,
|
||||||
email: u.email,
|
email: u.email,
|
||||||
name: u.name,
|
name: u.name,
|
||||||
|
phone: u.phone || null,
|
||||||
hasPassword: !!u.password_hash,
|
hasPassword: !!u.password_hash,
|
||||||
familyId: u.family_id,
|
familyId: u.family_id,
|
||||||
memberId: u.member_id,
|
memberId: u.member_id,
|
||||||
|
|
|
||||||
|
|
@ -38,8 +38,10 @@ export async function POST(request: Request) {
|
||||||
// ── 1. HARD GUARDRAIL: keyword-based medical detection (most conservative) ──
|
// ── 1. HARD GUARDRAIL: keyword-based medical detection (most conservative) ──
|
||||||
const medicalIntent = detectMedicalIntent(lastUserMsg);
|
const medicalIntent = detectMedicalIntent(lastUserMsg);
|
||||||
if (medicalIntent.isMedical) {
|
if (medicalIntent.isMedical) {
|
||||||
const families = await sql`SELECT pediatrician_phone FROM families WHERE id = ${familyId} LIMIT 1`;
|
const families = await sql`SELECT pediatrician_phone, pediatrician_name FROM families WHERE id = ${familyId} LIMIT 1`;
|
||||||
const phone = families[0]?.pediatrician_phone;
|
const phone = families[0]?.pediatrician_phone;
|
||||||
|
const pedName = families[0]?.pediatrician_name;
|
||||||
|
const pedLabel = pedName ? `Dr. ${pedName.replace(/^Dr\.?\s*/i, "")}` : "your pediatrician";
|
||||||
|
|
||||||
const reply = [
|
const reply = [
|
||||||
`I can't interpret symptoms — that's a pediatrician's job, not mine.`,
|
`I can't interpret symptoms — that's a pediatrician's job, not mine.`,
|
||||||
|
|
@ -47,8 +49,8 @@ export async function POST(request: Request) {
|
||||||
ESCALATION_RULES[medicalIntent.category],
|
ESCALATION_RULES[medicalIntent.category],
|
||||||
``,
|
``,
|
||||||
phone
|
phone
|
||||||
? `Call your pediatrician now: ${phone}`
|
? `Call ${pedLabel} now: ${phone}`
|
||||||
: `Add your pediatrician's phone in Settings so I can show it here.`,
|
: `Add your pediatrician's info in Settings so I can show it here.`,
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
||||||
await logAudit({
|
await logAudit({
|
||||||
|
|
@ -84,13 +86,15 @@ export async function POST(request: Request) {
|
||||||
|
|
||||||
// Medical_redirect from classifier → also redirect
|
// Medical_redirect from classifier → also redirect
|
||||||
if (classification.intent === "medical_redirect") {
|
if (classification.intent === "medical_redirect") {
|
||||||
const families = await sql`SELECT pediatrician_phone FROM families WHERE id = ${familyId} LIMIT 1`;
|
const families = await sql`SELECT pediatrician_phone, pediatrician_name FROM families WHERE id = ${familyId} LIMIT 1`;
|
||||||
const phone = families[0]?.pediatrician_phone;
|
const phone = families[0]?.pediatrician_phone;
|
||||||
|
const pedName = families[0]?.pediatrician_name;
|
||||||
|
const pedLabel = pedName ? `Dr. ${pedName.replace(/^Dr\.?\s*/i, "")}` : "your pediatrician";
|
||||||
const reply = [
|
const reply = [
|
||||||
`That sounds like something your pediatrician should assess.`,
|
`That sounds like something your pediatrician should assess.`,
|
||||||
``,
|
``,
|
||||||
ESCALATION_RULES["default"],
|
ESCALATION_RULES["default"],
|
||||||
phone ? `Call: ${phone}` : `Add your pediatrician's phone in Settings.`,
|
phone ? `Call ${pedLabel}: ${phone}` : `Add your pediatrician's info in Settings.`,
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
||||||
await logUsage({ familyId, userId: session.userId, intent: "medical_redirect", durationMs: Date.now() - start });
|
await logUsage({ familyId, userId: session.userId, intent: "medical_redirect", durationMs: Date.now() - start });
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { sql } from "@/db";
|
import { sql } from "@/db";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
|
import { toProxyUrl } from "@/lib/r2-proxy";
|
||||||
|
|
||||||
// GET current user profile from session
|
// GET current user profile from session
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
|
|
@ -14,7 +15,7 @@ export async function GET() {
|
||||||
|
|
||||||
// Get session and user
|
// Get session and user
|
||||||
const sessions = await sql`
|
const sessions = await sql`
|
||||||
SELECT s.user_id, s.expires, u.id, u.email, u.name, u.image, u.created_at
|
SELECT s.user_id, s.expires, u.id, u.email, u.name, u.image, u.phone, u.created_at
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
JOIN users u ON u.id = s.user_id
|
JOIN users u ON u.id = s.user_id
|
||||||
WHERE s.session_token = ${sessionToken}
|
WHERE s.session_token = ${sessionToken}
|
||||||
|
|
@ -40,7 +41,8 @@ export async function GET() {
|
||||||
id: session.id,
|
id: session.id,
|
||||||
email: session.email,
|
email: session.email,
|
||||||
name: session.name || "Parent",
|
name: session.name || "Parent",
|
||||||
avatarUrl: session.image || null,
|
phone: session.phone || null,
|
||||||
|
avatarUrl: toProxyUrl(session.image) || null,
|
||||||
familyId: members?.[0]?.family_id,
|
familyId: members?.[0]?.family_id,
|
||||||
familyName: members?.[0]?.family_name,
|
familyName: members?.[0]?.family_name,
|
||||||
memberSince: session.created_at,
|
memberSince: session.created_at,
|
||||||
|
|
@ -56,12 +58,29 @@ export async function GET() {
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { name } = body;
|
const { name, phone } = body as { name?: string; phone?: string };
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return NextResponse.json({ error: "Name required" }, { status: 400 });
|
return NextResponse.json({ error: "Name required" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phone is optional. Normalise: keep a leading + then digits only.
|
||||||
|
// Empty string clears it. Light validation — 8-15 digits if provided.
|
||||||
|
let normalizedPhone: string | null | undefined; // undefined = don't touch
|
||||||
|
if (phone !== undefined) {
|
||||||
|
const trimmed = (phone || "").trim();
|
||||||
|
if (trimmed === "") {
|
||||||
|
normalizedPhone = null;
|
||||||
|
} else {
|
||||||
|
const cleaned = trimmed.replace(/[^\d+]/g, "").replace(/(?!^)\+/g, "");
|
||||||
|
const digits = cleaned.replace(/\D/g, "");
|
||||||
|
if (digits.length < 8 || digits.length > 15) {
|
||||||
|
return NextResponse.json({ error: "Enter a valid phone number" }, { status: 400 });
|
||||||
|
}
|
||||||
|
normalizedPhone = cleaned;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const sessionToken = cookieStore.get("tia_session")?.value;
|
const sessionToken = cookieStore.get("tia_session")?.value;
|
||||||
|
|
||||||
|
|
@ -83,13 +102,20 @@ export async function POST(request: Request) {
|
||||||
return NextResponse.json({ error: "Invalid session" }, { status: 401 });
|
return NextResponse.json({ error: "Invalid session" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update user name
|
// Update user name (+ phone only when the field was sent)
|
||||||
await sql`
|
if (normalizedPhone !== undefined) {
|
||||||
UPDATE users SET name = ${name}, updated_at = NOW()
|
await sql`
|
||||||
WHERE id = ${session.user_id}
|
UPDATE users SET name = ${name}, phone = ${normalizedPhone}, updated_at = NOW()
|
||||||
`;
|
WHERE id = ${session.user_id}
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
await sql`
|
||||||
|
UPDATE users SET name = ${name}, updated_at = NOW()
|
||||||
|
WHERE id = ${session.user_id}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ success: true, name });
|
return NextResponse.json({ success: true, name, phone: normalizedPhone ?? undefined });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Profile update error:", error);
|
console.error("Profile update error:", error);
|
||||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { sql } from "@/db";
|
import { sql } from "@/db";
|
||||||
import { validateSession, requireFamily } from "@/lib/auth";
|
import { validateSession, requireFamily } from "@/lib/auth";
|
||||||
|
import { checkChildLimit } from "@/lib/quota";
|
||||||
|
import { toProxyUrl } from "@/lib/r2-proxy";
|
||||||
|
|
||||||
// GET - list children (family only)
|
// GET - list children (family only)
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
|
|
@ -12,11 +14,15 @@ export async function GET(request: Request) {
|
||||||
const familyId = auth.session!.familyId!;
|
const familyId = auth.session!.familyId!;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const children = await sql.unsafe(
|
const rows = await sql.unsafe(
|
||||||
`SELECT id, name, birth_date as "birthDate", sex, stage, image_url as "imageUrl", created_at as "createdAt" FROM children WHERE family_id = $1 ORDER BY created_at DESC`,
|
`SELECT id, name, birth_date as "birthDate", sex, stage, image_url as "imageUrl", created_at as "createdAt" FROM children WHERE family_id = $1 ORDER BY created_at DESC`,
|
||||||
[familyId]
|
[familyId]
|
||||||
);
|
);
|
||||||
return NextResponse.json({ children: children || [] });
|
const children = (rows || []).map((c: any) => ({
|
||||||
|
...c,
|
||||||
|
imageUrl: toProxyUrl(c.imageUrl) ?? null,
|
||||||
|
}));
|
||||||
|
return NextResponse.json({ children });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||||
|
|
@ -39,6 +45,14 @@ export async function POST(request: Request) {
|
||||||
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
|
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const limitCheck = await checkChildLimit(familyId);
|
||||||
|
if (!limitCheck.allowed) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: limitCheck.message, reason: limitCheck.reason, currentCount: limitCheck.currentCount, limit: limitCheck.limit },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const [child] = await sql.unsafe(
|
const [child] = await sql.unsafe(
|
||||||
`INSERT INTO children (family_id, name, birth_date, sex, stage) VALUES ($1, $2, $3, $4, 'newborn') RETURNING id, name, birth_date as "birthDate", sex, stage`,
|
`INSERT INTO children (family_id, name, birth_date, sex, stage) VALUES ($1, $2, $3, $4, 'newborn') RETURNING id, name, birth_date as "birthDate", sex, stage`,
|
||||||
[familyId, name, birthDate, sex]
|
[familyId, name, birthDate, sex]
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,10 @@ import { promisify } from "util";
|
||||||
import { S3Client, PutObjectCommand, DeleteObjectCommand, ListObjectsV2Command } from "@aws-sdk/client-s3";
|
import { S3Client, PutObjectCommand, DeleteObjectCommand, ListObjectsV2Command } from "@aws-sdk/client-s3";
|
||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
import { gzip } from "zlib";
|
import { gzip } from "zlib";
|
||||||
|
import { sendAlert } from "@/lib/alert";
|
||||||
|
|
||||||
|
// Below this, a gzipped dump almost certainly means an empty/failed pg_dump.
|
||||||
|
const MIN_BACKUP_BYTES = 1024;
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
const gzipAsync = promisify(gzip);
|
const gzipAsync = promisify(gzip);
|
||||||
|
|
@ -63,9 +67,26 @@ export async function POST(request: Request) {
|
||||||
// 5. Cleanup local
|
// 5. Cleanup local
|
||||||
await fs.unlink("/tmp/dump.sql").catch(() => {});
|
await fs.unlink("/tmp/dump.sql").catch(() => {});
|
||||||
|
|
||||||
|
const sizeKb = Math.round(compressed.length / 1024);
|
||||||
|
|
||||||
|
// Suspiciously small dump — almost certainly an empty or broken backup.
|
||||||
|
if (compressed.length < MIN_BACKUP_BYTES) {
|
||||||
|
await sendAlert("error", "Backup looks empty", `Compressed dump is only ${compressed.length} bytes`, {
|
||||||
|
fields: { File: filename },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await sendAlert("info", "Backup completed", undefined, {
|
||||||
|
fields: { File: filename, Size: `${sizeKb} KB` },
|
||||||
|
silent: true, // daily confirmation — no need to buzz
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ success: true, filename, size: compressed.length });
|
return NextResponse.json({ success: true, filename, size: compressed.length });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Backup failed:", e);
|
console.error("Backup failed:", e);
|
||||||
|
await sendAlert("fatal", "Database backup FAILED", String(e).slice(0, 500), {
|
||||||
|
fields: { File: filename },
|
||||||
|
});
|
||||||
return NextResponse.json({ error: String(e) }, { status: 500 });
|
return NextResponse.json({ error: String(e) }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
103
src/app/api/cron/monitor/route.ts
Normal file
103
src/app/api/cron/monitor/route.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { sql } from "@/db";
|
||||||
|
import { sendAlert } from "@/lib/alert";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Monitor cron — catches the failures Uptime Kuma can't see from the outside:
|
||||||
|
* • Error spikes (rising-edge: last hour vs the hour before)
|
||||||
|
* • DB unreachable
|
||||||
|
* • Migrations missing / integration env not configured
|
||||||
|
*
|
||||||
|
* Uptime Kuma handles up/down flip detection by pinging /api/healthz, so this
|
||||||
|
* focuses on internal signals. Recommended schedule: hourly.
|
||||||
|
*
|
||||||
|
* POST/GET /api/cron/monitor (header: x-cron-secret)
|
||||||
|
* GET /api/cron/monitor?test=1 — sends a test Telegram ping
|
||||||
|
*
|
||||||
|
* Stateless by design: error alerts use rising-edge comparison so a sustained
|
||||||
|
* (flat) error rate won't re-alert every run — only genuine new spikes do.
|
||||||
|
*/
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const SPIKE_MIN = 5; // need at least this many errors in the last hour
|
||||||
|
const SPIKE_MULTIPLIER = 2; // …and > 2× the previous hour to count as a spike
|
||||||
|
|
||||||
|
function authed(request: Request): boolean {
|
||||||
|
return request.headers.get("x-cron-secret") === process.env.CRON_SECRET;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
if (!authed(request)) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
return runMonitor(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
if (!authed(request)) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
return runMonitor(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runMonitor(request: Request) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
|
// Manual test ping — confirms the Telegram wiring end-to-end.
|
||||||
|
if (searchParams.get("test")) {
|
||||||
|
const ok = await sendAlert("info", "Monitor test ping", "Telegram alerting is wired correctly. 🎉");
|
||||||
|
return NextResponse.json({ ok, test: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fired: string[] = [];
|
||||||
|
|
||||||
|
// 1. Database reachable?
|
||||||
|
try {
|
||||||
|
await sql`SELECT 1`;
|
||||||
|
} catch (e) {
|
||||||
|
await sendAlert("fatal", "Database unreachable", String(e).slice(0, 300));
|
||||||
|
return NextResponse.json({ ok: false, dbOk: false, fired: ["db_down"] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Error spike — rising edge (last 1h vs the hour before it)
|
||||||
|
let recent = 0;
|
||||||
|
let prior = 0;
|
||||||
|
try {
|
||||||
|
const rows = await sql`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) FILTER (WHERE created_at > NOW() - INTERVAL '1 hour')::int AS recent,
|
||||||
|
COUNT(*) FILTER (WHERE created_at <= NOW() - INTERVAL '1 hour'
|
||||||
|
AND created_at > NOW() - INTERVAL '2 hours')::int AS prior
|
||||||
|
FROM error_events
|
||||||
|
`;
|
||||||
|
recent = Number(rows[0]?.recent) || 0;
|
||||||
|
prior = Number(rows[0]?.prior) || 0;
|
||||||
|
if (recent >= SPIKE_MIN && recent > prior * SPIKE_MULTIPLIER) {
|
||||||
|
await sendAlert("error", "Error spike detected", undefined, {
|
||||||
|
fields: { "Last hour": recent, "Previous hour": prior },
|
||||||
|
});
|
||||||
|
fired.push("error_spike");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* error_events may not exist yet — non-fatal */
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Migrations recorded
|
||||||
|
try {
|
||||||
|
const rows = await sql`SELECT COUNT(*)::int AS count FROM drizzle.__drizzle_migrations`;
|
||||||
|
if ((Number(rows[0]?.count) || 0) === 0) {
|
||||||
|
await sendAlert("warn", "No migrations recorded", "drizzle.__drizzle_migrations is empty", { silent: true });
|
||||||
|
fired.push("no_migrations");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Integration env presence
|
||||||
|
const missing: string[] = [];
|
||||||
|
if (!(process.env.LITELLM_BASE_URL && process.env.LITELLM_API_KEY)) missing.push("AI Gateway");
|
||||||
|
if (!(process.env.R2_ACCOUNT_ID && process.env.R2_ACCESS_KEY_ID && process.env.R2_BUCKET_NAME)) missing.push("R2 Storage");
|
||||||
|
if (!process.env.RESEND_API_KEY) missing.push("Email");
|
||||||
|
if (missing.length) {
|
||||||
|
await sendAlert("warn", "Integration config missing", missing.join(", "), { silent: true });
|
||||||
|
fired.push("config_missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, dbOk: true, errors: { recent, prior }, fired });
|
||||||
|
}
|
||||||
152
src/app/api/cron/visitor-summary/route.ts
Normal file
152
src/app/api/cron/visitor-summary/route.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { sendAlert } from "@/lib/alert";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Visitor summary cron — polls the self-hosted Umami API and posts a digest to
|
||||||
|
* Telegram. Umami has no native webhook, so we authenticate and read its REST API.
|
||||||
|
*
|
||||||
|
* POST/GET /api/cron/visitor-summary (header: x-cron-secret)
|
||||||
|
* GET /api/cron/visitor-summary?hours=24 — window (default 24, max 168)
|
||||||
|
*
|
||||||
|
* Env:
|
||||||
|
* UMAMI_BASE_URL (default https://analytics.manohargupta.com)
|
||||||
|
* UMAMI_USERNAME Umami login (admin or a read-only user)
|
||||||
|
* UMAMI_PASSWORD
|
||||||
|
* UMAMI_WEBSITE_ID (default the Tia website id)
|
||||||
|
*
|
||||||
|
* Recommended schedule: once a day. Set hours=24 for a daily digest, or call it
|
||||||
|
* more often (e.g. hours=1 hourly) if you want tighter pulse during launch.
|
||||||
|
*/
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const UMAMI_BASE = process.env.UMAMI_BASE_URL || "https://analytics.manohargupta.com";
|
||||||
|
const WEBSITE_ID = process.env.UMAMI_WEBSITE_ID || "79444c19-ee31-4fab-baf5-f4e61098eeba";
|
||||||
|
|
||||||
|
function authed(request: Request): boolean {
|
||||||
|
return request.headers.get("x-cron-secret") === process.env.CRON_SECRET;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
if (!authed(request)) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
return runSummary(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
if (!authed(request)) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
return runSummary(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Authenticate with Umami and return a Bearer token, or null on failure. */
|
||||||
|
async function umamiLogin(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${UMAMI_BASE}/api/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: process.env.UMAMI_USERNAME,
|
||||||
|
password: process.env.UMAMI_PASSWORD,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error("umamiLogin failed:", res.status, await res.text().catch(() => ""));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
return data?.token || null;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("umamiLogin error:", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Umami wraps metric values as { value, prev }; tolerate both shapes + raw numbers.
|
||||||
|
const num = (v: unknown): number => {
|
||||||
|
if (typeof v === "number") return v;
|
||||||
|
if (v && typeof v === "object" && "value" in v) return Number((v as { value: unknown }).value) || 0;
|
||||||
|
return Number(v) || 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function runSummary(request: Request) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const hours = Math.max(1, Math.min(168, Number(searchParams.get("hours")) || 24));
|
||||||
|
const endAt = Date.now();
|
||||||
|
const startAt = endAt - hours * 3_600_000;
|
||||||
|
|
||||||
|
if (!process.env.UMAMI_USERNAME || !process.env.UMAMI_PASSWORD) {
|
||||||
|
return NextResponse.json({ error: "UMAMI_USERNAME / UMAMI_PASSWORD not set" }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await umamiLogin();
|
||||||
|
if (!token) {
|
||||||
|
await sendAlert("warn", "Visitor summary unavailable", "Could not authenticate with Umami", { silent: true });
|
||||||
|
return NextResponse.json({ error: "umami auth failed" }, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = { Authorization: `Bearer ${token}` };
|
||||||
|
const qs = `startAt=${startAt}&endAt=${endAt}`;
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
let stats: Record<string, unknown> = {};
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${UMAMI_BASE}/api/websites/${WEBSITE_ID}/stats?${qs}`, { headers });
|
||||||
|
stats = await r.json();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("umami stats error:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top pages
|
||||||
|
let topPages: Array<{ x?: string; url?: string; y?: number; value?: number }> = [];
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${UMAMI_BASE}/api/websites/${WEBSITE_ID}/metrics?type=url&${qs}&limit=5`, { headers });
|
||||||
|
const j = await r.json();
|
||||||
|
if (Array.isArray(j)) topPages = j;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("umami metrics error:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active right now
|
||||||
|
let active = 0;
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${UMAMI_BASE}/api/websites/${WEBSITE_ID}/active`, { headers });
|
||||||
|
const a = await r.json();
|
||||||
|
active = typeof a === "number" ? a : num((a as { visitors?: unknown; x?: unknown })?.visitors ?? (a as { x?: unknown })?.x);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("umami active error:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageviews = num(stats.pageviews);
|
||||||
|
const visitors = num(stats.visitors);
|
||||||
|
const visits = num(stats.visits);
|
||||||
|
const bounces = num(stats.bounces);
|
||||||
|
const totaltime = num(stats.totaltime);
|
||||||
|
|
||||||
|
const bounceRate = visits ? Math.round((bounces / visits) * 100) : 0;
|
||||||
|
const avgVisit = visits ? Math.round(totaltime / visits) : 0; // seconds
|
||||||
|
|
||||||
|
const pagesText =
|
||||||
|
topPages
|
||||||
|
.slice(0, 5)
|
||||||
|
.map(p => `• ${p.x || p.url || "?"} — ${p.y ?? p.value ?? 0}`)
|
||||||
|
.join("\n") || "—";
|
||||||
|
|
||||||
|
const label = hours === 24 ? "Last 24h" : `Last ${hours}h`;
|
||||||
|
const body = [
|
||||||
|
`👥 Visitors: ${visitors}`,
|
||||||
|
`🔁 Visits: ${visits}`,
|
||||||
|
`📄 Page views: ${pageviews}`,
|
||||||
|
`↩️ Bounce rate: ${bounceRate}%`,
|
||||||
|
`⏱ Avg visit: ${avgVisit}s`,
|
||||||
|
`🟢 Active now: ${active}`,
|
||||||
|
``,
|
||||||
|
`Top pages:`,
|
||||||
|
pagesText,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const delivered = await sendAlert("info", `📊 Visitor summary — ${label}`, body, { silent: true });
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
delivered,
|
||||||
|
summary: { visitors, visits, pageviews, bounceRate, avgVisit, active },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -6,17 +6,78 @@ export async function GET() {
|
||||||
const auth = await requireFamily();
|
const auth = await requireFamily();
|
||||||
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
|
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||||
|
|
||||||
|
// Each probe is independent so one failure never blanks the whole diagnostic.
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
// pgvector status — FIRST and standalone (this is what diagnoses the
|
||||||
|
// "could not access file vector" production error). pg_available_extensions
|
||||||
|
// reads the on-disk extension catalog and never loads the vector library, so
|
||||||
|
// it works even when the image is missing the pgvector binaries.
|
||||||
try {
|
try {
|
||||||
const migrations = await sql.unsafe(
|
const vectorRows = await sql.unsafe(
|
||||||
`SELECT hash, created_at FROM __drizzle_migrations ORDER BY created_at DESC LIMIT 10`
|
`SELECT name, default_version, installed_version FROM pg_available_extensions WHERE name = 'vector'`
|
||||||
);
|
);
|
||||||
const circleTables = await sql.unsafe(
|
const v = vectorRows[0] as { default_version?: string; installed_version?: string } | undefined;
|
||||||
|
out.pgvector = {
|
||||||
|
binaryAvailable: !!v, // false → wrong Postgres image (no pgvector binaries)
|
||||||
|
installed: !!v?.installed_version, // false → needs CREATE EXTENSION vector
|
||||||
|
availableVersion: v?.default_version ?? null,
|
||||||
|
installedVersion: v?.installed_version ?? null,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
out.pgvectorError = String(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Does the error_events table exist yet? (admin Errors page depends on it)
|
||||||
|
try {
|
||||||
|
const r = await sql.unsafe(`SELECT to_regclass('public.error_events') AS reg`);
|
||||||
|
out.errorEventsExists = !!(r[0] as { reg?: string })?.reg;
|
||||||
|
} catch (e) {
|
||||||
|
out.errorEventsError = String(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Applied drizzle migrations (the journal lives in the "drizzle" schema).
|
||||||
|
try {
|
||||||
|
out.migrations = await sql.unsafe(
|
||||||
|
`SELECT hash, created_at FROM drizzle.__drizzle_migrations ORDER BY created_at DESC LIMIT 12`
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
out.migrationsError = String(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
out.circleTables = await sql.unsafe(
|
||||||
`SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND (tablename LIKE 'circle%' OR tablename = 'post_reports') ORDER BY tablename`
|
`SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND (tablename LIKE 'circle%' OR tablename = 'post_reports') ORDER BY tablename`
|
||||||
);
|
);
|
||||||
return NextResponse.json({ migrations, circleTables });
|
} catch (e) {
|
||||||
} catch (err: unknown) {
|
out.circleTablesError = String(e);
|
||||||
return NextResponse.json({ error: err instanceof Error ? err.message : String(err) });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Billing diagnostics — last webhook events + subscription rows + family tiers
|
||||||
|
try {
|
||||||
|
out.webhookEvents = await sql.unsafe(
|
||||||
|
`SELECT razorpay_event_id, event_type, received_at,
|
||||||
|
payload->'payload'->'subscription'->'entity'->>'id' AS sub_id,
|
||||||
|
payload->'payload'->'subscription'->'entity'->>'status' AS sub_status
|
||||||
|
FROM razorpay_webhook_events ORDER BY received_at DESC LIMIT 10`
|
||||||
|
);
|
||||||
|
} catch (e) { out.webhookEventsError = String(e); }
|
||||||
|
|
||||||
|
try {
|
||||||
|
out.subscriptions = await sql.unsafe(
|
||||||
|
`SELECT razorpay_subscription_id, status, family_id, current_end, updated_at
|
||||||
|
FROM family_subscriptions ORDER BY created_at DESC LIMIT 10`
|
||||||
|
);
|
||||||
|
} catch (e) { out.subscriptionsError = String(e); }
|
||||||
|
|
||||||
|
try {
|
||||||
|
out.paidFamilies = await sql.unsafe(
|
||||||
|
`SELECT id, tier, subscription_status, max_members, max_children
|
||||||
|
FROM families WHERE tier != 'free' OR subscription_status IS NOT NULL LIMIT 10`
|
||||||
|
);
|
||||||
|
} catch (e) { out.paidFamiliesError = String(e); }
|
||||||
|
|
||||||
|
return NextResponse.json(out);
|
||||||
}
|
}
|
||||||
|
|
||||||
// One-shot: apply circles migration via the app DB connection (requires login)
|
// One-shot: apply circles migration via the app DB connection (requires login)
|
||||||
|
|
@ -30,11 +91,16 @@ export async function POST(req: Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const steps = [
|
const steps = [
|
||||||
|
// One-time fix: memories stuck in 'processing' (vision pipeline was marking them failed)
|
||||||
|
// Reset to 'ready' so they're visible again — vision captions are optional
|
||||||
|
`UPDATE memories SET processing_status = 'ready', updated_at = now() WHERE processing_status = 'processing' AND created_at < NOW() - INTERVAL '10 minutes'`,
|
||||||
// family_invites missing columns (0006)
|
// family_invites missing columns (0006)
|
||||||
`ALTER TABLE family_invites ADD COLUMN IF NOT EXISTS display_name text`,
|
`ALTER TABLE family_invites ADD COLUMN IF NOT EXISTS display_name text`,
|
||||||
`ALTER TABLE family_invites ADD COLUMN IF NOT EXISTS accepted_at timestamp`,
|
`ALTER TABLE family_invites ADD COLUMN IF NOT EXISTS accepted_at timestamp`,
|
||||||
// subscription_status on families (0007) — payment-provider abstraction
|
// subscription_status on families (0007) — payment-provider abstraction
|
||||||
`ALTER TABLE families ADD COLUMN IF NOT EXISTS subscription_status varchar(20) DEFAULT NULL`,
|
`ALTER TABLE families ADD COLUMN IF NOT EXISTS subscription_status varchar(20) DEFAULT NULL`,
|
||||||
|
// pediatrician_name on families (0008)
|
||||||
|
`ALTER TABLE families ADD COLUMN IF NOT EXISTS pediatrician_name text`,
|
||||||
// circles tables (0003)
|
// circles tables (0003)
|
||||||
`CREATE TABLE IF NOT EXISTS 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())`,
|
`CREATE TABLE IF NOT EXISTS 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())`,
|
||||||
`CREATE TABLE IF NOT EXISTS 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', joined_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (circle_id, family_id))`,
|
`CREATE TABLE IF NOT EXISTS 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', joined_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (circle_id, family_id))`,
|
||||||
|
|
@ -50,6 +116,24 @@ export async function POST(req: Request) {
|
||||||
`CREATE INDEX IF NOT EXISTS circle_comments_post_idx ON circle_post_comments(post_id)`,
|
`CREATE INDEX IF NOT EXISTS circle_comments_post_idx ON circle_post_comments(post_id)`,
|
||||||
`CREATE INDEX IF NOT EXISTS circle_reactions_post_idx ON circle_post_reactions(post_id)`,
|
`CREATE INDEX IF NOT EXISTS circle_reactions_post_idx ON circle_post_reactions(post_id)`,
|
||||||
`CREATE INDEX IF NOT EXISTS circle_invites_token_idx ON circle_invites(token)`,
|
`CREATE INDEX IF NOT EXISTS circle_invites_token_idx ON circle_invites(token)`,
|
||||||
|
// 0009 — notifications table
|
||||||
|
`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, type VARCHAR(80) NOT NULL, title TEXT NOT NULL, message TEXT NOT NULL, action_url TEXT, is_read BOOLEAN NOT NULL DEFAULT false, scheduled_for DATE NOT NULL, metadata JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW())`,
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS notifications_unique_slot ON notifications(family_id, child_id, type, scheduled_for)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS notifications_family_child_idx ON notifications(family_id, child_id, is_read, created_at DESC)`,
|
||||||
|
// 0010 — error_events table (admin error/crash tracker)
|
||||||
|
`CREATE TABLE IF NOT EXISTS error_events (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), level varchar(20) NOT NULL DEFAULT 'error', source varchar(20) NOT NULL DEFAULT 'client', message text NOT NULL, stack text, url text, digest varchar(120), 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)`,
|
||||||
|
// 0011 — optional user phone number
|
||||||
|
`ALTER TABLE users ADD COLUMN IF NOT EXISTS phone text`,
|
||||||
|
// 0012 — billing / Razorpay subscriptions
|
||||||
|
`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 $$`,
|
||||||
|
`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())`,
|
||||||
|
`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)`,
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS family_live_sub_idx ON family_subscriptions (family_id) WHERE status IN ('created','authenticated','active','pending','halted')`,
|
||||||
|
`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())`,
|
||||||
];
|
];
|
||||||
|
|
||||||
const results: string[] = [];
|
const results: string[] = [];
|
||||||
|
|
|
||||||
53
src/app/api/errors/route.ts
Normal file
53
src/app/api/errors/route.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { logError } from "@/lib/error-log";
|
||||||
|
import { validateSession } from "@/lib/auth";
|
||||||
|
|
||||||
|
// Ingest endpoint for client-side error reports (from the error boundaries).
|
||||||
|
// Auth-optional: we attach the user/family if a session exists, but we never
|
||||||
|
// reject an error report for being unauthenticated — crashes can happen on
|
||||||
|
// public/logged-out screens too.
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const { message, stack, url, digest, level, metadata } = body as {
|
||||||
|
message?: string;
|
||||||
|
stack?: string;
|
||||||
|
url?: string;
|
||||||
|
digest?: string;
|
||||||
|
level?: "error" | "warn" | "fatal";
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!message || typeof message !== "string") {
|
||||||
|
return NextResponse.json({ error: "message required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let userId: string | null = null;
|
||||||
|
let familyId: string | null = null;
|
||||||
|
try {
|
||||||
|
const auth = await validateSession();
|
||||||
|
userId = auth.session?.userId ?? null;
|
||||||
|
familyId = auth.session?.familyId ?? null;
|
||||||
|
} catch {
|
||||||
|
// best-effort only
|
||||||
|
}
|
||||||
|
|
||||||
|
await logError({
|
||||||
|
source: "client",
|
||||||
|
level: level === "warn" || level === "fatal" ? level : "error",
|
||||||
|
message,
|
||||||
|
stack,
|
||||||
|
url,
|
||||||
|
digest,
|
||||||
|
userId,
|
||||||
|
familyId,
|
||||||
|
userAgent: request.headers.get("user-agent"),
|
||||||
|
metadata: metadata && typeof metadata === "object" ? metadata : {},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch {
|
||||||
|
// Swallow — the reporter must never surface its own error to the user.
|
||||||
|
return NextResponse.json({ ok: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,7 +9,7 @@ export async function GET(request: Request) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const family = await sql.unsafe(
|
const family = await sql.unsafe(
|
||||||
`SELECT id, name, tier, max_children, max_members FROM families WHERE id = $1`,
|
`SELECT id, name, tier, max_children, max_members, pediatrician_phone, pediatrician_name FROM families WHERE id = $1`,
|
||||||
[auth.session!.familyId]
|
[auth.session!.familyId]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -31,11 +31,27 @@ export async function PATCH(request: Request) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { name, pediatricianPhone, tier } = body;
|
const { name, pediatricianPhone, pediatricianName, tier } = body;
|
||||||
|
|
||||||
|
// Only update fields that are explicitly present in the request body.
|
||||||
|
// This avoids COALESCE masking clears and undefined params causing errors.
|
||||||
|
const setClauses: string[] = [];
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const params: any[] = [];
|
||||||
|
|
||||||
|
if (name !== undefined) { setClauses.push(`name = $${params.push(name)}`); }
|
||||||
|
if (pediatricianPhone !== undefined) { setClauses.push(`pediatrician_phone = $${params.push(pediatricianPhone || null)}`); }
|
||||||
|
if (pediatricianName !== undefined) { setClauses.push(`pediatrician_name = $${params.push(pediatricianName || null)}`); }
|
||||||
|
if (tier !== undefined) { setClauses.push(`tier = $${params.push(tier)}`); }
|
||||||
|
|
||||||
|
if (setClauses.length === 0) return NextResponse.json({ success: true });
|
||||||
|
|
||||||
|
setClauses.push("updated_at = NOW()");
|
||||||
|
params.push(auth.session!.familyId);
|
||||||
|
|
||||||
await sql.unsafe(
|
await sql.unsafe(
|
||||||
`UPDATE families SET name = COALESCE($1, name), pediatrician_phone = COALESCE($2, pediatrician_phone), tier = COALESCE($3, tier), updated_at = NOW() WHERE id = $4`,
|
`UPDATE families SET ${setClauses.join(", ")} WHERE id = $${params.length}`,
|
||||||
[name, pediatricianPhone, tier, auth.session!.familyId]
|
params
|
||||||
);
|
);
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,10 @@ import { requireFamily } from "@/lib/auth";
|
||||||
import { sql } from "@/db";
|
import { sql } from "@/db";
|
||||||
import { GARMENT_CATEGORIES } from "@/db/schema/wardrobe";
|
import { GARMENT_CATEGORIES } from "@/db/schema/wardrobe";
|
||||||
|
|
||||||
function toDto(g: Record<string, unknown>, baseUrl: string) {
|
function toDto(g: Record<string, unknown>) {
|
||||||
|
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "";
|
||||||
|
const ik = g.image_key as string | null;
|
||||||
|
const tk = g.thumb_key as string | null;
|
||||||
return {
|
return {
|
||||||
id: g.id,
|
id: g.id,
|
||||||
familyId: g.family_id,
|
familyId: g.family_id,
|
||||||
|
|
@ -14,10 +17,11 @@ function toDto(g: Record<string, unknown>, baseUrl: string) {
|
||||||
colors: g.colors || [],
|
colors: g.colors || [],
|
||||||
seasons: g.seasons || [],
|
seasons: g.seasons || [],
|
||||||
occasionTags: g.occasion_tags || [],
|
occasionTags: g.occasion_tags || [],
|
||||||
imageKey: g.image_key,
|
imageKey: ik,
|
||||||
thumbKey: g.thumb_key,
|
thumbKey: tk,
|
||||||
imageUrl: `${baseUrl}/${g.image_key}`,
|
// Proxy through /api/img — never expose raw pub-*.r2.dev URLs (blocked by Cloudflare Bot Mgmt)
|
||||||
thumbUrl: `${baseUrl}/${g.thumb_key}`,
|
imageUrl: ik ? `${appUrl}/api/img?key=${encodeURIComponent(ik)}` : null,
|
||||||
|
thumbUrl: tk ? `${appUrl}/api/img?key=${encodeURIComponent(tk)}` : null,
|
||||||
status: g.status,
|
status: g.status,
|
||||||
acquiredVia: g.acquired_via,
|
acquiredVia: g.acquired_via,
|
||||||
giftFrom: g.gift_from,
|
giftFrom: g.gift_from,
|
||||||
|
|
@ -27,10 +31,6 @@ function toDto(g: Record<string, unknown>, baseUrl: string) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBaseUrl() {
|
|
||||||
return process.env.R2_PUBLIC_URL || `https://pub-${process.env.R2_ACCOUNT_ID}.r2.dev`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /api/garments/[id]
|
// GET /api/garments/[id]
|
||||||
export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
const auth = await requireFamily();
|
const auth = await requireFamily();
|
||||||
|
|
@ -47,7 +47,7 @@ export async function GET(_req: NextRequest, { params }: { params: Promise<{ id:
|
||||||
WHERE garment_id = ${id} AND family_id = ${familyId}
|
WHERE garment_id = ${id} AND family_id = ${familyId}
|
||||||
ORDER BY worn_on DESC`;
|
ORDER BY worn_on DESC`;
|
||||||
|
|
||||||
return NextResponse.json({ success: true, item: toDto(rows[0], getBaseUrl()), wears });
|
return NextResponse.json({ success: true, item: toDto(rows[0]), wears });
|
||||||
}
|
}
|
||||||
|
|
||||||
// PATCH /api/garments/[id]
|
// PATCH /api/garments/[id]
|
||||||
|
|
@ -92,7 +92,7 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id
|
||||||
WHERE id = ${id} AND family_id = ${familyId}
|
WHERE id = ${id} AND family_id = ${familyId}
|
||||||
RETURNING *`;
|
RETURNING *`;
|
||||||
|
|
||||||
return NextResponse.json({ success: true, item: toDto(rows[0], getBaseUrl()) });
|
return NextResponse.json({ success: true, item: toDto(rows[0]) });
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE /api/garments/[id]
|
// DELETE /api/garments/[id]
|
||||||
|
|
|
||||||
|
|
@ -70,11 +70,9 @@ export async function GET(req: NextRequest) {
|
||||||
ORDER BY created_at DESC`;
|
ORDER BY created_at DESC`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseUrl = process.env.R2_PUBLIC_URL || `https://pub-${process.env.R2_ACCOUNT_ID}.r2.dev`;
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
items: rows.map(g => toDto(g, baseUrl)),
|
items: rows.map(g => toDto(g)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -132,11 +130,13 @@ export async function POST(req: NextRequest) {
|
||||||
${visionMetadata ? JSON.stringify(visionMetadata) : null})
|
${visionMetadata ? JSON.stringify(visionMetadata) : null})
|
||||||
RETURNING *`;
|
RETURNING *`;
|
||||||
|
|
||||||
const baseUrl = process.env.R2_PUBLIC_URL || `https://pub-${process.env.R2_ACCOUNT_ID}.r2.dev`;
|
return NextResponse.json({ success: true, item: toDto(rows[0]) }, { status: 201 });
|
||||||
return NextResponse.json({ success: true, item: toDto(rows[0], baseUrl) }, { status: 201 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toDto(g: Record<string, unknown>, baseUrl: string) {
|
function toDto(g: Record<string, unknown>) {
|
||||||
|
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "";
|
||||||
|
const ik = g.image_key as string | null;
|
||||||
|
const tk = g.thumb_key as string | null;
|
||||||
return {
|
return {
|
||||||
id: g.id,
|
id: g.id,
|
||||||
familyId: g.family_id,
|
familyId: g.family_id,
|
||||||
|
|
@ -147,10 +147,11 @@ function toDto(g: Record<string, unknown>, baseUrl: string) {
|
||||||
colors: g.colors || [],
|
colors: g.colors || [],
|
||||||
seasons: g.seasons || [],
|
seasons: g.seasons || [],
|
||||||
occasionTags: g.occasion_tags || [],
|
occasionTags: g.occasion_tags || [],
|
||||||
imageKey: g.image_key,
|
imageKey: ik,
|
||||||
thumbKey: g.thumb_key,
|
thumbKey: tk,
|
||||||
imageUrl: `${baseUrl}/${g.image_key}`,
|
// Proxy through /api/img — never expose raw pub-*.r2.dev URLs (blocked by Cloudflare Bot Mgmt)
|
||||||
thumbUrl: `${baseUrl}/${g.thumb_key}`,
|
imageUrl: ik ? `${appUrl}/api/img?key=${encodeURIComponent(ik)}` : null,
|
||||||
|
thumbUrl: tk ? `${appUrl}/api/img?key=${encodeURIComponent(tk)}` : null,
|
||||||
status: g.status,
|
status: g.status,
|
||||||
acquiredVia: g.acquired_via,
|
acquiredVia: g.acquired_via,
|
||||||
giftFrom: g.gift_from,
|
giftFrom: g.gift_from,
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,17 @@ import { randomUUID } from "crypto";
|
||||||
const ALLOWED_TYPES = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/heic"];
|
const ALLOWED_TYPES = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/heic"];
|
||||||
const MAX_BYTES = 8 * 1024 * 1024; // 8MB
|
const MAX_BYTES = 8 * 1024 * 1024; // 8MB
|
||||||
|
|
||||||
|
/** Android often returns file.type="" or "application/octet-stream" — infer from extension. */
|
||||||
|
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",
|
||||||
|
};
|
||||||
|
return map[ext] || "image/jpeg";
|
||||||
|
}
|
||||||
|
|
||||||
function getR2Config() {
|
function getR2Config() {
|
||||||
return {
|
return {
|
||||||
accountId: process.env.R2_ACCOUNT_ID!,
|
accountId: process.env.R2_ACCOUNT_ID!,
|
||||||
|
|
@ -50,7 +61,8 @@ export async function POST(req: NextRequest) {
|
||||||
const file = formData.get("file") as File | null;
|
const file = formData.get("file") as File | null;
|
||||||
if (!file) return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
if (!file) return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
||||||
|
|
||||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
const contentType = resolveContentType(file);
|
||||||
|
if (!ALLOWED_TYPES.includes(contentType)) {
|
||||||
return NextResponse.json({ error: "Unsupported file type" }, { status: 400 });
|
return NextResponse.json({ error: "Unsupported file type" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -78,7 +90,7 @@ export async function POST(req: NextRequest) {
|
||||||
Bucket: R2.bucket,
|
Bucket: R2.bucket,
|
||||||
Key: imageKey,
|
Key: imageKey,
|
||||||
Body: originalBuffer,
|
Body: originalBuffer,
|
||||||
ContentType: file.type,
|
ContentType: contentType,
|
||||||
})),
|
})),
|
||||||
client.send(new PutObjectCommand({
|
client.send(new PutObjectCommand({
|
||||||
Bucket: R2.bucket,
|
Bucket: R2.bucket,
|
||||||
|
|
@ -88,12 +100,12 @@ export async function POST(req: NextRequest) {
|
||||||
})),
|
})),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const baseUrl = R2.publicUrl || `https://pub-${R2.accountId}.r2.dev`;
|
// Return proxy URLs (never raw R2 pub URLs — those are blocked by Cloudflare Bot Management)
|
||||||
|
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "";
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
imageKey,
|
imageKey,
|
||||||
thumbKey,
|
thumbKey,
|
||||||
imageUrl: `${baseUrl}/${imageKey}`,
|
imageUrl: `${appUrl}/api/img?key=${encodeURIComponent(imageKey)}`,
|
||||||
thumbUrl: `${baseUrl}/${thumbKey}`,
|
thumbUrl: `${appUrl}/api/img?key=${encodeURIComponent(thumbKey)}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
27
src/app/api/healthz/route.ts
Normal file
27
src/app/api/healthz/route.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { sql } from "@/db";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/healthz — public, unauthenticated liveness probe.
|
||||||
|
*
|
||||||
|
* For external monitors (Uptime Kuma) and the Docker / Dokploy healthcheck.
|
||||||
|
* Returns NO sensitive detail — only whether the app can reach the database.
|
||||||
|
* 200 { status: "ok", db: true }
|
||||||
|
* 503 { status: "down", db: false }
|
||||||
|
*/
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
await sql`SELECT 1`;
|
||||||
|
return NextResponse.json(
|
||||||
|
{ status: "ok", db: true, ts: new Date().toISOString() },
|
||||||
|
{ headers: { "Cache-Control": "no-store" } },
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ status: "down", db: false, ts: new Date().toISOString() },
|
||||||
|
{ status: 503, headers: { "Cache-Control": "no-store" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
50
src/app/api/img/route.ts
Normal file
50
src/app/api/img/route.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
const ALLOWED_PREFIXES = ["avatars/", "profiles/", "memories/", "thumbnails/", "families/", "garments/"];
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const key = req.nextUrl.searchParams.get("key");
|
||||||
|
if (!key) return NextResponse.json({ error: "key required" }, { status: 400 });
|
||||||
|
|
||||||
|
// Only proxy our own R2 objects
|
||||||
|
if (!ALLOWED_PREFIXES.some(p => key.startsWith(p))) {
|
||||||
|
return NextResponse.json({ error: "Invalid key" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountId = process.env.R2_ACCOUNT_ID;
|
||||||
|
const accessKeyId = process.env.R2_ACCESS_KEY_ID;
|
||||||
|
const secretKey = process.env.R2_SECRET_ACCESS_KEY;
|
||||||
|
const bucket = process.env.R2_BUCKET_NAME;
|
||||||
|
|
||||||
|
if (!accountId || !accessKeyId || !secretKey || !bucket) {
|
||||||
|
return NextResponse.json({ error: "Storage not configured" }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new S3Client({
|
||||||
|
region: "auto",
|
||||||
|
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
|
||||||
|
credentials: { accessKeyId, secretAccessKey: secretKey },
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const obj = await client.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
|
||||||
|
if (!obj.Body) return new NextResponse(null, { status: 404 });
|
||||||
|
|
||||||
|
const bytes = await (obj.Body as any).transformToByteArray();
|
||||||
|
return new NextResponse(bytes, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": obj.ContentType || "image/jpeg",
|
||||||
|
"Cache-Control": "public, max-age=604800, immutable",
|
||||||
|
...(obj.ContentLength ? { "Content-Length": String(obj.ContentLength) } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.name === "NoSuchKey" || e?.$metadata?.httpStatusCode === 404) {
|
||||||
|
return new NextResponse(null, { status: 404 });
|
||||||
|
}
|
||||||
|
console.error("R2 img proxy error:", e);
|
||||||
|
return new NextResponse(null, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -84,10 +84,12 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark as processing and start the media pipeline
|
// Mark as processing and start the media pipeline.
|
||||||
|
// thumbnail failure is non-fatal — always proceed to vision, which marks 'ready'.
|
||||||
await sql`UPDATE memories SET processing_status = 'processing', updated_at = now() WHERE id = ${id}`;
|
await sql`UPDATE memories SET processing_status = 'processing', updated_at = now() WHERE id = ${id}`;
|
||||||
|
|
||||||
generateThumbnail(id)
|
generateThumbnail(id)
|
||||||
|
.catch(e => console.error(`[thumbnail] id=${id}`, e)) // swallow so vision always runs
|
||||||
.then(() => processMemoryVision(id))
|
.then(() => processMemoryVision(id))
|
||||||
.catch(e => console.error(`[memory pipeline] id=${id}`, e));
|
.catch(e => console.error(`[memory pipeline] id=${id}`, e));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { requireFamily } from "@/lib/auth";
|
import { requireFamily } from "@/lib/auth";
|
||||||
import { sql } from "@/db";
|
import { sql } from "@/db";
|
||||||
|
import { toProxyUrl } from "@/lib/r2-proxy";
|
||||||
|
|
||||||
function getBaseUrl() {
|
function getBaseUrl() {
|
||||||
const pub = process.env.R2_PUBLIC_URL;
|
const pub = process.env.R2_PUBLIC_URL;
|
||||||
|
|
@ -9,11 +10,13 @@ function getBaseUrl() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function toMemoryDto(m: Record<string, unknown>, baseUrl: string) {
|
function toMemoryDto(m: Record<string, unknown>, baseUrl: string) {
|
||||||
|
const rawUrl = `${baseUrl}/${m.r2_key}`;
|
||||||
|
const rawThumb = m.r2_thumbnail_key ? `${baseUrl}/${m.r2_thumbnail_key}` : null;
|
||||||
return {
|
return {
|
||||||
id: m.id,
|
id: m.id,
|
||||||
key: m.r2_key,
|
key: m.r2_key,
|
||||||
url: `${baseUrl}/${m.r2_key}`,
|
url: toProxyUrl(rawUrl) ?? rawUrl,
|
||||||
thumbnailUrl: m.r2_thumbnail_key ? `${baseUrl}/${m.r2_thumbnail_key}` : null,
|
thumbnailUrl: toProxyUrl(rawThumb),
|
||||||
sizeBytes: m.size_bytes,
|
sizeBytes: m.size_bytes,
|
||||||
mimeType: m.mime_type,
|
mimeType: m.mime_type,
|
||||||
title: m.title,
|
title: m.title,
|
||||||
|
|
@ -39,15 +42,52 @@ export async function GET(req: NextRequest) {
|
||||||
const limit = Math.min(parseInt(searchParams.get("limit") || "30"), 100);
|
const limit = Math.min(parseInt(searchParams.get("limit") || "30"), 100);
|
||||||
const cursor = searchParams.get("cursor"); // ISO timestamp for pagination
|
const cursor = searchParams.get("cursor"); // ISO timestamp for pagination
|
||||||
|
|
||||||
|
// Orphaned row definition:
|
||||||
|
// - processing_status = 'failed' → confirm step ran, HeadObject found nothing in R2
|
||||||
|
// - processing_status = 'uploading' older than 30 min → browser closed/connection dropped
|
||||||
|
// before the upload and confirm steps completed; file never reached R2.
|
||||||
|
// These rows have no R2 object behind them. Delete them silently so they stop
|
||||||
|
// inflating the count and making the grid look broken.
|
||||||
|
sql`
|
||||||
|
DELETE FROM memories
|
||||||
|
WHERE family_id = ${familyId}
|
||||||
|
AND (
|
||||||
|
processing_status = 'failed'
|
||||||
|
OR (processing_status = 'uploading' AND created_at < NOW() - INTERVAL '30 minutes')
|
||||||
|
)
|
||||||
|
`.catch(() => {}); // fire-and-forget, don't block the response
|
||||||
|
|
||||||
let rows;
|
let rows;
|
||||||
if (childId) {
|
if (childId) {
|
||||||
rows = cursor
|
rows = cursor
|
||||||
? await sql`SELECT * FROM memories WHERE family_id = ${familyId} AND child_id = ${childId} AND created_at < ${cursor} ORDER BY created_at DESC LIMIT ${limit}`
|
? await sql`
|
||||||
: await sql`SELECT * FROM memories WHERE family_id = ${familyId} AND child_id = ${childId} ORDER BY created_at DESC LIMIT ${limit}`;
|
SELECT * FROM memories
|
||||||
|
WHERE family_id = ${familyId} AND child_id = ${childId}
|
||||||
|
AND created_at < ${cursor}
|
||||||
|
AND processing_status NOT IN ('failed')
|
||||||
|
AND (processing_status != 'uploading' OR created_at > NOW() - INTERVAL '30 minutes')
|
||||||
|
ORDER BY created_at DESC LIMIT ${limit}`
|
||||||
|
: await sql`
|
||||||
|
SELECT * FROM memories
|
||||||
|
WHERE family_id = ${familyId} AND child_id = ${childId}
|
||||||
|
AND processing_status NOT IN ('failed')
|
||||||
|
AND (processing_status != 'uploading' OR created_at > NOW() - INTERVAL '30 minutes')
|
||||||
|
ORDER BY created_at DESC LIMIT ${limit}`;
|
||||||
} else {
|
} else {
|
||||||
rows = cursor
|
rows = cursor
|
||||||
? await sql`SELECT * FROM memories WHERE family_id = ${familyId} AND created_at < ${cursor} ORDER BY created_at DESC LIMIT ${limit}`
|
? await sql`
|
||||||
: await sql`SELECT * FROM memories WHERE family_id = ${familyId} ORDER BY created_at DESC LIMIT ${limit}`;
|
SELECT * FROM memories
|
||||||
|
WHERE family_id = ${familyId}
|
||||||
|
AND created_at < ${cursor}
|
||||||
|
AND processing_status NOT IN ('failed')
|
||||||
|
AND (processing_status != 'uploading' OR created_at > NOW() - INTERVAL '30 minutes')
|
||||||
|
ORDER BY created_at DESC LIMIT ${limit}`
|
||||||
|
: await sql`
|
||||||
|
SELECT * FROM memories
|
||||||
|
WHERE family_id = ${familyId}
|
||||||
|
AND processing_status NOT IN ('failed')
|
||||||
|
AND (processing_status != 'uploading' OR created_at > NOW() - INTERVAL '30 minutes')
|
||||||
|
ORDER BY created_at DESC LIMIT ${limit}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseUrl = getBaseUrl();
|
const baseUrl = getBaseUrl();
|
||||||
|
|
|
||||||
29
src/app/api/notifications/[id]/route.ts
Normal file
29
src/app/api/notifications/[id]/route.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { sql } from "@/db";
|
||||||
|
import { requireFamily } from "@/lib/auth";
|
||||||
|
|
||||||
|
// PATCH /api/notifications/[id] — mark a single notification as read
|
||||||
|
export async function PATCH(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const auth = await requireFamily();
|
||||||
|
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||||
|
|
||||||
|
const familyId = auth.session!.familyId!;
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
// Only update rows belonging to this family (security check via family_id)
|
||||||
|
await sql`
|
||||||
|
UPDATE notifications
|
||||||
|
SET is_read = true
|
||||||
|
WHERE id = ${id} AND family_id = ${familyId}
|
||||||
|
`;
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Notification PATCH [id] error:", err);
|
||||||
|
return NextResponse.json({ error: String(err) }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,104 +1,259 @@
|
||||||
import { NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { sql } from "@/db";
|
import { sql } from "@/db";
|
||||||
import { requireFamily, requireOwnership } from "@/lib/auth";
|
import { requireFamily } from "@/lib/auth";
|
||||||
|
|
||||||
// IAP Vaccination Schedule (weeks from birth)
|
// ─── IST helpers (server-side) ────────────────────────────────────────────────
|
||||||
|
const IST_OFFSET_MS = 5.5 * 60 * 60 * 1000; // UTC +5:30
|
||||||
|
|
||||||
|
function getISTDate(): string {
|
||||||
|
return new Date().toLocaleDateString("sv-SE", { timeZone: "Asia/Kolkata" }); // YYYY-MM-DD
|
||||||
|
}
|
||||||
|
|
||||||
|
function getISTHour(): number {
|
||||||
|
const str = new Intl.DateTimeFormat("en-IN", { timeZone: "Asia/Kolkata", hour: "numeric", hour12: false }).format(new Date());
|
||||||
|
const h = parseInt(str, 10);
|
||||||
|
return h === 24 ? 0 : h;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Monday of the current IST week (for weekly garment nudge slot) */
|
||||||
|
function getISTWeekMonday(): string {
|
||||||
|
const istNow = new Date(Date.now() + IST_OFFSET_MS);
|
||||||
|
const dow = istNow.getUTCDay(); // 0=Sun … 6=Sat
|
||||||
|
const mon = new Date(istNow.getTime() - ((dow === 0 ? 6 : dow - 1) * 86_400_000));
|
||||||
|
return mon.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── IAP vaccine schedule ─────────────────────────────────────────────────────
|
||||||
const IAP_SCHEDULE = [
|
const IAP_SCHEDULE = [
|
||||||
{ name: "BCG", weeks: 0 },
|
{ name: "BCG", weeks: 0 },
|
||||||
{ name: "OPV-0", weeks: 0 },
|
{ name: "OPV-0", weeks: 0 },
|
||||||
{ name: "HepB-1", weeks: 0 },
|
{ name: "HepB-1", weeks: 0 },
|
||||||
{ name: "OPV-1", weeks: 6 },
|
{ name: "OPV-1", weeks: 6 },
|
||||||
{ name: "Pentavalent-1", weeks: 6 },
|
{ name: "Pentavalent-1", weeks: 6 },
|
||||||
{ name: "PCV-1", weeks: 6 },
|
{ name: "PCV-1", weeks: 6 },
|
||||||
{ name: "Rota-1", weeks: 6 },
|
{ name: "Rota-1", weeks: 6 },
|
||||||
{ name: "OPV-2", weeks: 10 },
|
{ name: "OPV-2", weeks: 10 },
|
||||||
{ name: "Pentavalent-2", weeks: 10 },
|
{ name: "Pentavalent-2", weeks: 10 },
|
||||||
{ name: "PCV-2", weeks: 10 },
|
{ name: "PCV-2", weeks: 10 },
|
||||||
{ name: "Rota-2", weeks: 10 },
|
{ name: "Rota-2", weeks: 10 },
|
||||||
{ name: "OPV-3", weeks: 14 },
|
{ name: "OPV-3", weeks: 14 },
|
||||||
{ name: "Pentavalent-3", weeks: 14 },
|
{ name: "Pentavalent-3", weeks: 14 },
|
||||||
{ name: "PCV-3", weeks: 14 },
|
{ name: "PCV-3", weeks: 14 },
|
||||||
{ name: "Rota-3", weeks: 14 },
|
{ name: "Rota-3", weeks: 14 },
|
||||||
{ name: "MR-1", weeks: 48 },
|
{ name: "MR-1", weeks: 48 },
|
||||||
{ name: "JE-1", weeks: 48 },
|
{ name: "JE-1", weeks: 48 },
|
||||||
{ name: "Vitamin A-1", weeks: 48 },
|
{ name: "Vitamin A-1", weeks: 48 },
|
||||||
{ name: "OPV-4", weeks: 48 },
|
{ name: "OPV-4", weeks: 48 },
|
||||||
{ name: "MR-2", weeks: 96 },
|
{ name: "MR-2", weeks: 96 },
|
||||||
{ name: "JE-2", weeks: 96 },
|
{ name: "JE-2", weeks: 96 },
|
||||||
{ name: "DPT-Booster-1", weeks: 96 },
|
{ name: "DPT-Booster-1", weeks: 96 },
|
||||||
{ name: "Vitamin A-2", weeks: 96 },
|
{ name: "Vitamin A-2", weeks: 96 },
|
||||||
{ name: "OPV-5", weeks: 96 },
|
{ name: "OPV-5", weeks: 96 },
|
||||||
{ name: "DPT-Booster-2", weeks: 208 },
|
{ name: "DPT-Booster-2", weeks: 208 },
|
||||||
{ name: "Tetanus and adult diphtheria (Td)", weeks: 208 },
|
{ name: "Td", weeks: 208 },
|
||||||
];
|
];
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
/** Safe type code for a vaccine name (no slashes or spaces) */
|
||||||
|
function vaccineType(name: string) {
|
||||||
|
return `vaccine_${name.replace(/[^a-zA-Z0-9-]/g, "_")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── GET — generate today's notifications then return all ────────────────────
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const auth = await requireFamily();
|
const auth = await requireFamily();
|
||||||
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
|
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||||
|
|
||||||
|
const familyId = auth.session!.familyId!;
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const childId = searchParams.get("childId");
|
const childId = searchParams.get("childId");
|
||||||
|
|
||||||
if (!childId) {
|
if (!childId) return NextResponse.json({ error: "childId required" }, { status: 400 });
|
||||||
return NextResponse.json({ error: "childId required" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const ownership = await requireOwnership(childId, "children", "Child");
|
const istDate = getISTDate();
|
||||||
if (!ownership.success) return NextResponse.json({ error: ownership.error }, { status: ownership.status });
|
const istHour = getISTHour();
|
||||||
|
const weekSlot = getISTWeekMonday();
|
||||||
|
|
||||||
// Get child's birth date
|
// ── Fetch child info ──────────────────────────────────────────────────────
|
||||||
const children = await sql`
|
const children = await sql`
|
||||||
SELECT id, name, birth_date FROM children WHERE id = ${childId}
|
SELECT id, name, birth_date FROM children
|
||||||
|
WHERE id = ${childId} AND family_id = ${familyId}
|
||||||
`;
|
`;
|
||||||
|
if (!children[0]) return NextResponse.json({ notifications: [] });
|
||||||
|
const child = children[0];
|
||||||
|
const birthDate = new Date(child.birth_date as string);
|
||||||
|
|
||||||
if (!children || children.length === 0) {
|
// ── 1. Vaccine notifications ──────────────────────────────────────────────
|
||||||
return NextResponse.json({ notifications: [] });
|
|
||||||
}
|
|
||||||
|
|
||||||
const child = children[0];
|
|
||||||
const birthDate = new Date(child.birth_date);
|
|
||||||
const today = new Date();
|
|
||||||
today.setHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
// Get already given vaccines
|
|
||||||
const given = await sql`
|
const given = await sql`
|
||||||
SELECT vaccine_name, given_date FROM vaccinations
|
SELECT vaccine_name FROM vaccinations
|
||||||
WHERE child_id = ${childId} AND status = 'given'
|
WHERE child_id = ${childId} AND status = 'given'
|
||||||
`;
|
`;
|
||||||
const givenMap = new Set(given.map((v: any) => v.vaccine_name));
|
const givenSet = new Set((given as unknown as { vaccine_name: string }[]).map(r => r.vaccine_name));
|
||||||
|
|
||||||
// Calculate upcoming vaccine notifications
|
const today = new Date(istDate + "T00:00:00Z"); // IST midnight as Date for comparison
|
||||||
const notifications = [];
|
|
||||||
for (const vaccine of IAP_SCHEDULE) {
|
|
||||||
if (givenMap.has(vaccine.name)) continue;
|
|
||||||
|
|
||||||
// Calculate due date
|
for (const v of IAP_SCHEDULE) {
|
||||||
const dueDate = new Date(birthDate);
|
if (givenSet.has(v.name)) continue;
|
||||||
dueDate.setDate(dueDate.getDate() + vaccine.weeks * 7);
|
|
||||||
dueDate.setHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
const diffDays = Math.floor((dueDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
|
const dueDate = new Date(birthDate.getTime() + v.weeks * 7 * 86_400_000);
|
||||||
|
const dueDateStr = dueDate.toISOString().slice(0, 10);
|
||||||
|
|
||||||
// Notify if due today or overdue
|
// Only notify if due today or overdue
|
||||||
if (diffDays <= 0) {
|
if (dueDateStr > istDate) continue;
|
||||||
notifications.push({
|
|
||||||
id: `vaccine-${vaccine.name}`,
|
const diffDays = Math.round((today.getTime() - dueDate.getTime()) / 86_400_000);
|
||||||
type: "vaccine",
|
const isToday = diffDays === 0;
|
||||||
title: diffDays === 0 ? "Vaccine Due Today" : "Vaccine Overdue",
|
|
||||||
message: `${vaccine.name} is ${diffDays === 0 ? "due today" : `${Math.abs(diffDays)} days overdue`}`,
|
await sql`
|
||||||
vaccineName: vaccine.name,
|
INSERT INTO notifications
|
||||||
dueDate: dueDate.toISOString().split("T")[0],
|
(family_id, child_id, type, title, message, action_url, scheduled_for, metadata)
|
||||||
status: diffDays === 0 ? "due_today" : "overdue",
|
VALUES (
|
||||||
childId,
|
${familyId}, ${childId},
|
||||||
childName: child.name,
|
${vaccineType(v.name)},
|
||||||
});
|
${isToday ? "Vaccine due today" : "Vaccine overdue"},
|
||||||
|
${isToday
|
||||||
|
? `${v.name} is due today`
|
||||||
|
: `${v.name} is ${diffDays} day${diffDays === 1 ? "" : "s"} overdue`},
|
||||||
|
'/medical',
|
||||||
|
${dueDateStr}::date,
|
||||||
|
${JSON.stringify({ vaccineName: v.name, dueDate: dueDateStr })}::jsonb
|
||||||
|
)
|
||||||
|
ON CONFLICT (family_id, child_id, type, scheduled_for) DO UPDATE SET
|
||||||
|
title = EXCLUDED.title,
|
||||||
|
message = EXCLUDED.message,
|
||||||
|
action_url = EXCLUDED.action_url,
|
||||||
|
metadata = EXCLUDED.metadata
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. Log nudge — no activity today past noon IST ────────────────────────
|
||||||
|
if (istHour >= 12) {
|
||||||
|
const logCount = await sql`
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*) FROM feeds WHERE child_id = ${childId} AND (logged_at AT TIME ZONE 'Asia/Kolkata')::date = ${istDate}::date) +
|
||||||
|
(SELECT COUNT(*) FROM diapers_logs WHERE child_id = ${childId} AND (logged_at AT TIME ZONE 'Asia/Kolkata')::date = ${istDate}::date) +
|
||||||
|
(SELECT COUNT(*) FROM sleeps WHERE child_id = ${childId} AND (logged_at AT TIME ZONE 'Asia/Kolkata')::date = ${istDate}::date)
|
||||||
|
AS total
|
||||||
|
`;
|
||||||
|
if (Number((logCount[0] as { total: string }).total) === 0) {
|
||||||
|
await sql`
|
||||||
|
INSERT INTO notifications
|
||||||
|
(family_id, child_id, type, title, message, action_url, scheduled_for)
|
||||||
|
VALUES (
|
||||||
|
${familyId}, ${childId}, 'log_nudge',
|
||||||
|
${'No activity logged yet today'},
|
||||||
|
${`How is ${child.name as string} doing? Log a feed, diaper change, or sleep session`},
|
||||||
|
'/activity',
|
||||||
|
${istDate}::date
|
||||||
|
)
|
||||||
|
ON CONFLICT (family_id, child_id, type, scheduled_for) DO NOTHING
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ notifications });
|
// ── 3. Memory nudge — no photo shared today ───────────────────────────────
|
||||||
} catch (error) {
|
const memCount = await sql`
|
||||||
console.error("Notifications error:", error);
|
SELECT COUNT(*) AS total FROM memories
|
||||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
WHERE family_id = ${familyId}
|
||||||
|
AND (created_at AT TIME ZONE 'Asia/Kolkata')::date = ${istDate}::date
|
||||||
|
`;
|
||||||
|
if (Number((memCount[0] as { total: string }).total) === 0) {
|
||||||
|
await sql`
|
||||||
|
INSERT INTO notifications
|
||||||
|
(family_id, child_id, type, title, message, action_url, scheduled_for)
|
||||||
|
VALUES (
|
||||||
|
${familyId}, ${childId}, 'memory_nudge',
|
||||||
|
${'Capture a moment today 📸'},
|
||||||
|
${`Add a photo of ${child.name as string} to your memories — your future self will thank you`},
|
||||||
|
'/memories',
|
||||||
|
${istDate}::date
|
||||||
|
)
|
||||||
|
ON CONFLICT (family_id, child_id, type, scheduled_for) DO NOTHING
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. Garment nudge — wardrobe has fewer than 10 items (weekly) ──────────
|
||||||
|
const garmentCount = await sql`
|
||||||
|
SELECT COUNT(*) AS total FROM garments
|
||||||
|
WHERE child_id = ${childId} AND status = 'active'
|
||||||
|
`;
|
||||||
|
if (Number((garmentCount[0] as { total: string }).total) < 10) {
|
||||||
|
await sql`
|
||||||
|
INSERT INTO notifications
|
||||||
|
(family_id, child_id, type, title, message, action_url, scheduled_for)
|
||||||
|
VALUES (
|
||||||
|
${familyId}, ${childId}, 'garment_nudge',
|
||||||
|
${'Build out the wardrobe 👚'},
|
||||||
|
${`Add clothes to ${child.name as string}'s wardrobe to unlock outfit suggestions`},
|
||||||
|
'/wardrobe/add',
|
||||||
|
${weekSlot}::date
|
||||||
|
)
|
||||||
|
ON CONFLICT (family_id, child_id, type, scheduled_for) DO NOTHING
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5. Return all notifications, filtering out given vaccines ─────────────
|
||||||
|
const rows = await sql`
|
||||||
|
SELECT n.*
|
||||||
|
FROM notifications n
|
||||||
|
WHERE n.family_id = ${familyId}
|
||||||
|
AND n.child_id = ${childId}
|
||||||
|
AND (
|
||||||
|
-- Non-vaccine rows: always include
|
||||||
|
n.type NOT LIKE 'vaccine_%'
|
||||||
|
OR
|
||||||
|
-- Vaccine rows: only if the vaccine hasn't been given
|
||||||
|
NOT EXISTS (
|
||||||
|
SELECT 1 FROM vaccinations v
|
||||||
|
WHERE v.child_id = n.child_id
|
||||||
|
AND v.status = 'given'
|
||||||
|
AND v.vaccine_name = n.metadata->>'vaccineName'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ORDER BY n.is_read ASC, n.created_at DESC
|
||||||
|
LIMIT 60
|
||||||
|
`;
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
notifications: (rows as Record<string, unknown>[]).map(r => ({
|
||||||
|
id: r.id,
|
||||||
|
type: r.type,
|
||||||
|
title: r.title,
|
||||||
|
message: r.message,
|
||||||
|
actionUrl: r.action_url,
|
||||||
|
isRead: r.is_read,
|
||||||
|
scheduledFor: r.scheduled_for,
|
||||||
|
createdAt: r.created_at,
|
||||||
|
metadata: r.metadata,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Notifications GET error:", err);
|
||||||
|
return NextResponse.json({ error: String(err) }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── PATCH — mark all notifications as read for this family+child ─────────────
|
||||||
|
export async function PATCH(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const auth = await requireFamily();
|
||||||
|
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||||
|
|
||||||
|
const familyId = auth.session!.familyId!;
|
||||||
|
const body = await request.json().catch(() => ({})) as { childId?: string };
|
||||||
|
const childId = body.childId;
|
||||||
|
|
||||||
|
if (!childId) return NextResponse.json({ error: "childId required" }, { status: 400 });
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
UPDATE notifications
|
||||||
|
SET is_read = true
|
||||||
|
WHERE family_id = ${familyId} AND child_id = ${childId} AND is_read = false
|
||||||
|
`;
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Notifications PATCH error:", err);
|
||||||
|
return NextResponse.json({ error: String(err) }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -22,9 +22,23 @@ export async function POST(request: Request) {
|
||||||
const userId = sessions[0].user_id;
|
const userId = sessions[0].user_id;
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { familyName, memberName, childName, birthDate, sex } = body;
|
const { familyName, memberName, phone, childName, birthDate, sex } = body;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Save the parent's name + optional phone onto their user record.
|
||||||
|
// Phone: keep leading + then digits; store null when blank/invalid.
|
||||||
|
let normalizedPhone: string | null = null;
|
||||||
|
if (phone && typeof phone === "string") {
|
||||||
|
const cleaned = phone.trim().replace(/[^\d+]/g, "").replace(/(?!^)\+/g, "");
|
||||||
|
const digits = cleaned.replace(/\D/g, "");
|
||||||
|
if (digits.length >= 8 && digits.length <= 15) normalizedPhone = cleaned;
|
||||||
|
}
|
||||||
|
await sql`
|
||||||
|
UPDATE users
|
||||||
|
SET name = COALESCE(${memberName || null}, name), phone = ${normalizedPhone}, updated_at = NOW()
|
||||||
|
WHERE id = ${userId}
|
||||||
|
`;
|
||||||
|
|
||||||
// Create family
|
// Create family
|
||||||
const familyId = crypto.randomUUID();
|
const familyId = crypto.randomUUID();
|
||||||
await sql.unsafe(
|
await sql.unsafe(
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,13 @@ export async function GET() {
|
||||||
const familyId = auth.session!.familyId!;
|
const familyId = auth.session!.familyId!;
|
||||||
const info = await getStorageInfo(familyId);
|
const info = await getStorageInfo(familyId);
|
||||||
|
|
||||||
|
// Both tiers now have a real cap (free 1 GB / premium 50 GB) — show actuals.
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
usedBytes: info.usedBytes,
|
usedBytes: info.usedBytes,
|
||||||
limitBytes: info.isPaid ? null : info.limitBytes,
|
limitBytes: info.limitBytes,
|
||||||
usedFormatted: formatBytes(info.usedBytes),
|
usedFormatted: formatBytes(info.usedBytes),
|
||||||
limitFormatted: info.isPaid ? "Unlimited" : formatBytes(info.limitBytes),
|
limitFormatted: formatBytes(info.limitBytes),
|
||||||
fraction: info.isPaid ? 0 : info.fraction,
|
fraction: info.fraction,
|
||||||
approaching: info.approaching,
|
approaching: info.approaching,
|
||||||
exceeded: info.exceeded,
|
exceeded: info.exceeded,
|
||||||
isPaid: info.isPaid,
|
isPaid: info.isPaid,
|
||||||
|
|
|
||||||
67
src/app/api/subscriptions/cancel/route.ts
Normal file
67
src/app/api/subscriptions/cancel/route.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { sql } from "@/db";
|
||||||
|
import { requireFamily } from "@/lib/auth";
|
||||||
|
import { getRazorpayConfig, razorpayAuthHeader, RAZORPAY_API_BASE } from "@/lib/billing/config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/subscriptions/cancel
|
||||||
|
*
|
||||||
|
* Initiates cancellation at Razorpay with cancel_at_cycle_end=1 — the family
|
||||||
|
* keeps premium until the paid period ends. The actual state change happens
|
||||||
|
* later via the subscription.cancelled webhook; this route only initiates.
|
||||||
|
*
|
||||||
|
* family_id from session (IDOR-safe). Cancels the family's own live sub only.
|
||||||
|
*/
|
||||||
|
export async function POST() {
|
||||||
|
const auth = await requireFamily();
|
||||||
|
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||||
|
|
||||||
|
const familyId = auth.session!.familyId!;
|
||||||
|
|
||||||
|
let cfg;
|
||||||
|
try {
|
||||||
|
cfg = getRazorpayConfig();
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json({ error: String(e) }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the family's live subscription.
|
||||||
|
const rows = await sql`
|
||||||
|
SELECT razorpay_subscription_id, status FROM family_subscriptions
|
||||||
|
WHERE family_id = ${familyId}
|
||||||
|
AND status IN ('created','authenticated','active','pending')
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
const subId = rows[0]?.razorpay_subscription_id as string | undefined;
|
||||||
|
if (!subId) {
|
||||||
|
return NextResponse.json({ error: "No active subscription to cancel" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${RAZORPAY_API_BASE}/subscriptions/${subId}/cancel`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: razorpayAuthHeader(cfg),
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ cancel_at_cycle_end: 1 }), // keep premium until period end
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error("Razorpay cancel failed:", data);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: data?.error?.description || "Failed to cancel subscription" },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Razorpay cancel error:", e);
|
||||||
|
return NextResponse.json({ error: "Failed to reach payment provider" }, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: "Subscription will cancel at the end of your current billing period. You keep premium until then.",
|
||||||
|
});
|
||||||
|
}
|
||||||
135
src/app/api/subscriptions/create/route.ts
Normal file
135
src/app/api/subscriptions/create/route.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { sql } from "@/db";
|
||||||
|
import { requireFamily } from "@/lib/auth";
|
||||||
|
import { getRazorpayConfig, razorpayAuthHeader, RAZORPAY_API_BASE } from "@/lib/billing/config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/subscriptions/create
|
||||||
|
*
|
||||||
|
* Creates a Razorpay subscription for the caller's family and returns the
|
||||||
|
* subscription_id + key_id for Razorpay Checkout. Grants NOTHING — entitlement
|
||||||
|
* is applied only by the webhook (subscription.charged/activated).
|
||||||
|
*
|
||||||
|
* Security:
|
||||||
|
* - family_id comes from the session (requireFamily), never from the request
|
||||||
|
* body — so a user can only create a sub for their own family (IDOR-safe).
|
||||||
|
* - key_secret never leaves the server; only key_id is returned.
|
||||||
|
*/
|
||||||
|
export async function POST() {
|
||||||
|
const auth = await requireFamily();
|
||||||
|
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||||
|
|
||||||
|
const familyId = auth.session!.familyId!;
|
||||||
|
const userId = auth.session!.userId;
|
||||||
|
|
||||||
|
let cfg;
|
||||||
|
try {
|
||||||
|
cfg = getRazorpayConfig();
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json({ error: String(e) }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the plan row (must be seeded — see /api/admin/seed-plan).
|
||||||
|
const planRows = await sql`
|
||||||
|
SELECT id, razorpay_plan_id FROM subscription_plans
|
||||||
|
WHERE razorpay_plan_id = ${cfg.planId} AND is_active = true
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
if (!planRows[0]) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Plan not seeded. Run POST /api/admin/seed-plan first." },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const planRowId = planRows[0].id as string;
|
||||||
|
|
||||||
|
// Inspect any existing non-terminal subscription for this family.
|
||||||
|
const live = await sql`
|
||||||
|
SELECT id, razorpay_subscription_id, status FROM family_subscriptions
|
||||||
|
WHERE family_id = ${familyId}
|
||||||
|
AND status IN ('created','authenticated','active','pending','halted')
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
if (live[0]) {
|
||||||
|
const status = live[0].status as string;
|
||||||
|
|
||||||
|
// Genuinely subscribed → block (they don't need a second subscription).
|
||||||
|
if (status === "authenticated" || status === "active" || status === "pending") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "You already have an active subscription.", status },
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abandoned checkout: a 'created' row whose payment never completed. The
|
||||||
|
// Razorpay subscription is still payable, so REUSE it — reopen checkout for
|
||||||
|
// the same sub instead of locking the user out forever.
|
||||||
|
if (status === "created") {
|
||||||
|
return NextResponse.json({
|
||||||
|
subscriptionId: live[0].razorpay_subscription_id,
|
||||||
|
keyId: cfg.keyId,
|
||||||
|
reused: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 'halted' = retries exhausted, family already downgraded to free. Let them
|
||||||
|
// re-subscribe: retire the halted row (leaves the partial unique index) so
|
||||||
|
// a fresh subscription can be created below.
|
||||||
|
if (status === "halted") {
|
||||||
|
await sql`
|
||||||
|
UPDATE family_subscriptions
|
||||||
|
SET status = 'expired', ended_at = COALESCE(ended_at, NOW()), updated_at = NOW()
|
||||||
|
WHERE id = ${live[0].id}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the subscription at Razorpay.
|
||||||
|
let rzpSub: { id?: string; status?: string; error?: { description?: string } };
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${RAZORPAY_API_BASE}/subscriptions`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: razorpayAuthHeader(cfg),
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
plan_id: cfg.planId,
|
||||||
|
total_count: 120, // ~10 yrs of monthly cycles; avoids auto-complete
|
||||||
|
customer_notify: 1,
|
||||||
|
quantity: 1,
|
||||||
|
notes: { family_id: familyId, user_id: userId }, // correlation backup for webhook
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
rzpSub = await res.json();
|
||||||
|
if (!res.ok || !rzpSub.id) {
|
||||||
|
console.error("Razorpay create sub failed:", rzpSub);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: rzpSub?.error?.description || "Failed to create subscription" },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Razorpay create sub error:", e);
|
||||||
|
return NextResponse.json({ error: "Failed to reach payment provider" }, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record our side as 'created'. The webhook drives all later state.
|
||||||
|
try {
|
||||||
|
await sql`
|
||||||
|
INSERT INTO family_subscriptions
|
||||||
|
(family_id, plan_id, razorpay_subscription_id, status)
|
||||||
|
VALUES (${familyId}, ${planRowId}, ${rzpSub.id}, 'created')
|
||||||
|
`;
|
||||||
|
} catch (e) {
|
||||||
|
// Unique index race (double-click) — treat as the existing-sub case.
|
||||||
|
console.error("Insert family_subscriptions failed:", e);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Subscription already in progress for this family." },
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ subscriptionId: rzpSub.id, keyId: cfg.keyId });
|
||||||
|
}
|
||||||
76
src/app/api/subscriptions/verify/route.ts
Normal file
76
src/app/api/subscriptions/verify/route.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import crypto from "crypto";
|
||||||
|
import { sql } from "@/db";
|
||||||
|
import { requireFamily } from "@/lib/auth";
|
||||||
|
import { getRazorpayConfig } from "@/lib/billing/config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/subscriptions/verify — UX feedback ONLY.
|
||||||
|
*
|
||||||
|
* Called by the Checkout success handler so we can show "you're in!". It verifies
|
||||||
|
* the checkout signature but GRANTS NOTHING — entitlement is applied solely by
|
||||||
|
* the webhook (/api/webhooks/razorpay). This route just confirms the handshake
|
||||||
|
* looks authentic so the UI can show an optimistic "activating shortly" screen.
|
||||||
|
*
|
||||||
|
* Subscription signature order is payment_id|subscription_id (NOT the order_id
|
||||||
|
* flavour). We HMAC against the subscription_id WE stored, not whatever the
|
||||||
|
* client sends, so a client can't verify against an arbitrary subscription.
|
||||||
|
*/
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const auth = await requireFamily();
|
||||||
|
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||||
|
|
||||||
|
const familyId = auth.session!.familyId!;
|
||||||
|
|
||||||
|
let body: { razorpay_payment_id?: string; razorpay_signature?: string };
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const paymentId = body.razorpay_payment_id;
|
||||||
|
const signature = body.razorpay_signature;
|
||||||
|
if (!paymentId || !signature) {
|
||||||
|
return NextResponse.json({ error: "Missing payment id or signature" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let cfg;
|
||||||
|
try {
|
||||||
|
cfg = getRazorpayConfig();
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json({ error: String(e) }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the subscription_id we recorded for this family — never the client's.
|
||||||
|
const rows = await sql`
|
||||||
|
SELECT razorpay_subscription_id FROM family_subscriptions
|
||||||
|
WHERE family_id = ${familyId}
|
||||||
|
AND status IN ('created','authenticated','active','pending')
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
const subId = rows[0]?.razorpay_subscription_id as string | undefined;
|
||||||
|
if (!subId) {
|
||||||
|
return NextResponse.json({ error: "No pending subscription found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// SUBSCRIPTION order: payment_id|subscription_id
|
||||||
|
const expected = crypto
|
||||||
|
.createHmac("sha256", cfg.keySecret)
|
||||||
|
.update(`${paymentId}|${subId}`)
|
||||||
|
.digest("hex");
|
||||||
|
|
||||||
|
const sigBuf = Buffer.from(signature);
|
||||||
|
const expBuf = Buffer.from(expected);
|
||||||
|
const valid = sigBuf.length === expBuf.length && crypto.timingSafeEqual(sigBuf, expBuf);
|
||||||
|
|
||||||
|
if (!valid) {
|
||||||
|
return NextResponse.json({ error: "Signature verification failed" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Looks authentic. Do NOT grant here — the webhook is the source of truth.
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: "Payment received — your premium is activating shortly.",
|
||||||
|
});
|
||||||
|
}
|
||||||
50
src/app/api/time/route.ts
Normal file
50
src/app/api/time/route.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
const TZ = "Asia/Kolkata";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/time
|
||||||
|
* Returns the server's current time in IST. The app can call this to verify
|
||||||
|
* that the user's device clock matches the server, and to use a trusted
|
||||||
|
* timestamp source for log entries if needed.
|
||||||
|
*
|
||||||
|
* No auth required — time is not sensitive.
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const istDate = now.toLocaleDateString("sv-SE", { timeZone: TZ }); // YYYY-MM-DD
|
||||||
|
const istTime = now.toLocaleTimeString("en-IN", {
|
||||||
|
timeZone: TZ,
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
});
|
||||||
|
const istLabel = now.toLocaleString("en-IN", {
|
||||||
|
timeZone: TZ,
|
||||||
|
weekday: "short",
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
utc: now.toISOString(), // e.g. "2026-05-28T09:00:00.000Z"
|
||||||
|
istDate, // e.g. "2026-05-28"
|
||||||
|
istTime, // e.g. "14:30:00"
|
||||||
|
ist: istLabel, // e.g. "Wed, 28 May 2026, 02:30 PM"
|
||||||
|
offsetMinutes: 330, // IST = UTC +5:30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
// Do not cache — always return live server time
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
204
src/app/api/webhooks/razorpay/route.ts
Normal file
204
src/app/api/webhooks/razorpay/route.ts
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import crypto from "crypto";
|
||||||
|
import { sql } from "@/db";
|
||||||
|
import { getRazorpayConfig } from "@/lib/billing/config";
|
||||||
|
import { grantPremium, revokeToFree } from "@/lib/billing/entitlements";
|
||||||
|
import { sendAlert } from "@/lib/alert";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/webhooks/razorpay — THE source of truth for entitlement.
|
||||||
|
*
|
||||||
|
* Razorpay calls this on every subscription lifecycle change. We:
|
||||||
|
* 1. Verify the HMAC signature (timing-safe) over the RAW body.
|
||||||
|
* 2. Idempotency: unique-insert on x-razorpay-event-id. Duplicate → 200 & bail.
|
||||||
|
* 3. Update family_subscriptions by razorpay_subscription_id.
|
||||||
|
* 4. Sync entitlement onto families.tier (grant/revoke) so the existing
|
||||||
|
* quota.ts guards enforce the new limits.
|
||||||
|
*
|
||||||
|
* Status codes (Razorpay retries on non-2xx):
|
||||||
|
* 400 — bad signature (do NOT retry)
|
||||||
|
* 200 — processed OK, or duplicate (already processed)
|
||||||
|
* 500 — processing error (Razorpay WILL retry — that's what we want)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Razorpay event → how it affects entitlement.
|
||||||
|
const GRANT_EVENTS: Record<string, string> = {
|
||||||
|
"subscription.authenticated": "authenticated",
|
||||||
|
"subscription.activated": "active",
|
||||||
|
"subscription.charged": "active",
|
||||||
|
"subscription.resumed": "active",
|
||||||
|
"subscription.pending": "pending", // grace — keep entitled
|
||||||
|
};
|
||||||
|
const REVOKE_EVENTS: Record<string, string> = {
|
||||||
|
"subscription.halted": "halted",
|
||||||
|
"subscription.cancelled": "cancelled",
|
||||||
|
"subscription.completed": "completed",
|
||||||
|
"subscription.expired": "expired",
|
||||||
|
"subscription.paused": "paused",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Razorpay sends unix seconds. Return an ISO STRING (not a Date) — postgres.js
|
||||||
|
// in this repo binds timestamps as strings via the custom serializer; passing a
|
||||||
|
// raw Date object throws ERR_INVALID_ARG_TYPE.
|
||||||
|
function unixToISO(v: unknown): string | null {
|
||||||
|
const n = typeof v === "number" ? v : Number(v);
|
||||||
|
return Number.isFinite(n) && n > 0 ? new Date(n * 1000).toISOString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
// RAW body — never req.json() here; the signature is over the exact bytes.
|
||||||
|
const rawBody = await req.text();
|
||||||
|
const signature = req.headers.get("x-razorpay-signature") ?? "";
|
||||||
|
const eventId = req.headers.get("x-razorpay-event-id") ?? "";
|
||||||
|
|
||||||
|
let cfg;
|
||||||
|
try {
|
||||||
|
cfg = getRazorpayConfig();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Razorpay webhook: not configured", e);
|
||||||
|
return new NextResponse("not configured", { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Verify signature (timing-safe).
|
||||||
|
const expected = crypto
|
||||||
|
.createHmac("sha256", cfg.webhookSecret)
|
||||||
|
.update(rawBody)
|
||||||
|
.digest("hex");
|
||||||
|
const sigBuf = Buffer.from(signature);
|
||||||
|
const expBuf = Buffer.from(expected);
|
||||||
|
const validSig = sigBuf.length === expBuf.length && crypto.timingSafeEqual(sigBuf, expBuf);
|
||||||
|
if (!validSig) {
|
||||||
|
return new NextResponse("bad signature", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!eventId) {
|
||||||
|
// No event id to dedupe on — refuse so Razorpay retries with headers.
|
||||||
|
return new NextResponse("missing event id", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let event: {
|
||||||
|
event?: string;
|
||||||
|
payload?: { subscription?: { entity?: Record<string, unknown> } };
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
event = JSON.parse(rawBody);
|
||||||
|
} catch {
|
||||||
|
return new NextResponse("bad json", { status: 400 });
|
||||||
|
}
|
||||||
|
const eventType = event.event ?? "unknown";
|
||||||
|
|
||||||
|
// 2. Audit log (best-effort, never blocks processing). We intentionally do
|
||||||
|
// NOT early-return on duplicate here: processing is idempotent (status
|
||||||
|
// UPDATE + grant/revoke are all upserts), so re-running a redelivered event
|
||||||
|
// is safe. Early-returning on duplicate BEFORE processing was a trap — a
|
||||||
|
// processing error left the event logged-but-unapplied and every retry hit
|
||||||
|
// the duplicate guard and skipped processing forever.
|
||||||
|
try {
|
||||||
|
await sql`
|
||||||
|
INSERT INTO razorpay_webhook_events (razorpay_event_id, event_type, payload)
|
||||||
|
VALUES (${eventId}, ${eventType}, ${rawBody}::jsonb)
|
||||||
|
ON CONFLICT (razorpay_event_id) DO NOTHING
|
||||||
|
`;
|
||||||
|
} catch (e) {
|
||||||
|
// Logging is not critical to entitlement — carry on and still process.
|
||||||
|
console.error("Razorpay webhook: log insert failed (continuing)", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3 + 4. Process the event. Errors here → 500 so Razorpay retries (and the
|
||||||
|
// retry WILL reprocess, because we no longer short-circuit on duplicate).
|
||||||
|
try {
|
||||||
|
const sub = event.payload?.subscription?.entity;
|
||||||
|
const subId = sub?.id as string | undefined;
|
||||||
|
|
||||||
|
// Events without a subscription entity (e.g. payment.* if ever subscribed)
|
||||||
|
// are logged above; nothing to sync.
|
||||||
|
if (!subId) return new NextResponse("ok (no subscription entity)", { status: 200 });
|
||||||
|
|
||||||
|
// Find our row for this Razorpay subscription.
|
||||||
|
const rows = await sql`
|
||||||
|
SELECT id, family_id FROM family_subscriptions
|
||||||
|
WHERE razorpay_subscription_id = ${subId}
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
const row = rows[0] as { id: string; family_id: string } | undefined;
|
||||||
|
if (!row) {
|
||||||
|
// Unknown subscription (e.g. created outside this app). Logged; ack so
|
||||||
|
// Razorpay stops retrying — there's nothing for us to update.
|
||||||
|
console.warn("Razorpay webhook: no family_subscriptions row for", subId);
|
||||||
|
return new NextResponse("ok (unknown subscription)", { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentStart = unixToISO(sub?.current_start);
|
||||||
|
const currentEnd = unixToISO(sub?.current_end);
|
||||||
|
const customerId = (sub?.customer_id as string) ?? null;
|
||||||
|
|
||||||
|
const grantStatus = GRANT_EVENTS[eventType];
|
||||||
|
const revokeStatus = REVOKE_EVENTS[eventType];
|
||||||
|
|
||||||
|
// Family name for alert context (best-effort).
|
||||||
|
const famRows = await sql`SELECT name FROM families WHERE id = ${row.family_id} LIMIT 1`;
|
||||||
|
const familyName = (famRows[0]?.name as string) || row.family_id.slice(0, 8);
|
||||||
|
|
||||||
|
if (grantStatus) {
|
||||||
|
await sql`
|
||||||
|
UPDATE family_subscriptions SET
|
||||||
|
status = ${grantStatus}::subscription_status_enum,
|
||||||
|
razorpay_customer_id = COALESCE(${customerId}, razorpay_customer_id),
|
||||||
|
current_start = COALESCE(${currentStart}, current_start),
|
||||||
|
current_end = COALESCE(${currentEnd}, current_end),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = ${row.id}
|
||||||
|
`;
|
||||||
|
// resumed→active; pending is grace (kept entitled) but a payment FAILED.
|
||||||
|
await grantPremium(row.family_id, grantStatus);
|
||||||
|
|
||||||
|
if (eventType === "subscription.pending") {
|
||||||
|
// A charge failed; Razorpay is retrying. Reach out before they churn.
|
||||||
|
await sendAlert("warn", "Payment failing (grace period)", undefined, {
|
||||||
|
fields: { Family: familyName, Subscription: subId, Status: "pending — retrying" },
|
||||||
|
});
|
||||||
|
} else if (eventType === "subscription.charged") {
|
||||||
|
await sendAlert("info", "💸 Subscription charged", undefined, {
|
||||||
|
fields: { Family: familyName, Subscription: subId },
|
||||||
|
silent: true,
|
||||||
|
});
|
||||||
|
} else if (eventType === "subscription.activated") {
|
||||||
|
await sendAlert("info", "🎉 New premium subscriber", undefined, {
|
||||||
|
fields: { Family: familyName, Subscription: subId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (revokeStatus) {
|
||||||
|
const nowIso = new Date().toISOString();
|
||||||
|
const endedAt = revokeStatus === "paused" ? null : nowIso;
|
||||||
|
const cancelledAt = revokeStatus === "cancelled" ? nowIso : null;
|
||||||
|
await sql`
|
||||||
|
UPDATE family_subscriptions SET
|
||||||
|
status = ${revokeStatus}::subscription_status_enum,
|
||||||
|
cancelled_at = ${cancelledAt},
|
||||||
|
ended_at = COALESCE(${endedAt}, ended_at),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = ${row.id}
|
||||||
|
`;
|
||||||
|
await revokeToFree(row.family_id, revokeStatus);
|
||||||
|
|
||||||
|
if (revokeStatus === "halted") {
|
||||||
|
// Retries exhausted — customer just churned involuntarily. Loud alert.
|
||||||
|
await sendAlert("error", "🔴 Subscription HALTED (churn)", "Payment retries exhausted — family downgraded to free.", {
|
||||||
|
fields: { Family: familyName, Subscription: subId },
|
||||||
|
});
|
||||||
|
} else if (revokeStatus === "cancelled") {
|
||||||
|
await sendAlert("warn", "Subscription cancelled", undefined, {
|
||||||
|
fields: { Family: familyName, Subscription: subId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Unhandled event type — already logged, ack it.
|
||||||
|
return new NextResponse("ok (unhandled event)", { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NextResponse("ok", { status: 200 });
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Razorpay webhook: processing error", e);
|
||||||
|
return new NextResponse("processing error", { status: 500 }); // retry
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/app/global-error.tsx
Normal file
54
src/app/global-error.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
// Root-level error boundary. Catches errors thrown in the root layout / during
|
||||||
|
// rendering that no nested error.tsx caught. Must render its own <html>/<body>.
|
||||||
|
// Reports the crash to /api/errors so it shows up in the admin error tracker.
|
||||||
|
export default function GlobalError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const body = JSON.stringify({
|
||||||
|
message: error?.message || "Unknown global error",
|
||||||
|
stack: error?.stack,
|
||||||
|
digest: error?.digest,
|
||||||
|
url: typeof window !== "undefined" ? window.location.pathname : undefined,
|
||||||
|
level: "fatal",
|
||||||
|
metadata: { boundary: "global" },
|
||||||
|
});
|
||||||
|
// keepalive so the report survives the page being torn down
|
||||||
|
fetch("/api/errors", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body,
|
||||||
|
keepalive: true,
|
||||||
|
}).catch(() => {});
|
||||||
|
} catch {}
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body style={{ fontFamily: "system-ui, sans-serif", background: "#fdf2f2", color: "#1a1a1a" }}>
|
||||||
|
<div style={{ minHeight: "100vh", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 16, padding: 24, textAlign: "center" }}>
|
||||||
|
<div style={{ fontSize: 40 }}>😵</div>
|
||||||
|
<h1 style={{ fontSize: 20, fontWeight: 700 }}>Something went wrong</h1>
|
||||||
|
<p style={{ color: "#6b7280", maxWidth: 360 }}>
|
||||||
|
The app hit an unexpected error. It's been reported automatically — please try again.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => reset()}
|
||||||
|
style={{ background: "#fb7185", color: "white", border: "none", padding: "10px 20px", borderRadius: 12, fontWeight: 600, cursor: "pointer" }}
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -45,6 +45,25 @@
|
||||||
animation: marquee 20s linear infinite;
|
animation: marquee 20s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── CTA button pulse ring ── */
|
||||||
|
@keyframes cta-pulse {
|
||||||
|
0% { transform: scale(1); opacity: 0.45; }
|
||||||
|
60% { transform: scale(1.18); opacity: 0.1; }
|
||||||
|
100% { transform: scale(1.22); opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-cta-pulse {
|
||||||
|
animation: cta-pulse 2.6s ease-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Editorial font utilities (marketing pages) ── */
|
||||||
|
.font-fraunces { font-family: var(--font-fraunces, Georgia, serif); }
|
||||||
|
.font-newsreader { font-family: var(--font-newsreader, Georgia, serif); }
|
||||||
|
.font-jetbrains { font-family: var(--font-jetbrains, ui-monospace, monospace); }
|
||||||
|
|
||||||
|
/* Explicit pin so footer links read clearly lighter than their headings */
|
||||||
|
.text-gray-500 { color: var(--color-gray-500); }
|
||||||
|
|
||||||
/* hide scrollbar but keep scroll */
|
/* hide scrollbar but keep scroll */
|
||||||
.scrollbar-hide {
|
.scrollbar-hide {
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
|
|
@ -59,3 +78,16 @@ body {
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Prevent mobile auto-zoom on input focus.
|
||||||
|
iOS Safari (and other mobile browsers) zoom the viewport whenever a focused
|
||||||
|
input/select/textarea has a font-size below 16px — which is what made the
|
||||||
|
home "Ask AI" popup (and other text-sm inputs) jump and distort the UI.
|
||||||
|
Enforce a 16px minimum on touch devices only; desktop styling is unchanged. */
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
input:not([type="checkbox"]):not([type="radio"]):not([type="range"]),
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import { Geist, Geist_Mono, Caveat } from "next/font/google";
|
import { Geist, Geist_Mono, Caveat } from "next/font/google";
|
||||||
|
import Script from "next/script";
|
||||||
|
import { SITE_URL, SITE_NAME } from "@/lib/seo";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
|
|
@ -18,17 +20,41 @@ const caveat = Caveat({
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Tia — Baby Tracker",
|
// Resolves all relative metadata URLs (OG/Twitter images, canonicals) to
|
||||||
description: "Track feeds, sleep, milestones and memories.",
|
// absolute URLs — required for social previews to work.
|
||||||
|
metadataBase: new URL(SITE_URL),
|
||||||
|
title: {
|
||||||
|
default: "Tia — Baby Tracker & Digital Heirloom",
|
||||||
|
template: `%s | ${SITE_NAME}`,
|
||||||
|
},
|
||||||
|
description:
|
||||||
|
"Track feeds, sleep, milestones, and memories. Tia is a privacy-first baby tracker and digital heirloom built for Indian families.",
|
||||||
|
applicationName: SITE_NAME,
|
||||||
|
authors: [{ name: "Tia" }],
|
||||||
|
creator: "Tia",
|
||||||
|
publisher: "Tia",
|
||||||
|
category: "parenting",
|
||||||
|
formatDetection: { telephone: false, email: false, address: false },
|
||||||
|
// apple-touch-icon is provided by the src/app/apple-icon.png file convention.
|
||||||
icons: {
|
icons: {
|
||||||
icon: "/icon.svg",
|
icon: "/icon.svg",
|
||||||
apple: "/apple-icon.png",
|
|
||||||
},
|
},
|
||||||
appleWebApp: {
|
appleWebApp: {
|
||||||
capable: true,
|
capable: true,
|
||||||
statusBarStyle: "default",
|
statusBarStyle: "default",
|
||||||
title: "Tia",
|
title: "Tia",
|
||||||
},
|
},
|
||||||
|
robots: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
googleBot: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
"max-image-preview": "large",
|
||||||
|
"max-snippet": -1,
|
||||||
|
"max-video-preview": -1,
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
|
|
@ -41,9 +67,17 @@ export default function RootLayout({
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en-IN" suppressHydrationWarning>
|
||||||
<body className={`${geistSans.variable} ${geistMono.variable} ${caveat.variable} min-h-full antialiased`}>
|
<body className={`${geistSans.variable} ${geistMono.variable} ${caveat.variable} min-h-full antialiased`}>
|
||||||
{children}
|
{children}
|
||||||
|
{/* Umami analytics — script served locally to avoid Cloudflare cross-origin 503.
|
||||||
|
data-host-url tells the script where to POST events (analytics.manohargupta.com). */}
|
||||||
|
<Script
|
||||||
|
src="/umami.js"
|
||||||
|
data-website-id="79444c19-ee31-4fab-baf5-f4e61098eeba"
|
||||||
|
data-host-url="https://analytics.manohargupta.com"
|
||||||
|
strategy="afterInteractive"
|
||||||
|
/>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,18 @@ import type { MetadataRoute } from "next";
|
||||||
|
|
||||||
export default function manifest(): MetadataRoute.Manifest {
|
export default function manifest(): MetadataRoute.Manifest {
|
||||||
return {
|
return {
|
||||||
name: "Tia — Baby Tracker",
|
name: "Tia — Baby Tracker & Digital Heirloom",
|
||||||
short_name: "Tia",
|
short_name: "Tia",
|
||||||
description: "Track feeds, sleep, milestones and memories.",
|
description:
|
||||||
start_url: "/?source=pwa",
|
"Track feeds, sleep, milestones, and memories. Tia is a privacy-first baby tracker and digital heirloom built for Indian families — with the IAP vaccination schedule and Telegram alerts.",
|
||||||
|
id: "/home",
|
||||||
|
start_url: "/home?source=pwa",
|
||||||
|
scope: "/",
|
||||||
display: "standalone",
|
display: "standalone",
|
||||||
|
orientation: "portrait",
|
||||||
|
lang: "en-IN",
|
||||||
|
dir: "ltr",
|
||||||
|
categories: ["health", "lifestyle", "parenting", "medical"],
|
||||||
background_color: "#fdf2f2",
|
background_color: "#fdf2f2",
|
||||||
theme_color: "#fb7185",
|
theme_color: "#fb7185",
|
||||||
icons: [
|
icons: [
|
||||||
|
|
|
||||||
41
src/app/robots.ts
Normal file
41
src/app/robots.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import type { MetadataRoute } from "next";
|
||||||
|
import { SITE_URL } from "@/lib/seo";
|
||||||
|
|
||||||
|
export default function robots(): MetadataRoute.Robots {
|
||||||
|
return {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
userAgent: "*",
|
||||||
|
allow: "/",
|
||||||
|
// Private app, auth, and admin areas — keep them out of the index.
|
||||||
|
disallow: [
|
||||||
|
"/api/",
|
||||||
|
"/admin",
|
||||||
|
"/admin-login",
|
||||||
|
"/login",
|
||||||
|
"/verify",
|
||||||
|
"/invite/",
|
||||||
|
"/onboarding",
|
||||||
|
"/home",
|
||||||
|
"/activity",
|
||||||
|
"/ai",
|
||||||
|
"/circle",
|
||||||
|
"/family",
|
||||||
|
"/growth",
|
||||||
|
"/medical",
|
||||||
|
"/memories",
|
||||||
|
"/menu",
|
||||||
|
"/milestones",
|
||||||
|
"/notifications",
|
||||||
|
"/profile",
|
||||||
|
"/settings",
|
||||||
|
"/wardrobe",
|
||||||
|
"/dev",
|
||||||
|
"/m/",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sitemap: `${SITE_URL}/sitemap.xml`,
|
||||||
|
host: SITE_URL,
|
||||||
|
};
|
||||||
|
}
|
||||||
38
src/app/sitemap.ts
Normal file
38
src/app/sitemap.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import type { MetadataRoute } from "next";
|
||||||
|
import { SITE_URL } from "@/lib/seo";
|
||||||
|
import { POSTS } from "@/app/(marketing)/blog/posts";
|
||||||
|
|
||||||
|
export default function sitemap(): MetadataRoute.Sitemap {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// Static marketing routes with curated priorities.
|
||||||
|
const staticRoutes: {
|
||||||
|
path: string;
|
||||||
|
changeFrequency: MetadataRoute.Sitemap[number]["changeFrequency"];
|
||||||
|
priority: number;
|
||||||
|
}[] = [
|
||||||
|
{ path: "/", changeFrequency: "weekly", priority: 1.0 },
|
||||||
|
{ path: "/pricing", changeFrequency: "monthly", priority: 0.8 },
|
||||||
|
{ path: "/about", changeFrequency: "monthly", priority: 0.7 },
|
||||||
|
{ path: "/blog", changeFrequency: "weekly", priority: 0.7 },
|
||||||
|
{ path: "/partners", changeFrequency: "monthly", priority: 0.5 },
|
||||||
|
{ path: "/privacy", changeFrequency: "yearly", priority: 0.3 },
|
||||||
|
{ path: "/terms", changeFrequency: "yearly", priority: 0.3 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const staticEntries: MetadataRoute.Sitemap = staticRoutes.map((r) => ({
|
||||||
|
url: `${SITE_URL}${r.path}`,
|
||||||
|
lastModified: now,
|
||||||
|
changeFrequency: r.changeFrequency,
|
||||||
|
priority: r.priority,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const blogEntries: MetadataRoute.Sitemap = POSTS.map((post) => ({
|
||||||
|
url: `${SITE_URL}/blog/${post.slug}`,
|
||||||
|
lastModified: new Date(post.date),
|
||||||
|
changeFrequency: "monthly",
|
||||||
|
priority: 0.6,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return [...staticEntries, ...blogEntries];
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue