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>
230 lines
9.8 KiB
TypeScript
230 lines
9.8 KiB
TypeScript
"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>
|
|
);
|
|
}
|