From 4334a9f0444c4599046300cde4c24e18da81143a Mon Sep 17 00:00:00 2001 From: Grant Date: Mon, 11 May 2026 12:47:45 -0500 Subject: [PATCH] =?UTF-8?q?v0.2.0:16=20=E2=80=94=20Launch-special=20discou?= =?UTF-8?q?nt=20codes=20+=20marketing=20bullets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//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. --- .../0017_featured_discount_codes.sql | 31 ++ licensing-service/src/api/buy_page.rs | 162 ++++++++- licensing-service/src/api/discount_codes.rs | 12 + licensing-service/src/api/policies.rs | 54 +++ licensing-service/src/api/purchase.rs | 26 +- licensing-service/src/db/repo.rs | 84 ++++- licensing-service/src/models.rs | 7 + licensing-service/web/index.html | 313 +++++++++++++++--- startos/versions/v0.2.0.ts | 16 +- 9 files changed, 633 insertions(+), 72 deletions(-) create mode 100644 licensing-service/migrations/0017_featured_discount_codes.sql diff --git a/licensing-service/migrations/0017_featured_discount_codes.sql b/licensing-service/migrations/0017_featured_discount_codes.sql new file mode 100644 index 0000000..0d829d5 --- /dev/null +++ b/licensing-service/migrations/0017_featured_discount_codes.sql @@ -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; diff --git a/licensing-service/src/api/buy_page.rs b/licensing-service/src/api/buy_page.rs index 670d648..45fdb6b 100644 --- a/licensing-service/src/api/buy_page.rs +++ b/licensing-service/src/api/buy_page.rs @@ -120,10 +120,29 @@ pub async fn render( .map(|p| p.slug.clone()) .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 = + 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 // before JS runs. The picker only appears when the product has 2+ // 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 // the price card when the buyer clicks a different tier. let tiers_json = build_tiers_json(&public_policies, &product); @@ -319,17 +338,46 @@ h1 {{ .tier-description {{ 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; font-size:13px; color:var(--ink-700); }} -.tier-entitlements li {{ +.tier-entitlements li, .tier-bullets li {{ 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; 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 {{ margin-top:auto; padding:8px 12px; @@ -931,6 +979,7 @@ fn render_tier_picker( policies: &[crate::models::Policy], initial: &Option, product: &crate::models::Product, + featured_by_policy: &std::collections::HashMap, ) -> String { if policies.len() < 2 { return String::new(); @@ -949,14 +998,75 @@ fn render_tier_picker( // For SAT-currency products, the override is in sats; for // fiat-priced products it's in cents (USD/EUR). The price // unit cell renders in the right denomination either way. - let (price_fmt, price_unit) = if product.price_currency == "SAT" { - let price = p.price_sats_override.unwrap_or(product.price_sats); - (format_thousands(price), "sats".to_string()) + let base_price_units: i64 = if product.price_currency == "SAT" { + p.price_sats_override.unwrap_or(product.price_sats) } 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)); (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!( + "
Limited: {} of {} remaining
", + remaining, + code.max_uses.unwrap_or(0) + ) + } else { + String::new() + }; + ( + format!( + "
{}
{}", + html_escape(&tagline), + remaining_html, + ), + format!( + "
{}{}
", + original_fmt, + if product.price_currency == "SAT" { "sats" } else { product.price_currency.as_str() }, + ), + ) + } else { + (String::new(), String::new()) + }; let description = p .metadata .get("description") @@ -982,6 +1092,29 @@ fn render_tier_picker( // If the product has an entitlements catalog, render // each policy entitlement using the catalog's display // 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 = arr + .iter() + .filter_map(|v| v.as_str()) + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| format!("
  • {}
  • ", html_escape(s))) + .collect(); + if lis.is_empty() { + String::new() + } else { + format!("
      {}
    ", lis.join("")) + } + }) + .unwrap_or_default(); // raw slug if the catalog is empty or the slug isn't in // it (legacy slugs that predate the catalog land here). let entitlements_html = if p.entitlements.is_empty() { @@ -1075,12 +1208,22 @@ fn render_tier_picker( } else { ("", 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!( - r#"
    {popular_pill}
    {name}
    {price_fmt}{price_unit}{cadence_suffix}
    {dur_html}{recurring_meta}{trial_banner}{trial_meta}{description_html}{entitlements_html}
    "#, + r#"
    {popular_pill}{featured_ribbon}
    {name}
    {original_price_html}
    {price_fmt}{price_unit}{cadence_suffix}
    {dur_html}{recurring_meta}{trial_banner}{trial_meta}{description_html}{marketing_html}{entitlements_html}
    "#, classes = classes, slug = slug_attr, popular_pill = popular_pill, + featured_ribbon = featured_ribbon, name = name, + original_price_html = original_price_html, price_fmt = price_fmt, price_unit = price_unit, cadence_suffix = cadence_suffix, @@ -1089,6 +1232,7 @@ fn render_tier_picker( trial_banner = trial_banner, trial_meta = trial_meta, description_html = description_html, + marketing_html = marketing_html, entitlements_html = entitlements_html, ) }) diff --git a/licensing-service/src/api/discount_codes.rs b/licensing-service/src/api/discount_codes.rs index 2ce2b39..ad392be 100644 --- a/licensing-service/src/api/discount_codes.rs +++ b/licensing-service/src/api/discount_codes.rs @@ -47,6 +47,12 @@ pub struct CreateDiscountCodeReq { pub referrer_label: Option, #[serde(default)] 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( @@ -117,6 +123,7 @@ pub async fn create( policy_id.as_deref(), req.referrer_label.as_deref(), &req.description, + req.featured, ) .await?; @@ -197,6 +204,10 @@ pub struct UpdateDiscountCodeReq { pub description: Option, #[serde(default, deserialize_with = "deser_double_option", skip_serializing_if = "Option::is_none")] pub referrer_label: Option>, + /// 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, } /// Helper for `Option>` 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.description.as_deref(), req.referrer_label.as_ref().map(|opt| opt.as_deref()), + req.featured, ) .await?; diff --git a/licensing-service/src/api/policies.rs b/licensing-service/src/api/policies.rs index 5a1b7d7..9710d83 100644 --- a/licensing-service/src/api/policies.rs +++ b/licensing-service/src/api/policies.rs @@ -778,6 +778,22 @@ pub async fn list_public_policies( } 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 = + 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 = policies .into_iter() .map(|p| { @@ -797,7 +813,41 @@ pub async fn list_public_policies( .get("highlight") .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. + 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); + // 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!({ "slug": p.slug, "name": p.name, @@ -807,12 +857,16 @@ pub async fn list_public_policies( "max_machines": p.max_machines, "is_trial": p.is_trial, "entitlements": p.entitlements, + "marketing_bullets": marketing_bullets, "highlighted": highlighted, // Recurring-subscription cadence — buy page renders // "Renews every N days" / "$X/month" when is_recurring=true. "is_recurring": p.is_recurring, "renewal_period_days": p.renewal_period_days, "trial_days": p.trial_days, + // Featured (launch-special) discount metadata — + // null when no applicable featured code exists. + "featured_discount": featured, }) }) .collect(); diff --git a/licensing-service/src/api/purchase.rs b/licensing-service/src/api/purchase.rs index 9c58641..eebb629 100644 --- a/licensing-service/src/api/purchase.rs +++ b/licensing-service/src/api/purchase.rs @@ -249,8 +249,28 @@ pub async fn start( // // If C, D, or E fail after B succeeded, we call release_code_slot to // 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 = req + .code + .as_deref() + .filter(|s| !s.trim().is_empty()) + .map(|s| s.to_string()); + let effective_code: Option = 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) = - 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) .await? @@ -528,7 +548,9 @@ pub async fn start( /// Apply the discount math. Returns the sats to subtract from `base`. /// 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 { "percent" => { // amount is basis points (0..=10000). 5000 == 50%. diff --git a/licensing-service/src/db/repo.rs b/licensing-service/src/db/repo.rs index cad554a..ae25e7d 100644 --- a/licensing-service/src/db/repo.rs +++ b/licensing-service/src/db/repo.rs @@ -2064,6 +2064,13 @@ pub async fn list_audit( // ---------- Discount codes ---------- 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::("featured") + .map(|v| v != 0) + .unwrap_or(false); DiscountCode { id: row.get("id"), code: row.get("code"), @@ -2077,6 +2084,7 @@ fn row_to_discount_code(row: sqlx::sqlite::SqliteRow) -> DiscountCode { referrer_label: row.get("referrer_label"), description: row.get("description"), active: row.get::("active") != 0, + featured, created_at: row.get("created_at"), updated_at: row.get("updated_at"), } @@ -2122,6 +2130,7 @@ pub async fn create_discount_code( applies_to_policy_id, referrer_label, description, + false, // not featured by default — backwards-compat for callers ) .await } @@ -2148,6 +2157,7 @@ pub async fn create_discount_code_with_currency( applies_to_policy_id: Option<&str>, referrer_label: Option<&str>, description: &str, + featured: bool, ) -> AppResult { if !matches!( kind, @@ -2203,8 +2213,8 @@ pub async fn create_discount_code_with_currency( "INSERT INTO discount_codes (id, code, kind, amount, discount_currency, max_uses, used_count, expires_at, applies_to_product_id, applies_to_policy_id, referrer_label, - description, active, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, 1, ?, ?)", + description, active, featured, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, 1, ?, ?, ?)", ) .bind(&id) .bind(&normalized) @@ -2217,6 +2227,7 @@ pub async fn create_discount_code_with_currency( .bind(applies_to_policy_id) .bind(referrer_label) .bind(description) + .bind(featured as i64) .bind(&now) .bind(&now) .execute(pool) @@ -2239,7 +2250,7 @@ pub async fn get_discount_code_by_id( 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, created_at, updated_at + description, active, featured, created_at, updated_at FROM discount_codes WHERE id = ?", ) .bind(id) @@ -2256,7 +2267,7 @@ pub async fn get_discount_code_by_code( 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, created_at, updated_at + description, active, featured, created_at, updated_at FROM discount_codes WHERE code = ?", ) .bind(&normalized) @@ -2272,18 +2283,72 @@ pub async fn list_discount_codes( let q = if only_active { "SELECT id, code, kind, amount, max_uses, used_count, expires_at, 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" } else { "SELECT id, code, kind, amount, max_uses, used_count, expires_at, 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" }; let rows = sqlx::query(q).fetch_all(pool).await?; 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//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> { + 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( pool: &SqlitePool, id: &str, @@ -2317,6 +2382,7 @@ pub async fn update_discount_code( expires_at: Option>, description: Option<&str>, referrer_label: Option>, + featured: Option, ) -> AppResult { // Re-fetch to validate amount against the existing kind. let existing = get_discount_code_by_id(pool, id) @@ -2377,6 +2443,9 @@ pub async fn update_discount_code( if referrer_label.is_some() { sets.push("referrer_label = ?"); } + if featured.is_some() { + sets.push("featured = ?"); + } if sets.is_empty() { return Ok(existing); } @@ -2402,6 +2471,9 @@ pub async fn update_discount_code( if let Some(opt_r) = referrer_label { q = q.bind(opt_r); } + if let Some(f) = featured { + q = q.bind(f as i64); + } q = q.bind(&now).bind(id); q.execute(pool).await?; diff --git a/licensing-service/src/models.rs b/licensing-service/src/models.rs index 2dac6f0..1f30359 100644 --- a/licensing-service/src/models.rs +++ b/licensing-service/src/models.rs @@ -324,6 +324,13 @@ pub struct DiscountCode { pub referrer_label: Option, pub description: String, 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 updated_at: String, } diff --git a/licensing-service/web/index.html b/licensing-service/web/index.html index 22e9c15..356c039 100644 --- a/licensing-service/web/index.html +++ b/licensing-service/web/index.html @@ -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 card = el('div', { 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);', }, [ 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 description = (typeof meta.description === 'string') ? meta.description : '' 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 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 })() 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(() => { const cb = card.querySelector('[name=e_pol_highlight]') 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' }, [graceField, machinesField]), entField, + bulletsField, el('div', { class: 'row-2' }, [highlightField, trialField]), // Tier ladder rank — sits in its own row above the recurring section. tierRankField, @@ -2083,6 +2092,10 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; } else delete newMetadata.description if (newHighlight) newMetadata.highlight = true 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 price_sats_override = priceRaw === '' ? null : Math.max(0, parseInt(priceRaw, 10) || 0) // 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') : 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. const cat = product.entitlements_catalog || [] const entChips = (pol.entitlements || []).length === 0 ? null : 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) => { const entry = cat.find((c) => c.slug === 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);' : ''), }, [ 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', { style: 'font-family:var(--font-display); font-weight:600; font-size:18px; color:var(--navy-950); letter-spacing:-0.01em', }, pol.name), @@ -2401,6 +2449,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; } description ? el('p', { style: 'font-size:13px; line-height:1.45; color:var(--ink-700); margin:6px 0 0', }, description) : null, + marketingList, entChips, el('div', { class: 'muted', style: 'font-size:11px; margin-top:4px' }, (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', }) + // "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 // card narrow). 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.' : 'Comma-separated slugs. Define a product catalog for click-to-pick.', 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 // disclosure; cards already imply compactness). 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' ? Math.max(1, parseInt(customDaysInput.value, 10) || 0) * 86400 : 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 = { product_slug: product.slug, 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), is_trial: false, entitlements: Array.isArray(entRead()) ? entRead() : entRead(), - metadata: {}, + metadata: metadata, price_sats_override: isSat ? Math.max(0, parseInt(priceInput.value, 10) || 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') routes.policies() }) - target.appendChild(plainCard([ - el('div', { style: 'display:flex; align-items:center; gap:14px; flex-wrap:wrap' }, [ - 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', { - for: 'showArchivedPolicies', - style: 'font-size:12.5px; color:var(--ink-700); white-space:nowrap; cursor:pointer', - }, [archivedToggle, 'Show archived']), - ]), + target.appendChild(el('div', { + style: 'display:flex; justify-content:flex-end; margin-bottom:14px', + }, [ + el('label', { + for: 'showArchivedPolicies', + style: 'font-size:12.5px; color:var(--ink-700); white-space:nowrap; cursor:pointer', + }, [archivedToggle, 'Show archived']), ])) // Intentionally not used: `create` (legacy disclosure-form // 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('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 () { const status = el('div', { class: 'muted', style: 'margin-top:8px' }, 'Creating…') 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 const rl = create.querySelector('[name=referrer_label]').value.trim() 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 }) status.replaceWith(ok('Created. Reloading…')) setTimeout(routes.codes, 600) @@ -3696,6 +3790,23 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; } textarea: true, 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 status = el('div', { class: 'muted', style: 'margin-top:8px' }, 'Saving…') 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() body.referrer_label = refRaw === '' ? null : refRaw 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 }) status.replaceWith(ok('Saved. Reloading…')) 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' }, [expField, refField]), descField, + featuredField, el('div', { style: 'margin-top:8px' }, [saveBtn, cancelBtn]), ])) editPanel.scrollIntoView({ behavior: 'smooth', block: 'start' }) } try { - const j = await api('/v1/admin/discount-codes?include_inactive=true') - const codes = j.codes || [] - const rows = codes.map((c) => { - // Currency-aware rendering. SAT-currency codes show "5,000 - // sats off"; fiat codes show "$10.00 off" with cents-to- - // dollars conversion. Backwards-compat for older rows that - // don't carry discount_currency: treat as SAT. + // Fetch products + codes in parallel so we can group codes by product. + const [productsResp, codesResp] = await Promise.all([ + api('/v1/products').catch(() => ({ products: [] })), + api('/v1/admin/discount-codes?include_inactive=true'), + ]) + const products = productsResp.products || [] + 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 fmtFiat = (cents, sym) => sym + (cents / 100).toFixed(2) 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') const usage = c.used_count + ' / ' + (c.max_uses == null ? '∞' : c.max_uses) 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, amountStr), el('td', null, usage), @@ -3811,14 +3938,84 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; } }, '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', - codes.length + ' total', - ['Code', 'Kind', 'Amount', 'Used', 'Expires', 'Status', ''], - rows, - 'No codes yet.' - )) + + // Single-product instances: flat table with no grouping noise. + // Multi-product instances OR any global codes: render one card + // per group, matching the Licenses + Subscriptions tab pattern. + const useGrouping = products.length > 1 || globalCodes.length > 0 + 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) { 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) { const rowsHost = el('div', { style: 'display:flex; flex-direction:column; gap:8px' }) 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', { 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', { - class: 'input', placeholder: 'slug', - value: slug || '', - 'data-field': 'slug', - }), - 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 - })(), + el('div', { + style: 'display:grid; grid-template-columns: 1fr 1.4fr auto; gap:6px; align-items:center', + }, [slugInput, nameInput, removeBtn]), + descInput, ]) + removeBtn.addEventListener('click', () => row.remove()) rowsHost.appendChild(row) } if (Array.isArray(initial) && initial.length > 0) { diff --git a/startos/versions/v0.2.0.ts b/startos/versions/v0.2.0.ts index b9f566d..4367878 100644 --- a/startos/versions/v0.2.0.ts +++ b/startos/versions/v0.2.0.ts @@ -58,6 +58,20 @@ const RELEASE_NOTES = [ // in RELEASE_NOTES above (the milestone). Subsequent revisions // append here. 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//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.', '', '**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') export const v0_2_0 = VersionInfo.of({ - version: '0.2.0:15', + version: '0.2.0:16', releaseNotes: { en_US: ROUTINE_NOTES }, // No on-disk transformation needed — v0.2.0:0 is a label change. // SQLite-level migrations live separately under