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:
@@ -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;
|
||||||
@@ -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?;
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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%.
|
||||||
|
|||||||
@@ -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?;
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user