v0.2.0:24 — Per-entitlement "hide on buy page" toggle

Decouples "what the license grants" from "what the buyer sees on the
tier card." Operator can mark individual entitlements as hidden from
the buy page tier-card display; the issued license still carries
them. Enables the "Everything in Creator, plus:" marketing pattern
without duplicating implied entitlements on higher-tier cards.

- entitlementBubblePicker accepts a third `initialHidden` param and
  exposes a `readHidden()` method alongside `read()`. Each granted
  chip gets a small eye toggle (👁 visible / 👁‍🗨 hidden). Click chip
  name = grant/revoke. Click eye = hide-on-buy toggle. De-selecting
  a chip clears its hidden state automatically.
- New per-policy metadata: hidden_entitlements: string[]. Buy page
  filters before rendering tier-card entitlement chips. Public
  /v1/products/<slug>/policies exposes the array so SDKs and dynamic
  pricing pages stay in sync.
- Admin Policies grid still shows ALL entitlements (operator-truth
  view) but hidden ones get muted opacity + strikethrough + a small
  "(hidden on buy)" italic hint.

No schema change; pure metadata pass-through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Grant
2026-05-11 14:40:56 -05:00
parent 0e46ce399d
commit 033a1f4a6a
4 changed files with 158 additions and 29 deletions
+20 -4
View File
@@ -1156,15 +1156,31 @@ fn render_tier_picker(
.unwrap_or_default();
// raw slug if the catalog is empty or the slug isn't in
// it (legacy slugs that predate the catalog land here).
let entitlements_html = if p.entitlements.is_empty() {
// Operator-controlled hide list: entitlements the license
// grants but the operator doesn't want rendered on the buy
// page (e.g. when a higher tier card uses "Everything in
// Creator, plus:" marketing copy and doesn't need to repeat
// already-implied entitlements). The entitlements still
// appear on the issued license — this only filters display.
let hidden_on_buy: std::collections::HashSet<&str> = p
.metadata
.get("hidden_entitlements")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
.unwrap_or_default();
let visible_entitlements: Vec<&String> = p
.entitlements
.iter()
.filter(|s| !hidden_on_buy.contains(s.as_str()))
.collect();
let entitlements_html = if visible_entitlements.is_empty() {
String::new()
} else {
let catalog = product.entitlements_catalog.as_deref().unwrap_or(&[]);
let lis: Vec<String> = p
.entitlements
let lis: Vec<String> = visible_entitlements
.iter()
.map(|slug| {
let entry = catalog.iter().find(|e| &e.slug == slug);
let entry = catalog.iter().find(|e| &e.slug == *slug);
let display = entry
.map(|e| if e.name.trim().is_empty() { e.slug.as_str() } else { e.name.as_str() })
.unwrap_or(slug.as_str());
+10
View File
@@ -834,6 +834,15 @@ pub async fn list_public_policies(
Some("below") => "below",
_ => "above",
};
// Entitlement slugs the operator chose to hide from the
// buy-page tier-card display. The license still grants
// these — this only filters what buyers see. SDKs that
// render dynamic pricing pages should also filter on this.
let hidden_entitlements = p
.metadata
.get("hidden_entitlements")
.cloned()
.unwrap_or_else(|| json!([]));
let price_sats = p.price_sats_override.unwrap_or(product.price_sats);
// Featured discount (if any) — compute the post-discount
// price the buyer would actually pay if they bought right
@@ -871,6 +880,7 @@ pub async fn list_public_policies(
"entitlements": p.entitlements,
"marketing_bullets": marketing_bullets,
"marketing_bullets_position": marketing_bullets_position,
"hidden_entitlements": hidden_entitlements,
"highlighted": highlighted,
// Recurring-subscription cadence — buy page renders
// "Renews every N days" / "$X/month" when is_recurring=true.