v0.2.0:32 — Per-product policy cap pre-check + grandfather banner
Closes the third tier-enforced surface (Creator caps policies at 5 per product). Same UX shape as the global products + codes pre-check in v0.2.0:31, scoped to a single product instead of the whole instance. - routes.policies fetches /v1/admin/tier once on render and threads the status into renderPolicyCardGrid. - renderPolicyCardGrid renders a grandfather banner above the tier grid when policies.length > caps.policies_per_product (per- product, since the cap is per-product). - renderDraftTierCard accepts (tierStatus, productPolicyCount) and shows the same pre-check warning at the top of the draft form when used == cap - 1 (approaching) or used >= cap (over). - Reuses existing helpers (capPreCheckCard, grandfatherBanner) by synthesizing a tierStatus shape with caps.policies mapped to the per-product cap. No new component code. UI-only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2667,7 +2667,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
|||||||
* bubble picker against the product's catalog (or fallback
|
* bubble picker against the product's catalog (or fallback
|
||||||
* textarea when no catalog).
|
* textarea when no catalog).
|
||||||
*/
|
*/
|
||||||
function renderDraftTierCard(product, onCommit, onCancel) {
|
function renderDraftTierCard(product, onCommit, onCancel, tierStatus, productPolicyCount) {
|
||||||
// Compact inputs. Help icons replace per-field hint text to
|
// Compact inputs. Help icons replace per-field hint text to
|
||||||
// keep the card narrow.
|
// keep the card narrow.
|
||||||
const nameInput = el('input', {
|
const nameInput = el('input', {
|
||||||
@@ -2947,6 +2947,31 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
|||||||
if (det) det.style.display = recurringCb.checked ? 'block' : 'none'
|
if (det) det.style.display = recurringCb.checked ? 'block' : 'none'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Per-product policy cap pre-check. tierStatus.caps.policies_per_product
|
||||||
|
// is the cap (or null = unlimited). If the operator has 4/5 policies
|
||||||
|
// and they just clicked "+ Add tier", show an inline warning at the
|
||||||
|
// top of the draft so they know what to expect. If already over,
|
||||||
|
// show the "cap reached" notice instead (operator can still try; the
|
||||||
|
// existing 402 modal catches the failed submit).
|
||||||
|
let policyCapNotice = null
|
||||||
|
if (tierStatus && tierStatus.caps && tierStatus.caps.policies_per_product != null) {
|
||||||
|
const cap = tierStatus.caps.policies_per_product
|
||||||
|
const used = productPolicyCount || 0
|
||||||
|
if (used >= cap || used === cap - 1) {
|
||||||
|
// Synthesize a per-product tierStatus shape so capPreCheckCard
|
||||||
|
// can do its thing without a special case. We map the
|
||||||
|
// policies-per-product cap onto a synthetic 'policies' key.
|
||||||
|
const synth = {
|
||||||
|
tier_name: tierStatus.tier_name,
|
||||||
|
next_tier: tierStatus.next_tier,
|
||||||
|
upgrade_url: tierStatus.upgrade_url,
|
||||||
|
caps: { policies: cap },
|
||||||
|
usage: { policies: used },
|
||||||
|
}
|
||||||
|
policyCapNotice = capPreCheckCard(synth, 'policies', 'policies on this product')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return el('div', {
|
return el('div', {
|
||||||
class: 'tier-card draft',
|
class: 'tier-card draft',
|
||||||
style:
|
style:
|
||||||
@@ -2955,7 +2980,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
|||||||
'padding:18px 16px; min-height:280px; ' +
|
'padding:18px 16px; min-height:280px; ' +
|
||||||
'box-shadow:0 0 0 1px rgba(191,160,104,0.06); ' +
|
'box-shadow:0 0 0 1px rgba(191,160,104,0.06); ' +
|
||||||
'display:flex; flex-direction:column;',
|
'display:flex; flex-direction:column;',
|
||||||
}, [body])
|
}, [policyCapNotice, body].filter(Boolean))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2963,7 +2988,27 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
|||||||
* as a tier card + an "+ Add tier" card on the right. Click to
|
* as a tier card + an "+ Add tier" card on the right. Click to
|
||||||
* add → morphs into a draft card; multiple drafts can coexist.
|
* add → morphs into a draft card; multiple drafts can coexist.
|
||||||
*/
|
*/
|
||||||
function renderPolicyCardGrid(product, policies, byPolicyCounts, onMutate) {
|
function renderPolicyCardGrid(product, policies, byPolicyCounts, onMutate, tierStatus) {
|
||||||
|
// Grandfather banner for this product, when its policy count
|
||||||
|
// strictly exceeds the per-product cap (e.g. operator downgraded
|
||||||
|
// from Pro to Creator with 6 policies on a product, Creator caps
|
||||||
|
// at 5). Renders ABOVE the grid so the operator sees the state
|
||||||
|
// before scrolling through cards.
|
||||||
|
let gfBanner = null
|
||||||
|
if (tierStatus && tierStatus.caps && tierStatus.caps.policies_per_product != null) {
|
||||||
|
const cap = tierStatus.caps.policies_per_product
|
||||||
|
const used = (policies || []).length
|
||||||
|
if (used > cap) {
|
||||||
|
const synth = {
|
||||||
|
tier_name: tierStatus.tier_name,
|
||||||
|
next_tier: tierStatus.next_tier,
|
||||||
|
upgrade_url: tierStatus.upgrade_url,
|
||||||
|
caps: { policies: cap },
|
||||||
|
usage: { policies: used },
|
||||||
|
}
|
||||||
|
gfBanner = grandfatherBanner(synth, 'policies', 'policies on this product')
|
||||||
|
}
|
||||||
|
}
|
||||||
const grid = el('div', {
|
const grid = el('div', {
|
||||||
style:
|
style:
|
||||||
'display:grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); ' +
|
'display:grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); ' +
|
||||||
@@ -3023,6 +3068,8 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
|||||||
grid.replaceChild(newCard, draft)
|
grid.replaceChild(newCard, draft)
|
||||||
},
|
},
|
||||||
() => grid.replaceChild(makePlaceholder(), draft), // cancel → swap back
|
() => grid.replaceChild(makePlaceholder(), draft), // cancel → swap back
|
||||||
|
tierStatus,
|
||||||
|
(policies || []).length,
|
||||||
)
|
)
|
||||||
grid.replaceChild(draft, placeholder)
|
grid.replaceChild(draft, placeholder)
|
||||||
grid.appendChild(makePlaceholder())
|
grid.appendChild(makePlaceholder())
|
||||||
@@ -3032,6 +3079,13 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
|||||||
grid.appendChild(makePlaceholder())
|
grid.appendChild(makePlaceholder())
|
||||||
|
|
||||||
wireTierGridDragAndDrop(grid, onMutate)
|
wireTierGridDragAndDrop(grid, onMutate)
|
||||||
|
// If a grandfather banner exists, wrap [banner, grid] in one
|
||||||
|
// container; otherwise return the grid as before. Wrapping is the
|
||||||
|
// simplest way to render the banner inside the same product card
|
||||||
|
// body without altering the caller (which appends a single child).
|
||||||
|
if (gfBanner) {
|
||||||
|
return el('div', null, [gfBanner, grid])
|
||||||
|
}
|
||||||
return grid
|
return grid
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3109,6 +3163,12 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
|||||||
const target = document.getElementById('route-target')
|
const target = document.getElementById('route-target')
|
||||||
target.innerHTML = ''
|
target.innerHTML = ''
|
||||||
|
|
||||||
|
// Per-product policy cap state. Used by renderPolicyCardGrid +
|
||||||
|
// renderDraftTierCard to surface a grandfather banner (when over)
|
||||||
|
// and a pre-check warning (when at cap-1 or over) on each product
|
||||||
|
// card. Force-refresh so the count reflects any creates/deletes
|
||||||
|
// from the prior route.
|
||||||
|
const policiesTierStatus = await loadTierStatus({ forceRefresh: true })
|
||||||
const products = (await api('/v1/products').catch(() => ({ products: [] }))).products || []
|
const products = (await api('/v1/products').catch(() => ({ products: [] }))).products || []
|
||||||
if (products.length === 0) {
|
if (products.length === 0) {
|
||||||
target.appendChild(plainCard([
|
target.appendChild(plainCard([
|
||||||
@@ -3565,7 +3625,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
|||||||
previewBtn ? el('span', { style: 'margin-left:auto' }, previewBtn) : null,
|
previewBtn ? el('span', { style: 'margin-left:auto' }, previewBtn) : null,
|
||||||
]),
|
]),
|
||||||
el('div', { class: 'card-body' }, [
|
el('div', { class: 'card-body' }, [
|
||||||
renderPolicyCardGrid(p, policies, byPolicy, () => routes.policies()),
|
renderPolicyCardGrid(p, policies, byPolicy, () => routes.policies(), policiesTierStatus),
|
||||||
]),
|
]),
|
||||||
])
|
])
|
||||||
target.appendChild(productCard)
|
target.appendChild(productCard)
|
||||||
|
|||||||
@@ -58,6 +58,18 @@ const RELEASE_NOTES = [
|
|||||||
// in RELEASE_NOTES above (the milestone). Subsequent revisions
|
// in RELEASE_NOTES above (the milestone). Subsequent revisions
|
||||||
// append here.
|
// append here.
|
||||||
const ROUTINE_NOTES = [
|
const ROUTINE_NOTES = [
|
||||||
|
'0.2.0:32 — **Per-product policy cap also pre-checked + grandfathered.** Extends the v0.2.0:31 cap-handling pattern to the third tier-enforced surface (Creator caps each product at 5 policies). Same shape, just scoped to a single product instead of the whole instance.',
|
||||||
|
'',
|
||||||
|
'**Pre-check.** When the operator clicks "+ Add tier" on a product that already has 4 of 5 policies, the draft tier card opens with a gold-bordered "Approaching cap" warning at the top: "You\'re at 4/5 policies on this product. Creating one more will hit your Creator tier cap." Includes a direct upgrade link. The existing 402 → upgrade modal still fires if the operator pushes through.',
|
||||||
|
'',
|
||||||
|
'**Grandfather.** When a single product carries more policies than the current tier\'s per-product cap (e.g. operator on Pro authored 7 policies, then downgraded to Creator), that product\'s card now renders a persistent grandfather banner above its tier grid: "Grandfathered: 7 policies on this product active vs Creator tier cap of 5. Existing policies keep working. Creating new ones is blocked until you upgrade to Pro." The banner appears per-product (not page-wide) since the cap is per-product. Other products on the same instance show their own state independently.',
|
||||||
|
'',
|
||||||
|
'**Implementation note.** Reuses the v0.2.0:31 helpers (`capPreCheckCard`, `grandfatherBanner`) by synthesizing a `tierStatus` shape with `caps.policies` mapped to the per-product cap — no new component code needed, just an extra parameter threaded through `renderPolicyCardGrid` → `renderDraftTierCard`.',
|
||||||
|
'',
|
||||||
|
'**Test count: 87** (unchanged — pure UI).',
|
||||||
|
'',
|
||||||
|
'**Upgrade path.** v0.2.0:31 → v0.2.0:32 is a drop-in. No schema, no SDK breaking change.',
|
||||||
|
'',
|
||||||
'0.2.0:31 — **Four-item punchlist landed: cap-hit pre-check, grandfather banner, webhooks empty state, help-icon overhaul.** Clears the remaining outstanding admin-UI items.',
|
'0.2.0:31 — **Four-item punchlist landed: cap-hit pre-check, grandfather banner, webhooks empty state, help-icon overhaul.** Clears the remaining outstanding admin-UI items.',
|
||||||
'',
|
'',
|
||||||
'**Cap-hit pre-check (item #7).** Operators no longer have to submit-and-bounce off a 402 to learn they\'re about to hit a tier cap. The Products page and the Discount Codes page each call `/v1/admin/tier` on render and surface a gold-bordered "Approaching cap" warning inline above the create-form submit button whenever usage is at cap-1 (e.g. 4/5 products on Creator). The warning includes a direct upgrade link. The existing 402 → upgrade modal still fires if the operator goes ahead and submits.',
|
'**Cap-hit pre-check (item #7).** Operators no longer have to submit-and-bounce off a 402 to learn they\'re about to hit a tier cap. The Products page and the Discount Codes page each call `/v1/admin/tier` on render and surface a gold-bordered "Approaching cap" warning inline above the create-form submit button whenever usage is at cap-1 (e.g. 4/5 products on Creator). The warning includes a direct upgrade link. The existing 402 → upgrade modal still fires if the operator goes ahead and submits.',
|
||||||
@@ -477,7 +489,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:31',
|
version: '0.2.0:32',
|
||||||
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