Compare commits

..

No commits in common. "main" and "feature/quota-and-member-limits" have entirely different histories.

128 changed files with 892 additions and 8442 deletions

View file

@ -75,16 +75,6 @@ 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`
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
Use the **debug-migration endpoint** (same pattern as the Circles implementation):
@ -237,27 +227,6 @@ 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 Type | Storage | API |
@ -298,13 +267,7 @@ Set in `.env.local` for development, Dokploy dashboard for production.
| `RESEND_API_KEY` | ✅ | Resend API key for transactional email |
| `EMAIL_FROM` | ✅ | Sender address (e.g. `Tia <tia@manohargupta.com>`) |
| `NEXT_PUBLIC_APP_URL` | ✅ | Full app URL (e.g. `https://tia.manohargupta.com`) |
| `CRON_SECRET` | ✅ | Secret for cron endpoints (backup, monitor, visitor-summary) — sent as `x-cron-secret` header |
| `TELEGRAM_BOT_TOKEN` | ✅ | tiaBaby_Bot token from @BotFather — operational alerts |
| `TELEGRAM_CHAT_ID` | ✅ | Chat/group/channel id alerts post to (see `src/lib/alert.ts` header for how to get it) |
| `UMAMI_BASE_URL` | — | Umami instance (default `https://analytics.manohargupta.com`) |
| `UMAMI_USERNAME` | ✅ | Umami login — for the visitor-summary cron |
| `UMAMI_PASSWORD` | ✅ | Umami password |
| `UMAMI_WEBSITE_ID` | — | Umami website id (default Tia's id) |
| `CRON_SECRET` | ✅ | Secret for cron backup endpoint |
---

View file

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

View file

@ -1,38 +0,0 @@
-- 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

@ -1,22 +0,0 @@
-- Error / crash tracking. Captures both client-side React errors (via the
-- error boundaries that POST to /api/errors) and server-side failures (via
-- logError() in src/lib/error-log.ts). Surfaced in the admin panel at
-- /admin/errors so production bugs are visible instead of silent.
CREATE TABLE IF NOT EXISTS error_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
level varchar(20) NOT NULL DEFAULT 'error', -- error | warn | fatal
source varchar(20) NOT NULL DEFAULT 'client', -- client | server
message text NOT NULL,
stack text,
url text, -- route / pathname where it happened
digest varchar(120), -- Next.js error digest (server)
user_id uuid,
family_id uuid,
user_agent text,
metadata jsonb DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_error_events_created ON error_events (created_at DESC);
CREATE INDEX IF NOT EXISTS idx_error_events_source ON error_events (source);
CREATE INDEX IF NOT EXISTS idx_error_events_message ON error_events (message);

View file

@ -1,2 +0,0 @@
-- Add optional phone number to users (collected at onboarding / profile).
ALTER TABLE users ADD COLUMN IF NOT EXISTS phone text;

View file

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

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 885 KiB

File diff suppressed because one or more lines are too long

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,568 +0,0 @@
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

@ -1,82 +0,0 @@
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

@ -1,334 +0,0 @@
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

@ -1,221 +0,0 @@
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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,147 +0,0 @@
"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,7 +32,6 @@ interface EngagementData {
activitySummary: { active7d: number; active30d: number; neverActive: number; total: number };
aiUsage: { totalCalls: number; totalTokens: number; totalCostPaise: number; familiesUsingAI: number };
logsByDay: { date: string; count: number }[];
error?: string;
}
type Tab = "overview" | "families" | "ai";
@ -50,20 +49,7 @@ export default function AdminAnalytics() {
useEffect(() => {
fetch("/api/admin/engagement", { credentials: "include" })
.then(r => r.json())
.then(d => {
// Normalize into the full EngagementData shape so a malformed/error
// response can never crash the render (e.g. [...data.families]).
setData({
totalFamilies: Number(d?.totalFamilies) || 0,
featureAdoption: Array.isArray(d?.featureAdoption) ? d.featureAdoption : [],
families: Array.isArray(d?.families) ? d.families : [],
activitySummary: { active7d: 0, active30d: 0, neverActive: 0, total: 0, ...(d?.activitySummary || {}) },
aiUsage: { totalCalls: 0, totalTokens: 0, totalCostPaise: 0, familiesUsingAI: 0, ...(d?.aiUsage || {}) },
logsByDay: Array.isArray(d?.logsByDay) ? d.logsByDay : [],
error: typeof d?.error === "string" ? d.error : undefined,
});
setLoading(false);
})
.then(d => { setData(d); setLoading(false); })
.catch(() => setLoading(false));
}, []);
@ -118,13 +104,6 @@ export default function AdminAnalytics() {
</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 */}
<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}`} />

View file

@ -1,134 +0,0 @@
"use client";
import { useCallback, useEffect, useState } from "react";
interface AuditRow {
id: string;
action: string;
resource_type: string | null;
resource_id: string | null;
ip_address: string | null;
user_agent: string | null;
metadata: Record<string, unknown> | null;
created_at: string;
user_email: string | null;
user_name: string | null;
family_name: string | null;
}
interface Data { events: AuditRow[]; actions: string[]; resourceTypes: string[]; error?: string }
const WINDOWS = [
{ label: "24h", hours: 24 },
{ label: "7d", hours: 168 },
{ label: "30d", hours: 720 },
{ label: "90d", hours: 2160 },
];
export default function AdminAudit() {
const [data, setData] = useState<Data | null>(null);
const [loading, setLoading] = useState(true);
const [action, setAction] = useState("");
const [resourceType, setResourceType] = useState("");
const [hours, setHours] = useState(168);
const [q, setQ] = useState("");
const [expanded, setExpanded] = useState<string | null>(null);
const load = useCallback(() => {
setLoading(true);
const params = new URLSearchParams({ sinceHours: String(hours) });
if (action) params.set("action", action);
if (resourceType) params.set("resourceType", resourceType);
if (q.trim()) params.set("q", q.trim());
fetch(`/api/admin/audit?${params}`, { credentials: "include" })
.then(r => r.json())
.then(d => {
setData({
events: Array.isArray(d?.events) ? d.events : [],
actions: Array.isArray(d?.actions) ? d.actions : [],
resourceTypes: Array.isArray(d?.resourceTypes) ? d.resourceTypes : [],
error: d?.error,
});
setLoading(false);
})
.catch(() => setLoading(false));
}, [hours, action, resourceType, q]);
useEffect(() => { load(); }, [hours, action, resourceType]); // eslint-disable-line react-hooks/exhaustive-deps
return (
<div className="p-6 space-y-6">
<div className="flex justify-between items-start flex-wrap gap-3">
<div>
<h1 className="text-2xl font-bold">Audit Log</h1>
<p className="text-gray-400">Every recorded action across the platform</p>
</div>
<button onClick={load} className="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded-lg text-sm"> Refresh</button>
</div>
<div className="flex gap-2 flex-wrap items-center">
<select value={action} onChange={e => setAction(e.target.value)} className="bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-sm">
<option value="">All actions</option>
{data?.actions.map(a => <option key={a} value={a}>{a}</option>)}
</select>
<select value={resourceType} onChange={e => setResourceType(e.target.value)} className="bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-sm">
<option value="">All resources</option>
{data?.resourceTypes.map(r => <option key={r} value={r}>{r}</option>)}
</select>
<div className="flex gap-1 bg-gray-800 p-1 rounded-lg">
{WINDOWS.map(w => (
<button key={w.hours} onClick={() => setHours(w.hours)} className={`px-3 py-1.5 rounded-md text-sm ${hours === w.hours ? "bg-gray-600" : "text-gray-400 hover:text-white"}`}>{w.label}</button>
))}
</div>
<form onSubmit={e => { e.preventDefault(); load(); }} className="flex-1 min-w-[180px]">
<input value={q} onChange={e => setQ(e.target.value)} placeholder="Search user / family / action…" className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-sm" />
</form>
</div>
{data?.error && <div className="bg-rose-500/10 text-rose-400 text-sm p-3 rounded-lg">Feed error: {data.error}</div>}
{loading ? (
<div className="text-gray-400">Loading</div>
) : (
<div className="bg-gray-800 rounded-xl overflow-hidden">
{(data?.events.length ?? 0) === 0 ? (
<div className="p-8 text-center text-gray-500">No audit events match these filters</div>
) : (
<div className="divide-y divide-gray-700">
{data!.events.map(e => {
const hasMeta = e.metadata && Object.keys(e.metadata).length > 0;
return (
<div key={e.id} className="p-4 hover:bg-gray-750">
<div className="flex items-start gap-3 cursor-pointer" onClick={() => hasMeta && setExpanded(expanded === e.id ? null : e.id)}>
<span className="text-xs px-2 py-0.5 rounded bg-gray-700 text-gray-200 font-medium whitespace-nowrap">{e.action}</span>
{e.resource_type && <span className="text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-400">{e.resource_type}</span>}
<div className="flex-1 min-w-0 text-xs text-gray-500 flex gap-2 flex-wrap">
{(e.user_email || e.user_name) && <span>👤 {e.user_name || e.user_email}</span>}
{e.family_name && <span>🏠 {e.family_name}</span>}
{e.ip_address && <span>🌐 {e.ip_address}</span>}
<span>🕒 {timeAgo(e.created_at)}</span>
</div>
{hasMeta && <span className="text-gray-500 text-xs">{expanded === e.id ? "▲" : "▼"}</span>}
</div>
{hasMeta && expanded === e.id && (
<pre className="mt-3 bg-gray-900 p-3 rounded-lg text-xs text-gray-400 overflow-x-auto whitespace-pre-wrap break-words">{JSON.stringify(e.metadata, null, 2)}</pre>
)}
</div>
);
})}
</div>
)}
</div>
)}
</div>
);
}
function timeAgo(iso: string) {
const diff = Date.now() - new Date(iso).getTime();
const m = Math.floor(diff / 60000);
if (m < 1) return "just now";
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}

View file

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

View file

@ -1,68 +0,0 @@
"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,13 +1,7 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { verifyAdminSession } from "@/lib/admin-auth";
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 }) {
const auth = await verifyAdminSession();
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="Users" value={overview.totalUsers} icon="👥" color="blue" href="/admin/users" />
<StatCard label="Children" value={overview.totalChildren} icon="👶" color="amber" href="/admin/children" />
<StatCard label="MRR" value={`${(overview.mrr || 0).toLocaleString("en-IN")}`} icon="💰" color="emerald" href="/admin/revenue" />
<StatCard label="MRR" value={`$${overview.mrr.toFixed(2)}`} icon="💰" color="emerald" href="/admin/revenue" />
<StatCard
label="Active Sessions"
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>
<div className="grid grid-cols-2 gap-4">
<div>
<div className="text-2xl font-bold text-emerald-400">{(overview.mrr || 0).toLocaleString("en-IN")}</div>
<div className="text-2xl font-bold text-emerald-400">${overview.mrr.toFixed(2)}</div>
<div className="text-gray-400 text-sm">Monthly Recurring</div>
</div>
<div>
@ -117,7 +117,7 @@ export default function AdminDashboard() {
<div className="text-gray-400 text-sm">Free Families</div>
</div>
<div>
<div className="text-2xl font-bold text-amber-400">{(overview.avgRevenuePerUser || 0).toLocaleString("en-IN")}</div>
<div className="text-2xl font-bold text-amber-400">${overview.avgRevenuePerUser}</div>
<div className="text-gray-400 text-sm">Avg per Family</div>
</div>
</div>

View file

@ -5,101 +5,98 @@ import { useEffect, useState } from "react";
interface RevenueData {
proFamilies: number;
freeFamilies: number;
mrr: number; // rupees
mrr: number;
history: { month: string; revenue: number }[];
}
interface TrendPoint { month: string; paise: number; charges: number }
const inr = (rupees: number) =>
"₹" + (Number(rupees) || 0).toLocaleString("en-IN", { maximumFractionDigits: 0 });
const PRO_PRICE = 9.99;
export default function AdminRevenue() {
const [data, setData] = useState<RevenueData | null>(null);
const [trend, setTrend] = useState<TrendPoint[]>([]);
const [churnRate, setChurnRate] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
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));
fetchRevenue();
}, []);
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) {
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 (
<div className="p-6 space-y-6">
<div>
<h1 className="text-2xl font-bold">Revenue</h1>
<p className="text-gray-400">Real subscription revenue, in (from active Razorpay subscriptions)</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>
<p className="text-gray-400">Subscription revenue analytics</p>
<p className="text-sm text-rose-400">Pro: ${PRO_PRICE}/month per family</p>
</div>
{/* Key Metrics */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-gray-800 p-6 rounded-xl">
<div className="text-3xl font-bold text-emerald-400">{inr(data.mrr)}</div>
<div className="text-3xl font-bold text-emerald-400">${data.mrr.toFixed(2)}</div>
<div className="text-gray-400 text-sm">Monthly Recurring Revenue</div>
</div>
<div className="bg-gray-800 p-6 rounded-xl">
<div className="text-3xl font-bold text-rose-400">{inr(data.mrr * 12)}</div>
<div className="text-3xl font-bold text-rose-400">${(data.mrr * 12).toFixed(2)}</div>
<div className="text-gray-400 text-sm">Annual Run Rate</div>
</div>
<div className="bg-gray-800 p-6 rounded-xl">
<div className="text-3xl font-bold text-rose-400">{data.proFamilies}</div>
<div className="text-gray-400 text-sm">Paying Families</div>
<div className="text-gray-400 text-sm">Pro Families</div>
</div>
<div className="bg-gray-800 p-6 rounded-xl">
<div className={`text-3xl font-bold ${churnRate != null && churnRate > 10 ? "text-red-400" : "text-gray-400"}`}>
{churnRate != null ? `${churnRate}%` : "—"}
</div>
<div className="text-gray-400 text-sm">Churn rate</div>
<div className="text-3xl font-bold text-gray-400">{data.freeFamilies}</div>
<div className="text-gray-400 text-sm">Free Families</div>
</div>
</div>
{/* Real monthly revenue trend (from subscription.charged events) */}
{/* Revenue Chart */}
<div className="bg-gray-800 p-6 rounded-xl">
<h3 className="text-lg font-semibold mb-1">Monthly Revenue (charged)</h3>
<p className="text-xs text-gray-500 mb-4">Actual collections from Razorpay subscription.charged events</p>
{trend.length === 0 ? (
<p className="text-sm text-gray-500 py-8 text-center">No charges recorded yet</p>
) : (
<div className="h-48 flex items-end gap-2">
{trend.map((t) => (
<div key={t.month} className="flex-1 flex flex-col items-center gap-1" title={`${inr(t.paise / 100)} · ${t.charges} charge(s)`}>
<div className="text-[10px] text-gray-400">{inr(t.paise / 100)}</div>
<div
className="w-full bg-emerald-500 rounded-t"
style={{ height: `${(t.paise / maxPaise) * 100}%`, minHeight: t.paise > 0 ? "4px" : "0" }}
/>
<div className="text-[9px] text-gray-500">{t.month.slice(5)}/{t.month.slice(2, 4)}</div>
</div>
))}
</div>
)}
<h3 className="text-lg font-semibold mb-4">Monthly Revenue</h3>
<div className="h-48 flex items-end gap-1">
{data.history.map((h, i) => (
<div key={i} className="flex-1 flex flex-col items-center gap-1">
<div
className="w-full bg-emerald-500 rounded-t"
style={{
height: data.mrr > 0 ? `${(h.revenue / (data.mrr * 1.2)) * 100}%` : "0%",
minHeight: h.revenue > 0 ? "4px" : "0"
}}
/>
<div className="text-[8px] text-gray-500">{h.month}</div>
</div>
))}
</div>
</div>
{/* Revenue Breakdown */}
@ -108,35 +105,40 @@ export default function AdminRevenue() {
<h3 className="text-lg font-semibold mb-4">Revenue by Tier</h3>
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-emerald-400">Premium</span>
<span className="font-bold">{inr(data.mrr)}/mo</span>
<span className="text-emerald-400">Pro</span>
<span className="font-bold">${(data.proFamilies * PRO_PRICE).toFixed(2)}/mo</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-400">Free</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>
<span className="font-bold">$0.00/mo</span>
</div>
</div>
</div>
<div className="bg-gray-800 p-6 rounded-xl">
<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">
{[0.1, 0.25, 0.5].map((rate) => (
<div key={rate} className="flex justify-between items-center">
<span>If {rate * 100}% convert</span>
<span className={`font-bold ${rate >= 0.5 ? "text-emerald-400" : "text-amber-400"}`}>
{inr(data.freeFamilies * rate * arpu)}/mo
</span>
</div>
))}
<div className="flex justify-between items-center">
<span>If 10% convert</span>
<span className="font-bold text-amber-400">
${((data.freeFamilies * 0.1) * PRO_PRICE).toFixed(2)}/mo
</span>
</div>
<div className="flex justify-between items-center">
<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>
);
}
}

View file

@ -1,175 +0,0 @@
"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

@ -1,321 +0,0 @@
"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,7 +7,6 @@ interface User {
id: string;
email: string;
name: string;
phone?: string | null;
familyId: string;
familyName: string;
createdAt: string;
@ -123,17 +122,13 @@ export default function AdminUsers() {
const filteredUsers = users.filter((u) =>
u.email.toLowerCase().includes(search.toLowerCase()) ||
(u.name || "").toLowerCase().includes(search.toLowerCase()) ||
(u.phone || "").includes(search)
(u.name || "").toLowerCase().includes(search.toLowerCase())
);
const exportCSV = () => {
const headers = ["Email", "Name", "Phone", "Family", "Created"];
const rows = filteredUsers.map((u) => [u.email, u.name, u.phone || "", u.familyName, u.createdAt]);
// 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 headers = ["Email", "Name", "Family", "Created"];
const rows = filteredUsers.map((u) => [u.email, u.name, u.familyName, u.createdAt]);
const csv = [headers, ...rows].map((row) => row.join(",")).join("\n");
const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
@ -211,7 +206,7 @@ export default function AdminUsers() {
<Input
type="text"
placeholder="Search by name, email or phone…"
placeholder="Search by name or email…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
@ -221,7 +216,6 @@ export default function AdminUsers() {
<thead className="bg-gray-700">
<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">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">Password</th>
<th className="px-4 py-3 text-left text-sm font-medium">Joined</th>
@ -235,11 +229,6 @@ export default function AdminUsers() {
<div className="font-medium">{user.name || user.email}</div>
<div className="text-xs text-gray-500">{user.email}</div>
</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">
<button

View file

@ -1,101 +0,0 @@
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

@ -1,57 +0,0 @@
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,34 +2,15 @@ import { NextResponse } from "next/server";
import { requireAdmin } from "@/lib/admin-auth";
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) {
const auth = await requireAdmin(request);
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 {
// A. Feature adoption — % of families that have used each feature at least once
const adoptionRows = await sql`
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 dl.id IS NOT NULL)::int as families_diapers,
COUNT(DISTINCT f.id) FILTER (WHERE sl.id IS NOT NULL)::int as families_sleeping,
@ -47,78 +28,63 @@ 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 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)}`);
}
// B. Per-family engagement table.
//
// Pre-aggregate each log table to one row per child BEFORE joining — joining
// the raw tables together produces a cartesian product that times out in
// production. Memories are per-family. All MAX(*) timestamps are cast to
// timestamptz so GREATEST() can't choke on mixed timestamp/timestamptz types.
try {
const adoption = adoptionRows[0] || {};
const total = Number(adoption.total_families) || 1;
const featureAdoption = [
{ name: "Feed Logs", count: Number(adoption.families_feeding) || 0, pct: Math.round((Number(adoption.families_feeding) || 0) / total * 100) },
{ name: "Diaper Logs", count: Number(adoption.families_diapers) || 0, pct: Math.round((Number(adoption.families_diapers) || 0) / total * 100) },
{ name: "Sleep Logs", count: Number(adoption.families_sleeping) || 0, pct: Math.round((Number(adoption.families_sleeping) || 0) / total * 100) },
{ 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`
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
f.id,
f.name,
f.tier,
f.created_at,
GREATEST(
MAX(fa.last_at)::timestamptz,
MAX(da.last_at)::timestamptz,
MAX(sa.last_at)::timestamptz,
MAX(ma.last_at)::timestamptz
MAX(fd.logged_at),
MAX(dl.logged_at),
MAX(sl.started_at),
MAX(mem.created_at)
) as last_activity,
COALESCE(SUM(fa.cnt), 0)::int as feed_count,
COALESCE(SUM(da.cnt), 0)::int as diaper_count,
COALESCE(SUM(sa.cnt), 0)::int as sleep_count,
COALESCE(SUM(va.cnt), 0)::int as vaccination_count,
COALESCE(SUM(ga.cnt), 0)::int as growth_count,
COALESCE(MAX(ma.cnt), 0)::int as memory_count,
COALESCE(SUM(ca.cnt), 0)::int as chat_count
COUNT(DISTINCT fd.id)::int as feed_count,
COUNT(DISTINCT dl.id)::int as diaper_count,
COUNT(DISTINCT sl.id)::int as sleep_count,
COUNT(DISTINCT v.id)::int as vaccination_count,
COUNT(DISTINCT g.id)::int as growth_count,
COUNT(DISTINCT mem.id)::int as memory_count,
COUNT(DISTINCT cs.id)::int as chat_count
FROM families f
LEFT JOIN children c ON c.family_id = f.id
LEFT JOIN feed_agg fa ON fa.child_id = c.id
LEFT JOIN diaper_agg da ON da.child_id = c.id
LEFT JOIN sleep_agg sa ON sa.child_id = c.id
LEFT JOIN vacc_agg va ON va.child_id = c.id
LEFT JOIN growth_agg ga ON ga.child_id = c.id
LEFT JOIN chat_agg ca ON ca.child_id = c.id
LEFT JOIN mem_agg ma ON ma.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 diapers_logs dl ON dl.child_id = c.id
LEFT JOIN sleeps sl ON sl.child_id = c.id
LEFT JOIN vaccinations v ON v.child_id = c.id
LEFT JOIN growth g ON g.child_id = c.id
LEFT JOIN memories mem ON mem.family_id = f.id
LEFT JOIN chat_sessions cs ON cs.child_id = c.id
GROUP BY f.id, f.name, f.tier, f.created_at
ORDER BY last_activity DESC NULLS LAST
`;
const now = Date.now();
families = familyRows.map((f: Record<string, unknown>) => {
const lastActivity = f.last_activity ? new Date(f.last_activity as string).toISOString() : null;
const families = familyRows.map((f: any) => {
const lastActivity = f.last_activity ? new Date(f.last_activity).toISOString() : null;
const msSince = lastActivity ? now - new Date(lastActivity).getTime() : Infinity;
const activeStatus = !lastActivity ? "never" : msSince < 7 * 86400_000 ? "7d" : msSince < 30 * 86400_000 ? "30d" : "inactive";
return {
id: f.id,
name: f.name,
tier: f.tier || "free",
createdAt: f.created_at ? new Date(f.created_at as string).toISOString() : null,
createdAt: f.created_at ? new Date(f.created_at).toISOString() : null,
lastActivity,
activeStatus,
feedCount: Number(f.feed_count) || 0,
@ -129,44 +95,38 @@ export async function GET(request: Request) {
memoryCount: Number(f.memory_count) || 0,
chatCount: Number(f.chat_count) || 0,
totalLogs:
(Number(f.feed_count) || 0) + (Number(f.diaper_count) || 0) + (Number(f.sleep_count) || 0) +
(Number(f.vaccination_count) || 0) + (Number(f.growth_count) || 0),
Number(f.feed_count) + Number(f.diaper_count) + Number(f.sleep_count) +
Number(f.vaccination_count) + Number(f.growth_count),
};
});
if (!total) total = families.length;
} catch (e) {
errors.push(`families: ${String(e)}`);
}
// C. Activity summary counts (derived from families)
const active7d = families.filter(f => f.activeStatus === "7d").length;
const active30d = families.filter(f => f.activeStatus === "30d").length;
const neverActive = families.filter(f => f.activeStatus === "never").length;
// C. Activity summary counts
const active7d = families.filter(f => f.activeStatus === "7d").length;
const active30d = families.filter(f => f.activeStatus === "30d").length;
const neverActive = families.filter(f => f.activeStatus === "never").length;
// D. AI usage last 30 days
try {
const aiRows = 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
FROM ai_usage
WHERE created_at > NOW() - INTERVAL '30 days'
`;
const row = aiRows[0] || {};
aiUsage = {
totalCalls: Number(row.total_calls) || 0,
totalTokens: Number(row.total_tokens) || 0,
totalCostPaise: Number(row.total_cost_paise) || 0,
familiesUsingAI: Number(row.families_using_ai) || 0,
};
} catch (e) {
errors.push(`ai_usage: ${String(e)}`);
}
// D. AI usage last 30 days
let aiUsage = { totalCalls: 0, totalTokens: 0, totalCostPaise: 0, familiesUsingAI: 0 };
try {
const aiRows = 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
FROM ai_usage
WHERE created_at > NOW() - INTERVAL '30 days'
`;
const row = aiRows[0] || {};
aiUsage = {
totalCalls: Number(row.total_calls) || 0,
totalTokens: Number(row.total_tokens) || 0,
totalCostPaise: Number(row.total_cost_paise) || 0,
familiesUsingAI: Number(row.families_using_ai) || 0,
};
} catch {}
// E. Daily activity for chart (last 30 days, all log types combined)
try {
// E. Daily activity for chart (last 30 days, all log types combined)
const dailyRows = await sql`
SELECT day::date as date, SUM(cnt)::int as count
FROM (
@ -178,27 +138,25 @@ export async function GET(request: Request) {
UNION ALL
SELECT DATE(created_at) as day, COUNT(*) as cnt FROM memories WHERE created_at > NOW() - INTERVAL '30 days' GROUP BY day
) sub
WHERE day IS NOT NULL
GROUP 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],
count: Number(r.count) || 0,
count: Number(r.count),
}));
} catch (e) {
errors.push(`logs_by_day: ${String(e)}`);
return NextResponse.json({
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

@ -1,71 +0,0 @@
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,7 +1,6 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
import { requireAdmin } from "@/lib/admin-auth";
import { grantPremium, revokeToFree } from "@/lib/billing/entitlements";
// GET all families with members
export async function GET(request: Request) {
@ -55,35 +54,6 @@ 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({
families: families.map((f: any) => ({
id: f.id,
@ -97,7 +67,6 @@ export async function GET(request: Request) {
logCount: Number(f.log_count) || 0,
memoryCount: Number(f.memory_count) || 0,
members: memberMap.get(f.id) || [],
subscription: subMap.get(f.id) || null,
})),
});
} catch (error) {
@ -119,28 +88,14 @@ export async function PATCH(request: Request) {
return NextResponse.json({ error: "familyId required" }, { status: 400 });
}
// Tier change → apply the SAME grant/revoke logic the webhook uses, so a
// manual/comp upgrade gets the real premium grant (50GB/6/3), not ad-hoc
// hardcoded limits. subscription_status records it was set by admin.
if (tier === "pro") {
await grantPremium(familyId, "admin_comp");
} else if (tier === "free") {
await revokeToFree(familyId, "admin_downgrade");
}
await sql`
UPDATE families
SET tier = COALESCE(${tier}, tier),
max_children = COALESCE(${maxChildren}, max_children),
max_members = COALESCE(${maxMembers}, max_members)
WHERE id = ${familyId}
`;
// 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 });
} catch (error) {
console.error("Admin families error:", error);

View file

@ -1,109 +0,0 @@
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

@ -1,121 +0,0 @@
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

@ -1,59 +0,0 @@
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,23 +21,7 @@ export async function GET(request: Request) {
const proFamilies = tierStats.find((t: any) => t.tier === "pro")?.count || 0;
const freeFamilies = tierStats.find((t: any) => t.tier === "free")?.count || 0;
const totalFamilies = familyCount[0]?.count || 0;
// 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 mrr = proFamilies * 9.99;
const [familiesByDay, usersByDay, childrenByAge, recentLogins, failedLogins] = await Promise.all([
sql`

View file

@ -1,111 +0,0 @@
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

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

View file

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

View file

@ -1,7 +1,6 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
import { cookies } from "next/headers";
import { toProxyUrl } from "@/lib/r2-proxy";
// GET current user profile from session
export async function GET() {
@ -15,7 +14,7 @@ export async function GET() {
// Get session and user
const sessions = await sql`
SELECT s.user_id, s.expires, u.id, u.email, u.name, u.image, u.phone, u.created_at
SELECT s.user_id, s.expires, u.id, u.email, u.name, u.image, u.created_at
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.session_token = ${sessionToken}
@ -41,8 +40,7 @@ export async function GET() {
id: session.id,
email: session.email,
name: session.name || "Parent",
phone: session.phone || null,
avatarUrl: toProxyUrl(session.image) || null,
avatarUrl: session.image || null,
familyId: members?.[0]?.family_id,
familyName: members?.[0]?.family_name,
memberSince: session.created_at,
@ -58,29 +56,12 @@ export async function GET() {
export async function POST(request: Request) {
try {
const body = await request.json();
const { name, phone } = body as { name?: string; phone?: string };
const { name } = body;
if (!name) {
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 sessionToken = cookieStore.get("tia_session")?.value;
@ -102,20 +83,13 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Invalid session" }, { status: 401 });
}
// Update user name (+ phone only when the field was sent)
if (normalizedPhone !== undefined) {
await sql`
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}
`;
}
// Update user name
await sql`
UPDATE users SET name = ${name}, updated_at = NOW()
WHERE id = ${session.user_id}
`;
return NextResponse.json({ success: true, name, phone: normalizedPhone ?? undefined });
return NextResponse.json({ success: true, name });
} catch (error) {
console.error("Profile update error:", error);
return NextResponse.json({ error: String(error) }, { status: 500 });

View file

@ -1,8 +1,6 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
import { validateSession, requireFamily } from "@/lib/auth";
import { checkChildLimit } from "@/lib/quota";
import { toProxyUrl } from "@/lib/r2-proxy";
// GET - list children (family only)
export async function GET(request: Request) {
@ -14,15 +12,11 @@ export async function GET(request: Request) {
const familyId = auth.session!.familyId!;
try {
const rows = await sql.unsafe(
const children = 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`,
[familyId]
);
const children = (rows || []).map((c: any) => ({
...c,
imageUrl: toProxyUrl(c.imageUrl) ?? null,
}));
return NextResponse.json({ children });
return NextResponse.json({ children: children || [] });
} catch (error) {
console.error(error);
return NextResponse.json({ error: String(error) }, { status: 500 });
@ -45,14 +39,6 @@ export async function POST(request: Request) {
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(
`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]

View file

@ -4,10 +4,6 @@ import { promisify } from "util";
import { S3Client, PutObjectCommand, DeleteObjectCommand, ListObjectsV2Command } from "@aws-sdk/client-s3";
import fs from "fs/promises";
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 gzipAsync = promisify(gzip);
@ -67,26 +63,9 @@ export async function POST(request: Request) {
// 5. Cleanup local
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 });
} catch (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 });
}
}

View file

@ -1,103 +0,0 @@
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

@ -1,152 +0,0 @@
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,78 +6,17 @@ export async function GET() {
const auth = await requireFamily();
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 {
const vectorRows = await sql.unsafe(
`SELECT name, default_version, installed_version FROM pg_available_extensions WHERE name = 'vector'`
const migrations = await sql.unsafe(
`SELECT hash, created_at FROM __drizzle_migrations ORDER BY created_at DESC LIMIT 10`
);
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(
const circleTables = await sql.unsafe(
`SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND (tablename LIKE 'circle%' OR tablename = 'post_reports') ORDER BY tablename`
);
} catch (e) {
out.circleTablesError = String(e);
return NextResponse.json({ migrations, circleTables });
} catch (err: unknown) {
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)
@ -91,16 +30,11 @@ export async function POST(req: Request) {
}
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)
`ALTER TABLE family_invites ADD COLUMN IF NOT EXISTS display_name text`,
`ALTER TABLE family_invites ADD COLUMN IF NOT EXISTS accepted_at timestamp`,
// subscription_status on families (0007) — payment-provider abstraction
`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)
`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))`,
@ -116,24 +50,6 @@ 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_reactions_post_idx ON circle_post_reactions(post_id)`,
`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[] = [];

View file

@ -1,53 +0,0 @@
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 {
const family = await sql.unsafe(
`SELECT id, name, tier, max_children, max_members, pediatrician_phone, pediatrician_name FROM families WHERE id = $1`,
`SELECT id, name, tier, max_children, max_members FROM families WHERE id = $1`,
[auth.session!.familyId]
);
@ -31,27 +31,11 @@ export async function PATCH(request: Request) {
try {
const body = await request.json();
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);
const { name, pediatricianPhone, tier } = body;
await sql.unsafe(
`UPDATE families SET ${setClauses.join(", ")} WHERE id = $${params.length}`,
params
`UPDATE families SET name = COALESCE($1, name), pediatrician_phone = COALESCE($2, pediatrician_phone), tier = COALESCE($3, tier), updated_at = NOW() WHERE id = $4`,
[name, pediatricianPhone, tier, auth.session!.familyId]
);
return NextResponse.json({ success: true });

View file

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

View file

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

View file

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

View file

@ -1,27 +0,0 @@
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" } },
);
}
}

View file

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

View file

@ -1,7 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { requireFamily } from "@/lib/auth";
import { sql } from "@/db";
import { toProxyUrl } from "@/lib/r2-proxy";
function getBaseUrl() {
const pub = process.env.R2_PUBLIC_URL;
@ -10,13 +9,11 @@ function getBaseUrl() {
}
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 {
id: m.id,
key: m.r2_key,
url: toProxyUrl(rawUrl) ?? rawUrl,
thumbnailUrl: toProxyUrl(rawThumb),
url: `${baseUrl}/${m.r2_key}`,
thumbnailUrl: m.r2_thumbnail_key ? `${baseUrl}/${m.r2_thumbnail_key}` : null,
sizeBytes: m.size_bytes,
mimeType: m.mime_type,
title: m.title,
@ -42,52 +39,15 @@ export async function GET(req: NextRequest) {
const limit = Math.min(parseInt(searchParams.get("limit") || "30"), 100);
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;
if (childId) {
rows = cursor
? await sql`
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}`;
? 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`SELECT * FROM memories WHERE family_id = ${familyId} AND child_id = ${childId} ORDER BY created_at DESC LIMIT ${limit}`;
} else {
rows = cursor
? await sql`
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}`;
? await sql`SELECT * FROM memories WHERE family_id = ${familyId} AND created_at < ${cursor} ORDER BY created_at DESC LIMIT ${limit}`
: await sql`SELECT * FROM memories WHERE family_id = ${familyId} ORDER BY created_at DESC LIMIT ${limit}`;
}
const baseUrl = getBaseUrl();

View file

@ -1,29 +0,0 @@
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,259 +1,104 @@
import { NextRequest, NextResponse } from "next/server";
import { NextResponse } from "next/server";
import { sql } from "@/db";
import { requireFamily } from "@/lib/auth";
import { requireFamily, requireOwnership } from "@/lib/auth";
// ─── 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 ─────────────────────────────────────────────────────
// IAP Vaccination Schedule (weeks from birth)
const IAP_SCHEDULE = [
{ name: "BCG", weeks: 0 },
{ name: "OPV-0", weeks: 0 },
{ name: "HepB-1", weeks: 0 },
{ name: "OPV-1", weeks: 6 },
{ name: "Pentavalent-1", weeks: 6 },
{ name: "PCV-1", weeks: 6 },
{ name: "Rota-1", weeks: 6 },
{ name: "OPV-2", weeks: 10 },
{ name: "Pentavalent-2", weeks: 10 },
{ name: "PCV-2", weeks: 10 },
{ name: "Rota-2", weeks: 10 },
{ name: "OPV-3", weeks: 14 },
{ name: "Pentavalent-3", weeks: 14 },
{ name: "PCV-3", weeks: 14 },
{ name: "Rota-3", weeks: 14 },
{ name: "MR-1", weeks: 48 },
{ name: "JE-1", weeks: 48 },
{ name: "Vitamin A-1", weeks: 48 },
{ name: "OPV-4", weeks: 48 },
{ name: "MR-2", weeks: 96 },
{ name: "JE-2", weeks: 96 },
{ name: "DPT-Booster-1", weeks: 96 },
{ name: "Vitamin A-2", weeks: 96 },
{ name: "OPV-5", weeks: 96 },
{ name: "DPT-Booster-2", weeks: 208 },
{ name: "Td", weeks: 208 },
{ name: "BCG", weeks: 0 },
{ name: "OPV-0", weeks: 0 },
{ name: "HepB-1", weeks: 0 },
{ name: "OPV-1", weeks: 6 },
{ name: "Pentavalent-1", weeks: 6 },
{ name: "PCV-1", weeks: 6 },
{ name: "Rota-1", weeks: 6 },
{ name: "OPV-2", weeks: 10 },
{ name: "Pentavalent-2", weeks: 10 },
{ name: "PCV-2", weeks: 10 },
{ name: "Rota-2", weeks: 10 },
{ name: "OPV-3", weeks: 14 },
{ name: "Pentavalent-3", weeks: 14 },
{ name: "PCV-3", weeks: 14 },
{ name: "Rota-3", weeks: 14 },
{ name: "MR-1", weeks: 48 },
{ name: "JE-1", weeks: 48 },
{ name: "Vitamin A-1", weeks: 48 },
{ name: "OPV-4", weeks: 48 },
{ name: "MR-2", weeks: 96 },
{ name: "JE-2", weeks: 96 },
{ name: "DPT-Booster-1", weeks: 96 },
{ name: "Vitamin A-2", weeks: 96 },
{ name: "OPV-5", weeks: 96 },
{ name: "DPT-Booster-2", weeks: 208 },
{ name: "Tetanus and adult diphtheria (Td)", weeks: 208 },
];
/** 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) {
export async function GET(request: Request) {
try {
const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const familyId = auth.session!.familyId!;
const { searchParams } = new URL(request.url);
const childId = searchParams.get("childId");
const childId = searchParams.get("childId");
if (!childId) return NextResponse.json({ error: "childId required" }, { status: 400 });
const istDate = getISTDate();
const istHour = getISTHour();
const weekSlot = getISTWeekMonday();
// ── Fetch child info ──────────────────────────────────────────────────────
const children = await sql`
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);
// ── 1. Vaccine notifications ──────────────────────────────────────────────
const given = await sql`
SELECT vaccine_name FROM vaccinations
WHERE child_id = ${childId} AND status = 'given'
`;
const givenSet = new Set((given as unknown as { vaccine_name: string }[]).map(r => r.vaccine_name));
const today = new Date(istDate + "T00:00:00Z"); // IST midnight as Date for comparison
for (const v of IAP_SCHEDULE) {
if (givenSet.has(v.name)) continue;
const dueDate = new Date(birthDate.getTime() + v.weeks * 7 * 86_400_000);
const dueDateStr = dueDate.toISOString().slice(0, 10);
// Only notify if due today or overdue
if (dueDateStr > istDate) continue;
const diffDays = Math.round((today.getTime() - dueDate.getTime()) / 86_400_000);
const isToday = diffDays === 0;
await sql`
INSERT INTO notifications
(family_id, child_id, type, title, message, action_url, scheduled_for, metadata)
VALUES (
${familyId}, ${childId},
${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
`;
if (!childId) {
return NextResponse.json({ error: "childId required" }, { status: 400 });
}
// ── 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
`;
const ownership = await requireOwnership(childId, "children", "Child");
if (!ownership.success) return NextResponse.json({ error: ownership.error }, { status: ownership.status });
// Get child's birth date
const children = await sql`
SELECT id, name, birth_date FROM children WHERE id = ${childId}
`;
if (!children || children.length === 0) {
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`
SELECT vaccine_name, given_date FROM vaccinations
WHERE child_id = ${childId} AND status = 'given'
`;
const givenMap = new Set(given.map((v: any) => v.vaccine_name));
// Calculate upcoming vaccine notifications
const notifications = [];
for (const vaccine of IAP_SCHEDULE) {
if (givenMap.has(vaccine.name)) continue;
// Calculate due date
const dueDate = new Date(birthDate);
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));
// Notify if due today or overdue
if (diffDays <= 0) {
notifications.push({
id: `vaccine-${vaccine.name}`,
type: "vaccine",
title: diffDays === 0 ? "Vaccine Due Today" : "Vaccine Overdue",
message: `${vaccine.name} is ${diffDays === 0 ? "due today" : `${Math.abs(diffDays)} days overdue`}`,
vaccineName: vaccine.name,
dueDate: dueDate.toISOString().split("T")[0],
status: diffDays === 0 ? "due_today" : "overdue",
childId,
childName: child.name,
});
}
}
// ── 3. Memory nudge — no photo shared today ───────────────────────────────
const memCount = await sql`
SELECT COUNT(*) AS total FROM memories
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 });
return NextResponse.json({ notifications });
} catch (error) {
console.error("Notifications error:", error);
return NextResponse.json({ error: String(error) }, { 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,23 +22,9 @@ export async function POST(request: Request) {
const userId = sessions[0].user_id;
const body = await request.json();
const { familyName, memberName, phone, childName, birthDate, sex } = body;
const { familyName, memberName, childName, birthDate, sex } = body;
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
const familyId = crypto.randomUUID();
await sql.unsafe(

View file

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

View file

@ -1,67 +0,0 @@
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

@ -1,135 +0,0 @@
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

@ -1,76 +0,0 @@
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.",
});
}

View file

@ -1,50 +0,0 @@
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

@ -1,204 +0,0 @@
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
}
}

View file

@ -1,54 +0,0 @@
"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,25 +45,6 @@
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 */
.scrollbar-hide {
scrollbar-width: none;
@ -78,16 +59,3 @@ body {
color: var(--foreground);
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,7 +1,5 @@
import type { Metadata, Viewport } from "next";
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";
const geistSans = Geist({
@ -20,41 +18,17 @@ const caveat = Caveat({
});
export const metadata: Metadata = {
// Resolves all relative metadata URLs (OG/Twitter images, canonicals) to
// 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.
title: "Tia — Baby Tracker",
description: "Track feeds, sleep, milestones and memories.",
icons: {
icon: "/icon.svg",
apple: "/apple-icon.png",
},
appleWebApp: {
capable: true,
statusBarStyle: "default",
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 = {
@ -67,17 +41,9 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en-IN" suppressHydrationWarning>
<html lang="en" suppressHydrationWarning>
<body className={`${geistSans.variable} ${geistMono.variable} ${caveat.variable} min-h-full antialiased`}>
{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>
</html>
);

View file

@ -2,18 +2,11 @@ import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
name: "Tia — Baby Tracker & Digital Heirloom",
name: "Tia — Baby Tracker",
short_name: "Tia",
description:
"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: "/",
description: "Track feeds, sleep, milestones and memories.",
start_url: "/?source=pwa",
display: "standalone",
orientation: "portrait",
lang: "en-IN",
dir: "ltr",
categories: ["health", "lifestyle", "parenting", "medical"],
background_color: "#fdf2f2",
theme_color: "#fb7185",
icons: [

View file

@ -1,41 +0,0 @@
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,
};
}

View file

@ -1,38 +0,0 @@
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