diff --git a/scripts/plan-limit.test.mjs b/scripts/plan-limit.test.mjs new file mode 100644 index 0000000..117e799 --- /dev/null +++ b/scripts/plan-limit.test.mjs @@ -0,0 +1,85 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { mkdir, rm } from "node:fs/promises"; +import { pathToFileURL } from "node:url"; +import { build } from "esbuild"; + +const outdir = "tmp/plan-limit-test"; + +async function loadModule() { + await rm(outdir, { recursive: true, force: true }); + await mkdir(outdir, { recursive: true }); + await build({ + entryPoints: ["src/lib/planLimit.ts"], + outfile: `${outdir}/planLimit.mjs`, + bundle: true, + platform: "node", + format: "esm", + target: "node20", + }); + return import(`${pathToFileURL(`${process.cwd()}/${outdir}/planLimit.mjs`).href}?t=${Date.now()}`); +} + +const mod = await loadModule(); + +// The exact string assertListQuota throws (convex/lists.ts). +const REAL = "PLAN_LIMIT: You've reached the free plan limit of 5 lists. Upgrade at /pricing to create unlimited lists."; + +test("recognises the quota error the server actually throws", () => { + assert.equal(mod.isPlanLimitError(new Error(REAL)), true); + // Convex wraps mutation errors, so the marker is rarely at position 0. + assert.equal( + mod.isPlanLimitError(new Error(`[CONVEX M(lists:createList)] Uncaught Error: ${REAL}`)), + true + ); +}); + +test("does not mistake other failures for the quota", () => { + assert.equal(mod.isPlanLimitError(new Error("List name cannot be empty")), false); + assert.equal(mod.isPlanLimitError(new Error("Network request failed")), false); + assert.equal(mod.isPlanLimitError("not an error"), false); + assert.equal(mod.isPlanLimitError(null), false); +}); + +test("tells the user what to do, never 'try again'", () => { + const msg = mod.listCreationErrorMessage(new Error(REAL)); + assert.match(msg, /limit of 5 lists/); + // "Please try again" is advice that can never work at the cap. + assert.doesNotMatch(msg, /try again/i); +}); + +test("unexpected failures still get the generic retry message", () => { + const msg = mod.listCreationErrorMessage(new Error("boom")); + assert.match(msg, /try again/i); + assert.equal(mod.listCreationErrorMessage(new Error("boom"), "Custom"), "Custom"); +}); + +// The bug was not the helper — it was that only ONE of five creation paths +// recognised the quota at all, so a capped user got "please try again" or, on +// the Templates page, no message whatsoever. +test("every list-creation path handles the quota", async () => { + const paths = [ + "src/components/CreateListModal.tsx", + "src/components/TemplatePickerModal.tsx", + "src/pages/Templates.tsx", + "src/components/OnboardingFlow.tsx", + ]; + for (const p of paths) { + const src = await readFile(p, "utf8"); + assert.ok( + /isPlanLimitError|listCreationErrorMessage/.test(src), + `${p} creates lists but does not recognise the plan limit` + ); + // A generic fallback is fine for real failures; what must not happen is the + // quota reaching the user as one. CreateListModal branches on the quota + // first, so its fallback is only for genuinely unexpected errors. + const genericInCatch = /catch \([^)]*\) \{[^}]*Please try again[^}]*\}/s.test(src); + if (genericInCatch) { + assert.ok( + /isPlanLimitError\(/.test(src), + `${p} shows a retry message without first checking for the quota` + ); + } + } +}); diff --git a/src/components/CreateListModal.tsx b/src/components/CreateListModal.tsx index 78ab4a8..e167d0a 100644 --- a/src/components/CreateListModal.tsx +++ b/src/components/CreateListModal.tsx @@ -12,6 +12,7 @@ import type { Id } from "../../convex/_generated/dataModel"; import { useCurrentUser } from "../hooks/useCurrentUser"; import { useSettings } from "../hooks/useSettings"; import { createListAsset } from "../lib/originals"; +import { isPlanLimitError } from "../lib/planLimit"; import { CategorySelector } from "./lists/CategorySelector"; import { Panel } from "./ui/Panel"; import { trackListCreated, trackFirstListCreated, trackFeatureGateHit, trackInviteSent } from "../lib/analytics"; @@ -77,8 +78,7 @@ export function CreateListModal({ onClose, onListCreated }: CreateListModalProps onListCreated?.(listId, trimmedName); } catch (err) { console.error("Failed to create list:", err); - const msg = err instanceof Error ? err.message : ""; - if (msg.includes("PLAN_LIMIT")) { + if (isPlanLimitError(err)) { setPlanLimitHit(true); trackFeatureGateHit("list_limit", "free"); } else { diff --git a/src/components/OnboardingFlow.tsx b/src/components/OnboardingFlow.tsx index 2893d92..f02b03c 100644 --- a/src/components/OnboardingFlow.tsx +++ b/src/components/OnboardingFlow.tsx @@ -13,6 +13,7 @@ import type { Id } from "../../convex/_generated/dataModel"; import { useCurrentUser } from "../hooks/useCurrentUser"; import { useSettings } from "../hooks/useSettings"; import { createListAsset } from "../lib/originals"; +import { listCreationErrorMessage } from "../lib/planLimit"; import { buildListResourceDid, buildListResourceUrl } from "../lib/webvh"; import { trackInviteSent } from "../lib/analytics"; @@ -107,7 +108,7 @@ export function OnboardingFlow({ onComplete }: OnboardingFlowProps) { haptic("success"); setStep("add-items"); } catch (err) { - setCreateError("Couldn't create list. Try again."); + setCreateError(listCreationErrorMessage(err, "Couldn't create list. Try again.")); haptic("error"); } finally { setIsCreating(false); diff --git a/src/components/TemplatePickerModal.tsx b/src/components/TemplatePickerModal.tsx index cce02a8..efca049 100644 --- a/src/components/TemplatePickerModal.tsx +++ b/src/components/TemplatePickerModal.tsx @@ -12,6 +12,7 @@ import type { Id } from "../../convex/_generated/dataModel"; import { useCurrentUser } from "../hooks/useCurrentUser"; import { useSettings } from "../hooks/useSettings"; import { createListAsset } from "../lib/originals"; +import { listCreationErrorMessage } from "../lib/planLimit"; import { BUILTIN_TEMPLATES, type BuiltinTemplate } from "../lib/builtinTemplates"; import { Panel } from "./ui/Panel"; @@ -83,7 +84,7 @@ export function TemplatePickerModal({ onClose, onCreateBlank }: TemplatePickerMo navigate(`/list/${listId}`); } catch (err) { console.error("Failed to create list from template:", err); - setError("Failed to create list. Please try again."); + setError(listCreationErrorMessage(err)); haptic('error'); setIsCreating(false); } @@ -110,7 +111,7 @@ export function TemplatePickerModal({ onClose, onCreateBlank }: TemplatePickerMo navigate(`/list/${listId}`); } catch (err) { console.error("Failed to create list from saved template:", err); - setError("Failed to create list. Please try again."); + setError(listCreationErrorMessage(err)); haptic('error'); setIsCreating(false); } diff --git a/src/lib/planLimit.ts b/src/lib/planLimit.ts new file mode 100644 index 0000000..2233979 --- /dev/null +++ b/src/lib/planLimit.ts @@ -0,0 +1,30 @@ +/** + * The free-plan list cap, as it reaches the UI. + * + * assertListQuota (convex/lists.ts) throws a message prefixed PLAN_LIMIT when a + * free-plan user is at their list cap. Every path that creates a list has to + * recognise it: it is a gate the user can act on, not a failure, and "please try + * again" is advice that can never work. + */ + +const PLAN_LIMIT_PREFIX = "PLAN_LIMIT"; + +/** The message shown when someone is out of lists. */ +export const PLAN_LIMIT_MESSAGE = + "You've reached the free plan limit of 5 lists. Invite a friend or upgrade for unlimited lists."; + +export function isPlanLimitError(err: unknown): boolean { + // Convex wraps mutation errors, so the marker can sit anywhere in the string. + return err instanceof Error && err.message.includes(PLAN_LIMIT_PREFIX); +} + +/** + * What to show the user for a failed list creation: the actionable cap message, + * or a generic fallback for anything genuinely unexpected. + */ +export function listCreationErrorMessage( + err: unknown, + fallback = "Failed to create list. Please try again." +): string { + return isPlanLimitError(err) ? PLAN_LIMIT_MESSAGE : fallback; +} diff --git a/src/pages/Templates.tsx b/src/pages/Templates.tsx index 24eeb31..633fae8 100644 --- a/src/pages/Templates.tsx +++ b/src/pages/Templates.tsx @@ -11,6 +11,7 @@ import type { Id, Doc } from "../../convex/_generated/dataModel"; import { useCurrentUser } from "../hooks/useCurrentUser"; import { useSettings } from "../hooks/useSettings"; import { createListAsset } from "../lib/originals"; +import { listCreationErrorMessage } from "../lib/planLimit"; import { BUILTIN_TEMPLATES, type BuiltinTemplate } from "../lib/builtinTemplates"; type Template = Doc<"listTemplates">; @@ -20,6 +21,7 @@ export function Templates() { const { haptic } = useSettings(); const navigate = useNavigate(); const [isCreating, setIsCreating] = useState(null); + const [error, setError] = useState(null); // Fetch templates from API const userTemplates = useQuery( @@ -67,6 +69,7 @@ export function Templates() { navigate(`/list/${listId}`); } catch (err) { console.error("Failed to create from template:", err); + setError(listCreationErrorMessage(err)); haptic('error'); setIsCreating(null); } @@ -76,6 +79,7 @@ export function Templates() { if (!did) return; setIsCreating(template._id); + setError(null); haptic('medium'); try { @@ -92,6 +96,7 @@ export function Templates() { navigate(`/list/${listId}`); } catch (err) { console.error("Failed to create from saved template:", err); + setError(listCreationErrorMessage(err)); haptic('error'); setIsCreating(null); } @@ -137,6 +142,18 @@ export function Templates() { + {error && ( +
+
{error}
+ + View pricing → + +
+ )} + {/* Built-in Templates */}