Compare commits

..

82 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
128 changed files with 8431 additions and 881 deletions

View file

@ -75,6 +75,16 @@ drizzle/meta/_journal.json # Migration order — MUST update when adding new SQL
2. Add an entry to `drizzle/meta/_journal.json` with the next `idx` and matching `tag` 2. Add an entry to `drizzle/meta/_journal.json` with the next `idx` and matching `tag`
3. Push to git → Dokploy auto-applies on next deploy 3. Push to git → Dokploy auto-applies on next deploy
> ⚠️ **CRITICAL — the `when` timestamp must be greater than every existing entry.**
> Drizzle's migrator only applies a migration when its `when` (folderMillis) is **greater than the
> max `created_at` already in `drizzle.__drizzle_migrations`**. It does NOT diff by hash/idx.
> Migrations `0003``0010` were hand-added with 2025-era `when` values (1748…/1749…) that are
> SMALLER than the 2026 baseline (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.
### Hot-fix: apply migration without waiting for redeploy ### Hot-fix: apply migration without waiting for redeploy
Use the **debug-migration endpoint** (same pattern as the Circles implementation): Use the **debug-migration endpoint** (same pattern as the Circles implementation):
@ -227,6 +237,27 @@ Auth pattern: server component layout calls `verifyAdminSession()` → redirects
--- ---
## Monitoring & Alerting
Alerts go to Telegram (tiaBaby_Bot) via `src/lib/alert.ts``sendAlert(level, title, detail?, opts?)`. Best-effort, never throws.
| Signal | Where | How it fires |
|--------|-------|--------------|
| Site/health up-down | **Uptime Kuma** (external, in Dokploy) | Pings `GET /api/healthz` (public, 200/503). Kuma handles flip detection + recovery |
| Backup fail / empty | `POST /api/cron/backup` | Alerts on exception, and warns if the gzipped dump < 1KB. Success is a silent confirmation |
| Error spikes | `/api/cron/monitor` | Rising-edge: errors in last 1h > 5 **and** > 2× the prior hour. Stateless (no re-alert on flat rate) |
| Internal health | `/api/cron/monitor` | DB unreachable, no migrations, missing integration env |
| Visitor digest | `/api/cron/visitor-summary` | Polls Umami REST API (login → stats/metrics/active), posts digest. `?hours=N` window |
**Cron endpoints** all require the `x-cron-secret: $CRON_SECRET` header (same as backup). Schedule in Dokploy:
- `monitor` — hourly
- `visitor-summary` — daily (or `?hours=1` hourly during launch)
- `backup` — daily (existing)
**Test the Telegram wiring:** `GET /api/cron/monitor?test=1` with the cron-secret header sends a test ping.
---
## Data Storage Rules ## Data Storage Rules
| Data Type | Storage | API | | Data Type | Storage | API |
@ -267,7 +298,13 @@ Set in `.env.local` for development, Dokploy dashboard for production.
| `RESEND_API_KEY` | ✅ | Resend API key for transactional email | | `RESEND_API_KEY` | ✅ | Resend API key for transactional email |
| `EMAIL_FROM` | ✅ | Sender address (e.g. `Tia <tia@manohargupta.com>`) | | `EMAIL_FROM` | ✅ | Sender address (e.g. `Tia <tia@manohargupta.com>`) |
| `NEXT_PUBLIC_APP_URL` | ✅ | Full app URL (e.g. `https://tia.manohargupta.com`) | | `NEXT_PUBLIC_APP_URL` | ✅ | Full app URL (e.g. `https://tia.manohargupta.com`) |
| `CRON_SECRET` | ✅ | Secret for cron backup endpoint | | `CRON_SECRET` | ✅ | Secret for cron endpoints (backup, monitor, visitor-summary) — sent as `x-cron-secret` header |
| `TELEGRAM_BOT_TOKEN` | ✅ | tiaBaby_Bot token from @BotFather — operational alerts |
| `TELEGRAM_CHAT_ID` | ✅ | Chat/group/channel id alerts post to (see `src/lib/alert.ts` header for how to get it) |
| `UMAMI_BASE_URL` | — | Umami instance (default `https://analytics.manohargupta.com`) |
| `UMAMI_USERNAME` | ✅ | Umami login — for the visitor-summary cron |
| `UMAMI_PASSWORD` | ✅ | Umami password |
| `UMAMI_WEBSITE_ID` | — | Umami website id (default Tia's id) |
--- ---

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

@ -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

@ -57,6 +57,41 @@
"when": 1748480400000, "when": 1748480400000,
"tag": "0007_subscription_status", "tag": "0007_subscription_status",
"breakpoints": true "breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1748566800000,
"tag": "0008_pediatrician_name",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1748880000000,
"tag": "0009_notifications",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1749139200000,
"tag": "0010_error_events",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1780000000000,
"tag": "0011_user_phone",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1780100000000,
"tag": "0012_billing",
"breakpoints": true
} }
] ]
} }

View file

@ -22,10 +22,11 @@ const nextConfig: NextConfig = {
{ key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" }, { key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" },
{ key: "Content-Security-Policy", value: { key: "Content-Security-Policy", value:
"default-src 'self'; " + "default-src 'self'; " +
"img-src 'self' data: https://*.r2.cloudflarestorage.com https://*.r2.dev; " + "img-src 'self' data: https://*.r2.cloudflarestorage.com https://*.r2.dev https://*.razorpay.com; " +
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; " + "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://checkout.razorpay.com; " +
"style-src 'self' 'unsafe-inline'; " + "style-src 'self' 'unsafe-inline'; " +
"connect-src 'self' https://llm.manohargupta.com; " + "connect-src 'self' https://llm.manohargupta.com https://analytics.manohargupta.com https://*.razorpay.com https://lumberjack.razorpay.com; " +
"frame-src 'self' https://*.razorpay.com https://api.razorpay.com; " +
"font-src 'self' data:;" "font-src 'self' data:;"
}, },
], ],

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

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

@ -8,6 +8,7 @@ import { api } from "@/lib/api";
import { CalendarView } from "@/components/CalendarView"; import { CalendarView } from "@/components/CalendarView";
import { LogModal, type LogType as ModalLogType, type SmartDefault } from "@/components/LogModal"; import { LogModal, type LogType as ModalLogType, type SmartDefault } from "@/components/LogModal";
import type { Log, LogType } from "@/types"; import type { Log, LogType } from "@/types";
import { dateIST, todayIST, fmtTime, fmtDate, dayLabel } from "@/lib/date-ist";
type ViewMode = "timeline" | "calendar"; type ViewMode = "timeline" | "calendar";
@ -23,13 +24,9 @@ function getIcon(type: LogType) {
return "📝"; return "📝";
} }
// dateStr is now an IST YYYY-MM-DD string (from dateIST())
function formatDayLabel(dateStr: string): string { function formatDayLabel(dateStr: string): string {
const d = new Date(dateStr); return dayLabel(dateStr);
const today = new Date().toDateString();
const yesterday = new Date(Date.now() - 86_400_000).toDateString();
if (d.toDateString() === today) return "Today";
if (d.toDateString() === yesterday) return "Yesterday";
return d.toLocaleDateString("en-IN", { weekday: "short", month: "short", day: "numeric" });
} }
export default function ActivityPage() { export default function ActivityPage() {
@ -94,14 +91,18 @@ export default function ActivityPage() {
const handleEdit = (log: Log) => { const handleEdit = (log: Log) => {
// Store the old log to delete after the new one is saved // Store the old log to delete after the new one is saved
setPendingDeleteId({ id: log.id, type: log.type }); setPendingDeleteId({ id: log.id, type: log.type });
setSmartDefault({ subType: log.subType ?? "", amountMl: log.amount ?? undefined } as SmartDefault); setSmartDefault({
subType: log.subType ?? "",
amountMl: log.amount ?? undefined,
editTime: log.loggedAt, // pre-fills the time picker with the original log time
} as SmartDefault);
setModalType(log.type as ModalLogType); setModalType(log.type as ModalLogType);
setSelectedLog(null); setSelectedLog(null);
}; };
// Today's stats (computed from loaded logs — no extra fetch) // Today's stats (computed from loaded logs — no extra fetch)
const todayStr = new Date().toDateString(); const todayStr = todayIST();
const todayLogs = logs.filter(l => new Date(l.loggedAt).toDateString() === todayStr); const todayLogs = logs.filter(l => dateIST(l.loggedAt) === todayStr);
const todayCounts = { const todayCounts = {
feed: todayLogs.filter(l => l.type === "feed").length, feed: todayLogs.filter(l => l.type === "feed").length,
sleep: todayLogs.filter(l => l.type === "sleep").length, sleep: todayLogs.filter(l => l.type === "sleep").length,
@ -111,21 +112,21 @@ export default function ActivityPage() {
// Last 4 calendar days (computed from loaded logs — no extra fetch) // Last 4 calendar days (computed from loaded logs — no extra fetch)
const last4Days = Array.from({ length: 4 }, (_, i) => { const last4Days = Array.from({ length: 4 }, (_, i) => {
const d = new Date(Date.now() - i * 86_400_000); const d = new Date(Date.now() - i * 86_400_000);
const dateStr = d.toDateString(); const dateStr = dateIST(d);
const dayLogs = logs.filter(l => new Date(l.loggedAt).toDateString() === dateStr); const dLogs = logs.filter(l => dateIST(l.loggedAt) === dateStr);
return { return {
date: dateStr, date: dateStr,
label: i === 0 ? "Today" : i === 1 ? "Yest." : d.toLocaleDateString("en-IN", { weekday: "short" }), label: i === 0 ? "Today" : i === 1 ? "Yest." : fmtDate(d, { weekday: "short" }),
feed: dayLogs.filter(l => l.type === "feed").length, feed: dLogs.filter(l => l.type === "feed").length,
sleep: dayLogs.filter(l => l.type === "sleep").length, sleep: dLogs.filter(l => l.type === "sleep").length,
diaper: dayLogs.filter(l => l.type === "diaper").length, diaper: dLogs.filter(l => l.type === "diaper").length,
}; };
}); });
const filteredLogs = filter === "all" ? logs : logs.filter(l => l.type === filter); const filteredLogs = filter === "all" ? logs : logs.filter(l => l.type === filter);
const groupedByDay = filteredLogs.reduce<DayLogs[]>((acc, log) => { const groupedByDay = filteredLogs.reduce<DayLogs[]>((acc, log) => {
const date = new Date(log.loggedAt).toDateString(); const date = dateIST(log.loggedAt);
const existing = acc.find(d => d.date === date); const existing = acc.find(d => d.date === date);
if (existing) existing.logs.push(log); if (existing) existing.logs.push(log);
else acc.push({ date, logs: [log] }); else acc.push({ date, logs: [log] });
@ -359,7 +360,7 @@ export default function ActivityPage() {
</div> </div>
<div className="flex items-center gap-1.5 flex-shrink-0"> <div className="flex items-center gap-1.5 flex-shrink-0">
<span className="text-sm text-gray-400"> <span className="text-sm text-gray-400">
{new Date(log.loggedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} {fmtTime(log.loggedAt)}
</span> </span>
<span className="text-gray-300 dark:text-gray-600 group-hover:text-rose-400 transition-colors text-base leading-none"></span> <span className="text-gray-300 dark:text-gray-600 group-hover:text-rose-400 transition-colors text-base leading-none"></span>
</div> </div>
@ -374,7 +375,7 @@ export default function ActivityPage() {
</div> </div>
{/* FAB */} {/* FAB */}
<div className="fixed bottom-20 right-5 flex flex-col items-end gap-2 z-40"> <div className="fixed bottom-24 right-5 flex flex-col items-end gap-2 z-50">
{fabOpen && (["feed", "sleep", "diaper"] as ModalLogType[]).map(t => ( {fabOpen && (["feed", "sleep", "diaper"] as ModalLogType[]).map(t => (
<button <button
key={t} key={t}
@ -413,7 +414,7 @@ export default function ActivityPage() {
{[ {[
selectedLog.subType?.replace(/_/g, " "), selectedLog.subType?.replace(/_/g, " "),
selectedLog.amount ? `${selectedLog.amount}ml` : null, selectedLog.amount ? `${selectedLog.amount}ml` : null,
new Date(selectedLog.loggedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }), fmtTime(selectedLog.loggedAt),
].filter(Boolean).join(" · ")} ].filter(Boolean).join(" · ")}
</div> </div>
</div> </div>
@ -473,7 +474,7 @@ export default function ActivityPage() {
const { date: sheetDateStr, type: sheetType } = daySheetDate; const { date: sheetDateStr, type: sheetType } = daySheetDate;
const sheetLogs = logs const sheetLogs = logs
.filter(l => .filter(l =>
new Date(l.loggedAt).toDateString() === sheetDateStr && dateIST(l.loggedAt) === sheetDateStr &&
l.type === sheetType l.type === sheetType
) )
.sort((a, b) => new Date(b.loggedAt).getTime() - new Date(a.loggedAt).getTime()); .sort((a, b) => new Date(b.loggedAt).getTime() - new Date(a.loggedAt).getTime());
@ -538,7 +539,7 @@ export default function ActivityPage() {
</div> </div>
<div className="flex items-center gap-2 flex-shrink-0"> <div className="flex items-center gap-2 flex-shrink-0">
<span className="text-sm text-gray-400"> <span className="text-sm text-gray-400">
{new Date(log.loggedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} {fmtTime(log.loggedAt)}
</span> </span>
<span className="text-gray-300 dark:text-gray-600 group-hover:text-rose-400 transition-colors text-lg"></span> <span className="text-gray-300 dark:text-gray-600 group-hover:text-rose-400 transition-colors text-lg"></span>
</div> </div>

View file

@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
import { useFamily } from "@/app/FamilyProvider"; import { useFamily } from "@/app/FamilyProvider";
import { Button, Input, ConfirmDialog } from "@/components/ui"; import { Button, Input, ConfirmDialog } from "@/components/ui";
import type { AIChat, ChatSession } from "@/types"; import type { AIChat, ChatSession } from "@/types";
import { fmtDate } from "@/lib/date-ist";
export default function AIChatPage() { export default function AIChatPage() {
const { childId } = useFamily(); const { childId } = useFamily();
@ -50,8 +51,12 @@ export default function AIChatPage() {
}); });
const data = await res.json(); const data = await res.json();
if (data.session) { if (data.session) {
setSessions([data.session, ...sessions]); // POST /api/chat returns a session without a `messages` field — normalize it
setCurrentSessionId(data.session.id); // to an empty array so the render path (currentSession.messages.length) never
// hits `undefined.length`, which crashed the page on the first new chat.
const newSession = { ...data.session, messages: [] };
setSessions([newSession, ...sessions]);
setCurrentSessionId(newSession.id);
setSidebarOpen(false); setSidebarOpen(false);
} }
} catch (err) { } catch (err) {
@ -149,7 +154,7 @@ export default function AIChatPage() {
}; };
return ( return (
<div className="flex h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 overflow-hidden"> <div className="flex h-screen pb-16 bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 overflow-hidden">
{/* Sidebar overlay on mobile */} {/* Sidebar overlay on mobile */}
{sidebarOpen && ( {sidebarOpen && (
@ -186,7 +191,7 @@ export default function AIChatPage() {
> >
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate dark:text-gray-100">{session.title}</div> <div className="text-sm font-medium truncate dark:text-gray-100">{session.title}</div>
<div className="text-xs text-gray-400 dark:text-gray-500">{new Date(session.updatedAt).toLocaleDateString()}</div> <div className="text-xs text-gray-400 dark:text-gray-500">{fmtDate(session.updatedAt)}</div>
</div> </div>
<button <button
onClick={(e) => { e.stopPropagation(); setDeleteConfirm(session.id); }} onClick={(e) => { e.stopPropagation(); setDeleteConfirm(session.id); }}
@ -226,12 +231,12 @@ export default function AIChatPage() {
<p className="text-sm text-gray-400 dark:text-gray-500">Tap to see past chats, or just type below to start</p> <p className="text-sm text-gray-400 dark:text-gray-500">Tap to see past chats, or just type below to start</p>
<Button onClick={createNewSession} className="mt-2 rounded-full">New Chat</Button> <Button onClick={createNewSession} className="mt-2 rounded-full">New Chat</Button>
</div> </div>
) : currentSession.messages.length === 0 ? ( ) : (currentSession.messages?.length ?? 0) === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-center gap-2"> <div className="h-full flex flex-col items-center justify-center text-center gap-2">
<p className="text-gray-400 dark:text-gray-500 text-sm">Type a question below to get started</p> <p className="text-gray-400 dark:text-gray-500 text-sm">Type a question below to get started</p>
</div> </div>
) : ( ) : (
currentSession.messages.map((msg, i) => ( (currentSession.messages ?? []).map((msg, i) => (
<div <div
key={i} key={i}
className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`} className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
@ -262,15 +267,19 @@ export default function AIChatPage() {
{/* Input */} {/* Input */}
<div className="p-4 border-t bg-white dark:bg-gray-800 flex-shrink-0"> <div className="p-4 border-t bg-white dark:bg-gray-800 flex-shrink-0">
<div className="flex gap-2"> <div className="flex items-center gap-2">
<Input {/* Input renders its own wrapper div, so flex-1 must go on a wrapper
value={input} here putting it on <Input> only hits the inner <input> (already
onChange={e => setInput(e.target.value)} w-full) and the wrapper stays content-width, leaving it narrow. */}
onKeyDown={e => e.key === "Enter" && !e.shiftKey && handleSend()} <div className="flex-1">
placeholder="Ask about your baby..." <Input
disabled={loading} value={input}
className="flex-1" onChange={e => setInput(e.target.value)}
/> onKeyDown={e => e.key === "Enter" && !e.shiftKey && handleSend()}
placeholder="Ask about your baby..."
disabled={loading}
/>
</div>
<Button onClick={handleSend} disabled={loading || !input.trim()} loading={loading}> <Button onClick={handleSend} disabled={loading || !input.trim()} loading={loading}>
Send Send
</Button> </Button>

View file

@ -615,7 +615,7 @@ export default function CircleFeedPage({ params }: { params: Promise<{ id: strin
{/* FAB — create post */} {/* FAB — create post */}
<button <button
onClick={() => setShowCreate(true)} onClick={() => setShowCreate(true)}
className="fixed bottom-20 right-5 w-14 h-14 bg-rose-400 text-white rounded-full shadow-lg flex items-center justify-center text-2xl z-40" className="fixed bottom-24 right-5 w-14 h-14 bg-rose-400 text-white rounded-full shadow-lg flex items-center justify-center text-2xl z-50"
>+</button> >+</button>
{showCreate && circle && familyId && ( {showCreate && circle && familyId && (

48
src/app/(app)/error.tsx Normal file
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

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

View file

@ -6,6 +6,8 @@ import { useFamily } from "@/app/FamilyProvider";
import { Button, Card, Input, ConfirmDialog } from "@/components/ui"; import { Button, Card, Input, ConfirmDialog } from "@/components/ui";
import { WHO_BOY_WEIGHT, WHO_GIRL_WEIGHT, getAgeInMonthsFromBirth, getPercentile } from "@/lib/growth-standards"; import { WHO_BOY_WEIGHT, WHO_GIRL_WEIGHT, getAgeInMonthsFromBirth, getPercentile } from "@/lib/growth-standards";
import { formatAge } from "@/lib/formatting"; import { formatAge } from "@/lib/formatting";
import { fmtDate } from "@/lib/date-ist";
import { trackGrowthLogged } from "@/lib/analytics";
import type { GrowthRecord, Goal } from "@/types"; import type { GrowthRecord, Goal } from "@/types";
import { import {
Chart as ChartJS, Chart as ChartJS,
@ -115,6 +117,7 @@ export default function GrowthPage() {
setSaveError(err.error || "Failed to save"); setSaveError(err.error || "Failed to save");
return; return;
} }
trackGrowthLogged();
resetForm(); resetForm();
fetchGrowthData(); fetchGrowthData();
} catch (e) { } catch (e) {
@ -190,7 +193,7 @@ export default function GrowthPage() {
const headers = ["Date", "Weight (kg)", "Height (cm)", "Head (cm)", "Notes"]; const headers = ["Date", "Weight (kg)", "Height (cm)", "Head (cm)", "Notes"];
const rows = growthData.map(r => [ const rows = growthData.map(r => [
new Date(r.measured_at).toLocaleDateString(), fmtDate(r.measured_at),
r.weight_kg || "", r.weight_kg || "",
r.height_cm || "", r.height_cm || "",
r.head_circumference_cm || "", r.head_circumference_cm || "",
@ -423,7 +426,7 @@ export default function GrowthPage() {
<div> <div>
<div className="font-semibold text-rose-600 dark:text-rose-300">Latest Reading</div> <div className="font-semibold text-rose-600 dark:text-rose-300">Latest Reading</div>
<div className="text-sm text-gray-500"> <div className="text-sm text-gray-500">
{new Date(latest.measured_at).toLocaleDateString()} ({child ? formatAge(child.birthDate, latest.measured_at) : ""}) {fmtDate(latest.measured_at)} ({child ? formatAge(child.birthDate, latest.measured_at) : ""})
</div> </div>
</div> </div>
{velocity && ( {velocity && (
@ -637,7 +640,7 @@ export default function GrowthPage() {
<div key={i} className="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg hover:shadow-md transition-shadow flex justify-between items-center"> <div key={i} className="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg hover:shadow-md transition-shadow flex justify-between items-center">
<div> <div>
<div className="text-sm text-gray-500"> <div className="text-sm text-gray-500">
{new Date(record.measured_at).toLocaleDateString()} ({child ? formatAge(child.birthDate, record.measured_at) : ""}) {fmtDate(record.measured_at)} ({child ? formatAge(child.birthDate, record.measured_at) : ""})
</div> </div>
<div className="flex gap-2 mt-1"> <div className="flex gap-2 mt-1">
{record.weight_kg && ( {record.weight_kg && (

View file

@ -8,8 +8,21 @@ import { useStageCheck, type BabyStage } from "@/hooks/useStageCheck";
import { LogModal, type LogType } from "@/components/LogModal"; import { LogModal, type LogType } from "@/components/LogModal";
import { getOfflineQueue, processOfflineQueue } from "@/lib/offline-queue"; import { getOfflineQueue, processOfflineQueue } from "@/lib/offline-queue";
import { calculateAge, formatTimeAgo } from "@/lib/formatting"; import { calculateAge, formatTimeAgo } from "@/lib/formatting";
import { hourIST, isTodayIST, fmtTime } from "@/lib/date-ist";
import type { Log, AIChat, ChatSession } from "@/types"; import type { Log, AIChat, ChatSession } from "@/types";
/** Some Android cameras return file.type = "" — detect from extension as fallback. */
function resolveContentType(file: File): string {
if (file.type && file.type !== "application/octet-stream") return file.type;
const ext = file.name.split(".").pop()?.toLowerCase() ?? "";
const map: Record<string, string> = {
jpg: "image/jpeg", jpeg: "image/jpeg",
png: "image/png", webp: "image/webp",
heic: "image/heic", heif: "image/heic",
};
return map[ext] || "image/jpeg";
}
async function getSessions(cid: string): Promise<ChatSession[]> { async function getSessions(cid: string): Promise<ChatSession[]> {
try { try {
const res = await fetch(`/api/chat?childId=${cid}`); const res = await fetch(`/api/chat?childId=${cid}`);
@ -32,15 +45,14 @@ async function createSession(cid: string): Promise<ChatSession | null> {
function getGreeting() { function getGreeting() {
const hour = new Date().getHours(); const hour = hourIST();
if (hour < 12) return "Good morning"; if (hour < 12) return "Good morning";
if (hour < 18) return "Good afternoon"; if (hour < 18) return "Good afternoon";
return "Good evening"; return "Good evening";
} }
function TodaySummary({ logs }: { logs: Log[] }) { function TodaySummary({ logs }: { logs: Log[] }) {
const todayStr = new Date().toDateString(); const today = logs.filter(l => isTodayIST(l.loggedAt));
const today = logs.filter(l => new Date(l.loggedAt).toDateString() === todayStr);
const counts = { const counts = {
feed: today.filter(l => l.type === "feed").length, feed: today.filter(l => l.type === "feed").length,
diaper: today.filter(l => l.type === "diaper").length, diaper: today.filter(l => l.type === "diaper").length,
@ -87,12 +99,14 @@ export default function HomePage() {
const [recentLogs, setRecentLogs] = useState<Log[]>([]); const [recentLogs, setRecentLogs] = useState<Log[]>([]);
const [logsLoading, setLogsLoading] = useState(true); const [logsLoading, setLogsLoading] = useState(true);
const [vaccineReminders, setVaccineReminders] = useState<any[]>([]); const [vaccineReminders, setVaccineReminders] = useState<any[]>([]);
const [showPhoneNudge, setShowPhoneNudge] = useState(false);
const [aiChips, setAiChips] = useState<string[]>([]); const [aiChips, setAiChips] = useState<string[]>([]);
const [uploadingPhoto, setUploadingPhoto] = useState(false); const [uploadingPhoto, setUploadingPhoto] = useState(false);
const [showPhotoMenu, setShowPhotoMenu] = useState(false); const [photoError, setPhotoError] = useState(false);
const [showPhotoMenu, setShowPhotoMenu] = useState(false);
const photoInputRef = useRef<HTMLInputElement>(null); const photoInputRef = useRef<HTMLInputElement>(null);
const { theme, toggle: toggleTheme } = useTheme(); const { theme, toggle: toggleTheme } = useTheme();
const { childId, child, familyId, loading, updateChildImage } = useFamily(); const { childId, child, familyId, loading, tier, updateChildImage } = useFamily();
const stage = useStageCheck(child?.birthDate ?? null); const stage = useStageCheck(child?.birthDate ?? null);
useEffect(() => { useEffect(() => {
@ -112,11 +126,33 @@ export default function HomePage() {
window.addEventListener("online", handleOnline); window.addEventListener("online", handleOnline);
fetch(`/api/notifications?childId=${childId}`) fetch(`/api/notifications?childId=${childId}`)
.then(res => res.json()) .then(res => res.json())
.then(data => setVaccineReminders(data.notifications || [])) // Only vaccine notifications belong in the "Vaccine Reminder" banner —
// the API also returns log/memory/garment nudges.
.then(data => setVaccineReminders(
(data.notifications || []).filter((n: { type?: string }) => n.type?.startsWith("vaccine_"))
))
.catch(console.error); .catch(console.error);
return () => window.removeEventListener("online", handleOnline); return () => window.removeEventListener("online", handleOnline);
}, [childId]); }, [childId]);
// One-time nudge for existing users who have no phone number on file.
// Dismissable; remembered in localStorage so it never nags repeatedly.
useEffect(() => {
if (!childId) return;
if (localStorage.getItem("tia_phone_nudge_dismissed") === "1") return;
fetch("/api/auth/profile")
.then(r => r.json())
.then(data => {
if (data.user && !data.user.phone) setShowPhoneNudge(true);
})
.catch(() => {});
}, [childId]);
const dismissPhoneNudge = () => {
setShowPhoneNudge(false);
localStorage.setItem("tia_phone_nudge_dismissed", "1");
};
const fetchRecentLogs = async () => { const fetchRecentLogs = async () => {
if (!childId) return; if (!childId) return;
try { try {
@ -187,41 +223,38 @@ export default function HomePage() {
const handlePhotoChange = async (e: React.ChangeEvent<HTMLInputElement>) => { const handlePhotoChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file || !childId) return; if (!file || !childId) return;
const contentType = resolveContentType(file);
setUploadingPhoto(true); setUploadingPhoto(true);
setShowPhotoMenu(false);
try { try {
// 1. Get R2 key + public URL from server const initData = await fetch(`/api/children/${childId}`, {
const initRes = await fetch(`/api/children/${childId}`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contentType: file.type, filename: file.name }), body: JSON.stringify({ contentType, filename: file.name }),
}); }).then(r => r.json());
if (!initRes.ok) throw new Error("Failed to get upload URL"); if (!initData.key) throw new Error(initData.error || "Upload failed");
const { key, publicUrl } = await initRes.json(); const { key, publicUrl } = initData;
// 2. Upload via server proxy — avoids CORS on direct R2 PUT const putRes = await fetch(`/api/upload?${new URLSearchParams({ key, contentType })}`, {
const putParams = new URLSearchParams({ key, contentType: file.type }); method: "PUT", body: file, headers: { "Content-Type": contentType },
const putRes = await fetch(`/api/upload?${putParams}`, {
method: "PUT",
body: file,
headers: { "Content-Type": file.type },
}); });
if (!putRes.ok) throw new Error("Upload failed"); if (!putRes.ok) throw new Error(`Upload failed (${putRes.status})`);
// 3. Save URL to DB
await fetch(`/api/children/${childId}`, { await fetch(`/api/children/${childId}`, {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ imageUrl: publicUrl }), body: JSON.stringify({ imageUrl: publicUrl }),
}); });
// 4. Update in-memory state immediately — no full reload needed setPhotoError(false);
updateChildImage(childId, publicUrl); updateChildImage(childId, `/api/img?key=${encodeURIComponent(key)}`);
} catch (err) { } catch (err) {
console.error("Photo upload failed:", err); console.error("Photo upload failed:", err);
alert("Photo upload failed. Please try again."); } finally {
setUploadingPhoto(false);
if (photoInputRef.current) photoInputRef.current.value = "";
} }
setUploadingPhoto(false);
if (photoInputRef.current) photoInputRef.current.value = "";
}; };
const handleRemovePhoto = async () => { const handleRemovePhoto = async () => {
@ -293,9 +326,29 @@ export default function HomePage() {
return ( return (
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 pb-24"> <div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 pb-24">
<div className="p-4 flex justify-between items-center">
<div className="p-4 flex justify-between items-center">
<button className="p-2"><Link href="/menu"><svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" /></svg></Link></button> <button className="p-2"><Link href="/menu"><svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" /></svg></Link></button>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{tier === "pro" ? (
<Link
href="/settings#upgrade"
title="Tia Premium"
className="p-2 text-amber-500 hover:text-amber-600"
>
</Link>
) : (
<Link
href="/settings#upgrade"
title="Upgrade to Premium"
className="relative p-2 text-gray-400 hover:text-rose-500 transition-colors"
>
👑
{/* Pulsing dot hints there's something to explore — non-intrusive */}
<span className="absolute top-1 right-1 w-2 h-2 bg-rose-500 rounded-full animate-pulse" />
</Link>
)}
<Link href="/medical/emergency" className="p-2 text-red-500 hover:text-red-600" title="Emergency Guide">🆘</Link> <Link href="/medical/emergency" className="p-2 text-red-500 hover:text-red-600" title="Emergency Guide">🆘</Link>
<button onClick={toggleDarkMode} className="p-2">{theme === "dark" ? "☀️" : "🌙"}</button> <button onClick={toggleDarkMode} className="p-2">{theme === "dark" ? "☀️" : "🌙"}</button>
</div> </div>
@ -318,15 +371,18 @@ export default function HomePage() {
className="relative group" className="relative group"
title={child?.imageUrl ? "Photo options" : "Add photo"} title={child?.imageUrl ? "Photo options" : "Add photo"}
> >
{child?.imageUrl {child?.imageUrl && !photoError
? <img src={child.imageUrl} alt={child?.name} className="w-16 h-16 rounded-full object-cover" /> ? <img src={child.imageUrl} alt={child?.name} className="w-16 h-16 rounded-full object-cover" onError={() => setPhotoError(true)} />
: <div className="w-16 h-16 bg-rose-100 dark:bg-rose-900 rounded-full flex items-center justify-center text-2xl">👶</div> : <div className="w-16 h-16 bg-rose-100 dark:bg-rose-900 rounded-full flex items-center justify-center text-2xl">👶</div>
} }
{/* Camera overlay */} {/* Camera / upload overlay */}
<div className={`absolute inset-0 rounded-full flex items-center justify-center transition-opacity ${ <div className={`absolute inset-0 rounded-full flex items-center justify-center transition-opacity ${
uploadingPhoto ? "bg-black/40 opacity-100" : "bg-black/0 opacity-0 group-hover:opacity-100 group-active:opacity-100" uploadingPhoto ? "bg-black/40 opacity-100" : "bg-black/0 opacity-0 group-hover:opacity-100 group-active:opacity-100"
}`}> }`}>
<span className="text-white text-lg">{uploadingPhoto ? "⏳" : "📷"}</span> {uploadingPhoto
? <div className="w-6 h-6 border-2 border-white border-t-transparent rounded-full animate-spin" />
: <span className="text-white text-lg">📷</span>
}
</div> </div>
</button> </button>
@ -387,9 +443,7 @@ export default function HomePage() {
<div> <div>
<div className="font-semibold text-red-700">💊 Vaccine Reminder</div> <div className="font-semibold text-red-700">💊 Vaccine Reminder</div>
<div className="text-sm text-red-600"> <div className="text-sm text-red-600">
{vaccineReminders[0].status === "overdue" {vaccineReminders[0].message}
? `${vaccineReminders[0].message}`
: `${vaccineReminders[0].vaccineName} due today`}
</div> </div>
</div> </div>
<span className="text-red-400"></span> <span className="text-red-400"></span>
@ -397,10 +451,33 @@ export default function HomePage() {
</div> </div>
)} )}
{showPhoneNudge && (
<div className="mx-4 mb-4 bg-rose-50 dark:bg-rose-900/20 border border-rose-200 dark:border-rose-800/40 px-4 py-3 rounded-xl flex items-center gap-3">
<span className="text-xl flex-shrink-0">📱</span>
<div className="flex-1 min-w-0">
<div className="font-semibold text-rose-700 dark:text-rose-300 text-sm">Add your phone number</div>
<div className="text-xs text-rose-600 dark:text-rose-400">Get important reminders &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} /> <TodaySummary logs={recentLogs} />
{stage && (() => { {stage && (() => {
const h = new Date().getHours(); const h = hourIST();
type Suggestion = { label: string; type: "feed" | "sleep" | "diaper" }; type Suggestion = { label: string; type: "feed" | "sleep" | "diaper" };
const matrix: Record<BabyStage, Suggestion> = const matrix: Record<BabyStage, Suggestion> =
h >= 5 && h < 9 ? { newborn: { label: "Feed", type: "feed" }, infant: { label: "Feed", type: "feed" }, sitter: { label: "Feed", type: "feed" }, crawler: { label: "Feed", type: "feed" }, toddler: { label: "Feed", type: "feed" }, walker: { label: "Feed", type: "feed" } } : h >= 5 && h < 9 ? { newborn: { label: "Feed", type: "feed" }, infant: { label: "Feed", type: "feed" }, sitter: { label: "Feed", type: "feed" }, crawler: { label: "Feed", type: "feed" }, toddler: { label: "Feed", type: "feed" }, walker: { label: "Feed", type: "feed" } } :
@ -465,7 +542,7 @@ export default function HomePage() {
<div key={log.id} className="flex items-center justify-between p-3 bg-white dark:bg-gray-800 rounded-xl"> <div key={log.id} className="flex items-center justify-between p-3 bg-white dark:bg-gray-800 rounded-xl">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-xl">{log.type === "feed" && "🍼"}{log.type === "sleep" && "😴"}{log.type === "diaper" && "🚼"}</span> <span className="text-xl">{log.type === "feed" && "🍼"}{log.type === "sleep" && "😴"}{log.type === "diaper" && "🚼"}</span>
<div><div className="font-medium capitalize">{log.type}</div><div className="text-xs text-gray-500 dark:text-gray-400">{new Date(log.loggedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</div></div> <div><div className="font-medium capitalize">{log.type}</div><div className="text-xs text-gray-500 dark:text-gray-400">{fmtTime(log.loggedAt)}</div></div>
</div> </div>
{log.amount && <span className="text-sm text-gray-500 dark:text-gray-400">{log.amount}ml</span>} {log.amount && <span className="text-sm text-gray-500 dark:text-gray-400">{log.amount}ml</span>}
</div> </div>

View file

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

View file

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

View file

@ -6,6 +6,20 @@ import { useFamily } from "@/app/FamilyProvider";
import { Button, ConfirmDialog, Modal } from "@/components/ui"; import { Button, ConfirmDialog, Modal } from "@/components/ui";
import { StorageMeter, StorageQuotaBanner } from "@/components/StorageMeter"; import { StorageMeter, StorageQuotaBanner } from "@/components/StorageMeter";
import { formatBytes } from "@/lib/format-bytes"; import { formatBytes } from "@/lib/format-bytes";
import { trackMemoryAdded } from "@/lib/analytics";
/** Some Android cameras return file.type = "" — detect from extension as fallback. */
function resolveContentType(file: File): string {
if (file.type && file.type !== "application/octet-stream") return file.type;
const ext = file.name.split(".").pop()?.toLowerCase() ?? "";
const map: Record<string, string> = {
jpg: "image/jpeg", jpeg: "image/jpeg",
png: "image/png", webp: "image/webp",
heic: "image/heic", heif: "image/heic",
gif: "image/gif",
};
return map[ext] || "image/jpeg";
}
const PRESET_FOLDERS = [ const PRESET_FOLDERS = [
{ id: "", label: "All", emoji: "🌟" }, { id: "", label: "All", emoji: "🌟" },
@ -46,6 +60,7 @@ export default function MemoriesPage() {
const [nextCursor, setNextCursor] = useState<string | null>(null); const [nextCursor, setNextCursor] = useState<string | null>(null);
const [selected, setSelected] = useState<Memory | null>(null); const [selected, setSelected] = useState<Memory | null>(null);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [deleteTarget, setDeleteTarget] = useState<string | null>(null); const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const [loadingMore, setLoadingMore] = useState(false); const [loadingMore, setLoadingMore] = useState(false);
const [activeFolder, setActiveFolder] = useState(""); const [activeFolder, setActiveFolder] = useState("");
@ -143,71 +158,74 @@ export default function MemoriesPage() {
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file || !childId) return; if (!file || !childId) return;
const contentType = resolveContentType(file);
setUploading(true); setUploading(true);
setUploadError(null);
try { try {
// Step 1: Get presigned URL (quota gate runs here server-side) // Step 1: reserve slot + quota gate
const initRes = await fetch("/api/upload", { const initRes = await fetch("/api/upload", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename: file.name, contentType: file.type, childId, sizeBytes: file.size }), body: JSON.stringify({ filename: file.name, contentType, childId, sizeBytes: file.size }),
}); });
const initData = await initRes.json(); const initData = await initRes.json();
if (!initRes.ok) { if (!initRes.ok) {
if (initRes.status === 402 && initData.reason === "storage_quota_exceeded") { if (initRes.status === 402 && initData.reason === "storage_quota_exceeded") {
setQuotaExceeded(true); setQuotaExceeded(true);
setQuotaBanner({ usedBytes: initData.usedBytes, limitBytes: initData.limitBytes }); setQuotaBanner({ usedBytes: initData.usedBytes ?? 0, limitBytes: initData.limitBytes ?? 0 });
setUploadError("Storage quota exceeded");
} else { } else {
alert("Error: " + (initData.error ?? "Upload failed")); setUploadError(initData.error || `Upload failed (${initRes.status})`);
} }
return; return;
} }
const { key, memoryId } = initData;
const { key, memoryId, publicUrl } = initData; // Step 2: binary upload via proxy
const putRes = await fetch(`/api/upload?${new URLSearchParams({ key, contentType })}`, {
// Step 2: Upload file to R2 via proxy method: "PUT", body: file, headers: { "Content-Type": contentType },
const putParams = new URLSearchParams({ key, contentType: file.type });
const putRes = await fetch(`/api/upload?${putParams}`, {
method: "PUT", body: file, headers: { "Content-Type": file.type },
}); });
if (!putRes.ok) { if (!putRes.ok) { setUploadError(`Upload failed (${putRes.status})`); return; }
const putErr = await putRes.json().catch(() => ({})) as { error?: string };
throw new Error(putErr.error ?? `Upload failed (${putRes.status})`);
}
// Step 3: Assign folder if chosen // Assign folder if chosen
if (pendingFolder) { if (pendingFolder) {
await fetch(`/api/memories/${memoryId}`, { await fetch(`/api/memories/${memoryId}`, {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ description: pendingFolder }), body: JSON.stringify({ description: pendingFolder }),
}); }).catch(() => {});
} }
// Step 4: Confirm — server reconciles actual R2 size against quota // Step 3: confirm
const confirmRes = await fetch(`/api/memories/${memoryId}/confirm`, { method: "POST" }); const confirmRes = await fetch(`/api/memories/${memoryId}/confirm`, { method: "POST" });
if (!confirmRes.ok && confirmRes.status === 402) { if (!confirmRes.ok) {
// Actual file size exceeded quota; server deleted the R2 object and const cd = await confirmRes.json().catch(() => ({})) as { reason?: string; usedBytes?: number; limitBytes?: number; error?: string };
// marked the row failed. Refresh quota state and skip optimistic update. if (confirmRes.status === 402) {
setQuotaExceeded(true); setQuotaExceeded(true);
fetch("/api/storage-usage") fetch("/api/storage-usage").then(r => r.json())
.then(r => r.json()) .then(d => setQuotaBanner({ usedBytes: d.usedBytes, limitBytes: d.limitBytes }))
.then(d => setQuotaBanner({ usedBytes: d.usedBytes, limitBytes: d.limitBytes })) .catch(() => {});
.catch(() => {}); setUploadError("Storage quota exceeded");
} else {
setUploadError(cd.error || `Failed to confirm upload (${confirmRes.status})`);
}
return; return;
} }
// Step 5: Optimistic update in UI trackMemoryAdded();
const optimistic: Memory = { // Optimistic grid update
id: memoryId, key, url: publicUrl, thumbnailUrl: null, const proxyUrl = `/api/img?key=${encodeURIComponent(key)}`;
sizeBytes: file.size, mimeType: file.type, title: null, setMemories(prev => [{
id: memoryId, key, url: proxyUrl, thumbnailUrl: null,
sizeBytes: file.size, mimeType: contentType, title: null,
description: pendingFolder || null, takenAt: null, description: pendingFolder || null, takenAt: null,
visionCaption: null, visionTags: null, isPrivate: false, visionCaption: null, visionTags: null, isPrivate: false,
processingStatus: "processing", createdAt: new Date().toISOString(), processingStatus: "processing", createdAt: new Date().toISOString(),
}; }, ...prev]);
setMemories(prev => [optimistic, ...prev]);
} catch (err) { } catch (err) {
alert("Upload failed: " + err); setUploadError(err instanceof Error ? err.message : "Upload failed");
} finally { } finally {
setUploading(false); setUploading(false);
setPendingFolder(""); setPendingFolder("");
@ -351,8 +369,16 @@ export default function MemoriesPage() {
</div> </div>
</div> </div>
{/* Upload error banner */}
{uploadError && !uploading && (
<div className="fixed top-4 inset-x-4 z-50 bg-red-500 text-white rounded-xl px-4 py-3 flex items-center justify-between shadow-lg">
<span className="text-sm">{uploadError}</span>
<button onClick={() => setUploadError(null)} className="ml-3 text-white/80 hover:text-white text-lg leading-none"></button>
</div>
)}
{/* Upload FAB — disabled when storage quota is exceeded */} {/* Upload FAB — disabled when storage quota is exceeded */}
<div className="fixed bottom-6 right-6 z-20"> <div className="fixed bottom-24 right-6 z-50">
<button <button
onClick={quotaExceeded ? undefined : handleUploadClick} onClick={quotaExceeded ? undefined : handleUploadClick}
disabled={uploading || quotaExceeded} disabled={uploading || quotaExceeded}
@ -490,9 +516,9 @@ function MemoryTile({ memory, folder, onClick }: { memory: Memory; folder: Folde
<span className="text-white text-lg drop-shadow"></span> <span className="text-white text-lg drop-shadow"></span>
</div> </div>
{memory.processingStatus === "processing" && ( {memory.processingStatus === "processing" && (
<div className="absolute inset-0 bg-black/40 flex items-center justify-center rounded-xl"> <span className="absolute top-1 left-1 text-[8px] bg-black/60 text-white px-1.5 py-0.5 rounded-full leading-tight">
<span className="text-white text-[9px] bg-black/50 px-1.5 py-0.5 rounded-full">Processing</span>
</div> </span>
)} )}
{memory.isPrivate && <span className="absolute top-1 right-1 text-[9px]">🔒</span>} {memory.isPrivate && <span className="absolute top-1 right-1 text-[9px]">🔒</span>}
</button> </button>

View file

@ -9,7 +9,7 @@ export default function MenuPage() {
const [signingOut, setSigningOut] = useState(false); const [signingOut, setSigningOut] = useState(false);
const menuItems = [ const menuItems = [
{ icon: "🏡", label: "Home", href: "/" }, { icon: "🏡", label: "Home", href: "/home" },
{ icon: "📝", label: "Activity", href: "/activity" }, { icon: "📝", label: "Activity", href: "/activity" },
{ icon: "🌿", label: "Growth", href: "/growth" }, { icon: "🌿", label: "Growth", href: "/growth" },
{ icon: "🩺", label: "Medical", href: "/medical" }, { icon: "🩺", label: "Medical", href: "/medical" },

View file

@ -4,6 +4,7 @@ import { useFamily } from "@/app/FamilyProvider";
import { useStageCheck } from "@/hooks/useStageCheck"; import { useStageCheck } from "@/hooks/useStageCheck";
import { MILESTONES, type MilestoneDef } from "@/lib/milestones"; import { MILESTONES, type MilestoneDef } from "@/lib/milestones";
import { Button, Input } from "@/components/ui"; import { Button, Input } from "@/components/ui";
import { fmtDate } from "@/lib/date-ist";
type Category = "all" | "social" | "motor" | "language" | "cognitive"; type Category = "all" | "social" | "motor" | "language" | "cognitive";
type Filter = "all" | "achieved" | "upcoming"; type Filter = "all" | "achieved" | "upcoming";
@ -194,7 +195,7 @@ export default function MilestonesPage() {
<p className="text-xs text-gray-400 mt-1">{m.ageRangeLabel}</p> <p className="text-xs text-gray-400 mt-1">{m.ageRangeLabel}</p>
{m.achieved && m.achievedAt && ( {m.achieved && m.achievedAt && (
<p className="text-xs text-green-600 dark:text-green-400 mt-1"> <p className="text-xs text-green-600 dark:text-green-400 mt-1">
{new Date(m.achievedAt).toLocaleDateString("en-IN", { day: "numeric", month: "short" })} {fmtDate(m.achievedAt, { day: "numeric", month: "short" })}
</p> </p>
)} )}
<span className={`inline-block text-xs px-1.5 py-0.5 rounded-full mt-2 ${CATEGORY_COLORS[m.category]}`}> <span className={`inline-block text-xs px-1.5 py-0.5 rounded-full mt-2 ${CATEGORY_COLORS[m.category]}`}>

View file

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

View file

@ -55,6 +55,7 @@ export default function OnboardingPage() {
const [form, setForm] = useState({ const [form, setForm] = useState({
familyName: "", familyName: "",
memberName: "", memberName: "",
phone: "",
childName: "", childName: "",
birthDate: "", birthDate: "",
sex: "" as "male" | "female" | "other", sex: "" as "male" | "female" | "other",
@ -167,6 +168,10 @@ export default function OnboardingPage() {
<Input label="Your Name" type="text" value={form.memberName} <Input label="Your Name" type="text" value={form.memberName}
onChange={(e) => setForm({ ...form, memberName: e.target.value })} onChange={(e) => setForm({ ...form, memberName: e.target.value })}
placeholder="Mama" /> placeholder="Mama" />
<Input label="Phone Number (optional)" type="tel" value={form.phone}
onChange={(e) => setForm({ ...form, phone: e.target.value })}
placeholder="+91 98765 43210" />
<p className="text-xs text-gray-400 -mt-2">For important reminders &amp; updates about your baby</p>
<Button fullWidth size="lg" onClick={() => setStep(2)} disabled={!form.familyName || !form.memberName}> <Button fullWidth size="lg" onClick={() => setStep(2)} disabled={!form.familyName || !form.memberName}>
Next Next
</Button> </Button>

View file

@ -3,27 +3,40 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
/** Some Android cameras return file.type = "" — detect from extension as fallback. */
function resolveContentType(file: File): string {
if (file.type && file.type !== "application/octet-stream") return file.type;
const ext = file.name.split(".").pop()?.toLowerCase() ?? "";
const map: Record<string, string> = {
jpg: "image/jpeg", jpeg: "image/jpeg",
png: "image/png", webp: "image/webp",
heic: "image/heic", heif: "image/heic",
};
return map[ext] || "image/jpeg";
}
export default function ProfilePage() { export default function ProfilePage() {
const router = useRouter(); const router = useRouter();
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
const [userId, setUserId] = useState<string>("");
const [name, setName] = useState(""); const [name, setName] = useState("");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [phone, setPhone] = useState("");
const [avatarUrl, setAvatarUrl] = useState<string | null>(null); const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false);
const [saveMsg, setSaveMsg] = useState(""); const [saveMsg, setSaveMsg] = useState("");
const [avatarError, setAvatarError] = useState(false);
const [uploading, setUploading] = useState(false);
useEffect(() => { useEffect(() => {
fetch("/api/auth/profile") fetch("/api/auth/profile")
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
if (data.user) { if (data.user) {
setUserId(data.user.id || "");
setName(data.user.name || ""); setName(data.user.name || "");
setEmail(data.user.email || ""); setEmail(data.user.email || "");
setPhone(data.user.phone || "");
setAvatarUrl(data.user.avatarUrl || null); setAvatarUrl(data.user.avatarUrl || null);
} }
setLoading(false); setLoading(false);
@ -35,44 +48,45 @@ export default function ProfilePage() {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
const contentType = resolveContentType(file);
setUploading(true); setUploading(true);
setSaveMsg(""); setSaveMsg("");
try { try {
// Step 1: get R2 key + presigned upload URL // Step 1: reserve upload slot
const initRes = await fetch("/api/auth/avatar", { const initRes = await fetch("/api/auth/avatar", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contentType: file.type, filename: file.name }), body: JSON.stringify({ contentType, filename: file.name }),
}); });
const initData = await initRes.json(); const initData = await initRes.json();
if (!initRes.ok) throw new Error(initData.error || "Upload init failed"); if (!initRes.ok) throw new Error(initData.error || `Upload failed (${initRes.status})`);
const { key, publicUrl: newPublicUrl } = initData; const { key, publicUrl: r2Url } = initData;
// Step 2: proxy PUT through our server (avoids CORS on direct R2 PUT) // Step 2: upload binary via proxy
const putParams = new URLSearchParams({ key, contentType: file.type }); const putRes = await fetch(`/api/upload?${new URLSearchParams({ key, contentType })}`, {
const putRes = await fetch(`/api/upload?${putParams}`, {
method: "PUT", method: "PUT",
body: file, body: file,
headers: { "Content-Type": file.type }, headers: { "Content-Type": contentType },
}); });
if (!putRes.ok) throw new Error("Upload to storage failed"); if (!putRes.ok) throw new Error(`Upload failed (${putRes.status})`);
// Step 3: save the new avatarUrl in DB (server cleans up old R2 object) // Step 3: save R2 URL to DB
const patchRes = await fetch("/api/auth/avatar", { const patchRes = await fetch("/api/auth/avatar", {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ avatarUrl: newPublicUrl }), body: JSON.stringify({ avatarUrl: r2Url }),
}); });
const patchData = await patchRes.json(); if (!patchRes.ok) throw new Error("Failed to save photo");
if (!patchRes.ok) throw new Error(patchData.error || "Failed to save photo");
setAvatarUrl(newPublicUrl); setAvatarError(false);
setSaveMsg("Photo updated!"); setAvatarUrl(`/api/img?key=${encodeURIComponent(key)}`);
} catch (err) { } catch (err) {
setSaveMsg(err instanceof Error ? err.message : "Upload failed"); setSaveMsg(err instanceof Error ? err.message : "Upload failed");
} finally {
setUploading(false);
if (fileRef.current) fileRef.current.value = "";
} }
setUploading(false);
if (fileRef.current) fileRef.current.value = "";
}; };
const handleRemovePhoto = async () => { const handleRemovePhoto = async () => {
@ -82,7 +96,7 @@ export default function ProfilePage() {
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to remove photo"); if (!res.ok) throw new Error(data.error || "Failed to remove photo");
setAvatarUrl(null); setAvatarUrl(null);
setSaveMsg("Photo removed."); setAvatarError(false);
} catch (err) { } catch (err) {
setSaveMsg(err instanceof Error ? err.message : "Failed to remove photo"); setSaveMsg(err instanceof Error ? err.message : "Failed to remove photo");
} }
@ -96,7 +110,7 @@ export default function ProfilePage() {
const res = await fetch("/api/auth/profile", { const res = await fetch("/api/auth/profile", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }), body: JSON.stringify({ name, phone }),
}); });
const data = await res.json(); const data = await res.json();
setSaveMsg(data.success ? "Saved!" : data.error || "Save failed"); setSaveMsg(data.success ? "Saved!" : data.error || "Save failed");
@ -106,12 +120,7 @@ export default function ProfilePage() {
setSaving(false); setSaving(false);
}; };
const initials = name const initials = name.split(" ").map(w => w[0]).join("").toUpperCase().slice(0, 2);
.split(" ")
.map(w => w[0])
.join("")
.toUpperCase()
.slice(0, 2);
return ( return (
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800"> <div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800">
@ -123,20 +132,20 @@ export default function ProfilePage() {
<div className="px-4 pb-24 space-y-4"> <div className="px-4 pb-24 space-y-4">
{/* Avatar */} {/* Avatar */}
<div className="flex flex-col items-center pt-8 pb-4"> <div className="flex flex-col items-center pt-8 pb-2">
<div className="relative mb-3"> <div className="relative mb-3">
{avatarUrl ? ( {avatarUrl && !avatarError ? (
<img <img
src={avatarUrl} src={avatarUrl}
alt={name} alt={name}
className="w-24 h-24 rounded-full object-cover ring-4 ring-white dark:ring-gray-800 shadow-md" className="w-24 h-24 rounded-full object-cover ring-4 ring-white dark:ring-gray-800 shadow-md"
onError={() => setAvatarError(true)}
/> />
) : ( ) : (
<div className="w-24 h-24 rounded-full bg-gradient-to-br from-rose-300 to-amber-300 flex items-center justify-center text-white text-2xl font-bold shadow-md ring-4 ring-white dark:ring-gray-800"> <div className="w-24 h-24 rounded-full bg-gradient-to-br from-rose-300 to-amber-300 flex items-center justify-center text-white text-2xl font-bold shadow-md ring-4 ring-white dark:ring-gray-800">
{initials || "👤"} {initials || "👤"}
</div> </div>
)} )}
{/* Upload spinner overlay */}
{uploading && ( {uploading && (
<div className="absolute inset-0 rounded-full bg-black/40 flex items-center justify-center"> <div className="absolute inset-0 rounded-full bg-black/40 flex items-center justify-center">
<div className="w-6 h-6 border-2 border-white border-t-transparent rounded-full animate-spin" /> <div className="w-6 h-6 border-2 border-white border-t-transparent rounded-full animate-spin" />
@ -152,19 +161,18 @@ export default function ProfilePage() {
{uploading ? "Uploading…" : "Change Photo"} {uploading ? "Uploading…" : "Change Photo"}
</button> </button>
{avatarUrl && !uploading && ( {avatarUrl && !uploading && (
<button <button onClick={handleRemovePhoto} className="text-xs text-gray-400 mt-0.5 hover:text-red-400 transition-colors">
onClick={handleRemovePhoto}
className="text-xs text-gray-400 dark:text-gray-500 mt-0.5 hover:text-red-400 transition-colors"
>
Remove photo Remove photo
</button> </button>
)} )}
{!avatarUrl && <p className="text-xs text-gray-400 mt-0.5">JPEG, PNG or WebP · max 5 MB</p>} {!avatarUrl && !uploading && (
<p className="text-xs text-gray-400 mt-0.5">JPEG, PNG, WebP or HEIC</p>
)}
<input <input
ref={fileRef} ref={fileRef}
type="file" type="file"
accept="image/jpeg,image/jpg,image/png,image/webp" accept="image/jpeg,image/jpg,image/png,image/webp,image/heic"
onChange={handlePhotoChange} onChange={handlePhotoChange}
className="hidden" className="hidden"
/> />
@ -186,6 +194,18 @@ export default function ProfilePage() {
/> />
</div> </div>
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Phone Number</label>
<input
type="tel"
value={phone}
onChange={e => setPhone(e.target.value)}
className="w-full px-3 py-2.5 bg-gray-50 dark:bg-gray-700 rounded-xl border border-gray-200 dark:border-gray-600 text-sm dark:text-white focus:outline-none focus:ring-2 focus:ring-rose-300"
placeholder="+91 98765 43210"
/>
<p className="text-xs text-gray-400 mt-1">For important reminders &amp; updates (optional)</p>
</div>
<div> <div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Email</label> <label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Email</label>
<input <input
@ -198,7 +218,7 @@ export default function ProfilePage() {
</div> </div>
{saveMsg && ( {saveMsg && (
<p className={`text-xs text-center font-medium ${saveMsg === "Saved!" || saveMsg === "Photo updated!" ? "text-green-600 dark:text-green-400" : "text-red-500"}`}> <p className={`text-xs text-center font-medium ${saveMsg === "Saved!" ? "text-green-600 dark:text-green-400" : "text-red-500"}`}>
{saveMsg} {saveMsg}
</p> </p>
)} )}

View file

@ -7,6 +7,8 @@ import { useTheme } from "@/app/ThemeProvider";
import { useFamily } from "@/app/FamilyProvider"; import { useFamily } from "@/app/FamilyProvider";
import { Button, Card, Input, Select, Badge } from "@/components/ui"; import { Button, Card, Input, Select, Badge } from "@/components/ui";
import { StorageMeter, MemberLimitBanner } from "@/components/StorageMeter"; import { StorageMeter, MemberLimitBanner } from "@/components/StorageMeter";
import { UpgradeButton } from "@/components/UpgradeButton";
import { fmtDate } from "@/lib/date-ist";
interface Member { interface Member {
id: string; id: string;
@ -39,7 +41,26 @@ export default function SettingsPage() {
const [inviteRole, setInviteRole] = useState("caregiver"); const [inviteRole, setInviteRole] = useState("caregiver");
const [inviteLoading, setInviteLoading] = useState(false); const [inviteLoading, setInviteLoading] = useState(false);
const [pedPhone, setPedPhone] = useState(""); const [pedPhone, setPedPhone] = useState("");
const [pedName, setPedName] = useState("");
const [pedSaving, setPedSaving] = useState(false); const [pedSaving, setPedSaving] = useState(false);
const [pedEditing, setPedEditing] = useState(false);
const [pedSaved, setPedSaved] = useState(false);
const [pedError, setPedError] = useState("");
const [cancelling, setCancelling] = useState(false);
const [cancelMsg, setCancelMsg] = useState("");
const handleCancelSubscription = async () => {
if (!window.confirm("Cancel your subscription? You'll keep premium until the end of your current billing period.")) return;
setCancelling(true);
try {
const res = await fetch("/api/subscriptions/cancel", { method: "POST" });
const data = await res.json();
setCancelMsg(res.ok ? data.message : (data.error || "Could not cancel."));
} catch {
setCancelMsg("Could not reach the server. Try again.");
}
setCancelling(false);
};
// Check if can invite more members (client-side pre-check; server enforces) // Check if can invite more members (client-side pre-check; server enforces)
const canInvite = tier === "pro" || memberCount < 2; const canInvite = tier === "pro" || memberCount < 2;
@ -59,7 +80,10 @@ export default function SettingsPage() {
fetchMembers(); fetchMembers();
fetchInvites(); fetchInvites();
fetch("/api/family").then(r => r.json()).then(d => { fetch("/api/family").then(r => r.json()).then(d => {
if (d.family?.pediatrician_phone) setPedPhone(d.family.pediatrician_phone); setPedPhone(d.family?.pediatrician_phone || "");
setPedName(d.family?.pediatrician_name || "");
// If no data yet, open in edit mode so user can fill it in
if (!d.family?.pediatrician_phone && !d.family?.pediatrician_name) setPedEditing(true);
}).catch(() => {}); }).catch(() => {});
} }
}, [familyId]); }, [familyId]);
@ -74,7 +98,7 @@ export default function SettingsPage() {
if (records.length === 0) { alert("No growth records to export."); return; } if (records.length === 0) { alert("No growth records to export."); return; }
const headers = ["Date", "Weight (kg)", "Height (cm)", "Head (cm)", "Notes"]; const headers = ["Date", "Weight (kg)", "Height (cm)", "Head (cm)", "Notes"];
const rows = records.map((r: { measured_at: string; weight_kg: number | null; height_cm: number | null; head_circumference_cm: number | null; notes: string | null }) => [ const rows = records.map((r: { measured_at: string; weight_kg: number | null; height_cm: number | null; head_circumference_cm: number | null; notes: string | null }) => [
new Date(r.measured_at).toLocaleDateString(), fmtDate(r.measured_at),
r.weight_kg ?? "", r.weight_kg ?? "",
r.height_cm ?? "", r.height_cm ?? "",
r.head_circumference_cm ?? "", r.head_circumference_cm ?? "",
@ -92,13 +116,26 @@ export default function SettingsPage() {
setExporting(false); setExporting(false);
}; };
const savePedPhone = async () => { const savePedInfo = async () => {
setPedSaving(true); setPedSaving(true);
await fetch("/api/family", { setPedError("");
method: "PATCH", try {
headers: { "Content-Type": "application/json" }, const res = await fetch("/api/family", {
body: JSON.stringify({ pediatricianPhone: pedPhone }), method: "PATCH",
}).catch(() => {}); headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pediatricianPhone: pedPhone, pediatricianName: pedName }),
});
const data = await res.json();
if (!res.ok) {
setPedError(data.error || "Failed to save. Please try again.");
} else {
setPedEditing(false);
setPedSaved(true);
setTimeout(() => setPedSaved(false), 3000);
}
} catch {
setPedError("Network error. Please try again.");
}
setPedSaving(false); setPedSaving(false);
}; };
@ -171,6 +208,45 @@ export default function SettingsPage() {
</div> </div>
<div className="px-4 space-y-3"> <div className="px-4 space-y-3">
{/* Plan / Upgrade — anchor target for all "upgrade" CTAs across the app */}
<div id="upgrade" className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm scroll-mt-20">
<div className="flex items-center justify-between mb-1">
<p className="font-semibold text-gray-900 dark:text-white">
{tier === "pro" ? "✨ Tia Premium" : "Your Plan"}
</p>
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${tier === "pro" ? "bg-green-100 text-green-700" : "bg-rose-100 text-rose-700"}`}>
{tier === "pro" ? "Premium" : "Free"}
</span>
</div>
{tier === "pro" ? (
<>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3">
50 GB storage · up to 6 members · up to 3 baby profiles
</p>
{cancelMsg ? (
<p className="text-sm text-amber-600 dark:text-amber-400">{cancelMsg}</p>
) : (
<button
onClick={handleCancelSubscription}
disabled={cancelling}
className="text-sm text-gray-400 hover:text-red-500 disabled:opacity-50 underline"
>
{cancelling ? "Cancelling…" : "Cancel subscription"}
</button>
)}
</>
) : (
<>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3">
Upgrade for <strong>50 GB</strong> media storage, up to <strong>6 family members</strong>,
and <strong>3 baby profiles</strong>. 199/month, cancel anytime.
</p>
<UpgradeButton />
</>
)}
</div>
{/* My Profile Page */} {/* My Profile Page */}
<a href="/settings/profile" <a href="/settings/profile"
className="flex items-center justify-between bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm mb-3"> className="flex items-center justify-between bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm mb-3">
@ -223,7 +299,9 @@ export default function SettingsPage() {
</div> </div>
</div> </div>
{tier === "free" && ( {tier === "free" && (
<Button size="sm">Upgrade</Button> <a href="#upgrade">
<Button size="sm">Upgrade</Button>
</a>
)} )}
</div> </div>
@ -295,7 +373,7 @@ export default function SettingsPage() {
<div key={invite.id} className="flex justify-between items-center p-2 bg-gray-50 dark:bg-gray-700 rounded text-sm mb-1"> <div key={invite.id} className="flex justify-between items-center p-2 bg-gray-50 dark:bg-gray-700 rounded text-sm mb-1">
<div> <div>
<div className="font-medium text-gray-800 dark:text-gray-100">{invite.email}</div> <div className="font-medium text-gray-800 dark:text-gray-100">{invite.email}</div>
<div className="text-xs text-gray-400">Pending · expires {new Date(invite.expiresAt).toLocaleDateString()}</div> <div className="text-xs text-gray-400">Pending · expires {fmtDate(invite.expiresAt)}</div>
</div> </div>
<button <button
onClick={() => deleteInvite(invite.id)} onClick={() => deleteInvite(invite.id)}
@ -356,17 +434,56 @@ export default function SettingsPage() {
)} )}
</div> </div>
{/* Pediatrician Phone */} {/* Pediatrician */}
<div className="bg-white dark:bg-gray-800 rounded-xl p-4 space-y-2"> <div className="bg-white dark:bg-gray-800 rounded-xl p-4 space-y-3">
<div className="flex items-center gap-2"> <div className="flex items-center justify-between">
<span className="text-xl">🏥</span> <div className="flex items-center gap-2">
<div className="font-medium dark:text-white">Pediatrician Phone</div> <span className="text-xl">🏥</span>
</div> <div className="font-medium dark:text-white">Pediatrician</div>
<p className="text-xs text-gray-400 dark:text-gray-500">Shown on the emergency guide and in AI medical redirects.</p> </div>
<div className="flex gap-2"> {!pedEditing && (pedName || pedPhone) && (
<Input type="tel" value={pedPhone} onChange={e => setPedPhone(e.target.value)} placeholder="+91 98765 43210" className="flex-1" /> <button
<Button size="sm" loading={pedSaving} onClick={savePedPhone}>Save</Button> onClick={() => { setPedEditing(true); setPedSaved(false); setPedError(""); }}
className="text-sm text-rose-500 dark:text-rose-400 font-medium"
>
Edit
</button>
)}
</div> </div>
{/* Display mode */}
{!pedEditing && (pedName || pedPhone) ? (
<div className="space-y-0.5">
{pedName && <div className="text-sm font-medium text-gray-800 dark:text-gray-100">{pedName}</div>}
{pedPhone && <div className="text-sm text-gray-500 dark:text-gray-400">{pedPhone}</div>}
{pedSaved && <div className="text-xs text-green-600 dark:text-green-400">Saved!</div>}
</div>
) : (
/* Edit / first-fill mode */
<div className="space-y-2">
<p className="text-xs text-gray-400 dark:text-gray-500">Shown on the emergency guide and in AI medical redirects.</p>
<Input
type="text"
value={pedName}
onChange={e => setPedName(e.target.value)}
placeholder="Dr. Priya Sharma"
/>
<div className="flex gap-2">
<Input type="tel" value={pedPhone} onChange={e => setPedPhone(e.target.value)} placeholder="+91 98765 43210" className="flex-1" />
<Button size="sm" loading={pedSaving} onClick={savePedInfo}>Save</Button>
</div>
{pedEditing && (pedName || pedPhone) && (
<button
onClick={() => { setPedEditing(false); setPedError(""); }}
className="text-xs text-gray-400 dark:text-gray-500"
>
Cancel
</button>
)}
{pedError && <p className="text-xs text-red-500">{pedError}</p>}
</div>
)}
<Link href="/medical/emergency" className="text-xs text-rose-500 dark:text-rose-400"> <Link href="/medical/emergency" className="text-xs text-rose-500 dark:text-rose-400">
View Emergency Guide View Emergency Guide
</Link> </Link>

View file

@ -5,9 +5,10 @@ import { useRouter } from "next/navigation";
import Image from "next/image"; import Image from "next/image";
import { useFamily } from "@/app/FamilyProvider"; import { useFamily } from "@/app/FamilyProvider";
import { Button } from "@/components/ui"; import { Button } from "@/components/ui";
import { trackGarmentAdded } from "@/lib/analytics";
import { GARMENT_CATEGORIES, GARMENT_SIZE_ORDER } from "@/db/schema/wardrobe"; import { GARMENT_CATEGORIES, GARMENT_SIZE_ORDER } from "@/db/schema/wardrobe";
type Step = "capture" | "tagging" | "form"; type Step = "capture" | "form";
interface VisionResult { interface VisionResult {
name: string; name: string;
@ -110,7 +111,8 @@ function SingleChipRow<T extends string>({
export default function AddGarmentPage() { export default function AddGarmentPage() {
const { childId } = useFamily(); const { childId } = useFamily();
const router = useRouter(); const router = useRouter();
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null); // gallery picker
const cameraRef = useRef<HTMLInputElement>(null); // camera capture
const [step, setStep] = useState<Step>("capture"); const [step, setStep] = useState<Step>("capture");
const [preview, setPreview] = useState<string | null>(null); const [preview, setPreview] = useState<string | null>(null);
@ -119,6 +121,8 @@ export default function AddGarmentPage() {
const [thumbKey, setThumbKey] = useState(""); const [thumbKey, setThumbKey] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false); // photo still uploading to R2
const [visionPending, setVisionPending] = useState(false); // AI analysis running in bg
// Form fields // Form fields
const [name, setName] = useState(""); const [name, setName] = useState("");
@ -136,53 +140,59 @@ export default function AddGarmentPage() {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
setError(""); setError("");
setStep("tagging");
// Show local preview immediately // 1. Show local preview immediately and jump straight to the form.
// The user can start filling in size/occasions while the upload runs.
const reader = new FileReader(); const reader = new FileReader();
reader.onload = ev => setPreview(ev.target?.result as string); reader.onload = ev => setPreview(ev.target?.result as string);
reader.readAsDataURL(file); reader.readAsDataURL(file);
setStep("form");
setUploading(true);
setVisionPending(false);
// Upload to R2 // 2. Upload to R2
const form = new FormData(); const form = new FormData();
form.append("file", file); form.append("file", file);
let uploadRes; let ik = "", tk = "", tu = "";
try { try {
uploadRes = await fetch("/api/garments/upload", { method: "POST", body: form }); const uploadRes = await fetch("/api/garments/upload", { method: "POST", body: form });
if (!uploadRes.ok) throw new Error((await uploadRes.json()).error); if (!uploadRes.ok) throw new Error((await uploadRes.json()).error);
const uploadData = await uploadRes.json();
ik = uploadData.imageKey;
tk = uploadData.thumbKey;
tu = uploadData.thumbUrl;
setImageKey(ik);
setThumbKey(tk);
setThumbUrl(tu);
} catch (err) { } catch (err) {
setError(`Upload failed: ${err}`); setError(`Upload failed: ${err}`);
setStep("capture"); setStep("capture");
setUploading(false);
return; return;
} }
const { imageKey: ik, thumbKey: tk, thumbUrl: tu } = await uploadRes.json(); setUploading(false);
setImageKey(ik);
setThumbKey(tk);
setThumbUrl(tu);
// Vision tagging // 3. Fire vision AI in the background — do NOT await, form stays interactive.
let vision: VisionResult = { name: "", category: "top", colors: [], seasons: [], occasion_tags: [] }; setVisionPending(true);
try { fetch("/api/garments/tag", {
const tagRes = await fetch("/api/garments/tag", { method: "POST",
method: "POST", headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json" }, body: JSON.stringify({ imageKey: ik }),
body: JSON.stringify({ imageKey: ik }), })
}); .then(r => r.ok ? r.json() : null)
if (tagRes.ok) { .then((vision: VisionResult | null) => {
const tagData = await tagRes.json(); if (vision) {
vision = tagData; setVisionMetadata(vision);
} // Only pre-fill fields the user hasn't touched yet
} catch { setName(n => n || vision.name || "");
// Vision failure is non-fatal — proceed with empty defaults setCategory(c => c || vision.category || null);
} setColors(c => c.length ? c : (vision.colors || []));
setSeasons(s => s.length ? s : (vision.seasons || []));
setVisionMetadata(vision); setOccasionTags(t => t.length ? t : (vision.occasion_tags || []));
setName(vision.name || ""); }
setCategory(vision.category || null); })
setColors(vision.colors || []); .catch(() => {/* vision failure is non-fatal */})
setSeasons(vision.seasons || []); .finally(() => setVisionPending(false));
setOccasionTags(vision.occasion_tags || []);
setStep("form");
}; };
const addColor = () => { const addColor = () => {
@ -215,6 +225,7 @@ export default function AddGarmentPage() {
}), }),
}); });
if (!res.ok) throw new Error((await res.json()).error); if (!res.ok) throw new Error((await res.json()).error);
trackGarmentAdded();
handleAddAnother(); handleAddAnother();
} catch (err) { } catch (err) {
setError(`Save failed: ${err}`); setError(`Save failed: ${err}`);
@ -240,7 +251,10 @@ export default function AddGarmentPage() {
setGiftFrom(""); setGiftFrom("");
setVisionMetadata(null); setVisionMetadata(null);
setError(""); setError("");
if (fileRef.current) fileRef.current.value = ""; setUploading(false);
setVisionPending(false);
if (fileRef.current) fileRef.current.value = "";
if (cameraRef.current) cameraRef.current.value = "";
}; };
return ( return (
@ -249,9 +263,14 @@ export default function AddGarmentPage() {
<div className="flex items-center gap-3 p-4"> <div className="flex items-center gap-3 p-4">
<button onClick={() => router.back()} className="p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-xl"></button> <button onClick={() => router.back()} className="p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-xl"></button>
<h1 className="text-xl font-bold">Add Garment</h1> <h1 className="text-xl font-bold">Add Garment</h1>
{step === "form" && ( {step === "form" && visionPending && (
<span className="ml-auto text-xs text-amber-500 bg-amber-50 dark:bg-amber-900/20 px-2 py-1 rounded-full shadow-sm animate-pulse">
🔍 AI tagging
</span>
)}
{step === "form" && !visionPending && visionMetadata && (
<span className="ml-auto text-xs text-gray-400 bg-white dark:bg-gray-800 px-2 py-1 rounded-full shadow-sm"> <span className="ml-auto text-xs text-gray-400 bg-white dark:bg-gray-800 px-2 py-1 rounded-full shadow-sm">
Vision pre-filled AI pre-filled
</span> </span>
)} )}
</div> </div>
@ -265,23 +284,42 @@ export default function AddGarmentPage() {
{/* Step 1: Capture */} {/* Step 1: Capture */}
{step === "capture" && ( {step === "capture" && (
<div className="mx-4 mt-4"> <div className="mx-4 mt-4">
<div <div className="aspect-square rounded-3xl border-4 border-dashed border-rose-200 dark:border-gray-600 flex flex-col items-center justify-center gap-4 bg-white dark:bg-gray-800">
onClick={() => fileRef.current?.click()}
className="aspect-square rounded-3xl border-4 border-dashed border-rose-200 dark:border-gray-600 flex flex-col items-center justify-center gap-4 cursor-pointer hover:border-rose-400 transition-colors bg-white dark:bg-gray-800"
>
<span className="text-6xl">👚</span> <span className="text-6xl">👚</span>
<div className="text-center px-4"> <div className="text-center px-4">
<p className="font-semibold text-gray-700 dark:text-gray-200">Take or upload a photo</p> <p className="font-semibold text-gray-700 dark:text-gray-200">Add a garment photo</p>
<p className="text-sm text-gray-400 mt-1">Tip: flat-lay on a light surface for best tagging</p> <p className="text-sm text-gray-400 mt-1">Tip: flat-lay on a light surface for best AI tagging</p>
</div>
<div className="flex gap-3">
<button
type="button"
onClick={() => cameraRef.current?.click()}
className="flex items-center gap-2 px-5 py-2.5 bg-rose-400 text-white rounded-full text-sm font-medium shadow-sm active:scale-95 transition-transform"
>
📷 Camera
</button>
<button
type="button"
onClick={() => fileRef.current?.click()}
className="flex items-center gap-2 px-5 py-2.5 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 rounded-full text-sm font-medium shadow-sm active:scale-95 transition-transform"
>
🖼 Gallery
</button>
</div> </div>
<span className="px-4 py-2 bg-rose-400 text-white rounded-full text-sm font-medium">
📷 Add Photo
</span>
</div> </div>
{/* Gallery input — no capture attribute, Android shows full media picker */}
<input <input
ref={fileRef} ref={fileRef}
type="file" type="file"
accept="image/*" accept="image/*"
className="hidden"
onChange={handleFileChange}
/>
{/* Camera input — capture="environment" opens rear camera directly */}
<input
ref={cameraRef}
type="file"
accept="image/*"
capture="environment" capture="environment"
className="hidden" className="hidden"
onChange={handleFileChange} onChange={handleFileChange}
@ -289,34 +327,19 @@ export default function AddGarmentPage() {
</div> </div>
)} )}
{/* Step 2: Tagging spinner */} {/* Step 2: Form (shown immediately after photo selected) */}
{step === "tagging" && (
<div className="mx-4 mt-4 flex flex-col items-center gap-6">
{preview && (
<div className="w-48 h-48 rounded-2xl overflow-hidden shadow-lg">
<img src={preview} alt="preview" className="w-full h-full object-cover" />
</div>
)}
<div className="text-center">
<div className="flex gap-1 justify-center mb-3">
{[0, 150, 300].map(d => (
<span key={d} className="w-2.5 h-2.5 bg-rose-400 rounded-full animate-bounce" style={{ animationDelay: `${d}ms` }} />
))}
</div>
<p className="font-semibold text-gray-700 dark:text-gray-200">Analysing garment</p>
<p className="text-sm text-gray-400">Vision is detecting category & colors</p>
</div>
</div>
)}
{/* Step 3: Form */}
{step === "form" && ( {step === "form" && (
<div className="mx-4 mt-2 space-y-2"> <div className="mx-4 mt-2 space-y-2">
{/* Thumbnail */} {/* Thumbnail — shows upload spinner until R2 upload is done */}
<div className="flex gap-4 items-start mb-4"> <div className="flex gap-4 items-start mb-4">
{(thumbUrl || preview) && ( {(thumbUrl || preview) && (
<div className="w-24 h-24 rounded-2xl overflow-hidden shadow-md flex-shrink-0"> <div className="relative w-24 h-24 rounded-2xl overflow-hidden shadow-md flex-shrink-0">
<img src={thumbUrl || preview!} alt="garment" className="w-full h-full object-cover" /> <img src={thumbUrl || preview!} alt="garment" className="w-full h-full object-cover" />
{uploading && (
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
<div className="w-6 h-6 border-2 border-white border-t-transparent rounded-full animate-spin" />
</div>
)}
</div> </div>
)} )}
<div className="flex-1"> <div className="flex-1">
@ -446,12 +469,17 @@ export default function AddGarmentPage() {
fullWidth fullWidth
onClick={handleSave} onClick={handleSave}
loading={saving} loading={saving}
disabled={!category || !sizeLabel || !childId} disabled={!category || !sizeLabel || !childId || uploading}
size="lg" size="lg"
> >
Save Garment {uploading ? "Uploading photo…" : "Save Garment"}
</Button> </Button>
{!sizeLabel && ( {uploading && (
<p className="text-center text-sm text-gray-400">
Photo uploading you can pick size & tags while you wait
</p>
)}
{!uploading && !sizeLabel && (
<p className="text-center text-sm text-amber-600 dark:text-amber-400"> <p className="text-center text-sm text-amber-600 dark:text-amber-400">
Please select a size to save Please select a size to save
</p> </p>

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

@ -1,7 +1,29 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import { Fraunces, Newsreader, JetBrains_Mono } from "next/font/google";
import { MarketingNav } from "@/components/marketing/MarketingNav"; import { MarketingNav } from "@/components/marketing/MarketingNav";
import Script from "next/script";
// ── Editorial fonts — loaded once for all marketing pages ─────────
const fraunces = Fraunces({
subsets: ["latin"],
variable: "--font-fraunces",
weight: ["300", "400", "500", "600"],
style: ["normal", "italic"],
display: "swap",
});
const newsreader = Newsreader({
subsets: ["latin"],
variable: "--font-newsreader",
weight: ["300", "400", "500", "600"],
style: ["normal", "italic"],
display: "swap",
});
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-jetbrains",
weight: ["400", "500"],
display: "swap",
});
export const metadata: Metadata = { export const metadata: Metadata = {
title: { title: {
@ -31,16 +53,7 @@ export default function MarketingLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<> <div className={`${fraunces.variable} ${newsreader.variable} ${jetbrainsMono.variable}`}>
{/* Privacy-respecting analytics — no cookies, no tracking */}
<Script
defer
data-domain={process.env.NEXT_PUBLIC_APP_URL?.replace("https://", "").replace("http://", "")}
src="https://plausible.io/js/script.js"
strategy="afterInteractive"
/>
{/* Scroll-reveal nav — appears after scrolling past hero */}
<MarketingNav /> <MarketingNav />
<main>{children}</main> <main>{children}</main>
@ -48,38 +61,70 @@ export default function MarketingLayout({
{/* Footer */} {/* Footer */}
<footer className="bg-gray-50 border-t border-gray-100 mt-20"> <footer className="bg-gray-50 border-t border-gray-100 mt-20">
<div className="max-w-5xl mx-auto px-5 py-12"> <div className="max-w-5xl mx-auto px-5 py-12">
<div className="flex flex-col sm:flex-row justify-between items-start gap-8"> <div className="flex flex-col lg:flex-row lg:justify-between gap-10 mb-8">
<div>
{/* Brand block */}
<div className="lg:max-w-xs">
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
<span className="text-2xl">🌸</span> <span className="text-2xl">🌸</span>
<span className="text-xl font-bold text-gray-900" style={{ fontFamily: "var(--font-caveat)" }}> <span className="text-xl font-bold text-gray-900" style={{ fontFamily: "var(--font-caveat)" }}>
Tia Tia
</span> </span>
</div> </div>
<p className="text-sm text-gray-500 max-w-xs leading-relaxed"> <p className="font-newsreader text-sm text-gray-500 leading-relaxed">
A digital heirloom for your baby.<br />Every moment, preserved. A digital heirloom for your baby.<br />Every moment, preserved privately.
</p> </p>
</div> </div>
<div className="flex flex-col gap-2 text-sm"> {/* Three-column link group */}
<p className="font-semibold text-gray-700 mb-1">Links</p> <div className="grid grid-cols-2 sm:grid-cols-3 gap-8 lg:gap-14 text-sm">
<Link href="/pricing" className="text-gray-500 hover:text-rose-600 transition-colors duration-150">Pricing</Link>
<Link href="/privacy" className="text-gray-500 hover:text-rose-600 transition-colors duration-150">Privacy Policy</Link> {/* Company */}
<Link href="/terms" className="text-gray-500 hover:text-rose-600 transition-colors duration-150">Terms of Service</Link> <div className="flex flex-col gap-2">
<p className="font-fraunces font-semibold text-gray-700 mb-1">Company</p>
<Link href="/about" className="font-fraunces font-normal text-gray-500 hover:text-rose-600 transition-colors duration-150">About</Link>
<Link href="/blog" className="font-fraunces font-normal text-gray-500 hover:text-rose-600 transition-colors duration-150">Blog</Link>
<Link href="/partners" className="font-fraunces font-normal text-gray-500 hover:text-rose-600 transition-colors duration-150">Partners</Link>
</div>
{/* Legal */}
<div className="flex flex-col gap-2">
<p className="font-fraunces font-semibold text-gray-700 mb-1">Legal</p>
<Link href="/pricing" className="font-fraunces font-normal text-gray-500 hover:text-rose-600 transition-colors duration-150">Pricing</Link>
<Link href="/privacy" className="font-fraunces font-normal text-gray-500 hover:text-rose-600 transition-colors duration-150">Privacy Policy</Link>
<Link href="/terms" className="font-fraunces font-normal text-gray-500 hover:text-rose-600 transition-colors duration-150">Terms of Service</Link>
</div>
{/* Contact */}
<div className="flex flex-col gap-2">
<p className="font-fraunces font-semibold text-gray-700 mb-1">Contact</p>
<a
href="tel:+919554881799"
className="font-fraunces font-normal text-gray-500 hover:text-rose-600 transition-colors duration-150"
>
+91 95548 81799
</a>
<a
href="mailto:hello@tia.baby"
className="font-fraunces font-normal text-gray-500 hover:text-rose-600 transition-colors duration-150"
>
hello@tia.baby
</a>
</div>
</div> </div>
</div> </div>
</div> </div>
{/* Bottom bar — different shade */} {/* Bottom bar — different shade */}
<div className="bg-gray-100 border-t border-gray-200"> <div className="bg-gray-100 border-t border-gray-200">
<div className="max-w-5xl mx-auto px-5 py-4 flex flex-col sm:flex-row items-center justify-between gap-2 text-xs text-gray-500"> <div className="max-w-5xl mx-auto px-5 py-4 grid grid-cols-1 sm:grid-cols-3 items-center gap-1 text-xs text-gray-500 text-center sm:text-left">
<span>© {new Date().getFullYear()} Tia.</span> <p>© {new Date().getFullYear()} Tia.</p>
<span>We don&apos;t sell your data we preserve it.</span> <p className="sm:text-center">We don&apos;t sell your data we preserve it.</p>
<span>Built with in India.</span> <p className="sm:text-right">Built with <span className="inline-block transition-transform duration-300 hover:scale-150 cursor-default select-none"></span> in India.</p>
</div> </div>
</div> </div>
</footer> </footer>
</> </div>
); );
} }

View file

@ -1,48 +1,93 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import { PhoneMockup } from "@/components/marketing/PhoneMockup";
import {
jsonLdScript,
organizationSchema,
websiteSchema,
softwareApplicationSchema,
} from "@/lib/seo";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Tia — Your baby's digital heirloom", title: "Tia — Baby Tracker & Digital Heirloom for Indian Families",
description: description:
"Tia is a digital heirloom for your baby — not just a tracker. Log daily moments, track the IAP vaccination schedule with Telegram alerts, and build a living archive your child will one day inherit.", "Tia is a digital heirloom for your baby — not just a tracker. Log daily moments, track the IAP vaccination schedule with Telegram alerts, and build a living archive your child will one day inherit. Free during early access.",
keywords: [
"baby tracker",
"baby tracker app India",
"IAP vaccination schedule",
"baby vaccination tracker",
"newborn feed tracker",
"baby milestone tracker",
"digital baby book",
"parenting app India",
"baby memory keeper",
],
alternates: { canonical: "/" },
openGraph: {
type: "website",
url: "/",
siteName: "Tia",
locale: "en_IN",
title: "Tia — Baby Tracker & Digital Heirloom for Indian Families",
description:
"Log every feed, milestone, and memory. Track the IAP vaccination schedule with Telegram alerts. Built for Indian families. Privacy-first. Free during early access.",
},
}; };
// ── Google G icon (reusable) ────────────────────────────────────
function GoogleG({ size = 20 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" className="flex-shrink-0">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z" fill="#FBBC05"/>
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
</svg>
);
}
// ── Section: Hero ─────────────────────────────────────────────── // ── Section: Hero ───────────────────────────────────────────────
function Hero() { function Hero() {
return ( return (
<section className="relative overflow-hidden bg-gradient-to-br from-rose-50 via-amber-50 to-rose-50 pt-16 pb-24 px-4"> <section className="relative overflow-hidden bg-gradient-to-br from-rose-50 via-amber-50 to-rose-50 pt-12 pb-20 px-5">
<div className="max-w-2xl mx-auto text-center"> <div className="max-w-5xl mx-auto grid grid-cols-1 lg:grid-cols-[1.1fr_0.9fr] gap-10 items-center">
<div className="inline-flex items-center gap-2 bg-rose-100 text-rose-700 text-xs font-semibold px-3 py-1.5 rounded-full mb-6">
<span></span> Free during early access {/* LEFT: copy */}
<div className="text-center lg:text-left max-w-xl mx-auto lg:mx-0">
<div className="inline-flex items-center gap-2 bg-rose-100 text-rose-700 text-xs font-semibold px-3 py-1.5 rounded-full mb-6">
<span></span> Free during early access
</div>
<h1 className="font-fraunces italic text-4xl sm:text-5xl font-bold text-gray-900 leading-tight mb-5">
Your baby&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> </div>
<h1 className="text-4xl sm:text-5xl font-bold text-gray-900 leading-tight mb-5"> {/* RIGHT: animated phone mockup */}
Your baby&apos;s story,{" "} <div className="flex justify-center lg:justify-end">
<span className="text-rose-500" style={{ fontFamily: "var(--font-caveat)", fontSize: "1.1em" }}> <PhoneMockup />
preserved for a lifetime. </div>
</span>
</h1>
<p className="text-xl font-light text-gray-500 leading-loose mb-8 max-w-xl mx-auto">
Tia is a digital heirloom not just a tracker. Every feed, every first
word, every vaccination, archived in one private place your child will
one day look back on.
</p>
<Link
href="/login"
className="inline-flex items-center gap-2.5 bg-white hover:bg-rose-50 border border-gray-200 hover:border-rose-300 text-gray-700 font-semibold px-7 py-3.5 rounded-full text-base transition-all duration-200 shadow-md hover:shadow-lg active:scale-95"
>
<svg className="w-5 h-5 flex-shrink-0" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z" fill="#FBBC05"/>
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
</svg>
Continue with Google
</Link>
<p className="mt-4 text-xs text-gray-400">No credit card. No setup fee. Your data is yours.</p>
</div> </div>
{/* Decorative blobs */} {/* Decorative blobs */}
@ -55,16 +100,16 @@ function Hero() {
// ── Section: The Problem ──────────────────────────────────────── // ── Section: The Problem ────────────────────────────────────────
function TheProblem() { function TheProblem() {
return ( return (
<section className="py-20 px-4 bg-white"> <section className="py-20 px-5 bg-white">
<div className="max-w-2xl mx-auto"> <div className="max-w-2xl mx-auto">
<p className="text-xs font-semibold text-rose-500 uppercase tracking-widest mb-4">The 3am reality</p> <p className="font-jetbrains text-xs font-semibold text-rose-500 uppercase tracking-widest mb-4">The 3am reality</p>
<h2 className="text-3xl font-bold text-gray-900 mb-6 leading-tight"> <h2 className="font-fraunces text-3xl font-bold text-gray-900 mb-6 leading-tight">
You&apos;re awake at 3am.<br /> You&apos;re awake at 3am.<br />
When did she last feed? When did she last feed?
</h2> </h2>
<div className="space-y-4 text-gray-600 leading-relaxed"> <div className="font-newsreader space-y-4 text-gray-600 leading-relaxed">
<p> <p>
You scroll back through WhatsApp messages to your mother-in-law. You check a You scroll back through WhatsApp messages to your mother-in-law. You check a
sticky note on the refrigerator. You open a notes app you downloaded last week sticky note on the refrigerator. You open a notes app you downloaded last week
@ -114,19 +159,19 @@ const FEATURES = [
{ {
icon: "🔮", icon: "🔮",
title: "Ask Tia", title: "Ask Tia",
body: "Ask Tia parenting logistics questions — feeding windows, sleep patterns, when to introduce solids. For anything that sounds medical, Tia will always refer you to your pediatrician. That restraint is intentional. It&apos;s a trust feature, not a limitation.", body: "Ask Tia parenting logistics questions — feeding windows, sleep patterns, when to introduce solids. For anything medical, Tia defers to your pediatrician. That restraint is intentional — it&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.\"", example: "\"Is 90ml normal at 6 weeks?\" → Tia gives context, then: \"Your pediatrician can confirm this for your baby specifically.\"",
}, },
{ {
icon: "📚", icon: "📚",
title: "The heirloom archive", title: "The heirloom archive",
body: "Every log, photo, milestone, and memory becomes part of a permanent, private archive. Not a feed. Not a highlights reel. A complete record of your child&apos;s earliest years — searchable, exportable, and theirs to keep.", body: "Every log, photo, milestone, and memory becomes part of a permanent, private archive. Not a feed. A complete record — searchable, exportable, and theirs to keep.",
example: "\"Show me everything from her first month\" → every feed, every photo, every note.", example: "\"Show me everything from her first month\" → every feed, every photo, every note.",
}, },
{ {
icon: "👨‍👩‍👧", icon: "👨‍👩‍👧",
title: "Family circle", title: "Family circle",
body: "Role-based access so everyone in your baby&apos;s life can be as involved as they should be. Grandparents get view-only access to milestones. The nanny gets caregiver access to log feeds. You stay the admin.", body: "Role-based access so everyone in your baby&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.", example: "Nani in Jaipur sees today's photos in real time. The daai logs the afternoon feed.",
}, },
{ {
@ -139,25 +184,29 @@ const FEATURES = [
function Features() { function Features() {
return ( return (
<section className="py-20 px-4 bg-gray-50"> <section className="py-20 px-5 bg-gray-50">
<div className="max-w-2xl mx-auto"> <div className="max-w-5xl mx-auto">
<p className="text-xs font-semibold text-rose-500 uppercase tracking-widest mb-4 text-center">What Tia does</p> <p className="font-jetbrains text-xs font-semibold text-rose-500 uppercase tracking-widest mb-4 text-center">What Tia does</p>
<h2 className="text-3xl font-bold text-gray-900 text-center mb-12"> <h2 className="font-fraunces text-3xl font-bold text-gray-900 text-center mb-12">
Everything in one private place. Everything in one private place.
</h2> </h2>
<div className="space-y-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{FEATURES.map(f => ( {FEATURES.map(f => (
<div key={f.title} className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100 hover:border-rose-200 hover:shadow-md transition-all duration-200 group"> <div
<div className="flex items-start gap-4"> key={f.title}
<span className="text-3xl flex-shrink-0 transition-transform duration-200 group-hover:scale-110">{f.icon}</span> className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100 hover:border-rose-200 hover:shadow-md transition-all duration-200 group flex flex-col"
<div> >
<h3 className="font-bold text-gray-900 text-lg mb-2">{f.title}</h3> <span className="text-3xl mb-4 transition-transform duration-200 group-hover:scale-110 block">
<p className="text-gray-600 text-sm leading-relaxed mb-3" dangerouslySetInnerHTML={{ __html: f.body }} /> {f.icon}
<div className="bg-rose-50 rounded-lg px-3 py-2 text-xs text-rose-700 italic"> </span>
{f.example} <h3 className="font-fraunces font-bold text-gray-900 text-lg mb-2">{f.title}</h3>
</div> <p
</div> className="text-gray-600 text-sm leading-relaxed mb-4 flex-1"
dangerouslySetInnerHTML={{ __html: f.body }}
/>
<div className="bg-rose-50 rounded-lg px-3 py-2 text-xs text-rose-700 italic mt-auto">
{f.example}
</div> </div>
</div> </div>
))} ))}
@ -170,49 +219,56 @@ function Features() {
// ── Section: Founder Story ───────────────────────────────────── // ── Section: Founder Story ─────────────────────────────────────
function FounderStory() { function FounderStory() {
return ( return (
<section className="py-20 px-4 bg-white"> <section className="py-20 px-5 bg-white">
<div className="max-w-2xl mx-auto"> <div className="max-w-2xl mx-auto">
<p className="text-xs font-semibold text-rose-500 uppercase tracking-widest mb-4">Why Tia exists</p> <p className="font-jetbrains text-xs font-semibold text-rose-500 uppercase tracking-widest mb-4">Why Tia exists</p>
<div className="bg-amber-50 border border-amber-200 rounded-2xl p-6 sm:p-8"> <h2 className="font-fraunces text-3xl font-bold text-gray-900 mb-3 leading-tight">
{/* TIA began with a promise.
</h2>
PLACEHOLDER Manohar, this section is yours to write.
<div className="bg-amber-50 border border-amber-200 rounded-2xl p-6 sm:p-8 mt-6">
Tell the story of your daughter. The specific 3am moment in <div className="font-newsreader text-gray-700 leading-relaxed space-y-4 text-sm sm:text-base">
Gurugram that made you start building. The fear that she'd <p>
grow up and you'd have nothing but blurry photos. The reason When our daughter, Tia, was born, we wanted to remember everything the tiny
you want her to be able to read her own first chapter one day. stretches, the sleepy smiles, the moments that felt too precious to forget. But we
quickly discovered how easily those details fade, even when you&apos;re trying your
Replace everything between these comment tags with your own best to hold on to them.
words. This section is the moat it has to be in your voice. </p>
*/} <p className="font-medium text-gray-800">
<div className="flex items-center gap-3 mb-5"> We built TIA to help families preserve those memories.
<div className="w-10 h-10 rounded-full bg-rose-200 flex items-center justify-center text-lg">👨💻</div> </p>
<div>
<p className="font-semibold text-gray-900">Manohar Gupta</p> <p>
<p className="text-xs text-gray-500">Founder, Tia · Gurugram</p> Named after our daughter, Tia, the app reflects the values that guide us as parents:
</div> care, patience, trust, privacy, and respect. We believe your family&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>
<blockquote className="text-gray-700 leading-relaxed space-y-4 italic text-sm sm:text-base"> <div className="mt-8 pt-6 border-t border-amber-200 text-center">
<p> <p className="text-sm font-semibold text-amber-800 tracking-wide">
[PLACEHOLDER] My daughter was born and within two weeks I realised I was already Built by parents. Inspired by our daughter. Made for families.
forgetting things. Not forgetting them completely just losing the texture. The
exact weight on day five. The time of the first real smile. The way she sounded
when she figured out her own hands.
</p> </p>
<p> </div>
[PLACEHOLDER] I wanted to build something that would let her read her own first
chapter one day. Not a social media highlight reel. A real archive private,
complete, and hers to keep. That&apos;s Tia.
</p>
<p>
[PLACEHOLDER] Replace these paragraphs with your story in your own words.
The specific 3am moment. The specific fear. Why Gurugram, why this daughter, why now.
</p>
</blockquote>
</div> </div>
</div> </div>
</section> </section>
@ -222,18 +278,18 @@ function FounderStory() {
// ── Section: The Heirloom Vision ─────────────────────────────── // ── Section: The Heirloom Vision ───────────────────────────────
function HeirloomVision() { function HeirloomVision() {
return ( return (
<section className="py-20 px-4 bg-gradient-to-br from-rose-50 to-amber-50"> <section className="py-20 px-5 bg-gradient-to-br from-rose-50 to-amber-50">
<div className="max-w-2xl mx-auto text-center"> <div className="max-w-2xl mx-auto text-center">
<p className="text-xs font-semibold text-rose-500 uppercase tracking-widest mb-4">The heirloom vision</p> <p className="font-jetbrains text-xs font-semibold text-rose-500 uppercase tracking-widest mb-4">The heirloom vision</p>
<h2 className="text-3xl font-bold text-gray-900 mb-6 leading-tight"> <h2 className="font-fraunces text-3xl font-bold text-gray-900 mb-6 leading-tight">
One day, your child will be able to{" "} One day, your child will be able to{" "}
<span className="text-rose-500" style={{ fontFamily: "var(--font-caveat)", fontSize: "1.1em" }}> <span className="text-rose-500" style={{ fontFamily: "var(--font-caveat)", fontSize: "1.1em" }}>
read their own story. read their own story.
</span> </span>
</h2> </h2>
<p className="text-gray-600 leading-relaxed mb-8 max-w-xl mx-auto"> <p className="font-newsreader text-gray-600 leading-relaxed mb-8 max-w-xl mx-auto">
Everything you log today is a letter to your future child. The 2:47am feed. Everything you log today is a letter to your future child. The 2:47am feed.
The first solid. The doctor visit you worried about for a week. The photo from The first solid. The doctor visit you worried about for a week. The photo from
the moment you realised she could recognise your voice. the moment you realised she could recognise your voice.
@ -241,26 +297,14 @@ function HeirloomVision() {
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-left"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-left">
{[ {[
{ { icon: "📖", title: "A complete record", desc: "Not highlights. Everything — because you can't know yet which moment will matter most." },
icon: "📖", { icon: "🔒", title: "Private and permanent", desc: "No public feed. No algorithm. Your family's archive, locked to your family." },
title: "A complete record", { icon: "💾", title: "Fully exportable", desc: "Your data is yours. Export everything at any time. The heirloom is portable." },
desc: "Not highlights. Everything — because you can&apos;t know yet which moment will matter most.",
},
{
icon: "🔒",
title: "Private and permanent",
desc: "No public feed. No algorithm. Your family&apos;s archive, locked to your family.",
},
{
icon: "💾",
title: "Fully exportable",
desc: "Your data is yours. Export everything at any time. The heirloom is portable.",
},
].map(item => ( ].map(item => (
<div key={item.title} className="bg-white/80 rounded-2xl p-5 border border-rose-100 hover:border-rose-300 hover:shadow-sm transition-all duration-200"> <div key={item.title} className="bg-white/80 rounded-2xl p-5 border border-rose-100 hover:border-rose-300 hover:shadow-sm transition-all duration-200">
<span className="text-2xl mb-3 block">{item.icon}</span> <span className="text-2xl mb-3 block">{item.icon}</span>
<h3 className="font-bold text-gray-900 mb-1">{item.title}</h3> <h3 className="font-fraunces font-bold text-gray-900 mb-1">{item.title}</h3>
<p className="text-sm text-gray-600 leading-relaxed" dangerouslySetInnerHTML={{ __html: item.desc }} /> <p className="text-sm text-gray-600 leading-relaxed">{item.desc}</p>
</div> </div>
))} ))}
</div> </div>
@ -272,48 +316,32 @@ function HeirloomVision() {
// ── Section: Privacy & Trust ─────────────────────────────────── // ── Section: Privacy & Trust ───────────────────────────────────
function Privacy() { function Privacy() {
return ( return (
<section className="py-20 px-4 bg-white"> <section className="py-20 px-5 bg-white">
<div className="max-w-2xl mx-auto"> <div className="max-w-2xl mx-auto">
<p className="text-xs font-semibold text-rose-500 uppercase tracking-widest mb-4">Privacy & trust</p> <p className="font-jetbrains text-xs font-semibold text-rose-500 uppercase tracking-widest mb-4">Privacy & trust</p>
<h2 className="text-3xl font-bold text-gray-900 mb-4"> <h2 className="font-fraunces text-3xl font-bold text-gray-900 mb-4">
We don&apos;t sell your data <br /> We don&apos;t sell your data <br />
<span className="text-rose-500">we preserve it.</span> <span className="text-rose-500">we preserve it.</span>
</h2> </h2>
<p className="text-gray-600 leading-relaxed mb-8"> <p className="font-newsreader text-gray-600 leading-relaxed mb-8">
Tia is a baby-tracking app. Your child&apos;s records are not the product. Tia is a baby-tracking app. Your child&apos;s records are not the product.
They are the point. They are the point.
</p> </p>
<div className="space-y-3"> <div className="space-y-3">
{[ {[
{ { icon: "🏛️", title: "Row-Level Security", desc: "Your family's data is isolated at the database level. No other user — no other family — can reach your records." },
icon: "🏛️", { icon: "🚫", title: "No ads. No data resale.", desc: "We do not sell, share, or monetise your data with third parties. Ever." },
title: "Row-Level Security", { icon: "📤", title: "Full export at any time", desc: "Export everything: logs, photos, milestones, vaccinations. Your data leaves with you whenever you want." },
desc: "Your family&apos;s data is isolated at the database level. No other user — no other family — can reach your records.", { icon: "🔐", title: "Google Sign-In only", desc: "We don't store passwords. Authentication is handled by Google — the same account you already trust for everything else." },
},
{
icon: "🚫",
title: "No ads. No data resale.",
desc: "We do not sell, share, or monetise your data with third parties. Ever.",
},
{
icon: "📤",
title: "Full export at any time",
desc: "Export everything: logs, photos, milestones, vaccinations. Your data leaves with you whenever you want.",
},
{
icon: "🔐",
title: "Google Sign-In only",
desc: "We don&apos;t store passwords. Authentication is handled by Google — the same account you already trust for everything else.",
},
].map(item => ( ].map(item => (
<div key={item.title} className="flex items-start gap-4 p-4 bg-gray-50 hover:bg-rose-50 rounded-xl transition-colors duration-150"> <div key={item.title} className="flex items-start gap-4 p-4 bg-gray-50 hover:bg-rose-50 rounded-xl transition-colors duration-150">
<span className="text-xl flex-shrink-0 mt-0.5">{item.icon}</span> <span className="text-xl flex-shrink-0 mt-0.5">{item.icon}</span>
<div> <div>
<p className="font-semibold text-gray-900 text-sm">{item.title}</p> <p className="font-semibold text-gray-900 text-sm">{item.title}</p>
<p className="text-sm text-gray-600 leading-relaxed mt-0.5" dangerouslySetInnerHTML={{ __html: item.desc }} /> <p className="text-sm text-gray-600 leading-relaxed mt-0.5">{item.desc}</p>
</div> </div>
</div> </div>
))} ))}
@ -326,30 +354,21 @@ function Privacy() {
// ── Section: Early Access ────────────────────────────────────── // ── Section: Early Access ──────────────────────────────────────
function EarlyAccess() { function EarlyAccess() {
return ( return (
<section className="py-16 px-4 bg-gray-50 border-y border-gray-100"> <section className="py-16 px-5 bg-gray-50 border-y border-gray-100">
<div className="max-w-2xl mx-auto"> <div className="max-w-2xl mx-auto">
<p className="text-xs font-semibold text-rose-500 uppercase tracking-widest mb-4 text-center">Private early access</p> <p className="font-jetbrains text-xs font-semibold text-rose-500 uppercase tracking-widest mb-4 text-center">Private early access</p>
<h2 className="font-fraunces text-2xl font-bold text-gray-900 text-center mb-6">
<h2 className="text-2xl font-bold text-gray-900 text-center mb-6">
Built by a parent, being tested by parents. Built by a parent, being tested by parents.
</h2> </h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{[ {[
{ { icon: "🌱", title: "Free during early access", desc: "Core features — activity logging and the heirloom archive — are free while Tia is in early access. Early families keep their terms for life." },
icon: "🌱", { icon: "🇮🇳", title: "India-native from day one", desc: "IAP vaccination schedule. Telegram alerts. Indian naming conventions. Built for how Indian families actually live." },
title: "Free during early access",
desc: "Core features — activity logging and the heirloom archive — are free while Tia is in early access. Early families keep their terms for life.",
},
{
icon: "🇮🇳",
title: "India-native from day one",
desc: "IAP vaccination schedule. Telegram alerts. Indian naming conventions. Built for how Indian families actually live.",
},
].map(item => ( ].map(item => (
<div key={item.title} className="bg-white rounded-2xl p-5 border border-gray-100 shadow-sm"> <div key={item.title} className="bg-white rounded-2xl p-5 border border-gray-100 shadow-sm">
<span className="text-2xl mb-3 block">{item.icon}</span> <span className="text-2xl mb-3 block">{item.icon}</span>
<h3 className="font-bold text-gray-900 mb-2">{item.title}</h3> <h3 className="font-fraunces font-bold text-gray-900 mb-2">{item.title}</h3>
<p className="text-sm text-gray-600 leading-relaxed">{item.desc}</p> <p className="text-sm text-gray-600 leading-relaxed">{item.desc}</p>
</div> </div>
))} ))}
@ -362,9 +381,9 @@ function EarlyAccess() {
// ── Section: Final CTA ───────────────────────────────────────── // ── Section: Final CTA ─────────────────────────────────────────
function FinalCTA() { function FinalCTA() {
return ( return (
<section className="py-24 px-4 bg-gradient-to-br from-rose-500 to-rose-600 text-white text-center"> <section className="py-24 px-5 bg-gradient-to-br from-rose-500 to-rose-600 text-white text-center">
<div className="max-w-xl mx-auto"> <div className="max-w-xl mx-auto">
<h2 className="text-3xl sm:text-4xl font-bold mb-4 leading-tight"> <h2 className="font-fraunces text-3xl sm:text-4xl font-bold mb-4 leading-tight">
Start preserving your<br />child&apos;s story today. Start preserving your<br />child&apos;s story today.
</h2> </h2>
@ -376,12 +395,7 @@ function FinalCTA() {
href="/login" href="/login"
className="inline-flex items-center gap-2.5 bg-white text-rose-600 font-bold px-8 py-4 rounded-full text-base hover:bg-rose-50 hover:shadow-xl active:scale-95 transition-all duration-200 shadow-lg" className="inline-flex items-center gap-2.5 bg-white text-rose-600 font-bold px-8 py-4 rounded-full text-base hover:bg-rose-50 hover:shadow-xl active:scale-95 transition-all duration-200 shadow-lg"
> >
<svg className="w-5 h-5" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"> <GoogleG />
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z" fill="#FBBC05"/>
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
</svg>
Continue with Google Continue with Google
</Link> </Link>
@ -397,6 +411,15 @@ function FinalCTA() {
export default function MarketingHomePage() { export default function MarketingHomePage() {
return ( return (
<> <>
{/* Structured data — Organization, WebSite, and SoftwareApplication */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={jsonLdScript([
organizationSchema(),
websiteSchema(),
softwareApplicationSchema(),
])}
/>
<Hero /> <Hero />
<TheProblem /> <TheProblem />
<Features /> <Features />

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

@ -3,15 +3,22 @@ import Link from "next/link";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Pricing", title: "Pricing",
description: "Founder pricing — early families keep their terms for life.", description: "Founder pricing — early families keep their terms for life. Free during early access.",
alternates: { canonical: "/pricing" },
openGraph: {
type: "website",
url: "/pricing",
title: "Pricing | Tia",
description: "Founder pricing — early families keep their terms for life. Free during early access.",
},
}; };
export default function PricingPage() { export default function PricingPage() {
return ( return (
<div className="max-w-2xl mx-auto px-4 py-20"> <div className="max-w-2xl mx-auto px-4 py-20">
<p className="text-xs font-semibold text-rose-500 uppercase tracking-widest mb-4 text-center">Pricing</p> <p className="font-jetbrains text-xs font-semibold text-rose-500 uppercase tracking-widest mb-4 text-center">Pricing</p>
<h1 className="text-3xl sm:text-4xl font-bold text-gray-900 text-center mb-4"> <h1 className="font-fraunces text-3xl sm:text-4xl font-bold text-gray-900 text-center mb-4">
Founder pricing. Founder pricing.
</h1> </h1>
<p className="text-gray-500 text-center mb-12 max-w-md mx-auto"> <p className="text-gray-500 text-center mb-12 max-w-md mx-auto">

View file

@ -3,6 +3,13 @@ import type { Metadata } from "next";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Privacy Policy", title: "Privacy Policy",
description: "How Tia handles your family's data. We don't sell it — we preserve it.", description: "How Tia handles your family's data. We don't sell it — we preserve it.",
alternates: { canonical: "/privacy" },
openGraph: {
type: "website",
url: "/privacy",
title: "Privacy Policy | Tia",
description: "How Tia handles your family's data. We don't sell it — we preserve it.",
},
}; };
export default function PrivacyPage() { export default function PrivacyPage() {

View file

@ -2,7 +2,14 @@ import type { Metadata } from "next";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Terms of Service", title: "Terms of Service",
description: "Terms of service for Tia.", description: "Terms of service for Tia — the baby tracker and digital heirloom for Indian families.",
alternates: { canonical: "/terms" },
openGraph: {
type: "website",
url: "/terms",
title: "Terms of Service | Tia",
description: "Terms of service for Tia — the baby tracker and digital heirloom for Indian families.",
},
}; };
export default function TermsPage() { export default function TermsPage() {

View file

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

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

@ -32,6 +32,7 @@ interface EngagementData {
activitySummary: { active7d: number; active30d: number; neverActive: number; total: number }; activitySummary: { active7d: number; active30d: number; neverActive: number; total: number };
aiUsage: { totalCalls: number; totalTokens: number; totalCostPaise: number; familiesUsingAI: number }; aiUsage: { totalCalls: number; totalTokens: number; totalCostPaise: number; familiesUsingAI: number };
logsByDay: { date: string; count: number }[]; logsByDay: { date: string; count: number }[];
error?: string;
} }
type Tab = "overview" | "families" | "ai"; type Tab = "overview" | "families" | "ai";
@ -49,7 +50,20 @@ export default function AdminAnalytics() {
useEffect(() => { useEffect(() => {
fetch("/api/admin/engagement", { credentials: "include" }) fetch("/api/admin/engagement", { credentials: "include" })
.then(r => r.json()) .then(r => r.json())
.then(d => { setData(d); setLoading(false); }) .then(d => {
// Normalize into the full EngagementData shape so a malformed/error
// response can never crash the render (e.g. [...data.families]).
setData({
totalFamilies: Number(d?.totalFamilies) || 0,
featureAdoption: Array.isArray(d?.featureAdoption) ? d.featureAdoption : [],
families: Array.isArray(d?.families) ? d.families : [],
activitySummary: { active7d: 0, active30d: 0, neverActive: 0, total: 0, ...(d?.activitySummary || {}) },
aiUsage: { totalCalls: 0, totalTokens: 0, totalCostPaise: 0, familiesUsingAI: 0, ...(d?.aiUsage || {}) },
logsByDay: Array.isArray(d?.logsByDay) ? d.logsByDay : [],
error: typeof d?.error === "string" ? d.error : undefined,
});
setLoading(false);
})
.catch(() => setLoading(false)); .catch(() => setLoading(false));
}, []); }, []);
@ -104,6 +118,13 @@ export default function AdminAnalytics() {
</div> </div>
</div> </div>
{data.error && (
<div className="bg-rose-500/10 border border-rose-500/30 text-rose-300 text-sm p-3 rounded-lg">
<span className="font-semibold">Some analytics queries failed:</span>
<span className="ml-1 font-mono break-words">{data.error}</span>
</div>
)}
{/* Activity Summary Cards — always visible */} {/* Activity Summary Cards — always visible */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<SummaryCard label="Active 7d" value={activitySummary.active7d} color="text-emerald-400" sub={`of ${activitySummary.total}`} /> <SummaryCard label="Active 7d" value={activitySummary.active7d} color="text-emerald-400" sub={`of ${activitySummary.total}`} />

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

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

View file

@ -11,6 +11,15 @@ interface Member {
displayName: string; displayName: string;
} }
interface Subscription {
status: string;
planName: string | null;
pricePaise: number | null;
startedAt: string | null;
expiresAt: string | null;
cancelledAt: string | null;
}
interface Family { interface Family {
id: string; id: string;
name: string; name: string;
@ -23,6 +32,7 @@ interface Family {
logCount: number; logCount: number;
memoryCount: number; memoryCount: number;
members: Member[]; members: Member[];
subscription: Subscription | null;
} }
export default function AdminFamilies() { export default function AdminFamilies() {
@ -160,12 +170,9 @@ export default function AdminFamilies() {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: "include", credentials: "include",
body: JSON.stringify({ // Send only the tier — the server applies the real grant/revoke
familyId, // (50GB / 6 members / 3 children for pro; free defaults otherwise).
tier: newTier, body: JSON.stringify({ familyId, tier: newTier }),
maxChildren: newTier === "pro" ? 10 : 1,
maxMembers: newTier === "pro" ? 10 : 2,
}),
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to update tier"); if (!res.ok) throw new Error(data.error || "Failed to update tier");
@ -313,6 +320,20 @@ export default function AdminFamilies() {
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<Badge variant={family.tier === "pro" ? "rose" : "default"}>{family.tier}</Badge> <Badge variant={family.tier === "pro" ? "rose" : "default"}>{family.tier}</Badge>
{family.subscription && (
<div className="mt-1 text-[11px] leading-tight text-gray-500">
<div className="text-gray-400">{family.subscription.status}</div>
{family.subscription.startedAt && (
<div title="Subscribed since">since {family.subscription.startedAt.slice(0, 10)}</div>
)}
{family.subscription.expiresAt && (
<div title="Renews / expires" className={family.subscription.cancelledAt ? "text-amber-400" : ""}>
{family.subscription.cancelledAt ? "ends " : "renews "}
{family.subscription.expiresAt.slice(0, 10)}
</div>
)}
</div>
)}
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<Button <Button

View file

@ -0,0 +1,68 @@
"use client";
import { useCallback, useEffect, useState } from "react";
interface Check { name: string; status: "ok" | "warn" | "down"; detail: string }
interface Data { overall: "ok" | "warn" | "down"; checks: Check[]; checkedAt: string; error?: string }
const STATUS = {
ok: { dot: "bg-emerald-400", text: "text-emerald-400", label: "Healthy" },
warn: { dot: "bg-amber-400", text: "text-amber-400", label: "Degraded" },
down: { dot: "bg-rose-500", text: "text-rose-400", label: "Down" },
};
export default function AdminHealth() {
const [data, setData] = useState<Data | null>(null);
const [loading, setLoading] = useState(true);
const load = useCallback(() => {
setLoading(true);
fetch("/api/admin/health", { credentials: "include" })
.then(r => r.json())
.then(d => { setData(d); setLoading(false); })
.catch(() => { setData(null); setLoading(false); });
}, []);
useEffect(() => { load(); }, [load]);
const overall = data?.overall || "warn";
return (
<div className="p-6 space-y-6">
<div className="flex justify-between items-start flex-wrap gap-3">
<div>
<h1 className="text-2xl font-bold">System Health</h1>
<p className="text-gray-400">{data?.checkedAt ? `Checked ${new Date(data.checkedAt).toLocaleTimeString()}` : "Live status of core services"}</p>
</div>
<button onClick={load} className="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded-lg text-sm"> Refresh</button>
</div>
<div className="bg-gray-800 p-6 rounded-xl flex items-center gap-4">
<span className={`w-4 h-4 rounded-full ${STATUS[overall].dot} ${overall !== "ok" ? "animate-pulse" : ""}`} />
<div>
<div className={`text-xl font-bold ${STATUS[overall].text}`}>{STATUS[overall].label}</div>
<div className="text-sm text-gray-400">Overall system status</div>
</div>
</div>
{loading ? (
<div className="text-gray-400">Running checks</div>
) : !data ? (
<div className="bg-rose-500/10 text-rose-400 p-4 rounded-xl">Failed to load health checks.</div>
) : (
<div className="bg-gray-800 rounded-xl divide-y divide-gray-700">
{data.checks.map(c => (
<div key={c.name} className="flex items-center gap-4 p-4">
<span className={`w-3 h-3 rounded-full flex-shrink-0 ${STATUS[c.status].dot}`} />
<div className="flex-1 min-w-0">
<div className="font-medium">{c.name}</div>
<div className="text-sm text-gray-400 break-words">{c.detail}</div>
</div>
<span className={`text-xs font-medium uppercase ${STATUS[c.status].text}`}>{c.status}</span>
</div>
))}
</div>
)}
</div>
);
}

View file

@ -1,7 +1,13 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { verifyAdminSession } from "@/lib/admin-auth"; import { verifyAdminSession } from "@/lib/admin-auth";
import AdminSidebar from "./AdminSidebar"; import AdminSidebar from "./AdminSidebar";
// Admin panel — never index.
export const metadata: Metadata = {
robots: { index: false, follow: false },
};
export default async function AdminLayout({ children }: { children: React.ReactNode }) { export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const auth = await verifyAdminSession(); const auth = await verifyAdminSession();
if (!auth.success) { if (!auth.success) {

View file

@ -82,7 +82,7 @@ export default function AdminDashboard() {
<StatCard label="Families" value={overview.totalFamilies} icon="🏠" color="rose" href="/admin/families" /> <StatCard label="Families" value={overview.totalFamilies} icon="🏠" color="rose" href="/admin/families" />
<StatCard label="Users" value={overview.totalUsers} icon="👥" color="blue" href="/admin/users" /> <StatCard label="Users" value={overview.totalUsers} icon="👥" color="blue" href="/admin/users" />
<StatCard label="Children" value={overview.totalChildren} icon="👶" color="amber" href="/admin/children" /> <StatCard label="Children" value={overview.totalChildren} icon="👶" color="amber" href="/admin/children" />
<StatCard label="MRR" value={`$${overview.mrr.toFixed(2)}`} icon="💰" color="emerald" href="/admin/revenue" /> <StatCard label="MRR" value={`${(overview.mrr || 0).toLocaleString("en-IN")}`} icon="💰" color="emerald" href="/admin/revenue" />
<StatCard <StatCard
label="Active Sessions" label="Active Sessions"
value={overview.activeSessions} value={overview.activeSessions}
@ -105,7 +105,7 @@ export default function AdminDashboard() {
<h3 className="text-sm font-semibold text-gray-400 mb-4">REVENUE OVERVIEW</h3> <h3 className="text-sm font-semibold text-gray-400 mb-4">REVENUE OVERVIEW</h3>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<div className="text-2xl font-bold text-emerald-400">${overview.mrr.toFixed(2)}</div> <div className="text-2xl font-bold text-emerald-400">{(overview.mrr || 0).toLocaleString("en-IN")}</div>
<div className="text-gray-400 text-sm">Monthly Recurring</div> <div className="text-gray-400 text-sm">Monthly Recurring</div>
</div> </div>
<div> <div>
@ -117,7 +117,7 @@ export default function AdminDashboard() {
<div className="text-gray-400 text-sm">Free Families</div> <div className="text-gray-400 text-sm">Free Families</div>
</div> </div>
<div> <div>
<div className="text-2xl font-bold text-amber-400">${overview.avgRevenuePerUser}</div> <div className="text-2xl font-bold text-amber-400">{(overview.avgRevenuePerUser || 0).toLocaleString("en-IN")}</div>
<div className="text-gray-400 text-sm">Avg per Family</div> <div className="text-gray-400 text-sm">Avg per Family</div>
</div> </div>
</div> </div>

View file

@ -5,98 +5,101 @@ import { useEffect, useState } from "react";
interface RevenueData { interface RevenueData {
proFamilies: number; proFamilies: number;
freeFamilies: number; freeFamilies: number;
mrr: number; mrr: number; // rupees
history: { month: string; revenue: number }[];
} }
const PRO_PRICE = 9.99; interface TrendPoint { month: string; paise: number; charges: number }
const inr = (rupees: number) =>
"₹" + (Number(rupees) || 0).toLocaleString("en-IN", { maximumFractionDigits: 0 });
export default function AdminRevenue() { export default function AdminRevenue() {
const [data, setData] = useState<RevenueData | null>(null); const [data, setData] = useState<RevenueData | null>(null);
const [trend, setTrend] = useState<TrendPoint[]>([]);
const [churnRate, setChurnRate] = useState<number | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
fetchRevenue(); Promise.all([
fetch("/api/admin/stats", { credentials: "include" }).then((r) => r.json()),
fetch("/api/admin/subscriptions", { credentials: "include" }).then((r) => r.json()).catch(() => null),
])
.then(([stats, subs]) => {
setData({
proFamilies: stats.overview?.proFamilies || 0,
freeFamilies: stats.overview?.freeFamilies || 0,
mrr: stats.overview?.mrr || 0,
});
if (subs) {
setTrend(subs.revenueTrend || []);
setChurnRate(subs.summary?.churnRate ?? null);
}
})
.catch((err) => console.error("Failed to fetch revenue:", err))
.finally(() => setLoading(false));
}, []); }, []);
const fetchRevenue = async () => {
try {
const res = await fetch("/api/admin/stats", { credentials: "include" });
const stats = await res.json();
setData({
proFamilies: stats.overview?.proFamilies || 0,
freeFamilies: stats.overview?.freeFamilies || 0,
mrr: stats.overview?.mrr || 0,
history: generateMonthlyHistory(stats.overview?.proFamilies || 0),
});
} catch (err) {
console.error("Failed to fetch revenue:", err);
}
setLoading(false);
};
const generateMonthlyHistory = (proCount: number): { month: string; revenue: number }[] => {
const months: { month: string; revenue: number }[] = [];
for (let i = 11; i >= 0; i--) {
const date = new Date();
date.setMonth(date.getMonth() - i);
months.push({
month: date.toLocaleDateString("en-US", { month: "short", year: "2-digit" }),
revenue: proCount > 0 ? proCount * PRO_PRICE : 0,
});
}
return months;
};
if (loading || !data) { if (loading || !data) {
return <div className="p-6 text-white">Loading...</div>; return <div className="p-6 text-white">Loading...</div>;
} }
const maxPaise = Math.max(1, ...trend.map((t) => t.paise));
// ARPU across paying families only (avoids a misleading blended number).
const arpu = data.proFamilies > 0 ? data.mrr / data.proFamilies : 0;
return ( return (
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
<div> <div>
<h1 className="text-2xl font-bold">Revenue</h1> <h1 className="text-2xl font-bold">Revenue</h1>
<p className="text-gray-400">Subscription revenue analytics</p> <p className="text-gray-400">Real subscription revenue, in (from active Razorpay subscriptions)</p>
<p className="text-sm text-rose-400">Pro: ${PRO_PRICE}/month per family</p> <p className="text-xs text-gray-500 mt-1">
For per-subscription detail, see the <a href="/admin/subscriptions" className="text-rose-400 underline">Subscriptions</a> page.
</p>
</div> </div>
{/* Key Metrics */} {/* Key Metrics */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-gray-800 p-6 rounded-xl"> <div className="bg-gray-800 p-6 rounded-xl">
<div className="text-3xl font-bold text-emerald-400">${data.mrr.toFixed(2)}</div> <div className="text-3xl font-bold text-emerald-400">{inr(data.mrr)}</div>
<div className="text-gray-400 text-sm">Monthly Recurring Revenue</div> <div className="text-gray-400 text-sm">Monthly Recurring Revenue</div>
</div> </div>
<div className="bg-gray-800 p-6 rounded-xl"> <div className="bg-gray-800 p-6 rounded-xl">
<div className="text-3xl font-bold text-rose-400">${(data.mrr * 12).toFixed(2)}</div> <div className="text-3xl font-bold text-rose-400">{inr(data.mrr * 12)}</div>
<div className="text-gray-400 text-sm">Annual Run Rate</div> <div className="text-gray-400 text-sm">Annual Run Rate</div>
</div> </div>
<div className="bg-gray-800 p-6 rounded-xl"> <div className="bg-gray-800 p-6 rounded-xl">
<div className="text-3xl font-bold text-rose-400">{data.proFamilies}</div> <div className="text-3xl font-bold text-rose-400">{data.proFamilies}</div>
<div className="text-gray-400 text-sm">Pro Families</div> <div className="text-gray-400 text-sm">Paying Families</div>
</div> </div>
<div className="bg-gray-800 p-6 rounded-xl"> <div className="bg-gray-800 p-6 rounded-xl">
<div className="text-3xl font-bold text-gray-400">{data.freeFamilies}</div> <div className={`text-3xl font-bold ${churnRate != null && churnRate > 10 ? "text-red-400" : "text-gray-400"}`}>
<div className="text-gray-400 text-sm">Free Families</div> {churnRate != null ? `${churnRate}%` : "—"}
</div>
<div className="text-gray-400 text-sm">Churn rate</div>
</div> </div>
</div> </div>
{/* Revenue Chart */} {/* Real monthly revenue trend (from subscription.charged events) */}
<div className="bg-gray-800 p-6 rounded-xl"> <div className="bg-gray-800 p-6 rounded-xl">
<h3 className="text-lg font-semibold mb-4">Monthly Revenue</h3> <h3 className="text-lg font-semibold mb-1">Monthly Revenue (charged)</h3>
<div className="h-48 flex items-end gap-1"> <p className="text-xs text-gray-500 mb-4">Actual collections from Razorpay subscription.charged events</p>
{data.history.map((h, i) => ( {trend.length === 0 ? (
<div key={i} className="flex-1 flex flex-col items-center gap-1"> <p className="text-sm text-gray-500 py-8 text-center">No charges recorded yet</p>
<div ) : (
className="w-full bg-emerald-500 rounded-t" <div className="h-48 flex items-end gap-2">
style={{ {trend.map((t) => (
height: data.mrr > 0 ? `${(h.revenue / (data.mrr * 1.2)) * 100}%` : "0%", <div key={t.month} className="flex-1 flex flex-col items-center gap-1" title={`${inr(t.paise / 100)} · ${t.charges} charge(s)`}>
minHeight: h.revenue > 0 ? "4px" : "0" <div className="text-[10px] text-gray-400">{inr(t.paise / 100)}</div>
}} <div
/> className="w-full bg-emerald-500 rounded-t"
<div className="text-[8px] text-gray-500">{h.month}</div> style={{ height: `${(t.paise / maxPaise) * 100}%`, minHeight: t.paise > 0 ? "4px" : "0" }}
</div> />
))} <div className="text-[9px] text-gray-500">{t.month.slice(5)}/{t.month.slice(2, 4)}</div>
</div> </div>
))}
</div>
)}
</div> </div>
{/* Revenue Breakdown */} {/* Revenue Breakdown */}
@ -105,37 +108,32 @@ export default function AdminRevenue() {
<h3 className="text-lg font-semibold mb-4">Revenue by Tier</h3> <h3 className="text-lg font-semibold mb-4">Revenue by Tier</h3>
<div className="space-y-3"> <div className="space-y-3">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-emerald-400">Pro</span> <span className="text-emerald-400">Premium</span>
<span className="font-bold">${(data.proFamilies * PRO_PRICE).toFixed(2)}/mo</span> <span className="font-bold">{inr(data.mrr)}/mo</span>
</div> </div>
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-gray-400">Free</span> <span className="text-gray-400">Free</span>
<span className="font-bold">$0.00/mo</span> <span className="font-bold">0/mo</span>
</div>
<div className="flex justify-between items-center border-t border-gray-700 pt-3">
<span className="text-gray-400">ARPU (paying)</span>
<span className="font-bold text-gray-300">{inr(arpu)}/mo</span>
</div> </div>
</div> </div>
</div> </div>
<div className="bg-gray-800 p-6 rounded-xl"> <div className="bg-gray-800 p-6 rounded-xl">
<h3 className="text-lg font-semibold mb-4">Growth Potential</h3> <h3 className="text-lg font-semibold mb-4">Growth Potential</h3>
<p className="text-xs text-gray-500 mb-3">If free families convert at premium price</p>
<div className="space-y-3"> <div className="space-y-3">
<div className="flex justify-between items-center"> {[0.1, 0.25, 0.5].map((rate) => (
<span>If 10% convert</span> <div key={rate} className="flex justify-between items-center">
<span className="font-bold text-amber-400"> <span>If {rate * 100}% convert</span>
${((data.freeFamilies * 0.1) * PRO_PRICE).toFixed(2)}/mo <span className={`font-bold ${rate >= 0.5 ? "text-emerald-400" : "text-amber-400"}`}>
</span> {inr(data.freeFamilies * rate * arpu)}/mo
</div> </span>
<div className="flex justify-between items-center"> </div>
<span>If 25% convert</span> ))}
<span className="font-bold text-amber-400">
${((data.freeFamilies * 0.25) * PRO_PRICE).toFixed(2)}/mo
</span>
</div>
<div className="flex justify-between items-center">
<span>If 50% convert</span>
<span className="font-bold text-emerald-400">
${((data.freeFamilies * 0.5) * PRO_PRICE).toFixed(2)}/mo
</span>
</div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -0,0 +1,175 @@
"use client";
import { useEffect, useState } from "react";
interface FamilyRow {
id: string;
name: string;
tier: string;
isPaid: boolean;
bytes: number;
memoryCount: number;
attachmentCount: number;
objectCount: number;
fraction: number;
overLimit: boolean;
}
interface Summary {
totalBytes: number;
totalObjects: number;
familyCount: number;
paidCount: number;
freeCount: number;
overLimitCount: number;
approachingCount: number;
estMonthlyCostUsd: number;
freeLimitBytes: number;
avgBytesPerFamily: number;
}
interface Data { summary: Summary; families: FamilyRow[]; byDay: { date: string; bytes: number; count: number }[]; error?: string }
const EMPTY: Summary = {
totalBytes: 0, totalObjects: 0, familyCount: 0, paidCount: 0, freeCount: 0,
overLimitCount: 0, approachingCount: 0, estMonthlyCostUsd: 0, freeLimitBytes: 1_073_741_824, avgBytesPerFamily: 0,
};
function fmtBytes(n: number): string {
if (!n) return "0 B";
const u = ["B", "KB", "MB", "GB", "TB"];
const i = Math.min(Math.floor(Math.log(n) / Math.log(1024)), u.length - 1);
return `${(n / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1)} ${u[i]}`;
}
export default function AdminStorage() {
const [data, setData] = useState<Data>({ summary: EMPTY, families: [], byDay: [] });
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/api/admin/storage", { credentials: "include" })
.then(r => r.json())
.then(d => {
setData({
summary: { ...EMPTY, ...(d?.summary || {}) },
families: Array.isArray(d?.families) ? d.families : [],
byDay: Array.isArray(d?.byDay) ? d.byDay : [],
error: d?.error,
});
setLoading(false);
})
.catch(() => setLoading(false));
}, []);
const { summary, families, byDay } = data;
const maxDayBytes = Math.max(...byDay.map(d => d.bytes), 1);
return (
<div className="p-6 space-y-6">
<div className="flex justify-between items-start flex-wrap gap-3">
<div>
<h1 className="text-2xl font-bold">Storage &amp; Billing</h1>
<p className="text-gray-400">Per-family R2 storage usage the basis for usage billing</p>
</div>
</div>
{data.error && (
<div className="bg-rose-500/10 border border-rose-500/30 text-rose-300 text-sm p-3 rounded-lg">
<span className="font-semibold">Some queries failed:</span> <span className="font-mono break-words">{data.error}</span>
</div>
)}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card label="Total Storage" value={fmtBytes(summary.totalBytes)} color="text-rose-400" sub={`${summary.totalObjects.toLocaleString()} objects`} />
<Card label="Est. R2 Cost / mo" value={`$${summary.estMonthlyCostUsd.toFixed(2)}`} color="text-amber-400" sub="@ $0.015/GB-mo" />
<Card label="Avg / Family" value={fmtBytes(summary.avgBytesPerFamily)} color="text-blue-400" sub={`${summary.familyCount} families`} />
<Card label="Over Free Limit" value={summary.overLimitCount} color={summary.overLimitCount > 0 ? "text-rose-400" : "text-emerald-400"} sub={`${summary.approachingCount} approaching`} />
</div>
{/* Daily upload volume */}
<div className="bg-gray-800 p-6 rounded-xl">
<h3 className="text-lg font-semibold mb-4">Uploads last 30 days</h3>
<div className="h-32 flex items-end gap-1">
{byDay.length === 0 ? (
<div className="w-full text-center text-gray-500 text-sm self-center">No uploads in the last 30 days</div>
) : byDay.slice(-30).map((d, i) => (
<div key={i} title={`${d.date}: ${fmtBytes(d.bytes)} · ${d.count} files`} className="flex-1 group">
<div className="w-full bg-rose-500 group-hover:bg-rose-400 rounded-t" style={{ height: `${Math.max((d.bytes / maxDayBytes) * 100, d.bytes > 0 ? 3 : 0)}%` }} />
</div>
))}
</div>
</div>
{/* Per-family table */}
<div className="bg-gray-800 rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-gray-700 flex justify-between items-center">
<h3 className="text-lg font-semibold">By family</h3>
<span className="text-sm text-gray-500">{summary.paidCount} paid · {summary.freeCount} free</span>
</div>
{loading ? (
<div className="p-8 text-center text-gray-400">Loading</div>
) : families.length === 0 ? (
<div className="p-8 text-center text-gray-500">No families</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-700"><tr>
<th className="px-4 py-3 text-left text-sm font-medium">Family</th>
<th className="px-4 py-3 text-left text-sm font-medium">Tier</th>
<th className="px-4 py-3 text-right text-sm font-medium">Objects</th>
<th className="px-4 py-3 text-right text-sm font-medium">Storage</th>
<th className="px-4 py-3 text-left text-sm font-medium w-56">% of limit</th>
</tr></thead>
<tbody className="divide-y divide-gray-700">
{families.map(f => {
const limit = f.isPaid ? 25 * 1024 * 1024 * 1024 : 1_073_741_824;
const fraction = f.bytes / limit;
const pct = Math.min(Math.round(fraction * 100), 999);
const isOver = fraction >= 1;
const barColor = isOver ? "bg-rose-500" : fraction >= 0.8 ? "bg-amber-500" : "bg-emerald-500";
const limitLabel = f.isPaid ? "25 GB" : "1 GiB";
return (
<tr key={f.id} className="hover:bg-gray-750">
<td className="px-4 py-3 font-medium">{f.name}</td>
<td className="px-4 py-3">
<span className={`text-xs px-2 py-0.5 rounded font-medium ${f.isPaid ? "bg-rose-500/20 text-rose-400" : "bg-gray-700 text-gray-400"}`}>{f.tier}</span>
</td>
<td className="px-4 py-3 text-right text-sm text-gray-300">
{f.objectCount.toLocaleString()}
<span className="text-gray-600 text-xs"> ({f.memoryCount}📸 {f.attachmentCount}📎)</span>
</td>
<td className="px-4 py-3 text-right text-sm font-medium">{fmtBytes(f.bytes)}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<div className="flex-1 bg-gray-700 rounded-full h-2 overflow-hidden">
<div className={`h-full ${barColor}`} style={{ width: `${Math.min(pct, 100)}%` }} />
</div>
<span className={`text-xs w-24 text-right ${isOver ? "text-rose-400 font-bold" : "text-gray-400"}`}>
{pct}% of {limitLabel}
</span>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
<p className="text-xs text-gray-600">
Usage = SUM(size_bytes) over memories + attachments. Limits: free = 1 GiB · paid = 25 GB.
Objects missing a recorded size aren&apos;t counted in bytes. R2 cost is storage only (egress is free on R2).
</p>
</div>
);
}
function Card({ label, value, color, sub }: { label: string; value: string | number; color: string; sub?: string }) {
return (
<div className="bg-gray-800 p-4 rounded-xl">
<div className={`text-2xl font-bold ${color}`}>{value}</div>
<div className="text-sm text-gray-300 mt-0.5">{label}</div>
{sub && <div className="text-xs text-gray-500 mt-0.5">{sub}</div>}
</div>
);
}

View file

@ -0,0 +1,321 @@
"use client";
import { useEffect, useState, useCallback } from "react";
interface Subscription {
id: string;
family_id: string;
family_name: string | null;
plan_name: string | null;
price_paise: number | null;
status: string;
razorpay_subscription_id: string;
razorpay_customer_id: string | null;
current_start: string | null;
current_end: string | null;
cancelled_at: string | null;
ended_at: string | null;
created_at: string;
updated_at: string;
}
interface WebhookEvent {
razorpay_event_id: string;
event_type: string;
received_at: string;
sub_id: string | null;
sub_status: string | null;
}
interface Summary {
total: number;
byStatus: Record<string, number>;
mrrPaise: number;
arrPaise: number;
}
const inr = (paise: number | null | undefined) =>
"₹" + ((Number(paise) || 0) / 100).toLocaleString("en-IN", { maximumFractionDigits: 0 });
const fmt = (iso: string | null) =>
iso
? new Date(iso).toLocaleString("en-IN", {
timeZone: "Asia/Kolkata",
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})
: "—";
const STATUS_COLORS: Record<string, string> = {
active: "bg-emerald-500/15 text-emerald-400",
authenticated: "bg-emerald-500/15 text-emerald-400",
pending: "bg-amber-500/15 text-amber-400",
created: "bg-gray-500/15 text-gray-400",
halted: "bg-red-500/15 text-red-400",
cancelled: "bg-red-500/15 text-red-400",
completed: "bg-blue-500/15 text-blue-400",
expired: "bg-gray-500/15 text-gray-500",
paused: "bg-amber-500/15 text-amber-400",
};
export default function AdminSubscriptions() {
const [subs, setSubs] = useState<Subscription[]>([]);
const [events, setEvents] = useState<WebhookEvent[]>([]);
const [summary, setSummary] = useState<Summary | null>(null);
const [loading, setLoading] = useState(true);
const [reconciling, setReconciling] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const fetchData = useCallback(async () => {
try {
const res = await fetch("/api/admin/subscriptions", { credentials: "include" });
const data = await res.json();
if (data.error) throw new Error(data.error);
setSubs(data.subscriptions || []);
setEvents(data.webhookEvents || []);
setSummary(data.summary || null);
} catch (e) {
setMsg(e instanceof Error ? e.message : "Failed to load");
}
setLoading(false);
}, []);
useEffect(() => { fetchData(); }, [fetchData]);
const reconcile = async () => {
setReconciling(true);
setMsg(null);
try {
const res = await fetch("/api/admin/subscriptions", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ action: "reconcile" }),
});
const data = await res.json();
const ok = (data.reconciled || []).filter((r: { ok?: boolean }) => r.ok).length;
const failed = (data.reconciled || []).filter((r: { ok?: boolean }) => r.ok === false).length;
setMsg(`Reconciled ${ok} OK${failed ? `, ${failed} failed` : ""}.`);
fetchData();
} catch (e) {
setMsg(e instanceof Error ? e.message : "Reconcile failed");
}
setReconciling(false);
};
const cancelSub = async (subId: string, familyName: string | null) => {
if (!window.confirm(`Cancel subscription for ${familyName || "this family"}? They keep premium until the cycle ends.`)) return;
setMsg(null);
try {
const res = await fetch("/api/admin/subscriptions", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ action: "cancel", subscriptionId: subId }),
});
const data = await res.json();
setMsg(res.ok ? (data.message || "Cancellation scheduled.") : (data.error || "Cancel failed"));
if (res.ok) fetchData();
} catch (e) {
setMsg(e instanceof Error ? e.message : "Cancel failed");
}
};
const exportCSV = () => {
const headers = ["Family", "Plan", "Status", "Started", "Renews/Expires", "Cancelled", "RazorpaySubId", "PricePaise"];
const rows = subs.map((s) => [
s.family_name || s.family_id,
s.plan_name || "",
s.status,
s.current_start || "",
s.current_end || "",
s.cancelled_at || "",
s.razorpay_subscription_id,
String(s.price_paise ?? ""),
]);
const csv = [headers, ...rows]
.map((r) => r.map((c) => `"${String(c ?? "").replace(/"/g, '""')}"`).join(","))
.join("\n");
const url = URL.createObjectURL(new Blob([csv], { type: "text/csv" }));
const a = document.createElement("a");
a.href = url;
a.download = `subscriptions-${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
};
// Dunning: subs in 'pending' (a charge failed, Razorpay retrying) need attention.
const pendingSubs = subs.filter((s) => s.status === "pending");
if (loading) return <div className="p-6 text-white">Loading</div>;
return (
<div className="p-6 space-y-6">
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-bold">Subscriptions</h1>
<p className="text-gray-400">Razorpay subscription state &amp; webhook monitoring</p>
</div>
<div className="flex gap-2">
<button
onClick={exportCSV}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-sm font-medium"
>
Export CSV
</button>
<button
onClick={reconcile}
disabled={reconciling}
className="px-4 py-2 bg-rose-500 hover:bg-rose-600 disabled:opacity-50 rounded-lg text-sm font-medium"
title="Replay latest webhook events to re-sync entitlement"
>
{reconciling ? "Reconciling…" : "↻ Reconcile"}
</button>
</div>
</div>
{/* Dunning — failing payments that need attention before they churn */}
{pendingSubs.length > 0 && (
<div className="bg-amber-500/10 border border-amber-500/30 rounded-xl p-4">
<div className="font-semibold text-amber-400 mb-2">
{pendingSubs.length} payment{pendingSubs.length > 1 ? "s" : ""} failing (grace period)
</div>
<p className="text-xs text-amber-300/80 mb-3">
A charge failed and Razorpay is retrying. Reach out before retries exhaust and they churn (halted).
</p>
<div className="space-y-1">
{pendingSubs.map((s) => (
<div key={s.id} className="flex items-center justify-between text-sm">
<span>{s.family_name || s.family_id.slice(0, 8)} · {s.plan_name || "—"}</span>
<span className="text-gray-400">renews {fmt(s.current_end)}</span>
</div>
))}
</div>
</div>
)}
{msg && (
<div className="bg-blue-500/15 border border-blue-500/30 text-blue-300 px-4 py-2 rounded-lg text-sm">
{msg}
</div>
)}
{/* Summary cards */}
{summary && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-gray-800 p-5 rounded-xl">
<div className="text-2xl font-bold text-emerald-400">{inr(summary.mrrPaise)}</div>
<div className="text-gray-400 text-sm">MRR (active)</div>
</div>
<div className="bg-gray-800 p-5 rounded-xl">
<div className="text-2xl font-bold text-rose-400">{inr(summary.arrPaise)}</div>
<div className="text-gray-400 text-sm">Annual run rate</div>
</div>
<div className="bg-gray-800 p-5 rounded-xl">
<div className="text-2xl font-bold">{summary.byStatus.active || 0}</div>
<div className="text-gray-400 text-sm">Active</div>
</div>
<div className="bg-gray-800 p-5 rounded-xl">
<div className="text-2xl font-bold text-gray-300">{summary.total}</div>
<div className="text-gray-400 text-sm">Total subscriptions</div>
</div>
</div>
)}
{/* Status breakdown chips */}
{summary && Object.keys(summary.byStatus).length > 0 && (
<div className="flex flex-wrap gap-2">
{Object.entries(summary.byStatus).map(([status, count]) => (
<span key={status} className={`text-xs px-2.5 py-1 rounded-full ${STATUS_COLORS[status] || "bg-gray-700 text-gray-300"}`}>
{status}: {count}
</span>
))}
</div>
)}
{/* Subscriptions table */}
<div className="bg-gray-800 rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-gray-700 font-semibold">Subscriptions</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-700/50 text-gray-300">
<tr>
<th className="px-4 py-2 text-left">Family</th>
<th className="px-4 py-2 text-left">Plan</th>
<th className="px-4 py-2 text-left">Status</th>
<th className="px-4 py-2 text-left">Started</th>
<th className="px-4 py-2 text-left">Renews / Expires</th>
<th className="px-4 py-2 text-left">Razorpay ID</th>
<th className="px-4 py-2 text-left">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700">
{subs.map((s) => (
<tr key={s.id} className="hover:bg-gray-750">
<td className="px-4 py-2">{s.family_name || <span className="text-gray-600">{s.family_id.slice(0, 8)}</span>}</td>
<td className="px-4 py-2">
{s.plan_name || "—"}
{s.price_paise ? <span className="text-gray-500"> · {inr(s.price_paise)}</span> : null}
</td>
<td className="px-4 py-2">
<span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_COLORS[s.status] || "bg-gray-700 text-gray-300"}`}>
{s.status}
</span>
</td>
<td className="px-4 py-2 text-gray-400">{fmt(s.current_start)}</td>
<td className="px-4 py-2 text-gray-400">
{s.cancelled_at ? <span className="text-amber-400">cancels {fmt(s.current_end)}</span> : fmt(s.current_end)}
</td>
<td className="px-4 py-2 text-gray-500 font-mono text-xs">{s.razorpay_subscription_id}</td>
<td className="px-4 py-2">
{["active", "authenticated", "pending"].includes(s.status) && !s.cancelled_at ? (
<button
onClick={() => cancelSub(s.razorpay_subscription_id, s.family_name)}
className="text-xs text-red-400 hover:text-red-300 underline"
>
Cancel
</button>
) : (
<span className="text-gray-600 text-xs"></span>
)}
</td>
</tr>
))}
</tbody>
</table>
{subs.length === 0 && <div className="p-8 text-center text-gray-500">No subscriptions yet</div>}
</div>
</div>
{/* Webhook events log */}
<div className="bg-gray-800 rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-gray-700 font-semibold">Recent Webhook Events</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-700/50 text-gray-300">
<tr>
<th className="px-4 py-2 text-left">Received</th>
<th className="px-4 py-2 text-left">Event</th>
<th className="px-4 py-2 text-left">Subscription</th>
<th className="px-4 py-2 text-left">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700">
{events.map((e) => (
<tr key={e.razorpay_event_id} className="hover:bg-gray-750">
<td className="px-4 py-2 text-gray-400">{fmt(e.received_at)}</td>
<td className="px-4 py-2 font-mono text-xs">{e.event_type}</td>
<td className="px-4 py-2 text-gray-500 font-mono text-xs">{e.sub_id || "—"}</td>
<td className="px-4 py-2 text-gray-400">{e.sub_status || "—"}</td>
</tr>
))}
</tbody>
</table>
{events.length === 0 && <div className="p-8 text-center text-gray-500">No webhook events received yet</div>}
</div>
</div>
</div>
);
}

View file

@ -7,6 +7,7 @@ interface User {
id: string; id: string;
email: string; email: string;
name: string; name: string;
phone?: string | null;
familyId: string; familyId: string;
familyName: string; familyName: string;
createdAt: string; createdAt: string;
@ -122,13 +123,17 @@ export default function AdminUsers() {
const filteredUsers = users.filter((u) => const filteredUsers = users.filter((u) =>
u.email.toLowerCase().includes(search.toLowerCase()) || u.email.toLowerCase().includes(search.toLowerCase()) ||
(u.name || "").toLowerCase().includes(search.toLowerCase()) (u.name || "").toLowerCase().includes(search.toLowerCase()) ||
(u.phone || "").includes(search)
); );
const exportCSV = () => { const exportCSV = () => {
const headers = ["Email", "Name", "Family", "Created"]; const headers = ["Email", "Name", "Phone", "Family", "Created"];
const rows = filteredUsers.map((u) => [u.email, u.name, u.familyName, u.createdAt]); const rows = filteredUsers.map((u) => [u.email, u.name, u.phone || "", u.familyName, u.createdAt]);
const csv = [headers, ...rows].map((row) => row.join(",")).join("\n"); // Quote each cell so commas/empties don't shift columns
const csv = [headers, ...rows]
.map((row) => row.map((cell) => `"${String(cell ?? "").replace(/"/g, '""')}"`).join(","))
.join("\n");
const blob = new Blob([csv], { type: "text/csv" }); const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
@ -206,7 +211,7 @@ export default function AdminUsers() {
<Input <Input
type="text" type="text"
placeholder="Search by name or email…" placeholder="Search by name, email or phone…"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
/> />
@ -216,6 +221,7 @@ export default function AdminUsers() {
<thead className="bg-gray-700"> <thead className="bg-gray-700">
<tr> <tr>
<th className="px-4 py-3 text-left text-sm font-medium">User</th> <th className="px-4 py-3 text-left text-sm font-medium">User</th>
<th className="px-4 py-3 text-left text-sm font-medium">Phone</th>
<th className="px-4 py-3 text-left text-sm font-medium">Family</th> <th className="px-4 py-3 text-left text-sm font-medium">Family</th>
<th className="px-4 py-3 text-left text-sm font-medium">Password</th> <th className="px-4 py-3 text-left text-sm font-medium">Password</th>
<th className="px-4 py-3 text-left text-sm font-medium">Joined</th> <th className="px-4 py-3 text-left text-sm font-medium">Joined</th>
@ -229,6 +235,11 @@ export default function AdminUsers() {
<div className="font-medium">{user.name || user.email}</div> <div className="font-medium">{user.name || user.email}</div>
<div className="text-xs text-gray-500">{user.email}</div> <div className="text-xs text-gray-500">{user.email}</div>
</td> </td>
<td className="px-4 py-3 text-sm">
{user.phone
? <a href={`tel:${user.phone}`} className="text-gray-300 hover:text-rose-400">{user.phone}</a>
: <span className="text-gray-600"></span>}
</td>
<td className="px-4 py-3 text-gray-300">{user.familyName || <span className="text-gray-600"></span>}</td> <td className="px-4 py-3 text-gray-300">{user.familyName || <span className="text-gray-600"></span>}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<button <button

View file

@ -0,0 +1,101 @@
import { NextResponse } from "next/server";
import { requireAdmin } from "@/lib/admin-auth";
import { sql } from "@/db";
// AI observability feed over ai_usage: headline totals, per-intent latency &
// cost breakdown (incl. p95), daily trend, and the slowest recent calls.
export async function GET(request: Request) {
const auth = await requireAdmin(request);
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const { searchParams } = new URL(request.url);
const days = Math.min(Math.max(parseInt(searchParams.get("days") || "30", 10) || 30, 1), 365);
try {
const summaryRows = await sql`
SELECT
COUNT(*)::int AS total_calls,
COALESCE(SUM(total_tokens), 0)::int AS total_tokens,
COALESCE(SUM(cost_estimate_paise), 0)::numeric AS total_cost_paise,
COUNT(DISTINCT family_id)::int AS families_using_ai,
COALESCE(AVG(duration_ms), 0)::int AS avg_ms,
COALESCE(percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms), 0)::int AS p95_ms,
COUNT(*) FILTER (WHERE intent = 'medical_redirect')::int AS redirects
FROM ai_usage
WHERE created_at > NOW() - make_interval(days => ${days})
`;
const byIntent = await sql`
SELECT
COALESCE(intent, 'unknown') AS intent,
COUNT(*)::int AS count,
COALESCE(AVG(duration_ms), 0)::int AS avg_ms,
COALESCE(percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms), 0)::int AS p95_ms,
COALESCE(SUM(total_tokens), 0)::int AS tokens,
COALESCE(SUM(cost_estimate_paise), 0)::numeric AS cost_paise
FROM ai_usage
WHERE created_at > NOW() - make_interval(days => ${days})
GROUP BY intent
ORDER BY count DESC
`;
const byDay = await sql`
SELECT DATE(created_at) AS date, COUNT(*)::int AS count,
COALESCE(SUM(cost_estimate_paise), 0)::numeric AS cost_paise
FROM ai_usage
WHERE created_at > NOW() - make_interval(days => ${days})
GROUP BY DATE(created_at)
ORDER BY date
`;
const slowest = await sql`
SELECT id, COALESCE(intent, 'unknown') AS intent, duration_ms, model_used, created_at, family_id
FROM ai_usage
WHERE created_at > NOW() - make_interval(days => ${days}) AND duration_ms IS NOT NULL
ORDER BY duration_ms DESC
LIMIT 10
`;
const s = summaryRows[0] || {};
return NextResponse.json({
days,
summary: {
totalCalls: Number(s.total_calls) || 0,
totalTokens: Number(s.total_tokens) || 0,
totalCostPaise: Number(s.total_cost_paise) || 0,
familiesUsingAI: Number(s.families_using_ai) || 0,
avgMs: Number(s.avg_ms) || 0,
p95Ms: Number(s.p95_ms) || 0,
redirects: Number(s.redirects) || 0,
},
byIntent: byIntent.map((r: Record<string, unknown>) => ({
intent: r.intent,
count: Number(r.count) || 0,
avgMs: Number(r.avg_ms) || 0,
p95Ms: Number(r.p95_ms) || 0,
tokens: Number(r.tokens) || 0,
costPaise: Number(r.cost_paise) || 0,
})),
byDay: byDay.map((r: Record<string, unknown>) => ({
date: r.date instanceof Date ? r.date.toISOString().split("T")[0] : String(r.date).split("T")[0],
count: Number(r.count) || 0,
costPaise: Number(r.cost_paise) || 0,
})),
slowest: slowest.map((r: Record<string, unknown>) => ({
id: r.id,
intent: r.intent,
durationMs: Number(r.duration_ms) || 0,
model: r.model_used || "—",
createdAt: r.created_at instanceof Date ? (r.created_at as Date).toISOString() : String(r.created_at),
familyId: r.family_id,
})),
});
} catch (error) {
console.error("Admin AI feed error:", error);
return NextResponse.json({
days,
summary: { totalCalls: 0, totalTokens: 0, totalCostPaise: 0, familiesUsingAI: 0, avgMs: 0, p95Ms: 0, redirects: 0 },
byIntent: [], byDay: [], slowest: [], error: String(error),
});
}
}

View file

@ -0,0 +1,57 @@
import { NextResponse } from "next/server";
import { requireAdmin } from "@/lib/admin-auth";
import { sql } from "@/db";
// Full audit-log viewer feed — every action (not just auth), filterable by
// action / resource type / family / user / free-text, with the joined user
// email and family name for context.
export async function GET(request: Request) {
const auth = await requireAdmin(request);
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const { searchParams } = new URL(request.url);
const action = searchParams.get("action")?.trim();
const resourceType = searchParams.get("resourceType")?.trim();
const familyId = searchParams.get("familyId")?.trim();
const userId = searchParams.get("userId")?.trim();
const q = searchParams.get("q")?.trim();
const sinceHours = Math.min(Math.max(parseInt(searchParams.get("sinceHours") || "168", 10) || 168, 1), 24 * 90);
const limit = Math.min(Math.max(parseInt(searchParams.get("limit") || "100", 10) || 100, 1), 500);
try {
const where: string[] = [`al.created_at > NOW() - make_interval(hours => $1)`];
const params: unknown[] = [sinceHours];
if (action) { params.push(action); where.push(`al.action = $${params.length}`); }
if (resourceType) { params.push(resourceType); where.push(`al.resource_type = $${params.length}`); }
if (familyId) { params.push(familyId); where.push(`al.family_id = $${params.length}::uuid`); }
if (userId) { params.push(userId); where.push(`al.user_id = $${params.length}::uuid`); }
if (q) { params.push(`%${q}%`); where.push(`(u.email ILIKE $${params.length} OR f.name ILIKE $${params.length} OR al.action ILIKE $${params.length})`); }
const whereSql = where.join(" AND ");
const events = await sql.unsafe(
`SELECT al.id, al.action, al.resource_type, al.resource_id, al.ip_address,
al.user_agent, al.metadata, al.created_at, al.user_id, al.family_id,
u.email AS user_email, u.name AS user_name, f.name AS family_name
FROM audit_log al
LEFT JOIN users u ON u.id = al.user_id
LEFT JOIN families f ON f.id = al.family_id
WHERE ${whereSql}
ORDER BY al.created_at DESC
LIMIT ${limit}`,
params as never[]
);
// Distinct action + resource-type values for the filter dropdowns.
const actions = await sql`SELECT DISTINCT action FROM audit_log ORDER BY action`;
const resourceTypes = await sql`SELECT DISTINCT resource_type FROM audit_log WHERE resource_type IS NOT NULL ORDER BY resource_type`;
return NextResponse.json({
events,
actions: actions.map(r => (r as { action: string }).action),
resourceTypes: resourceTypes.map(r => (r as { resource_type: string }).resource_type),
});
} catch (error) {
console.error("Admin audit feed error:", error);
return NextResponse.json({ events: [], actions: [], resourceTypes: [], error: String(error) });
}
}

View file

@ -2,15 +2,34 @@ import { NextResponse } from "next/server";
import { requireAdmin } from "@/lib/admin-auth"; import { requireAdmin } from "@/lib/admin-auth";
import { sql } from "@/db"; import { sql } from "@/db";
// Engagement / analytics feed for /admin/analytics.
//
// Each section runs in its own try/catch and pushes any failure into `errors`,
// so a single broken query degrades gracefully (partial data still renders)
// AND the real error message is surfaced on the page instead of a silent blank.
export async function GET(request: Request) { export async function GET(request: Request) {
const auth = await requireAdmin(request); const auth = await requireAdmin(request);
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status }); if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const errors: string[] = [];
let total = 0;
let featureAdoption: { name: string; count: number; pct: number }[] = [];
let families: Array<Record<string, unknown>> = [];
let aiUsage = { totalCalls: 0, totalTokens: 0, totalCostPaise: 0, familiesUsingAI: 0 };
let logsByDay: { date: string; count: number }[] = [];
// 0. Total families (cheap, robust — used for denominators + summary total)
try {
const rows = await sql`SELECT COUNT(*)::int AS count FROM families`;
total = Number(rows[0]?.count) || 0;
} catch (e) {
errors.push(`families_count: ${String(e)}`);
}
// A. Feature adoption — % of families that have used each feature at least once
try { try {
// A. Feature adoption — % of families that have used each feature at least once
const adoptionRows = await sql` const adoptionRows = await sql`
SELECT SELECT
COUNT(DISTINCT f.id)::int as total_families,
COUNT(DISTINCT f.id) FILTER (WHERE fd.id IS NOT NULL)::int as families_feeding, COUNT(DISTINCT f.id) FILTER (WHERE fd.id IS NOT NULL)::int as families_feeding,
COUNT(DISTINCT f.id) FILTER (WHERE dl.id IS NOT NULL)::int as families_diapers, COUNT(DISTINCT f.id) FILTER (WHERE dl.id IS NOT NULL)::int as families_diapers,
COUNT(DISTINCT f.id) FILTER (WHERE sl.id IS NOT NULL)::int as families_sleeping, COUNT(DISTINCT f.id) FILTER (WHERE sl.id IS NOT NULL)::int as families_sleeping,
@ -28,63 +47,78 @@ export async function GET(request: Request) {
LEFT JOIN LATERAL (SELECT id FROM memories WHERE family_id = f.id LIMIT 1) mem ON true LEFT JOIN LATERAL (SELECT id FROM memories WHERE family_id = f.id LIMIT 1) mem ON true
LEFT JOIN LATERAL (SELECT id FROM chat_sessions WHERE child_id = c.id LIMIT 1) cs ON true LEFT JOIN LATERAL (SELECT id FROM chat_sessions WHERE child_id = c.id LIMIT 1) cs ON true
`; `;
const a = adoptionRows[0] || {};
const denom = total || 1;
const pct = (n: unknown) => Math.round((Number(n) || 0) / denom * 100);
featureAdoption = [
{ name: "Feed Logs", count: Number(a.families_feeding) || 0, pct: pct(a.families_feeding) },
{ name: "Diaper Logs", count: Number(a.families_diapers) || 0, pct: pct(a.families_diapers) },
{ name: "Sleep Logs", count: Number(a.families_sleeping) || 0, pct: pct(a.families_sleeping) },
{ name: "Vaccinations", count: Number(a.families_vaccinating) || 0, pct: pct(a.families_vaccinating) },
{ name: "Growth Tracking", count: Number(a.families_growth) || 0, pct: pct(a.families_growth) },
{ name: "Memories", count: Number(a.families_memories) || 0, pct: pct(a.families_memories) },
{ name: "AI Chat", count: Number(a.families_ai) || 0, pct: pct(a.families_ai) },
].sort((x, y) => y.pct - x.pct);
} catch (e) {
errors.push(`feature_adoption: ${String(e)}`);
}
const adoption = adoptionRows[0] || {}; // B. Per-family engagement table.
const total = Number(adoption.total_families) || 1; //
// Pre-aggregate each log table to one row per child BEFORE joining — joining
const featureAdoption = [ // the raw tables together produces a cartesian product that times out in
{ name: "Feed Logs", count: Number(adoption.families_feeding) || 0, pct: Math.round((Number(adoption.families_feeding) || 0) / total * 100) }, // production. Memories are per-family. All MAX(*) timestamps are cast to
{ name: "Diaper Logs", count: Number(adoption.families_diapers) || 0, pct: Math.round((Number(adoption.families_diapers) || 0) / total * 100) }, // timestamptz so GREATEST() can't choke on mixed timestamp/timestamptz types.
{ name: "Sleep Logs", count: Number(adoption.families_sleeping) || 0, pct: Math.round((Number(adoption.families_sleeping) || 0) / total * 100) }, try {
{ name: "Vaccinations", count: Number(adoption.families_vaccinating) || 0, pct: Math.round((Number(adoption.families_vaccinating) || 0) / total * 100) },
{ name: "Growth Tracking", count: Number(adoption.families_growth) || 0, pct: Math.round((Number(adoption.families_growth) || 0) / total * 100) },
{ name: "Memories", count: Number(adoption.families_memories) || 0, pct: Math.round((Number(adoption.families_memories) || 0) / total * 100) },
{ name: "AI Chat", count: Number(adoption.families_ai) || 0, pct: Math.round((Number(adoption.families_ai) || 0) / total * 100) },
].sort((a, b) => b.pct - a.pct);
// B. Per-family engagement table
const familyRows = await sql` const familyRows = await sql`
WITH feed_agg AS (SELECT child_id, COUNT(*)::int AS cnt, MAX(logged_at) AS last_at FROM feeds GROUP BY child_id),
diaper_agg AS (SELECT child_id, COUNT(*)::int AS cnt, MAX(logged_at) AS last_at FROM diapers_logs GROUP BY child_id),
sleep_agg AS (SELECT child_id, COUNT(*)::int AS cnt, MAX(started_at) AS last_at FROM sleeps GROUP BY child_id),
vacc_agg AS (SELECT child_id, COUNT(*)::int AS cnt FROM vaccinations GROUP BY child_id),
growth_agg AS (SELECT child_id, COUNT(*)::int AS cnt FROM growth GROUP BY child_id),
chat_agg AS (SELECT child_id, COUNT(*)::int AS cnt FROM chat_sessions GROUP BY child_id),
mem_agg AS (SELECT family_id, COUNT(*)::int AS cnt, MAX(created_at) AS last_at FROM memories GROUP BY family_id)
SELECT SELECT
f.id, f.id,
f.name, f.name,
f.tier, f.tier,
f.created_at, f.created_at,
GREATEST( GREATEST(
MAX(fd.logged_at), MAX(fa.last_at)::timestamptz,
MAX(dl.logged_at), MAX(da.last_at)::timestamptz,
MAX(sl.started_at), MAX(sa.last_at)::timestamptz,
MAX(mem.created_at) MAX(ma.last_at)::timestamptz
) as last_activity, ) as last_activity,
COUNT(DISTINCT fd.id)::int as feed_count, COALESCE(SUM(fa.cnt), 0)::int as feed_count,
COUNT(DISTINCT dl.id)::int as diaper_count, COALESCE(SUM(da.cnt), 0)::int as diaper_count,
COUNT(DISTINCT sl.id)::int as sleep_count, COALESCE(SUM(sa.cnt), 0)::int as sleep_count,
COUNT(DISTINCT v.id)::int as vaccination_count, COALESCE(SUM(va.cnt), 0)::int as vaccination_count,
COUNT(DISTINCT g.id)::int as growth_count, COALESCE(SUM(ga.cnt), 0)::int as growth_count,
COUNT(DISTINCT mem.id)::int as memory_count, COALESCE(MAX(ma.cnt), 0)::int as memory_count,
COUNT(DISTINCT cs.id)::int as chat_count COALESCE(SUM(ca.cnt), 0)::int as chat_count
FROM families f FROM families f
LEFT JOIN children c ON c.family_id = f.id LEFT JOIN children c ON c.family_id = f.id
LEFT JOIN feeds fd ON fd.child_id = c.id LEFT JOIN feed_agg fa ON fa.child_id = c.id
LEFT JOIN diapers_logs dl ON dl.child_id = c.id LEFT JOIN diaper_agg da ON da.child_id = c.id
LEFT JOIN sleeps sl ON sl.child_id = c.id LEFT JOIN sleep_agg sa ON sa.child_id = c.id
LEFT JOIN vaccinations v ON v.child_id = c.id LEFT JOIN vacc_agg va ON va.child_id = c.id
LEFT JOIN growth g ON g.child_id = c.id LEFT JOIN growth_agg ga ON ga.child_id = c.id
LEFT JOIN memories mem ON mem.family_id = f.id LEFT JOIN chat_agg ca ON ca.child_id = c.id
LEFT JOIN chat_sessions cs ON cs.child_id = c.id LEFT JOIN mem_agg ma ON ma.family_id = f.id
GROUP BY f.id, f.name, f.tier, f.created_at GROUP BY f.id, f.name, f.tier, f.created_at
ORDER BY last_activity DESC NULLS LAST ORDER BY last_activity DESC NULLS LAST
`; `;
const now = Date.now(); const now = Date.now();
const families = familyRows.map((f: any) => { families = familyRows.map((f: Record<string, unknown>) => {
const lastActivity = f.last_activity ? new Date(f.last_activity).toISOString() : null; const lastActivity = f.last_activity ? new Date(f.last_activity as string).toISOString() : null;
const msSince = lastActivity ? now - new Date(lastActivity).getTime() : Infinity; const msSince = lastActivity ? now - new Date(lastActivity).getTime() : Infinity;
const activeStatus = !lastActivity ? "never" : msSince < 7 * 86400_000 ? "7d" : msSince < 30 * 86400_000 ? "30d" : "inactive"; const activeStatus = !lastActivity ? "never" : msSince < 7 * 86400_000 ? "7d" : msSince < 30 * 86400_000 ? "30d" : "inactive";
return { return {
id: f.id, id: f.id,
name: f.name, name: f.name,
tier: f.tier || "free", tier: f.tier || "free",
createdAt: f.created_at ? new Date(f.created_at).toISOString() : null, createdAt: f.created_at ? new Date(f.created_at as string).toISOString() : null,
lastActivity, lastActivity,
activeStatus, activeStatus,
feedCount: Number(f.feed_count) || 0, feedCount: Number(f.feed_count) || 0,
@ -95,38 +129,44 @@ export async function GET(request: Request) {
memoryCount: Number(f.memory_count) || 0, memoryCount: Number(f.memory_count) || 0,
chatCount: Number(f.chat_count) || 0, chatCount: Number(f.chat_count) || 0,
totalLogs: totalLogs:
Number(f.feed_count) + Number(f.diaper_count) + Number(f.sleep_count) + (Number(f.feed_count) || 0) + (Number(f.diaper_count) || 0) + (Number(f.sleep_count) || 0) +
Number(f.vaccination_count) + Number(f.growth_count), (Number(f.vaccination_count) || 0) + (Number(f.growth_count) || 0),
}; };
}); });
if (!total) total = families.length;
} catch (e) {
errors.push(`families: ${String(e)}`);
}
// C. Activity summary counts // C. Activity summary counts (derived from families)
const active7d = families.filter(f => f.activeStatus === "7d").length; const active7d = families.filter(f => f.activeStatus === "7d").length;
const active30d = families.filter(f => f.activeStatus === "30d").length; const active30d = families.filter(f => f.activeStatus === "30d").length;
const neverActive = families.filter(f => f.activeStatus === "never").length; const neverActive = families.filter(f => f.activeStatus === "never").length;
// D. AI usage last 30 days // D. AI usage last 30 days
let aiUsage = { totalCalls: 0, totalTokens: 0, totalCostPaise: 0, familiesUsingAI: 0 }; try {
try { const aiRows = await sql`
const aiRows = await sql` SELECT
SELECT COUNT(*)::int as total_calls,
COUNT(*)::int as total_calls, COALESCE(SUM(total_tokens), 0)::int as total_tokens,
COALESCE(SUM(total_tokens), 0)::int as total_tokens, COALESCE(SUM(cost_estimate_paise), 0)::numeric as total_cost_paise,
COALESCE(SUM(cost_estimate_paise), 0)::numeric as total_cost_paise, COUNT(DISTINCT family_id)::int as families_using_ai
COUNT(DISTINCT family_id)::int as families_using_ai FROM ai_usage
FROM ai_usage WHERE created_at > NOW() - INTERVAL '30 days'
WHERE created_at > NOW() - INTERVAL '30 days' `;
`; const row = aiRows[0] || {};
const row = aiRows[0] || {}; aiUsage = {
aiUsage = { totalCalls: Number(row.total_calls) || 0,
totalCalls: Number(row.total_calls) || 0, totalTokens: Number(row.total_tokens) || 0,
totalTokens: Number(row.total_tokens) || 0, totalCostPaise: Number(row.total_cost_paise) || 0,
totalCostPaise: Number(row.total_cost_paise) || 0, familiesUsingAI: Number(row.families_using_ai) || 0,
familiesUsingAI: Number(row.families_using_ai) || 0, };
}; } catch (e) {
} catch {} errors.push(`ai_usage: ${String(e)}`);
}
// E. Daily activity for chart (last 30 days, all log types combined) // E. Daily activity for chart (last 30 days, all log types combined)
try {
const dailyRows = await sql` const dailyRows = await sql`
SELECT day::date as date, SUM(cnt)::int as count SELECT day::date as date, SUM(cnt)::int as count
FROM ( FROM (
@ -138,25 +178,27 @@ export async function GET(request: Request) {
UNION ALL UNION ALL
SELECT DATE(created_at) as day, COUNT(*) as cnt FROM memories WHERE created_at > NOW() - INTERVAL '30 days' GROUP BY day SELECT DATE(created_at) as day, COUNT(*) as cnt FROM memories WHERE created_at > NOW() - INTERVAL '30 days' GROUP BY day
) sub ) sub
WHERE day IS NOT NULL
GROUP BY day GROUP BY day
ORDER BY day ORDER BY day
`; `;
logsByDay = dailyRows.map((r: Record<string, unknown>) => ({
const logsByDay = dailyRows.map((r: any) => ({
date: r.date instanceof Date ? r.date.toISOString().split("T")[0] : String(r.date).split("T")[0], date: r.date instanceof Date ? r.date.toISOString().split("T")[0] : String(r.date).split("T")[0],
count: Number(r.count), count: Number(r.count) || 0,
})); }));
} catch (e) {
return NextResponse.json({ errors.push(`logs_by_day: ${String(e)}`);
totalFamilies: total,
featureAdoption,
families,
activitySummary: { active7d, active30d, neverActive, total },
aiUsage,
logsByDay,
});
} catch (error) {
console.error("Admin engagement error:", error);
return NextResponse.json({ error: String(error) }, { status: 500 });
} }
if (errors.length) console.error("Admin engagement partial errors:", errors);
return NextResponse.json({
totalFamilies: total,
featureAdoption,
families,
activitySummary: { active7d, active30d, neverActive, total },
aiUsage,
logsByDay,
...(errors.length ? { error: errors.join(" | ") } : {}),
});
} }

View file

@ -0,0 +1,71 @@
import { NextResponse } from "next/server";
import { requireAdmin } from "@/lib/admin-auth";
import { sql } from "@/db";
// Admin error tracker feed. Returns recent error events (filterable), a grouped
// "top errors" rollup, and headline counts.
export async function GET(request: Request) {
const auth = await requireAdmin(request);
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const { searchParams } = new URL(request.url);
const source = searchParams.get("source"); // client | server | null
const level = searchParams.get("level"); // error | warn | fatal | null
const q = searchParams.get("q")?.trim();
const sinceHours = Math.min(Math.max(parseInt(searchParams.get("sinceHours") || "168", 10) || 168, 1), 24 * 90);
const limit = Math.min(Math.max(parseInt(searchParams.get("limit") || "100", 10) || 100, 1), 500);
try {
// Build dynamic WHERE with positional params.
const where: string[] = [`e.created_at > NOW() - make_interval(hours => $1)`];
const params: unknown[] = [sinceHours];
if (source === "client" || source === "server") { params.push(source); where.push(`e.source = $${params.length}`); }
if (level === "error" || level === "warn" || level === "fatal") { params.push(level); where.push(`e.level = $${params.length}`); }
if (q) { params.push(`%${q}%`); where.push(`e.message ILIKE $${params.length}`); }
const whereSql = where.join(" AND ");
const events = await sql.unsafe(
`SELECT e.id, e.level, e.source, e.message, e.stack, e.url, e.digest,
e.user_id, e.family_id, e.user_agent, e.created_at,
u.email AS user_email, f.name AS family_name
FROM error_events e
LEFT JOIN users u ON u.id = e.user_id
LEFT JOIN families f ON f.id = e.family_id
WHERE ${whereSql}
ORDER BY e.created_at DESC
LIMIT ${limit}`,
params as never[]
);
const grouped = await sql.unsafe(
`SELECT message, source,
COUNT(*)::int AS count,
MAX(created_at) AS last_seen,
MIN(created_at) AS first_seen
FROM error_events e
WHERE ${whereSql}
GROUP BY message, source
ORDER BY count DESC
LIMIT 50`,
params as never[]
);
const stats = await sql`
SELECT
COUNT(*) FILTER (WHERE created_at > NOW() - INTERVAL '24 hours')::int AS last24h,
COUNT(*) FILTER (WHERE created_at > NOW() - INTERVAL '7 days')::int AS last7d,
COUNT(*) FILTER (WHERE source = 'client' AND created_at > NOW() - INTERVAL '7 days')::int AS client7d,
COUNT(*) FILTER (WHERE source = 'server' AND created_at > NOW() - INTERVAL '7 days')::int AS server7d
FROM error_events
`;
return NextResponse.json({
events,
grouped,
stats: stats[0] || { last24h: 0, last7d: 0, client7d: 0, server7d: 0 },
});
} catch (error) {
console.error("Admin errors feed error:", error);
return NextResponse.json({ events: [], grouped: [], stats: { last24h: 0, last7d: 0, client7d: 0, server7d: 0 }, error: String(error) });
}
}

View file

@ -1,6 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { sql } from "@/db"; import { sql } from "@/db";
import { requireAdmin } from "@/lib/admin-auth"; import { requireAdmin } from "@/lib/admin-auth";
import { grantPremium, revokeToFree } from "@/lib/billing/entitlements";
// GET all families with members // GET all families with members
export async function GET(request: Request) { export async function GET(request: Request) {
@ -54,6 +55,35 @@ export async function GET(request: Request) {
}); });
}); });
// Subscription info per family (most recent sub). Wrapped in try/catch so
// the families page still works if the billing tables aren't present.
const subMap = new Map<string, any>();
if (familyIds.length > 0) {
try {
const subs = await sql`
SELECT DISTINCT ON (fs.family_id)
fs.family_id, fs.status, fs.current_start, fs.current_end,
fs.cancelled_at, p.name AS plan_name, p.price_paise
FROM family_subscriptions fs
LEFT JOIN subscription_plans p ON p.id = fs.plan_id
WHERE fs.family_id = ANY(${familyIds})
ORDER BY fs.family_id, fs.created_at DESC
`;
(subs || []).forEach((s: any) => {
subMap.set(s.family_id, {
status: s.status,
planName: s.plan_name,
pricePaise: s.price_paise ? Number(s.price_paise) : null,
startedAt: s.current_start ? new Date(s.current_start).toISOString() : null,
expiresAt: s.current_end ? new Date(s.current_end).toISOString() : null,
cancelledAt: s.cancelled_at ? new Date(s.cancelled_at).toISOString() : null,
});
});
} catch {
/* billing tables not present yet */
}
}
return NextResponse.json({ return NextResponse.json({
families: families.map((f: any) => ({ families: families.map((f: any) => ({
id: f.id, id: f.id,
@ -67,6 +97,7 @@ export async function GET(request: Request) {
logCount: Number(f.log_count) || 0, logCount: Number(f.log_count) || 0,
memoryCount: Number(f.memory_count) || 0, memoryCount: Number(f.memory_count) || 0,
members: memberMap.get(f.id) || [], members: memberMap.get(f.id) || [],
subscription: subMap.get(f.id) || null,
})), })),
}); });
} catch (error) { } catch (error) {
@ -88,14 +119,28 @@ export async function PATCH(request: Request) {
return NextResponse.json({ error: "familyId required" }, { status: 400 }); return NextResponse.json({ error: "familyId required" }, { status: 400 });
} }
await sql` // Tier change → apply the SAME grant/revoke logic the webhook uses, so a
UPDATE families // manual/comp upgrade gets the real premium grant (50GB/6/3), not ad-hoc
SET tier = COALESCE(${tier}, tier), // hardcoded limits. subscription_status records it was set by admin.
max_children = COALESCE(${maxChildren}, max_children), if (tier === "pro") {
max_members = COALESCE(${maxMembers}, max_members) await grantPremium(familyId, "admin_comp");
WHERE id = ${familyId} } else if (tier === "free") {
`; await revokeToFree(familyId, "admin_downgrade");
}
// Explicit limit overrides (optional) still win — lets admin fine-tune.
if (maxChildren != null || maxMembers != null) {
await sql`
UPDATE families
SET max_children = COALESCE(${maxChildren ?? null}, max_children),
max_members = COALESCE(${maxMembers ?? null}, max_members),
updated_at = NOW()
WHERE id = ${familyId}
`;
}
// If only limits were passed (no tier), nothing above ran the tier path —
// that's fine, the override block handled it.
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} catch (error) { } catch (error) {
console.error("Admin families error:", error); console.error("Admin families error:", error);

View file

@ -0,0 +1,109 @@
import { NextResponse } from "next/server";
import { requireAdmin } from "@/lib/admin-auth";
import { sql } from "@/db";
type Check = { name: string; status: "ok" | "warn" | "down"; detail: string };
// System health snapshot: DB connectivity + latency, migration status, recent
// error volume, and which integrations are configured. Read-only and cheap —
// no external round-trips, just config presence + DB queries.
export async function GET(request: Request) {
const auth = await requireAdmin(request);
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const checks: Check[] = [];
// 1. Database connectivity + latency
let dbOk = false;
try {
const t0 = Date.now();
await sql`SELECT 1`;
const ms = Date.now() - t0;
dbOk = true;
checks.push({ name: "Database", status: ms < 500 ? "ok" : "warn", detail: `Connected — ${ms}ms` });
} catch (e) {
checks.push({ name: "Database", status: "down", detail: String(e).slice(0, 200) });
}
// 2. Migrations applied (drizzle stores its journal in drizzle.__drizzle_migrations)
if (dbOk) {
try {
const rows = await sql`
SELECT COUNT(*)::int AS count, MAX(created_at) AS latest
FROM drizzle.__drizzle_migrations
`;
const count = Number(rows[0]?.count) || 0;
const latest = rows[0]?.latest ? new Date(Number(rows[0].latest)).toISOString().split("T")[0] : "—";
checks.push({ name: "Migrations", status: count > 0 ? "ok" : "warn", detail: `${count} applied (latest ${latest})` });
} catch {
checks.push({ name: "Migrations", status: "warn", detail: "Could not read migration journal" });
}
}
// 3. Recent error volume (from the error tracker)
let recentErrors = { last24h: 0, last1h: 0 };
if (dbOk) {
try {
const rows = await sql`
SELECT
COUNT(*) FILTER (WHERE created_at > NOW() - INTERVAL '24 hours')::int AS last24h,
COUNT(*) FILTER (WHERE created_at > NOW() - INTERVAL '1 hour')::int AS last1h
FROM error_events
`;
recentErrors = { last24h: Number(rows[0]?.last24h) || 0, last1h: Number(rows[0]?.last1h) || 0 };
checks.push({
name: "Errors (24h)",
status: recentErrors.last1h > 5 ? "down" : recentErrors.last24h > 0 ? "warn" : "ok",
detail: `${recentErrors.last24h} in 24h · ${recentErrors.last1h} in last hour`,
});
} catch {
checks.push({ name: "Errors (24h)", status: "warn", detail: "error_events table unavailable" });
}
}
// 4. Integration config presence (no live calls — just whether env is wired)
const configCheck = (name: string, present: boolean, missingHint: string): Check =>
({ name, status: present ? "ok" : "warn", detail: present ? "Configured" : missingHint });
checks.push(configCheck("AI Gateway", !!(process.env.LITELLM_BASE_URL && process.env.LITELLM_API_KEY), "LITELLM_BASE_URL / LITELLM_API_KEY not set"));
checks.push(configCheck("R2 Storage", !!(process.env.R2_ACCOUNT_ID && process.env.R2_ACCESS_KEY_ID && process.env.R2_BUCKET_NAME), "R2_* env vars incomplete"));
checks.push(configCheck("Email (Resend)", !!process.env.RESEND_API_KEY, "RESEND_API_KEY not set"));
checks.push(configCheck("Razorpay", !!(process.env.RAZORPAY_KEY_ID && process.env.RAZORPAY_KEY_SECRET && process.env.RAZORPAY_WEBHOOK_SECRET && process.env.RAZORPAY_PLAN_ID), "RAZORPAY_* env vars incomplete"));
// 5. Razorpay webhook freshness — if webhooks silently stop, entitlement
// (and revenue) quietly breaks. Only meaningful once subscriptions exist.
if (dbOk) {
try {
const rows = await sql`
SELECT
(SELECT COUNT(*) FROM family_subscriptions)::int AS sub_count,
(SELECT MAX(received_at) FROM razorpay_webhook_events) AS last_event
`;
const subCount = Number(rows[0]?.sub_count) || 0;
const lastEvent = rows[0]?.last_event ? new Date(rows[0].last_event as string) : null;
if (subCount === 0) {
checks.push({ name: "Razorpay Webhooks", status: "ok", detail: "No subscriptions yet" });
} else if (!lastEvent) {
checks.push({ name: "Razorpay Webhooks", status: "warn", detail: "Subscriptions exist but no webhook ever received" });
} else {
const hoursAgo = (Date.now() - lastEvent.getTime()) / 3_600_000;
const rel =
hoursAgo < 1 ? `${Math.round(hoursAgo * 60)}m ago`
: hoursAgo < 48 ? `${Math.round(hoursAgo)}h ago`
: `${Math.round(hoursAgo / 24)}d ago`;
// Subscriptions renew at least monthly, so >35 days of silence is suspect.
const status = hoursAgo > 35 * 24 ? "warn" : "ok";
checks.push({ name: "Razorpay Webhooks", status, detail: `Last event ${rel}` });
}
} catch {
checks.push({ name: "Razorpay Webhooks", status: "warn", detail: "Billing tables unavailable" });
}
}
const overall: "ok" | "warn" | "down" = checks.some(c => c.status === "down")
? "down"
: checks.some(c => c.status === "warn") ? "warn" : "ok";
return NextResponse.json({ overall, checks, recentErrors, checkedAt: new Date().toISOString() });
}

View file

@ -0,0 +1,121 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
import { requireAdmin } from "@/lib/admin-auth";
import { grantPremium, revokeToFree, ENTITLED_STATUSES, TERMINAL_STATUSES } from "@/lib/billing/entitlements";
/**
* POST /api/admin/reconcile-subscriptions admin recovery + diagnostic.
*
* For each family_subscription, looks at the most recent webhook event we
* logged for its Razorpay subscription id, derives the intended status, and
* (re)applies the grant/revoke + row update catching and RETURNING any error.
*
* Why this exists: the webhook logs an event before processing it, so a
* processing error left the event logged-but-unprocessed and the idempotency
* guard blocked retries. This endpoint reprocesses those stuck events safely
* (all grant/revoke ops are idempotent) and surfaces the real error per sub.
*/
const GRANT_FROM_EVENT: Record<string, string> = {
"subscription.authenticated": "authenticated",
"subscription.activated": "active",
"subscription.charged": "active",
"subscription.resumed": "active",
"subscription.pending": "pending",
};
const REVOKE_FROM_EVENT: Record<string, string> = {
"subscription.halted": "halted",
"subscription.cancelled": "cancelled",
"subscription.completed": "completed",
"subscription.expired": "expired",
"subscription.paused": "paused",
};
export async function POST(request: Request) {
const auth = await requireAdmin(request);
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const subs = await sql`
SELECT id, family_id, razorpay_subscription_id, status FROM family_subscriptions
`;
const results: unknown[] = [];
for (const s of subs as Record<string, unknown>[]) {
const subId = s.razorpay_subscription_id as string;
const rowId = s.id as string;
const familyId = s.family_id as string;
// Most recent webhook event we have for this subscription.
const ev = await sql`
SELECT event_type, payload FROM razorpay_webhook_events
WHERE payload->'payload'->'subscription'->'entity'->>'id' = ${subId}
ORDER BY received_at DESC
LIMIT 1
`;
const event = ev[0] as { event_type: string; payload: Record<string, unknown> } | undefined;
if (!event) {
results.push({ subId, action: "skipped", reason: "no webhook event logged yet" });
continue;
}
const eventType = event.event_type;
const grant = GRANT_FROM_EVENT[eventType];
const revoke = REVOKE_FROM_EVENT[eventType];
try {
// Pull current_start/current_end from the stored payload.
const entity = ((event.payload as Record<string, unknown>)?.payload as Record<string, unknown>)
?.subscription as Record<string, unknown> | undefined;
const ent = (entity as { entity?: Record<string, unknown> })?.entity;
// ISO strings — postgres.js binds timestamps as strings, not Date objects.
const toISO = (v: unknown) => {
const n = Number(v);
return Number.isFinite(n) && n > 0 ? new Date(n * 1000).toISOString() : null;
};
const currentStart = toISO(ent?.current_start);
const currentEnd = toISO(ent?.current_end);
const customerId = (ent?.customer_id as string) ?? null;
if (grant) {
await sql`
UPDATE family_subscriptions SET
status = ${grant}::subscription_status_enum,
razorpay_customer_id = COALESCE(${customerId}, razorpay_customer_id),
current_start = COALESCE(${currentStart}, current_start),
current_end = COALESCE(${currentEnd}, current_end),
updated_at = NOW()
WHERE id = ${rowId}
`;
await grantPremium(familyId, grant);
results.push({ subId, familyId, eventType, applied: "grant", status: grant, ok: true });
} else if (revoke) {
await sql`
UPDATE family_subscriptions SET
status = ${revoke}::subscription_status_enum,
updated_at = NOW()
WHERE id = ${rowId}
`;
await revokeToFree(familyId, revoke);
results.push({ subId, familyId, eventType, applied: "revoke", status: revoke, ok: true });
} else {
results.push({ subId, eventType, action: "skipped", reason: "unhandled event type" });
}
} catch (e) {
results.push({ subId, familyId, eventType, ok: false, error: String(e) });
}
}
// Show resulting family tiers so we can confirm the grant landed.
const families = await sql`
SELECT id, tier, subscription_status, max_members, max_children
FROM families WHERE tier != 'free' OR subscription_status IS NOT NULL
`;
return NextResponse.json({
success: true,
reconciled: results,
paidFamilies: families,
entitledStatuses: [...ENTITLED_STATUSES],
terminalStatuses: [...TERMINAL_STATUSES],
});
}

View file

@ -0,0 +1,59 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
import { requireAdmin } from "@/lib/admin-auth";
import {
getRazorpayConfig,
PREMIUM_PLAN_NAME,
PREMIUM_PRICE_PAISE,
PREMIUM_STORAGE_BYTES,
PREMIUM_MEMBER_LIMIT,
PREMIUM_CHILD_LIMIT,
} from "@/lib/billing/config";
/**
* POST /api/admin/seed-plan idempotent upsert of the Tia Premium plan row.
*
* Admin-only. Reads RAZORPAY_PLAN_ID + the premium grant constants and writes
* one subscription_plans row. Re-runnable: ON CONFLICT updates the grant values
* so you can tweak storage/member limits and re-seed without a DB shell.
*
* GET shows the current plan row(s) for verification.
*/
export async function GET(request: Request) {
const auth = await requireAdmin(request);
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const rows = await sql`SELECT * FROM subscription_plans ORDER BY created_at DESC`;
return NextResponse.json({ plans: rows });
}
export async function POST(request: Request) {
const auth = await requireAdmin(request);
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
let cfg;
try {
cfg = getRazorpayConfig();
} catch (e) {
return NextResponse.json({ error: String(e) }, { status: 500 });
}
const rows = await sql`
INSERT INTO subscription_plans
(razorpay_plan_id, name, price_paise, storage_bytes, member_limit, child_limit, is_active)
VALUES (
${cfg.planId}, ${PREMIUM_PLAN_NAME}, ${PREMIUM_PRICE_PAISE},
${PREMIUM_STORAGE_BYTES}, ${PREMIUM_MEMBER_LIMIT}, ${PREMIUM_CHILD_LIMIT}, true
)
ON CONFLICT (razorpay_plan_id) DO UPDATE SET
name = EXCLUDED.name,
price_paise = EXCLUDED.price_paise,
storage_bytes = EXCLUDED.storage_bytes,
member_limit = EXCLUDED.member_limit,
child_limit = EXCLUDED.child_limit,
is_active = true
RETURNING *
`;
return NextResponse.json({ success: true, plan: rows[0] });
}

View file

@ -21,7 +21,23 @@ export async function GET(request: Request) {
const proFamilies = tierStats.find((t: any) => t.tier === "pro")?.count || 0; const proFamilies = tierStats.find((t: any) => t.tier === "pro")?.count || 0;
const freeFamilies = tierStats.find((t: any) => t.tier === "free")?.count || 0; const freeFamilies = tierStats.find((t: any) => t.tier === "free")?.count || 0;
const totalFamilies = familyCount[0]?.count || 0; const totalFamilies = familyCount[0]?.count || 0;
const mrr = proFamilies * 9.99;
// Real MRR (₹) from active subscriptions — only entitled/recurring states.
// Sums the plan price (paise) of each family_subscriptions row that is
// currently active/authenticated/pending; falls back to 0 if no subs table.
let mrrPaise = 0;
try {
const mrrRow = await sql`
SELECT COALESCE(SUM(p.price_paise), 0)::bigint AS paise
FROM family_subscriptions fs
JOIN subscription_plans p ON p.id = fs.plan_id
WHERE fs.status IN ('active','authenticated','pending')
`;
mrrPaise = Number(mrrRow[0]?.paise) || 0;
} catch {
// subscriptions tables not present yet — leave MRR at 0
}
const mrr = mrrPaise / 100; // rupees
const [familiesByDay, usersByDay, childrenByAge, recentLogins, failedLogins] = await Promise.all([ const [familiesByDay, usersByDay, childrenByAge, recentLogins, failedLogins] = await Promise.all([
sql` sql`

View file

@ -0,0 +1,111 @@
import { NextResponse } from "next/server";
import { requireAdmin } from "@/lib/admin-auth";
import { sql } from "@/db";
// Storage / billing monitor. Usage is SUM(size_bytes) across memories +
// attachments per family — the SAME basis the quota system enforces
// (src/lib/quota.ts), so admin numbers match what users are charged on.
const FREE_LIMIT_BYTES = 1_073_741_824; // 1 GiB (matches FREE_STORAGE_LIMIT_BYTES)
const R2_USD_PER_GB_MONTH = 0.015; // Cloudflare R2 storage price
export async function GET(request: Request) {
const auth = await requireAdmin(request);
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const errors: string[] = [];
let families: Array<Record<string, unknown>> = [];
let totalBytes = 0;
let totalObjects = 0;
let byDay: { date: string; bytes: number; count: number }[] = [];
// Per-family usage — aggregate each table to one row per family BEFORE joining
// (no cartesian blow-up), then sum. Sorted by heaviest consumer first.
try {
const rows = await sql`
WITH mem AS (
SELECT family_id, COALESCE(SUM(size_bytes::bigint), 0)::bigint AS bytes, COUNT(*)::int AS cnt
FROM memories GROUP BY family_id
),
att AS (
SELECT family_id, COALESCE(SUM(size_bytes::bigint), 0)::bigint AS bytes, COUNT(*)::int AS cnt
FROM attachments GROUP BY family_id
)
SELECT
f.id, f.name, f.tier,
(COALESCE(m.bytes, 0) + COALESCE(a.bytes, 0))::bigint AS total_bytes,
COALESCE(m.cnt, 0)::int AS memory_count,
COALESCE(a.cnt, 0)::int AS attachment_count
FROM families f
LEFT JOIN mem m ON m.family_id = f.id
LEFT JOIN att a ON a.family_id = f.id
ORDER BY total_bytes DESC
`;
families = rows.map((r: Record<string, unknown>) => {
const bytes = Number(r.total_bytes) || 0;
const isPaid = !!r.tier && r.tier !== "free";
return {
id: r.id,
name: r.name,
tier: r.tier || "free",
isPaid,
bytes,
memoryCount: Number(r.memory_count) || 0,
attachmentCount: Number(r.attachment_count) || 0,
objectCount: (Number(r.memory_count) || 0) + (Number(r.attachment_count) || 0),
// fraction of the free limit (paid families have no limit)
fraction: isPaid ? 0 : bytes / FREE_LIMIT_BYTES,
overLimit: !isPaid && bytes > FREE_LIMIT_BYTES,
};
});
totalBytes = families.reduce((s, f) => s + (f.bytes as number), 0);
totalObjects = families.reduce((s, f) => s + (f.objectCount as number), 0);
} catch (e) {
errors.push(`families: ${String(e)}`);
}
// Daily upload volume (bytes + objects), last 30 days
try {
const rows = await sql`
SELECT day::date AS date, SUM(bytes)::bigint AS bytes, SUM(cnt)::int AS count
FROM (
SELECT DATE(created_at) AS day, COALESCE(SUM(size_bytes::bigint), 0) AS bytes, COUNT(*) AS cnt
FROM memories WHERE created_at > NOW() - INTERVAL '30 days' GROUP BY day
UNION ALL
SELECT DATE(created_at) AS day, COALESCE(SUM(size_bytes::bigint), 0) AS bytes, COUNT(*) AS cnt
FROM attachments WHERE created_at > NOW() - INTERVAL '30 days' GROUP BY day
) sub
WHERE day IS NOT NULL
GROUP BY day ORDER BY day
`;
byDay = rows.map((r: Record<string, unknown>) => ({
date: r.date instanceof Date ? r.date.toISOString().split("T")[0] : String(r.date).split("T")[0],
bytes: Number(r.bytes) || 0,
count: Number(r.count) || 0,
}));
} catch (e) {
errors.push(`by_day: ${String(e)}`);
}
const paidCount = families.filter(f => f.isPaid).length;
const overLimitCount = families.filter(f => f.overLimit).length;
const approachingCount = families.filter(f => !f.isPaid && (f.fraction as number) >= 0.8 && !(f.overLimit as boolean)).length;
const estMonthlyCostUsd = (totalBytes / 1e9) * R2_USD_PER_GB_MONTH;
return NextResponse.json({
summary: {
totalBytes,
totalObjects,
familyCount: families.length,
paidCount,
freeCount: families.length - paidCount,
overLimitCount,
approachingCount,
estMonthlyCostUsd,
freeLimitBytes: FREE_LIMIT_BYTES,
avgBytesPerFamily: families.length ? Math.round(totalBytes / families.length) : 0,
},
families,
byDay,
...(errors.length ? { error: errors.join(" | ") } : {}),
});
}

View file

@ -0,0 +1,159 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
import { requireAdmin } from "@/lib/admin-auth";
import { getRazorpayConfig, razorpayAuthHeader, RAZORPAY_API_BASE } from "@/lib/billing/config";
/**
* GET /api/admin/subscriptions subscription monitoring data:
* - subscriptions: every family_subscriptions row joined to family + plan
* - webhookEvents: recent razorpay_webhook_events (debugging delivery)
* - summary: counts by status + MRR/ARR in paise (active+authenticated+pending)
*/
export async function GET(request: Request) {
const auth = await requireAdmin(request);
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const subscriptions = await sql`
SELECT
fs.id,
fs.family_id,
f.name AS family_name,
p.name AS plan_name,
p.price_paise,
fs.status,
fs.razorpay_subscription_id,
fs.razorpay_customer_id,
fs.current_start,
fs.current_end,
fs.cancelled_at,
fs.ended_at,
fs.created_at,
fs.updated_at
FROM family_subscriptions fs
LEFT JOIN families f ON f.id = fs.family_id
LEFT JOIN subscription_plans p ON p.id = fs.plan_id
ORDER BY fs.created_at DESC
LIMIT 200
`;
const webhookEvents = await sql`
SELECT
razorpay_event_id,
event_type,
received_at,
payload->'payload'->'subscription'->'entity'->>'id' AS sub_id,
payload->'payload'->'subscription'->'entity'->>'status' AS sub_status
FROM razorpay_webhook_events
ORDER BY received_at DESC
LIMIT 50
`;
// Summary: status counts + MRR (only entitled/recurring-active statuses).
const byStatus: Record<string, number> = {};
let mrrPaise = 0;
for (const s of subscriptions as Record<string, unknown>[]) {
const status = s.status as string;
byStatus[status] = (byStatus[status] || 0) + 1;
if (status === "active" || status === "authenticated" || status === "pending") {
mrrPaise += Number(s.price_paise) || 0;
}
}
// Real monthly revenue trend from subscription.charged webhook events.
// Each charged event = one plan-price collection. We join back to the sub's
// plan to value it (price_paise), grouped by IST month.
let revenueTrend: { month: string; paise: number; charges: number }[] = [];
try {
const trend = await sql`
SELECT
to_char((e.received_at AT TIME ZONE 'Asia/Kolkata'), 'YYYY-MM') AS month,
COUNT(*)::int AS charges,
COALESCE(SUM(p.price_paise), 0)::bigint AS paise
FROM razorpay_webhook_events e
JOIN family_subscriptions fs
ON fs.razorpay_subscription_id = e.payload->'payload'->'subscription'->'entity'->>'id'
JOIN subscription_plans p ON p.id = fs.plan_id
WHERE e.event_type = 'subscription.charged'
AND e.received_at > NOW() - INTERVAL '12 months'
GROUP BY 1
ORDER BY 1
`;
revenueTrend = (trend as Record<string, unknown>[]).map((r) => ({
month: r.month as string,
paise: Number(r.paise) || 0,
charges: Number(r.charges) || 0,
}));
} catch { /* billing tables absent */ }
// Churn: cancelled+halted+expired ÷ all subs that ever became live.
const everLive = (subscriptions as Record<string, unknown>[]).filter(
(s) => s.status !== "created",
).length;
const churned = (subscriptions as Record<string, unknown>[]).filter((s) =>
["cancelled", "halted", "expired"].includes(s.status as string),
).length;
const churnRate = everLive > 0 ? Math.round((churned / everLive) * 1000) / 10 : 0;
return NextResponse.json({
subscriptions,
webhookEvents,
summary: {
total: subscriptions.length,
byStatus,
mrrPaise,
arrPaise: mrrPaise * 12,
churnRate, // %
churned,
everLive,
},
revenueTrend,
});
}
/**
* POST /api/admin/subscriptions
* body { action: "reconcile" } re-run entitlement sync
* body { action: "cancel", subscriptionId } cancel a sub (admin, any family)
*/
export async function POST(request: Request) {
const auth = await requireAdmin(request);
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const body = await request.json().catch(() => ({}));
const action = body.action || "reconcile";
if (action === "cancel") {
const subId = body.subscriptionId as string | undefined;
if (!subId) return NextResponse.json({ error: "subscriptionId required" }, { status: 400 });
let cfg;
try {
cfg = getRazorpayConfig();
} catch (e) {
return NextResponse.json({ error: String(e) }, { status: 500 });
}
try {
const res = await fetch(`${RAZORPAY_API_BASE}/subscriptions/${subId}/cancel`, {
method: "POST",
headers: { Authorization: razorpayAuthHeader(cfg), "Content-Type": "application/json" },
body: JSON.stringify({ cancel_at_cycle_end: 1 }),
});
const data = await res.json();
if (!res.ok) {
return NextResponse.json(
{ error: data?.error?.description || "Cancel failed", razorpay: data },
{ status: 502 },
);
}
// The subscription.cancelled webhook will sync state; this just initiates.
return NextResponse.json({ success: true, message: "Cancellation scheduled at cycle end." });
} catch (e) {
return NextResponse.json({ error: String(e) }, { status: 502 });
}
}
// Default: reconcile. Delegate to the recovery endpoint.
const { POST: reconcilePOST } = await import("../reconcile-subscriptions/route");
return reconcilePOST(request);
}

View file

@ -16,6 +16,7 @@ export async function GET(request: Request) {
u.id, u.id,
u.email, u.email,
u.name, u.name,
u.phone,
u.password_hash, u.password_hash,
fm.family_id, fm.family_id,
fm.id as member_id, fm.id as member_id,
@ -32,6 +33,7 @@ export async function GET(request: Request) {
id: u.id, id: u.id,
email: u.email, email: u.email,
name: u.name, name: u.name,
phone: u.phone || null,
hasPassword: !!u.password_hash, hasPassword: !!u.password_hash,
familyId: u.family_id, familyId: u.family_id,
memberId: u.member_id, memberId: u.member_id,

View file

@ -38,8 +38,10 @@ export async function POST(request: Request) {
// ── 1. HARD GUARDRAIL: keyword-based medical detection (most conservative) ── // ── 1. HARD GUARDRAIL: keyword-based medical detection (most conservative) ──
const medicalIntent = detectMedicalIntent(lastUserMsg); const medicalIntent = detectMedicalIntent(lastUserMsg);
if (medicalIntent.isMedical) { if (medicalIntent.isMedical) {
const families = await sql`SELECT pediatrician_phone FROM families WHERE id = ${familyId} LIMIT 1`; const families = await sql`SELECT pediatrician_phone, pediatrician_name FROM families WHERE id = ${familyId} LIMIT 1`;
const phone = families[0]?.pediatrician_phone; const phone = families[0]?.pediatrician_phone;
const pedName = families[0]?.pediatrician_name;
const pedLabel = pedName ? `Dr. ${pedName.replace(/^Dr\.?\s*/i, "")}` : "your pediatrician";
const reply = [ const reply = [
`I can't interpret symptoms — that's a pediatrician's job, not mine.`, `I can't interpret symptoms — that's a pediatrician's job, not mine.`,
@ -47,8 +49,8 @@ export async function POST(request: Request) {
ESCALATION_RULES[medicalIntent.category], ESCALATION_RULES[medicalIntent.category],
``, ``,
phone phone
? `Call your pediatrician now: ${phone}` ? `Call ${pedLabel} now: ${phone}`
: `Add your pediatrician's phone in Settings so I can show it here.`, : `Add your pediatrician's info in Settings so I can show it here.`,
].join("\n"); ].join("\n");
await logAudit({ await logAudit({
@ -84,13 +86,15 @@ export async function POST(request: Request) {
// Medical_redirect from classifier → also redirect // Medical_redirect from classifier → also redirect
if (classification.intent === "medical_redirect") { if (classification.intent === "medical_redirect") {
const families = await sql`SELECT pediatrician_phone FROM families WHERE id = ${familyId} LIMIT 1`; const families = await sql`SELECT pediatrician_phone, pediatrician_name FROM families WHERE id = ${familyId} LIMIT 1`;
const phone = families[0]?.pediatrician_phone; const phone = families[0]?.pediatrician_phone;
const pedName = families[0]?.pediatrician_name;
const pedLabel = pedName ? `Dr. ${pedName.replace(/^Dr\.?\s*/i, "")}` : "your pediatrician";
const reply = [ const reply = [
`That sounds like something your pediatrician should assess.`, `That sounds like something your pediatrician should assess.`,
``, ``,
ESCALATION_RULES["default"], ESCALATION_RULES["default"],
phone ? `Call: ${phone}` : `Add your pediatrician's phone in Settings.`, phone ? `Call ${pedLabel}: ${phone}` : `Add your pediatrician's info in Settings.`,
].join("\n"); ].join("\n");
await logUsage({ familyId, userId: session.userId, intent: "medical_redirect", durationMs: Date.now() - start }); await logUsage({ familyId, userId: session.userId, intent: "medical_redirect", durationMs: Date.now() - start });

View file

@ -1,6 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { sql } from "@/db"; import { sql } from "@/db";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { toProxyUrl } from "@/lib/r2-proxy";
// GET current user profile from session // GET current user profile from session
export async function GET() { export async function GET() {
@ -14,7 +15,7 @@ export async function GET() {
// Get session and user // Get session and user
const sessions = await sql` const sessions = await sql`
SELECT s.user_id, s.expires, u.id, u.email, u.name, u.image, u.created_at SELECT s.user_id, s.expires, u.id, u.email, u.name, u.image, u.phone, u.created_at
FROM sessions s FROM sessions s
JOIN users u ON u.id = s.user_id JOIN users u ON u.id = s.user_id
WHERE s.session_token = ${sessionToken} WHERE s.session_token = ${sessionToken}
@ -40,7 +41,8 @@ export async function GET() {
id: session.id, id: session.id,
email: session.email, email: session.email,
name: session.name || "Parent", name: session.name || "Parent",
avatarUrl: session.image || null, phone: session.phone || null,
avatarUrl: toProxyUrl(session.image) || null,
familyId: members?.[0]?.family_id, familyId: members?.[0]?.family_id,
familyName: members?.[0]?.family_name, familyName: members?.[0]?.family_name,
memberSince: session.created_at, memberSince: session.created_at,
@ -56,12 +58,29 @@ export async function GET() {
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const body = await request.json(); const body = await request.json();
const { name } = body; const { name, phone } = body as { name?: string; phone?: string };
if (!name) { if (!name) {
return NextResponse.json({ error: "Name required" }, { status: 400 }); return NextResponse.json({ error: "Name required" }, { status: 400 });
} }
// Phone is optional. Normalise: keep a leading + then digits only.
// Empty string clears it. Light validation — 8-15 digits if provided.
let normalizedPhone: string | null | undefined; // undefined = don't touch
if (phone !== undefined) {
const trimmed = (phone || "").trim();
if (trimmed === "") {
normalizedPhone = null;
} else {
const cleaned = trimmed.replace(/[^\d+]/g, "").replace(/(?!^)\+/g, "");
const digits = cleaned.replace(/\D/g, "");
if (digits.length < 8 || digits.length > 15) {
return NextResponse.json({ error: "Enter a valid phone number" }, { status: 400 });
}
normalizedPhone = cleaned;
}
}
const cookieStore = await cookies(); const cookieStore = await cookies();
const sessionToken = cookieStore.get("tia_session")?.value; const sessionToken = cookieStore.get("tia_session")?.value;
@ -83,13 +102,20 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Invalid session" }, { status: 401 }); return NextResponse.json({ error: "Invalid session" }, { status: 401 });
} }
// Update user name // Update user name (+ phone only when the field was sent)
await sql` if (normalizedPhone !== undefined) {
UPDATE users SET name = ${name}, updated_at = NOW() await sql`
WHERE id = ${session.user_id} UPDATE users SET name = ${name}, phone = ${normalizedPhone}, updated_at = NOW()
`; WHERE id = ${session.user_id}
`;
} else {
await sql`
UPDATE users SET name = ${name}, updated_at = NOW()
WHERE id = ${session.user_id}
`;
}
return NextResponse.json({ success: true, name }); return NextResponse.json({ success: true, name, phone: normalizedPhone ?? undefined });
} catch (error) { } catch (error) {
console.error("Profile update error:", error); console.error("Profile update error:", error);
return NextResponse.json({ error: String(error) }, { status: 500 }); return NextResponse.json({ error: String(error) }, { status: 500 });

View file

@ -1,6 +1,8 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { sql } from "@/db"; import { sql } from "@/db";
import { validateSession, requireFamily } from "@/lib/auth"; import { validateSession, requireFamily } from "@/lib/auth";
import { checkChildLimit } from "@/lib/quota";
import { toProxyUrl } from "@/lib/r2-proxy";
// GET - list children (family only) // GET - list children (family only)
export async function GET(request: Request) { export async function GET(request: Request) {
@ -12,11 +14,15 @@ export async function GET(request: Request) {
const familyId = auth.session!.familyId!; const familyId = auth.session!.familyId!;
try { try {
const children = await sql.unsafe( const rows = await sql.unsafe(
`SELECT id, name, birth_date as "birthDate", sex, stage, image_url as "imageUrl", created_at as "createdAt" FROM children WHERE family_id = $1 ORDER BY created_at DESC`, `SELECT id, name, birth_date as "birthDate", sex, stage, image_url as "imageUrl", created_at as "createdAt" FROM children WHERE family_id = $1 ORDER BY created_at DESC`,
[familyId] [familyId]
); );
return NextResponse.json({ children: children || [] }); const children = (rows || []).map((c: any) => ({
...c,
imageUrl: toProxyUrl(c.imageUrl) ?? null,
}));
return NextResponse.json({ children });
} catch (error) { } catch (error) {
console.error(error); console.error(error);
return NextResponse.json({ error: String(error) }, { status: 500 }); return NextResponse.json({ error: String(error) }, { status: 500 });
@ -39,6 +45,14 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 }); return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
} }
const limitCheck = await checkChildLimit(familyId);
if (!limitCheck.allowed) {
return NextResponse.json(
{ error: limitCheck.message, reason: limitCheck.reason, currentCount: limitCheck.currentCount, limit: limitCheck.limit },
{ status: 403 }
);
}
const [child] = await sql.unsafe( const [child] = await sql.unsafe(
`INSERT INTO children (family_id, name, birth_date, sex, stage) VALUES ($1, $2, $3, $4, 'newborn') RETURNING id, name, birth_date as "birthDate", sex, stage`, `INSERT INTO children (family_id, name, birth_date, sex, stage) VALUES ($1, $2, $3, $4, 'newborn') RETURNING id, name, birth_date as "birthDate", sex, stage`,
[familyId, name, birthDate, sex] [familyId, name, birthDate, sex]

View file

@ -4,6 +4,10 @@ import { promisify } from "util";
import { S3Client, PutObjectCommand, DeleteObjectCommand, ListObjectsV2Command } from "@aws-sdk/client-s3"; import { S3Client, PutObjectCommand, DeleteObjectCommand, ListObjectsV2Command } from "@aws-sdk/client-s3";
import fs from "fs/promises"; import fs from "fs/promises";
import { gzip } from "zlib"; import { gzip } from "zlib";
import { sendAlert } from "@/lib/alert";
// Below this, a gzipped dump almost certainly means an empty/failed pg_dump.
const MIN_BACKUP_BYTES = 1024;
const execAsync = promisify(exec); const execAsync = promisify(exec);
const gzipAsync = promisify(gzip); const gzipAsync = promisify(gzip);
@ -63,9 +67,26 @@ export async function POST(request: Request) {
// 5. Cleanup local // 5. Cleanup local
await fs.unlink("/tmp/dump.sql").catch(() => {}); await fs.unlink("/tmp/dump.sql").catch(() => {});
const sizeKb = Math.round(compressed.length / 1024);
// Suspiciously small dump — almost certainly an empty or broken backup.
if (compressed.length < MIN_BACKUP_BYTES) {
await sendAlert("error", "Backup looks empty", `Compressed dump is only ${compressed.length} bytes`, {
fields: { File: filename },
});
} else {
await sendAlert("info", "Backup completed", undefined, {
fields: { File: filename, Size: `${sizeKb} KB` },
silent: true, // daily confirmation — no need to buzz
});
}
return NextResponse.json({ success: true, filename, size: compressed.length }); return NextResponse.json({ success: true, filename, size: compressed.length });
} catch (e) { } catch (e) {
console.error("Backup failed:", e); console.error("Backup failed:", e);
await sendAlert("fatal", "Database backup FAILED", String(e).slice(0, 500), {
fields: { File: filename },
});
return NextResponse.json({ error: String(e) }, { status: 500 }); return NextResponse.json({ error: String(e) }, { status: 500 });
} }
} }

View file

@ -0,0 +1,103 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
import { sendAlert } from "@/lib/alert";
/**
* Monitor cron catches the failures Uptime Kuma can't see from the outside:
* Error spikes (rising-edge: last hour vs the hour before)
* DB unreachable
* Migrations missing / integration env not configured
*
* Uptime Kuma handles up/down flip detection by pinging /api/healthz, so this
* focuses on internal signals. Recommended schedule: hourly.
*
* POST/GET /api/cron/monitor (header: x-cron-secret)
* GET /api/cron/monitor?test=1 sends a test Telegram ping
*
* Stateless by design: error alerts use rising-edge comparison so a sustained
* (flat) error rate won't re-alert every run only genuine new spikes do.
*/
export const dynamic = "force-dynamic";
const SPIKE_MIN = 5; // need at least this many errors in the last hour
const SPIKE_MULTIPLIER = 2; // …and > 2× the previous hour to count as a spike
function authed(request: Request): boolean {
return request.headers.get("x-cron-secret") === process.env.CRON_SECRET;
}
export async function POST(request: Request) {
if (!authed(request)) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
return runMonitor(request);
}
export async function GET(request: Request) {
if (!authed(request)) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
return runMonitor(request);
}
async function runMonitor(request: Request) {
const { searchParams } = new URL(request.url);
// Manual test ping — confirms the Telegram wiring end-to-end.
if (searchParams.get("test")) {
const ok = await sendAlert("info", "Monitor test ping", "Telegram alerting is wired correctly. 🎉");
return NextResponse.json({ ok, test: true });
}
const fired: string[] = [];
// 1. Database reachable?
try {
await sql`SELECT 1`;
} catch (e) {
await sendAlert("fatal", "Database unreachable", String(e).slice(0, 300));
return NextResponse.json({ ok: false, dbOk: false, fired: ["db_down"] });
}
// 2. Error spike — rising edge (last 1h vs the hour before it)
let recent = 0;
let prior = 0;
try {
const rows = await sql`
SELECT
COUNT(*) FILTER (WHERE created_at > NOW() - INTERVAL '1 hour')::int AS recent,
COUNT(*) FILTER (WHERE created_at <= NOW() - INTERVAL '1 hour'
AND created_at > NOW() - INTERVAL '2 hours')::int AS prior
FROM error_events
`;
recent = Number(rows[0]?.recent) || 0;
prior = Number(rows[0]?.prior) || 0;
if (recent >= SPIKE_MIN && recent > prior * SPIKE_MULTIPLIER) {
await sendAlert("error", "Error spike detected", undefined, {
fields: { "Last hour": recent, "Previous hour": prior },
});
fired.push("error_spike");
}
} catch {
/* error_events may not exist yet — non-fatal */
}
// 3. Migrations recorded
try {
const rows = await sql`SELECT COUNT(*)::int AS count FROM drizzle.__drizzle_migrations`;
if ((Number(rows[0]?.count) || 0) === 0) {
await sendAlert("warn", "No migrations recorded", "drizzle.__drizzle_migrations is empty", { silent: true });
fired.push("no_migrations");
}
} catch {
/* ignore */
}
// 4. Integration env presence
const missing: string[] = [];
if (!(process.env.LITELLM_BASE_URL && process.env.LITELLM_API_KEY)) missing.push("AI Gateway");
if (!(process.env.R2_ACCOUNT_ID && process.env.R2_ACCESS_KEY_ID && process.env.R2_BUCKET_NAME)) missing.push("R2 Storage");
if (!process.env.RESEND_API_KEY) missing.push("Email");
if (missing.length) {
await sendAlert("warn", "Integration config missing", missing.join(", "), { silent: true });
fired.push("config_missing");
}
return NextResponse.json({ ok: true, dbOk: true, errors: { recent, prior }, fired });
}

View file

@ -0,0 +1,152 @@
import { NextResponse } from "next/server";
import { sendAlert } from "@/lib/alert";
/**
* Visitor summary cron polls the self-hosted Umami API and posts a digest to
* Telegram. Umami has no native webhook, so we authenticate and read its REST API.
*
* POST/GET /api/cron/visitor-summary (header: x-cron-secret)
* GET /api/cron/visitor-summary?hours=24 window (default 24, max 168)
*
* Env:
* UMAMI_BASE_URL (default https://analytics.manohargupta.com)
* UMAMI_USERNAME Umami login (admin or a read-only user)
* UMAMI_PASSWORD
* UMAMI_WEBSITE_ID (default the Tia website id)
*
* Recommended schedule: once a day. Set hours=24 for a daily digest, or call it
* more often (e.g. hours=1 hourly) if you want tighter pulse during launch.
*/
export const dynamic = "force-dynamic";
const UMAMI_BASE = process.env.UMAMI_BASE_URL || "https://analytics.manohargupta.com";
const WEBSITE_ID = process.env.UMAMI_WEBSITE_ID || "79444c19-ee31-4fab-baf5-f4e61098eeba";
function authed(request: Request): boolean {
return request.headers.get("x-cron-secret") === process.env.CRON_SECRET;
}
export async function POST(request: Request) {
if (!authed(request)) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
return runSummary(request);
}
export async function GET(request: Request) {
if (!authed(request)) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
return runSummary(request);
}
/** Authenticate with Umami and return a Bearer token, or null on failure. */
async function umamiLogin(): Promise<string | null> {
try {
const res = await fetch(`${UMAMI_BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: process.env.UMAMI_USERNAME,
password: process.env.UMAMI_PASSWORD,
}),
});
if (!res.ok) {
console.error("umamiLogin failed:", res.status, await res.text().catch(() => ""));
return null;
}
const data = await res.json();
return data?.token || null;
} catch (e) {
console.error("umamiLogin error:", e);
return null;
}
}
// Umami wraps metric values as { value, prev }; tolerate both shapes + raw numbers.
const num = (v: unknown): number => {
if (typeof v === "number") return v;
if (v && typeof v === "object" && "value" in v) return Number((v as { value: unknown }).value) || 0;
return Number(v) || 0;
};
async function runSummary(request: Request) {
const { searchParams } = new URL(request.url);
const hours = Math.max(1, Math.min(168, Number(searchParams.get("hours")) || 24));
const endAt = Date.now();
const startAt = endAt - hours * 3_600_000;
if (!process.env.UMAMI_USERNAME || !process.env.UMAMI_PASSWORD) {
return NextResponse.json({ error: "UMAMI_USERNAME / UMAMI_PASSWORD not set" }, { status: 500 });
}
const token = await umamiLogin();
if (!token) {
await sendAlert("warn", "Visitor summary unavailable", "Could not authenticate with Umami", { silent: true });
return NextResponse.json({ error: "umami auth failed" }, { status: 502 });
}
const headers = { Authorization: `Bearer ${token}` };
const qs = `startAt=${startAt}&endAt=${endAt}`;
// Stats
let stats: Record<string, unknown> = {};
try {
const r = await fetch(`${UMAMI_BASE}/api/websites/${WEBSITE_ID}/stats?${qs}`, { headers });
stats = await r.json();
} catch (e) {
console.error("umami stats error:", e);
}
// Top pages
let topPages: Array<{ x?: string; url?: string; y?: number; value?: number }> = [];
try {
const r = await fetch(`${UMAMI_BASE}/api/websites/${WEBSITE_ID}/metrics?type=url&${qs}&limit=5`, { headers });
const j = await r.json();
if (Array.isArray(j)) topPages = j;
} catch (e) {
console.error("umami metrics error:", e);
}
// Active right now
let active = 0;
try {
const r = await fetch(`${UMAMI_BASE}/api/websites/${WEBSITE_ID}/active`, { headers });
const a = await r.json();
active = typeof a === "number" ? a : num((a as { visitors?: unknown; x?: unknown })?.visitors ?? (a as { x?: unknown })?.x);
} catch (e) {
console.error("umami active error:", e);
}
const pageviews = num(stats.pageviews);
const visitors = num(stats.visitors);
const visits = num(stats.visits);
const bounces = num(stats.bounces);
const totaltime = num(stats.totaltime);
const bounceRate = visits ? Math.round((bounces / visits) * 100) : 0;
const avgVisit = visits ? Math.round(totaltime / visits) : 0; // seconds
const pagesText =
topPages
.slice(0, 5)
.map(p => `${p.x || p.url || "?"}${p.y ?? p.value ?? 0}`)
.join("\n") || "—";
const label = hours === 24 ? "Last 24h" : `Last ${hours}h`;
const body = [
`👥 Visitors: ${visitors}`,
`🔁 Visits: ${visits}`,
`📄 Page views: ${pageviews}`,
`↩️ Bounce rate: ${bounceRate}%`,
`⏱ Avg visit: ${avgVisit}s`,
`🟢 Active now: ${active}`,
``,
`Top pages:`,
pagesText,
].join("\n");
const delivered = await sendAlert("info", `📊 Visitor summary — ${label}`, body, { silent: true });
return NextResponse.json({
ok: true,
delivered,
summary: { visitors, visits, pageviews, bounceRate, avgVisit, active },
});
}

View file

@ -6,17 +6,78 @@ export async function GET() {
const auth = await requireFamily(); const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status }); if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
// Each probe is independent so one failure never blanks the whole diagnostic.
const out: Record<string, unknown> = {};
// pgvector status — FIRST and standalone (this is what diagnoses the
// "could not access file vector" production error). pg_available_extensions
// reads the on-disk extension catalog and never loads the vector library, so
// it works even when the image is missing the pgvector binaries.
try { try {
const migrations = await sql.unsafe( const vectorRows = await sql.unsafe(
`SELECT hash, created_at FROM __drizzle_migrations ORDER BY created_at DESC LIMIT 10` `SELECT name, default_version, installed_version FROM pg_available_extensions WHERE name = 'vector'`
); );
const circleTables = await sql.unsafe( const v = vectorRows[0] as { default_version?: string; installed_version?: string } | undefined;
out.pgvector = {
binaryAvailable: !!v, // false → wrong Postgres image (no pgvector binaries)
installed: !!v?.installed_version, // false → needs CREATE EXTENSION vector
availableVersion: v?.default_version ?? null,
installedVersion: v?.installed_version ?? null,
};
} catch (e) {
out.pgvectorError = String(e);
}
// Does the error_events table exist yet? (admin Errors page depends on it)
try {
const r = await sql.unsafe(`SELECT to_regclass('public.error_events') AS reg`);
out.errorEventsExists = !!(r[0] as { reg?: string })?.reg;
} catch (e) {
out.errorEventsError = String(e);
}
// Applied drizzle migrations (the journal lives in the "drizzle" schema).
try {
out.migrations = await sql.unsafe(
`SELECT hash, created_at FROM drizzle.__drizzle_migrations ORDER BY created_at DESC LIMIT 12`
);
} catch (e) {
out.migrationsError = String(e);
}
try {
out.circleTables = await sql.unsafe(
`SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND (tablename LIKE 'circle%' OR tablename = 'post_reports') ORDER BY tablename` `SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND (tablename LIKE 'circle%' OR tablename = 'post_reports') ORDER BY tablename`
); );
return NextResponse.json({ migrations, circleTables }); } catch (e) {
} catch (err: unknown) { out.circleTablesError = String(e);
return NextResponse.json({ error: err instanceof Error ? err.message : String(err) });
} }
// Billing diagnostics — last webhook events + subscription rows + family tiers
try {
out.webhookEvents = await sql.unsafe(
`SELECT razorpay_event_id, event_type, received_at,
payload->'payload'->'subscription'->'entity'->>'id' AS sub_id,
payload->'payload'->'subscription'->'entity'->>'status' AS sub_status
FROM razorpay_webhook_events ORDER BY received_at DESC LIMIT 10`
);
} catch (e) { out.webhookEventsError = String(e); }
try {
out.subscriptions = await sql.unsafe(
`SELECT razorpay_subscription_id, status, family_id, current_end, updated_at
FROM family_subscriptions ORDER BY created_at DESC LIMIT 10`
);
} catch (e) { out.subscriptionsError = String(e); }
try {
out.paidFamilies = await sql.unsafe(
`SELECT id, tier, subscription_status, max_members, max_children
FROM families WHERE tier != 'free' OR subscription_status IS NOT NULL LIMIT 10`
);
} catch (e) { out.paidFamiliesError = String(e); }
return NextResponse.json(out);
} }
// One-shot: apply circles migration via the app DB connection (requires login) // One-shot: apply circles migration via the app DB connection (requires login)
@ -30,11 +91,16 @@ export async function POST(req: Request) {
} }
const steps = [ const steps = [
// One-time fix: memories stuck in 'processing' (vision pipeline was marking them failed)
// Reset to 'ready' so they're visible again — vision captions are optional
`UPDATE memories SET processing_status = 'ready', updated_at = now() WHERE processing_status = 'processing' AND created_at < NOW() - INTERVAL '10 minutes'`,
// family_invites missing columns (0006) // family_invites missing columns (0006)
`ALTER TABLE family_invites ADD COLUMN IF NOT EXISTS display_name text`, `ALTER TABLE family_invites ADD COLUMN IF NOT EXISTS display_name text`,
`ALTER TABLE family_invites ADD COLUMN IF NOT EXISTS accepted_at timestamp`, `ALTER TABLE family_invites ADD COLUMN IF NOT EXISTS accepted_at timestamp`,
// subscription_status on families (0007) — payment-provider abstraction // subscription_status on families (0007) — payment-provider abstraction
`ALTER TABLE families ADD COLUMN IF NOT EXISTS subscription_status varchar(20) DEFAULT NULL`, `ALTER TABLE families ADD COLUMN IF NOT EXISTS subscription_status varchar(20) DEFAULT NULL`,
// pediatrician_name on families (0008)
`ALTER TABLE families ADD COLUMN IF NOT EXISTS pediatrician_name text`,
// circles tables (0003) // circles tables (0003)
`CREATE TABLE IF NOT EXISTS circles (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), name text NOT NULL, created_by uuid NOT NULL REFERENCES families(id), created_at timestamptz NOT NULL DEFAULT now())`, `CREATE TABLE IF NOT EXISTS circles (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), name text NOT NULL, created_by uuid NOT NULL REFERENCES families(id), created_at timestamptz NOT NULL DEFAULT now())`,
`CREATE TABLE IF NOT EXISTS circle_members (circle_id uuid NOT NULL REFERENCES circles(id) ON DELETE CASCADE, family_id uuid NOT NULL REFERENCES families(id), role text NOT NULL DEFAULT 'member', joined_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (circle_id, family_id))`, `CREATE TABLE IF NOT EXISTS circle_members (circle_id uuid NOT NULL REFERENCES circles(id) ON DELETE CASCADE, family_id uuid NOT NULL REFERENCES families(id), role text NOT NULL DEFAULT 'member', joined_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (circle_id, family_id))`,
@ -50,6 +116,24 @@ export async function POST(req: Request) {
`CREATE INDEX IF NOT EXISTS circle_comments_post_idx ON circle_post_comments(post_id)`, `CREATE INDEX IF NOT EXISTS circle_comments_post_idx ON circle_post_comments(post_id)`,
`CREATE INDEX IF NOT EXISTS circle_reactions_post_idx ON circle_post_reactions(post_id)`, `CREATE INDEX IF NOT EXISTS circle_reactions_post_idx ON circle_post_reactions(post_id)`,
`CREATE INDEX IF NOT EXISTS circle_invites_token_idx ON circle_invites(token)`, `CREATE INDEX IF NOT EXISTS circle_invites_token_idx ON circle_invites(token)`,
// 0009 — notifications table
`CREATE TABLE IF NOT EXISTS notifications (id UUID PRIMARY KEY DEFAULT gen_random_uuid(), family_id UUID NOT NULL REFERENCES families(id) ON DELETE CASCADE, child_id UUID REFERENCES children(id) ON DELETE CASCADE, type VARCHAR(80) NOT NULL, title TEXT NOT NULL, message TEXT NOT NULL, action_url TEXT, is_read BOOLEAN NOT NULL DEFAULT false, scheduled_for DATE NOT NULL, metadata JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW())`,
`CREATE UNIQUE INDEX IF NOT EXISTS notifications_unique_slot ON notifications(family_id, child_id, type, scheduled_for)`,
`CREATE INDEX IF NOT EXISTS notifications_family_child_idx ON notifications(family_id, child_id, is_read, created_at DESC)`,
// 0010 — error_events table (admin error/crash tracker)
`CREATE TABLE IF NOT EXISTS error_events (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), level varchar(20) NOT NULL DEFAULT 'error', source varchar(20) NOT NULL DEFAULT 'client', message text NOT NULL, stack text, url text, digest varchar(120), user_id uuid, family_id uuid, user_agent text, metadata jsonb DEFAULT '{}', created_at timestamptz NOT NULL DEFAULT now())`,
`CREATE INDEX IF NOT EXISTS idx_error_events_created ON error_events (created_at DESC)`,
`CREATE INDEX IF NOT EXISTS idx_error_events_source ON error_events (source)`,
`CREATE INDEX IF NOT EXISTS idx_error_events_message ON error_events (message)`,
// 0011 — optional user phone number
`ALTER TABLE users ADD COLUMN IF NOT EXISTS phone text`,
// 0012 — billing / Razorpay subscriptions
`DO $$ BEGIN CREATE TYPE subscription_status_enum AS ENUM ('created','authenticated','active','pending','halted','cancelled','completed','expired','paused'); EXCEPTION WHEN duplicate_object THEN NULL; END $$`,
`CREATE TABLE IF NOT EXISTS subscription_plans (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), razorpay_plan_id text NOT NULL UNIQUE, name text NOT NULL, price_paise integer NOT NULL, storage_bytes bigint NOT NULL, member_limit integer NOT NULL, child_limit integer NOT NULL DEFAULT 3, is_active boolean NOT NULL DEFAULT true, created_at timestamptz NOT NULL DEFAULT now())`,
`CREATE TABLE IF NOT EXISTS family_subscriptions (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), family_id uuid NOT NULL REFERENCES families(id) ON DELETE CASCADE, plan_id uuid NOT NULL REFERENCES subscription_plans(id), razorpay_subscription_id text NOT NULL UNIQUE, razorpay_customer_id text, status subscription_status_enum NOT NULL DEFAULT 'created', current_start timestamptz, current_end timestamptz, cancelled_at timestamptz, ended_at timestamptz, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now())`,
`CREATE INDEX IF NOT EXISTS family_subscriptions_family_idx ON family_subscriptions (family_id)`,
`CREATE UNIQUE INDEX IF NOT EXISTS family_live_sub_idx ON family_subscriptions (family_id) WHERE status IN ('created','authenticated','active','pending','halted')`,
`CREATE TABLE IF NOT EXISTS razorpay_webhook_events (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), razorpay_event_id text NOT NULL UNIQUE, event_type text NOT NULL, payload jsonb NOT NULL, received_at timestamptz NOT NULL DEFAULT now())`,
]; ];
const results: string[] = []; const results: string[] = [];

View file

@ -0,0 +1,53 @@
import { NextResponse } from "next/server";
import { logError } from "@/lib/error-log";
import { validateSession } from "@/lib/auth";
// Ingest endpoint for client-side error reports (from the error boundaries).
// Auth-optional: we attach the user/family if a session exists, but we never
// reject an error report for being unauthenticated — crashes can happen on
// public/logged-out screens too.
export async function POST(request: Request) {
try {
const body = await request.json().catch(() => ({}));
const { message, stack, url, digest, level, metadata } = body as {
message?: string;
stack?: string;
url?: string;
digest?: string;
level?: "error" | "warn" | "fatal";
metadata?: Record<string, unknown>;
};
if (!message || typeof message !== "string") {
return NextResponse.json({ error: "message required" }, { status: 400 });
}
let userId: string | null = null;
let familyId: string | null = null;
try {
const auth = await validateSession();
userId = auth.session?.userId ?? null;
familyId = auth.session?.familyId ?? null;
} catch {
// best-effort only
}
await logError({
source: "client",
level: level === "warn" || level === "fatal" ? level : "error",
message,
stack,
url,
digest,
userId,
familyId,
userAgent: request.headers.get("user-agent"),
metadata: metadata && typeof metadata === "object" ? metadata : {},
});
return NextResponse.json({ ok: true });
} catch {
// Swallow — the reporter must never surface its own error to the user.
return NextResponse.json({ ok: false });
}
}

View file

@ -9,7 +9,7 @@ export async function GET(request: Request) {
try { try {
const family = await sql.unsafe( const family = await sql.unsafe(
`SELECT id, name, tier, max_children, max_members FROM families WHERE id = $1`, `SELECT id, name, tier, max_children, max_members, pediatrician_phone, pediatrician_name FROM families WHERE id = $1`,
[auth.session!.familyId] [auth.session!.familyId]
); );
@ -31,11 +31,27 @@ export async function PATCH(request: Request) {
try { try {
const body = await request.json(); const body = await request.json();
const { name, pediatricianPhone, tier } = body; const { name, pediatricianPhone, pediatricianName, tier } = body;
// Only update fields that are explicitly present in the request body.
// This avoids COALESCE masking clears and undefined params causing errors.
const setClauses: string[] = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const params: any[] = [];
if (name !== undefined) { setClauses.push(`name = $${params.push(name)}`); }
if (pediatricianPhone !== undefined) { setClauses.push(`pediatrician_phone = $${params.push(pediatricianPhone || null)}`); }
if (pediatricianName !== undefined) { setClauses.push(`pediatrician_name = $${params.push(pediatricianName || null)}`); }
if (tier !== undefined) { setClauses.push(`tier = $${params.push(tier)}`); }
if (setClauses.length === 0) return NextResponse.json({ success: true });
setClauses.push("updated_at = NOW()");
params.push(auth.session!.familyId);
await sql.unsafe( await sql.unsafe(
`UPDATE families SET name = COALESCE($1, name), pediatrician_phone = COALESCE($2, pediatrician_phone), tier = COALESCE($3, tier), updated_at = NOW() WHERE id = $4`, `UPDATE families SET ${setClauses.join(", ")} WHERE id = $${params.length}`,
[name, pediatricianPhone, tier, auth.session!.familyId] params
); );
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });

View file

@ -3,7 +3,10 @@ import { requireFamily } from "@/lib/auth";
import { sql } from "@/db"; import { sql } from "@/db";
import { GARMENT_CATEGORIES } from "@/db/schema/wardrobe"; import { GARMENT_CATEGORIES } from "@/db/schema/wardrobe";
function toDto(g: Record<string, unknown>, baseUrl: string) { function toDto(g: Record<string, unknown>) {
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "";
const ik = g.image_key as string | null;
const tk = g.thumb_key as string | null;
return { return {
id: g.id, id: g.id,
familyId: g.family_id, familyId: g.family_id,
@ -14,10 +17,11 @@ function toDto(g: Record<string, unknown>, baseUrl: string) {
colors: g.colors || [], colors: g.colors || [],
seasons: g.seasons || [], seasons: g.seasons || [],
occasionTags: g.occasion_tags || [], occasionTags: g.occasion_tags || [],
imageKey: g.image_key, imageKey: ik,
thumbKey: g.thumb_key, thumbKey: tk,
imageUrl: `${baseUrl}/${g.image_key}`, // Proxy through /api/img — never expose raw pub-*.r2.dev URLs (blocked by Cloudflare Bot Mgmt)
thumbUrl: `${baseUrl}/${g.thumb_key}`, imageUrl: ik ? `${appUrl}/api/img?key=${encodeURIComponent(ik)}` : null,
thumbUrl: tk ? `${appUrl}/api/img?key=${encodeURIComponent(tk)}` : null,
status: g.status, status: g.status,
acquiredVia: g.acquired_via, acquiredVia: g.acquired_via,
giftFrom: g.gift_from, giftFrom: g.gift_from,
@ -27,10 +31,6 @@ function toDto(g: Record<string, unknown>, baseUrl: string) {
}; };
} }
function getBaseUrl() {
return process.env.R2_PUBLIC_URL || `https://pub-${process.env.R2_ACCOUNT_ID}.r2.dev`;
}
// GET /api/garments/[id] // GET /api/garments/[id]
export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const auth = await requireFamily(); const auth = await requireFamily();
@ -47,7 +47,7 @@ export async function GET(_req: NextRequest, { params }: { params: Promise<{ id:
WHERE garment_id = ${id} AND family_id = ${familyId} WHERE garment_id = ${id} AND family_id = ${familyId}
ORDER BY worn_on DESC`; ORDER BY worn_on DESC`;
return NextResponse.json({ success: true, item: toDto(rows[0], getBaseUrl()), wears }); return NextResponse.json({ success: true, item: toDto(rows[0]), wears });
} }
// PATCH /api/garments/[id] // PATCH /api/garments/[id]
@ -92,7 +92,7 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id
WHERE id = ${id} AND family_id = ${familyId} WHERE id = ${id} AND family_id = ${familyId}
RETURNING *`; RETURNING *`;
return NextResponse.json({ success: true, item: toDto(rows[0], getBaseUrl()) }); return NextResponse.json({ success: true, item: toDto(rows[0]) });
} }
// DELETE /api/garments/[id] // DELETE /api/garments/[id]

View file

@ -70,11 +70,9 @@ export async function GET(req: NextRequest) {
ORDER BY created_at DESC`; ORDER BY created_at DESC`;
} }
const baseUrl = process.env.R2_PUBLIC_URL || `https://pub-${process.env.R2_ACCOUNT_ID}.r2.dev`;
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
items: rows.map(g => toDto(g, baseUrl)), items: rows.map(g => toDto(g)),
}); });
} }
@ -132,11 +130,13 @@ export async function POST(req: NextRequest) {
${visionMetadata ? JSON.stringify(visionMetadata) : null}) ${visionMetadata ? JSON.stringify(visionMetadata) : null})
RETURNING *`; RETURNING *`;
const baseUrl = process.env.R2_PUBLIC_URL || `https://pub-${process.env.R2_ACCOUNT_ID}.r2.dev`; return NextResponse.json({ success: true, item: toDto(rows[0]) }, { status: 201 });
return NextResponse.json({ success: true, item: toDto(rows[0], baseUrl) }, { status: 201 });
} }
function toDto(g: Record<string, unknown>, baseUrl: string) { function toDto(g: Record<string, unknown>) {
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "";
const ik = g.image_key as string | null;
const tk = g.thumb_key as string | null;
return { return {
id: g.id, id: g.id,
familyId: g.family_id, familyId: g.family_id,
@ -147,10 +147,11 @@ function toDto(g: Record<string, unknown>, baseUrl: string) {
colors: g.colors || [], colors: g.colors || [],
seasons: g.seasons || [], seasons: g.seasons || [],
occasionTags: g.occasion_tags || [], occasionTags: g.occasion_tags || [],
imageKey: g.image_key, imageKey: ik,
thumbKey: g.thumb_key, thumbKey: tk,
imageUrl: `${baseUrl}/${g.image_key}`, // Proxy through /api/img — never expose raw pub-*.r2.dev URLs (blocked by Cloudflare Bot Mgmt)
thumbUrl: `${baseUrl}/${g.thumb_key}`, imageUrl: ik ? `${appUrl}/api/img?key=${encodeURIComponent(ik)}` : null,
thumbUrl: tk ? `${appUrl}/api/img?key=${encodeURIComponent(tk)}` : null,
status: g.status, status: g.status,
acquiredVia: g.acquired_via, acquiredVia: g.acquired_via,
giftFrom: g.gift_from, giftFrom: g.gift_from,

View file

@ -7,6 +7,17 @@ import { randomUUID } from "crypto";
const ALLOWED_TYPES = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/heic"]; const ALLOWED_TYPES = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/heic"];
const MAX_BYTES = 8 * 1024 * 1024; // 8MB const MAX_BYTES = 8 * 1024 * 1024; // 8MB
/** Android often returns file.type="" or "application/octet-stream" — infer from extension. */
function resolveContentType(file: File): string {
if (file.type && file.type !== "application/octet-stream") return file.type;
const ext = file.name.split(".").pop()?.toLowerCase() || "";
const map: Record<string, string> = {
jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png",
webp: "image/webp", heic: "image/heic",
};
return map[ext] || "image/jpeg";
}
function getR2Config() { function getR2Config() {
return { return {
accountId: process.env.R2_ACCOUNT_ID!, accountId: process.env.R2_ACCOUNT_ID!,
@ -50,7 +61,8 @@ export async function POST(req: NextRequest) {
const file = formData.get("file") as File | null; const file = formData.get("file") as File | null;
if (!file) return NextResponse.json({ error: "No file provided" }, { status: 400 }); if (!file) return NextResponse.json({ error: "No file provided" }, { status: 400 });
if (!ALLOWED_TYPES.includes(file.type)) { const contentType = resolveContentType(file);
if (!ALLOWED_TYPES.includes(contentType)) {
return NextResponse.json({ error: "Unsupported file type" }, { status: 400 }); return NextResponse.json({ error: "Unsupported file type" }, { status: 400 });
} }
@ -78,7 +90,7 @@ export async function POST(req: NextRequest) {
Bucket: R2.bucket, Bucket: R2.bucket,
Key: imageKey, Key: imageKey,
Body: originalBuffer, Body: originalBuffer,
ContentType: file.type, ContentType: contentType,
})), })),
client.send(new PutObjectCommand({ client.send(new PutObjectCommand({
Bucket: R2.bucket, Bucket: R2.bucket,
@ -88,12 +100,12 @@ export async function POST(req: NextRequest) {
})), })),
]); ]);
const baseUrl = R2.publicUrl || `https://pub-${R2.accountId}.r2.dev`; // Return proxy URLs (never raw R2 pub URLs — those are blocked by Cloudflare Bot Management)
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "";
return NextResponse.json({ return NextResponse.json({
imageKey, imageKey,
thumbKey, thumbKey,
imageUrl: `${baseUrl}/${imageKey}`, imageUrl: `${appUrl}/api/img?key=${encodeURIComponent(imageKey)}`,
thumbUrl: `${baseUrl}/${thumbKey}`, thumbUrl: `${appUrl}/api/img?key=${encodeURIComponent(thumbKey)}`,
}); });
} }

View file

@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
/**
* GET /api/healthz public, unauthenticated liveness probe.
*
* For external monitors (Uptime Kuma) and the Docker / Dokploy healthcheck.
* Returns NO sensitive detail only whether the app can reach the database.
* 200 { status: "ok", db: true }
* 503 { status: "down", db: false }
*/
export const dynamic = "force-dynamic";
export async function GET() {
try {
await sql`SELECT 1`;
return NextResponse.json(
{ status: "ok", db: true, ts: new Date().toISOString() },
{ headers: { "Cache-Control": "no-store" } },
);
} catch {
return NextResponse.json(
{ status: "down", db: false, ts: new Date().toISOString() },
{ status: 503, headers: { "Cache-Control": "no-store" } },
);
}
}

50
src/app/api/img/route.ts Normal file
View file

@ -0,0 +1,50 @@
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
import { NextRequest, NextResponse } from "next/server";
const ALLOWED_PREFIXES = ["avatars/", "profiles/", "memories/", "thumbnails/", "families/", "garments/"];
export async function GET(req: NextRequest) {
const key = req.nextUrl.searchParams.get("key");
if (!key) return NextResponse.json({ error: "key required" }, { status: 400 });
// Only proxy our own R2 objects
if (!ALLOWED_PREFIXES.some(p => key.startsWith(p))) {
return NextResponse.json({ error: "Invalid key" }, { status: 403 });
}
const accountId = process.env.R2_ACCOUNT_ID;
const accessKeyId = process.env.R2_ACCESS_KEY_ID;
const secretKey = process.env.R2_SECRET_ACCESS_KEY;
const bucket = process.env.R2_BUCKET_NAME;
if (!accountId || !accessKeyId || !secretKey || !bucket) {
return NextResponse.json({ error: "Storage not configured" }, { status: 500 });
}
const client = new S3Client({
region: "auto",
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
credentials: { accessKeyId, secretAccessKey: secretKey },
});
try {
const obj = await client.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
if (!obj.Body) return new NextResponse(null, { status: 404 });
const bytes = await (obj.Body as any).transformToByteArray();
return new NextResponse(bytes, {
status: 200,
headers: {
"Content-Type": obj.ContentType || "image/jpeg",
"Cache-Control": "public, max-age=604800, immutable",
...(obj.ContentLength ? { "Content-Length": String(obj.ContentLength) } : {}),
},
});
} catch (e: any) {
if (e?.name === "NoSuchKey" || e?.$metadata?.httpStatusCode === 404) {
return new NextResponse(null, { status: 404 });
}
console.error("R2 img proxy error:", e);
return new NextResponse(null, { status: 502 });
}
}

View file

@ -84,10 +84,12 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
} }
} }
// Mark as processing and start the media pipeline // Mark as processing and start the media pipeline.
// thumbnail failure is non-fatal — always proceed to vision, which marks 'ready'.
await sql`UPDATE memories SET processing_status = 'processing', updated_at = now() WHERE id = ${id}`; await sql`UPDATE memories SET processing_status = 'processing', updated_at = now() WHERE id = ${id}`;
generateThumbnail(id) generateThumbnail(id)
.catch(e => console.error(`[thumbnail] id=${id}`, e)) // swallow so vision always runs
.then(() => processMemoryVision(id)) .then(() => processMemoryVision(id))
.catch(e => console.error(`[memory pipeline] id=${id}`, e)); .catch(e => console.error(`[memory pipeline] id=${id}`, e));

View file

@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { requireFamily } from "@/lib/auth"; import { requireFamily } from "@/lib/auth";
import { sql } from "@/db"; import { sql } from "@/db";
import { toProxyUrl } from "@/lib/r2-proxy";
function getBaseUrl() { function getBaseUrl() {
const pub = process.env.R2_PUBLIC_URL; const pub = process.env.R2_PUBLIC_URL;
@ -9,11 +10,13 @@ function getBaseUrl() {
} }
function toMemoryDto(m: Record<string, unknown>, baseUrl: string) { function toMemoryDto(m: Record<string, unknown>, baseUrl: string) {
const rawUrl = `${baseUrl}/${m.r2_key}`;
const rawThumb = m.r2_thumbnail_key ? `${baseUrl}/${m.r2_thumbnail_key}` : null;
return { return {
id: m.id, id: m.id,
key: m.r2_key, key: m.r2_key,
url: `${baseUrl}/${m.r2_key}`, url: toProxyUrl(rawUrl) ?? rawUrl,
thumbnailUrl: m.r2_thumbnail_key ? `${baseUrl}/${m.r2_thumbnail_key}` : null, thumbnailUrl: toProxyUrl(rawThumb),
sizeBytes: m.size_bytes, sizeBytes: m.size_bytes,
mimeType: m.mime_type, mimeType: m.mime_type,
title: m.title, title: m.title,
@ -39,15 +42,52 @@ export async function GET(req: NextRequest) {
const limit = Math.min(parseInt(searchParams.get("limit") || "30"), 100); const limit = Math.min(parseInt(searchParams.get("limit") || "30"), 100);
const cursor = searchParams.get("cursor"); // ISO timestamp for pagination const cursor = searchParams.get("cursor"); // ISO timestamp for pagination
// Orphaned row definition:
// - processing_status = 'failed' → confirm step ran, HeadObject found nothing in R2
// - processing_status = 'uploading' older than 30 min → browser closed/connection dropped
// before the upload and confirm steps completed; file never reached R2.
// These rows have no R2 object behind them. Delete them silently so they stop
// inflating the count and making the grid look broken.
sql`
DELETE FROM memories
WHERE family_id = ${familyId}
AND (
processing_status = 'failed'
OR (processing_status = 'uploading' AND created_at < NOW() - INTERVAL '30 minutes')
)
`.catch(() => {}); // fire-and-forget, don't block the response
let rows; let rows;
if (childId) { if (childId) {
rows = cursor rows = cursor
? await sql`SELECT * FROM memories WHERE family_id = ${familyId} AND child_id = ${childId} AND created_at < ${cursor} ORDER BY created_at DESC LIMIT ${limit}` ? await sql`
: await sql`SELECT * FROM memories WHERE family_id = ${familyId} AND child_id = ${childId} ORDER BY created_at DESC LIMIT ${limit}`; SELECT * FROM memories
WHERE family_id = ${familyId} AND child_id = ${childId}
AND created_at < ${cursor}
AND processing_status NOT IN ('failed')
AND (processing_status != 'uploading' OR created_at > NOW() - INTERVAL '30 minutes')
ORDER BY created_at DESC LIMIT ${limit}`
: await sql`
SELECT * FROM memories
WHERE family_id = ${familyId} AND child_id = ${childId}
AND processing_status NOT IN ('failed')
AND (processing_status != 'uploading' OR created_at > NOW() - INTERVAL '30 minutes')
ORDER BY created_at DESC LIMIT ${limit}`;
} else { } else {
rows = cursor rows = cursor
? await sql`SELECT * FROM memories WHERE family_id = ${familyId} AND created_at < ${cursor} ORDER BY created_at DESC LIMIT ${limit}` ? await sql`
: await sql`SELECT * FROM memories WHERE family_id = ${familyId} ORDER BY created_at DESC LIMIT ${limit}`; SELECT * FROM memories
WHERE family_id = ${familyId}
AND created_at < ${cursor}
AND processing_status NOT IN ('failed')
AND (processing_status != 'uploading' OR created_at > NOW() - INTERVAL '30 minutes')
ORDER BY created_at DESC LIMIT ${limit}`
: await sql`
SELECT * FROM memories
WHERE family_id = ${familyId}
AND processing_status NOT IN ('failed')
AND (processing_status != 'uploading' OR created_at > NOW() - INTERVAL '30 minutes')
ORDER BY created_at DESC LIMIT ${limit}`;
} }
const baseUrl = getBaseUrl(); const baseUrl = getBaseUrl();

View file

@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from "next/server";
import { sql } from "@/db";
import { requireFamily } from "@/lib/auth";
// PATCH /api/notifications/[id] — mark a single notification as read
export async function PATCH(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const familyId = auth.session!.familyId!;
const { id } = await params;
// Only update rows belonging to this family (security check via family_id)
await sql`
UPDATE notifications
SET is_read = true
WHERE id = ${id} AND family_id = ${familyId}
`;
return NextResponse.json({ success: true });
} catch (err) {
console.error("Notification PATCH [id] error:", err);
return NextResponse.json({ error: String(err) }, { status: 500 });
}
}

View file

@ -1,104 +1,259 @@
import { NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { sql } from "@/db"; import { sql } from "@/db";
import { requireFamily, requireOwnership } from "@/lib/auth"; import { requireFamily } from "@/lib/auth";
// IAP Vaccination Schedule (weeks from birth) // ─── IST helpers (server-side) ────────────────────────────────────────────────
const IST_OFFSET_MS = 5.5 * 60 * 60 * 1000; // UTC +5:30
function getISTDate(): string {
return new Date().toLocaleDateString("sv-SE", { timeZone: "Asia/Kolkata" }); // YYYY-MM-DD
}
function getISTHour(): number {
const str = new Intl.DateTimeFormat("en-IN", { timeZone: "Asia/Kolkata", hour: "numeric", hour12: false }).format(new Date());
const h = parseInt(str, 10);
return h === 24 ? 0 : h;
}
/** Monday of the current IST week (for weekly garment nudge slot) */
function getISTWeekMonday(): string {
const istNow = new Date(Date.now() + IST_OFFSET_MS);
const dow = istNow.getUTCDay(); // 0=Sun … 6=Sat
const mon = new Date(istNow.getTime() - ((dow === 0 ? 6 : dow - 1) * 86_400_000));
return mon.toISOString().slice(0, 10);
}
// ─── IAP vaccine schedule ─────────────────────────────────────────────────────
const IAP_SCHEDULE = [ const IAP_SCHEDULE = [
{ name: "BCG", weeks: 0 }, { name: "BCG", weeks: 0 },
{ name: "OPV-0", weeks: 0 }, { name: "OPV-0", weeks: 0 },
{ name: "HepB-1", weeks: 0 }, { name: "HepB-1", weeks: 0 },
{ name: "OPV-1", weeks: 6 }, { name: "OPV-1", weeks: 6 },
{ name: "Pentavalent-1", weeks: 6 }, { name: "Pentavalent-1", weeks: 6 },
{ name: "PCV-1", weeks: 6 }, { name: "PCV-1", weeks: 6 },
{ name: "Rota-1", weeks: 6 }, { name: "Rota-1", weeks: 6 },
{ name: "OPV-2", weeks: 10 }, { name: "OPV-2", weeks: 10 },
{ name: "Pentavalent-2", weeks: 10 }, { name: "Pentavalent-2", weeks: 10 },
{ name: "PCV-2", weeks: 10 }, { name: "PCV-2", weeks: 10 },
{ name: "Rota-2", weeks: 10 }, { name: "Rota-2", weeks: 10 },
{ name: "OPV-3", weeks: 14 }, { name: "OPV-3", weeks: 14 },
{ name: "Pentavalent-3", weeks: 14 }, { name: "Pentavalent-3", weeks: 14 },
{ name: "PCV-3", weeks: 14 }, { name: "PCV-3", weeks: 14 },
{ name: "Rota-3", weeks: 14 }, { name: "Rota-3", weeks: 14 },
{ name: "MR-1", weeks: 48 }, { name: "MR-1", weeks: 48 },
{ name: "JE-1", weeks: 48 }, { name: "JE-1", weeks: 48 },
{ name: "Vitamin A-1", weeks: 48 }, { name: "Vitamin A-1", weeks: 48 },
{ name: "OPV-4", weeks: 48 }, { name: "OPV-4", weeks: 48 },
{ name: "MR-2", weeks: 96 }, { name: "MR-2", weeks: 96 },
{ name: "JE-2", weeks: 96 }, { name: "JE-2", weeks: 96 },
{ name: "DPT-Booster-1", weeks: 96 }, { name: "DPT-Booster-1", weeks: 96 },
{ name: "Vitamin A-2", weeks: 96 }, { name: "Vitamin A-2", weeks: 96 },
{ name: "OPV-5", weeks: 96 }, { name: "OPV-5", weeks: 96 },
{ name: "DPT-Booster-2", weeks: 208 }, { name: "DPT-Booster-2", weeks: 208 },
{ name: "Tetanus and adult diphtheria (Td)", weeks: 208 }, { name: "Td", weeks: 208 },
]; ];
export async function GET(request: Request) { /** Safe type code for a vaccine name (no slashes or spaces) */
function vaccineType(name: string) {
return `vaccine_${name.replace(/[^a-zA-Z0-9-]/g, "_")}`;
}
// ─── GET — generate today's notifications then return all ────────────────────
export async function GET(request: NextRequest) {
try { try {
const auth = await requireFamily(); const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status }); if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const familyId = auth.session!.familyId!;
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const childId = searchParams.get("childId"); const childId = searchParams.get("childId");
if (!childId) { if (!childId) return NextResponse.json({ error: "childId required" }, { status: 400 });
return NextResponse.json({ error: "childId required" }, { status: 400 });
}
const ownership = await requireOwnership(childId, "children", "Child"); const istDate = getISTDate();
if (!ownership.success) return NextResponse.json({ error: ownership.error }, { status: ownership.status }); const istHour = getISTHour();
const weekSlot = getISTWeekMonday();
// Get child's birth date // ── Fetch child info ──────────────────────────────────────────────────────
const children = await sql` const children = await sql`
SELECT id, name, birth_date FROM children WHERE id = ${childId} SELECT id, name, birth_date FROM children
WHERE id = ${childId} AND family_id = ${familyId}
`; `;
if (!children[0]) return NextResponse.json({ notifications: [] });
const child = children[0];
const birthDate = new Date(child.birth_date as string);
if (!children || children.length === 0) { // ── 1. Vaccine notifications ──────────────────────────────────────────────
return NextResponse.json({ notifications: [] });
}
const child = children[0];
const birthDate = new Date(child.birth_date);
const today = new Date();
today.setHours(0, 0, 0, 0);
// Get already given vaccines
const given = await sql` const given = await sql`
SELECT vaccine_name, given_date FROM vaccinations SELECT vaccine_name FROM vaccinations
WHERE child_id = ${childId} AND status = 'given' WHERE child_id = ${childId} AND status = 'given'
`; `;
const givenMap = new Set(given.map((v: any) => v.vaccine_name)); const givenSet = new Set((given as unknown as { vaccine_name: string }[]).map(r => r.vaccine_name));
// Calculate upcoming vaccine notifications const today = new Date(istDate + "T00:00:00Z"); // IST midnight as Date for comparison
const notifications = [];
for (const vaccine of IAP_SCHEDULE) {
if (givenMap.has(vaccine.name)) continue;
// Calculate due date for (const v of IAP_SCHEDULE) {
const dueDate = new Date(birthDate); if (givenSet.has(v.name)) continue;
dueDate.setDate(dueDate.getDate() + vaccine.weeks * 7);
dueDate.setHours(0, 0, 0, 0);
const diffDays = Math.floor((dueDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)); const dueDate = new Date(birthDate.getTime() + v.weeks * 7 * 86_400_000);
const dueDateStr = dueDate.toISOString().slice(0, 10);
// Notify if due today or overdue // Only notify if due today or overdue
if (diffDays <= 0) { if (dueDateStr > istDate) continue;
notifications.push({
id: `vaccine-${vaccine.name}`, const diffDays = Math.round((today.getTime() - dueDate.getTime()) / 86_400_000);
type: "vaccine", const isToday = diffDays === 0;
title: diffDays === 0 ? "Vaccine Due Today" : "Vaccine Overdue",
message: `${vaccine.name} is ${diffDays === 0 ? "due today" : `${Math.abs(diffDays)} days overdue`}`, await sql`
vaccineName: vaccine.name, INSERT INTO notifications
dueDate: dueDate.toISOString().split("T")[0], (family_id, child_id, type, title, message, action_url, scheduled_for, metadata)
status: diffDays === 0 ? "due_today" : "overdue", VALUES (
childId, ${familyId}, ${childId},
childName: child.name, ${vaccineType(v.name)},
}); ${isToday ? "Vaccine due today" : "Vaccine overdue"},
${isToday
? `${v.name} is due today`
: `${v.name} is ${diffDays} day${diffDays === 1 ? "" : "s"} overdue`},
'/medical',
${dueDateStr}::date,
${JSON.stringify({ vaccineName: v.name, dueDate: dueDateStr })}::jsonb
)
ON CONFLICT (family_id, child_id, type, scheduled_for) DO UPDATE SET
title = EXCLUDED.title,
message = EXCLUDED.message,
action_url = EXCLUDED.action_url,
metadata = EXCLUDED.metadata
`;
}
// ── 2. Log nudge — no activity today past noon IST ────────────────────────
if (istHour >= 12) {
const logCount = await sql`
SELECT
(SELECT COUNT(*) FROM feeds WHERE child_id = ${childId} AND (logged_at AT TIME ZONE 'Asia/Kolkata')::date = ${istDate}::date) +
(SELECT COUNT(*) FROM diapers_logs WHERE child_id = ${childId} AND (logged_at AT TIME ZONE 'Asia/Kolkata')::date = ${istDate}::date) +
(SELECT COUNT(*) FROM sleeps WHERE child_id = ${childId} AND (logged_at AT TIME ZONE 'Asia/Kolkata')::date = ${istDate}::date)
AS total
`;
if (Number((logCount[0] as { total: string }).total) === 0) {
await sql`
INSERT INTO notifications
(family_id, child_id, type, title, message, action_url, scheduled_for)
VALUES (
${familyId}, ${childId}, 'log_nudge',
${'No activity logged yet today'},
${`How is ${child.name as string} doing? Log a feed, diaper change, or sleep session`},
'/activity',
${istDate}::date
)
ON CONFLICT (family_id, child_id, type, scheduled_for) DO NOTHING
`;
} }
} }
return NextResponse.json({ notifications }); // ── 3. Memory nudge — no photo shared today ───────────────────────────────
} catch (error) { const memCount = await sql`
console.error("Notifications error:", error); SELECT COUNT(*) AS total FROM memories
return NextResponse.json({ error: String(error) }, { status: 500 }); WHERE family_id = ${familyId}
AND (created_at AT TIME ZONE 'Asia/Kolkata')::date = ${istDate}::date
`;
if (Number((memCount[0] as { total: string }).total) === 0) {
await sql`
INSERT INTO notifications
(family_id, child_id, type, title, message, action_url, scheduled_for)
VALUES (
${familyId}, ${childId}, 'memory_nudge',
${'Capture a moment today 📸'},
${`Add a photo of ${child.name as string} to your memories — your future self will thank you`},
'/memories',
${istDate}::date
)
ON CONFLICT (family_id, child_id, type, scheduled_for) DO NOTHING
`;
}
// ── 4. Garment nudge — wardrobe has fewer than 10 items (weekly) ──────────
const garmentCount = await sql`
SELECT COUNT(*) AS total FROM garments
WHERE child_id = ${childId} AND status = 'active'
`;
if (Number((garmentCount[0] as { total: string }).total) < 10) {
await sql`
INSERT INTO notifications
(family_id, child_id, type, title, message, action_url, scheduled_for)
VALUES (
${familyId}, ${childId}, 'garment_nudge',
${'Build out the wardrobe 👚'},
${`Add clothes to ${child.name as string}'s wardrobe to unlock outfit suggestions`},
'/wardrobe/add',
${weekSlot}::date
)
ON CONFLICT (family_id, child_id, type, scheduled_for) DO NOTHING
`;
}
// ── 5. Return all notifications, filtering out given vaccines ─────────────
const rows = await sql`
SELECT n.*
FROM notifications n
WHERE n.family_id = ${familyId}
AND n.child_id = ${childId}
AND (
-- Non-vaccine rows: always include
n.type NOT LIKE 'vaccine_%'
OR
-- Vaccine rows: only if the vaccine hasn't been given
NOT EXISTS (
SELECT 1 FROM vaccinations v
WHERE v.child_id = n.child_id
AND v.status = 'given'
AND v.vaccine_name = n.metadata->>'vaccineName'
)
)
ORDER BY n.is_read ASC, n.created_at DESC
LIMIT 60
`;
return NextResponse.json({
notifications: (rows as Record<string, unknown>[]).map(r => ({
id: r.id,
type: r.type,
title: r.title,
message: r.message,
actionUrl: r.action_url,
isRead: r.is_read,
scheduledFor: r.scheduled_for,
createdAt: r.created_at,
metadata: r.metadata,
})),
});
} catch (err) {
console.error("Notifications GET error:", err);
return NextResponse.json({ error: String(err) }, { status: 500 });
}
}
// ─── PATCH — mark all notifications as read for this family+child ─────────────
export async function PATCH(request: NextRequest) {
try {
const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const familyId = auth.session!.familyId!;
const body = await request.json().catch(() => ({})) as { childId?: string };
const childId = body.childId;
if (!childId) return NextResponse.json({ error: "childId required" }, { status: 400 });
await sql`
UPDATE notifications
SET is_read = true
WHERE family_id = ${familyId} AND child_id = ${childId} AND is_read = false
`;
return NextResponse.json({ success: true });
} catch (err) {
console.error("Notifications PATCH error:", err);
return NextResponse.json({ error: String(err) }, { status: 500 });
} }
} }

View file

@ -22,9 +22,23 @@ export async function POST(request: Request) {
const userId = sessions[0].user_id; const userId = sessions[0].user_id;
const body = await request.json(); const body = await request.json();
const { familyName, memberName, childName, birthDate, sex } = body; const { familyName, memberName, phone, childName, birthDate, sex } = body;
try { try {
// Save the parent's name + optional phone onto their user record.
// Phone: keep leading + then digits; store null when blank/invalid.
let normalizedPhone: string | null = null;
if (phone && typeof phone === "string") {
const cleaned = phone.trim().replace(/[^\d+]/g, "").replace(/(?!^)\+/g, "");
const digits = cleaned.replace(/\D/g, "");
if (digits.length >= 8 && digits.length <= 15) normalizedPhone = cleaned;
}
await sql`
UPDATE users
SET name = COALESCE(${memberName || null}, name), phone = ${normalizedPhone}, updated_at = NOW()
WHERE id = ${userId}
`;
// Create family // Create family
const familyId = crypto.randomUUID(); const familyId = crypto.randomUUID();
await sql.unsafe( await sql.unsafe(

View file

@ -11,12 +11,13 @@ export async function GET() {
const familyId = auth.session!.familyId!; const familyId = auth.session!.familyId!;
const info = await getStorageInfo(familyId); const info = await getStorageInfo(familyId);
// Both tiers now have a real cap (free 1 GB / premium 50 GB) — show actuals.
return NextResponse.json({ return NextResponse.json({
usedBytes: info.usedBytes, usedBytes: info.usedBytes,
limitBytes: info.isPaid ? null : info.limitBytes, limitBytes: info.limitBytes,
usedFormatted: formatBytes(info.usedBytes), usedFormatted: formatBytes(info.usedBytes),
limitFormatted: info.isPaid ? "Unlimited" : formatBytes(info.limitBytes), limitFormatted: formatBytes(info.limitBytes),
fraction: info.isPaid ? 0 : info.fraction, fraction: info.fraction,
approaching: info.approaching, approaching: info.approaching,
exceeded: info.exceeded, exceeded: info.exceeded,
isPaid: info.isPaid, isPaid: info.isPaid,

View file

@ -0,0 +1,67 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
import { requireFamily } from "@/lib/auth";
import { getRazorpayConfig, razorpayAuthHeader, RAZORPAY_API_BASE } from "@/lib/billing/config";
/**
* POST /api/subscriptions/cancel
*
* Initiates cancellation at Razorpay with cancel_at_cycle_end=1 the family
* keeps premium until the paid period ends. The actual state change happens
* later via the subscription.cancelled webhook; this route only initiates.
*
* family_id from session (IDOR-safe). Cancels the family's own live sub only.
*/
export async function POST() {
const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const familyId = auth.session!.familyId!;
let cfg;
try {
cfg = getRazorpayConfig();
} catch (e) {
return NextResponse.json({ error: String(e) }, { status: 500 });
}
// Find the family's live subscription.
const rows = await sql`
SELECT razorpay_subscription_id, status FROM family_subscriptions
WHERE family_id = ${familyId}
AND status IN ('created','authenticated','active','pending')
ORDER BY created_at DESC
LIMIT 1
`;
const subId = rows[0]?.razorpay_subscription_id as string | undefined;
if (!subId) {
return NextResponse.json({ error: "No active subscription to cancel" }, { status: 404 });
}
try {
const res = await fetch(`${RAZORPAY_API_BASE}/subscriptions/${subId}/cancel`, {
method: "POST",
headers: {
Authorization: razorpayAuthHeader(cfg),
"Content-Type": "application/json",
},
body: JSON.stringify({ cancel_at_cycle_end: 1 }), // keep premium until period end
});
const data = await res.json();
if (!res.ok) {
console.error("Razorpay cancel failed:", data);
return NextResponse.json(
{ error: data?.error?.description || "Failed to cancel subscription" },
{ status: 502 },
);
}
} catch (e) {
console.error("Razorpay cancel error:", e);
return NextResponse.json({ error: "Failed to reach payment provider" }, { status: 502 });
}
return NextResponse.json({
success: true,
message: "Subscription will cancel at the end of your current billing period. You keep premium until then.",
});
}

View file

@ -0,0 +1,135 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
import { requireFamily } from "@/lib/auth";
import { getRazorpayConfig, razorpayAuthHeader, RAZORPAY_API_BASE } from "@/lib/billing/config";
/**
* POST /api/subscriptions/create
*
* Creates a Razorpay subscription for the caller's family and returns the
* subscription_id + key_id for Razorpay Checkout. Grants NOTHING entitlement
* is applied only by the webhook (subscription.charged/activated).
*
* Security:
* - family_id comes from the session (requireFamily), never from the request
* body so a user can only create a sub for their own family (IDOR-safe).
* - key_secret never leaves the server; only key_id is returned.
*/
export async function POST() {
const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const familyId = auth.session!.familyId!;
const userId = auth.session!.userId;
let cfg;
try {
cfg = getRazorpayConfig();
} catch (e) {
return NextResponse.json({ error: String(e) }, { status: 500 });
}
// Resolve the plan row (must be seeded — see /api/admin/seed-plan).
const planRows = await sql`
SELECT id, razorpay_plan_id FROM subscription_plans
WHERE razorpay_plan_id = ${cfg.planId} AND is_active = true
LIMIT 1
`;
if (!planRows[0]) {
return NextResponse.json(
{ error: "Plan not seeded. Run POST /api/admin/seed-plan first." },
{ status: 500 },
);
}
const planRowId = planRows[0].id as string;
// Inspect any existing non-terminal subscription for this family.
const live = await sql`
SELECT id, razorpay_subscription_id, status FROM family_subscriptions
WHERE family_id = ${familyId}
AND status IN ('created','authenticated','active','pending','halted')
ORDER BY created_at DESC
LIMIT 1
`;
if (live[0]) {
const status = live[0].status as string;
// Genuinely subscribed → block (they don't need a second subscription).
if (status === "authenticated" || status === "active" || status === "pending") {
return NextResponse.json(
{ error: "You already have an active subscription.", status },
{ status: 409 },
);
}
// Abandoned checkout: a 'created' row whose payment never completed. The
// Razorpay subscription is still payable, so REUSE it — reopen checkout for
// the same sub instead of locking the user out forever.
if (status === "created") {
return NextResponse.json({
subscriptionId: live[0].razorpay_subscription_id,
keyId: cfg.keyId,
reused: true,
});
}
// 'halted' = retries exhausted, family already downgraded to free. Let them
// re-subscribe: retire the halted row (leaves the partial unique index) so
// a fresh subscription can be created below.
if (status === "halted") {
await sql`
UPDATE family_subscriptions
SET status = 'expired', ended_at = COALESCE(ended_at, NOW()), updated_at = NOW()
WHERE id = ${live[0].id}
`;
}
}
// Create the subscription at Razorpay.
let rzpSub: { id?: string; status?: string; error?: { description?: string } };
try {
const res = await fetch(`${RAZORPAY_API_BASE}/subscriptions`, {
method: "POST",
headers: {
Authorization: razorpayAuthHeader(cfg),
"Content-Type": "application/json",
},
body: JSON.stringify({
plan_id: cfg.planId,
total_count: 120, // ~10 yrs of monthly cycles; avoids auto-complete
customer_notify: 1,
quantity: 1,
notes: { family_id: familyId, user_id: userId }, // correlation backup for webhook
}),
});
rzpSub = await res.json();
if (!res.ok || !rzpSub.id) {
console.error("Razorpay create sub failed:", rzpSub);
return NextResponse.json(
{ error: rzpSub?.error?.description || "Failed to create subscription" },
{ status: 502 },
);
}
} catch (e) {
console.error("Razorpay create sub error:", e);
return NextResponse.json({ error: "Failed to reach payment provider" }, { status: 502 });
}
// Record our side as 'created'. The webhook drives all later state.
try {
await sql`
INSERT INTO family_subscriptions
(family_id, plan_id, razorpay_subscription_id, status)
VALUES (${familyId}, ${planRowId}, ${rzpSub.id}, 'created')
`;
} catch (e) {
// Unique index race (double-click) — treat as the existing-sub case.
console.error("Insert family_subscriptions failed:", e);
return NextResponse.json(
{ error: "Subscription already in progress for this family." },
{ status: 409 },
);
}
return NextResponse.json({ subscriptionId: rzpSub.id, keyId: cfg.keyId });
}

View file

@ -0,0 +1,76 @@
import { NextResponse } from "next/server";
import crypto from "crypto";
import { sql } from "@/db";
import { requireFamily } from "@/lib/auth";
import { getRazorpayConfig } from "@/lib/billing/config";
/**
* POST /api/subscriptions/verify UX feedback ONLY.
*
* Called by the Checkout success handler so we can show "you're in!". It verifies
* the checkout signature but GRANTS NOTHING entitlement is applied solely by
* the webhook (/api/webhooks/razorpay). This route just confirms the handshake
* looks authentic so the UI can show an optimistic "activating shortly" screen.
*
* Subscription signature order is payment_id|subscription_id (NOT the order_id
* flavour). We HMAC against the subscription_id WE stored, not whatever the
* client sends, so a client can't verify against an arbitrary subscription.
*/
export async function POST(req: Request) {
const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const familyId = auth.session!.familyId!;
let body: { razorpay_payment_id?: string; razorpay_signature?: string };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const paymentId = body.razorpay_payment_id;
const signature = body.razorpay_signature;
if (!paymentId || !signature) {
return NextResponse.json({ error: "Missing payment id or signature" }, { status: 400 });
}
let cfg;
try {
cfg = getRazorpayConfig();
} catch (e) {
return NextResponse.json({ error: String(e) }, { status: 500 });
}
// Use the subscription_id we recorded for this family — never the client's.
const rows = await sql`
SELECT razorpay_subscription_id FROM family_subscriptions
WHERE family_id = ${familyId}
AND status IN ('created','authenticated','active','pending')
ORDER BY created_at DESC
LIMIT 1
`;
const subId = rows[0]?.razorpay_subscription_id as string | undefined;
if (!subId) {
return NextResponse.json({ error: "No pending subscription found" }, { status: 404 });
}
// SUBSCRIPTION order: payment_id|subscription_id
const expected = crypto
.createHmac("sha256", cfg.keySecret)
.update(`${paymentId}|${subId}`)
.digest("hex");
const sigBuf = Buffer.from(signature);
const expBuf = Buffer.from(expected);
const valid = sigBuf.length === expBuf.length && crypto.timingSafeEqual(sigBuf, expBuf);
if (!valid) {
return NextResponse.json({ error: "Signature verification failed" }, { status: 400 });
}
// Looks authentic. Do NOT grant here — the webhook is the source of truth.
return NextResponse.json({
success: true,
message: "Payment received — your premium is activating shortly.",
});
}

50
src/app/api/time/route.ts Normal file
View file

@ -0,0 +1,50 @@
import { NextResponse } from "next/server";
const TZ = "Asia/Kolkata";
/**
* GET /api/time
* Returns the server's current time in IST. The app can call this to verify
* that the user's device clock matches the server, and to use a trusted
* timestamp source for log entries if needed.
*
* No auth required time is not sensitive.
*/
export async function GET() {
const now = new Date();
const istDate = now.toLocaleDateString("sv-SE", { timeZone: TZ }); // YYYY-MM-DD
const istTime = now.toLocaleTimeString("en-IN", {
timeZone: TZ,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
const istLabel = now.toLocaleString("en-IN", {
timeZone: TZ,
weekday: "short",
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: true,
});
return NextResponse.json(
{
utc: now.toISOString(), // e.g. "2026-05-28T09:00:00.000Z"
istDate, // e.g. "2026-05-28"
istTime, // e.g. "14:30:00"
ist: istLabel, // e.g. "Wed, 28 May 2026, 02:30 PM"
offsetMinutes: 330, // IST = UTC +5:30
},
{
headers: {
// Do not cache — always return live server time
"Cache-Control": "no-store",
},
}
);
}

View file

@ -0,0 +1,204 @@
import { NextResponse } from "next/server";
import crypto from "crypto";
import { sql } from "@/db";
import { getRazorpayConfig } from "@/lib/billing/config";
import { grantPremium, revokeToFree } from "@/lib/billing/entitlements";
import { sendAlert } from "@/lib/alert";
/**
* POST /api/webhooks/razorpay THE source of truth for entitlement.
*
* Razorpay calls this on every subscription lifecycle change. We:
* 1. Verify the HMAC signature (timing-safe) over the RAW body.
* 2. Idempotency: unique-insert on x-razorpay-event-id. Duplicate 200 & bail.
* 3. Update family_subscriptions by razorpay_subscription_id.
* 4. Sync entitlement onto families.tier (grant/revoke) so the existing
* quota.ts guards enforce the new limits.
*
* Status codes (Razorpay retries on non-2xx):
* 400 bad signature (do NOT retry)
* 200 processed OK, or duplicate (already processed)
* 500 processing error (Razorpay WILL retry that's what we want)
*/
// Razorpay event → how it affects entitlement.
const GRANT_EVENTS: Record<string, string> = {
"subscription.authenticated": "authenticated",
"subscription.activated": "active",
"subscription.charged": "active",
"subscription.resumed": "active",
"subscription.pending": "pending", // grace — keep entitled
};
const REVOKE_EVENTS: Record<string, string> = {
"subscription.halted": "halted",
"subscription.cancelled": "cancelled",
"subscription.completed": "completed",
"subscription.expired": "expired",
"subscription.paused": "paused",
};
// Razorpay sends unix seconds. Return an ISO STRING (not a Date) — postgres.js
// in this repo binds timestamps as strings via the custom serializer; passing a
// raw Date object throws ERR_INVALID_ARG_TYPE.
function unixToISO(v: unknown): string | null {
const n = typeof v === "number" ? v : Number(v);
return Number.isFinite(n) && n > 0 ? new Date(n * 1000).toISOString() : null;
}
export async function POST(req: Request) {
// RAW body — never req.json() here; the signature is over the exact bytes.
const rawBody = await req.text();
const signature = req.headers.get("x-razorpay-signature") ?? "";
const eventId = req.headers.get("x-razorpay-event-id") ?? "";
let cfg;
try {
cfg = getRazorpayConfig();
} catch (e) {
console.error("Razorpay webhook: not configured", e);
return new NextResponse("not configured", { status: 500 });
}
// 1. Verify signature (timing-safe).
const expected = crypto
.createHmac("sha256", cfg.webhookSecret)
.update(rawBody)
.digest("hex");
const sigBuf = Buffer.from(signature);
const expBuf = Buffer.from(expected);
const validSig = sigBuf.length === expBuf.length && crypto.timingSafeEqual(sigBuf, expBuf);
if (!validSig) {
return new NextResponse("bad signature", { status: 400 });
}
if (!eventId) {
// No event id to dedupe on — refuse so Razorpay retries with headers.
return new NextResponse("missing event id", { status: 400 });
}
let event: {
event?: string;
payload?: { subscription?: { entity?: Record<string, unknown> } };
};
try {
event = JSON.parse(rawBody);
} catch {
return new NextResponse("bad json", { status: 400 });
}
const eventType = event.event ?? "unknown";
// 2. Audit log (best-effort, never blocks processing). We intentionally do
// NOT early-return on duplicate here: processing is idempotent (status
// UPDATE + grant/revoke are all upserts), so re-running a redelivered event
// is safe. Early-returning on duplicate BEFORE processing was a trap — a
// processing error left the event logged-but-unapplied and every retry hit
// the duplicate guard and skipped processing forever.
try {
await sql`
INSERT INTO razorpay_webhook_events (razorpay_event_id, event_type, payload)
VALUES (${eventId}, ${eventType}, ${rawBody}::jsonb)
ON CONFLICT (razorpay_event_id) DO NOTHING
`;
} catch (e) {
// Logging is not critical to entitlement — carry on and still process.
console.error("Razorpay webhook: log insert failed (continuing)", e);
}
// 3 + 4. Process the event. Errors here → 500 so Razorpay retries (and the
// retry WILL reprocess, because we no longer short-circuit on duplicate).
try {
const sub = event.payload?.subscription?.entity;
const subId = sub?.id as string | undefined;
// Events without a subscription entity (e.g. payment.* if ever subscribed)
// are logged above; nothing to sync.
if (!subId) return new NextResponse("ok (no subscription entity)", { status: 200 });
// Find our row for this Razorpay subscription.
const rows = await sql`
SELECT id, family_id FROM family_subscriptions
WHERE razorpay_subscription_id = ${subId}
LIMIT 1
`;
const row = rows[0] as { id: string; family_id: string } | undefined;
if (!row) {
// Unknown subscription (e.g. created outside this app). Logged; ack so
// Razorpay stops retrying — there's nothing for us to update.
console.warn("Razorpay webhook: no family_subscriptions row for", subId);
return new NextResponse("ok (unknown subscription)", { status: 200 });
}
const currentStart = unixToISO(sub?.current_start);
const currentEnd = unixToISO(sub?.current_end);
const customerId = (sub?.customer_id as string) ?? null;
const grantStatus = GRANT_EVENTS[eventType];
const revokeStatus = REVOKE_EVENTS[eventType];
// Family name for alert context (best-effort).
const famRows = await sql`SELECT name FROM families WHERE id = ${row.family_id} LIMIT 1`;
const familyName = (famRows[0]?.name as string) || row.family_id.slice(0, 8);
if (grantStatus) {
await sql`
UPDATE family_subscriptions SET
status = ${grantStatus}::subscription_status_enum,
razorpay_customer_id = COALESCE(${customerId}, razorpay_customer_id),
current_start = COALESCE(${currentStart}, current_start),
current_end = COALESCE(${currentEnd}, current_end),
updated_at = NOW()
WHERE id = ${row.id}
`;
// resumed→active; pending is grace (kept entitled) but a payment FAILED.
await grantPremium(row.family_id, grantStatus);
if (eventType === "subscription.pending") {
// A charge failed; Razorpay is retrying. Reach out before they churn.
await sendAlert("warn", "Payment failing (grace period)", undefined, {
fields: { Family: familyName, Subscription: subId, Status: "pending — retrying" },
});
} else if (eventType === "subscription.charged") {
await sendAlert("info", "💸 Subscription charged", undefined, {
fields: { Family: familyName, Subscription: subId },
silent: true,
});
} else if (eventType === "subscription.activated") {
await sendAlert("info", "🎉 New premium subscriber", undefined, {
fields: { Family: familyName, Subscription: subId },
});
}
} else if (revokeStatus) {
const nowIso = new Date().toISOString();
const endedAt = revokeStatus === "paused" ? null : nowIso;
const cancelledAt = revokeStatus === "cancelled" ? nowIso : null;
await sql`
UPDATE family_subscriptions SET
status = ${revokeStatus}::subscription_status_enum,
cancelled_at = ${cancelledAt},
ended_at = COALESCE(${endedAt}, ended_at),
updated_at = NOW()
WHERE id = ${row.id}
`;
await revokeToFree(row.family_id, revokeStatus);
if (revokeStatus === "halted") {
// Retries exhausted — customer just churned involuntarily. Loud alert.
await sendAlert("error", "🔴 Subscription HALTED (churn)", "Payment retries exhausted — family downgraded to free.", {
fields: { Family: familyName, Subscription: subId },
});
} else if (revokeStatus === "cancelled") {
await sendAlert("warn", "Subscription cancelled", undefined, {
fields: { Family: familyName, Subscription: subId },
});
}
} else {
// Unhandled event type — already logged, ack it.
return new NextResponse("ok (unhandled event)", { status: 200 });
}
return new NextResponse("ok", { status: 200 });
} catch (e) {
console.error("Razorpay webhook: processing error", e);
return new NextResponse("processing error", { status: 500 }); // retry
}
}

54
src/app/global-error.tsx Normal file
View file

@ -0,0 +1,54 @@
"use client";
import { useEffect } from "react";
// Root-level error boundary. Catches errors thrown in the root layout / during
// rendering that no nested error.tsx caught. Must render its own <html>/<body>.
// Reports the crash to /api/errors so it shows up in the admin error tracker.
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
try {
const body = JSON.stringify({
message: error?.message || "Unknown global error",
stack: error?.stack,
digest: error?.digest,
url: typeof window !== "undefined" ? window.location.pathname : undefined,
level: "fatal",
metadata: { boundary: "global" },
});
// keepalive so the report survives the page being torn down
fetch("/api/errors", {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
keepalive: true,
}).catch(() => {});
} catch {}
}, [error]);
return (
<html lang="en">
<body style={{ fontFamily: "system-ui, sans-serif", background: "#fdf2f2", color: "#1a1a1a" }}>
<div style={{ minHeight: "100vh", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 16, padding: 24, textAlign: "center" }}>
<div style={{ fontSize: 40 }}>😵</div>
<h1 style={{ fontSize: 20, fontWeight: 700 }}>Something went wrong</h1>
<p style={{ color: "#6b7280", maxWidth: 360 }}>
The app hit an unexpected error. It&apos;s been reported automatically please try again.
</p>
<button
onClick={() => reset()}
style={{ background: "#fb7185", color: "white", border: "none", padding: "10px 20px", borderRadius: 12, fontWeight: 600, cursor: "pointer" }}
>
Try again
</button>
</div>
</body>
</html>
);
}

View file

@ -45,6 +45,25 @@
animation: marquee 20s linear infinite; animation: marquee 20s linear infinite;
} }
/* ── CTA button pulse ring ── */
@keyframes cta-pulse {
0% { transform: scale(1); opacity: 0.45; }
60% { transform: scale(1.18); opacity: 0.1; }
100% { transform: scale(1.22); opacity: 0; }
}
.animate-cta-pulse {
animation: cta-pulse 2.6s ease-out infinite;
}
/* ── Editorial font utilities (marketing pages) ── */
.font-fraunces { font-family: var(--font-fraunces, Georgia, serif); }
.font-newsreader { font-family: var(--font-newsreader, Georgia, serif); }
.font-jetbrains { font-family: var(--font-jetbrains, ui-monospace, monospace); }
/* Explicit pin so footer links read clearly lighter than their headings */
.text-gray-500 { color: var(--color-gray-500); }
/* hide scrollbar but keep scroll */ /* hide scrollbar but keep scroll */
.scrollbar-hide { .scrollbar-hide {
scrollbar-width: none; scrollbar-width: none;
@ -59,3 +78,16 @@ body {
color: var(--foreground); color: var(--foreground);
font-family: Arial, Helvetica, sans-serif; font-family: Arial, Helvetica, sans-serif;
} }
/* Prevent mobile auto-zoom on input focus.
iOS Safari (and other mobile browsers) zoom the viewport whenever a focused
input/select/textarea has a font-size below 16px which is what made the
home "Ask AI" popup (and other text-sm inputs) jump and distort the UI.
Enforce a 16px minimum on touch devices only; desktop styling is unchanged. */
@media (pointer: coarse) {
input:not([type="checkbox"]):not([type="radio"]):not([type="range"]),
select,
textarea {
font-size: 16px;
}
}

View file

@ -1,5 +1,7 @@
import type { Metadata, Viewport } from "next"; import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono, Caveat } from "next/font/google"; import { Geist, Geist_Mono, Caveat } from "next/font/google";
import Script from "next/script";
import { SITE_URL, SITE_NAME } from "@/lib/seo";
import "./globals.css"; import "./globals.css";
const geistSans = Geist({ const geistSans = Geist({
@ -18,17 +20,41 @@ const caveat = Caveat({
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Tia — Baby Tracker", // Resolves all relative metadata URLs (OG/Twitter images, canonicals) to
description: "Track feeds, sleep, milestones and memories.", // absolute URLs — required for social previews to work.
metadataBase: new URL(SITE_URL),
title: {
default: "Tia — Baby Tracker & Digital Heirloom",
template: `%s | ${SITE_NAME}`,
},
description:
"Track feeds, sleep, milestones, and memories. Tia is a privacy-first baby tracker and digital heirloom built for Indian families.",
applicationName: SITE_NAME,
authors: [{ name: "Tia" }],
creator: "Tia",
publisher: "Tia",
category: "parenting",
formatDetection: { telephone: false, email: false, address: false },
// apple-touch-icon is provided by the src/app/apple-icon.png file convention.
icons: { icons: {
icon: "/icon.svg", icon: "/icon.svg",
apple: "/apple-icon.png",
}, },
appleWebApp: { appleWebApp: {
capable: true, capable: true,
statusBarStyle: "default", statusBarStyle: "default",
title: "Tia", title: "Tia",
}, },
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-image-preview": "large",
"max-snippet": -1,
"max-video-preview": -1,
},
},
}; };
export const viewport: Viewport = { export const viewport: Viewport = {
@ -41,9 +67,17 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="en" suppressHydrationWarning> <html lang="en-IN" suppressHydrationWarning>
<body className={`${geistSans.variable} ${geistMono.variable} ${caveat.variable} min-h-full antialiased`}> <body className={`${geistSans.variable} ${geistMono.variable} ${caveat.variable} min-h-full antialiased`}>
{children} {children}
{/* Umami analytics script served locally to avoid Cloudflare cross-origin 503.
data-host-url tells the script where to POST events (analytics.manohargupta.com). */}
<Script
src="/umami.js"
data-website-id="79444c19-ee31-4fab-baf5-f4e61098eeba"
data-host-url="https://analytics.manohargupta.com"
strategy="afterInteractive"
/>
</body> </body>
</html> </html>
); );

View file

@ -2,11 +2,18 @@ import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest { export default function manifest(): MetadataRoute.Manifest {
return { return {
name: "Tia — Baby Tracker", name: "Tia — Baby Tracker & Digital Heirloom",
short_name: "Tia", short_name: "Tia",
description: "Track feeds, sleep, milestones and memories.", description:
start_url: "/?source=pwa", "Track feeds, sleep, milestones, and memories. Tia is a privacy-first baby tracker and digital heirloom built for Indian families — with the IAP vaccination schedule and Telegram alerts.",
id: "/home",
start_url: "/home?source=pwa",
scope: "/",
display: "standalone", display: "standalone",
orientation: "portrait",
lang: "en-IN",
dir: "ltr",
categories: ["health", "lifestyle", "parenting", "medical"],
background_color: "#fdf2f2", background_color: "#fdf2f2",
theme_color: "#fb7185", theme_color: "#fb7185",
icons: [ icons: [

41
src/app/robots.ts Normal file
View file

@ -0,0 +1,41 @@
import type { MetadataRoute } from "next";
import { SITE_URL } from "@/lib/seo";
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: "*",
allow: "/",
// Private app, auth, and admin areas — keep them out of the index.
disallow: [
"/api/",
"/admin",
"/admin-login",
"/login",
"/verify",
"/invite/",
"/onboarding",
"/home",
"/activity",
"/ai",
"/circle",
"/family",
"/growth",
"/medical",
"/memories",
"/menu",
"/milestones",
"/notifications",
"/profile",
"/settings",
"/wardrobe",
"/dev",
"/m/",
],
},
],
sitemap: `${SITE_URL}/sitemap.xml`,
host: SITE_URL,
};
}

38
src/app/sitemap.ts Normal file
View file

@ -0,0 +1,38 @@
import type { MetadataRoute } from "next";
import { SITE_URL } from "@/lib/seo";
import { POSTS } from "@/app/(marketing)/blog/posts";
export default function sitemap(): MetadataRoute.Sitemap {
const now = new Date();
// Static marketing routes with curated priorities.
const staticRoutes: {
path: string;
changeFrequency: MetadataRoute.Sitemap[number]["changeFrequency"];
priority: number;
}[] = [
{ path: "/", changeFrequency: "weekly", priority: 1.0 },
{ path: "/pricing", changeFrequency: "monthly", priority: 0.8 },
{ path: "/about", changeFrequency: "monthly", priority: 0.7 },
{ path: "/blog", changeFrequency: "weekly", priority: 0.7 },
{ path: "/partners", changeFrequency: "monthly", priority: 0.5 },
{ path: "/privacy", changeFrequency: "yearly", priority: 0.3 },
{ path: "/terms", changeFrequency: "yearly", priority: 0.3 },
];
const staticEntries: MetadataRoute.Sitemap = staticRoutes.map((r) => ({
url: `${SITE_URL}${r.path}`,
lastModified: now,
changeFrequency: r.changeFrequency,
priority: r.priority,
}));
const blogEntries: MetadataRoute.Sitemap = POSTS.map((post) => ({
url: `${SITE_URL}/blog/${post.slug}`,
lastModified: new Date(post.date),
changeFrequency: "monthly",
priority: 0.6,
}));
return [...staticEntries, ...blogEntries];
}

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