feat(quota): enforce 1-baby limit on free plan
- quota.ts: checkChildLimit() mirrors checkMemberLimit() using families.max_children - POST /api/children: returns 403 child_limit_reached when free family is at limit - ChildLimitBanner: new banner component for the family page - /family page: shows banner + hides Add Baby button when at limit Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
90c8d13814
commit
0a9def36bf
4 changed files with 112 additions and 2 deletions
|
|
@ -3,6 +3,7 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useFamily } from "@/app/FamilyProvider";
|
||||
import { ChildLimitBanner } from "@/components/StorageMeter";
|
||||
|
||||
interface Child {
|
||||
id: string;
|
||||
|
|
@ -24,6 +25,7 @@ export default function FamilyPage() {
|
|||
const [newName, setNewName] = useState("");
|
||||
const [newDob, setNewDob] = useState("");
|
||||
const [newSex, setNewSex] = useState("male");
|
||||
const [childLimit, setChildLimit] = useState<{ currentCount: number; limit: number } | null>(null);
|
||||
|
||||
// Load children from FamilyProvider or fetch
|
||||
useEffect(() => {
|
||||
|
|
@ -35,6 +37,18 @@ export default function FamilyPage() {
|
|||
}
|
||||
}, [childrenFromProvider, familyId]);
|
||||
|
||||
// Sync limit state whenever children list changes
|
||||
useEffect(() => {
|
||||
if (!familyId) return;
|
||||
fetch("/api/family")
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
const maxChildren = d.family?.max_children ?? 1;
|
||||
setChildLimit({ currentCount: children.length, limit: maxChildren });
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [familyId, children.length]);
|
||||
|
||||
const fetchOwnChildren = async () => {
|
||||
if (!familyId) return;
|
||||
try {
|
||||
|
|
@ -85,12 +99,19 @@ export default function FamilyPage() {
|
|||
setShowAdd(false);
|
||||
setNewName("");
|
||||
setNewDob("");
|
||||
} else if (data.reason === "child_limit_reached") {
|
||||
setShowAdd(false);
|
||||
setChildLimit({ currentCount: data.currentCount, limit: data.limit });
|
||||
} else {
|
||||
alert(data.error);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to add:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const atChildLimit = childLimit !== null && childLimit.currentCount >= childLimit.limit;
|
||||
|
||||
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">
|
||||
|
|
@ -99,8 +120,13 @@ export default function FamilyPage() {
|
|||
</div>
|
||||
|
||||
<div className="px-4 space-y-4">
|
||||
{/* Child limit banner */}
|
||||
{atChildLimit && childLimit && (
|
||||
<ChildLimitBanner currentCount={childLimit.currentCount} limit={childLimit.limit} />
|
||||
)}
|
||||
|
||||
{/* Add Child Form */}
|
||||
{showAdd && (
|
||||
{showAdd && !atChildLimit && (
|
||||
<div className="p-4 bg-white dark:bg-gray-800 rounded-xl space-y-3">
|
||||
<input
|
||||
type="text"
|
||||
|
|
@ -200,7 +226,7 @@ export default function FamilyPage() {
|
|||
)}
|
||||
|
||||
{/* Add Button */}
|
||||
{!showAdd && children.length > 0 && (
|
||||
{!showAdd && children.length > 0 && !atChildLimit && (
|
||||
<button
|
||||
onClick={() => setShowAdd(true)}
|
||||
className="w-full p-4 border-2 border-dashed border-gray-300 rounded-xl text-gray-500"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { sql } from "@/db";
|
||||
import { validateSession, requireFamily } from "@/lib/auth";
|
||||
import { checkChildLimit } from "@/lib/quota";
|
||||
|
||||
// GET - list children (family only)
|
||||
export async function GET(request: Request) {
|
||||
|
|
@ -39,6 +40,14 @@ export async function POST(request: Request) {
|
|||
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
|
||||
}
|
||||
|
||||
const limitCheck = await checkChildLimit(familyId);
|
||||
if (!limitCheck.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: limitCheck.message, reason: limitCheck.reason, currentCount: limitCheck.currentCount, limit: limitCheck.limit },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const [child] = await sql.unsafe(
|
||||
`INSERT INTO children (family_id, name, birth_date, sex, stage) VALUES ($1, $2, $3, $4, 'newborn') RETURNING id, name, birth_date as "birthDate", sex, stage`,
|
||||
[familyId, name, birthDate, sex]
|
||||
|
|
|
|||
|
|
@ -128,6 +128,32 @@ export function StorageQuotaBanner({
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Banner for the child limit (shown when adding a second baby is blocked).
|
||||
*/
|
||||
export function ChildLimitBanner({
|
||||
currentCount,
|
||||
limit,
|
||||
}: {
|
||||
currentCount: number;
|
||||
limit: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 dark:bg-amber-900/20 dark:border-amber-800 p-4 space-y-1">
|
||||
<p className="text-sm font-semibold text-amber-700 dark:text-amber-300">Baby limit reached</p>
|
||||
<p className="text-sm text-amber-600 dark:text-amber-400">
|
||||
Your free plan supports {limit} baby profile. You already have {currentCount}. Upgrade to track multiple children.
|
||||
</p>
|
||||
<a
|
||||
href="/settings#upgrade"
|
||||
className="inline-block mt-2 text-sm font-medium text-rose-600 dark:text-rose-400 underline"
|
||||
>
|
||||
Upgrade to add more →
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Banner for the member limit (shown when invite is blocked).
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -208,6 +208,55 @@ export async function reconcileActualSize(
|
|||
return { keep: true };
|
||||
}
|
||||
|
||||
// ─── Child limit ─────────────────────────────────────────────────────────────
|
||||
|
||||
export type ChildLimitResult =
|
||||
| { allowed: true; currentCount: number; limit: number }
|
||||
| {
|
||||
allowed: false;
|
||||
reason: "child_limit_reached";
|
||||
currentCount: number;
|
||||
limit: number;
|
||||
message: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Child limit gate — called at child creation.
|
||||
* Free families are limited to max_children (default 1).
|
||||
* Paid families: always allowed.
|
||||
*/
|
||||
export async function checkChildLimit(familyId: string): Promise<ChildLimitResult> {
|
||||
const [row] = await sql<{ count: string; max_children: number | null; tier: string | null }[]>`
|
||||
SELECT
|
||||
COUNT(c.id)::text AS count,
|
||||
f.max_children,
|
||||
f.tier
|
||||
FROM families f
|
||||
LEFT JOIN children c ON c.family_id = f.id
|
||||
WHERE f.id = ${familyId}
|
||||
GROUP BY f.id
|
||||
`;
|
||||
|
||||
if (!row) return { allowed: false, reason: "child_limit_reached", currentCount: 0, limit: 1, message: "Family not found." };
|
||||
|
||||
const currentCount = Number(row.count);
|
||||
const limit = row.max_children ?? 1;
|
||||
|
||||
if (isPaidFamily(row.tier)) return { allowed: true, currentCount, limit };
|
||||
|
||||
if (currentCount >= limit) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "child_limit_reached",
|
||||
currentCount,
|
||||
limit,
|
||||
message: `Your free plan supports ${limit} baby profile. Upgrade to track multiple children.`,
|
||||
};
|
||||
}
|
||||
|
||||
return { allowed: true, currentCount, limit };
|
||||
}
|
||||
|
||||
/**
|
||||
* Member limit gate — called at invite creation (not acceptance).
|
||||
* COUNT(family_members) is the source of truth; no separate counter.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue