diff --git a/control-plane/tests/test_caddy_hardening.py b/control-plane/tests/test_caddy_hardening.py index 747322c..10767a2 100644 --- a/control-plane/tests/test_caddy_hardening.py +++ b/control-plane/tests/test_caddy_hardening.py @@ -94,6 +94,7 @@ def test_the_apps_own_routes_still_reach_the_shell() -> None: for path in ( "/", "/signin", + "/pricing", "/demo", "/demo/runs", "/app", @@ -108,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|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/deploy/Caddyfile b/deploy/Caddyfile index a387ba3..c8519d7 100644 --- a/deploy/Caddyfile +++ b/deploy/Caddyfile @@ -21,7 +21,7 @@ header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://avatars.githubusercontent.com; font-src 'self' data:; connect-src 'self' https://gateway.mergit.site https://store.mergit.site https://sepolia.base.org https://api.github.com; frame-ancestors 'none'; base-uri 'self'; object-src 'none'" # The app is a single-page app, so its own routes fall back to index.html. Anything else that is not a file is a # real 404 (not the app shell with a 200, which scanners flag and search engines index as soft 404s). - @spa path / /signin /signin/* /demo /demo/* /app /app/* + @spa path / /signin /signin/* /pricing /demo /demo/* /app /app/* rewrite @spa /index.html } diff --git a/frontend/.gitignore b/frontend/.gitignore index d434e23..54024ec 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -1,4 +1,4 @@ -node_modules/ +node_modules dist/ *.tsbuildinfo test-results/ diff --git a/frontend/e2e/live/account.spec.ts b/frontend/e2e/live/account.spec.ts index 5b0ecf9..a44bb5b 100644 --- a/frontend/e2e/live/account.spec.ts +++ b/frontend/e2e/live/account.spec.ts @@ -133,6 +133,11 @@ test("a new account chooses between the free trial and a wallet, and the trial h await page.goto("/app"); const choice = page.getByRole("dialog", { name: "Choose your plan" }); await expect(choice).toBeVisible(); + // Three plans: Premium is shown but not available, Pay as you go offers a wallet, and the detail page is linked. + await expect(choice.getByRole("heading", { level: 3 })).toHaveText(["Free", "Premium", "Pay as you go"]); + await expect(choice.getByRole("button", { name: "Coming soon" })).toBeDisabled(); + await expect(choice.getByRole("button", { name: /Connect a wallet/ })).toBeEnabled(); + await expect(choice.getByRole("link", { name: "Compare plans in detail" })).toHaveAttribute("href", "/pricing"); await choice.getByRole("button", { name: "Start free" }).click(); await expect(choice).toBeHidden(); expect(calls.some((c) => c.path === "/v0/account/trial")).toBe(true); diff --git a/frontend/e2e/pricing.spec.ts b/frontend/e2e/pricing.spec.ts new file mode 100644 index 0000000..26ddd3f --- /dev/null +++ b/frontend/e2e/pricing.spec.ts @@ -0,0 +1,63 @@ +import { expect, test as signedIn } from "./fixtures"; +import { test as signedOut } from "@playwright/test"; + +// The limits are the gateway defaults in src/pricing/plans.ts; e2e is a separate TypeScript project, so it spells them. +// 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"); + // Headings go h1, then an h2 for each section, then the cards' h3. + 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(); + 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("scrolls the table by keyboard, and shows Premium as soon in every row", async ({ page }) => { + await page.goto("/pricing"); + const region = page.getByRole("region", { name: "Plan comparison table" }); + await expect(region).toHaveAttribute("tabindex", "0"); + const premium = region.locator("tbody tr:not(.pr-group) td:nth-child(3)"); + for (const cell of await premium.all()) await expect(cell).toContainText("Soon"); + }); + + signedOut("Pay as you go leads to connecting a wallet", async ({ page }) => { + await page.goto("/pricing"); + await page.getByRole("button", { name: "Connect a wallet" }).click(); + await expect(page).toHaveURL(/\/app$/); + await expect(page.getByRole("dialog", { name: "Connect to Mergit" })).toBeVisible(); + }); + + 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", () => { + // The plan choice that starts the trial is the dialog over the dashboard, covered in live/account.spec.ts. + signedIn("Free links to the dashboard, and Pay as you go still offers a wallet", async ({ page }) => { + await page.goto("/pricing"); + await expect(page.getByRole("link", { name: "Open dashboard" }).first()).toHaveAttribute("href", "/app"); + await expect(page.getByRole("button", { name: "Connect a wallet" })).toBeEnabled(); + await expect(page.getByRole("button", { name: "Coming soon" })).toBeDisabled(); + }); +}); diff --git a/frontend/src/app/paths.ts b/frontend/src/app/paths.ts index edb0722..6bcb50e 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 3441251..5c86af1 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"; @@ -43,6 +44,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 ab5ba66..18f1918 100644 --- a/frontend/src/auth/FundingChoice.tsx +++ b/frontend/src/auth/FundingChoice.tsx @@ -1,7 +1,8 @@ import { useEffect, useLayoutEffect, useRef, useState } from "react"; -import { useLocation } from "react-router-dom"; +import { Link, useLocation } from "react-router-dom"; import { paths } from "../app/paths"; import { useCredits } from "../chain/useCredits"; +import { PlanCards } from "../components/PlanCards"; import { Icon } from "../components/Icon"; import { MergitSymbol } from "../components/MergitLogo"; import { startTrial } from "./accountApi"; @@ -109,71 +110,38 @@ export function FundingChoice() { {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 + diff --git a/frontend/src/components/Icon.tsx b/frontend/src/components/Icon.tsx index 66271aa..9d06f3a 100644 --- a/frontend/src/components/Icon.tsx +++ b/frontend/src/components/Icon.tsx @@ -26,6 +26,7 @@ const PATHS = { search: '', check: '', tick: '', + list: '', fail: '', running: '', clock: '', diff --git a/frontend/src/components/PlanCards.tsx b/frontend/src/components/PlanCards.tsx new file mode 100644 index 0000000..91e2041 --- /dev/null +++ b/frontend/src/components/PlanCards.tsx @@ -0,0 +1,53 @@ +import type { CSSProperties, ReactNode } from "react"; +import "@fontsource/instrument-serif/latin-400.css"; +import { PLANS, type Plan } from "../pricing/plans"; +import { Icon } from "./Icon"; + +/** + * The three plans as cards. The caller supplies each card's action and anything under it (the wallet list), so the + * dialog and the pricing page share the look while each keeps its own behaviour. + */ +export function PlanCards({ action, below }: { action: (plan: Plan) => ReactNode; below?: (plan: Plan) => ReactNode }) { + return ( +
+ {PLANS.map((plan, index) => ( +
+
+ {plan.tag} + {plan.soon && Soon} + {plan.featured && {plan.featured}} +
+

+ {plan.name} +

+

{plan.blurb}

+

+ {plan.amount} + {plan.unit} +

+
    + {plan.includes && ( +
  • + + {plan.includes} +
  • + )} + {plan.features.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+ {action(plan)} + {below?.(plan)} +
+ ))} +
+ ); +} diff --git a/frontend/src/pricing/plans.test.ts b/frontend/src/pricing/plans.test.ts new file mode 100644 index 0000000..8f39965 --- /dev/null +++ b/frontend/src/pricing/plans.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +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", () => { + expect(PLANS.map((p) => p.id)).toEqual(["free", "premium", "payg"]); + expect(PLANS.filter((p) => p.soon).map((p) => p.id)).toEqual(["premium"]); + }); + + it("gives Free the credits the gateway grants by default, and Premium no invented price", () => { + expect(PLANS[0]?.amount).toBe(FREE_CREDITS_LABEL); + 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"]); + expect(featured.every((p) => !p.soon)).toBe(true); + }); + + it("fills every comparison row for every plan", () => { + for (const group of COMPARISON) for (const row of group.rows) expect(row.cells).toHaveLength(PLANS.length); + }); + + it("shows Premium as soon in every row, since the plan is not built and claims nothing", () => { + for (const group of COMPARISON) + for (const row of group.rows) expect(row.cells[1], row.label).toEqual({ soon: true }); + }); + + it("does not claim reputation is earned yet", () => { + const row = COMPARISON.flatMap((g) => g.rows).find((r) => r.label.includes("reputation")); + expect(row?.cells.some((c) => "yes" in c)).toBe(false); + expect(PLANS[2]?.features.join(" ")).toMatch(/coming soon/); + }); + + it("states the free limits from the one set of constants, as defaults", () => { + expect(PLANS[0]?.features[0]).toBe( + `By default up to ${PLAN_CAP_LABEL} per plan, ${String(PLANS_PER_DAY)} plans a day`, + ); + const cells = COMPARISON.flatMap((g) => g.rows).flatMap((r) => r.cells); + expect(cells).toContainEqual({ text: String(PLANS_PER_DAY) }); + expect(cells).toContainEqual({ text: `Up to ${PLAN_CAP_LABEL}` }); + }); +}); diff --git a/frontend/src/pricing/plans.ts b/frontend/src/pricing/plans.ts new file mode 100644 index 0000000..471d40e --- /dev/null +++ b/frontend/src/pricing/plans.ts @@ -0,0 +1,157 @@ +/** + * What the plans say, in one place: the choose-your-plan dialog and the /pricing page both read this. + * + * Every figure is a setting of the running system, not a measurement. The free-credit numbers are the gateway's + * defaults (FREE_CREDITS_USDC_UNITS, CREDIT_PLAN_CAP_USDC_UNITS and CREDIT_PLANS_PER_DAY in + * chain/gateway/src/main.rs) and can differ per deployment. Credit packs are not built yet: that plan says so + * and shows no price. Pay as you go has no price either: each plan is funded from the wallet's own test USDC. + */ + +/** + * The free-credit limits, in whole USDC. They are the gateway's defaults (FREE_CREDITS_USDC_UNITS=5000000, + * 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 = 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`; + +export type PlanId = "free" | "premium" | "payg"; + +export type Plan = { + id: PlanId; + name: string; + /** Who it is for, under the name. */ + blurb: string; + /** The big figure and the line under it. */ + amount: string; + unit: string; + /** Small label above the name. */ + tag: string; + /** Not built yet: shown, but offers no action. */ + soon: boolean; + /** A ribbon label that sets this plan apart from the others. At most one plan has it, and never an unbuilt one. */ + featured?: string; + /** First line of the list, e.g. "Everything in Free" (shown with a list icon). */ + includes?: string; + features: readonly string[]; +}; + +export const PLANS: readonly Plan[] = [ + { + id: "free", + name: "Free", + blurb: "Try Mergit with no wallet and no cost.", + amount: FREE_CREDITS_LABEL, + unit: "in free test credits, once per user", + tag: "No wallet needed", + soon: false, + features: [ + `By default up to ${PLAN_CAP_LABEL} per plan, ${String(PLANS_PER_DAY)} plans a day`, + "Every task is audited", + "A receipt on chain for every task", + "No reputation is earned", + ], + }, + { + id: "premium", + name: "Premium", + blurb: "Credits in fixed packs, for teams that run often.", + amount: "Coming soon", + unit: "pricing to be announced", + tag: "Credit packs", + soon: true, + includes: "Will include everything in Free", + features: ["Top up with credit packs of fixed sizes", "Credits you can see and track"], + }, + { + id: "payg", + name: "Pay as you go", + blurb: "Fund each plan from your own wallet.", + amount: "Per task", + unit: "your own test USDC on Base Sepolia", + tag: "Wallet", + soon: false, + featured: "Most flexible", + includes: "Everything in Free, except the credit limits", + features: [ + "Proofs settle to your address", + "Counts toward reputation once validated outcomes are scored (coming soon)", + "You sign one message; it costs nothing", + ], + }, +]; + +/** A cell: plain text, a yes/no mark, or "soon" for what the plan will offer once it exists. */ +export type Cell = { text: string } | { yes: true } | { no: true } | { soon: true }; +export type Row = { label: string; hint?: string; cells: readonly [Cell, Cell, Cell] }; +export type Group = { title: string; rows: readonly Row[] }; + +const text = (t: string): Cell => ({ text: t }); +const YES: Cell = { yes: true }; +const NO: Cell = { no: true }; +const SOON: Cell = { soon: true }; + +export const COMPARISON: readonly Group[] = [ + { + title: "Funding", + rows: [ + { + label: "Where the money comes from", + cells: [text("Free test credits from Mergit"), SOON, text("Your test USDC on Base Sepolia")], + }, + { + label: "Credits included", + cells: [text(`${FREE_CREDITS_LABEL}, once per user`), SOON, text("None: you fund each plan")], + }, + { label: "Wallet needed", cells: [NO, SOON, YES] }, + ], + }, + { + title: "Limits", + rows: [ + { label: "Spend per plan (default)", cells: [text(`Up to ${PLAN_CAP_LABEL}`), SOON, text("Your balance")] }, + { label: "Plans per day (default)", cells: [text(String(PLANS_PER_DAY)), SOON, text("No credit limit applies")] }, + ], + }, + { + title: "Verification", + rows: [ + { + label: "Tasks audited", + hint: "Paid work is audited at a rate set per agent from its record, and never at zero.", + cells: [text("Every task"), SOON, text("At the agent's audit rate")], + }, + { + label: "Counts toward an agent's reputation", + hint: "Reputation is not scored yet. Free-credit work carries no stake, so it will not move an agent's standing.", + cells: [NO, SOON, SOON], + }, + ], + }, + { + title: "Settlement and proof", + rows: [ + { label: "Payment held in escrow before work starts", cells: [YES, SOON, YES] }, + { label: "Released only after verification", cells: [YES, SOON, YES] }, + { label: "A receipt for every task, committed on chain", cells: [YES, SOON, YES] }, + { label: "Open to a bonded challenge within the dispute window", cells: [YES, SOON, YES] }, + ], + }, + { + title: "Service", + rows: [ + { label: "All Mergit agents", cells: [YES, SOON, YES] }, + { + label: "Network", + cells: [text("Base Sepolia testnet"), SOON, text("Base Sepolia testnet")], + }, + ], + }, +]; diff --git a/frontend/src/screens/Pricing.tsx b/frontend/src/screens/Pricing.tsx new file mode 100644 index 0000000..a0a4032 --- /dev/null +++ b/frontend/src/screens/Pricing.tsx @@ -0,0 +1,168 @@ +import { Link } from "react-router-dom"; +import { paths } from "../app/paths"; +import { useAuth } from "../auth/context"; +import { Icon } from "../components/Icon"; +import { MergitLogo } from "../components/MergitLogo"; +import { PlanCards } from "../components/PlanCards"; +import { ThemeToggle } from "../components/ThemeToggle"; +import { COMPARISON, FREE_CREDITS_LABEL, PLANS, type Cell } from "../pricing/plans"; +import "../styles/screens/pricing.css"; + +function CellView({ cell }: { cell: Cell }) { + if ("yes" in cell) + return ( + + + Yes + + ); + if ("no" in cell) + return ( + + –No + + ); + if ("soon" in cell) return Soon; + return {cell.text}; +} + +/** + * The public pricing page. Its actions use the same paths the dialog does: Free signs in (or opens the dashboard, + * where the plan choice starts the trial), Pay as you go opens the wallet connect, Premium is not built yet. + */ +export function Pricing() { + const { session, openConnect } = useAuth(); + + return ( +
+
+ + + +
+ + + {session ? "Open dashboard" : "Sign in"} + +
+
+ +
+
+

Pricing

+

+ Start free with {FREE_CREDITS_LABEL} of test credits, no wallet needed. Connect a wallet when you want to + fund your own work. Free and Pay as you go settle the same way: escrow first, release after verification. +

+
    + {["Escrow before work starts", "Paid only after verification", "A receipt on chain for every task"].map( + (t) => ( +
  • + + {t} +
  • + ), + )} +
+
+ +

Plans

+ { + if (plan.soon) + return ( + + ); + if (plan.id === "free") + return ( + + {session ? "Open dashboard" : "Start free"} + + ); + return ( + + ); + }} + /> + +

+ Compare plans +

+ {/* Scrolls sideways on a phone: focusable, so a keyboard can scroll it. */} + {/* eslint-disable-next-line jsx-a11y-x/no-noninteractive-tabindex */} +
+ + + + + + {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. +

+

+ {FREE_CREDITS_LABEL} of test credits, no wallet, and every task leaves a receipt you can check. +

+
+ + {session ? "Open dashboard" : `Get ${FREE_CREDITS_LABEL} free`} + + + Compare plans + +
+
+ +

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

+
+
+ ); +} diff --git a/frontend/src/styles/auth.css b/frontend/src/styles/auth.css index 851cffa..d7da434 100644 --- a/frontend/src/styles/auth.css +++ b/frontend/src/styles/auth.css @@ -223,27 +223,90 @@ 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 { + /* Room for the hover lift and shadow, so a scrolling dialog does not clip them. */ + padding: 8px 6px 14px; 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; + /* Deeper mint and azure than the tokens, so the 11px white text is at least 4.5:1 (5.3 and 5.6); dark mode below. */ + background: linear-gradient(135deg, #0a7a58, #1a5ee0); + color: #fff; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; +} +@keyframes plan-shimmer { + from { + background-position: 160% 0; + } + to { + background-position: -60% 0; + } +} +:root[data-theme="dark"] .plan-ribbon { + background: linear-gradient(135deg, var(--mint), var(--azure)); + color: var(--on-ink); +} +:root[data-theme="dark"] .plan-soon { + --plan-soon-ink: var(--amber); +} +.plan-top { + align-items: center; + min-height: 26px; } .plan-tag { align-self: flex-start; @@ -254,30 +317,52 @@ button.avatarme { font-size: 11.5px; font-weight: 500; } -.plan-name { - font-size: 17px; - line-height: 22px; +.plan-soon { + /* A deeper amber than --amber (3.8:1 on the wash): 5.8:1 in light mode. Dark mode keeps --amber, 7.4:1. */ + --plan-soon-ink: #8a5200; + padding: 2px 8px; + border-radius: 999px; + background-color: var(--amber-wash); + 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; + color: var(--plan-soon-ink); + 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 +373,74 @@ 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, + .pr .plan-card { + 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..3e5dbf9 --- /dev/null +++ b/frontend/src/styles/screens/pricing.css @@ -0,0 +1,210 @@ +.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); + /* 4.8:1 on the wash (--mint is 3.0:1); dark mode keeps --mint, 7.7:1. */ + color: #0a7a58; +} +:root[data-theme="dark"] .pr-yes { + 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.is-featured-col, +.pr-table tbody tr:not(.pr-group) td.is-featured-col { + 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; +} +.pr-no { + color: var(--ink3); +} +.pr-foot { + max-width: 640px; +}