diff --git a/src/app/api/invites/accept/route.ts b/src/app/api/invites/accept/route.ts new file mode 100644 index 0000000..813a9ff --- /dev/null +++ b/src/app/api/invites/accept/route.ts @@ -0,0 +1,53 @@ +import { NextResponse } from "next/server"; +import { sql } from "@/db"; + +// POST /api/invites/accept - accept an invite with token +export async function POST(request: Request) { + try { + const body = await request.json(); + const { token, userId } = body; + + if (!token || !userId) { + return NextResponse.json({ error: "token and userId required" }, { status: 400 }); + } + + // Find invite + const invites = await sql.unsafe( + `SELECT * FROM family_invites WHERE token = $1 AND expires_at > NOW() AND accepted_at IS NULL`, + [token] + ); + + if (!invites || invites.length === 0) { + return NextResponse.json({ error: "Invalid or expired invite" }, { status: 404 }); + } + + const invite = invites[0]; + + // Check if user already in family + const existingMember = await sql.unsafe( + `SELECT id FROM family_members WHERE family_id = $1 AND user_id = $2`, + [invite.family_id, userId] + ); + + if (existingMember && existingMember.length > 0) { + return NextResponse.json({ error: "Already a member of this family" }, { status: 400 }); + } + + // Add member + await sql.unsafe( + `INSERT INTO family_members (family_id, user_id, role, display_name) VALUES ($1, $2, $3, $4)`, + [invite.family_id, userId, invite.role, invite.display_name] + ); + + // Mark invite as accepted + await sql.unsafe( + `UPDATE family_invites SET accepted_at = NOW() WHERE id = $1`, + [invite.id] + ); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error(error); + return NextResponse.json({ error: String(error) }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/invites/route.ts b/src/app/api/invites/route.ts new file mode 100644 index 0000000..3dc31a4 --- /dev/null +++ b/src/app/api/invites/route.ts @@ -0,0 +1,70 @@ +import { NextResponse } from "next/server"; +import { sql } from "@/db"; +import { randomBytes } from "crypto"; + +// GET - list invites for a family +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const familyId = searchParams.get("familyId") || "default"; + + try { + const invites = await sql.unsafe( + `SELECT id, email, role, display_name as "displayName", expires_at as "expiresAt", accepted_at as "acceptedAt", created_at as "createdAt" + FROM family_invites + WHERE family_id = $1 AND accepted_at IS NULL AND expires_at > NOW() + ORDER BY created_at DESC`, + [familyId] + ); + return NextResponse.json({ invites: invites || [] }); + } catch (error) { + console.error(error); + return NextResponse.json({ error: String(error) }, { status: 500 }); + } +} + +// POST - create invite +export async function POST(request: Request) { + try { + const body = await request.json(); + const { familyId, email, role, displayName } = body; + + if (!familyId || !email) { + return NextResponse.json({ error: "familyId and email required" }, { status: 400 }); + } + + // Check member count limit + const memberCheck = await sql.unsafe( + `SELECT f.max_members, f.tier, COUNT(fm.id) as member_count + FROM families f + LEFT JOIN family_members fm ON fm.family_id = f.id + WHERE f.id = $1 + GROUP BY f.id`, + [familyId] + ); + + const family = memberCheck[0]; + const canAdd = family.tier === "pro" || (family.member_count || 0) < (family.max_members || 2); + + if (!canAdd) { + return NextResponse.json({ error: "Upgrade to Pro to add more members" }, { status: 403 }); + } + + const token = randomBytes(32).toString("hex"); + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + 7); // 7 days + + const [invite] = await sql.unsafe( + `INSERT INTO family_invites (family_id, email, role, display_name, token, expires_at) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, email, role, display_name as "displayName", expires_at as "expiresAt"`, + [familyId, email, role || "caregiver", displayName, token, expiresAt.toISOString()] + ); + + // TODO: Send invite email with magic link + + return NextResponse.json({ success: true, invite, inviteUrl: `/invite/${token}` }); + } catch (error) { + console.error(error); + return NextResponse.json({ error: String(error) }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/invite/[token]/page.tsx b/src/app/invite/[token]/page.tsx new file mode 100644 index 0000000..b5e1973 --- /dev/null +++ b/src/app/invite/[token]/page.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; + +export default function InvitePage({ params }: { params: { token: string } }) { + const router = useRouter(); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [invite, setInvite] = useState(null); + + useEffect(() => { + async function checkInvite() { + try { + // Get current user (would use NextAuth in production) + const userId = "current-user-id"; // TODO: Get from session + + const res = await fetch("/api/invites/accept", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: params.token, userId }), + }); + + const data = await res.json(); + + if (data.success) { + router.push("/"); + } else { + setError(data.error || "Invalid invite"); + } + } catch (err) { + setError("Failed to accept invite"); + } finally { + setLoading(false); + } + } + + checkInvite(); + }, [params.token, router]); + + if (loading) { + return ( +
+
+
+

Accepting invite...

+
+
+ ); + } + + if (error) { + return ( +
+
+
+

{error}

+ +
+
+ ); + } + + return ( +
+
+
+

Invite accepted!

+
+
+ ); +} \ No newline at end of file diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 43269eb..7e2f51f 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -1,14 +1,33 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useTheme } from "../ThemeProvider"; +import { useFamily } from "../FamilyProvider"; + +interface Invite { + id: string; + email: string; + displayName: string; + role: string; + expiresAt: string; +} export default function SettingsPage() { const router = useRouter(); const { theme, mode, setMode } = useTheme(); + const { tier, memberCount } = useFamily(); const [themeOpen, setThemeOpen] = useState(false); + const [inviteOpen, setInviteOpen] = useState(false); + const [invites, setInvites] = useState([]); + const [showAddInvite, setShowAddInvite] = useState(false); + const [inviteEmail, setInviteEmail] = useState(""); + const [inviteRole, setInviteRole] = useState("caregiver"); + const [inviteLoading, setInviteLoading] = useState(false); + + // Check if can invite more members + const canInvite = tier === "pro" || memberCount < 2; const themeOptions = [ { value: "light", label: "Light" }, @@ -17,6 +36,47 @@ export default function SettingsPage() { { value: "time", label: "Time of Day" }, ] as const; + useEffect(() => { + fetchInvites(); + }, []); + + const fetchInvites = async () => { + try { + const res = await fetch("/api/invites?familyId=default"); + const data = await res.json(); + setInvites(data.invites || []); + } catch (err) { + console.error("Failed to fetch invites:", err); + } + }; + + const sendInvite = async () => { + if (!inviteEmail) return; + setInviteLoading(true); + try { + const res = await fetch("/api/invites", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + familyId: "default", + email: inviteEmail, + role: inviteRole, + displayName: inviteEmail.split("@")[0], + }), + }); + const data = await res.json(); + if (data.success) { + setInviteEmail(""); + fetchInvites(); + } else { + alert(data.error); + } + } catch (err) { + console.error("Failed to send invite:", err); + } + setInviteLoading(false); + }; + return (
@@ -52,6 +112,75 @@ export default function SettingsPage() { + {/* Invite Members - Collapsible */} +
+ + + {inviteOpen && ( +
+ {/* Pro upgrade prompt */} + {tier === "free" && !canInvite && ( +
+

Upgrade to Pro for unlimited family members

+
+ )} + + {/* Pending invites */} + {invites.length > 0 && ( +
+
Pending Invites
+ {invites.map((invite) => ( +
+ {invite.email} + Pending +
+ ))} +
+ )} + + {/* Add invite form */} + {canInvite && ( +
+ setInviteEmail(e.target.value)} + placeholder="Email address" + className="w-full p-2 border rounded-lg text-sm" + /> + + +
+ )} +
+ )} +
+ {/* Theme - Collapsible */}