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
// 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();
+16 -3
View File
@@ -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.