Add free tier (unlicensed users get one-at-a-time summarization)

Unlicensed users can now summarize a single video at a time using their
own Gemini API key. The result renders in the UI exactly like a paid
summary, but is not persisted — there's no library entry, no history,
and a second submission while one is in flight is rejected.

Server (server/index.js):
  • /api/process is now in LICENSE_OPEN_PATHS. The route handler
    distinguishes free users (state !== "licensed" || no "core") and:
      - rejects USE_SERVER_KEY / empty key with 402 byo_key_required
        (so the bundled Gemini key stays paid-only)
      - rejects a second concurrent job with 409 processing_in_progress
        via a module-level freeJobInFlight flag, released in finally
      - skips saveToHistory so the host's library stays clean
  • Pro feature gates (history/library/subscriptions) unchanged — still
    return 402 feature_not_in_tier for unlicensed callers.

Frontend (public/index.html):
  • New state.activationSkipped flag (persisted to localStorage). The
    activation screen still appears on first launch, but now offers a
    "Skip — use free mode" button alongside Activate / Buy a key.
    Once skipped, the main app renders normally.
  • Free-mode upgrade banner under the top bar with Upgrade and "I have
    a key" buttons (the latter routes back to the activation screen).
  • handleLibraryClick / handleSubscribeClick wrappers — for unlicensed
    users, the library (clock) icon and the channel-URL Subscribe
    submission show a toast explaining the upgrade rather than opening
    an empty sidebar / hitting a 402.
  • Submit button enforces BYO key for unlicensed users (the bundled
    state.hasServerKey doesn't enable submit). handleSubmit shows a
    toast when an unlicensed user tries to queue a second video.
This commit is contained in:
Keysat
2026-05-08 11:16:02 -05:00
parent 7d71150439
commit 25b1c3a366
2 changed files with 160 additions and 25 deletions
+118 -20
View File
@@ -1281,6 +1281,10 @@
licenseActivating: false,
licenseActivationError: null,
licenseActivationKey: "",
// Free tier: once dismissed, the activation screen no longer
// hard-gates the UI. Persisted so returning unlicensed users land
// straight in the app.
activationSkipped: localStorage.getItem("yt-summarizer-activation-skipped") === "1",
};
const MODELS = ["gemini-3.1-pro-preview", "gemini-3-pro-preview", "gemini-3-flash-preview"];
@@ -1320,23 +1324,54 @@
// ── Process ──────────────────────────────────────────────────────────────
async function handleSubmit() {
const hasKey = state.apiKey.trim() || state.hasServerKey;
if (!state.url.trim() || !hasKey) return;
// Free tier requires BYO Gemini key — bundled key is licensed-only.
const free = !isLicensed();
const hasKey = free ? !!state.apiKey.trim() : (state.apiKey.trim() || state.hasServerKey);
if (!state.url.trim() || !hasKey) {
if (free && !state.apiKey.trim() && state.url.trim()) {
showToast("Free mode needs your own Gemini API key — open Settings to enter one.", "🔑");
}
return;
}
const url = state.url.trim();
state.url = "";
// If already processing, add to queue
// If already processing — free tier blocks, paid tier queues.
if (state.loading) {
if (free) {
showToast("Free mode handles one video at a time. Wait for the current one to finish.", "⏳");
return;
}
state.url = "";
state.queue.push({ id: Date.now().toString(), url, status: "queued", error: null });
render();
return;
}
// Start processing immediately
state.url = "";
await processUrl(url);
}
function handleLibraryClick() {
// Library / history is a paid feature (history entitlement). For
// free-tier users, surface the upgrade prompt instead of opening an
// empty sidebar.
if (!hasEntitlement("history")) {
showToast("Library is a paid feature — keep every summary you process. Tap upgrade to unlock.", "📚");
return;
}
toggleHistory();
}
function handleSubscribeClick() {
// Subscriptions / channel auto-queue is Pro-only.
if (!hasEntitlement("subscriptions")) {
showToast("Channel & podcast subscriptions are a Pro feature. Upgrade to auto-summarize new uploads.", "📡");
return;
}
addSubscriptionFromInput();
}
function extractVideoId(url) {
const patterns = [
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/v\/)([a-zA-Z0-9_-]{11})/,
@@ -1586,11 +1621,11 @@
return `
<div class="activation-screen">
<div class="activation-card">
<h1>Activate YouTube Summarizer</h1>
<h1>YouTube Summarizer</h1>
<p class="activation-sub">
${loading
? "Checking license…"
: "Paste your Keysat license key to unlock this app. Buy a key from the seller, then come back here."
: "Activate a Keysat license to unlock the full app — library, subscriptions, channel auto-queue, and the bundled API key. Or skip to use free mode (one video at a time, bring your own Gemini key)."
}
</p>
${loading ? "" : `
@@ -1606,6 +1641,11 @@
${state.licenseActivating ? "Activating…" : "Activate"}
</button>
<a class="activation-link" href="${escHtml(buyUrl)}" target="_blank" rel="noopener">Buy a key &rarr;</a>
<button class="activation-link"
style="background:none;border:none;color:#94a3b8;cursor:pointer;padding:0;font-size:13px;"
onclick="dismissActivation()">
Skip &mdash; use free mode
</button>
</div>
<div class="activation-meta">
Product: <strong>${escHtml(lic.productSlug || "youtube-summarizer")}</strong>
@@ -1617,20 +1657,67 @@
`;
}
function dismissActivation() {
state.activationSkipped = true;
try { localStorage.setItem("yt-summarizer-activation-skipped", "1"); } catch {}
render();
}
function renderFreeBanner() {
const buyUrl = upgradeToProUrl();
return `
<div class="free-banner" style="
margin: 8px 0 12px;
padding: 10px 14px;
background: linear-gradient(90deg, rgba(168,85,247,0.12), rgba(99,102,241,0.10));
border: 1px solid rgba(168,85,247,0.35);
border-radius: 10px;
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
color: #e2e8f0;
font-size: 13px;
">
<span style="flex:1; min-width: 220px;">
<strong style="color:#c4b5fd;">Free mode</strong>
&middot; one video at a time, bring your own Gemini key &middot;
no library, no subscriptions
</span>
<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;">
Upgrade
</a>
<button onclick="showActivationScreen()"
style="background:transparent;color:#94a3b8;border:1px solid #334155;padding:6px 10px;border-radius:6px;cursor:pointer;font-size:12px;">
I have a key
</button>
</div>
`;
}
function showActivationScreen() {
// Take user back to the activation modal (e.g. from an upgrade banner).
state.activationSkipped = false;
try { localStorage.removeItem("yt-summarizer-activation-skipped"); } catch {}
render();
}
function render() {
// Hard-gate the entire UI behind a valid license (matches the server's
// activation-screen flavor). Once licensed + has core, fall through to
// the normal app render below.
if (state.license.loaded && !isLicensed()) {
// Initial paint while license-status is still in-flight: show the
// activation card in its loading skeleton state rather than a flash of
// the underlying app.
if (!state.license.loaded) {
const app = document.getElementById("app");
app.className = "container";
app.innerHTML = renderActivationScreen();
return;
}
// Initial paint while license-status is still in-flight: show the
// activation card in its loading skeleton state rather than a flash of
// the underlying app.
if (!state.license.loaded) {
// Show the activation screen on first launch for unlicensed users so
// they discover the upgrade path. Once they hit "Skip — use free mode"
// (which sets activationSkipped = true) they fall through to the
// normal app, which renders an upgrade banner instead.
if (!isLicensed() && !state.activationSkipped) {
const app = document.getElementById("app");
app.className = "container";
app.innerHTML = renderActivationScreen();
@@ -1646,11 +1733,20 @@
// Preserve library sidebar scroll position across full re-renders
const __prevHistoryListEl = document.querySelector(".history-list");
const __prevHistoryScroll = __prevHistoryListEl ? __prevHistoryListEl.scrollTop : 0;
const free = !isLicensed();
const submitNeedsBYO = free; // bundled key is licensed-only
const submitDisabled = !state.url.trim()
|| (!isSubscribeUrl(state.url)
&& (submitNeedsBYO
? !state.apiKey.trim()
: (!state.apiKey.trim() && !state.hasServerKey)));
app.innerHTML = `
<!-- Top bar: title + action icons -->
<div class="top-bar">
<div class="top-left-actions">
<button class="icon-btn ${state.historyOpen ? "active" : ""}" onclick="toggleHistory()" title="Library">
<button class="icon-btn ${state.historyOpen && !free ? "active" : ""}" onclick="handleLibraryClick()"
title="${free ? "Library (upgrade to unlock)" : "Library"}"
style="${free ? "opacity:0.55;" : ""}">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline>
</svg>
@@ -1658,13 +1754,13 @@
</div>
<div class="top-bar-input">
<input type="text" class="url-input"
placeholder="${state.loading ? "Paste another URL to queue it..." : "Paste a YouTube video, channel, or podcast RSS URL..."}"
placeholder="${state.loading ? (free ? "Wait for the current video — free mode is one at a time" : "Paste another URL to queue it...") : "Paste a YouTube video, channel, or podcast RSS URL..."}"
value="${escHtml(state.url)}"
oninput="state.url=this.value; updateInputMode()"
onkeydown="if(event.key==='Enter'){ isSubscribeUrl(state.url) ? addSubscriptionFromInput() : handleSubmit() }" />
onkeydown="if(event.key==='Enter'){ isSubscribeUrl(state.url) ? handleSubscribeClick() : handleSubmit() }" />
<button class="submit-btn"
onclick="${isSubscribeUrl(state.url) ? "addSubscriptionFromInput()" : "handleSubmit()"}"
${!state.url.trim() || (!isSubscribeUrl(state.url) && !state.apiKey.trim() && !state.hasServerKey) ? "disabled" : ""}
onclick="${isSubscribeUrl(state.url) ? "handleSubscribeClick()" : "handleSubmit()"}"
${submitDisabled ? "disabled" : ""}
style="${isSubscribeUrl(state.url) ? "background:#6366f1" : ""}">
${isSubscribeUrl(state.url) ? (state.addingSubLoading ? "Subscribing..." : "Subscribe") : (state.loading ? "Queue" : "Summarize")}
</button>
@@ -1738,6 +1834,8 @@
<!-- Settings modal -->
${state.settingsOpen ? renderSettingsModal() : ""}
${free ? renderFreeBanner() : ""}
${isSubscribeUrl(state.url) ? `<div id="subscribe-prompt">${renderSubscribePrompt()}</div>` : ""}
${state.queue.length > 0 ? renderQueue() : ""}