diff --git a/src/index.html b/src/index.html
index 9f9e3f1..d4966bb 100644
--- a/src/index.html
+++ b/src/index.html
@@ -7157,6 +7157,44 @@
localStorage.setItem(PARTIAL_UNLOCK_KEY, JSON.stringify(all));
} catch (err) { console.warn('[platform] clearPartialUnlock failed', err); }
}
+ function partialUnlockSummary(eventId) {
+ const partial = getPartialUnlock(eventId);
+ if (!partial) return { status: 'none', confirmed: 0, missing: 2, partial: null };
+ const creatorDone = !!partial.creator?.preimage;
+ const platformDone = !!partial.platform?.preimage;
+ const prepared = !!(partial.creator?.bolt11 || partial.platform?.bolt11 || partial.creator?.signedReq || partial.platform?.signedReq);
+ const confirmed = (creatorDone ? 1 : 0) + (platformDone ? 1 : 0);
+ return {
+ status: confirmed === 2 ? 'complete' : confirmed > 0 ? 'partial' : prepared ? 'prepared' : 'started',
+ confirmed,
+ missing: 2 - confirmed,
+ creatorDone,
+ platformDone,
+ partial,
+ };
+ }
+ function partialUnlockActionLabel(eventId, amountSats) {
+ const summary = partialUnlockSummary(eventId);
+ if (summary.status === 'none') return `⚡ Unlock for ${amountSats} sats`;
+ if (summary.status === 'partial') return `Resume payment (${summary.missing} left)`;
+ return 'Resume payment';
+ }
+ function clearStalePartialUnlocks(maxAgeMs = 7 * 24 * 60 * 60 * 1000) {
+ const all = getAllPartialUnlocks();
+ const now = Date.now();
+ let cleared = 0;
+ for (const [eventId, partial] of Object.entries(all)) {
+ const startedAt = Number(partial?.startedAt || 0);
+ if (startedAt && now - startedAt <= maxAgeMs) continue;
+ if (partial?.creator?.preimage || partial?.platform?.preimage) continue;
+ delete all[eventId];
+ cleared++;
+ }
+ if (cleared) {
+ try { localStorage.setItem(PARTIAL_UNLOCK_KEY, JSON.stringify(all)); } catch {}
+ }
+ return cleared;
+ }
// payZapSplit — the dual-zap orchestrator. Parallel where possible:
// 1. resolve creator + platform LNURL metadata (parallel)
@@ -7657,16 +7695,20 @@
}
const min = getZapMin(row.e) || 100;
const { creatorSats, platformSats } = platform.splitZapAmount(min);
+ const partialSummary = partialUnlockSummary(eventId);
+ const resumeNote = partialSummary.status !== 'none'
+ ? `\n\nResume detected: ${partialSummary.confirmed} payment leg${partialSummary.confirmed === 1 ? '' : 's'} confirmed, ${partialSummary.missing} left. Artstr will reuse cached invoices/preimages and only pay missing legs.`
+ : '';
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` +
+ `${partialSummary.status === 'none' ? 'Unlock' : 'Resume unlock for'} "${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)}%)\n\n` +
- `Soft-gate, not strong DRM — once unlocked you can save / export / share the design like any other Artstr file.`
+ `Soft-gate, not strong DRM — once unlocked you can save / export / share the design like any other Artstr file.${resumeNote}`
);
if (!ok) return;
// Live status line; reuses the toast system for simplicity.
@@ -8406,8 +8448,9 @@
async function repairPremiumPurchases({ silent = false } = {}) {
if (!_supportsVault() || !state.community?.myPubkey) {
- return { status: 'unavailable', pending: null, migrated: null, checked: 0, ok: 0, missing: 0, unrecoverable: 0 };
+ return { status: 'unavailable', pending: null, migrated: null, stalePartialCleared: 0, checked: 0, ok: 0, missing: 0, unrecoverable: 0 };
}
+ const stalePartialCleared = clearStalePartialUnlocks();
const pending = await retryPendingVaultWrites({ silent: true });
const migrated = await migrateLegacyVaultPrivateCopies({ silent: true });
const vault = await loadPurchaseVault({ force: true });
@@ -8433,7 +8476,7 @@
}
}
const status = pending.failed || migrated.failed || unrecoverable ? 'needs_attention' : missing ? 'repairable' : 'clean';
- const result = { status, pending, migrated, checked, ok, missing, unrecoverable };
+ const result = { status, pending, migrated, stalePartialCleared, checked, ok, missing, unrecoverable };
if (!silent) {
if (status === 'clean') {
toastSuccess('Purchase repair found everything clean ✓');
@@ -35085,7 +35128,7 @@
btn.disabled = true;
btn.title = 'This public premium storefront is no longer claimable. Purchased private copies still open from the vault.';
} else {
- btn.textContent = `⚡ Unlock for ${row.zapGate.min} sats`;
+ btn.textContent = partialUnlockActionLabel(row.e?.id, row.zapGate.min);
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); });
@@ -36087,7 +36130,7 @@
forkBtn.disabled = true;
forkBtn.title = 'This public premium storefront is no longer claimable. Purchased private copies still open from the vault.';
} else {
- forkBtn.textContent = `⚡ Unlock for ${row.zapGate?.min || 100} sats`;
+ forkBtn.textContent = partialUnlockActionLabel(row.e?.id, row.zapGate?.min || 100);
forkBtn.className = 'primary';
forkBtn.title = `Premium template — zap to unlock (creator + ${(PLATFORM_FEE_BPS / 100).toFixed(0)}% Artstr platform fee).`;
forkBtn.addEventListener('click', (e) => {
diff --git a/tests/premium/premium2-index-integration.test.mjs b/tests/premium/premium2-index-integration.test.mjs
index 10f0811..c69f7b5 100644
--- a/tests/premium/premium2-index-integration.test.mjs
+++ b/tests/premium/premium2-index-integration.test.mjs
@@ -91,6 +91,20 @@ test('index page persists and retries failed Premium 2.0 private-copy saves', as
assert.match(html, /Use Purchased → Retry saving private copy when relay sync recovers/);
});
+test('index page exposes partial-payment resume state without discarding paid legs', async () => {
+ const html = await indexHtml();
+
+ assert.match(html, /function partialUnlockSummary\(eventId\)/);
+ assert.match(html, /function partialUnlockActionLabel\(eventId, amountSats\)/);
+ assert.match(html, /Resume payment \(\$\{summary\.missing\} left\)/);
+ assert.match(html, /Artstr will reuse cached invoices\/preimages and only pay missing legs/);
+ assert.match(html, /btn\.textContent = partialUnlockActionLabel\(row\.e\?\.id, row\.zapGate\.min\)/);
+ assert.match(html, /forkBtn\.textContent = partialUnlockActionLabel\(row\.e\?\.id, row\.zapGate\?\.min \|\| 100\)/);
+ assert.match(html, /function clearStalePartialUnlocks/);
+ assert.match(html, /if \(partial\?\.creator\?\.preimage \|\| partial\?\.platform\?\.preimage\) continue/);
+ assert.match(html, /const stalePartialCleared = clearStalePartialUnlocks\(\)/);
+});
+
test('index page migrates legacy inline vault purchases to private copies', async () => {
const html = await indexHtml();