diff --git a/src/__tests__/exportPaywall.test.ts b/src/__tests__/exportPaywall.test.ts deleted file mode 100644 index 6d1339e..0000000 --- a/src/__tests__/exportPaywall.test.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import type { NextRequest } from "next/server"; - -// A Lemon Squeezy export purchase unlocks exactly one site. If the exported HTML -// were built from the request body, a single $19 purchase would export every site -// the buyer can name — so a FREE user's content must come from the stored site row. - -const dbMock = vi.hoisted(() => ({ - exportPurchase: { findFirst: vi.fn() }, - site: { findFirst: vi.fn() }, -})); - -const authMock = vi.hoisted(() => vi.fn()); -const rateLimitMock = vi.hoisted(() => vi.fn()); - -vi.mock("@/lib/db", () => ({ db: dbMock })); -vi.mock("@/auth", () => ({ auth: authMock })); -vi.mock("@/lib/rateLimit", () => ({ - rateLimit: rateLimitMock, - getClientIp: () => "1.2.3.4", -})); - -type Handler = typeof import("../app/api/export-site/route").POST; -let POST: Handler; - -function exportRequest(body: Record): NextRequest { - return new Request("https://scrollcraft.app/api/export-site", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body), - }) as unknown as NextRequest; -} - -const ATTACKER_BODY = { - siteId: "site_paid_for", - frameCount: 120, - sections: [{ heading: "SMUGGLED-FROM-BODY", scrollHeight: 1000 }], - siteName: "SMUGGLED-NAME", - customCss: ".smuggled{color:red}", - customHead: "", - fps: 60, -}; - -const STORED_SITE = { - name: "STORED-NAME", - fps: 24, - sectionsJson: JSON.stringify([{ heading: "STORED-HEADING", scrollHeight: 2000 }]), - customHead: "", - customCss: ".stored{color:blue}", -}; - -function freeSession() { - return { user: { id: "user_1", email: "a@b.com", plan: "FREE" } }; -} - -beforeEach(async () => { - vi.resetModules(); - vi.spyOn(console, "error").mockImplementation(() => {}); - authMock.mockReset().mockResolvedValue(freeSession()); - rateLimitMock.mockReset().mockResolvedValue({ allowed: true, remaining: 9, resetAt: 0 }); - dbMock.exportPurchase.findFirst.mockReset().mockResolvedValue({ id: "ep_1", status: "PAID" }); - dbMock.site.findFirst.mockReset().mockResolvedValue(STORED_SITE); - ({ POST } = await import("../app/api/export-site/route")); -}); - -afterEach(() => { - vi.restoreAllMocks(); -}); - -describe("export-site — FREE users export the site they paid for, not the request body", () => { - it("builds the export from the stored site record and drops body-supplied content", async () => { - const res = await POST(exportRequest(ATTACKER_BODY)); - const body = await res.json(); - - expect(res.status).toBe(200); - expect(body.html).toContain("STORED-HEADING"); - expect(body.html).toContain("STORED-NAME"); - expect(body.html).toContain(".stored{color:blue}"); - // None of the request-body content may reach the generated page. - expect(body.html).not.toContain("SMUGGLED-FROM-BODY"); - expect(body.html).not.toContain("SMUGGLED-NAME"); - expect(body.html).not.toContain("smuggled"); - expect(body.siteName).toBe("STORED-NAME"); - expect(body.fps).toBe(24); - }); - - it("reads the stored site scoped to the session user", async () => { - await POST(exportRequest(ATTACKER_BODY)); - - expect(dbMock.site.findFirst).toHaveBeenCalledWith( - expect.objectContaining({ where: { id: "site_paid_for", userId: "user_1" } }) - ); - }); - - it("checks the purchase for the exact site and user before reading anything", async () => { - await POST(exportRequest(ATTACKER_BODY)); - - expect(dbMock.exportPurchase.findFirst).toHaveBeenCalledWith({ - where: { siteId: "site_paid_for", userId: "user_1", status: "PAID" }, - }); - }); - - it("402s when the user has no PAID purchase for that site", async () => { - dbMock.exportPurchase.findFirst.mockResolvedValue(null); - - const res = await POST(exportRequest(ATTACKER_BODY)); - const body = await res.json(); - - expect(res.status).toBe(402); - expect(body.code).toBe("PURCHASE_REQUIRED"); - expect(dbMock.site.findFirst).not.toHaveBeenCalled(); - }); - - it("402s when no siteId is supplied, rather than exporting body content", async () => { - const noSiteId: Record = { ...ATTACKER_BODY }; - delete noSiteId.siteId; - - const res = await POST(exportRequest(noSiteId)); - const body = await res.json(); - - expect(res.status).toBe(402); - expect(body.code).toBe("SAVE_REQUIRED"); - expect(dbMock.exportPurchase.findFirst).not.toHaveBeenCalled(); - }); - - it("rejects a Prisma filter object smuggled in as siteId", async () => { - // `{ not: "" }` in a `where` clause would match any owned site and defeat the - // per-site entitlement, so anything but a plain string is refused. - const res = await POST(exportRequest({ ...ATTACKER_BODY, siteId: { not: "" } })); - const body = await res.json(); - - expect(res.status).toBe(402); - expect(body.code).toBe("SAVE_REQUIRED"); - expect(dbMock.exportPurchase.findFirst).not.toHaveBeenCalled(); - }); - - it("rejects an empty-string siteId", async () => { - const res = await POST(exportRequest({ ...ATTACKER_BODY, siteId: "" })); - - expect((await res.json()).code).toBe("SAVE_REQUIRED"); - expect(dbMock.exportPurchase.findFirst).not.toHaveBeenCalled(); - }); - - it("rejects an over-long siteId instead of passing it to Prisma", async () => { - const res = await POST(exportRequest({ ...ATTACKER_BODY, siteId: "x".repeat(129) })); - - expect((await res.json()).code).toBe("SAVE_REQUIRED"); - expect(dbMock.exportPurchase.findFirst).not.toHaveBeenCalled(); - }); - - it("404s when the purchased site is not owned by the session user", async () => { - dbMock.site.findFirst.mockResolvedValue(null); - - const res = await POST(exportRequest(ATTACKER_BODY)); - - expect(res.status).toBe(404); - }); - - it("refuses to fall back to body sections when the stored site has none", async () => { - dbMock.site.findFirst.mockResolvedValue({ ...STORED_SITE, sectionsJson: null }); - - const res = await POST(exportRequest(ATTACKER_BODY)); - - expect(res.status).toBe(400); - expect(await res.text()).not.toContain("SMUGGLED-FROM-BODY"); - }); - - it("refuses to fall back to body sections when the stored content is corrupt", async () => { - dbMock.site.findFirst.mockResolvedValue({ ...STORED_SITE, sectionsJson: "{not json" }); - - const res = await POST(exportRequest(ATTACKER_BODY)); - - expect(res.status).toBe(400); - expect(await res.text()).not.toContain("SMUGGLED-FROM-BODY"); - }); - - it("requires a session", async () => { - authMock.mockResolvedValue(null); - - const res = await POST(exportRequest(ATTACKER_BODY)); - - expect(res.status).toBe(401); - expect(dbMock.exportPurchase.findFirst).not.toHaveBeenCalled(); - }); - - it("stops at the rate limit before consulting the paywall", async () => { - rateLimitMock.mockResolvedValue({ allowed: false, remaining: 0, resetAt: 0 }); - - const res = await POST(exportRequest(ATTACKER_BODY)); - - expect(res.status).toBe(429); - expect(dbMock.exportPurchase.findFirst).not.toHaveBeenCalled(); - }); -}); - -describe("export-site — paid plans", () => { - it("lets a subscriber export the content in the request body without a purchase", async () => { - authMock.mockResolvedValue({ user: { id: "user_1", email: "a@b.com", plan: "PRO" } }); - - const res = await POST(exportRequest(ATTACKER_BODY)); - const body = await res.json(); - - expect(res.status).toBe(200); - expect(body.html).toContain("SMUGGLED-FROM-BODY"); - expect(dbMock.exportPurchase.findFirst).not.toHaveBeenCalled(); - expect(dbMock.site.findFirst).not.toHaveBeenCalled(); - }); - - it("treats a session with no plan as FREE", async () => { - authMock.mockResolvedValue({ user: { id: "user_1", email: "a@b.com" } }); - dbMock.exportPurchase.findFirst.mockResolvedValue(null); - - const res = await POST(exportRequest(ATTACKER_BODY)); - - expect(res.status).toBe(402); - }); -}); diff --git a/src/__tests__/exportSiteSource.test.ts b/src/__tests__/exportSiteSource.test.ts new file mode 100644 index 0000000..fee7e8e --- /dev/null +++ b/src/__tests__/exportSiteSource.test.ts @@ -0,0 +1,191 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import type { NextRequest } from "next/server"; + +// Export is free on every plan, so there is no entitlement left to protect. What still +// matters is where the exported content comes from: when a request names a siteId, the +// stored row is authoritative and the lookup is scoped to the caller. Without that, one +// user could read another's site content by naming its id — and a `siteId` that is not a +// plain string would reach a Prisma `where`, whose generated type also accepts a filter +// object, widening a lookup meant to identify a single row. + +const dbMock = vi.hoisted(() => ({ + site: { findFirst: vi.fn() }, +})); + +const authMock = vi.hoisted(() => vi.fn()); +const rateLimitMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/lib/db", () => ({ db: dbMock })); +vi.mock("@/auth", () => ({ auth: authMock })); +vi.mock("@/lib/rateLimit", () => ({ + rateLimit: rateLimitMock, + getClientIp: () => "1.2.3.4", +})); + +type Handler = typeof import("../app/api/export-site/route").POST; +let POST: Handler; + +function exportRequest(body: Record): NextRequest { + return new Request("https://scrollcraft.app/api/export-site", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }) as unknown as NextRequest; +} + +const BODY_CONTENT = { + siteId: "site_1", + frameCount: 120, + sections: [{ heading: "FROM-BODY", scrollHeight: 1000 }], + siteName: "BODY-NAME", + customCss: ".body{color:red}", + customHead: "", + fps: 60, +}; + +const STORED_SITE = { + name: "STORED-NAME", + description: null, + styleJson: null, + fps: 24, + sectionsJson: JSON.stringify([{ heading: "STORED-HEADING", scrollHeight: 2000 }]), + customHead: "", + customCss: ".stored{color:blue}", + themeJson: null, +}; + +beforeEach(async () => { + vi.resetModules(); + vi.spyOn(console, "error").mockImplementation(() => {}); + authMock.mockReset().mockResolvedValue({ user: { id: "user_1", email: "a@b.com", plan: "FREE" } }); + rateLimitMock.mockReset().mockResolvedValue({ allowed: true, remaining: 9, resetAt: 0 }); + dbMock.site.findFirst.mockReset().mockResolvedValue(STORED_SITE); + ({ POST } = await import("../app/api/export-site/route")); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("export-site — a named site is read from storage, not from the request body", () => { + it("builds the export from the stored record and ignores body-supplied content", async () => { + const res = await POST(exportRequest(BODY_CONTENT)); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.html).toContain("STORED-HEADING"); + expect(body.html).toContain("STORED-NAME"); + expect(body.html).toContain(".stored{color:blue}"); + expect(body.html).not.toContain("FROM-BODY"); + expect(body.html).not.toContain("BODY-NAME"); + expect(body.html).not.toContain("frombody"); + expect(body.siteName).toBe("STORED-NAME"); + expect(body.fps).toBe(24); + }); + + it("scopes the stored-site lookup to the session user", async () => { + await POST(exportRequest(BODY_CONTENT)); + + expect(dbMock.site.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: "site_1", userId: "user_1" } }) + ); + }); + + it("404s a site the caller does not own rather than exporting it", async () => { + dbMock.site.findFirst.mockResolvedValue(null); + + const res = await POST(exportRequest({ ...BODY_CONTENT, siteId: "someone_elses" })); + + expect(res.status).toBe(404); + }); +}); + +describe("export-site — a hostile siteId never reaches Prisma", () => { + // Anything that is not a plain string is discarded, so the request falls back to + // exporting its own body — which is free, and reveals nothing stored. + const hostile: Array<[string, unknown]> = [ + ["a Prisma filter object", { not: "" }], + ["an empty string", ""], + ["an over-long id", "x".repeat(129)], + ["an array", ["site_1"]], + ["a number", 12345], + ["null", null], + ]; + + for (const [label, siteId] of hostile) { + it(`discards ${label} and never queries for a site`, async () => { + const res = await POST(exportRequest({ ...BODY_CONTENT, siteId })); + + expect(res.status).toBe(200); + expect(dbMock.site.findFirst).not.toHaveBeenCalled(); + // Falls back to the caller's own body content, which they already possess. + expect((await res.json()).html).toContain("FROM-BODY"); + }); + } +}); + +describe("export-site — free on every plan", () => { + it("exports body content for a FREE user with no saved site", async () => { + const noSiteId: Record = { ...BODY_CONTENT }; + delete noSiteId.siteId; + + const res = await POST(exportRequest(noSiteId)); + + expect(res.status).toBe(200); + expect((await res.json()).html).toContain("FROM-BODY"); + expect(dbMock.site.findFirst).not.toHaveBeenCalled(); + }); + + it("treats a session with no plan the same as any other", async () => { + authMock.mockResolvedValue({ user: { id: "user_1", email: "a@b.com" } }); + + const res = await POST(exportRequest(BODY_CONTENT)); + + expect(res.status).toBe(200); + }); + + it("never answers with a payment-required status", async () => { + for (const session of [ + { user: { id: "user_1", email: "a@b.com", plan: "FREE" } }, + { user: { id: "user_1", email: "a@b.com" } }, + ]) { + authMock.mockResolvedValue(session); + const res = await POST(exportRequest(BODY_CONTENT)); + expect(res.status).not.toBe(402); + } + }); + + it("still requires a session", async () => { + authMock.mockResolvedValue(null); + expect((await POST(exportRequest(BODY_CONTENT))).status).toBe(401); + }); + + it("stops at the rate limit before reading anything", async () => { + rateLimitMock.mockResolvedValue({ allowed: false, remaining: 0, resetAt: 0 }); + + const res = await POST(exportRequest(BODY_CONTENT)); + + expect(res.status).toBe(429); + expect(dbMock.site.findFirst).not.toHaveBeenCalled(); + }); +}); + +describe("export-site — stored content that cannot be used", () => { + it("refuses to fall back to body sections when the stored site has none", async () => { + dbMock.site.findFirst.mockResolvedValue({ ...STORED_SITE, sectionsJson: null }); + + const res = await POST(exportRequest(BODY_CONTENT)); + + expect(res.status).toBe(400); + expect(JSON.stringify(await res.json())).not.toContain("FROM-BODY"); + }); + + it("refuses to fall back to body sections when the stored content is corrupt", async () => { + dbMock.site.findFirst.mockResolvedValue({ ...STORED_SITE, sectionsJson: "{not json" }); + + const res = await POST(exportRequest(BODY_CONTENT)); + + expect(res.status).toBe(400); + expect(JSON.stringify(await res.json())).not.toContain("FROM-BODY"); + }); +}); diff --git a/src/__tests__/siteAllowance.test.ts b/src/__tests__/siteAllowance.test.ts index 5e02b9a..e25643f 100644 --- a/src/__tests__/siteAllowance.test.ts +++ b/src/__tests__/siteAllowance.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import type { NextRequest } from "next/server"; -import { PLANS } from "@/lib/plans"; +import { PLANS, siteAllowance } from "@/lib/plans"; const dbMock = vi.hoisted(() => ({ user: { findUnique: vi.fn() }, @@ -54,28 +54,37 @@ describe("saved-website allowance", () => { expect(dbMock.site.create).not.toHaveBeenCalled(); }); - it("honours each plan's advertised allowance", async () => { + it("honours each plan's effective allowance", async () => { for (const plan of Object.values(PLANS)) { + const allowance = siteAllowance(plan.key); dbMock.site.create.mockClear(); dbMock.user.findUnique.mockResolvedValue({ id: "u1", plan: plan.key }); - dbMock.site.count.mockResolvedValue(plan.sites - 1); + dbMock.site.count.mockResolvedValue(allowance - 1); expect((await POST(req({ name: "under" }))).status).toBe(200); dbMock.site.create.mockClear(); - dbMock.site.count.mockResolvedValue(plan.sites); + dbMock.site.count.mockResolvedValue(allowance); const res = await POST(req({ name: "at limit" })); expect(res.status).toBe(409); - expect((await res.json()).allowance).toBe(plan.sites); + expect((await res.json()).allowance).toBe(allowance); expect(dbMock.site.create).not.toHaveBeenCalled(); } }); - it("a higher plan allows strictly more than a lower one", () => { - const ladder = [PLANS.FREE, PLANS.BASIC, PLANS.BASIC_PLUS, PLANS.PRO, PLANS.PREMIUM]; - for (let i = 1; i < ladder.length; i++) { - expect(ladder[i].sites).toBeGreaterThan(ladder[i - 1].sites); + it("never gives a legacy subscriber less than a new free account", () => { + // The free allowance grew when the paid tiers were retired, and BASIC's stored value + // is now below it. Nobody who paid may end up worse off than someone who did not. + for (const plan of Object.values(PLANS)) { + expect(siteAllowance(plan.key)).toBeGreaterThanOrEqual(PLANS.FREE.sites); } + expect(PLANS.BASIC.sites).toBeLessThan(PLANS.FREE.sites); + expect(siteAllowance("BASIC")).toBe(PLANS.FREE.sites); + }); + + it("treats an unknown plan as free rather than as no allowance", () => { + expect(siteAllowance("NOT_A_PLAN")).toBe(PLANS.FREE.sites); + expect(siteAllowance(null)).toBe(PLANS.FREE.sites); }); it("updating an existing site is not blocked by the allowance", async () => { diff --git a/src/app/api/export-site/route.ts b/src/app/api/export-site/route.ts index e8c3c97..4b7c19a 100644 --- a/src/app/api/export-site/route.ts +++ b/src/app/api/export-site/route.ts @@ -97,27 +97,10 @@ export async function POST(req: NextRequest) { fps = 24, } = body; - // FREE users must purchase an export; paid subscribers export freely - const userPlan = session.user.plan ?? "FREE"; - if (userPlan === "FREE") { - if (!siteId) { - return NextResponse.json( - { error: "Save your site before exporting.", code: "SAVE_REQUIRED" }, - { status: 402 } - ); - } - const purchase = await db.exportPurchase.findFirst({ - where: { siteId, userId: session.user.id, status: "PAID" }, - }); - if (!purchase) { - return NextResponse.json( - { error: "Export purchase required", code: "PURCHASE_REQUIRED" }, - { status: 402 } - ); - } - - // A purchase unlocks one site, so build the export from that site's stored - // content — body content would let a single purchase export anything. + // Export is free on every plan. A supplied siteId still makes the stored record + // authoritative and is scoped to the caller, so nobody can export a site they do + // not own by passing its id. Without a siteId the request body is exported as-is. + if (siteId) { const site = await db.site.findFirst({ where: { id: siteId, userId: session.user.id }, select: { diff --git a/src/app/api/sites/[id]/publish/route.ts b/src/app/api/sites/[id]/publish/route.ts index 2bb107e..83ff3f9 100644 --- a/src/app/api/sites/[id]/publish/route.ts +++ b/src/app/api/sites/[id]/publish/route.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { db } from "@/lib/db"; import { auth } from "@/auth"; import { rateLimit, getClientIp } from "@/lib/rateLimit"; -import { planByKey } from "@/lib/plans"; +import { siteAllowance } from "@/lib/plans"; import { parseSectionsJson, parseStyleJson, visibleSections } from "@/lib/siteSchema"; const bodySchema = z.object({ action: z.enum(["publish", "unpublish"]) }); @@ -92,7 +92,7 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string return NextResponse.json({ published: true, slug: site.publishSlug }); } - const allowance = planByKey(user.plan).sites; + const allowance = siteAllowance(user.plan); // Serialise this user's concurrent publishes. A plain count-then-update races under // READ COMMITTED: two transactions publishing DIFFERENT rows both read count=0 and both diff --git a/src/app/api/sites/route.ts b/src/app/api/sites/route.ts index 604b28f..6770042 100644 --- a/src/app/api/sites/route.ts +++ b/src/app/api/sites/route.ts @@ -4,7 +4,7 @@ import { db } from "@/lib/db"; import { auth } from "@/auth"; import { rateLimit, getClientIp } from "@/lib/rateLimit"; import { parseSectionsJson, parseStyleJson, parseThemeJson } from "@/lib/siteSchema"; -import { planByKey } from "@/lib/plans"; +import { siteAllowance } from "@/lib/plans"; // The schema alone admits ~11 MB per call; without a cap a client could stream far more // before Zod ever sees it. @@ -142,7 +142,7 @@ export async function POST(req: NextRequest) { // Create new site const siteCount = await db.site.count({ where: { userId: user.id } }); - const allowance = Math.min(planByKey(user.plan).sites, MAX_SITES_PER_USER); + const allowance = Math.min(siteAllowance(user.plan), MAX_SITES_PER_USER); if (siteCount >= allowance) { return NextResponse.json( { diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 5349485..e694ae1 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -12,7 +12,7 @@ import { Zap, Globe, Download, Loader2 } from "lucide-react"; import Navbar from "@/components/Navbar"; -import { planByKey } from "@/lib/plans"; +import { planByKey, siteAllowance } from "@/lib/plans"; import { PRESETS } from "@/lib/presets"; import { deleteFrames } from "@/lib/frameStorage"; @@ -43,7 +43,7 @@ export default function DashboardPage() { const [exportCount, setExportCount] = useState(0); const userPlanKey = (session?.user?.plan ?? "FREE") as string; const plan = planByKey(userPlanKey); - const siteLimit = plan.sites; + const siteLimit = siteAllowance(userPlanKey); useEffect(() => { if (status === "unauthenticated") router.push("/auth/signin"); @@ -165,9 +165,9 @@ export default function DashboardPage() { {plan.label} - + @@ -183,11 +183,13 @@ export default function DashboardPage() {

{Math.max(0, siteLimit - sites.length)} slot{siteLimit - sites.length === 1 ? "" : "s"} left on {plan.label}

- - - +

+ Need more than this for a client project?{" "} + + Talk to us + + . +

@@ -307,7 +309,7 @@ export default function DashboardPage() { {[ { icon: Plus, title: "Start from a template", desc: "Pick a ready-made scroll site", href: "/templates", color: "text-primary" }, { icon: Sparkles, title: "Browse presets", desc: `${PRESETS.length} production-ready templates`, href: "/presets", color: "text-violet-400" }, - { icon: Zap, title: "Upgrade plan", desc: "Keep more websites saved", href: "/pricing", color: "text-amber-400" }, + { icon: Zap, title: "Enterprise", desc: "A site built for your brand", href: "/contact", color: "text-amber-400" }, ].map(action => (
diff --git a/src/app/editor/page.tsx b/src/app/editor/page.tsx index 9bf8173..2c45a41 100644 --- a/src/app/editor/page.tsx +++ b/src/app/editor/page.tsx @@ -429,11 +429,9 @@ function EditorInner() { } setIsExporting(true); try { - // A purchase must be tied to a saved site, and a purchased export is rebuilt - // server-side from that site's stored content — so save first, or unsaved edits - // are silently dropped from the ZIP. A failed save is not fatal on its own: - // paid plans export straight from the editor's own content, so only fall back - // to the last known site id and warn. + // The export is rebuilt server-side from the saved site's stored content, so save + // first or unsaved edits are silently dropped from the ZIP. A failed save is not + // fatal: fall back to the last known site id and warn. const savedSiteId = await handleSave({ silent: true }); const effectiveSiteId = savedSiteId ?? siteId; if (!effectiveSiteId) { @@ -476,7 +474,7 @@ function EditorInner() { } } - // Ask the server to validate auth + purchase + generate the HTML template. + // Ask the server to validate auth and generate the HTML template. // Frames are NOT sent — they stay on the client to avoid Vercel's 4.5 MB limit. const res = await fetch("/api/export-site", { method: "POST", @@ -499,32 +497,6 @@ function EditorInner() { }), }); - if (res.status === 402) { - const checkoutRes = await fetch("/api/payments/ls-checkout", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ siteId: effectiveSiteId }), - }); - const checkoutData = await checkoutRes.json().catch(() => ({})); - if (checkoutData.alreadyPurchased) { - toast.info("Purchase confirmed — preparing your download…"); - setIsExporting(false); - setExportStage(null); - return handleExport(); - } - if (checkoutRes.status === 503) { - toast.error("Paid exports aren't available right now. Try again later."); - return; - } - if (!checkoutRes.ok || !checkoutData.checkoutUrl) { - toast.error("Could not start checkout. Please try again."); - return; - } - toast.info("Redirecting to secure checkout…"); - window.location.assign(checkoutData.checkoutUrl); - return; - } - if (!res.ok) { const msg = await res.json().then((d) => d?.error).catch(() => null); throw new Error(msg || "Export failed. Please try again."); diff --git a/src/app/page.tsx b/src/app/page.tsx index d8de68f..7e4c322 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -347,11 +347,11 @@ export default function Home() {
Pricing

- Free to start.
- Scales with you. + Free.
+ Genuinely.

- Every template is free on every plan. No card required. Upgrade only to keep more websites saved. + Every template, the editor, publishing and ZIP export cost nothing. No card, no trial clock.

diff --git a/src/app/pricing/page.tsx b/src/app/pricing/page.tsx index 760dcb8..0812541 100644 --- a/src/app/pricing/page.tsx +++ b/src/app/pricing/page.tsx @@ -1,534 +1,159 @@ "use client"; import { useState } from "react"; import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { useSession } from "next-auth/react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; -import { Check, Minus, Sparkles, Zap, Loader2, Tag, X } from "lucide-react"; -import { toast } from "sonner"; +import { Check, Sparkles, Zap, ChevronDown, Mail } from "lucide-react"; import Navbar from "@/components/Navbar"; -import { planByName, formatINR } from "@/lib/plans"; - -const PLANS = [ - { - name: "Free Trial", - monthly: 0, - annual: 0, - description: "Every template, no card needed", - badge: null, - cta: "Start free", - ctaVariant: "outline" as const, - highlight: false, - allowance: "1 website", - features: [ - "Every template", - "1 saved website", - "Publish to a hosted link, with a ScrollCraft badge", - "Visual editor", - "Community support", - ], - missing: [ - "ZIP export included (buy per site, or upgrade)", - "More than one saved website", - "Badge-free published pages", - "Priority support", - ], - }, - { - name: "Basic", - monthly: 25, - annual: 20, - description: "For freelancers and side projects", - badge: null, - cta: "Get Basic", - ctaVariant: "outline" as const, - highlight: false, - allowance: "2 websites", - features: [ - "Every template", - "2 saved websites", - "Publish to a hosted link, badge-free", - "Visual editor", - "ZIP export", - "Email support", - ], - missing: [ - "Priority support", - ], - }, - { - name: "Basic Plus", - monthly: 37, - annual: 30, - description: "For people shipping more than one thing", - badge: null, - cta: "Get Basic Plus", - ctaVariant: "outline" as const, - highlight: false, - allowance: "4 websites", - features: [ - "Every template", - "4 saved websites", - "Publish to a hosted link, badge-free", - "Visual editor", - "ZIP export", - "Email support", - ], - missing: [ - "Priority support", - ], - }, - { - name: "Pro", - monthly: 62, - annual: 50, - description: "For studios and agencies", - badge: "Most popular", - cta: "Get Pro", - ctaVariant: "default" as const, - highlight: true, - allowance: "7 websites", - features: [ - "Every template", - "7 saved websites", - "Publish to a hosted link, badge-free", - "Visual editor", - "ZIP export", - "Priority support", - ], - missing: [], - }, - { - name: "Premium", - monthly: 187, - annual: 150, - description: "For teams running many sites", - badge: null, - cta: "Get Premium", - ctaVariant: "outline" as const, - highlight: false, - allowance: "30 websites", - features: [ - "Every template", - "30 saved websites", - "Publish to a hosted link, badge-free", - "Visual editor", - "ZIP export", - "Priority support", - ], - missing: [], - }, +import { PLANS, siteAllowance } from "@/lib/plans"; +import { TEMPLATES } from "@/lib/templates"; +import { PRESETS } from "@/lib/presets"; + +// ScrollCraft is free. The subscription tiers this page used to sell are retired — see +// the note in lib/plans.ts — so there is no billing toggle, no promo field and no +// checkout here any more. Revenue comes from enterprise work arranged by email. + +const FREE_FEATURES = [ + `All ${TEMPLATES.length} templates`, + `All ${PRESETS.length} background presets`, + "Visual editor with undo history", + `${siteAllowance("FREE")} saved websites`, + "Publish to a hosted link", + "ZIP export — the code is yours", + "Deploy anywhere that serves static files", ]; const FAQ = [ { - q: "Are the templates really free?", - a: "Yes. Every template is available on every plan, including the free one. Exporting a site to a ZIP you own outright is included on the paid plans, or you can buy an export for a single site.", - }, - { - q: "What do the paid plans actually add?", - a: "ZIP export without buying it per site, how many websites you can keep saved and published, badge-free published pages that search engines index, and how quickly we answer support. Nothing about the templates themselves is gated.", + q: "Is it really free?", + a: `Yes. Every one of the ${TEMPLATES.length} templates, the editor, publishing and ZIP export cost nothing, and there is no time limit. You keep ${siteAllowance("FREE")} saved websites on a free account.`, }, { - q: "Can I cancel anytime?", - a: "Yes. Email hello@scrollcraft.app to cancel — you keep access until the end of your billing period.", + q: "What's the catch on ZIP export?", + a: "There isn't one. Export downloads plain HTML, CSS and JavaScript you own outright — no build step, no runtime dependency on us, and nothing phones home. Host it anywhere that serves static files.", }, { - q: "What's the difference between monthly and annual?", - a: "Annual billing saves you 20%. You're charged once per year upfront.", + q: "Do published pages carry a badge?", + a: "A published page carries a small ScrollCraft badge and is not indexed by search engines. If you need an unbadged, indexable page for a client, that is what the enterprise route is for.", }, { - q: "Do I own the exported code?", - a: "Yes, 100%. You can modify, host, resell, or white-label any site you export.", + q: "I subscribed before. What happens to my plan?", + a: "Nothing you paid for is taken away. The old tiers are retired, but your account keeps its saved-website allowance, and the free allowance is a floor — you can never end up with less than a new free account.", }, { - q: "Is there an enterprise plan?", - a: "Yes. We build custom scroll websites for your brand and offer white-label solutions. Contact us for pricing.", + q: "What does enterprise cover?", + a: "A custom scroll site built for your brand by us: your own design rather than a template, white-labelled pages, and a support arrangement in writing. Email us and we will scope it.", }, ]; -declare global { - interface Window { - Razorpay: new (options: Record) => { open(): void }; - } -} - -function loadRazorpayScript(): Promise { - return new Promise((resolve, reject) => { - if (window.Razorpay) { resolve(); return; } - const script = document.createElement("script"); - script.src = "https://checkout.razorpay.com/v1/checkout.js"; - script.onload = () => resolve(); - script.onerror = () => reject(new Error("Failed to load Razorpay")); - document.head.appendChild(script); - }); -} - -// Displayed prices must match what create-order actually charges. The page rendered -// dollar amounts ("$200/mo") against a Razorpay order billed in INR (₹14,999) — the -// wrong symbol and a different number at the moment of payment. -function priceLabel(name: string, annual: boolean): string { - const p = planByName(name); - if (!p) return "—"; - const paise = annual ? p.annualPaise : p.monthlyPaise; - return paise === 0 ? "Free" : formatINR(paise); -} - -function annualTotalLabel(name: string): string { - const p = planByName(name); - return p ? formatINR(p.annualPaise * 12) : "—"; -} - export default function PricingPage() { - const router = useRouter(); - const { status } = useSession(); - const [annual, setAnnual] = useState(true); const [openFaq, setOpenFaq] = useState(null); - const [checkingOut, setCheckingOut] = useState(null); - const [promoInput, setPromoInput] = useState(""); - const [promoApplied, setPromoApplied] = useState<{ code: string; discountPct: number } | null>(null); - const [validatingPromo, setValidatingPromo] = useState(false); - - const handleApplyPromo = async () => { - if (!promoInput.trim()) return; - setValidatingPromo(true); - try { - const res = await fetch("/api/promo", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ code: promoInput.trim() }), - }); - const data = await res.json(); - if (!res.ok) throw new Error(data.error || "Invalid code"); - setPromoApplied({ code: data.code, discountPct: data.discountPct }); - toast.success(`${data.discountPct}% discount applied!`); - } catch (err) { - toast.error(err instanceof Error ? err.message : "Invalid promo code"); - } finally { - setValidatingPromo(false); - } - }; - - const handleCheckout = async (planName: string) => { - if (status !== "authenticated") { - router.push("/auth/signin?callbackUrl=/pricing"); - return; - } - setCheckingOut(planName); - try { - const res = await fetch("/api/payments/create-order", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - plan: planName, - billing: annual ? "annual" : "monthly", - ...(promoApplied ? { promoCode: promoApplied.code } : {}), - }), - }); - const data = await res.json(); - if (!res.ok) throw new Error(data.error || "Failed to create order"); - - await loadRazorpayScript(); - - const rzp = new window.Razorpay({ - key: data.keyId, - amount: data.amount, - currency: data.currency, - order_id: data.orderId, - name: "ScrollCraft", - description: `${planName} plan — ${annual ? "Annual" : "Monthly"}`, - theme: { color: "#7c3aed" }, - // The user has already been charged by the time this runs, so nothing in here - // may throw unhandled — an unhandled rejection left them with no toast, no - // redirect and no way to know whether the payment landed. - handler: async (response: { razorpay_order_id: string; razorpay_payment_id: string; razorpay_signature: string }) => { - try { - const verifyRes = await fetch("/api/payments/verify", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - orderId: response.razorpay_order_id, - paymentId: response.razorpay_payment_id, - signature: response.razorpay_signature, - }), - }); - const verifyData = await verifyRes.json().catch(() => ({})); - if (verifyRes.ok && verifyData.success) { - toast.success(`${planName} activated! Welcome aboard.`); - window.location.href = `/create?plan=${encodeURIComponent(planName)}`; - return; - } - // Surface the reason the server gave instead of one generic string. - toast.error( - verifyData.error - ? `${verifyData.error} Your payment went through — contact support if this persists.` - : "We couldn't confirm your payment. Contact support with your payment ID." - ); - } catch { - toast.error("Your payment went through but we couldn't confirm it. Contact support before paying again."); - } finally { - setCheckingOut(null); - } - }, - modal: { - // Without this the button left its "Processing…" state as soon as the modal - // opened, so dismissing and clicking again created more orders — five cycles - // hit the 5/hour create-order limit and locked the user out of buying at all. - ondismiss: () => setCheckingOut(null), - }, - }); - rzp.open(); - } catch (err) { - toast.error(err instanceof Error ? err.message : "Checkout failed"); - setCheckingOut(null); - } - }; return (
- {/* Nav */} {/* Header */}
- Simple, transparent pricing + Free, with no time limit

- Start free.
+ It's free.
- Scale when ready. + All of it.

-

- Every plan includes the full animated scroll engine and visual editor. ZIP export is included on every paid plan, or buy it per site. +

+ Every template, the editor, publishing and ZIP export. No card, no trial clock, + no feature held back. Need something built for your brand? That part we charge for.

- - {/* Billing toggle */} -
- - -
- - {/* Promo code */} -
- {promoApplied ? ( -
- - Code {promoApplied.code} — {promoApplied.discountPct}% off applied - -
- ) : ( -
-
- - setPromoInput(e.target.value.toUpperCase())} - onKeyDown={(e) => e.key === "Enter" && handleApplyPromo()} - placeholder="Promo code" - className="pl-8 pr-3 py-2 text-sm rounded-lg border border-white/10 bg-white/5 focus:outline-none focus:border-violet-500/50 w-36 placeholder:text-muted-foreground" - /> -
- -
- )} -
- {/* Plans */} -
-
- {PLANS.map((plan) => ( -
- {plan.badge && ( -
- - {plan.badge} - -
- )} - -
-

{plan.name}

-

{plan.description}

-
- -
-
- - {priceLabel(plan.name, annual)} - - /mo -
- {plan.annual > 0 && annual && ( -

- Billed {annualTotalLabel(plan.name)}/yr -

- )} - {plan.annual === 0 && ( -

No credit card needed

- )} -
- - - {plan.allowance} - - -
- {plan.monthly === 0 ? ( - - - - ) : ( - - )} -
- -
- {plan.features.map((f) => ( -
- - {f} -
- ))} - {plan.missing.map((f) => ( -
- - {f} -
- ))} -
-
- ))} + {/* Free + Enterprise */} +
+ {/* Free */} +
+
+ Everything, free +
+

{PLANS.FREE.label}

+
+ ₹0 + forever +
+
    + {FREE_FEATURES.map((f) => ( +
  • + + {f} +
  • + ))} +
+ + + +

+ No credit card. Sign in with GitHub or Google. +

{/* Enterprise */} -
-
-

Enterprise

-

- Custom scroll websites built for your brand by our team. White-label, dedicated infrastructure, and an SLA. -

+
+

Enterprise

+
+ Let's talk
- - -
-
- - {/* Feature comparison table */} -
-

Full comparison

-
- - - - - {PLANS.map(p => ( - - ))} - - - - {[ - { label: "Templates", values: ["All", "All", "All", "All", "All"] }, - { label: "Saved websites", values: ["1", "2", "4", "7", "30"] }, - { label: "Published sites", values: ["1", "2", "4", "7", "30"] }, - { label: "Badge-free pages", values: [false, true, true, true, true] }, - { label: "Visual editor", values: [true, true, true, true, true] }, - { label: "ZIP export", values: [false, true, true, true, true] }, - ].map((row, i) => ( - - - {row.values.map((v, vi) => ( - - ))} - - ))} - -
Feature - {p.name} -
{row.label} - {typeof v === "boolean" ? ( - v ? : - ) : ( - {v} - )} -
+

+ Or email hello@scrollcraft.app directly. +

{/* FAQ */} -
-

Frequently asked

+
+

Questions

{FAQ.map((item, i) => ( -
+
{openFaq === i && ( -
- {item.a} -
+

{item.a}

)}
))} @@ -537,13 +162,13 @@ export default function PricingPage() { {/* Bottom CTA */}
-

- Start free. No card required. -

-

Every template, one saved website, no time limit.

- +

Start free. Stay free.

+

+ Nothing to cancel, because there is nothing to subscribe to. +

+
diff --git a/src/app/templates/page.tsx b/src/app/templates/page.tsx index 9cdee2c..56c6f3b 100644 --- a/src/app/templates/page.tsx +++ b/src/app/templates/page.tsx @@ -38,7 +38,7 @@ export default function TemplatesPage() {
- {TEMPLATES.length} templates, free on every plan + {TEMPLATES.length} templates, all free

Start from a finished site diff --git a/src/lib/plans.ts b/src/lib/plans.ts index f6fc81c..ba7bdc8 100644 --- a/src/lib/plans.ts +++ b/src/lib/plans.ts @@ -1,11 +1,13 @@ /** - * Single source of truth for plan pricing and the saved-website allowance. + * Single source of truth for the saved-website allowance and legacy plan pricing. * - * These lived in three places that had already drifted apart: the pricing page - * rendered dollar amounts while the order endpoint charged rupees, and the - * dashboard's credit denominator for Basic Plus did not match the number the - * pricing page advertised. Anything that shows a price or a credit allowance - * reads it from here. + * ScrollCraft is free: every template, the editor, publishing and ZIP export cost + * nothing, and revenue comes from individually purchased premium templates and from + * enterprise work arranged by email. + * + * The four paid subscription tiers below are retired. They are kept because existing + * subscribers still carry those values in the database and must keep the allowance they + * paid for; `legacy: true` keeps them off the pricing page. Nothing new is sold on them. */ export type PlanKey = "FREE" | "BASIC" | "BASIC_PLUS" | "PRO" | "PREMIUM"; @@ -21,37 +23,53 @@ export interface Plan { annualPaise: number; /** Saved websites the plan allows. Enforced by POST /api/sites. */ sites: number; + /** A retired tier: honoured for existing subscribers, never offered to new ones. */ + legacy?: boolean; color: string; } export const PLANS: Record = { FREE: { - key: "FREE", name: "Free Trial", label: "Free Trial", - monthlyPaise: 0, annualPaise: 0, sites: 1, + key: "FREE", name: "Free", label: "Free", + monthlyPaise: 0, annualPaise: 0, sites: 3, color: "text-muted-foreground", }, BASIC: { key: "BASIC", name: "Basic", label: "Basic", monthlyPaise: 199900, annualPaise: 159900, sites: 2, + legacy: true, color: "text-blue-400", }, BASIC_PLUS: { key: "BASIC_PLUS", name: "Basic Plus", label: "Basic Plus", monthlyPaise: 299900, annualPaise: 239900, sites: 4, + legacy: true, color: "text-cyan-400", }, PRO: { key: "PRO", name: "Pro", label: "Pro", monthlyPaise: 499900, annualPaise: 399900, sites: 7, + legacy: true, color: "text-primary", }, PREMIUM: { key: "PREMIUM", name: "Premium", label: "Premium", monthlyPaise: 1499900, annualPaise: 1199900, sites: 30, + legacy: true, color: "text-amber-400", }, }; +/** + * Saved websites a plan actually allows. + * + * Floored at the free allowance: the free tier grew when the subscription tiers were + * retired, and a legacy subscriber must never end up with less than a new free account. + */ +export function siteAllowance(key: string | null | undefined): number { + return Math.max(planByKey(key).sites, PLANS.FREE.sites); +} + /** The names the checkout API accepts, i.e. every plan that is actually charged for. */ export const PAID_PLAN_NAMES = Object.values(PLANS) .filter((p) => p.monthlyPaise > 0)