diff --git a/src/components/AppLayout.tsx b/src/components/AppLayout.tsx index 7ac90a0..cd99ae7 100644 --- a/src/components/AppLayout.tsx +++ b/src/components/AppLayout.tsx @@ -12,6 +12,7 @@ import { HelpButton } from '@/components/HelpButton'; import { calculateWeakQuestionIds } from '@/lib/weakQuestions'; import { filterByTestType } from '@/lib/testTypeUtils'; import { SkipLink } from '@/components/SkipLink'; +import { useCommunityPromoToast } from '@/hooks/useCommunityPromoToast'; interface AppLayoutProps { children: ReactNode; @@ -79,6 +80,13 @@ export function AppLayout({ children, currentView, onViewChange, selectedTest, o (b) => b.display_name ); + // Show community promo toast for eligible users + useCommunityPromoToast({ + userCreatedAt: user?.created_at, + forumUsername: profile?.forum_username, + isAuthenticated: !!user, + }); + const handleSignOut = async () => { // Navigate first to ensure we redirect before state changes trigger re-renders navigate('/auth'); diff --git a/src/components/DashboardSidebar.test.tsx b/src/components/DashboardSidebar.test.tsx index a9d8db3..1f21d28 100644 --- a/src/components/DashboardSidebar.test.tsx +++ b/src/components/DashboardSidebar.test.tsx @@ -390,20 +390,20 @@ describe('DashboardSidebar', () => { }); }); - describe('Forum Link', () => { - it('displays Forum link', () => { + describe('Community Link', () => { + it('displays Community link', () => { render(, { wrapper: createWrapper() }); - expect(screen.getByText('Forum')).toBeInTheDocument(); + expect(screen.getByText('Community')).toBeInTheDocument(); }); - it('has correct href for Forum link', () => { + it('has correct href for Community link', () => { render(, { wrapper: createWrapper() }); - const forumLink = screen.getByText('Forum').closest('a'); - expect(forumLink).toHaveAttribute('href', 'https://forum.openhamprep.com/auth/oidc'); - expect(forumLink).toHaveAttribute('target', '_blank'); - expect(forumLink).toHaveAttribute('rel', 'noopener noreferrer'); + const communityLink = screen.getByText('Community').closest('a'); + expect(communityLink).toHaveAttribute('href', 'https://forum.openhamprep.com/auth/oidc'); + expect(communityLink).toHaveAttribute('target', '_blank'); + expect(communityLink).toHaveAttribute('rel', 'noopener noreferrer'); }); }); diff --git a/src/components/DashboardSidebar.tsx b/src/components/DashboardSidebar.tsx index f5a2920..c11f4ab 100644 --- a/src/components/DashboardSidebar.tsx +++ b/src/components/DashboardSidebar.tsx @@ -134,7 +134,7 @@ export function DashboardSidebar({ { id: 'glossary', label: 'Glossary', icon: BookText }, { id: 'tools', label: 'Tools', icon: Wrench }, { id: 'find-test-site', label: 'Find Test Site', icon: MapPin }, - { id: 'forum', label: 'Forum', icon: Users, external: 'https://forum.openhamprep.com/auth/oidc' }, + { id: 'forum', label: 'Community', icon: Users, external: 'https://forum.openhamprep.com/auth/oidc' }, ]; const handleNavClick = (view: View, disabled?: boolean) => { diff --git a/src/components/ProfileModal.test.tsx b/src/components/ProfileModal.test.tsx index be43f4e..4ad07d7 100644 --- a/src/components/ProfileModal.test.tsx +++ b/src/components/ProfileModal.test.tsx @@ -123,7 +123,7 @@ describe('ProfileModal', () => { await user.click(accountMenuItem!); expect(screen.getByText('Display Name')).toBeInTheDocument(); - expect(screen.getByText('Forum Username')).toBeInTheDocument(); + expect(screen.getByText('Community Username')).toBeInTheDocument(); expect(screen.getByText('Email Address')).toBeInTheDocument(); expect(screen.getByText('Password')).toBeInTheDocument(); }); diff --git a/src/components/ProfileModal.tsx b/src/components/ProfileModal.tsx index 52e3061..331cbae 100644 --- a/src/components/ProfileModal.tsx +++ b/src/components/ProfileModal.tsx @@ -476,7 +476,7 @@ export function ProfileModal({
diff --git a/src/components/sidebar/SidebarNavItem.test.tsx b/src/components/sidebar/SidebarNavItem.test.tsx index e6d08e7..07b9bda 100644 --- a/src/components/sidebar/SidebarNavItem.test.tsx +++ b/src/components/sidebar/SidebarNavItem.test.tsx @@ -105,7 +105,7 @@ describe('SidebarNavItem', () => { describe('External Links', () => { const externalItem = { id: 'forum', - label: 'Forum', + label: 'Community', icon: BarChart3, external: 'https://forum.example.com', }; @@ -161,7 +161,7 @@ describe('SidebarNavItem', () => { onClick={vi.fn()} /> ); - expect(screen.queryByText('Forum')).not.toBeInTheDocument(); + expect(screen.queryByText('Community')).not.toBeInTheDocument(); // Should only have one SVG (the item icon) when collapsed const link = screen.getByRole('link'); const svgs = link.querySelectorAll('svg'); @@ -177,7 +177,7 @@ describe('SidebarNavItem', () => { onClick={vi.fn()} /> ); - expect(screen.getByText('Forum')).toBeInTheDocument(); + expect(screen.getByText('Community')).toBeInTheDocument(); }); }); diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx index 0699548..9808c31 100644 --- a/src/components/ui/toast.tsx +++ b/src/components/ui/toast.tsx @@ -23,11 +23,11 @@ const ToastViewport = React.forwardRef< ToastViewport.displayName = ToastPrimitives.Viewport.displayName; const toastVariants = cva( - "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", + "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-xl border p-4 shadow-lg transition-all duration-300 ease-out data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-bottom-5 data-[state=open]:duration-300 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-bottom-5 data-[state=closed]:duration-300 data-[swipe=end]:animate-out", { variants: { variant: { - default: "border bg-background text-foreground", + default: "border-border bg-card text-card-foreground", destructive: "destructive group border-destructive bg-destructive text-destructive-foreground", }, }, @@ -67,7 +67,7 @@ const ToastClose = React.forwardRef< -
- {title && {title}} - {description && {description}} +
+
+
+ {title && {title}} + {description && {description}} +
+ +
+ {action &&
{action}
}
- {action} - ); })} diff --git a/src/hooks/use-toast.ts b/src/hooks/use-toast.ts index ca1316d..4e89edd 100644 --- a/src/hooks/use-toast.ts +++ b/src/hooks/use-toast.ts @@ -151,6 +151,9 @@ function toast({ ...props }: Toast) { id, open: true, onOpenChange: (open) => { + // Call custom onOpenChange first to allow side effects (e.g., localStorage writes) + // before the toast is dismissed from the UI + props.onOpenChange?.(open); if (!open) dismiss(); }, }, diff --git a/src/hooks/useCommunityPromoToast.test.ts b/src/hooks/useCommunityPromoToast.test.ts new file mode 100644 index 0000000..9a4e7fd --- /dev/null +++ b/src/hooks/useCommunityPromoToast.test.ts @@ -0,0 +1,309 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { useCommunityPromoToast } from './useCommunityPromoToast'; +import { toast } from '@/hooks/use-toast'; + +// Mock the toast hook +vi.mock('@/hooks/use-toast', () => ({ + toast: vi.fn(), +})); + +// Mock the ToastAction component +vi.mock('@/components/ui/toast', () => ({ + ToastAction: 'button', +})); + +// Mock the PWA install hook +vi.mock('@/hooks/usePWAInstall', () => ({ + usePWAInstall: vi.fn(() => ({ + showPrompt: false, + canInstall: false, + isInstalled: false, + isIOS: false, + triggerInstall: vi.fn(), + dismissPrompt: vi.fn(), + })), +})); + +describe('useCommunityPromoToast', () => { + const mockToast = vi.mocked(toast); + + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + const getDateDaysAgo = (days: number): string => { + const date = new Date(); + date.setDate(date.getDate() - days); + return date.toISOString(); + }; + + it('does not show toast when user is not authenticated', () => { + renderHook(() => + useCommunityPromoToast({ + userCreatedAt: getDateDaysAgo(5), + forumUsername: null, + isAuthenticated: false, + }) + ); + + vi.advanceTimersByTime(2000); + expect(mockToast).not.toHaveBeenCalled(); + }); + + it('does not show toast when userCreatedAt is null', () => { + renderHook(() => + useCommunityPromoToast({ + userCreatedAt: null, + forumUsername: null, + isAuthenticated: true, + }) + ); + + vi.advanceTimersByTime(2000); + expect(mockToast).not.toHaveBeenCalled(); + }); + + it('does not show toast when user has forum_username', () => { + renderHook(() => + useCommunityPromoToast({ + userCreatedAt: getDateDaysAgo(5), + forumUsername: 'existingUser', + isAuthenticated: true, + }) + ); + + vi.advanceTimersByTime(2000); + expect(mockToast).not.toHaveBeenCalled(); + }); + + it('does not show toast when less than 3 days since signup', () => { + renderHook(() => + useCommunityPromoToast({ + userCreatedAt: getDateDaysAgo(2), + forumUsername: null, + isAuthenticated: true, + }) + ); + + vi.advanceTimersByTime(2000); + expect(mockToast).not.toHaveBeenCalled(); + }); + + it('does not show toast when already shown (localStorage)', () => { + localStorage.setItem('community-toast-shown', 'true'); + + renderHook(() => + useCommunityPromoToast({ + userCreatedAt: getDateDaysAgo(5), + forumUsername: null, + isAuthenticated: true, + }) + ); + + vi.advanceTimersByTime(2000); + expect(mockToast).not.toHaveBeenCalled(); + }); + + it('shows toast when all conditions are met', () => { + renderHook(() => + useCommunityPromoToast({ + userCreatedAt: getDateDaysAgo(5), + forumUsername: null, + isAuthenticated: true, + }) + ); + + vi.advanceTimersByTime(2000); + + expect(mockToast).toHaveBeenCalledTimes(1); + expect(mockToast).toHaveBeenCalledWith( + expect.objectContaining({ + description: expect.stringContaining('Connect with fellow ham radio'), + }) + ); + }); + + it('does not set localStorage when toast is shown (only when dismissed)', () => { + renderHook(() => + useCommunityPromoToast({ + userCreatedAt: getDateDaysAgo(5), + forumUsername: null, + isAuthenticated: true, + }) + ); + + vi.advanceTimersByTime(2000); + + // localStorage should NOT be set just by showing the toast + // It should only be set when user dismisses it (clicks X or action button) + expect(localStorage.getItem('community-toast-shown')).toBeNull(); + }); + + it('sets localStorage when toast is dismissed via onOpenChange', () => { + renderHook(() => + useCommunityPromoToast({ + userCreatedAt: getDateDaysAgo(5), + forumUsername: null, + isAuthenticated: true, + }) + ); + + vi.advanceTimersByTime(2000); + + // Simulate the toast being dismissed by calling onOpenChange + const toastCall = mockToast.mock.calls[0][0]; + expect(toastCall.onOpenChange).toBeDefined(); + toastCall.onOpenChange(false); + + expect(localStorage.getItem('community-toast-shown')).toBe('true'); + }); + + it('only shows toast once even with re-renders', () => { + const { rerender } = renderHook( + ({ userCreatedAt, forumUsername, isAuthenticated }) => + useCommunityPromoToast({ userCreatedAt, forumUsername, isAuthenticated }), + { + initialProps: { + userCreatedAt: getDateDaysAgo(5), + forumUsername: null as string | null, + isAuthenticated: true, + }, + } + ); + + vi.advanceTimersByTime(2000); + expect(mockToast).toHaveBeenCalledTimes(1); + + // Re-render with same props + rerender({ + userCreatedAt: getDateDaysAgo(5), + forumUsername: null, + isAuthenticated: true, + }); + + vi.advanceTimersByTime(2000); + // Should still only be called once due to localStorage + expect(mockToast).toHaveBeenCalledTimes(1); + }); + + it('shows toast exactly at 3 days boundary', () => { + renderHook(() => + useCommunityPromoToast({ + userCreatedAt: getDateDaysAgo(3), + forumUsername: null, + isAuthenticated: true, + }) + ); + + vi.advanceTimersByTime(2000); + expect(mockToast).toHaveBeenCalledTimes(1); + }); + + it('shows toast immediately in test mode (bypasses 3-day check)', () => { + // Enable test mode + localStorage.setItem('community-toast-test-mode', 'true'); + + renderHook(() => + useCommunityPromoToast({ + userCreatedAt: getDateDaysAgo(0), // Brand new account + forumUsername: null, + isAuthenticated: true, + }) + ); + + vi.advanceTimersByTime(2000); + expect(mockToast).toHaveBeenCalledTimes(1); + }); + + it('does not show toast when PWA install banner is visible', async () => { + // Mock PWA banner as showing + const { usePWAInstall } = await import('@/hooks/usePWAInstall'); + vi.mocked(usePWAInstall).mockReturnValue({ + showPrompt: true, + canInstall: true, + isInstalled: false, + isIOS: false, + triggerInstall: vi.fn(), + dismissPrompt: vi.fn(), + }); + + renderHook(() => + useCommunityPromoToast({ + userCreatedAt: getDateDaysAgo(5), + forumUsername: null, + isAuthenticated: true, + }) + ); + + vi.advanceTimersByTime(2000); + expect(mockToast).not.toHaveBeenCalled(); + }); + + it('clears timeout on unmount before toast is shown', () => { + const { unmount } = renderHook(() => + useCommunityPromoToast({ + userCreatedAt: getDateDaysAgo(5), + forumUsername: null, + isAuthenticated: true, + }) + ); + + // Unmount before the toast timeout fires + unmount(); + vi.advanceTimersByTime(2000); + + // Toast should not be called after unmount + expect(mockToast).not.toHaveBeenCalled(); + }); + + it('handles popup blocker gracefully when action button is clicked', async () => { + // Reset PWA mock to default (not showing) - previous test may have changed it + const { usePWAInstall } = await import('@/hooks/usePWAInstall'); + vi.mocked(usePWAInstall).mockReturnValue({ + showPrompt: false, + canInstall: false, + isInstalled: false, + isIOS: false, + triggerInstall: vi.fn(), + dismissPrompt: vi.fn(), + }); + + // Mock window.open to return null (simulating popup blocker) + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const windowOpenSpy = vi.spyOn(window, 'open').mockReturnValue(null); + + renderHook(() => + useCommunityPromoToast({ + userCreatedAt: getDateDaysAgo(5), + forumUsername: null, + isAuthenticated: true, + }) + ); + + vi.advanceTimersByTime(2000); + + // Get the action element and simulate click + const toastCall = mockToast.mock.calls[0][0]; + expect(toastCall.action).toBeDefined(); + + // The action is a createElement result, so we need to get the onClick from props + const actionProps = toastCall.action.props; + actionProps.onClick(); + + // Should log a warning about popup blocker + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Popup blocked. Please allow popups to visit the community forum.' + ); + + // Cleanup + consoleWarnSpy.mockRestore(); + windowOpenSpy.mockRestore(); + }); +}); diff --git a/src/hooks/useCommunityPromoToast.ts b/src/hooks/useCommunityPromoToast.ts new file mode 100644 index 0000000..8a6b7f2 --- /dev/null +++ b/src/hooks/useCommunityPromoToast.ts @@ -0,0 +1,135 @@ +import { useEffect, useRef, createElement } from 'react'; +import { toast } from '@/hooks/use-toast'; +import { ToastAction } from '@/components/ui/toast'; +import { Users } from 'lucide-react'; +import { usePWAInstall } from '@/hooks/usePWAInstall'; + +const COMMUNITY_URL = 'https://forum.openhamprep.com/auth/oidc'; +const STORAGE_KEY = 'community-toast-shown'; +const TEST_MODE_KEY = 'community-toast-test-mode'; +const DAYS_AFTER_SIGNUP = 3; + +interface UseCommunityPromoToastOptions { + userCreatedAt: string | null | undefined; + forumUsername: string | null | undefined; + isAuthenticated: boolean; +} + +export function useCommunityPromoToast({ + userCreatedAt, + forumUsername, + isAuthenticated, +}: UseCommunityPromoToastOptions) { + // Check if PWA install banner is showing to avoid overlap + const { showPrompt: isPWABannerShowing } = usePWAInstall(); + + // Use ref to prevent multiple toast triggers during re-renders + const hasScheduledRef = useRef(false); + const timeoutRef = useRef | null>(null); + + useEffect(() => { + // Guard: Must be authenticated + if (!isAuthenticated || !userCreatedAt) { + return; + } + + // Guard: User has already authenticated with Discourse + if (forumUsername) { + return; + } + + // Guard: Toast already shown (check localStorage) + if (localStorage.getItem(STORAGE_KEY) === 'true') { + return; + } + + // Guard: Don't show while PWA install banner is visible + if (isPWABannerShowing) { + return; + } + + // Guard: Already scheduled (prevents re-scheduling on re-renders) + if (hasScheduledRef.current) { + return; + } + + // Check if 3 days have passed since signup (skip in test mode) + const isTestMode = localStorage.getItem(TEST_MODE_KEY) === 'true'; + + if (!isTestMode) { + const signupDate = new Date(userCreatedAt); + const now = new Date(); + const daysSinceSignup = Math.floor( + (now.getTime() - signupDate.getTime()) / (1000 * 60 * 60 * 24) + ); + + if (daysSinceSignup < DAYS_AFTER_SIGNUP) { + return; + } + } + + // Mark as scheduled (don't clear this on re-render) + hasScheduledRef.current = true; + + // Clear any existing timeout before scheduling a new one (prevents memory leak) + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + // Small delay to ensure the page is fully loaded + timeoutRef.current = setTimeout(() => { + toast({ + title: createElement('div', { + className: 'flex items-center gap-3', + role: 'status', + 'aria-live': 'polite', + }, + createElement('div', { className: 'p-2 rounded-lg bg-primary/10 shrink-0' }, + createElement(Users, { className: 'w-5 h-5 text-primary', 'aria-hidden': true }) + ), + createElement('span', { className: 'font-semibold text-foreground' }, 'Join the Community!') + ), + description: 'Connect with fellow ham radio enthusiasts, ask questions, and share your progress.', + duration: Infinity, // Stay open until manually dismissed + action: createElement(ToastAction, { + altText: 'Visit the Open Ham Prep community forum', + className: 'bg-primary text-primary-foreground hover:bg-primary/90 font-medium px-4 py-2 h-auto rounded-lg', + onClick: () => { + const newWindow = window.open(COMMUNITY_URL, '_blank', 'noopener,noreferrer'); + if (!newWindow) { + // Popup was blocked - still mark as shown since user attempted to visit + // The toast will close via onOpenChange which also sets localStorage + console.warn('Popup blocked. Please allow popups to visit the community forum.'); + } + // Note: localStorage is set via onOpenChange when toast closes, avoiding duplicate writes + }, + }, + createElement('span', { className: 'flex items-center gap-2' }, + createElement(Users, { className: 'w-4 h-4', 'aria-hidden': true }), + 'Visit Community' + ) + ), + // Only mark as shown when user dismisses the toast (clicks X or action button) + onOpenChange: (open: boolean) => { + if (!open) { + localStorage.setItem(STORAGE_KEY, 'true'); + } + }, + }); + }, 1500); + // Note: Only props are in deps array. isTestMode is read from localStorage inside + // the effect and doesn't need to trigger re-runs (it's checked fresh each time). + }, [userCreatedAt, forumUsername, isAuthenticated, isPWABannerShowing]); + + // Cleanup on unmount - reset refs so remounting can re-schedule if needed + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + // Reset scheduled flag so remounting allows toast to show again + // (if localStorage wasn't set, the toast should still be shown) + hasScheduledRef.current = false; + }; + }, []); +} diff --git a/website/about.html b/website/about.html index 7a3b78b..0bc865e 100644 --- a/website/about.html +++ b/website/about.html @@ -5,6 +5,7 @@ About Us - Open Ham Prep + diff --git a/website/faq.html b/website/faq.html index 68015b0..1c6824c 100644 --- a/website/faq.html +++ b/website/faq.html @@ -5,6 +5,7 @@ FAQ - Open Ham Prep + diff --git a/website/favicon.svg b/website/favicon.svg new file mode 100644 index 0000000..16de820 --- /dev/null +++ b/website/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/website/features.html b/website/features.html index 9ca736d..479dd58 100644 --- a/website/features.html +++ b/website/features.html @@ -5,6 +5,7 @@ Features - Open Ham Prep + diff --git a/website/index.html b/website/index.html index ba48583..d55109b 100644 --- a/website/index.html +++ b/website/index.html @@ -5,6 +5,7 @@ Open Ham Prep - Pass Your Ham Radio Exam. First Try. For Free. +