Skip to content

Sell 8 premium templates, with the content actually withheld - #341

Merged
singhharsh1708 merged 1 commit into
mainfrom
feat/premium-templates
Aug 26, 2026
Merged

Sell 8 premium templates, with the content actually withheld#341
singhharsh1708 merged 1 commit into
mainfrom
feat/premium-templates

Conversation

@singhharsh1708

Copy link
Copy Markdown
Owner

Second of three PRs for the new model. 8 of 21 templates become individually purchasable; 13 stay free.

The gate is real, not cosmetic

lib/templates.ts is imported by six client components, so everything in it ships to every visitor. A lock drawn in the UI would be defeated by opening devtools — so this splits the data:

  • A premium template publishes only its opening section (a teaser).
  • The rest lives in premiumTemplateSections.ts, marked import "server-only", served by an entitlement-checked route.

Verified against the production bundle: none of the 33 withheld strings appears in any client chunk, while free-template copy still does (control check, so the test can't pass vacuously). A test also fails if any "use client" file ever imports the server-only module.

No duplication, no drift

The teaser is not copied into both modules — it lives once in the public catalogue and fullTemplateSections() joins the halves. The numbers the gallery quotes about a locked template (fullSectionCount, fullScrollHeight) are baked in and held to the real sections by a parity test, so a locked card can't understate what's behind it. Without that, premium cards would have advertised "1 section".

Purchases mirror the export flow

Its own Lemon Squeezy variant; a TemplatePurchase row keyed by slug rather than a foreign key, so a purchase survives a template being renamed or withdrawn; idempotent grants; the refund tombstone from #263; and reuse of an in-flight checkout instead of a second charge.

What was bought is decided by the variant the store recorded, never by the browser's custom_data — otherwise a cheaper template order could carry a site_id and unlock an export, or the reverse. Both directions are tested.

Which 8, and why

Chosen for category spread rather than content depth (depth was uniform): Meridian, AuraBeauty, NightRealm, Ledger, Harbour, Ember, GreenShift, TripVault. A test asserts SaaS, Portfolio, Agency, Editorial and Food & Drink each keep a free option, so a first site never requires a purchase.

Verified

  • tsc, eslint, next build clean; suite 462 → 497.
  • Mutation-tested the paywall: serving regardless of purchase fails 3 tests, accepting any purchase status fails 2, treating premium as free fails 7. Trusting custom_data over the variant fails 2 webhook tests.
  • Hit the React Compiler twice (a body-scope helper breaking undo/redo memoization; setState in an effect) and restructured both rather than suppressing.

TemplatePurchase migration auto-applies on merge. Requires LEMONSQUEEZY_TEMPLATE_VARIANT_ID and LEMONSQUEEZY_TEMPLATE_PRICE_CENTS in Vercel — until those are set the checkout returns 503 and templates simply stay locked, which is the safe failure.

Eight of the twenty-one templates become individually purchasable; thirteen stay
free, chosen so every category a first site reaches for still has a free option.

The gate is real rather than cosmetic. lib/templates.ts is imported by six client
components, so everything in it ships to every visitor: a lock drawn in the UI
would be defeated by opening devtools. A premium template therefore publishes
only its opening section, and the rest lives in premiumTemplateSections.ts, which
is server-only and served by an entitlement-checked route. Verified against the
production bundle: none of the 33 withheld strings appears in any client chunk,
while free template copy still does.

The teaser is not duplicated across the two modules — it lives once in the public
catalogue and fullTemplateSections joins the halves, so they cannot drift. The
numbers the gallery quotes about a premium template are baked in and held to the
real sections by a parity test, so a locked card cannot understate what is behind
it.

Purchases mirror the export flow: a Lemon Squeezy variant of its own, a
TemplatePurchase row keyed by slug rather than a foreign key so a purchase
survives a rename, idempotent grants, the refund tombstone, and reuse of an
in-flight checkout instead of a second charge. What was bought is decided by the
variant the store recorded, never by the browser's custom_data, so a template
order cannot unlock an export or the reverse.

Pricing now shows Free, premium templates, and enterprise by contact.

Fixes nothing on the tracker — this is the new business model.
@vercel

vercel Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
scrollcraft Ready Ready Preview Aug 26, 2026 8:47pm

@singhharsh1708
singhharsh1708 merged commit b3ce6ef into main Aug 26, 2026
3 checks passed
@singhharsh1708
singhharsh1708 deleted the feat/premium-templates branch August 26, 2026 20:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant