UI: persistent upgrade banner; surface render errors

Two bugs reported in 0.1.16 testing:

1. Library button shows a toast "Library is a paid feature ... Tap
   upgrade to unlock" but no Upgrade button is visible anywhere on the
   page. The free-tier banner only rendered when isLicensed() was false,
   so a licensed-but-missing-entitlements user got the toast directing
   them at an invisible button.

2. Clicking the gear (Settings) icon does nothing — the modal never
   appears. The Activity Log button works on the same page.

Fix 1: replace the unlicensed-only renderFreeBanner with renderUpgrade
       Banner driven by shouldShowUpgradeBanner() = !isProTier(). Copy
       adapts by tier:
         • Free: "Free mode · one video at a time · no library, no subs"
         • Limited license (missing core entitlements): "Limited license
           — your license is missing some Core features"
         • Core tier: "Core tier — upgrade to Pro for subs, auto-queue,
           clips"
       Pro tier shows nothing.

Fix 1b: rewordtoasts on Library / Subscribe clicks to drop the
        "tap Upgrade" instruction — the persistent banner is the action
        path now, no need to direct users at a maybe-not-there button.

Fix 2 (diagnostic): wrap the main app.innerHTML build in try/catch.
       A thrown exception in any of the ${...} template substitutions
       silently aborts the innerHTML assignment, leaving the previous
       DOM in place — which looks exactly like a button doing nothing
       when it actually fired and called render(). Now an exception
       lands in a visible error-box with the message + console trace,
       so the next reproduction tells us what's actually throwing.
This commit is contained in:
Keysat
2026-05-08 12:26:29 -05:00
parent 8d9b384e32
commit c8b3300904
+54 -9
View File
@@ -1360,7 +1360,7 @@
// free-tier users, surface the upgrade prompt instead of opening an // free-tier users, surface the upgrade prompt instead of opening an
// empty sidebar. // empty sidebar.
if (!hasEntitlement("history")) { if (!hasEntitlement("history")) {
showToast("Library is a paid feature — keep every summary you process. Tap upgrade to unlock.", "📚"); showToast("Library requires a paid license — every summary you process gets saved.", "📚");
return; return;
} }
toggleHistory(); toggleHistory();
@@ -1369,7 +1369,7 @@
function handleSubscribeClick() { function handleSubscribeClick() {
// Subscriptions / channel auto-queue is Pro-only. // Subscriptions / channel auto-queue is Pro-only.
if (!hasEntitlement("subscriptions")) { if (!hasEntitlement("subscriptions")) {
showToast("Channel & podcast subscriptions are a Pro feature. Upgrade to auto-summarize new uploads.", "📡"); showToast("Channel & podcast subscriptions are a Pro-tier feature.", "📡");
return; return;
} }
addSubscriptionFromInput(); addSubscriptionFromInput();
@@ -1666,10 +1666,37 @@
render(); render();
} }
function renderFreeBanner() { // True for anyone who isn't on the top tier — drives whether to show
// the persistent upgrade banner. Covers: unlicensed, unlicensed-skipped,
// Core (full or partial), and any "licensed but missing entitlements"
// anomaly. Pro users see no banner.
function shouldShowUpgradeBanner() {
if (!state.license || !state.license.loaded) return false;
return !isProTier();
}
function renderUpgradeBanner() {
const buyUrl = upgradeToProUrl(); const buyUrl = upgradeToProUrl();
const free = !isLicensed();
const partialCore = isLicensed() && (!hasEntitlement("history") || !hasEntitlement("library"));
const fullCore = isLicensed() && hasEntitlement("history") && hasEntitlement("library") && !isProTier();
let label, descr;
if (free) {
label = "Free mode";
descr = "one video at a time · no library, no subscriptions";
} else if (partialCore) {
label = "Limited license";
descr = "your license is missing some Core features — contact the seller or upgrade";
} else if (fullCore) {
label = "Core tier";
descr = "upgrade to Pro for channel & podcast subscriptions, auto-queue, and clips";
} else {
return ""; // shouldn't reach
}
return ` return `
<div class="free-banner" style=" <div class="upgrade-banner" style="
margin: 8px 0 12px; margin: 8px 0 12px;
padding: 10px 14px; padding: 10px 14px;
background: linear-gradient(90deg, rgba(168,85,247,0.12), rgba(99,102,241,0.10)); background: linear-gradient(90deg, rgba(168,85,247,0.12), rgba(99,102,241,0.10));
@@ -1683,18 +1710,19 @@
font-size: 13px; font-size: 13px;
"> ">
<span style="flex:1; min-width: 220px;"> <span style="flex:1; min-width: 220px;">
<strong style="color:#c4b5fd;">Free mode</strong> <strong style="color:#c4b5fd;">${label}</strong>
&middot; one video at a time &middot; &middot; ${descr}
no library, no subscriptions
</span> </span>
<a href="${escHtml(buyUrl)}" target="_blank" rel="noopener" <a href="${escHtml(buyUrl)}" target="_blank" rel="noopener"
style="background:#a855f7;color:#fff;border:none;padding:6px 12px;border-radius:6px;text-decoration:none;font-size:12px;font-weight:600;"> style="background:#a855f7;color:#fff;border:none;padding:6px 12px;border-radius:6px;text-decoration:none;font-size:12px;font-weight:600;">
Upgrade Upgrade
</a> </a>
${free ? `
<button onclick="showActivationScreen()" <button onclick="showActivationScreen()"
style="background:transparent;color:#94a3b8;border:1px solid #334155;padding:6px 10px;border-radius:6px;cursor:pointer;font-size:12px;"> style="background:transparent;color:#94a3b8;border:1px solid #334155;padding:6px 10px;border-radius:6px;cursor:pointer;font-size:12px;">
I have a key I have a key
</button> </button>
` : ""}
</div> </div>
`; `;
} }
@@ -1741,7 +1769,9 @@
// a server-side key set via the StartOS config action. // a server-side key set via the StartOS config action.
const submitDisabled = !state.url.trim() const submitDisabled = !state.url.trim()
|| (!isSubscribeUrl(state.url) && !state.apiKey.trim() && !state.hasServerKey); || (!isSubscribeUrl(state.url) && !state.apiKey.trim() && !state.hasServerKey);
app.innerHTML = ` let __renderedHtml;
try {
__renderedHtml = `
<!-- Top bar: title + action icons --> <!-- Top bar: title + action icons -->
<div class="top-bar"> <div class="top-bar">
<div class="top-left-actions"> <div class="top-left-actions">
@@ -1835,7 +1865,7 @@
<!-- Settings modal --> <!-- Settings modal -->
${state.settingsOpen ? renderSettingsModal() : ""} ${state.settingsOpen ? renderSettingsModal() : ""}
${free ? renderFreeBanner() : ""} ${shouldShowUpgradeBanner() ? renderUpgradeBanner() : ""}
${isSubscribeUrl(state.url) ? `<div id="subscribe-prompt">${renderSubscribePrompt()}</div>` : ""} ${isSubscribeUrl(state.url) ? `<div id="subscribe-prompt">${renderSubscribePrompt()}</div>` : ""}
${state.queue.length > 0 ? renderQueue() : ""} ${state.queue.length > 0 ? renderQueue() : ""}
@@ -1852,6 +1882,21 @@
${state.historyOpen ? renderHistorySidebar() : ""} ${state.historyOpen ? renderHistorySidebar() : ""}
${state.clipPanelOpen ? renderClipPanel() : ""} ${state.clipPanelOpen ? renderClipPanel() : ""}
`; `;
app.innerHTML = __renderedHtml;
} catch (renderErr) {
// A thrown exception inside any ${...} expression silently aborts
// the innerHTML assignment, leaving the previous DOM in place — so
// a button click that calls render() looks like a no-op. Surface
// the actual error instead of leaving the user wondering.
console.error("[render] failed to build HTML:", renderErr);
app.innerHTML = `<div class="error-box" style="margin:20px;">
<strong>Render error:</strong> ${escHtml(renderErr && renderErr.message || String(renderErr))}
<div style="font-size:11px;margin-top:6px;opacity:0.7;">
Check the browser console for the full stack trace, or share it with the developer.
</div>
</div>`;
return;
}
// Re-init player if the yt-player div exists // Re-init player if the yt-player div exists
if (state.videoId && ytReady && document.getElementById("yt-player")) { if (state.videoId && ytReady && document.getElementById("yt-player")) {
setTimeout(() => initPlayer(state.videoId), 50); setTimeout(() => initPlayer(state.videoId), 50);