v0.2.0:23 — Buy-page polish: width balance, auto-discount, bullet gap
Three concrete fixes after :21 rolled the wider buy page:
- Layout proportions. Headline + price card span the full 1040px
container with center-aligned text (matches the tier picker
width). Only the email/discount/pay form stays narrow at 560px
since input fields look stretched at 1040px.
- Featured discount auto-applies on the headline price. Tier JSON
now carries each tier's featured-discount snapshot, and the JS
selectTier() renders strike-through + discounted price when an
active featured code applies. Tier switching also re-applies the
featured code for the new tier instead of resetting to base.
- Marketing-bullets gap. Added mirror CSS rule
`.tier-entitlements + .tier-bullets { margin-top:2px }` so the
bullets-below layout has the same tight visual continuity that
bullets-above already had.
Public buy-page CSS + JS only.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -143,9 +143,12 @@ pub async fn render(
|
|||||||
&product,
|
&product,
|
||||||
&featured_by_policy,
|
&featured_by_policy,
|
||||||
);
|
);
|
||||||
// Compact JSON map of {policy_slug: {price, name}} so the JS can update
|
// Compact JSON map of {policy_slug: {price, name, featured?}} so the
|
||||||
// the price card when the buyer clicks a different tier.
|
// JS can update the price card when the buyer clicks a different tier.
|
||||||
let tiers_json = build_tiers_json(&public_policies, &product);
|
// Featured info is keyed per-policy so each tier's headline price
|
||||||
|
// reflects an active launch-special discount automatically (matches
|
||||||
|
// the tier-card ribbon + slashed-price display).
|
||||||
|
let tiers_json = build_tiers_json(&public_policies, &product, &featured_by_policy);
|
||||||
|
|
||||||
let body = format!(
|
let body = format!(
|
||||||
r#"<!doctype html>
|
r#"<!doctype html>
|
||||||
@@ -197,21 +200,25 @@ body {{
|
|||||||
color:var(--ink-500);
|
color:var(--ink-500);
|
||||||
margin-left:auto;
|
margin-left:auto;
|
||||||
}}
|
}}
|
||||||
/* Outer container width — was 560px (single-column friendly), now
|
/* Outer container width. The 3-tier picker breathes at this width and
|
||||||
wider so the 3-tier grid below has room to breathe and matches the
|
matches the admin Policies page layout. Headline + price card are
|
||||||
admin Policies page layout. Inner text + form blocks are constrained
|
centered text within the full width; only the form below is
|
||||||
back to ~560px reading width by the `.wrap > :not(.tiers)` rule
|
constrained narrower for focused interaction. */
|
||||||
below so only the tier grid breaks out. */
|
|
||||||
.wrap {{ max-width:1040px; margin:48px auto; padding:0 24px; }}
|
.wrap {{ max-width:1040px; margin:48px auto; padding:0 24px; }}
|
||||||
.wrap > :not(.tiers) {{ max-width:560px; margin-left:auto; margin-right:auto; }}
|
/* Form stays narrow so input fields aren't oddly stretched. */
|
||||||
|
.wrap > form {{ max-width:560px; margin-left:auto; margin-right:auto; }}
|
||||||
|
/* Headline elements + price card text-align center at the wider width
|
||||||
|
so the page visually stays centered as readers scan top-to-bottom. */
|
||||||
|
.wrap > h1, .wrap > .product-slug, .wrap > .description {{ text-align:center; }}
|
||||||
|
.cert {{ text-align:center; }}
|
||||||
.eyebrow {{
|
.eyebrow {{
|
||||||
font-size:11.5px; font-weight:700; letter-spacing:0.18em;
|
font-size:11.5px; font-weight:700; letter-spacing:0.18em;
|
||||||
text-transform:uppercase; color:var(--gold-700); margin-bottom:14px;
|
text-transform:uppercase; color:var(--gold-700); margin-bottom:14px;
|
||||||
/* `flex; width:fit-content` instead of `inline-flex` so the
|
/* `flex; width:fit-content` + explicit margin:auto so this small
|
||||||
wrap-children margin:auto centering rule applies — otherwise
|
pill sits centered like its block siblings below. (Was inline-flex,
|
||||||
this inline element would sit flush left of the wider 1040px
|
which can't be centered via margin:auto.) */
|
||||||
container while its centered block-level siblings sit middle. */
|
|
||||||
display:flex; width:fit-content; align-items:center; gap:10px;
|
display:flex; width:fit-content; align-items:center; gap:10px;
|
||||||
|
margin-left:auto; margin-right:auto;
|
||||||
}}
|
}}
|
||||||
.eyebrow::before {{ content:''; display:inline-block; width:28px; height:1px; background:var(--gold-500); }}
|
.eyebrow::before {{ content:''; display:inline-block; width:28px; height:1px; background:var(--gold-500); }}
|
||||||
h1 {{
|
h1 {{
|
||||||
@@ -385,9 +392,10 @@ h1 {{
|
|||||||
content:'✓'; position:absolute; left:0; top:3px;
|
content:'✓'; position:absolute; left:0; top:3px;
|
||||||
color:var(--gold-700); font-weight:700;
|
color:var(--gold-700); font-weight:700;
|
||||||
}}
|
}}
|
||||||
/* Marketing bullets render above entitlements with a slightly tighter
|
/* Marketing bullets and entitlements should read as one coherent
|
||||||
top margin so they read as one coherent feature list. */
|
feature list regardless of which one renders first. */
|
||||||
.tier-bullets + .tier-entitlements {{ margin-top:2px; }}
|
.tier-bullets + .tier-entitlements {{ margin-top:2px; }}
|
||||||
|
.tier-entitlements + .tier-bullets {{ margin-top:2px; }}
|
||||||
.tier-select-btn {{
|
.tier-select-btn {{
|
||||||
margin-top:auto;
|
margin-top:auto;
|
||||||
padding:8px 12px;
|
padding:8px 12px;
|
||||||
@@ -686,8 +694,18 @@ footer.kfooter a:hover {{ color:var(--navy-900); }}
|
|||||||
const t = TIERS[slug];
|
const t = TIERS[slug];
|
||||||
const fmt = formatTierPrice(t);
|
const fmt = formatTierPrice(t);
|
||||||
currentBaseFmt = fmt.amount;
|
currentBaseFmt = fmt.amount;
|
||||||
priceStrike.style.display = 'none';
|
// Featured discount auto-applies on the headline price (matches
|
||||||
priceTag.style.display = 'none';
|
// the tier card's ribbon + slashed-price display). The strike +
|
||||||
|
// tag stay hidden when there's no featured code for this tier.
|
||||||
|
if (t.featured) {{
|
||||||
|
priceStrike.textContent = fmt.amount + ' sats';
|
||||||
|
priceStrike.style.display = 'block';
|
||||||
|
priceTag.textContent = t.featured.label;
|
||||||
|
priceTag.style.display = 'inline-block';
|
||||||
|
}} else {{
|
||||||
|
priceStrike.style.display = 'none';
|
||||||
|
priceTag.style.display = 'none';
|
||||||
|
}}
|
||||||
const unitEl = document.querySelector('.unit');
|
const unitEl = document.querySelector('.unit');
|
||||||
let unitText = fmt.unit;
|
let unitText = fmt.unit;
|
||||||
if (t.is_recurring) {{
|
if (t.is_recurring) {{
|
||||||
@@ -718,7 +736,11 @@ footer.kfooter a:hover {{ color:var(--navy-900); }}
|
|||||||
if (unitEl) unitEl.textContent = '';
|
if (unitEl) unitEl.textContent = '';
|
||||||
setRedeemButton();
|
setRedeemButton();
|
||||||
}} else {{
|
}} else {{
|
||||||
priceCurrent.textContent = currentBaseFmt;
|
// Paid tier. If a featured discount applies, show the discounted
|
||||||
|
// price as the headline (the original is struck-through above).
|
||||||
|
priceCurrent.textContent = t.featured
|
||||||
|
? fmtSats(t.featured.discounted_price_sats)
|
||||||
|
: currentBaseFmt;
|
||||||
setPaidButton();
|
setPaidButton();
|
||||||
}}
|
}}
|
||||||
}}
|
}}
|
||||||
@@ -1273,6 +1295,7 @@ fn render_tier_picker(
|
|||||||
fn build_tiers_json(
|
fn build_tiers_json(
|
||||||
policies: &[crate::models::Policy],
|
policies: &[crate::models::Policy],
|
||||||
product: &crate::models::Product,
|
product: &crate::models::Product,
|
||||||
|
featured_by_policy: &std::collections::HashMap<String, crate::models::DiscountCode>,
|
||||||
) -> String {
|
) -> String {
|
||||||
// Each tier carries enough info for the JS to render its price
|
// Each tier carries enough info for the JS to render its price
|
||||||
// in the right unit. For SAT-currency products, `price_sats`
|
// in the right unit. For SAT-currency products, `price_sats`
|
||||||
@@ -1292,6 +1315,30 @@ fn build_tiers_json(
|
|||||||
// (cents for USD/EUR). Most operators leave the override
|
// (cents for USD/EUR). Most operators leave the override
|
||||||
// unset; the inheritance path covers the common case.
|
// unset; the inheritance path covers the common case.
|
||||||
let price_value = p.price_sats_override.unwrap_or(product.price_value);
|
let price_value = p.price_sats_override.unwrap_or(product.price_value);
|
||||||
|
// Featured-discount snapshot per policy — mirrors the math in
|
||||||
|
// policies.rs's GET /v1/products/<slug>/policies so the JS-
|
||||||
|
// driven headline price below the tier picker matches what's
|
||||||
|
// rendered on the tier card. `label` is human-friendly ("60%
|
||||||
|
// off", "10,000 sats off", "FREE") so the JS doesn't need to
|
||||||
|
// know the kind enum.
|
||||||
|
let featured = featured_by_policy.get(&p.id).map(|code| {
|
||||||
|
let discount = crate::api::purchase::compute_discount(
|
||||||
|
&code.kind, code.amount, price_sats_value,
|
||||||
|
);
|
||||||
|
let final_price = (price_sats_value - discount).max(0);
|
||||||
|
let label = match code.kind.as_str() {
|
||||||
|
"percent" => format!("{}% off", (code.amount as f64 / 100.0) as i64),
|
||||||
|
"free_license" => "FREE".to_string(),
|
||||||
|
_ => format!("{} sats off", discount),
|
||||||
|
};
|
||||||
|
serde_json::json!({
|
||||||
|
"code": code.code,
|
||||||
|
"kind": code.kind,
|
||||||
|
"discount_applied_sats": discount,
|
||||||
|
"discounted_price_sats": final_price,
|
||||||
|
"label": label,
|
||||||
|
})
|
||||||
|
});
|
||||||
map.insert(
|
map.insert(
|
||||||
p.slug.clone(),
|
p.slug.clone(),
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
@@ -1302,6 +1349,7 @@ fn build_tiers_json(
|
|||||||
"is_recurring": p.is_recurring,
|
"is_recurring": p.is_recurring,
|
||||||
"renewal_period_days": p.renewal_period_days,
|
"renewal_period_days": p.renewal_period_days,
|
||||||
"trial_days": p.trial_days,
|
"trial_days": p.trial_days,
|
||||||
|
"featured": featured,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,18 @@ const RELEASE_NOTES = [
|
|||||||
// in RELEASE_NOTES above (the milestone). Subsequent revisions
|
// in RELEASE_NOTES above (the milestone). Subsequent revisions
|
||||||
// append here.
|
// append here.
|
||||||
const ROUTINE_NOTES = [
|
const ROUTINE_NOTES = [
|
||||||
|
'0.2.0:23 — **Three buy-page fixes: layout proportions, featured discount on default tier, marketing-bullet gap.** Surfaced by feedback after the wider buy page rolled out in :21.',
|
||||||
|
'',
|
||||||
|
'**Headline + price card no longer look pinched.** The :21 release widened the outer container to 1040px to give the 3-tier picker room, but kept the headline ("Keysat", description) and the bottom price card constrained at 560px — which made them look dwarfed against the wider tier picker. Now: headline elements + the price card span the full container width with center-aligned text. The Email / Discount code / Pay-with-Bitcoin form stays narrower (560px) since input fields look stretched at 1040px. Net effect: the page reads as one cohesive width top to bottom.',
|
||||||
|
'',
|
||||||
|
'**Featured discount auto-applies on the headline price.** Previously the tier card showed "100,000 sats slashed → 40,000 sats/yr" via a server-rendered ribbon, but the price card below ("PRICE · PRO") rendered the un-discounted 100,000 sats until the buyer typed the code in by hand — even though the discount is featured (auto-applies). Fixed: the tier JSON now carries each tier\'s featured-discount snapshot, and `selectTier()` renders the strike-through + discounted price headline whenever a tier has an active featured code. Tier switching also re-applies the featured code for the new tier instead of clearing the strike-through.',
|
||||||
|
'',
|
||||||
|
'**Marketing-bullets gap when positioned below.** When `metadata.marketing_bullets_position = "below"` (added in :19), the entitlements list and the marketing-bullets list rendered as two `<ul>`s with default spacing between them — visible as a ~10px gap on the tier card. Symmetrical CSS rule `.tier-entitlements + .tier-bullets { margin-top:2px }` fixes it; the two lists now read as one coherent feature ladder regardless of order.',
|
||||||
|
'',
|
||||||
|
'**Test count: 87** (unchanged).',
|
||||||
|
'',
|
||||||
|
'**Upgrade path.** v0.2.0:22 → v0.2.0:23 is a drop-in. No schema, no SDK change. Public buy-page CSS + JS only.',
|
||||||
|
'',
|
||||||
'0.2.0:22 — **Policy scope is now editable on existing discount codes.** Previously, the Edit form showed scope as a read-only "Applies to:" label and forced operators to disable + recreate any code whose tier scope needed adjusting. That rule existed to "avoid silently invalidating distributed links" — but the same argument applies to `amount`, `max_uses`, and `expires_at`, all of which are already editable. Inconsistent. So: policy scope joins them.',
|
'0.2.0:22 — **Policy scope is now editable on existing discount codes.** Previously, the Edit form showed scope as a read-only "Applies to:" label and forced operators to disable + recreate any code whose tier scope needed adjusting. That rule existed to "avoid silently invalidating distributed links" — but the same argument applies to `amount`, `max_uses`, and `expires_at`, all of which are already editable. Inconsistent. So: policy scope joins them.',
|
||||||
'',
|
'',
|
||||||
'**Edit form: pill multi-picker for policy scope.** The Edit modal now renders the same gold-on-navy pill picker as Create, pre-selected with the code\'s current allowed-policy set. Toggle pills to refine: 0 picked → "any policy on this product"; 1 picked → singular scope (writes the legacy column); 2+ picked → multi-policy scope (writes the JSON column).',
|
'**Edit form: pill multi-picker for policy scope.** The Edit modal now renders the same gold-on-navy pill picker as Create, pre-selected with the code\'s current allowed-policy set. Toggle pills to refine: 0 picked → "any policy on this product"; 1 picked → singular scope (writes the legacy column); 2+ picked → multi-policy scope (writes the JSON column).',
|
||||||
@@ -376,7 +388,7 @@ const ROUTINE_NOTES = [
|
|||||||
].join('\n\n')
|
].join('\n\n')
|
||||||
|
|
||||||
export const v0_2_0 = VersionInfo.of({
|
export const v0_2_0 = VersionInfo.of({
|
||||||
version: '0.2.0:22',
|
version: '0.2.0:23',
|
||||||
releaseNotes: { en_US: ROUTINE_NOTES },
|
releaseNotes: { en_US: ROUTINE_NOTES },
|
||||||
// No on-disk transformation needed — v0.2.0:0 is a label change.
|
// No on-disk transformation needed — v0.2.0:0 is a label change.
|
||||||
// SQLite-level migrations live separately under
|
// SQLite-level migrations live separately under
|
||||||
|
|||||||
Reference in New Issue
Block a user