From b89074e6c6f372ab9c7b461d2b18b47c07579965 Mon Sep 17 00:00:00 2001 From: le king fu Date: Mon, 20 Jul 2026 22:24:23 -0400 Subject: [PATCH] feat(gating): multi-profile gate (Base+), non-destructive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Free user keeps full access to their active profile; profiles beyond it show a lock in ProfileSwitcher and open an upsell dialog instead of switching. Creating a profile beyond the first is locked at the single creation point, ProfileFormModal (reached from both ProfileSwitcher and ProfileSelectionPage), with a race guard in handleSave covering the license boot window. Both creation entries stay visible with a lock (locked-not-hidden). Nothing is ever removed from profiles.json — an upgrade to Base/Premium makes every profile reappear untouched. - New pure predicates in src/shared/profileGate.ts (isProfileSwitchLocked, isProfileCreationLocked) + 10 vitest - ProfileFormModal upsell panel reuses upsell.* keys WITHOUT UpsellGate: the modal also opens from ProfileSelectionPage, which renders outside BrowserRouter, where UpsellGate's useNavigate would throw - UpsellGate gains an optional onNavigate callback so the ProfileSwitcher upsell dialog can close itself after navigation - No lock while the license is loading (anti-flash, ready guard); zero new i18n keys; no DB migration Resolves #300 Generated autonomously by /autopilot run of 2026-07-20 Co-Authored-By: Claude Fable 5 --- src/components/profile/ProfileFormModal.tsx | 74 ++++++++++++++++-- src/components/profile/ProfileSwitcher.tsx | 87 ++++++++++++++++----- src/components/shared/UpsellGate.tsx | 14 +++- src/pages/ProfileSelectionPage.tsx | 22 +++++- src/shared/profileGate.test.ts | 59 ++++++++++++++ src/shared/profileGate.ts | 55 +++++++++++++ 6 files changed, 283 insertions(+), 28 deletions(-) create mode 100644 src/shared/profileGate.test.ts create mode 100644 src/shared/profileGate.ts diff --git a/src/components/profile/ProfileFormModal.tsx b/src/components/profile/ProfileFormModal.tsx index b1d6d09..4a1be7f 100644 --- a/src/components/profile/ProfileFormModal.tsx +++ b/src/components/profile/ProfileFormModal.tsx @@ -1,7 +1,10 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { X, Trash2, Lock, LockOpen, Plus } from "lucide-react"; +import { X, Trash2, Lock, LockOpen, Plus, ShoppingCart } from "lucide-react"; import { useProfile } from "../../contexts/ProfileContext"; +import { useEntitlement } from "../../hooks/useEntitlement"; +import { requiredTierFor } from "../../shared/entitlements"; +import { isProfileCreationLocked } from "../../shared/profileGate"; const PRESET_COLORS = [ "#4A90A4", "#22c55e", "#ef4444", "#f59e0b", "#8b5cf6", @@ -16,12 +19,20 @@ interface Props { export default function ProfileFormModal({ onClose, editProfileId }: Props) { const { t } = useTranslation(); const { profiles, createProfile, updateProfile, deleteProfile, setPin } = useProfile(); + // Multi-profile gate (Base+, #300) — this modal is the SINGLE creation + // point (`createProfile`), reached from ProfileSwitcher AND + // ProfileSelectionPage, so gating here covers both entries. Managing + // existing profiles (rename/PIN/delete) stays available: the gate is + // non-destructive and only blocks creating a profile beyond the first. + const gate = useEntitlement("multi-profile"); + const creationLocked = isProfileCreationLocked(profiles.length, gate); + const upsellTier = t(`license.editions.${requiredTierFor("multi-profile")}`); const editProfile = editProfileId ? profiles.find((p) => p.id === editProfileId) : null; - const [mode, setMode] = useState<"list" | "create" | "edit">( + const [mode, setMode] = useState<"list" | "create" | "edit" | "upsell">( editProfileId ? "edit" : "list" ); const [selectedId, setSelectedId] = useState(editProfileId ?? null); @@ -31,6 +42,10 @@ export default function ProfileFormModal({ onClose, editProfileId }: Props) { const [saving, setSaving] = useState(false); const handleCreate = () => { + if (creationLocked) { + setMode("upsell"); + return; + } setMode("create"); setName(""); setColor(PRESET_COLORS[Math.floor(Math.random() * PRESET_COLORS.length)]); @@ -50,6 +65,13 @@ export default function ProfileFormModal({ onClose, editProfileId }: Props) { const handleSave = async () => { if (!name.trim()) return; + // Race guard: the create form is reachable while the license is still + // loading (`!ready` shows no lock, anti-flash). If the gate became active + // in the meantime, never call createProfile — show the upsell instead. + if (mode === "create" && creationLocked) { + setMode("upsell"); + return; + } setSaving(true); try { if (mode === "create") { @@ -89,7 +111,7 @@ export default function ProfileFormModal({ onClose, editProfileId }: Props) {

- {mode === "create" + {mode === "create" || mode === "upsell" ? t("profile.create") : mode === "edit" ? t("profile.edit") @@ -142,12 +164,54 @@ export default function ProfileFormModal({ onClose, editProfileId }: Props) { ))}

+ ) : mode === "upsell" ? ( + /* Compact upsell panel — NOT the shared : this modal + also opens from ProfileSelectionPage, which renders OUTSIDE + BrowserRouter (App.tsx mounts the router only once a profile is + active), so UpsellGate's unconditional useNavigate would throw. + Same i18n keys; the "I already have a key" CTA is omitted here — + the ProfileSwitcher upsell dialog (always in-router) exposes it. */ +
+ +
+

+ {t("upsell.title", { tier: upsellTier })} +

+

+ {t("upsell.features.multi-profile")} +

+
+
+ +

+ {t("upsell.ctaGetSoon")} +

+
+ +
) : (
diff --git a/src/components/profile/ProfileSwitcher.tsx b/src/components/profile/ProfileSwitcher.tsx index d92f082..918780e 100644 --- a/src/components/profile/ProfileSwitcher.tsx +++ b/src/components/profile/ProfileSwitcher.tsx @@ -1,7 +1,11 @@ import { useState, useRef, useEffect } from "react"; import { useTranslation } from "react-i18next"; -import { ChevronDown, Lock, Settings } from "lucide-react"; +import { ChevronDown, Lock, Settings, X } from "lucide-react"; import { useProfile } from "../../contexts/ProfileContext"; +import { useEntitlement } from "../../hooks/useEntitlement"; +import { requiredTierFor } from "../../shared/entitlements"; +import { isProfileSwitchLocked } from "../../shared/profileGate"; +import UpsellGate from "../shared/UpsellGate"; import PinDialog from "./PinDialog"; import ProfileFormModal from "./ProfileFormModal"; import type { Profile } from "../../services/profileService"; @@ -9,9 +13,14 @@ import type { Profile } from "../../services/profileService"; export default function ProfileSwitcher() { const { t } = useTranslation(); const { profiles, activeProfile, switchProfile, updateProfile } = useProfile(); + // Multi-profile gate (Base+, #300): profiles beyond the active one are + // LOCKED for a Free user — non-destructive, switching is blocked but nothing + // is removed from profiles.json. No lock while `!ready` (anti-flash). + const gate = useEntitlement("multi-profile"); const [open, setOpen] = useState(false); const [pinProfile, setPinProfile] = useState(null); const [showManage, setShowManage] = useState(false); + const [showUpsell, setShowUpsell] = useState(false); const ref = useRef(null); // Close on outside click @@ -25,10 +34,18 @@ export default function ProfileSwitcher() { return () => document.removeEventListener("mousedown", handleClick); }, [open]); + const isLocked = (profile: Profile) => + isProfileSwitchLocked(profile.id, activeProfile?.id ?? null, gate); + const handleSelect = (profile: Profile) => { setOpen(false); if (profile.id === activeProfile?.id) return; + if (isLocked(profile)) { + setShowUpsell(true); + return; + } + if (profile.pin_hash) { setPinProfile(profile); } else { @@ -67,24 +84,34 @@ export default function ProfileSwitcher() { {open && (
- {profiles.map((profile) => ( - - ))} + {profiles.map((profile) => { + const locked = isLocked(profile); + return ( + + ); + })} +
+
+ setShowUpsell(false)} + /> +
+
+
+ )} ); } diff --git a/src/components/shared/UpsellGate.tsx b/src/components/shared/UpsellGate.tsx index 715a0c6..605430b 100644 --- a/src/components/shared/UpsellGate.tsx +++ b/src/components/shared/UpsellGate.tsx @@ -7,6 +7,13 @@ import type { FeatureKey } from "../../shared/entitlements"; interface UpsellGateProps { feature: FeatureKey; requiredTier: Edition; + /** + * Called right after a CTA navigates away. Lets modal hosts (e.g. the + * ProfileSwitcher upsell dialog) close themselves — the Sidebar stays + * mounted across navigations, so an uncontrolled modal would linger on top + * of the destination page. Optional: route-level usage ignores it. + */ + onNavigate?: () => void; } /** @@ -21,7 +28,7 @@ interface UpsellGateProps { * The tier label reuses the existing `license.editions.*` keys; the feature * description comes from `upsell.features.` (FR/EN). */ -export default function UpsellGate({ feature, requiredTier }: UpsellGateProps) { +export default function UpsellGate({ feature, requiredTier, onNavigate }: UpsellGateProps) { const { t } = useTranslation(); const navigate = useNavigate(); const tier = t(`license.editions.${requiredTier}`); @@ -57,7 +64,10 @@ export default function UpsellGate({ feature, requiredTier }: UpsellGateProps) {