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.
|
||||
|
||||
Reference in New Issue
Block a user