diff --git a/licensing-service/src/api/buy_page.rs b/licensing-service/src/api/buy_page.rs index 42b4ff3..01a45b1 100644 --- a/licensing-service/src/api/buy_page.rs +++ b/licensing-service/src/api/buy_page.rs @@ -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 = p - .entitlements + let lis: Vec = 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()); diff --git a/licensing-service/src/api/policies.rs b/licensing-service/src/api/policies.rs index b907620..a389781 100644 --- a/licensing-service/src/api/policies.rs +++ b/licensing-service/src/api/policies.rs @@ -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. diff --git a/licensing-service/web/index.html b/licensing-service/web/index.html index 5952f7e..49823d0 100644 --- a/licensing-service/web/index.html +++ b/licensing-service/web/index.html @@ -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 `` (not a `