Premium step 3 — encrypted publish + premium consume

PREMIUM_DESIGNS_FEATURE.md §19 item 3 + the consumer changes needed
to actually display, gate, and decrypt the new event shape. Closes
the architectural gap that Adam called out in testing: premium
templates were being published with the design payload in plaintext
on relays, with only a client-side tag asking nicely not to import.

Publish side
  - _publishPremiumDesign(payload, mode, typeTag) — new helper that
    runs when state.publish.zapGate && visibility === 'public'.
    Steps: render the watermarked preview (bails with a precise
    error if the design is too large or has CORS images), pick a
    `premium:<mode>:<base>` d-tag (or reuse state.editingDTag on
    edit-in-place), encrypt the payload via Premium.encryptPayload
    with the event coordinate as AAD, embed the preview JPEG in
    envelope.preview, then publish a single kind-30078 event with
    the §5.1 tag set (premium / premium-mode=softgate-v1 /
    softgate-epoch / encrypted / zap-gate / zap-min / zap-split /
    two `zap` tags with weights from PLATFORM_FEE_BPS / discovery
    tags / etc.).
  - publishCurrentTemplateToNostr branches into the premium path
    before the existing public path, after the private path. The
    label in the publish toast says "Premium" so the user knows
    which path ran.
  - getTemplateTags no longer emits zap-gate / zap-min from
    state.publish — that tag set is owned by the premium publish
    helper now, so non-premium publishes don't accidentally tag.

Consume side
  - isPremiumEvent(event), getPremiumEnvelope(event),
    premiumEventDTag, premiumAddressCoord, premiumEventMode,
    parsePremiumZapSplits — pure tag/content readers.
  - _stubPayloadFromPremiumEvent — synthesises a payload shape from
    public tags (title / category / language / mode) so the existing
    feed-card readers don't choke on a locked row whose real
    payload is encrypted.
  - buildRowsFromEvents: detects premium events; if the user has
    already unlocked (cached payload in UNLOCK_CACHE_KEY) we use
    the real decrypted payload, otherwise the stub. Annotates the
    row with row.premium = { envelope, previewJpegDataUrl,
    decrypted, address, dTag } AND row.zapGate so the existing
    lock-CTA logic keeps working.
  - Older tag-only events (zap-gate=true with no `premium` tag) are
    still surfaced as locked, but they're now a "delete + republish"
    nag — their plaintext payload is already on relays.
  - buildFeedCard miniPreviewWrap: for locked premium rows, renders
    the watermarked JPEG from envelope.preview.dataUrl as an <img>
    instead of trying to renderTemplatePreviewDOM on the stub
    payload. fitMiniPreview is skipped (image fits via CSS).
  - openDesignPreview (the preview pane): same gate. The preview
    body shows the watermarked JPEG + an honest "Soft-gate, not
    strong DRM" note. The Use/Fork button is replaced with a
    "⚡ Unlock for N sats" CTA that runs the existing
    openUnlockConfirm flow. The Save JSON button is disabled with a
    "Locked — pay to unlock the design data" tooltip. (Previously
    Save JSON downloaded the full payload — a flat bypass.)

Unlock decryption
  - openUnlockConfirm: after payZapSplit succeeds, if the row is
    premium, call Premium.decryptPayload with the row's envelope +
    coord. Cache the decrypted payload in markUnlocked alongside
    creator/platform preimages + the address coord. If decrypt
    fails after a successful payment (epoch mismatch, corrupted
    envelope), the payment proof is still cached so the user
    doesn't re-pay; the message asks them to refresh.
  - Once decrypted is in the cache, buildRowsFromEvents picks it
    up on the next rerender and the row's Use/Fork / Save JSON
    work normally (the existing import path reads row.payload).

Receipt fast-path by address
  - relayFastPathUnlockCheck now takes full events (not just ids).
    It runs two relay queries in parallel: kind-9735 with #a for
    premium events (address-coord scope so edit-in-place doesn't
    invalidate prior purchases — §13.2), and kind-9735 with #e for
    legacy tag-only events. Marks unlocked off whichever matches.
  - The call site in fetchFeedAndReactions now filters the
    template events to (premium || zap-gated) and passes them in
    full.

Copy
  - Publish modal toggle: "Premium encrypted — viewers see a
    watermarked preview and must zap to unlock the full design.
    Soft-gate, not strong DRM — once unlocked, buyers can still
    export or share." Reflects the spec's §13.5 honesty rule.

Tested via CDP with a stubbed publishCommunityEvent:
  - All 17 spec §5.1 tags emitted in the right shapes including
    the two `zap` split tags with weights pulled from
    PLATFORM_FEE_BPS (70 / 30).
  - Envelope has aes-256-gcm + IV + salt + ciphertext + softgate
    metadata + embedded preview (~18 KB for a 1920x1080
    customart).
  - Direct string search against the serialized envelope for the
    layer text "Locked!" and color hex "7c3aed" → not present.
    Plaintext does NOT leak through the envelope.
  - buildRowsFromEvents on the captured event produces a row with
    row.premium.decrypted = false, full address coord, mode and
    title derived from public tags.
  - Premium.decryptPayload(envelope, coord) returns the exact
    original payload via deep-equal JSON comparison.
  - Regression smoke (existing editor + soft-gate self-test) still
    passes.

Next step is the buyer-side purchase vault (§8) — NIP-44 self-
encrypted kind-30078 vault so unlocked keys sync across browsers /
devices, and the "My Purchases" tab in the Nostr modal.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.7 committed 2026-05-28 21:26:09 -04:00
1 parent 713ffefe83
commit 2aed156912
1 file changed
+352 -62
+352 -62
View File
@@ -4289,7 +4289,7 @@
<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>
<span>Premium encrypted — viewers see a watermarked preview and must zap to unlock the full design. <em>Soft-gate, not strong DRM</em> — once unlocked, buyers can still export or share.</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">
@@ -5392,6 +5392,76 @@
const n = tag ? parseInt(tag[1], 10) : 0;
return Number.isFinite(n) && n > 0 ? n : 0;
}
// Premium event detection (PREMIUM_DESIGNS_FEATURE.md §5.1).
// A premium event carries `premium=true` + `premium-mode=softgate-v1`
// tags AND has an encrypted envelope as content. Older tag-only
// events (just zap-gate=true) are NOT considered premium — they're
// the leaky transitional shape from before this work landed; the
// feed still shows them as gated but they can't actually be
// unlocked (the design payload is in plaintext on relays).
function isPremiumEvent(event) {
if (!event || !Array.isArray(event.tags)) return false;
return event.tags.some((t) => t[0] === 'premium' && (t[1] === 'true' || t[1] === '1'));
}
function getPremiumEnvelope(event) {
try {
const parsed = JSON.parse(event?.content || '{}');
if (parsed?.visibility !== 'premium' || !parsed?.enc?.alg) return null;
return parsed;
} catch { return null; }
}
function premiumEventTagValue(event, key) {
if (!event || !Array.isArray(event.tags)) return '';
const t = event.tags.find((tag) => tag[0] === key);
return t ? (t[1] || '') : '';
}
function premiumEventDTag(event) { return premiumEventTagValue(event, 'd'); }
function premiumAddressCoord(event) {
const d = premiumEventDTag(event);
const pk = event?.pubkey;
return (d && pk) ? `30078:${pk}:${d}` : '';
}
function premiumEventMode(event) {
const tTags = (event?.tags || []).filter((t) => t[0] === 't' && typeof t[1] === 'string').map((t) => t[1]);
for (const tt of tTags) {
const m = tt.match(/^casewrap-(.+)$/);
if (m) return m[1];
}
return 'cover';
}
// Synthesize a partial payload from public tags so the feed-card readers
// (title/category/mode) work on a still-locked premium row. The real
// decrypted payload replaces this on unlock.
function _stubPayloadFromPremiumEvent(event) {
return {
version: 5,
premium: true,
visibility: 'premium',
templateMode: premiumEventMode(event),
meta: {
title: premiumEventTagValue(event, 'title'),
category: premiumEventTagValue(event, 'category'),
language: premiumEventTagValue(event, 'language') || 'en',
imdbId: '',
},
};
}
// Parse §5.1 / §11.1 zap split tags. Falls back to the platform
// 70/30 default if no zap tags are present.
function parsePremiumZapSplits(event) {
const tags = event?.tags || [];
const zapTags = tags.filter((t) => t[0] === 'zap' && t[1] && t[2]);
const hasWeights = zapTags.some((t) => t[3] != null);
const parsed = zapTags
.map((t) => ({
pubkey: t[1],
relay: t[2],
weight: hasWeights ? Math.max(0, parseInt(t[3] || '0', 10) || 0) : 1,
}))
.filter((s) => s.weight > 0);
return parsed;
}
function getAllUnlocked() {
try { return JSON.parse(localStorage.getItem(UNLOCK_CACHE_KEY) || '{}') || {}; }
catch { return {}; }
@@ -5415,36 +5485,66 @@
// 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;
// Relay fast-path. Premium designs use address-based purchase scope
// (PREMIUM_DESIGNS_FEATURE.md §13.2) so a creator's edit-in-place
// doesn't invalidate prior unlocks. We query by `#a` first, then `#e`
// as a fallback for legacy tag-only events.
async function relayFastPathUnlockCheck(gatedEvents) {
if (!Array.isArray(gatedEvents) || !gatedEvents.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;
const freshEvents = gatedEvents.filter((e) => !isUnlockedLocally(e.id));
if (!freshEvents.length) return;
const addressByEvent = new Map(); // event id → address coord
const addressesToQuery = [];
const eventIdsToQuery = [];
for (const ev of freshEvents) {
const addr = premiumAddressCoord(ev);
if (addr) {
addressByEvent.set(ev.id, addr);
addressesToQuery.push(addr);
}
eventIdsToQuery.push(ev.id);
}
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]);
const queries = [];
if (addressesToQuery.length) {
queries.push(queryRelays(relays, {
kinds: [9735], '#a': [...new Set(addressesToQuery)], '#P': [myPubkey],
}, { timeoutMs: 4000 }).catch(() => []));
}
if (eventIdsToQuery.length) {
queries.push(queryRelays(relays, {
kinds: [9735], '#e': eventIdsToQuery, '#P': [myPubkey],
}, { timeoutMs: 4000 }).catch(() => []));
}
const receiptSets = await Promise.all(queries);
const seenAddresses = new Set();
const seenEventIds = new Set();
for (const receipts of receiptSets) {
for (const ev of receipts || []) {
const aTag = (ev.tags || []).find((t) => t[0] === 'a' && t[1]);
if (aTag) seenAddresses.add(aTag[1]);
const eTag = (ev.tags || []).find((t) => t[0] === 'e' && t[1]);
if (eTag) seenEventIds.add(eTag[1]);
}
}
for (const ev of freshEvents) {
if (isUnlockedLocally(ev.id)) continue;
const addr = addressByEvent.get(ev.id);
if (addr && seenAddresses.has(addr)) {
markUnlocked(ev.id, { source: 'relay-fastpath-a', address: addr });
continue;
}
if (seenEventIds.has(ev.id)) {
markUnlocked(ev.id, { source: 'relay-fastpath-e' });
}
}
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);
}
}
@@ -5494,12 +5594,43 @@
totalSats: min,
onStatus,
});
// Premium event → also decrypt the design payload now so subsequent
// renders / forks have the real data. Tag-only zap-gate events
// (no envelope) fall through with the existing markUnlocked.
let decryptedPayload = null;
if (row.premium?.envelope) {
onStatus('Decrypting design…');
try {
decryptedPayload = await Premium.decryptPayload(row.premium.envelope, {
creatorPubkey: row.e.pubkey,
dTag: row.premium.dTag,
schemaVersion: row.premium.envelope.payloadSchemaVersion || 5,
});
} catch (decryptErr) {
if (statusToast?.dismiss) statusToast.dismiss();
toastError(`Payment went through, but the design couldn't be decrypted: ${decryptErr?.message || decryptErr}. Try refreshing — receipts may still be settling.`);
// Stash payment proof anyway so the user doesn't re-pay; the
// next page load can retry the decrypt.
markUnlocked(eventId, {
creatorSats: result.creatorSats,
platformSats: result.platformSats,
creatorPreimage: result.creatorPreimage,
platformPreimage: result.platformPreimage,
address: row.premium.address || '',
decryptError: decryptErr?.message || String(decryptErr),
});
if (typeof _currentFeedRerender === 'function') { try { _currentFeedRerender(); } catch {} }
return;
}
}
if (statusToast?.dismiss) statusToast.dismiss();
markUnlocked(eventId, {
creatorSats: result.creatorSats,
platformSats: result.platformSats,
creatorPreimage: result.creatorPreimage,
platformPreimage: result.platformPreimage,
address: row.premium?.address || '',
payload: decryptedPayload || undefined,
});
toastSuccess(`Unlocked "${row.title || 'template'}" ✓`);
// Re-render the feed in place — the card will now show Import.
@@ -6190,13 +6321,10 @@
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)]);
}
// Premium / zap-gated events are published via _publishPremiumDesign,
// which builds its own tag set per PREMIUM_DESIGNS_FEATURE.md §5.1.
// getTemplateTags() is only called from the public non-premium path,
// so no zap-gate tags here.
return tags;
}
@@ -13707,6 +13835,16 @@
return signed;
}
const mode = normalizeTemplateMode(payload);
// Premium: publish the design as an encrypted envelope + watermarked
// preview JPEG, with the §5.1 tag set. Soft-gate; honest about the
// limitation in the publish-modal copy.
if (state.publish?.zapGate) {
const signed = await _publishPremiumDesign(payload, mode, typeTag);
const label = isEditing() ? `Updated "${state.meta.title}" (premium)` : `Premium "${state.meta.title}"`;
await finalizePublishedTemplateEvent(signed, { label });
if (isEditing()) exitEditMode();
return signed;
}
const dBase = state.meta.imdbId
|| state.meta.title
|| (isCanvasMode(mode) ? `${state.customArt?.width || 0}x${state.customArt?.height || 0}` : '');
@@ -13725,6 +13863,76 @@
return signed;
}
// Premium publish path (PREMIUM_DESIGNS_FEATURE.md §5 / §6).
// Encrypts the payload via Premium.encryptPayload, renders the
// watermarked preview JPEG, builds the §5.1 tag set including the
// platform-fee zap split, and publishes a single kind-30078 event.
async function _publishPremiumDesign(payload, mode, typeTag) {
// Pre-flight: lud16 on creator (already enforced at the modal level),
// and resolve the platform lud16 so the split-zap actually works for
// buyers later.
const myPubkey = state.community?.myPubkey;
if (!myPubkey) throw new Error('Sign in with Nostr before publishing premium.');
// Generate the preview FIRST so a too-large artboard bails before we
// build any event.
const preview = await renderPremiumPreviewDataUrl();
// Stable d-tag with a `premium:` prefix so it can't collide with an
// existing non-premium event from the same author. Edit-in-place
// reuses the original.
const dBase = state.meta.imdbId
|| state.meta.title
|| (isCanvasMode(mode) ? `${state.customArt?.width || 0}x${state.customArt?.height || 0}` : '');
const dTag = isEditing() ? state.editingDTag : `premium:${mode}:${dBase}`;
const coord = { creatorPubkey: myPubkey, dTag, schemaVersion: 5 };
// Encrypt the payload. The decrypted shape is the existing
// loadProjectFromText-compatible JSON.
const envelope = await Premium.encryptPayload(payload, coord);
envelope.payloadSchemaVersion = 5;
envelope.premiumMode = 'softgate-v1';
envelope.preview = {
kind: 'watermarked-image',
dataUrl: preview.dataUrl,
width: preview.width,
height: preview.height,
};
// Build the §5.1 tag set. Discovery tags (title/category/language/
// mode) stay public; encrypted-only data lives in `content`.
const zapMin = Math.max(10, Math.floor(Number(state.publish?.zapMin) || 100));
const platformPubkey = platform.getPubkeyHex();
const relayHint = (splitRelayList(state.community?.relays || '')[0]) || '';
const epoch = envelope.softgate?.epoch || Premium.CURRENT_EPOCH;
const tags = [
['d', dTag],
['t', 'casewrap'],
...(typeTag ? [['t', typeTag]] : []),
['title', state.meta?.title || ''],
['summary', state.meta?.title ? `Premium encrypted Artstr design — ${state.meta.title}` : 'Premium encrypted Artstr design'],
['category', state.meta?.category || ''],
['language', state.meta?.language || 'en'],
['client', 'Artstr Studio'],
['premium', 'true'],
['premium-mode', 'softgate-v1'],
['softgate-epoch', epoch],
['encrypted', 'artstr-softgate-v1'],
['zap-gate', 'true'],
['zap-min', String(zapMin)],
['zap-split', 'required'],
// Designer share — full creator share weight; consumer side splits
// using the existing 70/30 PLATFORM_FEE_BPS constant when these
// weights aren't present. We emit explicit weights so future
// consumers don't depend on the client constant.
['zap', myPubkey, relayHint, String(10000 - PLATFORM_FEE_BPS)],
['zap', platformPubkey, relayHint, String(PLATFORM_FEE_BPS)],
];
const evt = {
kind: 30078,
created_at: Math.floor(Date.now() / 1000),
tags,
content: JSON.stringify(envelope),
};
return publishCommunityEvent(evt);
}
async function publishDiscDesignToNostr() {
const design = state.discDesign;
if (!state.meta.category?.trim()) {
@@ -17818,12 +18026,11 @@
// 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(() => {});
// again. Premium events use address-coord receipts; legacy
// tag-only events fall back to event-id receipts.
const gatedEvents = templateEvents.filter((e) => isPremiumEvent(e) || isZapGated(e));
if (gatedEvents.length && state.community?.myPubkey) {
relayFastPathUnlockCheck(gatedEvents).then(() => notifyFeedProgress()).catch(() => {});
}
}
_feedCache.fetchedAt = Date.now();
@@ -19447,10 +19654,25 @@
const authorPubkey = normalizePubkey(e.pubkey);
if (authorPubkey && _blockedAuthors.has(authorPubkey)) continue;
let payload = null;
try { payload = JSON.parse(e.content || '{}'); } catch { continue; }
let premiumEnvelope = null;
const isPremium = isPremiumEvent(e);
try {
const parsed = JSON.parse(e.content || '{}');
if (isPremium) {
premiumEnvelope = parsed;
// If the user has already unlocked + decrypted this design,
// surface the real payload so the existing card renderers can
// produce a full preview. Otherwise synthesise a stub from the
// public tags so title/category/mode still work.
const cached = getAllUnlocked()[e.id];
payload = cached?.payload || _stubPayloadFromPremiumEvent(e);
} else {
payload = parsed;
}
} catch { continue; }
// Encrypted designs never render here — they're feed-invisible and
// are pre-decrypted by loadMyDesigns before reaching this builder.
if (isEncryptedEnvelope(payload)) continue;
if (!isPremium && isEncryptedEnvelope(payload)) continue;
if (!skipVersionFilter && (payload.version ?? 0) < MIN_SUPPORTED_SCHEMA_VERSION) continue;
const address = templateEventAddress(e, payload);
const eventTime = e.created_at || 0;
@@ -19497,7 +19719,26 @@
trust: trustTierForPubkey(e.pubkey)
};
row.softMod = computeSoftMod(row);
if (isZapGated(e)) {
if (isPremium) {
const cached = getAllUnlocked()[e.id];
row.premium = {
envelope: premiumEnvelope,
previewJpegDataUrl: premiumEnvelope?.preview?.dataUrl || '',
decrypted: !!cached?.payload,
address: premiumAddressCoord(e),
dTag: premiumEventDTag(e),
};
row.zapGate = {
gated: true,
min: getZapMin(e) || 100,
unlocked: !!cached?.payload,
};
} else if (isZapGated(e)) {
// Older tag-only events from before the encrypted publish path
// landed. Still surfaced as locked in the feed, but the design
// payload is in plaintext on relays so this is now just a
// visible reminder to the author to delete + republish under the
// new path.
row.zapGate = {
gated: true,
min: getZapMin(e) || 100,
@@ -19604,23 +19845,33 @@
const thumb = document.createElement('div');
thumb.className = 'feedCardThumb';
// Mini-preview: same DOM as the full preview rendered at its natural size,
// then CSS-scaled via transform to fit the thumb. Layers, text, and slot
// art all scale together so it's WYSIWYG with the click-Preview pane.
// Mini-preview: for premium *locked* rows, show the watermarked JPEG
// embedded in the envelope. Otherwise re-render the design DOM at its
// natural size and CSS-scale to fit (WYSIWYG with the preview pane).
const previewWrap = document.createElement('div');
previewWrap.className = 'miniPreviewWrap';
try {
const payload = row.payload || {};
if (row.mode === 'disc-design') renderDiscPreviewDOM(previewWrap, payload, { compact: true });
else if (row.mode === 'disc') renderDiscSheetPreviewDOM(previewWrap, payload, { compact: true });
else if (row.mode === 'jewel') renderJewelPreviewDOM(previewWrap, payload, { compact: true });
else if (row.mode === 'customart' || row.mode === 'slide') renderCustomArtPreviewDOM(previewWrap, payload, { compact: true });
else if (row.mode === 'deck') renderDeckPreviewDOM(previewWrap, payload, { compact: true });
else if (row.mode === 'pixelart') renderPixelArtPreviewDOM(previewWrap, payload, { compact: true });
else renderTemplatePreviewDOM(previewWrap, payload, { compact: true });
} catch {}
const isLockedPremium = !!(row.premium && !row.premium.decrypted);
if (isLockedPremium && row.premium.previewJpegDataUrl) {
const img = document.createElement('img');
img.src = row.premium.previewJpegDataUrl;
img.alt = row.title || 'Premium template preview';
img.style.cssText = 'width:100%;height:100%;object-fit:contain;display:block;background:#fff';
previewWrap.style.cssText = 'width:100%;height:100%;display:flex';
previewWrap.appendChild(img);
} else {
try {
const payload = row.payload || {};
if (row.mode === 'disc-design') renderDiscPreviewDOM(previewWrap, payload, { compact: true });
else if (row.mode === 'disc') renderDiscSheetPreviewDOM(previewWrap, payload, { compact: true });
else if (row.mode === 'jewel') renderJewelPreviewDOM(previewWrap, payload, { compact: true });
else if (row.mode === 'customart' || row.mode === 'slide') renderCustomArtPreviewDOM(previewWrap, payload, { compact: true });
else if (row.mode === 'deck') renderDeckPreviewDOM(previewWrap, payload, { compact: true });
else if (row.mode === 'pixelart') renderPixelArtPreviewDOM(previewWrap, payload, { compact: true });
else renderTemplatePreviewDOM(previewWrap, payload, { compact: true });
} catch {}
}
thumb.appendChild(previewWrap);
fitMiniPreview(previewWrap);
if (!isLockedPremium) fitMiniPreview(previewWrap);
// Fallback label so empty designs still show their mode (and as a placeholder
// if the preview produced nothing visible).
@@ -20510,15 +20761,36 @@
});
actionsDiv.insertBefore(shareBtn, saveJsonBtn);
// Gate Use/Fork + Save JSON when the row is a locked premium template
// (the design content is encrypted; downloading the JSON would just
// hand the buyer the envelope, not the plaintext).
const lockedPremium = !!(row.premium && !row.premium.decrypted);
const forkBtn = document.createElement('button');
forkBtn.type = 'button';
forkBtn.textContent = 'Use/Fork';
forkBtn.title = 'Fork this design — opens it in the editor so you can save your own version';
forkBtn.addEventListener('click', (e) => {
e.stopPropagation();
forkRowIntoEditor(row);
});
if (lockedPremium) {
forkBtn.textContent = `⚡ Unlock for ${row.zapGate?.min || 100} sats`;
forkBtn.className = 'primary';
forkBtn.title = `Premium template — zap to unlock (creator + ${(PLATFORM_FEE_BPS / 100).toFixed(0)}% Artstr platform fee).`;
forkBtn.addEventListener('click', (e) => {
e.stopPropagation();
openUnlockConfirm(row);
});
} else {
forkBtn.textContent = 'Use/Fork';
forkBtn.title = 'Fork this design — opens it in the editor so you can save your own version';
forkBtn.addEventListener('click', (e) => {
e.stopPropagation();
forkRowIntoEditor(row);
});
}
actionsDiv.insertBefore(forkBtn, saveJsonBtn);
if (lockedPremium && saveJsonBtn) {
saveJsonBtn.disabled = true;
saveJsonBtn.title = 'Locked — pay to unlock the design data.';
} else if (saveJsonBtn) {
saveJsonBtn.disabled = false;
saveJsonBtn.title = '';
}
// Decks get a Start Presentation button — present straight from the
// browser without forking.
@@ -20632,7 +20904,25 @@
if (rd) rd.style.display = 'none';
content.style.display = '';
if (mode === 'disc-design') {
// Locked premium row → render the watermarked JPEG instead of the
// (unavailable) plaintext design. Add an honest "soft-gate" note.
if (row.premium && !row.premium.decrypted) {
const wrap = document.createElement('div');
wrap.style.cssText = 'display:flex;flex-direction:column;align-items:center;gap:8px;padding:8px';
if (row.premium.previewJpegDataUrl) {
const img = document.createElement('img');
img.src = row.premium.previewJpegDataUrl;
img.alt = row.title || 'Premium template preview';
img.style.cssText = 'max-width:100%;height:auto;border:1px solid var(--line);background:#fff';
wrap.appendChild(img);
}
const note = document.createElement('p');
note.className = 'hint';
note.style.cssText = 'margin:0;text-align:center;max-width:520px';
note.textContent = 'Premium encrypted design. Soft-gated — unlock pays the creator and Artstr; once paid the full editable design opens here. Soft-gate is not strong DRM; buyers can still export or share after unlocking.';
wrap.appendChild(note);
content.appendChild(wrap);
} else if (mode === 'disc-design') {
renderDiscPreviewDOM(content, p);
} else if (mode === 'disc') {
renderDiscSheetPreviewDOM(content, p);