feat(g5-g6): age-aware UX + mama affiliate page

G5 — Age-Aware UX:
- useStageCheck hook: maps birth date → BabyStage (newborn/infant/sitter/crawler/toddler/walker)
- Time-of-day fast-log suggestion chip on home page (time × stage matrix)
- Milestones page: 25 WHO/AAP milestones, category filter, progress bar, inline date picker
- Milestones API: GET (merged definitions + achievements), POST (upsert), DELETE (un-mark)
- DB: milestone_achievements table with unique(child_id, milestone_key)
- Milestones 🌟 added to menu

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

DB migrations: 0014_milestones, 0015_affiliate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Manohar Gupta 2026-05-18 00:31:16 +05:30
parent c2cabc01d3
commit 7c4f6d0d78
17 changed files with 1064 additions and 1 deletions

View file

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

View file

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

View file

@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
import { requireFamily } from "@/lib/auth";
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ key: string }> }
) {
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");
if (!childId) return NextResponse.json({ error: "childId required" }, { status: 400 });
const { key } = await params;
await sql`
DELETE FROM milestone_achievements
WHERE child_id = ${childId} AND family_id = ${familyId} AND milestone_key = ${key}
`;
return NextResponse.json({ success: true });
}

View file

@ -0,0 +1,54 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
import { requireFamily } from "@/lib/auth";
import { MILESTONES } from "@/lib/milestones";
export async function GET(request: Request) {
const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const { searchParams } = new URL(request.url);
const childId = searchParams.get("childId");
if (!childId) return NextResponse.json({ error: "childId required" }, { status: 400 });
const familyId = auth.session!.familyId!;
const rows = await sql`
SELECT milestone_key, achieved_at, notes
FROM milestone_achievements
WHERE child_id = ${childId} AND family_id = ${familyId}
`;
const achievedMap = new Map(rows.map(r => [r.milestone_key, r]));
const items = MILESTONES.map(m => ({
...m,
achieved: achievedMap.has(m.key),
achievedAt: achievedMap.get(m.key)?.achieved_at ?? null,
notes: achievedMap.get(m.key)?.notes ?? null,
}));
return NextResponse.json({ items });
}
export async function POST(request: Request) {
const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const familyId = auth.session!.familyId!;
const { childId, milestoneKey, achievedAt, notes } = await request.json();
if (!childId || !milestoneKey || !achievedAt) {
return NextResponse.json({ error: "childId, milestoneKey, achievedAt required" }, { status: 400 });
}
await sql`
INSERT INTO milestone_achievements (child_id, family_id, milestone_key, achieved_at, notes)
VALUES (${childId}, ${familyId}, ${milestoneKey}, ${achievedAt}, ${notes ?? null})
ON CONFLICT (child_id, milestone_key) DO UPDATE SET
achieved_at = EXCLUDED.achieved_at,
notes = EXCLUDED.notes
`;
return NextResponse.json({ success: true });
}

View file

@ -0,0 +1,30 @@
import { NextResponse } from "next/server";
import { createHash } from "crypto";
import { sql } from "@/db";
export async function POST(request: Request, { params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const { productId } = await request.json();
if (!productId) return NextResponse.json({ error: "productId required" }, { status: 400 });
// verify product belongs to this slug's profile
const check = await sql`
SELECT rp.url FROM recommended_products rp
JOIN member_profiles mp ON mp.id = rp.profile_id
WHERE rp.id = ${productId} AND mp.slug = ${slug} AND rp.is_active = true
LIMIT 1
`;
if (!check[0]) return NextResponse.json({ error: "Product not found" }, { status: 404 });
const forwarded = request.headers.get("x-forwarded-for") ?? "unknown";
const ip = forwarded.split(",")[0].trim();
const ipHash = createHash("sha256").update(ip).digest("hex");
const referrer = request.headers.get("referer") ?? null;
await sql`
INSERT INTO product_clicks (product_id, referrer, ip_hash)
VALUES (${productId}, ${referrer}, ${ipHash})
`;
return NextResponse.json({ url: check[0].url });
}

View file

@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
export async function GET(_req: Request, { params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const profiles = await sql`
SELECT * FROM member_profiles WHERE slug = ${slug} AND is_public = true LIMIT 1
`;
if (!profiles[0]) return NextResponse.json({ error: "Not found" }, { status: 404 });
const profile = profiles[0];
const products = await sql`
SELECT * FROM recommended_products
WHERE profile_id = ${profile.id} AND is_active = true
ORDER BY display_order ASC
`;
return NextResponse.json({ profile, products });
}

View file

@ -0,0 +1,48 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
import { requireFamily } from "@/lib/auth";
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const userId = auth.session!.userId!;
const { id } = await params;
const updates = await request.json();
// verify ownership
const check = await sql`
SELECT rp.id FROM recommended_products rp
JOIN member_profiles mp ON mp.id = rp.profile_id
WHERE rp.id = ${id} AND mp.user_id = ${userId}
`;
if (!check[0]) return NextResponse.json({ error: "Not found" }, { status: 404 });
if ("title" in updates) await sql`UPDATE recommended_products SET title = ${updates.title} WHERE id = ${id}`;
if ("description" in updates) await sql`UPDATE recommended_products SET description = ${updates.description} WHERE id = ${id}`;
if ("url" in updates) await sql`UPDATE recommended_products SET url = ${updates.url} WHERE id = ${id}`;
if ("image_url" in updates) await sql`UPDATE recommended_products SET image_url = ${updates.image_url} WHERE id = ${id}`;
if ("category" in updates) await sql`UPDATE recommended_products SET category = ${updates.category} WHERE id = ${id}`;
if ("display_order" in updates) await sql`UPDATE recommended_products SET display_order = ${updates.display_order} WHERE id = ${id}`;
if ("is_active" in updates) await sql`UPDATE recommended_products SET is_active = ${updates.is_active} WHERE id = ${id}`;
const rows = await sql`SELECT * FROM recommended_products WHERE id = ${id}`;
return NextResponse.json({ product: rows[0] });
}
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const userId = auth.session!.userId!;
const { id } = await params;
const check = await sql`
SELECT rp.id FROM recommended_products rp
JOIN member_profiles mp ON mp.id = rp.profile_id
WHERE rp.id = ${id} AND mp.user_id = ${userId}
`;
if (!check[0]) return NextResponse.json({ error: "Not found" }, { status: 404 });
await sql`DELETE FROM recommended_products WHERE id = ${id}`;
return NextResponse.json({ success: true });
}

View file

@ -0,0 +1,40 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
import { requireFamily } from "@/lib/auth";
export async function GET() {
const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const userId = auth.session!.userId!;
const rows = await sql`
SELECT rp.*, COUNT(pc.id)::int as click_count
FROM recommended_products rp
JOIN member_profiles mp ON mp.id = rp.profile_id
LEFT JOIN product_clicks pc ON pc.product_id = rp.id
WHERE mp.user_id = ${userId} AND rp.is_active = true
GROUP BY rp.id
ORDER BY rp.display_order ASC
`;
return NextResponse.json({ items: rows });
}
export async function POST(request: Request) {
const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const userId = auth.session!.userId!;
const profiles = await sql`SELECT id FROM member_profiles WHERE user_id = ${userId} LIMIT 1`;
if (!profiles[0]) return NextResponse.json({ error: "Profile not set up yet" }, { status: 400 });
const profileId = profiles[0].id;
const { title, description, url, image_url, category, display_order } = await request.json();
if (!title || !url) return NextResponse.json({ error: "title and url required" }, { status: 400 });
const rows = await sql`
INSERT INTO recommended_products (profile_id, title, description, url, image_url, category, display_order)
VALUES (${profileId}, ${title}, ${description ?? null}, ${url}, ${image_url ?? null}, ${category ?? "general"}, ${display_order ?? 0})
RETURNING *
`;
return NextResponse.json({ product: rows[0] });
}

View file

@ -0,0 +1,41 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
import { requireFamily } from "@/lib/auth";
function isValidSlug(slug: string) {
return /^[a-z0-9-]{3,40}$/.test(slug);
}
export async function GET() {
const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const userId = auth.session!.userId!;
const rows = await sql`SELECT * FROM member_profiles WHERE user_id = ${userId} LIMIT 1`;
return NextResponse.json({ profile: rows[0] ?? null });
}
export async function PUT(request: Request) {
const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const userId = auth.session!.userId!;
const familyId = auth.session!.familyId!;
const { slug, display_name, bio, avatar_url, is_public } = await request.json();
if (!slug || !display_name) return NextResponse.json({ error: "slug and display_name required" }, { status: 400 });
if (!isValidSlug(slug)) return NextResponse.json({ error: "Slug must be 340 lowercase letters, numbers, or hyphens" }, { status: 400 });
const rows = await sql`
INSERT INTO member_profiles (user_id, family_id, slug, display_name, bio, avatar_url, is_public, updated_at)
VALUES (${userId}, ${familyId}, ${slug}, ${display_name}, ${bio ?? null}, ${avatar_url ?? null}, ${is_public ?? false}, now())
ON CONFLICT (user_id) DO UPDATE SET
slug = EXCLUDED.slug,
display_name = EXCLUDED.display_name,
bio = EXCLUDED.bio,
avatar_url = EXCLUDED.avatar_url,
is_public = EXCLUDED.is_public,
updated_at = now()
RETURNING *
`;
return NextResponse.json({ profile: rows[0] });
}

138
src/app/m/[slug]/page.tsx Normal file
View file

@ -0,0 +1,138 @@
"use client";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
interface Profile {
id: string;
slug: string;
display_name: string;
bio: string | null;
avatar_url: string | null;
}
interface Product {
id: string;
title: string;
description: string | null;
url: string;
image_url: string | null;
category: string;
display_order: number;
}
const CATEGORY_EMOJI: Record<string, string> = { feeding: "🍼", sleep: "💤", play: "🎮", clothing: "👗", general: "🛍️" };
export default function MamaPage() {
const params = useParams();
const slug = params.slug as string;
const [profile, setProfile] = useState<Profile | null>(null);
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
const [notFound, setNotFound] = useState(false);
useEffect(() => {
fetch(`/api/profile/${slug}`)
.then(r => {
if (!r.ok) { setNotFound(true); setLoading(false); return null; }
return r.json();
})
.then(d => {
if (!d) return;
setProfile(d.profile);
setProducts(d.products || []);
setLoading(false);
})
.catch(() => { setNotFound(true); setLoading(false); });
}, [slug]);
async function handleClick(productId: string, url: string) {
try {
await fetch(`/api/profile/${slug}/click`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ productId }),
});
} catch {}
window.open(url, "_blank", "noopener,noreferrer");
}
if (loading) return (
<div className="flex items-center justify-center min-h-screen bg-gradient-to-br from-pink-50 to-purple-50">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-pink-400" />
</div>
);
if (notFound || !profile) return (
<div className="flex items-center justify-center min-h-screen bg-gradient-to-br from-pink-50 to-purple-50">
<div className="text-center">
<div className="text-5xl mb-4">🍼</div>
<h1 className="text-xl font-bold text-gray-700">Page not found</h1>
<p className="text-gray-500 text-sm mt-2">This profile doesn&apos;t exist or is private.</p>
</div>
</div>
);
const initials = profile.display_name.split(" ").map((w: string) => w[0]).join("").slice(0, 2).toUpperCase();
return (
<div className="min-h-screen bg-gradient-to-br from-pink-50 via-purple-50 to-rose-50">
<div className="max-w-2xl mx-auto px-4 py-10">
{/* Profile header */}
<div className="text-center mb-10">
<div className="w-20 h-20 rounded-full mx-auto mb-4 flex items-center justify-center text-2xl font-bold text-white shadow-lg"
style={{ background: "linear-gradient(135deg, #f472b6, #a78bfa)" }}>
{profile.avatar_url
? <img src={profile.avatar_url} alt={profile.display_name} className="w-20 h-20 rounded-full object-cover" />
: initials
}
</div>
<h1 className="text-2xl font-bold text-gray-900">{profile.display_name}</h1>
{profile.bio && <p className="text-gray-500 mt-2 max-w-xs mx-auto text-sm">{profile.bio}</p>}
</div>
{/* Products */}
{products.length > 0 && (
<>
<h2 className="text-lg font-bold text-gray-800 mb-4 text-center">Products I Love 💕</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
{products.map(p => (
<div key={p.id} className="bg-white rounded-2xl overflow-hidden shadow-sm hover:shadow-md transition-shadow">
<div className={`h-32 flex items-center justify-center text-4xl ${
p.image_url ? "" : "bg-gradient-to-br from-pink-100 to-purple-100"
}`}>
{p.image_url
? <img src={p.image_url} alt={p.title} className="h-32 w-full object-cover" />
: CATEGORY_EMOJI[p.category] || "🛍️"
}
</div>
<div className="p-3">
<p className="font-medium text-gray-900 text-sm line-clamp-2">{p.title}</p>
{p.description && (
<p className="text-xs text-gray-500 mt-1 line-clamp-2">{p.description}</p>
)}
<div className="flex items-center justify-between mt-2">
<span className="text-xs text-gray-400">{CATEGORY_EMOJI[p.category]}</span>
<button
onClick={() => handleClick(p.id, p.url)}
className="text-xs font-semibold text-pink-600 hover:text-pink-700"
>
Shop
</button>
</div>
</div>
</div>
))}
</div>
</>
)}
{products.length === 0 && (
<p className="text-center text-gray-400 py-8">No recommendations yet.</p>
)}
{/* Footer */}
<p className="text-center text-xs text-gray-400 mt-12">Made with Tia 👶</p>
</div>
</div>
);
}

View file

@ -15,6 +15,7 @@ export default function MenuPage() {
{ icon: "💊", label: "Medical", href: "/medical" },
{ icon: "📸", label: "Memories", href: "/memories" },
{ icon: "🤖", label: "AI Chat", href: "/ai" },
{ icon: "🌟", label: "Milestones", href: "/milestones" },
];
const handleSignOut = async () => {

230
src/app/milestones/page.tsx Normal file
View file

@ -0,0 +1,230 @@
"use client";
import { useEffect, useState, useMemo } from "react";
import { useFamily } from "@/app/FamilyProvider";
import { useStageCheck } from "@/hooks/useStageCheck";
import { MILESTONES, type MilestoneDef } from "@/lib/milestones";
type Category = "all" | "social" | "motor" | "language" | "cognitive";
type Filter = "all" | "achieved" | "upcoming";
interface MilestoneWithStatus extends MilestoneDef {
achieved: boolean;
achievedAt: string | null;
notes: string | null;
}
const CATEGORY_COLORS: Record<string, string> = {
social: "bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300",
motor: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300",
language: "bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300",
cognitive: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300",
};
export default function MilestonesPage() {
const { child } = useFamily();
const stage = useStageCheck(child?.birthDate ?? null);
const [items, setItems] = useState<MilestoneWithStatus[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<Filter>("all");
const [category, setCategory] = useState<Category>("all");
// date picker state per milestone key
const [pendingKey, setPendingKey] = useState<string | null>(null);
const [pendingDate, setPendingDate] = useState<string>(
new Date().toISOString().slice(0, 10)
);
useEffect(() => {
if (!child?.id) return;
setLoading(true);
fetch(`/api/milestones?childId=${child.id}`)
.then(r => r.json())
.then(d => { setItems(d.items || []); setLoading(false); })
.catch(() => setLoading(false));
}, [child?.id]);
async function toggleMilestone(m: MilestoneWithStatus) {
if (m.achieved) {
// un-mark
await fetch(`/api/milestones/${m.key}?childId=${child!.id}`, { method: "DELETE" });
setItems(prev => prev.map(x => x.key === m.key ? { ...x, achieved: false, achievedAt: null } : x));
if (pendingKey === m.key) setPendingKey(null);
} else {
setPendingKey(m.key);
setPendingDate(new Date().toISOString().slice(0, 10));
}
}
async function confirmAchieved(key: string) {
await fetch("/api/milestones", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ childId: child!.id, milestoneKey: key, achievedAt: pendingDate }),
});
setItems(prev => prev.map(x => x.key === key ? { ...x, achieved: true, achievedAt: pendingDate } : x));
setPendingKey(null);
}
const filtered = useMemo(() => {
const ageMonths = stage?.ageMonths ?? 0;
return items.filter(m => {
if (category !== "all" && m.category !== category) return false;
if (filter === "achieved") return m.achieved;
if (filter === "upcoming") return !m.achieved && m.ageMonths <= ageMonths + 6;
return true;
});
}, [items, filter, category, stage]);
const achievedCount = items.filter(m => m.achieved).length;
const nowItems = useMemo(() => {
if (!stage) return new Set<string>();
const lo = stage.ageMonths - 3, hi = stage.ageMonths + 3;
return new Set(items.filter(m => m.ageMonths >= lo && m.ageMonths <= hi).map(m => m.key));
}, [items, stage]);
if (!child) return (
<div className="flex items-center justify-center min-h-screen">
<p className="text-gray-500">Select a child to view milestones.</p>
</div>
);
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 pb-20">
<div className="max-w-2xl mx-auto px-4 py-6">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Milestones</h1>
<p className="text-sm text-gray-500">{child.name}</p>
</div>
{stage && (
<span className="text-sm font-medium px-3 py-1 rounded-full bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300">
{stage.emoji} {stage.label}
</span>
)}
</div>
{/* Progress bar */}
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 mb-4 shadow-sm">
<div className="flex items-center justify-between text-sm mb-2">
<span className="font-medium text-gray-700 dark:text-gray-300">{achievedCount} of {items.length} milestones</span>
<span className="text-gray-400">{Math.round(achievedCount / Math.max(items.length, 1) * 100)}%</span>
</div>
<div className="h-2 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-pink-400 to-purple-400 rounded-full transition-all duration-500"
style={{ width: `${achievedCount / Math.max(items.length, 1) * 100}%` }}
/>
</div>
</div>
{/* Filter tabs */}
<div className="flex gap-2 mb-3 overflow-x-auto pb-1 scrollbar-hide">
{(["all", "achieved", "upcoming"] as Filter[]).map(f => (
<button
key={f}
onClick={() => setFilter(f)}
className={`px-4 py-1.5 rounded-full text-sm font-medium whitespace-nowrap transition-colors ${
filter === f
? "bg-purple-500 text-white"
: "bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 border dark:border-gray-700"
}`}
>
{f.charAt(0).toUpperCase() + f.slice(1)}
</button>
))}
</div>
{/* Category chips */}
<div className="flex gap-2 mb-5 overflow-x-auto pb-1 scrollbar-hide">
{(["all", "social", "motor", "language", "cognitive"] as Category[]).map(c => (
<button
key={c}
onClick={() => setCategory(c)}
className={`px-3 py-1 rounded-full text-xs font-medium whitespace-nowrap transition-colors ${
category === c
? "bg-gray-800 dark:bg-gray-200 text-white dark:text-gray-900"
: "bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 border dark:border-gray-700"
}`}
>
{c.charAt(0).toUpperCase() + c.slice(1)}
</button>
))}
</div>
{/* Milestone grid */}
{loading ? (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-28 bg-white dark:bg-gray-800 rounded-2xl animate-pulse" />
))}
</div>
) : filtered.length === 0 ? (
<p className="text-center text-gray-400 py-12">No milestones match this filter.</p>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{filtered.map(m => (
<div key={m.key}>
<button
onClick={() => toggleMilestone(m)}
className={`w-full text-left rounded-2xl p-4 transition-all shadow-sm relative ${
m.achieved
? "bg-green-50 dark:bg-green-900/20 border-2 border-green-300 dark:border-green-700"
: "bg-white dark:bg-gray-800 border border-transparent hover:border-gray-200 dark:hover:border-gray-600"
}`}
>
{nowItems.has(m.key) && !m.achieved && (
<span className="absolute top-2 right-2 text-xs">📍</span>
)}
{m.achieved && (
<span className="absolute top-2 right-2 text-green-500 text-sm"></span>
)}
<div className="text-2xl mb-2">{m.emoji}</div>
<p className="text-xs font-semibold text-gray-800 dark:text-white leading-tight">{m.label}</p>
<p className="text-xs text-gray-400 mt-1">{m.ageRangeLabel}</p>
{m.achieved && m.achievedAt && (
<p className="text-xs text-green-600 dark:text-green-400 mt-1">
{new Date(m.achievedAt).toLocaleDateString("en-IN", { day: "numeric", month: "short" })}
</p>
)}
<span className={`inline-block text-xs px-1.5 py-0.5 rounded-full mt-2 ${CATEGORY_COLORS[m.category]}`}>
{m.category}
</span>
</button>
{/* Inline date picker */}
{pendingKey === m.key && (
<div className="mt-2 bg-white dark:bg-gray-800 rounded-xl p-3 shadow-md border dark:border-gray-700">
<p className="text-xs text-gray-600 dark:text-gray-400 mb-2">When did this happen?</p>
<input
type="date"
value={pendingDate}
max={new Date().toISOString().slice(0, 10)}
onChange={e => setPendingDate(e.target.value)}
className="w-full text-sm border dark:border-gray-600 rounded-lg px-2 py-1.5 dark:bg-gray-700 dark:text-white mb-2"
/>
<div className="flex gap-2">
<button
onClick={() => confirmAchieved(m.key)}
className="flex-1 bg-green-500 text-white rounded-lg py-1.5 text-xs font-medium"
>
Mark achieved
</button>
<button
onClick={() => setPendingKey(null)}
className="flex-1 border dark:border-gray-600 rounded-lg py-1.5 text-xs text-gray-600 dark:text-gray-400"
>
Cancel
</button>
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
);
}

View file

@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
import Link from "next/link";
import { useTheme } from "./ThemeProvider";
import { useFamily } from "./FamilyProvider";
import { useStageCheck, type BabyStage } from "@/hooks/useStageCheck";
const OFFLINE_QUEUE_KEY = "tia_offline_queue";
@ -205,6 +206,7 @@ export default function HomePage() {
const [vaccineReminders, setVaccineReminders] = useState<any[]>([]);
const { theme, toggle: toggleTheme } = useTheme();
const { childId, child, familyId, loading } = useFamily();
const stage = useStageCheck(child?.birthDate ?? null);
useEffect(() => {
if (!childId) return;
@ -353,7 +355,26 @@ export default function HomePage() {
<ActivityScroller lastLogs={lastLogs} />
<div className="px-4 mb-4">
<h2 className="font-semibold mb-3 ml-1">Quick Log</h2>
<div className="flex items-center justify-between mb-3">
<h2 className="font-semibold ml-1">Quick Log</h2>
{stage && (() => {
const h = new Date().getHours();
type Suggestion = { label: string; type: string };
const matrix: Record<BabyStage, Suggestion> =
h >= 5 && h < 9 ? { newborn: { label: "Feed", type: "feed" }, infant: { label: "Feed", type: "feed" }, sitter: { label: "Feed", type: "feed" }, crawler: { label: "Feed", type: "feed" }, toddler: { label: "Feed", type: "feed" }, walker: { label: "Feed", type: "feed" } } :
h >= 9 && h < 12 ? { newborn: { label: "Feed", type: "feed" }, infant: { label: "Feed + Diaper", type: "feed" }, sitter: { label: "Solids", type: "feed" }, crawler: { label: "Solids", type: "feed" }, toddler: { label: "Solids", type: "feed" }, walker: { label: "Solids", type: "feed" } } :
h >= 12 && h < 15 ? { newborn: { label: "Sleep", type: "sleep" }, infant: { label: "Sleep", type: "sleep" }, sitter: { label: "Nap", type: "sleep" }, crawler: { label: "Nap", type: "sleep" }, toddler: { label: "Nap", type: "sleep" }, walker: { label: "Rest", type: "sleep" } } :
h >= 15 && h < 18 ? { newborn: { label: "Feed", type: "feed" }, infant: { label: "Feed", type: "feed" }, sitter: { label: "Snack", type: "feed" }, crawler: { label: "Snack", type: "feed" }, toddler: { label: "Snack", type: "feed" }, walker: { label: "Snack", type: "feed" } } :
h >= 18 && h < 21 ? { newborn: { label: "Feed", type: "feed" }, infant: { label: "Bath + Feed", type: "feed" }, sitter: { label: "Bath", type: "feed" }, crawler: { label: "Bath", type: "feed" }, toddler: { label: "Bath", type: "feed" }, walker: { label: "Bath", type: "feed" } } :
{ newborn: { label: "Night feed", type: "feed" }, infant: { label: "Night feed", type: "feed" }, sitter: { label: "Sleep", type: "sleep" }, crawler: { label: "Sleep", type: "sleep" }, toddler: { label: "Sleep", type: "sleep" }, walker: { label: "Sleep", type: "sleep" } };
const s = matrix[stage.stage];
return (
<span className="text-xs font-medium px-2.5 py-1 rounded-full bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
Suggested now {s.label}
</span>
);
})()}
</div>
<div className="grid grid-cols-4 gap-2">
<button onClick={() => setModalType("feed")} className="flex flex-col items-center p-4 bg-white dark:bg-gray-800 rounded-xl shadow-sm"><span className="text-3xl">🍼</span><span className="text-sm mt-1">Feed</span></button>
<button onClick={() => setModalType("sleep")} className="flex flex-col items-center p-4 bg-white dark:bg-gray-800 rounded-xl shadow-sm"><span className="text-3xl">😴</span><span className="text-sm mt-1">Sleep</span></button>

View file

@ -128,6 +128,16 @@ export default function SettingsPage() {
</div>
<div className="px-4 space-y-3">
{/* My Profile Page */}
<a href="/settings/profile"
className="flex items-center justify-between bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm mb-3">
<div>
<p className="font-medium text-gray-900 dark:text-white">My Profile Page</p>
<p className="text-xs text-gray-500 dark:text-gray-400">Create your public product recommendation page</p>
</div>
<span className="text-gray-400"></span>
</a>
{/* Notifications */}
<Link href="/notifications" className="flex items-center justify-between p-4 bg-white dark:bg-gray-800 rounded-xl">
<div className="flex items-center gap-3">

View file

@ -0,0 +1,280 @@
"use client";
import { useEffect, useState } from "react";
interface Profile {
id: string;
slug: string;
display_name: string;
bio: string | null;
avatar_url: string | null;
is_public: boolean;
}
interface Product {
id: string;
title: string;
description: string | null;
url: string;
image_url: string | null;
category: string;
display_order: number;
click_count: number;
}
const CATEGORIES = ["general", "feeding", "sleep", "play", "clothing"] as const;
const CATEGORY_EMOJI: Record<string, string> = { feeding: "🍼", sleep: "💤", play: "🎮", clothing: "👗", general: "🛍️" };
export default function ProfileSettingsPage() {
const [profile, setProfile] = useState<Profile | null>(null);
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saveMsg, setSaveMsg] = useState("");
// form state
const [slug, setSlug] = useState("");
const [displayName, setDisplayName] = useState("");
const [bio, setBio] = useState("");
const [isPublic, setIsPublic] = useState(false);
// product form
const [showAddProduct, setShowAddProduct] = useState(false);
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
const [pTitle, setPTitle] = useState("");
const [pUrl, setPUrl] = useState("");
const [pDesc, setPDesc] = useState("");
const [pImageUrl, setPImageUrl] = useState("");
const [pCategory, setPCategory] = useState("general");
useEffect(() => {
Promise.all([
fetch("/api/profile").then(r => r.json()),
fetch("/api/profile/products").then(r => r.json()),
]).then(([pd, prods]) => {
if (pd.profile) {
setProfile(pd.profile);
setSlug(pd.profile.slug);
setDisplayName(pd.profile.display_name);
setBio(pd.profile.bio ?? "");
setIsPublic(pd.profile.is_public);
}
setProducts(prods.items || []);
setLoading(false);
}).catch(() => setLoading(false));
}, []);
async function saveProfile() {
setSaving(true);
setSaveMsg("");
const r = await fetch("/api/profile", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug, display_name: displayName, bio: bio || null, is_public: isPublic }),
});
const d = await r.json();
if (r.ok) {
setProfile(d.profile);
setSaveMsg(`Saved! View at /m/${d.profile.slug}`);
} else {
setSaveMsg(d.error || "Save failed");
}
setSaving(false);
}
function resetProductForm() {
setPTitle(""); setPUrl(""); setPDesc(""); setPImageUrl(""); setPCategory("general");
setShowAddProduct(false); setEditingProduct(null);
}
async function saveProduct() {
const body = { title: pTitle, url: pUrl, description: pDesc || null, image_url: pImageUrl || null, category: pCategory };
if (editingProduct) {
await fetch(`/api/profile/products/${editingProduct.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
} else {
await fetch("/api/profile/products", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...body, display_order: products.length }),
});
}
const r = await fetch("/api/profile/products");
const d = await r.json();
setProducts(d.items || []);
resetProductForm();
}
async function deleteProduct(id: string) {
if (!confirm("Remove this product?")) return;
await fetch(`/api/profile/products/${id}`, { method: "DELETE" });
setProducts(p => p.filter(x => x.id !== id));
}
async function moveProduct(id: string, dir: "up" | "down") {
const idx = products.findIndex(p => p.id === id);
if ((dir === "up" && idx === 0) || (dir === "down" && idx === products.length - 1)) return;
const swapIdx = dir === "up" ? idx - 1 : idx + 1;
await fetch(`/api/profile/products/${id}`, {
method: "PATCH", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ display_order: products[swapIdx].display_order }),
});
await fetch(`/api/profile/products/${products[swapIdx].id}`, {
method: "PATCH", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ display_order: products[idx].display_order }),
});
const r = await fetch("/api/profile/products");
const d = await r.json();
setProducts(d.items || []);
}
function startEdit(p: Product) {
setEditingProduct(p); setPTitle(p.title); setPUrl(p.url);
setPDesc(p.description ?? ""); setPImageUrl(p.image_url ?? ""); setPCategory(p.category);
setShowAddProduct(true);
}
const slugValid = /^[a-z0-9-]{3,40}$/.test(slug);
const baseUrl = typeof window !== "undefined" ? window.location.origin : "";
if (loading) return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-pink-400" />
</div>
);
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 pb-20">
<div className="max-w-lg mx-auto px-4 py-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-6">My Profile Page</h1>
{/* Profile section */}
<div className="bg-white dark:bg-gray-800 rounded-2xl p-5 mb-4 shadow-sm">
<h2 className="font-semibold text-gray-800 dark:text-white mb-4">Profile</h2>
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">Display Name</label>
<input
value={displayName}
onChange={e => setDisplayName(e.target.value)}
className="w-full border dark:border-gray-600 rounded-xl px-3 py-2 text-sm dark:bg-gray-700 dark:text-white mb-3"
placeholder="Priya Sharma"
/>
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">Your Page URL</label>
<div className="flex items-center gap-1 border dark:border-gray-600 rounded-xl px-3 py-2 text-sm mb-1 dark:bg-gray-700">
<span className="text-gray-400 text-xs">{baseUrl}/m/</span>
<input
value={slug}
onChange={e => setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""))}
className="flex-1 bg-transparent outline-none dark:text-white"
placeholder="priya-sharma"
/>
</div>
{slug && !slugValid && (
<p className="text-xs text-red-500 mb-2">340 chars, lowercase letters, numbers, hyphens only</p>
)}
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1 mt-3">Bio <span className="text-gray-400">({bio.length}/200)</span></label>
<textarea
value={bio}
onChange={e => setBio(e.target.value.slice(0, 200))}
rows={3}
className="w-full border dark:border-gray-600 rounded-xl px-3 py-2 text-sm dark:bg-gray-700 dark:text-white mb-3 resize-none"
placeholder="New mama sharing what works for us 💕"
/>
<label className="flex items-center gap-3 cursor-pointer mb-4">
<input
type="checkbox"
checked={isPublic}
onChange={e => setIsPublic(e.target.checked)}
className="w-4 h-4 accent-pink-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Make my page public</span>
</label>
<button
onClick={saveProfile}
disabled={saving || !displayName || !slugValid}
className="w-full bg-pink-500 text-white rounded-xl py-2.5 font-medium text-sm disabled:opacity-50"
>
{saving ? "Saving..." : "Save Profile"}
</button>
{saveMsg && (
<p className={`text-xs mt-2 text-center ${saveMsg.startsWith("Saved") ? "text-green-600" : "text-red-500"}`}>
{saveMsg}
</p>
)}
</div>
{/* Products section */}
<div className="bg-white dark:bg-gray-800 rounded-2xl p-5 shadow-sm">
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold text-gray-800 dark:text-white">Product Recommendations</h2>
<button
onClick={() => { resetProductForm(); setShowAddProduct(true); }}
className="text-sm text-pink-500 font-medium"
>
+ Add
</button>
</div>
{/* Add/Edit form */}
{showAddProduct && (
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-xl p-4 mb-4">
<input value={pTitle} onChange={e => setPTitle(e.target.value)}
placeholder="Product title *" className="w-full border dark:border-gray-600 rounded-lg px-3 py-2 text-sm dark:bg-gray-700 dark:text-white mb-2" />
<input value={pUrl} onChange={e => setPUrl(e.target.value)}
placeholder="Product URL *" className="w-full border dark:border-gray-600 rounded-lg px-3 py-2 text-sm dark:bg-gray-700 dark:text-white mb-2" />
<textarea value={pDesc} onChange={e => setPDesc(e.target.value)} rows={2}
placeholder="Description (optional)" className="w-full border dark:border-gray-600 rounded-lg px-3 py-2 text-sm dark:bg-gray-700 dark:text-white mb-2 resize-none" />
<input value={pImageUrl} onChange={e => setPImageUrl(e.target.value)}
placeholder="Image URL (optional)" className="w-full border dark:border-gray-600 rounded-lg px-3 py-2 text-sm dark:bg-gray-700 dark:text-white mb-2" />
<select value={pCategory} onChange={e => setPCategory(e.target.value)}
className="w-full border dark:border-gray-600 rounded-lg px-3 py-2 text-sm dark:bg-gray-700 dark:text-white mb-3">
{CATEGORIES.map(c => <option key={c} value={c}>{CATEGORY_EMOJI[c]} {c.charAt(0).toUpperCase() + c.slice(1)}</option>)}
</select>
<div className="flex gap-2">
<button onClick={saveProduct} disabled={!pTitle || !pUrl}
className="flex-1 bg-pink-500 text-white rounded-lg py-2 text-sm font-medium disabled:opacity-50">
{editingProduct ? "Update" : "Add Product"}
</button>
<button onClick={resetProductForm}
className="flex-1 border dark:border-gray-600 rounded-lg py-2 text-sm text-gray-600 dark:text-gray-300">
Cancel
</button>
</div>
</div>
)}
{products.length === 0 && !showAddProduct && (
<p className="text-sm text-gray-400 text-center py-4">No products yet. Add your first recommendation!</p>
)}
<div className="space-y-2">
{products.map((p, i) => (
<div key={p.id} className="flex items-center gap-3 p-3 rounded-xl border dark:border-gray-700">
<div className="text-2xl">{CATEGORY_EMOJI[p.category] || "🛍️"}</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-800 dark:text-white truncate">{p.title}</p>
<p className="text-xs text-gray-400">{p.category} · {p.click_count} clicks</p>
</div>
<div className="flex items-center gap-1">
<button onClick={() => moveProduct(p.id, "up")} disabled={i === 0}
className="text-gray-400 hover:text-gray-600 disabled:opacity-30 text-xs px-1"></button>
<button onClick={() => moveProduct(p.id, "down")} disabled={i === products.length - 1}
className="text-gray-400 hover:text-gray-600 disabled:opacity-30 text-xs px-1"></button>
<button onClick={() => startEdit(p)} className="text-blue-400 hover:text-blue-600 text-xs px-1">Edit</button>
<button onClick={() => deleteProduct(p.id)} className="text-red-400 hover:text-red-600 text-xs px-1"></button>
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,35 @@
import { useMemo } from "react";
export type BabyStage = "newborn" | "infant" | "sitter" | "crawler" | "toddler" | "walker";
export interface StageInfo {
stage: BabyStage;
ageMonths: number;
label: string;
nextStageAt: number; // months until next stage
emoji: string;
}
const STAGES: { stage: BabyStage; minMonths: number; maxMonths: number; label: string; emoji: string }[] = [
{ stage: "newborn", minMonths: 0, maxMonths: 3, label: "Newborn (03 mo)", emoji: "🍼" },
{ stage: "infant", minMonths: 3, maxMonths: 6, label: "Infant (36 mo)", emoji: "👶" },
{ stage: "sitter", minMonths: 6, maxMonths: 9, label: "Sitter (69 mo)", emoji: "🧸" },
{ stage: "crawler", minMonths: 9, maxMonths: 12, label: "Crawler (912 mo)", emoji: "🐛" },
{ stage: "toddler", minMonths: 12, maxMonths: 24, label: "Toddler (12 yr)", emoji: "🚶" },
{ stage: "walker", minMonths: 24, maxMonths: Infinity, label: "Walker (2+ yr)", emoji: "🏃" },
];
export function useStageCheck(birthDate: string | null): StageInfo | null {
return useMemo(() => {
if (!birthDate) return null;
const birth = new Date(birthDate);
const now = new Date();
const ageMonths = Math.floor((now.getTime() - birth.getTime()) / (30.44 * 24 * 3600 * 1000));
const current = STAGES.find(s => ageMonths >= s.minMonths && ageMonths < s.maxMonths)
?? STAGES[STAGES.length - 1];
const nextStageAt = current.maxMonths === Infinity ? 0 : current.maxMonths - ageMonths;
return { stage: current.stage, ageMonths, label: current.label, nextStageAt, emoji: current.emoji };
}, [birthDate]);
}

43
src/lib/milestones.ts Normal file
View file

@ -0,0 +1,43 @@
export interface MilestoneDef {
key: string;
label: string;
ageMonths: number;
ageRangeLabel: string;
category: "social" | "motor" | "language" | "cognitive";
emoji: string;
}
export const MILESTONES: MilestoneDef[] = [
// 12 months
{ key: "lifts_head", label: "Lifts head briefly", ageMonths: 1, ageRangeLabel: "1 month", category: "motor", emoji: "💪" },
{ key: "social_smile", label: "Social smile", ageMonths: 2, ageRangeLabel: "2 months", category: "social", emoji: "😊" },
{ key: "follows_movement", label: "Follows movement with eyes", ageMonths: 2, ageRangeLabel: "2 months", category: "cognitive",emoji: "👁️" },
// 34 months
{ key: "coos", label: "Coos and makes sounds", ageMonths: 3, ageRangeLabel: "3 months", category: "language", emoji: "🗣️" },
{ key: "recognizes_faces", label: "Recognizes familiar faces", ageMonths: 3, ageRangeLabel: "3 months", category: "social", emoji: "👨‍👩‍👧" },
{ key: "holds_head_steady", label: "Holds head steady", ageMonths: 4, ageRangeLabel: "4 months", category: "motor", emoji: "🦒" },
{ key: "laughs", label: "Laughs out loud", ageMonths: 4, ageRangeLabel: "4 months", category: "social", emoji: "😂" },
// 56 months
{ key: "reaches_objects", label: "Reaches for objects", ageMonths: 5, ageRangeLabel: "5 months", category: "cognitive",emoji: "✋" },
{ key: "rolls_over", label: "Rolls over (front to back)", ageMonths: 5, ageRangeLabel: "5 months", category: "motor", emoji: "🔄" },
{ key: "sits_with_support", label: "Sits with support", ageMonths: 6, ageRangeLabel: "6 months", category: "motor", emoji: "🧸" },
// 79 months
{ key: "sits_without_support",label: "Sits without support", ageMonths: 7, ageRangeLabel: "7 months", category: "motor", emoji: "🧘" },
{ key: "babbles", label: "Babbles (mama/dada sounds)", ageMonths: 8, ageRangeLabel: "8 months", category: "language", emoji: "👶" },
{ key: "object_permanence", label: "Understands object permanence", ageMonths: 9, ageRangeLabel: "9 months", category: "cognitive",emoji: "🎭" },
// 912 months
{ key: "crawls", label: "Crawls", ageMonths: 9, ageRangeLabel: "9 months", category: "motor", emoji: "🐛" },
{ key: "pulls_to_stand", label: "Pulls to standing", ageMonths: 10, ageRangeLabel: "10 months", category: "motor", emoji: "🏋️" },
{ key: "pincer_grasp", label: "Pincer grasp (finger + thumb)", ageMonths: 10, ageRangeLabel: "10 months", category: "motor", emoji: "🤏" },
{ key: "waves_bye", label: "Waves bye-bye", ageMonths: 10, ageRangeLabel: "10 months", category: "social", emoji: "👋" },
{ key: "first_word", label: "First real word", ageMonths: 12, ageRangeLabel: "12 months", category: "language", emoji: "💬" },
// 1218 months
{ key: "walks", label: "Walks independently", ageMonths: 12, ageRangeLabel: "12 months", category: "motor", emoji: "🚶" },
{ key: "uses_cup", label: "Drinks from cup", ageMonths: 12, ageRangeLabel: "12 months", category: "motor", emoji: "🥤" },
{ key: "points_to_things", label: "Points to show interest", ageMonths: 14, ageRangeLabel: "14 months", category: "language", emoji: "👆" },
{ key: "ten_words", label: "10+ words", ageMonths: 18, ageRangeLabel: "18 months", category: "language", emoji: "🗨️" },
// 1824 months
{ key: "runs", label: "Runs", ageMonths: 18, ageRangeLabel: "18 months", category: "motor", emoji: "🏃" },
{ key: "pretend_play", label: "Pretend play (feeds doll etc)", ageMonths: 18, ageRangeLabel: "18 months", category: "cognitive",emoji: "🎭" },
{ key: "two_word_phrases", label: "Two-word phrases", ageMonths: 24, ageRangeLabel: "24 months", category: "language", emoji: "💭" },
];