/** * FollowButton — reusable star/follow-button helper for U4. * * Mounted by CountryDeepDivePanel and CIIPanel rows via `renderFollowButton({...})`. * * Owns: * - Visual states: outlined star (not followed), filled star (followed), * spinner (entitlement loading), hidden (feature flag off). * - Click handler that calls into `addCountry` / `removeCountry`. * - Subscription to watchlist + entitlement changes (re-render on update). * - Branch on `FollowMutationResult.reason` — opens the upgrade modal * on `FREE_CAP` via the same path `notifications-settings.ts` uses * (lazy `@/services/clerk` + `@/services/checkout`). * * Pattern: * - `{ html, attach } → teardown` matches `src/services/notifications-settings.ts`. * - The factory does NOT produce real DOM nodes; it returns an `html` * string for the host to insert and an `attach(host)` that owns the * container's innerHTML on each re-render. This keeps the helper * DOM-light and unit-testable against a minimal host stub (the * project's `tests/*.test.mjs` runner has no jsdom). * * Memory: * - `paywalled-feature-needs-three-layer-entitlement-gate` — the button * consults `serviceEntitlementState()` (not raw `getEntitlementState()`) * so anonymous users render interactive immediately while signed-in * users awaiting their first entitlement snapshot show the spinner. * - `discriminated-union-over-sentinel-boolean` — branches on * `FollowMutationResult.reason`, never on a boolean. * * NOTE: cap-drop toast (the `WM_FOLLOWED_COUNTRIES_CAP_DROP` event from * U3) is intentionally NOT handled here. The button is a per-country * primitive; the toast is App-level UI. TODO(U7+): wire a single * cap-drop listener at the App / toast-service level so it doesn't * fire once-per-mounted-button. */ import { addCountry, removeCountry, isFollowed, getFollowed, subscribe, serviceEntitlementState, isFollowFeatureEnabled, FREE_TIER_FOLLOW_LIMIT, type FollowMutationResult, } from '@/services/followed-countries'; import { onEntitlementChange } from '@/services/entitlements'; import { escapeHtml } from '@/utils/sanitize'; import { setTrustedHtml, trustedHtml } from '@/utils/dom-utils'; // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- export interface FollowButtonProps { countryCode: string; /** Render size. Default `'md'`. */ size?: 'sm' | 'md'; /** * Optional country display name for the tooltip. If omitted the * tooltip falls back to the country code. We don't depend on * `getCountryNameByCode` because GeoJSON may not be loaded when * the button is mounted and we want zero render-blocking awaits. */ countryName?: string; } export interface FollowButtonHandle { /** * Initial markup the host inserts. The host then calls `attach(host)` * which owns subsequent re-renders inside that same node. */ html: string; /** * Mounts the button into `host`. Returns a teardown function that * unsubs both watchlist + entitlement listeners and removes the * click listener. Safe to call twice. */ attach: (host: HTMLElement) => () => void; } // --------------------------------------------------------------------------- // Test-injection seam: upgrade-modal trigger // --------------------------------------------------------------------------- // // In production, the `FREE_CAP` branch dynamically imports clerk + // checkout (the same lazy path `notifications-settings.ts` uses for the // "Upgrade to Pro" button). Tests inject a synchronous fake here so // they can assert the trigger was called without spinning up the real // import graph. type UpgradeTrigger = (source: string) => void; let _upgradeTrigger: UpgradeTrigger = (source) => { // Match the notifications-settings.ts pattern: try sign-in first if no // user, otherwise drop into checkout. If anything fails we fall back // to the `/pro` page (consistent w/ ProBanner CTA). try { void import('@/services/clerk').then((clerk) => { const user = clerk.getCurrentClerkUser?.(); if (!user) { const opener = clerk.openSignIn; if (typeof opener === 'function') { opener(); return; } } // Signed-in OR no openSignIn helper — go straight to checkout. void import('@/services/checkout') .then((checkout) => import('@/config/products').then((products) => { const product = (products as { DEFAULT_UPGRADE_PRODUCT?: unknown }) .DEFAULT_UPGRADE_PRODUCT; if (product && typeof checkout.startCheckout === 'function') { checkout.startCheckout( product as Parameters[0], ); } else { window.open('/pro#pricing', '_blank', 'noopener,noreferrer'); } }), ) .catch(() => { window.open('/pro#pricing', '_blank', 'noopener,noreferrer'); }); }); } catch { try { window.open('/pro#pricing', '_blank', 'noopener,noreferrer'); } catch { /* swallow — non-browser env */ } } // `source` is informational; analytics integration is App-level. // We deliberately don't pull in `@/services/analytics` here to avoid // a heavy import chain on the button factory. void source; }; /** * Test-only override for the upgrade-modal trigger. Pass `null` to * restore the production lazy-import path. */ export function _setUpgradeTriggerForTests(fn: UpgradeTrigger | null): void { _upgradeTrigger = fn ?? ((source) => { void source; try { window.open('/pro#pricing', '_blank', 'noopener,noreferrer'); } catch { /* swallow */ } }); } // --------------------------------------------------------------------------- // Factory // --------------------------------------------------------------------------- interface ButtonViewState { visible: boolean; followed: boolean; loading: boolean; atCap: boolean; } function computeViewState(countryCode: string): ButtonViewState { if (!isFollowFeatureEnabled()) { return { visible: false, followed: false, loading: false, atCap: false }; } const entState = serviceEntitlementState(); if (entState === 'loading') { return { visible: true, followed: false, loading: true, atCap: false }; } const followed = isFollowed(countryCode); // We don't query getFollowed().length here for `atCap` — the *click* // path is the source of truth (the service rejects on FREE_CAP and // returns the discriminated reason). The tooltip is the only thing // that benefits from knowing "would clicking this fail?" upfront, and // for that we do a cheap-and-correct check: if free + already at cap // + not currently followed, the next click would hit FREE_CAP. let atCap = false; if (entState === 'free' && !followed) { // Local import to avoid a circular dependency through addCountry's // re-entry. We import getFollowed lazily via the top-level service. // Doing this dynamically keeps the synchronous render path simple. // (We DO statically import the rest of the service above.) try { // The countModule branch is intentionally a defensive try; if // anything throws we fall back to atCap=false and let the click // handler reveal the cap. const list = _getFollowedListSafe(); atCap = list.length >= FREE_TIER_FOLLOW_LIMIT; } catch { atCap = false; } } return { visible: true, followed, loading: false, atCap }; } function _getFollowedListSafe(): string[] { try { return getFollowed(); } catch { return []; } } function renderHtml(state: ButtonViewState, props: FollowButtonProps): string { if (!state.visible) return ''; const sizeCls = `wm-follow-btn--${props.size ?? 'md'}`; const displayName = props.countryName?.trim() || props.countryCode; const safeCode = escapeHtml(props.countryCode); const safeName = escapeHtml(displayName); if (state.loading) { return ( `` ); } if (state.followed) { return ( `` ); } // Not followed. const tooltip = state.atCap ? 'Upgrade to follow more' : `Follow ${displayName}`; return ( `` ); } export function renderFollowButton( props: FollowButtonProps, ): FollowButtonHandle { const flagOn = isFollowFeatureEnabled(); // Feature flag off → empty html, no-op attach. The host inserts // nothing; nothing to teardown. if (!flagOn) { return { html: '', attach: (_host: HTMLElement) => () => { /* no-op */ }, }; } // Initial render uses the current (synchronous) state. The host // inserts this directly; `attach` then re-renders inside the host // when state changes. const initialState = computeViewState(props.countryCode); const initialHtml = renderHtml(initialState, props); return { html: initialHtml, attach(host: HTMLElement): () => void { let tornDown = false; // P2 #17 — inFlight latch prevents rapid double-click duplicate // mutations. Set true at click-handler entry, cleared in finally // after the awaited mutation resolves. While true, additional // clicks are dropped silently (no second addCountry/removeCountry // is fired). Without this, a double-click on an unfollowed button // would produce TWO follows that both succeed (the service is // idempotent on (user, country) but the second add is wasted // network + counter increment work). let inFlight = false; // Re-render uses host.innerHTML (the host is dedicated to this // button; it's not a delegated container). This keeps the // rendering side-effect-free w.r.t. the surrounding panel. const rerender = () => { if (tornDown) return; const next = computeViewState(props.countryCode); setTrustedHtml(host, trustedHtml(renderHtml(next, props), "legacy direct innerHTML migration")); }; // Render once on attach so any state drift between the initial // `html` snapshot and `attach()` time (rare but possible) is // resolved. Also ensures a freshly-mounted button always reflects // the current world. rerender(); const clickHandler = (ev: Event) => { if (tornDown) return; // Resolve the actual