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.
+115 -24
View File
@@ -1977,12 +1977,16 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
// textarea. The picker pre-selects the policy's current
// entitlements; the textarea pre-fills with one slug per line.
const editCatalog_pol = (prod && prod.entitlements_catalog) || []
const initialHidden_pol = Array.isArray(meta.hidden_entitlements)
? meta.hidden_entitlements
: []
const entField = (() => {
const host = el('div', { 'data-ent-host': '1' })
if (editCatalog_pol.length > 0) {
const picker = entitlementBubblePicker(editCatalog_pol, pol.entitlements || [])
const picker = entitlementBubblePicker(editCatalog_pol, pol.entitlements || [], initialHidden_pol)
host.appendChild(picker.element)
host._read = picker.read
host._readHidden = picker.readHidden
host._mode = 'bubbles'
} else {
const fallback = formInput('e_pol_entitlements', 'Entitlements (one per line, or comma-separated)', {
@@ -2151,6 +2155,15 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
} else {
delete newMetadata.marketing_bullets_position
}
// Per-chip "hide on buy page" toggles from the bubble picker.
// Only persisted when non-empty; the buy page + admin grid
// treat an absent field as "show everything".
const entHostNode = card.querySelector('[data-ent-host]')
const hiddenList = (entHostNode && entHostNode._readHidden)
? entHostNode._readHidden().filter((s) => ents.includes(s))
: []
if (hiddenList.length > 0) newMetadata.hidden_entitlements = hiddenList
else delete newMetadata.hidden_entitlements
const priceRaw = card.querySelector('[name=e_pol_price]').value.trim()
const price_sats_override = priceRaw === '' ? null : Math.max(0, parseInt(priceRaw, 10) || 0)
// Recurring subscription — send the fields whenever the operator
@@ -2324,6 +2337,13 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
// Top-margin: tighten when this list follows another (marketing
// list rendered ABOVE), normal when it leads the section.
const entLeadsSection = !marketingList || bulletsBelow
// Per-chip "hide on buy page" list. The license still grants these,
// but the buy-page tier card renders them filtered out. Surface that
// here as muted strikethrough + a small "(hidden on buy)" hint so
// the operator can spot which chips don't appear to buyers.
const hiddenEnts = Array.isArray((pol.metadata || {}).hidden_entitlements)
? new Set(pol.metadata.hidden_entitlements)
: new Set()
const entChips = (pol.entitlements || []).length === 0
? null
: el('ul', {
@@ -2332,14 +2352,22 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
const entry = cat.find((c) => c.slug === slug)
const display = entry && entry.name ? entry.name : slug
const desc = entry && entry.description ? entry.description : slug
const isHidden = hiddenEnts.has(slug)
return el('li', {
title: desc,
style: 'padding:2px 0 2px 16px; position:relative',
title: isHidden
? desc + ' — Hidden from the buy page tier card (license still grants it).'
: desc,
style: 'padding:2px 0 2px 16px; position:relative' +
(isHidden ? '; opacity:0.55; text-decoration:line-through' : ''),
}, [
el('span', {
style: 'position:absolute; left:0; top:2px; color:var(--gold-700); font-weight:700',
}, '✓'),
display,
isHidden ? el('span', {
style: 'margin-left:6px; font-size:10.5px; color:var(--ink-500); ' +
'text-decoration:none; font-style:italic',
}, '(hidden on buy)') : null,
])
}))
@@ -2636,10 +2664,12 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
const cat = product.entitlements_catalog || []
const entHost = el('div')
let entRead = () => []
let entReadHidden = () => []
if (cat.length > 0) {
const picker = entitlementBubblePicker(cat, [])
const picker = entitlementBubblePicker(cat, [], [])
entHost.appendChild(picker.element)
entRead = picker.read
entReadHidden = picker.readHidden
} else {
const textarea = el('textarea', {
class: 'input', rows: '2',
@@ -2787,6 +2817,13 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
metadata.marketing_bullets_position = 'below'
}
}
// Hide-on-buy-page entitlement slugs from the bubble picker.
// Filter against the granted-set so we never persist stale
// hidden entries (de-selecting a chip clears its hidden
// state too, but defensive).
const grantedEnts = entRead()
const hiddenEnts = entReadHidden().filter((s) => grantedEnts.includes(s))
if (hiddenEnts.length > 0) metadata.hidden_entitlements = hiddenEnts
const body = {
product_slug: product.slug,
slug: slugInput.value.trim(),
@@ -5950,39 +5987,89 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
* container.appendChild(picker.element)
* const slugs = picker.read() // -> ['core', 'pro']
*/
function entitlementBubblePicker(catalog, initialSelection) {
function entitlementBubblePicker(catalog, initialSelection, initialHidden) {
const selected = new Set(Array.isArray(initialSelection) ? initialSelection : [])
// Per-chip "hidden on buy page" set. An entitlement can be granted
// by the license (in `selected`) without being displayed on the
// public buy-page tier card — useful for "Everything in Creator,
// plus:" marketing where the operator doesn't want to duplicate
// already-implied entitlements visually.
const hidden = new Set(Array.isArray(initialHidden) ? initialHidden : [])
const host = el('div', {
style: 'display:flex; flex-wrap:wrap; gap:6px; margin-top:4px',
})
const pills = []
function paint(pill, slug) {
const isSel = selected.has(slug)
const isHid = hidden.has(slug)
// Pill container styling tracks selection state.
pill.style.background = isSel ? 'var(--gold-500)' : 'transparent'
pill.style.color = isSel ? 'var(--navy-950)' : 'var(--ink-700)'
pill.style.borderColor = isSel ? 'var(--gold-500)' : 'var(--border-2)'
// Eye toggle: only visible/clickable when entitlement is selected.
const eye = pill.querySelector('[data-eye]')
const nameEl = pill.querySelector('[data-name]')
if (eye) {
eye.style.display = isSel ? 'inline-flex' : 'none'
// "Open eye" = visible on buy; "closed eye" = hidden on buy.
eye.textContent = isHid ? '\u{1F441}\u{200D}\u{1F5E8}' : '\u{1F441}'
eye.style.opacity = isHid ? '0.5' : '1'
eye.title = isHid
? 'Hidden from the buy page tier card. Click to show.'
: 'Shown on the buy page tier card. Click to hide (license still grants it).'
}
if (nameEl) {
nameEl.style.textDecoration = isSel && isHid ? 'line-through' : 'none'
nameEl.style.opacity = isSel && isHid ? '0.6' : '1'
}
}
function renderPill(entry) {
const isSel = selected.has(entry.slug)
const pill = el('button', {
type: 'button',
title: entry.description || entry.slug,
// Container is a flex `<span>` (not a `<button>`) so we can nest
// two clickable buttons inside — the selection button and the eye
// toggle button — without nested-interactive-element issues.
const pill = el('span', {
'data-slug': entry.slug,
style:
'padding:6px 12px; border-radius:999px; cursor:pointer; ' +
'display:inline-flex; align-items:center; gap:6px; ' +
'padding:5px 10px; border-radius:999px; ' +
'font-family:var(--font-body); font-size:13px; font-weight:500; ' +
'transition:all 100ms; ' +
(isSel
? 'background:var(--gold-500); color:var(--navy-950); border:1px solid var(--gold-500); '
: 'background:transparent; color:var(--ink-700); border:1px solid var(--border-2); '),
}, entry.name || entry.slug)
pill.addEventListener('click', () => {
'border:1px solid var(--border-2); background:transparent; ' +
'transition:background 100ms, color 100ms, border-color 100ms;',
}, [
el('button', {
type: 'button',
'data-name': '1',
title: entry.description || entry.slug,
style: 'background:none; border:none; padding:0; cursor:pointer; ' +
'font:inherit; color:inherit; text-align:left;',
}, entry.name || entry.slug),
el('button', {
type: 'button',
'data-eye': '1',
style: 'background:none; border:none; padding:0 0 0 2px; cursor:pointer; ' +
'font-size:13px; line-height:1; display:none;',
}, ''),
])
const nameBtn = pill.querySelector('[data-name]')
const eyeBtn = pill.querySelector('[data-eye]')
nameBtn.addEventListener('click', () => {
if (selected.has(entry.slug)) {
selected.delete(entry.slug)
// De-selecting also clears any "hidden" state so stale entries
// don't accumulate in metadata.
hidden.delete(entry.slug)
} else {
selected.add(entry.slug)
}
// Re-style only this pill rather than re-rendering the host.
const nowSel = selected.has(entry.slug)
pill.style.background = nowSel ? 'var(--gold-500)' : 'transparent'
pill.style.color = nowSel ? 'var(--navy-950)' : 'var(--ink-700)'
pill.style.borderColor = nowSel ? 'var(--gold-500)' : 'var(--border-2)'
paint(pill, entry.slug)
})
pills.push(pill)
eyeBtn.addEventListener('click', (e) => {
e.stopPropagation()
if (!selected.has(entry.slug)) return
if (hidden.has(entry.slug)) hidden.delete(entry.slug)
else hidden.add(entry.slug)
paint(pill, entry.slug)
})
paint(pill, entry.slug)
host.appendChild(pill)
}
;(catalog || []).forEach(renderPill)
@@ -5990,12 +6077,16 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
const wrap = el('div', { class: 'field' }, [
el('label', { class: 'lbl' }, 'Entitlements'),
el('div', { class: 'muted', style: 'font-size:12px; margin-bottom:6px' },
'Click each entitlement this tier should grant. Defined on the parent product\'s catalog.'),
'Click an entitlement to grant it. The eye toggle on a granted entitlement controls visibility on the buy page tier card (the license still grants it either way).'),
host,
])
return {
element: wrap,
read: () => Array.from(selected),
// Returns slugs that are SELECTED *and* hidden — callers filter
// for that pair so we never persist stale slugs that aren't even
// granted by the policy.
readHidden: () => Array.from(hidden).filter((s) => selected.has(s)),
}
}