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
+18 -5
View File
@@ -1094,8 +1094,15 @@ fn render_tier_picker(
// name + description (as a tooltip). Falls back to the // name + description (as a tooltip). Falls back to the
// Marketing bullets — operator-controlled copy from // Marketing bullets — operator-controlled copy from
// metadata.marketing_bullets. Rendered as ✓ checkmarks // metadata.marketing_bullets. Rendered as ✓ checkmarks
// above the entitlement bullets. Skipped silently if // above (default) or below the entitlement bullets based
// absent / wrong shape. // on metadata.marketing_bullets_position. Skipped silently
// if absent / wrong shape.
let bullets_below = p
.metadata
.get("marketing_bullets_position")
.and_then(|v| v.as_str())
.map(|s| s == "below")
.unwrap_or(false);
let marketing_html = p let marketing_html = p
.metadata .metadata
.get("marketing_bullets") .get("marketing_bullets")
@@ -1216,8 +1223,14 @@ fn render_tier_picker(
} else { } else {
classes.clone() classes.clone()
}; };
// Operator-controlled order: above (default) or below.
let (first_block, second_block) = if bullets_below {
(&entitlements_html, &marketing_html)
} else {
(&marketing_html, &entitlements_html)
};
format!( format!(
r#"<div class="{classes}" data-policy-slug="{slug}">{popular_pill}{featured_ribbon}<div class="tier-name">{name}</div>{original_price_html}<div class="tier-price">{price_fmt}<span class="tier-price-unit">{price_unit}{cadence_suffix}</span></div>{dur_html}{recurring_meta}{trial_banner}{trial_meta}{description_html}{marketing_html}{entitlements_html}<button type="button" class="tier-select-btn">Select</button></div>"#, r#"<div class="{classes}" data-policy-slug="{slug}">{popular_pill}{featured_ribbon}<div class="tier-name">{name}</div>{original_price_html}<div class="tier-price">{price_fmt}<span class="tier-price-unit">{price_unit}{cadence_suffix}</span></div>{dur_html}{recurring_meta}{trial_banner}{trial_meta}{description_html}{first_block}{second_block}<button type="button" class="tier-select-btn">Select</button></div>"#,
classes = classes, classes = classes,
slug = slug_attr, slug = slug_attr,
popular_pill = popular_pill, popular_pill = popular_pill,
@@ -1232,8 +1245,8 @@ fn render_tier_picker(
trial_banner = trial_banner, trial_banner = trial_banner,
trial_meta = trial_meta, trial_meta = trial_meta,
description_html = description_html, description_html = description_html,
marketing_html = marketing_html, first_block = first_block,
entitlements_html = entitlements_html, second_block = second_block,
) )
}) })
.collect(); .collect();
+16 -3
View File
@@ -814,14 +814,26 @@ pub async fn list_public_policies(
.and_then(|v| v.as_bool()) .and_then(|v| v.as_bool())
.unwrap_or(false); .unwrap_or(false);
// Marketing bullets: operator-controlled copy that renders // Marketing bullets: operator-controlled copy that renders
// as ✓ checkmarks ABOVE the entitlement bullets on the buy // as ✓ checkmarks above (default) or below the entitlement
// page. Stored as an array of strings in metadata; passes // bullets on the buy page based on
// through to JSON unchanged. // `marketing_bullets_position`. Stored as an array of
// strings in metadata; passes through to JSON unchanged.
let marketing_bullets = p let marketing_bullets = p
.metadata .metadata
.get("marketing_bullets") .get("marketing_bullets")
.cloned() .cloned()
.unwrap_or_else(|| json!([])); .unwrap_or_else(|| json!([]));
// "above" (default — matches prior behavior) or "below".
// Normalize anything else to "above" so SDK consumers don't
// have to defensively coerce.
let marketing_bullets_position = match p
.metadata
.get("marketing_bullets_position")
.and_then(|v| v.as_str())
{
Some("below") => "below",
_ => "above",
};
let price_sats = p.price_sats_override.unwrap_or(product.price_sats); let price_sats = p.price_sats_override.unwrap_or(product.price_sats);
// Featured discount (if any) — compute the post-discount // Featured discount (if any) — compute the post-discount
// price the buyer would actually pay if they bought right // price the buyer would actually pay if they bought right
@@ -858,6 +870,7 @@ pub async fn list_public_policies(
"is_trial": p.is_trial, "is_trial": p.is_trial,
"entitlements": p.entitlements, "entitlements": p.entitlements,
"marketing_bullets": marketing_bullets, "marketing_bullets": marketing_bullets,
"marketing_bullets_position": marketing_bullets_position,
"highlighted": highlighted, "highlighted": highlighted,
// Recurring-subscription cadence — buy page renders // Recurring-subscription cadence — buy page renders
// "Renews every N days" / "$X/month" when is_recurring=true. // "Renews every N days" / "$X/month" when is_recurring=true.
+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)', { const bulletsField = formInput('e_pol_bullets', 'Marketing bullets (optional)', {
textarea: true, textarea: true,
value: marketingBulletsInit, 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(() => { if (highlight) setTimeout(() => {
const cb = card.querySelector('[name=e_pol_highlight]') const cb = card.querySelector('[name=e_pol_highlight]')
if (cb) cb.checked = true 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]), el('div', { class: 'row-2' }, [graceField, machinesField]),
entField, entField,
bulletsField, bulletsField,
bulletsPositionField,
el('div', { class: 'row-2' }, [highlightField, trialField]), el('div', { class: 'row-2' }, [highlightField, trialField]),
// Tier ladder rank — sits in its own row above the recurring section. // Tier ladder rank — sits in its own row above the recurring section.
tierRankField, 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) .split('\n').map((s) => s.trim()).filter(Boolean)
if (newBullets.length > 0) newMetadata.marketing_bullets = newBullets if (newBullets.length > 0) newMetadata.marketing_bullets = newBullets
else delete newMetadata.marketing_bullets 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 priceRaw = card.querySelector('[name=e_pol_price]').value.trim()
const price_sats_override = priceRaw === '' ? null : Math.max(0, parseInt(priceRaw, 10) || 0) const price_sats_override = priceRaw === '' ? null : Math.max(0, parseInt(priceRaw, 10) || 0)
// Recurring subscription — send the fields whenever the operator // 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 : null
// Marketing bullets — operator-controlled copy that renders as // Marketing bullets — operator-controlled copy that renders as
// ✓ checkmarks ABOVE the entitlement bullets. Things like // ✓ checkmarks above (default) or below the entitlement chips.
// "Up to 5 products" or "BTCPay integration" that aren't real // Things like "Up to 5 products" or "BTCPay integration" that
// entitlement gates but are buyer-relevant. // aren't real entitlement gates but are buyer-relevant.
const marketingBullets = Array.isArray((pol.metadata || {}).marketing_bullets) const marketingBullets = Array.isArray((pol.metadata || {}).marketing_bullets)
? 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 const marketingList = marketingBullets.length === 0
? null ? null
: el('ul', { : 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', { }, marketingBullets.map((b) => el('li', {
style: 'padding:2px 0 2px 16px; position:relative', 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. // Entitlements as small chips with display name + tooltip.
const cat = product.entitlements_catalog || [] 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 const entChips = (pol.entitlements || []).length === 0
? null ? null
: el('ul', { : 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) => { }, (pol.entitlements || []).map((slug) => {
const entry = cat.find((c) => c.slug === slug) const entry = cat.find((c) => c.slug === slug)
const display = entry && entry.name ? entry.name : 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', { description ? el('p', {
style: 'font-size:13px; line-height:1.45; color:var(--ink-700); margin:6px 0 0', style: 'font-size:13px; line-height:1.45; color:var(--ink-700); margin:6px 0 0',
}, description) : null, }, description) : null,
marketingList, // Operator-controlled order: marketing bullets above (default)
entChips, // 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' }, el('div', { class: 'muted', style: 'font-size:11px; margin-top:4px' },
(pol._license_count || 0) + ' license' + ((pol._license_count || 0) === 1 ? '' : 's')), (pol._license_count || 0) + ' license' + ((pol._license_count || 0) === 1 ? '' : 's')),
actions, 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', 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;', 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 // Tip recipient (advanced — collapsed by default to keep the
// card narrow). // 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 // Free-form marketing bullets — operator-controlled copy that
// renders as additional ✓ checkmarks above the entitlement // renders as additional ✓ checkmarks alongside the entitlement
// bullets. Not enforced anywhere; pure marketing surface. // bullets. Position (above vs below entitlements) is operator-
// controlled per tier. Not enforced anywhere; pure marketing.
fieldRow('Marketing bullets', 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), bulletsTextarea),
fieldRow('Position on tier card',
'Where these bullets render relative to the entitlement chips.',
bulletsPositionSel),
// Recurring section — minimal, expanded inline (no nested // Recurring section — minimal, expanded inline (no nested
// disclosure; cards already imply compactness). // disclosure; cards already imply compactness).
el('div', { style: 'display:flex; align-items:center; gap:6px; margin-top:8px' }, [ 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) .filter(Boolean)
const metadata = {} const metadata = {}
if (highlightCb.checked) metadata.highlight = true 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 = { const body = {
product_slug: product.slug, product_slug: product.slug,
slug: slugInput.value.trim(), slug: slugInput.value.trim(),
+9 -1
View File
@@ -58,6 +58,14 @@ const RELEASE_NOTES = [
// in RELEASE_NOTES above (the milestone). Subsequent revisions // in RELEASE_NOTES above (the milestone). Subsequent revisions
// append here. // append here.
const ROUTINE_NOTES = [ const ROUTINE_NOTES = [
'0.2.0:19 — **Marketing-bullets position: above or below the entitlements.** Tiny operator-control add: pick where the free-form ✓ checkmark copy renders on each tier card.',
'',
'**The change.** Marketing bullets (`metadata.marketing_bullets`) have always rendered ABOVE the entitlement chips. That\'s usually right for "lifestyle" bullets like "Up to 5 products" / "BTCPay integration" — they sell the tier. But for tiers where the entitlements ARE the headline and the marketing bullets are caveats or fine-print, operators want them BELOW. New `metadata.marketing_bullets_position` field (`"above"` default, `"below"` opt-in) controls this per-policy. UI: small dropdown next to the bullets textarea on both create and edit forms. Renders consistently across the admin grid, the buy page, and the public `/v1/products/<slug>/policies` JSON (so SDK consumers stay in sync).',
'',
'**Test count: 87** (unchanged — metadata pass-through; no new branches to test).',
'',
'**Upgrade path.** v0.2.0:18 → v0.2.0:19 is a drop-in. No schema, no SDK breaking change. Existing policies keep rendering bullets above (the default). The new JSON field appears in policies-list responses but is optional and back-compat: old SDKs ignore it.',
'',
'0.2.0:18 — **Discount Codes form polish: less typing, clearer intent.** Three small admin-UI changes that make the create + edit forms less footgun-prone.', '0.2.0:18 — **Discount Codes form polish: less typing, clearer intent.** Three small admin-UI changes that make the create + edit forms less footgun-prone.',
'', '',
'**Max-uses: checkbox + dependent number, not "0 = unlimited".** Previously the form had a single number input with a hint that read `"0 = unlimited"`. That meant the default value was `0`, which displayed as "no cap" but read like "0 uses allowed." Now it\'s a "Limit total uses" checkbox + a number input that only appears when the checkbox is checked (default 100). Unchecked = no cap is sent. Edit form matches.', '**Max-uses: checkbox + dependent number, not "0 = unlimited".** Previously the form had a single number input with a hint that read `"0 = unlimited"`. That meant the default value was `0`, which displayed as "no cap" but read like "0 uses allowed." Now it\'s a "Limit total uses" checkbox + a number input that only appears when the checkbox is checked (default 100). Unchecked = no cap is sent. Edit form matches.',
@@ -338,7 +346,7 @@ const ROUTINE_NOTES = [
].join('\n\n') ].join('\n\n')
export const v0_2_0 = VersionInfo.of({ export const v0_2_0 = VersionInfo.of({
version: '0.2.0:18', version: '0.2.0:19',
releaseNotes: { en_US: ROUTINE_NOTES }, releaseNotes: { en_US: ROUTINE_NOTES },
// No on-disk transformation needed — v0.2.0:0 is a label change. // No on-disk transformation needed — v0.2.0:0 is a label change.
// SQLite-level migrations live separately under // SQLite-level migrations live separately under