fix: open club updates from the notification bell
This commit is contained in:
parent
f495ec0cac
commit
aced2b5435
|
|
@ -60,6 +60,8 @@ export function FounderPortalShell({
|
||||||
onPostAnnouncement={onPostAnnouncement}
|
onPostAnnouncement={onPostAnnouncement}
|
||||||
onSwitchRole={onSwitchRole}
|
onSwitchRole={onSwitchRole}
|
||||||
onSwitchClub={onSwitchClub}
|
onSwitchClub={onSwitchClub}
|
||||||
|
pendingApplicationsCount={pendingApplicationsCount}
|
||||||
|
upcomingRidesCount={upcomingRidesCount}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Content Body Container */}
|
{/* Content Body Container */}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect, useId } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { Club, UserPortalPermissions, ClubRole } from './types';
|
import { Club, UserPortalPermissions, ClubRole } from './types';
|
||||||
|
|
||||||
interface FounderTopBarProps {
|
interface FounderTopBarProps {
|
||||||
|
|
@ -13,6 +14,8 @@ interface FounderTopBarProps {
|
||||||
onPostAnnouncement?: () => void;
|
onPostAnnouncement?: () => void;
|
||||||
onSwitchRole?: (role: ClubRole) => void;
|
onSwitchRole?: (role: ClubRole) => void;
|
||||||
onSwitchClub?: () => void;
|
onSwitchClub?: () => void;
|
||||||
|
pendingApplicationsCount?: number;
|
||||||
|
upcomingRidesCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FounderTopBar({
|
export function FounderTopBar({
|
||||||
|
|
@ -24,9 +27,47 @@ export function FounderTopBar({
|
||||||
onPostAnnouncement,
|
onPostAnnouncement,
|
||||||
onSwitchRole,
|
onSwitchRole,
|
||||||
onSwitchClub,
|
onSwitchClub,
|
||||||
|
pendingApplicationsCount,
|
||||||
|
upcomingRidesCount,
|
||||||
}: FounderTopBarProps) {
|
}: FounderTopBarProps) {
|
||||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||||
const userMenuRef = useRef<HTMLDivElement>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
function handleClickOutside(event: MouseEvent) {
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
|
@ -80,6 +121,12 @@ export function FounderTopBar({
|
||||||
<span className="text-brand-cyan font-semibold truncate">Club Profile</span>
|
<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' && (
|
{activeNav === 'applications' && (
|
||||||
<>
|
<>
|
||||||
<span className="text-outline-variant shrink-0">/</span>
|
<span className="text-outline-variant shrink-0">/</span>
|
||||||
|
|
@ -189,22 +236,56 @@ export function FounderTopBar({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Notifications */}
|
{/* 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
|
<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"
|
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"
|
title="Notifications"
|
||||||
aria-label="Notifications"
|
aria-label="Notifications"
|
||||||
|
aria-expanded={notificationsOpen}
|
||||||
|
aria-controls={notificationsId}
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined text-[18px]">notifications</span>
|
<span className="material-symbols-outlined text-[18px]">notifications</span>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Rider Profile Menu Dropdown */}
|
{/* Rider Profile Menu Dropdown */}
|
||||||
<div className="relative shrink-0 ml-0.5" ref={userMenuRef}>
|
<div className="relative shrink-0 ml-0.5" ref={userMenuRef}>
|
||||||
<button
|
<button
|
||||||
type="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 ${
|
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
|
userMenuOpen
|
||||||
? 'border-primary-container ring-2 ring-primary-container/20'
|
? 'border-primary-container ring-2 ring-primary-container/20'
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue