Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions scripts/plan-limit.test.mjs
Original file line number Diff line number Diff line change
@@ -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`
);
}
}
});
4 changes: 2 additions & 2 deletions src/components/CreateListModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion src/components/OnboardingFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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);
Expand Down
5 changes: 3 additions & 2 deletions src/components/TemplatePickerModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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);
}
Expand All @@ -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);
}
Expand Down
30 changes: 30 additions & 0 deletions src/lib/planLimit.ts
Original file line number Diff line number Diff line change
@@ -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;
}
17 changes: 17 additions & 0 deletions src/pages/Templates.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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">;
Expand All @@ -20,6 +21,7 @@ export function Templates() {
const { haptic } = useSettings();
const navigate = useNavigate();
const [isCreating, setIsCreating] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);

// Fetch templates from API
const userTemplates = useQuery(
Expand Down Expand Up @@ -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);
}
Expand All @@ -76,6 +79,7 @@ export function Templates() {
if (!did) return;

setIsCreating(template._id);
setError(null);
haptic('medium');

try {
Expand All @@ -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);
}
Expand Down Expand Up @@ -137,6 +142,18 @@ export function Templates() {
</h1>
</div>

{error && (
<div className="mb-6 px-4 py-4 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-xl text-amber-800 dark:text-amber-300 text-sm space-y-3">
<div>{error}</div>
<Link
to="/pricing"
className="inline-flex items-center gap-1.5 px-4 py-2 bg-amber-500 hover:bg-amber-400 text-white rounded-lg font-semibold text-sm transition-colors"
>
View pricing →
</Link>
</div>
)}

{/* Built-in Templates */}
<section className="mb-8">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
Expand Down
Loading