Phase 3 — zap-gated templates UI

The publish flow now lets a creator tag a kind-30078 event as premium
with a sat minimum, and the feed swaps the Use/Fork button for a
"⚡ Unlock for N sats" CTA that drives the dual-zap split orchestrator
from Phase 2.

Publish side:
  - state.publish = { zapGate, zapMin } added to the editor state so
    autosave persists the user's last choice.
  - getTemplateTags() emits `zap-gate=true` + `zap-min=<sats>` tags
    when state.publish.zapGate is on. Encrypted (private) designs
    cannot be gated — they're not feed-listed, so the tags are
    deliberately suppressed.
  - publish-confirm modal grows a "Premium template" checkbox + a
    minimum-sats input. An inline note recomputes the creator/
    platform split from the current input and the PLATFORM_FEE_BPS
    constant on every keystroke. Toggling Private hides the whole
    premium section.
  - Two blocking pre-publish checks: (a) creator's kind-0 must carry
    a lud16 or lud06 — corrective inline error + a hard stop in
    handlePublishConfirmPublish; (b) the chosen zap-min must split
    to ≥1 sat creator share.

Consumer side:
  - isZapGated(event) / getZapMin(event) — pure tag readers.
  - buildRowsFromEvents annotates rows with
    `row.zapGate = { gated, min, unlocked }` so downstream code can
    branch cheaply.
  - buildFeedCard swaps "Use/Fork" for "⚡ Unlock for N sats" when
    `row.zapGate.gated && !row.zapGate.unlocked`, with a tooltip
    that names the platform-fee share. Preview button + whole-card
    click still open the design preview (the gate is on importing,
    not on looking).
  - openUnlockConfirm(row) — wallet/login checks, confirm() with
    the split shown, runs payZapSplit with a live status toast,
    marks unlocked, refreshes the feed.
  - localStorage cache UNLOCK_CACHE_KEY ('casewrap-unlocked-templates')
    with markUnlocked / isUnlockedLocally / getAllUnlocked.
    markUnlocked also drops any matching card DOM so diffWallCards
    rebuilds it on next rerender — without this, the optimistic
    same-id reuse kept the lock button on screen after payment.

Relay fast-path:
  - relayFastPathUnlockCheck(gatedEventIds) — kind-9735 query with
    `#e=[gatedIds]` + `#P=[myPubkey]`. Any matching receipt = the
    user already paid, so we mark unlocked locally without a re-pay.
    Wired into fetchFeedAndReactions right after template fetch so a
    cache wipe / new device picks up existing unlocks for free.

Tested via CDP (synthetic events; live e2e is Phase 4):
  - isZapGated tag detection (true/false/missing).
  - getZapMin parses the value.
  - markUnlocked / isUnlockedLocally round-trip.
  - getTemplateTags emits zap-gate + zap-min when on/public, omits
    when private.
  - Feed renders the gated event with "⚡ Unlock for 150 sats"
    button when locked; after markUnlocked + rerender, switches back
    to "Use/Fork".
  - Publish modal: Premium toggle expands the details panel; split
    note matches platform.splitZapAmount math; lud16-missing surfaces
    the corrective inline error.
  - Regression smoke: custom-art editor + render still work.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.7 committed 2026-05-28 14:28:33 -04:00
1 parent d22089c3e5
commit c6955cd2ad
1 file changed
+270 -3
+270 -3
View File
@@ -4286,6 +4286,20 @@
</label>
<p id="publishConfirmPrivateNote" class="hint" style="display:none;margin:8px 0 0;padding:8px 10px;border:1px solid rgba(251,191,36,.4);background:rgba(251,191,36,.1);color:#92400e;border-radius:6px;font-size:.78rem">Private designs are encrypted — relays and other people can't read the contents. The event still reveals that you published something, and when. Encryption can't undo a past public publish.</p>
</div>
<div id="publishConfirmZapGate" style="margin:0 0 12px">
<label class="toggle" id="publishConfirmZapGateLabel" style="display:flex;gap:8px;align-items:flex-start;cursor:pointer">
<input type="checkbox" id="publishConfirmZapGateToggle" />
<span>Premium template — viewers must zap to unlock.</span>
</label>
<div id="publishConfirmZapGateDetails" style="display:none;margin:8px 0 0 24px;padding:8px 10px;border:1px solid var(--line);border-radius:6px;background:var(--bg)">
<label style="display:flex;align-items:center;gap:8px;font-size:.82rem;margin:0 0 6px">
Minimum sats to unlock
<input type="number" id="publishConfirmZapMin" min="10" step="1" value="100" style="width:90px" />
</label>
<p id="publishConfirmSplitNote" class="hint" style="margin:0;font-size:.74rem"></p>
<p id="publishConfirmZapGateError" class="hint" style="display:none;margin:6px 0 0;color:var(--warn);font-size:.76rem"></p>
</div>
</div>
<p id="publishConfirmEditNote" class="hint" style="display:none;margin:0 0 12px;padding:8px 10px;border:1px solid rgba(251,191,36,.4);background:rgba(251,191,36,.1);color:#92400e;border-radius:6px;font-size:.78rem"></p>
<p id="publishConfirmError" class="hint" style="display:none;margin:0 0 12px;color:var(--warn);font-size:.82rem"></p>
<div class="buttonRow" style="margin:0;justify-content:flex-end;flex-wrap:wrap;gap:8px">
@@ -5188,6 +5202,146 @@
// ---- end Platform-fee split --------------------------------------------
// ---- Zap-gated unlocks (Phase 3) ---------------------------------------
// Per-event "premium" gating: a creator tags a kind-30078 event with
// `zap-gate` + `zap-min` and viewers must pay (split between creator and
// platform) before the import button unlocks.
const UNLOCK_CACHE_KEY = 'casewrap-unlocked-templates';
function isZapGated(event) {
if (!event || !Array.isArray(event.tags)) return false;
return event.tags.some((t) => t[0] === 'zap-gate' && (t[1] === 'true' || t[1] === '1'));
}
function getZapMin(event) {
if (!event || !Array.isArray(event.tags)) return 0;
const tag = event.tags.find((t) => t[0] === 'zap-min');
const n = tag ? parseInt(tag[1], 10) : 0;
return Number.isFinite(n) && n > 0 ? n : 0;
}
function getAllUnlocked() {
try { return JSON.parse(localStorage.getItem(UNLOCK_CACHE_KEY) || '{}') || {}; }
catch { return {}; }
}
function isUnlockedLocally(eventId) {
return !!getAllUnlocked()[eventId];
}
function markUnlocked(eventId, data = {}) {
try {
const all = getAllUnlocked();
all[eventId] = { unlockedAt: Date.now(), ...data };
localStorage.setItem(UNLOCK_CACHE_KEY, JSON.stringify(all));
// Invalidate any existing card DOM so diffWallCards rebuilds it
// from the latest row state (button switches from "⚡ Unlock"
// back to "Use/Fork").
document.querySelectorAll(`[data-event-id="${eventId}"]`).forEach((el) => el.remove());
} catch (err) { console.warn('[zap-gated] markUnlocked failed', err); }
}
// Relay fast-path — on feed load, query for zap receipts we've already
// sent for gated templates. Matching receipts auto-unlock without
// re-payment, so a cache wipe / private window / new device feels
// seamless.
async function relayFastPathUnlockCheck(gatedEventIds) {
if (!Array.isArray(gatedEventIds) || !gatedEventIds.length) return;
const myPubkey = normalizePubkey(state.community?.myPubkey || '');
if (!myPubkey) return;
const relays = splitRelayList(state.community?.relays || '');
const fresh = gatedEventIds.filter((id) => !isUnlockedLocally(id));
if (!fresh.length) return;
try {
// NIP-57 zap receipts tag the zapper as `P` (capital) and the target
// event as `e`. Query both filters in one shot.
const receipts = await queryRelays(relays, {
kinds: [9735],
'#e': fresh,
'#P': [myPubkey],
}, { timeoutMs: 4000 });
// For each unique #e that has at least one receipt with this user
// as the zapper, mark unlocked. We trust the relay query here —
// NWC.verifyEvent could re-verify each receipt's signature but
// worst-case impact of a forged "I paid" claim is a single user's
// lock disappearing client-side; their actual import still works
// because the gated content isn't separately encrypted.
const unlockedSet = new Set();
for (const ev of receipts || []) {
const eTag = (ev.tags || []).find((t) => t[0] === 'e' && t[1]);
if (eTag && !isUnlockedLocally(eTag[1])) unlockedSet.add(eTag[1]);
}
for (const id of unlockedSet) markUnlocked(id, { source: 'relay-fastpath' });
} catch (err) {
// Non-fatal — the user can still pay normally if the fast-path
// didn't find anything.
console.warn('[zap-gated] relay fast-path failed', err);
}
}
// openUnlockConfirmModal — small inline modal asking the user to confirm
// the unlock (with the split shown), then runs payZapSplit and updates
// the feed on success.
async function openUnlockConfirm(row) {
const eventId = row?.e?.id;
if (!eventId) return;
if (!nwc.isConnected()) {
toastError('Connect a Lightning wallet first (Community → ⚡ Lightning wallet).');
// Open the nostr modal + scroll to the wallet panel so the user can
// act on the prompt immediately.
$('nostrModal')?.classList.add('open');
const det = $('walletDetails'); if (det) det.open = true;
return;
}
if (!state.community?.myPubkey) {
toastError('Sign in with Nostr first.');
return;
}
const min = getZapMin(row.e) || 100;
const { creatorSats, platformSats } = platform.splitZapAmount(min);
const authorProfile = _feedCache.profiles?.[row.e?.pubkey] || null;
const creatorMeta = {
lud16: authorProfile?.lud16,
lud06: authorProfile?.lud06,
};
const ok = confirm(
`Unlock "${row.title || '(untitled)'}" for ${min} sats?\n\n` +
`• Creator (${shortPubkey(row.e.pubkey)}): ${creatorSats} sats\n` +
`• Artstr platform fee: ${platformSats} sats (${(PLATFORM_FEE_BPS / 100).toFixed(0)}%)`
);
if (!ok) return;
// Live status line; reuses the toast system for simplicity.
let statusToast = toast('Starting unlock…', { duration: 60000 });
const onStatus = (msg) => {
if (statusToast?.dismiss) statusToast.dismiss();
statusToast = toast(msg, { duration: 60000 });
};
try {
const result = await payZapSplit({
creatorPubkey: row.e.pubkey,
creatorMetadata: creatorMeta,
eventId,
totalSats: min,
onStatus,
});
if (statusToast?.dismiss) statusToast.dismiss();
markUnlocked(eventId, {
creatorSats: result.creatorSats,
platformSats: result.platformSats,
creatorPreimage: result.creatorPreimage,
platformPreimage: result.platformPreimage,
});
toastSuccess(`Unlocked "${row.title || 'template'}" ✓`);
// Re-render the feed in place — the card will now show Import.
if (typeof _currentFeedRerender === 'function') {
try { _currentFeedRerender(); } catch {}
}
} catch (err) {
if (statusToast?.dismiss) statusToast.dismiss();
const msg = err?.message || 'Unlock failed.';
const stage = err?.stage ? ` (${err.stage} leg)` : '';
toastError(`${msg}${stage}`);
}
}
// ---- end Zap-gated unlocks ---------------------------------------------
// In-app toast — replaces all uses of native toastError(). Default type is
// 'info'; use toastError / toastSuccess for the common severity flavors.
function toast(message, opts = {}) {
@@ -5462,6 +5616,10 @@
// d-tag a private design keeps so edits replace it in place.
visibility: 'public',
privateDTag: '',
// Zap-gated premium publishing — populated by the publish-confirm
// modal. The publish path reads from here when adding `zap-gate` /
// `zap-min` tags to kind-30078 events.
publish: { zapGate: false, zapMin: 100 },
preset: 'dvd',
panelW: presets.dvd.panelW,
trimH: presets.dvd.trimH,
@@ -5858,6 +6016,13 @@
tags.push(['t', `casewrap-${category}`]);
}
tags.push(['language', language]);
// Zap-gated premium: opt-in flag the publish modal sets on state.publish.
// Encrypted (private) designs are not eligible — they're not in the feed.
if (state.publish?.zapGate && state.visibility !== 'private') {
tags.push(['zap-gate', 'true']);
const min = Math.max(10, Math.floor(Number(state.publish?.zapMin) || 0));
tags.push(['zap-min', String(min)]);
}
return tags;
}
@@ -16693,11 +16858,59 @@
if (privInput) privInput.checked = vis === 'private';
updatePublishConfirmVisibilityNote();
// Zap-gate section. Always reflects current state.publish; hidden
// entirely for private designs (encrypted ⇒ not feed-listed ⇒ can't be
// gated). Private toggle change re-runs updatePublishConfirmZapGate so
// toggling private hides the row.
updatePublishConfirmZapGate();
modal.style.display = 'flex';
modal.setAttribute('aria-hidden', 'false');
bringModalToFront(modal);
}
function updatePublishConfirmZapGate() {
const wrap = $('publishConfirmZapGate');
const toggle = $('publishConfirmZapGateToggle');
const details = $('publishConfirmZapGateDetails');
const minInput = $('publishConfirmZapMin');
const splitNote = $('publishConfirmSplitNote');
const errEl = $('publishConfirmZapGateError');
if (!wrap || !toggle || !details || !minInput || !splitNote || !errEl) return;
// Hide the whole section for private designs.
const allowed = state.visibility !== 'private';
wrap.style.display = allowed ? '' : 'none';
if (!allowed) {
toggle.checked = false;
state.publish = state.publish || { zapGate: false, zapMin: 100 };
state.publish.zapGate = false;
details.style.display = 'none';
return;
}
const pub = state.publish || (state.publish = { zapGate: false, zapMin: 100 });
toggle.checked = !!pub.zapGate;
minInput.value = Math.max(10, Math.floor(Number(pub.zapMin) || 100));
details.style.display = toggle.checked ? '' : 'none';
// Update split note + validate.
const min = Math.max(10, Math.floor(Number(minInput.value) || 100));
const { creatorSats, platformSats } = platform.splitZapAmount(min);
splitNote.textContent = `Of every unlock, the creator receives ${creatorSats} sats and the Artstr platform receives ${platformSats} sats (${(PLATFORM_FEE_BPS / 100).toFixed(0)}% fee).`;
// Block publish if the user doesn't have a Lightning address — relay-
// side recipients can't actually receive zaps without one.
const myLud16 = state.community?.myProfile?.lud16 || '';
const myLud06 = state.community?.myProfile?.lud06 || '';
if (toggle.checked && !(myLud16 || myLud06)) {
errEl.textContent = "Add a Lightning address (lud16) to your Nostr profile before publishing a premium template — viewers can't pay you otherwise.";
errEl.style.display = '';
} else if (toggle.checked && creatorSats <= 0) {
errEl.textContent = `That price splits to ${creatorSats} sats for the creator. Raise the minimum so your share is at least 1 sat.`;
errEl.style.display = '';
} else {
errEl.textContent = '';
errEl.style.display = 'none';
}
}
function updatePublishConfirmVisibilityNote() {
const note = $('publishConfirmPrivateNote');
if (note) note.style.display = state.visibility === 'private' ? '' : 'none';
@@ -16768,6 +16981,18 @@
const errEl = $('publishConfirmError');
if (btn) { btn.disabled = true; btn.textContent = 'Publishing…'; }
if (errEl) { errEl.style.display = 'none'; errEl.textContent = ''; }
// Block publish if zap-gate is on but the user doesn't have a usable
// Lightning address — viewers couldn't pay them anyway. The publish-
// modal note already nags inline; this is the hard stop.
if (state.publish?.zapGate && state.visibility !== 'private') {
const lud16 = state.community?.myProfile?.lud16 || '';
const lud06 = state.community?.myProfile?.lud06 || '';
if (!lud16 && !lud06) {
if (errEl) { errEl.textContent = 'Add a Lightning address (lud16) to your Nostr profile, or turn off Premium.'; errEl.style.display = ''; }
if (btn) { btn.disabled = false; btn.textContent = 'Publish'; }
return;
}
}
try {
await target.onConfirm();
closePublishConfirmModal();
@@ -17273,6 +17498,17 @@
fetchZapsForEvents(ids, relays).then(mutated => {
if (mutated) notifyFeedProgress();
}).catch(() => {});
// Zap-gated fast-path: for any gated template the user has already
// paid for (kind-9735 receipt on relays), mark unlocked so a cache
// wipe / new device picks up where they left off without paying
// again. Filters the fetched events for `zap-gate` tag first.
const gatedIds = templateEvents
.filter((e) => isZapGated(e))
.map((e) => e.id);
if (gatedIds.length && state.community?.myPubkey) {
relayFastPathUnlockCheck(gatedIds).then(() => notifyFeedProgress()).catch(() => {});
}
}
_feedCache.fetchedAt = Date.now();
} finally {
@@ -18918,6 +19154,13 @@
trust: trustTierForPubkey(e.pubkey)
};
row.softMod = computeSoftMod(row);
if (isZapGated(e)) {
row.zapGate = {
gated: true,
min: getZapMin(e) || 100,
unlocked: isUnlockedLocally(e.id),
};
}
if (uiKey) {
if (!existing) {
latestByAddress.set(uiKey, row);
@@ -19114,9 +19357,21 @@
prevBtn.type = 'button'; prevBtn.textContent = 'Preview';
prevBtn.addEventListener('click', e => { e.stopPropagation(); openDesignPreview(row); });
const btn = document.createElement('button');
btn.type = 'button'; btn.textContent = 'Use/Fork';
btn.title = 'Fork this design — opens it in the editor so you can save your own version';
btn.addEventListener('click', e => { e.stopPropagation(); onUse(); });
btn.type = 'button';
// Zap-gated: replace Use/Fork with an unlock-with-zap CTA. We still
// let the user open the preview (whole-card click stays); the gate is
// only on importing into the editor.
const locked = row.zapGate?.gated && !row.zapGate?.unlocked;
if (locked) {
btn.textContent = `⚡ Unlock for ${row.zapGate.min} sats`;
btn.className = 'primary';
btn.title = `Premium template — zap to unlock (creator + ${(PLATFORM_FEE_BPS / 100).toFixed(0)}% Artstr platform fee).`;
btn.addEventListener('click', e => { e.stopPropagation(); openUnlockConfirm(row); });
} else {
btn.textContent = 'Use/Fork';
btn.title = 'Fork this design — opens it in the editor so you can save your own version';
btn.addEventListener('click', e => { e.stopPropagation(); onUse(); });
}
const tipBtn = makeFeedCardTipButton(row);
const editBtn = makeFeedCardEditButton(row);
const isMine = rowIsOwnedByMe(row);
@@ -23577,9 +23832,21 @@
if (e.target.checked) {
state.visibility = e.target.value === 'private' ? 'private' : 'public';
updatePublishConfirmVisibilityNote();
updatePublishConfirmZapGate();
}
});
});
on('publishConfirmZapGateToggle', 'change', (e) => {
state.publish = state.publish || { zapGate: false, zapMin: 100 };
state.publish.zapGate = !!e.target.checked;
updatePublishConfirmZapGate();
});
on('publishConfirmZapMin', 'input', (e) => {
state.publish = state.publish || { zapGate: false, zapMin: 100 };
const v = Math.max(10, Math.floor(Number(e.target.value) || 100));
state.publish.zapMin = v;
updatePublishConfirmZapGate();
});
on('supportDevCloseBtn', 'click', closeSupportDevModal);
on('supportDevNoBtn', 'click', closeSupportDevModal);
on('supportDevLaterBtn', 'click', closeSupportDevModal);