diff --git a/licensing-service/src/api/buy_page.rs b/licensing-service/src/api/buy_page.rs index 88f5d9f..a33c345 100644 --- a/licensing-service/src/api/buy_page.rs +++ b/licensing-service/src/api/buy_page.rs @@ -1133,7 +1133,15 @@ fn render_tier_picker( .map(|c| crate::api::purchase::compute_discount(&c.kind, c.amount, base_price_units)) .unwrap_or(0); let post_discount_units = (base_price_units - discount_units).max(0); - let (price_fmt, price_unit) = if product.price_currency == "SAT" { + // Render free tiers as "Free" rather than "0 sats" / "0.00 + // USD". Matches the price card below the tier picker, which + // already swaps the price + unit to "FREE" via the JS path. + // Cadence suffix is suppressed below as well, since a + // "Free / yr" suffix would be incoherent. + let is_free = post_discount_units == 0; + let (price_fmt, price_unit) = if is_free { + ("Free".to_string(), String::new()) + } else if product.price_currency == "SAT" { (format_thousands(post_discount_units), "sats".to_string()) } else { let cents = post_discount_units; @@ -1395,6 +1403,12 @@ fn render_tier_picker( "
{}{}{}{}
", dur_html, recurring_meta, trial_banner, trial_meta ); + // Suppress the cadence suffix on free tiers so the price + // doesn't render as "Free /yr" or similar. The "Free" + // string alone is enough; the recurring_meta line below + // (e.g. "Renews annually") still surfaces the cadence for + // recurring tiers that aren't free. + let cadence_suffix = if is_free { "" } else { cadence_suffix }; format!( r#"
{popular_pill}{featured_ribbon}{launch_meta_html}
{name}
{original_price_html}
{price_fmt}{price_unit}{cadence_suffix}
{meta_block_html}{description_html}{features_html}
"#, classes = classes, diff --git a/startos/versions/v0.2.0.ts b/startos/versions/v0.2.0.ts index 2e10c54..9972203 100644 --- a/startos/versions/v0.2.0.ts +++ b/startos/versions/v0.2.0.ts @@ -58,6 +58,8 @@ const RELEASE_NOTES = [ // in RELEASE_NOTES above (the milestone). Subsequent revisions // append here. const ROUTINE_NOTES = [ + '0.2.0:35 — **Free tiers render as "Free" on the buy page tier card.** Previously the server rendered a 0-priced tier as `0 sats` (or `0.00 USD`) in the tier-card price headline, even though the price card BELOW the picker already swapped to `FREE` via the JS path. Inconsistency fixed at the server-render layer: when post-discount price is 0, the tier card renders the headline as `Free` with no unit suffix and no cadence (`Free /yr` would be incoherent). Recurring-meta line ("Renews annually") still surfaces beneath for recurring tiers that happen to be free, so the cadence is still visible — just not stuffed into the headline. Cosmetic; no API or schema change.', + '', '0.2.0:34 — **Buy page: featured discount pre-populates the code field and shows "Launch special applied" on load.** Previously the launch-special discount auto-applied silently at payment time but the discount-code input was empty, leaving buyers unsure whether they needed to type anything. Now: when a tier has an active featured code, the input renders pre-filled with the code (e.g. `LAUNCH`) and the green "Launch special applied" status badge shows on load. The price card has always rendered the slashed-original + discounted-current price; this change just makes the form match. Tier switches clear an auto-populated code so a code that doesn\'t apply to the new tier doesn\'t linger; buyer-typed codes are untouched. UI / JS only; no API or schema change.', '', '0.2.0:33 — **Drop a long-standing unused-variable warning.** Removed `let invoice_id_safe = html_escape(&invoice_id);` in `src/api/mod.rs` — the value was computed but never referenced anywhere in the thank-you-page template (the HTML uses `invoice_id_json` for the inline JS, and the on-screen invoice id renders from JS via that JSON variable). One-line cleanup; `cargo check` is now warning-free. No behavior change.', @@ -493,7 +495,7 @@ const ROUTINE_NOTES = [ ].join('\n\n') export const v0_2_0 = VersionInfo.of({ - version: '0.2.0:34', + version: '0.2.0:35', releaseNotes: { en_US: ROUTINE_NOTES }, // No on-disk transformation needed — v0.2.0:0 is a label change. // SQLite-level migrations live separately under