605 lines
29 KiB
JavaScript
605 lines
29 KiB
JavaScript
// Generated, do not edit. sha256:867f87b26d0b27fe51f4a0cb77d5196bad8cd8b58a6856168c9cad25f5776d99
|
|
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, IMAGE } from '../ui/icons.js';
|
|
import { setPageHint } from '../ui/page-hint.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 standHtml(s) {
|
|
const out = [];
|
|
if (s.installed) {
|
|
const buyer = buyerOf(s);
|
|
const range = t('license.cardFacts', {
|
|
issued: s.issued ?? '', from: s.installed.majorFrom,
|
|
to: s.installed.majorTo === 'unlimited' ? t('license.unlimited') : s.installed.majorTo,
|
|
});
|
|
const [before, after] = t('license.cardFor', { npub: '§' }).split('§');
|
|
out.push(`<p class="t-body-sm">${esc(before)}<span class="badge badge-accent" title="${esc(`${buyer.title}\n${range}`)}">${esc(buyer.text)}</span>${esc(after)}</p>`);
|
|
} else {
|
|
out.push(`<p class="t-body-sm">${esc(t(s.level === 'full' ? 'license.cardFull' : 'license.cardFree'))}</p>`);
|
|
}
|
|
if (BROKEN.has(s.why)) out.push(`<p class="t-body-sm">${esc(`${t('license.cardProblem')} ${t(`license.why_${s.why}`)}.`)}</p>`);
|
|
return out.join('');
|
|
}
|
|
|
|
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 legendRows = () => [
|
|
say('license.payIntro'),
|
|
...['easy', 'other'].flatMap(group => [{ heading: say(`license.payGroup_${group}`) },
|
|
...CONFIG.LICENSE.PAY_APPS.filter(a => a.group === group)
|
|
.map(a => `${link(a.url, `<strong>${esc(a.name)}</strong>`)} ${say(`license.payApp_${a.id}`)}`)]),
|
|
say('license.payNote'),
|
|
{ heading: say('license.nostrMore') },
|
|
...INFO.map(k => `<strong>${say(`license.info${k}Head`)}</strong> ${say(`license.info${k}`)}`),
|
|
...CONFIG.LICENSE.INFO_LINKS.map(l => link(l.url, say(`license.link_${l.id}`))),
|
|
];
|
|
|
|
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 = `
|
|
<div class="flex-between gap-sm"><span class="t-body-sm">${esc(before)}${npubText(signer.pubkey)}${esc(after)}</span>
|
|
<button class="btn btn-ghost btn-sm" type="button" data-signout>${say('license.signOut')}</button></div>
|
|
${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>`;
|
|
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 t-muted lic-status">${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;
|
|
box.hidden = !signer && !purchase;
|
|
if (box.hidden) 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>
|
|
<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-primary btn-sm" type="button" data-support-send>${say('license.supportSend')}</button>
|
|
<input type="file" accept="${esc(info.attachmentTypes.join(','))}" data-support-input hidden>
|
|
<button class="btn btn-ghost btn-sm lic-attach" type="button" data-support-pick
|
|
title="${esc(`${t('license.supportAttach')}: ${t('license.supportAttachHint', { mb })}`)}" aria-label="${esc(t('license.supportAttach'))}">${IMAGE}</button>
|
|
</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'))}">${CHECK}</span>`;
|
|
return `<span class="lic-feat-open" title="${esc(t('license.featUndecided'))}">?</span>`;
|
|
};
|
|
|
|
const MARKS = '.lic-feat-yes, .lic-feat-no, .lic-feat-part, .lic-feat-open';
|
|
|
|
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('');
|
|
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>`;
|
|
}
|
|
|
|
function placeShot(shot, anchor) {
|
|
const gap = 8, pad = 8;
|
|
const r = anchor.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 + r.width / 2 - w / 2), 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, anchor = row) => {
|
|
if (anchor === on && !shot.hidden) return;
|
|
on = anchor;
|
|
root.querySelectorAll('[data-feature]').forEach(r => r.classList.toggle('is-on', r === row));
|
|
shot.hidden = false;
|
|
placeShot(shot, anchor);
|
|
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 mark = e.target.closest?.(MARKS);
|
|
const row = mark?.closest('[data-feature]');
|
|
if (row && root.contains(row)) show(row, mark); 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', Object.fromEntries(CONFIG.LICENSE.FEATURE_GROUPS.map(g => [groupKey(g), false])));
|
|
|
|
el.innerHTML = `
|
|
<div class="lic-layout">
|
|
<div class="lic-main">
|
|
<div class="card lic-card" data-features>${featuresHtml(expand)}</div>
|
|
</div>
|
|
<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-accent">${esc(t('license.cardVersion', { version: s.version }))}</span>
|
|
</div>
|
|
${standHtml(s)}
|
|
<section class="lic-section" data-signin></section>
|
|
<section class="lic-section" data-buy hidden></section>
|
|
</div>
|
|
<div class="card lic-card lic-chat-card" data-support hidden></div>
|
|
</div>
|
|
</div>`;
|
|
setPageHint(el, { title: t('license.payTitle'), rows: legendRows() });
|
|
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(() => { });
|
|
}
|