From eb360a325ea54a80343be6a286b81f36fa0b9d3e Mon Sep 17 00:00:00 2001 From: Grant Date: Mon, 11 May 2026 13:31:53 -0500 Subject: [PATCH] =?UTF-8?q?v0.2.0:19=20=E2=80=94=20Marketing=20bullets:=20?= =?UTF-8?q?choose=20above=20or=20below=20entitlements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//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) --- licensing-service/src/api/buy_page.rs | 23 ++++++-- licensing-service/src/api/policies.rs | 19 +++++-- licensing-service/web/index.html | 77 ++++++++++++++++++++++----- startos/versions/v0.2.0.ts | 10 +++- 4 files changed, 108 insertions(+), 21 deletions(-) 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