From b640328e45e8459f2a38aedc20a82ba93a3612b8 Mon Sep 17 00:00:00 2001 From: Aspirant200715 Date: Sun, 1 Nov 2026 17:37:49 +0530 Subject: [PATCH 1/4] feat(frontend): pricing plans: Free, Premium (coming soon) and Pay as you go - shared plan data and cards for the choose-your-plan dialog and a new /pricing page - comparison table across funding, limits, verification, settlement and service - Free and Pay as you go keep their existing wiring (startTrial, wallet connect) - Premium is shown but disabled, with no price, until credit packs exist --- frontend/e2e/pricing.spec.ts | 42 +++++ frontend/node_modules | 1 + frontend/src/app/paths.ts | 1 + frontend/src/app/router.tsx | 2 + frontend/src/auth/FundingChoice.tsx | 113 +++++-------- frontend/src/components/Icon.tsx | 1 + frontend/src/components/PlanCards.tsx | 56 +++++++ frontend/src/pricing/plans.test.ts | 34 ++++ frontend/src/pricing/plans.ts | 138 ++++++++++++++++ frontend/src/screens/Pricing.tsx | 163 ++++++++++++++++++ frontend/src/styles/auth.css | 165 +++++++++++++++--- frontend/src/styles/screens/pricing.css | 211 ++++++++++++++++++++++++ 12 files changed, 839 insertions(+), 88 deletions(-) create mode 100644 frontend/e2e/pricing.spec.ts create mode 120000 frontend/node_modules create mode 100644 frontend/src/components/PlanCards.tsx create mode 100644 frontend/src/pricing/plans.test.ts create mode 100644 frontend/src/pricing/plans.ts create mode 100644 frontend/src/screens/Pricing.tsx create mode 100644 frontend/src/styles/screens/pricing.css diff --git a/frontend/e2e/pricing.spec.ts b/frontend/e2e/pricing.spec.ts new file mode 100644 index 0000000..39e0ede --- /dev/null +++ b/frontend/e2e/pricing.spec.ts @@ -0,0 +1,42 @@ +import { expect, test as signedIn } from "./fixtures"; +import { test as signedOut } from "@playwright/test"; + +// Mock project: /pricing is served at the site root, never under /demo, so the dashboard is /app. +signedOut.describe("pricing, signed out", () => { + signedOut("shows the three plans, with Premium visible but not available", async ({ page }) => { + await page.goto("/pricing"); + await expect(page.getByRole("heading", { level: 1 })).toHaveText("Pricing"); + for (const name of ["Free", "Premium", "Pay as you go"]) + await expect(page.getByRole("heading", { level: 3, name })).toBeVisible(); + await expect(page.getByText("5 USDC", { exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Coming soon" })).toBeDisabled(); + await expect(page.getByRole("link", { name: "Start free" })).toHaveAttribute("href", "/signin"); + }); + + signedOut("compares the plans in a table with a column each", async ({ page }) => { + await page.goto("/pricing"); + const table = page.getByRole("table", { name: "What each plan includes" }); + await expect(table.locator("thead").getByRole("columnheader")).toHaveText([ + "Feature", + "Free", + "Premium", + "Pay as you go", + ]); + await expect(table.getByRole("row", { name: /Wallet needed/ })).toBeVisible(); + await expect(table.getByRole("row", { name: /Plans per day/ })).toContainText("3"); + }); + + signedOut("has no sample prices: Premium shows none, and nothing is priced in dollars", async ({ page }) => { + await page.goto("/pricing"); + const text = await page.locator("main").innerText(); + expect(text).not.toMatch(/\$\s?\d/); + await expect(page.locator(".plan-premium .plan-amount")).toHaveText("Coming soon"); + }); +}); + +signedIn.describe("pricing, signed in", () => { + signedIn("Free opens the dashboard, where the plan choice starts the trial", async ({ page }) => { + await page.goto("/pricing"); + await expect(page.getByRole("link", { name: "Open dashboard" }).first()).toHaveAttribute("href", "/app"); + }); +}); diff --git a/frontend/node_modules b/frontend/node_modules new file mode 120000 index 0000000..35d7722 --- /dev/null +++ b/frontend/node_modules @@ -0,0 +1 @@ +/home/aspirant/Projects/mergit/frontend/node_modules \ No newline at end of file diff --git a/frontend/src/app/paths.ts b/frontend/src/app/paths.ts index 2e5af57..c2ef4f1 100644 --- a/frontend/src/app/paths.ts +++ b/frontend/src/app/paths.ts @@ -7,6 +7,7 @@ const APP = appBase(IN_DEMO); export const paths = { landing: "/", signIn: "/signin", + pricing: "/pricing", app: APP, /** The demo and the real app are different page loads, so these two never depend on where this page is. */ demo: "/demo", diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index 7f4360a..16da314 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -6,6 +6,7 @@ import { runDetail } from "../mock/data"; import { Agents } from "../screens/agents/Agents"; import { Integrations } from "../screens/Integrations"; import { Leaderboard } from "../screens/Leaderboard"; +import { Pricing } from "../screens/Pricing"; import { NewRun } from "../screens/NewRun"; import { NotFound } from "../screens/NotFound"; import { Overview } from "../screens/Overview"; @@ -42,6 +43,7 @@ export const router = createBrowserRouter([ children: [ { path: paths.landing, element: IN_DEMO ? : }, { path: paths.signIn, element: IN_DEMO ? : }, + { path: paths.pricing, element: IN_DEMO ? : }, { path: IN_DEMO ? `${paths.liveApp}/*` : `${paths.demo}/*`, element: }, { path: paths.app, diff --git a/frontend/src/auth/FundingChoice.tsx b/frontend/src/auth/FundingChoice.tsx index 083032b..734200d 100644 --- a/frontend/src/auth/FundingChoice.tsx +++ b/frontend/src/auth/FundingChoice.tsx @@ -1,4 +1,7 @@ import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { paths } from "../app/paths"; +import { PlanCards } from "../components/PlanCards"; import { Icon } from "../components/Icon"; import { MergitSymbol } from "../components/MergitLogo"; import { startTrial } from "./accountApi"; @@ -61,78 +64,46 @@ export function FundingChoice() {

{account?.name ? `Welcome, ${account.name}. ` : "Welcome. "} - Start free with test credits, or connect a wallet and pay as you go. You can switch later. + Start free with 5 USDC of test credits, or connect a wallet and pay as you go. Premium credit packs are + coming. You can switch later.

{error && (

{error}

)} -
-
- No wallet needed -

- Free -

-

- $0 - test credits -

-
    -
  • - - Runs on free test credits -
  • -
  • - - Every task is audited -
  • -
  • - - No reputation is earned -
  • -
- -
-
- Wallet -

- Pay as you go -

-

- Per task - your own test USDC -

-
    -
  • - - Each plan is funded from your wallet -
  • -
  • - - Proofs settle to your address -
  • -
  • - - You sign one message; it costs nothing -
  • -
- - {walletOpen && - (wallet.offer ? ( + { + if (plan.soon) + return ( + + ); + if (plan.id === "free") + return ( + + ); + return ( + + ); + }} + below={(plan) => + plan.id === "payg" && walletOpen ? ( + wallet.offer ? ( ) : ( - ))} -
-
+ ) + ) : null + } + /> + + Compare plans in detail + + ); + if (plan.id === "free") + return ( + + {session ? "Open dashboard" : "Start free"} + + ); + return ( + + ); + }} + /> + +

+ Compare plans +

+
+ + + + + + {PLANS.map((plan) => ( + + ))} + + + {COMPARISON.map((group) => ( + + + + + {group.rows.map((row) => ( + + + {row.cells.map((cell, i) => ( + + ))} + + ))} + + ))} +
What each plan includes
+ Feature + + {plan.name} +
+ {group.title} +
+ {row.label} + {row.hint && {row.hint}} + + +
+
+ +
+

+ Start free. Pay when you are ready. +

+

5 USDC of test credits, no wallet, and every task leaves a receipt you can check.

+
+ + {session ? "Open dashboard" : "Get 5 USDC free"} + + + Compare plans + +
+
+ +

+ Mergit runs on the Base Sepolia testnet. Test funds only: nothing here is real money. The free-trial limits + are the current settings and can change. Premium credit packs are not available yet. +

+ + + ); +} diff --git a/frontend/src/styles/auth.css b/frontend/src/styles/auth.css index 851cffa..fdc109a 100644 --- a/frontend/src/styles/auth.css +++ b/frontend/src/styles/auth.css @@ -223,27 +223,86 @@ button.avatarme { color: var(--ink3); } -/* Plan choice: Free and Pay as you go side by side, one column on a phone. */ +/* Plan cards, shared by the plan-choice dialog and the pricing page: three side by side, one column on a phone. */ .connect-dialog.plan-dialog { - width: min(640px, 100vw - 32px); + width: min(940px, 100vw - 32px); + max-height: calc(100dvh - 32px); + overflow-y: auto; } .plan-grid { display: grid; - grid-template-columns: 1fr 1fr; - gap: 12px; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; } .plan-card { + position: relative; + transition: + transform 0.28s cubic-bezier(0.2, 0.7, 0.2, 1), + box-shadow 0.28s ease, + border-color 0.28s ease; display: flex; flex-direction: column; gap: 10px; - padding: 18px; + padding: 22px; border: 1px solid var(--border); - border-radius: 16px; + border-radius: 24px; background: var(--surface); + box-shadow: var(--float-shadow); } -.plan-card:first-child { +.plan-card.plan-free { border-color: var(--border-strong); +} +.plan-card.is-soon { background: var(--soft); + box-shadow: none; +} +.plan-card:hover { + transform: translateY(-6px); + border-color: var(--border-strong); + box-shadow: 0 24px 48px -24px rgb(14 16 24 / 0.28); +} +.plan-card.is-soon:hover { + transform: translateY(-3px); + box-shadow: 0 14px 32px -22px rgb(14 16 24 / 0.2); +} +/* The one plan set apart: a gradient edge and a glow, not a bigger box. */ +.plan-card.is-featured { + border: 1.5px solid transparent; + background: + linear-gradient(var(--surface), var(--surface)) padding-box, + linear-gradient(135deg, var(--mint), var(--azure) 55%, #a794ff) border-box; + box-shadow: 0 28px 64px -28px color-mix(in srgb, var(--azure) 45%, transparent); +} +.plan-card.is-featured:hover { + box-shadow: 0 34px 72px -26px color-mix(in srgb, var(--azure) 60%, transparent); +} +.plan-ribbon { + margin-left: auto; + padding: 3px 10px; + border-radius: 999px; + background: linear-gradient(135deg, var(--mint), var(--azure)); + color: #fff; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; +} +.plan-soon { + background-image: linear-gradient(100deg, transparent 30%, rgb(255 255 255 / 0.55) 50%, transparent 70%); + background-size: 220% 100%; + background-repeat: no-repeat; + animation: plan-shimmer 3.2s ease-in-out infinite; +} +@keyframes plan-shimmer { + from { + background-position: 160% 0; + } + to { + background-position: -60% 0; + } +} +.plan-top { + align-items: center; + min-height: 26px; } .plan-tag { align-self: flex-start; @@ -254,30 +313,46 @@ button.avatarme { font-size: 11.5px; font-weight: 500; } -.plan-name { - font-size: 17px; - line-height: 22px; +.plan-soon { + padding: 2px 8px; + border-radius: 999px; + background: var(--amber-wash); + color: var(--amber); + font-size: 11px; font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; +} +.plan-name { + font-family: "Instrument Serif", Georgia, "Times New Roman", serif; + font-size: 30px; + line-height: 34px; + font-weight: 400; letter-spacing: -0.01em; } +.plan-blurb { + min-height: 40px; +} .plan-price { display: flex; flex-direction: column; gap: 2px; + padding: 6px 0 4px; } .plan-amount { - font-size: 22px; - line-height: 28px; - font-weight: 600; + font-family: "Instrument Serif", Georgia, "Times New Roman", serif; + font-size: 40px; + line-height: 44px; + font-weight: 400; letter-spacing: -0.02em; } .plan-list { list-style: none; display: flex; flex-direction: column; - gap: 8px; + gap: 9px; flex: 1; - margin: 4px 0 8px; + margin: 6px 0 10px; } .plan-list li { display: flex; @@ -288,21 +363,73 @@ button.avatarme { flex: none; margin-top: 3px; color: var(--ink3); + transition: + color 0.25s ease, + transform 0.25s ease; +} +.plan-card:hover .plan-list svg { + color: var(--mint); + transform: translateX(2px); +} +.plan-card .btn { + position: relative; + overflow: hidden; +} +.plan-card .btn.primary::after, +.pr-cta .btn.primary::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(105deg, transparent 35%, rgb(255 255 255 / 0.35) 50%, transparent 65%); + transform: translateX(-120%); + transition: transform 0.6s ease; +} +.plan-card .btn.primary:hover::after, +.pr-cta .btn.primary:hover::after { + transform: translateX(120%); +} +.plan-card .btn:active { + transform: scale(0.985); +} +.plan-card .btn:focus-visible, +.pr-cta .btn:focus-visible { + outline: 2px solid var(--azure); + outline-offset: 2px; +} +.plan-includes { + color: var(--ink); + font-weight: 500; } .plan-skip { align-self: center; } -@media (max-width: 560px) { +.plan-compare { + align-self: center; +} +@media (prefers-reduced-motion: reduce) { + .plan-card, + .plan-card:hover, + .plan-list svg, + .plan-card:hover .plan-list svg { + transition: none; + transform: none; + } + .plan-soon { + animation: none; + } +} +@media (max-width: 860px) { .plan-grid { grid-template-columns: 1fr; } .connect-dialog.plan-dialog { - max-height: calc(100dvh - 32px); - overflow-y: auto; padding: 20px; } .plan-card { - padding: 14px; + padding: 18px; + } + .plan-blurb { + min-height: 0; } } diff --git a/frontend/src/styles/screens/pricing.css b/frontend/src/styles/screens/pricing.css new file mode 100644 index 0000000..10a6764 --- /dev/null +++ b/frontend/src/styles/screens/pricing.css @@ -0,0 +1,211 @@ +.pr { + position: relative; + min-height: 100vh; + overflow-x: clip; +} +/* One large soft glow behind the cards, so the page is not a flat wash. */ +.pr::before { + content: ""; + position: absolute; + inset: 120px 0 auto; + height: 620px; + background: + radial-gradient(ellipse 46% 60% at 28% 40%, color-mix(in srgb, var(--mint) 22%, transparent), transparent 70%), + radial-gradient(ellipse 46% 60% at 74% 45%, color-mix(in srgb, var(--azure) 20%, transparent), transparent 70%); + filter: blur(40px); + pointer-events: none; +} +.pr-bar, +.pr-main { + position: relative; +} +.pr-bar { + display: flex; + align-items: center; + justify-content: space-between; + max-width: 1120px; + margin: 0 auto; + padding: 24px 24px 0; +} +.pr-main { + max-width: 1120px; + margin: 0 auto; + padding: 56px 24px 80px; + display: flex; + flex-direction: column; + gap: 40px; +} +.pr-head { + max-width: 680px; +} +.pr-assure { + list-style: none; + display: flex; + flex-wrap: wrap; + gap: 8px 10px; + margin-top: 6px; +} +.pr-assure li { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 12px; + border-radius: 999px; + border: 1px solid var(--border); + background: color-mix(in srgb, var(--surface) 80%, transparent); + backdrop-filter: blur(6px); + color: var(--ink2); +} +.pr-assure svg { + color: var(--mint); +} +.pr .plan-card { + animation: pr-rise 0.7s cubic-bezier(0.2, 0.7, 0.2, 1) both; + animation-delay: calc(var(--i) * 110ms + 120ms); +} +@keyframes pr-rise { + from { + opacity: 0; + transform: translateY(22px); + } +} +.pr-title { + font-family: "Instrument Serif", Georgia, "Times New Roman", serif; + font-size: clamp(48px, 7vw, 76px); + line-height: 1; + font-weight: 400; + letter-spacing: -0.02em; +} +.pr-lede { + font-size: 17px; + line-height: 26px; +} +.pr-sub { + font-family: "Instrument Serif", Georgia, "Times New Roman", serif; + font-size: 34px; + line-height: 40px; + font-weight: 400; + margin-bottom: -16px; +} +.pr-table-wrap { + overflow-x: auto; + border: 1px solid var(--border); + border-radius: 24px; + background: var(--surface); +} +.pr-table { + width: 100%; + min-width: 720px; + border-collapse: collapse; + font-size: 14px; +} +.pr-table thead th { + text-align: left; + font-weight: 600; + padding: 18px 20px; + border-bottom: 1px solid var(--border); +} +.pr-table td, +.pr-table tbody th[scope="row"] { + padding: 14px 20px; + border-bottom: 1px solid var(--border); + text-align: left; + vertical-align: top; + font-weight: 400; +} +.pr-table tbody th[scope="row"] { + width: 36%; +} +.pr-table tbody:last-of-type tr:last-child td, +.pr-table tbody:last-of-type tr:last-child th { + border-bottom: 0; +} +.pr-group th { + padding: 18px 20px 8px; + font-size: 12px; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--ink3); + text-align: left; + border-bottom: 0; +} +.pr-hint { + display: block; + margin-top: 2px; + font-size: 12px; + color: var(--ink3); +} +.pr-yes { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 50%; + background: var(--mint-wash); + color: var(--mint); +} +.pr-table tbody tr:not(.pr-group) th, +.pr-table tbody tr:not(.pr-group) td { + transition: background-color 0.18s ease; +} +.pr-table tbody tr:not(.pr-group):hover th, +.pr-table tbody tr:not(.pr-group):hover td { + background: var(--soft); +} +/* The set-apart plan's column carries a faint tint, so it reads down the whole table. */ +.pr-table thead th:nth-child(4), +.pr-table tbody tr:not(.pr-group) td:nth-child(4) { + background: color-mix(in srgb, var(--azure) 5%, transparent); +} +.pr-group th::before { + content: ""; + display: inline-block; + width: 14px; + height: 2px; + margin-right: 8px; + vertical-align: middle; + border-radius: 2px; + background: var(--mint); +} +.pr-cta { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 44px 24px; + text-align: center; + border: 1px solid var(--border); + border-radius: 28px; + background: + radial-gradient(ellipse 60% 120% at 20% 0%, color-mix(in srgb, var(--mint) 16%, transparent), transparent 70%), + radial-gradient(ellipse 60% 120% at 80% 100%, color-mix(in srgb, var(--azure) 14%, transparent), transparent 70%), + var(--surface); +} +.pr-cta-title { + font-family: "Instrument Serif", Georgia, "Times New Roman", serif; + font-size: clamp(30px, 4vw, 42px); + line-height: 1.1; + font-weight: 400; + letter-spacing: -0.01em; +} +.pr-cta-actions { + margin-top: 8px; + flex-wrap: wrap; + justify-content: center; +} +.pr-cta .btn { + position: relative; + overflow: hidden; +} +@media (prefers-reduced-motion: reduce) { + .pr .plan-card { + animation: none; + } +} +.pr-no { + color: var(--ink3); +} +.pr-foot { + max-width: 640px; +} From 8fb5bc256019caa5dd1f428a2f2b4382697fe44f Mon Sep 17 00:00:00 2001 From: OfficialAbhinavSingh Date: Mon, 2 Nov 2026 19:34:41 +0530 Subject: [PATCH 2/4] frontend: drop an accidentally committed node_modules path file It held a local path (/home/aspirant/Projects/mergit/frontend/node_modules) instead of a real node_modules tree. --- frontend/node_modules | 1 - 1 file changed, 1 deletion(-) delete mode 120000 frontend/node_modules diff --git a/frontend/node_modules b/frontend/node_modules deleted file mode 120000 index 35d7722..0000000 --- a/frontend/node_modules +++ /dev/null @@ -1 +0,0 @@ -/home/aspirant/Projects/mergit/frontend/node_modules \ No newline at end of file From eb4503c3d432b1551f02ecc5212e6ebf4d426053 Mon Sep 17 00:00:00 2001 From: Aspirant200715 Date: Tue, 3 Nov 2026 11:09:38 +0530 Subject: [PATCH 3/4] fix: pricing e2e matches the Plans heading exactly; wrap a long line in test_caddy_hardening - 'Plans' also matched 'Compare plans' (strict-mode violation), failing test:e2e - ruff E501 on test_caddy_hardening.py:113 failed the control-plane check --- control-plane/tests/test_caddy_hardening.py | 5 ++--- frontend/e2e/pricing.spec.ts | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/control-plane/tests/test_caddy_hardening.py b/control-plane/tests/test_caddy_hardening.py index 55cb8f6..10767a2 100644 --- a/control-plane/tests/test_caddy_hardening.py +++ b/control-plane/tests/test_caddy_hardening.py @@ -109,9 +109,8 @@ def test_the_apps_own_routes_still_reach_the_shell() -> None: def test_the_router_has_no_top_level_route_the_site_would_404() -> None: paths = (ROOT / "frontend" / "src" / "app" / "paths.ts").read_text() - top_level = { - m.group(1) for m in re.finditer(r'(?m)^\s+(?:landing|signIn|pricing|demo|liveApp):\s*"(/[^"]*)"', paths) - } + route = r'(?m)^\s+(?:landing|signIn|pricing|demo|liveApp):\s*"(/[^"]*)"' + top_level = {m.group(1) for m in re.finditer(route, paths)} match = re.search(r"(?m)^\t@spa path ([^\n]+)$", _snippet()) assert match and top_level patterns = match.group(1).split() diff --git a/frontend/e2e/pricing.spec.ts b/frontend/e2e/pricing.spec.ts index f123b55..26ddd3f 100644 --- a/frontend/e2e/pricing.spec.ts +++ b/frontend/e2e/pricing.spec.ts @@ -8,7 +8,7 @@ signedOut.describe("pricing, signed out", () => { await page.goto("/pricing"); await expect(page.getByRole("heading", { level: 1 })).toHaveText("Pricing"); // Headings go h1, then an h2 for each section, then the cards' h3. - await expect(page.getByRole("heading", { level: 2, name: "Plans" })).toBeAttached(); + await expect(page.getByRole("heading", { level: 2, name: "Plans", exact: true })).toBeAttached(); for (const name of ["Free", "Premium", "Pay as you go"]) await expect(page.getByRole("heading", { level: 3, name })).toBeVisible(); await expect(page.getByText("5 USDC", { exact: true })).toBeVisible(); From f6355e642874e52d2be481f67f7c2754842125cd Mon Sep 17 00:00:00 2001 From: OfficialAbhinavSingh Date: Tue, 3 Nov 2026 12:00:19 +0530 Subject: [PATCH 4/4] frontend: PLAN_CAP_USDC tracks the free grant again, not a hardcoded 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A rework of this page reintroduced the stale per-plan cap this PR had already fixed: PLAN_CAP_USDC went back to a literal 2, with a comment claiming CREDIT_PLAN_CAP_USDC_UNITS defaults to 2000000. It does not — chain/gateway/src/main.rs still defaults it to FREE_CREDITS_USDC_UNITS itself (the grant), with its own comment naming the 2-under-5 mismatch as a past bug. No deploy file sets it to 2000000 either. PLAN_CAP_USDC is now derived from FREE_CREDITS_USDC directly, so the two cannot drift apart silently again, and a regression test (removed in the same rework) is back, extended to also check the derivation. Full suite green: 292 vitest, 233 Playwright passed (the one Playwright failure, the flows story, is the pre-existing flaky-under-load test; it passes alone). --- frontend/src/pricing/plans.test.ts | 18 +++++++++++++++++- frontend/src/pricing/plans.ts | 11 +++++++---- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/frontend/src/pricing/plans.test.ts b/frontend/src/pricing/plans.test.ts index e3e3d1c..8f39965 100644 --- a/frontend/src/pricing/plans.test.ts +++ b/frontend/src/pricing/plans.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from "vitest"; -import { COMPARISON, FREE_CREDITS_LABEL, PLAN_CAP_LABEL, PLANS, PLANS_PER_DAY } from "./plans"; +import { + COMPARISON, + FREE_CREDITS_LABEL, + FREE_CREDITS_USDC, + PLAN_CAP_LABEL, + PLAN_CAP_USDC, + PLANS, + PLANS_PER_DAY, +} from "./plans"; describe("pricing data", () => { it("has Free, Premium and Pay as you go, and only Premium is unbuilt", () => { @@ -12,6 +20,14 @@ describe("pricing data", () => { expect(PLANS[1]?.amount).not.toMatch(/\d/); }); + it("caps a plan at the free grant, not a smaller number", () => { + // chain/gateway/src/main.rs: CREDIT_PLAN_CAP_USDC_UNITS has no separate default, it defaults to + // FREE_CREDITS_USDC_UNITS itself, after a past bug where a hardcoded cap under the grant let the New + // run page offer a budget the gateway then refused. PLAN_CAP_USDC must track the grant, not a literal. + expect(PLAN_CAP_USDC).toBe(FREE_CREDITS_USDC); + expect(PLAN_CAP_LABEL).toBe(FREE_CREDITS_LABEL); + }); + it("sets one built plan apart, never an unbuilt one", () => { const featured = PLANS.filter((p) => p.featured); expect(featured.map((p) => p.id)).toEqual(["payg"]); diff --git a/frontend/src/pricing/plans.ts b/frontend/src/pricing/plans.ts index 1b7b47e..471d40e 100644 --- a/frontend/src/pricing/plans.ts +++ b/frontend/src/pricing/plans.ts @@ -9,12 +9,15 @@ /** * The free-credit limits, in whole USDC. They are the gateway's defaults (FREE_CREDITS_USDC_UNITS=5000000, - * CREDIT_PLAN_CAP_USDC_UNITS=2000000, CREDIT_PLANS_PER_DAY=3) and a deployment can change them, so the page words - * them as defaults and change them here, in one place, when the gateway's change. A signed-in account's real values - * come from GET /v0/account/credits (see chain/useCredits); the pricing page is public and has no session to ask. + * CREDIT_PLANS_PER_DAY=3) and a deployment can change them, so the page words them as defaults and change + * them here, in one place, when the gateway's change. CREDIT_PLAN_CAP_USDC_UNITS has no separate default: + * chain/gateway/src/main.rs defaults it to FREE_CREDITS_USDC_UNITS itself, after a bug where a hardcoded + * 2 USDC cap under a 5 USDC grant let the New run page offer a budget the gateway then refused. A signed-in + * account's real values come from GET /v0/account/credits (see chain/useCredits); the pricing page is public + * and has no session to ask. */ export const FREE_CREDITS_USDC = 5; -export const PLAN_CAP_USDC = 2; +export const PLAN_CAP_USDC = FREE_CREDITS_USDC; export const PLANS_PER_DAY = 3; export const FREE_CREDITS_LABEL = `${String(FREE_CREDITS_USDC)} USDC`; export const PLAN_CAP_LABEL = `${String(PLAN_CAP_USDC)} USDC`;