// @ts-check /** * Account — the HF login chip and the daily-limit modal. * * Reads `/api/me` to learn the current tier (anonymous / signed-in / PRO) and * remaining daily talk-time, renders a sign-in pill or a signed-in chip with a * small popover (tier, remaining, sign out, upgrade), and shows the limit modal * when a conversation is refused or cut. The time metering itself lives in * main.js (heartbeat loop) + the server; this module is just the surface. * * Inert unless the deploy is in LB mode (`/api/me` → `{enabled:true}`). */ import { $, escHtml } from "./dom.js"; const PRO_URL = "https://huggingface.co/subscribe/pro"; // Official multi-color Hugging Face logo, used in the badge + sign-in CTA. const HF_MARK = ``; /** @param {number} sec @returns {string} "m:ss" */ function fmt(sec) { const s = Math.max(0, Math.round(sec)); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; } export class Account { constructor() { /** @type {HTMLElement} */ this._root = $("#account"); /** @type {HTMLDialogElement} */ this._modal = $("#limit-modal"); this._modalTitle = $("#limit-title"); this._modalMsg = $("#limit-msg"); this._modalNote = $("#limit-note"); /** @type {HTMLAnchorElement} */ this._modalCta = /** @type {any} */ ($("#limit-cta")); /** @type {{enabled:boolean, auth?:boolean, loggedIn?:boolean, username?:string, avatar?:string, tier?:string, remainingSec?:number|null, limitSec?:number|null, loginUrl?:string|null, logoutUrl?:string|null}} */ this._me = { enabled: false }; this._popoverOpen = false; $("#limit-close").addEventListener("click", () => this._modal.close()); this._modal.addEventListener("click", (e) => { if (e.target === this._modal) this._modal.close(); }); // Close the popover on an outside click. document.addEventListener("click", (e) => { if (this._popoverOpen && !this._root.contains(/** @type {Node} */ (e.target))) { this._closePopover(); } }); } get tier() { return this._me.tier || "anon"; } /** Fetch `/api/me` and (re)render the chip. Safe to call repeatedly (load, * after the OAuth redirect, after a conversation ends). */ async refresh() { try { const res = await fetch("api/me"); this._me = res.ok ? await res.json() : { enabled: false }; } catch { this._me = { enabled: false }; } this._render(); } _render() { const me = this._me; if (!me.enabled) { this._root.hidden = true; this._root.innerHTML = ""; return; } this._root.hidden = false; if (!me.loggedIn) { // Signed-out: a sign-in pill (only when OAuth is actually available). if (me.auth && me.loginUrl) { this._root.innerHTML = `Sign in`; } else { this._root.innerHTML = ""; this._root.hidden = true; } return; } // Signed-in: avatar + handle chip that toggles a popover. const isPro = me.tier === "pro"; // Org members get unlimited usage too, but aren't PRO — don't brand them so. const isUnlimited = isPro || me.tier === "org"; const avatar = me.avatar ? `` : `${escHtml((me.username || "?")[0].toUpperCase())}`; const remaining = isUnlimited || me.remainingSec == null ? "Unlimited" : `${fmt(me.remainingSec)} left today`; const tierLabel = isPro ? "PRO" : isUnlimited ? "Team" : "Free"; this._root.innerHTML = ` `; const chip = $("#account-chip"); chip.addEventListener("click", (e) => { e.stopPropagation(); this._popoverOpen ? this._closePopover() : this._openPopover(); }); } _openPopover() { const pop = document.getElementById("account-pop"); const chip = document.getElementById("account-chip"); if (!pop || !chip) return; pop.hidden = false; chip.setAttribute("aria-expanded", "true"); this._popoverOpen = true; } _closePopover() { const pop = document.getElementById("account-pop"); const chip = document.getElementById("account-chip"); if (pop) pop.hidden = true; if (chip) chip.setAttribute("aria-expanded", "false"); this._popoverOpen = false; } /** * Show the limit modal for a tier — used both when a conversation is refused * at start (402) and when a live one is cut (heartbeat `expired`). * @param {string} [tier] */ showLimit(tier = this.tier) { const canSignIn = this._me.auth && this._me.loginUrl; this._modalTitle.textContent = "Thanks for chatting!"; if (tier === "anon") { this._modalMsg.textContent = "Guest conversations run for 5 minutes. Sign in with Hugging Face to get 10 minutes a day for free, and PRO members chat with no limit at all."; this._modalNote.textContent = "Your free minutes refresh tomorrow."; if (canSignIn) { this._modalCta.innerHTML = `${HF_MARK}Sign in with Hugging Face`; this._modalCta.href = /** @type {string} */ (this._me.loginUrl); this._modalCta.hidden = false; } else { this._modalCta.hidden = true; } } else { // Signed-in, non-PRO. this._modalMsg.textContent = "You've enjoyed your 10 minutes for today. Go PRO for unlimited conversations and to support open source AI."; this._modalNote.textContent = "Or come back tomorrow. Your minutes reset daily."; this._modalCta.innerHTML = "Upgrade to PRO"; this._modalCta.href = PRO_URL; this._modalCta.hidden = false; } if (!this._modal.open) this._modal.showModal(); } /** Show a warm "we're at capacity" message when even the waiting line is full. * Reuses the limit modal shell; no call-to-action, just reassurance. */ showBusy() { this._modalTitle.textContent = "Hugged to the limit 🤗"; this._modalMsg.textContent = "Every slot and the whole line are full right now. Too much love! Grab a coffee and pop back in a minute."; this._modalNote.textContent = "A spot usually opens up within a few minutes."; this._modalCta.hidden = true; if (!this._modal.open) this._modal.showModal(); } }