From f7e9106b8ab2bae8bec2e6983cdb63ca7831aa68 Mon Sep 17 00:00:00 2001 From: Rafay K <82721722+rafaykhan-source@users.noreply.github.com> Date: Tue, 5 May 2026 22:41:27 -0400 Subject: [PATCH] feat(nudges): add unified nudge system --- packages/app/cypress/e2e/navigation.cy.ts | 5 +- packages/app/cypress/e2e/nudge-system.cy.ts | 242 +++++++++ .../src/app/(dashboard)/evaluation/page.tsx | 4 +- .../app/src/components/dashboard-shell.tsx | 10 +- .../app/src/components/dsv4-launch-modal.tsx | 92 ---- .../app/src/components/eval-samples-nudge.tsx | 81 --- .../app/src/components/export-nudge.test.ts | 97 ---- packages/app/src/components/export-nudge.tsx | 72 --- .../app/src/components/github-star-modal.tsx | 137 ----- .../components/gradient-label-nudge.test.ts | 97 ---- .../src/components/gradient-label-nudge.tsx | 75 --- .../components/inference/ui/ScatterGraph.tsx | 2 +- .../src/components/landing/landing-page.tsx | 8 +- .../src/components/landing/launch-banner.tsx | 96 ---- packages/app/src/components/nudge-engine.tsx | 468 ++++++++++++++++++ .../src/components/reproducibility-nudge.tsx | 64 --- .../app/src/components/star-nudge.test.ts | 105 ---- packages/app/src/components/star-nudge.tsx | 104 ---- .../app/src/components/ui/github-icon.tsx | 12 + packages/app/src/lib/dsv4-launch-storage.ts | 17 - .../app/src/lib/nudges/persistence.test.ts | 214 ++++++++ packages/app/src/lib/nudges/persistence.ts | 83 ++++ packages/app/src/lib/nudges/registry.test.ts | 81 +++ packages/app/src/lib/nudges/registry.tsx | 276 +++++++++++ packages/app/src/lib/nudges/types.ts | 129 +++++ 25 files changed, 1514 insertions(+), 1057 deletions(-) create mode 100644 packages/app/cypress/e2e/nudge-system.cy.ts delete mode 100644 packages/app/src/components/dsv4-launch-modal.tsx delete mode 100644 packages/app/src/components/eval-samples-nudge.tsx delete mode 100644 packages/app/src/components/export-nudge.test.ts delete mode 100644 packages/app/src/components/export-nudge.tsx delete mode 100644 packages/app/src/components/github-star-modal.tsx delete mode 100644 packages/app/src/components/gradient-label-nudge.test.ts delete mode 100644 packages/app/src/components/gradient-label-nudge.tsx delete mode 100644 packages/app/src/components/landing/launch-banner.tsx create mode 100644 packages/app/src/components/nudge-engine.tsx delete mode 100644 packages/app/src/components/reproducibility-nudge.tsx delete mode 100644 packages/app/src/components/star-nudge.test.ts delete mode 100644 packages/app/src/components/star-nudge.tsx create mode 100644 packages/app/src/components/ui/github-icon.tsx delete mode 100644 packages/app/src/lib/dsv4-launch-storage.ts create mode 100644 packages/app/src/lib/nudges/persistence.test.ts create mode 100644 packages/app/src/lib/nudges/persistence.ts create mode 100644 packages/app/src/lib/nudges/registry.test.ts create mode 100644 packages/app/src/lib/nudges/registry.tsx create mode 100644 packages/app/src/lib/nudges/types.ts diff --git a/packages/app/cypress/e2e/navigation.cy.ts b/packages/app/cypress/e2e/navigation.cy.ts index c15e49b5..0c6b094a 100644 --- a/packages/app/cypress/e2e/navigation.cy.ts +++ b/packages/app/cypress/e2e/navigation.cy.ts @@ -48,12 +48,11 @@ describe('First-load navigation', () => { win.localStorage.removeItem('inferencex-starred'); win.localStorage.removeItem('inferencex-star-modal-dismissed'); win.localStorage.removeItem('inferencex-dsv4-modal-dismissed'); + win.localStorage.removeItem('inferencex-dsv4-banner-dismissed'); }, }); - // dsv4 launch modal takes precedence over the GitHub star modal on first - // load — only one modal shows at a time. Either is fine for this test, we - // just need *a* first-load modal up to verify it doesn't block navigation. + // Banner (inline) and overlay modal coexist in independent slots. cy.get('[data-testid="dsv4-launch-modal"]').should('be.visible'); cy.get('body').should('not.have.attr', 'data-scroll-locked'); }); diff --git a/packages/app/cypress/e2e/nudge-system.cy.ts b/packages/app/cypress/e2e/nudge-system.cy.ts new file mode 100644 index 00000000..701b029e --- /dev/null +++ b/packages/app/cypress/e2e/nudge-system.cy.ts @@ -0,0 +1,242 @@ +/** + * E2E tests for the unified NudgeEngine. + * + * Covers: landing modals (priority ordering, dismissal persistence), + * landing banner, dashboard toasts, evaluation toast, and the + * permanent-suppress ("starred") cross-nudge mechanism. + */ + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function clearAllNudgeStorage(win: Cypress.AUTWindow) { + const keys = [ + 'inferencex-starred', + 'inferencex-star-modal-dismissed', + 'inferencex-dsv4-modal-dismissed', + 'inferencex-dsv4-banner-dismissed', + 'inferencex-reproducibility-nudge-shown', + 'inferencex-star-nudge-shown', + 'inferencex-export-nudge-shown', + 'inferencex-gradient-nudge-shown', + 'inferencex-eval-samples-nudge-dismissed', + ]; + for (const key of keys) { + win.localStorage.removeItem(key); + win.sessionStorage.removeItem(key); + } +} + +// --------------------------------------------------------------------------- +// Landing — modal priority & dismissal +// --------------------------------------------------------------------------- + +describe('Landing nudges — modals', () => { + it('shows dsv4 modal and banner simultaneously on fresh first load', () => { + cy.visit('/', { + onBeforeLoad: clearAllNudgeStorage, + }); + // Banner (inline) and modal (overlay) occupy independent slots + cy.get('[data-testid="launch-banner"]').should('be.visible'); + cy.get('[data-testid="dsv4-launch-modal"]').should('be.visible'); + // Only one overlay at a time — star modal should not appear + cy.get('[data-testid="github-star-modal"]').should('not.exist'); + }); + + it('dismissing dsv4 modal persists — not shown on reload', () => { + cy.visit('/', { + onBeforeLoad: clearAllNudgeStorage, + }); + cy.get('[data-testid="dsv4-launch-modal"]').should('be.visible'); + cy.get('[data-testid="dsv4-launch-modal-dismiss"]').click(); + cy.get('[data-testid="dsv4-launch-modal"]').should('not.exist'); + + cy.reload(); + cy.get('[data-testid="dsv4-launch-modal"]').should('not.exist'); + }); + + it('shows star modal when dsv4 modal was previously dismissed', () => { + cy.visit('/', { + onBeforeLoad(win) { + clearAllNudgeStorage(win); + win.localStorage.setItem('inferencex-dsv4-modal-dismissed', '1'); + }, + }); + cy.get('[data-testid="dsv4-launch-modal"]').should('not.exist'); + cy.get('[data-testid="github-star-modal"]').should('be.visible'); + }); + + it('star modal dismiss uses timed strategy — re-shows after expiry', () => { + cy.visit('/', { + onBeforeLoad(win) { + clearAllNudgeStorage(win); + win.localStorage.setItem('inferencex-dsv4-modal-dismissed', '1'); + }, + }); + cy.get('[data-testid="github-star-modal"]').should('be.visible'); + cy.get('[data-testid="github-star-modal-dismiss"]').click(); + cy.get('[data-testid="github-star-modal"]').should('not.exist'); + + cy.window().then((win) => { + const value = win.localStorage.getItem('inferencex-star-modal-dismissed'); + expect(value).to.not.equal(null); + expect(Number(value)).to.be.greaterThan(0); + }); + }); + + it('starring permanently suppresses both star modal and star nudge', () => { + cy.visit('/', { + onBeforeLoad(win) { + clearAllNudgeStorage(win); + win.localStorage.setItem('inferencex-dsv4-modal-dismissed', '1'); + }, + }); + cy.get('[data-testid="github-star-modal"]').should('be.visible'); + cy.get('[data-testid="github-star-modal-action"]').click(); + cy.get('[data-testid="github-star-modal"]').should('not.exist'); + + cy.window().then((win) => { + expect(win.localStorage.getItem('inferencex-starred')).to.eq('1'); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Landing — banner +// --------------------------------------------------------------------------- + +describe('Landing nudges — banner', () => { + it('shows launch banner on landing page', () => { + cy.visit('/', { + onBeforeLoad(win) { + clearAllNudgeStorage(win); + // Dismiss modals so the banner (highest priority at 60) is the active nudge. + // Actually the banner has priority 60 > dsv4 modal 50, so it should show first. + // But the engine only shows one nudge at a time; the banner wins because of priority. + }, + }); + // The banner has the highest priority (60), so it should appear. + // However, NudgeEngine only shows one nudge at a time. + // With immediate triggers and priority 60 > 50 > 40, the banner wins. + cy.get('[data-testid="launch-banner"]').should('be.visible'); + }); + + it('banner renders within container constraints (not full-width)', () => { + cy.visit('/', { + onBeforeLoad: clearAllNudgeStorage, + }); + cy.get('[data-testid="launch-banner"]').should('be.visible'); + // The banner's parent section has the container class for width constraints + cy.get('[data-testid="launch-banner"]').parent('section.container').should('exist'); + }); + + it('dismissing the banner persists across reloads', () => { + cy.visit('/', { + onBeforeLoad: clearAllNudgeStorage, + }); + cy.get('[data-testid="launch-banner"]').should('be.visible'); + cy.get('[data-testid="launch-banner-dismiss"]').click(); + cy.get('[data-testid="launch-banner"]').should('not.exist'); + + cy.reload(); + cy.get('[data-testid="launch-banner"]').should('not.exist'); + }); +}); + +// --------------------------------------------------------------------------- +// Dashboard — reproducibility toast +// --------------------------------------------------------------------------- + +describe('Dashboard nudges — reproducibility toast', () => { + it('shows reproducibility nudge after 1.5s delay on dashboard', () => { + cy.visit('/inference', { + onBeforeLoad(win) { + clearAllNudgeStorage(win); + }, + }); + // Should not be visible immediately + cy.get('[data-testid="reproducibility-nudge"]').should('not.exist'); + // After the timer fires (~1.5s + buffer) + cy.get('[data-testid="reproducibility-nudge"]', { timeout: 4000 }).should('be.visible'); + }); + + it('reproducibility nudge is session-only — gone after reload', () => { + cy.visit('/inference', { + onBeforeLoad(win) { + clearAllNudgeStorage(win); + }, + }); + cy.get('[data-testid="reproducibility-nudge"]', { timeout: 4000 }).should('be.visible'); + + // Session storage should be set + cy.window().then((win) => { + expect(win.sessionStorage.getItem('inferencex-reproducibility-nudge-shown')).to.not.equal( + null, + ); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Evaluation — eval-samples toast +// --------------------------------------------------------------------------- + +describe('Evaluation nudges — eval-samples toast', () => { + it('shows eval-samples nudge after delay on evaluation page', () => { + cy.visit('/evaluation', { + onBeforeLoad(win) { + clearAllNudgeStorage(win); + }, + }); + cy.get('[data-testid="eval-samples-nudge"]', { timeout: 4000 }).should('be.visible'); + }); + + it('eval-samples nudge uses timed dismissal (localStorage timestamp)', () => { + cy.visit('/evaluation', { + onBeforeLoad(win) { + clearAllNudgeStorage(win); + }, + }); + cy.get('[data-testid="eval-samples-nudge"]', { timeout: 4000 }).should('be.visible'); + + // The engine marks it dismissed on show — verify a timestamp is stored + cy.window().then((win) => { + const value = win.localStorage.getItem('inferencex-eval-samples-nudge-dismissed'); + expect(value).to.not.equal(null); + expect(Number(value)).to.be.greaterThan(0); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Cross-scope isolation +// --------------------------------------------------------------------------- + +describe('Nudge scope isolation', () => { + it('landing nudges do not appear on dashboard', () => { + cy.visit('/inference', { + onBeforeLoad: clearAllNudgeStorage, + }); + cy.get('[data-testid="dsv4-launch-modal"]').should('not.exist'); + cy.get('[data-testid="github-star-modal"]').should('not.exist'); + cy.get('[data-testid="launch-banner"]').should('not.exist'); + }); + + it('dashboard nudges do not appear on landing page', () => { + cy.visit('/', { + onBeforeLoad(win) { + clearAllNudgeStorage(win); + // Dismiss all landing nudges so nothing blocks visibility checks + win.localStorage.setItem('inferencex-dsv4-modal-dismissed', '1'); + win.localStorage.setItem('inferencex-dsv4-banner-dismissed', '1'); + win.localStorage.setItem('inferencex-starred', '1'); + }, + }); + // Wait a bit for any timer-based nudges + cy.wait(2000); + cy.get('[data-testid="reproducibility-nudge"]').should('not.exist'); + cy.get('[data-testid="star-nudge"]').should('not.exist'); + cy.get('[data-testid="export-nudge"]').should('not.exist'); + }); +}); diff --git a/packages/app/src/app/(dashboard)/evaluation/page.tsx b/packages/app/src/app/(dashboard)/evaluation/page.tsx index 57411388..649ce620 100644 --- a/packages/app/src/app/(dashboard)/evaluation/page.tsx +++ b/packages/app/src/app/(dashboard)/evaluation/page.tsx @@ -1,8 +1,8 @@ import type { Metadata } from 'next'; -import { EvalSamplesNudge } from '@/components/eval-samples-nudge'; import { EvaluationProvider } from '@/components/evaluation/EvaluationContext'; import EvaluationChartDisplay from '@/components/evaluation/ui/ChartDisplay'; +import { NudgeEngine } from '@/components/nudge-engine'; import { tabMetadata } from '@/lib/tab-meta'; export const metadata: Metadata = tabMetadata('evaluation'); @@ -11,7 +11,7 @@ export default function EvaluationPage() { return ( - + ); } diff --git a/packages/app/src/components/dashboard-shell.tsx b/packages/app/src/components/dashboard-shell.tsx index c7991fc7..17eb1386 100644 --- a/packages/app/src/components/dashboard-shell.tsx +++ b/packages/app/src/components/dashboard-shell.tsx @@ -1,20 +1,14 @@ 'use client'; -import { ExportNudge } from '@/components/export-nudge'; import { GlobalFilterProvider } from '@/components/GlobalFilterContext'; -import { GradientLabelNudge } from '@/components/gradient-label-nudge'; -import { ReproducibilityNudge } from '@/components/reproducibility-nudge'; -import { StarNudge } from '@/components/star-nudge'; +import { NudgeEngine } from '@/components/nudge-engine'; import { TabNav } from '@/components/tab-nav'; import { UnofficialRunProvider } from '@/components/unofficial-run-provider'; export function DashboardShell({ children }: { children: React.ReactNode }) { return ( <> - - - - +
diff --git a/packages/app/src/components/dsv4-launch-modal.tsx b/packages/app/src/components/dsv4-launch-modal.tsx deleted file mode 100644 index 80eed9f8..00000000 --- a/packages/app/src/components/dsv4-launch-modal.tsx +++ /dev/null @@ -1,92 +0,0 @@ -'use client'; - -import { ArrowRight, Sparkles, X } from 'lucide-react'; -import { useCallback, useEffect, useState } from 'react'; - -import { track } from '@/lib/analytics'; -import { isDsv4ModalDismissed, saveDsv4ModalDismissed } from '@/lib/dsv4-launch-storage'; -import { Button } from '@/components/ui/button'; - -const PRESET_HREF = '/inference?preset=dsv4-launch'; - -let sessionDismissed = false; - -export function shouldShowDsv4Modal(): boolean { - if (sessionDismissed) return false; - return !isDsv4ModalDismissed(); -} - -export function Dsv4LaunchModal() { - const [open, setOpen] = useState(false); - - useEffect(() => { - if (shouldShowDsv4Modal()) { - setOpen(true); - track('dsv4_modal_shown'); - } - }, []); - - const dismiss = useCallback(() => { - setOpen(false); - sessionDismissed = true; - saveDsv4ModalDismissed(); - }, []); - - const handleDismiss = useCallback(() => { - dismiss(); - track('dsv4_modal_dismissed'); - }, [dismiss]); - - const handleExplore = useCallback(() => { - track('dsv4_modal_explored'); - dismiss(); - // Hard navigation so `?preset=` is in the URL when InferenceContext mounts. - window.location.href = PRESET_HREF; - }, [dismiss]); - - if (!open) return null; - - return ( - - ); -} diff --git a/packages/app/src/components/eval-samples-nudge.tsx b/packages/app/src/components/eval-samples-nudge.tsx deleted file mode 100644 index f7a142ae..00000000 --- a/packages/app/src/components/eval-samples-nudge.tsx +++ /dev/null @@ -1,81 +0,0 @@ -'use client'; - -import { MessageSquareText } from 'lucide-react'; -import { useCallback, useEffect, useState } from 'react'; - -import { BottomToast } from '@/components/ui/bottom-toast'; -import { track } from '@/lib/analytics'; - -const DISMISS_KEY = 'inferencex-eval-samples-nudge-dismissed'; -const OPEN_EVENT = 'inferencex:eval-samples-opened'; -const SHOW_DELAY_MS = 1500; -/** Re-show the nudge after a week so returning users see it again. Mirrors the GitHub-star modal's cadence. */ -const DISMISS_DURATION_MS = 7 * 24 * 60 * 60 * 1000; - -function shouldShow(): boolean { - try { - const value = localStorage.getItem(DISMISS_KEY); - if (!value) return true; - const dismissedAt = Number(value); - if (Number.isNaN(dismissedAt)) return true; - return Date.now() - dismissedAt >= DISMISS_DURATION_MS; - } catch { - return false; - } -} - -function markShown(): void { - try { - localStorage.setItem(DISMISS_KEY, String(Date.now())); - } catch { - // localStorage unavailable — fail silently. - } -} - -/** - * Periodic nudge that points users at the per-sample drawer on the evaluation - * tab. localStorage stores a dismissal timestamp; the nudge re-shows after one - * week so returning users notice the feature again. The drawer-opens event - * also marks the nudge dismissed (if the user finds the affordance on their own). - */ -export function EvalSamplesNudge() { - const [visible, setVisible] = useState(false); - - useEffect(() => { - if (!shouldShow()) return; - - const timer = window.setTimeout(() => { - if (!shouldShow()) return; - markShown(); - setVisible(true); - track('evaluation_samples_nudge_shown'); - }, SHOW_DELAY_MS); - - const handleOpened = () => { - window.clearTimeout(timer); - markShown(); - setVisible(false); - }; - window.addEventListener(OPEN_EVENT, handleOpened); - return () => { - window.clearTimeout(timer); - window.removeEventListener(OPEN_EVENT, handleOpened); - }; - }, []); - - const handleDismiss = useCallback(() => { - track('evaluation_samples_nudge_dismissed'); - }, []); - - if (!visible) return null; - - return ( - } - title="See the model's actual answers" - description="Click Prompts on any row to compare each prompt, the expected answer, and what the model actually responded." - onDismiss={handleDismiss} - /> - ); -} diff --git a/packages/app/src/components/export-nudge.test.ts b/packages/app/src/components/export-nudge.test.ts deleted file mode 100644 index ab3e96e2..00000000 --- a/packages/app/src/components/export-nudge.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; - -import { - COPY_THRESHOLD, - SESSION_KEY, - saveExportNudgeShown, - shouldShowExportNudge, -} from '@/components/export-nudge'; - -describe('ExportNudge constants', () => { - it('uses the expected sessionStorage key', () => { - expect(SESSION_KEY).toBe('inferencex-export-nudge-shown'); - }); - - it('requires 2 copies before showing', () => { - expect(COPY_THRESHOLD).toBe(2); - }); -}); - -describe('shouldShowExportNudge', () => { - const mockStorage = new Map(); - - beforeEach(() => { - mockStorage.clear(); - vi.stubGlobal('sessionStorage', { - getItem: (key: string) => mockStorage.get(key) ?? null, - setItem: (key: string, value: string) => mockStorage.set(key, value), - removeItem: (key: string) => mockStorage.delete(key), - }); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - vi.restoreAllMocks(); - }); - - it('returns true when no session value is stored', () => { - expect(shouldShowExportNudge()).toBe(true); - }); - - it('returns false when nudge was already shown this session', () => { - mockStorage.set(SESSION_KEY, '1'); - expect(shouldShowExportNudge()).toBe(false); - }); - - it('returns false when sessionStorage throws (fails closed)', () => { - vi.stubGlobal('sessionStorage', { - getItem: () => { - throw new Error('SecurityError'); - }, - setItem: () => { - throw new Error('SecurityError'); - }, - }); - expect(shouldShowExportNudge()).toBe(false); - }); -}); - -describe('saveExportNudgeShown', () => { - const mockStorage = new Map(); - - beforeEach(() => { - mockStorage.clear(); - vi.stubGlobal('sessionStorage', { - getItem: (key: string) => mockStorage.get(key) ?? null, - setItem: (key: string, value: string) => mockStorage.set(key, value), - removeItem: (key: string) => mockStorage.delete(key), - }); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - vi.restoreAllMocks(); - }); - - it('stores a value in sessionStorage', () => { - saveExportNudgeShown(); - expect(mockStorage.get(SESSION_KEY)).toBe('1'); - }); - - it('makes shouldShowExportNudge return false after saving', () => { - saveExportNudgeShown(); - expect(shouldShowExportNudge()).toBe(false); - }); - - it('does not throw when sessionStorage is unavailable', () => { - vi.stubGlobal('sessionStorage', { - getItem: () => { - throw new Error('SecurityError'); - }, - setItem: () => { - throw new Error('SecurityError'); - }, - }); - expect(() => saveExportNudgeShown()).not.toThrow(); - }); -}); diff --git a/packages/app/src/components/export-nudge.tsx b/packages/app/src/components/export-nudge.tsx deleted file mode 100644 index 73e2f925..00000000 --- a/packages/app/src/components/export-nudge.tsx +++ /dev/null @@ -1,72 +0,0 @@ -'use client'; - -import { track } from '@/lib/analytics'; -import { Download } from 'lucide-react'; -import { useCallback, useEffect, useRef, useState } from 'react'; - -import { BottomToast } from '@/components/ui/bottom-toast'; - -export const SESSION_KEY = 'inferencex-export-nudge-shown'; -export const COPY_THRESHOLD = 2; - -/** Check if the export nudge should show (fails closed if sessionStorage is unavailable). */ -export function shouldShowExportNudge(): boolean { - try { - return !sessionStorage.getItem(SESSION_KEY); - } catch { - return false; - } -} - -/** Persist that the nudge was shown this session. */ -export function saveExportNudgeShown(): void { - try { - sessionStorage.setItem(SESSION_KEY, '1'); - } catch { - // ignore — nudge still shows this mount but won't persist - } -} - -export function ExportNudge() { - const [visible, setVisible] = useState(false); - const copyCount = useRef(0); - const hasShown = useRef(false); - - const showNudge = useCallback(() => { - if (hasShown.current) return; - if (!shouldShowExportNudge()) return; - hasShown.current = true; - saveExportNudgeShown(); - setVisible(true); - track('export_nudge_shown'); - }, []); - - useEffect(() => { - const handleCopy = (e: ClipboardEvent) => { - const target = e.target as HTMLElement | null; - if (!target) return; - const isTooltip = target.closest('[data-chart-tooltip]'); - if (!isTooltip) return; - - copyCount.current += 1; - if (copyCount.current >= COPY_THRESHOLD) { - showNudge(); - } - }; - - document.addEventListener('copy', handleCopy); - return () => document.removeEventListener('copy', handleCopy); - }, [showNudge]); - - if (!visible) return null; - - return ( - } - title="Need the data?" - description="Use the download button on any chart to export as PNG or CSV — no need to copy from tooltips." - onDismiss={() => track('export_nudge_dismissed')} - /> - ); -} diff --git a/packages/app/src/components/github-star-modal.tsx b/packages/app/src/components/github-star-modal.tsx deleted file mode 100644 index 9d281354..00000000 --- a/packages/app/src/components/github-star-modal.tsx +++ /dev/null @@ -1,137 +0,0 @@ -'use client'; - -import { track } from '@/lib/analytics'; -import { shouldShowDsv4Modal } from '@/components/dsv4-launch-modal'; -import { - DISMISS_DURATION_MS, - DISMISS_KEY, - STARRED_EVENT, - STARRED_KEY, - saveDismissTimestamp, - saveStarred, -} from '@/lib/star-storage'; -import { Star, X } from 'lucide-react'; -import { useCallback, useEffect, useState } from 'react'; - -import { GITHUB_OWNER, GITHUB_REPO } from '@semianalysisai/inferencex-constants'; -import { Button } from '@/components/ui/button'; - -const GITHUB_REPO_URL = `https://github.com/${GITHUB_OWNER}/${GITHUB_REPO}`; - -let sessionDismissed = false; - -function shouldShowModal(): boolean { - if (sessionDismissed) return false; - // Defer to the dsv4 launch modal until the user has resolved it — only one - // modal at a time, and the launch modal is more time-sensitive. - if (shouldShowDsv4Modal()) return false; - try { - if (localStorage.getItem(STARRED_KEY)) return false; - const value = localStorage.getItem(DISMISS_KEY); - if (!value) return true; - const dismissedAt = Number(value); - if (Number.isNaN(dismissedAt)) return true; - return Date.now() - dismissedAt >= DISMISS_DURATION_MS; - } catch { - return false; - } -} - -export function GitHubStarModal() { - const [open, setOpen] = useState(false); - const [ready, setReady] = useState(false); - - useEffect(() => { - if (shouldShowModal()) { - setOpen(true); - track('star_modal_shown'); - } - setReady(true); - }, []); - - useEffect(() => { - const handleStarred = () => { - setOpen(false); - sessionDismissed = true; - }; - window.addEventListener(STARRED_EVENT, handleStarred); - return () => window.removeEventListener(STARRED_EVENT, handleStarred); - }, []); - - const handleDismiss = useCallback(() => { - setOpen(false); - sessionDismissed = true; - saveDismissTimestamp(); - track('star_modal_dismissed'); - }, []); - - const handleStar = useCallback(() => { - window.open(GITHUB_REPO_URL, '_blank', 'noopener,noreferrer'); - setOpen(false); - sessionDismissed = true; - saveStarred(); - track('star_modal_starred'); - }, []); - - return ( - <> - {ready &&