From c6955cd2ade05df9c9d51b2e3cb9a0962932e529 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 28 May 2026 14:28:33 -0400 Subject: [PATCH] =?UTF-8?q?Phase=203=20=E2=80=94=20zap-gated=20templates?= =?UTF-8?q?=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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=` 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 --- src/index.html | 273 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 270 insertions(+), 3 deletions(-) diff --git a/src/index.html b/src/index.html index d50493c..b4db7a1 100644 --- a/src/index.html +++ b/src/index.html @@ -4286,6 +4286,20 @@ +
+ + +
@@ -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);