fix: open club updates from the notification bell

This commit is contained in:
Developer 2026-10-01 08:32:06 -04:00
parent f495ec0cac
commit aced2b5435
3 changed files with 160 additions and 4 deletions

View File

@ -60,6 +60,8 @@ export function FounderPortalShell({
onPostAnnouncement={onPostAnnouncement}
onSwitchRole={onSwitchRole}
onSwitchClub={onSwitchClub}
pendingApplicationsCount={pendingApplicationsCount}
upcomingRidesCount={upcomingRidesCount}
/>
{/* Content Body Container */}

View File

@ -1,7 +1,8 @@
'use client';
import React, { useState, useRef, useEffect } from 'react';
import React, { useState, useRef, useEffect, useId } from 'react';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { Club, UserPortalPermissions, ClubRole } from './types';
interface FounderTopBarProps {
@ -13,6 +14,8 @@ interface FounderTopBarProps {
onPostAnnouncement?: () => void;
onSwitchRole?: (role: ClubRole) => void;
onSwitchClub?: () => void;
pendingApplicationsCount?: number;
upcomingRidesCount?: number;
}
export function FounderTopBar({
@ -24,9 +27,47 @@ export function FounderTopBar({
onPostAnnouncement,
onSwitchRole,
onSwitchClub,
pendingApplicationsCount,
upcomingRidesCount,
}: FounderTopBarProps) {
const [userMenuOpen, setUserMenuOpen] = useState(false);
const userMenuRef = useRef<HTMLDivElement>(null);
const [notificationsOpen, setNotificationsOpen] = useState(false);
const notificationsRef = useRef<HTMLDivElement>(null);
const notificationsButtonRef = useRef<HTMLButtonElement>(null);
const notificationsId = useId();
const searchParams = useSearchParams();
const previewQuery = searchParams?.get('preview') === 'demo' ? '?preview=demo' : '';
const managePath = `/clubs/${club.slug || club.id}/manage`;
const pendingCount = permissions.canApproveApplications ? (pendingApplicationsCount ?? 0) : 0;
useEffect(() => {
setNotificationsOpen(false);
}, [club.id, activeNav, permissions.role]);
useEffect(() => {
if (!notificationsOpen) return;
function handleClickOutside(event: MouseEvent) {
if (!notificationsRef.current?.contains(event.target as Node)) setNotificationsOpen(false);
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
setNotificationsOpen(false);
notificationsButtonRef.current?.focus();
}
}
function handleFocusOutside(event: FocusEvent) {
if (!notificationsRef.current?.contains(event.target as Node)) setNotificationsOpen(false);
}
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleKeyDown);
document.addEventListener('focusin', handleFocusOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('focusin', handleFocusOutside);
};
}, [notificationsOpen]);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
@ -80,6 +121,12 @@ export function FounderTopBar({
<span className="text-brand-cyan font-semibold truncate">Club Profile</span>
</>
)}
{(activeNav === 'members' || activeNav === 'wall') && (
<>
<span className="text-outline-variant shrink-0">/</span>
<span className="text-brand-cyan font-semibold truncate">{activeNav === 'members' ? 'Members' : 'Wall'}</span>
</>
)}
{activeNav === 'applications' && (
<>
<span className="text-outline-variant shrink-0">/</span>
@ -189,22 +236,56 @@ export function FounderTopBar({
)}
{/* Notifications */}
<div className="relative ml-0.5 sm:ml-1 shrink-0">
<div ref={notificationsRef} className="relative ml-0.5 sm:ml-1 shrink-0">
<button
ref={notificationsButtonRef}
type="button"
onClick={() => {
setUserMenuOpen(false);
setNotificationsOpen((open) => !open);
}}
className="w-8 h-8 rounded-xl bg-surface-container border border-brand-border/60 flex items-center justify-center text-outline hover:text-on-surface transition-colors"
title="Notifications"
aria-label="Notifications"
aria-expanded={notificationsOpen}
aria-controls={notificationsId}
>
<span className="material-symbols-outlined text-[18px]">notifications</span>
</button>
<span className="absolute top-1 right-1 w-2 h-2 rounded-full bg-error" />
{pendingCount > 0 && <span aria-label={`${pendingCount} pending applications`} className="pointer-events-none absolute top-0 right-0 w-2 h-2 rounded-full bg-error" />}
{notificationsOpen && (
<section id={notificationsId} aria-label="Club updates"
className="fixed top-20 right-4 w-80 max-w-[calc(100vw-2rem)] sm:absolute sm:top-full sm:right-0 sm:mt-3 rounded-2xl bg-surface-container-lowest border border-brand-border shadow-2xl p-4 z-50">
<div className="flex items-center justify-between gap-3 mb-3">
<h2 className="font-headline text-sm font-semibold text-white">Club updates</h2>
<button type="button" aria-label="Close notifications" className="p-1 text-outline hover:text-white" onClick={() => {
setNotificationsOpen(false); notificationsButtonRef.current?.focus();
}}><span aria-hidden="true" className="material-symbols-outlined text-lg">close</span></button>
</div>
<p className="text-xs text-on-surface-variant mb-3">{permissions.canManageSettings ? 'Open a section to review current club activity.' : 'Club management updates are available to Owners and Admins.'}</p>
<div className="space-y-2">
{permissions.canApproveApplications && <Link href={`${managePath}/applications${previewQuery}`} onClick={() => setNotificationsOpen(false)} className="block rounded-xl border border-brand-border p-3 hover:border-brand-cyan focus-visible:outline focus-visible:outline-brand-cyan">
<span className="block text-sm text-brand-cyan font-semibold">{pendingCount > 0 ? `${pendingCount} pending ${pendingCount === 1 ? 'application' : 'applications'}` : 'Membership applications'}</span>
<span className="block text-xs text-outline mt-1">Review membership requests.</span>
</Link>}
{permissions.canPlanRides && <Link href={`${managePath}/rides${previewQuery}`} onClick={() => setNotificationsOpen(false)} className="block rounded-xl border border-brand-border p-3 hover:border-brand-cyan focus-visible:outline focus-visible:outline-brand-cyan">
<span className="block text-sm text-brand-cyan font-semibold">{upcomingRidesCount && upcomingRidesCount > 0 ? `${upcomingRidesCount} upcoming ${upcomingRidesCount === 1 ? 'ride' : 'rides'}` : 'Upcoming rides'}</span>
<span className="block text-xs text-outline mt-1">See scheduled club rides.</span>
</Link>}
{permissions.canManageSettings && <Link href={`${managePath}/wall${previewQuery}`} onClick={() => setNotificationsOpen(false)} className="block rounded-xl border border-brand-border p-3 hover:border-brand-cyan focus-visible:outline focus-visible:outline-brand-cyan">
<span className="block text-sm text-brand-cyan font-semibold">Club Wall</span>
<span className="block text-xs text-outline mt-1">Read announcements and discussions.</span>
</Link>}
</div>
</section>
)}
</div>
{/* Rider Profile Menu Dropdown */}
<div className="relative shrink-0 ml-0.5" ref={userMenuRef}>
<button
type="button"
onClick={() => setUserMenuOpen(!userMenuOpen)}
onClick={() => { setNotificationsOpen(false); setUserMenuOpen(!userMenuOpen); }}
className={`w-8 h-8 rounded-xl bg-surface-container-low hover:bg-surface-container-high border transition-all flex items-center justify-center cursor-pointer ${
userMenuOpen
? 'border-primary-container ring-2 ring-primary-container/20'

View File

@ -0,0 +1,73 @@
import React from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { FounderTopBar } from '../components/founder-portal/founder-top-bar';
import { FIXTURE_FOUNDER_CLUB } from '../lib/club-fixtures';
import { getPortalPermissions } from '../lib/club-data';
vi.mock('next/navigation', () => ({ useSearchParams: () => new URLSearchParams('preview=demo') }));
vi.mock('../lib/firebase-client', () => ({ firestore: {}, firebaseAuth: {} }));
afterEach(cleanup);
const props = { club: FIXTURE_FOUNDER_CLUB, permissions: getPortalPermissions('owner') };
describe('Founder notification bell', () => {
it('opens and closes club updates and preserves demo preview on destination links', () => {
render(<FounderTopBar {...props} pendingApplicationsCount={2} upcomingRidesCount={1} />);
const bell = screen.getByRole('button', { name: 'Notifications' });
expect(bell.getAttribute('aria-expanded')).toBe('false');
fireEvent.click(bell);
expect(screen.getByRole('region', { name: 'Club updates' })).toBeDefined();
expect(bell.getAttribute('aria-expanded')).toBe('true');
expect(screen.getByRole('link', { name: /2 pending applications/ }).getAttribute('href')).toBe(`/clubs/${props.club.slug}/manage/applications?preview=demo`);
expect(screen.getByRole('link', { name: /1 upcoming ride/ }).getAttribute('href')).toBe(`/clubs/${props.club.slug}/manage/rides?preview=demo`);
expect(screen.getByRole('link', { name: /Club Wall/ }).getAttribute('href')).toBe(`/clubs/${props.club.slug}/manage/wall?preview=demo`);
fireEvent.click(bell);
expect(screen.queryByRole('region', { name: 'Club updates' })).toBeNull();
});
it('does not fabricate an unread indicator or empty-inbox claim when counts are unavailable', () => {
render(<FounderTopBar {...props} />);
expect(screen.queryByLabelText(/pending applications/)).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'Notifications' }));
expect(screen.getByRole('link', { name: /Membership applications/ })).toBeDefined();
expect(screen.getByText('Open a section to review current club activity.')).toBeDefined();
});
it('closes on Escape and restores keyboard focus to the bell', () => {
render(<FounderTopBar {...props} />);
const bell = screen.getByRole('button', { name: 'Notifications' });
fireEvent.click(bell);
screen.getByRole('button', { name: 'Close notifications' }).focus();
fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.queryByRole('region', { name: 'Club updates' })).toBeNull();
expect(document.activeElement).toBe(bell);
});
it('dismisses when clicking outside, following a link, or switching to the rider menu', () => {
render(<FounderTopBar {...props} />);
const bell = screen.getByRole('button', { name: 'Notifications' });
fireEvent.click(bell);
fireEvent.mouseDown(document.body);
expect(screen.queryByRole('region', { name: 'Club updates' })).toBeNull();
fireEvent.click(bell);
const wallLink = screen.getByRole('link', { name: /Club Wall/ });
wallLink.addEventListener('click', (event) => event.preventDefault());
fireEvent.click(wallLink);
expect(screen.queryByRole('region', { name: 'Club updates' })).toBeNull();
fireEvent.click(bell);
fireEvent.click(screen.getByRole('button', { name: 'Open rider menu' }));
expect(screen.queryByRole('region', { name: 'Club updates' })).toBeNull();
expect(screen.getByRole('menu')).toBeDefined();
});
it('closes when club context changes', () => {
const view = render(<FounderTopBar {...props} />);
fireEvent.click(screen.getByRole('button', { name: 'Notifications' }));
view.rerender(<FounderTopBar {...props} club={{ ...props.club, id: 'other-club' }} />);
expect(screen.queryByRole('region', { name: 'Club updates' })).toBeNull();
});
it('does not expose management destinations or pending counts to ordinary members', () => {
render(<FounderTopBar {...props} permissions={getPortalPermissions('member')} pendingApplicationsCount={4} upcomingRidesCount={2} />);
expect(screen.queryByLabelText(/pending applications/)).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'Notifications' }));
expect(screen.getByText('Club management updates are available to Owners and Admins.')).toBeDefined();
const panel = screen.getByRole('region', { name: 'Club updates' });
expect(panel.querySelector('a')).toBeNull();
});
});