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:
+118
-20
@@ -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 →</a>
|
||||
<button class="activation-link"
|
||||
style="background:none;border:none;color:#94a3b8;cursor:pointer;padding:0;font-size:13px;"
|
||||
onclick="dismissActivation()">
|
||||
Skip — 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>
|
||||
· one video at a time, bring your own Gemini key ·
|
||||
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() : ""}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user