Compare commits

..

161 commits

Author SHA1 Message Date
bf82ef3364 fix(admin): rupee formatting for avg revenue, per-tier storage bars
- Avg per Family now shows ₹ with en-IN locale instead of $
- Storage table: paid families show progress bar vs 25 GB limit (was "Unlimited")
- Column header changed from "% of free (1 GiB)" to "% of limit"
- Bar label shows "X% of 1 GiB" (free) or "X% of 25 GB" (paid)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 15:52:48 +05:30
4d29ef89a0 feat(admin): dunning, admin cancel, CSV, real revenue trend + churn
Subscriptions page:
- Dunning banner: lists 'pending' (grace) subs at top — failing payments to
  chase before they halt/churn
- Per-row admin Cancel button (cancel_at_cycle_end via Razorpay; any family) —
  POST /api/admin/subscriptions {action:"cancel", subscriptionId}
- Export CSV (family/plan/status/dates/rzp id/price), quoted
- Reconcile button now sends {action:"reconcile"}

Revenue page:
- Real monthly revenue chart from subscription.charged events (valued by plan
  price, grouped by IST month) — replaces the fabricated chart
- Churn rate card = cancelled+halted+expired ÷ ever-live subs (red if >10%)

subscriptions API: added revenueTrend + churn to GET; POST routes
reconcile|cancel actions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 15:32:16 +05:30
756f5d6cfb feat(admin/billing): grant-logic tier change, churn alerts, webhook health
#1 Admin tier change uses real grant logic:
- families PATCH calls grantPremium()/revokeToFree() on tier change instead of
  hardcoded maxMembers:10. Manual/comp upgrades now match real grant (50GB/6/3).
  subscription_status records 'admin_comp'/'admin_downgrade'. Explicit
  maxChildren/maxMembers overrides still honored. Client sends tier only.

#2 Failed-payment / churn Telegram alerts (webhook):
- subscription.pending  -> warn "Payment failing (grace)" — reach out pre-churn
- subscription.halted   -> error "Subscription HALTED (churn)"
- subscription.cancelled-> warn; activated -> "New subscriber"; charged -> silent
  All include family name + sub id.

#3 Webhook freshness in admin health:
- New "Razorpay Webhooks" check: last event age (Xm/Xh/Xd ago). warn if >35d
  silence while subs exist (renewals should keep it fresh). Also added a
  "Razorpay" config-presence check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 15:28:23 +05:30
fbcfff47bd feat(admin): subscriptions page, real ₹ revenue, family sub dates
Subscriptions monitoring (NEW):
- GET /api/admin/subscriptions: live subs (family+plan+status+dates+rzp id),
  recent webhook events, summary (status counts, MRR/ARR paise). POST = reconcile
- /admin/subscriptions page: summary cards, status chips, subs table, webhook
  log, one-click Reconcile button. Added to sidebar (💳)

Real revenue in INR (was mock $9.99):
- stats API: MRR now SUM(plan price_paise) of active/authenticated/pending subs
- revenue page: all ₹, real MRR/ARR/ARPU, growth potential off real ARPU,
  links to subscriptions page (removed fabricated monthly history)
- dashboard MRR card + revenue overview: $ -> ₹ with en-IN formatting

Family subscription dates:
- families API: joins latest family_subscriptions -> {status, plan, startedAt,
  expiresAt, cancelledAt} (try/catch safe if billing tables absent)
- families page: tier cell shows status + "since" date + renews/ends date
  (amber when cancelled)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 15:18:43 +05:30
9dcd0fc854 feat(billing): enforce 50 GB premium storage cap (was unlimited)
Premium granted unlimited storage because quota.ts treated any paid tier as
Infinity. Plan sells 50 GB — now enforced:
- quota.ts: PAID_STORAGE_LIMIT_BYTES = 50 GiB; getStorageInfo/checkStorageQuota/
  reconcileActualSize use it for paid families instead of Infinity. Meter/
  warn/exceeded now computed for both tiers.
- storage-usage route: return real limit for paid (was forcing "Unlimited"/null)
- StorageMeter: show meter for premium too (had `isPaid -> return null`);
  exceeded message drops the Upgrade link for premium (delete-only)

Free 1 GB unchanged. Premium = 50 GB / 6 members / 3 babies, all enforced.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 15:07:19 +05:30
a69546977c fix(billing): webhook crashed binding Date to postgres.js — use ISO strings
THE actual root cause of "charged but still free". reconcile surfaced it:
  TypeError: The "string" argument must be of type string... Received Date

postgres.js in this repo binds timestamp params via a custom string serializer
— passing a raw JS Date object throws. The webhook built currentStart/
currentEnd/endedAt/cancelledAt as Date objects and bound them into the
UPDATE family_subscriptions query, so EVERY charged/activated/authenticated
event crashed in processing → 500 → (with the now-fixed idempotency) retries
also failed → entitlement never applied.

Fix: all timestamp params are now ISO strings (.toISOString()):
- webhook: unixToDate -> unixToISO; revoke branch ended_at/cancelled_at as ISO
- reconcile endpoint: same toISO conversion

Combined with the previous idempotency fix, live charges now grant correctly
and a transient failure can be retried successfully.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 15:00:29 +05:30
e989e6c558 fix(billing): webhook idempotency trap + add reconcile recovery endpoint
ROOT CAUSE of "charged but still free": the webhook logged each event BEFORE
processing, then early-returned 200 on duplicate. So when processing threw
after the log insert, every Razorpay retry hit the duplicate guard and skipped
processing forever — entitlement never applied. Diagnostic confirmed: 6 events
logged with correct sub_ids + status=active, but family_subscriptions still
'created' and no paid families.

Fix:
- Webhook no longer early-returns on duplicate. Log is best-effort (never blocks
  or fails the request); processing always runs. All processing ops are
  idempotent (status UPDATE + grantPremium/revokeToFree upserts) so reprocessing
  a redelivered event is safe. Now a transient error → 500 → retry actually
  reprocesses and lands the grant.

- NEW POST /api/admin/reconcile-subscriptions: admin recovery. For each
  subscription, replays its latest logged webhook event, reapplies grant/revoke
  with per-sub error capture, returns resulting paid families. Recovers the two
  families already stuck in 'created' despite successful charges.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 14:55:33 +05:30
0c88058a79 debug: add billing diagnostics to debug-migration GET (webhook events, subs, paid families)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 14:44:54 +05:30
b5f1e5540b feat(billing): brand checkout + auto-prefill user details
#1 Checkout branding (within Razorpay's hosted-UI limits):
- image: /icons/192.png — Tia logo in the checkout header
- theme color #fb7185 (Tia rose, matches app theme-color) — was terracotta
- Note: Razorpay Checkout is their hosted UI; logo + brand color + name are
  the only customisable bits. Fonts/layout cannot be changed (platform limit).

#2 Auto-prefill name/email/phone:
- UpgradeButton now fetches /api/auth/profile on mount and passes
  prefill {name, email, contact} to Razorpay. User can still edit in checkout.
- Saves manual entry; uses the phone we now collect.

(#3 "seller doesn't support recurring payments" is a Razorpay ACCOUNT setting,
 not code — needs subscriptions enabled on the account. Handled separately.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 13:39:57 +05:30
082956adea fix(billing): abandoned-checkout lockout + stuck "Opening checkout…"
Issue 1 — "An active subscription already exists" lockout:
Clicking Upgrade creates a 'created' row + Razorpay sub BEFORE payment. If the
user closes checkout without paying, that row persisted forever and the partial
unique index blocked all future upgrade attempts (Razorpay also refuses to
cancel a 'created' sub — "no billing cycle"). Real users hit this on every
abandoned checkout.
Fix: create route now inspects existing non-terminal sub:
  - active/authenticated/pending -> block (genuinely subscribed)
  - created -> REUSE it (return same sub_id so checkout reopens) instead of lock
  - halted -> retire to 'expired' so a fresh sub can be created

Issue 2 — iOS PWA stuck on "Opening checkout…":
- loadCheckout() could hang forever if a script tag existed but its load event
  already fired (listeners never run). Rewrote with a polling fallback +10s
  timeout so it always resolves.
- Button stayed loading until dismiss; now clears loading right after rzp.open()
  so it never sticks in an iOS standalone PWA.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 13:24:09 +05:30
1577303582 feat(billing): header upgrade affordance on home page
Small, non-intrusive upgrade entry point before SOS + dark-mode toggle:
- Free tier: muted gray 👑 crown with a pulsing rose dot (discoverable hint,
  not a nag) — links to /settings#upgrade
- Premium tier: amber  sparkle (status reward, no upsell) — links to the
  same Plan section showing their grants + cancel

Reads tier from useFamily(). Both states link to the working Plan section
built in Task 8, so it's wired into the real upgrade/cancel flow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 13:11:14 +05:30
80390e7f13 fix(billing): allow Razorpay Checkout domains in CSP
Checkout.razorpay.com script + payment iframe were blocked by CSP
(CHECKOUT_LOAD_FAILED). Added Razorpay to:
- script-src: https://checkout.razorpay.com
- frame-src:  https://*.razorpay.com https://api.razorpay.com (payment iframe)
- connect-src: https://*.razorpay.com + lumberjack.razorpay.com (telemetry)
- img-src:    https://*.razorpay.com (payment-method logos)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:59:38 +05:30
3604f7314d feat(billing): Task 8 — checkout UI (UpgradeButton + settings Plan section)
- components/UpgradeButton.tsx: client component running the full flow —
  lazy-loads checkout.js (warmed on mount), POST /create, opens Razorpay with
  subscription_id, handler POSTs to /verify for UX, terracotta theme #C26B4E.
  Shows "activating shortly" success state; never touches entitlement.
- settings page: dedicated #upgrade Plan section (anchor target for all the
  existing /settings#upgrade CTAs from StorageMeter/MemberLimitBanner/etc):
    free  -> pitch + UpgradeButton
    pro   -> shows grants + Cancel subscription (cancel_at_cycle_end)
  Replaced the old dead "Upgrade" button in Family section with #upgrade anchor.

Completes the 8-task build. Live acceptance gates (create returns sub_id,
webhook flips tier, full test-mode checkout) run after Task 0 (dashboard +
env vars) per the handoff.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:23:12 +05:30
99b5543eb9 feat(billing): Tasks 6-7 — verify + cancel routes
Task 6 — POST /api/subscriptions/verify (UX only):
- HMAC of payment_id|subscription_id (SUBSCRIPTION order, not order_id flavour)
- uses the subscription_id WE stored for the family, not the client's
- grants NOTHING — webhook is source of truth; returns "activating shortly"
- 200 valid / 400 tampered

Task 7 — POST /api/subscriptions/cancel:
- family_id from session (IDOR-safe), cancels family's own live sub only
- RZP cancel with cancel_at_cycle_end=1 — keep premium until period end
- actual downgrade happens later via subscription.cancelled webhook

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:20:01 +05:30
2bd45bd4fd feat(billing): Tasks 4-5 — create-subscription + webhook routes
Task 4 — POST /api/subscriptions/create:
- family_id from session (requireFamily) — IDOR-safe, never from body
- rejects if a live sub exists (also enforced by partial unique index)
- creates RZP sub via fetch Basic auth, total_count 120, notes carry family_id
- inserts family_subscriptions row 'created'; returns subscriptionId + keyId only
- key_secret never sent to client

Task 5 — POST /api/webhooks/razorpay (source of truth):
- RAW body, timing-safe HMAC over webhook secret
- idempotency: unique insert on x-razorpay-event-id; duplicate -> 200 bail
- routes events -> family_subscriptions status + syncs families.tier:
    authenticated/activated/charged/resumed/pending -> grantPremium (pending=grace)
    halted/cancelled/completed/expired/paused -> revokeToFree
- 400 bad sig, 200 success/duplicate/unknown, 500 processing error (retry)

middleware: /api/subscriptions protected; /api/webhooks/razorpay intentionally
public (authenticates via HMAC, not cookie).

Verified locally: HMAC valid/tampered, unix->date, event routing maps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:18:00 +05:30
6a1aaa38a2 feat(billing): Tasks 2-3 — config, plan seed, entitlement sync
Task 2:
- lib/billing/config.ts: reads 4 Razorpay env vars (throws at call time, not
  boot), premium grant constants (50GB / 6 members / 3 children / ₹199),
  razorpayAuthHeader() Basic-auth helper
- POST /api/admin/seed-plan: admin-only idempotent upsert of the Premium plan
  row from env + constants (GET shows current plans). Re-runnable, no DB shell

Task 3:
- lib/billing/entitlements.ts: grantPremium() / revokeToFree() sync onto
  families.tier + max_members + max_children. Existing quota.ts guards UNCHANGED
  — they already read these via isPaidFamily(). revoke = limit downgrade only,
  data untouched (freeze-not-demote)
- ENTITLED_STATUSES (active/authenticated/pending=grace) + TERMINAL_STATUSES

No guard refactor needed: chosen "sync to families.tier" approach means the
3 existing guards (storage/member/child) keep working as-is.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:14:47 +05:30
714909d7ee feat(billing): Task 1 — Razorpay subscription schema + migration
Three tables + lifecycle enum for Razorpay subscriptions:
- subscription_plans: maps razorpay_plan_id -> grants (price/storage/member/child)
- family_subscriptions: per-family sub state mirrored from Razorpay
- razorpay_webhook_events: append-only log, razorpay_event_id = idempotency key
- subscription_status_enum: mirrors Razorpay's lifecycle states exactly
- partial unique index family_live_sub_idx: at most one non-terminal sub/family

Notes:
- Raw-SQL + Drizzle schema both added (repo uses raw sql`` at runtime;
  schema file keeps drizzle-kit + type inference working)
- child_limit added to plan (not in original handoff) since premium lifts the
  free 1-baby cap to 3 per product decision
- Migration 0012 idempotent; also added to debug-migration hot-apply steps
- when=1780100000000 (> last entry, per journal drift rule)

Entitlement will sync onto families.tier (Task 3/5) so existing quota.ts
guards stay unchanged — these tables are audit + Razorpay state mirror.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:10:53 +05:30
87e795c837 feat: show user phone in admin users page
Answer to "is phone visible in admin?": it wasn't — added it.

- /api/admin/users: SELECT u.phone, return phone in the DTO
- admin users page: new Phone column (tap-to-call tel: link, "—" when empty),
  searchable by phone, included in CSV export (now properly quoted so
  commas/empty cells don't shift columns)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 21:48:57 +05:30
0f3e87b67a feat: home nudge for existing users with no phone number
Existing users (signed up before phone collection) won't have a number.
Show a dismissable home banner prompting them to add one.

- Checks /api/auth/profile on load; shows banner only if user.phone is empty
- "Add" links to /profile; ✕ dismisses
- Dismissal remembered in localStorage (tia_phone_nudge_dismissed) so it
  never nags repeatedly
- Dark-mode styled, sits below the vaccine reminder banner

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 21:44:58 +05:30
38bb5af01c feat: collect optional user phone number (onboarding + profile)
Google OAuth cannot provide phone numbers (no scope returns them reliably),
so we collect it ourselves. Optional, stored unverified.

- Migration 0011: users.phone text column (+ debug-migration hot-apply step)
- schema/auth.ts: add phone field
- onboarding: optional phone input on step 1; saved to users.phone via the
  onboarding API (normalised: leading + then digits, 8-15 digit validation)
- profile page: editable Phone field; loaded from + saved to /api/auth/profile
- /api/auth/profile: GET returns phone; POST accepts & normalises it
  (empty string clears, undefined leaves untouched)

Capture point covers both Google and email/password signups since both land
on onboarding. Verification (OTP) and marketing-consent flag intentionally
deferred per product decision.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 21:25:00 +05:30
5083961c6b fix(mockup): clean PNG avatar, local memory photos
Avatar (baby card):
- Switch tia-portrait.jpg → tia-portrait.png (white/transparent bg, no dark edges)
- bg-white on container so PNG transparency shows correctly
- object-position: 65% top to shift face right and center it in the circle

Memories grid (all local, no external URLs):
- memory-bath.jpg: baby feet in towel (downloaded from Unsplash, stored locally)
- memory-milestone.jpg: baby in pool float with sunglasses (local)
- Captions updated: 'Tiny toes 🛁' and 'Summer fun 😎'

About page P.S.:
- tia-portrait.jpg → tia-portrait.png (clean white background)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 12:33:59 +05:30
14db731ed4 fix(mockup): Viradhya name, Tia portrait avatar, real photos in memories grid
- Baby name: Arjun → Viradhya (greeting + card name + img alt)
- Baby card avatar: 👶 emoji → tia-portrait.jpg (circle crop, object-top)
- Memories grid: emoji color blocks → real photos
    Slot 1: tia-portrait.jpg (First smile 😊)
    Slot 2: Unsplash baby photo (Bath time 🛁)
    Slot 3: family-illustration.jpg (With family 🌸)
    Slot 4: Unsplash baby photo (8 months! 🎉)
  Each card has dark gradient overlay so white caption is readable

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 12:23:05 +05:30
2608c7a146 fix(marketing): hero subtitle readable, footer links distinct from headings
Homepage hero subtitle:
- Remove font-light (was hard to read on gradient bg)
- Change text-gray-500 → text-gray-800 (dark, legible)
- Keep font-newsreader and leading-relaxed

Footer:
- Brand tagline → font-newsreader
- All footer links → font-normal text-gray-500 (explicit weight prevents
  Fraunces from blending with semibold headings at gray-700)
- globals.css: pin .text-gray-500 { color: var(--color-gray-500) } so
  links read clearly lighter than Company/Legal/Contact headings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 12:07:01 +05:30
af0dad6922 fix(marketing): Newsreader hero subtitle, Fraunces nav+footer links
- Hero subtitle paragraph → font-newsreader (warm reading serif)
- Nav About/Blog links → font-fraunces (via navLinkClass base string)
- Footer column headings (Company/Legal/Contact) → font-fraunces
- Footer all links and contact details → font-fraunces

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 11:52:16 +05:30
c523533531 feat(marketing): editorial fonts (Fraunces/Newsreader/JetBrains Mono) site-wide
Loading:
- Fraunces, Newsreader, JetBrains_Mono added to (marketing)/layout.tsx
  as CSS variables -- one load point, cached for all marketing pages
- 3 utility classes added to globals.css: .font-fraunces, .font-newsreader,
  .font-jetbrains
- about/page.tsx: removed duplicate font loading (now from layout)

Font roles applied:
  Fraunces   → all h1/h2/h3 headings + blog card titles + hero h1 (italic)
  Newsreader → long prose blocks: TheProblem, FounderStory card, Privacy
               intro, HeirloomVision description, blog article paragraphs,
               blog post excerpt
  JetBrains  → all small uppercase eyebrow labels across every section
  Geist      → nav, buttons, feature card body, short UI text (unchanged)

Pages updated: homepage, blog listing, blog articles, pricing, partners
About page: fonts resolve identically via layout variables, no visual change

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 11:32:44 +05:30
2a450c7644 fix(about): align accent colors with rose/pink brand theme
- --accent: #c98a2b (marigold) → #f43f5e (rose-500) — cascades to
  pull-quote borders, creed numbers, eyebrow lines, ✦ divider,
  callout quote mark, place name highlights
- --accent-2: #b8503e (dusty rose) → #e11d48 (rose-600) — cascades to
  drop cap, display highlight line, closing display text, .tia-pull.accent
- .tia-hero: add rose-50→amber-50→rose-50 gradient (matches homepage hero)
- .tia-cta .big: rose-500 bg + white text + rose shadow; hover → rose-600
- .tia-ps: background tint changed from marigold to faint rose
- Cream paper body, fonts, layout, grain overlay — all unchanged

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 11:01:53 +05:30
69caff5226 fix(about): remove dark manifesto band, flow What We Believe on cream paper
The dark #1f1b16 full-bleed section broke the continuous letter feel.
Replaced with a standard letter article on var(--paper):
- tia-subhead 'What we believe' (same as other subheadings)
- tia-pull.accent for the opening line
- Paragraphs in tia-col as usual
- Creed list (01-06) retains mono numbers in marigold + ink body text,
  hairline borders — now reads as part of the letter, not a separate block
Removed all .tia-mani* CSS rules and --mani-bg/fg tokens.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 10:49:14 +05:30
a3d89ee37c feat(about): heirloom letter redesign ported from About.html prototype
Design system (cream paper, keepsake direction):
- --paper #f7f1e6 · --ink #1f1b16 · --accent #c98a2b (marigold)
- --accent-2 #b8503e (dusty rose) · --surface #fbf6ea
- Fonts: Fraunces (headings, italic) · Newsreader (body 19px/1.75)
         JetBrains Mono (labels) · Caveat (hand/caption, already loaded)
- Inline scoped <style> block — no globals pollution

Page structure (letter reading flow):
- Title-only hero with mono eyebrow — no byline, no photo (suspense)
- Opening: drop cap + family illustration floated right in a cream photo
  frame (washi tape + slight rotation + handwritten caption)
- 5 pull-quotes (side border + italic Fraunces) + centered variant
- Quiet italic subheadings with hairline rule
- Callout card: Nani/Dadu passage with marigold handwriting place names
- Dark manifesto band (#1f1b16): numbered creed 01–06
- Authorship revealed only at the end: closing display line → ✦ divider
  → signature "Yashika & Manohar" in Caveat
- P.S.: Tia portrait circle-floated left, playful note in tinted card

Interaction:
- AboutScrollReveal client component: elements above fold visible on
  first paint; below-fold get .tia-pre (opacity 0 + translateY 18px);
  scroll/resize handler removes .tia-pre as they enter viewport
- Respects prefers-reduced-motion — never hides content for users who
  prefer reduced motion

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 10:35:14 +05:30
8f141883bb feat(about): full letter page with family illustration + Tia portrait
Content: complete "A Letter to Every New Parent" letter by Yashika &
Manohar — four named sections (Why We Named the App, Modern Families,
What We Believe, To the Parent Reading This) + signature

Images:
- /images/family-illustration.jpg — watercolour illustration of the
  family in the hero, 2-col layout at lg (copy left, image right)
- /images/tia-portrait.jpg — Tia's portrait cropped to circle in
  the closing signature

Design:
- Hero: rose→amber gradient, 2-col at lg, family illustration bottom-aligned
- Breadcrumb below hero intro
- Letter body max-w-2xl, generous leading, section borders
- "Nani in Lucknow / Dadu in Jaipur" in amber callout card
- Beliefs rendered as rose dot list
- Key line "Tia is here to help you remember your baby" in rose-700
- Closing parenthetical about Tia in a rose-50 italic card
- CTA: Get started → with hover lift

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 02:30:40 +05:30
1cbdd68756 fix(nav): mobile flex layout restored, desktop 3-col grid, gray→rose colors
Layout:
- Mobile: flex layout unchanged — [Logo] ml-auto [About] [Auth]
- Desktop sm+: 3-col grid [1fr Logo | auto nav centered | 1fr Auth]
  achieved by flex→sm:grid responsive switch + sm:ml-0 on nav

Colors:
- Default: text-gray-600 font-normal (matches app theme, not rose)
- Hover: border-rose-300 + bg-rose-50 + text-rose-500 (border reserved
  via border-transparent at rest to prevent layout shift)
- Active: text-rose-600 font-medium (slight weight bump, not semibold)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 02:22:16 +05:30
f5d21eea28 fix: home "Vaccine Reminder" showing "undefined due today"
The home banner read fields (.status, .vaccineName) that the DB-backed
notifications API never returns — its DTO exposes {type, title, message,
metadata,...}. So .vaccineName was undefined → "undefined due today", and
.status was undefined → the ternary always fell to the "due today" branch
even for overdue/nudge items.

Two issues, both fixed:
1. Contract mismatch: render the real `message` field instead of the
   non-existent `.vaccineName`/`.status`. message is already correctly built
   server-side ("BCG is due today" / "BCG is 3 days overdue").
2. Wrong source set: the banner consumed the FIRST notification of ANY type.
   Since the API also returns log/memory/garment nudges, a nudge could land
   at index 0 and render under the vaccine header. Now filtered to
   type starting with "vaccine_" before use.

Also hardened the vaccine upsert: ON CONFLICT DO NOTHING -> DO UPDATE SET
title/message/action_url/metadata, so future copy/day-count changes refresh
existing rows instead of freezing the first version (preserves id/is_read).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 02:15:51 +05:30
52a60a7cff fix(nav): center About/Blog using 3-col grid layout
Replace flex+ml-auto with grid-cols-[1fr_auto_1fr]:
- Col 1 (1fr): logo, left-aligned
- Col 2 (auto): nav links, truly centered regardless of button width
- Col 3 (1fr): auth button, flex justify-end

Works on mobile too: About sits perfectly centered between logo and button.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 02:11:32 +05:30
c5cb9570b2 fix(nav): rose text links, border on hover only, bold active state
- Both About and Blog: text-rose-500 at rest, no border, no background
- Hover: border-rose-300 + bg-rose-50 + text-rose-600 (border space
  always pre-reserved via border-transparent to prevent layout shift)
- Active page (usePathname): font-semibold text-rose-600, no visual
  container — bold text only, clean
- About: always visible on all breakpoints including mobile
- Blog: hidden on mobile, sm:inline-flex on desktop

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 02:05:37 +05:30
68a911c6db fix(nav+story): About pill always-on-mobile, Blog desktop-only, remove author block
MarketingNav:
- About: always visible on all breakpoints; styled as outlined rose pill
  (border-rose-300, rose-600 text) — pairs naturally with the solid rose
  CTA button beside it; hover fills bg-rose-50
- Blog: hidden on mobile (sm:inline-flex), plain text gray-600 → rose-600
- Use ml-auto mr-3 on nav so links sit right of center, next to the button

Homepage FounderStory:
- Remove 👨‍💻 / Manohar Gupta / Founder block — author details move to About
- Story content and amber card remain unchanged

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 01:51:19 +05:30
6df914ddf9 feat(marketing): About+Blog in nav, real founder story content
MarketingNav:
- Add center nav with About and Blog links (hidden on mobile xs,
  visible from sm: breakpoint)
- Logo + nav + auth button now use justify-between with gap-4

Homepage FounderStory section:
- Replace [PLACEHOLDER] blocks with Manohar's real founder story
- Section opens with bold "TIA began with a promise." heading
- Key line "TIA isn't here to track your child. It's here to help
  you remember them." highlighted in rose-700
- Closes with "Built by parents. Inspired by our daughter. Made for
  families." in a bordered amber footer strip

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 01:42:51 +05:30
c1e02249d6 fix: home page vaccine reminder showing "undefined due today"
Two stacked causes:

1. ROOT CAUSE (home page field mismatch): the DB-backed notifications rewrite
   (678cf65) changed the API response shape, but the home page banner still read
   the OLD fields. It checked `notif.status === "overdue"` (no longer returned —
   now it's `title`) so it always fell to the else branch `${notif.vaccineName}
   due today`, and `vaccineName` no longer exists at top level (now in
   `metadata.vaccineName`) → rendered "undefined due today".
   Fix: render the complete server-built `message` ("BCG is N days overdue" /
   "BCG is due today"). Also filter to vaccine_* notifications so the banner can't
   show a log/memory/garment nudge under a "Vaccine Reminder" header.

2. Stale message data: an earlier generator had stored "undefined …" in the
   message itself, frozen by ON CONFLICT DO NOTHING. Delete legacy "undefined%"
   rows and switch vaccine upsert to DO UPDATE so messages (and overdue
   day-counts) stay correct instead of freezing the first version. is_read / id /
   created_at preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 22:52:05 +05:30
ab937c4e9d feat: Telegram alerting + public health probe + Umami visitor digest
Launch-critical monitoring wiring — alerts go to tiaBaby_Bot via Telegram.

- src/lib/alert.ts: sendAlert(level, title, detail?, {fields, silent}) — HTML
  formatted, IST timestamped, best-effort (never throws). Env: TELEGRAM_BOT_TOKEN,
  TELEGRAM_CHAT_ID
- GET /api/healthz: public, no-auth liveness probe (200 ok / 503 down) for
  Uptime Kuma + Dokploy healthcheck. No sensitive detail
- cron/backup: alert on failure (fatal), warn if dump < 1KB (empty), silent
  success confirmation with file + size
- cron/monitor: error-spike rising-edge detection (last 1h > 5 and > 2x prior
  hour — stateless, no re-alert on flat rate), DB/migrations/integration checks.
  ?test=1 sends a Telegram test ping
- cron/visitor-summary: polls Umami REST API (login -> stats/metrics/active),
  posts visitor digest to Telegram. ?hours=N window (default 24)
- CLAUDE.md: new env vars + Monitoring & Alerting section

Health up/down flip detection is delegated to Uptime Kuma (pings /api/healthz);
this code covers what Kuma can't see from outside.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 22:01:18 +05:30
a89ab96a12 fix: add analytics.manohargupta.com to CSP connect-src
Browser was blocking the POST to analytics.manohargupta.com/api/send
with 'Failed to fetch' because the Content-Security-Policy connect-src
only listed plausible.io (old analytics tool, now removed).

- connect-src: replace plausible.io with analytics.manohargupta.com
- script-src: remove plausible.io (no longer needed, Umami script is self-hosted)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 21:30:54 +05:30
27b07a5cfc fix: patch umami.js to work with Next.js async script injection
document.currentScript is always null for async scripts (which is how
next/script afterInteractive works). Umami's first check was:
  const{currentScript:l}=c; if(!l)return;
This caused an immediate exit — zero tracking.

Fix: fall back to querySelector('script[data-website-id]') when
currentScript is null, so Umami finds its own script tag and reads
the data-website-id / data-host-url attributes correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 21:21:53 +05:30
4e90064989 fix: self-host Umami script to bypass Cloudflare cross-origin 503
analytics.manohargupta.com/script.js returns 503 when loaded as a
browser sub-resource from tia.manohargupta.com (same Cloudflare Bot
Management issue as R2 images). Fix: serve the script from /umami.js
(same origin, no cross-origin block) and use data-host-url to tell it
where to POST events back to the Umami instance.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 12:16:51 +05:30
67f7c4836d docs: warn that migration journal when must exceed the last applied
Drizzle's migrator applies a migration only when its journal `when` is greater
than the max created_at already recorded. Entries 0003-0010 were given 2025-era
timestamps (smaller than the 2026 baseline), so drizzle silently skipped them —
they only applied via the debug-migration hot-apply endpoint. Document the rule.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 09:55:35 +05:30
7332bd1e8b Add admin Storage & Billing monitor
Per-family R2 storage usage (the basis for usage billing), computed from
SUM(size_bytes) over memories + attachments — same basis as the quota system,
so admin numbers match what users are charged on.

- GET /api/admin/storage: per-family bytes/objects (sorted by heaviest),
  totals, over/approaching free-limit counts, est. R2 cost, 30-day upload trend.
- /admin/storage page: summary cards, daily upload chart, per-family table with
  % of free quota bar and paid/over-limit flags.
- Sidebar: added Storage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 09:45:24 +05:30
05975b51a1 Make debug-migration GET a robust pgvector/migration diagnostic
Each probe now runs independently (pgvector check first and standalone) and
reads the drizzle journal from the correct "drizzle" schema, so the endpoint
returns a usable diagnostic instead of crashing on the first missing relation.
Also reports whether error_events exists.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 08:59:16 +05:30
91c25b2c15 Add error_events to debug-migration hot-apply steps
Migration 0010 (error_events) didn't land via the drizzle journal on prod, so
add an idempotent CREATE TABLE + indexes to the /api/debug-migration steps so
the table can be created instantly via the documented hot-apply endpoint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 01:11:01 +05:30
470df7fb9f Ensure pgvector extension in migrator + add pgvector diagnostic
The baseline schema needs the `vector` extension (memories.vision_embedding),
but nothing created it on deploy — a fresh DB hit "could not access file
vector" and migrations failed.

- migrate.ts now runs CREATE EXTENSION IF NOT EXISTS vector (superuser) before
  applying migrations, with a clear error if the Postgres image lacks pgvector.
- /api/debug-migration GET now reports pgvector status (binaryAvailable /
  installed) so the image/extension can be checked from the browser.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 00:49:43 +05:30
deaa1810d7 feat: add Umami self-hosted analytics with custom event tracking
- Root layout: load Umami script (afterInteractive) — covers all pages including
  SPA navigation auto-tracking
- Marketing layout: remove Plausible script (Umami now covers marketing pages too)
- src/lib/analytics.ts: type-safe track() wrapper + typed helpers for each event;
  window.umami declared globally; safe no-op on SSR/ad-block
- Custom events wired:
    log-created { logType }  — LogModal on successful save
    garment-added            — wardrobe/add after save
    memory-added             — memories after upload pipeline completes
    growth-logged            — growth page after measurement saved
    pwa-installed            — InstallPrompt when Android prompt accepted

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 00:40:05 +05:30
cbbe8f24ac Make admin engagement feed resilient + self-diagnosing
Analytics showed blank despite real data because one failing sub-query made
the whole route fall back to an empty shape, and the page dropped the error.

- Each sub-query (adoption, families, AI, daily) now runs in its own
  try/catch; failures are collected and returned in `error` while the other
  sections still render (partial data instead of all-or-nothing).
- Cast all MAX() timestamps to timestamptz inside GREATEST() so mixed
  timestamp/timestamptz columns can't error.
- Dedicated COUNT(*) for total families (robust denominator/summary total).
- Analytics page now surfaces the `error` string in a banner instead of
  silently rendering empty.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 00:37:53 +05:30
7a60132bb2 Add admin observability: error tracking, audit viewer, AI metrics, health
Turns the admin panel into a real monitoring tool so production bugs are
visible instead of silent.

- Error & crash tracking: error_events table (migration 0010) + logError()
  helper + /api/errors ingest; global-error.tsx and (app)/error.tsx report
  crashes automatically; /admin/errors viewer (recent + grouped, filters).
- Full audit-log viewer at /admin/audit over the existing audit_log (all
  actions, not just auth) with action/resource/family/user/text filters.
- AI observability at /admin/ai over ai_usage: per-intent latency (avg/p95),
  tokens, cost, daily trend, slowest calls, medical-redirect count.
- System health at /admin/health: DB latency, migration status, recent error
  volume, and integration config presence.
- Sidebar updated with Health / Errors / Audit Log / AI Usage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 00:27:07 +05:30
94d9b234f8 Fix admin analytics crash — cartesian join + undefended render
The /admin/analytics page calls /api/admin/engagement, whose per-family
query LEFT JOINed feeds × diapers_logs × sleeps × vaccinations × growth ×
chat_sessions before GROUP BY — a cartesian explosion that times out for
any family with real activity, 500ing the route. The page then ran
[...data.families] on the error object and white-screened.

- Pre-aggregate each log table to one row per child (CTEs) before joining,
  eliminating the row explosion; memories aggregated per family.
- Route error fallback now returns the full shape (safe empties) not {error}.
- Page normalizes the response into the full EngagementData shape so a bad
  response renders an empty state instead of crashing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 00:03:48 +05:30
e53c51f044 Fix mobile zoom-on-focus in AI chat (and all inputs)
Focusing an input with font-size <16px makes mobile browsers auto-zoom
the viewport, which distorted the home "Ask AI" popup UI. Enforce a 16px
minimum on form controls for touch devices via globals.css; desktop and
pinch-zoom accessibility are untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 23:25:51 +05:30
45f9d6261b Fix AI chat input width — stretch to fill the row
The Input component renders its own wrapper div, so flex-1 passed to
<Input> only hit the inner <input> (already w-full) while the wrapper
stayed content-width, leaving the box narrow with empty space beside it.
Wrap Input in a flex-1 div so the flex child actually stretches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 22:23:05 +05:30
2b534d4c43 Fix AI chat: input blocked by bottom nav + first-chat crash
- Add pb-16 to /ai root so the input clears the fixed BottomNav
  (only page using h-screen instead of min-h-screen+pb-24).
- Normalize new sessions with messages:[] in createNewSession so
  render no longer hits undefined.length and crashes on first chat.
- Make render defensive (messages?.length ?? 0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 22:17:10 +05:30
dad0611350 SEO overhaul: metadata, robots, sitemap, structured data
- Add metadataBase to root layout so OG/Twitter/canonical URLs resolve
  to absolute https URLs (fixes broken social previews)
- New src/lib/seo.ts with SITE_URL + JSON-LD builders
- New robots.ts (disallow api/admin/private app paths) and sitemap.ts
  (marketing pages + blog posts with real lastmod dates)
- JSON-LD: Organization/WebSite/SoftwareApplication on home,
  Blog+Breadcrumb on blog list, BlogPosting+Breadcrumb on posts
- Per-page canonical + Open Graph on all marketing pages; article OG
  + Twitter cards on blog posts; per-post dynamic OG image
- noindex on (app) and admin layouts; richer PWA manifest
- Fix CSP to allow plausible.io in script-src/connect-src (analytics
  was silently blocked)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 11:03:04 +05:30
39b2787484 fix(hero): remove floating screen label above phone mockup
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 10:39:29 +05:30
e309c91309 fix(hero): replace pulse ring with hover-only lift + rose glow on CTA
Remove continuous animate-cta-pulse ring — too distracting.
On hover: button lifts 0.5px, scales 1.03×, shadow grows with a
rose-200/60 tint. Fades back on mouse-out. Active state still
scales down to 0.95 for tactile press feel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 10:36:45 +05:30
261a9cbbcb feat(hero): CSS-animated 3-screen phone mockup carousel
Replaces the static placeholder with an auto-cycling mockup that shows
three real app screens — Home/Quick Log, Vaccinations (IAP), and
Memories — mirroring the actual app card/row UI patterns.

- 2500ms auto-advance, pauses on hover
- Smooth opacity crossfade (duration-500) between screens
- Active dot indicator stretches to pill shape (w-4)
- Floating label pill above phone changes with active screen
- All pure CSS/Tailwind — zero external assets, static page unchanged

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 10:32:39 +05:30
daf6b34281 feat(marketing): CTA pulse animation + breadcrumb repositioned below hero
Homepage:
- Wrap "Continue with Google" in a relative group; add rose-300 pulse
  ring (animate-cta-pulse) that fades out smoothly on hover
- @keyframes cta-pulse added to globals.css (2.6s, scale 1→1.22, opacity 0.45→0)

Blog listing (/blog):
- Remove breadcrumb from above the hero header
- Place it in a pt-5 strip directly above the 3-column content grid
  so it reads as "you are here" navigation rather than floating chrome

Blog post (/blog/[slug]):
- Remove breadcrumb from inside the hero gradient section
- Place it in the same pt-5 strip between hero and 3-col grid for
  consistent placement across listing + post pages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 10:23:28 +05:30
e5a59c5191 feat(blog): 3-col layout, breadcrumbs, TOC + footer 3-col bottom bar
Blog listing (/blog):
- Breadcrumb: Home › Blog
- Left sidebar: chronological timeline with dot + date + category badge
- Right sidebar: "Browse by topic" category counts, quick reads, CTA card
- Still single column on mobile (sidebars hidden)

Blog post (/blog/[slug]):
- Breadcrumb: Home › Blog › Post title
- Left sidebar: numbered Table of Contents (section headings as anchor links)
  with "← All articles" back link below
- Right sidebar: "More articles" list + "Filed under" category + CTA
- scroll-mt-28 on headings so sticky nav doesn't cover anchor targets
- Both back-to-listing links visible (sidebar + article footer)

Footer bottom bar:
- Split into 3-column grid: © left · privacy tagline center · ❤️ India right

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 10:06:14 +05:30
8be6bbe23f feat(blog): proper blog structure with 4 sample posts + footer polish
Blog:
- posts.ts data file with 4 authored posts: feeding by age, health
  warning signs, getting started guide, Telegram alerts feature
- /blog listing page with category pills, read time, hover cards
- /blog/[slug] post template: hero, sections (paragraphs/lists/tables/
  callouts), IAP schedule table, article footer CTA
- All 4 posts prerendered as SSG (generateStaticParams)

Footer:
- Update phone number to +91 95548 81799
- ❤️ grows on hover with inline-block + hover:scale-150 transition

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 09:39:00 +05:30
678cf65d70 feat: DB-backed notification system with vaccine + activity nudges
Migration (0009): notifications table with unique daily/weekly slots,
is_read column, metadata JSONB for vaccine info. No more localStorage
for read state — syncs across devices.

API GET /api/notifications?childId=:
- Generates vaccine notifications (upsert, filtered by given vaccines at query time)
- log_nudge: if no feed/diaper/sleep logged today after noon IST
- memory_nudge: if no photo added to memories today
- garment_nudge: if wardrobe < 10 items (once per week slot)
- Returns unread first, then recent read, limit 60

API PATCH /api/notifications  — mark all read for family+child
API PATCH /api/notifications/[id] — mark single notification read

Page /notifications:
- Fetches from real API (no hardcoded mock data)
- Optimistic mark-read on tap, navigates to actionUrl
- Colored cards per type (red=vaccine, amber=log, purple=memory, pink=garment)
- Unread badge + Mark all read button in sticky header
- Legend row at bottom

debug-migration: added notifications table CREATE IF NOT EXISTS for hot-apply

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 09:33:51 +05:30
ee4bcc4498 fix: notifications page — wire to real API, make Mark all read functional
- Replaced hardcoded mock data with real call to /api/notifications?childId=
- Read/unread state stored in localStorage (tia_read_notifications) since
  notifications are computed on-the-fly from vaccination schedule, no DB row
- Tapping a notification marks it read individually
- Mark all read button appears in header only when there are unread items
- Unread count badge shown in header
- Empty state now says "All caught up!" instead of generic "No notifications"
- Shows 🚨 for overdue vaccines, 💉 for due-today
- Added due date display in IST-formatted date

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 01:08:18 +05:30
e7a332cacd tweak: install prompt snooze — Later=2d, No thanks=7d
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 01:05:00 +05:30
093903162e improve: smarter install prompt — visit gate + snooze instead of permanent dismiss
Old behavior: dismiss once -> never shown again forever.
New behavior:
- Only shows after 3+ sessions (user has seen value first)
- "Later" -> snoozes for 7 days, re-asks after that
- "No thanks" -> snoozes for 30 days
- Once actually installed (Android accepted) -> permanently hidden
- iOS: two buttons (Later / No thanks) instead of bare X, so intent is clear
- Android: tapping X now snoozes rather than permanently suppressing
- Dark mode support added to both banners

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 01:00:52 +05:30
3cfcbdc0ca fix: garment upload MIME/proxy, log edit time pre-fill, date-ist hardening, wardrobe camera+gallery
- /api/img: add garments/ to ALLOWED_PREFIXES so garment images proxy correctly
- garments upload: resolve Android empty MIME type from file extension; return
  /api/img proxy URLs instead of raw pub-*.r2.dev (blocked by Cloudflare Bot Mgmt)
- garments route + [id] route: toDto() now builds /api/img?key= proxy URLs
- date-ist.ts: add toUTCDate() helper -- strings without Z/offset treated as UTC,
  preventing browser local-time misinterpretation; used in fmtTime, fmtDate, dateIST
- LogModal: add editTime to SmartDefault; pre-fill time picker (custom preset) when
  editing an existing log instead of defaulting to now
- activity page: pass editTime: log.loggedAt in handleEdit so LogModal pre-fills
- wardrobe/add: explicit Camera and Gallery buttons via separate hidden inputs
  (one with capture=environment for direct camera, one without for media picker)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 00:42:04 +05:30
e99a874309 Wardrobe: gallery picker + non-blocking vision AI; add /api/time endpoint
Wardrobe add page:
- Remove capture="environment" so Android shows the Camera/Gallery chooser
  instead of opening the camera directly
- Vision AI no longer blocks the UI: photo preview + form appear instantly
  after selecting an image; upload spinner shows on the thumbnail while R2
  upload runs; vision AI fires in the background and fills in tags when done
  without interrupting the user (they can pick size/occasions in parallel)
- "AI tagging…" pulsing badge in header while vision runs; " AI pre-filled"
  badge when done; form fields are only overwritten if the user hasn't already
  typed/selected something (functional state updater with prev-value guard)
- Save button is disabled (with "Uploading photo…" label) until R2 upload
  completes — prevents saving a garment with no imageKey

/api/time endpoint (GET, no auth):
- Returns { utc, istDate, istTime, ist, offsetMinutes } for the current server
  time in Asia/Kolkata so the app can verify server clock and surface IST time
  reliably (can be called from browser console at /api/time)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 23:31:18 +05:30
cfb0f4b2eb Fix timestamp timezone — logs now always show in IST regardless of server TZ
Root cause: postgres.js v3 parses `timestamp without timezone` columns as
`new Date("YYYY-MM-DD HH:mm:ss")` (space format, no Z). V8 treats this as
*local time*, so on a non-UTC server (Dokploy host = Europe/Helsinki UTC+3)
the parsed Date object is 3 hours off, making logged times show as server
time instead of the user's IST.

Fixes:
- db/index.ts: add custom `timestamp` type parser that forces UTC by
  converting space-format to ISO with 'Z' before calling new Date().
  Also set `connection: { TimeZone: "UTC" }` so PostgreSQL sessions always
  store/return timestamps in UTC regardless of server OS timezone.
- CalendarView.tsx: use `dateIST()` for day grouping (fixes midnight-boundary
  bug where a 12:30 AM IST entry appeared on the previous UTC day) and
  `fmtTime()` for time display (replaces toLocaleTimeString without timezone).
- MedicineTab.tsx: replace toLocaleString() with fmtDate/fmtTime (IST-aware).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 23:13:57 +05:30
d8c9500949 Remove UploadProgress debug toast; fix R2 image proxy and memory pipeline
- Delete UploadProgress component (was debug UI, no longer needed)
- All 3 pages (home, memories, profile) now use simple inline error state
  instead of the step-by-step toast
- /api/img proxy: fetch R2 objects server-side to bypass Cloudflare Bot
  Management 503s on pub-xxx.r2.dev cross-origin img requests
- All API responses (memories, children, profile) now return /api/img proxy
  URLs via toProxyUrl() helper in src/lib/r2-proxy.ts
- Fix memory pipeline: vision failure now marks status='ready' instead of
  'failed'; thumbnail failure no longer blocks vision via .catch() separation
- Reset stuck 'processing' memories via debug-migration endpoint
- memories page: replace full-screen overlay with small  badge on tile

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 22:53:16 +05:30
27709dc851 Add one-time fix: reset stuck processing memories to ready
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 22:38:45 +05:30
f953963b3b Fix memories disappearing + always-processing state
Three bugs fixed:

1. Vision failure was marking memories as 'failed', triggering orphan
   cleanup that permanently deleted them. Vision is optional — on error,
   now marks 'ready' so the image stays visible without AI captions.

2. Thumbnail failure was blocking vision from running (rejected promise
   swallowed the chain). Fixed by catching thumbnail error separately so
   processMemoryVision always executes and always sets a final status.

3. /api/img proxy was rejecting thumbnail keys (families/{id}/thumbnails/…)
   because 'families/' was not in ALLOWED_PREFIXES. Added it.

Also: replaced the full-bleed 'Processing…' overlay with a small corner
badge so the uploaded photo is visible immediately after upload.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 22:37:46 +05:30
a3a0ddf3c9 Proxy R2 images through /api/img to fix 503 from Cloudflare Bot Management
pub-xxx.r2.dev returns 503 for cross-origin sub-resource requests (img tags,
fetch) due to Cloudflare Bot Management on the r2.dev dev domain. Direct
browser navigation works but programmatic loading fails, so all uploaded
images appeared as placeholders after upload.

Fix: route all image display through a same-origin /api/img?key=... proxy that
fetches from R2 via the S3 API server-side. API responses (profile, children,
memories) now return proxy URLs. After upload, UI state is updated with proxy
URLs directly rather than raw R2 URLs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 21:35:25 +05:30
51e36633b9 Add step-by-step upload progress UI across all three upload points
Each upload now shows a persistent card with 3 labelled steps and their
live status (pending → active → done / error). Errors include the exact
HTTP status code + raw response body (handles non-JSON from Traefik,
nginx, etc. that return HTML error pages). The card stays visible after
failure so the user can read the diagnostic before dismissing.

Changes per surface:
- src/components/UploadProgress.tsx — new shared step-tracker component
- profile/page.tsx — step card rendered below avatar; safeResponseText()
  reads raw body so a Traefik 413 shows "HTTP 413: <html>..." not just
  "Upload failed"
- memories/page.tsx — fixed toast expands to show all 3 steps; dismissible
  after done/error; same safeResponseText pattern
- home/page.tsx (baby photo) — same fixed toast as memories; 3 steps with
  HTTP codes and raw body on error

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 11:17:01 +05:30
ccae6d85d2 Fix broken memories count + silent upload failures
Memories "count 2 but no images" root cause:
- DB rows exist with processing_status='failed'/'uploading' from aborted uploads
  whose R2 objects never actually landed. The img onError fires and hides the
  tile, but the count still includes these orphaned rows.
- Fix: GET /api/memories now excludes failed rows and uploading rows older than
  30 min from both the SELECT and the count. Also fires a background DELETE to
  clean up orphaned rows so they stop accumulating.

Profile / memories upload silent failures:
- Some Android cameras return file.type="" which caused the avatar API to reject
  the upload with a 400 error. Error was caught but shown in a small text node
  buried below the form — invisible when looking at the avatar area.
- Fix: added resolveContentType() helper (used in profile, memories, home) that
  falls back to extension-based detection when file.type is empty/octet-stream.
- Fix: profile page now uses a separate uploadMsg state rendered immediately
  below the avatar so errors/success are always visible on mobile without scroll.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 10:48:28 +05:30
9c2e7328ab Fix PWA always opening marketing page instead of app home
Two-pronged fix for Android PWA shell launching to the wrong page:

1. middleware.ts: if a logged-in user (valid tia_session cookie) visits /,
   immediately redirect them to /home — catches all existing installs whose
   cached start_url still points to /?source=pwa
2. manifest.ts: change start_url from /?source=pwa to /home?source=pwa
   so any fresh install or reinstall opens directly to the app home

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 10:28:31 +05:30
52ec89f5a4 feat(marketing): 2-col hero, 3-col features grid, expanded footer + stub pages
- Hero: split into 2-column lg layout (copy left, phone mockup right)
- Features: grid cols 1→2→3 with icon-on-top flex cards
- Footer: brand block left + Company/Legal/Contact 3-column group right;
  bottom bar condenses copyright + privacy tagline left, India badge right
- Add /about, /blog, /partners stub pages (all static)
- Middleware: whitelist /about, /blog, /partners as public routes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 10:23:22 +05:30
ef30f27e9c Fix FAB z-index/position, broken image fallbacks, and upload progress UI
- Bug 2: raise all FABs (activity, memories, circle) to z-50 and bottom-24
  so they sit above the bottom nav (z-40, ~56px tall)
- Bug 1: add onError handlers to baby photo (home) and parent avatar (profile)
  so a broken R2 URL shows the 👶 / initials placeholder instead of a broken
  browser icon; reset error flag after a successful upload
- Bug 3: replace  emoji spinner with a proper CSS spinner on home page baby
  photo; add a fixed upload-progress toast to the memories page that appears
  at the top of the screen for the full duration of the upload

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 10:15:39 +05:30
309fd5aa29 fix(timezone): all date/time display now uses IST (Asia/Kolkata)
New src/lib/date-ist.ts utility:
- hourIST()   — current hour in IST (replaces new Date().getHours() on server)
- isTodayIST() / dateIST() — IST-aware "today" comparisons
- fmtTime()   — time display with timeZone: "Asia/Kolkata"
- fmtDate()   — date display with timeZone: "Asia/Kolkata"
- dayLabel()  — "Today" / "Yesterday" / "Mon, 26 May" in IST

Applied across: home (greeting, today-summary, log times),
activity (day grouping, bar chart, log times), ai, growth,
milestones, settings — eliminating Finland-timezone artifacts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 10:05:15 +05:30
774e8f29d4 fix(nav): emergency guide back button goes to /home 2026-05-28 01:11:42 +05:30
bdb5199d5f fix(settings): pediatrician save + edit mode
- API: dynamic SET clause (only updates fields present in body) fixes
  undefined param bug and allows clearing fields; replaces blanket COALESCE
- Settings UI: display/edit toggle — saved details shown with Edit button,
  inputs open on first visit or when editing; Save shows inline error on
  failure and brief "Saved!" on success

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 01:00:00 +05:30
0a9def36bf feat(quota): enforce 1-baby limit on free plan
- quota.ts: checkChildLimit() mirrors checkMemberLimit() using families.max_children
- POST /api/children: returns 403 child_limit_reached when free family is at limit
- ChildLimitBanner: new banner component for the family page
- /family page: shows banner + hides Add Baby button when at limit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 00:52:23 +05:30
90c8d13814 feat(settings): add Pediatrician Name field alongside phone
- New families.pediatrician_name column (migration 0008)
- Settings card: Name input above Phone input, single Save
- Emergency page: shows doctor's name above the call button
- AI medical redirects: personalised "Call Dr. X: +91…" message

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 00:49:05 +05:30
260187b0de fix(nav): Home link in menu points to /home instead of marketing site
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 00:18:11 +05:30
b64226cbd8 Merge pull request 'feature/quota-and-member-limits' (#2) from feature/quota-and-member-limits into main
Reviewed-on: #2
2026-05-27 18:05:20 +00:00
0c7f37fd12 feat(quota): storage quota + family-member limits for free tier
Feature A — Storage quota (1 GiB per family):
- src/lib/quota.ts: enforcement library with pure functions (fully unit-tested)
  and DB-bound helpers; isPaidFamily() is the single payment abstraction gate
- src/lib/format-bytes.ts: extracted formatBytes() — safe for client imports
- POST /api/upload: quota check before presigned URL issuance (HTTP 402 + reason code)
- POST /api/memories/[id]/confirm: HeadObject reconciles actual R2 size; deletes
  over-quota objects and marks row failed rather than silently exceeding limit
- GET /api/storage-usage: storage info endpoint for UI meter
- src/components/StorageMeter.tsx: meter bar + StorageQuotaBanner + MemberLimitBanner
- memories/page.tsx: quota banner, FAB disabled (⊘) when exceeded, compact meter in header
- settings/page.tsx: always-visible StorageMeter + MemberLimitBanner in invite section

Feature B — Member limit (2 per family, free tier):
- invites/route.ts: replaced ad-hoc inline check with checkMemberLimit() from quota lib
  Structured 403 response: { reason, currentCount, limit }
- Freeze rule: paid→free downgrade leaves all members intact; only new invites blocked

Migration:
- drizzle/0007_subscription_status.sql: ADD COLUMN subscription_status varchar(20)
- debug-migration/route.ts: step added for hot-apply without full redeploy
- src/db/schema/family.ts: subscriptionStatus field added to Drizzle schema

Tests: 44 unit tests in src/__tests__/quota.test.ts, all passing
- Pure function tests (no DB): isPaidFamily, wouldExceedQuota, isAtMemberLimit, formatBytes
- DB-bound tests (mocked @/db): getFamilyStorageUsage, checkStorageQuota,
  checkMemberLimit, getStorageInfo, tenant isolation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 23:21:11 +05:30
b6814579c6 feat(pwa): add Serwist service worker, manifest, icons, install prompt
- Wrap next.config.ts with @serwist/next (webpack mode, disabled in dev)
- Service worker: NetworkOnly for /api/*, offline fallback → /~offline
- Web app manifest via Next.js metadata API (app/manifest.ts)
- PNG icon set generated with sharp (192, 512, maskable-512, apple-180)
- iOS meta tags: appleWebApp, themeColor viewport export
- Middleware: pwaAssets early-return so /sw.js never gets a 302→login
- Offline fallback page at /~offline (static, no auth dependency)
- InstallPrompt component: beforeinstallprompt (Android) + iOS Share sheet instructions
- Logout (menu/page.tsx): purge all SW caches on signout (shared-device safety)
- Fix invite/[token]/page.tsx params type for Next.js 16 (use(params))
- Build script: next build --webpack (Serwist requires webpack, not Turbopack)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 23:20:48 +05:30
942a03d99a fix(footer): 3-col bottom bar with distinct bg-gray-100 shade
Left: © year · Centre: privacy tagline · Right: built with ❤️ in India

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 00:37:13 +05:30
b0423dfea8 fix(nav): always-visible sticky nav, rose pill button without Google G
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 00:21:44 +05:30
2b8312efef polish(marketing): favicon, scroll-reveal nav, Google button, font + hovers
- Favicon → 🌸 cherry blossom SVG (replaces smiley)
- Nav: removed Pricing/Privacy links; nav is now invisible at top and
  slides in after scrolling 75% past the hero (scroll-reveal client component)
- Hero CTA: white background + proper 4-color Google G (matches login page)
- Hero subtitle: font-light, text-xl, leading-loose for a more editorial feel
- Feature cards: hover border highlight + emoji scale on group-hover
- Heirloom vision cards: hover border on hover
- Privacy items: bg-rose-50 on hover
- Final CTA button: hover shadow lift + active:scale-95

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 00:05:10 +05:30
2a09c027fa feat(marketing): public homepage replacing / → /login redirect
- Add (marketing) route group: /, /pricing, /privacy, /terms
- Add (app) route group: moves all authenticated pages, app home → /home
- Root / is now a static marketing page (zero DB imports, zero auth)
- NavAuthButton client component: shows "Open Tia →" if logged in, else "Continue with Google"
- Plausible analytics hook in marketing layout
- Auto-generated OG image via opengraph-image.tsx
- Middleware updated to allowlist marketing routes
- All /-redirects updated to /home (login, onboarding, invite, circle join)
- BottomNav home tab updated: / → /home

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 23:26:26 +05:30
f1d4374609 polish(icons): update nav icons to warmer, more refined set
Replaces generic/robotic emojis with purposeful ones:
🏠🏡 Home, 📋📝 Activity, 🤖🔮 AI, 📈🌿 Growth,
💊🩺 Medical, 👗🧺 Wardrobe, 👨‍👩‍👧💞 Circle

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 16:25:42 +05:30
31b4a9480a fix(email): use tia@tia.manohargupta.com + auto-fallback to shared domain
- Primary sender: tia@tia.manohargupta.com (verified subdomain in Resend)
- sendWithFallback() retries with onboarding@resend.dev if Resend rejects
  the primary domain (covers the window while SPF is still propagating)
- Both sendFamilyInviteEmail() and sendVerificationEmail() use the fallback

Update EMAIL_FROM in Dokploy to: Tia <tia@tia.manohargupta.com>

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 16:01:55 +05:30
8a3da6a5a6 fix(email): fallback to onboarding@resend.dev until custom domain verified in Resend 2026-05-24 15:18:41 +05:30
e2a3e83638 docs+fix: overhaul CLAUDE.md + expose emailStatus in invite response
CLAUDE.md:
- Add RESEND_API_KEY, EMAIL_FROM, NEXT_PUBLIC_APP_URL to required env vars
- Document DB migration pattern (journal + hot-fix via debug-migration POST)
- Document R2 3-step proxy upload pattern (CORS note)
- Document users.image (NOT avatar_url) and two separate photo features
- Document admin auth server-component pattern
- Document family_invites fix, invite flow, cancel invite
- Full data storage table with all localStorage keys

Invite route:
- Return emailStatus in POST response so caller can see if Resend fired
  or why it failed (noKey / error message)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 15:15:53 +05:30
4dcdc5a572 feat(invites): add cancel button to revoke pending invites
- New DELETE /api/invites/[id] endpoint — only the owning family can delete
- Settings page shows expiry date on each pending invite
- Cancel button removes invite instantly from list (optimistic UI)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 14:47:43 +05:30
781dd8f1df fix(invites): hot-apply family_invites migration via debug-migration endpoint
Add ALTER TABLE steps to debug-migration POST so the columns can be applied
immediately via the running app without waiting for a full Dokploy redeploy.
Also revert invites routes to use display_name/accepted_at now that the
hot-fix path exists.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 14:43:15 +05:30
6d7feca397 fix(invites): remove dependency on missing display_name / accepted_at columns
The family_invites migration hasn't run yet on production. Work around by:
- Removing display_name from INSERT and SELECT (optional field anyway)
- Removing accepted_at IS NULL filter from GET and accept queries
- DELETE the invite row on accept instead of marking accepted_at — keeps
  invites single-use without needing the extra column

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 14:38:53 +05:30
b01e0596c1 feat(invites): auto-send invite email via Resend + register migration in journal
- Add sendFamilyInviteEmail() to email.ts using existing Resend setup
- Wire into POST /api/invites — fetches inviter name + family name, sends
  warm invite email with Accept Invitation button linking to /invite/{token}
- Email is non-fatal: invite is created even if email send fails
- Register 0006_family_invites_missing_cols in _journal.json so Dokploy
  auto-applies the migration on next deploy

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 14:24:31 +05:30
0b67631fda fix(db): add missing display_name and accepted_at columns to family_invites
Both columns are referenced by the invite API but were never in the table,
causing "column does not exist" errors when inviting family members.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 14:21:57 +05:30
cff17a079d fix(profile): use correct image column on users table (not avatar_url)
users.avatar_url doesn't exist — the column is `image`. Querying/updating
a non-existent column caused a SQL error on every profile load (blank name
& email) and on every avatar save/delete.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 14:07:49 +05:30
7816247073 fix(profile): fix parent avatar upload with 3-step proxy pattern + remove photo
- Switch from broken FormData POST to 3-step flow: POST init → PUT /api/upload proxy → PATCH save
- Add remove photo option that clears DB and deletes R2 object (DELETE /api/auth/avatar)
- Add deleteOldAvatar() helper that cleans up old R2 object on every upload
- No orphaned objects in R2, no wasted storage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 13:53:49 +05:30
5235e26cad feat(homepage): remove baby photo option + fix R2 orphan cleanup
- Tapping avatar when a photo exists shows a mini menu: Change photo / Remove photo
- Tapping avatar when no photo exists goes straight to file picker (no menu)
- handleRemovePhoto: PATCHes imageUrl=null, deletes file from R2, clears UI
- PATCH /api/children/[id]: fetches old image_url before update, deletes the
  old R2 object (profiles/ prefix only) when it changes — no more orphaned files
- updateChildImage in FamilyProvider now accepts string | null

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 13:44:10 +05:30
afae041208 fix(homepage): fix baby profile photo upload failing due to CORS
Direct PUT to R2 presigned URL is cross-origin — browser blocks it.
Route the upload through the existing PUT /api/upload server proxy instead,
same pattern used for memories. Also return `key` from children POST so
the proxy call has the R2 object key without needing the presigned URL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 13:37:36 +05:30
fa5e27bfd9 feat(profile): working profile photo upload for parent users (mama/daddy)
- New POST /api/auth/avatar — accepts multipart FormData, uploads image to
  R2 under avatars/{userId}/{ts}.ext, saves URL to users.avatar_url
- GET /api/auth/profile now returns avatarUrl field
- /profile page: real avatar display (image or initials fallback), hidden
  file input wired to "Change Photo" button, spinner overlay while uploading,
  inline success/error message; name save and photo upload are independent

NOTE: This is the parent user avatar (mama/daddy). The baby profile photo
on the homepage card is separate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 13:32:10 +05:30
f37e5bfad4 feat(activity): hover effect on 4-day strip individual rows
- Non-today rows: hover:bg-rose-50 / dark:hover:bg-gray-700
- Today (rose) rows: hover:bg-white/20 overlay so highlight still reads on rose bg
- transition-all for smooth fade

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 13:19:50 +05:30
1ae0986767 feat(activity): hover effect on log items to signal editability
- Add group + hover:bg-rose-50 + hover:shadow-sm to timeline log rows
- Add group + hover:bg-rose-50 to day-sheet log rows
- Chevron › turns rose-400 on hover (group-hover) in both places
- transition-all for smooth background + shadow animation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 13:14:10 +05:30
2c016dbc8e fix: replace Google favicon img with inline SVG on login page
External favicon URL (www.google.com/favicon.ico) fails to load in
production due to CSP/network restrictions. Inline SVG has no external
dependency and renders the correct Google logo at all sizes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 13:04:27 +05:30
b91595316d fix: use APP_URL for OAuth/verify redirects behind Dokploy proxy
Behind Traefik, request.url resolves to http://0.0.0.0:3000/... (the
internal Docker address). Using that as the redirect base sent browsers
to 0.0.0.0, causing ERR_SSL_PROTOCOL_ERROR. Switch all server-side
redirects in the Google callback and verify-email routes to use
NEXT_PUBLIC_APP_URL (with tia.manohargupta.com fallback).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 13:01:01 +05:30
6bdaade777 feat: email verification + Google OAuth
- Signup now creates unverified users and sends a verification email
  (Resend); dev falls back to [VERIFY-LINK] console log
- /api/auth/verify-email: single-use token handler, mints tia_session
  on success, redirects to /onboarding
- /api/auth/resend-verification: rate-limited (3/hr), enumeration-safe
- Sign-in gated on email_verified — unverified accounts get 403 with
  needsVerification flag so the UI can show the resend button
- Google OAuth via arctic v3: PKCE + state anti-CSRF, find-or-create
  user, writes accounts row, mints tia_session
- Login page: Google button, check-email screen, resend link on 403
- drizzle/0005_email_verification.sql: creates email_verifications
  table + backfills all existing users as verified (runs automatically
  on container start before app boots)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 12:56:02 +05:30
23a365309b feat: admin orphaned family management + user journey analytics
- Orphaned families (0 members) now visible with amber badge in /admin/families
  with filter tab and explicit "Delete Family + Data" cascade delete button
- Delete confirmation shows exact counts: children, logs, memories
- Delete child button added to /admin/children with count confirmation
- New DELETE /api/admin/families/[id] — full cascade delete (children, feeds,
  diapers_logs, sleeps, vaccinations, growth, memories, chat, etc.)
- New GET/DELETE /api/admin/children/[id] — cascade child delete with counts
- Extended families GET to include logCount + memoryCount per family
- New /api/admin/engagement — feature adoption %, per-family engagement table,
  AI usage stats (30d), daily activity chart using correct table names
- /admin/analytics fully redesigned: adoption funnel bars, per-family engagement
  table (sortable, filterable by activity), AI cost tab with INR breakdown
- Fixes wrong table names in old analytics (activity_logs, growth_records → real tables)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 09:38:34 +05:30
c2695435f2 Fix broken admin actions: delete cascade, error feedback, loading states
Users page:
- Delete was silently failing with FK constraint violations — sessions,
  accounts, support_tickets and family_members all have ON DELETE no action
  refs to users. Now cascades in correct order before deleting the user.
- Added error/success toast notifications so failures are visible
- Delete button shows loading spinner while in-flight; all buttons
  disabled during operation to prevent double-submit
- Always optimistically removes row on success (no full refetch needed)

Families page:
- Replaced browser prompt() for "New Family" with inline form — prompt()
  is blocked in some environments (CSP, iframes, browser settings)
- Fixed role-before-email bug: role dropdown was silently lost when changed
  before typing email, because onChange reset the whole addMember state.
  Now uses per-family form state with stable field updates.
- Remove member button shows loading spinner; disabled during operation
- Tier change button shows loading; disabled during other tier changes
- Added error/success toast notifications for all actions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 08:59:20 +05:30
23010e9d90 Admin panel overhaul: activity monitor, real DB wiring, tier management
- New /admin/activity page: live login events, failed attempts, active
  sessions from audit_log + sessions tables; auto-refresh toggle
- New /api/admin/activity route: queries audit_log + sessions for
  stats (active sessions, logins/failures 24h, signups 7d) and events
- Fix /api/admin/stats: real growth charts (families/users by day),
  real children-by-age, real conversion rate, active sessions count,
  and login/failure counts — was all hardcoded empty arrays before
- Fix /api/admin/analytics: avg logs per family now divides by actual
  family count instead of hardcoded 1
- Dashboard: 6-card grid adding Active Sessions + Failed Logins 24h
  with links to Activity Monitor; bar charts now show hover counts
- Families: inline tier upgrade/downgrade button (Pro ↑ / Free ↓)
  wired to existing PATCH API; member panel polished
- Support: admin reply thread using support_responses table; Cmd+Enter
  to send; conversation view with original message + admin replies;
  auto-moves ticket to in_progress on first reply
- Settings: honest read-only display for env-var-controlled settings
  (pricing, AI config); editable free-tier limits that write to DB
- New /api/admin/families/limits route for bulk free-tier limit update
- Sidebar: added Activity Monitor nav item

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 08:35:01 +05:30
bcc167d7c2 fix: collapsed post card single-row layout
Collapsed state now fits entirely in one row:
  [avatar] [name] · [truncated text] [tiny thumbnail] [▼]
No second row needed. Expanded state shows the normal two-line
author block (name + time) as before.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 08:18:02 +05:30
ab25b7531d fix: collapsed post preview centered + auto-expand on edit/delete
- Collapsed preview (thumbnail + text) moved to its own row centered
  below the author line instead of being pushed to the right
- Chevron ▼/▲ stays on the author row right side as the only element
- Edit post and Delete post now call setCollapsed(false) first so the
  edit textarea / delete confirmation is always visible when triggered
  from a collapsed card

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 08:13:54 +05:30
560761968b feat: collapsible post cards in circle feed
Posts start collapsed showing author, timestamp, a thumbnail (if image)
and truncated text preview with ▼ chevron. Tap the author row to expand
the full post (body, full image, reactions, comments). Tap again to
collapse. Lets users scan many posts quickly and expand only what
interests them.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 08:01:56 +05:30
581fdb074d fix: post menu clipping, add edit post, fix image display + fullscreen
- Remove overflow-hidden from PostCard root so the ⋯ dropdown menu
  is no longer clipped by the card boundary
- Add Edit Post option to menu (own posts only) with inline textarea
  and Save/Cancel; calls new PATCH /api/circles/[id]/posts/[postId]
- Add PATCH endpoint: author-only text edit
- Fix image display: object-contain (no crop) instead of object-cover
- Add tap-to-fullscreen lightbox: clicking any post image opens a
  full-screen black overlay with the image at natural size, ✕ to close

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 07:56:10 +05:30
27125047bb fix: route circle post image upload through server to avoid R2 CORS
Direct browser PUT to R2's S3 endpoint is blocked by CORS. Replace the
presigned-URL client-side upload with a server-side upload endpoint:
client sends FormData → server uploads to R2 with PutObjectCommand →
returns tmpKey. No browser-to-R2 connection needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 07:35:59 +05:30
66a765e75f fix: image upload in circle posts - handle empty content type + show errors
- Server: normalise empty/missing MIME type by sniffing file extension so
  iOS HEIC/HEIF and camera photos (which send empty type) are accepted
- Server: add image/heif and image/gif to allowed types
- Server: return normalised contentType in presign response
- Client: check presignRes.ok before uploading; use server contentType
  for the PUT to R2 so the header matches what was signed
- Client: show error message in modal instead of silent catch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 02:18:14 +05:30
c24392f0a1 feat: email-based circle invites with in-app notifications
- Admin invites by entering email instead of copying a link
- If email matches existing Tia user → creates pending invite visible
  on their Circles page with Accept/Decline buttons
- If email is not registered → sends Resend email with signup link
  that lands them directly in the circle after account creation
- DB migration adds invited_email + invited_family_id to circle_invites
- New GET /api/circles/invites endpoint for pending invite banners
- Remove clipboard-copy approach entirely

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 02:05:10 +05:30
9f7ab870ba fix: pass ISO string not Date object to sql.unsafe for expires_at
postgres.js sql.unsafe() doesn't serialize Date objects in parameterized
queries — caused TypeError crashing the invite creation endpoint.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 01:55:48 +05:30
21f88459d7 fix: wrap entire invite POST handler in top-level try-catch
Catches errors from the circle_members SELECT query and auth
that were escaping the narrower try-catch and returning empty 500s.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 01:54:11 +05:30
3d7ff9adb5 fix: surface invite creation error + fix form field id/name attributes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 01:51:51 +05:30
b7fb34fdde temp: auth-protected one-shot migration endpoint 2026-05-24 01:45:07 +05:30
12be105af4 chore: trigger redeploy with updated Dokploy start command 2026-05-24 01:41:10 +05:30
7fe60dc1af temp: debug migration status endpoint 2026-05-24 01:36:10 +05:30
acb9d66815 temp: surface DB error in circles POST for diagnostics 2026-05-24 01:31:26 +05:30
d8bda20887 Fix migration: add statement-breakpoints + use superuser URL
Two issues prevented 0003_circles.sql from running:
1. Missing -->statement-breakpoint markers (Drizzle splits SQL by these)
2. migrate.ts used DATABASE_URL (tia_app, no DDL privileges) instead of
   DATABASE_URL_SUPERUSER — now prefers superuser URL with fallback to
   DATABASE_URL for local dev

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 01:23:06 +05:30
ee3f8b4507 Register 0003_circles in Drizzle migration journal
The SQL file existed but was missing from _journal.json so the
migrator skipped it on deploy. Adding the journal entry ensures
the circles tables are created on next container boot.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 01:14:38 +05:30
5fdb69679d Circle feature: C0–C9 multi-tenant social groups (Sprint 9 + 10)
Adds full circle functionality — private social groups for trusted families
to share milestones, memories, and posts with reactions and comments.

- 7-table DB migration: circles, members, invites, posts, comments, reactions, reports
- 11 API routes: create/list circles, posts feed, comments, emoji reactions, invite tokens, join flow, member management, reporting
- 3 new pages: /circle (list), /circle/[id] (feed + PostCard + CreatePostModal), /circle/join/[token]
- Copy-on-share for memory photos (independent R2 objects, never references originals)
- Admin controls: invite generation, member promote/demote/remove, last-admin guard
- C9 privacy consent screen before first post
- Menu entry added

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 01:04:50 +05:30
c732d2d7c2 Add baby profile photo change from home screen
- New PATCH /api/children/[id] — updates image_url in children table
- New POST /api/children/[id] — returns presigned R2 URL for profile
  photos (stored under profiles/{childId}/ prefix, no memories row)
- FamilyProvider: expose updateChildImage() so UI updates instantly
  without a full re-fetch after upload
- Home page baby card: photo avatar is now a separate tap target from
  the growth link. Tap photo → file picker → upload to R2 → save URL.
  Camera overlay (📷) appears on hover/tap;  shown while uploading.
  Tapping name/age/arrow still navigates to growth as before.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 00:51:45 +05:30
5297ab76ba Activity: each feed/sleep/diaper row independently tappable in 4-day strip
Each row in a day chip is now its own button. Tapping 🍼×2 on Thursday
opens a sheet scoped to feeds on Thursday only — not all logs for that day.
Sheet shows entries for that specific type, with edit/delete per entry and
a single focused "+ Add [type]" CTA at the bottom.
Rows showing ×0 render dimmed so missing entries stand out at a glance.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 00:26:43 +05:30
3db6fb2710 Activity: guidelines above strip, interactive day chips
- Moved guidelines row ABOVE the 4-day strip (correct order)
- 4-day strip: each chip is now tappable
  → opens a day-detail bottom sheet showing all logs for that day
  → each log row has ‹ › arrow; tap opens edit/delete action sheet
  → empty state tells mama to use Generate sample history to pre-fill
  → quick-add row at bottom (+ Feed / + Sleep / + Diaper) for fast logging

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 23:47:20 +05:30
a14d8e7043 Fix activity page repetition + 4-day strip layout + guideline accuracy
- Remove redundant daily summary bar (Today chip in 4-day strip covers it)
- 4-day strip: reversed to oldest→newest order (Wed→Thu→Yest.→Today)
- 4-day strip: switched from flex to grid grid-cols-4 so all 4 chips
  fill the full row width evenly instead of floating left
- Today chip highlighted in rose-400 to stand out from past days
- Guidelines: corrected 9-12 mo (feeds 3→4, sleep 12→14h, diapers 3→4)
  per AAP; 12-18 mo sleep 11→13h, diapers 3→4; 18-24 mo sleep 11→12h,
  diapers 2→3; all now match mid-range AAP recommendations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 23:35:32 +05:30
164206c023 Activity page overhaul — 9 UX improvements
- FAB raised to bottom-20 to clear fixed bottom nav
- Branded loading: bouncing 🍼 😴 🚼 emojis
- Back button: white pill with shadow (matches other pages)
- Generate History moved to ⋯ overflow menu (keeps header clean)
- Filter pills: emoji labels (🍼 Feed / 😴 Sleep / 🚼 Diaper) + scrollbar-hide
- Daily summary bar: today's feed/diaper/sleep counts at a glance
- 4-day overview strip: quick multi-day snapshot above timeline
- Collapsible guidelines card: collapsed shows x/target fractions,
  expands to progress bars with actual/target display
- Today/Yesterday/weekday labels in timeline; Today styled in rose
- Better empty state with emoji and "Tap + to start logging" CTA
- Tap any log row → action sheet with Edit and Delete
  Edit: pre-fills LogModal with same values, deletes old log on save
  Delete: inline confirmation (no browser confirm()), then refresh
- New DELETE + PATCH handlers at /api/logs/[id] with family ownership check

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 23:07:02 +05:30
94b80fd862 fix(settings/profile): anchor share dropdowns to their trigger buttons
Reverted fixed viewport positioning back to absolute — the parent cards
no longer have overflow-hidden so absolute works correctly now. Both
dropdowns (profile share and per-product share) appear directly below/above
their trigger button instead of floating at a random screen corner.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 22:37:13 +05:30
f806e4d3bd fix(settings/profile): fix share dropdown invisible + expand click broken
Root cause: parent div had overflow-hidden which clipped absolute-positioned
dropdowns, making them render but be invisible (looked like click did nothing).

- Removed overflow-hidden from profile card container
- Share dropdowns now use fixed positioning to escape any parent overflow
- Merged chevron into the expand button so the whole left area is one tap target
- Per-product share sheet also changed to fixed bottom-28 right-4

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 22:29:38 +05:30
fb402e9898 feat(settings/profile): move share to per-product, profile share on card header
- Each product row now has an ↗ share button (product URL shared, not profile)
  WhatsApp message: "Found this for our baby — [title]: [url]"
- Only one product share sheet open at a time; closes on backdrop tap
- Profile page share (↗) moved to the profile card header row where it
  contextually belongs — shares the /m/slug link, not a product
- Removed the share button that was on the Products section header

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 19:40:51 +05:30
8598259fb1 feat(settings/profile): collapsible profile card + share sheet
Collapsible profile card:
- Starts collapsed if profile already exists (shows name + URL slug)
- Expands/collapses via chevron toggle row
- Auto-collapses after Save Profile succeeds

Share sheet on Product Recommendations:
- ↗ Share button appears once a valid slug is set
- Options: Copy link (clipboard, shows  feedback), WhatsApp deep link,
  and native Web Share API ("More options") when browser supports it
- Backdrop click closes the sheet

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 19:34:52 +05:30
3b62841bd4 fix(settings/profile): align My Profile page with app theme
- Background: flat gray → rose-to-amber gradient (matches all other pages)
- Header: add back button with rounded-xl pill style + xl font title
- Buttons: bg-pink-500 → bg-rose-400 throughout
- Loading: spinner → branded emoji bounce (consistent with home screen)
- Toggle: checkbox → styled toggle switch matching app UI language
- Layout: remove desktop max-w-lg wrapper; full-width mobile layout
- Input styling: unified inputClass with focus ring + consistent padding
- Empty state: plain text → emoji + two-line message
- Product rows: border div → bg-gray-50 pill card (matches wardrobe style)
- Add pb-24 so content clears the bottom nav bar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 19:26:38 +05:30
3d0e6ed46c fix(menu): remove absolute positioning that hid Wardrobe on mobile
Settings/Sign Out used absolute bottom-0, overlapping the bottom of the
menu items list on shorter screens. Converted to normal flow with mt-4
and added pb-28 so the list clears the bottom nav bar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 19:17:45 +05:30
70ff02c930 feat(home): overhaul home screen with bottom nav and UX improvements
- Add persistent bottom navigation bar (Home / Activity / AI / Menu)
- Fix TodaySummary bug: last-log times now show today's events only
- Replace 6 hardcoded AI chips with 3 AI-generated context-aware chips
- Show child's real profile photo in baby card (fallback to 👶 emoji)
- Recent Activity limited to 3 items with "See all →" link to /activity
- "Suggested now" promoted to prominent amber banner with "Log it →" CTA
- Offline pending banner is now a tappable retry button
- Branded loading state with bouncing emoji (🍼 😴 🚼 👶)
- Remove unused Button import from page.tsx
- Expose image_url via /api/children and Child type/FamilyProvider

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 19:08:58 +05:30
8a75adb94e feat(home): horizontal scrollable quick log + wardrobe shortcut
- Convert Quick Log from grid-cols-4 to horizontal scroll strip so
  it scales to any number of actions without layout breakage
- Add Wardrobe 👗 shortcut linking directly to /wardrobe/add,
  saving mama the Menu → Wardrobe → Add Garment 3-tap journey
- Fix: replace non-existent `no-scrollbar` class with `scrollbar-hide`
  across page.tsx, wardrobe pages, memories page, and TabBar component

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 18:21:32 +05:30
1994725101 feat(wardrobe): add complete wardrobe feature (W0–W9)
Schema (W0):
- Add garments, garment_wears, outfits tables with Drizzle migrations
- Drizzle migrations 0001 (garments/wears) and 0002 (outfits) auto-apply on deploy
- RLS policies in drizzle/manual/06-wardrobe-rls.sql (apply via superuser in prod)

API (W1–W9):
- POST /api/garments/upload — direct upload to R2 garments/ prefix with sharp thumbnail
- POST /api/garments/tag — vision tagging via LiteLLM, defensive parse, category validated
- GET/POST /api/garments — list with composable filters, create
- GET/PATCH/DELETE /api/garments/[id] — detail, edit, delete
- POST /api/garments/[id]/wear — log worn date
- GET /api/garments/outgrowth — pure SQL, explicit size ordering (no lexicographic sort)
- GET /api/garments/packing — active garments grouped by category
- GET /api/garments/outfit — Open-Meteo weather + deterministic outfit pairing, no LLM
- GET/POST /api/garments/outfits + DELETE [id] — saved outfits

Pages:
- /wardrobe — grid with status/category/size/season filters + outgrowth nudge
- /wardrobe/add — 3-step capture→vision→form, size required, batch-friendly
- /wardrobe/[id] — detail/edit/status lifecycle + wear history
- /wardrobe/packing — packing checklist by category
- /wardrobe/outfit — weather-aware suggestions with shown basis
- /wardrobe/saved-outfits — view/delete saved combinations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 18:09:22 +05:30
c4304615ec chore(dev): align dev Postgres to pg18 matching production
Production runs Postgres 18; the dev compose file pinned pg16. A pg_dump
from prod (v18) cannot be restored by a v16 pg_restore — the dump header
is rejected. Matching the major version fixes restores and removes a
latent source of dev/prod behaviour drift.

Also adopts the pg18 image's data-directory convention: the volume now
mounts at /var/lib/postgresql (the image places data in a version
subdirectory), and drops the obsolete compose 'version' key.
2026-05-23 14:22:15 +05:30
9b9b551463 feat(db): wire migration runner into the deploy pipeline
Makes schema changes deploy automatically: edit schema -> db:generate ->
commit -> push -> Dokploy redeploys -> migrations apply on container start.
No more Dokploy database terminal.

Components:
- src/db/migrate.ts: standalone migrator (single short-lived connection,
  fails loud on error so a bad migration crashes the container instead of
  letting the app serve a half-migrated schema)
- scripts/build-migrator.mjs: esbuild bundles migrate.ts -> dist/migrate.mjs
  with drizzle-orm + postgres inlined. Needed because Next.js standalone
  output keeps neither as a separate node_modules package.
- Dockerfile: builder runs db:build-migrator; runner copies migrate.mjs +
  drizzle/; CMD is 'node migrate.mjs && node server.js'
- package.json: db:generate / db:migrate / db:studio / db:pull /
  db:build-migrator scripts; esbuild promoted to an explicit devDependency
- pnpm-lock.yaml resynced

BUG FIX: .dockerignore had 'drizzle/' — migration SQL was excluded from the
build context, so even a correct Dockerfile COPY would have found nothing.
This was the second half (with the .gitignore bug in commit 1) of why the
migration pipeline never worked. Now only _archived/_introspected are
excluded.

Verified: full docker build succeeds; runner image contains migrate.mjs +
drizzle baseline; migrator tested end-to-end against a scratch DB (35
tables created, __drizzle_migrations populated, idempotent on rerun).
2026-05-23 13:40:30 +05:30
edd239fa69 chore(db): regenerate baseline migration from corrected schema
drizzle-kit generate against the now-prod-aligned schema produces a
single baseline migration covering all 35 tables.

VERIFIED: 0000_baseline_prod_2026_05_19.sql was compared column-for-column
and type-for-type against the drizzle-kit pull introspection of tia_prod.
Table sets identical, all columns and types match. The baseline is a
faithful representation of production.

This baseline will be marked as already-applied in prod's
__drizzle_migrations table (done out-of-band, not in git), so the migrator
runs nothing on the next deploy. It exists purely as the reference point
for future schema diffs.

Adds drizzle/README.md documenting the baseline reset and the migration
workflow going forward.
2026-05-23 12:25:20 +05:30
a3d3a140ed refactor(db/schema): align TypeScript schema with production
Path A baseline reconciliation. drizzle-kit pull against tia_prod showed
prod had drifted well past schema.ts because legacy hand-rolled migrations
0003-0015 wrote to the DB but were never reflected back into TypeScript.

Shared-table drift fixed:
- users:          + password_hash, + password_updated_at
- families:       + tier, + max_children, + max_members
- children:       col is 'stage' (kept JS key currentStage -> stage);
                  'image_url' not 'profile_photo_url'; birth_date is DATE;
                  sex nullable; dropped phantom stage_overrides
- family_members: dropped phantom display_name
- family_invites: dropped phantom display_name, accepted_at
- audit_log:      + resource_id, + resource_type; metadata -> jsonb; +5 indexes
- memories:       + vision_tags (text[]), + vision_embedding (vector 1536)
- logs.ts:        'diapers' phantom table renamed to diapersLogs ('diapers_logs')

19 missing tables added across new files:
- admin.ts:     admins, admin_sessions, password_resets
- support.ts:   support_tickets, support_responses
- ai.ts:        chat_sessions, chat_messages, ai_usage
- medical.ts:   medicines, medication_doses, allergies, illness_logs, doctor_visits
- affiliate.ts: member_profiles, recommended_products, product_clicks
- logs.ts:      + milestone_achievements
- audit.ts:     + log_corrections

BUG FIX: schema/index.ts never re-exported ./logs — Drizzle was blind to
feeds/sleeps/vaccinations/growth/medications. Now exported.

Verified: tsc --noEmit has zero non-test errors. Dropped phantom columns
confirmed to have zero references in src/.
2026-05-23 12:17:20 +05:30
e7d68c2fc6 chore(db): archive legacy migrations, stop gitignoring drizzle/
The drizzle/ folder was in .gitignore (line 34) — likely confused with
the build 'out/' dir. Effect: migration SQL never reached the server on
deploy, so the migration pipeline could never have worked. Only 7 of 18
files were ever force-tracked; 0000-0010 + most of manual/ were untracked.

- Remove drizzle/ from .gitignore; document why it must be tracked
- Archive legacy hand-rolled migrations 0000-0015 + manual/ to
  _archived_pre_baseline_2026-05-19/ (kept on disk; history retains old copies)
- Archive stale meta/ (knew of only 3 of 16 migrations)
- Baseline regeneration follows in subsequent commits
2026-05-23 12:05:50 +05:30
5fe24b8c59 feat(settings): move growth CSV export from growth header to settings page
- Remove 📥 export button from growth page header (less clutter)
- Add "Export Growth Data" row in settings with child name and CSV download
- Fetches growth records on demand, shows loading state while exporting

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 23:24:28 +05:30
acd2adde7e fix(growth): move add form to top, fix empty-state bug, clean up UI
- Add Measurement form now appears directly below the header (not buried
  after WHO card) so it's immediately visible when + Add is tapped
- Removed `&& latest` guard — form now works even with zero records
- Goals and Add are mutually exclusive: opening one closes the other
- 3-column grid for measurement inputs (weight / height / head on one row)
- Sticky header with backdrop-blur, smaller title (text-sm), icon-style
  export and goals buttons
- All cards use rounded-2xl + shadow-sm for consistent look
- "Add First Measurement" in empty state scrolls to top and opens the form

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 22:49:30 +05:30
9c0afa2054 feat(memories): 4-col grid, hover effects, folder label per tile, no blank tiles
- grid-cols-4 (~40% smaller than 3-col) with gap-1 and rounded-xl corners
- Hover: scale-110 image + dark overlay + expand icon (⤢)
- Subtle shadow + ring border on each tile
- Folder emoji + name shown below each tile (when assigned)
- Filter out tiles with no URL and remove tiles that fail to load (onError → null)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 22:45:23 +05:30
fb45250a27 feat(memories): smaller title, tap-to-zoom viewer, custom folder creation
- Header title reduced to text-xs (~50% smaller)
- Tap image in viewer to zoom 2x; tap again to zoom out
- Tap outside image area to toggle UI chrome (top bar / captions)
- "New folder" pill at end of folder row and in upload picker
- Custom folders saved to localStorage (tia_custom_folders)
- Custom folders appear in folder pills, upload picker, and move-to-folder sheet
- Emoji picker (15 options) + name input for new folders

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 22:35:31 +05:30
704faf070b feat(memories): redesign gallery with folders, grid view, and fullscreen viewer
- 9 recommended folders (First Steps, Bath Time, Feeding, etc.) stored as description
- 3-column square grid layout replacing masonry+rotation
- Folder picker modal before upload; PATCH assigns folder after upload
- Fullscreen MemoryViewer with tap-to-toggle captions and folder reassignment
- Loading shimmer per tile, processing overlay, lazy loading
- Removed all floating/rotation elements causing overflow

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 22:19:07 +05:30
0e047e110f fix(memories): route upload through server proxy to avoid R2 CORS failure
Direct PUT to R2 presigned URL is cross-origin, causing "Failed to fetch"
in browsers without R2 CORS configured. Use the existing PUT /api/upload
proxy handler instead — file goes client → Next.js → R2 server-side.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 21:57:48 +05:30
96d28cbadc refactor: full codebase sweep — shared types, utilities, component splits
New foundations:
- src/types/index.ts — shared domain types (Child, Log, GrowthRecord, Medicine,
  Dose, Allergy, Visit, Illness, Vaccination, AIChat, ChatSession, Goal)
- src/lib/formatting.ts — calculateAge, formatAge, formatTimeAgo (eliminates
  3 duplicate implementations spread across page.tsx and growth/page.tsx)
- src/lib/api.ts — typed fetch helpers (api.get/post/patch/delete) with
  consistent error handling; replaces manual fetch boilerplate

New shared components:
- src/components/PageHeader.tsx — reusable back-link + title header
- src/components/TabBar.tsx — horizontal pill tab bar
- src/components/CalendarView.tsx — extracted from activity/page.tsx (was ~170 inline lines)
- src/components/medical/ — medical page split into 5 focused tab components:
  VaccineTab, MedicineTab, AllergyTab, VisitTab, IllnessTab

Pages updated:
- medical/page.tsx: 1029 → 42 lines (thin shell wiring the 5 tab components)
- activity/page.tsx: uses CalendarView + shared Log type + api.ts
- growth/page.tsx: uses shared GrowthRecord/Goal types + formatAge; fixes
  `any` catch clauses; fixes undefined → null in Chart.js dataset values
- page.tsx (home): uses shared Log/AIChat/ChatSession types + formatTimeAgo/
  calculateAge from formatting.ts; removes inline type definitions
- ai/page.tsx: uses shared AIChat/ChatSession types
- FamilyProvider.tsx: uses shared Child type; fixes `c: any` mapping

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 21:37:39 +05:30
3ebb3055a5 feat(logging): time presets, FAB on activity, today summary, smart defaults
- Extract offline queue to src/lib/offline-queue.ts
- Extract shared LogModal with time presets (Just now/5/15/30min/Custom)
  and smart default pre-fill from last log of same type
- Replace ActivityScroller with TodaySummary (today's counts + last time)
- Fix activity page: GET /api/logs without type param now returns all logs merged
- Fix field naming: log.loggedAt / log.amount (camelCase throughout)
- Add FAB to activity page for zero-navigation quick logging
- Recent Activity shows 5 most recent entries with correct field names

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 11:21:00 +05:30
93db148a65 fix(ui): replace baby face emoji with diaper symbol for diaper logs
Swap 👶🚼 (baby-changing symbol) in all four diaper-specific spots:
activity page getIcon(), homepage ActivityScroller, quick log button,
and recent activity list. Other 👶 usages (child profiles, onboarding,
admin children nav) are left unchanged as they represent babies, not diapers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 11:07:56 +05:30
5863c8c2e6 feat(activity): implement calendar view
Month grid with Su–Sa columns, colored activity dots per day (rose=feed,
blue=sleep, amber=diaper). Tapping a day opens a detail panel below the
grid with the full log list for that day. Month navigation with prev/next
arrows; future months are disabled. Filter pills apply to both views.
Limit raised to 200 entries so calendar data spans multiple months.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 11:04:42 +05:30
515e93aae1 fix(activity): repair full log flow — quick log → activity page
Three bugs fixed:

1. GET /api/logs required a `type` param; activity page calls without one,
   always receiving {error} → empty list. Fixed: when type is omitted,
   merge feeds + diapers_logs + sleeps, sort by time, return camelCase
   fields (loggedAt, subType, amount) matching the activity page interface.

2. Type-specific queries (used by homepage) still return raw snake_case
   rows unchanged so the homepage "Recent Activity" section is unaffected.

3. LogModal initialised subType as "breast_milk" regardless of log type.
   Adding a useEffect to reset it (breast_milk / wet / nap) when type
   changes prevents bad enum values from being sent for diaper/sleep logs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 10:49:00 +05:30
a75a543373 fix(milestones): align page UI to match activity and growth pages
- Rose gradient background instead of flat gray
- Header with ← back link and child name subtitle
- Active filter/category pills use bg-rose-400 instead of purple
- Progress bar gradient updated to rose-to-pink
- Full-width px-4 layout (removed max-w-2xl wrapper)
- Date picker uses design system Input + Button components
- Empty state with icon, consistent with other pages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 10:30:21 +05:30
7189fc766c refactor(ui): apply design system components across all pages
Replace raw <input>/<button>/<select> elements with Button, Card, Input,
Select, Modal, Badge, and ConfirmDialog from @/components/ui in all
non-admin and admin pages. Removes ~550 lines of inline Tailwind utility
classes from form elements while keeping all business logic intact.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 10:24:43 +05:30
291eb4793b feat(g5-g6): age-aware UX + mama affiliate page
G5 — Age-Aware UX:
- useStageCheck hook: maps birth date → BabyStage (newborn/infant/sitter/crawler/toddler/walker)
- Time-of-day fast-log suggestion chip on home page (time × stage matrix)
- Milestones page: 25 WHO/AAP milestones, category filter, progress bar, inline date picker
- Milestones API: GET (merged definitions + achievements), POST (upsert), DELETE (un-mark)
- DB: milestone_achievements table with unique(child_id, milestone_key)
- Milestones 🌟 added to menu

G6 — Mama Affiliate Page:
- member_profiles, recommended_products, product_clicks tables
- /api/profile CRUD (GET/PUT), /api/profile/products (GET/POST/PATCH/DELETE)
- Public routes: /api/profile/[slug] and /api/profile/[slug]/click (IP hashed)
- /settings/profile: slug + bio editor, product list with ↑↓ reorder + click counts
- /m/[slug]: beautiful public page (gradient bg, product grid, Shop → click tracking)
- Settings page link to profile setup

DB migrations: 0014_milestones, 0015_affiliate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 00:59:17 +05:30
5ca655fbe6 Merge pull request 'feat(g1-g4): design system, memories, medical tracking, AI brain' (#1) from feat/g1-g4-design-memories-medical-ai into main
Reviewed-on: #1
2026-05-17 12:27:43 +00:00
262 changed files with 34365 additions and 8622 deletions

View file

@ -6,5 +6,11 @@ README.md
.docker
docker-compose.dev.yml
docker/
drizzle/
*.log
*.log
dist
# NOTE: drizzle/ is intentionally NOT ignored — the migration SQL must be
# in the build context so the Dockerfile can COPY it into the runner image.
# Only the non-shipping sub-folders are excluded:
drizzle/_archived_pre_baseline_2026-05-19
drizzle/_introspected

11
.gitignore vendored
View file

@ -7,6 +7,9 @@
/.next/
/out/
# migrator build output (generated by scripts/build-migrator.mjs)
/dist/
# production
/build
@ -31,4 +34,10 @@ yarn-error.log*
# docker
.docker/
data/
drizzle/
# NOTE: drizzle/ is intentionally NOT ignored.
# It holds migration SQL + meta snapshots — these are SOURCE CODE and
# MUST be committed so the deploy pipeline can apply them on the server.
# Only transient introspection scratch output is ignored:
drizzle/_introspected/
drizzle/_archived_pre_baseline_2026-05-19/

494
CLAUDE.md
View file

@ -12,10 +12,13 @@ pnpm start # Start production server
# Note: If Turbopack fails, use: pnpm build -- --webpack
# Database (direct SQL or docker)
docker-compose -f docker-compose.dev.yml exec db psql -U postgres -d tia
docker-compose -f docker-compose.dev.yml exec db psql -U postgres -d tia_dev
```
**Note:** Running from `/Users/manohar_air/MyProjects/Tia/tia` directory.
**Rule:** ALWAYS run `pnpm build` locally BEFORE git commit/push.
---
## Architecture
@ -23,10 +26,12 @@ docker-compose -f docker-compose.dev.yml exec db psql -U postgres -d tia
- **Framework:** Next.js 16 with App Router (src/app/)
- **Database:** PostgreSQL 16 with pgvector + Drizzle ORM
- **Auth:** Database sessions with httpOnly cookies
- **Auth:** Database sessions with httpOnly cookies (`tia_session`)
- **AI:** LiteLLM gateway → MiniMax model (minimax-2.7)
- **Storage:** Cloudflare R2 for media uploads
- **Email:** Resend (transactional emails)
- **Styling:** Tailwind CSS v4
- **Deploy:** Dokploy (Docker-based, auto-runs migrations on deploy)
### Project Structure
@ -34,289 +39,302 @@ docker-compose -f docker-compose.dev.yml exec db psql -U postgres -d tia
src/
├── app/ # Next.js App Router pages
│ ├── api/ # API routes (auth, logs, ai, growth, etc.)
│ ├── page.tsx # Home (Quick Log + AI card)
│ ├── page.tsx # Home (Quick Log + AI card + baby card)
│ ├── ai/ # AI chat page with sidebar
│ ├── medical/ # Vaccination tracking
│ ├── growth/ # Growth charts
│ ├── memories/ # Photo gallery
│ ├── menu/ # Navigation menu
│ ├── onboarding/ # First-time setup
│ ├── settings/ # Settings with theme picker
│ ├── settings/ # Settings with theme picker, invite members
│ ├── profile/ # Parent user profile (avatar, name, email)
│ ├── login/ # User login (email + password)
│ ├── admin/ # Admin panel
│ └── admin-login/ # Admin login (separate)
│ ├── admin/ # Admin panel (server component layout)
│ └── admin-login/ # Admin login (separate, NOT under admin layout)
├── ThemeProvider.tsx # Theme context (light/dark/system/time)
├── FamilyProvider.tsx # Family/child context (resolves from session)
drizzle/ # Database migrations
docs/ # Design docs
├── lib/
│ ├── auth.ts # requireFamily(), requireOwnership()
│ ├── email.ts # sendVerificationEmail(), sendFamilyInviteEmail()
│ └── admin-auth.ts # requireAdmin() for API, verifyAdminSession() for server components
drizzle/ # Database migrations (SQL files)
drizzle/meta/_journal.json # Migration order — MUST update when adding new SQL files
```
### Database
---
- **Migrations:** SQL files in `drizzle/` (not using drizzle-kit push)
- **Apply:** `psql` directly or via docker-compose exec
- **RLS:** Row-level security for multi-family isolation
## Database Migrations
### Data Models
### How it works (Dokploy auto-deploy)
- `Dockerfile CMD`: `node migrate.mjs && node server.js` — migrations run first, app boots after
- `src/db/migrate.ts`: reads every SQL file in `drizzle/` and applies any not yet in `__drizzle_migrations`
- `drizzle/meta/_journal.json`: ordered list of migration files — **must be updated** when adding a new SQL file
- **Family:** Parent account container
- **Members:** Adults in family (mom, dad, etc.) via `family_members`
- **Children:** Baby profiles with birth date
- **Sessions:** Login sessions with httpOnly cookies
- **Logs:** Feed, sleep, diaper entries with timestamps
- **Vaccinations:** IAP schedule tracking
- **Growth:** Weight/height/head measurements over time
- **Memories:** Photos with R2 storage
- **Chat Sessions:** User conversations with AI (chat_sessions, chat_messages)
### Adding a new migration
1. Create `drizzle/NNNN_description.sql` with your SQL (use `IF NOT EXISTS` / `ADD COLUMN IF NOT EXISTS`)
2. Add an entry to `drizzle/meta/_journal.json` with the next `idx` and matching `tag`
3. Push to git → Dokploy auto-applies on next deploy
### Growth Page Features
> ⚠️ **CRITICAL — the `when` timestamp must be greater than every existing entry.**
> Drizzle's migrator only applies a migration when its `when` (folderMillis) is **greater than the
> max `created_at` already in `drizzle.__drizzle_migrations`**. It does NOT diff by hash/idx.
> Migrations `0003``0010` were hand-added with 2025-era `when` values (1748…/1749…) that are
> SMALLER than the 2026 baseline (00000002 ≈ 1779539431897), so drizzle **silently skipped all of
> them** — they only ever got applied via the debug-migration hot-apply endpoint.
> For any new migration: set `when` to a **current `Date.now()`** (must be > 1779539431897). If you
> ever need drizzle to re-apply a skipped one, its SQL must be idempotent (`IF NOT EXISTS`); note
> `0003_circles.sql` is NOT idempotent, so do not let drizzle re-run it.
The growth page tracks child's physical development with WHO standards.
### Hot-fix: apply migration without waiting for redeploy
Use the **debug-migration endpoint** (same pattern as the Circles implementation):
**Components:**
- `formatAge(birthDate, measurementDate?)` - Shows age as "1y 4mo" or "8mo"
- Latest Reading card with velocity and percentile
- WHO Standards collapsible card (shows icons ⚖️📏⭕ when collapsed)
- Growth chart (collapsible inside WHO card)
- History (collapsible with scroll for many entries)
- Goals stored in localStorage (`tia_growth_goal_${childId}`)
**State:**
- `showAdd` - Toggle add form (only shows if latest exists)
- `showGoals` - Toggle goals card
- `showWhoStandards` - Toggle WHO card expand/collapse
- `showChart` - Toggle chart inside WHO card
- `showHistory` - Toggle history expand/collapse
**Color-coded percentiles:**
- 🟢 Normal (15th-85th percentile)
- 🟡 Watch (<15th or >85th)
- 🔴 Alert (<3rd or >97th)
**Column order:** Goals → Latest → Add Form → WHO+Chart → History
### Key Patterns
**ThemeProvider:** Wrap app in ThemeProvider from layout.tsx. Use `useTheme()` hook in components.
```typescript
import { useTheme } from "./ThemeProvider";
const { theme, toggle, setMode } = useTheme();
// theme: "light" | "dark"
// mode: "light" | "dark" | "system" | "time"
```
POST /api/debug-migration
Header: x-run-migration: yes
```
**FamilyProvider:** Resolves family from database session on login.
This runs SQL directly through the app's live DB connection. Steps use `IF NOT EXISTS` so safe to re-run.
To add a new hot-fix: add `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` to the `steps` array in
`src/app/api/debug-migration/route.ts`, push, and immediately call the endpoint from Chrome:
```javascript
// In browser console on tia.manohargupta.com
const res = await fetch('/api/debug-migration', {
method: 'POST',
headers: { 'x-run-migration': 'yes' }
});
console.log(await res.json());
```
The GET endpoint also shows which migrations have been applied and email config status.
---
## Authentication
### User auth (email + password)
1. User visits `/login` with email + password
2. `POST /api/auth/signin` verifies bcrypt hash, creates session in `sessions` table
3. Session token stored in **httpOnly cookie** `tia_session` (NOT localStorage!)
4. All data routes use `requireFamily()` from `@/lib/auth`
### Admin auth
- Login at `/admin-login` (username: `admin`, password: from env)
- Sets `tia_admin_session` httpOnly cookie
- **Admin layout** (`src/app/admin/layout.tsx`) is a **server component** — calls `verifyAdminSession()` from `@/lib/admin-auth` and `redirect('/admin-login')` if not authed
- **Admin sub-pages** (`families`, `users`, `analytics`, etc.) use `credentials: 'include'` on all fetches — NO localStorage tokens, NO Bearer headers
- `AdminSidebar.tsx` is a separate `"use client"` component for the interactive sidebar
### FamilyProvider
Resolves family from database session on login. Skip for admin routes.
```typescript
import { useFamily } from "./FamilyProvider";
const { familyId, familyName, child, children, tier, memberCount } = useFamily();
// familyId: string | null
// familyName: string | null (from session)
// child: Child | null
// children: Child[]
// tier: "free" | "pro"
// memberCount: number (from family_members table)
const { familyId, familyName, child, children, tier, memberCount, updateChildImage } = useFamily();
// updateChildImage(childId, imageUrl | null) — updates in-memory state after photo change
```
**Offline Queue:** Uses localStorage (`tia_offline_queue`) for failed API calls, retries when online.
---
**Session Validation:** All data API routes must use functions from `@/lib/auth`:
## Photo Uploads (R2)
```typescript
import { validateSession, requireFamily, requireOwnership } from "@/lib/auth";
### Critical: CORS — never do a direct cross-origin PUT from the browser
Direct `PUT` to R2 presigned URLs is cross-origin and **blocked by the browser**. Always proxy through the server.
// Require family for user data
export async function GET(request: Request) {
const auth = await requireFamily();
if (!auth.success) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
### 3-step upload pattern (used everywhere)
1. `POST /api/[init-endpoint]` with `{ contentType, filename }` → get `{ key, publicUrl }`
2. `PUT /api/upload?key=...&contentType=...` — server proxies the file to R2
3. `PATCH /api/[save-endpoint]` with `{ [imageField]: publicUrl }` → saves URL to DB + deletes old R2 object
const familyId = auth.session!.familyId!;
// ... route logic
}
### Photo storage — two completely separate features
// Require ownership for specific resources
const ownership = await requireOwnership(childId, "children", "Child");
if (!ownership.success) {
return NextResponse.json({ error: ownership.error }, { status: ownership.status });
}
```
| | **Parent profile photo** | **Baby card photo** |
|---|---|---|
| Page | `/profile` | Homepage (`/`) |
| DB column | `users.image` | `children.image_url` |
| API | `POST/PATCH/DELETE /api/auth/avatar` | `POST/PATCH /api/children/[id]` |
| R2 prefix | `avatars/{userId}/...` | `profiles/{childId}/...` |
**Chat Sessions:** Stored in localStorage (`tia_chat_sessions`) - shared between home page AI card and /ai page. Database tables: `chat_sessions`, `chat_messages`.
### Orphan cleanup pattern
Before overwriting an image URL in the DB, fetch the old URL, extract the R2 key, verify it's under a controlled prefix (`avatars/` or `profiles/`), then `DeleteObjectCommand`. Applied in both avatar and children routes.
**API Routes:** Return standard JSON `{ success: true, items: [...] }` format for lists.
### users.image — NOT users.avatar_url
The `users` table column is called **`image`** (not `avatar_url`). `avatar_url` belongs to `member_profiles`.
Always use `u.image` in SQL queries against the `users` table.
**AI Integration:**
---
- Route: `/api/ai` → LiteLLM (set via `LITELLM_BASE_URL` env var)
- Model: `minimax-2.7`
- See `/docs/debugging.md` for troubleshooting
## Authentication (Email + Password)
1. User visits `/login` with email + password (or signs up for new account)
2. API `/api/auth/signin` verifies password hash and creates session in `sessions` table
3. Session token stored in **httpOnly cookie** (NOT localStorage!)
4. Password stored with simple hash in `users.password_hash`
### Tables Used
- **users:** User accounts (email, name)
- **families:** Family accounts (name, tier, limits)
- **family_members:** Links users to families (user_id, family_id, role)
- **children:** Child profiles (name, birth_date, family_id)
- **sessions:** Login sessions (session_token, user_id, expires)
### NEVER use localStorage for:
- authentication tokens
- family_id after login
- Any data that should persist across devices
### localStorage Acceptable For:
- Theme preference (user-specific display only)
- Temporary cache (offline queue for retry)
- Chat sessions local cache (synced from database)
## Admin Panel
Access at: `/admin-login` (username: `admin`, password: `admin123`)
### Pages
- `/admin` - Dashboard with clickable stat cards
- `/admin/families` - Manage families (create, view/add/remove members, set tier)
- `/admin/users` - Manage users (add to family, password status, delete)
- `/admin/children` - Manage children
- `/admin/revenue` - Revenue analytics
- `/admin/analytics` - Feature usage
- `/admin/support` - Support tickets
- `/admin/settings` - Platform settings
## Data Storage Consistency
### RULE: All user data must persist to database, NOT localStorage
| Data Type | Storage | API Key | Persists After Refresh | Persists After Logout |
|----------|---------|--------|------------------------|-------------------|
| Children | Database | `/api/children` | ✅ Yes | ✅ Yes |
| Activity Logs | Database | `/api/logs` | ✅ Yes | ✅ Yes |
| Vaccinations | Database | `/api/vaccinations` | ✅ Yes | ✅ Yes |
| Growth Records | Database | `/api/growth` | ✅ Yes | ✅ Yes |
| User Profile | Database | `/api/auth/profile` | ✅ Yes | ✅ Yes |
| Memories/Photos | Database + R2 | `/api/upload` | ✅ Yes | ✅ Yes |
| Auth Session | Database + Cookie | `/api/auth/signin` | ✅ Yes | ✅ No |
| Theme | localStorage | `tia_theme` | ✅ Yes | ✅ Yes |
| Chat Sessions | Database | `/api/chat` | ✅ Yes | ✅ Yes |
| Offline Queue | localStorage | `tia_offline_queue` | ✅ Yes | ❌ No |
## R2 Storage (Cloudflare)
### Setup
1. **Create bucket** in Cloudflare Dashboard → R2
2. **Create API token** with "Object Read & Write" permissions
3. **Enable Public Development URL** in bucket settings (gives pub-*.r2.dev URL)
### API Endpoint Format
The S3 API endpoint is: `https://<accountId>.r2.cloudflarestorage.com`
For your bucket named "tia":
- Account ID: `e71f22a2f8614fb3ba6d9b28a264d8ce`
- S3 Endpoint: `https://e71f22a2f8614fb3ba6d9b28a264d8ce.r2.cloudflarestorage.com`
- Public URL: `https://pub-37a76fd657c94d1dbc521a109c087a11.r2.dev` (no bucket name in path!)
### Code Example
```typescript
const client = new S3Client({
region: "auto",
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
credentials: { accessKeyId, secretAccessKey },
});
// List objects
const command = new ListObjectsV2Command({ Bucket: "tia" });
const res = await client.send(command);
// Get presigned upload URL
const command = new PutObjectCommand({ Bucket: "tia", Key: key, ContentType });
const url = await getSignedUrl(client, command, { expiresIn: 3600 });
```
### Key Learnings
1. **S3 API endpoint** does NOT include bucket name: `https://...r2.cloudflarestorage.com`
2. **Public URL** does NOT include bucket path: `https://pub-...r2.dev` (NOT `/tia/...`)
3. **CORS** needs GET and PUT methods for uploads
4. ListObjects needs bucket name in command, not endpoint
## Environment Variables
Set in `.env.local` for development, or in Dokploy dashboard for production.
Required:
- `DATABASE_URL` - PostgreSQL connection (as `tia_app` role after H2.1)
- `DATABASE_URL_SUPERUSER` - Superuser connection (for migrations only)
- `LITELLM_BASE_URL` - AI gateway URL (e.g., https://llm.manohargupta.com)
- `LITELLM_API_KEY` - AI API key
- `R2_ACCOUNT_ID` - Cloudflare R2 account ID
- `R2_ACCESS_KEY_ID` - R2 access key
- `R2_SECRET_ACCESS_KEY` - R2 secret key
- `R2_BUCKET_NAME` - R2 bucket name (e.g., "tia")
- `R2_PUBLIC_URL` - Public R2 URL
- `CRON_SECRET` - Secret for cron backup endpoint
### Security Patterns
All data API routes must validate sessions using `@/lib/auth`:
## Key Patterns
### Session validation in API routes
```typescript
import { requireFamily, requireOwnership } from "@/lib/auth";
export async function GET(request: Request) {
const auth = await requireFamily();
if (!auth.success) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
// ... route logic
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const familyId = auth.session!.familyId!;
// ...
}
```
### Current Security Status (May 2026)
- **RLS (Row-Level Security):** DISABLED on family_members, children, and growth tables (app-level security via requireOwnership)
- **App-level security:** All routes use `requireFamily()` and `requireOwnership()` checks
- **This is secure because:** All API routes validate session before returning data
- **To re-enable RLS later:** Add proper INSERT bypass policy, keep RLS for SELECT only
AI routes use medical guardrails from `@/lib/ai/medical-triggers`:
### ThemeProvider
```typescript
import { detectMedicalIntent } from "@/lib/ai/medical-triggers";
const intent = detectMedicalIntent(query);
if (intent.isMedical) {
// Redirect to pediatrician
}
import { useTheme } from "./ThemeProvider";
const { theme, toggle, setMode } = useTheme();
// theme: "light" | "dark" | mode: "light" | "dark" | "system" | "time"
```
## Known Issues
### Offline Queue
Uses localStorage (`tia_offline_queue`) for failed API calls, retries when online.
### Chat Sessions
Stored in localStorage (`tia_chat_sessions`) — shared between home page AI card and `/ai` page.
Database tables: `chat_sessions`, `chat_messages`.
---
## Email (Resend)
Transactional emails via Resend. Functions in `src/lib/email.ts`:
- `sendVerificationEmail(email, token, userId)` — account verification
- `sendFamilyInviteEmail({ to, inviterName, familyName, token, role })` — family invite
Emails fall back to console log if `RESEND_API_KEY` is not set (dev mode).
---
## Family Invites
Flow:
1. Settings → Invite → `POST /api/invites` → creates row in `family_invites`, sends email via Resend
2. Invitee clicks `/invite/{token}` link → logs in/signs up
3. `POST /api/invites/accept` → adds to `family_members`, **deletes** the invite row (single-use)
4. `DELETE /api/invites/[id]` — cancel a pending invite
`family_invites` table columns: `id, family_id, email, role, token, expires_at, created_at, display_name, accepted_at`
(Note: `display_name` and `accepted_at` were missing from the original schema — added in migration `0006`)
---
## Growth Page
- `formatAge(birthDate, measurementDate?)` — shows age as "1y 4mo" or "8mo"
- Goals stored in localStorage (`tia_growth_goal_${childId}`)
- Column order: Goals → Latest Reading → Add Form → WHO+Chart → History
- Color-coded percentiles: 🟢 Normal (1585th) · 🟡 Watch (<15 or >85) · 🔴 Alert (<3 or >97)
---
## Admin Panel
Access at `/admin-login`.
Pages: `/admin`, `/admin/families`, `/admin/users`, `/admin/children`, `/admin/revenue`, `/admin/analytics`, `/admin/support`, `/admin/settings`
Auth pattern: server component layout calls `verifyAdminSession()` → redirects to `/admin-login` if not authenticated. No client-side cookie checks.
---
## Monitoring & Alerting
Alerts go to Telegram (tiaBaby_Bot) via `src/lib/alert.ts``sendAlert(level, title, detail?, opts?)`. Best-effort, never throws.
| Signal | Where | How it fires |
|--------|-------|--------------|
| Site/health up-down | **Uptime Kuma** (external, in Dokploy) | Pings `GET /api/healthz` (public, 200/503). Kuma handles flip detection + recovery |
| Backup fail / empty | `POST /api/cron/backup` | Alerts on exception, and warns if the gzipped dump < 1KB. Success is a silent confirmation |
| Error spikes | `/api/cron/monitor` | Rising-edge: errors in last 1h > 5 **and** > 2× the prior hour. Stateless (no re-alert on flat rate) |
| Internal health | `/api/cron/monitor` | DB unreachable, no migrations, missing integration env |
| Visitor digest | `/api/cron/visitor-summary` | Polls Umami REST API (login → stats/metrics/active), posts digest. `?hours=N` window |
**Cron endpoints** all require the `x-cron-secret: $CRON_SECRET` header (same as backup). Schedule in Dokploy:
- `monitor` — hourly
- `visitor-summary` — daily (or `?hours=1` hourly during launch)
- `backup` — daily (existing)
**Test the Telegram wiring:** `GET /api/cron/monitor?test=1` with the cron-secret header sends a test ping.
---
## Data Storage Rules
| Data Type | Storage | API |
|----------|---------|-----|
| Children | Database | `/api/children` |
| Activity Logs | Database | `/api/logs` |
| Vaccinations | Database | `/api/vaccinations` |
| Growth Records | Database | `/api/growth` |
| User Profile | Database | `/api/auth/profile` |
| Parent Avatar | Database (`users.image`) + R2 | `/api/auth/avatar` |
| Baby Photo | Database (`children.image_url`) + R2 | `/api/children/[id]` |
| Memories/Photos | Database + R2 | `/api/upload` |
| Auth Session | Database + Cookie | `/api/auth/signin` |
| Theme | localStorage | `tia_theme` |
| Growth Goals | localStorage | `tia_growth_goal_${childId}` |
| Custom Folders | localStorage | `tia_custom_folders` |
| Offline Queue | localStorage | `tia_offline_queue` |
**NEVER use localStorage for:** authentication tokens, family_id, or any data that should persist across devices.
---
## Environment Variables
Set in `.env.local` for development, Dokploy dashboard for production.
| Variable | Required | Description |
|----------|----------|-------------|
| `DATABASE_URL` | ✅ | PostgreSQL connection |
| `DATABASE_URL_SUPERUSER` | ✅ | Superuser connection (migrations only) |
| `LITELLM_BASE_URL` | ✅ | AI gateway URL |
| `LITELLM_API_KEY` | ✅ | AI API key |
| `R2_ACCOUNT_ID` | ✅ | Cloudflare R2 account ID |
| `R2_ACCESS_KEY_ID` | ✅ | R2 access key |
| `R2_SECRET_ACCESS_KEY` | ✅ | R2 secret key |
| `R2_BUCKET_NAME` | ✅ | R2 bucket name (e.g. "tia") |
| `R2_PUBLIC_URL` | ✅ | Public R2 URL |
| `RESEND_API_KEY` | ✅ | Resend API key for transactional email |
| `EMAIL_FROM` | ✅ | Sender address (e.g. `Tia <tia@manohargupta.com>`) |
| `NEXT_PUBLIC_APP_URL` | ✅ | Full app URL (e.g. `https://tia.manohargupta.com`) |
| `CRON_SECRET` | ✅ | Secret for cron endpoints (backup, monitor, visitor-summary) — sent as `x-cron-secret` header |
| `TELEGRAM_BOT_TOKEN` | ✅ | tiaBaby_Bot token from @BotFather — operational alerts |
| `TELEGRAM_CHAT_ID` | ✅ | Chat/group/channel id alerts post to (see `src/lib/alert.ts` header for how to get it) |
| `UMAMI_BASE_URL` | — | Umami instance (default `https://analytics.manohargupta.com`) |
| `UMAMI_USERNAME` | ✅ | Umami login — for the visitor-summary cron |
| `UMAMI_PASSWORD` | ✅ | Umami password |
| `UMAMI_WEBSITE_ID` | — | Umami website id (default Tia's id) |
---
## R2 Storage (Cloudflare)
- Account ID: `e71f22a2f8614fb3ba6d9b28a264d8ce`
- S3 Endpoint: `https://e71f22a2f8614fb3ba6d9b28a264d8ce.r2.cloudflarestorage.com`
- Public URL: `https://pub-37a76fd657c94d1dbc521a109c087a11.r2.dev` (no bucket name in path!)
Key learnings:
1. S3 API endpoint does NOT include bucket name
2. Public URL does NOT include `/tia/` bucket path
3. Direct browser PUT to presigned URL = CORS blocked → always proxy via `/api/upload`
---
## Known Issues & Fixes Applied
### Turbopack Parsing Issue
Some files cause "Unterminated regexp literal" with Turbopack. Fix: `pnpm build -- --webpack`
Avoid patterns like `name: "TT/Td"` in arrays (the `/` can be misread as regex).
Some files may cause "Unterminated regexp literal" errors when building with Turbopack. If build fails:
### users.image vs users.avatar_url
The `users` table uses `image` for the profile photo column. Using `avatar_url` in SQL will throw
"column does not exist". `avatar_url` exists only on `member_profiles`.
1. Try building with Webpack instead:
```bash
pnpm build -- --webpack
```
2. The issue is in SWC parser - avoid patterns like `name: "TT/Td"` in arrays as the `/` can be interpreted as regex
3. Apply fixes one change at a time between builds - cumulative changes can confuse Turbopack's cache
### family_invites missing columns
Original schema was missing `display_name` and `accepted_at`. Added in `drizzle/0006_family_invites_missing_cols.sql`.
Hot-fixed via `/api/debug-migration` POST.
### Admin auth (httpOnly cookie)
`tia_admin_session` is httpOnly — `document.cookie` can never read it. Admin pages use a **server component layout**
that calls `verifyAdminSession()` server-side. Client pages use `credentials: 'include'` on fetches; no Bearer tokens.

View file

@ -14,6 +14,10 @@ COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN corepack enable pnpm
RUN pnpm run build
# Bundle the standalone migration runner. This produces dist/migrate.mjs
# with drizzle-orm + postgres inlined, so the runner stage needs no extra
# node_modules. Runs here because the builder has full dependencies.
RUN pnpm run db:build-migrator
# Stage 3: Production runner
FROM node:22-alpine AS runner
@ -24,8 +28,15 @@ RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder /app/.next/static .next/static
# Migration runner + migration SQL. The migrator runs on container start
# (see CMD) BEFORE the Next.js server boots — see drizzle/README.md.
COPY --from=builder --chown=nextjs:nodejs /app/dist/migrate.mjs ./migrate.mjs
COPY --from=builder --chown=nextjs:nodejs /app/drizzle ./drizzle
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
# Apply pending migrations, THEN start the server. If migration fails the
# process exits non-zero and the container crashes — a loud, safe failure
# that prevents the app from serving against a half-migrated schema.
CMD ["sh", "-c", "node migrate.mjs && node server.js"]

View file

@ -1,8 +1,11 @@
version: '3.8'
# Local development services for Tia (Postgres + Redis).
# Postgres major version is pinned to match production.
services:
postgres:
image: pgvector/pgvector:pg16
# pg18 to match production (Hetzner prod runs Postgres 18).
# Keeping dev on the same major version avoids dump/restore mismatches
# and version-specific behaviour drift.
image: pgvector/pgvector:pg18
container_name: tia-postgres
environment:
POSTGRES_DB: tia_dev
@ -11,7 +14,9 @@ services:
ports:
- "5433:5432"
volumes:
- ./data/postgres:/var/lib/postgresql/data
# pg18 image convention: mount at /var/lib/postgresql (NOT .../data).
# The image places data in a version-specific subdirectory under it.
- ./data/postgres:/var/lib/postgresql
- ./docker/init-db:/docker-entrypoint-initdb.d
healthcheck:
test: ["CMD-SHELL", "pg_isready -U tia -d tia_dev"]

View file

@ -0,0 +1,430 @@
CREATE TYPE "public"."child_sex" AS ENUM('male', 'female', 'other');--> statement-breakpoint
CREATE TYPE "public"."child_stage" AS ENUM('newborn', 'infant', 'solids_start', 'toddler_early', 'toddler_late', 'preschool');--> statement-breakpoint
CREATE TYPE "public"."member_role" AS ENUM('admin', 'caregiver', 'viewer');--> statement-breakpoint
CREATE TYPE "public"."diaper_type" AS ENUM('wet', 'dirty', 'both', 'dry');--> statement-breakpoint
CREATE TYPE "public"."feed_method" AS ENUM('bottle', 'breast_left', 'breast_right', 'breast_both', 'cup', 'spoon', 'finger', 'self');--> statement-breakpoint
CREATE TYPE "public"."feed_type" AS ENUM('breast_milk', 'formula', 'solid', 'water', 'other');--> statement-breakpoint
CREATE TYPE "public"."sleep_type" AS ENUM('nap', 'night');--> statement-breakpoint
CREATE TABLE "admin_sessions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"admin_id" uuid NOT NULL,
"session_token" text NOT NULL,
"expires" timestamp with time zone NOT NULL,
"created_at" timestamp with time zone DEFAULT now(),
CONSTRAINT "admin_sessions_session_token_unique" UNIQUE("session_token")
);
--> statement-breakpoint
CREATE TABLE "admins" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"username" varchar(50) NOT NULL,
"password_hash" varchar(255) NOT NULL,
"role" varchar(20) DEFAULT 'admin',
"created_at" timestamp with time zone DEFAULT now(),
"last_login" timestamp with time zone,
CONSTRAINT "admins_username_unique" UNIQUE("username"),
CONSTRAINT "admins_role_check" CHECK ((role)::text = ANY (ARRAY['super_admin','admin','support']))
);
--> statement-breakpoint
CREATE TABLE "password_resets" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"token" text NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"used_at" timestamp with time zone,
CONSTRAINT "password_resets_token_unique" UNIQUE("token")
);
--> statement-breakpoint
CREATE TABLE "member_profiles" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"family_id" uuid NOT NULL,
"slug" text NOT NULL,
"display_name" text NOT NULL,
"bio" text,
"avatar_url" text,
"is_public" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "member_profiles_user_id_unique" UNIQUE("user_id")
);
--> statement-breakpoint
CREATE TABLE "product_clicks" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"product_id" uuid NOT NULL,
"clicked_at" timestamp with time zone DEFAULT now() NOT NULL,
"referrer" text,
"ip_hash" text
);
--> statement-breakpoint
CREATE TABLE "recommended_products" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"profile_id" uuid NOT NULL,
"title" text NOT NULL,
"description" text,
"url" text NOT NULL,
"image_url" text,
"category" text DEFAULT 'general' NOT NULL,
"display_order" integer DEFAULT 0 NOT NULL,
"is_active" boolean DEFAULT true NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "ai_usage" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"family_id" uuid,
"user_id" uuid,
"intent" text,
"model_used" text,
"prompt_tokens" integer,
"completion_tokens" integer,
"total_tokens" integer,
"cost_estimate_paise" numeric(10, 4),
"duration_ms" integer,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "chat_messages" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"session_id" uuid NOT NULL,
"role" varchar(20) NOT NULL,
"content" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE "chat_sessions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"child_id" uuid NOT NULL,
"title" varchar(255) DEFAULT 'New conversation' NOT NULL,
"created_at" timestamp with time zone DEFAULT now(),
"updated_at" timestamp with time zone DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE "audit_log" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"family_id" uuid,
"user_id" uuid,
"action" varchar(50) NOT NULL,
"resource_type" varchar(50),
"resource_id" uuid,
"ip_address" varchar(45),
"user_agent" text,
"metadata" jsonb DEFAULT '{}'::jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "log_corrections" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"family_id" uuid NOT NULL,
"dose_id" uuid NOT NULL,
"original_value" jsonb NOT NULL,
"corrected_value" jsonb NOT NULL,
"reason" text,
"corrected_by" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "accounts" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"type" text NOT NULL,
"provider" text NOT NULL,
"provider_account_id" text NOT NULL,
"refresh_token" text,
"access_token" text,
"expires_at" timestamp,
"token_type" text,
"scope" text,
"id_token" text,
"session_state" text
);
--> statement-breakpoint
CREATE TABLE "sessions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"session_token" text NOT NULL,
"user_id" uuid NOT NULL,
"expires" timestamp NOT NULL,
CONSTRAINT "sessions_session_token_unique" UNIQUE("session_token")
);
--> statement-breakpoint
CREATE TABLE "users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text,
"email" text NOT NULL,
"email_verified" timestamp,
"image" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
"password_hash" varchar(255),
"password_updated_at" timestamp with time zone,
CONSTRAINT "users_email_unique" UNIQUE("email")
);
--> statement-breakpoint
CREATE TABLE "verification_tokens" (
"identifier" text NOT NULL,
"token" text NOT NULL,
"expires" timestamp NOT NULL
);
--> statement-breakpoint
CREATE TABLE "children" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"family_id" uuid NOT NULL,
"name" text NOT NULL,
"birth_date" date NOT NULL,
"sex" "child_sex",
"stage" "child_stage" DEFAULT 'newborn' NOT NULL,
"image_url" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "families" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"tier" varchar(20) DEFAULT 'free',
"max_children" integer DEFAULT 1,
"max_members" integer DEFAULT 2,
"pediatrician_phone" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "family_invites" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"family_id" uuid NOT NULL,
"email" text NOT NULL,
"role" "member_role" DEFAULT 'viewer' NOT NULL,
"token" text NOT NULL,
"expires_at" timestamp NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "family_invites_token_unique" UNIQUE("token")
);
--> statement-breakpoint
CREATE TABLE "family_members" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"family_id" uuid NOT NULL,
"user_id" uuid NOT NULL,
"role" "member_role" DEFAULT 'caregiver' NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "attachments" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"family_id" uuid NOT NULL,
"log_entry_id" uuid,
"r2_key" text NOT NULL,
"r2_thumbnail_key" text,
"mime_type" text,
"size_bytes" integer,
"uploaded_by" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "memories" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"family_id" uuid NOT NULL,
"child_id" uuid,
"title" text,
"description" text,
"taken_at" timestamp with time zone,
"r2_key" text NOT NULL,
"r2_thumbnail_key" text,
"mime_type" text,
"size_bytes" integer,
"width" integer,
"height" integer,
"vision_caption" text,
"vision_tags" text[],
"vision_embedding" vector(1536),
"is_private" boolean DEFAULT false NOT NULL,
"processing_status" text DEFAULT 'uploading' NOT NULL,
"uploaded_by" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "diapers_logs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"child_id" uuid NOT NULL,
"type" "diaper_type" NOT NULL,
"notes" text,
"logged_at" timestamp DEFAULT now() NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "feeds" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"child_id" uuid NOT NULL,
"type" "feed_type" NOT NULL,
"method" "feed_method",
"amount_ml" real,
"notes" text,
"logged_at" timestamp DEFAULT now() NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "growth" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"child_id" uuid NOT NULL,
"measured_at" timestamp NOT NULL,
"weight_kg" real,
"height_cm" real,
"head_circumference_cm" real,
"notes" text,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "medications" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"child_id" uuid NOT NULL,
"name" text NOT NULL,
"dosage" text,
"frequency" text,
"start_date" date NOT NULL,
"end_date" date,
"active" boolean DEFAULT true NOT NULL,
"notes" text,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "milestone_achievements" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"child_id" uuid NOT NULL,
"family_id" uuid NOT NULL,
"milestone_key" text NOT NULL,
"achieved_at" date NOT NULL,
"notes" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "sleeps" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"child_id" uuid NOT NULL,
"type" "sleep_type" NOT NULL,
"started_at" timestamp,
"ended_at" timestamp,
"duration_minutes" integer,
"notes" text,
"logged_at" timestamp DEFAULT now() NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "vaccinations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"child_id" uuid NOT NULL,
"vaccine_name" text NOT NULL,
"scheduled_date" date NOT NULL,
"given_date" date,
"status" text DEFAULT 'pending' NOT NULL,
"provider" text,
"lot_number" text,
"notes" text,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "allergies" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"child_id" uuid NOT NULL,
"name" varchar(255) NOT NULL,
"severity" varchar(50) DEFAULT 'mild',
"notes" text,
"created_at" timestamp with time zone DEFAULT now(),
"updated_at" timestamp with time zone DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE "doctor_visits" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"child_id" uuid NOT NULL,
"doctor_name" varchar(255) NOT NULL,
"reason" varchar(255),
"visit_date" date NOT NULL,
"notes" text,
"created_at" timestamp with time zone DEFAULT now(),
"updated_at" timestamp with time zone DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE "illness_logs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"child_id" uuid NOT NULL,
"name" varchar(255) NOT NULL,
"start_date" date NOT NULL,
"end_date" date,
"notes" text,
"created_at" timestamp with time zone DEFAULT now(),
"updated_at" timestamp with time zone DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE "medication_doses" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"medicine_id" uuid NOT NULL,
"family_id" uuid NOT NULL,
"administered_at" timestamp with time zone DEFAULT now() NOT NULL,
"administered_by" uuid,
"amount_given" text,
"notes" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "medicines" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"child_id" uuid NOT NULL,
"name" varchar(255) NOT NULL,
"dose" varchar(255),
"notes" text,
"reminder_time" varchar(10),
"created_at" timestamp with time zone DEFAULT now(),
"updated_at" timestamp with time zone DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE "support_responses" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"ticket_id" uuid,
"admin_id" uuid,
"message" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE "support_tickets" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"family_id" uuid,
"user_id" uuid,
"email" varchar(255) NOT NULL,
"subject" varchar(255) NOT NULL,
"description" text,
"status" varchar(20) DEFAULT 'open',
"priority" varchar(20) DEFAULT 'normal',
"created_at" timestamp with time zone DEFAULT now(),
"updated_at" timestamp with time zone DEFAULT now(),
CONSTRAINT "support_tickets_status_check" CHECK ((status)::text = ANY (ARRAY['open','in_progress','resolved','closed'])),
CONSTRAINT "support_tickets_priority_check" CHECK ((priority)::text = ANY (ARRAY['low','normal','high','urgent']))
);
--> statement-breakpoint
ALTER TABLE "diapers_logs" ADD CONSTRAINT "diapers_logs_child_id_children_id_fk" FOREIGN KEY ("child_id") REFERENCES "public"."children"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "feeds" ADD CONSTRAINT "feeds_child_id_children_id_fk" FOREIGN KEY ("child_id") REFERENCES "public"."children"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "growth" ADD CONSTRAINT "growth_child_id_children_id_fk" FOREIGN KEY ("child_id") REFERENCES "public"."children"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "medications" ADD CONSTRAINT "medications_child_id_children_id_fk" FOREIGN KEY ("child_id") REFERENCES "public"."children"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sleeps" ADD CONSTRAINT "sleeps_child_id_children_id_fk" FOREIGN KEY ("child_id") REFERENCES "public"."children"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "vaccinations" ADD CONSTRAINT "vaccinations_child_id_children_id_fk" FOREIGN KEY ("child_id") REFERENCES "public"."children"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "member_profiles_slug_idx" ON "member_profiles" USING btree ("slug");--> statement-breakpoint
CREATE INDEX "product_clicks_product_idx" ON "product_clicks" USING btree ("product_id","clicked_at");--> statement-breakpoint
CREATE INDEX "recommended_products_profile_idx" ON "recommended_products" USING btree ("profile_id","display_order");--> statement-breakpoint
CREATE INDEX "ai_usage_created_idx" ON "ai_usage" USING btree ("created_at");--> statement-breakpoint
CREATE INDEX "ai_usage_family_idx" ON "ai_usage" USING btree ("family_id");--> statement-breakpoint
CREATE INDEX "audit_family_idx" ON "audit_log" USING btree ("family_id","created_at");--> statement-breakpoint
CREATE INDEX "idx_audit_log_action" ON "audit_log" USING btree ("action");--> statement-breakpoint
CREATE INDEX "idx_audit_log_created" ON "audit_log" USING btree ("created_at");--> statement-breakpoint
CREATE INDEX "idx_audit_log_family" ON "audit_log" USING btree ("family_id");--> statement-breakpoint
CREATE INDEX "idx_audit_log_user" ON "audit_log" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "log_corrections_dose_idx" ON "log_corrections" USING btree ("dose_id");--> statement-breakpoint
CREATE UNIQUE INDEX "accounts_provider_idx" ON "accounts" USING btree ("provider","provider_account_id");--> statement-breakpoint
CREATE UNIQUE INDEX "verification_tokens_idx" ON "verification_tokens" USING btree ("identifier","token");--> statement-breakpoint
CREATE INDEX "children_family_idx" ON "children" USING btree ("family_id");--> statement-breakpoint
CREATE UNIQUE INDEX "invite_token_idx" ON "family_invites" USING btree ("token");--> statement-breakpoint
CREATE UNIQUE INDEX "family_members_family_id_user_id_key" ON "family_members" USING btree ("family_id","user_id");--> statement-breakpoint
CREATE INDEX "attachments_family_idx" ON "attachments" USING btree ("family_id");--> statement-breakpoint
CREATE INDEX "memories_family_idx" ON "memories" USING btree ("family_id");--> statement-breakpoint
CREATE INDEX "memories_child_idx" ON "memories" USING btree ("child_id");--> statement-breakpoint
CREATE INDEX "memories_embedding_idx" ON "memories" USING ivfflat ("vision_embedding" vector_cosine_ops) WITH (lists=100);--> statement-breakpoint
CREATE INDEX "milestone_child_idx" ON "milestone_achievements" USING btree ("child_id");--> statement-breakpoint
CREATE UNIQUE INDEX "milestone_achievements_child_milestone_unique" ON "milestone_achievements" USING btree ("child_id","milestone_key");--> statement-breakpoint
CREATE INDEX "medication_doses_family_idx" ON "medication_doses" USING btree ("family_id");--> statement-breakpoint
CREATE INDEX "medication_doses_medicine_idx" ON "medication_doses" USING btree ("medicine_id");

View file

@ -0,0 +1,39 @@
CREATE TABLE "garment_wears" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"family_id" uuid NOT NULL,
"garment_id" uuid NOT NULL,
"worn_on" date NOT NULL,
"memory_id" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "garments" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"family_id" uuid NOT NULL,
"child_id" uuid NOT NULL,
"name" text,
"category" text NOT NULL,
"size_label" text NOT NULL,
"colors" text[] DEFAULT '{}',
"seasons" text[] DEFAULT '{}',
"occasion_tags" text[] DEFAULT '{}',
"image_key" text NOT NULL,
"thumb_key" text NOT NULL,
"status" text DEFAULT 'active' NOT NULL,
"acquired_via" text,
"gift_from" text,
"vision_metadata" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "garment_wears" ADD CONSTRAINT "garment_wears_family_id_families_id_fk" FOREIGN KEY ("family_id") REFERENCES "public"."families"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "garment_wears" ADD CONSTRAINT "garment_wears_garment_id_garments_id_fk" FOREIGN KEY ("garment_id") REFERENCES "public"."garments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "garment_wears" ADD CONSTRAINT "garment_wears_memory_id_memories_id_fk" FOREIGN KEY ("memory_id") REFERENCES "public"."memories"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "garments" ADD CONSTRAINT "garments_family_id_families_id_fk" FOREIGN KEY ("family_id") REFERENCES "public"."families"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "garments" ADD CONSTRAINT "garments_child_id_children_id_fk" FOREIGN KEY ("child_id") REFERENCES "public"."children"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "garment_wears_garment_idx" ON "garment_wears" USING btree ("garment_id");--> statement-breakpoint
CREATE INDEX "garment_wears_family_idx" ON "garment_wears" USING btree ("family_id");--> statement-breakpoint
CREATE INDEX "garments_family_idx" ON "garments" USING btree ("family_id");--> statement-breakpoint
CREATE INDEX "garments_child_idx" ON "garments" USING btree ("child_id");--> statement-breakpoint
CREATE INDEX "garments_status_idx" ON "garments" USING btree ("status");

View file

@ -0,0 +1,14 @@
CREATE TABLE "outfits" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"family_id" uuid NOT NULL,
"child_id" uuid NOT NULL,
"name" text NOT NULL,
"garment_ids" uuid[] DEFAULT '{}' NOT NULL,
"occasion_tags" text[] DEFAULT '{}',
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "outfits" ADD CONSTRAINT "outfits_family_id_families_id_fk" FOREIGN KEY ("family_id") REFERENCES "public"."families"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "outfits" ADD CONSTRAINT "outfits_child_id_children_id_fk" FOREIGN KEY ("child_id") REFERENCES "public"."children"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "outfits_family_idx" ON "outfits" USING btree ("family_id");--> statement-breakpoint
CREATE INDEX "outfits_child_idx" ON "outfits" USING btree ("child_id");

94
drizzle/0003_circles.sql Normal file
View file

@ -0,0 +1,94 @@
-- C0: Circle multi-tenant social tables
-- Security model: RLS enabled (deny-by-default at DB layer).
-- App-level enforcement via requireFamily() + WHERE family_id checks
-- mirrors the existing pattern used for all other tables in this codebase.
CREATE TABLE circles (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
created_by uuid NOT NULL REFERENCES families(id),
created_at timestamptz NOT NULL DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE circle_members (
circle_id uuid NOT NULL REFERENCES circles(id) ON DELETE CASCADE,
family_id uuid NOT NULL REFERENCES families(id),
role text NOT NULL DEFAULT 'member', -- admin | member
joined_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (circle_id, family_id)
);
--> statement-breakpoint
CREATE TABLE circle_invites (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
circle_id uuid NOT NULL REFERENCES circles(id) ON DELETE CASCADE,
token text NOT NULL UNIQUE, -- crypto-random, unguessable
created_by uuid NOT NULL REFERENCES families(id),
expires_at timestamptz NOT NULL,
consumed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE circle_posts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
circle_id uuid NOT NULL REFERENCES circles(id) ON DELETE CASCADE,
author_family_id uuid NOT NULL REFERENCES families(id),
body text,
image_key text, -- R2 key under circle-posts/{id}/ prefix
source_kind text, -- NULL | 'milestone' | 'memory'
created_at timestamptz NOT NULL DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE circle_post_comments (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
post_id uuid NOT NULL REFERENCES circle_posts(id) ON DELETE CASCADE,
author_family_id uuid NOT NULL REFERENCES families(id),
body text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE circle_post_reactions (
post_id uuid NOT NULL REFERENCES circle_posts(id) ON DELETE CASCADE,
family_id uuid NOT NULL REFERENCES families(id),
emoji text NOT NULL,
PRIMARY KEY (post_id, family_id, emoji)
);
--> statement-breakpoint
CREATE TABLE post_reports (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
post_id uuid NOT NULL REFERENCES circle_posts(id) ON DELETE CASCADE,
reported_by uuid NOT NULL REFERENCES families(id),
reason text,
created_at timestamptz NOT NULL DEFAULT now()
);
--> statement-breakpoint
CREATE INDEX circle_members_family_idx ON circle_members(family_id);
--> statement-breakpoint
CREATE INDEX circle_members_circle_idx ON circle_members(circle_id);
--> statement-breakpoint
CREATE INDEX circle_posts_circle_idx ON circle_posts(circle_id);
--> statement-breakpoint
CREATE INDEX circle_posts_author_idx ON circle_posts(author_family_id);
--> statement-breakpoint
CREATE INDEX circle_comments_post_idx ON circle_post_comments(post_id);
--> statement-breakpoint
CREATE INDEX circle_reactions_post_idx ON circle_post_reactions(post_id);
--> statement-breakpoint
CREATE INDEX circle_invites_token_idx ON circle_invites(token);
--> statement-breakpoint
ALTER TABLE circles ENABLE ROW LEVEL SECURITY;
--> statement-breakpoint
ALTER TABLE circle_members ENABLE ROW LEVEL SECURITY;
--> statement-breakpoint
ALTER TABLE circle_invites ENABLE ROW LEVEL SECURITY;
--> statement-breakpoint
ALTER TABLE circle_posts ENABLE ROW LEVEL SECURITY;
--> statement-breakpoint
ALTER TABLE circle_post_comments ENABLE ROW LEVEL SECURITY;
--> statement-breakpoint
ALTER TABLE circle_post_reactions ENABLE ROW LEVEL SECURITY;
--> statement-breakpoint
ALTER TABLE post_reports ENABLE ROW LEVEL SECURITY;
--> statement-breakpoint
GRANT ALL ON circles, circle_members, circle_invites, circle_posts,
circle_post_comments, circle_post_reactions, post_reports
TO tia_app;

View file

@ -0,0 +1,9 @@
-- Add email-based invite columns to circle_invites.
-- invited_email: the address that was invited
-- invited_family_id: set when the email matches an existing family (for in-app notification)
ALTER TABLE circle_invites ADD COLUMN IF NOT EXISTS invited_email text;
--> statement-breakpoint
ALTER TABLE circle_invites ADD COLUMN IF NOT EXISTS invited_family_id uuid REFERENCES families(id);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS circle_invites_invited_family_idx ON circle_invites(invited_family_id) WHERE invited_family_id IS NOT NULL;

View file

@ -0,0 +1,21 @@
-- Email verification tokens (single-use, 24 h).
-- Mirrors the password_resets shape already in prod.
-- Backfill grandfathers all existing users as verified so the Task C
-- sign-in gate does not lock out accounts created before this migration.
CREATE TABLE IF NOT EXISTS email_verifications (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token text NOT NULL UNIQUE,
expires_at timestamptz NOT NULL,
used_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS email_verifications_token_idx
ON email_verifications(token);
--> statement-breakpoint
-- Grandfather every existing user as verified.
UPDATE users SET email_verified = now() WHERE email_verified IS NULL;
--> statement-breakpoint
GRANT ALL ON email_verifications TO tia_app;

View file

@ -0,0 +1,3 @@
-- Add missing columns to family_invites that the invite flow requires
ALTER TABLE family_invites ADD COLUMN IF NOT EXISTS display_name text;
ALTER TABLE family_invites ADD COLUMN IF NOT EXISTS accepted_at timestamp;

View file

@ -0,0 +1,6 @@
-- Add subscription_status to families for payment-provider abstraction.
-- The actual payment integration (Razorpay TBD) will only need to flip
-- families.tier and set subscription_status; all quota/member enforcement
-- reads from these two fields via isPaidFamily() in src/lib/quota.ts.
ALTER TABLE families
ADD COLUMN IF NOT EXISTS subscription_status varchar(20) DEFAULT NULL;

View file

@ -0,0 +1,2 @@
ALTER TABLE families
ADD COLUMN IF NOT EXISTS pediatrician_name text;

View 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);

View 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);

View file

@ -1,45 +0,0 @@
-- Enable pgvector if not already
CREATE EXTENSION IF NOT EXISTS vector;
-- Memories table (photos with vision metadata)
CREATE TABLE IF NOT EXISTS memories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
family_id UUID NOT NULL,
child_id UUID,
title TEXT,
description TEXT,
taken_at TIMESTAMPTZ,
r2_key TEXT NOT NULL,
r2_thumbnail_key TEXT,
mime_type TEXT,
size_bytes INTEGER,
width INTEGER,
height INTEGER,
vision_caption TEXT,
vision_tags TEXT[],
vision_embedding VECTOR(1536),
is_private BOOLEAN NOT NULL DEFAULT FALSE,
processing_status TEXT NOT NULL DEFAULT 'uploading'
CHECK (processing_status IN ('uploading', 'processing', 'ready', 'failed')),
uploaded_by UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS memories_family_idx ON memories (family_id);
CREATE INDEX IF NOT EXISTS memories_child_idx ON memories (child_id);
-- Attachments table (files linked to log entries, no vision)
CREATE TABLE IF NOT EXISTS attachments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
family_id UUID NOT NULL,
log_entry_id UUID,
r2_key TEXT NOT NULL,
r2_thumbnail_key TEXT,
mime_type TEXT,
size_bytes INTEGER,
uploaded_by UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS attachments_family_idx ON attachments (family_id);

View 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
View 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()
);

View file

@ -1,28 +0,0 @@
-- G3.2: Medication dose log
CREATE TABLE IF NOT EXISTS medication_doses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
medicine_id UUID NOT NULL,
family_id UUID NOT NULL,
administered_at TIMESTAMPTZ NOT NULL DEFAULT now(),
administered_by UUID,
amount_given TEXT,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS medication_doses_medicine_idx ON medication_doses (medicine_id);
CREATE INDEX IF NOT EXISTS medication_doses_family_idx ON medication_doses (family_id);
-- G3.4: Log corrections (audit trail for edited doses)
CREATE TABLE IF NOT EXISTS log_corrections (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
family_id UUID NOT NULL,
dose_id UUID NOT NULL REFERENCES medication_doses(id) ON DELETE CASCADE,
original_value JSONB NOT NULL,
corrected_value JSONB NOT NULL,
reason TEXT,
corrected_by UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS log_corrections_dose_idx ON log_corrections (dose_id);

View file

@ -1,16 +0,0 @@
CREATE TABLE IF NOT EXISTS ai_usage (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
family_id UUID,
user_id UUID,
intent TEXT, -- structured_query | memory_search | general_parenting | medical_redirect
model_used TEXT,
prompt_tokens INTEGER,
completion_tokens INTEGER,
total_tokens INTEGER,
cost_estimate_paise NUMERIC(10,4), -- rough estimate for monitoring
duration_ms INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS ai_usage_family_idx ON ai_usage (family_id);
CREATE INDEX IF NOT EXISTS ai_usage_created_idx ON ai_usage (created_at DESC);

View file

@ -1,11 +0,0 @@
CREATE TABLE IF NOT EXISTS milestone_achievements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
child_id UUID NOT NULL REFERENCES children(id) ON DELETE CASCADE,
family_id UUID NOT NULL,
milestone_key TEXT NOT NULL,
achieved_at DATE NOT NULL,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(child_id, milestone_key)
);
CREATE INDEX IF NOT EXISTS milestone_child_idx ON milestone_achievements (child_id);

View file

@ -1,37 +0,0 @@
CREATE TABLE IF NOT EXISTS member_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
family_id UUID NOT NULL REFERENCES families(id) ON DELETE CASCADE,
slug TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
bio TEXT,
avatar_url TEXT,
is_public BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS recommended_products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
profile_id UUID NOT NULL REFERENCES member_profiles(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT,
url TEXT NOT NULL,
image_url TEXT,
category TEXT NOT NULL DEFAULT 'general',
display_order INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS product_clicks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
product_id UUID NOT NULL REFERENCES recommended_products(id) ON DELETE CASCADE,
clicked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
referrer TEXT,
ip_hash TEXT
);
CREATE INDEX IF NOT EXISTS member_profiles_slug_idx ON member_profiles (slug);
CREATE INDEX IF NOT EXISTS recommended_products_profile_idx ON recommended_products (profile_id, display_order);
CREATE INDEX IF NOT EXISTS product_clicks_product_idx ON product_clicks (product_id, clicked_at DESC);

55
drizzle/README.md Normal file
View file

@ -0,0 +1,55 @@
# Tia — Database Migrations
This folder is **source code** and is committed to git. It is consumed by the
deploy pipeline (`pnpm db:migrate`, run on container start — see `Dockerfile`).
## Baseline reset — 2026-05-19
The project's first 16 migrations (`0000``0015`) plus a `manual/` folder were
hand-rolled SQL applied directly via the Dokploy database terminal. They were
**never** run through Drizzle's migrator, so:
- prod had no `__drizzle_migrations` tracking table;
- the `drizzle/` folder was gitignored, so migration SQL never reached the server;
- `schema.ts` had drifted well behind the real production schema.
To fix this we performed a **Path A baseline reset**:
1. `pg_dump` backup of prod taken and stored off-server.
2. `drizzle-kit pull` introspected the live prod schema (35 tables).
3. `src/db/schema/*.ts` was rewritten to match prod exactly.
4. Legacy migrations were archived to `_archived_pre_baseline_2026-05-19/`
(also retained in git history).
5. A single fresh baseline — `0000_baseline_prod_2026_05_19.sql` — was generated
and **verified column-for-column against the introspected prod schema**.
6. Prod's `drizzle.__drizzle_migrations` table was created and seeded with one
row marking `0000_baseline_prod_2026_05_19` as already applied, so the
migrator treats prod as up-to-date and runs nothing on the next deploy.
## Normal workflow from here
```bash
# 1. Edit src/db/schema/*.ts
# 2. Generate a migration from the diff:
pnpm db:generate # writes drizzle/000N_<name>.sql
# 3. Review the generated SQL by eye.
# 4. Apply locally against the dev DB:
pnpm db:migrate
# 5. Commit schema + migration together, then push.
# Dokploy redeploys; the migrator applies it in prod on container start.
```
## Hard rules
- **Never** edit a migration file after it has been pushed. Fix-forward with a
new migration instead.
- **Never** run schema-changing SQL directly against prod. It becomes drift.
- The `drizzle/` folder must stay **out** of `.gitignore`.
## RLS policies
Five log tables (`feeds`, `diapers_logs`, `sleeps`, `vaccinations`, `growth`)
plus `children` / `family_members` carry row-level-security policies in prod.
These are **not** modelled in the `pgTable` definitions and are managed
separately in the database. Drizzle migrations will not recreate them — keep
that in mind if you ever rebuild the DB from scratch.

View file

@ -1,17 +0,0 @@
-- Create audit_log table
CREATE TABLE IF NOT EXISTS audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID,
family_id UUID,
action TEXT NOT NULL,
metadata JSONB,
ip_address TEXT,
user_agent TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Create index for queries
CREATE INDEX IF NOT EXISTS idx_audit_log_user ON audit_log(user_id);
CREATE INDEX IF NOT EXISTS idx_audit_log_family ON audit_log(family_id);
CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action);
CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC);

View file

@ -1,8 +0,0 @@
-- Create password_resets table
CREATE TABLE IF NOT EXISTS password_resets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
token TEXT UNIQUE NOT NULL,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
used_at TIMESTAMP WITH TIME ZONE
);

View file

@ -0,0 +1,21 @@
-- RLS for garments and garment_wears
-- Run AFTER 0001_wardrobe_tables.sql has been applied
-- Apply as superuser: psql $DATABASE_URL_SUPERUSER -f drizzle/manual/06-wardrobe-rls.sql
ALTER TABLE garments ENABLE ROW LEVEL SECURITY;
ALTER TABLE garment_wears ENABLE ROW LEVEL SECURITY;
-- Both tables carry family_id directly, so we use the same direct comparison
-- pattern as family_invites rather than the child_id subquery pattern.
-- FOR ALL with USING also enforces WITH CHECK on INSERT (prevents cross-family writes).
CREATE POLICY family_isolation ON garments
FOR ALL USING (family_id = current_setting('app.current_family_id', true)::uuid);
CREATE POLICY family_isolation ON garment_wears
FOR ALL USING (family_id = current_setting('app.current_family_id', true)::uuid);
-- W9: Saved outfits
ALTER TABLE outfits ENABLE ROW LEVEL SECURITY;
CREATE POLICY family_isolation ON outfits
FOR ALL USING (family_id = current_setting('app.current_family_id', true)::uuid);

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,97 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1779518962214,
"tag": "0000_baseline_prod_2026_05_19",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1779538936553,
"tag": "0001_wardrobe_tables",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1779539431897,
"tag": "0002_outfits_table",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1748134800000,
"tag": "0003_circles",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1748221200000,
"tag": "0004_circle_invite_email",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1748307600000,
"tag": "0005_email_verification",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1748394000000,
"tag": "0006_family_invites_missing_cols",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1748480400000,
"tag": "0007_subscription_status",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1748566800000,
"tag": "0008_pediatrician_name",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1748880000000,
"tag": "0009_notifications",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1749139200000,
"tag": "0010_error_events",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1780000000000,
"tag": "0011_user_phone",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1780100000000,
"tag": "0012_billing",
"breakpoints": true
}
]
}

View file

@ -1,4 +1,12 @@
import type { NextConfig } from "next";
import withSerwistInit from "@serwist/next";
const withSerwist = withSerwistInit({
swSrc: "src/app/sw.ts",
swDest: "public/sw.js",
// Disable in dev so it never interferes with HMR / hot reload
disable: process.env.NODE_ENV === "development",
});
const nextConfig: NextConfig = {
output: "standalone",
@ -14,10 +22,11 @@ const nextConfig: NextConfig = {
{ key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" },
{ key: "Content-Security-Policy", value:
"default-src 'self'; " +
"img-src 'self' data: https://*.r2.cloudflarestorage.com https://*.r2.dev; " +
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; " +
"img-src 'self' data: https://*.r2.cloudflarestorage.com https://*.r2.dev https://*.razorpay.com; " +
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://checkout.razorpay.com; " +
"style-src 'self' 'unsafe-inline'; " +
"connect-src 'self' https://llm.manohargupta.com; " +
"connect-src 'self' https://llm.manohargupta.com https://analytics.manohargupta.com https://*.razorpay.com https://lumberjack.razorpay.com; " +
"frame-src 'self' https://*.razorpay.com https://api.razorpay.com; " +
"font-src 'self' data:;"
},
],
@ -26,4 +35,4 @@ const nextConfig: NextConfig = {
},
};
export default nextConfig;
export default withSerwist(nextConfig);

View file

@ -4,14 +4,23 @@
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
"build": "next build --webpack",
"start": "next start",
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx src/db/migrate.ts",
"db:studio": "drizzle-kit studio",
"db:pull": "drizzle-kit pull",
"db:build-migrator": "node scripts/build-migrator.mjs",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@auth/drizzle-adapter": "^1.11.2",
"@aws-sdk/client-s3": "^3.1045.0",
"@aws-sdk/s3-request-presigner": "^3.1045.0",
"@react-pdf/renderer": "^4.5.1",
"@serwist/next": "9.5.11",
"arctic": "^3.7.0",
"bcryptjs": "^3.0.3",
"chart.js": "^4.5.1",
"date-fns": "^4.1.0",
@ -20,7 +29,6 @@
"nanoid": "^5.1.11",
"next": "16.2.6",
"next-auth": "5.0.0-beta.31",
"next-pwa": "^5.6.0",
"nodemailer": "^7.0.13",
"openai": "^6.37.0",
"postgres": "^3.4.9",
@ -30,6 +38,7 @@
"react-dom": "19.2.4",
"recharts": "^3.8.1",
"resend": "^6.12.3",
"serwist": "9.5.11",
"sharp": "^0.34.5",
"zod": "^4.4.3"
},
@ -40,9 +49,12 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/sharp": "^0.32.0",
"@vitest/coverage-v8": "^4.1.7",
"drizzle-kit": "^0.31.10",
"esbuild": "^0.25.12",
"tailwindcss": "^4",
"tsx": "^4.21.0",
"typescript": "^5"
"typescript": "^5",
"vitest": "^4.1.7"
}
}

4539
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<circle cx="16" cy="16" r="15" fill="#fb7185"/>
<circle cx="12" cy="12" r="3" fill="white"/>
<circle cx="20" cy="12" r="3" fill="white"/>
<path d="M10 20 Q16 26 22 20" stroke="white" stroke-width="2" fill="none"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<text y=".9em" font-size="88">🌸</text>
</svg>

Before

Width:  |  Height:  |  Size: 289 B

After

Width:  |  Height:  |  Size: 114 B

BIN
public/icons/192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
public/icons/512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

BIN
public/icons/apple-180.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 885 KiB

View file

@ -1,21 +0,0 @@
{
"name": "Tia - Baby Tracking",
"short_name": "Tia",
"description": "Your baby tracking companion",
"start_url": "/",
"display": "standalone",
"background_color": "#fdf2f2",
"theme_color": "#fb7185",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

File diff suppressed because one or more lines are too long

1
public/umami.js Normal file
View 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)}();

View file

@ -0,0 +1,30 @@
/**
* Bundles src/db/migrate.ts into a single self-contained dist/migrate.mjs.
*
* Why: the Next.js standalone production image does NOT keep drizzle-orm or
* postgres as separate node_modules packages (Next traces them straight into
* server.js). The migration runner is a separate entrypoint, so it needs its
* own bundle with those deps inlined. esbuild does exactly that.
*
* esbuild is already available as a transitive dependency of drizzle-kit/tsx,
* so this adds no new package to the project.
*
* Run via: pnpm db:build-migrator (invoked automatically inside the build).
*/
import { build } from "esbuild";
await build({
entryPoints: ["src/db/migrate.ts"],
bundle: true, // inline drizzle-orm + postgres into the output
platform: "node",
target: "node22",
format: "esm",
outfile: "dist/migrate.mjs",
// postgres ships optional native bits; keep it bundled but let node resolve
// built-ins normally. No externals — we want a fully standalone file.
banner: {
js: "// AUTO-GENERATED by scripts/build-migrator.mjs — do not edit.",
},
});
console.log("[build-migrator] dist/migrate.mjs written.");

View file

@ -0,0 +1,47 @@
// Generates PWA icon PNGs from an SVG template using sharp.
// Run: node scripts/generate-icons.mjs
import sharp from "sharp";
import { writeFileSync } from "fs";
import path from "path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const outDir = path.join(__dirname, "../public/icons");
// Brand colors from the app
const BG = "#fb7185"; // rose-400
// SVG template — rounded square with cherry blossom character.
// For maskable: full bleed background, content within safe zone (inner 80%).
function makeSvg(size, maskable = false) {
const radius = maskable ? 0 : Math.round(size * 0.18);
const fontSize = maskable ? Math.round(size * 0.48) : Math.round(size * 0.56);
const y = maskable ? Math.round(size * 0.68) : Math.round(size * 0.72);
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
<rect width="${size}" height="${size}" rx="${radius}" fill="${BG}"/>
<text
x="${size / 2}"
y="${y}"
font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-weight="700"
font-size="${fontSize}"
text-anchor="middle"
fill="white"
dominant-baseline="auto"
>T</text>
</svg>`;
}
const icons = [
{ name: "192.png", size: 192, maskable: false },
{ name: "512.png", size: 512, maskable: false },
{ name: "maskable-512.png", size: 512, maskable: true },
{ name: "apple-180.png", size: 180, maskable: false },
];
for (const icon of icons) {
const svg = Buffer.from(makeSvg(icon.size, icon.maskable));
const outPath = path.join(outDir, icon.name);
await sharp(svg).png().toFile(outPath);
console.log(`${icon.name}`);
}

391
src/__tests__/quota.test.ts Normal file
View file

@ -0,0 +1,391 @@
/**
* quota.test.ts Unit tests for storage quota and member limit enforcement.
*
* Pure-function tests run without any DB. DB-bound function tests mock the
* @/db module so no real database connection is required.
*
* Run: pnpm test
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
isPaidFamily,
wouldExceedQuota,
isAtMemberLimit,
formatBytes,
FREE_STORAGE_LIMIT_BYTES,
FREE_MEMBER_LIMIT,
STORAGE_WARN_THRESHOLD,
} from "@/lib/quota";
// ─── Pure function tests (no DB, no mocks needed) ─────────────────────────────
describe("isPaidFamily", () => {
it("returns false for 'free'", () => {
expect(isPaidFamily("free")).toBe(false);
});
it("returns false for null", () => {
expect(isPaidFamily(null)).toBe(false);
});
it("returns false for undefined", () => {
expect(isPaidFamily(undefined)).toBe(false);
});
it("returns true for 'pro'", () => {
expect(isPaidFamily("pro")).toBe(true);
});
it("returns true for any non-free string", () => {
expect(isPaidFamily("paid")).toBe(true);
expect(isPaidFamily("enterprise")).toBe(true);
});
});
describe("wouldExceedQuota", () => {
const LIMIT = FREE_STORAGE_LIMIT_BYTES; // 1 GiB
it("allows upload when usage + declared <= limit", () => {
expect(wouldExceedQuota(900_000_000, 100_000_000, LIMIT)).toBe(false); // exactly 1 GiB
});
it("blocks upload when usage + declared > limit", () => {
expect(wouldExceedQuota(900_000_000, 200_000_000, LIMIT)).toBe(true); // over by 100 MB
});
it("blocks when already at limit with any positive declared size", () => {
expect(wouldExceedQuota(LIMIT, 1, LIMIT)).toBe(true);
});
it("allows zero-byte declared upload when under limit", () => {
expect(wouldExceedQuota(500_000_000, 0, LIMIT)).toBe(false);
});
it("blocks zero-byte declared upload when already over limit (post-downgrade)", () => {
// Family was on paid, accumulated > 1 GiB, then downgraded.
// Even a 0-byte declared should report over quota.
expect(wouldExceedQuota(LIMIT + 1, 0, LIMIT)).toBe(true);
});
it("correctly handles large files within quota", () => {
// 950 MB used, 50 MB upload → 1000 MB total, under 1 GiB (1024 MB)
expect(wouldExceedQuota(950_000_000, 50_000_000, LIMIT)).toBe(false);
});
it("correctly handles combined memories + attachments sum", () => {
// SUM from memories (600 MB) + attachments (200 MB) = 800 MB, + 300 MB upload → over
const memoriesBytes = 600_000_000;
const attachmentsBytes = 200_000_000;
const combined = memoriesBytes + attachmentsBytes;
expect(wouldExceedQuota(combined, 300_000_000, LIMIT)).toBe(true);
});
});
describe("isAtMemberLimit", () => {
it("blocks at limit on free tier", () => {
expect(isAtMemberLimit(2, FREE_MEMBER_LIMIT, false)).toBe(true);
});
it("blocks above limit on free tier", () => {
expect(isAtMemberLimit(5, FREE_MEMBER_LIMIT, false)).toBe(true);
});
it("allows below limit on free tier", () => {
expect(isAtMemberLimit(1, FREE_MEMBER_LIMIT, false)).toBe(false);
});
it("always allows on paid tier regardless of count", () => {
expect(isAtMemberLimit(100, FREE_MEMBER_LIMIT, true)).toBe(false);
});
it("freeze rule: paid → free downgrade with 4 members blocks new invites, not existing members", () => {
// After downgrade, all 4 members still exist (roles unchanged — enforced elsewhere).
// The limit check sees count=4, limit=2, paid=false → blocked for new invites.
expect(isAtMemberLimit(4, 2, false)).toBe(true);
// But if tier were still paid, it would be allowed:
expect(isAtMemberLimit(4, 2, true)).toBe(false);
});
});
describe("formatBytes", () => {
it("formats bytes", () => {
expect(formatBytes(512)).toBe("512 B");
});
it("formats kilobytes", () => {
expect(formatBytes(1024)).toBe("1.0 KB");
});
it("formats megabytes", () => {
expect(formatBytes(10 * 1024 * 1024)).toBe("10.0 MB");
});
it("formats gigabytes", () => {
expect(formatBytes(1_073_741_824)).toBe("1.00 GB");
});
it("formats 0 bytes", () => {
expect(formatBytes(0)).toBe("0 B");
});
});
describe("constants", () => {
it("FREE_STORAGE_LIMIT_BYTES is exactly 1 GiB", () => {
expect(FREE_STORAGE_LIMIT_BYTES).toBe(1_073_741_824);
});
it("FREE_MEMBER_LIMIT is 2", () => {
expect(FREE_MEMBER_LIMIT).toBe(2);
});
it("STORAGE_WARN_THRESHOLD is 0.8", () => {
expect(STORAGE_WARN_THRESHOLD).toBe(0.8);
});
});
// ─── DB-bound function tests (mocked DB) ──────────────────────────────────────
// We mock the @/db module so tests don't need a real Postgres connection.
vi.mock("@/db", () => ({
sql: vi.fn(),
}));
import { sql } from "@/db";
import {
getFamilyStorageUsage,
checkStorageQuota,
checkMemberLimit,
getStorageInfo,
} from "@/lib/quota";
const mockSql = sql as unknown as ReturnType<typeof vi.fn>;
// Helper: make sql return specific results for tagged template literals.
// The postgres.js `sql` is a tagged template function, not a regular function,
// but vi.fn() captures the call regardless.
function sqlReturns(...results: unknown[][]) {
let callIndex = 0;
mockSql.mockImplementation((..._args: unknown[]) => {
return Promise.resolve(results[callIndex++ % results.length]);
});
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("getFamilyStorageUsage", () => {
it("sums memories and attachments correctly", async () => {
// memories: 600 MB, attachments: 200 MB → 800 MB total
sqlReturns(
[{ bytes: "629145600" }], // memories: 600 MB
[{ bytes: "209715200" }] // attachments: 200 MB
);
const usage = await getFamilyStorageUsage("family-a");
expect(usage).toBe(629145600 + 209715200);
});
it("returns 0 when both tables are empty", async () => {
sqlReturns([{ bytes: "0" }], [{ bytes: "0" }]);
expect(await getFamilyStorageUsage("family-empty")).toBe(0);
});
it("tenant isolation: queried with the correct familyId", async () => {
sqlReturns([{ bytes: "0" }], [{ bytes: "0" }]);
await getFamilyStorageUsage("family-a");
// Both SQL calls should have received family-a in their args
const calls = mockSql.mock.calls;
expect(calls).toHaveLength(2);
// The familyId is passed as a tagged template argument; verify it appears
const allArgs = calls.flatMap((c: unknown[]) => c);
expect(allArgs).toContain("family-a");
});
it("excludes uploading-status memories (counted by WHERE clause in SQL)", async () => {
// The WHERE processing_status != 'uploading' is in the SQL template.
// Here we verify the SUM result reflects only confirmed uploads.
// Simulate: one confirmed memory (100 MB), pending stays excluded via SQL
sqlReturns([{ bytes: "104857600" }], [{ bytes: "0" }]);
const usage = await getFamilyStorageUsage("family-b");
expect(usage).toBe(104857600);
});
});
describe("checkStorageQuota", () => {
it("allows upload when family is under quota", async () => {
// family: free tier, 500 MB used
sqlReturns(
[{ tier: "free" }], // families query
[{ bytes: "524288000" }], // memories SUM (500 MB)
[{ bytes: "0" }] // attachments SUM
);
const result = await checkStorageQuota("family-a", 100 * 1024 * 1024); // +100 MB
expect(result.allowed).toBe(true);
});
it("blocks upload when declared size would push over quota", async () => {
// family: free tier, 950 MB used; trying to upload 200 MB → would be 1150 MB > 1 GiB
sqlReturns(
[{ tier: "free" }],
[{ bytes: "996147200" }], // 950 MB
[{ bytes: "0" }]
);
const result = await checkStorageQuota("family-a", 200 * 1024 * 1024); // 200 MB
expect(result.allowed).toBe(false);
if (!result.allowed) {
expect(result.reason).toBe("storage_quota_exceeded");
expect(result.usedBytes).toBe(996147200);
expect(result.limitBytes).toBe(FREE_STORAGE_LIMIT_BYTES);
}
});
it("blocks upload when family is already over quota (post-downgrade)", async () => {
// Paid family accumulated 2 GB, then downgraded to free.
// Even a tiny upload (1 byte) should be blocked.
sqlReturns(
[{ tier: "free" }],
[{ bytes: "2147483648" }], // 2 GB in memories
[{ bytes: "0" }]
);
const result = await checkStorageQuota("family-a", 1);
expect(result.allowed).toBe(false);
});
it("allows upload for paid family regardless of usage", async () => {
sqlReturns(
[{ tier: "pro" }],
[{ bytes: "10737418240" }], // 10 GB (way over free limit)
[{ bytes: "0" }]
);
const result = await checkStorageQuota("family-paid", 500 * 1024 * 1024);
expect(result.allowed).toBe(true);
});
it("blocks upload when declared=0 but family is over quota", async () => {
sqlReturns(
[{ tier: "free" }],
[{ bytes: `${FREE_STORAGE_LIMIT_BYTES + 1}` }],
[{ bytes: "0" }]
);
const result = await checkStorageQuota("family-a", 0);
expect(result.allowed).toBe(false);
});
});
describe("checkMemberLimit", () => {
it("allows invite when count < limit on free tier", async () => {
sqlReturns([{ count: "1", max_members: 2, tier: "free" }]);
const result = await checkMemberLimit("family-a");
expect(result.allowed).toBe(true);
if (result.allowed) expect(result.currentCount).toBe(1);
});
it("blocks invite when count = limit on free tier", async () => {
sqlReturns([{ count: "2", max_members: 2, tier: "free" }]);
const result = await checkMemberLimit("family-a");
expect(result.allowed).toBe(false);
if (!result.allowed) {
expect(result.reason).toBe("member_limit_reached");
expect(result.currentCount).toBe(2);
expect(result.limit).toBe(2);
}
});
it("blocks invite when count > limit (post-downgrade freeze)", async () => {
// 4 members from paid tier, now free — new invites blocked, existing retained
sqlReturns([{ count: "4", max_members: 2, tier: "free" }]);
const result = await checkMemberLimit("family-downgraded");
expect(result.allowed).toBe(false);
if (!result.allowed) {
expect(result.currentCount).toBe(4);
expect(result.limit).toBe(2);
}
});
it("allows invite on paid tier regardless of count", async () => {
sqlReturns([{ count: "50", max_members: 2, tier: "pro" }]);
const result = await checkMemberLimit("family-paid");
expect(result.allowed).toBe(true);
});
it("respects custom max_members from DB (configurable per family)", async () => {
// Admin could set max_members=5 for a special promo plan
sqlReturns([{ count: "4", max_members: 5, tier: "free" }]);
const result = await checkMemberLimit("family-promo");
expect(result.allowed).toBe(true);
});
});
describe("getStorageInfo", () => {
it("returns approaching=true near 80% usage", async () => {
const nearLimit = Math.floor(FREE_STORAGE_LIMIT_BYTES * 0.85);
sqlReturns(
[{ tier: "free" }],
[{ bytes: String(nearLimit) }],
[{ bytes: "0" }]
);
const info = await getStorageInfo("family-a");
expect(info.approaching).toBe(true);
expect(info.exceeded).toBe(false);
});
it("returns exceeded=true at 100%+ usage", async () => {
sqlReturns(
[{ tier: "free" }],
[{ bytes: String(FREE_STORAGE_LIMIT_BYTES + 1000) }],
[{ bytes: "0" }]
);
const info = await getStorageInfo("family-a");
expect(info.exceeded).toBe(true);
expect(info.approaching).toBe(false); // exceeded takes over
});
it("returns approaching=false and exceeded=false for paid family", async () => {
sqlReturns(
[{ tier: "pro" }],
[{ bytes: String(FREE_STORAGE_LIMIT_BYTES * 10) }],
[{ bytes: "0" }]
);
const info = await getStorageInfo("family-paid");
expect(info.approaching).toBe(false);
expect(info.exceeded).toBe(false);
expect(info.isPaid).toBe(true);
});
it("over-quota family can still derive usage (read path unblocked)", async () => {
// getStorageInfo is the read path for the UI meter — must work even over quota.
sqlReturns(
[{ tier: "free" }],
[{ bytes: "2000000000" }],
[{ bytes: "0" }]
);
const info = await getStorageInfo("family-over");
expect(info.usedBytes).toBe(2_000_000_000);
expect(info.exceeded).toBe(true);
// The read itself succeeds — memories can still be read (enforced at route level)
});
});
// ─── RLS / tenant isolation ────────────────────────────────────────────────────
describe("tenant isolation", () => {
it("does not mix usage across families", async () => {
// Family A: 900 MB; Family B: 100 MB. Each call should see only its own usage.
const familyAUsage = 943718400; // 900 MB
const familyBUsage = 104857600; // 100 MB
mockSql
.mockImplementationOnce(() => Promise.resolve([{ bytes: String(familyAUsage) }]))
.mockImplementationOnce(() => Promise.resolve([{ bytes: "0" }]))
.mockImplementationOnce(() => Promise.resolve([{ bytes: String(familyBUsage) }]))
.mockImplementationOnce(() => Promise.resolve([{ bytes: "0" }]));
const usageA = await getFamilyStorageUsage("family-a");
const usageB = await getFamilyStorageUsage("family-b");
expect(usageA).toBe(familyAUsage);
expect(usageB).toBe(familyBUsage);
expect(usageA).not.toBe(usageB);
});
});

View file

@ -0,0 +1,587 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useFamily } from "@/app/FamilyProvider";
import { getGuideline, getAgeInMonths } from "@/lib/guidelines";
import { api } from "@/lib/api";
import { CalendarView } from "@/components/CalendarView";
import { LogModal, type LogType as ModalLogType, type SmartDefault } from "@/components/LogModal";
import type { Log, LogType } from "@/types";
import { dateIST, todayIST, fmtTime, fmtDate, dayLabel } from "@/lib/date-ist";
type ViewMode = "timeline" | "calendar";
interface DayLogs {
date: string;
logs: Log[];
}
function getIcon(type: LogType) {
if (type === "feed") return "🍼";
if (type === "sleep") return "😴";
if (type === "diaper") return "🚼";
return "📝";
}
// dateStr is now an IST YYYY-MM-DD string (from dateIST())
function formatDayLabel(dateStr: string): string {
return dayLabel(dateStr);
}
export default function ActivityPage() {
const router = useRouter();
const { child, childId: providerChildId } = useFamily();
const [view, setView] = useState<ViewMode>("timeline");
const [logs, setLogs] = useState<Log[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<LogType | "all">("all");
const [showSuggested, setShowSuggested] = useState(true);
const [guideExpanded, setGuideExpanded] = useState(false);
const [generating, setGenerating] = useState(false);
const [fabOpen, setFabOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const [modalType, setModalType] = useState<ModalLogType | null>(null);
const [selectedLog, setSelectedLog] = useState<Log | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState(false);
const [smartDefault, setSmartDefault] = useState<SmartDefault | null>(null);
const [pendingDeleteId, setPendingDeleteId] = useState<{ id: string; type: string } | null>(null);
const [daySheetDate, setDaySheetDate] = useState<{ date: string; type: LogType } | null>(null);
const childId = providerChildId ?? "";
useEffect(() => {
if (providerChildId) fetchLogs();
}, [providerChildId]);
const fetchLogs = async () => {
if (!childId) return;
try {
const data = await api.get<{ entries: Log[] }>(`/api/logs?childId=${childId}&limit=200`);
setLogs(data.entries || []);
} catch (err) {
console.error("Failed to fetch logs:", err);
}
setLoading(false);
};
const generateHistory = async () => {
if (!child) return;
setMenuOpen(false);
setGenerating(true);
try {
const data = await api.post<{ success: boolean }>("/api/history", { childId: child.id, birthDate: child.birthDate });
if (data.success) fetchLogs();
} catch (err) {
console.error("Failed to generate history:", err);
}
setGenerating(false);
};
const deleteLog = async (log: Log) => {
try {
await fetch(`/api/logs/${log.id}?type=${log.type}`, { method: "DELETE" });
setSelectedLog(null);
setDeleteConfirm(false);
fetchLogs();
} catch (err) {
console.error("Failed to delete log:", err);
}
};
const handleEdit = (log: Log) => {
// Store the old log to delete after the new one is saved
setPendingDeleteId({ id: log.id, type: log.type });
setSmartDefault({
subType: log.subType ?? "",
amountMl: log.amount ?? undefined,
editTime: log.loggedAt, // pre-fills the time picker with the original log time
} as SmartDefault);
setModalType(log.type as ModalLogType);
setSelectedLog(null);
};
// Today's stats (computed from loaded logs — no extra fetch)
const todayStr = todayIST();
const todayLogs = logs.filter(l => dateIST(l.loggedAt) === todayStr);
const todayCounts = {
feed: todayLogs.filter(l => l.type === "feed").length,
sleep: todayLogs.filter(l => l.type === "sleep").length,
diaper: todayLogs.filter(l => l.type === "diaper").length,
};
// Last 4 calendar days (computed from loaded logs — no extra fetch)
const last4Days = Array.from({ length: 4 }, (_, i) => {
const d = new Date(Date.now() - i * 86_400_000);
const dateStr = dateIST(d);
const dLogs = logs.filter(l => dateIST(l.loggedAt) === dateStr);
return {
date: dateStr,
label: i === 0 ? "Today" : i === 1 ? "Yest." : fmtDate(d, { weekday: "short" }),
feed: dLogs.filter(l => l.type === "feed").length,
sleep: dLogs.filter(l => l.type === "sleep").length,
diaper: dLogs.filter(l => l.type === "diaper").length,
};
});
const filteredLogs = filter === "all" ? logs : logs.filter(l => l.type === filter);
const groupedByDay = filteredLogs.reduce<DayLogs[]>((acc, log) => {
const date = dateIST(log.loggedAt);
const existing = acc.find(d => d.date === date);
if (existing) existing.logs.push(log);
else acc.push({ date, logs: [log] });
return acc;
}, []);
const guide = child ? getGuideline(child.birthDate) : null;
const ageMonths = child ? getAgeInMonths(child.birthDate) : 0;
const guideItems = guide ? [
{ icon: "🍼", label: "Feeds/day", count: todayCounts.feed, target: guide.feeds.times, barClass: "bg-rose-400", textClass: "text-rose-600 dark:text-rose-400" },
{ icon: "😴", label: "Sleep (hrs)", count: todayCounts.sleep, target: guide.sleep.totalHours, barClass: "bg-amber-400", textClass: "text-amber-600 dark:text-amber-400" },
{ icon: "🚼", label: "Diapers", count: todayCounts.diaper, target: guide.diapers.count, barClass: "bg-blue-400", textClass: "text-blue-600 dark:text-blue-400" },
] : [];
return (
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800">
{/* Header */}
<div className="p-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={() => router.back()}
className="p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-xl"
></button>
<h1 className="text-xl font-bold">Activity</h1>
</div>
<div className="flex items-center gap-2">
{/* ⋯ overflow menu */}
<div className="relative">
<button
onClick={() => setMenuOpen(o => !o)}
className="p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-lg leading-none"
></button>
{menuOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setMenuOpen(false)} />
<div className="absolute right-0 top-10 z-50 bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-100 dark:border-gray-700 min-w-[200px]">
<button
onClick={generateHistory}
disabled={generating}
className="w-full text-left px-4 py-3 text-sm hover:bg-gray-50 dark:hover:bg-gray-700 rounded-xl disabled:opacity-50"
>
{generating ? "Generating…" : "📋 Generate sample history"}
</button>
</div>
</>
)}
</div>
{/* View toggle */}
<div className="flex bg-white dark:bg-gray-800 rounded-lg p-1">
{(["timeline", "calendar"] as const).map(v => (
<button
key={v}
onClick={() => setView(v)}
className={`px-3 py-1 rounded-md text-sm transition-colors ${
view === v ? "bg-rose-400 text-white" : "text-gray-600 dark:text-gray-400"
}`}
>
{v === "timeline" ? "📋" : "📅"}
</button>
))}
</div>
</div>
</div>
{/* Filter pills */}
<div className="px-4 mb-3 flex gap-2 overflow-x-auto scrollbar-hide pb-1">
{([
{ value: "all", label: "All" },
{ value: "feed", label: "🍼 Feed" },
{ value: "sleep", label: "😴 Sleep" },
{ value: "diaper", label: "🚼 Diaper" },
] as { value: LogType | "all"; label: string }[]).map(f => (
<button
key={f.value}
onClick={() => setFilter(f.value)}
className={`px-4 py-2 rounded-full text-sm whitespace-nowrap flex-shrink-0 transition-colors ${
filter === f.value ? "bg-rose-400 text-white" : "bg-white dark:bg-gray-800"
}`}
>
{f.label}
</button>
))}
</div>
{!loading && (
<>
{/* Collapsible guidelines card — above strip */}
{child && guide && showSuggested && (
<div className="mx-4 mb-3">
<button
onClick={() => setGuideExpanded(v => !v)}
className="w-full flex items-center justify-between px-4 py-2.5 bg-white dark:bg-gray-800 rounded-2xl shadow-sm"
>
<span className="text-sm text-gray-500 dark:text-gray-400 text-left">
📋 {child.name} · {ageMonths}mo &nbsp;·&nbsp;
🍼 {todayCounts.feed}/{guide.feeds.times} &nbsp;·&nbsp;
😴 {todayCounts.sleep}/{guide.sleep.totalHours}h &nbsp;·&nbsp;
🚼 {todayCounts.diaper}/{guide.diapers.count}
</span>
<span className={`text-gray-400 text-xs ml-2 flex-shrink-0 transition-transform ${guideExpanded ? "rotate-180" : ""}`}></span>
</button>
{guideExpanded && (
<div className="mt-2 p-4 bg-gradient-to-r from-rose-100 to-amber-100 dark:from-rose-900/40 dark:to-amber-900/40 rounded-2xl">
<div className="flex justify-end mb-2">
<button onClick={() => setShowSuggested(false)} className="text-gray-400 text-sm"> Hide</button>
</div>
<div className="grid grid-cols-3 gap-3">
{guideItems.map(item => (
<div key={item.label} className="text-center">
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">{item.label}</div>
<div className={`text-lg font-bold ${item.textClass}`}>
{item.count}
<span className="text-sm font-normal text-gray-400">/{item.target}</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5 mt-1">
<div
className={`${item.barClass} h-1.5 rounded-full transition-all`}
style={{ width: `${Math.min(100, item.target > 0 ? (item.count / item.target) * 100 : 0)}%` }}
/>
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* 4-day overview strip — oldest → newest, each row independently tappable */}
<div className="grid grid-cols-4 gap-2 px-4 mb-3">
{[...last4Days].reverse().map(d => {
const isToday = d.label === "Today";
const rows: { type: LogType; icon: string; count: number }[] = [
{ type: "feed", icon: "🍼", count: d.feed },
{ type: "sleep", icon: "😴", count: d.sleep },
{ type: "diaper", icon: "🚼", count: d.diaper },
];
return (
<div
key={d.label}
className={`rounded-xl shadow-sm overflow-hidden ${
isToday ? "bg-rose-400" : "bg-white dark:bg-gray-800"
}`}
>
{/* Day label — non-interactive header */}
<div className={`text-xs font-semibold text-center py-1.5 ${
isToday ? "text-rose-100" : "text-gray-500 dark:text-gray-400"
}`}>
{d.label}
</div>
{/* Each row is its own tap target */}
{rows.map((row, idx) => (
<button
key={row.type}
onClick={() => setDaySheetDate({ date: d.date, type: row.type })}
className={`w-full flex items-center justify-center py-1.5 text-xs transition-all active:opacity-60 ${
isToday ? "border-t border-rose-300" : "border-t border-gray-100 dark:border-gray-700"
} ${
isToday
? "text-white hover:bg-white/20"
: row.count === 0
? "text-gray-300 dark:text-gray-600 hover:bg-rose-50 dark:hover:bg-gray-700"
: "text-gray-700 dark:text-gray-200 hover:bg-rose-50 dark:hover:bg-gray-700"
}`}
>
{row.icon}×{row.count}
</button>
))}
</div>
);
})}
</div>
</>
)}
{/* Content */}
<div className="px-4 pb-24">
{loading ? (
<div className="flex flex-col items-center justify-center py-20 gap-3">
<div className="flex gap-3 text-3xl">
{["🍼", "😴", "🚼"].map((e, i) => (
<span key={i} className="animate-bounce" style={{ animationDelay: `${i * 120}ms` }}>{e}</span>
))}
</div>
<p className="text-sm text-gray-400">Loading activity</p>
</div>
) : view === "calendar" ? (
<CalendarView logs={logs} filter={filter} />
) : groupedByDay.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-center px-8">
<span className="text-5xl mb-4">📋</span>
<p className="font-semibold text-gray-600 dark:text-gray-300">
No {filter !== "all" ? filter : ""} logs yet
</p>
<p className="text-sm text-gray-400 mt-1">Tap + to start logging</p>
</div>
) : (
<div className="space-y-6">
{groupedByDay.map(day => {
const label = formatDayLabel(day.date);
const isToday = label === "Today";
return (
<div key={day.date}>
<div className={`text-sm mb-2 ${isToday ? "text-rose-500 font-semibold" : "font-medium text-gray-500 dark:text-gray-400"}`}>
{label}
</div>
<div className="space-y-2">
{day.logs
.sort((a, b) => new Date(b.loggedAt).getTime() - new Date(a.loggedAt).getTime())
.map(log => (
<button
key={log.id}
onClick={() => { setSelectedLog(log); setDeleteConfirm(false); }}
className="group w-full flex items-center gap-3 p-3 bg-white dark:bg-gray-800 rounded-xl text-left transition-all active:scale-[0.98] hover:bg-rose-50/70 dark:hover:bg-gray-700/80 hover:shadow-sm"
>
<span className="text-2xl">{getIcon(log.type)}</span>
<div className="flex-1 min-w-0">
<div className="font-medium capitalize">{log.type}</div>
<div className="text-sm text-gray-500 dark:text-gray-400 truncate">
{[
log.subType?.replace(/_/g, " "),
log.amount ? `${log.amount}ml` : null,
log.notes,
].filter(Boolean).join(" · ")}
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
<span className="text-sm text-gray-400">
{fmtTime(log.loggedAt)}
</span>
<span className="text-gray-300 dark:text-gray-600 group-hover:text-rose-400 transition-colors text-base leading-none"></span>
</div>
</button>
))}
</div>
</div>
);
})}
</div>
)}
</div>
{/* FAB */}
<div className="fixed bottom-24 right-5 flex flex-col items-end gap-2 z-50">
{fabOpen && (["feed", "sleep", "diaper"] as ModalLogType[]).map(t => (
<button
key={t}
onClick={() => { setFabOpen(false); setModalType(t); }}
className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-gray-800 rounded-full shadow-lg text-sm font-medium capitalize"
>
<span>{t === "feed" ? "🍼" : t === "sleep" ? "😴" : "🚼"}</span>
{t}
</button>
))}
<button
onClick={() => setFabOpen(o => !o)}
className="w-14 h-14 bg-rose-400 text-white rounded-full shadow-lg flex items-center justify-center text-2xl transition-transform"
style={{ transform: fabOpen ? "rotate(45deg)" : "rotate(0deg)" }}
>
+
</button>
</div>
{fabOpen && <div className="fixed inset-0 z-30" onClick={() => setFabOpen(false)} />}
{/* Log action sheet */}
{selectedLog && (
<>
<div
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50"
onClick={() => { setSelectedLog(null); setDeleteConfirm(false); }}
/>
<div className="fixed bottom-0 inset-x-0 z-50 bg-white dark:bg-gray-900 rounded-t-2xl p-4 pb-10 shadow-xl">
{/* Summary row */}
<div className="flex items-center gap-3 mb-4 p-3 bg-gray-50 dark:bg-gray-800 rounded-xl">
<span className="text-2xl">{getIcon(selectedLog.type)}</span>
<div className="flex-1">
<div className="font-medium capitalize">{selectedLog.type}</div>
<div className="text-sm text-gray-500">
{[
selectedLog.subType?.replace(/_/g, " "),
selectedLog.amount ? `${selectedLog.amount}ml` : null,
fmtTime(selectedLog.loggedAt),
].filter(Boolean).join(" · ")}
</div>
</div>
</div>
{!deleteConfirm ? (
<div className="space-y-2">
<button
onClick={() => handleEdit(selectedLog)}
className="w-full flex items-center gap-3 px-4 py-3 bg-gray-50 dark:bg-gray-800 rounded-xl text-left"
>
<span className="text-lg"></span>
<div>
<div className="font-medium">Edit</div>
<div className="text-xs text-gray-400">Pre-fills a new log with same values</div>
</div>
</button>
<button
onClick={() => setDeleteConfirm(true)}
className="w-full flex items-center gap-3 px-4 py-3 bg-red-50 dark:bg-red-900/20 text-red-500 rounded-xl text-left"
>
<span className="text-lg">🗑</span>
<span className="font-medium">Delete</span>
</button>
<button
onClick={() => setSelectedLog(null)}
className="w-full py-3 text-gray-400 text-sm text-center"
>
Cancel
</button>
</div>
) : (
<div className="space-y-2">
<p className="text-center text-sm text-gray-600 dark:text-gray-300 py-2">
Delete this {selectedLog.type} log?
</p>
<button
onClick={() => deleteLog(selectedLog)}
className="w-full py-3 bg-red-500 text-white rounded-xl font-medium"
>
Yes, delete
</button>
<button
onClick={() => setDeleteConfirm(false)}
className="w-full py-3 text-gray-400 text-sm text-center"
>
Cancel
</button>
</div>
)}
</div>
</>
)}
{/* Day-type detail sheet — tap a specific row (feed/sleep/diaper) on a day chip */}
{daySheetDate && (() => {
const { date: sheetDateStr, type: sheetType } = daySheetDate;
const sheetLogs = logs
.filter(l =>
dateIST(l.loggedAt) === sheetDateStr &&
l.type === sheetType
)
.sort((a, b) => new Date(b.loggedAt).getTime() - new Date(a.loggedAt).getTime());
const sheetDayLabel = formatDayLabel(sheetDateStr);
const sheetTypeIcon = getIcon(sheetType);
const sheetTypeLabel = sheetType.charAt(0).toUpperCase() + sheetType.slice(1);
return (
<>
<div
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50"
onClick={() => setDaySheetDate(null)}
/>
<div className="fixed bottom-0 inset-x-0 z-50 bg-white dark:bg-gray-900 rounded-t-2xl shadow-xl flex flex-col max-h-[75vh]">
{/* Sheet header */}
<div className="flex items-center justify-between px-4 pt-4 pb-3 border-b border-gray-100 dark:border-gray-800">
<div className="flex items-center gap-2">
<span className="text-2xl">{sheetTypeIcon}</span>
<div>
<h3 className="font-semibold text-gray-900 dark:text-white">
{sheetTypeLabel} · {sheetDayLabel}
</h3>
<p className="text-xs text-gray-400 mt-0.5">
{sheetLogs.length === 0
? "Nothing logged — tap + to add"
: `${sheetLogs.length} entr${sheetLogs.length === 1 ? "y" : "ies"} · tap to edit or delete`}
</p>
</div>
</div>
<button
onClick={() => setDaySheetDate(null)}
className="p-2 text-gray-400 text-lg"
></button>
</div>
{/* Log list */}
<div className="overflow-y-auto flex-1 p-4 space-y-2">
{sheetLogs.length === 0 ? (
<div className="text-center py-10 text-gray-400">
<p className="text-4xl mb-2">{sheetTypeIcon}</p>
<p className="text-sm font-medium">No {sheetType} logged for {sheetDayLabel}</p>
<p className="text-xs mt-1 text-gray-300">
Tap + below to add one, or use Generate sample history
</p>
</div>
) : (
sheetLogs.map(log => (
<button
key={log.id}
onClick={() => { setSelectedLog(log); setDeleteConfirm(false); setDaySheetDate(null); }}
className="group w-full flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-800 rounded-xl text-left transition-all active:scale-[0.98] hover:bg-rose-50 dark:hover:bg-gray-700 hover:shadow-sm"
>
<span className="text-2xl">{sheetTypeIcon}</span>
<div className="flex-1 min-w-0">
<div className="font-medium capitalize">{log.type}</div>
<div className="text-sm text-gray-500 dark:text-gray-400 truncate">
{[
log.subType?.replace(/_/g, " "),
log.amount ? `${log.amount}ml` : null,
log.notes,
].filter(Boolean).join(" · ")}
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<span className="text-sm text-gray-400">
{fmtTime(log.loggedAt)}
</span>
<span className="text-gray-300 dark:text-gray-600 group-hover:text-rose-400 transition-colors text-lg"></span>
</div>
</button>
))
)}
</div>
{/* Single add button for this specific type */}
<div className="px-4 py-3 border-t border-gray-100 dark:border-gray-800">
<button
onClick={() => { setDaySheetDate(null); setModalType(sheetType as ModalLogType); }}
className="w-full flex items-center justify-center gap-2 py-3 bg-rose-400 text-white rounded-xl font-medium"
>
<span>{sheetTypeIcon}</span>
<span>+ Add {sheetTypeLabel}</span>
</button>
</div>
</div>
</>
);
})()}
<LogModal
type={modalType}
childId={childId}
onClose={() => { setModalType(null); setSmartDefault(null); setPendingDeleteId(null); }}
onSaved={async () => {
// If editing, delete the old log after the new one is saved
if (pendingDeleteId) {
try {
await fetch(`/api/logs/${pendingDeleteId.id}?type=${pendingDeleteId.type}`, { method: "DELETE" });
} catch (err) {
console.error("Failed to delete old log during edit:", err);
}
setPendingDeleteId(null);
}
setSmartDefault(null);
fetchLogs();
}}
smartDefault={smartDefault}
/>
</div>
);
}

View file

@ -1,22 +1,10 @@
"use client";
import { useState, useEffect } from "react";
import { useFamily } from "../FamilyProvider";
interface AIChat {
id: string;
role: "user" | "assistant";
content: string;
createdAt: string;
}
interface ChatSession {
id: string;
title: string;
messages: AIChat[];
createdAt: string;
updatedAt: string;
}
import { useFamily } from "@/app/FamilyProvider";
import { Button, Input, ConfirmDialog } from "@/components/ui";
import type { AIChat, ChatSession } from "@/types";
import { fmtDate } from "@/lib/date-ist";
export default function AIChatPage() {
const { childId } = useFamily();
@ -63,8 +51,12 @@ export default function AIChatPage() {
});
const data = await res.json();
if (data.session) {
setSessions([data.session, ...sessions]);
setCurrentSessionId(data.session.id);
// POST /api/chat returns a session without a `messages` field — normalize it
// to an empty array so the render path (currentSession.messages.length) never
// hits `undefined.length`, which crashed the page on the first new chat.
const newSession = { ...data.session, messages: [] };
setSessions([newSession, ...sessions]);
setCurrentSessionId(newSession.id);
setSidebarOpen(false);
}
} catch (err) {
@ -162,7 +154,7 @@ export default function AIChatPage() {
};
return (
<div className="flex h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 overflow-hidden">
<div className="flex h-screen pb-16 bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 overflow-hidden">
{/* Sidebar overlay on mobile */}
{sidebarOpen && (
@ -182,7 +174,7 @@ export default function AIChatPage() {
<a href="/menu" className="text-rose-500 dark:text-rose-400"></a>
<h1 className="font-bold dark:text-white">Chats</h1>
</div>
<button onClick={createNewSession} className="w-8 h-8 flex items-center justify-center bg-rose-400 text-white rounded-full text-lg leading-none">+</button>
<Button size="sm" onClick={createNewSession} className="w-8 h-8 !p-0 rounded-full">+</Button>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-1">
{sessions.length === 0 ? (
@ -199,7 +191,7 @@ export default function AIChatPage() {
>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate dark:text-gray-100">{session.title}</div>
<div className="text-xs text-gray-400 dark:text-gray-500">{new Date(session.updatedAt).toLocaleDateString()}</div>
<div className="text-xs text-gray-400 dark:text-gray-500">{fmtDate(session.updatedAt)}</div>
</div>
<button
onClick={(e) => { e.stopPropagation(); setDeleteConfirm(session.id); }}
@ -237,16 +229,14 @@ export default function AIChatPage() {
<div className="text-4xl">🤱</div>
<p className="text-gray-500 dark:text-gray-400 font-medium">Ask anything about your baby</p>
<p className="text-sm text-gray-400 dark:text-gray-500">Tap to see past chats, or just type below to start</p>
<button onClick={createNewSession} className="mt-2 px-5 py-2 bg-rose-400 text-white rounded-full text-sm">
New Chat
</button>
<Button onClick={createNewSession} className="mt-2 rounded-full">New Chat</Button>
</div>
) : currentSession.messages.length === 0 ? (
) : (currentSession.messages?.length ?? 0) === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-center gap-2">
<p className="text-gray-400 dark:text-gray-500 text-sm">Type a question below to get started</p>
</div>
) : (
currentSession.messages.map((msg, i) => (
(currentSession.messages ?? []).map((msg, i) => (
<div
key={i}
className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
@ -277,39 +267,35 @@ export default function AIChatPage() {
{/* Input */}
<div className="p-4 border-t bg-white dark:bg-gray-800 flex-shrink-0">
<div className="flex gap-2">
<input
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => e.key === "Enter" && !e.shiftKey && handleSend()}
placeholder="Ask about your baby..."
className="flex-1 p-3 border dark:border-gray-600 rounded-xl dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 text-sm focus:outline-none focus:ring-2 focus:ring-rose-300"
disabled={loading}
/>
<button
onClick={handleSend}
disabled={loading || !input.trim()}
className={`px-4 py-2 text-white rounded-xl text-sm font-medium transition-colors ${loading || !input.trim() ? "bg-gray-200 dark:bg-gray-600 text-gray-400 cursor-not-allowed" : "bg-rose-400 hover:bg-rose-500"}`}
>
{loading ? "..." : "Send"}
</button>
<div className="flex items-center gap-2">
{/* Input renders its own wrapper div, so flex-1 must go on a wrapper
here putting it on <Input> only hits the inner <input> (already
w-full) and the wrapper stays content-width, leaving it narrow. */}
<div className="flex-1">
<Input
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => e.key === "Enter" && !e.shiftKey && handleSend()}
placeholder="Ask about your baby..."
disabled={loading}
/>
</div>
<Button onClick={handleSend} disabled={loading || !input.trim()} loading={loading}>
Send
</Button>
</div>
</div>
</div>
{/* Delete confirm modal */}
{deleteConfirm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 p-6 rounded-2xl w-72 shadow-xl">
<p className="mb-4 dark:text-white font-medium">Delete this conversation?</p>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-5">This can't be undone.</p>
<div className="flex gap-2">
<button onClick={() => deleteSession(deleteConfirm)} className="flex-1 py-2 bg-red-500 text-white rounded-xl text-sm">Delete</button>
<button onClick={() => setDeleteConfirm(null)} className="flex-1 py-2 bg-gray-100 dark:bg-gray-700 dark:text-white rounded-xl text-sm">Cancel</button>
</div>
</div>
</div>
)}
<ConfirmDialog
open={!!deleteConfirm}
onClose={() => setDeleteConfirm(null)}
onConfirm={() => deleteConfirm && deleteSession(deleteConfirm)}
title="Delete this conversation?"
description="This can't be undone."
confirmLabel="Delete"
variant="danger"
/>
</div>
);
}

View file

@ -0,0 +1,677 @@
"use client";
import { useState, useEffect, useCallback, use } from "react";
import { useRouter } from "next/navigation";
import { useFamily } from "@/app/FamilyProvider";
import type { CirclePost, CircleComment, Circle } from "@/types";
const REACTIONS = ["❤️", "😂", "👍", "🙏", "😮"];
function timeAgo(iso: string) {
const diff = Date.now() - new Date(iso).getTime();
const m = Math.floor(diff / 60000);
if (m < 1) return "just now";
if (m < 60) return `${m}m`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h`;
return `${Math.floor(h / 24)}d`;
}
// ── Post card ─────────────────────────────────────────────────────────────────
function PostCard({
post, myFamilyId, circleId, isAdmin,
onDeleted, onReact,
}: {
post: CirclePost;
myFamilyId: string;
circleId: string;
isAdmin: boolean;
onDeleted: (id: string) => void;
onReact: (postId: string, emoji: string) => void;
}) {
const [collapsed, setCollapsed] = useState(true);
const [showComments, setShowComments] = useState(false);
const [comments, setComments] = useState<CircleComment[]>([]);
const [commentText, setCommentText] = useState("");
const [posting, setPosting] = useState(false);
const [showMenu, setShowMenu] = useState(false);
const [reportSent, setReportSent] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState(false);
const [editMode, setEditMode] = useState(false);
const [editBody, setEditBody] = useState(post.body ?? "");
const [savingEdit, setSavingEdit] = useState(false);
const [lightbox, setLightbox] = useState(false);
const loadComments = async () => {
const res = await fetch(`/api/circles/${circleId}/posts/${post.id}/comments`);
const data = await res.json();
setComments(data.comments ?? []);
};
const toggleComments = () => {
if (!showComments) loadComments();
setShowComments(v => !v);
};
const addComment = async () => {
if (!commentText.trim()) return;
setPosting(true);
await fetch(`/api/circles/${circleId}/posts/${post.id}/comments`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: commentText.trim() }),
});
setCommentText("");
loadComments();
setPosting(false);
};
const sendReport = async (reason: string) => {
await fetch(`/api/circles/${circleId}/posts/${post.id}/report`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason }),
});
setReportSent(true);
setShowMenu(false);
};
const deletePost = async () => {
await fetch(`/api/circles/${circleId}/posts/${post.id}`, { method: "DELETE" });
onDeleted(post.id);
};
const saveEdit = async () => {
setSavingEdit(true);
await fetch(`/api/circles/${circleId}/posts/${post.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: editBody }),
});
post.body = editBody; // optimistic local update
setEditMode(false);
setSavingEdit(false);
};
const isOwn = post.authorFamilyId === myFamilyId;
return (
<>
{/* Fullscreen image lightbox */}
{lightbox && post.imageUrl && (
<div
className="fixed inset-0 z-[100] bg-black/90 flex items-center justify-center p-4"
onClick={() => setLightbox(false)}
>
<img
src={post.imageUrl}
alt="Full size"
className="max-w-full max-h-full object-contain rounded-xl"
/>
<button className="absolute top-4 right-4 text-white text-2xl bg-black/40 rounded-full w-10 h-10 flex items-center justify-center"></button>
</div>
)}
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-sm">
{/* Author row — tap to collapse/expand */}
<div className="flex items-center justify-between px-4 pt-3 pb-2">
<button
onClick={() => setCollapsed(v => !v)}
className="flex items-center gap-2 flex-1 min-w-0 text-left"
>
<div className="w-8 h-8 bg-rose-100 dark:bg-rose-900/40 rounded-full flex items-center justify-center text-sm flex-shrink-0">👨👩👧</div>
{collapsed ? (
/* Single-row collapsed: name · preview text · thumbnail · chevron */
<>
<span className="text-sm font-medium flex-shrink-0">{post.authorFamilyName}</span>
{(post.body || post.imageUrl) && <span className="text-gray-300 text-xs flex-shrink-0">·</span>}
{post.body && <span className="text-xs text-gray-400 truncate flex-1 min-w-0">{post.body}</span>}
{!post.body && <span className="flex-1" />}
{post.imageUrl && <img src={post.imageUrl} alt="" className="w-7 h-7 rounded-md object-cover flex-shrink-0" />}
</>
) : (
/* Expanded: two-line author block */
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{post.authorFamilyName}</p>
<p className="text-xs text-gray-400">{timeAgo(post.createdAt)}{post.sourceKind ? ` · shared a ${post.sourceKind}` : ""}</p>
</div>
)}
<span className="text-gray-300 text-xs flex-shrink-0 ml-1">{collapsed ? "▼" : "▲"}</span>
</button>
{/* ⋯ menu — rendered outside overflow:hidden so it doesn't get clipped */}
<div className="relative">
<button onClick={() => setShowMenu(v => !v)} className="p-2 text-gray-400 text-lg"></button>
{showMenu && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowMenu(false)} />
<div className="absolute right-0 top-9 z-50 bg-white dark:bg-gray-900 rounded-xl shadow-xl border border-gray-100 dark:border-gray-700 min-w-[170px] py-1">
{isOwn && (
<button
onClick={() => { setShowMenu(false); setCollapsed(false); setEditMode(true); setEditBody(post.body ?? ""); }}
className="w-full text-left px-4 py-2.5 text-sm hover:bg-gray-50 dark:hover:bg-gray-700"
> Edit post</button>
)}
{(isOwn || isAdmin) && (
<button
onClick={() => { setShowMenu(false); setCollapsed(false); setDeleteConfirm(true); }}
className="w-full text-left px-4 py-2.5 text-sm text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20"
>🗑 Delete post</button>
)}
{!isOwn && !reportSent && (
<button
onClick={() => sendReport("inappropriate")}
className="w-full text-left px-4 py-2.5 text-sm hover:bg-gray-50 dark:hover:bg-gray-700"
>🚩 Report post</button>
)}
{reportSent && <p className="px-4 py-2.5 text-xs text-gray-400">Reported thank you</p>}
</div>
</>
)}
</div>
</div>
{/* Collapsed: no extra row needed — everything is in the author row */}
{/* Expanded content */}
{!collapsed && <>
{/* Delete confirmation */}
{deleteConfirm && (
<div className="mx-4 mb-3 p-3 bg-red-50 dark:bg-red-900/20 rounded-xl space-y-2">
<p className="text-sm text-red-600 font-medium">Delete this post?</p>
<div className="flex gap-2">
<button onClick={deletePost} className="flex-1 py-1.5 bg-red-500 text-white rounded-lg text-sm">Delete</button>
<button onClick={() => setDeleteConfirm(false)} className="flex-1 py-1.5 text-gray-400 text-sm">Cancel</button>
</div>
</div>
)}
{/* Body — inline edit or display */}
{editMode ? (
<div className="px-4 pb-3 space-y-2">
<textarea
value={editBody}
onChange={e => setEditBody(e.target.value)}
rows={3}
autoFocus
className="w-full px-3 py-2 bg-gray-50 dark:bg-gray-700 rounded-xl text-sm outline-none resize-none border border-rose-300"
/>
<div className="flex gap-2">
<button
onClick={saveEdit}
disabled={savingEdit}
className="flex-1 py-1.5 bg-rose-400 text-white rounded-lg text-sm disabled:opacity-50"
>{savingEdit ? "Saving…" : "Save"}</button>
<button onClick={() => setEditMode(false)} className="flex-1 py-1.5 text-gray-400 text-sm">Cancel</button>
</div>
</div>
) : (
post.body && <p className="px-4 pb-3 text-sm leading-relaxed">{post.body}</p>
)}
{/* Image — contain (no crop) + tap for fullscreen */}
{post.imageUrl && (
<div className="overflow-hidden rounded-none mx-0 mb-0">
<img
src={post.imageUrl}
alt="Post"
onClick={() => setLightbox(true)}
className="w-full max-h-80 object-contain bg-gray-50 dark:bg-gray-900 cursor-pointer"
/>
<p className="text-center text-xs text-gray-400 py-1">Tap to view full size</p>
</div>
)}
{/* Reactions */}
<div className="px-4 py-2 flex items-center gap-1 flex-wrap border-t border-gray-50 dark:border-gray-700">
{REACTIONS.map(emoji => {
const r = post.reactions.find(x => x.emoji === emoji);
return (
<button
key={emoji}
onClick={() => onReact(post.id, emoji)}
className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs transition-colors ${
r?.includedMe
? "bg-rose-100 dark:bg-rose-900/40 text-rose-600"
: "bg-gray-50 dark:bg-gray-700 text-gray-500"
}`}
>
{emoji}{r?.count ? ` ${r.count}` : ""}
</button>
);
})}
<button
onClick={toggleComments}
className="ml-auto flex items-center gap-1 text-xs text-gray-400 px-2 py-1"
>
💬 {post.commentCount > 0 ? post.commentCount : ""} {showComments ? "▲" : "▼"}
</button>
</div>
{/* Comments */}
{showComments && (
<div className="border-t border-gray-50 dark:border-gray-700 px-4 py-3 space-y-3">
{comments.map(c => (
<div key={c.id} className="flex gap-2">
<div className="w-7 h-7 bg-rose-50 dark:bg-rose-900/30 rounded-full flex items-center justify-center text-xs flex-shrink-0">👨👩👧</div>
<div className="flex-1 bg-gray-50 dark:bg-gray-700 rounded-xl px-3 py-2">
<p className="text-xs font-medium text-gray-600 dark:text-gray-300">{c.authorFamilyName}</p>
<p className="text-sm">{c.body}</p>
</div>
</div>
))}
<div className="flex gap-2 pt-1">
<input
id="comment-input"
name="comment-input"
autoComplete="off"
value={commentText}
onChange={e => setCommentText(e.target.value)}
onKeyDown={e => e.key === "Enter" && addComment()}
placeholder="Add a comment…"
className="flex-1 px-3 py-2 bg-gray-50 dark:bg-gray-700 rounded-xl text-sm outline-none"
/>
<button
onClick={addComment}
disabled={posting || !commentText.trim()}
className="px-3 py-2 bg-rose-400 text-white rounded-xl text-sm disabled:opacity-40"
></button>
</div>
</div>
)}
</>} {/* end !collapsed */}
</div>
</>
);
}
// ── Create Post Modal (C5 + C9 consent) ──────────────────────────────────────
function CreatePostModal({
circleId, circleName, memberCount,
onClose, onPosted,
}: {
circleId: string;
circleName: string;
memberCount: number;
onClose: () => void;
onPosted: () => void;
}) {
const [body, setBody] = useState("");
const [imageFile, setImageFile] = useState<File | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [posting, setPosting] = useState(false);
const [step, setStep] = useState<"compose" | "confirm">("compose");
const [postError, setPostError] = useState<string | null>(null);
const pickImage = (e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0];
if (!f) return;
setImageFile(f);
setImagePreview(URL.createObjectURL(f));
setPostError(null);
};
const submit = async () => {
setPosting(true);
setPostError(null);
try {
let tmpKey: string | null = null;
if (imageFile) {
// Upload via server (avoids CORS issues with direct R2 PUT)
const fd = new FormData();
fd.append("file", imageFile);
const uploadRes = await fetch(`/api/circles/${circleId}/posts/upload`, {
method: "POST",
body: fd,
});
const uploadData = await uploadRes.json();
if (!uploadRes.ok) {
setPostError(uploadData.error ?? "Image upload failed");
setPosting(false);
return;
}
tmpKey = uploadData.tmpKey;
}
const postRes = await fetch(`/api/circles/${circleId}/posts`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ postBody: body, tmpKey }),
});
const postData = await postRes.json();
if (!postRes.ok) {
setPostError(postData.error ?? "Failed to create post");
setPosting(false);
return;
}
onPosted();
onClose();
} catch (err) {
setPostError(err instanceof Error ? err.message : "Something went wrong");
}
setPosting(false);
};
return (
<>
<div className="fixed inset-0 bg-black/50 z-50" onClick={onClose} />
<div className="fixed bottom-0 inset-x-0 z-50 bg-white dark:bg-gray-900 rounded-t-2xl p-4 pb-10 shadow-xl max-h-[85vh] flex flex-col">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold">{step === "confirm" ? "Confirm post" : "New post"}</h3>
<button onClick={onClose} className="text-gray-400"></button>
</div>
{step === "compose" ? (
<>
<textarea
id="post-body"
name="post-body"
autoFocus
value={body}
onChange={e => setBody(e.target.value)}
placeholder="What's on your mind?"
rows={4}
className="w-full px-3 py-2 bg-gray-50 dark:bg-gray-700 rounded-xl text-sm outline-none resize-none mb-3"
/>
{imagePreview && (
<div className="relative mb-3">
<img src={imagePreview} alt="Preview" className="w-full max-h-48 object-cover rounded-xl" />
<button
onClick={() => { setImageFile(null); setImagePreview(null); }}
className="absolute top-2 right-2 w-6 h-6 bg-black/50 text-white rounded-full text-xs"
></button>
</div>
)}
<label className="flex items-center gap-2 text-sm text-gray-500 cursor-pointer mb-4">
<span className="text-xl">📷</span> Add photo
<input id="post-image" name="post-image" type="file" accept="image/*" className="hidden" onChange={pickImage} />
</label>
<button
onClick={() => setStep("confirm")}
disabled={!body.trim() && !imageFile}
className="w-full py-3 bg-rose-400 text-white rounded-xl font-medium disabled:opacity-40"
>Continue </button>
</>
) : (
/* C9 — Explicit consent surface */
<div className="flex-1 flex flex-col gap-4">
<div className="p-4 bg-amber-50 dark:bg-amber-900/20 rounded-2xl border border-amber-200 dark:border-amber-700">
<p className="text-sm font-semibold text-amber-800 dark:text-amber-200 mb-1">📣 Heads up</p>
<p className="text-sm text-amber-700 dark:text-amber-300">
This will be visible to all <strong>{memberCount} {memberCount === 1 ? "family" : "families"}</strong> in{" "}
<strong>{circleName}</strong>.{" "}
{imageFile && "The photo you selected will also be shared. "}
Once posted, other members can see it.
</p>
</div>
{body && <p className="text-sm bg-gray-50 dark:bg-gray-700 rounded-xl px-3 py-2 italic text-gray-600 dark:text-gray-300">"{body}"</p>}
{postError && (
<p className="text-sm text-red-600 bg-red-50 dark:bg-red-900/20 rounded-xl px-3 py-2 mb-2">
{postError}
</p>
)}
<div className="flex gap-3 mt-auto">
<button onClick={() => setStep("compose")} className="flex-1 py-3 border border-gray-200 dark:border-gray-600 rounded-xl text-sm"> Edit</button>
<button
onClick={submit}
disabled={posting}
className="flex-1 py-3 bg-rose-400 text-white rounded-xl font-medium disabled:opacity-50"
>{posting ? "Posting…" : "Post to Circle ✓"}</button>
</div>
</div>
)}
</div>
</>
);
}
// ── Circle feed page ──────────────────────────────────────────────────────────
export default function CircleFeedPage({ params }: { params: Promise<{ id: string }> }) {
const { id: circleId } = use(params);
const router = useRouter();
const { familyId } = useFamily();
const [circle, setCircle] = useState<Circle | null>(null);
const [posts, setPosts] = useState<CirclePost[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [showMembers, setShowMembers] = useState(false);
const [members, setMembers] = useState<{ familyId: string; familyName: string; role: string }[]>([]);
const [myRole, setMyRole] = useState<"admin" | "member">("member");
const [showInviteModal, setShowInviteModal] = useState(false);
const [inviteEmail, setInviteEmail] = useState("");
const [inviteSending, setInviteSending] = useState(false);
const [inviteResult, setInviteResult] = useState<{ type: "success" | "error"; msg: string } | null>(null);
const fetchFeed = useCallback(async () => {
try {
const [circleRes, postsRes] = await Promise.all([
fetch(`/api/circles/${circleId}`),
fetch(`/api/circles/${circleId}/posts`),
]);
const circleData = await circleRes.json();
const postsData = await postsRes.json();
if (circleData.circle) {
setCircle(circleData.circle);
setMembers(circleData.members ?? []);
setMyRole(circleData.circle.role ?? "member");
}
setPosts(postsData.posts ?? []);
} catch { /* silent */ }
setLoading(false);
}, [circleId]);
useEffect(() => { fetchFeed(); }, [fetchFeed]);
const handleReact = async (postId: string, emoji: string) => {
await fetch(`/api/circles/${circleId}/posts/${postId}/reactions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ emoji }),
});
// Refresh just this post's reactions
fetchFeed();
};
const handleInviteSend = async () => {
if (!inviteEmail.trim()) return;
setInviteSending(true);
setInviteResult(null);
try {
const res = await fetch(`/api/circles/${circleId}/invite`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: inviteEmail.trim() }),
});
const data = await res.json();
if (data.success) {
const msg = data.type === "in_app"
? `Invite sent! ${inviteEmail} will see it on their Circles page.`
: `Invite email sent to ${inviteEmail}!`;
setInviteResult({ type: "success", msg });
setInviteEmail("");
} else {
setInviteResult({ type: "error", msg: data.error ?? "Failed to send invite" });
}
} catch {
setInviteResult({ type: "error", msg: "Something went wrong" });
}
setInviteSending(false);
};
const handleLeave = async () => {
if (!confirm("Leave this circle?")) return;
const res = await fetch(`/api/circles/${circleId}/members`, { method: "DELETE" });
const data = await res.json();
if (data.success) router.push("/circle");
else alert(data.error);
};
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 flex items-center justify-center">
<div className="flex gap-3 text-3xl">
{["👨‍👩‍👧", "💬", "❤️"].map((e, i) => (
<span key={i} className="animate-bounce" style={{ animationDelay: `${i * 120}ms` }}>{e}</span>
))}
</div>
</div>
);
}
if (!circle) {
return (
<div className="min-h-screen flex items-center justify-center text-gray-400">
<div className="text-center">
<p className="text-4xl mb-2">🔒</p>
<p>Circle not found or you are not a member.</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 pb-24">
{/* Header */}
<div className="p-4 flex items-center gap-3">
<button onClick={() => router.push("/circle")} className="p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-xl"></button>
<div className="flex-1 min-w-0">
<h1 className="text-lg font-bold truncate">{circle.name}</h1>
<p className="text-xs text-gray-400">{circle.memberCount} {circle.memberCount === 1 ? "family" : "families"}</p>
</div>
{/* Members panel toggle */}
<button
onClick={() => setShowMembers(v => !v)}
className="p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-sm"
>👥</button>
{/* Admin: invite */}
{myRole === "admin" && (
<button
onClick={() => { setShowInviteModal(true); setInviteResult(null); setInviteEmail(""); }}
className="px-3 py-2 bg-rose-400 text-white rounded-xl text-sm font-medium"
>+ Invite</button>
)}
</div>
{/* Members panel */}
{showMembers && (
<div className="mx-4 mb-4 bg-white dark:bg-gray-800 rounded-2xl shadow-sm p-4">
<div className="flex items-center justify-between mb-3">
<p className="font-medium text-sm">Members</p>
<button onClick={handleLeave} className="text-xs text-red-400">Leave circle</button>
</div>
<div className="space-y-2">
{members.map(m => (
<div key={m.familyId} className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-7 h-7 bg-rose-100 dark:bg-rose-900/40 rounded-full flex items-center justify-center text-xs">👨👩👧</div>
<span className="text-sm">{m.familyName}</span>
</div>
<div className="flex items-center gap-2">
<span className={`text-xs px-2 py-0.5 rounded-full ${m.role === "admin" ? "bg-rose-100 text-rose-600" : "bg-gray-100 text-gray-500"}`}>
{m.role}
</span>
{myRole === "admin" && m.familyId !== familyId && (
<button
onClick={async () => {
await fetch(`/api/circles/${circleId}/members/${m.familyId}`, { method: "DELETE" });
fetchFeed();
}}
className="text-xs text-red-400"
>Remove</button>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* Posts feed */}
<div className="px-4 space-y-4">
{posts.length === 0 ? (
<div className="text-center py-16 px-8">
<p className="text-4xl mb-3">💬</p>
<p className="font-semibold text-gray-700 dark:text-gray-200">No posts yet</p>
<p className="text-sm text-gray-400 mt-1">Be the first to share something with {circle.name}!</p>
</div>
) : (
posts.map(p => (
<PostCard
key={p.id}
post={p}
myFamilyId={familyId ?? ""}
circleId={circleId}
isAdmin={myRole === "admin"}
onDeleted={id => setPosts(prev => prev.filter(x => x.id !== id))}
onReact={handleReact}
/>
))
)}
</div>
{/* FAB — create post */}
<button
onClick={() => setShowCreate(true)}
className="fixed bottom-24 right-5 w-14 h-14 bg-rose-400 text-white rounded-full shadow-lg flex items-center justify-center text-2xl z-50"
>+</button>
{showCreate && circle && familyId && (
<CreatePostModal
circleId={circleId}
circleName={circle.name}
memberCount={circle.memberCount}
onClose={() => setShowCreate(false)}
onPosted={fetchFeed}
/>
)}
{/* Invite by email modal */}
{showInviteModal && (
<>
<div className="fixed inset-0 bg-black/50 z-50" onClick={() => setShowInviteModal(false)} />
<div className="fixed bottom-0 inset-x-0 z-50 bg-white dark:bg-gray-900 rounded-t-2xl p-5 pb-10 shadow-xl">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold">Invite to {circle?.name}</h3>
<button onClick={() => setShowInviteModal(false)} className="text-gray-400 text-xl"></button>
</div>
<p className="text-sm text-gray-500 mb-4">
Enter the email address of the person you want to invite.
If they already have a Tia account they'll get a notification;
otherwise we'll email them a link to sign up and join.
</p>
<input
id="invite-email"
name="invite-email"
type="email"
autoFocus
value={inviteEmail}
onChange={e => setInviteEmail(e.target.value)}
onKeyDown={e => e.key === "Enter" && handleInviteSend()}
placeholder="their@email.com"
className="w-full px-4 py-3 bg-gray-50 dark:bg-gray-700 rounded-xl text-sm outline-none mb-3 border border-gray-200 dark:border-gray-600"
/>
{inviteResult && (
<p className={`text-sm mb-3 px-3 py-2 rounded-xl ${
inviteResult.type === "success"
? "bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300"
: "bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-300"
}`}>
{inviteResult.type === "success" ? "✓ " : "✗ "}{inviteResult.msg}
</p>
)}
<button
onClick={handleInviteSend}
disabled={inviteSending || !inviteEmail.trim()}
className="w-full py-3 bg-rose-400 text-white rounded-xl font-medium disabled:opacity-40"
>
{inviteSending ? "Sending…" : "Send Invite"}
</button>
</div>
</>
)}
</div>
);
}

View file

@ -0,0 +1,124 @@
"use client";
import { useState, useEffect, use } from "react";
import { useRouter } from "next/navigation";
import { useFamily } from "@/app/FamilyProvider";
type State = "loading" | "preview" | "joining" | "success" | "error";
export default function JoinCirclePage({ params }: { params: Promise<{ token: string }> }) {
const { token } = use(params);
const router = useRouter();
const { familyId, loading: authLoading } = useFamily();
const [state, setState] = useState<State>("loading");
const [circleName, setCircleName] = useState("");
const [memberCount, setMemberCount] = useState(0);
const [errorMsg, setErrorMsg] = useState("");
// Preview the circle info (no auth required)
useEffect(() => {
fetch(`/api/circle/join/${token}`)
.then(r => r.json())
.then(data => {
if (data.error) { setErrorMsg(data.error); setState("error"); return; }
setCircleName(data.circleName);
setMemberCount(data.memberCount);
setState("preview");
})
.catch(() => { setErrorMsg("Could not load this invite."); setState("error"); });
}, [token]);
const joinCircle = async () => {
if (!familyId) {
// Not logged in — redirect to login with return URL
router.push(`/login?next=/circle/join/${token}`);
return;
}
setState("joining");
try {
const res = await fetch(`/api/circle/join/${token}`, { method: "POST" });
const data = await res.json();
if (data.success) {
setState("success");
setTimeout(() => router.push(`/circle/${data.circleId}`), 1500);
} else {
setErrorMsg(data.error ?? "Could not join circle.");
setState("error");
}
} catch {
setErrorMsg("Something went wrong. Please try again.");
setState("error");
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 flex items-center justify-center p-6">
<div className="w-full max-w-sm bg-white dark:bg-gray-800 rounded-3xl shadow-xl p-6 text-center">
{(state === "loading" || authLoading) && (
<>
<div className="flex justify-center gap-2 text-3xl mb-4">
{["👨‍👩‍👧", "❤️", "👶"].map((e, i) => (
<span key={i} className="animate-bounce" style={{ animationDelay: `${i * 120}ms` }}>{e}</span>
))}
</div>
<p className="text-gray-500">Loading invite</p>
</>
)}
{state === "preview" && !authLoading && (
<>
<div className="w-16 h-16 bg-rose-100 dark:bg-rose-900/40 rounded-full flex items-center justify-center text-3xl mx-auto mb-4">
👨👩👧👦
</div>
<h1 className="text-xl font-bold mb-1">You're invited!</h1>
<p className="text-gray-500 text-sm mb-4">
Join <strong className="text-gray-800 dark:text-white">{circleName}</strong>
<br />
<span className="text-xs">{memberCount} {memberCount === 1 ? "family" : "families"} already inside</span>
</p>
{!familyId && (
<p className="text-xs text-amber-600 bg-amber-50 rounded-xl px-3 py-2 mb-4">
You'll need to log in to Tia first.
</p>
)}
<button
onClick={joinCircle}
className="w-full py-3 bg-rose-400 text-white rounded-xl font-semibold"
>
{familyId ? `Join ${circleName}` : "Log in to Join"}
</button>
<button onClick={() => router.push("/home")} className="mt-3 text-xs text-gray-400">
Not now
</button>
</>
)}
{state === "joining" && (
<>
<div className="w-12 h-12 border-4 border-rose-400 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-gray-500">Joining circle</p>
</>
)}
{state === "success" && (
<>
<div className="text-5xl mb-4">🎉</div>
<h1 className="text-xl font-bold mb-1">You're in!</h1>
<p className="text-gray-500 text-sm">Welcome to <strong>{circleName}</strong>. Taking you there</p>
</>
)}
{state === "error" && (
<>
<div className="text-5xl mb-4">😕</div>
<h1 className="text-lg font-bold mb-2 text-gray-800 dark:text-white">Invite issue</h1>
<p className="text-gray-500 text-sm mb-5">{errorMsg}</p>
<button onClick={() => router.push("/home")} className="w-full py-3 bg-gray-100 dark:bg-gray-700 rounded-xl text-sm">Go to Home</button>
</>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,196 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { useFamily } from "@/app/FamilyProvider";
import type { Circle } from "@/types";
type PendingInvite = {
id: string;
token: string;
circleId: string;
circleName: string;
inviterName: string;
memberCount: number;
expiresAt: string;
};
export default function CirclePage() {
const router = useRouter();
const { familyId } = useFamily();
const [circles, setCircles] = useState<Circle[]>([]);
const [pendingInvites, setPendingInvites] = useState<PendingInvite[]>([]);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState("");
const [showCreate, setShowCreate] = useState(false);
const [joiningId, setJoiningId] = useState<string | null>(null);
useEffect(() => {
if (familyId) { fetchCircles(); fetchPendingInvites(); }
}, [familyId]);
const fetchCircles = async () => {
try {
const res = await fetch("/api/circles");
const data = await res.json();
setCircles(data.circles ?? []);
} catch { /* silent */ }
setLoading(false);
};
const fetchPendingInvites = async () => {
try {
const res = await fetch("/api/circles/invites");
const data = await res.json();
setPendingInvites(data.invites ?? []);
} catch { /* silent */ }
};
const createCircle = async () => {
if (!newName.trim()) return;
setCreating(true);
try {
const res = await fetch("/api/circles", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newName.trim() }),
});
const data = await res.json();
if (data.success) {
setNewName("");
setShowCreate(false);
router.push(`/circle/${data.circle.id}`);
}
} catch { /* silent */ }
setCreating(false);
};
const acceptInvite = async (invite: PendingInvite) => {
setJoiningId(invite.id);
try {
const res = await fetch(`/api/circle/join/${invite.token}`, { method: "POST" });
const data = await res.json();
if (data.success) {
router.push(`/circle/${data.circleId}`);
} else {
alert(data.error ?? "Could not join circle");
setPendingInvites(prev => prev.filter(i => i.id !== invite.id));
}
} catch { /* silent */ }
setJoiningId(null);
};
const declineInvite = (inviteId: string) => {
setPendingInvites(prev => prev.filter(i => i.id !== inviteId));
};
return (
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 pb-24">
{/* Header */}
<div className="p-4 flex items-center gap-3">
<button onClick={() => router.back()} className="p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-xl"></button>
<div className="flex-1">
<h1 className="text-xl font-bold">My Circles</h1>
<p className="text-xs text-gray-500 dark:text-gray-400">Private groups with trusted families</p>
</div>
<button
onClick={() => setShowCreate(v => !v)}
className="px-4 py-2 bg-rose-400 text-white rounded-xl text-sm font-medium"
>+ New</button>
</div>
{/* Create circle form */}
{showCreate && (
<div className="mx-4 mb-4 p-4 bg-white dark:bg-gray-800 rounded-2xl shadow-sm space-y-3">
<p className="text-sm font-medium text-gray-700 dark:text-gray-200">Create a new Circle</p>
<input
id="circle-name"
name="circle-name"
autoFocus
autoComplete="off"
value={newName}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => e.key === "Enter" && createCircle()}
placeholder="e.g. NCB Mamas 2024"
className="w-full px-3 py-2 bg-gray-50 dark:bg-gray-700 rounded-xl text-sm outline-none border border-gray-200 dark:border-gray-600 focus:border-rose-400"
/>
<div className="flex gap-2">
<button
onClick={createCircle}
disabled={creating || !newName.trim()}
className="flex-1 py-2 bg-rose-400 text-white rounded-xl text-sm font-medium disabled:opacity-50"
>{creating ? "Creating…" : "Create Circle"}</button>
<button onClick={() => setShowCreate(false)} className="px-4 py-2 text-gray-400 text-sm">Cancel</button>
</div>
</div>
)}
{/* Pending invites */}
{pendingInvites.length > 0 && (
<div className="px-4 mb-4 space-y-2">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide px-1 mb-1">Pending Invites</p>
{pendingInvites.map(inv => (
<div key={inv.id} className="bg-rose-50 dark:bg-rose-900/20 border border-rose-200 dark:border-rose-700 rounded-2xl p-4 flex items-center gap-3">
<div className="w-10 h-10 bg-rose-100 dark:bg-rose-900/40 rounded-full flex items-center justify-center text-xl flex-shrink-0">
👨👩👧👦
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold truncate">{inv.circleName}</p>
<p className="text-xs text-gray-500">{inv.inviterName} invited you · {inv.memberCount} {inv.memberCount === 1 ? "family" : "families"}</p>
</div>
<div className="flex gap-2 flex-shrink-0">
<button
onClick={() => acceptInvite(inv)}
disabled={joiningId === inv.id}
className="px-3 py-1.5 bg-rose-400 text-white rounded-xl text-xs font-medium disabled:opacity-50"
>{joiningId === inv.id ? "…" : "Join"}</button>
<button
onClick={() => declineInvite(inv.id)}
className="px-3 py-1.5 bg-gray-100 dark:bg-gray-700 text-gray-500 rounded-xl text-xs"
></button>
</div>
</div>
))}
</div>
)}
{/* List */}
<div className="px-4 space-y-3">
{loading ? (
<div className="flex justify-center py-16">
<div className="flex gap-2 text-3xl">
{["👨‍👩‍👧", "💬", "❤️"].map((e, i) => (
<span key={i} className="animate-bounce" style={{ animationDelay: `${i * 120}ms` }}>{e}</span>
))}
</div>
</div>
) : circles.length === 0 && pendingInvites.length === 0 ? (
<div className="text-center py-16 px-8">
<p className="text-4xl mb-3">👨👩👧👦</p>
<p className="font-semibold text-gray-700 dark:text-gray-200">No circles yet</p>
<p className="text-sm text-gray-400 mt-1">Create one and invite trusted families to share milestones privately.</p>
</div>
) : (
circles.map(c => (
<Link
key={c.id}
href={`/circle/${c.id}`}
className="flex items-center gap-4 p-4 bg-white dark:bg-gray-800 rounded-2xl shadow-sm"
>
<div className="w-12 h-12 bg-rose-100 dark:bg-rose-900/40 rounded-full flex items-center justify-center text-2xl flex-shrink-0">
👨👩👧
</div>
<div className="flex-1 min-w-0">
<div className="font-semibold truncate">{c.name}</div>
<div className="text-xs text-gray-400">{c.memberCount} {c.memberCount === 1 ? "family" : "families"} · {c.role === "admin" ? "Admin" : "Member"}</div>
</div>
<span className="text-gray-400"></span>
</Link>
))
)}
</div>
</div>
);
}

View file

@ -6,7 +6,7 @@ import {
EmptyState, LoadingShimmer, ConfirmDialog, WashiTape,
Badge, Avatar, Tabs, TabPanel,
} from "@/components/ui";
import { useTheme } from "../../ThemeProvider";
import { useTheme } from "@/app/ThemeProvider";
export default function DevComponentsPage() {
const { theme, toggle } = useTheme();

48
src/app/(app)/error.tsx Normal file
View 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&apos;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>
);
}

View file

@ -2,7 +2,8 @@
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useFamily } from "../FamilyProvider";
import { useFamily } from "@/app/FamilyProvider";
import { ChildLimitBanner } from "@/components/StorageMeter";
interface Child {
id: string;
@ -24,6 +25,7 @@ export default function FamilyPage() {
const [newName, setNewName] = useState("");
const [newDob, setNewDob] = useState("");
const [newSex, setNewSex] = useState("male");
const [childLimit, setChildLimit] = useState<{ currentCount: number; limit: number } | null>(null);
// Load children from FamilyProvider or fetch
useEffect(() => {
@ -35,6 +37,18 @@ export default function FamilyPage() {
}
}, [childrenFromProvider, familyId]);
// Sync limit state whenever children list changes
useEffect(() => {
if (!familyId) return;
fetch("/api/family")
.then(r => r.json())
.then(d => {
const maxChildren = d.family?.max_children ?? 1;
setChildLimit({ currentCount: children.length, limit: maxChildren });
})
.catch(() => {});
}, [familyId, children.length]);
const fetchOwnChildren = async () => {
if (!familyId) return;
try {
@ -85,12 +99,19 @@ export default function FamilyPage() {
setShowAdd(false);
setNewName("");
setNewDob("");
} else if (data.reason === "child_limit_reached") {
setShowAdd(false);
setChildLimit({ currentCount: data.currentCount, limit: data.limit });
} else {
alert(data.error);
}
} catch (err) {
console.error("Failed to add:", err);
}
};
const atChildLimit = childLimit !== null && childLimit.currentCount >= childLimit.limit;
return (
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800">
<div className="p-4 flex items-center gap-4">
@ -99,8 +120,13 @@ export default function FamilyPage() {
</div>
<div className="px-4 space-y-4">
{/* Child limit banner */}
{atChildLimit && childLimit && (
<ChildLimitBanner currentCount={childLimit.currentCount} limit={childLimit.limit} />
)}
{/* Add Child Form */}
{showAdd && (
{showAdd && !atChildLimit && (
<div className="p-4 bg-white dark:bg-gray-800 rounded-xl space-y-3">
<input
type="text"
@ -200,7 +226,7 @@ export default function FamilyPage() {
)}
{/* Add Button */}
{!showAdd && children.length > 0 && (
{!showAdd && children.length > 0 && !atChildLimit && (
<button
onClick={() => setShowAdd(true)}
className="w-full p-4 border-2 border-dashed border-gray-300 rounded-xl text-gray-500"

View file

@ -1,8 +1,14 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useFamily } from "../FamilyProvider";
import { WHO_BOY_WEIGHT, WHO_GIRL_WEIGHT, getAgeInMonthsFromBirth, getPercentile, type GrowthStandard } from "@/lib/growth-standards";
import { useState, useEffect } from "react";
import Link from "next/link";
import { useFamily } from "@/app/FamilyProvider";
import { Button, Card, Input, ConfirmDialog } from "@/components/ui";
import { WHO_BOY_WEIGHT, WHO_GIRL_WEIGHT, getAgeInMonthsFromBirth, getPercentile } from "@/lib/growth-standards";
import { formatAge } from "@/lib/formatting";
import { fmtDate } from "@/lib/date-ist";
import { trackGrowthLogged } from "@/lib/analytics";
import type { GrowthRecord, Goal } from "@/types";
import {
Chart as ChartJS,
CategoryScale,
@ -15,59 +21,23 @@ import {
Filler,
} from "chart.js";
import { Line } from "react-chartjs-2";
import { useTheme } from "../ThemeProvider";
import { useTheme } from "@/app/ThemeProvider";
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler
);
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, Filler);
interface GrowthRecord {
id: string;
child_id: string;
measured_at: string;
weight_kg: number | null;
height_cm: number | null;
head_circumference_cm: number | null;
notes: string | null;
}
interface Goal {
weightKg?: number;
heightCm?: number;
targetDate?: string;
}
function formatAge(birthDate: string, measurementDate?: string): string {
const birth = new Date(birthDate);
const now = measurementDate ? new Date(measurementDate) : new Date();
const years = Math.floor((now.getTime() - birth.getTime()) / (1000 * 60 * 60 * 24 * 365));
const months = Math.floor(((now.getTime() - birth.getTime()) % (1000 * 60 * 60 * 24 * 365)) / (1000 * 60 * 60 * 24 * 30));
if (years > 0 && months > 0) {
return `${years}y ${months}mo`;
} else if (years > 0) {
return `${years}y`;
} else if (months > 0) {
return `${months}mo`;
} else {
return "Newborn";
}
interface Percentiles { p3: number; p15: number; p50: number; p85: number; p97: number }
interface WhoStandard {
weight: Percentiles;
height: Percentiles;
headCircumference: Percentiles;
}
export default function GrowthPage() {
const { childId, child, familyId } = useFamily();
const { theme } = useTheme();
const isDark = theme === "dark";
useTheme(); // keep theme context alive for dark mode CSS
const [growthData, setGrowthData] = useState<GrowthRecord[]>([]);
const [whoStandard, setWhoStandard] = useState<any>(null);
const [whoStandard, setWhoStandard] = useState<WhoStandard | null>(null);
const [loading, setLoading] = useState(true);
const [showAdd, setShowAdd] = useState(false);
const [showGoals, setShowGoals] = useState(false);
@ -83,6 +53,7 @@ export default function GrowthPage() {
const [chartMetric, setChartMetric] = useState<"weight" | "height" | "head">("weight");
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const [showWhoStandards, setShowWhoStandards] = useState(true);
const [showChart, setShowChart] = useState(true);
const [showHistory, setShowHistory] = useState(true);
@ -146,10 +117,11 @@ export default function GrowthPage() {
setSaveError(err.error || "Failed to save");
return;
}
trackGrowthLogged();
resetForm();
fetchGrowthData();
} catch (e: any) {
setSaveError(e.message || "Failed to save");
} catch (e) {
setSaveError(e instanceof Error ? e.message : "Failed to save");
} finally {
setSaving(false);
}
@ -186,8 +158,8 @@ export default function GrowthPage() {
};
const handleDelete = async (id: string) => {
if (!confirm("Delete this record?")) return;
await fetch(`/api/growth?id=${id}`, { method: "DELETE" });
setConfirmDeleteId(null);
fetchGrowthData();
};
@ -221,7 +193,7 @@ export default function GrowthPage() {
const headers = ["Date", "Weight (kg)", "Height (cm)", "Head (cm)", "Notes"];
const rows = growthData.map(r => [
new Date(r.measured_at).toLocaleDateString(),
fmtDate(r.measured_at),
r.weight_kg || "",
r.height_cm || "",
r.head_circumference_cm || "",
@ -310,7 +282,7 @@ export default function GrowthPage() {
// WHO 85th percentile
{
label: "85th",
data: labels.map(() => whoStandard?.[whoKey]?.p85),
data: labels.map(() => whoStandard?.[whoKey]?.p85 ?? null),
borderColor: "#fbbf24",
borderDash: [5, 5],
fill: false,
@ -320,7 +292,7 @@ export default function GrowthPage() {
// WHO 50th percentile
{
label: "50th",
data: labels.map(() => whoStandard?.[whoKey]?.p50),
data: labels.map(() => whoStandard?.[whoKey]?.p50 ?? null),
borderColor: "#22c55e",
borderDash: [5, 5],
fill: false,
@ -330,21 +302,21 @@ export default function GrowthPage() {
// WHO 15th percentile
{
label: "15th",
data: labels.map(() => whoStandard?.[whoKey]?.p15),
data: labels.map(() => whoStandard?.[whoKey]?.p15 ?? null),
borderColor: "#fbbf24",
borderDash: [5, 5],
fill: false,
pointRadius: 0,
tension: 0.4,
},
// WHO 3rd-97th band
{
label: "3rd-97th",
data: labels.map(() => [whoStandard?.[whoKey]?.p3, whoStandard?.[whoKey]?.p97]),
borderColor: "transparent",
backgroundColor: "rgba(34, 197, 94, 0.1)",
fill: "+1",
label: "3rd",
data: labels.map(() => whoStandard?.[whoKey]?.p3 ?? null),
borderColor: "#fbbf24",
borderDash: [5, 5],
fill: false,
pointRadius: 0,
tension: 0.4,
},
],
};
@ -369,71 +341,92 @@ export default function GrowthPage() {
return (
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 pb-20">
<div className="p-4 flex justify-between items-center">
<div className="flex items-center gap-4">
<a href="/menu" className="p-2"></a>
<h1 className="text-xl font-bold">Growth 📈</h1>
{/* Header */}
<div className="sticky top-0 z-10 bg-white/80 dark:bg-gray-900/80 backdrop-blur-sm border-b border-gray-100 dark:border-gray-800 px-4 py-3 flex justify-between items-center">
<div className="flex items-center gap-3">
<Link href="/menu" className="text-gray-500 dark:text-gray-400 p-1"></Link>
<h1 className="text-sm font-semibold dark:text-white">Growth 📈</h1>
</div>
<div className="flex gap-2">
<button onClick={exportCSV} className="p-2 text-sm bg-gray-200 dark:bg-gray-700 rounded-lg" title="Export CSV">
📥
</button>
<button onClick={() => setShowGoals(!showGoals)} className="p-2 text-sm bg-gray-200 dark:bg-gray-700 rounded-lg" title="Set goals">
<div className="flex items-center gap-2">
<button
title="Set goals"
onClick={() => { setShowGoals(g => !g); setShowAdd(false); setEditingId(null); }}
className={`p-2 rounded-lg text-sm transition-colors ${showGoals ? "bg-amber-100 dark:bg-amber-900 text-amber-600" : "text-gray-400 hover:text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700"}`}
>
🎯
</button>
<button onClick={() => { setShowAdd(!showAdd); setEditingId(null); setWeight(""); setHeight(""); setHeadCircumference(""); setMeasuredAt(new Date().toISOString().split("T")[0]); }} className="p-2 bg-rose-400 text-white rounded-lg">
<Button
variant="primary"
size="sm"
onClick={() => {
setShowAdd(a => !a);
setShowGoals(false);
setEditingId(null);
setWeight(""); setHeight(""); setHeadCircumference("");
setMeasuredAt(new Date().toISOString().split("T")[0]);
}}
>
+ Add
</button>
</Button>
</div>
</div>
{/* Goals Card */}
{showGoals && (
<div className="mx-4 mb-4 p-4 bg-white dark:bg-gray-800 rounded-xl">
<h3 className="font-semibold mb-3">Set Growth Goals</h3>
<div className="space-y-3">
<div>
<label className="text-sm text-gray-500">Target Weight (kg)</label>
<input
type="number"
step="0.1"
placeholder="e.g., 10"
value={goal.weightKg || ""}
onChange={e => setGoal({ ...goal, weightKg: parseFloat(e.target.value) || undefined })}
className="w-full p-2 border rounded-lg dark:bg-gray-700"
/>
</div>
<div>
<label className="text-sm text-gray-500">Target Height (cm)</label>
<input
type="number"
step="0.1"
placeholder="e.g., 80"
value={goal.heightCm || ""}
onChange={e => setGoal({ ...goal, heightCm: parseFloat(e.target.value) || undefined })}
className="w-full p-2 border rounded-lg dark:bg-gray-700"
/>
</div>
<div className="flex gap-2">
<button onClick={saveGoal} className="flex-1 p-2 bg-rose-400 text-white rounded-lg">
Save Goal
</button>
<button onClick={() => setShowGoals(false)} className="flex-1 p-2 bg-gray-200 dark:bg-gray-700 rounded-lg">
Cancel
</button>
</div>
<div className="mx-4 mt-4 mb-2 p-4 bg-white dark:bg-gray-800 rounded-2xl shadow-sm space-y-3">
<h3 className="font-semibold text-sm dark:text-white">🎯 Growth Goals</h3>
<Input label="Target Weight (kg)" type="number" step="0.1" placeholder="e.g., 10"
value={goal.weightKg || ""}
onChange={e => setGoal({ ...goal, weightKg: parseFloat(e.target.value) || undefined })}
/>
<Input label="Target Height (cm)" type="number" step="0.1" placeholder="e.g., 80"
value={goal.heightCm || ""}
onChange={e => setGoal({ ...goal, heightCm: parseFloat(e.target.value) || undefined })}
/>
<div className="flex gap-2">
<Button fullWidth onClick={saveGoal}>Save Goal</Button>
<Button variant="secondary" fullWidth onClick={() => setShowGoals(false)}>Cancel</Button>
</div>
</div>
)}
{/* Add / Edit Measurement — always at the top, right below the header */}
{showAdd && (
<div className="mx-4 mt-4 mb-2 p-4 bg-white dark:bg-gray-800 rounded-2xl shadow-sm space-y-3">
<h3 className="font-semibold text-sm dark:text-white">{editingId ? "✏️ Edit Record" : "📏 New Measurement"}</h3>
{saveError && (
<div className="p-2 bg-red-50 dark:bg-red-900/40 text-red-600 dark:text-red-300 rounded-lg text-xs">{saveError}</div>
)}
<Input type="date" value={measuredAt} onChange={e => setMeasuredAt(e.target.value)} />
<div className="grid grid-cols-3 gap-2">
<Input type="number" step="0.01" placeholder="Weight kg" value={weight} onChange={e => setWeight(e.target.value)} />
<Input type="number" step="0.1" placeholder="Height cm" value={height} onChange={e => setHeight(e.target.value)} />
<Input type="number" step="0.1" placeholder="Head cm" value={headCircumference} onChange={e => setHeadCircumference(e.target.value)} />
</div>
<div className="flex gap-2">
{editingId ? (
<>
<Button fullWidth loading={saving} onClick={() => handleEdit(editingId)}>Update</Button>
<Button variant="secondary" fullWidth onClick={resetForm}>Cancel</Button>
</>
) : (
<>
<Button fullWidth loading={saving} onClick={() => handleAdd()}>Save</Button>
<Button variant="secondary" fullWidth onClick={resetForm}>Cancel</Button>
</>
)}
</div>
</div>
)}
{/* Latest Reading Card */}
{latest && (
<div className="mx-4 mb-4 p-4 bg-gradient-to-r from-rose-50 to-pink-50 dark:from-rose-900 dark:to-pink-900 rounded-xl hover:shadow-lg transition-shadow cursor-pointer">
<div className="mx-4 mt-4 mb-2 p-4 bg-gradient-to-r from-rose-50 to-pink-50 dark:from-rose-900/40 dark:to-pink-900/40 rounded-2xl shadow-sm">
<div className="flex justify-between items-center mb-3">
<div>
<div className="font-semibold text-rose-600 dark:text-rose-300">Latest Reading</div>
<div className="text-sm text-gray-500">
{new Date(latest.measured_at).toLocaleDateString()} ({child ? formatAge(child.birthDate, latest.measured_at) : ""})
{fmtDate(latest.measured_at)} ({child ? formatAge(child.birthDate, latest.measured_at) : ""})
</div>
</div>
{velocity && (
@ -492,7 +485,7 @@ export default function GrowthPage() {
{/* WHO Standards + Chart Card - Collapsible */}
{child && standard && (
<div className="mx-4 mb-4 bg-white dark:bg-gray-800 rounded-xl overflow-hidden">
<div className="mx-4 mt-4 mb-2 bg-white dark:bg-gray-800 rounded-2xl shadow-sm overflow-hidden">
{/* WHO Standards Header - Clickable */}
<button
onClick={() => setShowWhoStandards(!showWhoStandards)}
@ -614,66 +607,6 @@ export default function GrowthPage() {
</div>
)}
{/* Add Measurement - Same pattern as Goals */}
{showAdd && latest && (
<div className="mx-4 mb-4 p-4 bg-white dark:bg-gray-800 rounded-xl space-y-3">
<h3 className="font-semibold">{editingId ? "Edit Record" : "Add New Measurement"}</h3>
{saveError && (
<div className="p-2 bg-red-100 dark:bg-red-900 text-red-600 rounded-lg text-sm">{saveError}</div>
)}
<input
type="date"
value={measuredAt}
onChange={e => setMeasuredAt(e.target.value)}
className="w-full p-3 border rounded-xl dark:bg-gray-700"
/>
<input
type="number"
step="0.01"
placeholder="Weight (kg)"
value={weight}
onChange={e => setWeight(e.target.value)}
className="w-full p-3 border rounded-xl dark:bg-gray-700"
/>
<input
type="number"
step="0.1"
placeholder="Height (cm)"
value={height}
onChange={e => setHeight(e.target.value)}
className="w-full p-3 border rounded-xl dark:bg-gray-700"
/>
<input
type="number"
step="0.1"
placeholder="Head circumference (cm)"
value={headCircumference}
onChange={e => setHeadCircumference(e.target.value)}
className="w-full p-3 border rounded-xl dark:bg-gray-700"
/>
<div className="flex gap-2">
{editingId ? (
<>
<button onClick={() => handleEdit(editingId)} disabled={saving} className="flex-1 p-3 bg-rose-400 text-white rounded-xl disabled:opacity-50">
{saving ? "Saving..." : "Update"}
</button>
<button onClick={resetForm} className="flex-1 p-3 bg-gray-200 dark:bg-gray-700 rounded-xl">
Cancel
</button>
</>
) : (
<>
<button onClick={() => handleAdd()} disabled={saving} className="flex-1 p-3 bg-rose-400 text-white rounded-xl disabled:opacity-50">
{saving ? "Saving..." : "Save"}
</button>
<button onClick={() => { setShowAdd(false); setWeight(""); setHeight(""); setHeadCircumference(""); }} className="flex-1 p-3 bg-gray-200 dark:bg-gray-700 rounded-xl">
Cancel
</button>
</>
)}
</div>
</div>
)}
{/* Empty State */}
{loading ? (
@ -681,19 +614,19 @@ export default function GrowthPage() {
<p>Loading...</p>
</div>
) : growthData.length === 0 ? (
<div className="mx-4 p-8 bg-white dark:bg-gray-800 rounded-xl text-center">
<div className="text-4xl mb-4">📏</div>
<h3 className="text-lg font-semibold mb-2">Track {child?.name}'s Growth</h3>
<p className="text-gray-500 mb-4">
Start logging weight, height, and head measurements to see how {child?.name} is growing compared to WHO standards.
<div className="mx-4 mt-4 mb-4 p-6 bg-white dark:bg-gray-800 rounded-2xl shadow-sm text-center">
<div className="text-4xl mb-3">📏</div>
<h3 className="text-base font-semibold mb-1 dark:text-white">Track {child?.name}&apos;s Growth</h3>
<p className="text-gray-500 dark:text-gray-400 mb-4 text-sm">
Log weight, height, and head measurements to see how {child?.name} compares to WHO standards.
</p>
<button onClick={() => setShowAdd(true)} className="p-3 bg-rose-400 text-white rounded-xl">
<Button onClick={() => { setShowAdd(true); setShowGoals(false); window.scrollTo({ top: 0, behavior: "smooth" }); }}>
Add First Measurement
</button>
</Button>
</div>
) : (
/* History List - Collapsible */
<div className="mx-4 mb-4 bg-white dark:bg-gray-800 rounded-xl overflow-hidden">
<div className="mx-4 mt-4 mb-4 bg-white dark:bg-gray-800 rounded-2xl shadow-sm overflow-hidden">
<button
onClick={() => setShowHistory(!showHistory)}
className="w-full p-4 flex justify-between items-center hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
@ -707,7 +640,7 @@ export default function GrowthPage() {
<div key={i} className="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg hover:shadow-md transition-shadow flex justify-between items-center">
<div>
<div className="text-sm text-gray-500">
{new Date(record.measured_at).toLocaleDateString()} ({child ? formatAge(child.birthDate, record.measured_at) : ""})
{fmtDate(record.measured_at)} ({child ? formatAge(child.birthDate, record.measured_at) : ""})
</div>
<div className="flex gap-2 mt-1">
{record.weight_kg && (
@ -730,7 +663,7 @@ export default function GrowthPage() {
</button>
<button
onClick={() => handleDelete(record.id)}
onClick={() => setConfirmDeleteId(record.id)}
className="p-2 text-sm text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900 rounded-lg transition-colors"
title="Delete"
>
@ -743,6 +676,16 @@ export default function GrowthPage() {
)}
</div>
)}
<ConfirmDialog
open={!!confirmDeleteId}
onClose={() => setConfirmDeleteId(null)}
onConfirm={() => confirmDeleteId && handleDelete(confirmDeleteId)}
title="Delete this record?"
description="This measurement will be permanently removed."
confirmLabel="Delete"
variant="danger"
/>
</div>
);
}

581
src/app/(app)/home/page.tsx Normal file
View file

@ -0,0 +1,581 @@
"use client";
import { useState, useEffect, useRef } from "react";
import Link from "next/link";
import { useTheme } from "@/app/ThemeProvider";
import { useFamily } from "@/app/FamilyProvider";
import { useStageCheck, type BabyStage } from "@/hooks/useStageCheck";
import { LogModal, type LogType } from "@/components/LogModal";
import { getOfflineQueue, processOfflineQueue } from "@/lib/offline-queue";
import { calculateAge, formatTimeAgo } from "@/lib/formatting";
import { hourIST, isTodayIST, fmtTime } from "@/lib/date-ist";
import type { Log, AIChat, ChatSession } from "@/types";
/** Some Android cameras return file.type = "" — detect from extension as fallback. */
function resolveContentType(file: File): string {
if (file.type && file.type !== "application/octet-stream") return file.type;
const ext = file.name.split(".").pop()?.toLowerCase() ?? "";
const map: Record<string, string> = {
jpg: "image/jpeg", jpeg: "image/jpeg",
png: "image/png", webp: "image/webp",
heic: "image/heic", heif: "image/heic",
};
return map[ext] || "image/jpeg";
}
async function getSessions(cid: string): Promise<ChatSession[]> {
try {
const res = await fetch(`/api/chat?childId=${cid}`);
const data = await res.json();
return data.sessions || [];
} catch { return []; }
}
async function createSession(cid: string): Promise<ChatSession | null> {
try {
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ childId: cid, title: "New conversation" }),
});
const data = await res.json();
return data.session || null;
} catch { return null; }
}
function getGreeting() {
const hour = hourIST();
if (hour < 12) return "Good morning";
if (hour < 18) return "Good afternoon";
return "Good evening";
}
function TodaySummary({ logs }: { logs: Log[] }) {
const today = logs.filter(l => isTodayIST(l.loggedAt));
const counts = {
feed: today.filter(l => l.type === "feed").length,
diaper: today.filter(l => l.type === "diaper").length,
sleep: today.filter(l => l.type === "sleep").length,
};
const lastFeed = today.find(l => l.type === "feed");
const lastDiaper = today.find(l => l.type === "diaper");
const lastSleep = today.find(l => l.type === "sleep");
if (!lastFeed && !lastDiaper && !lastSleep) return null;
return (
<div className="mx-4 mb-3 bg-white dark:bg-gray-800 rounded-2xl shadow-sm overflow-hidden">
<div className="grid grid-cols-3 divide-x divide-gray-100 dark:divide-gray-700">
{[
{ icon: "🍼", label: "Feeds", count: counts.feed, last: lastFeed?.loggedAt },
{ icon: "🚼", label: "Diapers", count: counts.diaper, last: lastDiaper?.loggedAt },
{ icon: "😴", label: "Sleep", count: counts.sleep, last: lastSleep?.loggedAt },
].map(item => (
<div key={item.label} className="flex flex-col items-center py-2 px-1">
<span className="text-xl mb-0.5">{item.icon}</span>
<span className="text-xl font-bold text-gray-800 dark:text-white leading-tight">{item.count}</span>
<span className="text-xs text-gray-400 dark:text-gray-500">{item.label}</span>
{item.last && (
<span className="text-xs text-rose-400 mt-0.5">{formatTimeAgo(item.last)}</span>
)}
</div>
))}
</div>
</div>
);
}
const AI_CHIP_FALLBACK = ["How much should baby eat?", "Sleep schedule tips?", "Development milestones?"];
export default function HomePage() {
const [modalType, setModalType] = useState<"feed" | "diaper" | "sleep" | null>(null);
const [aiOpen, setAiOpen] = useState(false);
const [aiInput, setAiInput] = useState("");
const [aiChats, setAiChats] = useState<AIChat[]>([]);
const [aiLoading, setAiLoading] = useState(false);
const [homeSessionId, setHomeSessionId] = useState<string | null>(null);
const [pendingCount, setPendingCount] = useState(0);
const [recentLogs, setRecentLogs] = useState<Log[]>([]);
const [logsLoading, setLogsLoading] = useState(true);
const [vaccineReminders, setVaccineReminders] = useState<any[]>([]);
const [showPhoneNudge, setShowPhoneNudge] = useState(false);
const [aiChips, setAiChips] = useState<string[]>([]);
const [uploadingPhoto, setUploadingPhoto] = useState(false);
const [photoError, setPhotoError] = useState(false);
const [showPhotoMenu, setShowPhotoMenu] = useState(false);
const photoInputRef = useRef<HTMLInputElement>(null);
const { theme, toggle: toggleTheme } = useTheme();
const { childId, child, familyId, loading, tier, updateChildImage } = useFamily();
const stage = useStageCheck(child?.birthDate ?? null);
useEffect(() => {
if (!childId) return;
getSessions(childId).then(sessions => {
if (sessions.length > 0) {
setAiChats(sessions[0].messages);
}
});
}, [childId]);
useEffect(() => {
if (!childId) return;
const queue = getOfflineQueue();
setPendingCount(queue.length);
const handleOnline = () => processOfflineQueue();
window.addEventListener("online", handleOnline);
fetch(`/api/notifications?childId=${childId}`)
.then(res => res.json())
// Only vaccine notifications belong in the "Vaccine Reminder" banner —
// the API also returns log/memory/garment nudges.
.then(data => setVaccineReminders(
(data.notifications || []).filter((n: { type?: string }) => n.type?.startsWith("vaccine_"))
))
.catch(console.error);
return () => window.removeEventListener("online", handleOnline);
}, [childId]);
// One-time nudge for existing users who have no phone number on file.
// Dismissable; remembered in localStorage so it never nags repeatedly.
useEffect(() => {
if (!childId) return;
if (localStorage.getItem("tia_phone_nudge_dismissed") === "1") return;
fetch("/api/auth/profile")
.then(r => r.json())
.then(data => {
if (data.user && !data.user.phone) setShowPhoneNudge(true);
})
.catch(() => {});
}, [childId]);
const dismissPhoneNudge = () => {
setShowPhoneNudge(false);
localStorage.setItem("tia_phone_nudge_dismissed", "1");
};
const fetchRecentLogs = async () => {
if (!childId) return;
try {
const res = await fetch(`/api/logs?childId=${childId}&limit=50`);
const data = await res.json();
setRecentLogs(data.entries || []);
} catch {}
setLogsLoading(false);
};
useEffect(() => {
if (!childId) return;
setLogsLoading(true);
fetchRecentLogs();
}, [childId]);
useEffect(() => {
if (!childId || !child) return;
const age = calculateAge(child.birthDate);
fetch("/api/ai", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
childId,
messages: [{
role: "user",
content: `Generate exactly 3 short question chips (max 7 words each) a parent might want to ask right now about their baby ${child.name}, age ${age}. Reply ONLY with a JSON array of 3 strings, no other text.`,
}],
}),
})
.then(r => r.json())
.then(d => {
const chips = JSON.parse(d.reply);
if (Array.isArray(chips) && chips.length > 0) setAiChips(chips.slice(0, 3));
else setAiChips(AI_CHIP_FALLBACK);
})
.catch(() => setAiChips(AI_CHIP_FALLBACK));
}, [childId, child?.id]);
if (loading) {
return (
<div className="min-h-screen flex flex-col items-center justify-center gap-4 bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800">
<div className="flex gap-3 text-4xl">
{["🍼", "😴", "🚼", "👶"].map((e, i) => (
<span key={i} className="animate-bounce" style={{ animationDelay: `${i * 120}ms` }}>{e}</span>
))}
</div>
<p className="text-sm text-gray-400">Loading Tia</p>
</div>
);
}
if (!familyId) {
if (typeof window !== "undefined") {
window.location.href = "/login";
}
return <div className="min-h-screen flex items-center justify-center">Redirecting...</div>;
}
if (!childId) {
return <div className="min-h-screen flex items-center justify-center">No child found. Add a child in Family settings.</div>;
}
const toggleDarkMode = () => {
toggleTheme();
};
const handlePhotoChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !childId) return;
const contentType = resolveContentType(file);
setUploadingPhoto(true);
setShowPhotoMenu(false);
try {
const initData = await fetch(`/api/children/${childId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contentType, filename: file.name }),
}).then(r => r.json());
if (!initData.key) throw new Error(initData.error || "Upload failed");
const { key, publicUrl } = initData;
const putRes = await fetch(`/api/upload?${new URLSearchParams({ key, contentType })}`, {
method: "PUT", body: file, headers: { "Content-Type": contentType },
});
if (!putRes.ok) throw new Error(`Upload failed (${putRes.status})`);
await fetch(`/api/children/${childId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ imageUrl: publicUrl }),
});
setPhotoError(false);
updateChildImage(childId, `/api/img?key=${encodeURIComponent(key)}`);
} catch (err) {
console.error("Photo upload failed:", err);
} finally {
setUploadingPhoto(false);
if (photoInputRef.current) photoInputRef.current.value = "";
}
};
const handleRemovePhoto = async () => {
setShowPhotoMenu(false);
if (!childId) return;
setUploadingPhoto(true);
try {
await fetch(`/api/children/${childId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ imageUrl: null }),
});
updateChildImage(childId, null);
} catch (err) {
console.error("Remove photo failed:", err);
alert("Failed to remove photo.");
}
setUploadingPhoto(false);
};
const handleAiChat = async (question?: string) => {
const q = question || aiInput;
if (!q.trim() || aiLoading) return;
setAiLoading(true);
setAiOpen(true);
const inputVal = q.trim();
if (!question) setAiInput("");
let sessionId = homeSessionId;
if (!sessionId) {
const newSession = await createSession(childId);
if (!newSession) { setAiLoading(false); return; }
sessionId = newSession.id;
setHomeSessionId(sessionId);
setAiChats([]);
}
const userMsg: AIChat = { id: "tmp-" + Date.now(), role: "user", content: inputVal, createdAt: new Date().toISOString() };
setAiChats(prev => [...prev, userMsg]);
try {
await fetch("/api/chat", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId, role: "user", content: inputVal }),
});
const res = await fetch("/api/ai", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: [{ role: "user", content: inputVal }] }),
});
const data = await res.json();
const reply = data.reply || "Sorry, I'm having trouble connecting. Please try again.";
await fetch("/api/chat", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId, role: "assistant", content: reply }),
});
setAiChats(prev => [...prev, { id: "ai-" + Date.now(), role: "assistant", content: reply, createdAt: new Date().toISOString() }]);
} catch (err) {
console.error("AI chat error:", err);
}
setAiLoading(false);
};
return (
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 pb-24">
<div className="p-4 flex justify-between items-center">
<button className="p-2"><Link href="/menu"><svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" /></svg></Link></button>
<div className="flex items-center gap-1">
{tier === "pro" ? (
<Link
href="/settings#upgrade"
title="Tia Premium"
className="p-2 text-amber-500 hover:text-amber-600"
>
</Link>
) : (
<Link
href="/settings#upgrade"
title="Upgrade to Premium"
className="relative p-2 text-gray-400 hover:text-rose-500 transition-colors"
>
👑
{/* Pulsing dot hints there's something to explore — non-intrusive */}
<span className="absolute top-1 right-1 w-2 h-2 bg-rose-500 rounded-full animate-pulse" />
</Link>
)}
<Link href="/medical/emergency" className="p-2 text-red-500 hover:text-red-600" title="Emergency Guide">🆘</Link>
<button onClick={toggleDarkMode} className="p-2">{theme === "dark" ? "☀️" : "🌙"}</button>
</div>
</div>
<div className="px-6 pb-4">
<h1 className="text-2xl font-bold">{getGreeting()} 👋</h1>
<p className="text-gray-600 dark:text-gray-300">How is {child?.name || "your baby"} doing today?</p>
</div>
<div className="mx-4 mb-4 p-4 bg-white dark:bg-gray-800 rounded-2xl shadow-md flex items-center gap-4">
{/* Avatar — tap to change/remove photo */}
<div className="relative flex-shrink-0">
<button
onClick={() => {
if (child?.imageUrl) setShowPhotoMenu(m => !m);
else photoInputRef.current?.click();
}}
disabled={uploadingPhoto}
className="relative group"
title={child?.imageUrl ? "Photo options" : "Add photo"}
>
{child?.imageUrl && !photoError
? <img src={child.imageUrl} alt={child?.name} className="w-16 h-16 rounded-full object-cover" onError={() => setPhotoError(true)} />
: <div className="w-16 h-16 bg-rose-100 dark:bg-rose-900 rounded-full flex items-center justify-center text-2xl">👶</div>
}
{/* Camera / upload overlay */}
<div className={`absolute inset-0 rounded-full flex items-center justify-center transition-opacity ${
uploadingPhoto ? "bg-black/40 opacity-100" : "bg-black/0 opacity-0 group-hover:opacity-100 group-active:opacity-100"
}`}>
{uploadingPhoto
? <div className="w-6 h-6 border-2 border-white border-t-transparent rounded-full animate-spin" />
: <span className="text-white text-lg">📷</span>
}
</div>
</button>
{/* Photo options menu */}
{showPhotoMenu && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowPhotoMenu(false)} />
<div className="absolute left-0 top-[68px] z-50 bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-100 dark:border-gray-700 overflow-hidden w-44">
<button
onClick={() => { setShowPhotoMenu(false); photoInputRef.current?.click(); }}
className="w-full flex items-center gap-2.5 px-3 py-2.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
>
<span>📷</span> Change photo
</button>
<button
onClick={handleRemovePhoto}
className="w-full flex items-center gap-2.5 px-3 py-2.5 text-sm text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 border-t border-gray-100 dark:border-gray-700"
>
<span>🗑</span> Remove photo
</button>
</div>
</>
)}
</div>
{/* Hidden file input */}
<input
ref={photoInputRef}
type="file"
accept="image/jpeg,image/jpg,image/png,image/webp,image/heic"
className="hidden"
onChange={handlePhotoChange}
/>
{/* Name + age — tap to go to growth */}
<Link href="/growth" className="flex-1 flex items-center gap-2">
<div className="flex-1">
<div className="text-lg font-semibold">{child?.name || "Baby"}</div>
<div className="text-sm text-gray-500 dark:text-gray-400">{calculateAge(child?.birthDate || "")}</div>
</div>
<div className="text-2xl text-gray-400"></div>
</Link>
</div>
{pendingCount > 0 && (
<button
onClick={() => { processOfflineQueue(); setPendingCount(0); }}
className="mx-4 mb-3 w-[calc(100%-2rem)] bg-amber-100 text-amber-800 px-4 py-2 rounded-xl flex items-center justify-between"
>
<span> {pendingCount} pending log{pendingCount > 1 ? "s" : ""}</span>
<span className="text-sm font-medium">Retry now </span>
</button>
)}
{vaccineReminders.length > 0 && (
<div className="mx-4 mb-4 bg-red-50 border border-red-200 px-4 py-3 rounded-xl">
<Link href="/medical" className="flex items-center justify-between">
<div>
<div className="font-semibold text-red-700">💊 Vaccine Reminder</div>
<div className="text-sm text-red-600">
{vaccineReminders[0].message}
</div>
</div>
<span className="text-red-400"></span>
</Link>
</div>
)}
{showPhoneNudge && (
<div className="mx-4 mb-4 bg-rose-50 dark:bg-rose-900/20 border border-rose-200 dark:border-rose-800/40 px-4 py-3 rounded-xl flex items-center gap-3">
<span className="text-xl flex-shrink-0">📱</span>
<div className="flex-1 min-w-0">
<div className="font-semibold text-rose-700 dark:text-rose-300 text-sm">Add your phone number</div>
<div className="text-xs text-rose-600 dark:text-rose-400">Get important reminders &amp; updates about your baby</div>
</div>
<Link
href="/profile"
className="flex-shrink-0 text-xs font-semibold text-white bg-rose-400 px-3 py-1.5 rounded-lg active:scale-95 transition-transform"
>
Add
</Link>
<button
onClick={dismissPhoneNudge}
className="flex-shrink-0 text-rose-300 dark:text-rose-500 text-lg leading-none px-1"
aria-label="Dismiss"
>
</button>
</div>
)}
<TodaySummary logs={recentLogs} />
{stage && (() => {
const h = hourIST();
type Suggestion = { label: string; type: "feed" | "sleep" | "diaper" };
const matrix: Record<BabyStage, Suggestion> =
h >= 5 && h < 9 ? { newborn: { label: "Feed", type: "feed" }, infant: { label: "Feed", type: "feed" }, sitter: { label: "Feed", type: "feed" }, crawler: { label: "Feed", type: "feed" }, toddler: { label: "Feed", type: "feed" }, walker: { label: "Feed", type: "feed" } } :
h >= 9 && h < 12 ? { newborn: { label: "Feed", type: "feed" }, infant: { label: "Feed + Diaper", type: "feed" }, sitter: { label: "Solids", type: "feed" }, crawler: { label: "Solids", type: "feed" }, toddler: { label: "Solids", type: "feed" }, walker: { label: "Solids", type: "feed" } } :
h >= 12 && h < 15 ? { newborn: { label: "Nap time", type: "sleep" }, infant: { label: "Nap time", type: "sleep" }, sitter: { label: "Nap time", type: "sleep" }, crawler: { label: "Nap time", type: "sleep" }, toddler: { label: "Nap time", type: "sleep" }, walker: { label: "Rest time", type: "sleep" } } :
h >= 15 && h < 18 ? { newborn: { label: "Feed", type: "feed" }, infant: { label: "Feed", type: "feed" }, sitter: { label: "Snack time", type: "feed" }, crawler: { label: "Snack time", type: "feed" }, toddler: { label: "Snack time", type: "feed" }, walker: { label: "Snack time", type: "feed" } } :
h >= 18 && h < 21 ? { newborn: { label: "Evening feed", type: "feed" }, infant: { label: "Evening feed", type: "feed" }, sitter: { label: "Dinner feed", type: "feed" }, crawler: { label: "Dinner feed", type: "feed" }, toddler: { label: "Dinner feed", type: "feed" }, walker: { label: "Dinner feed", type: "feed" } } :
{ newborn: { label: "Night feed", type: "feed" }, infant: { label: "Night feed", type: "feed" }, sitter: { label: "Bedtime", type: "sleep" }, crawler: { label: "Bedtime", type: "sleep" }, toddler: { label: "Bedtime", type: "sleep" }, walker: { label: "Bedtime", type: "sleep" } };
const s = matrix[stage.stage];
return (
<div className="mx-4 mb-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-2xl px-4 py-3 flex items-center justify-between">
<div>
<p className="text-xs font-semibold text-amber-600 dark:text-amber-400 uppercase tracking-wide">Suggested now</p>
<p className="text-base font-bold text-amber-900 dark:text-amber-100">{s.label}</p>
</div>
<button
onClick={() => setModalType(s.type)}
className="px-4 py-2 bg-amber-400 text-white rounded-xl text-sm font-semibold"
>
Log it
</button>
</div>
);
})()}
<div className="px-4 mb-4">
<h2 className="font-semibold mb-3 ml-1">Quick Log</h2>
<div className="flex gap-3 overflow-x-auto scrollbar-hide pb-1 -mx-4 px-4">
<button onClick={() => setModalType("feed")} className="flex-shrink-0 flex flex-col items-center p-4 bg-white dark:bg-gray-800 rounded-xl shadow-sm w-[72px]"><span className="text-3xl">🍼</span><span className="text-sm mt-1">Feed</span></button>
<button onClick={() => setModalType("sleep")} className="flex-shrink-0 flex flex-col items-center p-4 bg-white dark:bg-gray-800 rounded-xl shadow-sm w-[72px]"><span className="text-3xl">😴</span><span className="text-sm mt-1">Sleep</span></button>
<button onClick={() => setModalType("diaper")} className="flex-shrink-0 flex flex-col items-center p-4 bg-white dark:bg-gray-800 rounded-xl shadow-sm w-[72px]"><span className="text-3xl">🚼</span><span className="text-sm mt-1">Diaper</span></button>
<Link href="/medical" className="flex-shrink-0 flex flex-col items-center p-4 bg-white dark:bg-gray-800 rounded-xl shadow-sm w-[72px]"><span className="text-3xl">💊</span><span className="text-sm mt-1">Medical</span></Link>
<Link href="/wardrobe/add" className="flex-shrink-0 flex flex-col items-center p-4 bg-white dark:bg-gray-800 rounded-xl shadow-sm w-[72px]"><span className="text-3xl">👗</span><span className="text-sm mt-1">Wardrobe</span></Link>
</div>
</div>
<div className="px-4">
<div className="flex justify-between items-center mb-3">
<h2 className="font-semibold ml-1">Ask AI</h2>
<Link href="/ai" className="text-rose-500 text-sm">View all chats </Link>
</div>
<div className="p-4 bg-white dark:bg-gray-800 rounded-2xl shadow-md">
<div className="flex gap-2 mb-3">
<input type="text" value={aiInput} onChange={e => setAiInput(e.target.value)} onKeyDown={e => e.key === "Enter" && handleAiChat()} placeholder="Ask anything..." className="flex-1 p-2 border dark:border-gray-600 rounded-xl text-sm bg-white dark:bg-gray-700 dark:text-white dark:placeholder-gray-400" disabled={aiLoading} />
<button onClick={() => handleAiChat()} disabled={aiLoading || !aiInput.trim()} className="px-4 bg-rose-400 text-white rounded-xl text-sm">{aiLoading ? "..." : "Ask"}</button>
</div>
<div className="flex gap-2 overflow-x-auto scrollbar-hide">
{(aiChips.length > 0 ? aiChips : AI_CHIP_FALLBACK).map((q, i) => (
<button key={i} onClick={() => handleAiChat(q)} className="flex-shrink-0 px-3 py-1.5 bg-rose-50 dark:bg-gray-700 text-rose-600 dark:text-rose-400 rounded-full text-sm whitespace-nowrap">{q}</button>
))}
</div>
</div>
</div>
<div className="px-4 mt-4">
<div className="flex justify-between items-center mb-3">
<h2 className="font-semibold ml-1">Recent Activity</h2>
<Link href="/activity" className="text-rose-500 text-sm">See all </Link>
</div>
<div className="space-y-2">
{logsLoading ? <p className="text-gray-400 text-sm">Loading...</p> : recentLogs.length === 0 ? <p className="text-gray-400 text-sm">No logs yet today</p> : recentLogs.slice(0, 3).map(log => (
<div key={log.id} className="flex items-center justify-between p-3 bg-white dark:bg-gray-800 rounded-xl">
<div className="flex items-center gap-3">
<span className="text-xl">{log.type === "feed" && "🍼"}{log.type === "sleep" && "😴"}{log.type === "diaper" && "🚼"}</span>
<div><div className="font-medium capitalize">{log.type}</div><div className="text-xs text-gray-500 dark:text-gray-400">{fmtTime(log.loggedAt)}</div></div>
</div>
{log.amount && <span className="text-sm text-gray-500 dark:text-gray-400">{log.amount}ml</span>}
</div>
))}
</div>
</div>
<LogModal
type={modalType}
childId={childId}
onClose={() => setModalType(null)}
onSaved={fetchRecentLogs}
smartDefault={modalType ? (() => {
const last = recentLogs.find(l => l.type === modalType);
return last?.subType ? { subType: last.subType, amountMl: last.amount ?? undefined } : null;
})() : null}
/>
{aiOpen && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => { setAiOpen(false); setHomeSessionId(null); }}>
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 w-full max-w-sm mx-4 max-h-[80vh] flex flex-col" onClick={e => e.stopPropagation()}>
<div className="flex justify-between items-center mb-3"><h2 className="font-bold">Ask AI</h2><button onClick={() => { setAiOpen(false); setHomeSessionId(null); }}></button></div>
<div className="flex-1 overflow-y-auto space-y-3 mb-3 min-h-[250px]">
{aiChats.map((chat) => (<div key={chat.id} className={`max-w-[85%] p-3 rounded-xl text-sm ${chat.role === "user" ? "ml-auto bg-rose-400 text-white" : "bg-rose-50 dark:bg-gray-700"}`}>{chat.content}</div>))}
{aiLoading && <div className="bg-rose-50 dark:bg-gray-700 p-3 rounded-xl text-sm animate-pulse">Thinking...</div>}
</div>
<div className="flex gap-2">
<input type="text" value={aiInput} onChange={e => setAiInput(e.target.value)} onKeyDown={e => e.key === "Enter" && handleAiChat()} placeholder="Ask a question..." className="flex-1 p-2 border dark:border-gray-600 rounded-xl text-sm dark:bg-gray-700 dark:text-white dark:placeholder-gray-400" disabled={aiLoading} />
<button onClick={() => handleAiChat()} disabled={aiLoading || !aiInput.trim()} className="px-3 bg-rose-400 text-white rounded-xl"></button>
</div>
</div>
</div>
)}
</div>
);
}

27
src/app/(app)/layout.tsx Normal file
View file

@ -0,0 +1,27 @@
import type { Metadata } from "next";
import { ThemeProvider } from "@/app/ThemeProvider";
import { FamilyProvider } from "@/app/FamilyProvider";
import { PageTransition } from "@/components/PageTransition";
import { BottomNav } from "@/components/BottomNav";
import { InstallPrompt } from "@/components/InstallPrompt";
// Private, authenticated app — never index these pages.
export const metadata: Metadata = {
robots: { index: false, follow: false },
};
export default function AppLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<ThemeProvider>
<FamilyProvider>
<PageTransition>{children}</PageTransition>
<BottomNav />
<InstallPrompt />
</FamilyProvider>
</ThemeProvider>
);
}

View file

@ -17,10 +17,12 @@ const RULE_META: Record<string, { icon: string; title: string; color: string }>
export default function EmergencyPage() {
const [phone, setPhone] = useState<string | null>(null);
const [name, setName] = useState<string | null>(null);
useEffect(() => {
fetch("/api/family").then(r => r.json()).then(d => {
setPhone(d.family?.pediatrician_phone || null);
setName(d.family?.pediatrician_name || null);
}).catch(() => {});
}, []);
@ -29,7 +31,7 @@ export default function EmergencyPage() {
{/* Header */}
<div className="sticky top-0 bg-red-500 text-white z-10">
<div className="flex items-center gap-3 p-4">
<Link href="/medical" className="text-white/80 hover:text-white text-xl"></Link>
<Link href="/home" className="text-white/80 hover:text-white text-xl"></Link>
<div>
<h1 className="font-bold text-lg">Emergency Guide</h1>
<p className="text-red-100 text-xs">When to call the doctor immediately</p>
@ -38,20 +40,25 @@ export default function EmergencyPage() {
</div>
{/* Call button */}
<div className="p-4">
<div className="p-4 space-y-1">
{phone ? (
<a
href={`tel:${phone}`}
className="w-full flex items-center justify-center gap-3 bg-red-500 hover:bg-red-600 text-white rounded-2xl p-4 text-lg font-bold shadow-md shadow-red-200 dark:shadow-red-900/30 transition-colors"
>
📞 Call Pediatrician Now
</a>
<>
{name && (
<p className="text-center text-sm font-medium text-gray-600 dark:text-gray-300 mb-2">{name}</p>
)}
<a
href={`tel:${phone}`}
className="w-full flex items-center justify-center gap-3 bg-red-500 hover:bg-red-600 text-white rounded-2xl p-4 text-lg font-bold shadow-md shadow-red-200 dark:shadow-red-900/30 transition-colors"
>
📞 Call Pediatrician Now
</a>
</>
) : (
<Link
href="/settings"
className="w-full flex items-center justify-center gap-2 bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded-2xl p-4 font-medium"
>
+ Add pediatrician phone in Settings
+ Add pediatrician info in Settings
</Link>
)}
</div>

View file

@ -0,0 +1,46 @@
"use client";
import { useState } from "react";
import { useFamily } from "@/app/FamilyProvider";
import { PageHeader } from "@/components/PageHeader";
import { TabBar } from "@/components/TabBar";
import { VaccineTab } from "@/components/medical/VaccineTab";
import { MedicineTab } from "@/components/medical/MedicineTab";
import { AllergyTab } from "@/components/medical/AllergyTab";
import { VisitTab } from "@/components/medical/VisitTab";
import { IllnessTab } from "@/components/medical/IllnessTab";
type MedTab = "vaccinations" | "medicine" | "allergies" | "visits" | "illness";
const TABS = [
{ value: "vaccinations", label: "Vaccines" },
{ value: "medicine", label: "Medicine" },
{ value: "allergies", label: "Allergies" },
{ value: "visits", label: "Doctor Visit" },
{ value: "illness", label: "Illness" },
];
export default function MedicalPage() {
const { childId, child } = useFamily();
const [tab, setTab] = useState<MedTab>("vaccinations");
if (!childId || !child) {
return <div className="p-8 text-center text-gray-400">Loading...</div>;
}
return (
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 pb-20">
<PageHeader title="Medical" />
<div className="px-4 mb-4">
<TabBar tabs={TABS} value={tab} onChange={v => setTab(v as MedTab)} />
</div>
<div className="px-4">
{tab === "vaccinations" && <VaccineTab childId={childId} birthDate={child.birthDate} />}
{tab === "medicine" && <MedicineTab childId={childId} />}
{tab === "allergies" && <AllergyTab childId={childId} />}
{tab === "visits" && <VisitTab childId={childId} />}
{tab === "illness" && <IllnessTab childId={childId} />}
</div>
</div>
);
}

View file

@ -0,0 +1,673 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import Link from "next/link";
import { useFamily } from "@/app/FamilyProvider";
import { Button, ConfirmDialog, Modal } from "@/components/ui";
import { StorageMeter, StorageQuotaBanner } from "@/components/StorageMeter";
import { formatBytes } from "@/lib/format-bytes";
import { trackMemoryAdded } from "@/lib/analytics";
/** Some Android cameras return file.type = "" — detect from extension as fallback. */
function resolveContentType(file: File): string {
if (file.type && file.type !== "application/octet-stream") return file.type;
const ext = file.name.split(".").pop()?.toLowerCase() ?? "";
const map: Record<string, string> = {
jpg: "image/jpeg", jpeg: "image/jpeg",
png: "image/png", webp: "image/webp",
heic: "image/heic", heif: "image/heic",
gif: "image/gif",
};
return map[ext] || "image/jpeg";
}
const PRESET_FOLDERS = [
{ id: "", label: "All", emoji: "🌟" },
{ id: "first-steps", label: "First Steps", emoji: "👣" },
{ id: "bath-time", label: "Bath Time", emoji: "🛁" },
{ id: "feeding", label: "Feeding", emoji: "🍼" },
{ id: "family", label: "Family", emoji: "👨‍👩‍👧" },
{ id: "milestones", label: "Milestones", emoji: "🏆" },
{ id: "birthday", label: "Birthday", emoji: "🎂" },
{ id: "outings", label: "Outings", emoji: "🌳" },
{ id: "smiles", label: "Smiles", emoji: "😊" },
];
const EMOJI_OPTIONS = ["📁","🌈","⭐","🎵","🏖","🎈","🐣","💛","🌸","🦋","🐾","🎀","🌙","🏠","🎠"];
interface Folder { id: string; label: string; emoji: string; }
interface Memory {
id: string;
key: string;
url: string;
thumbnailUrl: string | null;
sizeBytes: number | null;
mimeType: string | null;
title: string | null;
description: string | null;
takenAt: string | null;
visionCaption: string | null;
visionTags: string[] | null;
isPrivate: boolean;
processingStatus: "uploading" | "processing" | "ready" | "failed";
createdAt: string;
}
export default function MemoriesPage() {
const { childId } = useFamily();
const [memories, setMemories] = useState<Memory[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [selected, setSelected] = useState<Memory | null>(null);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const [loadingMore, setLoadingMore] = useState(false);
const [activeFolder, setActiveFolder] = useState("");
const [showFolderPicker, setShowFolderPicker] = useState(false);
const [pendingFolder, setPendingFolder] = useState("");
const [query, setQuery] = useState("");
const [searchResults, setSearchResults] = useState<Memory[] | null>(null);
const [searching, setSearching] = useState(false);
// Custom folders
const [customFolders, setCustomFolders] = useState<Folder[]>([]);
const [showNewFolder, setShowNewFolder] = useState(false);
const [newFolderName, setNewFolderName] = useState("");
const [newFolderEmoji, setNewFolderEmoji] = useState("📁");
// Quota state — fetched on mount, refreshed after any 402 response
const [quotaExceeded, setQuotaExceeded] = useState(false);
const [quotaBanner, setQuotaBanner] = useState<{ usedBytes: number; limitBytes: number } | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
const loaderRef = useRef<HTMLDivElement>(null);
useEffect(() => {
try {
const saved = localStorage.getItem("tia_custom_folders");
if (saved) setCustomFolders(JSON.parse(saved));
} catch {}
}, []);
// Fetch storage quota on mount so we can disable the FAB before the user
// tries to upload and hits a 402 (better UX than a failed upload).
useEffect(() => {
fetch("/api/storage-usage")
.then(r => r.json())
.then(d => {
if (d.exceeded) {
setQuotaExceeded(true);
setQuotaBanner({ usedBytes: d.usedBytes, limitBytes: d.limitBytes });
}
})
.catch(() => {});
}, []);
const allFolders: Folder[] = [...PRESET_FOLDERS, ...customFolders];
const handleCreateFolder = () => {
const trimmed = newFolderName.trim();
if (!trimmed) return;
const id = trimmed.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "") || `folder-${Date.now()}`;
if (allFolders.some(f => f.id === id)) { alert("A folder with this name already exists."); return; }
const folder: Folder = { id, label: trimmed, emoji: newFolderEmoji };
const updated = [...customFolders, folder];
setCustomFolders(updated);
localStorage.setItem("tia_custom_folders", JSON.stringify(updated));
setNewFolderName("");
setNewFolderEmoji("📁");
setShowNewFolder(false);
};
const fetchMemories = useCallback(async (cursor?: string) => {
if (!childId) return;
try {
const params = new URLSearchParams({ childId, limit: "60" });
if (cursor) params.set("cursor", cursor);
const res = await fetch(`/api/memories?${params}`);
const data = await res.json();
if (cursor) setMemories(prev => [...prev, ...(data.items || [])]);
else setMemories(data.items || []);
setNextCursor(data.nextCursor || null);
} catch (err) { console.error("Failed to fetch memories:", err); }
}, [childId]);
useEffect(() => { if (childId) fetchMemories(); }, [childId, fetchMemories]);
useEffect(() => {
if (!loaderRef.current) return;
const observer = new IntersectionObserver(entries => {
if (entries[0].isIntersecting && nextCursor && !loadingMore) {
setLoadingMore(true);
fetchMemories(nextCursor).finally(() => setLoadingMore(false));
}
});
observer.observe(loaderRef.current);
return () => observer.disconnect();
}, [nextCursor, loadingMore, fetchMemories]);
const handleUploadClick = () => setShowFolderPicker(true);
const handleFolderChosen = (folderId: string) => {
setPendingFolder(folderId);
setShowFolderPicker(false);
fileRef.current?.click();
};
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !childId) return;
const contentType = resolveContentType(file);
setUploading(true);
setUploadError(null);
try {
// Step 1: reserve slot + quota gate
const initRes = await fetch("/api/upload", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename: file.name, contentType, childId, sizeBytes: file.size }),
});
const initData = await initRes.json();
if (!initRes.ok) {
if (initRes.status === 402 && initData.reason === "storage_quota_exceeded") {
setQuotaExceeded(true);
setQuotaBanner({ usedBytes: initData.usedBytes ?? 0, limitBytes: initData.limitBytes ?? 0 });
setUploadError("Storage quota exceeded");
} else {
setUploadError(initData.error || `Upload failed (${initRes.status})`);
}
return;
}
const { key, memoryId } = initData;
// Step 2: binary upload via proxy
const putRes = await fetch(`/api/upload?${new URLSearchParams({ key, contentType })}`, {
method: "PUT", body: file, headers: { "Content-Type": contentType },
});
if (!putRes.ok) { setUploadError(`Upload failed (${putRes.status})`); return; }
// Assign folder if chosen
if (pendingFolder) {
await fetch(`/api/memories/${memoryId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ description: pendingFolder }),
}).catch(() => {});
}
// Step 3: confirm
const confirmRes = await fetch(`/api/memories/${memoryId}/confirm`, { method: "POST" });
if (!confirmRes.ok) {
const cd = await confirmRes.json().catch(() => ({})) as { reason?: string; usedBytes?: number; limitBytes?: number; error?: string };
if (confirmRes.status === 402) {
setQuotaExceeded(true);
fetch("/api/storage-usage").then(r => r.json())
.then(d => setQuotaBanner({ usedBytes: d.usedBytes, limitBytes: d.limitBytes }))
.catch(() => {});
setUploadError("Storage quota exceeded");
} else {
setUploadError(cd.error || `Failed to confirm upload (${confirmRes.status})`);
}
return;
}
trackMemoryAdded();
// Optimistic grid update
const proxyUrl = `/api/img?key=${encodeURIComponent(key)}`;
setMemories(prev => [{
id: memoryId, key, url: proxyUrl, thumbnailUrl: null,
sizeBytes: file.size, mimeType: contentType, title: null,
description: pendingFolder || null, takenAt: null,
visionCaption: null, visionTags: null, isPrivate: false,
processingStatus: "processing", createdAt: new Date().toISOString(),
}, ...prev]);
} catch (err) {
setUploadError(err instanceof Error ? err.message : "Upload failed");
} finally {
setUploading(false);
setPendingFolder("");
if (fileRef.current) fileRef.current.value = "";
}
};
const handleDelete = async (id: string) => {
await fetch(`/api/memories/${id}`, { method: "DELETE" });
setMemories(prev => prev.filter(m => m.id !== id));
if (selected?.id === id) setSelected(null);
setDeleteTarget(null);
};
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault();
if (!query.trim()) { setSearchResults(null); return; }
setSearching(true);
try {
const res = await fetch("/api/memories/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: query.trim(), childId }),
});
const data = await res.json();
setSearchResults(data.items || []);
} catch { setSearchResults([]); }
setSearching(false);
};
const baseList = searchResults !== null ? searchResults : memories;
const displayMemories = (activeFolder
? baseList.filter(m => m.description === activeFolder)
: baseList
).filter(m => m.url || m.thumbnailUrl);
const folderCounts = memories.reduce<Record<string, number>>((acc, m) => {
if (m.description) acc[m.description] = (acc[m.description] || 0) + 1;
return acc;
}, {});
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
{/* Header */}
<div className="sticky top-0 z-10 bg-white dark:bg-gray-900 border-b border-gray-100 dark:border-gray-800 px-4 py-3 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/menu" className="text-gray-500 dark:text-gray-400 p-1"></Link>
<h1 className="text-xs font-semibold dark:text-white">Memories 📸</h1>
</div>
</div>
{/* Storage meter — only visible when approaching or exceeded */}
<StorageMeter compact className="px-1" />
</div>
{/* Storage quota banner — shown when upload is blocked */}
{quotaBanner && (
<div className="px-4 pt-3">
<StorageQuotaBanner
usedBytes={quotaBanner.usedBytes}
limitBytes={quotaBanner.limitBytes}
usedFormatted={formatBytes(quotaBanner.usedBytes)}
limitFormatted={formatBytes(quotaBanner.limitBytes)}
/>
</div>
)}
{/* Search */}
<form onSubmit={handleSearch} className="px-4 pt-3 pb-1 flex gap-2">
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search photos..."
className="flex-1 px-3 py-2 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm dark:text-white dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-rose-300"
/>
<Button type="submit" size="sm" loading={searching}>Search</Button>
{searchResults !== null && (
<Button type="button" variant="ghost" size="sm" onClick={() => { setSearchResults(null); setQuery(""); }}>Clear</Button>
)}
</form>
{/* Folder pills */}
<div className="px-4 py-2 flex gap-2 overflow-x-auto scrollbar-hide">
{allFolders.map(folder => {
const count = folder.id === "" ? memories.length : (folderCounts[folder.id] || 0);
const isActive = activeFolder === folder.id;
return (
<button
key={folder.id}
onClick={() => setActiveFolder(folder.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm whitespace-nowrap flex-shrink-0 transition-colors ${
isActive
? "bg-rose-400 text-white"
: "bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 border border-gray-200 dark:border-gray-700"
}`}
>
<span>{folder.emoji}</span>
<span>{folder.label}</span>
{count > 0 && (
<span className={`text-xs ${isActive ? "text-white/80" : "text-gray-400"}`}>{count}</span>
)}
</button>
);
})}
{/* New folder button */}
<button
onClick={() => setShowNewFolder(true)}
className="flex items-center gap-1 px-3 py-1.5 rounded-full text-sm whitespace-nowrap flex-shrink-0 bg-white dark:bg-gray-800 text-gray-400 dark:text-gray-500 border border-dashed border-gray-300 dark:border-gray-600 hover:border-rose-300 hover:text-rose-400 transition-colors"
>
<span>+</span>
<span>New</span>
</button>
</div>
{/* Gallery grid */}
<div className="px-3 pb-24">
{displayMemories.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-center gap-3">
<span className="text-6xl">📷</span>
<p className="font-medium text-gray-500 dark:text-gray-400">
{searchResults !== null ? "No photos found" : activeFolder ? "No photos in this folder yet" : "No memories yet"}
</p>
<p className="text-sm text-gray-400 dark:text-gray-500">
{searchResults !== null ? "Try a different search term." : "Tap + to add your first photo."}
</p>
</div>
) : (
<div className="grid grid-cols-4 gap-1 pt-1">
{displayMemories.map(mem => (
<MemoryTile
key={mem.id}
memory={mem}
folder={allFolders.find(f => f.id === mem.description) ?? null}
onClick={() => setSelected(mem)}
/>
))}
</div>
)}
<div ref={loaderRef} className="py-4 text-center text-sm text-gray-400">
{loadingMore && "Loading more..."}
</div>
</div>
{/* Upload error banner */}
{uploadError && !uploading && (
<div className="fixed top-4 inset-x-4 z-50 bg-red-500 text-white rounded-xl px-4 py-3 flex items-center justify-between shadow-lg">
<span className="text-sm">{uploadError}</span>
<button onClick={() => setUploadError(null)} className="ml-3 text-white/80 hover:text-white text-lg leading-none"></button>
</div>
)}
{/* Upload FAB — disabled when storage quota is exceeded */}
<div className="fixed bottom-24 right-6 z-50">
<button
onClick={quotaExceeded ? undefined : handleUploadClick}
disabled={uploading || quotaExceeded}
title={quotaExceeded ? "Storage full — delete some memories or upgrade to upload more" : undefined}
className={`w-14 h-14 rounded-full shadow-xl flex items-center justify-center text-white text-2xl font-light transition-colors ${
uploading || quotaExceeded ? "bg-gray-400 cursor-not-allowed" : "bg-rose-400 hover:bg-rose-500 active:bg-rose-600"
}`}
>
{uploading ? "…" : quotaExceeded ? "⊘" : "+"}
</button>
<input ref={fileRef} type="file" accept="image/*" onChange={handleUpload} className="hidden" disabled={uploading || quotaExceeded} />
</div>
{/* Folder picker modal */}
<Modal open={showFolderPicker} onClose={() => setShowFolderPicker(false)} title="Add to folder" maxWidth="sm">
<div className="grid grid-cols-3 gap-2">
{allFolders.map(folder => (
<button
key={folder.id}
onClick={() => handleFolderChosen(folder.id)}
className="flex flex-col items-center gap-1 p-3 rounded-xl bg-gray-50 dark:bg-gray-700 hover:bg-rose-50 dark:hover:bg-rose-900/30 transition-colors"
>
<span className="text-2xl">{folder.emoji}</span>
<span className="text-xs text-center text-gray-600 dark:text-gray-300 font-medium leading-tight">{folder.label}</span>
</button>
))}
<button
onClick={() => { setShowFolderPicker(false); setShowNewFolder(true); }}
className="flex flex-col items-center gap-1 p-3 rounded-xl border-2 border-dashed border-gray-200 dark:border-gray-600 hover:border-rose-300 dark:hover:border-rose-700 text-gray-400 hover:text-rose-400 transition-colors"
>
<span className="text-2xl"></span>
<span className="text-xs font-medium">New folder</span>
</button>
</div>
<p className="text-xs text-gray-400 text-center mt-3">Tap "All" to upload without a folder</p>
</Modal>
{/* New folder modal */}
<Modal open={showNewFolder} onClose={() => { setShowNewFolder(false); setNewFolderName(""); setNewFolderEmoji("📁"); }} title="Create folder" maxWidth="sm">
<div className="space-y-3">
<div>
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">Choose an emoji</p>
<div className="flex flex-wrap gap-2">
{EMOJI_OPTIONS.map(em => (
<button
key={em}
onClick={() => setNewFolderEmoji(em)}
className={`text-xl p-1.5 rounded-lg transition-colors ${
newFolderEmoji === em
? "bg-rose-100 dark:bg-rose-900 ring-2 ring-rose-400"
: "bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600"
}`}
>
{em}
</button>
))}
</div>
</div>
<div>
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Folder name</p>
<input
autoFocus
value={newFolderName}
onChange={e => setNewFolderName(e.target.value)}
onKeyDown={e => e.key === "Enter" && handleCreateFolder()}
placeholder="e.g. Park days"
maxLength={30}
className="w-full px-3 py-2 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm dark:text-white focus:outline-none focus:ring-2 focus:ring-rose-300"
/>
</div>
<div className="flex gap-2 pt-1">
<Button variant="secondary" fullWidth onClick={() => { setShowNewFolder(false); setNewFolderName(""); setNewFolderEmoji("📁"); }}>Cancel</Button>
<Button variant="primary" fullWidth onClick={handleCreateFolder} disabled={!newFolderName.trim()}>
{newFolderEmoji} Create
</Button>
</div>
</div>
</Modal>
{/* Fullscreen viewer */}
{selected && (
<MemoryViewer
memory={selected}
allFolders={allFolders}
onClose={() => setSelected(null)}
onDelete={() => setDeleteTarget(selected.id)}
onUpdate={updated => {
setMemories(prev => prev.map(m => m.id === updated.id ? { ...m, ...updated } : m));
setSelected(prev => prev?.id === updated.id ? { ...prev, ...updated } : prev);
}}
/>
)}
<ConfirmDialog
open={!!deleteTarget}
onClose={() => setDeleteTarget(null)}
onConfirm={() => deleteTarget && handleDelete(deleteTarget)}
title="Delete this photo?"
description="This removes the photo permanently from your memories."
confirmLabel="Delete"
variant="danger"
/>
</div>
);
}
// ─── Grid tile ────────────────────────────────────────────────────────────────
function MemoryTile({ memory, folder, onClick }: { memory: Memory; folder: Folder | null; onClick: () => void }) {
const [loaded, setLoaded] = useState(false);
const [imgError, setImgError] = useState(false);
const src = memory.thumbnailUrl || memory.url;
if (imgError) return null;
return (
<div className="flex flex-col gap-0.5">
<button
className="relative w-full aspect-square rounded-xl overflow-hidden group bg-gray-100 dark:bg-gray-800 shadow-sm ring-1 ring-black/5 dark:ring-white/5"
onClick={onClick}
>
<img
src={src}
alt={memory.title || "Memory"}
className={`w-full h-full object-cover transition-all duration-300 group-hover:scale-110 ${loaded ? "opacity-100" : "opacity-0"}`}
onLoad={() => setLoaded(true)}
onError={() => setImgError(true)}
loading="lazy"
/>
{!loaded && <div className="absolute inset-0 bg-gray-200 dark:bg-gray-700 animate-pulse rounded-xl" />}
{/* hover overlay */}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/25 transition-colors duration-200" />
{/* hover expand icon */}
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<span className="text-white text-lg drop-shadow"></span>
</div>
{memory.processingStatus === "processing" && (
<span className="absolute top-1 left-1 text-[8px] bg-black/60 text-white px-1.5 py-0.5 rounded-full leading-tight">
</span>
)}
{memory.isPrivate && <span className="absolute top-1 right-1 text-[9px]">🔒</span>}
</button>
{/* Folder label */}
{folder && folder.id && (
<p className="text-[9px] text-center text-gray-400 dark:text-gray-500 truncate leading-tight px-0.5">
{folder.emoji} {folder.label}
</p>
)}
</div>
);
}
// ─── Fullscreen viewer ────────────────────────────────────────────────────────
function MemoryViewer({ memory, allFolders, onClose, onDelete, onUpdate }: {
memory: Memory;
allFolders: Folder[];
onClose: () => void;
onDelete: () => void;
onUpdate: (updated: Partial<Memory> & { id: string }) => void;
}) {
const [isPrivate, setIsPrivate] = useState(memory.isPrivate);
const [toggling, setToggling] = useState(false);
const [showChrome, setShowChrome] = useState(true);
const [zoomed, setZoomed] = useState(false);
const [folderEditing, setFolderEditing] = useState(false);
const togglePrivate = async () => {
setToggling(true);
const newVal = !isPrivate;
try {
await fetch(`/api/memories/${memory.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ isPrivate: newVal }),
});
setIsPrivate(newVal);
onUpdate({ id: memory.id, isPrivate: newVal, ...(newVal ? { visionCaption: null, visionTags: null } : {}) });
} finally { setToggling(false); }
};
const assignFolder = async (folderId: string) => {
try {
await fetch(`/api/memories/${memory.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ description: folderId || null }),
});
onUpdate({ id: memory.id, description: folderId || null });
} catch (err) { console.error("Failed to assign folder:", err); }
setFolderEditing(false);
};
const currentFolder = allFolders.find(f => f.id === memory.description);
const handleImageTap = () => {
if (zoomed) {
setZoomed(false);
} else {
setShowChrome(s => !s);
}
};
return (
<div className="fixed inset-0 bg-black z-50 flex flex-col" onClick={e => e.target === e.currentTarget && onClose()}>
{/* Top bar */}
<div className={`flex items-center justify-between px-4 py-3 flex-shrink-0 bg-gradient-to-b from-black/70 to-transparent transition-opacity duration-200 ${showChrome ? "opacity-100" : "opacity-0 pointer-events-none"}`}>
<button onClick={onClose} className="text-white/90 hover:text-white p-1 text-lg"></button>
<div className="flex items-center gap-3">
<button
onClick={() => setFolderEditing(true)}
className="text-sm text-white/70 hover:text-white flex items-center gap-1"
>
{currentFolder && currentFolder.id ? `${currentFolder.emoji} ${currentFolder.label}` : "📁 Folder"}
</button>
<button
onClick={togglePrivate}
disabled={toggling}
className={`text-sm px-3 py-1 rounded-full transition-colors ${
isPrivate ? "bg-gray-700 text-gray-200" : "bg-gray-800 text-gray-400 hover:text-white"
}`}
>
{toggling ? "…" : isPrivate ? "🔒" : "🌐"}
</button>
<button onClick={onDelete} className="text-red-400 hover:text-red-300 p-1">🗑</button>
</div>
</div>
{/* Image area */}
<div
className="flex-1 flex items-center justify-center min-h-0 overflow-hidden"
onClick={handleImageTap}
>
<img
src={memory.url}
alt={memory.title || "Memory"}
style={{ transform: zoomed ? "scale(2)" : "scale(1)", transition: "transform 0.25s ease-out" }}
className={`max-w-full max-h-full object-contain ${zoomed ? "cursor-zoom-out" : "cursor-zoom-in"}`}
/>
</div>
{/* Zoom hint */}
{showChrome && !zoomed && (
<div className="absolute bottom-20 left-1/2 -translate-x-1/2 text-white/40 text-xs pointer-events-none">
tap to hide UI · tap again to zoom
</div>
)}
{/* Caption/tags */}
{showChrome && !zoomed && (memory.visionCaption || (memory.visionTags && memory.visionTags.length > 0)) && (
<div className="px-4 py-3 bg-gradient-to-t from-black/80 to-transparent flex-shrink-0">
{memory.visionCaption && (
<p className="text-white/90 text-sm leading-relaxed mb-2">{memory.visionCaption}</p>
)}
{memory.visionTags && memory.visionTags.length > 0 && (
<div className="flex flex-wrap gap-1">
{memory.visionTags.map((tag, i) => (
<span key={i} className="text-xs bg-white/10 text-white/70 px-2 py-0.5 rounded-full">#{tag}</span>
))}
</div>
)}
</div>
)}
{/* Folder assignment bottom sheet */}
{folderEditing && (
<div className="absolute inset-0 bg-black/70 flex items-end z-10" onClick={() => setFolderEditing(false)}>
<div className="w-full bg-white dark:bg-gray-900 rounded-t-2xl p-4 max-h-[70vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<h3 className="font-semibold text-center mb-3 dark:text-white">Move to folder</h3>
<div className="grid grid-cols-4 gap-2">
{allFolders.map(folder => (
<button
key={folder.id}
onClick={() => assignFolder(folder.id)}
className={`flex flex-col items-center gap-1 p-2 rounded-xl transition-colors ${
memory.description === folder.id
? "bg-rose-100 dark:bg-rose-900 ring-2 ring-rose-400"
: "bg-gray-100 dark:bg-gray-800"
}`}
>
<span className="text-xl">{folder.emoji}</span>
<span className="text-xs text-center text-gray-600 dark:text-gray-300 leading-tight">{folder.label}</span>
</button>
))}
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -9,13 +9,15 @@ export default function MenuPage() {
const [signingOut, setSigningOut] = useState(false);
const menuItems = [
{ icon: "🏠", label: "Home", href: "/" },
{ icon: "📊", label: "Activity", href: "/activity" },
{ icon: "📈", label: "Growth", href: "/growth" },
{ icon: "💊", label: "Medical", href: "/medical" },
{ icon: "🏡", label: "Home", href: "/home" },
{ icon: "📝", label: "Activity", href: "/activity" },
{ icon: "🌿", label: "Growth", href: "/growth" },
{ icon: "🩺", label: "Medical", href: "/medical" },
{ icon: "📸", label: "Memories", href: "/memories" },
{ icon: "🤖", label: "AI Chat", href: "/ai" },
{ icon: "🔮", label: "AI Chat", href: "/ai" },
{ icon: "🌟", label: "Milestones", href: "/milestones" },
{ icon: "🧺", label: "Wardrobe", href: "/wardrobe" },
{ icon: "💞", label: "Circle", href: "/circle" },
];
const handleSignOut = async () => {
@ -23,6 +25,12 @@ export default function MenuPage() {
setSigningOut(true);
try {
await fetch("/api/auth/signout", { method: "POST" });
// Purge SW caches so a shared device can't see the previous user's
// cached baby photos or shell state after login.
if ("caches" in window) {
const keys = await caches.keys();
await Promise.all(keys.map((k) => caches.delete(k)));
}
router.push("/login");
} catch (err) {
console.error("Sign out failed:", err);
@ -31,7 +39,7 @@ export default function MenuPage() {
};
return (
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800">
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 pb-28">
{/* Header */}
<div className="p-4 flex justify-between items-center">
<h1 className="text-xl font-bold">Menu</h1>
@ -53,7 +61,7 @@ export default function MenuPage() {
</div>
{/* Bottom Section - Settings and Sign Out */}
<div className="absolute bottom-0 left-0 right-0 p-4 space-y-2">
<div className="px-4 mt-4 space-y-2">
<Link
href="/settings"
className="flex items-center gap-4 p-4 bg-white dark:bg-gray-800 rounded-xl"

View file

@ -3,6 +3,8 @@ import { useEffect, useState, useMemo } from "react";
import { useFamily } from "@/app/FamilyProvider";
import { useStageCheck } from "@/hooks/useStageCheck";
import { MILESTONES, type MilestoneDef } from "@/lib/milestones";
import { Button, Input } from "@/components/ui";
import { fmtDate } from "@/lib/date-ist";
type Category = "all" | "social" | "motor" | "language" | "cognitive";
type Filter = "all" | "achieved" | "upcoming";
@ -29,7 +31,6 @@ export default function MilestonesPage() {
const [filter, setFilter] = useState<Filter>("all");
const [category, setCategory] = useState<Category>("all");
// date picker state per milestone key
const [pendingKey, setPendingKey] = useState<string | null>(null);
const [pendingDate, setPendingDate] = useState<string>(
new Date().toISOString().slice(0, 10)
@ -46,7 +47,6 @@ export default function MilestonesPage() {
async function toggleMilestone(m: MilestoneWithStatus) {
if (m.achieved) {
// un-mark
await fetch(`/api/milestones/${m.key}?childId=${child!.id}`, { method: "DELETE" });
setItems(prev => prev.map(x => x.key === m.key ? { ...x, achieved: false, achievedAt: null } : x));
if (pendingKey === m.key) setPendingKey(null);
@ -84,76 +84,83 @@ export default function MilestonesPage() {
}, [items, stage]);
if (!child) return (
<div className="flex items-center justify-center min-h-screen">
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 flex items-center justify-center">
<p className="text-gray-500">Select a child to view milestones.</p>
</div>
);
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 pb-20">
<div className="max-w-2xl mx-auto px-4 py-6">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 pb-20">
{/* Header */}
<div className="p-4 flex justify-between items-center">
<div className="flex items-center gap-4">
<a href="/menu" className="p-2"></a>
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Milestones</h1>
<p className="text-sm text-gray-500">{child.name}</p>
</div>
{stage && (
<span className="text-sm font-medium px-3 py-1 rounded-full bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300">
{stage.emoji} {stage.label}
</span>
)}
</div>
{/* Progress bar */}
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 mb-4 shadow-sm">
<div className="flex items-center justify-between text-sm mb-2">
<span className="font-medium text-gray-700 dark:text-gray-300">{achievedCount} of {items.length} milestones</span>
<span className="text-gray-400">{Math.round(achievedCount / Math.max(items.length, 1) * 100)}%</span>
</div>
<div className="h-2 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-pink-400 to-purple-400 rounded-full transition-all duration-500"
style={{ width: `${achievedCount / Math.max(items.length, 1) * 100}%` }}
/>
<h1 className="text-xl font-bold">Milestones 🌱</h1>
<p className="text-xs text-gray-500 dark:text-gray-400">{child.name}</p>
</div>
</div>
{stage && (
<span className="text-xs font-medium px-3 py-1.5 rounded-full bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 shadow-sm">
{stage.emoji} {stage.label}
</span>
)}
</div>
{/* Filter tabs */}
<div className="flex gap-2 mb-3 overflow-x-auto pb-1 scrollbar-hide">
{(["all", "achieved", "upcoming"] as Filter[]).map(f => (
<button
key={f}
onClick={() => setFilter(f)}
className={`px-4 py-1.5 rounded-full text-sm font-medium whitespace-nowrap transition-colors ${
filter === f
? "bg-purple-500 text-white"
: "bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 border dark:border-gray-700"
}`}
>
{f.charAt(0).toUpperCase() + f.slice(1)}
</button>
))}
{/* Progress bar */}
<div className="mx-4 mb-4 p-4 bg-white dark:bg-gray-800 rounded-xl shadow-sm">
<div className="flex items-center justify-between text-sm mb-2">
<span className="font-medium text-gray-700 dark:text-gray-300">
{achievedCount} of {items.length} milestones
</span>
<span className="text-gray-400">
{Math.round(achievedCount / Math.max(items.length, 1) * 100)}%
</span>
</div>
{/* Category chips */}
<div className="flex gap-2 mb-5 overflow-x-auto pb-1 scrollbar-hide">
{(["all", "social", "motor", "language", "cognitive"] as Category[]).map(c => (
<button
key={c}
onClick={() => setCategory(c)}
className={`px-3 py-1 rounded-full text-xs font-medium whitespace-nowrap transition-colors ${
category === c
? "bg-gray-800 dark:bg-gray-200 text-white dark:text-gray-900"
: "bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 border dark:border-gray-700"
}`}
>
{c.charAt(0).toUpperCase() + c.slice(1)}
</button>
))}
<div className="h-2 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-rose-400 to-pink-400 rounded-full transition-all duration-500"
style={{ width: `${achievedCount / Math.max(items.length, 1) * 100}%` }}
/>
</div>
</div>
{/* Milestone grid */}
{/* Filter tabs */}
<div className="px-4 mb-3 flex gap-2 overflow-x-auto">
{(["all", "achieved", "upcoming"] as Filter[]).map(f => (
<button
key={f}
onClick={() => setFilter(f)}
className={`px-4 py-2 rounded-full text-sm whitespace-nowrap transition-colors ${
filter === f
? "bg-rose-400 text-white"
: "bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400"
}`}
>
{f.charAt(0).toUpperCase() + f.slice(1)}
</button>
))}
</div>
{/* Category chips */}
<div className="px-4 mb-4 flex gap-2 overflow-x-auto">
{(["all", "social", "motor", "language", "cognitive"] as Category[]).map(c => (
<button
key={c}
onClick={() => setCategory(c)}
className={`px-3 py-1.5 rounded-full text-xs font-medium whitespace-nowrap transition-colors ${
category === c
? "bg-rose-400 text-white"
: "bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400"
}`}
>
{c.charAt(0).toUpperCase() + c.slice(1)}
</button>
))}
</div>
{/* Milestone grid */}
<div className="px-4">
{loading ? (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{Array.from({ length: 6 }).map((_, i) => (
@ -161,7 +168,10 @@ export default function MilestonesPage() {
))}
</div>
) : filtered.length === 0 ? (
<p className="text-center text-gray-400 py-12">No milestones match this filter.</p>
<div className="text-center py-20 text-gray-400">
<div className="text-5xl mb-3">🌱</div>
<p>No milestones match this filter.</p>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{filtered.map(m => (
@ -185,7 +195,7 @@ export default function MilestonesPage() {
<p className="text-xs text-gray-400 mt-1">{m.ageRangeLabel}</p>
{m.achieved && m.achievedAt && (
<p className="text-xs text-green-600 dark:text-green-400 mt-1">
{new Date(m.achievedAt).toLocaleDateString("en-IN", { day: "numeric", month: "short" })}
{fmtDate(m.achievedAt, { day: "numeric", month: "short" })}
</p>
)}
<span className={`inline-block text-xs px-1.5 py-0.5 rounded-full mt-2 ${CATEGORY_COLORS[m.category]}`}>
@ -195,28 +205,22 @@ export default function MilestonesPage() {
{/* Inline date picker */}
{pendingKey === m.key && (
<div className="mt-2 bg-white dark:bg-gray-800 rounded-xl p-3 shadow-md border dark:border-gray-700">
<p className="text-xs text-gray-600 dark:text-gray-400 mb-2">When did this happen?</p>
<input
<div className="mt-2 bg-white dark:bg-gray-800 rounded-xl p-3 shadow-md border border-gray-100 dark:border-gray-700">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2">When did this happen?</p>
<Input
type="date"
value={pendingDate}
max={new Date().toISOString().slice(0, 10)}
onChange={e => setPendingDate(e.target.value)}
className="w-full text-sm border dark:border-gray-600 rounded-lg px-2 py-1.5 dark:bg-gray-700 dark:text-white mb-2"
className="mb-2"
/>
<div className="flex gap-2">
<button
onClick={() => confirmAchieved(m.key)}
className="flex-1 bg-green-500 text-white rounded-lg py-1.5 text-xs font-medium"
>
<Button size="sm" fullWidth onClick={() => confirmAchieved(m.key)}>
Mark achieved
</button>
<button
onClick={() => setPendingKey(null)}
className="flex-1 border dark:border-gray-600 rounded-lg py-1.5 text-xs text-gray-600 dark:text-gray-400"
>
</Button>
<Button size="sm" variant="secondary" fullWidth onClick={() => setPendingKey(null)}>
Cancel
</button>
</Button>
</div>
</div>
)}

View file

@ -0,0 +1,186 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { useFamily } from "@/app/FamilyProvider";
import { fmtDate } from "@/lib/date-ist";
interface AppNotification {
id: string;
type: string;
title: string;
message: string;
actionUrl?: string;
isRead: boolean;
scheduledFor?: string;
createdAt: string;
metadata?: { vaccineName?: string; dueDate?: string };
}
function notifIcon(type: string, metadata?: { dueDate?: string }): string {
if (type.startsWith("vaccine_")) {
const isOverdue = metadata?.dueDate && metadata.dueDate < new Date().toISOString().slice(0, 10);
return isOverdue ? "🚨" : "💉";
}
if (type === "log_nudge") return "🍼";
if (type === "memory_nudge") return "📸";
if (type === "garment_nudge") return "👚";
return "🔔";
}
function notifColor(type: string): string {
if (type.startsWith("vaccine_")) return "bg-red-50 dark:bg-red-900/20 border-red-100 dark:border-red-800/40";
if (type === "log_nudge") return "bg-amber-50 dark:bg-amber-900/20 border-amber-100 dark:border-amber-800/40";
if (type === "memory_nudge") return "bg-purple-50 dark:bg-purple-900/20 border-purple-100 dark:border-purple-800/40";
if (type === "garment_nudge") return "bg-pink-50 dark:bg-pink-900/20 border-pink-100 dark:border-pink-800/40";
return "bg-white dark:bg-gray-800";
}
export default function NotificationsPage() {
const router = useRouter();
const { childId } = useFamily();
const [items, setItems] = useState<AppNotification[]>([]);
const [loading, setLoading] = useState(true);
const [markingAll, setMarkingAll] = useState(false);
const fetchNotifications = useCallback(async () => {
if (!childId) return;
setLoading(true);
try {
const res = await fetch(`/api/notifications?childId=${childId}`);
const data = await res.json();
setItems(data.notifications || []);
} catch { /* swallow — show empty state */ }
setLoading(false);
}, [childId]);
useEffect(() => { fetchNotifications(); }, [fetchNotifications]);
const markRead = async (notif: AppNotification) => {
if (notif.isRead) {
// Navigate immediately if already read
if (notif.actionUrl) router.push(notif.actionUrl);
return;
}
// Optimistic update
setItems(prev => prev.map(n => n.id === notif.id ? { ...n, isRead: true } : n));
try {
await fetch(`/api/notifications/${notif.id}`, { method: "PATCH" });
} catch { /* no-op — optimistic state is fine */ }
if (notif.actionUrl) router.push(notif.actionUrl);
};
const markAllRead = async () => {
if (!childId) return;
setMarkingAll(true);
setItems(prev => prev.map(n => ({ ...n, isRead: true })));
try {
await fetch("/api/notifications", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ childId }),
});
} catch { /* optimistic update already done */ }
setMarkingAll(false);
};
const unreadCount = items.filter(n => !n.isRead).length;
return (
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800">
{/* Header */}
<div className="sticky top-0 z-20 bg-rose-50/80 dark:bg-gray-900/80 backdrop-blur-sm px-4 py-3 flex items-center gap-3 border-b border-rose-100 dark:border-gray-700">
<button onClick={() => router.back()} className="p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-xl"></button>
<h1 className="text-xl font-bold flex-1">Notifications</h1>
{unreadCount > 0 && (
<span className="px-2.5 py-0.5 bg-rose-400 text-white text-xs font-bold rounded-full">
{unreadCount}
</span>
)}
{unreadCount > 0 && (
<button
onClick={markAllRead}
disabled={markingAll}
className="text-sm text-rose-500 font-medium disabled:opacity-50"
>
{markingAll ? "Marking…" : "Mark all read"}
</button>
)}
</div>
{/* Section hint */}
{!loading && items.length > 0 && (
<p className="px-4 pt-3 pb-1 text-xs text-gray-400 dark:text-gray-500">
Tap a notification to go to the relevant page
</p>
)}
{/* List */}
<div className="px-4 py-2 space-y-2 pb-24">
{loading ? (
<div className="text-center py-20 text-gray-400">
<div className="text-4xl mb-3 animate-pulse">🔔</div>
<p className="text-sm">Loading</p>
</div>
) : items.length === 0 ? (
<div className="text-center py-20 text-gray-400">
<div className="text-6xl mb-4">🔔</div>
<p className="font-semibold text-gray-600 dark:text-gray-300">All caught up!</p>
<p className="text-sm mt-1 text-gray-400">No pending reminders right now</p>
</div>
) : (
items.map(notif => (
<button
key={notif.id}
onClick={() => markRead(notif)}
className={`w-full text-left rounded-2xl border transition-all active:scale-[0.98] ${
notif.isRead
? "bg-gray-50 dark:bg-gray-800/40 border-gray-100 dark:border-gray-700/40 opacity-60"
: `${notifColor(notif.type)} border shadow-sm`
}`}
>
<div className="flex items-start gap-3 p-4">
<span className="text-2xl mt-0.5 flex-shrink-0">{notifIcon(notif.type, notif.metadata)}</span>
<div className="flex-1 min-w-0">
<div className={`font-semibold text-sm ${notif.isRead ? "text-gray-400 dark:text-gray-500" : "text-gray-900 dark:text-white"}`}>
{notif.title}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5 leading-snug">
{notif.message}
</div>
{notif.metadata?.dueDate && (
<div className="text-xs text-gray-400 dark:text-gray-500 mt-1">
Due: {fmtDate(notif.metadata.dueDate + "T00:00:00Z", { day: "numeric", month: "short", year: "numeric" })}
</div>
)}
{notif.actionUrl && !notif.isRead && (
<div className="text-xs text-rose-400 dark:text-rose-500 mt-1.5 font-medium">
Tap to open
</div>
)}
</div>
{!notif.isRead && (
<div className="w-2.5 h-2.5 bg-rose-400 rounded-full mt-1.5 flex-shrink-0" />
)}
</div>
</button>
))
)}
</div>
{/* Legend */}
{!loading && items.length > 0 && (
<div className="px-4 pb-8">
<div className="flex flex-wrap gap-x-4 gap-y-1 justify-center text-xs text-gray-400">
<span>🚨 Vaccine overdue</span>
<span>💉 Due today</span>
<span>🍼 Log reminder</span>
<span>📸 Memory nudge</span>
<span>👚 Wardrobe nudge</span>
</div>
</div>
)}
</div>
);
}

View file

@ -2,6 +2,7 @@
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Button, Card, Input, Select } from "@/components/ui";
const IAP_SCHEDULE = [
{ name: "BCG", weeks: 0, milestone: "At Birth" },
@ -54,6 +55,7 @@ export default function OnboardingPage() {
const [form, setForm] = useState({
familyName: "",
memberName: "",
phone: "",
childName: "",
birthDate: "",
sex: "" as "male" | "female" | "other",
@ -67,7 +69,7 @@ export default function OnboardingPage() {
if (!data.authenticated) {
router.push("/login");
} else if (data.familyId) {
router.push("/");
router.push("/home");
}
setCheckingAuth(false);
}
@ -126,7 +128,7 @@ export default function OnboardingPage() {
console.error(e);
}
setSaving(false);
router.push("/");
router.push("/home");
};
if (checkingAuth) {
@ -155,38 +157,24 @@ export default function OnboardingPage() {
</div>
)}
<div className="bg-white rounded-3xl shadow-lg p-6 space-y-4">
<Card className="space-y-4" padding="lg">
{/* Step 1 — Family info */}
{step === 1 && (
<>
<h2 className="font-semibold text-gray-800">Your family</h2>
<label className="block">
<span className="text-sm font-medium">Family Name</span>
<input
type="text"
value={form.familyName}
onChange={(e) => setForm({ ...form, familyName: e.target.value })}
placeholder="The Gupta Family"
className="w-full p-3 border rounded-xl mt-1"
/>
</label>
<label className="block">
<span className="text-sm font-medium">Your Name</span>
<input
type="text"
value={form.memberName}
onChange={(e) => setForm({ ...form, memberName: e.target.value })}
placeholder="Mama"
className="w-full p-3 border rounded-xl mt-1"
/>
</label>
<button
onClick={() => setStep(2)}
disabled={!form.familyName || !form.memberName}
className="w-full p-3 bg-rose-400 text-white rounded-xl font-medium disabled:opacity-50"
>
<Input label="Family Name" type="text" value={form.familyName}
onChange={(e) => setForm({ ...form, familyName: e.target.value })}
placeholder="The Gupta Family" />
<Input label="Your Name" type="text" value={form.memberName}
onChange={(e) => setForm({ ...form, memberName: e.target.value })}
placeholder="Mama" />
<Input label="Phone Number (optional)" type="tel" value={form.phone}
onChange={(e) => setForm({ ...form, phone: e.target.value })}
placeholder="+91 98765 43210" />
<p className="text-xs text-gray-400 -mt-2">For important reminders &amp; updates about your baby</p>
<Button fullWidth size="lg" onClick={() => setStep(2)} disabled={!form.familyName || !form.memberName}>
Next
</button>
</Button>
</>
)}
@ -194,49 +182,35 @@ export default function OnboardingPage() {
{step === 2 && (
<>
<h2 className="font-semibold text-gray-800">Your baby</h2>
<label className="block">
<span className="text-sm font-medium">Baby's Name</span>
<input
type="text"
value={form.childName}
onChange={(e) => setForm({ ...form, childName: e.target.value })}
placeholder="Tia"
className="w-full p-3 border rounded-xl mt-1"
/>
</label>
<label className="block">
<span className="text-sm font-medium">Birth Date</span>
<input
type="date"
value={form.birthDate}
onChange={(e) => setForm({ ...form, birthDate: e.target.value })}
className="w-full p-3 border rounded-xl mt-1"
/>
</label>
<label className="block">
<span className="text-sm font-medium">Sex</span>
<Input label="Baby's Name" type="text" value={form.childName}
onChange={(e) => setForm({ ...form, childName: e.target.value })}
placeholder="Tia" />
<Input label="Birth Date" type="date" value={form.birthDate}
onChange={(e) => setForm({ ...form, birthDate: e.target.value })} />
<div>
<span className="text-sm font-medium text-gray-700">Sex</span>
<div className="flex gap-2 mt-1">
{(["male", "female", "other"] as const).map((s) => (
<button
<Button
key={s}
type="button"
fullWidth
variant={form.sex === s ? "primary" : "secondary"}
onClick={() => setForm({ ...form, sex: s })}
className={`flex-1 p-3 rounded-xl border capitalize ${form.sex === s ? "bg-rose-400 text-white border-rose-400" : "bg-white"}`}
className="capitalize"
>
{s}
</button>
</Button>
))}
</div>
</label>
</div>
<div className="flex gap-2">
<button type="button" onClick={() => setStep(1)} className="flex-1 p-3 border rounded-xl font-medium">Back</button>
<button
onClick={handleSubmit}
disabled={!form.childName || !form.birthDate || !form.sex || loading}
className="flex-1 p-3 bg-rose-400 text-white rounded-xl font-medium disabled:opacity-50"
>
{loading ? "Creating..." : "Next →"}
</button>
<Button variant="secondary" fullWidth onClick={() => setStep(1)}>Back</Button>
<Button fullWidth loading={loading}
disabled={!form.childName || !form.birthDate || !form.sex}
onClick={handleSubmit}>
Next
</Button>
</div>
</>
)}
@ -285,11 +259,8 @@ export default function OnboardingPage() {
</button>
</div>
{s.given && (
<input
type="date"
value={s.date}
<Input type="date" value={s.date} className="mt-2"
onChange={e => setVaccStates(prev => ({ ...prev, [v.name]: { ...s, date: e.target.value } }))}
className="mt-2 w-full p-1.5 border rounded-lg text-sm"
/>
)}
</div>
@ -302,23 +273,12 @@ export default function OnboardingPage() {
)}
<div className="flex gap-2 pt-2">
<button
onClick={() => router.push("/")}
className="flex-1 p-3 border rounded-xl text-sm text-gray-500"
>
Skip for now
</button>
<button
onClick={handleVaccSave}
disabled={saving}
className="flex-1 p-3 bg-rose-400 text-white rounded-xl font-medium disabled:opacity-50"
>
{saving ? "Saving..." : "Save & Go Home"}
</button>
<Button variant="ghost" fullWidth onClick={() => router.push("/home")}>Skip for now</Button>
<Button fullWidth loading={saving} onClick={handleVaccSave}>Save & Go Home</Button>
</div>
</>
)}
</div>
</Card>
</div>
</div>
);

View file

@ -0,0 +1,244 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
/** Some Android cameras return file.type = "" — detect from extension as fallback. */
function resolveContentType(file: File): string {
if (file.type && file.type !== "application/octet-stream") return file.type;
const ext = file.name.split(".").pop()?.toLowerCase() ?? "";
const map: Record<string, string> = {
jpg: "image/jpeg", jpeg: "image/jpeg",
png: "image/png", webp: "image/webp",
heic: "image/heic", heif: "image/heic",
};
return map[ext] || "image/jpeg";
}
export default function ProfilePage() {
const router = useRouter();
const fileRef = useRef<HTMLInputElement>(null);
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [phone, setPhone] = useState("");
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saveMsg, setSaveMsg] = useState("");
const [avatarError, setAvatarError] = useState(false);
const [uploading, setUploading] = useState(false);
useEffect(() => {
fetch("/api/auth/profile")
.then(r => r.json())
.then(data => {
if (data.user) {
setName(data.user.name || "");
setEmail(data.user.email || "");
setPhone(data.user.phone || "");
setAvatarUrl(data.user.avatarUrl || null);
}
setLoading(false);
})
.catch(() => setLoading(false));
}, []);
const handlePhotoChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const contentType = resolveContentType(file);
setUploading(true);
setSaveMsg("");
try {
// Step 1: reserve upload slot
const initRes = await fetch("/api/auth/avatar", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contentType, filename: file.name }),
});
const initData = await initRes.json();
if (!initRes.ok) throw new Error(initData.error || `Upload failed (${initRes.status})`);
const { key, publicUrl: r2Url } = initData;
// Step 2: upload binary via proxy
const putRes = await fetch(`/api/upload?${new URLSearchParams({ key, contentType })}`, {
method: "PUT",
body: file,
headers: { "Content-Type": contentType },
});
if (!putRes.ok) throw new Error(`Upload failed (${putRes.status})`);
// Step 3: save R2 URL to DB
const patchRes = await fetch("/api/auth/avatar", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ avatarUrl: r2Url }),
});
if (!patchRes.ok) throw new Error("Failed to save photo");
setAvatarError(false);
setAvatarUrl(`/api/img?key=${encodeURIComponent(key)}`);
} catch (err) {
setSaveMsg(err instanceof Error ? err.message : "Upload failed");
} finally {
setUploading(false);
if (fileRef.current) fileRef.current.value = "";
}
};
const handleRemovePhoto = async () => {
setSaveMsg("");
try {
const res = await fetch("/api/auth/avatar", { method: "DELETE" });
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to remove photo");
setAvatarUrl(null);
setAvatarError(false);
} catch (err) {
setSaveMsg(err instanceof Error ? err.message : "Failed to remove photo");
}
};
const saveProfile = async () => {
if (!name.trim()) { setSaveMsg("Please enter your name"); return; }
setSaving(true);
setSaveMsg("");
try {
const res = await fetch("/api/auth/profile", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, phone }),
});
const data = await res.json();
setSaveMsg(data.success ? "Saved!" : data.error || "Save failed");
} catch {
setSaveMsg("Failed to save");
}
setSaving(false);
};
const initials = name.split(" ").map(w => w[0]).join("").toUpperCase().slice(0, 2);
return (
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800">
{/* Header */}
<div className="sticky top-0 z-10 bg-white/80 dark:bg-gray-900/80 backdrop-blur-sm border-b border-gray-100 dark:border-gray-800 px-4 py-3 flex items-center gap-3">
<button onClick={() => router.back()} className="text-gray-500 dark:text-gray-400 p-1"></button>
<h1 className="text-sm font-semibold dark:text-white">My Profile</h1>
</div>
<div className="px-4 pb-24 space-y-4">
{/* Avatar */}
<div className="flex flex-col items-center pt-8 pb-2">
<div className="relative mb-3">
{avatarUrl && !avatarError ? (
<img
src={avatarUrl}
alt={name}
className="w-24 h-24 rounded-full object-cover ring-4 ring-white dark:ring-gray-800 shadow-md"
onError={() => setAvatarError(true)}
/>
) : (
<div className="w-24 h-24 rounded-full bg-gradient-to-br from-rose-300 to-amber-300 flex items-center justify-center text-white text-2xl font-bold shadow-md ring-4 ring-white dark:ring-gray-800">
{initials || "👤"}
</div>
)}
{uploading && (
<div className="absolute inset-0 rounded-full bg-black/40 flex items-center justify-center">
<div className="w-6 h-6 border-2 border-white border-t-transparent rounded-full animate-spin" />
</div>
)}
</div>
<button
onClick={() => fileRef.current?.click()}
disabled={uploading}
className="text-sm font-medium text-rose-500 dark:text-rose-400 disabled:opacity-50"
>
{uploading ? "Uploading…" : "Change Photo"}
</button>
{avatarUrl && !uploading && (
<button onClick={handleRemovePhoto} className="text-xs text-gray-400 mt-0.5 hover:text-red-400 transition-colors">
Remove photo
</button>
)}
{!avatarUrl && !uploading && (
<p className="text-xs text-gray-400 mt-0.5">JPEG, PNG, WebP or HEIC</p>
)}
<input
ref={fileRef}
type="file"
accept="image/jpeg,image/jpg,image/png,image/webp,image/heic"
onChange={handlePhotoChange}
className="hidden"
/>
</div>
{/* Form */}
{loading ? (
<div className="text-center py-8 text-gray-400 text-sm">Loading</div>
) : (
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-sm p-4 space-y-4">
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Name</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
className="w-full px-3 py-2.5 bg-gray-50 dark:bg-gray-700 rounded-xl border border-gray-200 dark:border-gray-600 text-sm dark:text-white focus:outline-none focus:ring-2 focus:ring-rose-300"
placeholder="Your name"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Phone Number</label>
<input
type="tel"
value={phone}
onChange={e => setPhone(e.target.value)}
className="w-full px-3 py-2.5 bg-gray-50 dark:bg-gray-700 rounded-xl border border-gray-200 dark:border-gray-600 text-sm dark:text-white focus:outline-none focus:ring-2 focus:ring-rose-300"
placeholder="+91 98765 43210"
/>
<p className="text-xs text-gray-400 mt-1">For important reminders &amp; updates (optional)</p>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Email</label>
<input
type="email"
value={email}
disabled
className="w-full px-3 py-2.5 bg-gray-100 dark:bg-gray-700/50 rounded-xl border border-gray-200 dark:border-gray-600 text-sm text-gray-400 dark:text-gray-500 cursor-not-allowed"
/>
<p className="text-xs text-gray-400 mt-1">Email cannot be changed</p>
</div>
{saveMsg && (
<p className={`text-xs text-center font-medium ${saveMsg === "Saved!" ? "text-green-600 dark:text-green-400" : "text-red-500"}`}>
{saveMsg}
</p>
)}
<button
onClick={saveProfile}
disabled={saving || !name.trim()}
className="w-full py-3 bg-rose-400 text-white rounded-xl font-medium text-sm disabled:opacity-50 active:scale-95 transition-transform"
>
{saving ? "Saving…" : "Save Changes"}
</button>
</div>
)}
{/* Account info */}
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-sm p-4">
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Account</p>
<p className="text-sm text-gray-700 dark:text-gray-300">{email}</p>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,516 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useTheme } from "@/app/ThemeProvider";
import { useFamily } from "@/app/FamilyProvider";
import { Button, Card, Input, Select, Badge } from "@/components/ui";
import { StorageMeter, MemberLimitBanner } from "@/components/StorageMeter";
import { UpgradeButton } from "@/components/UpgradeButton";
import { fmtDate } from "@/lib/date-ist";
interface Member {
id: string;
userId: string;
role: string;
displayName: string;
name: string;
email: string;
}
interface Invite {
id: string;
email: string;
displayName: string;
role: string;
expiresAt: string;
}
export default function SettingsPage() {
const router = useRouter();
const { theme, mode, setMode } = useTheme();
const { tier, memberCount, familyId, children, familyName: providerFamilyName, childId, child } = useFamily();
const [exporting, setExporting] = useState(false);
const [themeOpen, setThemeOpen] = useState(false);
const [inviteOpen, setInviteOpen] = useState(false);
const [familyOpen, setFamilyOpen] = useState(true);
const [members, setMembers] = useState<Member[]>([]);
const [invites, setInvites] = useState<Invite[]>([]);
const [inviteEmail, setInviteEmail] = useState("");
const [inviteRole, setInviteRole] = useState("caregiver");
const [inviteLoading, setInviteLoading] = useState(false);
const [pedPhone, setPedPhone] = useState("");
const [pedName, setPedName] = useState("");
const [pedSaving, setPedSaving] = useState(false);
const [pedEditing, setPedEditing] = useState(false);
const [pedSaved, setPedSaved] = useState(false);
const [pedError, setPedError] = useState("");
const [cancelling, setCancelling] = useState(false);
const [cancelMsg, setCancelMsg] = useState("");
const handleCancelSubscription = async () => {
if (!window.confirm("Cancel your subscription? You'll keep premium until the end of your current billing period.")) return;
setCancelling(true);
try {
const res = await fetch("/api/subscriptions/cancel", { method: "POST" });
const data = await res.json();
setCancelMsg(res.ok ? data.message : (data.error || "Could not cancel."));
} catch {
setCancelMsg("Could not reach the server. Try again.");
}
setCancelling(false);
};
// Check if can invite more members (client-side pre-check; server enforces)
const canInvite = tier === "pro" || memberCount < 2;
// Family name from provider or fallback
const familyName = providerFamilyName || "My Family";
const themeOptions = [
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
{ value: "system", label: "System" },
{ value: "time", label: "Time of Day" },
] as const;
useEffect(() => {
if (familyId) {
fetchMembers();
fetchInvites();
fetch("/api/family").then(r => r.json()).then(d => {
setPedPhone(d.family?.pediatrician_phone || "");
setPedName(d.family?.pediatrician_name || "");
// If no data yet, open in edit mode so user can fill it in
if (!d.family?.pediatrician_phone && !d.family?.pediatrician_name) setPedEditing(true);
}).catch(() => {});
}
}, [familyId]);
const exportGrowthCSV = async () => {
if (!childId) return;
setExporting(true);
try {
const res = await fetch(`/api/growth?childId=${childId}`);
const data = await res.json();
const records = data.growth || [];
if (records.length === 0) { alert("No growth records to export."); return; }
const headers = ["Date", "Weight (kg)", "Height (cm)", "Head (cm)", "Notes"];
const rows = records.map((r: { measured_at: string; weight_kg: number | null; height_cm: number | null; head_circumference_cm: number | null; notes: string | null }) => [
fmtDate(r.measured_at),
r.weight_kg ?? "",
r.height_cm ?? "",
r.head_circumference_cm ?? "",
r.notes ?? "",
]);
const csv = [headers, ...rows].map(row => row.join(",")).join("\n");
const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${child?.name || "child"}_growth_${new Date().toISOString().split("T")[0]}.csv`;
a.click();
URL.revokeObjectURL(url);
} catch { alert("Export failed. Please try again."); }
setExporting(false);
};
const savePedInfo = async () => {
setPedSaving(true);
setPedError("");
try {
const res = await fetch("/api/family", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pediatricianPhone: pedPhone, pediatricianName: pedName }),
});
const data = await res.json();
if (!res.ok) {
setPedError(data.error || "Failed to save. Please try again.");
} else {
setPedEditing(false);
setPedSaved(true);
setTimeout(() => setPedSaved(false), 3000);
}
} catch {
setPedError("Network error. Please try again.");
}
setPedSaving(false);
};
const fetchMembers = async () => {
if (!familyId) return;
try {
const res = await fetch(`/api/family/members?familyId=${familyId}`);
const data = await res.json();
setMembers(data.members || []);
} catch (err) {
console.error("Failed to fetch members:", err);
}
};
const fetchInvites = async () => {
if (!familyId) return;
try {
const res = await fetch(`/api/invites?familyId=${familyId}`);
const data = await res.json();
setInvites(data.invites || []);
} catch (err) {
console.error("Failed to fetch invites:", err);
}
};
const deleteInvite = async (inviteId: string) => {
try {
await fetch(`/api/invites/${inviteId}`, { method: "DELETE" });
setInvites(prev => prev.filter(i => i.id !== inviteId));
} catch (err) {
console.error("Failed to delete invite:", err);
}
};
const sendInvite = async () => {
if (!inviteEmail || !familyId) return;
setInviteLoading(true);
try {
const res = await fetch("/api/invites", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
familyId: familyId,
email: inviteEmail,
role: inviteRole,
displayName: inviteEmail.split("@")[0],
}),
});
const data = await res.json();
if (data.success) {
setInviteEmail("");
fetchInvites();
} else if (data.reason === "member_limit_reached") {
// Trigger re-render of the limit banner with fresh data
fetchMembers();
} else {
alert(data.error);
}
} catch (err) {
console.error("Failed to send invite:", err);
}
setInviteLoading(false);
};
return (
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800">
<div className="p-4 flex items-center gap-4">
<button onClick={() => router.back()} className="p-2"></button>
<h1 className="text-xl font-bold">Settings</h1>
</div>
<div className="px-4 space-y-3">
{/* Plan / Upgrade — anchor target for all "upgrade" CTAs across the app */}
<div id="upgrade" className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm scroll-mt-20">
<div className="flex items-center justify-between mb-1">
<p className="font-semibold text-gray-900 dark:text-white">
{tier === "pro" ? "✨ Tia Premium" : "Your Plan"}
</p>
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${tier === "pro" ? "bg-green-100 text-green-700" : "bg-rose-100 text-rose-700"}`}>
{tier === "pro" ? "Premium" : "Free"}
</span>
</div>
{tier === "pro" ? (
<>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3">
50 GB storage · up to 6 members · up to 3 baby profiles
</p>
{cancelMsg ? (
<p className="text-sm text-amber-600 dark:text-amber-400">{cancelMsg}</p>
) : (
<button
onClick={handleCancelSubscription}
disabled={cancelling}
className="text-sm text-gray-400 hover:text-red-500 disabled:opacity-50 underline"
>
{cancelling ? "Cancelling…" : "Cancel subscription"}
</button>
)}
</>
) : (
<>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3">
Upgrade for <strong>50 GB</strong> media storage, up to <strong>6 family members</strong>,
and <strong>3 baby profiles</strong>. 199/month, cancel anytime.
</p>
<UpgradeButton />
</>
)}
</div>
{/* My Profile Page */}
<a href="/settings/profile"
className="flex items-center justify-between bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm mb-3">
<div>
<p className="font-medium text-gray-900 dark:text-white">My Profile Page</p>
<p className="text-xs text-gray-500 dark:text-gray-400">Create your public product recommendation page</p>
</div>
<span className="text-gray-400"></span>
</a>
{/* Notifications */}
<Link href="/notifications" className="flex items-center justify-between p-4 bg-white dark:bg-gray-800 rounded-xl">
<div className="flex items-center gap-3">
<span className="text-xl">🔔</span>
<div className="font-medium">Notifications</div>
</div>
<span className="text-gray-400"></span>
</Link>
{/* Profile */}
<Link href="/profile" className="flex items-center justify-between p-4 bg-white dark:bg-gray-800 rounded-xl">
<div className="flex items-center gap-3">
<span className="text-xl">👤</span>
<div className="font-medium">Profile</div>
</div>
<span className="text-gray-400"></span>
</Link>
{/* Family - Single consolidated section */}
<div className="bg-white dark:bg-gray-800 rounded-xl overflow-hidden">
<button
onClick={() => setFamilyOpen(!familyOpen)}
className="w-full flex items-center justify-between p-4"
>
<div className="flex items-center gap-3">
<span className="text-xl">🏠</span>
<div className="font-medium">Family</div>
</div>
<span className={`text-gray-400 transition-transform ${familyOpen ? "rotate-180" : ""}`}></span>
</button>
{familyOpen && (
<div className="px-4 pb-4 space-y-4">
{/* Family Info */}
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-lg">{familyName}</div>
<div className="text-sm text-gray-500">
{tier === "pro" ? "Pro Plan" : <span className="bg-rose-100 text-rose-700 px-2 py-0.5 rounded-full text-xs">Free Plan</span>} · {members.length} member{members.length !== 1 ? "s" : ""} · {children?.length || 0} child{children?.length !== 1 ? "ren" : ""}
</div>
</div>
{tier === "free" && (
<a href="#upgrade">
<Button size="sm">Upgrade</Button>
</a>
)}
</div>
{/* Family Members */}
<div>
<div className="text-sm font-medium text-gray-500 mb-2">Family Members</div>
{members.length > 0 ? (
<div className="space-y-2">
{members.map((member) => (
<div key={member.id} className="flex items-center justify-between p-2 bg-gray-50 dark:bg-gray-700 rounded">
<div>
<div className="font-medium text-sm">{member.name || member.email}</div>
<div className="text-xs text-gray-400">{member.email}</div>
</div>
<Badge variant={member.role === "admin" ? "rose" : "default"}>{member.role}</Badge>
</div>
))}
</div>
) : (
<p className="text-gray-400 text-sm">No family members</p>
)}
</div>
{/* Storage usage meter */}
<div>
<div className="text-sm font-medium text-gray-500 mb-2">Storage</div>
<StorageMeter />
</div>
{/* Manage Children */}
<Link href="/family" className="flex items-center justify-between p-3 border border-dashed border-gray-300 dark:border-gray-600 rounded-lg">
<span className="text-sm">Manage Children</span>
<span className="text-gray-400"></span>
</Link>
</div>
)}
</div>
{/* Invite Members */}
<div className="bg-white dark:bg-gray-800 rounded-xl overflow-hidden">
<button
onClick={() => setInviteOpen(!inviteOpen)}
className="w-full flex items-center justify-between p-4"
>
<div className="flex items-center gap-3">
<span className="text-xl"></span>
<div className="font-medium">Invite Members</div>
{tier === "free" && (
<span className="text-xs px-2 py-0.5 bg-rose-100 text-rose-600 rounded-full">{memberCount}/2</span>
)}
</div>
<span className={`text-gray-400 transition-transform ${inviteOpen ? "rotate-180" : ""}`}></span>
</button>
{inviteOpen && (
<div className="px-4 pb-4">
{/* Member limit banner */}
{tier === "free" && !canInvite && (
<div className="mb-3">
<MemberLimitBanner currentCount={memberCount} limit={2} />
</div>
)}
{/* Pending invites */}
{invites.length > 0 && (
<div className="mb-3">
<div className="text-sm text-gray-500 mb-2">Pending Invites</div>
{invites.map((invite) => (
<div key={invite.id} className="flex justify-between items-center p-2 bg-gray-50 dark:bg-gray-700 rounded text-sm mb-1">
<div>
<div className="font-medium text-gray-800 dark:text-gray-100">{invite.email}</div>
<div className="text-xs text-gray-400">Pending · expires {fmtDate(invite.expiresAt)}</div>
</div>
<button
onClick={() => deleteInvite(invite.id)}
className="text-xs text-red-400 hover:text-red-600 dark:hover:text-red-300 px-2 py-1 rounded hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
title="Cancel invite"
>
Cancel
</button>
</div>
))}
</div>
)}
{/* Add invite form */}
{canInvite && (
<div className="space-y-2">
<Input type="email" value={inviteEmail} onChange={(e) => setInviteEmail(e.target.value)} placeholder="Email address" />
<Select value={inviteRole} onChange={(e) => setInviteRole(e.target.value)}>
<option value="caregiver">Caregiver</option>
<option value="viewer">Viewer (read-only)</option>
</Select>
<Button fullWidth loading={inviteLoading} disabled={!inviteEmail} onClick={sendInvite}>
Send Invite
</Button>
</div>
)}
</div>
)}
</div>
{/* Theme */}
<div className="bg-white dark:bg-gray-800 rounded-xl overflow-hidden">
<button
onClick={() => setThemeOpen(!themeOpen)}
className="w-full flex items-center justify-between p-4"
>
<div className="flex items-center gap-3">
<span className="text-xl">{theme === "dark" ? "🌙" : "☀️"}</span>
<div className="font-medium">Theme</div>
</div>
<span className={`text-gray-400 transition-transform ${themeOpen ? "rotate-180" : ""}`}></span>
</button>
{themeOpen && (
<div className="px-4 pb-4">
<div className="grid grid-cols-2 gap-2">
{themeOptions.map((opt) => (
<Button
key={opt.value}
onClick={() => setMode(opt.value)}
variant={mode === opt.value ? "primary" : "secondary"}
>
{opt.label}
</Button>
))}
</div>
</div>
)}
</div>
{/* Pediatrician */}
<div className="bg-white dark:bg-gray-800 rounded-xl p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xl">🏥</span>
<div className="font-medium dark:text-white">Pediatrician</div>
</div>
{!pedEditing && (pedName || pedPhone) && (
<button
onClick={() => { setPedEditing(true); setPedSaved(false); setPedError(""); }}
className="text-sm text-rose-500 dark:text-rose-400 font-medium"
>
Edit
</button>
)}
</div>
{/* Display mode */}
{!pedEditing && (pedName || pedPhone) ? (
<div className="space-y-0.5">
{pedName && <div className="text-sm font-medium text-gray-800 dark:text-gray-100">{pedName}</div>}
{pedPhone && <div className="text-sm text-gray-500 dark:text-gray-400">{pedPhone}</div>}
{pedSaved && <div className="text-xs text-green-600 dark:text-green-400">Saved!</div>}
</div>
) : (
/* Edit / first-fill mode */
<div className="space-y-2">
<p className="text-xs text-gray-400 dark:text-gray-500">Shown on the emergency guide and in AI medical redirects.</p>
<Input
type="text"
value={pedName}
onChange={e => setPedName(e.target.value)}
placeholder="Dr. Priya Sharma"
/>
<div className="flex gap-2">
<Input type="tel" value={pedPhone} onChange={e => setPedPhone(e.target.value)} placeholder="+91 98765 43210" className="flex-1" />
<Button size="sm" loading={pedSaving} onClick={savePedInfo}>Save</Button>
</div>
{pedEditing && (pedName || pedPhone) && (
<button
onClick={() => { setPedEditing(false); setPedError(""); }}
className="text-xs text-gray-400 dark:text-gray-500"
>
Cancel
</button>
)}
{pedError && <p className="text-xs text-red-500">{pedError}</p>}
</div>
)}
<Link href="/medical/emergency" className="text-xs text-rose-500 dark:text-rose-400">
View Emergency Guide
</Link>
</div>
{/* Export Data */}
<button
onClick={exportGrowthCSV}
disabled={exporting || !childId}
className="w-full flex items-center justify-between p-4 bg-white dark:bg-gray-800 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50"
>
<div className="flex items-center gap-3">
<span className="text-xl">📥</span>
<div>
<div className="font-medium text-left">Export Growth Data</div>
<div className="text-xs text-gray-400 text-left">Download {child?.name ? `${child.name}'s` : "growth"} records as CSV</div>
</div>
</div>
<span className="text-gray-400 text-sm">{exporting ? "…" : "→"}</span>
</button>
{/* App Version */}
<div className="p-4 bg-white dark:bg-gray-800 rounded-xl mt-4">
<div className="font-medium">App Version</div>
<div className="text-sm text-gray-500">Tia v1.0.0</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,427 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
interface Profile {
id: string;
slug: string;
display_name: string;
bio: string | null;
avatar_url: string | null;
is_public: boolean;
}
interface Product {
id: string;
title: string;
description: string | null;
url: string;
image_url: string | null;
category: string;
display_order: number;
click_count: number;
}
const CATEGORIES = ["general", "feeding", "sleep", "play", "clothing"] as const;
const CATEGORY_EMOJI: Record<string, string> = { feeding: "🍼", sleep: "💤", play: "🎮", clothing: "👗", general: "🛍️" };
export default function ProfileSettingsPage() {
const router = useRouter();
const [profile, setProfile] = useState<Profile | null>(null);
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saveMsg, setSaveMsg] = useState("");
const [profileExpanded, setProfileExpanded] = useState(true);
const [shareProductId, setShareProductId] = useState<string | null>(null);
const [profileShareOpen, setProfileShareOpen] = useState(false);
const [copied, setCopied] = useState(false);
const [slug, setSlug] = useState("");
const [displayName, setDisplayName] = useState("");
const [bio, setBio] = useState("");
const [isPublic, setIsPublic] = useState(false);
const [showAddProduct, setShowAddProduct] = useState(false);
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
const [pTitle, setPTitle] = useState("");
const [pUrl, setPUrl] = useState("");
const [pDesc, setPDesc] = useState("");
const [pImageUrl, setPImageUrl] = useState("");
const [pCategory, setPCategory] = useState("general");
useEffect(() => {
Promise.all([
fetch("/api/profile").then(r => r.json()),
fetch("/api/profile/products").then(r => r.json()),
]).then(([pd, prods]) => {
if (pd.profile) {
setProfile(pd.profile);
setSlug(pd.profile.slug);
setDisplayName(pd.profile.display_name);
setBio(pd.profile.bio ?? "");
setIsPublic(pd.profile.is_public);
setProfileExpanded(false); // already set up — start collapsed
}
setProducts(prods.items || []);
setLoading(false);
}).catch(() => setLoading(false));
}, []);
const profileUrl = typeof window !== "undefined" && slug ? `${window.location.origin}/m/${slug}` : "";
async function saveProfile() {
setSaving(true);
setSaveMsg("");
const r = await fetch("/api/profile", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug, display_name: displayName, bio: bio || null, is_public: isPublic }),
});
const d = await r.json();
if (r.ok) {
setProfile(d.profile);
setSaveMsg("Saved!");
setProfileExpanded(false); // collapse after save
} else {
setSaveMsg(d.error || "Save failed");
}
setSaving(false);
}
async function copyLink(url: string) {
await navigator.clipboard.writeText(url);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
function shareViaWhatsApp(url: string, text: string) {
const msg = encodeURIComponent(`${text} ${url}`);
window.open(`https://wa.me/?text=${msg}`, "_blank");
}
async function shareNative(title: string, url: string) {
if (navigator.share) {
await navigator.share({ title, url });
}
}
function resetProductForm() {
setPTitle(""); setPUrl(""); setPDesc(""); setPImageUrl(""); setPCategory("general");
setShowAddProduct(false); setEditingProduct(null);
}
async function saveProduct() {
const body = { title: pTitle, url: pUrl, description: pDesc || null, image_url: pImageUrl || null, category: pCategory };
if (editingProduct) {
await fetch(`/api/profile/products/${editingProduct.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
} else {
await fetch("/api/profile/products", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...body, display_order: products.length }),
});
}
const r = await fetch("/api/profile/products");
const d = await r.json();
setProducts(d.items || []);
resetProductForm();
}
async function deleteProduct(id: string) {
if (!confirm("Remove this product?")) return;
await fetch(`/api/profile/products/${id}`, { method: "DELETE" });
setProducts(p => p.filter(x => x.id !== id));
}
async function moveProduct(id: string, dir: "up" | "down") {
const idx = products.findIndex(p => p.id === id);
if ((dir === "up" && idx === 0) || (dir === "down" && idx === products.length - 1)) return;
const swapIdx = dir === "up" ? idx - 1 : idx + 1;
await fetch(`/api/profile/products/${id}`, {
method: "PATCH", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ display_order: products[swapIdx].display_order }),
});
await fetch(`/api/profile/products/${products[swapIdx].id}`, {
method: "PATCH", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ display_order: products[idx].display_order }),
});
const r = await fetch("/api/profile/products");
const d = await r.json();
setProducts(d.items || []);
}
function startEdit(p: Product) {
setEditingProduct(p); setPTitle(p.title); setPUrl(p.url);
setPDesc(p.description ?? ""); setPImageUrl(p.image_url ?? ""); setPCategory(p.category);
setShowAddProduct(true);
}
const slugValid = /^[a-z0-9-]{3,40}$/.test(slug);
const baseUrl = typeof window !== "undefined" ? window.location.origin : "";
const inputClass = "w-full border border-gray-200 dark:border-gray-600 rounded-xl px-3 py-2.5 text-sm bg-white dark:bg-gray-700 dark:text-white focus:outline-none focus:ring-2 focus:ring-rose-300";
if (loading) return (
<div className="min-h-screen flex flex-col items-center justify-center gap-4 bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800">
<div className="flex gap-3 text-4xl">
{["🍼", "😴", "🚼", "👶"].map((e, i) => (
<span key={i} className="animate-bounce" style={{ animationDelay: `${i * 120}ms` }}>{e}</span>
))}
</div>
<p className="text-sm text-gray-400">Loading profile</p>
</div>
);
return (
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 pb-24">
{/* Header */}
<div className="flex items-center gap-3 p-4">
<button onClick={() => router.back()} className="p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-xl"></button>
<h1 className="text-xl font-bold">My Profile Page</h1>
</div>
<div className="px-4 space-y-4">
{/* Profile section — collapsible */}
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-sm">
{/* Always-visible header row — single tappable button + share action */}
<div className="flex items-center gap-2 px-4 py-3">
{/* Main expand/collapse — takes all remaining space */}
<button
onClick={() => setProfileExpanded(v => !v)}
className="flex items-center gap-3 flex-1 text-left min-w-0"
>
<span className="text-xl">👤</span>
<div className="min-w-0">
<p className="font-semibold text-gray-800 dark:text-white text-sm truncate">
{displayName || "Set up your profile"}
</p>
{!profileExpanded && slug && (
<p className="text-xs text-gray-400 truncate">{baseUrl}/m/{slug}</p>
)}
</div>
<span className={`text-gray-400 text-sm transition-transform flex-shrink-0 ${profileExpanded ? "rotate-180" : ""}`}></span>
</button>
{/* Profile page share — floats outside the expand button to avoid triggering expand */}
{slug && slugValid && (
<div className="relative flex-shrink-0">
<button
onClick={() => setProfileShareOpen(v => !v)}
className="p-2 rounded-xl bg-rose-50 dark:bg-rose-900/20 text-rose-500 text-sm"
title="Share your profile page"
>
</button>
{profileShareOpen && (
<>
<div className="fixed inset-0 z-30" onClick={() => setProfileShareOpen(false)} />
<div className="absolute right-0 top-10 z-40 bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-100 dark:border-gray-700 p-3 w-52 space-y-1">
<p className="text-xs text-gray-400 px-2 pb-1">Share your profile page</p>
<button onClick={() => { copyLink(profileUrl); setProfileShareOpen(false); }}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700 text-sm text-gray-700 dark:text-gray-200">
<span className="text-lg">{copied ? "✅" : "📋"}</span>
{copied ? "Copied!" : "Copy link"}
</button>
<button onClick={() => { shareViaWhatsApp(profileUrl, "Check out my baby product recommendations!"); setProfileShareOpen(false); }}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700 text-sm text-gray-700 dark:text-gray-200">
<span className="text-lg">💬</span>
WhatsApp
</button>
{typeof navigator !== "undefined" && "share" in navigator && (
<button onClick={() => { shareNative(`${displayName}'s baby picks`, profileUrl); setProfileShareOpen(false); }}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700 text-sm text-gray-700 dark:text-gray-200">
<span className="text-lg">📤</span>
More options
</button>
)}
</div>
</>
)}
</div>
)}
</div>
{/* Expanded form */}
{profileExpanded && (
<div className="px-4 pb-4 space-y-3 border-t border-gray-100 dark:border-gray-700 pt-3">
<div>
<label className="block text-sm text-gray-500 dark:text-gray-400 mb-1">Display Name</label>
<input
value={displayName}
onChange={e => setDisplayName(e.target.value)}
className={inputClass}
placeholder="Priya Sharma"
/>
</div>
<div>
<label className="block text-sm text-gray-500 dark:text-gray-400 mb-1">Your Page URL</label>
<div className="flex items-center gap-1 border border-gray-200 dark:border-gray-600 rounded-xl px-3 py-2.5 text-sm bg-white dark:bg-gray-700 focus-within:ring-2 focus-within:ring-rose-300">
<span className="text-gray-400 text-xs whitespace-nowrap">{baseUrl}/m/</span>
<input
value={slug}
onChange={e => setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""))}
className="flex-1 bg-transparent outline-none dark:text-white text-sm"
placeholder="priya-sharma"
/>
</div>
{slug && !slugValid && (
<p className="text-xs text-red-500 mt-1">340 chars, lowercase letters, numbers, hyphens only</p>
)}
</div>
<div>
<label className="block text-sm text-gray-500 dark:text-gray-400 mb-1">
Bio <span className="text-gray-400">({bio.length}/200)</span>
</label>
<textarea
value={bio}
onChange={e => setBio(e.target.value.slice(0, 200))}
rows={3}
className={`${inputClass} resize-none`}
placeholder="New mama sharing what works for us 💕"
/>
</div>
<label className="flex items-center gap-3 cursor-pointer">
<div
onClick={() => setIsPublic(v => !v)}
className={`w-10 h-6 rounded-full transition-colors relative cursor-pointer ${isPublic ? "bg-rose-400" : "bg-gray-300 dark:bg-gray-600"}`}
>
<div className={`absolute top-1 w-4 h-4 bg-white rounded-full shadow transition-transform ${isPublic ? "translate-x-5" : "translate-x-1"}`} />
</div>
<span className="text-sm text-gray-700 dark:text-gray-300">Make my page public</span>
</label>
<button
onClick={saveProfile}
disabled={saving || !displayName || !slugValid}
className="w-full bg-rose-400 text-white rounded-xl py-2.5 font-medium text-sm disabled:opacity-50 active:scale-95 transition-transform"
>
{saving ? "Saving…" : "Save Profile"}
</button>
{saveMsg && (
<p className={`text-xs text-center ${saveMsg.startsWith("Saved") ? "text-green-600 dark:text-green-400" : "text-red-500"}`}>
{saveMsg}
</p>
)}
</div>
)}
</div>
{/* Products section */}
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold text-gray-800 dark:text-white">Product Recommendations</h2>
<button
onClick={() => { resetProductForm(); setShowAddProduct(true); }}
className="text-sm text-rose-500 font-medium px-2.5 py-1.5"
>
+ Add
</button>
</div>
{/* Add/Edit form */}
{showAddProduct && (
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-xl p-4 mb-4 space-y-2">
<input value={pTitle} onChange={e => setPTitle(e.target.value)}
placeholder="Product title *" className={inputClass} />
<input value={pUrl} onChange={e => setPUrl(e.target.value)}
placeholder="Product URL *" className={inputClass} />
<textarea value={pDesc} onChange={e => setPDesc(e.target.value)} rows={2}
placeholder="Description (optional)" className={`${inputClass} resize-none`} />
<input value={pImageUrl} onChange={e => setPImageUrl(e.target.value)}
placeholder="Image URL (optional)" className={inputClass} />
<select value={pCategory} onChange={e => setPCategory(e.target.value)}
className={inputClass}>
{CATEGORIES.map(c => <option key={c} value={c}>{CATEGORY_EMOJI[c]} {c.charAt(0).toUpperCase() + c.slice(1)}</option>)}
</select>
<div className="flex gap-2 pt-1">
<button onClick={saveProduct} disabled={!pTitle || !pUrl}
className="flex-1 bg-rose-400 text-white rounded-xl py-2.5 text-sm font-medium disabled:opacity-50">
{editingProduct ? "Update" : "Add Product"}
</button>
<button onClick={resetProductForm}
className="flex-1 border border-gray-200 dark:border-gray-600 rounded-xl py-2.5 text-sm text-gray-600 dark:text-gray-300">
Cancel
</button>
</div>
</div>
)}
{products.length === 0 && !showAddProduct && (
<div className="text-center py-8">
<span className="text-4xl">🛍</span>
<p className="text-sm text-gray-400 mt-2">No products yet.</p>
<p className="text-xs text-gray-400">Add your first recommendation!</p>
</div>
)}
<div className="space-y-2">
{products.map((p, i) => (
<div key={p.id} className="rounded-xl bg-gray-50 dark:bg-gray-700/50 overflow-visible">
<div className="flex items-center gap-3 p-3">
<div className="text-2xl">{CATEGORY_EMOJI[p.category] || "🛍️"}</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-800 dark:text-white truncate">{p.title}</p>
<p className="text-xs text-gray-400">{p.category} · {p.click_count} clicks</p>
</div>
<div className="flex items-center gap-1">
<button onClick={() => moveProduct(p.id, "up")} disabled={i === 0}
className="text-gray-400 disabled:opacity-30 text-xs px-1.5 py-1"></button>
<button onClick={() => moveProduct(p.id, "down")} disabled={i === products.length - 1}
className="text-gray-400 disabled:opacity-30 text-xs px-1.5 py-1"></button>
<button onClick={() => startEdit(p)} className="text-rose-400 text-xs px-1.5 py-1">Edit</button>
{/* Per-product share */}
<div className="relative">
<button
onClick={() => setShareProductId(id => id === p.id ? null : p.id)}
className="text-rose-400 text-xs px-1.5 py-1"
title="Share this product"
>
</button>
{shareProductId === p.id && (
<>
<div className="fixed inset-0 z-30" onClick={() => setShareProductId(null)} />
<div className="absolute right-0 bottom-8 z-40 bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-100 dark:border-gray-700 p-3 w-52 space-y-1">
<p className="text-xs text-gray-400 px-2 pb-1 truncate">{p.title}</p>
<button onClick={() => { copyLink(p.url); setShareProductId(null); }}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700 text-sm text-gray-700 dark:text-gray-200">
<span className="text-lg">{copied ? "✅" : "📋"}</span>
{copied ? "Copied!" : "Copy link"}
</button>
<button onClick={() => { shareViaWhatsApp(p.url, `Found this for our baby — ${p.title}:`); setShareProductId(null); }}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700 text-sm text-gray-700 dark:text-gray-200">
<span className="text-lg">💬</span>
WhatsApp
</button>
{typeof navigator !== "undefined" && "share" in navigator && (
<button onClick={() => { shareNative(p.title, p.url); setShareProductId(null); }}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700 text-sm text-gray-700 dark:text-gray-200">
<span className="text-lg">📤</span>
More options
</button>
)}
</div>
</>
)}
</div>
<button onClick={() => deleteProduct(p.id)} className="text-gray-400 text-xs px-1.5 py-1"></button>
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,411 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter, useParams } from "next/navigation";
import { Button } from "@/components/ui";
import { GARMENT_CATEGORIES, GARMENT_SIZE_ORDER } from "@/db/schema/wardrobe";
interface Garment {
id: string;
name: string | null;
category: string;
sizeLabel: string;
colors: string[];
seasons: string[];
occasionTags: string[];
imageKey: string;
thumbKey: string;
imageUrl: string;
thumbUrl: string;
status: string;
acquiredVia: string | null;
giftFrom: string | null;
visionMetadata: unknown;
createdAt: string;
updatedAt: string;
}
interface Wear {
id: string;
worn_on: string;
memory_id: string | null;
}
const STATUS_FLOW = ["active", "stored", "outgrown", "donated"] as const;
const STATUS_COLORS: Record<string, string> = {
active: "bg-green-100 text-green-700",
stored: "bg-blue-100 text-blue-700",
outgrown: "bg-amber-100 text-amber-700",
donated: "bg-gray-100 text-gray-600",
};
const CATEGORY_LABELS: Record<string, string> = {
onesie: "Onesie", top: "Top", bottom: "Bottom", dress: "Dress",
outerwear: "Jacket", sleepwear: "Sleepwear", accessory: "Accessory",
};
function Chip({ label, color }: { label: string; color?: string }) {
return (
<span className={`px-2.5 py-1 rounded-full text-xs font-medium ${color || "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300"}`}>
{label}
</span>
);
}
export default function GarmentDetailPage() {
const router = useRouter();
const params = useParams();
const id = params.id as string;
const [garment, setGarment] = useState<Garment | null>(null);
const [wears, setWears] = useState<Wear[]>([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const [loggingWear, setLoggingWear] = useState(false);
const [error, setError] = useState("");
// Edit state
const [editName, setEditName] = useState("");
const [editCategory, setEditCategory] = useState("");
const [editSize, setEditSize] = useState("");
const [editStatus, setEditStatus] = useState("");
const [editColors, setEditColors] = useState<string[]>([]);
const [editSeasons, setEditSeasons] = useState<string[]>([]);
const [editOccasions, setEditOccasions] = useState<string[]>([]);
const [editColorInput, setEditColorInput] = useState("");
const [editAcquiredVia, setEditAcquiredVia] = useState<string | null>(null);
const [editGiftFrom, setEditGiftFrom] = useState("");
const load = async () => {
setLoading(true);
try {
const res = await fetch(`/api/garments/${id}`);
if (!res.ok) { router.replace("/wardrobe"); return; }
const data = await res.json();
setGarment(data.item);
setWears(data.wears || []);
} catch {
router.replace("/wardrobe");
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, [id]);
const startEdit = () => {
if (!garment) return;
setEditName(garment.name || "");
setEditCategory(garment.category);
setEditSize(garment.sizeLabel);
setEditStatus(garment.status);
setEditColors([...garment.colors]);
setEditSeasons([...garment.seasons]);
setEditOccasions([...garment.occasionTags]);
setEditAcquiredVia(garment.acquiredVia);
setEditGiftFrom(garment.giftFrom || "");
setEditing(true);
};
const saveEdit = async () => {
setSaving(true);
setError("");
try {
const res = await fetch(`/api/garments/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: editName || null,
category: editCategory,
sizeLabel: editSize,
status: editStatus,
colors: editColors,
seasons: editSeasons,
occasionTags: editOccasions,
acquiredVia: editAcquiredVia,
giftFrom: editGiftFrom || null,
}),
});
if (!res.ok) throw new Error((await res.json()).error);
const data = await res.json();
setGarment(data.item);
setEditing(false);
} catch (err) {
setError(`Save failed: ${err}`);
} finally {
setSaving(false);
}
};
const logWear = async () => {
setLoggingWear(true);
try {
const res = await fetch(`/api/garments/${id}/wear`, { method: "POST" });
if (res.ok) {
const data = await res.json();
setWears(prev => [data.wear, ...prev]);
}
} catch {}
setLoggingWear(false);
};
const updateStatus = async (newStatus: string) => {
try {
const res = await fetch(`/api/garments/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: newStatus }),
});
if (res.ok) {
const data = await res.json();
setGarment(data.item);
}
} catch {}
};
const toggleArray = (arr: string[], val: string) =>
arr.includes(val) ? arr.filter(x => x !== val) : [...arr, val];
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 flex items-center justify-center">
<div className="flex gap-1">
{[0, 150, 300].map(d => (
<span key={d} className="w-2.5 h-2.5 bg-rose-400 rounded-full animate-bounce" style={{ animationDelay: `${d}ms` }} />
))}
</div>
</div>
);
}
if (!garment) return null;
return (
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 pb-24">
{/* Header */}
<div className="flex items-center gap-3 p-4">
<button onClick={() => router.back()} className="p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-xl"></button>
<h1 className="text-lg font-bold truncate">{garment.name || CATEGORY_LABELS[garment.category] || garment.category}</h1>
<button
onClick={editing ? () => setEditing(false) : startEdit}
className="ml-auto text-sm px-3 py-1.5 bg-white dark:bg-gray-800 rounded-xl shadow-sm"
>
{editing ? "Cancel" : "Edit"}
</button>
</div>
{error && (
<div className="mx-4 mb-3 p-3 bg-red-50 rounded-xl text-sm text-red-700">{error}</div>
)}
{!editing ? (
/* ─── View mode ─── */
<div className="mx-4 space-y-3">
{/* Full-res image */}
<div className="rounded-3xl overflow-hidden shadow-lg">
<img src={garment.imageUrl} alt={garment.name || garment.category} className="w-full object-contain max-h-80 bg-white dark:bg-gray-800" />
</div>
{/* Status + quick actions */}
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
<div className="flex items-center justify-between mb-3">
<span className={`px-3 py-1 rounded-full text-sm font-semibold ${STATUS_COLORS[garment.status] || "bg-gray-100"}`}>
{garment.status}
</span>
<Button size="sm" loading={loggingWear} onClick={logWear} variant="secondary">
👕 Worn today
</Button>
</div>
<div className="flex gap-2 flex-wrap">
{STATUS_FLOW.filter(s => s !== garment.status).map(s => (
<button
key={s}
onClick={() => updateStatus(s)}
className="text-xs px-2.5 py-1 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded-full hover:bg-gray-200 dark:hover:bg-gray-600"
>
Move to {s}
</button>
))}
</div>
</div>
{/* Details */}
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<p className="text-xs text-gray-400">Category</p>
<p className="font-medium text-sm">{CATEGORY_LABELS[garment.category] || garment.category}</p>
</div>
<div>
<p className="text-xs text-gray-400">Size</p>
<p className="font-medium text-sm">{garment.sizeLabel}</p>
</div>
{garment.acquiredVia && (
<div>
<p className="text-xs text-gray-400">Acquired via</p>
<p className="font-medium text-sm">{garment.acquiredVia}</p>
</div>
)}
{garment.giftFrom && (
<div>
<p className="text-xs text-gray-400">Gift from</p>
<p className="font-medium text-sm">💝 {garment.giftFrom}</p>
</div>
)}
</div>
{garment.colors.length > 0 && (
<div>
<p className="text-xs text-gray-400 mb-1.5">Colors</p>
<div className="flex flex-wrap gap-1.5">
{garment.colors.map(c => <Chip key={c} label={c} color="bg-pink-50 text-pink-700 dark:bg-pink-900/20 dark:text-pink-300" />)}
</div>
</div>
)}
{garment.seasons.length > 0 && (
<div>
<p className="text-xs text-gray-400 mb-1.5">Seasons</p>
<div className="flex flex-wrap gap-1.5">
{garment.seasons.map(s => <Chip key={s} label={s} color="bg-orange-50 text-orange-700 dark:bg-orange-900/20 dark:text-orange-300" />)}
</div>
</div>
)}
{garment.occasionTags.length > 0 && (
<div>
<p className="text-xs text-gray-400 mb-1.5">Occasions</p>
<div className="flex flex-wrap gap-1.5">
{garment.occasionTags.map(t => <Chip key={t} label={t} color="bg-purple-50 text-purple-700 dark:bg-purple-900/20 dark:text-purple-300" />)}
</div>
</div>
)}
</div>
{/* Wear history */}
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
<p className="font-semibold text-sm mb-3">
Wear history
<span className="ml-2 text-xs font-normal text-gray-400">({wears.length} times)</span>
</p>
{wears.length === 0 ? (
<p className="text-sm text-gray-400">Not worn yet tap "Worn today" to start tracking</p>
) : (
<div className="space-y-1 max-h-40 overflow-y-auto">
{wears.map(w => (
<div key={w.id} className="flex items-center text-sm text-gray-600 dark:text-gray-300 gap-2">
<span className="text-gray-300">📅</span>
{new Date(w.worn_on).toLocaleDateString("en-IN", { day: "numeric", month: "short", year: "numeric" })}
</div>
))}
</div>
)}
</div>
</div>
) : (
/* ─── Edit mode ─── */
<div className="mx-4 space-y-3">
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
<label className="text-xs text-gray-500">Name</label>
<input
value={editName}
onChange={e => setEditName(e.target.value)}
className="mt-1 w-full px-3 py-2 rounded-xl bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 text-sm focus:outline-none"
/>
</div>
{/* Category chips */}
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
<p className="text-xs text-gray-500 mb-2">Category</p>
<div className="flex flex-wrap gap-2">
{GARMENT_CATEGORIES.map(c => (
<button key={c} type="button" onClick={() => setEditCategory(c)}
className={`px-3 py-1.5 rounded-full text-sm font-medium ${editCategory === c ? "bg-rose-400 text-white" : "bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300"}`}
>
{CATEGORY_LABELS[c] || c}
</button>
))}
</div>
</div>
{/* Size chips */}
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
<p className="text-xs text-gray-500 mb-2">Size</p>
<div className="flex flex-wrap gap-2">
{GARMENT_SIZE_ORDER.map(s => (
<button key={s} type="button" onClick={() => setEditSize(s)}
className={`px-3 py-1.5 rounded-full text-sm font-medium ${editSize === s ? "bg-rose-400 text-white" : "bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300"}`}
>
{s}
</button>
))}
</div>
</div>
{/* Status */}
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
<p className="text-xs text-gray-500 mb-2">Status</p>
<div className="flex flex-wrap gap-2">
{STATUS_FLOW.map(s => (
<button key={s} type="button" onClick={() => setEditStatus(s)}
className={`px-3 py-1.5 rounded-full text-sm font-medium ${editStatus === s ? "bg-rose-400 text-white" : "bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300"}`}
>
{s}
</button>
))}
</div>
</div>
{/* Colors */}
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
<p className="text-xs text-gray-500 mb-2">Colors</p>
<div className="flex flex-wrap gap-2 mb-2">
{editColors.map(c => (
<button key={c} type="button" onClick={() => setEditColors(editColors.filter(x => x !== c))}
className="px-2.5 py-1 rounded-full text-xs bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300"
>
{c} ×
</button>
))}
</div>
<div className="flex gap-2">
<input value={editColorInput} onChange={e => setEditColorInput(e.target.value)}
onKeyDown={e => { if (e.key === "Enter") { const c = editColorInput.trim().toLowerCase(); if (c && !editColors.includes(c)) setEditColors([...editColors, c]); setEditColorInput(""); }}}
placeholder="Add color…" className="flex-1 px-3 py-1.5 rounded-xl bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 text-sm focus:outline-none" />
<button type="button" onClick={() => { const c = editColorInput.trim().toLowerCase(); if (c && !editColors.includes(c)) setEditColors([...editColors, c]); setEditColorInput(""); }}
className="px-3 py-1.5 bg-rose-400 text-white rounded-xl text-sm">Add</button>
</div>
</div>
{/* Seasons */}
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
<p className="text-xs text-gray-500 mb-2">Seasons</p>
<div className="flex gap-2">
{["summer", "monsoon", "winter"].map(s => (
<button key={s} type="button" onClick={() => setEditSeasons(toggleArray(editSeasons, s))}
className={`px-3 py-1.5 rounded-full text-sm font-medium ${editSeasons.includes(s) ? "bg-rose-400 text-white" : "bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300"}`}
>
{s}
</button>
))}
</div>
</div>
{/* Occasions */}
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
<p className="text-xs text-gray-500 mb-2">Occasions</p>
<div className="flex flex-wrap gap-2">
{["everyday", "daycare", "festive", "photoshoot"].map(o => (
<button key={o} type="button" onClick={() => setEditOccasions(toggleArray(editOccasions, o))}
className={`px-3 py-1.5 rounded-full text-sm font-medium ${editOccasions.includes(o) ? "bg-rose-400 text-white" : "bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300"}`}
>
{o}
</button>
))}
</div>
</div>
<Button fullWidth onClick={saveEdit} loading={saving} size="lg">Save Changes</Button>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,495 @@
"use client";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import Image from "next/image";
import { useFamily } from "@/app/FamilyProvider";
import { Button } from "@/components/ui";
import { trackGarmentAdded } from "@/lib/analytics";
import { GARMENT_CATEGORIES, GARMENT_SIZE_ORDER } from "@/db/schema/wardrobe";
type Step = "capture" | "form";
interface VisionResult {
name: string;
category: string;
colors: string[];
seasons: string[];
occasion_tags: string[];
}
const SEASON_OPTIONS = ["summer", "monsoon", "winter"];
const OCCASION_OPTIONS = ["everyday", "daycare", "festive", "photoshoot"];
const ACQUIRED_OPTIONS = ["bought", "gift", "handmedown"];
const CATEGORY_LABELS: Record<string, string> = {
onesie: "Onesie", top: "Top", bottom: "Bottom", dress: "Dress",
outerwear: "Jacket", sleepwear: "Sleepwear", accessory: "Accessory",
};
const CATEGORY_COLORS: Record<string, string> = {
onesie: "bg-pink-100 text-pink-700", top: "bg-blue-100 text-blue-700",
bottom: "bg-indigo-100 text-indigo-700", dress: "bg-purple-100 text-purple-700",
outerwear: "bg-teal-100 text-teal-700", sleepwear: "bg-amber-100 text-amber-700",
accessory: "bg-rose-100 text-rose-700",
};
function ChipRow<T extends string>({
label, options, selected, onChange, required, optionLabel,
}: {
label: string;
options: readonly T[];
selected: T[];
onChange: (v: T[]) => void;
required?: boolean;
optionLabel?: (v: T) => string;
}) {
const toggle = (v: T) => {
if (selected.includes(v)) onChange(selected.filter(x => x !== v));
else onChange([...selected, v]);
};
return (
<div className="mb-4">
<p className="text-sm font-semibold text-gray-600 dark:text-gray-300 mb-2">
{label}{required && <span className="text-red-500 ml-0.5">*</span>}
</p>
<div className="flex flex-wrap gap-2">
{options.map(o => (
<button
key={o}
type="button"
onClick={() => toggle(o)}
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-all ${
selected.includes(o)
? "bg-rose-400 text-white shadow-sm scale-105"
: "bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300"
}`}
>
{optionLabel ? optionLabel(o) : o}
</button>
))}
</div>
</div>
);
}
function SingleChipRow<T extends string>({
label, options, selected, onChange, required, optionLabel,
}: {
label: string;
options: readonly T[];
selected: T | null;
onChange: (v: T) => void;
required?: boolean;
optionLabel?: (v: T) => string;
}) {
return (
<div className="mb-4">
<p className="text-sm font-semibold text-gray-600 dark:text-gray-300 mb-2">
{label}{required && <span className="text-red-500 ml-0.5">*</span>}
</p>
<div className="flex flex-wrap gap-2">
{options.map(o => (
<button
key={o}
type="button"
onClick={() => onChange(o)}
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-all ${
selected === o
? "bg-rose-400 text-white shadow-sm scale-105"
: "bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300"
}`}
>
{optionLabel ? optionLabel(o) : o}
</button>
))}
</div>
</div>
);
}
export default function AddGarmentPage() {
const { childId } = useFamily();
const router = useRouter();
const fileRef = useRef<HTMLInputElement>(null); // gallery picker
const cameraRef = useRef<HTMLInputElement>(null); // camera capture
const [step, setStep] = useState<Step>("capture");
const [preview, setPreview] = useState<string | null>(null);
const [thumbUrl, setThumbUrl] = useState<string | null>(null);
const [imageKey, setImageKey] = useState("");
const [thumbKey, setThumbKey] = useState("");
const [error, setError] = useState("");
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false); // photo still uploading to R2
const [visionPending, setVisionPending] = useState(false); // AI analysis running in bg
// Form fields
const [name, setName] = useState("");
const [category, setCategory] = useState<string | null>(null);
const [sizeLabel, setSizeLabel] = useState<string | null>(null);
const [colors, setColors] = useState<string[]>([]);
const [colorInput, setColorInput] = useState("");
const [seasons, setSeasons] = useState<string[]>([]);
const [occasionTags, setOccasionTags] = useState<string[]>([]);
const [acquiredVia, setAcquiredVia] = useState<string | null>(null);
const [giftFrom, setGiftFrom] = useState("");
const [visionMetadata, setVisionMetadata] = useState<VisionResult | null>(null);
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setError("");
// 1. Show local preview immediately and jump straight to the form.
// The user can start filling in size/occasions while the upload runs.
const reader = new FileReader();
reader.onload = ev => setPreview(ev.target?.result as string);
reader.readAsDataURL(file);
setStep("form");
setUploading(true);
setVisionPending(false);
// 2. Upload to R2
const form = new FormData();
form.append("file", file);
let ik = "", tk = "", tu = "";
try {
const uploadRes = await fetch("/api/garments/upload", { method: "POST", body: form });
if (!uploadRes.ok) throw new Error((await uploadRes.json()).error);
const uploadData = await uploadRes.json();
ik = uploadData.imageKey;
tk = uploadData.thumbKey;
tu = uploadData.thumbUrl;
setImageKey(ik);
setThumbKey(tk);
setThumbUrl(tu);
} catch (err) {
setError(`Upload failed: ${err}`);
setStep("capture");
setUploading(false);
return;
}
setUploading(false);
// 3. Fire vision AI in the background — do NOT await, form stays interactive.
setVisionPending(true);
fetch("/api/garments/tag", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ imageKey: ik }),
})
.then(r => r.ok ? r.json() : null)
.then((vision: VisionResult | null) => {
if (vision) {
setVisionMetadata(vision);
// Only pre-fill fields the user hasn't touched yet
setName(n => n || vision.name || "");
setCategory(c => c || vision.category || null);
setColors(c => c.length ? c : (vision.colors || []));
setSeasons(s => s.length ? s : (vision.seasons || []));
setOccasionTags(t => t.length ? t : (vision.occasion_tags || []));
}
})
.catch(() => {/* vision failure is non-fatal */})
.finally(() => setVisionPending(false));
};
const addColor = () => {
const c = colorInput.trim().toLowerCase();
if (c && !colors.includes(c)) setColors([...colors, c]);
setColorInput("");
};
const handleSave = async () => {
if (!childId || !category || !sizeLabel) return;
setSaving(true);
setError("");
try {
const res = await fetch("/api/garments", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
childId,
name: name || null,
category,
sizeLabel,
colors,
seasons,
occasionTags,
imageKey,
thumbKey,
acquiredVia: acquiredVia || null,
giftFrom: giftFrom || null,
visionMetadata,
}),
});
if (!res.ok) throw new Error((await res.json()).error);
trackGarmentAdded();
handleAddAnother();
} catch (err) {
setError(`Save failed: ${err}`);
} finally {
setSaving(false);
}
};
const handleAddAnother = () => {
setStep("capture");
setPreview(null);
setThumbUrl(null);
setImageKey("");
setThumbKey("");
setName("");
setCategory(null);
setSizeLabel(null);
setColors([]);
setColorInput("");
setSeasons([]);
setOccasionTags([]);
setAcquiredVia(null);
setGiftFrom("");
setVisionMetadata(null);
setError("");
setUploading(false);
setVisionPending(false);
if (fileRef.current) fileRef.current.value = "";
if (cameraRef.current) cameraRef.current.value = "";
};
return (
<div className="min-h-screen bg-gradient-to-br from-pink-50 via-purple-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 pb-24">
{/* Header */}
<div className="flex items-center gap-3 p-4">
<button onClick={() => router.back()} className="p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-xl"></button>
<h1 className="text-xl font-bold">Add Garment</h1>
{step === "form" && visionPending && (
<span className="ml-auto text-xs text-amber-500 bg-amber-50 dark:bg-amber-900/20 px-2 py-1 rounded-full shadow-sm animate-pulse">
🔍 AI tagging
</span>
)}
{step === "form" && !visionPending && visionMetadata && (
<span className="ml-auto text-xs text-gray-400 bg-white dark:bg-gray-800 px-2 py-1 rounded-full shadow-sm">
AI pre-filled
</span>
)}
</div>
{error && (
<div className="mx-4 mb-3 p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700 rounded-xl text-sm text-red-700 dark:text-red-300">
{error}
</div>
)}
{/* Step 1: Capture */}
{step === "capture" && (
<div className="mx-4 mt-4">
<div className="aspect-square rounded-3xl border-4 border-dashed border-rose-200 dark:border-gray-600 flex flex-col items-center justify-center gap-4 bg-white dark:bg-gray-800">
<span className="text-6xl">👚</span>
<div className="text-center px-4">
<p className="font-semibold text-gray-700 dark:text-gray-200">Add a garment photo</p>
<p className="text-sm text-gray-400 mt-1">Tip: flat-lay on a light surface for best AI tagging</p>
</div>
<div className="flex gap-3">
<button
type="button"
onClick={() => cameraRef.current?.click()}
className="flex items-center gap-2 px-5 py-2.5 bg-rose-400 text-white rounded-full text-sm font-medium shadow-sm active:scale-95 transition-transform"
>
📷 Camera
</button>
<button
type="button"
onClick={() => fileRef.current?.click()}
className="flex items-center gap-2 px-5 py-2.5 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 rounded-full text-sm font-medium shadow-sm active:scale-95 transition-transform"
>
🖼 Gallery
</button>
</div>
</div>
{/* Gallery input — no capture attribute, Android shows full media picker */}
<input
ref={fileRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleFileChange}
/>
{/* Camera input — capture="environment" opens rear camera directly */}
<input
ref={cameraRef}
type="file"
accept="image/*"
capture="environment"
className="hidden"
onChange={handleFileChange}
/>
</div>
)}
{/* Step 2: Form (shown immediately after photo selected) */}
{step === "form" && (
<div className="mx-4 mt-2 space-y-2">
{/* Thumbnail — shows upload spinner until R2 upload is done */}
<div className="flex gap-4 items-start mb-4">
{(thumbUrl || preview) && (
<div className="relative w-24 h-24 rounded-2xl overflow-hidden shadow-md flex-shrink-0">
<img src={thumbUrl || preview!} alt="garment" className="w-full h-full object-cover" />
{uploading && (
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
<div className="w-6 h-6 border-2 border-white border-t-transparent rounded-full animate-spin" />
</div>
)}
</div>
)}
<div className="flex-1">
<label className="text-sm font-semibold text-gray-600 dark:text-gray-300">Name</label>
<input
value={name}
onChange={e => setName(e.target.value)}
placeholder="e.g. Striped blue onesie"
className="mt-1 w-full px-3 py-2 rounded-xl bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 text-sm focus:outline-none focus:ring-2 focus:ring-rose-300"
/>
</div>
</div>
{/* Category */}
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
<SingleChipRow
label="Category"
options={GARMENT_CATEGORIES}
selected={category as typeof GARMENT_CATEGORIES[number] | null}
onChange={v => setCategory(v)}
required
optionLabel={v => CATEGORY_LABELS[v] || v}
/>
</div>
{/* Size — required, not pre-filled */}
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
<SingleChipRow
label="Size"
options={GARMENT_SIZE_ORDER}
selected={sizeLabel as typeof GARMENT_SIZE_ORDER[number] | null}
onChange={v => setSizeLabel(v)}
required
/>
{!sizeLabel && (
<p className="text-xs text-amber-600 dark:text-amber-400 mt-2">
Size must be set manually vision cannot guess it
</p>
)}
</div>
{/* Colors */}
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
<p className="text-sm font-semibold text-gray-600 dark:text-gray-300 mb-2">Colors</p>
<div className="flex flex-wrap gap-2 mb-3">
{colors.map(c => (
<button
key={c}
type="button"
onClick={() => setColors(colors.filter(x => x !== c))}
className="px-3 py-1 rounded-full text-sm bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300 flex items-center gap-1"
>
{c} ×
</button>
))}
</div>
<div className="flex gap-2">
<input
value={colorInput}
onChange={e => setColorInput(e.target.value)}
onKeyDown={e => e.key === "Enter" && addColor()}
placeholder="Add color…"
className="flex-1 px-3 py-1.5 rounded-xl bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 text-sm focus:outline-none"
/>
<button type="button" onClick={addColor} className="px-3 py-1.5 bg-rose-400 text-white rounded-xl text-sm">Add</button>
</div>
</div>
{/* Seasons */}
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
<ChipRow
label="Seasons"
options={SEASON_OPTIONS as readonly string[]}
selected={seasons}
onChange={setSeasons}
/>
</div>
{/* Occasions */}
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
<ChipRow
label="Occasions"
options={OCCASION_OPTIONS as readonly string[]}
selected={occasionTags}
onChange={setOccasionTags}
/>
</div>
{/* Optional fields */}
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
<p className="text-sm font-semibold text-gray-600 dark:text-gray-300 mb-3">Optional</p>
<div className="mb-3">
<p className="text-xs text-gray-500 mb-1">Acquired via</p>
<div className="flex gap-2">
{ACQUIRED_OPTIONS.map(o => (
<button
key={o}
type="button"
onClick={() => setAcquiredVia(acquiredVia === o ? null : o)}
className={`px-3 py-1 rounded-full text-xs font-medium transition-all ${
acquiredVia === o
? "bg-indigo-400 text-white"
: "bg-gray-100 dark:bg-gray-700 text-gray-500"
}`}
>
{o}
</button>
))}
</div>
</div>
{acquiredVia === "gift" && (
<div>
<p className="text-xs text-gray-500 mb-1">Gift from</p>
<input
value={giftFrom}
onChange={e => setGiftFrom(e.target.value)}
placeholder="e.g. Nani"
className="w-full px-3 py-2 rounded-xl bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 text-sm focus:outline-none"
/>
</div>
)}
</div>
{/* Action buttons */}
<div className="pt-2 space-y-3">
<Button
fullWidth
onClick={handleSave}
loading={saving}
disabled={!category || !sizeLabel || !childId || uploading}
size="lg"
>
{uploading ? "Uploading photo…" : "Save Garment"}
</Button>
{uploading && (
<p className="text-center text-sm text-gray-400">
Photo uploading you can pick size & tags while you wait
</p>
)}
{!uploading && !sizeLabel && (
<p className="text-center text-sm text-amber-600 dark:text-amber-400">
Please select a size to save
</p>
)}
<Button fullWidth variant="secondary" onClick={handleAddAnother} size="lg">
+ Add Another
</Button>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,198 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useFamily } from "@/app/FamilyProvider";
import { Button } from "@/components/ui";
interface OutfitItem {
id: string;
name: string | null;
category: string;
sizeLabel: string;
thumbUrl: string;
imageUrl: string;
colors: string[];
}
interface OutfitSuggestion {
label: string;
items: OutfitItem[];
}
interface OutfitResponse {
weatherBasis: string;
weather: { tempC: number; season: string };
outfits: OutfitSuggestion[];
}
const OCCASION_OPTS = ["", "everyday", "daycare", "festive", "photoshoot"] as const;
const SEASON_ICON: Record<string, string> = {
summer: "☀️", monsoon: "🌧️", winter: "❄️",
};
export default function OutfitSuggestionPage() {
const { childId } = useFamily();
const router = useRouter();
const [occasion, setOccasion] = useState("");
const [result, setResult] = useState<OutfitResponse | null>(null);
const [loading, setLoading] = useState(false);
const [savedOutfitName, setSavedOutfitName] = useState<Record<number, string>>({});
const [savedIdx, setSavedIdx] = useState<Set<number>>(new Set());
const fetchSuggestions = async () => {
if (!childId) return;
setLoading(true);
try {
const params = new URLSearchParams({ childId });
if (occasion) params.set("occasion", occasion);
const res = await fetch(`/api/garments/outfit?${params}`);
const data = await res.json();
setResult(data);
} catch {}
setLoading(false);
};
useEffect(() => { fetchSuggestions(); }, [childId]);
const saveOutfit = async (idx: number, outfit: OutfitSuggestion) => {
const name = savedOutfitName[idx]?.trim() || outfit.label;
if (!childId) return;
try {
const res = await fetch("/api/garments/outfits", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
childId,
name,
garmentIds: outfit.items.map(i => i.id),
occasionTags: occasion ? [occasion] : [],
}),
});
if (res.ok) setSavedIdx(prev => new Set([...prev, idx]));
} catch {}
};
return (
<div className="min-h-screen bg-gradient-to-br from-yellow-50 via-orange-50 to-pink-50 dark:from-gray-900 dark:to-gray-800 pb-24">
<div className="flex items-center gap-3 p-4">
<button onClick={() => router.back()} className="p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-xl"></button>
<h1 className="text-xl font-bold"> Today&apos;s Outfit</h1>
<button
onClick={fetchSuggestions}
className="ml-auto p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-lg"
title="Refresh"
>
🔄
</button>
</div>
{/* Weather basis */}
{result && (
<div className="mx-4 mb-4 p-3 bg-white dark:bg-gray-800 rounded-2xl shadow-sm flex items-center gap-3">
<span className="text-2xl">{SEASON_ICON[result.weather.season] || "🌤️"}</span>
<div>
<p className="text-sm font-semibold text-gray-700 dark:text-gray-200">{result.weatherBasis}</p>
<p className="text-xs text-gray-400">Suggestions filtered for {result.weather.season} garments</p>
</div>
</div>
)}
{/* Occasion filter */}
<div className="px-4 mb-4 flex gap-2 overflow-x-auto scrollbar-hide">
{OCCASION_OPTS.map(o => (
<button
key={o || "all"}
onClick={() => { setOccasion(o); }}
className={`px-3 py-1.5 rounded-full text-sm font-medium whitespace-nowrap flex-shrink-0 transition-all ${
occasion === o ? "bg-rose-400 text-white" : "bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300"
}`}
>
{o || "Any occasion"}
</button>
))}
</div>
{loading ? (
<div className="flex flex-col items-center justify-center py-16 gap-3">
<div className="flex gap-1">
{[0, 150, 300].map(d => (
<span key={d} className="w-2.5 h-2.5 bg-rose-400 rounded-full animate-bounce" style={{ animationDelay: `${d}ms` }} />
))}
</div>
<p className="text-sm text-gray-500">Fetching weather + building outfits</p>
</div>
) : result && result.outfits.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 px-8 text-center">
<span className="text-5xl mb-4">🌂</span>
<p className="font-semibold text-gray-600 dark:text-gray-300">No outfits for today&apos;s weather</p>
<p className="text-sm text-gray-400 mt-1">
Add more {result.weather.season} garments to your wardrobe
</p>
<button
onClick={() => router.push("/wardrobe/add")}
className="mt-4 px-5 py-2 bg-rose-400 text-white rounded-xl text-sm font-medium"
>
+ Add Garment
</button>
</div>
) : result ? (
<div className="mx-4 space-y-4">
{result.outfits.map((outfit, idx) => (
<div key={idx} className="bg-white dark:bg-gray-800 rounded-2xl shadow-sm overflow-hidden">
<div className="px-4 pt-3 pb-2 flex items-center justify-between">
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">
Outfit {idx + 1} {outfit.label}
</span>
{outfit.items[0]?.colors?.length > 0 && (
<span className="text-xs text-gray-400">{outfit.items.flatMap(i => i.colors).slice(0, 3).join(", ")}</span>
)}
</div>
<div className={`flex gap-3 px-4 pb-3 ${outfit.items.length === 1 ? "justify-center" : ""}`}>
{outfit.items.map(item => (
<div key={item.id} className="flex flex-col items-center gap-1.5">
<div className="w-28 h-28 rounded-xl overflow-hidden bg-gray-50">
<img src={item.thumbUrl} alt={item.name || item.category} className="w-full h-full object-cover" />
</div>
<p className="text-xs text-center text-gray-600 dark:text-gray-400 max-w-24 truncate">
{item.name || item.category}
</p>
<span className="text-xs bg-gray-100 dark:bg-gray-700 text-gray-500 px-1.5 py-0.5 rounded-full">
{item.sizeLabel}
</span>
</div>
))}
</div>
{/* Save outfit */}
{savedIdx.has(idx) ? (
<div className="px-4 pb-3 text-sm text-green-600 dark:text-green-400"> Saved to wardrobe</div>
) : (
<div className="px-4 pb-3 flex gap-2">
<input
value={savedOutfitName[idx] ?? ""}
onChange={e => setSavedOutfitName(prev => ({ ...prev, [idx]: e.target.value }))}
placeholder={outfit.label}
className="flex-1 px-3 py-1.5 rounded-xl bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 text-sm focus:outline-none"
/>
<button
onClick={() => saveOutfit(idx, outfit)}
className="px-3 py-1.5 bg-indigo-400 text-white rounded-xl text-sm font-medium"
>
💾 Save
</button>
</div>
)}
</div>
))}
<Button fullWidth variant="secondary" onClick={fetchSuggestions} size="lg">
🔄 Suggest again
</Button>
</div>
) : null}
</div>
);
}

View file

@ -0,0 +1,164 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useFamily } from "@/app/FamilyProvider";
import { Button } from "@/components/ui";
interface PackingItem {
id: string;
name: string;
sizeLabel: string;
thumbUrl: string;
checked: boolean;
}
type PackingGroups = Record<string, PackingItem[]>;
const SEASON_OPTS = ["summer", "monsoon", "winter"] as const;
const OCCASION_OPTS = ["everyday", "daycare", "festive", "photoshoot"] as const;
const CATEGORY_EMOJI: Record<string, string> = {
onesie: "👶", top: "👕", bottom: "👖", dress: "👗",
outerwear: "🧥", sleepwear: "😴", accessory: "🎀",
};
export default function PackingListPage() {
const { childId } = useFamily();
const router = useRouter();
const [season, setSeason] = useState<string>("");
const [occasion, setOccasion] = useState<string>("");
const [groups, setGroups] = useState<PackingGroups | null>(null);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const generate = async () => {
if (!childId) return;
setLoading(true);
try {
const params = new URLSearchParams({ childId });
if (season) params.set("season", season);
if (occasion) params.set("occasion", occasion);
const res = await fetch(`/api/garments/packing?${params}`);
const data = await res.json();
setGroups(data.groups || {});
setTotal(data.total || 0);
} catch {}
setLoading(false);
};
const toggleItem = (cat: string, itemId: string) => {
if (!groups) return;
setGroups(prev => ({
...prev!,
[cat]: prev![cat].map(i => i.id === itemId ? { ...i, checked: !i.checked } : i),
}));
};
const checkedCount = groups
? Object.values(groups).flat().filter(i => i.checked).length
: 0;
return (
<div className="min-h-screen bg-gradient-to-br from-sky-50 via-purple-50 to-pink-50 dark:from-gray-900 dark:to-gray-800 pb-24">
<div className="flex items-center gap-3 p-4">
<button onClick={() => router.back()} className="p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-xl"></button>
<h1 className="text-xl font-bold">🧳 Packing List</h1>
</div>
{/* Filters */}
<div className="mx-4 bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm mb-4 space-y-4">
<div>
<p className="text-sm font-semibold text-gray-600 dark:text-gray-300 mb-2">Season</p>
<div className="flex gap-2 flex-wrap">
<button
onClick={() => setSeason("")}
className={`px-3 py-1.5 rounded-full text-sm font-medium ${!season ? "bg-rose-400 text-white" : "bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300"}`}
>
Any
</button>
{SEASON_OPTS.map(s => (
<button
key={s}
onClick={() => setSeason(season === s ? "" : s)}
className={`px-3 py-1.5 rounded-full text-sm font-medium ${season === s ? "bg-rose-400 text-white" : "bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300"}`}
>
{s}
</button>
))}
</div>
</div>
<div>
<p className="text-sm font-semibold text-gray-600 dark:text-gray-300 mb-2">Occasion</p>
<div className="flex gap-2 flex-wrap">
<button
onClick={() => setOccasion("")}
className={`px-3 py-1.5 rounded-full text-sm font-medium ${!occasion ? "bg-rose-400 text-white" : "bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300"}`}
>
Any
</button>
{OCCASION_OPTS.map(o => (
<button
key={o}
onClick={() => setOccasion(occasion === o ? "" : o)}
className={`px-3 py-1.5 rounded-full text-sm font-medium ${occasion === o ? "bg-rose-400 text-white" : "bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300"}`}
>
{o}
</button>
))}
</div>
</div>
<Button fullWidth onClick={generate} loading={loading} size="lg">
Generate List
</Button>
</div>
{groups && (
<>
<div className="mx-4 mb-3 flex items-center justify-between">
<p className="text-sm text-gray-500">{total} garments found · {checkedCount} packed</p>
{total === 0 && (
<p className="text-sm text-amber-600">No matching garments try broader filters</p>
)}
</div>
<div className="mx-4 space-y-3">
{Object.entries(groups).map(([cat, items]) => (
<div key={cat} className="bg-white dark:bg-gray-800 rounded-2xl shadow-sm overflow-hidden">
<div className="px-4 py-2.5 bg-gray-50 dark:bg-gray-700 border-b border-gray-100 dark:border-gray-600 flex items-center gap-2">
<span className="text-lg">{CATEGORY_EMOJI[cat] || "👗"}</span>
<span className="font-semibold text-sm capitalize">{cat}</span>
<span className="ml-auto text-xs text-gray-400">{items.length}</span>
</div>
{items.map(item => (
<button
key={item.id}
onClick={() => toggleItem(cat, item.id)}
className="w-full flex items-center gap-3 px-4 py-3 border-b border-gray-50 dark:border-gray-700 last:border-0 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<div className={`w-5 h-5 rounded-full border-2 flex items-center justify-center flex-shrink-0 transition-all ${
item.checked ? "border-rose-400 bg-rose-400" : "border-gray-300 dark:border-gray-500"
}`}>
{item.checked && <span className="text-white text-xs"></span>}
</div>
<div className="w-10 h-10 rounded-xl overflow-hidden flex-shrink-0">
<img src={item.thumbUrl} alt={item.name} className="w-full h-full object-cover" loading="lazy" />
</div>
<div className="flex-1 text-left">
<p className={`text-sm font-medium ${item.checked ? "line-through text-gray-400" : "text-gray-700 dark:text-gray-200"}`}>
{item.name}
</p>
<p className="text-xs text-gray-400">{item.sizeLabel}</p>
</div>
</button>
))}
</div>
))}
</div>
</>
)}
</div>
);
}

View file

@ -0,0 +1,248 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useFamily } from "@/app/FamilyProvider";
import { GARMENT_CATEGORIES, GARMENT_SIZE_ORDER } from "@/db/schema/wardrobe";
interface Garment {
id: string;
name: string | null;
category: string;
sizeLabel: string;
thumbUrl: string;
status: string;
seasons: string[];
occasionTags: string[];
colors: string[];
}
interface OutgrowthNudge {
candidates: { id: string; sizeLabel: string }[];
currentSizeLabel: string | null;
}
const STATUS_OPTS = [
{ value: "active", label: "Active" },
{ value: "stored", label: "Stored" },
{ value: "outgrown", label: "Outgrown" },
{ value: "donated", label: "Donated" },
];
const CATEGORY_EMOJI: Record<string, string> = {
onesie: "👶", top: "👕", bottom: "👖", dress: "👗",
outerwear: "🧥", sleepwear: "😴", accessory: "🎀",
};
export default function WardrobePage() {
const { childId } = useFamily();
const router = useRouter();
const [garments, setGarments] = useState<Garment[]>([]);
const [loading, setLoading] = useState(true);
const [status, setStatus] = useState("active");
const [filterCategory, setFilterCategory] = useState("");
const [filterSize, setFilterSize] = useState("");
const [filterSeason, setFilterSeason] = useState("");
const [nudge, setNudge] = useState<OutgrowthNudge | null>(null);
const [nudgeDismissed, setNudgeDismissed] = useState(false);
const fetchGarments = useCallback(async () => {
if (!childId) return;
setLoading(true);
try {
const params = new URLSearchParams({ childId, status });
if (filterCategory) params.set("category", filterCategory);
if (filterSize) params.set("sizeLabel", filterSize);
if (filterSeason) params.set("season", filterSeason);
const res = await fetch(`/api/garments?${params}`);
const data = await res.json();
setGarments(data.items || []);
} catch {
setGarments([]);
} finally {
setLoading(false);
}
}, [childId, status, filterCategory, filterSize, filterSeason]);
const fetchNudge = useCallback(async () => {
if (!childId || nudgeDismissed) return;
try {
const res = await fetch(`/api/garments/outgrowth?childId=${childId}`);
const data = await res.json();
if (data.candidates?.length > 0) setNudge(data);
} catch {}
}, [childId, nudgeDismissed]);
useEffect(() => { fetchGarments(); }, [fetchGarments]);
useEffect(() => { fetchNudge(); }, [fetchNudge]);
return (
<div className="min-h-screen bg-gradient-to-br from-pink-50 via-purple-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 pb-24">
{/* Header */}
<div className="flex items-center gap-3 p-4">
<button onClick={() => router.back()} className="p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-xl"></button>
<h1 className="text-xl font-bold">Wardrobe 👗</h1>
<div className="ml-auto flex gap-2">
<Link href="/wardrobe/outfit" className="p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-lg" title="Today's outfit"></Link>
<Link href="/wardrobe/packing" className="p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-lg" title="Packing list">🧳</Link>
<Link href="/wardrobe/saved-outfits" className="p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-lg" title="Saved outfits">💾</Link>
<Link href="/wardrobe/add" className="px-3 py-2 rounded-xl bg-rose-400 text-white text-sm font-medium shadow-sm">+ Add</Link>
</div>
</div>
{/* Outgrowth nudge */}
{nudge && !nudgeDismissed && (
<div className="mx-4 mb-3 p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-2xl flex items-start gap-3">
<span className="text-2xl mt-0.5">📦</span>
<div className="flex-1">
<p className="text-sm font-semibold text-amber-800 dark:text-amber-200">
{nudge.candidates.length} item{nudge.candidates.length !== 1 ? "s" : ""} not worn in 45+ days and may be outgrown
</p>
<p className="text-xs text-amber-600 dark:text-amber-400 mt-0.5">
Current size: <strong>{nudge.currentSizeLabel}</strong>
</p>
<div className="flex gap-2 mt-2">
<button
onClick={() => { setFilterSize(nudge.candidates[0].sizeLabel); setNudgeDismissed(true); }}
className="text-xs px-2 py-1 bg-amber-400 text-white rounded-lg"
>
View items
</button>
<button onClick={() => setNudgeDismissed(true)} className="text-xs px-2 py-1 text-amber-600 dark:text-amber-400">
Dismiss
</button>
</div>
</div>
</div>
)}
{/* Filters */}
<div className="px-4 space-y-2 mb-4">
{/* Status row */}
<div className="flex gap-2 overflow-x-auto scrollbar-hide pb-1">
{STATUS_OPTS.map(s => (
<button
key={s.value}
onClick={() => setStatus(s.value)}
className={`px-3 py-1.5 rounded-full text-sm font-medium whitespace-nowrap flex-shrink-0 transition-all ${
status === s.value
? "bg-rose-400 text-white"
: "bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300"
}`}
>
{s.label}
</button>
))}
</div>
{/* Category row */}
<div className="flex gap-2 overflow-x-auto scrollbar-hide pb-1">
<button
onClick={() => setFilterCategory("")}
className={`px-3 py-1 rounded-full text-sm whitespace-nowrap flex-shrink-0 transition-all ${
!filterCategory ? "bg-indigo-400 text-white" : "bg-white dark:bg-gray-800 text-gray-500"
}`}
>
All
</button>
{GARMENT_CATEGORIES.map(c => (
<button
key={c}
onClick={() => setFilterCategory(filterCategory === c ? "" : c)}
className={`px-3 py-1 rounded-full text-sm whitespace-nowrap flex-shrink-0 transition-all ${
filterCategory === c ? "bg-indigo-400 text-white" : "bg-white dark:bg-gray-800 text-gray-500"
}`}
>
{CATEGORY_EMOJI[c]} {c}
</button>
))}
</div>
{/* Size row */}
<div className="flex gap-2 overflow-x-auto scrollbar-hide pb-1">
<button
onClick={() => setFilterSize("")}
className={`px-3 py-1 rounded-full text-xs whitespace-nowrap flex-shrink-0 transition-all ${
!filterSize ? "bg-teal-400 text-white" : "bg-white dark:bg-gray-800 text-gray-500"
}`}
>
Any size
</button>
{GARMENT_SIZE_ORDER.map(s => (
<button
key={s}
onClick={() => setFilterSize(filterSize === s ? "" : s)}
className={`px-3 py-1 rounded-full text-xs whitespace-nowrap flex-shrink-0 transition-all ${
filterSize === s ? "bg-teal-400 text-white" : "bg-white dark:bg-gray-800 text-gray-500"
}`}
>
{s}
</button>
))}
</div>
{/* Season row */}
<div className="flex gap-2 pb-1">
{["", "summer", "monsoon", "winter"].map(s => (
<button
key={s || "all"}
onClick={() => setFilterSeason(s)}
className={`px-3 py-1 rounded-full text-xs whitespace-nowrap transition-all ${
filterSeason === s ? "bg-orange-400 text-white" : "bg-white dark:bg-gray-800 text-gray-500"
}`}
>
{s || "All seasons"}
</button>
))}
</div>
</div>
{/* Grid */}
{loading ? (
<div className="grid grid-cols-3 gap-3 px-4">
{[...Array(9)].map((_, i) => (
<div key={i} className="aspect-square rounded-2xl bg-white dark:bg-gray-800 animate-pulse" />
))}
</div>
) : garments.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 px-8 text-center">
<span className="text-5xl mb-4">👚</span>
<p className="font-semibold text-gray-600 dark:text-gray-300">No garments yet</p>
<p className="text-sm text-gray-400 mt-1">Add your first garment to build the wardrobe catalogue</p>
<Link href="/wardrobe/add" className="mt-4 px-5 py-2 bg-rose-400 text-white rounded-xl text-sm font-medium">
+ Add Garment
</Link>
</div>
) : (
<div className="grid grid-cols-3 gap-3 px-4">
{garments.map(g => (
<Link
key={g.id}
href={`/wardrobe/${g.id}`}
className="flex flex-col bg-white dark:bg-gray-800 rounded-2xl overflow-hidden shadow-sm hover:shadow-md transition-shadow"
>
<div className="aspect-square relative">
<img
src={g.thumbUrl}
alt={g.name || g.category}
className="w-full h-full object-cover"
loading="lazy"
/>
<span className="absolute top-1 right-1 text-xs bg-white/90 dark:bg-gray-900/90 px-1.5 py-0.5 rounded-full font-medium text-gray-700 dark:text-gray-200">
{g.sizeLabel}
</span>
</div>
<div className="px-2 py-1.5">
<p className="text-xs font-medium text-gray-700 dark:text-gray-200 truncate">
{g.name || `${CATEGORY_EMOJI[g.category] || ""} ${g.category}`}
</p>
</div>
</Link>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,146 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { useFamily } from "@/app/FamilyProvider";
interface SavedOutfit {
id: string;
name: string;
garment_ids: string[];
occasion_tags: string[];
created_at: string;
}
interface GarmentThumb {
id: string;
thumbUrl: string;
name: string | null;
category: string;
}
export default function SavedOutfitsPage() {
const { childId } = useFamily();
const router = useRouter();
const [outfits, setOutfits] = useState<SavedOutfit[]>([]);
const [thumbs, setThumbs] = useState<Record<string, GarmentThumb>>({});
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!childId) return;
(async () => {
try {
const res = await fetch(`/api/garments/outfits?childId=${childId}`);
const data = await res.json();
setOutfits(data.items || []);
// Fetch thumbnails for garment IDs referenced by outfits
const allIds = [...new Set((data.items || []).flatMap((o: SavedOutfit) => o.garment_ids))] as string[];
if (allIds.length > 0) {
const garmentRes = await fetch(`/api/garments?childId=${childId}&status=active`);
const garmentData = await garmentRes.json();
const map: Record<string, GarmentThumb> = {};
for (const g of garmentData.items || []) {
map[g.id] = { id: g.id, thumbUrl: g.thumbUrl, name: g.name, category: g.category };
}
setThumbs(map);
}
} catch {}
setLoading(false);
})();
}, [childId]);
const deleteOutfit = async (id: string) => {
try {
const res = await fetch(`/api/garments/outfits/${id}`, { method: "DELETE" });
if (res.ok) setOutfits(prev => prev.filter(o => o.id !== id));
} catch {}
};
return (
<div className="min-h-screen bg-gradient-to-br from-indigo-50 via-purple-50 to-pink-50 dark:from-gray-900 dark:to-gray-800 pb-24">
<div className="flex items-center gap-3 p-4">
<button onClick={() => router.back()} className="p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-xl"></button>
<h1 className="text-xl font-bold">💾 Saved Outfits</h1>
<Link href="/wardrobe/outfit" className="ml-auto text-sm px-3 py-1.5 bg-rose-400 text-white rounded-xl font-medium shadow-sm">
+ New
</Link>
</div>
{loading ? (
<div className="flex justify-center py-16">
<div className="flex gap-1">
{[0, 150, 300].map(d => (
<span key={d} className="w-2.5 h-2.5 bg-rose-400 rounded-full animate-bounce" style={{ animationDelay: `${d}ms` }} />
))}
</div>
</div>
) : outfits.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 px-8 text-center">
<span className="text-5xl mb-4">👗</span>
<p className="font-semibold text-gray-600 dark:text-gray-300">No saved outfits yet</p>
<p className="text-sm text-gray-400 mt-1">Save combinations from the outfit suggestion screen</p>
<Link href="/wardrobe/outfit" className="mt-4 px-5 py-2 bg-rose-400 text-white rounded-xl text-sm font-medium">
Get suggestions
</Link>
</div>
) : (
<div className="mx-4 space-y-3">
{outfits.map(outfit => (
<div key={outfit.id} className="bg-white dark:bg-gray-800 rounded-2xl shadow-sm p-4">
<div className="flex items-start justify-between mb-3">
<div>
<p className="font-semibold text-gray-700 dark:text-gray-200">{outfit.name}</p>
{outfit.occasion_tags?.length > 0 && (
<div className="flex gap-1 mt-1">
{outfit.occasion_tags.map(t => (
<span key={t} className="text-xs px-2 py-0.5 bg-purple-100 text-purple-700 dark:bg-purple-900/20 dark:text-purple-300 rounded-full">
{t}
</span>
))}
</div>
)}
</div>
<button
onClick={() => deleteOutfit(outfit.id)}
className="text-gray-300 hover:text-red-400 text-lg p-1 transition-colors"
>
×
</button>
</div>
<div className="flex gap-2">
{outfit.garment_ids.slice(0, 4).map(gid => {
const g = thumbs[gid];
if (!g) return (
<div key={gid} className="w-16 h-16 rounded-xl bg-gray-100 dark:bg-gray-700 flex items-center justify-center text-gray-300 text-xl">
👗
</div>
);
return (
<div key={gid} className="flex flex-col items-center gap-1">
<div className="w-16 h-16 rounded-xl overflow-hidden">
<img src={g.thumbUrl} alt={g.name || g.category} className="w-full h-full object-cover" loading="lazy" />
</div>
</div>
);
})}
{outfit.garment_ids.length > 4 && (
<div className="w-16 h-16 rounded-xl bg-gray-100 dark:bg-gray-700 flex items-center justify-center text-sm text-gray-500">
+{outfit.garment_ids.length - 4}
</div>
)}
</div>
<p className="text-xs text-gray-400 mt-2">
{new Date(outfit.created_at).toLocaleDateString("en-IN", { day: "numeric", month: "short", year: "numeric" })}
</p>
</div>
))}
</div>
)}
</div>
);
}

View 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&apos;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&nbsp;a.m.
wondering whether their baby&apos;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&apos;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&apos;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&nbsp;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&apos;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&apos;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>&ldquo;What was I like when I was little?&rdquo;</em> we hope you&apos;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&apos;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 &amp; 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&apos;ll
find out. We can&apos;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&apos;s story today.</h3>
<Link className="big" href="/login">
Get started it&apos;s free
</Link>
<p className="fine">Free during early access. No credit card.</p>
</div>
</article>
</div>
</div>
);
}

View 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 }
);
}

View 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>
);
}

View 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>
);
}

View 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: "04 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 23 hours (812 feeds/day)" },
{ label: "Duration", text: "1020 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: "13 Months: Settling into a Rhythm",
paragraphs: [
"By six weeks, most babies start stretching their feeds slightly — you might see 34 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 90120 ml per feed, about 68 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: "90120 ml, 68 times/day" },
{ label: "Night feeds", text: "Still expected — 1 to 3 per night is normal" },
],
},
{
heading: "46 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: "69 Months: Building Food Variety",
paragraphs: [
"Now the real exploration begins. Introduce one new food every 34 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: "23 times/day + breast milk / formula" },
{ label: "Portion", text: "24 tablespoons per meal, increasing gradually" },
{ label: "Texture", text: "Mashed, soft lumps" },
{ label: "Good first foods", text: "Khichdi, ragi porridge, soft dal, banana, curd" },
],
},
{
heading: "912 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 12 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 + 12 snacks" },
{ label: "Milk", text: "Breast milk or follow-on formula, 34 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"],
["36 months", "38.5°C (101.3°F) or above", "Call your paediatrician same day"],
["612 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",
});
}

View file

@ -0,0 +1,130 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Fraunces, Newsreader, JetBrains_Mono } from "next/font/google";
import { MarketingNav } from "@/components/marketing/MarketingNav";
// ── Editorial fonts — loaded once for all marketing pages ─────────
const fraunces = Fraunces({
subsets: ["latin"],
variable: "--font-fraunces",
weight: ["300", "400", "500", "600"],
style: ["normal", "italic"],
display: "swap",
});
const newsreader = Newsreader({
subsets: ["latin"],
variable: "--font-newsreader",
weight: ["300", "400", "500", "600"],
style: ["normal", "italic"],
display: "swap",
});
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-jetbrains",
weight: ["400", "500"],
display: "swap",
});
export const metadata: Metadata = {
title: {
default: "Tia — Your baby's digital heirloom",
template: "%s | Tia",
},
description:
"Tia is a digital heirloom for your baby — not just a tracker. Log daily moments, track the IAP vaccination schedule, and build a living archive your child will one day inherit.",
openGraph: {
type: "website",
siteName: "Tia",
title: "Tia — Your baby's digital heirloom",
description:
"Log every feed, milestone, and memory. Built for Indian families. Privacy-first. Free during early access.",
},
twitter: {
card: "summary_large_image",
title: "Tia — Your baby's digital heirloom",
description:
"Log every feed, milestone, and memory. Built for Indian families. Privacy-first. Free during early access.",
},
};
export default function MarketingLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className={`${fraunces.variable} ${newsreader.variable} ${jetbrainsMono.variable}`}>
<MarketingNav />
<main>{children}</main>
{/* Footer */}
<footer className="bg-gray-50 border-t border-gray-100 mt-20">
<div className="max-w-5xl mx-auto px-5 py-12">
<div className="flex flex-col lg:flex-row lg:justify-between gap-10 mb-8">
{/* Brand block */}
<div className="lg:max-w-xs">
<div className="flex items-center gap-2 mb-3">
<span className="text-2xl">🌸</span>
<span className="text-xl font-bold text-gray-900" style={{ fontFamily: "var(--font-caveat)" }}>
Tia
</span>
</div>
<p className="font-newsreader text-sm text-gray-500 leading-relaxed">
A digital heirloom for your baby.<br />Every moment, preserved privately.
</p>
</div>
{/* Three-column link group */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-8 lg:gap-14 text-sm">
{/* Company */}
<div className="flex flex-col gap-2">
<p className="font-fraunces font-semibold text-gray-700 mb-1">Company</p>
<Link href="/about" className="font-fraunces font-normal text-gray-500 hover:text-rose-600 transition-colors duration-150">About</Link>
<Link href="/blog" className="font-fraunces font-normal text-gray-500 hover:text-rose-600 transition-colors duration-150">Blog</Link>
<Link href="/partners" className="font-fraunces font-normal text-gray-500 hover:text-rose-600 transition-colors duration-150">Partners</Link>
</div>
{/* Legal */}
<div className="flex flex-col gap-2">
<p className="font-fraunces font-semibold text-gray-700 mb-1">Legal</p>
<Link href="/pricing" className="font-fraunces font-normal text-gray-500 hover:text-rose-600 transition-colors duration-150">Pricing</Link>
<Link href="/privacy" className="font-fraunces font-normal text-gray-500 hover:text-rose-600 transition-colors duration-150">Privacy Policy</Link>
<Link href="/terms" className="font-fraunces font-normal text-gray-500 hover:text-rose-600 transition-colors duration-150">Terms of Service</Link>
</div>
{/* Contact */}
<div className="flex flex-col gap-2">
<p className="font-fraunces font-semibold text-gray-700 mb-1">Contact</p>
<a
href="tel:+919554881799"
className="font-fraunces font-normal text-gray-500 hover:text-rose-600 transition-colors duration-150"
>
+91 95548 81799
</a>
<a
href="mailto:hello@tia.baby"
className="font-fraunces font-normal text-gray-500 hover:text-rose-600 transition-colors duration-150"
>
hello@tia.baby
</a>
</div>
</div>
</div>
</div>
{/* Bottom bar — different shade */}
<div className="bg-gray-100 border-t border-gray-200">
<div className="max-w-5xl mx-auto px-5 py-4 grid grid-cols-1 sm:grid-cols-3 items-center gap-1 text-xs text-gray-500 text-center sm:text-left">
<p>© {new Date().getFullYear()} Tia.</p>
<p className="sm:text-center">We don&apos;t sell your data we preserve it.</p>
<p className="sm:text-right">Built with <span className="inline-block transition-transform duration-300 hover:scale-150 cursor-default select-none"></span> in India.</p>
</div>
</div>
</footer>
</div>
);
}

View file

@ -0,0 +1,66 @@
import { ImageResponse } from "next/og";
export const alt = "Tia — Your baby's digital heirloom";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export default function OgImage() {
return new ImageResponse(
(
<div
style={{
background: "linear-gradient(135deg, #fdf2f2 0%, #fef3c7 50%, #fdf2f2 100%)",
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
fontFamily: "sans-serif",
padding: 80,
}}
>
<div style={{ fontSize: 72, marginBottom: 24 }}>🌸</div>
<div
style={{
fontSize: 60,
fontWeight: 800,
color: "#111827",
textAlign: "center",
lineHeight: 1.2,
marginBottom: 20,
}}
>
Tia
</div>
<div
style={{
fontSize: 28,
color: "#f43f5e",
fontWeight: 600,
textAlign: "center",
marginBottom: 24,
}}
>
Your baby&apos;s digital heirloom
</div>
<div
style={{
fontSize: 22,
color: "#6b7280",
textAlign: "center",
maxWidth: 800,
lineHeight: 1.5,
}}
>
Log every feed, milestone, and memory.
Built for Indian families. Privacy-first. Free during early access.
</div>
</div>
),
{ ...size }
);
}

View file

@ -0,0 +1,433 @@
import type { Metadata } from "next";
import Link from "next/link";
import { PhoneMockup } from "@/components/marketing/PhoneMockup";
import {
jsonLdScript,
organizationSchema,
websiteSchema,
softwareApplicationSchema,
} from "@/lib/seo";
export const metadata: Metadata = {
title: "Tia — Baby Tracker & Digital Heirloom for Indian Families",
description:
"Tia is a digital heirloom for your baby — not just a tracker. Log daily moments, track the IAP vaccination schedule with Telegram alerts, and build a living archive your child will one day inherit. Free during early access.",
keywords: [
"baby tracker",
"baby tracker app India",
"IAP vaccination schedule",
"baby vaccination tracker",
"newborn feed tracker",
"baby milestone tracker",
"digital baby book",
"parenting app India",
"baby memory keeper",
],
alternates: { canonical: "/" },
openGraph: {
type: "website",
url: "/",
siteName: "Tia",
locale: "en_IN",
title: "Tia — Baby Tracker & Digital Heirloom for Indian Families",
description:
"Log every feed, milestone, and memory. Track the IAP vaccination schedule with Telegram alerts. Built for Indian families. Privacy-first. Free during early access.",
},
};
// ── Google G icon (reusable) ────────────────────────────────────
function GoogleG({ size = 20 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" className="flex-shrink-0">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z" fill="#FBBC05"/>
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
</svg>
);
}
// ── Section: Hero ───────────────────────────────────────────────
function Hero() {
return (
<section className="relative overflow-hidden bg-gradient-to-br from-rose-50 via-amber-50 to-rose-50 pt-12 pb-20 px-5">
<div className="max-w-5xl mx-auto grid grid-cols-1 lg:grid-cols-[1.1fr_0.9fr] gap-10 items-center">
{/* LEFT: copy */}
<div className="text-center lg:text-left max-w-xl mx-auto lg:mx-0">
<div className="inline-flex items-center gap-2 bg-rose-100 text-rose-700 text-xs font-semibold px-3 py-1.5 rounded-full mb-6">
<span></span> Free during early access
</div>
<h1 className="font-fraunces italic text-4xl sm:text-5xl font-bold text-gray-900 leading-tight mb-5">
Your baby&apos;s story,{" "}
<span className="text-rose-500" style={{ fontFamily: "var(--font-caveat)", fontSize: "1.1em" }}>
preserved for a lifetime.
</span>
</h1>
<p className="font-newsreader text-xl text-gray-800 leading-relaxed mb-8 max-w-xl mx-auto lg:mx-0">
Tia is a digital heirloom not just a tracker. Every feed, every first
word, every vaccination, archived in one private place your child will
one day look back on.
</p>
<Link
href="/login"
className="inline-flex items-center gap-2.5 bg-white hover:bg-rose-50 border border-gray-200 hover:border-rose-300 text-gray-700 font-semibold px-7 py-3.5 rounded-full text-base transition-all duration-200 shadow-md hover:shadow-xl hover:shadow-rose-200/60 hover:-translate-y-0.5 hover:scale-[1.03] active:scale-95 active:translate-y-0"
>
<GoogleG />
Continue with Google
</Link>
<p className="mt-4 text-xs text-gray-400">No credit card. No setup fee. Your data is yours.</p>
</div>
{/* RIGHT: animated phone mockup */}
<div className="flex justify-center lg:justify-end">
<PhoneMockup />
</div>
</div>
{/* Decorative blobs */}
<div className="absolute -top-20 -right-20 w-64 h-64 bg-rose-100 rounded-full opacity-40 blur-3xl pointer-events-none" />
<div className="absolute -bottom-16 -left-16 w-48 h-48 bg-amber-100 rounded-full opacity-50 blur-2xl pointer-events-none" />
</section>
);
}
// ── Section: The Problem ────────────────────────────────────────
function TheProblem() {
return (
<section className="py-20 px-5 bg-white">
<div className="max-w-2xl mx-auto">
<p className="font-jetbrains text-xs font-semibold text-rose-500 uppercase tracking-widest mb-4">The 3am reality</p>
<h2 className="font-fraunces text-3xl font-bold text-gray-900 mb-6 leading-tight">
You&apos;re awake at 3am.<br />
When did she last feed?
</h2>
<div className="font-newsreader space-y-4 text-gray-600 leading-relaxed">
<p>
You scroll back through WhatsApp messages to your mother-in-law. You check a
sticky note on the refrigerator. You open a notes app you downloaded last week
and another one you downloaded the week before that.
</p>
<p>
Every precious detail the exact weight at the six-week checkup, the tiny smile
at eleven weeks, the first solid feed is scattered across six apps, two
notebooks, and a dozen photographs that may or may not be backed up.
</p>
<p>
And somewhere underneath the exhaustion, a quieter fear: <em>I&apos;m going to forget this.</em>
</p>
</div>
<div className="mt-8 grid grid-cols-1 sm:grid-cols-3 gap-4">
{[
{ icon: "📱", text: "Notes scattered across 4+ apps" },
{ icon: "😰", text: "Fear of losing precious moments" },
{ icon: "🌙", text: "No answers at 3am" },
].map(item => (
<div key={item.text} className="flex items-start gap-3 bg-rose-50 rounded-xl p-4">
<span className="text-2xl flex-shrink-0">{item.icon}</span>
<p className="text-sm text-gray-700 font-medium">{item.text}</p>
</div>
))}
</div>
</div>
</section>
);
}
// ── Section: Features ──────────────────────────────────────────
const FEATURES = [
{
icon: "🍼",
title: "Log in 5 seconds",
body: "Feed, sleep, diaper — one tap. Tia timestamps everything automatically. At 3am, you should be sleeping, not typing.",
example: "\"Fed 90ml at 2:47am\" → logged and archived forever.",
},
{
icon: "💉",
title: "IAP vaccination schedule",
body: "Tia tracks your baby's vaccination schedule against the Indian Academy of Pediatrics (IAP) recommended schedule. Reminders arrive via Telegram — the one app you actually check.",
example: "BCG, OPV, Hepatitis B — all tracked. Telegram alert: \"Pentavalent 2 due in 3 days.\"",
},
{
icon: "🔮",
title: "Ask Tia",
body: "Ask Tia parenting logistics questions — feeding windows, sleep patterns, when to introduce solids. For anything medical, Tia defers to your pediatrician. That restraint is intentional — it&apos;s a trust feature.",
example: "\"Is 90ml normal at 6 weeks?\" → Tia gives context, then: \"Your pediatrician can confirm this for your baby specifically.\"",
},
{
icon: "📚",
title: "The heirloom archive",
body: "Every log, photo, milestone, and memory becomes part of a permanent, private archive. Not a feed. A complete record — searchable, exportable, and theirs to keep.",
example: "\"Show me everything from her first month\" → every feed, every photo, every note.",
},
{
icon: "👨‍👩‍👧",
title: "Family circle",
body: "Role-based access so everyone in your baby&apos;s life can be as involved as they should be. Grandparents get view-only access. The nanny gets caregiver access. You stay the admin.",
example: "Nani in Jaipur sees today's photos in real time. The daai logs the afternoon feed.",
},
{
icon: "📈",
title: "Growth tracking",
body: "Weight, length, and head circumference plotted on clear charts. See how your baby is growing over time and carry the full history into every pediatric visit.",
example: "\"She's been in the 60th percentile for weight since month two.\"",
},
];
function Features() {
return (
<section className="py-20 px-5 bg-gray-50">
<div className="max-w-5xl mx-auto">
<p className="font-jetbrains text-xs font-semibold text-rose-500 uppercase tracking-widest mb-4 text-center">What Tia does</p>
<h2 className="font-fraunces text-3xl font-bold text-gray-900 text-center mb-12">
Everything in one private place.
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{FEATURES.map(f => (
<div
key={f.title}
className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100 hover:border-rose-200 hover:shadow-md transition-all duration-200 group flex flex-col"
>
<span className="text-3xl mb-4 transition-transform duration-200 group-hover:scale-110 block">
{f.icon}
</span>
<h3 className="font-fraunces font-bold text-gray-900 text-lg mb-2">{f.title}</h3>
<p
className="text-gray-600 text-sm leading-relaxed mb-4 flex-1"
dangerouslySetInnerHTML={{ __html: f.body }}
/>
<div className="bg-rose-50 rounded-lg px-3 py-2 text-xs text-rose-700 italic mt-auto">
{f.example}
</div>
</div>
))}
</div>
</div>
</section>
);
}
// ── Section: Founder Story ─────────────────────────────────────
function FounderStory() {
return (
<section className="py-20 px-5 bg-white">
<div className="max-w-2xl mx-auto">
<p className="font-jetbrains text-xs font-semibold text-rose-500 uppercase tracking-widest mb-4">Why Tia exists</p>
<h2 className="font-fraunces text-3xl font-bold text-gray-900 mb-3 leading-tight">
TIA began with a promise.
</h2>
<div className="bg-amber-50 border border-amber-200 rounded-2xl p-6 sm:p-8 mt-6">
<div className="font-newsreader text-gray-700 leading-relaxed space-y-4 text-sm sm:text-base">
<p>
When our daughter, Tia, was born, we wanted to remember everything the tiny
stretches, the sleepy smiles, the moments that felt too precious to forget. But we
quickly discovered how easily those details fade, even when you&apos;re trying your
best to hold on to them.
</p>
<p className="font-medium text-gray-800">
We built TIA to help families preserve those memories.
</p>
<p>
Named after our daughter, Tia, the app reflects the values that guide us as parents:
care, patience, trust, privacy, and respect. We believe your family&apos;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&apos;t here to track your child. It&apos;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&apos;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>&quot;What was I like when I was little?&quot;</em> we hope TIA helps you
answer with photos, stories, milestones, and moments preserved with love.
</p>
</div>
<div className="mt-8 pt-6 border-t border-amber-200 text-center">
<p className="text-sm font-semibold text-amber-800 tracking-wide">
Built by parents. Inspired by our daughter. Made for families.
</p>
</div>
</div>
</div>
</section>
);
}
// ── Section: The Heirloom Vision ───────────────────────────────
function HeirloomVision() {
return (
<section className="py-20 px-5 bg-gradient-to-br from-rose-50 to-amber-50">
<div className="max-w-2xl mx-auto text-center">
<p className="font-jetbrains text-xs font-semibold text-rose-500 uppercase tracking-widest mb-4">The heirloom vision</p>
<h2 className="font-fraunces text-3xl font-bold text-gray-900 mb-6 leading-tight">
One day, your child will be able to{" "}
<span className="text-rose-500" style={{ fontFamily: "var(--font-caveat)", fontSize: "1.1em" }}>
read their own story.
</span>
</h2>
<p className="font-newsreader text-gray-600 leading-relaxed mb-8 max-w-xl mx-auto">
Everything you log today is a letter to your future child. The 2:47am feed.
The first solid. The doctor visit you worried about for a week. The photo from
the moment you realised she could recognise your voice.
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-left">
{[
{ icon: "📖", title: "A complete record", desc: "Not highlights. Everything — because you can't know yet which moment will matter most." },
{ icon: "🔒", title: "Private and permanent", desc: "No public feed. No algorithm. Your family's archive, locked to your family." },
{ icon: "💾", title: "Fully exportable", desc: "Your data is yours. Export everything at any time. The heirloom is portable." },
].map(item => (
<div key={item.title} className="bg-white/80 rounded-2xl p-5 border border-rose-100 hover:border-rose-300 hover:shadow-sm transition-all duration-200">
<span className="text-2xl mb-3 block">{item.icon}</span>
<h3 className="font-fraunces font-bold text-gray-900 mb-1">{item.title}</h3>
<p className="text-sm text-gray-600 leading-relaxed">{item.desc}</p>
</div>
))}
</div>
</div>
</section>
);
}
// ── Section: Privacy & Trust ───────────────────────────────────
function Privacy() {
return (
<section className="py-20 px-5 bg-white">
<div className="max-w-2xl mx-auto">
<p className="font-jetbrains text-xs font-semibold text-rose-500 uppercase tracking-widest mb-4">Privacy & trust</p>
<h2 className="font-fraunces text-3xl font-bold text-gray-900 mb-4">
We don&apos;t sell your data <br />
<span className="text-rose-500">we preserve it.</span>
</h2>
<p className="font-newsreader text-gray-600 leading-relaxed mb-8">
Tia is a baby-tracking app. Your child&apos;s records are not the product.
They are the point.
</p>
<div className="space-y-3">
{[
{ icon: "🏛️", title: "Row-Level Security", desc: "Your family's data is isolated at the database level. No other user — no other family — can reach your records." },
{ icon: "🚫", title: "No ads. No data resale.", desc: "We do not sell, share, or monetise your data with third parties. Ever." },
{ icon: "📤", title: "Full export at any time", desc: "Export everything: logs, photos, milestones, vaccinations. Your data leaves with you whenever you want." },
{ icon: "🔐", title: "Google Sign-In only", desc: "We don't store passwords. Authentication is handled by Google — the same account you already trust for everything else." },
].map(item => (
<div key={item.title} className="flex items-start gap-4 p-4 bg-gray-50 hover:bg-rose-50 rounded-xl transition-colors duration-150">
<span className="text-xl flex-shrink-0 mt-0.5">{item.icon}</span>
<div>
<p className="font-semibold text-gray-900 text-sm">{item.title}</p>
<p className="text-sm text-gray-600 leading-relaxed mt-0.5">{item.desc}</p>
</div>
</div>
))}
</div>
</div>
</section>
);
}
// ── Section: Early Access ──────────────────────────────────────
function EarlyAccess() {
return (
<section className="py-16 px-5 bg-gray-50 border-y border-gray-100">
<div className="max-w-2xl mx-auto">
<p className="font-jetbrains text-xs font-semibold text-rose-500 uppercase tracking-widest mb-4 text-center">Private early access</p>
<h2 className="font-fraunces text-2xl font-bold text-gray-900 text-center mb-6">
Built by a parent, being tested by parents.
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{[
{ icon: "🌱", title: "Free during early access", desc: "Core features — activity logging and the heirloom archive — are free while Tia is in early access. Early families keep their terms for life." },
{ icon: "🇮🇳", title: "India-native from day one", desc: "IAP vaccination schedule. Telegram alerts. Indian naming conventions. Built for how Indian families actually live." },
].map(item => (
<div key={item.title} className="bg-white rounded-2xl p-5 border border-gray-100 shadow-sm">
<span className="text-2xl mb-3 block">{item.icon}</span>
<h3 className="font-fraunces font-bold text-gray-900 mb-2">{item.title}</h3>
<p className="text-sm text-gray-600 leading-relaxed">{item.desc}</p>
</div>
))}
</div>
</div>
</section>
);
}
// ── Section: Final CTA ─────────────────────────────────────────
function FinalCTA() {
return (
<section className="py-24 px-5 bg-gradient-to-br from-rose-500 to-rose-600 text-white text-center">
<div className="max-w-xl mx-auto">
<h2 className="font-fraunces text-3xl sm:text-4xl font-bold mb-4 leading-tight">
Start preserving your<br />child&apos;s story today.
</h2>
<p className="text-rose-100 mb-8 leading-relaxed">
Free during early access. No credit card. Your data is yours always.
</p>
<Link
href="/login"
className="inline-flex items-center gap-2.5 bg-white text-rose-600 font-bold px-8 py-4 rounded-full text-base hover:bg-rose-50 hover:shadow-xl active:scale-95 transition-all duration-200 shadow-lg"
>
<GoogleG />
Continue with Google
</Link>
<p className="mt-4 text-xs text-rose-200">
Invite-only early access join from a shared link or reach out directly.
</p>
</div>
</section>
);
}
// ── Page ───────────────────────────────────────────────────────
export default function MarketingHomePage() {
return (
<>
{/* Structured data — Organization, WebSite, and SoftwareApplication */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={jsonLdScript([
organizationSchema(),
websiteSchema(),
softwareApplicationSchema(),
])}
/>
<Hero />
<TheProblem />
<Features />
<FounderStory />
<HeirloomVision />
<Privacy />
<EarlyAccess />
<FinalCTA />
</>
);
}

View 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&apos;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&apos;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>
);
}

View file

@ -0,0 +1,88 @@
import type { Metadata } from "next";
import Link from "next/link";
export const metadata: Metadata = {
title: "Pricing",
description: "Founder pricing — early families keep their terms for life. Free during early access.",
alternates: { canonical: "/pricing" },
openGraph: {
type: "website",
url: "/pricing",
title: "Pricing | Tia",
description: "Founder pricing — early families keep their terms for life. Free during early access.",
},
};
export default function PricingPage() {
return (
<div className="max-w-2xl mx-auto px-4 py-20">
<p className="font-jetbrains text-xs font-semibold text-rose-500 uppercase tracking-widest mb-4 text-center">Pricing</p>
<h1 className="font-fraunces text-3xl sm:text-4xl font-bold text-gray-900 text-center mb-4">
Founder pricing.
</h1>
<p className="text-gray-500 text-center mb-12 max-w-md mx-auto">
Early families keep their terms for life.
</p>
<div className="space-y-4">
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
<div className="flex items-start justify-between mb-4">
<div>
<h2 className="text-lg font-bold text-gray-900">Free always</h2>
<p className="text-sm text-gray-500 mt-1">The core heirloom experience</p>
</div>
<span className="bg-green-100 text-green-700 text-xs font-semibold px-3 py-1 rounded-full">Included</span>
</div>
<ul className="space-y-2 text-sm text-gray-700">
{[
"Activity logging — feed, sleep, diaper, and more",
"The heirloom archive — every log, searchable forever",
"Milestone tracking",
"IAP vaccination schedule",
"Telegram alerts for upcoming vaccinations",
"Ask Tia (parenting logistics AI)",
"Family circle with role-based access",
"Growth tracking",
"Full data export — the archive is yours",
].map(item => (
<li key={item} className="flex items-start gap-2">
<span className="text-rose-400 flex-shrink-0 mt-0.5"></span>
{item}
</li>
))}
</ul>
</div>
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
<div className="flex items-start justify-between mb-4">
<div>
<h2 className="text-lg font-bold text-gray-900">Media storage</h2>
<p className="text-sm text-gray-500 mt-1">Photos and video in the archive</p>
</div>
<span className="bg-amber-100 text-amber-700 text-xs font-semibold px-3 py-1 rounded-full">Paid / metered</span>
</div>
<p className="text-sm text-gray-600 leading-relaxed">
Storing photos and video at scale has a real infrastructure cost. Media storage is
the one thing we charge for metered, so you only pay for what you use.
Pricing details will be shared before the early-access period ends.
</p>
<p className="text-xs text-gray-400 mt-3">
Founder families joining during early access will be the first to know and will
keep whatever terms we agree on, for life.
</p>
</div>
</div>
<div className="mt-12 text-center">
<Link
href="/login"
className="inline-flex items-center gap-2 bg-rose-500 hover:bg-rose-600 text-white font-semibold px-7 py-3.5 rounded-full text-base transition-colors"
>
Continue with Google it&apos;s free
</Link>
<p className="text-xs text-gray-400 mt-3">No credit card required.</p>
</div>
</div>
);
}

View file

@ -0,0 +1,87 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Privacy Policy",
description: "How Tia handles your family's data. We don't sell it — we preserve it.",
alternates: { canonical: "/privacy" },
openGraph: {
type: "website",
url: "/privacy",
title: "Privacy Policy | Tia",
description: "How Tia handles your family's data. We don't sell it — we preserve it.",
},
};
export default function PrivacyPage() {
return (
<div className="max-w-2xl mx-auto px-4 py-20">
<h1 className="text-3xl font-bold text-gray-900 mb-2">Privacy Policy</h1>
<p className="text-sm text-gray-400 mb-10">Last updated: May 2026</p>
<div className="prose prose-gray max-w-none space-y-8 text-gray-700 leading-relaxed">
<section>
<h2 className="text-xl font-bold text-gray-900 mb-3">The short version</h2>
<p>
We don&apos;t sell your data. We don&apos;t show you ads. We don&apos;t share your records
with third parties. Your family&apos;s data exists in Tia for one purpose: to
be preserved for you and your child.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-gray-900 mb-3">What we collect</h2>
<ul className="list-disc pl-5 space-y-2 text-sm">
<li>Your name and email address, via Google Sign-In.</li>
<li>Activity logs you enter (feeds, sleep, diapers, medical records, milestones).</li>
<li>Photos and media you upload to the heirloom archive.</li>
<li>Device and session information for security purposes.</li>
</ul>
</section>
<section>
<h2 className="text-xl font-bold text-gray-900 mb-3">How we protect it</h2>
<p>
Your family&apos;s data is isolated at the database level using Row-Level Security (RLS).
No other user no other family can access your records. We use HTTPS everywhere.
Sessions are managed with secure, httpOnly cookies.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-gray-900 mb-3">Who we share it with</h2>
<p>
We use the following third-party services to operate Tia. We do not sell your data
to any third party, ever.
</p>
<ul className="list-disc pl-5 space-y-2 text-sm mt-3">
<li><strong>Google</strong> authentication only.</li>
<li><strong>Cloudflare R2</strong> media storage (photos, video).</li>
<li><strong>Telegram</strong> vaccination alerts, if you opt in.</li>
<li><strong>Resend</strong> transactional email (invites, verification).</li>
<li><strong>Plausible Analytics</strong> privacy-preserving, cookie-free analytics on our marketing pages only. No tracking inside the app.</li>
</ul>
</section>
<section>
<h2 className="text-xl font-bold text-gray-900 mb-3">Your rights</h2>
<ul className="list-disc pl-5 space-y-2 text-sm">
<li><strong>Export:</strong> You can export all your data at any time from Settings. The heirloom is portable and yours.</li>
<li><strong>Deletion:</strong> You can delete your account and all associated data. Contact us at <a href="mailto:tia@manohargupta.com" className="text-rose-500 hover:underline">tia@manohargupta.com</a>.</li>
<li><strong>Correction:</strong> You can edit or delete any log or record within the app.</li>
</ul>
</section>
<section>
<h2 className="text-xl font-bold text-gray-900 mb-3">Contact</h2>
<p>
Questions about this policy? Email us at{" "}
<a href="mailto:tia@manohargupta.com" className="text-rose-500 hover:underline">
tia@manohargupta.com
</a>
.
</p>
</section>
</div>
</div>
);
}

View file

@ -0,0 +1,94 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Terms of Service",
description: "Terms of service for Tia — the baby tracker and digital heirloom for Indian families.",
alternates: { canonical: "/terms" },
openGraph: {
type: "website",
url: "/terms",
title: "Terms of Service | Tia",
description: "Terms of service for Tia — the baby tracker and digital heirloom for Indian families.",
},
};
export default function TermsPage() {
return (
<div className="max-w-2xl mx-auto px-4 py-20">
<h1 className="text-3xl font-bold text-gray-900 mb-2">Terms of Service</h1>
<p className="text-sm text-gray-400 mb-10">Last updated: May 2026</p>
<div className="space-y-8 text-gray-700 leading-relaxed">
<section>
<h2 className="text-xl font-bold text-gray-900 mb-3">Acceptance</h2>
<p>
By using Tia, you agree to these terms. If you don&apos;t agree, please don&apos;t use
the service.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-gray-900 mb-3">What Tia is</h2>
<p>
Tia is a baby-tracking and heirloom-archiving application for families. It is
not a medical service. Tia does not provide medical advice, diagnosis, or
treatment. For any health concerns about your child, consult your pediatrician.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-gray-900 mb-3">Your account</h2>
<p>
You are responsible for maintaining the security of your account. You must
be at least 18 years old to create an account. Family members you invite
are governed by these same terms.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-gray-900 mb-3">Your data</h2>
<p>
You own your data. We do not claim any ownership over the records, photos,
or content you create in Tia. You can export and delete your data at any time.
See our <a href="/privacy" className="text-rose-500 hover:underline">Privacy Policy</a> for details.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-gray-900 mb-3">Acceptable use</h2>
<p>
You agree not to use Tia to store illegal content, harm others, or interfere
with the service. We may terminate accounts that violate these terms.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-gray-900 mb-3">Service availability</h2>
<p>
Tia is provided as-is during early access. We make no guarantees of uptime
or availability, though we work hard to keep the service running reliably.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-gray-900 mb-3">Changes to these terms</h2>
<p>
We may update these terms. We will notify you by email before any material
changes take effect.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-gray-900 mb-3">Contact</h2>
<p>
Questions? Email{" "}
<a href="mailto:tia@manohargupta.com" className="text-rose-500 hover:underline">
tia@manohargupta.com
</a>
.
</p>
</section>
</div>
</div>
);
}

View file

@ -3,13 +3,7 @@
import { useState, useEffect, createContext, useContext } from "react";
import { ReactNode } from "react";
import { useRouter, usePathname } from "next/navigation";
interface Child {
id: string;
name: string;
birthDate: string;
sex: string;
}
import type { Child } from "@/types";
interface FamilyContextType {
familyId: string | null;
@ -20,6 +14,7 @@ interface FamilyContextType {
loading: boolean;
tier: "free" | "pro";
memberCount: number;
updateChildImage: (childId: string, imageUrl: string | null) => void;
}
const FamilyContext = createContext<FamilyContextType>({
@ -31,6 +26,7 @@ const FamilyContext = createContext<FamilyContextType>({
loading: true,
tier: "free",
memberCount: 2,
updateChildImage: () => {},
});
export function useFamily() {
@ -87,11 +83,12 @@ export function FamilyProvider({ children: providerChildren }: { children: React
}
if (data.children?.length > 0) {
const childList = data.children.map((c: any) => ({
const childList: Child[] = data.children.map((c: Child) => ({
id: c.id,
name: c.name,
birthDate: c.birthDate,
sex: c.sex,
imageUrl: c.imageUrl ?? null,
}));
setChildren(childList);
@ -126,6 +123,11 @@ export function FamilyProvider({ children: providerChildren }: { children: React
fetchFamilyData();
}, [router]);
const updateChildImage = (cId: string, imageUrl: string | null) => {
setChildren(prev => prev.map(c => c.id === cId ? { ...c, imageUrl } : c));
setChild(prev => prev?.id === cId ? { ...prev, imageUrl } : prev);
};
return (
<FamilyContext.Provider
value={{
@ -137,6 +139,7 @@ export function FamilyProvider({ children: providerChildren }: { children: React
loading,
tier,
memberCount,
updateChildImage,
}}
>
{providerChildren}

View file

@ -1,244 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { useFamily } from "../FamilyProvider";
type ViewMode = "timeline" | "calendar";
type LogType = "feed" | "sleep" | "diaper";
interface Log {
id: string;
type: LogType;
subType?: string;
amount?: number;
notes?: string;
loggedAt: string;
}
interface Child {
id: string;
name: string;
birthDate: string;
}
import { getGuideline, getAgeInMonths, guidelines } from "@/lib/guidelines";
interface DayLogs {
date: string;
logs: Log[];
}
export default function ActivityPage() {
const { child, childId: providerChildId, familyId } = useFamily();
const [view, setView] = useState<ViewMode>("timeline");
const [logs, setLogs] = useState<Log[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<LogType | "all">("all");
const [showSuggested, setShowSuggested] = useState(true);
const [generating, setGenerating] = useState(false);
const childId = providerChildId || "default";
useEffect(() => {
if (providerChildId) {
fetchLogs();
}
}, [providerChildId]);
const fetchLogs = async () => {
if (!childId) return;
try {
const res = await fetch(`/api/logs?childId=${childId}&limit=100`);
const data = await res.json();
setLogs(data.entries || []);
} catch (err) {
console.error("Failed to fetch:", err);
}
setLoading(false);
};
const generateHistory = async () => {
if (!child) return;
setGenerating(true);
try {
const res = await fetch("/api/history", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ childId: child.id, birthDate: child.birthDate }),
});
const data = await res.json();
if (data.success) {
fetchLogs();
}
} catch (err) {
console.error("Failed to generate:", err);
}
setGenerating(false);
};
const filteredLogs = filter === "all" ? logs : logs.filter((l) => l.type === filter);
const groupedByDay = filteredLogs.reduce((acc: DayLogs[], log) => {
const date = new Date(log.loggedAt).toDateString();
const existing = acc.find((d) => d.date === date);
if (existing) {
existing.logs.push(log);
} else {
acc.push({ date, logs: [log] });
}
return acc;
}, []);
const getIcon = (type: LogType) => {
switch (type) {
case "feed": return "🍼";
case "sleep": return "😴";
case "diaper": return "👶";
default: return "📝";
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800">
{/* Header */}
<div className="p-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/menu" className="p-2"></Link>
<h1 className="text-xl font-bold">Activity</h1>
{child && logs.length === 0 && (
<button
onClick={generateHistory}
disabled={generating}
className="text-xs px-2 py-1 bg-rose-400 text-white rounded-full"
>
{generating ? "..." : "Generate History"}
</button>
)}
</div>
{/* View Toggle */}
<div className="flex bg-white dark:bg-gray-800 rounded-lg p-1">
<button
onClick={() => setView("timeline")}
className={`px-3 py-1 rounded-md text-sm ${view === "timeline" ? "bg-rose-400 text-white" : ""}`}
>
Timeline
</button>
<button
onClick={() => setView("calendar")}
className={`px-3 py-1 rounded-md text-sm ${view === "calendar" ? "bg-rose-400 text-white" : ""}`}
>
Calendar
</button>
</div>
</div>
{/* Filter */}
<div className="px-4 mb-4 flex gap-2 overflow-x-auto">
{(["all", "feed", "sleep", "diaper"] as const).map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={`px-4 py-2 rounded-full text-sm whitespace-nowrap ${
filter === f
? "bg-rose-400 text-white"
: "bg-white dark:bg-gray-800"
}`}
>
{f === "all" ? "All" : f.charAt(0).toUpperCase() + f.slice(1)}
</button>
))}
</div>
{/* Guidelines Card */}
{child && showSuggested && (
<div className="px-4 mb-4">
{(() => {
const guide = getGuideline(child.birthDate);
const ageMonths = getAgeInMonths(child.birthDate);
return (
<div className="p-4 bg-gradient-to-r from-rose-100 to-amber-100 dark:from-rose-900 dark:to-amber-900 rounded-xl">
<div className="flex items-center justify-between mb-2">
<div className="font-medium text-rose-800 dark:text-rose-200">
{child.name} · {ageMonths} months old
</div>
<button onClick={() => setShowSuggested(false)} className="text-gray-400"></button>
</div>
<div className="grid grid-cols-3 gap-2 text-sm">
<div className="text-center">
<div className="text-lg font-bold text-rose-600 dark:text-rose-400">{guide.feeds.times}</div>
<div className="text-xs text-gray-600 dark:text-gray-400">feeds/day</div>
</div>
<div className="text-center">
<div className="text-lg font-bold text-amber-600 dark:text-amber-400">{guide.sleep.totalHours}h</div>
<div className="text-xs text-gray-600 dark:text-gray-400">sleep/day</div>
</div>
<div className="text-center">
<div className="text-lg font-bold text-blue-600 dark:text-blue-400">{guide.diapers.count}</div>
<div className="text-xs text-gray-600 dark:text-gray-400">diapers/day</div>
</div>
</div>
</div>
);
})()}
</div>
)}
{/* Content */}
<div className="px-4 pb-20">
{loading ? (
<div className="text-center py-20 text-gray-400">Loading...</div>
) : view === "timeline" ? (
/* Timeline View */
groupedByDay.length === 0 ? (
<div className="text-center py-20 text-gray-400">
<div className="text-6xl mb-4">📊</div>
<p>No activity yet</p>
</div>
) : (
<div className="space-y-6">
{groupedByDay.map((day) => (
<div key={day.date}>
<div className="text-sm font-medium text-gray-500 mb-2">
{new Date(day.date).toLocaleDateString("en-US", {
weekday: "long",
month: "short",
day: "numeric",
})}
</div>
<div className="space-y-2">
{day.logs
.sort((a, b) => new Date(b.loggedAt).getTime() - new Date(a.loggedAt).getTime())
.map((log) => (
<div key={log.id} className="flex items-center gap-3 p-3 bg-white dark:bg-gray-800 rounded-xl">
<span className="text-2xl">{getIcon(log.type)}</span>
<div className="flex-1">
<div className="font-medium capitalize">{log.type}</div>
<div className="text-sm text-gray-500">
{log.subType && `${log.subType} · `}
{log.amount && `${log.amount}ml`}
{log.notes && ` · ${log.notes}`}
</div>
</div>
<div className="text-sm text-gray-400">
{new Date(log.loggedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
</div>
</div>
))}
</div>
</div>
))}
</div>
)
) : (
/* Calendar View */
<div className="bg-white dark:bg-gray-800 rounded-xl p-4">
<div className="text-center text-gray-400 py-20">
<div className="text-6xl mb-4">📅</div>
<p>Calendar view coming soon</p>
</div>
</div>
)}
</div>
</div>
);
}

View file

@ -2,6 +2,7 @@
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Button, Input } from "@/components/ui";
export default function AdminLoginPage() {
const router = useRouter();
@ -34,16 +35,13 @@ export default function AdminLoginPage() {
const data = await res.json();
if (res.ok && data.success) {
// Verify session server-side before redirect
const sessionRes = await fetch("/api/admin/auth");
const sessionData = await sessionRes.json();
if (sessionData.authenticated) {
router.push("/admin");
}
if (sessionData.authenticated) router.push("/admin");
} else {
setError(data.error || "Invalid credentials");
}
} catch (err) {
} catch {
setError("Login failed");
} finally {
setLoading(false);
@ -56,39 +54,30 @@ export default function AdminLoginPage() {
<h1 className="text-2xl font-bold text-center mb-6 text-white">Admin Login</h1>
<form onSubmit={handleLogin} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1 text-gray-300">Username</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full p-3 bg-gray-700 border border-gray-600 rounded-xl text-white"
required
/>
</div>
<Input
label="Username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
className="bg-gray-700 border-gray-600 text-white"
/>
<div>
<label className="block text-sm font-medium mb-1 text-gray-300">Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full p-3 bg-gray-700 border border-gray-600 rounded-xl text-white"
required
/>
</div>
<Input
label="Password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
error={error || undefined}
className="bg-gray-700 border-gray-600 text-white"
/>
{error && <p className="text-red-400 text-sm">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full py-3 bg-rose-500 text-white rounded-xl font-medium disabled:opacity-50"
>
{loading ? "..." : "Login"}
</button>
<Button type="submit" fullWidth size="lg" loading={loading}>
Login
</Button>
</form>
</div>
</div>
);
}
}

View file

@ -12,11 +12,18 @@ interface NavItem {
const navItems: NavItem[] = [
{ name: "Dashboard", href: "/admin", icon: "📊" },
{ name: "Health", href: "/admin/health", icon: "❤️‍🩹" },
{ name: "Errors", href: "/admin/errors", icon: "🐞" },
{ name: "Activity", href: "/admin/activity", icon: "🔍" },
{ name: "Audit Log", href: "/admin/audit", icon: "📜" },
{ name: "Families", href: "/admin/families", icon: "🏠" },
{ name: "Users", href: "/admin/users", icon: "👥" },
{ name: "Children", href: "/admin/children", icon: "👶" },
{ name: "Revenue", href: "/admin/revenue", icon: "💰" },
{ name: "Subscriptions", href: "/admin/subscriptions", icon: "💳" },
{ name: "Storage", href: "/admin/storage", icon: "💾" },
{ name: "Analytics", href: "/admin/analytics", icon: "📈" },
{ name: "AI Usage", href: "/admin/ai", icon: "🤖" },
{ name: "Support", href: "/admin/support", icon: "🎫" },
{ name: "Settings", href: "/admin/settings", icon: "⚙️" },
];

View file

@ -0,0 +1,263 @@
"use client";
import { useEffect, useState } from "react";
import { Button, Badge } from "@/components/ui";
interface ActivityStats {
activeSessions: number;
loginsToday: number;
failedToday: number;
signupsWeek: number;
}
interface Event {
id: string;
action: string;
email: string;
userName: string | null;
familyName: string | null;
ipAddress: string | null;
userAgent: string | null;
createdAt: string;
}
interface ActivityData {
stats: ActivityStats;
loginsByDay: { date: string; count: number }[];
failedByDay: { date: string; count: number }[];
events: Event[];
}
const ACTION_CONFIG: Record<string, { label: string; color: string; badge: "rose" | "warning" | "default" }> = {
login: { label: "Login", color: "text-emerald-400", badge: "default" },
login_failed: { label: "Failed Login", color: "text-rose-400", badge: "rose" },
signup: { label: "Signup", color: "text-blue-400", badge: "default" },
logout: { label: "Logout", color: "text-gray-400", badge: "default" },
};
function timeAgo(iso: string) {
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
return `${days}d ago`;
}
function parseDevice(ua: string | null) {
if (!ua) return "Unknown";
if (ua.includes("iPhone") || ua.includes("Android")) return "Mobile";
if (ua.includes("iPad")) return "Tablet";
return "Desktop";
}
export default function AdminActivity() {
const [data, setData] = useState<ActivityData | null>(null);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState("all");
const [autoRefresh, setAutoRefresh] = useState(false);
useEffect(() => {
fetchActivity();
}, [filter]);
useEffect(() => {
if (!autoRefresh) return;
const id = setInterval(fetchActivity, 15000);
return () => clearInterval(id);
}, [autoRefresh, filter]);
const fetchActivity = async () => {
try {
const res = await fetch(`/api/admin/activity?action=${filter}&limit=100`, { credentials: "include" });
const json = await res.json();
setData(json);
} catch (err) {
console.error("Failed to fetch activity:", err);
}
setLoading(false);
};
if (loading || !data) {
return (
<div className="p-6">
<div className="animate-pulse space-y-4">
<div className="h-8 bg-gray-700 rounded w-48" />
<div className="grid grid-cols-4 gap-4">
{[1, 2, 3, 4].map(i => <div key={i} className="h-24 bg-gray-800 rounded-xl" />)}
</div>
</div>
</div>
);
}
const { stats, loginsByDay, failedByDay, events } = data;
const maxLogins = Math.max(...loginsByDay.map(d => d.count), 1);
const maxFailed = Math.max(...failedByDay.map(d => d.count), 1);
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold">Activity Monitor</h1>
<p className="text-gray-400">Real-time login events and session tracking</p>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${autoRefresh ? "bg-emerald-400 animate-pulse" : "bg-gray-600"}`} />
<button
onClick={() => setAutoRefresh(!autoRefresh)}
className="text-sm text-gray-400 hover:text-white"
>
{autoRefresh ? "Live" : "Paused"}
</button>
</div>
<Button size="sm" variant="secondary" onClick={fetchActivity}>Refresh</Button>
</div>
</div>
{/* Stat Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-gray-800 p-5 rounded-xl border border-gray-700">
<div className="text-3xl font-bold text-emerald-400">{stats.activeSessions}</div>
<div className="text-gray-400 text-sm mt-1">Active Sessions</div>
<div className="text-xs text-gray-600 mt-1">Currently online</div>
</div>
<div className="bg-gray-800 p-5 rounded-xl border border-gray-700">
<div className="text-3xl font-bold text-blue-400">{stats.loginsToday}</div>
<div className="text-gray-400 text-sm mt-1">Logins Today</div>
<div className="text-xs text-gray-600 mt-1">Last 24 hours</div>
</div>
<div className="bg-gray-800 p-5 rounded-xl border border-gray-700">
<div className={`text-3xl font-bold ${stats.failedToday > 0 ? "text-rose-400" : "text-gray-400"}`}>
{stats.failedToday}
</div>
<div className="text-gray-400 text-sm mt-1">Failed Attempts</div>
<div className="text-xs text-gray-600 mt-1">Last 24 hours</div>
</div>
<div className="bg-gray-800 p-5 rounded-xl border border-gray-700">
<div className="text-3xl font-bold text-amber-400">{stats.signupsWeek}</div>
<div className="text-gray-400 text-sm mt-1">New Signups</div>
<div className="text-xs text-gray-600 mt-1">Last 7 days</div>
</div>
</div>
{/* Charts */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-gray-800 p-6 rounded-xl">
<h3 className="text-sm font-semibold text-gray-400 mb-4">LOGIN ACTIVITY LAST 30 DAYS</h3>
<div className="h-32 flex items-end gap-1">
{loginsByDay.slice(-30).map((d, i) => (
<div key={i} className="flex-1 flex flex-col items-center gap-1">
<div
className="w-full bg-emerald-500 rounded-t opacity-80"
style={{ height: `${(d.count / maxLogins) * 100}%`, minHeight: d.count > 0 ? "3px" : "0" }}
/>
<div className="text-[7px] text-gray-600">{d.date?.slice(5) || ""}</div>
</div>
))}
{loginsByDay.length === 0 && (
<div className="w-full h-full flex items-center justify-center text-gray-600 text-sm">
No login data yet
</div>
)}
</div>
</div>
<div className="bg-gray-800 p-6 rounded-xl">
<h3 className="text-sm font-semibold text-gray-400 mb-4">FAILED LOGINS LAST 30 DAYS</h3>
<div className="h-32 flex items-end gap-1">
{failedByDay.slice(-30).map((d, i) => (
<div key={i} className="flex-1 flex flex-col items-center gap-1">
<div
className="w-full bg-rose-500 rounded-t opacity-80"
style={{ height: `${(d.count / maxFailed) * 100}%`, minHeight: d.count > 0 ? "3px" : "0" }}
/>
<div className="text-[7px] text-gray-600">{d.date?.slice(5) || ""}</div>
</div>
))}
{failedByDay.length === 0 && (
<div className="w-full h-full flex items-center justify-center text-gray-600 text-sm">
No failed logins
</div>
)}
</div>
</div>
</div>
{/* Events Table */}
<div className="bg-gray-800 rounded-xl overflow-hidden">
{/* Filter tabs */}
<div className="flex gap-1 p-3 border-b border-gray-700 bg-gray-900">
{[
{ key: "all", label: "All Events" },
{ key: "login", label: "Logins" },
{ key: "login_failed", label: "Failures" },
{ key: "signup", label: "Signups" },
].map(({ key, label }) => (
<button
key={key}
onClick={() => setFilter(key)}
className={`px-3 py-1.5 rounded text-sm font-medium transition-colors ${
filter === key
? "bg-rose-500 text-white"
: "text-gray-400 hover:text-white hover:bg-gray-700"
}`}
>
{label}
</button>
))}
<div className="ml-auto text-xs text-gray-600 flex items-center">
{events.length} events
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-700">
<tr>
<th className="px-4 py-2.5 text-left text-xs font-medium text-gray-400">TIME</th>
<th className="px-4 py-2.5 text-left text-xs font-medium text-gray-400">EVENT</th>
<th className="px-4 py-2.5 text-left text-xs font-medium text-gray-400">USER</th>
<th className="px-4 py-2.5 text-left text-xs font-medium text-gray-400">FAMILY</th>
<th className="px-4 py-2.5 text-left text-xs font-medium text-gray-400">IP</th>
<th className="px-4 py-2.5 text-left text-xs font-medium text-gray-400">DEVICE</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700">
{events.map((event) => {
const config = ACTION_CONFIG[event.action] || { label: event.action, color: "text-gray-400", badge: "default" as const };
return (
<tr key={event.id} className={`hover:bg-gray-750 ${event.action === "login_failed" ? "bg-rose-500/5" : ""}`}>
<td className="px-4 py-2.5 text-gray-500 whitespace-nowrap">
{timeAgo(event.createdAt)}
</td>
<td className="px-4 py-2.5">
<span className={`font-medium ${config.color}`}>{config.label}</span>
</td>
<td className="px-4 py-2.5">
<div className="font-medium truncate max-w-[180px]">{event.email}</div>
{event.userName && event.userName !== event.email && (
<div className="text-xs text-gray-500">{event.userName}</div>
)}
</td>
<td className="px-4 py-2.5 text-gray-400">{event.familyName || "—"}</td>
<td className="px-4 py-2.5 text-gray-500 font-mono text-xs">{event.ipAddress || "—"}</td>
<td className="px-4 py-2.5 text-gray-500 text-xs">{parseDevice(event.userAgent)}</td>
</tr>
);
})}
</tbody>
</table>
{events.length === 0 && (
<div className="p-12 text-center text-gray-600">
No events recorded yet
</div>
)}
</div>
</div>
</div>
);
}

147
src/app/admin/ai/page.tsx Normal file
View 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>
);
}

View file

@ -2,130 +2,334 @@
import { useEffect, useState } from "react";
interface EngagementStats {
totalLogs: number;
totalMedicines: number;
totalVaccinations: number;
totalGrowth: number;
totalMemories: number;
totalChatSessions: number;
logsByDay: { date: string; count: number }[];
topFeatures: { name: string; count: number }[];
interface FeatureAdoption {
name: string;
count: number;
pct: number;
}
interface FamilyEngagement {
id: string;
name: string;
tier: string;
createdAt: string | null;
lastActivity: string | null;
activeStatus: "7d" | "30d" | "inactive" | "never";
feedCount: number;
diaperCount: number;
sleepCount: number;
vaccinationCount: number;
growthCount: number;
memoryCount: number;
chatCount: number;
totalLogs: number;
}
interface EngagementData {
totalFamilies: number;
featureAdoption: FeatureAdoption[];
families: FamilyEngagement[];
activitySummary: { active7d: number; active30d: number; neverActive: number; total: number };
aiUsage: { totalCalls: number; totalTokens: number; totalCostPaise: number; familiesUsingAI: number };
logsByDay: { date: string; count: number }[];
error?: string;
}
type Tab = "overview" | "families" | "ai";
type SortField = "lastActivity" | "totalLogs" | "name";
type ActivityFilter = "all" | "7d" | "30d" | "inactive" | "never";
export default function AdminAnalytics() {
const [stats, setStats] = useState<EngagementStats | null>(null);
const [data, setData] = useState<EngagementData | null>(null);
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState<Tab>("overview");
const [sortField, setSortField] = useState<SortField>("lastActivity");
const [sortAsc, setSortAsc] = useState(false);
const [activityFilter, setActivityFilter] = useState<ActivityFilter>("all");
useEffect(() => {
fetchAnalytics();
fetch("/api/admin/engagement", { credentials: "include" })
.then(r => r.json())
.then(d => {
// Normalize into the full EngagementData shape so a malformed/error
// response can never crash the render (e.g. [...data.families]).
setData({
totalFamilies: Number(d?.totalFamilies) || 0,
featureAdoption: Array.isArray(d?.featureAdoption) ? d.featureAdoption : [],
families: Array.isArray(d?.families) ? d.families : [],
activitySummary: { active7d: 0, active30d: 0, neverActive: 0, total: 0, ...(d?.activitySummary || {}) },
aiUsage: { totalCalls: 0, totalTokens: 0, totalCostPaise: 0, familiesUsingAI: 0, ...(d?.aiUsage || {}) },
logsByDay: Array.isArray(d?.logsByDay) ? d.logsByDay : [],
error: typeof d?.error === "string" ? d.error : undefined,
});
setLoading(false);
})
.catch(() => setLoading(false));
}, []);
const fetchAnalytics = async () => {
try {
const res = await fetch("/api/admin/analytics", { credentials: "include" });
const data = await res.json();
setStats(data);
} catch (err) {
console.error("Failed to fetch analytics:", err);
}
setLoading(false);
if (loading) return <div className="p-6 text-white">Loading...</div>;
if (!data) return <div className="p-6 text-red-400">Failed to load analytics</div>;
const handleSort = (field: SortField) => {
if (sortField === field) setSortAsc(a => !a);
else { setSortField(field); setSortAsc(false); }
};
if (loading || !stats) {
return <div className="p-6 text-white">Loading...</div>;
}
const sortedFamilies = [...data.families]
.filter(f => activityFilter === "all" || f.activeStatus === activityFilter)
.sort((a, b) => {
let diff = 0;
if (sortField === "lastActivity") {
const ta = a.lastActivity ? new Date(a.lastActivity).getTime() : 0;
const tb = b.lastActivity ? new Date(b.lastActivity).getTime() : 0;
diff = tb - ta;
} else if (sortField === "totalLogs") {
diff = b.totalLogs - a.totalLogs;
} else {
diff = a.name.localeCompare(b.name);
}
return sortAsc ? -diff : diff;
});
const maxLog = Math.max(...data.logsByDay.map(d => d.count), 1);
const { aiUsage, activitySummary } = data;
const costINR = (aiUsage.totalCostPaise / 100).toFixed(2);
const costPerFamily = aiUsage.familiesUsingAI > 0
? (aiUsage.totalCostPaise / 100 / aiUsage.familiesUsingAI).toFixed(2)
: "0.00";
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-2xl font-bold">Analytics</h1>
<p className="text-gray-400">Feature usage and engagement</p>
</div>
{/* Usage Overview */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
<StatCard label="Activity Logs" value={stats.totalLogs} icon="📝" />
<StatCard label="Medicines" value={stats.totalMedicines} icon="💊" />
<StatCard label="Vaccinations" value={stats.totalVaccinations} icon="💉" />
<StatCard label="Growth" value={stats.totalGrowth} icon="📏" />
<StatCard label="Memories" value={stats.totalMemories} icon="📸" />
<StatCard label="Chat Sessions" value={stats.totalChatSessions} icon="💬" />
</div>
{/* Activity Chart */}
<div className="bg-gray-800 p-6 rounded-xl">
<h3 className="text-lg font-semibold mb-4">Daily Activity (Last 30 days)</h3>
<div className="h-48 flex items-end gap-1">
{stats.logsByDay.slice(-30).map((d, i) => (
<div key={i} className="flex-1 flex flex-col items-center gap-1">
<div
className="w-full bg-rose-500 rounded-t"
style={{
height: `${Math.min((d.count / Math.max(...stats.logsByDay.map(l => l.count), 1)) * 100, 100)}%`,
minHeight: d.count > 0 ? "4px" : "0"
}}
/>
<div className="text-[8px] text-gray-500">{d.date?.slice(5) || ""}</div>
</div>
<div className="flex justify-between items-start">
<div>
<h1 className="text-2xl font-bold">Analytics</h1>
<p className="text-gray-400">User journey and feature engagement</p>
</div>
<div className="flex gap-1 bg-gray-800 p-1 rounded-lg">
{(["overview", "families", "ai"] as Tab[]).map(t => (
<button
key={t}
onClick={() => setTab(t)}
className={`px-3 py-1.5 rounded-md text-sm font-medium capitalize transition-colors ${tab === t ? "bg-gray-600 text-white" : "text-gray-400 hover:text-white"}`}
>
{t === "ai" ? "AI Usage" : t}
</button>
))}
</div>
{stats.logsByDay.length === 0 && (
<div className="h-48 flex items-center justify-center text-gray-500">
No activity data yet
</div>
)}
</div>
{/* Feature Breakdown */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-gray-800 p-6 rounded-xl">
<h3 className="text-lg font-semibold mb-4">Top Features</h3>
<div className="space-y-3">
{stats.topFeatures.map((feature, i) => (
<div key={i} className="flex justify-between items-center">
<span>{feature.name}</span>
<span className="font-bold text-rose-400">{feature.count}</span>
</div>
{data.error && (
<div className="bg-rose-500/10 border border-rose-500/30 text-rose-300 text-sm p-3 rounded-lg">
<span className="font-semibold">Some analytics queries failed:</span>
<span className="ml-1 font-mono break-words">{data.error}</span>
</div>
)}
{/* Activity Summary Cards — always visible */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<SummaryCard label="Active 7d" value={activitySummary.active7d} color="text-emerald-400" sub={`of ${activitySummary.total}`} />
<SummaryCard label="Active 30d" value={activitySummary.active30d} color="text-blue-400" sub={`of ${activitySummary.total}`} />
<SummaryCard label="Inactive" value={activitySummary.total - activitySummary.active7d - activitySummary.active30d - activitySummary.neverActive} color="text-amber-400" sub="no activity >30d" />
<SummaryCard label="Never Active" value={activitySummary.neverActive} color="text-gray-500" sub="0 logs ever" />
</div>
{tab === "overview" && (
<>
{/* Feature Adoption Funnel */}
<div className="bg-gray-800 p-6 rounded-xl">
<h3 className="text-lg font-semibold mb-5">Feature Adoption</h3>
<div className="space-y-3">
{data.featureAdoption.map(f => (
<div key={f.name} className="flex items-center gap-4">
<div className="w-32 text-sm text-gray-300 shrink-0">{f.name}</div>
<div className="flex-1 bg-gray-700 rounded-full h-5 relative overflow-hidden">
<div
className="h-full bg-rose-500 rounded-full transition-all"
style={{ width: `${f.pct}%` }}
/>
</div>
<div className="w-24 text-right text-sm">
<span className="font-bold text-white">{f.pct}%</span>
<span className="text-gray-500 ml-1">({f.count})</span>
</div>
</div>
))}
{data.featureAdoption.length === 0 && (
<div className="text-gray-500 text-sm">No data yet</div>
)}
</div>
</div>
{/* Daily Activity Chart */}
<div className="bg-gray-800 p-6 rounded-xl">
<h3 className="text-lg font-semibold mb-4">Daily Activity Last 30 Days</h3>
<div className="h-40 flex items-end gap-1">
{data.logsByDay.slice(-30).map((d, i) => (
<div key={i} title={`${d.date}: ${d.count}`} className="flex-1 flex flex-col items-center gap-1 group">
<div
className="w-full bg-rose-500 rounded-t group-hover:bg-rose-400 transition-colors"
style={{
height: `${Math.min((d.count / maxLog) * 100, 100)}%`,
minHeight: d.count > 0 ? "4px" : "0"
}}
/>
<div className="text-[8px] text-gray-500">{d.date?.slice(5) || ""}</div>
</div>
))}
{data.logsByDay.length === 0 && (
<div className="w-full text-center text-gray-500 text-sm self-center">No activity in the last 30 days</div>
)}
</div>
</div>
</>
)}
{tab === "families" && (
<div className="bg-gray-800 rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-gray-700 flex gap-2 flex-wrap">
{(["all", "7d", "30d", "inactive", "never"] as ActivityFilter[]).map(f => (
<button
key={f}
onClick={() => setActivityFilter(f)}
className={`px-3 py-1 rounded-lg text-sm transition-colors ${activityFilter === f ? "bg-rose-500 text-white" : "bg-gray-700 text-gray-300 hover:bg-gray-600"}`}
>
{f === "all" ? "All" : f === "7d" ? "Active 7d" : f === "30d" ? "Active 30d" : f === "inactive" ? "Inactive >30d" : "Never Active"}
{f !== "all" && (
<span className="ml-1.5 text-xs opacity-70">
{data.families.filter(x => x.activeStatus === f).length}
</span>
)}
</button>
))}
{stats.topFeatures.length === 0 && (
<div className="text-gray-500">No data yet</div>
<span className="text-gray-500 text-sm self-center ml-auto">{sortedFamilies.length} families</span>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-700">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium">Family</th>
<th className="px-4 py-3 text-left text-sm font-medium">Tier</th>
<SortHeader label="Last Active" field="lastActivity" current={sortField} asc={sortAsc} onSort={handleSort} />
<SortHeader label="Total Logs" field="totalLogs" current={sortField} asc={sortAsc} onSort={handleSort} />
<th className="px-4 py-3 text-left text-sm font-medium">Features Used</th>
<th className="px-4 py-3 text-left text-sm font-medium">AI Chats</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700">
{sortedFamilies.map(f => (
<tr key={f.id} className="hover:bg-gray-750">
<td className="px-4 py-3">
<div className="font-medium">{f.name}</div>
<div className="text-xs text-gray-500">{f.createdAt?.slice(0, 10)}</div>
</td>
<td className="px-4 py-3">
<span className={`text-xs px-2 py-0.5 rounded font-medium ${f.tier === "pro" ? "bg-rose-500/20 text-rose-400" : "bg-gray-700 text-gray-400"}`}>
{f.tier}
</span>
</td>
<td className="px-4 py-3 text-sm">
{f.lastActivity ? (
<span className={f.activeStatus === "7d" ? "text-emerald-400" : f.activeStatus === "30d" ? "text-blue-400" : "text-gray-500"}>
{f.lastActivity.slice(0, 10)}
</span>
) : (
<span className="text-gray-600">Never</span>
)}
</td>
<td className="px-4 py-3 text-sm font-medium">{f.totalLogs}</td>
<td className="px-4 py-3">
<div className="flex gap-1 flex-wrap">
{f.feedCount > 0 && <FeatureBadge emoji="🍼" label="Feed" />}
{f.sleepCount > 0 && <FeatureBadge emoji="💤" label="Sleep" />}
{f.diaperCount > 0 && <FeatureBadge emoji="🚼" label="Diaper" />}
{f.vaccinationCount > 0 && <FeatureBadge emoji="💉" label="Vacc" />}
{f.growthCount > 0 && <FeatureBadge emoji="📏" label="Growth" />}
{f.memoryCount > 0 && <FeatureBadge emoji="📸" label="Memory" />}
{f.chatCount > 0 && <FeatureBadge emoji="💬" label="AI" />}
{f.totalLogs === 0 && f.memoryCount === 0 && f.chatCount === 0 && (
<span className="text-gray-600 text-xs">None</span>
)}
</div>
</td>
<td className="px-4 py-3 text-sm text-gray-300">{f.chatCount || "—"}</td>
</tr>
))}
</tbody>
</table>
{sortedFamilies.length === 0 && (
<div className="p-8 text-center text-gray-500">No families match this filter</div>
)}
</div>
</div>
)}
<div className="bg-gray-800 p-6 rounded-xl">
<h3 className="text-lg font-semibold mb-4">Engagement Summary</h3>
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-gray-400">Avg Logs per Family</span>
{tab === "ai" && (
<div className="space-y-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<SummaryCard label="API Calls (30d)" value={aiUsage.totalCalls} color="text-blue-400" />
<SummaryCard label="Total Tokens" value={aiUsage.totalTokens.toLocaleString()} color="text-purple-400" />
<SummaryCard label="Cost (30d)" value={`${costINR}`} color="text-amber-400" />
<SummaryCard label="Families Using AI" value={aiUsage.familiesUsingAI} color="text-emerald-400" />
</div>
<div className="bg-gray-800 p-6 rounded-xl space-y-3">
<h3 className="text-lg font-semibold">AI Cost Breakdown</h3>
<div className="flex justify-between text-sm py-2 border-b border-gray-700">
<span className="text-gray-400">Total cost (30 days)</span>
<span className="font-bold">{costINR}</span>
</div>
<div className="flex justify-between text-sm py-2 border-b border-gray-700">
<span className="text-gray-400">Avg cost per AI-using family</span>
<span className="font-bold">{costPerFamily}</span>
</div>
<div className="flex justify-between text-sm py-2 border-b border-gray-700">
<span className="text-gray-400">Total tokens used</span>
<span className="font-bold">{aiUsage.totalTokens.toLocaleString()}</span>
</div>
<div className="flex justify-between text-sm py-2">
<span className="text-gray-400">Families using AI</span>
<span className="font-bold">
{stats.totalLogs > 0 ? (stats.totalLogs / 1).toFixed(1) : "0"}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-400">Avg Children per Family</span>
<span className="font-bold">1.0</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-400">Chat Adoption</span>
<span className="font-bold text-emerald-400">
{stats.totalChatSessions > 0 ? "Active" : "None"}
{aiUsage.familiesUsingAI} / {data.totalFamilies}
<span className="text-gray-500 ml-1 text-xs">
({data.totalFamilies > 0 ? Math.round(aiUsage.familiesUsingAI / data.totalFamilies * 100) : 0}%)
</span>
</span>
</div>
</div>
</div>
</div>
)}
</div>
);
}
function StatCard({ label, value, icon }: { label: string; value: number; icon: string }) {
function SummaryCard({ label, value, color, sub }: { label: string; value: string | number; color: string; sub?: string }) {
return (
<div className="bg-gray-800 p-4 rounded-xl">
<div className="text-2xl mb-1">{icon}</div>
<div className="text-xl font-bold">{value}</div>
<div className="text-xs text-gray-400">{label}</div>
<div className={`text-2xl font-bold ${color}`}>{value}</div>
<div className="text-sm text-gray-300 mt-0.5">{label}</div>
{sub && <div className="text-xs text-gray-500 mt-0.5">{sub}</div>}
</div>
);
}
}
function FeatureBadge({ emoji, label }: { emoji: string; label: string }) {
return (
<span title={label} className="text-xs bg-gray-700 px-1.5 py-0.5 rounded">
{emoji}
</span>
);
}
function SortHeader({ label, field, current, asc, onSort }: {
label: string; field: SortField; current: SortField; asc: boolean; onSort: (f: SortField) => void;
}) {
return (
<th
className="px-4 py-3 text-left text-sm font-medium cursor-pointer hover:text-white select-none"
onClick={() => onSort(field)}
>
{label} {current === field ? (asc ? "↑" : "↓") : "↕"}
</th>
);
}

View 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`;
}

View file

@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { Button, Input } from "@/components/ui";
interface Child {
id: string;
@ -15,11 +16,19 @@ export default function AdminChildren() {
const [children, setChildren] = useState<Child[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [deletingId, setDeletingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
useEffect(() => {
fetchChildren();
}, []);
const showMessage = (msg: string, type: "success" | "error") => {
if (type === "success") { setSuccess(msg); setTimeout(() => setSuccess(null), 3000); }
else { setError(msg); setTimeout(() => setError(null), 5000); }
};
const fetchChildren = async () => {
try {
const res = await fetch("/api/admin/children", { credentials: "include" });
@ -31,9 +40,48 @@ export default function AdminChildren() {
setLoading(false);
};
const handleDeleteChild = async (child: Child) => {
// Fetch counts for the confirmation message
let confirmMsg = `Delete ${child.name}? This will permanently delete all their logs, growth records, vaccinations, and associated data. This cannot be undone.`;
try {
const res = await fetch(`/api/admin/children/${child.id}`, { credentials: "include" });
if (res.ok) {
const counts = await res.json();
const total =
Number(counts.feed_count || 0) +
Number(counts.diaper_count || 0) +
Number(counts.sleep_count || 0);
const parts: string[] = [];
if (total > 0) parts.push(`${total} activity logs`);
if (Number(counts.growth_count || 0) > 0) parts.push(`${counts.growth_count} growth records`);
if (Number(counts.vaccination_count || 0) > 0) parts.push(`${counts.vaccination_count} vaccination records`);
if (parts.length > 0) {
confirmMsg = `Delete ${child.name}? This will permanently delete ${parts.join(", ")}, and all associated data. This cannot be undone.`;
}
}
} catch {}
if (!window.confirm(confirmMsg)) return;
setDeletingId(child.id);
try {
const res = await fetch(`/api/admin/children/${child.id}`, {
method: "DELETE",
credentials: "include",
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Delete failed");
showMessage(`${child.name} and all data deleted`, "success");
setChildren(prev => prev.filter(c => c.id !== child.id));
} catch (err: any) {
showMessage(err.message, "error");
}
setDeletingId(null);
};
const filteredChildren = children.filter((c) =>
c.name.toLowerCase().includes(search.toLowerCase()) ||
c.familyName.toLowerCase().includes(search.toLowerCase())
(c.familyName || "").toLowerCase().includes(search.toLowerCase())
);
const exportCSV = () => {
@ -48,28 +96,35 @@ export default function AdminChildren() {
a.click();
};
if (loading) {
return <div className="p-6 text-white">Loading...</div>;
}
if (loading) return <div className="p-6 text-white">Loading...</div>;
return (
<div className="p-6 space-y-4">
{error && (
<div className="bg-red-500/15 border border-red-500/30 text-red-400 px-4 py-3 rounded-xl text-sm flex justify-between items-center">
<span> {error}</span>
<button onClick={() => setError(null)} className="ml-4 opacity-60 hover:opacity-100"></button>
</div>
)}
{success && (
<div className="bg-emerald-500/15 border border-emerald-500/30 text-emerald-400 px-4 py-3 rounded-xl text-sm">
{success}
</div>
)}
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold">Children</h1>
<p className="text-gray-400">{children.length} total children</p>
</div>
<button onClick={exportCSV} className="px-4 py-2 bg-emerald-600 hover:bg-emerald-700 rounded-lg">
Export CSV
</button>
<Button variant="secondary" onClick={exportCSV}>Export CSV</Button>
</div>
<input
<Input
type="text"
placeholder="Search children..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
/>
<div className="bg-gray-800 rounded-xl overflow-hidden">
@ -80,6 +135,7 @@ export default function AdminChildren() {
<th className="px-4 py-3 text-left text-sm font-medium">Birth Date</th>
<th className="px-4 py-3 text-left text-sm font-medium">Age</th>
<th className="px-4 py-3 text-left text-sm font-medium">Family</th>
<th className="px-4 py-3 text-left text-sm font-medium">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700">
@ -91,6 +147,17 @@ export default function AdminChildren() {
</td>
<td className="px-4 py-3 text-sm text-gray-400">{child.age}</td>
<td className="px-4 py-3">{child.familyName}</td>
<td className="px-4 py-3">
<Button
variant="danger"
size="sm"
loading={deletingId === child.id}
disabled={!!deletingId}
onClick={() => handleDeleteChild(child)}
>
Delete
</Button>
</td>
</tr>
))}
</tbody>
@ -101,4 +168,4 @@ export default function AdminChildren() {
</div>
</div>
);
}
}

View 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 &amp; Crashes</h1>
<p className="text-gray-400">Client &amp; 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`;
}

Some files were not shown because too many files have changed in this diff Show more