diff --git a/src/index.html b/src/index.html
index 4c84095..09740fd 100644
--- a/src/index.html
+++ b/src/index.html
@@ -7422,6 +7422,22 @@
document.querySelectorAll(`[data-event-id="${eventId}"]`).forEach((el) => el.remove());
} catch (err) { console.warn('[zap-gated] markUnlocked failed', err); }
}
+ function _premiumClaimUntil(row) {
+ return Number(
+ row?.premium?.envelope?.softgate?.claimUntil
+ || premiumEventTagValue(row?.e, 'claim-until')
+ || 0
+ ) || 0;
+ }
+ function _premiumClaimStatusForRow(row) {
+ const helpers = window.ArtstrPremium2;
+ const address = row?.premium?.address || (row?.e ? premiumAddressCoord(row.e) : '');
+ return helpers?.claimStatus?.({
+ claimUntil: _premiumClaimUntil(row),
+ owned: !!(row?.zapGate?.unlocked || isUnlockedLocally(row?.e?.id, address)),
+ selfAuthored: !!(row?.e?.pubkey && normalizePubkey(row.e.pubkey) === normalizePubkey(state.community?.myPubkey || '')),
+ }) || 'claimable';
+ }
// Relay fast-path — on feed load, query for zap receipts we've already
// sent for gated templates. Matching receipts auto-unlock without
@@ -7607,6 +7623,11 @@
async function openUnlockConfirm(row) {
const eventId = row?.e?.id;
if (!eventId) return;
+ const claimState = _premiumClaimStatusForRow(row);
+ if (claimState === 'expired' || claimState === 'closed') {
+ toastError('This premium design is no longer claimable from the public storefront. Already-purchased private copies still open from your vault.');
+ return;
+ }
if (!nwc.isConnected()) {
toastError('Connect a Lightning wallet first (Profile → Settings → ⚡ Lightning wallet).');
// Open Settings + expand the wallet panel so the user can act on
@@ -34611,10 +34632,17 @@
// 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); });
+ const claimState = _premiumClaimStatusForRow(row);
+ if (claimState === 'expired' || claimState === 'closed') {
+ btn.textContent = 'Claim period ended';
+ 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.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';
@@ -35605,13 +35633,20 @@
const forkBtn = document.createElement('button');
forkBtn.type = 'button';
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);
- });
+ const claimState = _premiumClaimStatusForRow(row);
+ if (claimState === 'expired' || claimState === 'closed') {
+ forkBtn.textContent = 'Claim period ended';
+ 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.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';
diff --git a/tests/premium/premium2-index-integration.test.mjs b/tests/premium/premium2-index-integration.test.mjs
index b1600f5..6a13428 100644
--- a/tests/premium/premium2-index-integration.test.mjs
+++ b/tests/premium/premium2-index-integration.test.mjs
@@ -48,6 +48,19 @@ test('soft-gate crypto supports the active Premium 2.0 policy epoch', async () =
assert.match(html, /const CURRENT_EPOCH = '2026-07'/);
});
+test('index page blocks expired first-time premium claims but keeps owned paths separate', async () => {
+ const html = await indexHtml();
+
+ assert.match(html, /function _premiumClaimUntil\(row\)/);
+ assert.match(html, /function _premiumClaimStatusForRow\(row\)/);
+ assert.match(html, /owned: !!\(row\?\.zapGate\?\.unlocked \|\| isUnlockedLocally\(row\?\.e\?\.id, address\)\)/);
+ assert.match(html, /const claimState = _premiumClaimStatusForRow\(row\);\n\s+if \(claimState === 'expired' \|\| claimState === 'closed'\)/);
+ assert.match(html, /This premium design is no longer claimable from the public storefront/);
+ assert.match(html, /btn\.textContent = 'Claim period ended'/);
+ assert.match(html, /forkBtn\.textContent = 'Claim period ended'/);
+ assert.match(html, /Purchased private copies still open from the vault/);
+});
+
test('vault item stubs preserve Premium 2.0 private-copy fields', async () => {
const html = await indexHtml();
const stubStart = html.indexOf('function _vaultItemStub(item)');