diff --git a/drizzle/0014_milestones.sql b/drizzle/0014_milestones.sql new file mode 100644 index 0000000..2dc0fc8 --- /dev/null +++ b/drizzle/0014_milestones.sql @@ -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); diff --git a/drizzle/0015_affiliate.sql b/drizzle/0015_affiliate.sql new file mode 100644 index 0000000..72745fd --- /dev/null +++ b/drizzle/0015_affiliate.sql @@ -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); diff --git a/src/app/api/milestones/[key]/route.ts b/src/app/api/milestones/[key]/route.ts new file mode 100644 index 0000000..f2f74cd --- /dev/null +++ b/src/app/api/milestones/[key]/route.ts @@ -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 }); +} diff --git a/src/app/api/milestones/route.ts b/src/app/api/milestones/route.ts new file mode 100644 index 0000000..f59591e --- /dev/null +++ b/src/app/api/milestones/route.ts @@ -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 }); +} diff --git a/src/app/api/profile/[slug]/click/route.ts b/src/app/api/profile/[slug]/click/route.ts new file mode 100644 index 0000000..086d231 --- /dev/null +++ b/src/app/api/profile/[slug]/click/route.ts @@ -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 }); +} diff --git a/src/app/api/profile/[slug]/route.ts b/src/app/api/profile/[slug]/route.ts new file mode 100644 index 0000000..46ef194 --- /dev/null +++ b/src/app/api/profile/[slug]/route.ts @@ -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 }); +} diff --git a/src/app/api/profile/products/[id]/route.ts b/src/app/api/profile/products/[id]/route.ts new file mode 100644 index 0000000..cb0688e --- /dev/null +++ b/src/app/api/profile/products/[id]/route.ts @@ -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 }); +} diff --git a/src/app/api/profile/products/route.ts b/src/app/api/profile/products/route.ts new file mode 100644 index 0000000..8429b41 --- /dev/null +++ b/src/app/api/profile/products/route.ts @@ -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] }); +} diff --git a/src/app/api/profile/route.ts b/src/app/api/profile/route.ts new file mode 100644 index 0000000..b4e0206 --- /dev/null +++ b/src/app/api/profile/route.ts @@ -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] }); +} diff --git a/src/app/m/[slug]/page.tsx b/src/app/m/[slug]/page.tsx new file mode 100644 index 0000000..0c1ec24 --- /dev/null +++ b/src/app/m/[slug]/page.tsx @@ -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 = { feeding: "๐Ÿผ", sleep: "๐Ÿ’ค", play: "๐ŸŽฎ", clothing: "๐Ÿ‘—", general: "๐Ÿ›๏ธ" }; + +export default function MamaPage() { + const params = useParams(); + const slug = params.slug as string; + const [profile, setProfile] = useState(null); + const [products, setProducts] = useState([]); + 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 ( +
+
+
+ ); + + if (notFound || !profile) return ( +
+
+
๐Ÿผ
+

Page not found

+

This profile doesn't exist or is private.

+
+
+ ); + + const initials = profile.display_name.split(" ").map((w: string) => w[0]).join("").slice(0, 2).toUpperCase(); + + return ( +
+
+ {/* Profile header */} +
+
+ {profile.avatar_url + ? {profile.display_name} + : initials + } +
+

{profile.display_name}

+ {profile.bio &&

{profile.bio}

} +
+ + {/* Products */} + {products.length > 0 && ( + <> +

Products I Love ๐Ÿ’•

+
+ {products.map(p => ( +
+
+ {p.image_url + ? {p.title} + : CATEGORY_EMOJI[p.category] || "๐Ÿ›๏ธ" + } +
+
+

{p.title}

+ {p.description && ( +

{p.description}

+ )} +
+ {CATEGORY_EMOJI[p.category]} + +
+
+
+ ))} +
+ + )} + + {products.length === 0 && ( +

No recommendations yet.

+ )} + + {/* Footer */} +

Made with Tia ๐Ÿ‘ถ

+
+
+ ); +} diff --git a/src/app/menu/page.tsx b/src/app/menu/page.tsx index 4202a5a..ad4b84d 100644 --- a/src/app/menu/page.tsx +++ b/src/app/menu/page.tsx @@ -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 () => { diff --git a/src/app/milestones/page.tsx b/src/app/milestones/page.tsx new file mode 100644 index 0000000..2b35038 --- /dev/null +++ b/src/app/milestones/page.tsx @@ -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 = { + 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([]); + const [loading, setLoading] = useState(true); + const [filter, setFilter] = useState("all"); + const [category, setCategory] = useState("all"); + + // date picker state per milestone key + const [pendingKey, setPendingKey] = useState(null); + const [pendingDate, setPendingDate] = useState( + 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(); + 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 ( +
+

Select a child to view milestones.

+
+ ); + + return ( +
+
+ {/* Header */} +
+
+

Milestones

+

{child.name}

+
+ {stage && ( + + {stage.emoji} {stage.label} + + )} +
+ + {/* Progress bar */} +
+
+ {achievedCount} of {items.length} milestones + {Math.round(achievedCount / Math.max(items.length, 1) * 100)}% +
+
+
+
+
+ + {/* Filter tabs */} +
+ {(["all", "achieved", "upcoming"] as Filter[]).map(f => ( + + ))} +
+ + {/* Category chips */} +
+ {(["all", "social", "motor", "language", "cognitive"] as Category[]).map(c => ( + + ))} +
+ + {/* Milestone grid */} + {loading ? ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ ))} +
+ ) : filtered.length === 0 ? ( +

No milestones match this filter.

+ ) : ( +
+ {filtered.map(m => ( +
+ + + {/* Inline date picker */} + {pendingKey === m.key && ( +
+

When did this happen?

+ 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" + /> +
+ + +
+
+ )} +
+ ))} +
+ )} +
+
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 6000106..a3705a5 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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([]); 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() {
-

Quick Log

+
+

Quick Log

+ {stage && (() => { + const h = new Date().getHours(); + type Suggestion = { label: string; type: string }; + const matrix: Record = + 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 ( + + Suggested now โ†’ {s.label} + + ); + })()} +
diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index a50a384..283ae7d 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -128,6 +128,16 @@ export default function SettingsPage() {
+ {/* My Profile Page */} + +
+

My Profile Page

+

Create your public product recommendation page

+
+ โ†’ +
+ {/* Notifications */}
diff --git a/src/app/settings/profile/page.tsx b/src/app/settings/profile/page.tsx new file mode 100644 index 0000000..ef9579f --- /dev/null +++ b/src/app/settings/profile/page.tsx @@ -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 = { feeding: "๐Ÿผ", sleep: "๐Ÿ’ค", play: "๐ŸŽฎ", clothing: "๐Ÿ‘—", general: "๐Ÿ›๏ธ" }; + +export default function ProfileSettingsPage() { + const [profile, setProfile] = useState(null); + const [products, setProducts] = useState([]); + 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(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 ( +
+
+
+ ); + + return ( +
+
+

My Profile Page

+ + {/* Profile section */} +
+

Profile

+ + + 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" + /> + + +
+ {baseUrl}/m/ + setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""))} + className="flex-1 bg-transparent outline-none dark:text-white" + placeholder="priya-sharma" + /> +
+ {slug && !slugValid && ( +

3โ€“40 chars, lowercase letters, numbers, hyphens only

+ )} + + +