diff --git a/licensing-service/src/api/buy_page.rs b/licensing-service/src/api/buy_page.rs
index 45fdb6b..40423bc 100644
--- a/licensing-service/src/api/buy_page.rs
+++ b/licensing-service/src/api/buy_page.rs
@@ -1094,8 +1094,15 @@ fn render_tier_picker(
// name + description (as a tooltip). Falls back to the
// Marketing bullets — operator-controlled copy from
// metadata.marketing_bullets. Rendered as ✓ checkmarks
- // above the entitlement bullets. Skipped silently if
- // absent / wrong shape.
+ // above (default) or below the entitlement bullets based
+ // 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
.metadata
.get("marketing_bullets")
@@ -1216,8 +1223,14 @@ fn render_tier_picker(
} else {
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!(
- r#"
{popular_pill}{featured_ribbon}
{name}
{original_price_html}
{price_fmt}{price_unit}{cadence_suffix}
{dur_html}{recurring_meta}{trial_banner}{trial_meta}{description_html}{marketing_html}{entitlements_html}
"#,
+ r#"{popular_pill}{featured_ribbon}
{name}
{original_price_html}
{price_fmt}{price_unit}{cadence_suffix}
{dur_html}{recurring_meta}{trial_banner}{trial_meta}{description_html}{first_block}{second_block}
"#,
classes = classes,
slug = slug_attr,
popular_pill = popular_pill,
@@ -1232,8 +1245,8 @@ fn render_tier_picker(
trial_banner = trial_banner,
trial_meta = trial_meta,
description_html = description_html,
- marketing_html = marketing_html,
- entitlements_html = entitlements_html,
+ first_block = first_block,
+ second_block = second_block,
)
})
.collect();
diff --git a/licensing-service/src/api/policies.rs b/licensing-service/src/api/policies.rs
index 9710d83..b907620 100644
--- a/licensing-service/src/api/policies.rs
+++ b/licensing-service/src/api/policies.rs
@@ -814,14 +814,26 @@ pub async fn list_public_policies(
.and_then(|v| v.as_bool())
.unwrap_or(false);
// Marketing bullets: operator-controlled copy that renders
- // as ✓ checkmarks ABOVE the entitlement bullets on the buy
- // page. Stored as an array of strings in metadata; passes
- // through to JSON unchanged.
+ // as ✓ checkmarks above (default) or below the entitlement
+ // bullets on the buy page based on
+ // `marketing_bullets_position`. Stored as an array of
+ // strings in metadata; passes through to JSON unchanged.
let marketing_bullets = p
.metadata
.get("marketing_bullets")
.cloned()
.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);
// Featured discount (if any) — compute the post-discount
// 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,
"entitlements": p.entitlements,
"marketing_bullets": marketing_bullets,
+ "marketing_bullets_position": marketing_bullets_position,
"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 9ab5e6d..64f5d0f 100644
--- a/licensing-service/web/index.html
+++ b/licensing-service/web/index.html
@@ -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(),
diff --git a/startos/versions/v0.2.0.ts b/startos/versions/v0.2.0.ts
index 1a3ccb7..afcc085 100644
--- a/startos/versions/v0.2.0.ts
+++ b/startos/versions/v0.2.0.ts
@@ -58,6 +58,14 @@ const RELEASE_NOTES = [
// in RELEASE_NOTES above (the milestone). Subsequent revisions
// append here.
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//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.',
'',
'**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')
export const v0_2_0 = VersionInfo.of({
- version: '0.2.0:18',
+ version: '0.2.0:19',
releaseNotes: { en_US: ROUTINE_NOTES },
// No on-disk transformation needed — v0.2.0:0 is a label change.
// SQLite-level migrations live separately under