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:
@@ -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#"<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,
|
||||
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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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/<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.',
|
||||
'',
|
||||
'**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
|
||||
|
||||
Reference in New Issue
Block a user