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:
@@ -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());
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user