v0.2.0:16 — Launch-special discount codes + marketing bullets

Major feature release.

Featured (launch-special) discount codes:
  - New 'featured' flag on discount_codes (migration 0017). When true,
    the buy page renders a diagonal LAUNCH SPECIAL ribbon + slashed
    original price + new price for every applicable tier. Purchase
    endpoint auto-applies the discount for buyers who don't type a
    code. Operator-typed codes still win.
  - find_applicable_featured_discount repo helper: most-specific match
    (policy > product > global), tiebreak by created_at.
  - GET /v1/products/<slug>/policies now returns featured_discount per
    policy with the post-discount price computed server-side. SDK
    consumers + the dynamic pricing page get this for free.

Marketing bullets on policies:
  - metadata.marketing_bullets — operator-controlled copy that renders
    as additional checkmarks above the entitlement bullets on both the
    admin grid tier card and the buy page tier. For things like 'Up
    to 5 products' or 'BTCPay integration' that aren't real
    entitlement gates.
  - Authored via textarea on draft + edit policy forms.

UI:
  - 'Most popular' checkbox now on the draft tier card (was edit-only).
  - Discount codes tab grouped by product (matching Licenses /
    Subscriptions tabs). Each code row gets a 'featured' badge when
    flagged.

All 87 tests still pass. Migration is additive, no SDK changes,
backwards-compatible.
This commit is contained in:
Grant
2026-05-11 12:47:45 -05:00
parent 2789d1da1f
commit 4334a9f044
9 changed files with 633 additions and 72 deletions
@@ -0,0 +1,31 @@
-- Migration 0017: featured discount codes
--
-- Adds a `featured` flag to discount_codes. A "featured" discount is one
-- the operator wants prominently displayed on the buy page — typically a
-- launch promotion or a time-limited deal — rather than a normal discount
-- code that requires the buyer to type it in.
--
-- Effect when featured = true:
-- - The buy page renders the policy's original price struck through
-- plus the discounted price, with a "LAUNCH SPECIAL" diagonal
-- corner ribbon and the discount tagline.
-- - The purchase endpoint auto-applies the discount when no `code`
-- query param / body field is supplied. Buyers who type a different
-- code in the form get that code instead (operator-typed codes win
-- over auto-featured codes).
--
-- Activation/eligibility rules are unchanged from non-featured codes:
-- `active = 1` AND not expired AND below max_uses. So when a featured
-- code exhausts its 100-use cap, the buy page automatically stops
-- showing the launch-special ribbon and reverts to the standard
-- non-discounted price.
ALTER TABLE discount_codes ADD COLUMN featured INTEGER NOT NULL DEFAULT 0;
-- Partial index: featured codes only. Lookups for "the active featured
-- discount that applies to this policy" hit this index instead of
-- scanning every discount code. Tiny table either way today, but the
-- pattern scales.
CREATE INDEX IF NOT EXISTS idx_discount_codes_featured
ON discount_codes(applies_to_policy_id, applies_to_product_id, featured)
WHERE featured = 1 AND active = 1;
+153 -9
View File
@@ -120,10 +120,29 @@ pub async fn render(
.map(|p| p.slug.clone()) .map(|p| p.slug.clone())
.unwrap_or_default(); .unwrap_or_default();
// Look up applicable featured (launch-special) discounts per
// policy. The tier picker renders the ribbon + slashed price for
// any policy with a match. Sequential because policy count is
// small per product.
let mut featured_by_policy: std::collections::HashMap<String, crate::models::DiscountCode> =
std::collections::HashMap::new();
for p in &public_policies {
if let Ok(Some(code)) =
repo::find_applicable_featured_discount(&state.db, &product.id, &p.id).await
{
featured_by_policy.insert(p.id.clone(), code);
}
}
// Server-render the tier picker HTML so the page is functional even // Server-render the tier picker HTML so the page is functional even
// before JS runs. The picker only appears when the product has 2+ // before JS runs. The picker only appears when the product has 2+
// public policies; otherwise the existing single-price view is used. // public policies; otherwise the existing single-price view is used.
let tiers_html = render_tier_picker(&public_policies, &initial_policy, &product); let tiers_html = render_tier_picker(
&public_policies,
&initial_policy,
&product,
&featured_by_policy,
);
// Compact JSON map of {policy_slug: {price, name}} so the JS can update // Compact JSON map of {policy_slug: {price, name}} so the JS can update
// the price card when the buyer clicks a different tier. // the price card when the buyer clicks a different tier.
let tiers_json = build_tiers_json(&public_policies, &product); let tiers_json = build_tiers_json(&public_policies, &product);
@@ -319,17 +338,46 @@ h1 {{
.tier-description {{ .tier-description {{
font-size:13.5px; line-height:1.45; color:var(--ink-700); margin:0; font-size:13.5px; line-height:1.45; color:var(--ink-700); margin:0;
}} }}
.tier-entitlements {{ /* Launch-special ribbon — diagonal banner anchored to the top-right
corner of any tier with an active featured discount. Plus the
strike-through original-price line that renders ABOVE the
discounted price. */
.tier.has-launch {{ overflow:hidden; }}
.tier-launch-ribbon {{
position:absolute; top:14px; right:-44px;
background:var(--gold-500); color:var(--navy-950);
font-family:var(--font-display); font-weight:700; font-size:10.5px;
letter-spacing:0.14em; text-transform:uppercase;
padding:4px 50px; transform:rotate(35deg);
box-shadow:0 2px 6px rgba(14,31,51,0.15);
z-index:2;
}}
.tier-launch-meta {{
font-size:11.5px; color:var(--gold-700); font-weight:600;
margin-top:4px;
}}
.tier-price-original {{
font-family:var(--font-display); font-weight:500; font-size:14px;
color:var(--ink-500); margin-top:4px;
text-decoration:line-through; text-decoration-color:rgba(14,31,51,0.4);
}}
.tier-price-original-unit {{
font-size:11.5px; margin-left:4px; color:var(--ink-500);
}}
.tier-entitlements, .tier-bullets {{
list-style:none; padding:0; margin:6px 0 0; list-style:none; padding:0; margin:6px 0 0;
font-size:13px; color:var(--ink-700); font-size:13px; color:var(--ink-700);
}} }}
.tier-entitlements li {{ .tier-entitlements li, .tier-bullets li {{
padding:3px 0 3px 18px; position:relative; padding:3px 0 3px 18px; position:relative;
}} }}
.tier-entitlements li::before {{ .tier-entitlements li::before, .tier-bullets li::before {{
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
top margin so they read as one coherent feature list. */
.tier-bullets + .tier-entitlements {{ margin-top:2px; }}
.tier-select-btn {{ .tier-select-btn {{
margin-top:auto; margin-top:auto;
padding:8px 12px; padding:8px 12px;
@@ -931,6 +979,7 @@ fn render_tier_picker(
policies: &[crate::models::Policy], policies: &[crate::models::Policy],
initial: &Option<crate::models::Policy>, initial: &Option<crate::models::Policy>,
product: &crate::models::Product, product: &crate::models::Product,
featured_by_policy: &std::collections::HashMap<String, crate::models::DiscountCode>,
) -> String { ) -> String {
if policies.len() < 2 { if policies.len() < 2 {
return String::new(); return String::new();
@@ -949,14 +998,75 @@ fn render_tier_picker(
// For SAT-currency products, the override is in sats; for // For SAT-currency products, the override is in sats; for
// fiat-priced products it's in cents (USD/EUR). The price // fiat-priced products it's in cents (USD/EUR). The price
// unit cell renders in the right denomination either way. // unit cell renders in the right denomination either way.
let (price_fmt, price_unit) = if product.price_currency == "SAT" { let base_price_units: i64 = if product.price_currency == "SAT" {
let price = p.price_sats_override.unwrap_or(product.price_sats); p.price_sats_override.unwrap_or(product.price_sats)
(format_thousands(price), "sats".to_string())
} else { } else {
let cents = p.price_sats_override.unwrap_or(product.price_value); p.price_sats_override.unwrap_or(product.price_value)
};
// Featured discount (if any) — apply the same math the
// purchase endpoint uses so the buyer sees the same number
// here as at checkout. Note: for fiat products the units
// are cents, but compute_discount is currency-agnostic
// (works on any positive integer).
let featured = featured_by_policy.get(&p.id);
let discount_units = featured
.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" {
(format_thousands(post_discount_units), "sats".to_string())
} else {
let cents = post_discount_units;
let main = format!("{}.{:02}", cents / 100, (cents.abs() % 100)); let main = format!("{}.{:02}", cents / 100, (cents.abs() % 100));
(main, product.price_currency.clone()) (main, product.price_currency.clone())
}; };
// Original (pre-discount) price for the strikethrough.
let original_fmt = if featured.is_some() {
if product.price_currency == "SAT" {
format_thousands(base_price_units)
} else {
format!("{}.{:02}", base_price_units / 100, (base_price_units.abs() % 100))
}
} else {
String::new()
};
// Ribbon + slashed-original-price markup. Only emitted when
// a featured discount actually applies.
let (featured_ribbon, original_price_html) = if let Some(code) = featured {
let tagline = if code.kind == "percent" {
format!("{}% OFF", code.amount / 100)
} else if code.kind == "free_license" {
"FREE".to_string()
} else if code.kind == "set_price" {
"LIMITED PRICE".to_string()
} else {
"LAUNCH SPECIAL".to_string()
};
let remaining = code.max_uses.map(|m| (m - code.used_count).max(0)).unwrap_or(-1);
let remaining_html = if remaining > 0 {
format!(
"<div class=\"tier-launch-meta\">Limited: {} of {} remaining</div>",
remaining,
code.max_uses.unwrap_or(0)
)
} else {
String::new()
};
(
format!(
"<div class=\"tier-launch-ribbon\">{}</div>{}",
html_escape(&tagline),
remaining_html,
),
format!(
"<div class=\"tier-price-original\">{}<span class=\"tier-price-original-unit\">{}</span></div>",
original_fmt,
if product.price_currency == "SAT" { "sats" } else { product.price_currency.as_str() },
),
)
} else {
(String::new(), String::new())
};
let description = p let description = p
.metadata .metadata
.get("description") .get("description")
@@ -982,6 +1092,29 @@ fn render_tier_picker(
// If the product has an entitlements catalog, render // If the product has an entitlements catalog, render
// each policy entitlement using the catalog's display // each policy entitlement using the catalog's display
// name + description (as a tooltip). Falls back to the // 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.
let marketing_html = p
.metadata
.get("marketing_bullets")
.and_then(|v| v.as_array())
.map(|arr| {
let lis: Vec<String> = arr
.iter()
.filter_map(|v| v.as_str())
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| format!("<li>{}</li>", html_escape(s)))
.collect();
if lis.is_empty() {
String::new()
} else {
format!("<ul class=\"tier-bullets\">{}</ul>", lis.join(""))
}
})
.unwrap_or_default();
// raw slug if the catalog is empty or the slug isn't in // raw slug if the catalog is empty or the slug isn't in
// it (legacy slugs that predate the catalog land here). // it (legacy slugs that predate the catalog land here).
let entitlements_html = if p.entitlements.is_empty() { let entitlements_html = if p.entitlements.is_empty() {
@@ -1075,12 +1208,22 @@ fn render_tier_picker(
} else { } else {
("", String::new(), String::new()) ("", String::new(), String::new())
}; };
// Add `has-launch` to the card class when a featured
// discount applies so the CSS can lift the price + draw
// the diagonal ribbon.
let classes = if featured.is_some() {
format!("{} has-launch", classes)
} else {
classes.clone()
};
format!( format!(
r#"<div class="{classes}" data-policy-slug="{slug}">{popular_pill}<div class="tier-name">{name}</div><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}{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}{marketing_html}{entitlements_html}<button type="button" class="tier-select-btn">Select</button></div>"#,
classes = classes, classes = classes,
slug = slug_attr, slug = slug_attr,
popular_pill = popular_pill, popular_pill = popular_pill,
featured_ribbon = featured_ribbon,
name = name, name = name,
original_price_html = original_price_html,
price_fmt = price_fmt, price_fmt = price_fmt,
price_unit = price_unit, price_unit = price_unit,
cadence_suffix = cadence_suffix, cadence_suffix = cadence_suffix,
@@ -1089,6 +1232,7 @@ fn render_tier_picker(
trial_banner = trial_banner, trial_banner = trial_banner,
trial_meta = trial_meta, trial_meta = trial_meta,
description_html = description_html, description_html = description_html,
marketing_html = marketing_html,
entitlements_html = entitlements_html, entitlements_html = entitlements_html,
) )
}) })
@@ -47,6 +47,12 @@ pub struct CreateDiscountCodeReq {
pub referrer_label: Option<String>, pub referrer_label: Option<String>,
#[serde(default)] #[serde(default)]
pub description: String, pub description: String,
/// Mark this as a "launch special" — publicly displayed on the buy
/// page with a diagonal LAUNCH SPECIAL ribbon + original price
/// struck through. Auto-applies for buyers who don't type any
/// code. Operator-typed codes still win when the buyer pastes one.
#[serde(default)]
pub featured: bool,
} }
pub async fn create( pub async fn create(
@@ -117,6 +123,7 @@ pub async fn create(
policy_id.as_deref(), policy_id.as_deref(),
req.referrer_label.as_deref(), req.referrer_label.as_deref(),
&req.description, &req.description,
req.featured,
) )
.await?; .await?;
@@ -197,6 +204,10 @@ pub struct UpdateDiscountCodeReq {
pub description: Option<String>, pub description: Option<String>,
#[serde(default, deserialize_with = "deser_double_option", skip_serializing_if = "Option::is_none")] #[serde(default, deserialize_with = "deser_double_option", skip_serializing_if = "Option::is_none")]
pub referrer_label: Option<Option<String>>, pub referrer_label: Option<Option<String>>,
/// Toggle the launch-special public-display flag. `Some(true)` to
/// promote, `Some(false)` to demote, omit to leave alone.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub featured: Option<bool>,
} }
/// Helper for `Option<Option<T>>` with serde — distinguishes "not present in /// Helper for `Option<Option<T>>` with serde — distinguishes "not present in
@@ -227,6 +238,7 @@ pub async fn update(
req.expires_at.as_ref().map(|opt| opt.as_deref()), req.expires_at.as_ref().map(|opt| opt.as_deref()),
req.description.as_deref(), req.description.as_deref(),
req.referrer_label.as_ref().map(|opt| opt.as_deref()), req.referrer_label.as_ref().map(|opt| opt.as_deref()),
req.featured,
) )
.await?; .await?;
+54
View File
@@ -778,6 +778,22 @@ pub async fn list_public_policies(
} }
let policies = repo::list_public_policies_by_product(&state.db, &product.id).await?; let policies = repo::list_public_policies_by_product(&state.db, &product.id).await?;
// For each policy, look up an applicable active featured discount
// (if any). The buy page + dynamic pricing page render the ribbon +
// slashed price using this. Done as a sequential loop because the
// policy count per product is small (≤ tier-cap = 5 on Creator,
// unlimited on Pro but realistically <20). Switch to a single
// batched SQL if profiling ever flags this.
let mut featured_by_policy: std::collections::HashMap<String, crate::models::DiscountCode> =
std::collections::HashMap::new();
for p in &policies {
if let Some(code) =
repo::find_applicable_featured_discount(&state.db, &product.id, &p.id).await?
{
featured_by_policy.insert(p.id.clone(), code);
}
}
let policies_json: Vec<Value> = policies let policies_json: Vec<Value> = policies
.into_iter() .into_iter()
.map(|p| { .map(|p| {
@@ -797,7 +813,41 @@ pub async fn list_public_policies(
.get("highlight") .get("highlight")
.and_then(|v| v.as_bool()) .and_then(|v| v.as_bool())
.unwrap_or(false); .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.
let marketing_bullets = p
.metadata
.get("marketing_bullets")
.cloned()
.unwrap_or_else(|| json!([]));
let price_sats = p.price_sats_override.unwrap_or(product.price_sats); 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
// now without typing any code. We mirror the same math
// the purchase endpoint applies (compute_discount), and
// floor the result at 0 so a 100%-off code doesn't go
// negative.
let featured = featured_by_policy.get(&p.id).map(|code| {
let discount = crate::api::purchase::compute_discount(
&code.kind, code.amount, price_sats,
);
let final_price = (price_sats - discount).max(0);
let remaining = code.max_uses.map(|m| (m - code.used_count).max(0));
json!({
"code": code.code,
"kind": code.kind,
"amount": code.amount,
"description": code.description,
"expires_at": code.expires_at,
"max_uses": code.max_uses,
"used_count": code.used_count,
"remaining_uses": remaining,
"discount_applied_sats": discount,
"discounted_price_sats": final_price,
})
});
json!({ json!({
"slug": p.slug, "slug": p.slug,
"name": p.name, "name": p.name,
@@ -807,12 +857,16 @@ pub async fn list_public_policies(
"max_machines": p.max_machines, "max_machines": p.max_machines,
"is_trial": p.is_trial, "is_trial": p.is_trial,
"entitlements": p.entitlements, "entitlements": p.entitlements,
"marketing_bullets": marketing_bullets,
"highlighted": highlighted, "highlighted": highlighted,
// Recurring-subscription cadence — buy page renders // Recurring-subscription cadence — buy page renders
// "Renews every N days" / "$X/month" when is_recurring=true. // "Renews every N days" / "$X/month" when is_recurring=true.
"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 (launch-special) discount metadata —
// null when no applicable featured code exists.
"featured_discount": featured,
}) })
}) })
.collect(); .collect();
+24 -2
View File
@@ -249,8 +249,28 @@ pub async fn start(
// //
// If C, D, or E fail after B succeeded, we call release_code_slot to // If C, D, or E fail after B succeeded, we call release_code_slot to
// give the slot back. // give the slot back.
// Determine the effective discount code. If the buyer typed one,
// honor it (operator-typed beats auto-applied). Otherwise, look up
// the most applicable active featured discount for the chosen
// policy and use it. This is the "launch special" auto-apply
// path — operators can run a public promo without making buyers
// type the code.
let explicit_code: Option<String> = req
.code
.as_deref()
.filter(|s| !s.trim().is_empty())
.map(|s| s.to_string());
let effective_code: Option<String> = if explicit_code.is_some() {
explicit_code
} else if let Some(pol) = chosen_policy.as_ref() {
repo::find_applicable_featured_discount(&state.db, &product.id, &pol.id)
.await?
.map(|c| c.code)
} else {
None
};
let (final_price, reservation, discount_applied) = if let Some(raw_code) = let (final_price, reservation, discount_applied) = if let Some(raw_code) =
req.code.as_deref().filter(|s| !s.trim().is_empty()) effective_code.as_deref().filter(|s| !s.trim().is_empty())
{ {
let code = repo::get_discount_code_by_code(&state.db, raw_code) let code = repo::get_discount_code_by_code(&state.db, raw_code)
.await? .await?
@@ -528,7 +548,9 @@ pub async fn start(
/// Apply the discount math. Returns the sats to subtract from `base`. /// Apply the discount math. Returns the sats to subtract from `base`.
/// Caller is responsible for clamping the result (and for floor enforcement). /// Caller is responsible for clamping the result (and for floor enforcement).
fn compute_discount(kind: &str, amount: i64, base_price_sats: i64) -> i64 { /// `pub(crate)` so the public policies endpoint can preview the post-
/// discount price on tier cards for featured (auto-applied) codes.
pub(crate) fn compute_discount(kind: &str, amount: i64, base_price_sats: i64) -> i64 {
match kind { match kind {
"percent" => { "percent" => {
// amount is basis points (0..=10000). 5000 == 50%. // amount is basis points (0..=10000). 5000 == 50%.
+78 -6
View File
@@ -2064,6 +2064,13 @@ pub async fn list_audit(
// ---------- Discount codes ---------- // ---------- Discount codes ----------
fn row_to_discount_code(row: sqlx::sqlite::SqliteRow) -> DiscountCode { fn row_to_discount_code(row: sqlx::sqlite::SqliteRow) -> DiscountCode {
// `featured` lands in migration 0017. Same fallback pattern as
// tier_rank / archived_at — try_get + ok().flatten() so pre-0017
// databases (theoretical) don't crash here.
let featured: bool = row
.try_get::<i64, _>("featured")
.map(|v| v != 0)
.unwrap_or(false);
DiscountCode { DiscountCode {
id: row.get("id"), id: row.get("id"),
code: row.get("code"), code: row.get("code"),
@@ -2077,6 +2084,7 @@ fn row_to_discount_code(row: sqlx::sqlite::SqliteRow) -> DiscountCode {
referrer_label: row.get("referrer_label"), referrer_label: row.get("referrer_label"),
description: row.get("description"), description: row.get("description"),
active: row.get::<i64, _>("active") != 0, active: row.get::<i64, _>("active") != 0,
featured,
created_at: row.get("created_at"), created_at: row.get("created_at"),
updated_at: row.get("updated_at"), updated_at: row.get("updated_at"),
} }
@@ -2122,6 +2130,7 @@ pub async fn create_discount_code(
applies_to_policy_id, applies_to_policy_id,
referrer_label, referrer_label,
description, description,
false, // not featured by default — backwards-compat for callers
) )
.await .await
} }
@@ -2148,6 +2157,7 @@ pub async fn create_discount_code_with_currency(
applies_to_policy_id: Option<&str>, applies_to_policy_id: Option<&str>,
referrer_label: Option<&str>, referrer_label: Option<&str>,
description: &str, description: &str,
featured: bool,
) -> AppResult<DiscountCode> { ) -> AppResult<DiscountCode> {
if !matches!( if !matches!(
kind, kind,
@@ -2203,8 +2213,8 @@ pub async fn create_discount_code_with_currency(
"INSERT INTO discount_codes "INSERT INTO discount_codes
(id, code, kind, amount, discount_currency, max_uses, used_count, expires_at, (id, code, kind, amount, discount_currency, max_uses, used_count, expires_at,
applies_to_product_id, applies_to_policy_id, referrer_label, applies_to_product_id, applies_to_policy_id, referrer_label,
description, active, created_at, updated_at) description, active, featured, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, 1, ?, ?)", VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, 1, ?, ?, ?)",
) )
.bind(&id) .bind(&id)
.bind(&normalized) .bind(&normalized)
@@ -2217,6 +2227,7 @@ pub async fn create_discount_code_with_currency(
.bind(applies_to_policy_id) .bind(applies_to_policy_id)
.bind(referrer_label) .bind(referrer_label)
.bind(description) .bind(description)
.bind(featured as i64)
.bind(&now) .bind(&now)
.bind(&now) .bind(&now)
.execute(pool) .execute(pool)
@@ -2239,7 +2250,7 @@ pub async fn get_discount_code_by_id(
let row = sqlx::query( let row = sqlx::query(
"SELECT id, code, kind, amount, max_uses, used_count, expires_at, "SELECT id, code, kind, amount, max_uses, used_count, expires_at,
applies_to_product_id, applies_to_policy_id, referrer_label, applies_to_product_id, applies_to_policy_id, referrer_label,
description, active, created_at, updated_at description, active, featured, created_at, updated_at
FROM discount_codes WHERE id = ?", FROM discount_codes WHERE id = ?",
) )
.bind(id) .bind(id)
@@ -2256,7 +2267,7 @@ pub async fn get_discount_code_by_code(
let row = sqlx::query( let row = sqlx::query(
"SELECT id, code, kind, amount, max_uses, used_count, expires_at, "SELECT id, code, kind, amount, max_uses, used_count, expires_at,
applies_to_product_id, applies_to_policy_id, referrer_label, applies_to_product_id, applies_to_policy_id, referrer_label,
description, active, created_at, updated_at description, active, featured, created_at, updated_at
FROM discount_codes WHERE code = ?", FROM discount_codes WHERE code = ?",
) )
.bind(&normalized) .bind(&normalized)
@@ -2272,18 +2283,72 @@ pub async fn list_discount_codes(
let q = if only_active { let q = if only_active {
"SELECT id, code, kind, amount, max_uses, used_count, expires_at, "SELECT id, code, kind, amount, max_uses, used_count, expires_at,
applies_to_product_id, applies_to_policy_id, referrer_label, applies_to_product_id, applies_to_policy_id, referrer_label,
description, active, created_at, updated_at description, active, featured, created_at, updated_at
FROM discount_codes WHERE active = 1 ORDER BY created_at DESC" FROM discount_codes WHERE active = 1 ORDER BY created_at DESC"
} else { } else {
"SELECT id, code, kind, amount, max_uses, used_count, expires_at, "SELECT id, code, kind, amount, max_uses, used_count, expires_at,
applies_to_product_id, applies_to_policy_id, referrer_label, applies_to_product_id, applies_to_policy_id, referrer_label,
description, active, created_at, updated_at description, active, featured, created_at, updated_at
FROM discount_codes ORDER BY created_at DESC" FROM discount_codes ORDER BY created_at DESC"
}; };
let rows = sqlx::query(q).fetch_all(pool).await?; let rows = sqlx::query(q).fetch_all(pool).await?;
Ok(rows.into_iter().map(row_to_discount_code).collect()) Ok(rows.into_iter().map(row_to_discount_code).collect())
} }
/// Find the most-applicable active featured discount for a given
/// (product_id, policy_id) pair, if any. Returns `None` if no featured
/// code applies, or if all matching codes are expired / exhausted /
/// inactive. Precedence: policy-specific > product-specific > global,
/// then by created_at ascending (operator-set priority).
///
/// Used by:
/// - `GET /v1/products/<slug>/policies` to surface launch-special
/// prices on the public buy page + dynamic pricing page.
/// - `POST /v1/purchase` to auto-apply the featured code when the
/// buyer doesn't type one.
pub async fn find_applicable_featured_discount(
pool: &SqlitePool,
product_id: &str,
policy_id: &str,
) -> AppResult<Option<DiscountCode>> {
let now = Utc::now().to_rfc3339();
// The SQL filters by featured + active + not-expired +
// remaining-uses, scopes to either the policy, the product, or
// global, and orders by specificity (policy match first) then
// created_at ascending. LIMIT 1 — we only need the winner.
let row = sqlx::query(
"SELECT id, code, kind, amount, max_uses, used_count, expires_at,
applies_to_product_id, applies_to_policy_id, referrer_label,
description, active, featured, created_at, updated_at
FROM discount_codes
WHERE featured = 1
AND active = 1
AND (expires_at IS NULL OR expires_at > ?)
AND (max_uses IS NULL OR used_count < max_uses)
AND (
applies_to_policy_id = ?
OR (applies_to_policy_id IS NULL AND applies_to_product_id = ?)
OR (applies_to_policy_id IS NULL AND applies_to_product_id IS NULL)
)
ORDER BY
CASE
WHEN applies_to_policy_id = ? THEN 0
WHEN applies_to_product_id = ? THEN 1
ELSE 2
END,
created_at ASC
LIMIT 1",
)
.bind(&now)
.bind(policy_id)
.bind(product_id)
.bind(policy_id)
.bind(product_id)
.fetch_optional(pool)
.await?;
Ok(row.map(row_to_discount_code))
}
pub async fn set_discount_code_active( pub async fn set_discount_code_active(
pool: &SqlitePool, pool: &SqlitePool,
id: &str, id: &str,
@@ -2317,6 +2382,7 @@ pub async fn update_discount_code(
expires_at: Option<Option<&str>>, expires_at: Option<Option<&str>>,
description: Option<&str>, description: Option<&str>,
referrer_label: Option<Option<&str>>, referrer_label: Option<Option<&str>>,
featured: Option<bool>,
) -> AppResult<DiscountCode> { ) -> AppResult<DiscountCode> {
// Re-fetch to validate amount against the existing kind. // Re-fetch to validate amount against the existing kind.
let existing = get_discount_code_by_id(pool, id) let existing = get_discount_code_by_id(pool, id)
@@ -2377,6 +2443,9 @@ pub async fn update_discount_code(
if referrer_label.is_some() { if referrer_label.is_some() {
sets.push("referrer_label = ?"); sets.push("referrer_label = ?");
} }
if featured.is_some() {
sets.push("featured = ?");
}
if sets.is_empty() { if sets.is_empty() {
return Ok(existing); return Ok(existing);
} }
@@ -2402,6 +2471,9 @@ pub async fn update_discount_code(
if let Some(opt_r) = referrer_label { if let Some(opt_r) = referrer_label {
q = q.bind(opt_r); q = q.bind(opt_r);
} }
if let Some(f) = featured {
q = q.bind(f as i64);
}
q = q.bind(&now).bind(id); q = q.bind(&now).bind(id);
q.execute(pool).await?; q.execute(pool).await?;
+7
View File
@@ -324,6 +324,13 @@ pub struct DiscountCode {
pub referrer_label: Option<String>, pub referrer_label: Option<String>,
pub description: String, pub description: String,
pub active: bool, pub active: bool,
/// When `true`, the buy page renders this code as a public "launch
/// special" — striking the original price, showing the discounted
/// price, with a "LAUNCH SPECIAL" diagonal ribbon. The purchase
/// endpoint auto-applies it for buyers who don't type any code.
/// Operator-typed codes still win if the buyer manually enters one.
#[serde(default)]
pub featured: bool,
pub created_at: String, pub created_at: String,
pub updated_at: String, pub updated_at: String,
} }
+259 -54
View File
@@ -1451,7 +1451,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
const status = el('div', { class: 'muted', style: 'margin-top:6px; font-size:12.5px;' }, '') const status = el('div', { class: 'muted', style: 'margin-top:6px; font-size:12.5px;' }, '')
const card = el('div', { const card = el('div', {
style: 'background:var(--cream-50); border:1px solid var(--border-1); ' + style: 'background:var(--cream-50); border:1px solid var(--border-1); ' +
'border-radius:12px; max-width:480px; width:100%; padding:24px; ' + 'border-radius:12px; max-width:640px; width:100%; padding:24px; ' +
'box-shadow:0 0 0 1px var(--gold-500) inset, 0 16px 32px rgba(14,31,51,0.20);', 'box-shadow:0 0 0 1px var(--gold-500) inset, 0 16px 32px rgba(14,31,51,0.20);',
}, [ }, [
el('div', { class: 'eyebrow', style: 'margin-bottom:8px' }, 'Edit product'), el('div', { class: 'eyebrow', style: 'margin-bottom:8px' }, 'Edit product'),
@@ -1912,6 +1912,9 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
const meta = pol.metadata || {} const meta = pol.metadata || {}
const description = (typeof meta.description === 'string') ? meta.description : '' const description = (typeof meta.description === 'string') ? meta.description : ''
const highlight = !!meta.highlight const highlight = !!meta.highlight
const marketingBulletsInit = Array.isArray(meta.marketing_bullets)
? meta.marketing_bullets.join('\n')
: ''
const nameField = formInput('e_pol_name', 'Display name', { value: pol.name || '', required: true }) const nameField = formInput('e_pol_name', 'Display name', { value: pol.name || '', required: true })
const descField = formInput('e_pol_description', 'Tier description (optional)', { const descField = formInput('e_pol_description', 'Tier description (optional)', {
@@ -1956,6 +1959,11 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
return host return host
})() })()
const highlightField = formCheckbox('e_pol_highlight', 'Mark as "Most popular" on the tier picker') const highlightField = formCheckbox('e_pol_highlight', 'Mark as "Most popular" on the tier picker')
const bulletsField = formInput('e_pol_bullets', 'Marketing bullets (optional)', {
textarea: true,
value: marketingBulletsInit,
hint: 'One per line. Renders as ✓ checkmarks above the entitlements on the buy page. Use for things like "Up to 5 products" or "BTCPay integration" that aren\'t real entitlements.',
})
if (highlight) setTimeout(() => { if (highlight) setTimeout(() => {
const cb = card.querySelector('[name=e_pol_highlight]') const cb = card.querySelector('[name=e_pol_highlight]')
if (cb) cb.checked = true if (cb) cb.checked = true
@@ -2039,6 +2047,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
el('div', { class: 'row-2' }, [presetSel, customDur]), el('div', { class: 'row-2' }, [presetSel, customDur]),
el('div', { class: 'row-2' }, [graceField, machinesField]), el('div', { class: 'row-2' }, [graceField, machinesField]),
entField, entField,
bulletsField,
el('div', { class: 'row-2' }, [highlightField, trialField]), el('div', { class: 'row-2' }, [highlightField, trialField]),
// Tier ladder rank — sits in its own row above the recurring section. // Tier ladder rank — sits in its own row above the recurring section.
tierRankField, tierRankField,
@@ -2083,6 +2092,10 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
else delete newMetadata.description else delete newMetadata.description
if (newHighlight) newMetadata.highlight = true if (newHighlight) newMetadata.highlight = true
else delete newMetadata.highlight else delete newMetadata.highlight
const newBullets = (card.querySelector('[name=e_pol_bullets]').value || '')
.split('\n').map((s) => s.trim()).filter(Boolean)
if (newBullets.length > 0) newMetadata.marketing_bullets = newBullets
else delete newMetadata.marketing_bullets
const priceRaw = card.querySelector('[name=e_pol_price]').value.trim() const priceRaw = card.querySelector('[name=e_pol_price]').value.trim()
const price_sats_override = priceRaw === '' ? null : Math.max(0, parseInt(priceRaw, 10) || 0) const price_sats_override = priceRaw === '' ? null : Math.max(0, parseInt(priceRaw, 10) || 0)
// Recurring subscription — send the fields whenever the operator // Recurring subscription — send the fields whenever the operator
@@ -2227,12 +2240,32 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
pol.trial_days + ' day free trial') pol.trial_days + ' day free trial')
: null : null
// Marketing bullets — operator-controlled copy that renders as
// ✓ checkmarks ABOVE the entitlement bullets. Things like
// "Up to 5 products" or "BTCPay integration" that aren't real
// entitlement gates but are buyer-relevant.
const marketingBullets = Array.isArray((pol.metadata || {}).marketing_bullets)
? pol.metadata.marketing_bullets
: []
const marketingList = marketingBullets.length === 0
? null
: el('ul', {
style: 'list-style:none; padding:0; margin:8px 0 0; font-size:12.5px; color:var(--ink-700)',
}, marketingBullets.map((b) => el('li', {
style: 'padding:2px 0 2px 16px; position:relative',
}, [
el('span', {
style: 'position:absolute; left:0; top:2px; color:var(--gold-700); font-weight:700',
}, '✓'),
b,
])))
// Entitlements as small chips with display name + tooltip. // Entitlements as small chips with display name + tooltip.
const cat = product.entitlements_catalog || [] const cat = product.entitlements_catalog || []
const entChips = (pol.entitlements || []).length === 0 const entChips = (pol.entitlements || []).length === 0
? null ? null
: el('ul', { : el('ul', {
style: 'list-style:none; padding:0; margin:8px 0 0; font-size:12.5px; color:var(--ink-700)', style: 'list-style:none; padding:0; margin:' + (marketingList ? '2px' : '8px') + ' 0 0; font-size:12.5px; color:var(--ink-700)',
}, (pol.entitlements || []).map((slug) => { }, (pol.entitlements || []).map((slug) => {
const entry = cat.find((c) => c.slug === slug) const entry = cat.find((c) => c.slug === slug)
const display = entry && entry.name ? entry.name : slug const display = entry && entry.name ? entry.name : slug
@@ -2379,6 +2412,21 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
(isArchived ? ' opacity:0.55; background:repeating-linear-gradient(135deg, var(--cream-50) 0 10px, rgba(14,31,51,0.025) 10px 20px);' : ''), (isArchived ? ' opacity:0.55; background:repeating-linear-gradient(135deg, var(--cream-50) 0 10px, rgba(14,31,51,0.025) 10px 20px);' : ''),
}, [ }, [
popularPill, popularPill,
// Drag-handle affordance — shown only on non-archived (draggable)
// tiers. Subtle muted icon top-right; the cursor still flips to
// grab on hover anywhere on the card, this just makes the
// affordance discoverable without reading any intro text.
!isArchived
? el('div', {
style:
'position:absolute; top:8px; right:8px; ' +
'color:var(--ink-500); opacity:0.5; ' +
'display:flex; align-items:center; pointer-events:none;',
title: 'Drag to reorder tier ladder',
}, [
el('i', { 'data-lucide': 'grip-vertical', style: 'width:16px;height:16px' }),
])
: null,
el('div', { el('div', {
style: 'font-family:var(--font-display); font-weight:600; font-size:18px; color:var(--navy-950); letter-spacing:-0.01em', style: 'font-family:var(--font-display); font-weight:600; font-size:18px; color:var(--navy-950); letter-spacing:-0.01em',
}, pol.name), }, pol.name),
@@ -2401,6 +2449,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
description ? el('p', { description ? el('p', {
style: 'font-size:13px; line-height:1.45; color:var(--ink-700); margin:6px 0 0', style: 'font-size:13px; line-height:1.45; color:var(--ink-700); margin:6px 0 0',
}, description) : null, }, description) : null,
marketingList,
entChips, entChips,
el('div', { class: 'muted', style: 'font-size:11px; margin-top:4px' }, el('div', { class: 'muted', style: 'font-size:11px; margin-top:4px' },
(pol._license_count || 0) + ' license' + ((pol._license_count || 0) === 1 ? '' : 's')), (pol._license_count || 0) + ' license' + ((pol._license_count || 0) === 1 ? '' : 's')),
@@ -2547,6 +2596,21 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
class: 'input', type: 'number', min: '1', value: '30', style: 'width:60px', class: 'input', type: 'number', min: '1', value: '30', style: 'width:60px',
}) })
// "Most popular" highlight + free-form marketing bullets. Both
// write into metadata: metadata.highlight (boolean — drives the
// "Most popular" pill on the buy page tier card) and
// metadata.marketing_bullets (array of strings — extra ✓ bullets
// rendered above the entitlement bullets on the buy page card).
// Marketing bullets aren't enforced anywhere; they're operator-
// controlled copy for things like "5 active products" or "BTCPay
// integration" that don't map to a real entitlement gate.
const highlightCb = el('input', { type: 'checkbox' })
const bulletsTextarea = el('textarea', {
class: 'input', rows: '3',
placeholder: 'One bullet per line — e.g.\nUp to 5 products\nBTCPay integration\nWebhooks + audit log',
style: 'font-family:var(--font-body); font-size:12px; line-height:1.45;',
})
// Tip recipient (advanced — collapsed by default to keep the // Tip recipient (advanced — collapsed by default to keep the
// card narrow). // card narrow).
const status = el('div', { style: 'font-size:12px; min-height:16px' }, '') const status = el('div', { style: 'font-size:12px; min-height:16px' }, '')
@@ -2574,6 +2638,21 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
? 'Click to toggle. Defined on the product\'s catalog.' ? 'Click to toggle. Defined on the product\'s catalog.'
: 'Comma-separated slugs. Define a product catalog for click-to-pick.', : 'Comma-separated slugs. Define a product catalog for click-to-pick.',
entHost), entHost),
// "Most popular" toggle — drives the gold-pill anchored above
// the tier card on the buy page.
el('div', { style: 'display:flex; align-items:center; gap:6px; margin-top:8px' }, [
highlightCb,
el('label', { class: 'lbl', style: 'margin:0; font-size:11.5px; display:flex; align-items:center' }, [
'Mark as "Most popular"',
helpIcon('Renders a "Most popular" pill above this tier card on the buy page. Pick one tier per product.'),
]),
]),
// Free-form marketing bullets — operator-controlled copy that
// renders as additional ✓ checkmarks above the entitlement
// bullets. Not enforced anywhere; pure marketing surface.
fieldRow('Marketing bullets',
'One per line. Buyer sees these as ✓ checkmarks above the entitlement bullets on the buy page. Use for things like "Up to 5 products" or "BTCPay integration" that aren\'t real entitlements.',
bulletsTextarea),
// Recurring section — minimal, expanded inline (no nested // Recurring section — minimal, expanded inline (no nested
// disclosure; cards already imply compactness). // disclosure; cards already imply compactness).
el('div', { style: 'display:flex; align-items:center; gap:6px; margin-top:8px' }, [ el('div', { style: 'display:flex; align-items:center; gap:6px; margin-top:8px' }, [
@@ -2611,6 +2690,13 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
const durationSeconds = durationSel.value === 'custom' const durationSeconds = durationSel.value === 'custom'
? Math.max(1, parseInt(customDaysInput.value, 10) || 0) * 86400 ? Math.max(1, parseInt(customDaysInput.value, 10) || 0) * 86400
: parseInt(durationSel.value, 10) || 0 : parseInt(durationSel.value, 10) || 0
const marketingBullets = bulletsTextarea.value
.split('\n')
.map((s) => s.trim())
.filter(Boolean)
const metadata = {}
if (highlightCb.checked) metadata.highlight = true
if (marketingBullets.length > 0) metadata.marketing_bullets = marketingBullets
const body = { const body = {
product_slug: product.slug, product_slug: product.slug,
slug: slugInput.value.trim(), slug: slugInput.value.trim(),
@@ -2620,7 +2706,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
max_machines: parseInt(maxMachinesInput.value, 10), max_machines: parseInt(maxMachinesInput.value, 10),
is_trial: false, is_trial: false,
entitlements: Array.isArray(entRead()) ? entRead() : entRead(), entitlements: Array.isArray(entRead()) ? entRead() : entRead(),
metadata: {}, metadata: metadata,
price_sats_override: isSat price_sats_override: isSat
? Math.max(0, parseInt(priceInput.value, 10) || 0) ? Math.max(0, parseInt(priceInput.value, 10) || 0)
: Math.max(0, Math.round(parseFloat(priceInput.value) * 100) || 0), : Math.max(0, Math.round(parseFloat(priceInput.value) * 100) || 0),
@@ -3201,15 +3287,13 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
localStorage.setItem('ks_show_archived_policies', archivedToggle.checked ? '1' : '0') localStorage.setItem('ks_show_archived_policies', archivedToggle.checked ? '1' : '0')
routes.policies() routes.policies()
}) })
target.appendChild(plainCard([ target.appendChild(el('div', {
el('div', { style: 'display:flex; align-items:center; gap:14px; flex-wrap:wrap' }, [ style: 'display:flex; justify-content:flex-end; margin-bottom:14px',
el('p', { class: 'muted', style: 'margin:0; flex:1; min-width:280px' }, }, [
'Reusable license templates per product. The policy slugged "default" is consumed by the public purchase flow; other slugs are used by manual license issuance. Click the dashed "+ Add tier" card to author a new policy. Drag tier cards left/right to reorder — the ladder rank used by tier-upgrade flow follows the visual order.'), el('label', {
el('label', { for: 'showArchivedPolicies',
for: 'showArchivedPolicies', style: 'font-size:12.5px; color:var(--ink-700); white-space:nowrap; cursor:pointer',
style: 'font-size:12.5px; color:var(--ink-700); white-space:nowrap; cursor:pointer', }, [archivedToggle, 'Show archived']),
}, [archivedToggle, 'Show archived']),
]),
])) ]))
// Intentionally not used: `create` (legacy disclosure-form // Intentionally not used: `create` (legacy disclosure-form
// create-policy flow). Kept around as dead code for one release // create-policy flow). Kept around as dead code for one release
@@ -3602,6 +3686,14 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
]), ]),
formInput('referrer_label', 'Referrer / campaign label (optional)'), formInput('referrer_label', 'Referrer / campaign label (optional)'),
formInput('description', 'Description (internal note)', { textarea: true }), formInput('description', 'Description (internal note)', { textarea: true }),
el('div', { style: 'display:flex; align-items:flex-start; gap:8px; margin-top:8px' }, [
el('input', { type: 'checkbox', name: 'featured', id: 'create_featured_cb', style: 'margin-top:3px' }),
el('label', { for: 'create_featured_cb', style: 'font-size:12.5px; line-height:1.45; cursor:pointer' }, [
el('strong', null, 'Featured (launch special) '),
el('span', { class: 'muted' },
'— display on the buy page with a diagonal "LAUNCH SPECIAL" ribbon + the original price struck through. Auto-applies to buyers who don\'t type a code. Operator-typed codes still win when a buyer pastes one.'),
]),
]),
el('button', { class: 'btn primary', onclick: async function () { el('button', { class: 'btn primary', onclick: async function () {
const status = el('div', { class: 'muted', style: 'margin-top:8px' }, 'Creating…') const status = el('div', { class: 'muted', style: 'margin-top:8px' }, 'Creating…')
create.querySelector('.body').appendChild(status) create.querySelector('.body').appendChild(status)
@@ -3632,6 +3724,8 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
if (ps) body.product_slug = ps if (ps) body.product_slug = ps
const rl = create.querySelector('[name=referrer_label]').value.trim() const rl = create.querySelector('[name=referrer_label]').value.trim()
if (rl) body.referrer_label = rl if (rl) body.referrer_label = rl
const featured = create.querySelector('[name=featured]').checked
if (featured) body.featured = true
await api('/v1/admin/discount-codes', { method: 'POST', body }) await api('/v1/admin/discount-codes', { method: 'POST', body })
status.replaceWith(ok('Created. Reloading…')) status.replaceWith(ok('Created. Reloading…'))
setTimeout(routes.codes, 600) setTimeout(routes.codes, 600)
@@ -3696,6 +3790,23 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
textarea: true, textarea: true,
value: c.description || '', value: c.description || '',
}) })
// Featured toggle — same shape as in Create. Pre-populated with
// the existing value.
const featuredCb = el('input', {
type: 'checkbox', name: 'e_featured', id: 'e_featured_cb',
style: 'margin-top:3px',
})
if (c.featured) featuredCb.checked = true
const featuredField = el('div', {
style: 'display:flex; align-items:flex-start; gap:8px; margin-top:8px',
}, [
featuredCb,
el('label', { for: 'e_featured_cb', style: 'font-size:12.5px; line-height:1.45; cursor:pointer' }, [
el('strong', null, 'Featured (launch special) '),
el('span', { class: 'muted' },
'— display on the buy page with a diagonal ribbon + slashed price. Auto-applies for buyers who don\'t type a code.'),
]),
])
const saveBtn = el('button', { class: 'btn primary', onclick: async function () { const saveBtn = el('button', { class: 'btn primary', onclick: async function () {
const status = el('div', { class: 'muted', style: 'margin-top:8px' }, 'Saving…') const status = el('div', { class: 'muted', style: 'margin-top:8px' }, 'Saving…')
editPanel.appendChild(status) editPanel.appendChild(status)
@@ -3714,6 +3825,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
const refRaw = editPanel.querySelector('[name=e_referrer_label]').value.trim() const refRaw = editPanel.querySelector('[name=e_referrer_label]').value.trim()
body.referrer_label = refRaw === '' ? null : refRaw body.referrer_label = refRaw === '' ? null : refRaw
body.description = editPanel.querySelector('[name=e_description]').value || '' body.description = editPanel.querySelector('[name=e_description]').value || ''
body.featured = editPanel.querySelector('[name=e_featured]').checked
await api('/v1/admin/discount-codes/' + c.id, { method: 'PATCH', body }) await api('/v1/admin/discount-codes/' + c.id, { method: 'PATCH', body })
status.replaceWith(ok('Saved. Reloading…')) status.replaceWith(ok('Saved. Reloading…'))
setTimeout(routes.codes, 600) setTimeout(routes.codes, 600)
@@ -3737,19 +3849,27 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
el('div', { class: 'row-2' }, [amtField, muField]), el('div', { class: 'row-2' }, [amtField, muField]),
el('div', { class: 'row-2' }, [expField, refField]), el('div', { class: 'row-2' }, [expField, refField]),
descField, descField,
featuredField,
el('div', { style: 'margin-top:8px' }, [saveBtn, cancelBtn]), el('div', { style: 'margin-top:8px' }, [saveBtn, cancelBtn]),
])) ]))
editPanel.scrollIntoView({ behavior: 'smooth', block: 'start' }) editPanel.scrollIntoView({ behavior: 'smooth', block: 'start' })
} }
try { try {
const j = await api('/v1/admin/discount-codes?include_inactive=true') // Fetch products + codes in parallel so we can group codes by product.
const codes = j.codes || [] const [productsResp, codesResp] = await Promise.all([
const rows = codes.map((c) => { api('/v1/products').catch(() => ({ products: [] })),
// Currency-aware rendering. SAT-currency codes show "5,000 api('/v1/admin/discount-codes?include_inactive=true'),
// sats off"; fiat codes show "$10.00 off" with cents-to- ])
// dollars conversion. Backwards-compat for older rows that const products = productsResp.products || []
// don't carry discount_currency: treat as SAT. const codes = codesResp.codes || []
const productById = {}
products.forEach((p) => { productById[p.id] = p })
// Build a row for one code. Same render whether the code is in
// a per-product section or the "Global" section.
function codeRow(c) {
// Currency-aware amount rendering (unchanged).
const cur = (c.discount_currency || 'SAT').toUpperCase() const cur = (c.discount_currency || 'SAT').toUpperCase()
const fmtFiat = (cents, sym) => sym + (cents / 100).toFixed(2) const fmtFiat = (cents, sym) => sym + (cents / 100).toFixed(2)
let amountStr = '' let amountStr = ''
@@ -3769,7 +3889,14 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
else amountStr = el('span', { class: 'badge b-gold' }, 'free') else amountStr = el('span', { class: 'badge b-gold' }, 'free')
const usage = c.used_count + ' / ' + (c.max_uses == null ? '∞' : c.max_uses) const usage = c.used_count + ' / ' + (c.max_uses == null ? '∞' : c.max_uses)
return el('tr', null, [ return el('tr', null, [
el('td', null, el('code', null, c.code)), el('td', null, [
el('code', null, c.code),
c.featured ? el('span', {
class: 'badge b-gold',
style: 'margin-left:8px; font-size:10px; padding:2px 6px; letter-spacing:0.05em',
title: 'Public launch-special — auto-applies on the buy page',
}, 'featured') : null,
]),
el('td', null, c.kind), el('td', null, c.kind),
el('td', null, amountStr), el('td', null, amountStr),
el('td', null, usage), el('td', null, usage),
@@ -3811,14 +3938,84 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
}, 'Delete'), }, 'Delete'),
])), ])),
]) ])
}
// Group codes by product. Codes without a product (applies_to_
// product_id null) land in the "Global" bucket — they apply to
// every product on this instance.
const grouped = new Map() // product_id -> codes[]
const globalCodes = []
codes.forEach((c) => {
if (c.applies_to_product_id && productById[c.applies_to_product_id]) {
if (!grouped.has(c.applies_to_product_id)) {
grouped.set(c.applies_to_product_id, [])
}
grouped.get(c.applies_to_product_id).push(c)
} else {
globalCodes.push(c)
}
}) })
target.appendChild(tableCard(
'All codes', // Single-product instances: flat table with no grouping noise.
codes.length + ' total', // Multi-product instances OR any global codes: render one card
['Code', 'Kind', 'Amount', 'Used', 'Expires', 'Status', ''], // per group, matching the Licenses + Subscriptions tab pattern.
rows, const useGrouping = products.length > 1 || globalCodes.length > 0
'No codes yet.' if (!useGrouping) {
)) target.appendChild(tableCard(
'All codes',
codes.length + ' total',
['Code', 'Kind', 'Amount', 'Used', 'Expires', 'Status', ''],
codes.map(codeRow),
'No codes yet.'
))
} else {
// Per-product sections, sorted: products with codes first
// (preserving the product list order), Global section last.
products.forEach((p) => {
const list = grouped.get(p.id)
if (!list || list.length === 0) return
const featuredCount = list.filter((c) => c.featured).length
const activeCount = list.filter((c) => c.active).length
const breakdown =
list.length + ' code' + (list.length === 1 ? '' : 's') +
' · ' + activeCount + ' active' +
(featuredCount > 0 ? ' · ' + featuredCount + ' featured' : '')
const card = el('div', { class: 'card', style: 'margin-bottom:14px' }, [
el('div', { class: 'card-head' }, [
el('h3', null, p.name),
el('span', { class: 'sub' }, breakdown),
]),
tableCard('', null,
['Code', 'Kind', 'Amount', 'Used', 'Expires', 'Status', ''],
list.map(codeRow), '(none)'),
])
target.appendChild(card)
})
if (globalCodes.length > 0) {
const featuredCount = globalCodes.filter((c) => c.featured).length
const activeCount = globalCodes.filter((c) => c.active).length
const breakdown =
globalCodes.length + ' code' + (globalCodes.length === 1 ? '' : 's') +
' · ' + activeCount + ' active' +
(featuredCount > 0 ? ' · ' + featuredCount + ' featured' : '')
const card = el('div', { class: 'card', style: 'margin-bottom:14px' }, [
el('div', { class: 'card-head' }, [
el('h3', null, 'All products (global)'),
el('span', { class: 'sub' }, breakdown),
]),
tableCard('', null,
['Code', 'Kind', 'Amount', 'Used', 'Expires', 'Status', ''],
globalCodes.map(codeRow), '(none)'),
])
target.appendChild(card)
}
if (codes.length === 0) {
target.appendChild(plainCard([
el('p', { class: 'muted', style: 'margin:0' },
'No codes yet — use the "Create a new code" form above.'),
]))
}
}
} catch (e) { } catch (e) {
target.appendChild(plainCard([err(e.message)])) target.appendChild(plainCard([err(e.message)]))
} }
@@ -5233,37 +5430,45 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
function catalogEditor(initial) { function catalogEditor(initial) {
const rowsHost = el('div', { style: 'display:flex; flex-direction:column; gap:8px' }) const rowsHost = el('div', { style: 'display:flex; flex-direction:column; gap:8px' })
const addRow = (slug, name, description) => { const addRow = (slug, name, description) => {
// Each entitlement is a 2-line block instead of 4 cramped
// columns: slug + display name share the top line (so they fit
// side-by-side at typical lengths), description gets its own
// full-width line below so longer copy reads without truncation.
// The remove button anchors to the top-right of the block.
const slugInput = el('input', {
class: 'input mono', placeholder: 'slug (e.g. unlimited_products)',
value: slug || '',
'data-field': 'slug',
})
const nameInput = el('input', {
class: 'input', placeholder: 'Display name (e.g. Unlimited products)',
value: name || '',
'data-field': 'name',
})
const descInput = el('input', {
class: 'input', placeholder: 'Description — shown as a hover tooltip on the buy page',
value: description || '',
'data-field': 'description',
})
const removeBtn = el('button', {
type: 'button',
class: 'btn sm danger',
title: 'Remove this entitlement',
style: 'padding:6px 10px; flex-shrink:0',
}, '×')
const row = el('div', { const row = el('div', {
class: 'catalog-row', class: 'catalog-row',
style: 'display:grid; grid-template-columns: 1fr 1fr 1.6fr auto; gap:6px; align-items:flex-start', style:
'display:flex; flex-direction:column; gap:6px; ' +
'padding:10px; border:1px solid var(--border-1); ' +
'border-radius:8px; background:var(--cream-50);',
}, [ }, [
el('input', { el('div', {
class: 'input', placeholder: 'slug', style: 'display:grid; grid-template-columns: 1fr 1.4fr auto; gap:6px; align-items:center',
value: slug || '', }, [slugInput, nameInput, removeBtn]),
'data-field': 'slug', descInput,
}),
el('input', {
class: 'input', placeholder: 'Display name',
value: name || '',
'data-field': 'name',
}),
el('input', {
class: 'input', placeholder: 'Description (buyer tooltip)',
value: description || '',
'data-field': 'description',
title: 'Description shown as a hover tooltip on the buy page',
}),
(() => {
const btn = el('button', {
type: 'button',
class: 'btn sm danger',
title: 'Remove this entitlement',
style: 'padding:6px 10px',
}, '×')
btn.addEventListener('click', () => row.remove())
return btn
})(),
]) ])
removeBtn.addEventListener('click', () => row.remove())
rowsHost.appendChild(row) rowsHost.appendChild(row)
} }
if (Array.isArray(initial) && initial.length > 0) { if (Array.isArray(initial) && initial.length > 0) {
+15 -1
View File
@@ -58,6 +58,20 @@ 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:16 — **Launch-special discount codes + marketing bullets + discount codes per-product UI.** Operators can now run public promotional discounts that auto-apply on the buy page, plus author marketing-copy bullets on tiers that don\'t map to real entitlements.',
'',
'**Launch-special (featured) discount codes (migration 0017).** Flag a discount code as `featured` and three things happen automatically: (1) the buy page renders a diagonal "LAUNCH SPECIAL" gold ribbon on every tier the code applies to; (2) the original price is struck through and replaced with the discounted price; (3) the purchase endpoint auto-applies the discount for buyers who don\'t type any code. Operator-typed codes still win — a buyer who pastes a different code in the form gets that code instead. When a featured code exhausts its `max_uses` cap (e.g. "first 100 buyers"), the ribbon disappears automatically and pricing reverts to standard. Expiry dates work the same way. New repo helper `find_applicable_featured_discount` picks the most specific match (policy > product > global) with operator priority by created_at.',
'',
'**Marketing bullets on policies.** Tier cards can now carry operator-controlled marketing copy in addition to the technical entitlements. Authored via a textarea on the policy create + edit forms (one bullet per line), stored as `metadata.marketing_bullets` on the policy, and rendered as ✓ checkmarks ABOVE the entitlement bullets on both the admin grid and the buy page. Use for things like "Up to 5 products" or "BTCPay integration" — features that are real but don\'t gate on a daemon-level entitlement. SDK consumers also receive these via `GET /v1/products/<slug>/policies` so dynamic pricing pages can render them too.',
'',
'**"Most popular" checkbox on draft tier cards.** Previously only the Edit modal had this toggle, which meant authoring a new tier required commit-then-edit to get the gold "Most Popular" pill. Now exposed on the draft create card too, alongside the new marketing-bullets textarea. Writes `metadata.highlight = true`.',
'',
'**Discount codes admin tab — per-product organization.** Replaces the flat-table view with per-product sections matching the Licenses + Subscriptions tab pattern. Each card shows a breakdown ("3 codes · 2 active · 1 featured"). Global codes (those without `applies_to_product_id`) get their own "All products (global)" section. Single-product instances continue to see a flat table. Each code row now carries a small gold "featured" badge when applicable.',
'',
'**Test count: 87** (unchanged — UI-heavy release; the existing CORS + entitlements-catalog regression tests cover the surface).',
'',
'**Upgrade path.** v0.2.0:15 → v0.2.0:16 is a drop-in. Migration 0017 is additive (one nullable column on `discount_codes` + a partial index). All new behavior is opt-in — `featured` defaults to false on existing codes, `marketing_bullets` defaults to absent. No SDK changes; the new fields appear in JSON responses but old SDKs ignore unknown fields. Operators who don\'t use launch specials see no behavior change at all.',
'',
'0.2.0:15 — **Multi-draft tier authoring + custom durations on draft cards.** Small admin-UI release that fixes two papercuts from authoring fresh policies side-by-side.', '0.2.0:15 — **Multi-draft tier authoring + custom durations on draft cards.** Small admin-UI release that fixes two papercuts from authoring fresh policies side-by-side.',
'', '',
'**Multi-draft survival.** Previously, committing one draft tier (clicking Create on a side-by-side draft card) reloaded the whole Policies tab — which wiped any other drafts the operator had open. Now the commit replaces ONLY that draft\'s grid slot with a finalized tier card; sibling drafts keep their in-progress input state untouched. Author Creator, Pro, Patron in parallel and click Create on each as it\'s ready, in any order.', '**Multi-draft survival.** Previously, committing one draft tier (clicking Create on a side-by-side draft card) reloaded the whole Policies tab — which wiped any other drafts the operator had open. Now the commit replaces ONLY that draft\'s grid slot with a finalized tier card; sibling drafts keep their in-progress input state untouched. Author Creator, Pro, Patron in parallel and click Create on each as it\'s ready, in any order.',
@@ -300,7 +314,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:15', version: '0.2.0:16',
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