| /** | |
| * 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<typeof checkout.startCheckout>[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 ( | |
| `<button type="button" class="wm-follow-btn ${sizeCls} wm-follow-btn--loading"` + | |
| ` data-country="${safeCode}" data-state="loading"` + | |
| ` aria-label="Loading follow state for ${safeName}"` + | |
| ` title="Syncing your follows…" disabled>` + | |
| `<span class="wm-follow-btn-spinner" aria-hidden="true"></span>` + | |
| `</button>` | |
| ); | |
| } | |
| if (state.followed) { | |
| return ( | |
| `<button type="button" class="wm-follow-btn ${sizeCls} wm-follow-btn--followed"` + | |
| ` data-country="${safeCode}" data-state="followed"` + | |
| ` aria-pressed="true"` + | |
| ` aria-label="Unfollow ${safeName}"` + | |
| ` title="Unfollow ${safeName}">` + | |
| // Filled star | |
| `<svg class="wm-follow-btn-icon" width="16" height="16" viewBox="0 0 24 24"` + | |
| ` fill="currentColor" aria-hidden="true">` + | |
| `<path d="M12 17.27 18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/>` + | |
| `</svg>` + | |
| `</button>` | |
| ); | |
| } | |
| // Not followed. | |
| const tooltip = state.atCap ? 'Upgrade to follow more' : `Follow ${displayName}`; | |
| return ( | |
| `<button type="button" class="wm-follow-btn ${sizeCls} wm-follow-btn--unfollowed${ | |
| state.atCap ? ' wm-follow-btn--at-cap' : '' | |
| }"` + | |
| ` data-country="${safeCode}" data-state="unfollowed"` + | |
| ` aria-pressed="false"` + | |
| ` aria-label="${escapeHtml(tooltip)}"` + | |
| ` title="${escapeHtml(tooltip)}">` + | |
| // Outlined star | |
| `<svg class="wm-follow-btn-icon" width="16" height="16" viewBox="0 0 24 24"` + | |
| ` fill="none" stroke="currentColor" stroke-width="2"` + | |
| ` stroke-linejoin="round" aria-hidden="true">` + | |
| `<path d="M12 17.27 18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/>` + | |
| `</svg>` + | |
| `</button>` | |
| ); | |
| } | |
| 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 <button>; ignore clicks on nested children | |
| // that bubble up. | |
| const target = ev.target as Element | null; | |
| const btn = | |
| target && typeof (target as Element).closest === 'function' | |
| ? (target as Element).closest<HTMLElement>('.wm-follow-btn') | |
| : null; | |
| if (!btn) return; | |
| if (btn.getAttribute('data-state') === 'loading') { | |
| // Defensive: spinner state should already be `disabled`, but | |
| // make the click a no-op regardless. | |
| return; | |
| } | |
| // P2 #17 — drop duplicate clicks while a mutation is in flight. | |
| if (inFlight) { | |
| ev.preventDefault(); | |
| return; | |
| } | |
| ev.preventDefault(); | |
| inFlight = true; | |
| void onClick(props, () => rerender()).finally(() => { | |
| inFlight = false; | |
| }); | |
| }; | |
| host.addEventListener('click', clickHandler); | |
| // Subscribe to watchlist + entitlement changes; re-render on | |
| // either signal. | |
| const unsubWatchlist = subscribe(rerender); | |
| const unsubEntitlement = onEntitlementChange(rerender); | |
| return () => { | |
| if (tornDown) return; | |
| tornDown = true; | |
| try { | |
| host.removeEventListener('click', clickHandler); | |
| } catch { | |
| /* swallow */ | |
| } | |
| try { | |
| unsubWatchlist(); | |
| } catch { | |
| /* swallow */ | |
| } | |
| try { | |
| unsubEntitlement(); | |
| } catch { | |
| /* swallow */ | |
| } | |
| }; | |
| }, | |
| }; | |
| } | |
| /** | |
| * P2 #16 — exhaustiveness helper. Used in the `onClick` switch on | |
| * `FollowMutationResult.reason`. If a future contributor adds a new | |
| * reason variant to `FollowMutationResult` and forgets to add a | |
| * `case` here, TypeScript will fail to compile because the residual | |
| * `result.reason` won't narrow to `never`. The runtime fallback only | |
| * fires for an actual untyped value (e.g., from a malformed test | |
| * fake) — production code paths are caught at typecheck time. | |
| */ | |
| function assertNever(value: never, where = 'follow-button'): never { | |
| throw new Error(`[${where}] unhandled discriminant: ${String(value)}`); | |
| } | |
| async function onClick( | |
| props: FollowButtonProps, | |
| rerenderForFailure: () => void, | |
| ): Promise<void> { | |
| const followedNow = isFollowed(props.countryCode); | |
| let result: FollowMutationResult; | |
| try { | |
| result = followedNow | |
| ? await removeCountry(props.countryCode) | |
| : await addCountry(props.countryCode); | |
| } catch (err) { | |
| // Service contract is "never throws," but defensively re-render so | |
| // the visual state stays in sync if some transitive call rejects. | |
| console.warn('[follow-button] mutation threw unexpectedly:', err); | |
| rerenderForFailure(); | |
| return; | |
| } | |
| if (result.ok) { | |
| // The service dispatches WM_FOLLOWED_COUNTRIES_CHANGED on success, | |
| // which our `subscribe(rerender)` listener picks up. No manual | |
| // re-render needed. | |
| return; | |
| } | |
| switch (result.reason) { | |
| case 'FREE_CAP': | |
| try { | |
| _upgradeTrigger('follow-cap'); | |
| } catch (err) { | |
| console.warn('[follow-button] upgrade trigger failed:', err); | |
| } | |
| // Re-render so the tooltip / aria-label settles back to the | |
| // "Upgrade to follow more" state if it wasn't already there. | |
| rerenderForFailure(); | |
| return; | |
| case 'HANDOFF_PENDING': | |
| case 'ENTITLEMENT_LOADING': | |
| case 'DISABLED': | |
| // Defensive: button should have been hidden / disabled. Re-render | |
| // so the visible state catches up. | |
| rerenderForFailure(); | |
| return; | |
| case 'INVALID_INPUT': | |
| console.warn( | |
| '[follow-button] invalid country code at mount site:', | |
| props.countryCode, | |
| ); | |
| return; | |
| case 'STORAGE_FULL': | |
| // Anonymous-mode quota exhausted. Not user-actionable; logging | |
| // suffices. (A toast would be nice but is out of scope for U4.) | |
| console.warn( | |
| '[follow-button] anonymous storage full — cannot persist follow', | |
| ); | |
| return; | |
| default: | |
| // P2 #16 — exhaustiveness: when every variant is handled above, | |
| // `result` narrows to `never` here. Adding a new reason to | |
| // `FollowMutationResult` widens the residual type and produces | |
| // a TS2345 ('not assignable to never') at the call site below. | |
| assertNever(result); | |
| } | |
| } | |