v0.2.0:39 — Buy page: render tier card for single-public-policy products

Previously the tier picker gated on `policies.len() < 2` and returned
an empty string when a product had only one public policy. Buyers
saw just the price card + form — none of the entitlements, marketing
bullets, or description the operator had carefully authored on that
tier. Reported against the Recap product, which has 3 policies but
only Pro public; Pro's bullets were invisible to buyers.

Fixed:

- render_tier_picker gate flipped from `< 2` to `is_empty()`. A
  single public policy now renders a single tier card.
- New `.tiers-1` grid class: one centered column at ~480px max-width.
  Keeps the single card from stretching to the full 1040px container.
- `n` computation extends to handle 1 in the existing match arm.

The price card below the picker still renders unchanged for the
single-policy case — acts as the buy-confirmation summary. Operators
keeping most tiers private and only exposing one to buyers now get
the same rich tier-card render that multi-tier products always had.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Grant
2026-05-11 22:48:33 -05:00
parent 5c7d66dbb2
commit 1a14b9c2e3
2 changed files with 14 additions and 4 deletions
+11 -3
View File
@@ -305,6 +305,10 @@ h1 {{
1fr /* row 7: features (fills) */
auto; /* row 8: button */
}}
/* Single-policy products: one centered card at a comfortable width.
Wide enough to read entitlements + marketing bullets clearly without
stretching across the full container. */
.tiers-1 {{ grid-template-columns:minmax(0, 480px); justify-content:center; }}
.tiers-2 {{ grid-template-columns:repeat(2, 1fr); }}
.tiers-3 {{ grid-template-columns:repeat(3, 1fr); }}
.tiers-4 {{ grid-template-columns:repeat(2, 1fr); }}
@@ -1093,19 +1097,23 @@ footer.kfooter a:hover {{ color:var(--navy-900); }}
}
/// Build the server-rendered tier-picker HTML. Returns an empty string
/// when the product has fewer than 2 public policies (i.e., the existing
/// single-price view is sufficient).
/// only when the product has zero public policies (the bare price-card +
/// form fallback covers that case). For one public policy, we still
/// render a single tier card so the operator-configured entitlements
/// and marketing bullets surface — without this, single-tier products
/// showed only price + form, eating the operator's tier copy.
fn render_tier_picker(
policies: &[crate::models::Policy],
initial: &Option<crate::models::Policy>,
product: &crate::models::Product,
featured_by_policy: &std::collections::HashMap<String, crate::models::DiscountCode>,
) -> String {
if policies.len() < 2 {
if policies.is_empty() {
return String::new();
}
let n = policies.len().min(4);
let class_n = match n {
1 => "tiers-1",
2 => "tiers-2",
3 => "tiers-3",
_ => "tiers-4",
+3 -1
View File
@@ -58,6 +58,8 @@ const RELEASE_NOTES = [
// in RELEASE_NOTES above (the milestone). Subsequent revisions
// append here.
const ROUTINE_NOTES = [
'0.2.0:39 — **Buy page now renders a tier card for single-public-policy products.** Previously the tier picker only rendered when a product had two or more public policies; single-public-policy products fell back to a bare price card + form, swallowing all the operator-configured entitlements, marketing bullets, and tier descriptions. Fixed: render a single centered tier card (new `.tiers-1` grid class, ~480px max-width) whenever there\'s at least one public policy. Operators who keep most tiers private and only expose one (e.g. "Pro" public, "Core" and "Max" admin-only) now see the same rich tier-card render that multi-tier products get. The price card below still renders unchanged as the buy-confirmation summary.',
'',
'0.2.0:38 — **Admin UI: Create-product Cancel button + modal-overflow fix across all dialogs.** Two operator-reported bugs.',
'',
'**Create product: Cancel button.** The "Create a new product" disclosure had a Create button but no way to back out without scrolling up to the chevron. Added a secondary Cancel button alongside Create — collapses the disclosure (returns to the products list) without clearing typed input, so re-expanding picks up where the operator left off.',
@@ -505,7 +507,7 @@ const ROUTINE_NOTES = [
].join('\n\n')
export const v0_2_0 = VersionInfo.of({
version: '0.2.0:38',
version: '0.2.0:39',
releaseNotes: { en_US: ROUTINE_NOTES },
// No on-disk transformation needed — v0.2.0:0 is a label change.
// SQLite-level migrations live separately under