Feature A — Storage quota (1 GiB per family):
- src/lib/quota.ts: enforcement library with pure functions (fully unit-tested)
and DB-bound helpers; isPaidFamily() is the single payment abstraction gate
- src/lib/format-bytes.ts: extracted formatBytes() — safe for client imports
- POST /api/upload: quota check before presigned URL issuance (HTTP 402 + reason code)
- POST /api/memories/[id]/confirm: HeadObject reconciles actual R2 size; deletes
over-quota objects and marks row failed rather than silently exceeding limit
- GET /api/storage-usage: storage info endpoint for UI meter
- src/components/StorageMeter.tsx: meter bar + StorageQuotaBanner + MemberLimitBanner
- memories/page.tsx: quota banner, FAB disabled (⊘) when exceeded, compact meter in header
- settings/page.tsx: always-visible StorageMeter + MemberLimitBanner in invite section
Feature B — Member limit (2 per family, free tier):
- invites/route.ts: replaced ad-hoc inline check with checkMemberLimit() from quota lib
Structured 403 response: { reason, currentCount, limit }
- Freeze rule: paid→free downgrade leaves all members intact; only new invites blocked
Migration:
- drizzle/0007_subscription_status.sql: ADD COLUMN subscription_status varchar(20)
- debug-migration/route.ts: step added for hot-apply without full redeploy
- src/db/schema/family.ts: subscriptionStatus field added to Drizzle schema
Tests: 44 unit tests in src/__tests__/quota.test.ts, all passing
- Pure function tests (no DB): isPaidFamily, wouldExceedQuota, isAtMemberLimit, formatBytes
- DB-bound tests (mocked @/db): getFamilyStorageUsage, checkStorageQuota,
checkMemberLimit, getStorageInfo, tenant isolation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
399 lines
No EOL
16 KiB
TypeScript
399 lines
No EOL
16 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect } from "react";
|
||
import Link from "next/link";
|
||
import { useRouter } from "next/navigation";
|
||
import { useTheme } from "@/app/ThemeProvider";
|
||
import { useFamily } from "@/app/FamilyProvider";
|
||
import { Button, Card, Input, Select, Badge } from "@/components/ui";
|
||
import { StorageMeter, MemberLimitBanner } from "@/components/StorageMeter";
|
||
|
||
interface Member {
|
||
id: string;
|
||
userId: string;
|
||
role: string;
|
||
displayName: string;
|
||
name: string;
|
||
email: string;
|
||
}
|
||
|
||
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, familyId, children, familyName: providerFamilyName, childId, child } = useFamily();
|
||
const [exporting, setExporting] = useState(false);
|
||
const [themeOpen, setThemeOpen] = useState(false);
|
||
const [inviteOpen, setInviteOpen] = useState(false);
|
||
const [familyOpen, setFamilyOpen] = useState(true);
|
||
const [members, setMembers] = useState<Member[]>([]);
|
||
const [invites, setInvites] = useState<Invite[]>([]);
|
||
const [inviteEmail, setInviteEmail] = useState("");
|
||
const [inviteRole, setInviteRole] = useState("caregiver");
|
||
const [inviteLoading, setInviteLoading] = useState(false);
|
||
const [pedPhone, setPedPhone] = useState("");
|
||
const [pedSaving, setPedSaving] = useState(false);
|
||
|
||
// Check if can invite more members (client-side pre-check; server enforces)
|
||
const canInvite = tier === "pro" || memberCount < 2;
|
||
|
||
// Family name from provider or fallback
|
||
const familyName = providerFamilyName || "My Family";
|
||
|
||
const themeOptions = [
|
||
{ value: "light", label: "Light" },
|
||
{ value: "dark", label: "Dark" },
|
||
{ value: "system", label: "System" },
|
||
{ value: "time", label: "Time of Day" },
|
||
] as const;
|
||
|
||
useEffect(() => {
|
||
if (familyId) {
|
||
fetchMembers();
|
||
fetchInvites();
|
||
fetch("/api/family").then(r => r.json()).then(d => {
|
||
if (d.family?.pediatrician_phone) setPedPhone(d.family.pediatrician_phone);
|
||
}).catch(() => {});
|
||
}
|
||
}, [familyId]);
|
||
|
||
const exportGrowthCSV = async () => {
|
||
if (!childId) return;
|
||
setExporting(true);
|
||
try {
|
||
const res = await fetch(`/api/growth?childId=${childId}`);
|
||
const data = await res.json();
|
||
const records = data.growth || [];
|
||
if (records.length === 0) { alert("No growth records to export."); return; }
|
||
const headers = ["Date", "Weight (kg)", "Height (cm)", "Head (cm)", "Notes"];
|
||
const rows = records.map((r: { measured_at: string; weight_kg: number | null; height_cm: number | null; head_circumference_cm: number | null; notes: string | null }) => [
|
||
new Date(r.measured_at).toLocaleDateString(),
|
||
r.weight_kg ?? "",
|
||
r.height_cm ?? "",
|
||
r.head_circumference_cm ?? "",
|
||
r.notes ?? "",
|
||
]);
|
||
const csv = [headers, ...rows].map(row => row.join(",")).join("\n");
|
||
const blob = new Blob([csv], { type: "text/csv" });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = `${child?.name || "child"}_growth_${new Date().toISOString().split("T")[0]}.csv`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
} catch { alert("Export failed. Please try again."); }
|
||
setExporting(false);
|
||
};
|
||
|
||
const savePedPhone = async () => {
|
||
setPedSaving(true);
|
||
await fetch("/api/family", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ pediatricianPhone: pedPhone }),
|
||
}).catch(() => {});
|
||
setPedSaving(false);
|
||
};
|
||
|
||
const fetchMembers = async () => {
|
||
if (!familyId) return;
|
||
try {
|
||
const res = await fetch(`/api/family/members?familyId=${familyId}`);
|
||
const data = await res.json();
|
||
setMembers(data.members || []);
|
||
} catch (err) {
|
||
console.error("Failed to fetch members:", err);
|
||
}
|
||
};
|
||
|
||
const fetchInvites = async () => {
|
||
if (!familyId) return;
|
||
try {
|
||
const res = await fetch(`/api/invites?familyId=${familyId}`);
|
||
const data = await res.json();
|
||
setInvites(data.invites || []);
|
||
} catch (err) {
|
||
console.error("Failed to fetch invites:", err);
|
||
}
|
||
};
|
||
|
||
const deleteInvite = async (inviteId: string) => {
|
||
try {
|
||
await fetch(`/api/invites/${inviteId}`, { method: "DELETE" });
|
||
setInvites(prev => prev.filter(i => i.id !== inviteId));
|
||
} catch (err) {
|
||
console.error("Failed to delete invite:", err);
|
||
}
|
||
};
|
||
|
||
const sendInvite = async () => {
|
||
if (!inviteEmail || !familyId) return;
|
||
setInviteLoading(true);
|
||
try {
|
||
const res = await fetch("/api/invites", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
familyId: familyId,
|
||
email: inviteEmail,
|
||
role: inviteRole,
|
||
displayName: inviteEmail.split("@")[0],
|
||
}),
|
||
});
|
||
const data = await res.json();
|
||
if (data.success) {
|
||
setInviteEmail("");
|
||
fetchInvites();
|
||
} else if (data.reason === "member_limit_reached") {
|
||
// Trigger re-render of the limit banner with fresh data
|
||
fetchMembers();
|
||
} else {
|
||
alert(data.error);
|
||
}
|
||
} catch (err) {
|
||
console.error("Failed to send invite:", err);
|
||
}
|
||
setInviteLoading(false);
|
||
};
|
||
|
||
return (
|
||
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800">
|
||
<div className="p-4 flex items-center gap-4">
|
||
<button onClick={() => router.back()} className="p-2">←</button>
|
||
<h1 className="text-xl font-bold">Settings</h1>
|
||
</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">
|
||
<span className="text-xl">🔔</span>
|
||
<div className="font-medium">Notifications</div>
|
||
</div>
|
||
<span className="text-gray-400">→</span>
|
||
</Link>
|
||
|
||
{/* Profile */}
|
||
<Link href="/profile" className="flex items-center justify-between p-4 bg-white dark:bg-gray-800 rounded-xl">
|
||
<div className="flex items-center gap-3">
|
||
<span className="text-xl">👤</span>
|
||
<div className="font-medium">Profile</div>
|
||
</div>
|
||
<span className="text-gray-400">→</span>
|
||
</Link>
|
||
|
||
{/* Family - Single consolidated section */}
|
||
<div className="bg-white dark:bg-gray-800 rounded-xl overflow-hidden">
|
||
<button
|
||
onClick={() => setFamilyOpen(!familyOpen)}
|
||
className="w-full flex items-center justify-between p-4"
|
||
>
|
||
<div className="flex items-center gap-3">
|
||
<span className="text-xl">🏠</span>
|
||
<div className="font-medium">Family</div>
|
||
</div>
|
||
<span className={`text-gray-400 transition-transform ${familyOpen ? "rotate-180" : ""}`}>▼</span>
|
||
</button>
|
||
|
||
{familyOpen && (
|
||
<div className="px-4 pb-4 space-y-4">
|
||
{/* Family Info */}
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<div className="font-medium text-lg">{familyName}</div>
|
||
<div className="text-sm text-gray-500">
|
||
{tier === "pro" ? "Pro Plan" : <span className="bg-rose-100 text-rose-700 px-2 py-0.5 rounded-full text-xs">Free Plan</span>} · {members.length} member{members.length !== 1 ? "s" : ""} · {children?.length || 0} child{children?.length !== 1 ? "ren" : ""}
|
||
</div>
|
||
</div>
|
||
{tier === "free" && (
|
||
<Button size="sm">Upgrade</Button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Family Members */}
|
||
<div>
|
||
<div className="text-sm font-medium text-gray-500 mb-2">Family Members</div>
|
||
{members.length > 0 ? (
|
||
<div className="space-y-2">
|
||
{members.map((member) => (
|
||
<div key={member.id} className="flex items-center justify-between p-2 bg-gray-50 dark:bg-gray-700 rounded">
|
||
<div>
|
||
<div className="font-medium text-sm">{member.name || member.email}</div>
|
||
<div className="text-xs text-gray-400">{member.email}</div>
|
||
</div>
|
||
<Badge variant={member.role === "admin" ? "rose" : "default"}>{member.role}</Badge>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<p className="text-gray-400 text-sm">No family members</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Storage usage meter */}
|
||
<div>
|
||
<div className="text-sm font-medium text-gray-500 mb-2">Storage</div>
|
||
<StorageMeter />
|
||
</div>
|
||
|
||
{/* Manage Children */}
|
||
<Link href="/family" className="flex items-center justify-between p-3 border border-dashed border-gray-300 dark:border-gray-600 rounded-lg">
|
||
<span className="text-sm">Manage Children</span>
|
||
<span className="text-gray-400">→</span>
|
||
</Link>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Invite Members */}
|
||
<div className="bg-white dark:bg-gray-800 rounded-xl overflow-hidden">
|
||
<button
|
||
onClick={() => setInviteOpen(!inviteOpen)}
|
||
className="w-full flex items-center justify-between p-4"
|
||
>
|
||
<div className="flex items-center gap-3">
|
||
<span className="text-xl">✉️</span>
|
||
<div className="font-medium">Invite Members</div>
|
||
{tier === "free" && (
|
||
<span className="text-xs px-2 py-0.5 bg-rose-100 text-rose-600 rounded-full">{memberCount}/2</span>
|
||
)}
|
||
</div>
|
||
<span className={`text-gray-400 transition-transform ${inviteOpen ? "rotate-180" : ""}`}>▼</span>
|
||
</button>
|
||
|
||
{inviteOpen && (
|
||
<div className="px-4 pb-4">
|
||
{/* Member limit banner */}
|
||
{tier === "free" && !canInvite && (
|
||
<div className="mb-3">
|
||
<MemberLimitBanner currentCount={memberCount} limit={2} />
|
||
</div>
|
||
)}
|
||
|
||
{/* Pending invites */}
|
||
{invites.length > 0 && (
|
||
<div className="mb-3">
|
||
<div className="text-sm text-gray-500 mb-2">Pending Invites</div>
|
||
{invites.map((invite) => (
|
||
<div key={invite.id} className="flex justify-between items-center p-2 bg-gray-50 dark:bg-gray-700 rounded text-sm mb-1">
|
||
<div>
|
||
<div className="font-medium text-gray-800 dark:text-gray-100">{invite.email}</div>
|
||
<div className="text-xs text-gray-400">Pending · expires {new Date(invite.expiresAt).toLocaleDateString()}</div>
|
||
</div>
|
||
<button
|
||
onClick={() => deleteInvite(invite.id)}
|
||
className="text-xs text-red-400 hover:text-red-600 dark:hover:text-red-300 px-2 py-1 rounded hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
||
title="Cancel invite"
|
||
>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Add invite form */}
|
||
{canInvite && (
|
||
<div className="space-y-2">
|
||
<Input type="email" value={inviteEmail} onChange={(e) => setInviteEmail(e.target.value)} placeholder="Email address" />
|
||
<Select value={inviteRole} onChange={(e) => setInviteRole(e.target.value)}>
|
||
<option value="caregiver">Caregiver</option>
|
||
<option value="viewer">Viewer (read-only)</option>
|
||
</Select>
|
||
<Button fullWidth loading={inviteLoading} disabled={!inviteEmail} onClick={sendInvite}>
|
||
Send Invite
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Theme */}
|
||
<div className="bg-white dark:bg-gray-800 rounded-xl overflow-hidden">
|
||
<button
|
||
onClick={() => setThemeOpen(!themeOpen)}
|
||
className="w-full flex items-center justify-between p-4"
|
||
>
|
||
<div className="flex items-center gap-3">
|
||
<span className="text-xl">{theme === "dark" ? "🌙" : "☀️"}</span>
|
||
<div className="font-medium">Theme</div>
|
||
</div>
|
||
<span className={`text-gray-400 transition-transform ${themeOpen ? "rotate-180" : ""}`}>▼</span>
|
||
</button>
|
||
|
||
{themeOpen && (
|
||
<div className="px-4 pb-4">
|
||
<div className="grid grid-cols-2 gap-2">
|
||
{themeOptions.map((opt) => (
|
||
<Button
|
||
key={opt.value}
|
||
onClick={() => setMode(opt.value)}
|
||
variant={mode === opt.value ? "primary" : "secondary"}
|
||
>
|
||
{opt.label}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Pediatrician Phone */}
|
||
<div className="bg-white dark:bg-gray-800 rounded-xl p-4 space-y-2">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-xl">🏥</span>
|
||
<div className="font-medium dark:text-white">Pediatrician Phone</div>
|
||
</div>
|
||
<p className="text-xs text-gray-400 dark:text-gray-500">Shown on the emergency guide and in AI medical redirects.</p>
|
||
<div className="flex gap-2">
|
||
<Input type="tel" value={pedPhone} onChange={e => setPedPhone(e.target.value)} placeholder="+91 98765 43210" className="flex-1" />
|
||
<Button size="sm" loading={pedSaving} onClick={savePedPhone}>Save</Button>
|
||
</div>
|
||
<Link href="/medical/emergency" className="text-xs text-rose-500 dark:text-rose-400">
|
||
View Emergency Guide →
|
||
</Link>
|
||
</div>
|
||
|
||
{/* Export Data */}
|
||
<button
|
||
onClick={exportGrowthCSV}
|
||
disabled={exporting || !childId}
|
||
className="w-full flex items-center justify-between p-4 bg-white dark:bg-gray-800 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50"
|
||
>
|
||
<div className="flex items-center gap-3">
|
||
<span className="text-xl">📥</span>
|
||
<div>
|
||
<div className="font-medium text-left">Export Growth Data</div>
|
||
<div className="text-xs text-gray-400 text-left">Download {child?.name ? `${child.name}'s` : "growth"} records as CSV</div>
|
||
</div>
|
||
</div>
|
||
<span className="text-gray-400 text-sm">{exporting ? "…" : "→"}</span>
|
||
</button>
|
||
|
||
{/* App Version */}
|
||
<div className="p-4 bg-white dark:bg-gray-800 rounded-xl mt-4">
|
||
<div className="font-medium">App Version</div>
|
||
<div className="text-sm text-gray-500">Tia v1.0.0</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
} |