v0.2.0:19 — Marketing bullets: choose above or below entitlements

Operator picks where the free-form ✓ checkmark copy renders on each
tier card. Default "above" matches prior behavior; "below" is opt-in
per policy.

- New metadata field metadata.marketing_bullets_position ("above" |
  "below"). Persisted only when bullets exist AND choice != default.
- UI: select next to the bullets textarea on create + edit forms.
- Admin grid: swaps marketingList + entChips order accordingly,
  including the top-margin tighten-up so the lists hug each other.
- Buy page (buy_page.rs): swaps marketing_html + entitlements_html in
  the tier-card template via destructured (first, second) tuple.
- Public /v1/products/<slug>/policies: exposes the position field as
  "above" | "below" (normalized) so SDK consumers stay in sync.

UI-only/metadata-only; no schema, no SDK breaking change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Grant
2026-05-11 13:31:53 -05:00
parent bb53d708a1
commit eb360a325e
4 changed files with 108 additions and 21 deletions
+65 -12
View File
@@ -1999,8 +1999,16 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
const bulletsField = formInput('e_pol_bullets', 'Marketing bullets (optional)', {
textarea: true,
value: marketingBulletsInit,
hint: 'One per line. Renders as ✓ checkmarks above the entitlements on the buy page. Use for things like "Up to 5 products" or "BTCPay integration" that aren\'t real entitlements.',
hint: 'One per line. Renders as ✓ checkmarks on the tier card. Use for things like "Up to 5 products" or "BTCPay integration" that aren\'t real entitlements.',
})
// Marketing bullets position — "above" (default, prior behavior) or
// "below" the entitlement chips on the tier card. Pre-populated
// from existing metadata.marketing_bullets_position.
const bulletsPositionInit = meta.marketing_bullets_position === 'below' ? 'below' : 'above'
const bulletsPositionField = formSelect('e_pol_bullets_position', 'Bullets position on tier card', [
{ value: 'above', label: 'Above entitlements' },
{ value: 'below', label: 'Below entitlements' },
], { value: bulletsPositionInit })
if (highlight) setTimeout(() => {
const cb = card.querySelector('[name=e_pol_highlight]')
if (cb) cb.checked = true
@@ -2085,6 +2093,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
el('div', { class: 'row-2' }, [graceField, machinesField]),
entField,
bulletsField,
bulletsPositionField,
el('div', { class: 'row-2' }, [highlightField, trialField]),
// Tier ladder rank — sits in its own row above the recurring section.
tierRankField,
@@ -2133,6 +2142,15 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
.split('\n').map((s) => s.trim()).filter(Boolean)
if (newBullets.length > 0) newMetadata.marketing_bullets = newBullets
else delete newMetadata.marketing_bullets
// Position: only persist when bullets exist AND the operator
// picked something other than the default ("above"). Keeps
// metadata clean for tiers that use defaults or have no bullets.
const newBulletsPos = card.querySelector('[name=e_pol_bullets_position]').value
if (newBullets.length > 0 && newBulletsPos === 'below') {
newMetadata.marketing_bullets_position = 'below'
} else {
delete newMetadata.marketing_bullets_position
}
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
@@ -2278,16 +2296,20 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
: null
// Marketing bullets — operator-controlled copy that renders as
// ✓ checkmarks ABOVE the entitlement bullets. Things like
// "Up to 5 products" or "BTCPay integration" that aren't real
// entitlement gates but are buyer-relevant.
// ✓ checkmarks above (default) or below the entitlement chips.
// Things like "Up to 5 products" or "BTCPay integration" that
// aren't real entitlement gates but are buyer-relevant.
const marketingBullets = Array.isArray((pol.metadata || {}).marketing_bullets)
? pol.metadata.marketing_bullets
: []
const bulletsBelow = (pol.metadata || {}).marketing_bullets_position === 'below'
// Tighten top-margin when marketing list follows entitlements
// (bulletsBelow=true) — entChips renders first with the normal
// 8px gap, marketingList trails directly under it.
const marketingList = marketingBullets.length === 0
? null
: el('ul', {
style: 'list-style:none; padding:0; margin:8px 0 0; font-size:12.5px; color:var(--ink-700)',
style: 'list-style:none; padding:0; margin:' + (bulletsBelow ? '2px' : '8px') + ' 0 0; font-size:12.5px; color:var(--ink-700)',
}, marketingBullets.map((b) => el('li', {
style: 'padding:2px 0 2px 16px; position:relative',
}, [
@@ -2299,10 +2321,13 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
// Entitlements as small chips with display name + tooltip.
const cat = product.entitlements_catalog || []
// Top-margin: tighten when this list follows another (marketing
// list rendered ABOVE), normal when it leads the section.
const entLeadsSection = !marketingList || bulletsBelow
const entChips = (pol.entitlements || []).length === 0
? null
: el('ul', {
style: 'list-style:none; padding:0; margin:' + (marketingList ? '2px' : '8px') + ' 0 0; font-size:12.5px; color:var(--ink-700)',
style: 'list-style:none; padding:0; margin:' + (entLeadsSection ? '8px' : '2px') + ' 0 0; font-size:12.5px; color:var(--ink-700)',
}, (pol.entitlements || []).map((slug) => {
const entry = cat.find((c) => c.slug === slug)
const display = entry && entry.name ? entry.name : slug
@@ -2486,8 +2511,10 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
description ? el('p', {
style: 'font-size:13px; line-height:1.45; color:var(--ink-700); margin:6px 0 0',
}, description) : null,
marketingList,
entChips,
// Operator-controlled order: marketing bullets above (default)
// or below the entitlements (metadata.marketing_bullets_position).
bulletsBelow ? entChips : marketingList,
bulletsBelow ? marketingList : entChips,
el('div', { class: 'muted', style: 'font-size:11px; margin-top:4px' },
(pol._license_count || 0) + ' license' + ((pol._license_count || 0) === 1 ? '' : 's')),
actions,
@@ -2647,6 +2674,19 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
placeholder: 'One bullet per line — e.g.\nUp to 5 products\nBTCPay integration\nWebhooks + audit log',
style: 'font-family:var(--font-body); font-size:12px; line-height:1.45;',
})
// Position: where the marketing bullets render relative to the
// entitlements list on the tier card. "above" matches the previous
// hardcoded behavior; "below" puts them after entitlements (useful
// when marketing bullets are general value-prop blurbs and the
// entitlements are the technical contract a buyer wants to see
// first).
const bulletsPositionSel = el('select', {
class: 'input',
style: 'font-size:12px; max-width:200px',
}, [
el('option', { value: 'above' }, 'Above entitlements'),
el('option', { value: 'below' }, 'Below entitlements'),
])
// Tip recipient (advanced — collapsed by default to keep the
// card narrow).
@@ -2685,11 +2725,15 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
]),
]),
// Free-form marketing bullets — operator-controlled copy that
// renders as additional ✓ checkmarks above the entitlement
// bullets. Not enforced anywhere; pure marketing surface.
// renders as additional ✓ checkmarks alongside the entitlement
// bullets. Position (above vs below entitlements) is operator-
// controlled per tier. Not enforced anywhere; pure marketing.
fieldRow('Marketing bullets',
'One per line. Buyer sees these as ✓ checkmarks above the entitlement bullets on the buy page. Use for things like "Up to 5 products" or "BTCPay integration" that aren\'t real entitlements.',
'One per line. Buyer sees these as ✓ checkmarks on the tier card. Use for things like "Up to 5 products" or "BTCPay integration" that aren\'t real entitlements.',
bulletsTextarea),
fieldRow('Position on tier card',
'Where these bullets render relative to the entitlement chips.',
bulletsPositionSel),
// Recurring section — minimal, expanded inline (no nested
// disclosure; cards already imply compactness).
el('div', { style: 'display:flex; align-items:center; gap:6px; margin-top:8px' }, [
@@ -2733,7 +2777,16 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
.filter(Boolean)
const metadata = {}
if (highlightCb.checked) metadata.highlight = true
if (marketingBullets.length > 0) metadata.marketing_bullets = marketingBullets
if (marketingBullets.length > 0) {
metadata.marketing_bullets = marketingBullets
// Only persist position when bullets exist — keeps
// metadata clean for tiers that don't use bullets.
// Default ("above") matches the previous hardcoded
// behavior, so we only write the field when it differs.
if (bulletsPositionSel.value === 'below') {
metadata.marketing_bullets_position = 'below'
}
}
const body = {
product_slug: product.slug,
slug: slugInput.value.trim(),