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:
parent
c2cabc01d3
commit
7c4f6d0d78
17 changed files with 1064 additions and 1 deletions
11
drizzle/0014_milestones.sql
Normal file
11
drizzle/0014_milestones.sql
Normal 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);
|
||||
37
drizzle/0015_affiliate.sql
Normal file
37
drizzle/0015_affiliate.sql
Normal 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);
|
||||
25
src/app/api/milestones/[key]/route.ts
Normal file
25
src/app/api/milestones/[key]/route.ts
Normal 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 });
|
||||
}
|
||||
54
src/app/api/milestones/route.ts
Normal file
54
src/app/api/milestones/route.ts
Normal 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 });
|
||||
}
|
||||
30
src/app/api/profile/[slug]/click/route.ts
Normal file
30
src/app/api/profile/[slug]/click/route.ts
Normal 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 });
|
||||
}
|
||||
19
src/app/api/profile/[slug]/route.ts
Normal file
19
src/app/api/profile/[slug]/route.ts
Normal 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 });
|
||||
}
|
||||
48
src/app/api/profile/products/[id]/route.ts
Normal file
48
src/app/api/profile/products/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
40
src/app/api/profile/products/route.ts
Normal file
40
src/app/api/profile/products/route.ts
Normal 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] });
|
||||
}
|
||||
41
src/app/api/profile/route.ts
Normal file
41
src/app/api/profile/route.ts
Normal 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 3–40 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
138
src/app/m/[slug]/page.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
230
src/app/milestones/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
280
src/app/settings/profile/page.tsx
Normal file
280
src/app/settings/profile/page.tsx
Normal 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">3–40 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>
|
||||
);
|
||||
}
|
||||
35
src/hooks/useStageCheck.ts
Normal file
35
src/hooks/useStageCheck.ts
Normal 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 (0–3 mo)", emoji: "🍼" },
|
||||
{ stage: "infant", minMonths: 3, maxMonths: 6, label: "Infant (3–6 mo)", emoji: "👶" },
|
||||
{ stage: "sitter", minMonths: 6, maxMonths: 9, label: "Sitter (6–9 mo)", emoji: "🧸" },
|
||||
{ stage: "crawler", minMonths: 9, maxMonths: 12, label: "Crawler (9–12 mo)", emoji: "🐛" },
|
||||
{ stage: "toddler", minMonths: 12, maxMonths: 24, label: "Toddler (1–2 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
43
src/lib/milestones.ts
Normal 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[] = [
|
||||
// 1–2 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: "👁️" },
|
||||
// 3–4 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: "😂" },
|
||||
// 5–6 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: "🧸" },
|
||||
// 7–9 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: "🎭" },
|
||||
// 9–12 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: "💬" },
|
||||
// 12–18 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: "🗨️" },
|
||||
// 18–24 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: "💭" },
|
||||
];
|
||||
Loading…
Add table
Reference in a new issue