Versie 0.2.0

This commit is contained in:
Atreides Assets committed 2026-10-29 11:57:29 +01:00
commit e77d8fd373
495 files changed
+81026

No files matched your search

+642
View File
@@ -0,0 +1,642 @@
// Generated, do not edit. sha256:5dea810da6d84ed187a1154955f4194498f812771e3ddaa6b6dc7c547a305b8d
import { t, localeTag } from '../i18n/index.js';
import { CONFIG } from '../../shared/config.js';
import { esc } from '../ui/escape.js';
import { licenseSnapshot, loadLicense, markLicenseSeen } from '../license.js';
import { installLicense } from '../data/license-install.js';
import { refreshAttention } from '../ui/attention.js';
import { npubFromHex, shortKey, secretFromText, publicKeyOf, nsecFromSecret, newSecret } from '../nostr/keys.js';
import { keySigner, extensionSigner, nip98Header } from '../nostr/nip98.js';
import { qrSvg, invoiceQrText } from '../ui/qr.js';
import { CHECK, CHEVRON } from '../ui/icons.js';
import { splashMarkHtml } from '../ui/splash.js';
import { expanders } from '../ui/expand.js';
import { supportInfo, supportMessages, sendSupport, supportAttachment } from '../data/support.js';
import { licenseShop, buyerLicenses, saleInfo, placeOrder, orderStatus } from '../data/license-buyer.js';
const BROKEN = new Set(['invalid', 'unreadable']);
function buyerOf(s) {
const npub = npubFromHex(s.buyer);
return { text: shortKey(npub ?? s.buyer, 10), title: npub ?? s.buyer ?? '' };
}
function lines(s) {
const out = [];
const line = (text, title = '') => out.push({ text, title });
if (s.why === 'develop' || s.why === 'forced-develop') line(t('license.cardDevelop'));
line(s.level === 'full' ? t('license.cardFull') : t('license.cardFree'));
if (s.installed) {
const buyer = buyerOf(s);
line(t('license.cardFacts', {
buyer: buyer.text, issued: s.issued ?? '',
from: s.installed.majorFrom,
to: s.installed.majorTo === 'unlimited' ? t('license.unlimited') : s.installed.majorTo,
}), buyer.title);
if (!s.installedCovers) line(t('license.cardOutOfRange', { version: s.version }));
}
if (BROKEN.has(s.why)) line(`${t('license.cardProblem')} ${t(`license.why_${s.why}`)}.`);
if (s.level !== 'full') line(t('license.cardBuy'));
return out;
}
async function installAndReload(text, message) {
message.className = 't-body-sm';
message.textContent = '';
try {
const result = await installLicense(text);
message.classList.add('t-pos');
message.textContent = result.covers ? t('license.installOk') : t('license.installOkFuture');
setTimeout(() => location.reload(), 1200);
} catch (err) {
message.classList.add('t-neg');
message.textContent = err.message;
}
}
const INFO = ['Nostr', 'Why', 'SignIn', 'Zap', 'Privacy'];
const link = (url, html) => `<a href="${esc(url)}" target="_blank" rel="noopener noreferrer">${html}</a>`;
const payHtml = (expand) => `<div class="lic-head"><div class="t-label">${say('license.payTitle')}</div></div>
<p class="t-body-sm">${say('license.payIntro')}</p>`
+ ['easy', 'other'].map(group => `<div class="lic-apps"><div class="t-caption">${say(`license.payGroup_${group}`)}</div>`
+ CONFIG.LICENSE.PAY_APPS.filter(a => a.group === group)
.map(a => `<p class="t-body-sm">${link(a.url, `<strong>${esc(a.name)}</strong>`)} ${say(`license.payApp_${a.id}`)}</p>`).join('')
+ '</div>').join('')
+ `<p class="t-caption">${say('license.payNote')}</p>
<details class="lic-more" ${expand.attr('nostr')}><summary class="t-body-sm">${say('license.nostrMore')}</summary>`
+ INFO.map(k => `<p class="t-body-sm"><strong>${say(`license.info${k}Head`)}</strong> ${say(`license.info${k}`)}</p>`).join('')
+ CONFIG.LICENSE.INFO_LINKS.map(l => `<p class="t-body-sm">${link(l.url, say(`license.link_${l.id}`))}</p>`).join('')
+ '</details>';
let signer = null;
let newKey = null;
const npubText = (hex) => {
const npub = npubFromHex(hex) ?? hex;
return `<span title="${esc(npub)}">${esc(shortKey(npub, 10))}</span>`;
};
const say = (key, params) => esc(t(key, params));
async function drawSignIn(box, s) {
let shop = { serviceUrl: null, buyer: null };
try { shop = await licenseShop(); } catch { }
serviceUrl = shop.serviceUrl;
drawBuy();
drawSupport();
const head = `<div class="lic-head"><div class="t-label">${say('license.signInTitle')}</div></div>`;
if (signer) {
const who = t(signer.kind === 'extension' ? 'license.signedInExtension' : 'license.signedIn', { npub: '§' });
const [before, after] = who.split('§');
const other = s.installed && s.buyer && s.buyer !== signer.pubkey;
box.innerHTML = `${head}
<p class="t-body-sm">${esc(before)}${npubText(signer.pubkey)}${esc(after)}</p>
${other ? `<p class="t-body-sm">${say('license.otherBuyer')}</p>` : ''}
<div data-licenses class="mt-sm"></div>
<p class="t-body-sm" data-signin-msg></p>
<div class="flex gap-sm"><button class="btn btn-secondary btn-sm" type="button" data-signout>${say('license.signOut')}</button></div>`;
box.querySelector('[data-signout]').onclick = () => { signer = null; drawSignIn(box, s); };
await drawLicenses(box.querySelector('[data-licenses]'), box.querySelector('[data-signin-msg]'), shop);
return;
}
if (newKey) {
const nsec = nsecFromSecret(newKey.secret);
box.innerHTML = `${head}
<p class="t-body-sm">${say('license.newKeyIntro')}</p>
<input class="input t-mono" type="text" readonly value="${esc(nsec)}" data-nsec aria-label="${esc(t('license.newKeyTitle'))}">
<p class="t-caption mt-sm">${say('license.newKeyPublic', { npub: npubFromHex(publicKeyOf(newKey.secret)) })}</p>
<div class="flex gap-sm mt-sm">
<button class="btn btn-secondary btn-sm" type="button" data-key-copy>${say(newKey.copied ? 'license.newKeyCopied' : 'license.newKeyCopy')}</button>
<button class="btn btn-secondary btn-sm" type="button" data-download>${say('license.newKeyDownload')}</button>
</div>
<label class="flex gap-sm mt-md t-body-sm"><input type="checkbox" data-saved> ${say('license.newKeySaved')}</label>
<div class="flex gap-sm mt-md">
<button class="btn btn-primary btn-sm" type="button" data-go disabled>${say('license.newKeyGo')}</button>
<button class="btn btn-ghost btn-sm" type="button" data-cancel>${say('license.newKeyCancel')}</button>
</div>`;
const go = box.querySelector('[data-go]');
const saved = box.querySelector('[data-saved]');
const allow = () => { go.disabled = !(saved.checked && newKey.copied); };
saved.onchange = allow;
box.querySelector('[data-key-copy]').onclick = (e) => {
newKey.copied = true;
allow();
copyText(nsec, box.querySelector('[data-nsec]')).then((ok) => { if (ok) e.target.textContent = t('license.newKeyCopied'); });
};
box.querySelector('[data-download]').onclick = () => {
download(t('license.newKeyFileName'), `${nsec}\n`);
newKey.copied = true;
allow();
};
go.onclick = () => { signer = keySigner(newKey.secret); newKey = null; drawSignIn(box, s); };
box.querySelector('[data-cancel]').onclick = () => { newKey = null; drawSignIn(box, s); };
return;
}
const hasExtension = !!globalThis.nostr;
const known = s.installed && s.buyer ? { key: 'license.signInKnown', hex: s.buyer }
: shop.buyer ? { key: 'license.signInLast', hex: shop.buyer } : null;
box.innerHTML = `${head}
<p class="t-body-sm">${say('license.signInIntro')}</p>
${known ? `<p class="t-body-sm">${esc(t(known.key, { npub: '§' })).replace('§', npubText(known.hex))}</p>` : ''}
<div class="flex gap-sm">
<button class="btn btn-primary btn-sm" type="button" data-ext${hasExtension ? '' : ' disabled'}>${say('license.signInExtension')}</button>
<button class="btn btn-secondary btn-sm" type="button" data-key>${say('license.signInKey')}</button>
<button class="btn btn-ghost btn-sm" type="button" data-new>${say('license.signInNew')}</button>
</div>
${hasExtension ? '' : `<p class="t-caption mt-sm">${say('license.signInExtensionNone')}</p>`}
<form class="mt-md" data-key-form hidden autocomplete="off">
<label class="form-label" for="lic-nsec">${say('license.signInKeyLabel')}</label>
<input class="input t-mono mt-sm" id="lic-nsec" type="password" autocomplete="off" spellcheck="false">
<p class="t-caption mt-sm">${say('license.signInKeyHint')}</p>
<div class="flex gap-sm mt-sm"><button class="btn btn-primary btn-sm" type="submit">${say('license.signInGo')}</button></div>
</form>
<p class="t-body-sm mt-sm" data-signin-msg></p>`;
const msg = box.querySelector('[data-signin-msg]');
const fault = (text) => { msg.className = 't-body-sm mt-sm t-neg'; msg.textContent = text; };
box.querySelector('[data-ext]').onclick = async () => {
try { signer = await extensionSigner(); drawSignIn(box, s); } catch (err) { fault(err.message); }
};
const form = box.querySelector('[data-key-form]');
box.querySelector('[data-key]').onclick = () => { form.hidden = false; form.querySelector('input').focus(); };
form.onsubmit = (e) => {
e.preventDefault();
const input = form.querySelector('input');
const { secret, error } = secretFromText(input.value);
input.value = '';
if (error) { fault(t(error === 'npub' ? 'license.signInNpub' : 'license.signInBadKey')); return; }
signer = keySigner(secret);
drawSignIn(box, s);
};
box.querySelector('[data-new]').onclick = () => { newKey = { secret: newSecret(), copied: false }; drawSignIn(box, s); };
}
const BUY_POLL_MS = 3000;
let buyBox = null;
let serviceUrl = null;
let purchase = null;
let pollTimer = null;
const sats = (n) => Number(n).toLocaleString(localeTag());
const clock = (ms) => new Date(ms).toLocaleTimeString(localeTag(), { hour: '2-digit', minute: '2-digit' });
const FINAL = { expired: 'license.buyExpired', canceled: 'license.buyCanceled', underpaid: 'license.buyUnderpaid' };
const AWAY = new Set(['marketService.unreachable', 'marketService.timeout', 'marketService.unavailable']);
async function drawBuy() {
const box = buyBox;
if (!box) return;
const head = `<div class="lic-head"><div class="t-label">${say('license.buyTitle')}</div></div>`;
if (purchase) {
const o = purchase.order;
const final = FINAL[o.status];
const state = final ? say(final)
: o.status === 'paid' ? say(o.waived ? 'license.buyWaived' : 'license.buyPaid')
: say('license.buyWaiting');
box.innerHTML = `${head}
${o.status === 'open' && o.paymentRequest ? `
<p class="t-body-sm">${say('license.buyInvoice', { time: clock(o.expiresAt) })}</p>
<div class="mt-sm">${qrSvg(invoiceQrText(o.paymentRequest), { label: t('license.buyQr') })}</div>
<input class="input t-mono mt-sm" type="text" readonly value="${esc(o.paymentRequest)}" data-invoice aria-label="${esc(t('license.buyTitle'))}">
<div class="flex gap-sm mt-sm">
<a class="btn btn-primary btn-sm" href="${esc(`lightning:${o.paymentRequest}`)}">${say('license.buyOpenWallet')}</a>
<button class="btn btn-secondary btn-sm" type="button" data-invoice-copy>${say('license.buyCopy')}</button>
</div>` : ''}
<p class="t-body-sm mt-md${final ? ' t-neg' : ''}">${state}</p>
<p class="t-body-sm" data-buy-msg>${purchase.note ? esc(purchase.note) : ''}</p>
${final ? `<div class="flex gap-sm"><button class="btn btn-secondary btn-sm" type="button" data-buy-again>${say('license.buyAgain')}</button></div>` : ''}`;
const copy = box.querySelector('[data-invoice-copy]');
if (copy) copy.onclick = (e) => copyText(o.paymentRequest, box.querySelector('[data-invoice]')).then((ok) => { if (ok) e.target.textContent = t('license.buyCopied'); });
const again = box.querySelector('[data-buy-again]');
if (again) again.onclick = () => { purchase = null; drawBuy(); };
return;
}
let sale = null;
let problem = null;
try { sale = await saleInfo(); } catch (err) { problem = AWAY.has(err.key) ? t('license.buyAway') : err.message; }
const price = sale?.open ? say('license.buyPrice', { major: sale.major, sats: sats(sale.priceSat) })
: sale ? say('license.buyClosed') : esc(problem ?? '');
box.innerHTML = `${head}
<p class="t-body-sm${problem ? ' t-neg' : ''}">${price}</p>
${sale?.open && !signer ? `<p class="t-body-sm">${say('license.buySignInFirst')}</p>` : ''}
<div class="flex gap-sm"><button class="btn btn-primary btn-sm" type="button" data-buy-go${sale?.open && signer ? '' : ' disabled'}>${say('license.buyButton')}</button></div>
<p class="t-body-sm mt-sm" data-buy-msg></p>`;
box.querySelector('[data-buy-go]').onclick = async (e) => {
const msg = box.querySelector('[data-buy-msg]');
e.target.disabled = true;
try {
const body = '{}';
const header = await nip98Header(signer, { url: `${serviceUrl}/v1/orders`, method: 'POST', body });
const { order, readKey } = await placeOrder(header, body);
purchase = { order, readKey, note: '' };
drawBuy();
schedulePoll();
} catch (err) {
e.target.disabled = false;
msg.className = 't-body-sm mt-sm t-neg';
msg.textContent = err.message;
}
};
}
function schedulePoll() {
clearTimeout(pollTimer);
pollTimer = setTimeout(pollOnce, BUY_POLL_MS);
}
async function pollOnce() {
if (!purchase?.readKey) return;
try {
const { order, license } = await orderStatus(purchase.order.id, purchase.readKey);
purchase.order = order;
purchase.note = '';
if (license) {
drawBuy();
const msg = buyBox?.querySelector('[data-buy-msg]');
if (!license.valid) { purchase.note = t('license.buyNotValid'); drawBuy(); return; }
if (msg && buyBox.isConnected) await installAndReload(license.file, msg);
else await installLicense(license.file).then(() => location.reload());
return;
}
drawBuy();
if (FINAL[order.status]) return;
} catch (err) {
purchase.note = err.message;
drawBuy();
}
schedulePoll();
}
const SUPPORT_POLL_MS = 30_000;
let supportBox = null;
let supportDraft = '';
let supportFile = null;
let supportTimer = null;
const shots = new Map();
const supportUrl = (info, path) => `${info.serviceUrl}/v1/support/${path}`;
async function drawSupport() {
const box = supportBox;
if (!box) return;
clearInterval(supportTimer);
box.hidden = !signer;
if (!signer) return;
let info = null;
try { info = await supportInfo(); } catch { info = null; }
const head = `<div class="lic-head"><div class="t-label">${say('license.supportTitle')}</div>
${info ? `<button class="btn btn-ghost btn-sm" type="button" data-support-refresh>${say('license.supportRefresh')}</button>` : ''}</div>`;
const nostr = info?.nostr?.pubkey
? `<p class="t-caption">${esc(t('license.supportNostr', { name: '§' })).replace('§', `<strong>${esc(info.nostr.name)}</strong> (${npubText(info.nostr.pubkey)})`)}</p>` : '';
if (!info?.serviceUrl) {
box.innerHTML = `${head}<p class="t-body-sm">${say('license.supportIntro')}</p>${nostr}`;
return;
}
const mb = info.maxAttachmentBytes / 1024 / 1024;
box.innerHTML = `${head}
<p class="t-body-sm">${say('license.supportIntro')}</p>
<div class="lic-chat" data-support-thread></div>
<label class="form-label" for="lic-support">${say('license.supportLabel')}</label>
<textarea class="input mt-sm" id="lic-support" rows="3" maxlength="${info.maxText}" placeholder="${esc(t('license.supportPlaceholder'))}">${esc(supportDraft)}</textarea>
<div data-support-file></div>
<div class="flex gap-sm mt-sm">
<button class="btn btn-secondary btn-sm" type="button" data-support-pick>${say('license.supportAttach')}</button>
<input type="file" accept="${esc(info.attachmentTypes.join(','))}" data-support-input hidden>
<button class="btn btn-primary btn-sm" type="button" data-support-send>${say('license.supportSend')}</button>
<span class="t-caption">${say('license.supportAttachHint', { mb })}</span>
</div>
<p class="t-body-sm mt-sm" data-support-msg></p>
${nostr}`;
const field = box.querySelector('#lic-support');
const msg = box.querySelector('[data-support-msg]');
const thread = box.querySelector('[data-support-thread]');
const picker = box.querySelector('[data-support-input]');
const show = (cls, line) => { msg.className = `t-body-sm mt-sm ${cls}`; msg.textContent = line; };
field.oninput = () => { supportDraft = field.value; };
const showFile = () => {
const area = box.querySelector('[data-support-file]');
area.innerHTML = supportFile ? `<div class="lic-pending"><img class="lic-shot" src="${esc(supportFile.url)}" alt="${esc(t('license.supportScreenshot'))}">
<button class="btn btn-ghost btn-sm" type="button" data-support-unpick>${say('license.supportRemoveAttachment')}</button></div>` : '';
area.querySelector('[data-support-unpick]')?.addEventListener('click', () => { supportFile = null; showFile(); });
};
const take = (file) => {
if (!file) return;
if (!info.attachmentTypes.includes(file.type)) { show('t-neg', t('license.supportAttachType')); return; }
if (file.size > info.maxAttachmentBytes) { show('t-neg', t('license.supportAttachTooLarge', { mb })); return; }
const reader = new FileReader();
reader.onload = () => {
const url = String(reader.result);
supportFile = { type: file.type, data: url.slice(url.indexOf(',') + 1), url };
show('', '');
showFile();
};
reader.readAsDataURL(file);
};
showFile();
box.querySelector('[data-support-pick]').onclick = () => picker.click();
picker.onchange = () => { take(picker.files?.[0]); picker.value = ''; };
field.addEventListener('paste', (e) => {
const item = [...(e.clipboardData?.items ?? [])].find(i => i.kind === 'file' && i.type.startsWith('image/'));
if (item) { e.preventDefault(); take(item.getAsFile()); }
});
box.querySelector('[data-support-refresh]').onclick = () => drawThread(thread, info);
drawThread(thread, info);
if (signer.kind === 'key') {
supportTimer = setInterval(() => {
if (!thread.isConnected) { clearInterval(supportTimer); return; }
drawThread(thread, info, { quiet: true });
}, SUPPORT_POLL_MS);
}
box.querySelector('[data-support-send]').onclick = async (e) => {
const text = field.value.trim();
if (!text && !supportFile) { show('t-neg', t('license.supportEmpty')); return; }
if (text.length > info.maxText) { show('t-neg', t('license.supportTooLong', { max: info.maxText })); return; }
e.target.disabled = true;
e.target.textContent = t('license.supportSending');
try {
const body = JSON.stringify({ text, attachment: supportFile ? { type: supportFile.type, data: supportFile.data } : null });
const header = await nip98Header(signer, { url: supportUrl(info, 'messages'), method: 'POST', body });
const sent = await sendSupport(header, body);
if (supportFile && sent.attachments?.[0]) shots.set(sent.attachments[0].id, supportFile.url);
supportDraft = '';
supportFile = null;
field.value = '';
showFile();
show('', '');
await drawThread(thread, info);
} catch (err) {
show('t-neg', AWAY.has(err.key) ? t('license.supportAway') : err.message);
} finally {
e.target.disabled = false;
e.target.textContent = t('license.supportSend');
}
};
}
async function drawThread(area, info, { quiet = false } = {}) {
if (!quiet) area.innerHTML = `<p class="t-body-sm">${say('license.supportThreadLoading')}</p>`;
let list;
try {
const header = await nip98Header(signer, { url: supportUrl(info, 'messages'), method: 'GET' });
list = await supportMessages(header);
} catch (err) {
if (quiet) return;
area.innerHTML = `<p class="t-body-sm t-neg">${esc(AWAY.has(err.key) ? t('license.supportAway') : err.message)}</p>`;
return;
}
if (!list.length) { area.innerHTML = `<p class="t-body-sm">${say('license.supportThreadEmpty')}</p>`; return; }
const when = (ms) => new Date(ms).toLocaleString(localeTag(), { dateStyle: 'short', timeStyle: 'short' });
const who = (m) => t(m.from === 'buyer' ? 'license.supportYou' : m.ai ? 'license.supportAi' : 'license.supportSeller');
const shot = (a) => (shots.has(a.id)
? `<img class="lic-shot" src="${esc(shots.get(a.id))}" alt="${esc(t('license.supportScreenshot'))}">`
: `<button class="btn btn-ghost btn-sm" type="button" data-shot="${esc(a.id)}">${say('license.supportShowScreenshot')}</button>`);
area.innerHTML = list.map(m => `<div class="lic-msg ${m.from === 'buyer' ? 'is-mine' : 'is-theirs'}">
<div class="lic-bubble">${m.text ? `<p class="t-body-sm">${esc(m.text)}</p>` : ''}${m.attachments.map(shot).join('')}</div>
<span class="t-caption">${esc(who(m))} · ${esc(when(m.at))}</span>
</div>`).join('');
const toEnd = () => { area.scrollTop = area.scrollHeight; };
toEnd();
area.querySelectorAll('img').forEach(img => img.addEventListener('load', toEnd, { once: true }));
const load = async (button) => {
const id = button.dataset.shot;
try {
const header = await nip98Header(signer, { url: supportUrl(info, `attachments/${id}`), method: 'GET' });
const { mime, data } = await supportAttachment(header, id);
if (!info.attachmentTypes.includes(mime) || !/^[A-Za-z0-9+/=]+$/.test(data)) return;
shots.set(id, `data:${mime};base64,${data}`);
const img = document.createElement('img');
img.className = 'lic-shot';
img.alt = t('license.supportScreenshot');
img.addEventListener('load', toEnd, { once: true });
img.src = shots.get(id);
button.replaceWith(img);
} catch (err) {
button.textContent = err.message;
}
};
area.querySelectorAll('[data-shot]').forEach(button => {
if (signer.kind === 'key') load(button);
else button.onclick = () => load(button);
});
}
async function drawLicenses(area, msg, shop) {
area.innerHTML = `<p class="t-body-sm">${say('license.licensesLoading')}</p>`;
let list;
try {
const header = await nip98Header(signer, { url: `${shop.serviceUrl}/v1/license`, method: 'GET' });
list = await buyerLicenses(header);
} catch (err) {
area.innerHTML = '';
msg.className = 't-body-sm t-neg';
msg.textContent = err.message;
return;
}
if (!list.length) { area.innerHTML = `<p class="t-body-sm">${say('license.licensesNone')}</p>`; return; }
area.innerHTML = list.map((l, i) => {
if (l.unreadable) return `<p class="t-body-sm">${say('license.licenseNotValid')}</p>`;
const text = say('license.licenseLine', {
level: t(`license.level_${l.level}`), from: l.majorFrom,
to: l.majorTo === 'unlimited' ? t('license.unlimited') : l.majorTo, issued: l.issued,
});
const state = !l.valid ? say('license.licenseNotValid')
: l.installed ? `<span class="badge badge-neutral">${say('license.licenseInstalled')}</span>`
: `<button class="btn btn-secondary btn-sm" type="button" data-install="${i}">${say('license.licenseInstall')}</button>`
+ (l.covers ? '' : ` <span class="t-caption">${say('license.licenseNotCovered')}</span>`);
return `<div class="flex-between gap-sm mt-sm"><span class="t-body-sm">${text}</span><span>${state}</span></div>`;
}).join('');
area.querySelectorAll('[data-install]').forEach(b => { b.onclick = () => installAndReload(list[Number(b.dataset.install)].file, msg); });
}
async function copyText(text, field) {
try { field.select(); if (document.execCommand('copy')) return true; } catch { }
try { await navigator.clipboard.writeText(text); return true; } catch { return false; }
}
function download(name, text) {
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([text], { type: 'text/plain' }));
a.download = name;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
}
const mark = (v) => {
if (v === true) return `<span class="lic-feat-yes" title="${esc(t('license.featIncluded'))}">${CHECK}</span>`;
if (v === false) return `<span class="lic-feat-no" title="${esc(t('license.featExcluded'))}">-</span>`;
if (v === 'partial') return `<span class="lic-feat-part" title="${esc(t('license.featPartialTitle'))}">${esc(t('license.featPartial'))}</span>`;
return `<span class="lic-feat-open" title="${esc(t('license.featUndecided'))}">?</span>`;
};
const inFree = f => (f.has ? CONFIG.LICENSE.FREE.HAS[f.has] : f.free);
function groupFree(items) {
const v = items.map(inFree);
if (v.every(x => x === true)) return true;
if (v.every(x => x === false)) return false;
return v.some(x => x === true) ? 'partial' : null;
}
const groupKey = g => `feat_${g}`;
function featuresHtml(expand) {
const groups = CONFIG.LICENSE.FEATURE_GROUPS.map((g) => {
const items = CONFIG.LICENSE.FEATURES.filter(f => f.group === g);
const sub = items.map(f => `
<li class="lic-feat-row" tabindex="0" data-feature="${esc(f.id)}"><span>${esc(t(`license.feat_${f.id}`))}</span>${mark(true)}${mark(inFree(f))}</li>`).join('');
return `
<details class="lic-group" ${expand.attr(groupKey(g))}>
<summary class="lic-feat-row lic-group-row" data-feature="${esc(items[0].id)}">
<span class="lic-group-name"><span class="chev-btn">${CHEVRON}</span>${esc(t(`license.featGroup_${g}`))}</span>${mark(true)}${mark(groupFree(items))}
</summary>
<ul class="lic-feat-sub">${sub}</ul>
</details>`;
}).join('');
const open = CONFIG.LICENSE.FEATURES.some(f => inFree(f) === null);
return `
<div class="lic-hero">${splashMarkHtml()}<div class="t-label">${esc(t('license.featTitle'))}</div></div>
<div class="lic-feat">
<div class="lic-feat-row lic-feat-cols"><span></span><span>${esc(t('license.featFull'))}</span><span>${esc(t('license.featFree'))}</span></div>
${groups}
</div>
${open ? `<p class="t-caption">${esc(t('license.featFreeOpen'))}</p>` : ''}`;
}
function placeShot(shot, row) {
const gap = 8, pad = 8;
const r = row.getBoundingClientRect();
const w = shot.offsetWidth, h = shot.offsetHeight;
let top = r.top - gap - h;
if (top < pad) top = r.bottom + gap;
const left = Math.min(Math.max(pad, r.left + 24), window.innerWidth - pad - w);
shot.style.left = `${left}px`;
shot.style.top = `${Math.min(Math.max(pad, top), window.innerHeight - pad - h)}px`;
}
function bindFeatures(root) {
document.querySelector('body > .lic-feat-shot')?.remove();
const shot = document.createElement('div');
shot.className = 'lic-feat-shot';
shot.hidden = true;
document.body.appendChild(shot);
let current = null;
let on = null;
const show = (row) => {
if (row === on && !shot.hidden) return;
on = row;
root.querySelectorAll('[data-feature]').forEach(r => r.classList.toggle('is-on', r === row));
shot.hidden = false;
placeShot(shot, row);
const id = row.dataset.feature;
if (id === current) return;
current = id;
shot.innerHTML = '';
const img = new Image();
img.className = 'lic-feat-img';
img.alt = t(`license.feat_${id}`);
img.onload = () => { if (current === id) shot.replaceChildren(img); };
img.onerror = () => { if (current === id) shot.innerHTML = `<p class="t-caption">${esc(t('license.featNoShot'))}</p>`; };
img.src = `${CONFIG.LICENSE.FEATURE_IMAGES}/${id}.webp`;
};
const hide = () => {
on = null;
shot.hidden = true;
root.querySelectorAll('.is-on').forEach(r => r.classList.remove('is-on'));
};
const over = (e) => {
if (!root.isConnected) { stop(); return; }
const row = e.target.closest?.('[data-feature]');
if (row && root.contains(row)) show(row); else hide();
};
const stop = () => {
shot.remove();
document.removeEventListener('pointerover', over);
window.removeEventListener('scroll', hide, true);
window.removeEventListener('hashchange', stop);
};
document.addEventListener('pointerover', over);
window.addEventListener('scroll', hide, { capture: true, passive: true });
window.addEventListener('hashchange', stop);
root.addEventListener('focusin', (e) => { const row = e.target.closest('[data-feature]'); if (row) show(row); });
root.addEventListener('focusout', hide);
root.querySelectorAll('.lic-group').forEach(d => d.addEventListener('toggle', hide));
}
export async function render(el) {
const s = licenseSnapshot() ?? await loadLicense();
const expand = await expanders('license', {
nostr: false, ...Object.fromEntries(CONFIG.LICENSE.FEATURE_GROUPS.map(g => [groupKey(g), false])),
});
el.innerHTML = `
<div class="lic-layout">
<div class="lic-side">
<div class="card lic-card">
<div class="lic-head">
<div class="t-label">${esc(t('license.cardTitle'))}</div>
<span class="badge badge-neutral">${esc(t('license.cardVersion', { version: s.version }))}</span>
</div>
${lines(s).map(r => `<p class="t-body-sm"${r.title ? ` title="${esc(r.title)}"` : ''}>${esc(r.text)}</p>`).join('')}
<div class="lic-drop" id="lic-drop" tabindex="0" role="button">
<span class="t-body-sm">${esc(t('license.dropHere'))}</span>
<span class="t-caption">${esc(t('license.dropOr'))}</span>
<input type="file" id="lic-file" accept=".json,application/json" hidden>
</div>
<span id="lic-msg" class="t-body-sm"></span>
<section class="lic-section" data-signin></section>
<section class="lic-section" data-buy></section>
</div>
<div class="card lic-card">${payHtml(expand)}</div>
</div>
<div class="lic-main">
<div class="card lic-card" data-features>${featuresHtml(expand)}</div>
<div class="card lic-card lic-chat-card" data-support hidden></div>
</div>
</div>`;
expand.bind(el);
bindFeatures(el.querySelector('[data-features]'));
buyBox = el.querySelector('[data-buy]');
supportBox = el.querySelector('[data-support]');
drawSignIn(el.querySelector('[data-signin]'), s);
markLicenseSeen().then(() => refreshAttention()).catch(() => { });
const message = el.querySelector('#lic-msg');
const install = (text) => installAndReload(text, message);
const MAX = 64 * 1024;
const zone = el.querySelector('#lic-drop');
const picker = el.querySelector('#lic-file');
async function fromFile(file) {
if (!file) return;
if (file.size > MAX) {
message.className = 't-body-sm t-neg';
message.textContent = t('license.dropTooBig');
return;
}
await install(await file.text());
}
zone.onclick = () => picker.click();
zone.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); picker.click(); } };
picker.onchange = () => fromFile(picker.files?.[0]);
zone.ondragover = (e) => { e.preventDefault(); zone.classList.add('is-over'); };
zone.ondragleave = () => zone.classList.remove('is-over');
zone.ondrop = (e) => {
e.preventDefault();
zone.classList.remove('is-over');
fromFile(e.dataTransfer?.files?.[0]);
};
}