- {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.
+