From 074790be75ec0bdd92be7d69d463623921cf3d2c Mon Sep 17 00:00:00 2001 From: johnmeshulam <55348702+johnmeshulam@users.noreply.github.com> Date: Sat, 10 Jan 2026 15:30:37 +0200 Subject: [PATCH 1/2] Added draft awards presentation --- .../components/audience-display-control.tsx | 2 +- .../audience-display-settings-modal.tsx | 46 ++++- .../awards-presentation-wrapper.tsx | 107 ++++++++++ .../components/presentation-controller.tsx | 192 ++++++++++++++++++ .../(dashboard)/scorekeeper/graphql/query.ts | 31 +++ .../(dashboard)/scorekeeper/graphql/types.ts | 34 ++++ .../(dashboard)/scorekeeper/page.tsx | 9 +- .../components/audience-display-context.tsx | 34 +++- .../components/awards-display.tsx | 33 +++ .../components/awards/graphql/index.ts | 2 + .../components/awards/graphql/query.ts | 47 +++++ .../components/awards/graphql/types.ts | 49 +++++ .../components/awards/slides-builder.ts | 98 +++++++++ .../components/awards/slides-builder.tsx | 98 +++++++++ .../awards/slides/advancing-teams-slide.tsx | 41 ++++ .../awards/slides/award-winner-slide.tsx | 66 ++++++ .../components/awards/slides/title-slide.tsx | 23 +++ .../audience-display/graphql/query.ts | 31 +++ .../audience-display/graphql/types.ts | 34 ++++ .../(volunteer)/audience-display/page.tsx | 31 ++- .../src/lib/components/slide.tsx | 99 ++++----- .../src/lib/hooks/use-deck-state.tsx | 53 +++-- .../src/lib/hooks/use-dimensions.ts | 24 +-- .../presentations/src/lib/hooks/use-steps.tsx | 25 +-- 24 files changed, 1077 insertions(+), 132 deletions(-) create mode 100644 apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/scorekeeper/components/awards-presentation-wrapper.tsx create mode 100644 apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/scorekeeper/components/presentation-controller.tsx create mode 100644 apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/components/awards-display.tsx create mode 100644 apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/components/awards/graphql/index.ts create mode 100644 apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/components/awards/graphql/query.ts create mode 100644 apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/components/awards/graphql/types.ts create mode 100644 apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/components/awards/slides-builder.ts create mode 100644 apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/components/awards/slides-builder.tsx create mode 100644 apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/components/awards/slides/advancing-teams-slide.tsx create mode 100644 apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/components/awards/slides/award-winner-slide.tsx create mode 100644 apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/components/awards/slides/title-slide.tsx diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/scorekeeper/components/audience-display-control.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/scorekeeper/components/audience-display-control.tsx index 15c07c313..b77793288 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/scorekeeper/components/audience-display-control.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/scorekeeper/components/audience-display-control.tsx @@ -126,7 +126,7 @@ export function AudienceDisplayControl() { {t('modes.message')} - + {t('modes.awards')} diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/scorekeeper/components/audience-display-settings-modal.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/scorekeeper/components/audience-display-settings-modal.tsx index 30c85f54d..7f1e0b54b 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/scorekeeper/components/audience-display-settings-modal.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/scorekeeper/components/audience-display-settings-modal.tsx @@ -13,7 +13,11 @@ import { FormControlLabel, Switch, Box, - Typography + Typography, + Select, + MenuItem, + FormControl, + InputLabel } from '@mui/material'; import toast from 'react-hot-toast'; import { useMutation } from '@apollo/client/react'; @@ -35,6 +39,11 @@ export function AudienceDisplaySettingsModal({ open, onClose }: AudienceDisplayS (audienceDisplay?.settings?.message?.value as string) || '' ); + const [awardWinnerSlideStyle, setAwardWinnerSlideStyle] = useState<'chroma' | 'full' | 'both'>( + (audienceDisplay?.settings?.awards?.awardWinnerSlideStyle as 'chroma' | 'full' | 'both') || + 'both' + ); + const [updateAudienceDisplaySetting] = useMutation(UPDATE_AUDIENCE_DISPLAY_SETTING_MUTATION, { onError: () => { toast.error(t('errors.update-failed')); @@ -81,6 +90,18 @@ export function AudienceDisplaySettingsModal({ open, onClose }: AudienceDisplayS } }); + const handleAwardWinnerSlideStyleChange = async (style: 'chroma' | 'full' | 'both') => { + setAwardWinnerSlideStyle(style); + await updateAudienceDisplaySetting({ + variables: { + divisionId: currentDivision.id, + display: 'awards', + settingKey: 'awardWinnerSlideStyle', + settingValue: style + } + }); + }; + return ( {t('title')} @@ -149,6 +170,29 @@ export function AudienceDisplaySettingsModal({ open, onClose }: AudienceDisplayS /> + + + + {t('sections.awards')} + + + {t('fields.award-winner-slide-style')} + + + diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/scorekeeper/components/awards-presentation-wrapper.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/scorekeeper/components/awards-presentation-wrapper.tsx new file mode 100644 index 000000000..0beca8c96 --- /dev/null +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/scorekeeper/components/awards-presentation-wrapper.tsx @@ -0,0 +1,107 @@ +'use client'; + +import React, { useRef, useCallback, useMemo } from 'react'; +import { Stack } from '@mui/material'; +import { DeckRef } from '@lems/presentations'; +import toast from 'react-hot-toast'; +import { useMutation } from '@apollo/client/react'; +import { AwardsDisplay } from '../../../audience-display/components/awards-display'; +import { UPDATE_AUDIENCE_DISPLAY_SETTING_MUTATION } from '../graphql'; +import { useEvent } from '../../../components/event-context'; +import { useScorekeeperData } from './scorekeeper-context'; +import { PresentationController } from './presentation-controller'; + +interface Award { + id: string; + name: string; + index: number; + place: number; + type: string; + isOptional: boolean; + winner?: { + id: string; + name?: string; + number?: number; + affiliation?: { id: string; name: string; city: string }; + }; +} + +interface ScorekeeperContextData { + field: { + audienceDisplay?: { + settings?: { + awards?: { + awardWinnerSlideStyle?: 'chroma' | 'full' | 'both'; + presentationState?: { + slideIndex: number; + stepIndex: number; + }; + }; + }; + }; + judging?: { + awards: Award[]; + }; + }; +} + +export function AwardsPresentationWrapper() { + const deckRef = useRef(null); + const { currentDivision } = useEvent(); + const data = useScorekeeperData() as unknown as ScorekeeperContextData; + const field = data?.field; + + const [updateAudienceDisplaySetting] = useMutation(UPDATE_AUDIENCE_DISPLAY_SETTING_MUTATION, { + onError: () => { + toast.error('Failed to update presentation state'); + } + }); + + const awardWinnerSlideStyle = + field?.audienceDisplay?.settings?.awards?.awardWinnerSlideStyle || 'both'; + const presentationState = field?.audienceDisplay?.settings?.awards?.presentationState || { + slideIndex: 0, + stepIndex: 0 + }; + + const awards = useMemo(() => field?.judging?.awards ?? [], [field?.judging?.awards]); + + // Calculate total slides: title + awards grouped by index + const totalSlides = useMemo(() => { + if (!awards.length) return 0; + const uniqueIndices = new Set(awards.map((a: Award) => a.index)); + return 1 + uniqueIndices.size; // title slide + one per award index + }, [awards]); + + const handlePresentationStateChange = useCallback( + (slideIndex: number, stepIndex: number) => { + updateAudienceDisplaySetting({ + variables: { + divisionId: currentDivision.id, + display: 'awards', + settingKey: 'presentationState', + settingValue: { slideIndex, stepIndex } + } + }); + }, + [updateAudienceDisplaySetting, currentDivision.id] + ); + + return ( + +
+ +
+ +
+ ); +} diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/scorekeeper/components/presentation-controller.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/scorekeeper/components/presentation-controller.tsx new file mode 100644 index 000000000..664f1c663 --- /dev/null +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/scorekeeper/components/presentation-controller.tsx @@ -0,0 +1,192 @@ +import React, { useState, useEffect } from 'react'; +import { DeckRef } from '@lems/presentations'; +import { Button } from '@mui/material'; +import { + ChevronLeft as ChevronLeftIcon, + ChevronRight as ChevronRightIcon, + SkipNext as SkipNextIcon, + SkipPrevious as SkipPreviousIcon, + Home as HomeIcon, + GetApp as GetAppIcon +} from '@mui/icons-material'; + +interface PresentationControllerProps { + deckRef: React.RefObject; + onPresentationStateChange: (slideIndex: number, stepIndex: number) => void; + totalSlides?: number; +} + +export const PresentationController: React.FC = ({ + deckRef, + onPresentationStateChange, + totalSlides = 0 +}) => { + const [slideInfo, setSlideInfo] = useState({ slideIndex: 0, stepIndex: 0 }); + + useEffect(() => { + // Update slide info whenever ref changes or totalSlides changes + if (deckRef.current?.activeView) { + setSlideInfo({ + slideIndex: deckRef.current.activeView.slideIndex, + stepIndex: deckRef.current.activeView.stepIndex + }); + } + }, [deckRef, totalSlides]); + + const getCurrentState = () => ({ + slideCount: totalSlides, + slideIndex: slideInfo.slideIndex, + stepIndex: slideInfo.stepIndex + }); + + const current = getCurrentState(); + + const updateSlideInfo = () => { + if (deckRef.current?.activeView) { + setSlideInfo({ + slideIndex: deckRef.current.activeView.slideIndex, + stepIndex: deckRef.current.activeView.stepIndex + }); + } + }; + + const handlePreviousSlide = () => { + deckRef.current?.regressSlide(); + setTimeout(() => { + updateSlideInfo(); + if (deckRef.current?.activeView) { + onPresentationStateChange( + deckRef.current.activeView.slideIndex, + deckRef.current.activeView.stepIndex + ); + } + }, 0); + }; + + const handleNextSlide = () => { + deckRef.current?.advanceSlide(); + setTimeout(() => { + updateSlideInfo(); + if (deckRef.current?.activeView) { + onPresentationStateChange( + deckRef.current.activeView.slideIndex, + deckRef.current.activeView.stepIndex + ); + } + }, 0); + }; + + const handlePreviousStep = () => { + deckRef.current?.stepBackward(); + setTimeout(() => { + updateSlideInfo(); + if (deckRef.current?.activeView) { + onPresentationStateChange( + deckRef.current.activeView.slideIndex, + deckRef.current.activeView.stepIndex + ); + } + }, 0); + }; + + const handleNextStep = () => { + deckRef.current?.stepForward(); + setTimeout(() => { + updateSlideInfo(); + if (deckRef.current?.activeView) { + onPresentationStateChange( + deckRef.current.activeView.slideIndex, + deckRef.current.activeView.stepIndex + ); + } + }, 0); + }; + + const handleHome = () => { + deckRef.current?.skipTo({ slideIndex: 0, stepIndex: 0 }); + setTimeout(() => { + setSlideInfo({ slideIndex: 0, stepIndex: 0 }); + onPresentationStateChange(0, 0); + }, 0); + }; + + const handleEnd = () => { + const lastSlide = Math.max(0, current.slideCount - 1); + deckRef.current?.skipTo({ slideIndex: lastSlide, stepIndex: 0 }); + setTimeout(() => { + setSlideInfo({ slideIndex: lastSlide, stepIndex: 0 }); + onPresentationStateChange(lastSlide, 0); + }, 0); + }; + + return ( +
+
+
+ Slide: {current.slideIndex + 1} / {current.slideCount} +
+
Step: {current.stepIndex}
+
+ +
+ + + + +
+ +
+ + +
+
+ ); +}; diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/scorekeeper/graphql/query.ts b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/scorekeeper/graphql/query.ts index c2aef3f85..5ac0cbdbc 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/scorekeeper/graphql/query.ts +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/(dashboard)/scorekeeper/graphql/query.ts @@ -43,6 +43,37 @@ export const GET_SCOREKEEPER_DATA: TypedDocumentNode @@ -101,9 +104,11 @@ export default function ScorekeeperPage() { - {t('current-match.section-title')} + {isAwardsMode + ? t('awards-presentation.title') + : t('current-match.section-title')} - + {isAwardsMode ? : } diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/components/audience-display-context.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/components/audience-display-context.tsx index c2c88e4ff..4e5a24c5f 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/components/audience-display-context.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/components/audience-display-context.tsx @@ -1,17 +1,31 @@ 'use client'; import { createContext, useContext, ReactNode } from 'react'; -import { AudienceDisplayState } from '../graphql'; +import { AudienceDisplayState, Award } from '../graphql'; -const AudienceDisplayContext = createContext(null); +interface AudienceDisplayContextData { + displayState: AudienceDisplayState; + awards: Award[]; +} + +const AudienceDisplayContext = createContext(null); interface AudienceDisplayProviderProps { - data: AudienceDisplayState; + displayState: AudienceDisplayState; + awards?: Award[]; children?: ReactNode; } -export function AudienceDisplayProvider({ data, children }: AudienceDisplayProviderProps) { - return {children}; +export function AudienceDisplayProvider({ + displayState, + awards = [], + children +}: AudienceDisplayProviderProps) { + return ( + + {children} + + ); } export function useAudienceDisplayData(): AudienceDisplayState { @@ -19,5 +33,13 @@ export function useAudienceDisplayData(): AudienceDisplayState { if (!context) { throw new Error('useAudienceDisplayData must be used within a AudienceDisplayProvider'); } - return context; + return context.displayState; +} + +export function useAwards(): Award[] { + const context = useContext(AudienceDisplayContext); + if (!context) { + throw new Error('useAwards must be used within a AudienceDisplayProvider'); + } + return context.awards; } diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/components/awards-display.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/components/awards-display.tsx new file mode 100644 index 000000000..93921f42d --- /dev/null +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/components/awards-display.tsx @@ -0,0 +1,33 @@ +import React, { useMemo, forwardRef } from 'react'; +import { Deck, DeckRef } from '@lems/presentations'; +import { Award } from './graphql/types'; +import { buildAwardsSlides, AwardWinnerSlideStyle } from './slides-builder'; +import { TitleSlide } from './slides/title-slide'; + +export interface AwardsDisplayProps { + awards: Award[]; + awardWinnerSlideStyle?: AwardWinnerSlideStyle; + presentationState?: { slideIndex: number; stepIndex: number }; +} + +export const AwardsDisplay = forwardRef( + ( + { awards, awardWinnerSlideStyle = 'both', presentationState = { slideIndex: 0, stepIndex: 0 } }, + ref + ) => { + const awardSlides = useMemo( + () => buildAwardsSlides(awards, awardWinnerSlideStyle), + [awards, awardWinnerSlideStyle] + ); + + return ( + + + {awardSlides} + + + ); + } +); + +AwardsDisplay.displayName = 'AwardsDisplay'; diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/components/awards/graphql/index.ts b/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/components/awards/graphql/index.ts new file mode 100644 index 000000000..7f281c34c --- /dev/null +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/components/awards/graphql/index.ts @@ -0,0 +1,2 @@ +export { GET_AWARDS_DATA, parseAwardsData } from './query'; +export type { AwardsData, AwardsVars, Award, TeamWinner, PersonalWinner } from './types'; diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/components/awards/graphql/query.ts b/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/components/awards/graphql/query.ts new file mode 100644 index 000000000..def180ebf --- /dev/null +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/components/awards/graphql/query.ts @@ -0,0 +1,47 @@ +import { gql, TypedDocumentNode } from '@apollo/client'; +import { AwardsData, AwardsVars } from './types'; + +export const GET_AWARDS_DATA: TypedDocumentNode = gql` + query GetAwardsData($divisionId: String!) { + division(id: $divisionId) { + id + field { + judging { + awards { + id + name + index + place + type + isOptional + winner { + ... on TeamWinner { + id + name + number + affiliation { + id + name + city + } + } + ... on PersonalWinner { + id + name + team { + id + number + name + } + } + } + } + } + } + } + } +`; + +export function parseAwardsData(data: AwardsData) { + return data.division.field.judging?.awards ?? []; +} diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/components/awards/graphql/types.ts b/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/components/awards/graphql/types.ts new file mode 100644 index 000000000..8d972ae6b --- /dev/null +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/components/awards/graphql/types.ts @@ -0,0 +1,49 @@ +export interface TeamWinner { + id: string; + name: string; + number: number; + affiliation: { + id: string; + name: string; + city: string; + } | null; +} + +export interface PersonalWinner { + id: string; + name: string; + team: { + id: string; + number: number; + name: string; + }; +} + +export interface Award { + id: string; + name: string; + index: number; + place: number; + type: 'PERSONAL' | 'TEAM'; + isOptional: boolean; + winner?: TeamWinner | PersonalWinner | null; +} + +export interface AwardsData { + division: { + id: string; + field: { + judging: { + awards: Award[]; + } | null; + }; + }; +} + +export interface AwardsVars { + divisionId: string; +} + +export function parseAwardsData(data: AwardsData) { + return data.division.field.judging?.awards ?? []; +} diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/components/awards/slides-builder.ts b/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/components/awards/slides-builder.ts new file mode 100644 index 000000000..167d7b94d --- /dev/null +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/components/awards/slides-builder.ts @@ -0,0 +1,98 @@ +import React from 'react'; +import { Award } from './graphql/types'; +import { TitleSlide } from './slides/title-slide'; +import { AwardWinnerSlide } from './slides/award-winner-slide'; +import { AdvancingTeamsSlide } from './slides/advancing-teams-slide'; + +export type AwardWinnerSlideStyle = 'chroma' | 'full' | 'both'; + +export function buildAwardsSlides( + awards: Award[], + style: AwardWinnerSlideStyle = 'both' +): React.ReactNode[] { + if (!awards || awards.length === 0) { + return []; + } + + const slides: React.ReactNode[] = []; + + // Group awards by index + const awardsByIndex = new Map(); + const advancingAwards: Award[] = []; + + awards.forEach(award => { + if (award.name === 'advancement') { + advancingAwards.push(award); + } else if (award.index >= 0) { + if (!awardsByIndex.has(award.index)) { + awardsByIndex.set(award.index, []); + } + awardsByIndex.get(award.index)!.push(award); + } + }); + + // Sort indices + const sortedIndices = Array.from(awardsByIndex.keys()).sort((a, b) => a - b); + + // Build slides for each award + sortedIndices.forEach(index => { + const awardGroup = awardsByIndex.get(index)!; + if (awardGroup.length === 0) return; + + const firstAward = awardGroup[0]; + const showPlace = awardGroup.length > 1; + + // Add title slide + slides.push( + React.createElement(TitleSlide, { + key: `title-${index}`, + primary: `פרס ${firstAward.name}`, + secondary: 'מיוחד לצוותים במדגם' + }) + ); + + // Add winner slides based on style + awardGroup.forEach(award => { + const awardWithPlace = { ...award, place: showPlace ? award.place : 0 }; + + if (['chroma', 'both'].includes(style)) { + slides.push( + React.createElement(AwardWinnerSlide, { + key: `chroma-${award.id}`, + award: awardWithPlace, + chromaKey: true + }) + ); + } + + if (['full', 'both'].includes(style)) { + slides.push( + React.createElement(AwardWinnerSlide, { + key: `full-${award.id}`, + award: awardWithPlace, + chromaKey: false + }) + ); + } + }); + }); + + // Add advancing teams slide before champions if advancing teams exist + if (advancingAwards.length > 0) { + const championsIndex = slides.findIndex( + slide => + React.isValidElement(slide) && typeof slide.key === 'string' && slide.key.includes('title') + ); + const advancingSlide = React.createElement(AdvancingTeamsSlide, { + key: 'advancing-teams', + awards: advancingAwards + }); + if (championsIndex >= 0) { + slides.splice(championsIndex, 0, advancingSlide); + } else { + slides.push(advancingSlide); + } + } + + return slides; +} diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/components/awards/slides-builder.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/components/awards/slides-builder.tsx new file mode 100644 index 000000000..167d7b94d --- /dev/null +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/components/awards/slides-builder.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import { Award } from './graphql/types'; +import { TitleSlide } from './slides/title-slide'; +import { AwardWinnerSlide } from './slides/award-winner-slide'; +import { AdvancingTeamsSlide } from './slides/advancing-teams-slide'; + +export type AwardWinnerSlideStyle = 'chroma' | 'full' | 'both'; + +export function buildAwardsSlides( + awards: Award[], + style: AwardWinnerSlideStyle = 'both' +): React.ReactNode[] { + if (!awards || awards.length === 0) { + return []; + } + + const slides: React.ReactNode[] = []; + + // Group awards by index + const awardsByIndex = new Map(); + const advancingAwards: Award[] = []; + + awards.forEach(award => { + if (award.name === 'advancement') { + advancingAwards.push(award); + } else if (award.index >= 0) { + if (!awardsByIndex.has(award.index)) { + awardsByIndex.set(award.index, []); + } + awardsByIndex.get(award.index)!.push(award); + } + }); + + // Sort indices + const sortedIndices = Array.from(awardsByIndex.keys()).sort((a, b) => a - b); + + // Build slides for each award + sortedIndices.forEach(index => { + const awardGroup = awardsByIndex.get(index)!; + if (awardGroup.length === 0) return; + + const firstAward = awardGroup[0]; + const showPlace = awardGroup.length > 1; + + // Add title slide + slides.push( + React.createElement(TitleSlide, { + key: `title-${index}`, + primary: `פרס ${firstAward.name}`, + secondary: 'מיוחד לצוותים במדגם' + }) + ); + + // Add winner slides based on style + awardGroup.forEach(award => { + const awardWithPlace = { ...award, place: showPlace ? award.place : 0 }; + + if (['chroma', 'both'].includes(style)) { + slides.push( + React.createElement(AwardWinnerSlide, { + key: `chroma-${award.id}`, + award: awardWithPlace, + chromaKey: true + }) + ); + } + + if (['full', 'both'].includes(style)) { + slides.push( + React.createElement(AwardWinnerSlide, { + key: `full-${award.id}`, + award: awardWithPlace, + chromaKey: false + }) + ); + } + }); + }); + + // Add advancing teams slide before champions if advancing teams exist + if (advancingAwards.length > 0) { + const championsIndex = slides.findIndex( + slide => + React.isValidElement(slide) && typeof slide.key === 'string' && slide.key.includes('title') + ); + const advancingSlide = React.createElement(AdvancingTeamsSlide, { + key: 'advancing-teams', + awards: advancingAwards + }); + if (championsIndex >= 0) { + slides.splice(championsIndex, 0, advancingSlide); + } else { + slides.push(advancingSlide); + } + } + + return slides; +} diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/components/awards/slides/advancing-teams-slide.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/components/awards/slides/advancing-teams-slide.tsx new file mode 100644 index 000000000..9a0b69c2a --- /dev/null +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/components/awards/slides/advancing-teams-slide.tsx @@ -0,0 +1,41 @@ +import { Slide, Stepper } from '@lems/presentations'; +import { Award, TeamWinner } from '../graphql/types'; + +interface AdvancingTeamsSlideProps { + awards: Award[]; +} + +export const AdvancingTeamsSlide: React.FC = ({ awards }) => { + const teams: TeamWinner[] = awards + .filter(award => award.winner && 'number' in award.winner) + .map(award => award.winner as TeamWinner); + + if (teams.length === 0) { + return null; + } + + return ( + +
+

קבוצות מתקדמות

+
+ { + const teamData = team as TeamWinner; + return ( +
+

#{teamData.number}

+

{teamData.name}

+
+ ); + }} + /> +
+
+
+ ); +}; diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/components/awards/slides/award-winner-slide.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/components/awards/slides/award-winner-slide.tsx new file mode 100644 index 000000000..b911db37f --- /dev/null +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/components/awards/slides/award-winner-slide.tsx @@ -0,0 +1,66 @@ +import { Slide, Appear } from '@lems/presentations'; +import { Award, TeamWinner, PersonalWinner } from '../graphql/types'; + +interface AwardWinnerSlideProps { + award: Award; + chromaKey?: boolean; +} + +export const AwardWinnerSlide: React.FC = ({ award, chromaKey = false }) => { + if (!award.winner) { + return null; + } + + const isTeamWinner = 'number' in award.winner; + const winner = award.winner as TeamWinner | PersonalWinner; + + return ( + +
+
+ +
+

פרס {award.name}

+ {award.place && award.place > 0 && ( +

מקום {award.place}

+ )} +
+
+ +
+ +
+ {isTeamWinner && 'number' in winner ? ( + <> +

+ #{(winner as TeamWinner).number} {winner.name} +

+ {(winner as TeamWinner).affiliation && ( +

+ {(winner as TeamWinner).affiliation?.name},{' '} + {(winner as TeamWinner).affiliation?.city} +

+ )} + + ) : ( + <> +

{winner.name}

+

+ {(winner as PersonalWinner).team?.name} (# + {(winner as PersonalWinner).team?.number}) +

+ + )} +
+
+
+
+
+
+ ); +}; diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/components/awards/slides/title-slide.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/components/awards/slides/title-slide.tsx new file mode 100644 index 000000000..ad38937e3 --- /dev/null +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/components/awards/slides/title-slide.tsx @@ -0,0 +1,23 @@ +import { Slide, Appear } from '@lems/presentations'; + +interface TitleSlideProps { + primary: string; + secondary?: string; +} + +export const TitleSlide: React.FC = ({ primary, secondary }) => { + return ( + +
+ +

{primary}

+
+ {secondary && ( + +

{secondary}

+
+ )} +
+
+ ); +}; diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/graphql/query.ts b/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/graphql/query.ts index 74fcc40ec..624372c52 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/graphql/query.ts +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/graphql/query.ts @@ -11,6 +11,37 @@ export const GET_AUDIENCE_DISPLAY_DATA = gql` activeDisplay settings } + judging { + awards { + id + name + index + place + type + isOptional + winner { + ... on TeamWinner { + id + name + number + affiliation { + id + name + city + } + } + ... on PersonalWinner { + id + name + team { + id + number + name + } + } + } + } + } } } } diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/graphql/types.ts b/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/graphql/types.ts index 7371c4440..dd317c7ea 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/graphql/types.ts +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/graphql/types.ts @@ -11,11 +11,45 @@ export interface AudienceDisplayState { settings?: Record>; } +export interface TeamWinner { + id: string; + name: string; + number: number; + affiliation: { + id: string; + name: string; + city: string; + } | null; +} + +export interface PersonalWinner { + id: string; + name: string; + team: { + id: string; + number: number; + name: string; + }; +} + +export interface Award { + id: string; + name: string; + index: number; + place: number; + type: 'PERSONAL' | 'TEAM'; + isOptional: boolean; + winner?: TeamWinner | PersonalWinner | null; +} + export interface AudienceDisplayData { division: { id: string; field: { audienceDisplay: AudienceDisplayState | null; + judging: { + awards: Award[]; + } | null; }; }; } diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/page.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/page.tsx index 00175b026..65d4c7d9b 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/page.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/page.tsx @@ -12,12 +12,19 @@ import { MessageDisplay } from './components/message-display'; import { SponsorsDisplay } from './components/sponsors-display'; import { MatchPreviewDisplay } from './components/match-preview/match-preview-display'; import { ScoreboardDisplay } from './components/scoreboard/scoreboard-display'; +import { AwardsDisplay } from './components/awards-display'; import { createAudienceDisplaySettingUpdatedSubscription, createAudienceDisplaySwitchedSubscription, GET_AUDIENCE_DISPLAY_DATA, parseAudienceDisplayData } from './graphql'; +import type { AudienceDisplayState, Award } from './graphql'; + +interface ParsedAudienceDisplayData { + displayState: AudienceDisplayState; + awards: Award[]; +} export default function AudienceDisplayPage() { const { currentDivision } = useEvent(); @@ -51,7 +58,11 @@ export default function AudienceDisplayPage() { { divisionId: currentDivision.id }, - parseAudienceDisplayData, + (rawData): ParsedAudienceDisplayData => { + const displayState = parseAudienceDisplayData(rawData); + const awards = rawData.division.field.judging?.awards ?? []; + return { displayState, awards }; + }, subscriptions ); @@ -63,15 +74,29 @@ export default function AudienceDisplayPage() { return null; } - const activeDisplay = data.activeDisplay; + const activeDisplay = data.displayState.activeDisplay; + const awardWinnerSlideStyle = + (data.displayState.settings?.awards?.awardWinnerSlideStyle as 'chroma' | 'full' | 'both') || + 'both'; + const presentationState = (data.displayState.settings?.awards?.presentationState as { + slideIndex: number; + stepIndex: number; + }) || { slideIndex: 0, stepIndex: 0 }; return ( - + {activeDisplay === 'logo' && } {activeDisplay === 'message' && } {activeDisplay === 'sponsors' && } {activeDisplay === 'match_preview' && } {activeDisplay === 'scoreboard' && } + {activeDisplay === 'awards' && ( + + )} ); } diff --git a/libs/presentations/src/lib/components/slide.tsx b/libs/presentations/src/lib/components/slide.tsx index 2a39605cd..0272e98e6 100644 --- a/libs/presentations/src/lib/components/slide.tsx +++ b/libs/presentations/src/lib/components/slide.tsx @@ -34,15 +34,12 @@ export const Slide: React.FC = ({ const immediate = false; const { - slideCount, slidePortalNode, slideIds, activeView, pendingView, advanceSlide, regressSlide, - skipTo, - navigationDirection, commitTransition, cancelTransition } = useContext(DeckContext); @@ -60,68 +57,56 @@ export const Slide: React.FC = ({ const infinityDirection = slideIndex < activeView.slideIndex ? Infinity : -Infinity; const internalStepIndex = isActive ? activeView.stepIndex : infinityDirection; + // Handle navigation transitions: step changes on active slide, slide enter/exit useEffect(() => { - if (!isActive) return; - if (!stepWillChange) return; - if (slideWillChange) return; + // Case 1: Exiting to non-existent slide - cancel + if (willExit && pendingView.slideId === undefined) { + cancelTransition(); + return; + } + + // Case 2: Entering a new slide - validate and commit step bounds + if (willEnter && finalStepIndex !== undefined) { + const step = pendingView.stepIndex; + if (step < 0) { + commitTransition({ stepIndex: 0 }); + } else if (step === GOTO_FINAL_STEP || step > finalStepIndex) { + // GOTO_FINAL_STEP is a sentinel for "go to last step of this slide" + commitTransition({ stepIndex: finalStepIndex }); + } else { + commitTransition(); + } + return; + } - if (pendingView.stepIndex < 0) { - regressSlide(); - } else if (pendingView.stepIndex > finalStepIndex) { - advanceSlide(); - } else if (pendingView.stepIndex === GOTO_FINAL_STEP) { - commitTransition({ - stepIndex: finalStepIndex - }); - } else { - commitTransition(); + // Case 3: Step change on active slide - validate bounds + if (isActive && stepWillChange && !slideWillChange) { + const step = pendingView.stepIndex; + if (step < 0) { + regressSlide(); + } else if (step > finalStepIndex) { + advanceSlide(); + } else if (step === GOTO_FINAL_STEP) { + commitTransition({ stepIndex: finalStepIndex }); + } else { + commitTransition(); + } } }, [ - activeView, - advanceSlide, - commitTransition, - finalStepIndex, - navigationDirection, isActive, - pendingView, - regressSlide, - skipTo, - slideCount, + willEnter, + willExit, + stepWillChange, slideWillChange, - stepWillChange + pendingView.slideId, + pendingView.stepIndex, + finalStepIndex, + advanceSlide, + regressSlide, + commitTransition, + cancelTransition ]); - // Bounds checking for slides in the presentation. - useEffect(() => { - if (!willExit) return; - if (pendingView.slideId === undefined) cancelTransition(); - }, [willExit, cancelTransition, pendingView.slideId]); - - useEffect(() => { - if (!willEnter) return; - if (finalStepIndex === undefined) return; - - if (pendingView.stepIndex < 0) { - commitTransition({ - stepIndex: 0 - }); - } else if (pendingView.stepIndex === GOTO_FINAL_STEP) { - // Because elements enumerate their own steps, nobody else - // actually knows how many steps are in a slide. So other slides put a - // value of GOTO_FINAL_STEP in the step index to indicate that the slide - // should fill in the correct finalStepIndex before we commit the change. - commitTransition({ - stepIndex: finalStepIndex - }); - } else if (pendingView.stepIndex > finalStepIndex) { - commitTransition({ - stepIndex: finalStepIndex - }); - } else { - commitTransition(); - } - }, [activeView, commitTransition, finalStepIndex, navigationDirection, pendingView, willEnter]); - return ( <> {placeholder} diff --git a/libs/presentations/src/lib/hooks/use-deck-state.tsx b/libs/presentations/src/lib/hooks/use-deck-state.tsx index 38d8b4d8a..0e581d1f2 100644 --- a/libs/presentations/src/lib/hooks/use-deck-state.tsx +++ b/libs/presentations/src/lib/hooks/use-deck-state.tsx @@ -1,9 +1,8 @@ import { useReducer, useMemo } from 'react'; -import { merge } from 'merge-anything'; import { SlideId } from '../components/deck'; import clamp from '../utils/clamp'; -export const GOTO_FINAL_STEP = null as unknown as number; +export const GOTO_FINAL_STEP = -Infinity; export type DeckView = { slideId?: SlideId; @@ -45,67 +44,61 @@ function deckReducer(state: DeckState, { type, payload = {} }: ReducerActions) { case 'INITIALIZE_TO': return { navigationDirection: 0, - activeView: merge(state.activeView, payload), - pendingView: merge(state.pendingView, payload), + activeView: { ...state.activeView, ...payload }, + pendingView: { ...state.pendingView, ...payload }, initialized: true }; - case 'SKIP_TO': - // eslint-disable-next-line no-case-declarations - const navigationDirection = (() => { - if ('slideIndex' in payload && payload.slideIndex) { - return clamp(payload.slideIndex - state.activeView.slideIndex, -1, 1); - } - return null; - })(); + case 'SKIP_TO': { + const navDir = + 'slideIndex' in payload && payload.slideIndex + ? clamp(payload.slideIndex - state.activeView.slideIndex, -1, 1) + : state.navigationDirection; return { ...state, - navigationDirection: navigationDirection || state.navigationDirection, - pendingView: merge(state.pendingView, payload) + navigationDirection: navDir, + pendingView: { ...state.pendingView, ...payload } }; + } case 'STEP_FORWARD': return { ...state, navigationDirection: 1, - pendingView: merge(state.pendingView, { - stepIndex: state.pendingView.stepIndex + 1 - }) + pendingView: { ...state.pendingView, stepIndex: state.pendingView.stepIndex + 1 } }; case 'STEP_BACKWARD': return { ...state, navigationDirection: -1, - pendingView: merge(state.pendingView, { - stepIndex: state.pendingView.stepIndex - 1 - }) + pendingView: { ...state.pendingView, stepIndex: state.pendingView.stepIndex - 1 } }; case 'ADVANCE_SLIDE': return { ...state, navigationDirection: 1, - pendingView: merge(state.pendingView, { - stepIndex: 0, - slideIndex: state.pendingView.slideIndex + 1 - }) + pendingView: { ...state.pendingView, stepIndex: 0, slideIndex: state.pendingView.slideIndex + 1 } }; case 'REGRESS_SLIDE': return { ...state, navigationDirection: -1, - pendingView: merge(state.pendingView, { + pendingView: { + ...state.pendingView, stepIndex: payload?.stepIndex ?? GOTO_FINAL_STEP, slideIndex: state.pendingView.slideIndex - 1 - }) + } }; - case 'COMMIT_TRANSITION': + case 'COMMIT_TRANSITION': { + const newPendingView = { ...state.pendingView, ...payload }; return { ...state, - pendingView: merge(state.pendingView, payload), - activeView: merge(state.activeView, merge(state.pendingView, payload)) + pendingView: newPendingView, + activeView: { ...state.activeView, ...newPendingView } }; + } case 'CANCEL_TRANSITION': return { ...state, - pendingView: merge(state.pendingView, state.activeView) + pendingView: { ...state.pendingView, ...state.activeView } }; default: return state; diff --git a/libs/presentations/src/lib/hooks/use-dimensions.ts b/libs/presentations/src/lib/hooks/use-dimensions.ts index 699dc42fe..fc11eea7e 100644 --- a/libs/presentations/src/lib/hooks/use-dimensions.ts +++ b/libs/presentations/src/lib/hooks/use-dimensions.ts @@ -1,20 +1,20 @@ -import { useMemo, useSyncExternalStore } from 'react'; +import { useSyncExternalStore } from 'react'; const subscribe = (callback: () => void) => { window.addEventListener('resize', callback); - return () => { - window.removeEventListener('resize', callback); - }; + return () => window.removeEventListener('resize', callback); }; -function useDimensions(ref: React.RefObject) { - const dimensions = useSyncExternalStore(subscribe, () => - JSON.stringify({ - width: ref.current?.offsetWidth ?? 0, // 0 is default width - height: ref.current?.offsetHeight ?? 0 // 0 is default height - }) - ); - return useMemo(() => JSON.parse(dimensions), [dimensions]); +function useDimensions(ref: React.RefObject) { + const getSnapshot = () => { + const width = ref.current?.offsetWidth ?? 0; + const height = ref.current?.offsetHeight ?? 0; + return `${width},${height}`; + }; + + const dims = useSyncExternalStore(subscribe, getSnapshot); + const [width, height] = dims.split(',').map(Number); + return { width, height }; } export { useDimensions }; diff --git a/libs/presentations/src/lib/hooks/use-steps.tsx b/libs/presentations/src/lib/hooks/use-steps.tsx index 1a6d48be9..d39432e34 100644 --- a/libs/presentations/src/lib/hooks/use-steps.tsx +++ b/libs/presentations/src/lib/hooks/use-steps.tsx @@ -13,11 +13,7 @@ const PLACEHOLDER_CLASS_NAME = 'step-placeholder'; */ export function useSteps( numSteps = 1, - { - id: userProvidedId, - priority, - stepIndex - }: { id?: string | number; priority?: number; stepIndex?: number } = {} + { id: userProvidedId, priority }: { id?: string | number; priority?: number } = {} ) { const id = useId(); const [stepId] = useState(userProvidedId || id); @@ -63,28 +59,17 @@ export function useSteps( `A placeholder ref does not appear to be present in the DOM for stepper element with id '${stepId}'. (Did you forget to render it?)` ); } - }); + }, [stepId]); - const basePlaceholderProps = { + const placeholderProps = { ref: placeholderRef as React.RefObject, className: PLACEHOLDER_CLASS_NAME, style: { display: 'none' } as const, 'data-step-id': stepId, - 'data-step-count': numSteps + 'data-step-count': numSteps, + ...(priority !== undefined && { 'data-priority': priority }) }; - type PlaceholderProps = typeof basePlaceholderProps & { 'data-priority'?: number }; - - let placeholderProps: PlaceholderProps = basePlaceholderProps; - if (priority !== undefined) { - placeholderProps = { ...basePlaceholderProps, 'data-priority': priority }; - } else if (stepIndex !== undefined) { - console.warn( - '`options.stepIndex` option to `useSteps` is deprecated- please use `priority` option instead.' - ); - placeholderProps = { ...basePlaceholderProps, 'data-priority': stepIndex }; - } - return { stepId, isActive, From 8c2d037a301e31bf033b435b65b3649e80267b62 Mon Sep 17 00:00:00 2001 From: johnmeshulam <55348702+johnmeshulam@users.noreply.github.com> Date: Sat, 10 Jan 2026 18:25:40 +0200 Subject: [PATCH 2/2] Cleanup + use awards assigned --- .../audience-display/switch-active-display.ts | 11 +++++++++++ .../components/audience-display-context.tsx | 19 +++++++------------ .../components/awards-display.tsx | 16 +++++++++++++--- .../audience-display/graphql/query.ts | 11 ++++++++--- .../audience-display/graphql/types.ts | 1 + .../(volunteer)/audience-display/page.tsx | 10 ++++++++-- 6 files changed, 48 insertions(+), 20 deletions(-) diff --git a/apps/backend/src/lib/graphql/resolvers/mutations/audience-display/switch-active-display.ts b/apps/backend/src/lib/graphql/resolvers/mutations/audience-display/switch-active-display.ts index b4a00a98c..a4f5781a1 100644 --- a/apps/backend/src/lib/graphql/resolvers/mutations/audience-display/switch-active-display.ts +++ b/apps/backend/src/lib/graphql/resolvers/mutations/audience-display/switch-active-display.ts @@ -29,6 +29,17 @@ export const switchActiveDisplayResolver: GraphQLFieldResolver< try { await authorizeAudienceDisplayAccess(context, divisionId); + // Safety check: prevent switching to awards mode before awards have been assigned + if (newDisplay === 'awards') { + const division = await db.divisions.byId(divisionId).get(); + if (!division?.awards_assigned) { + throw new MutationError( + MutationErrorCode.CONFLICT, + 'Cannot switch to awards display mode before awards have been assigned' + ); + } + } + // Update the division's active display in MongoDB const result = await db.raw.mongo.collection('division_states').findOneAndUpdate( { divisionId }, diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/components/audience-display-context.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/components/audience-display-context.tsx index 4e5a24c5f..c8c272f97 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/components/audience-display-context.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/components/audience-display-context.tsx @@ -6,6 +6,7 @@ import { AudienceDisplayState, Award } from '../graphql'; interface AudienceDisplayContextData { displayState: AudienceDisplayState; awards: Award[]; + awardsAssigned: boolean; } const AudienceDisplayContext = createContext(null); @@ -13,33 +14,27 @@ const AudienceDisplayContext = createContext( interface AudienceDisplayProviderProps { displayState: AudienceDisplayState; awards?: Award[]; + awardsAssigned?: boolean; children?: ReactNode; } export function AudienceDisplayProvider({ displayState, awards = [], + awardsAssigned = false, children }: AudienceDisplayProviderProps) { return ( - + {children} ); } -export function useAudienceDisplayData(): AudienceDisplayState { +export function useAudienceDisplay(): AudienceDisplayContextData { const context = useContext(AudienceDisplayContext); if (!context) { - throw new Error('useAudienceDisplayData must be used within a AudienceDisplayProvider'); + throw new Error('useAudienceDisplay must be used within a AudienceDisplayProvider'); } - return context.displayState; -} - -export function useAwards(): Award[] { - const context = useContext(AudienceDisplayContext); - if (!context) { - throw new Error('useAwards must be used within a AudienceDisplayProvider'); - } - return context.awards; + return context; } diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/components/awards-display.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/components/awards-display.tsx index 93921f42d..f524e83dc 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/components/awards-display.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/components/awards-display.tsx @@ -1,8 +1,9 @@ import React, { useMemo, forwardRef } from 'react'; import { Deck, DeckRef } from '@lems/presentations'; -import { Award } from './graphql/types'; -import { buildAwardsSlides, AwardWinnerSlideStyle } from './slides-builder'; -import { TitleSlide } from './slides/title-slide'; +import { Award } from '../graphql'; +import { buildAwardsSlides, AwardWinnerSlideStyle } from './awards/slides-builder'; +import { TitleSlide } from './awards/slides/title-slide'; +import { useAudienceDisplay } from './audience-display-context'; export interface AwardsDisplayProps { awards: Award[]; @@ -15,11 +16,20 @@ export const AwardsDisplay = forwardRef( { awards, awardWinnerSlideStyle = 'both', presentationState = { slideIndex: 0, stepIndex: 0 } }, ref ) => { + const { awardsAssigned } = useAudienceDisplay(); + const awardSlides = useMemo( () => buildAwardsSlides(awards, awardWinnerSlideStyle), [awards, awardWinnerSlideStyle] ); + if (!awardsAssigned) { + console.warn( + '[AwardsDisplay] Attempted to render awards presentation before awards_assigned flag was set. Rendering nothing.' + ); + return null; + } + return ( diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/graphql/query.ts b/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/graphql/query.ts index 624372c52..8e5958478 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/graphql/query.ts +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/graphql/query.ts @@ -1,10 +1,11 @@ import { gql } from '@apollo/client'; -import { AudienceDisplayData } from './types'; +import { AudienceDisplayData, AudienceDisplayState } from './types'; export const GET_AUDIENCE_DISPLAY_DATA = gql` query GetAudienceDisplayData($divisionId: String!) { division(id: $divisionId) { id + awards_assigned field { divisionId audienceDisplay { @@ -47,6 +48,10 @@ export const GET_AUDIENCE_DISPLAY_DATA = gql` } `; -export function parseAudienceDisplayData(data: AudienceDisplayData) { - return data.division.field.audienceDisplay; +export function parseAudienceDisplayData(data: AudienceDisplayData): AudienceDisplayState { + return ( + data.division.field.audienceDisplay ?? { + activeDisplay: 'logo' + } + ); } diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/graphql/types.ts b/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/graphql/types.ts index dd317c7ea..9b464211a 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/graphql/types.ts +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/graphql/types.ts @@ -45,6 +45,7 @@ export interface Award { export interface AudienceDisplayData { division: { id: string; + awards_assigned: boolean; field: { audienceDisplay: AudienceDisplayState | null; judging: { diff --git a/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/page.tsx b/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/page.tsx index 65d4c7d9b..f0bb4ed48 100644 --- a/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/page.tsx +++ b/apps/frontend/src/app/[locale]/lems/(volunteer)/audience-display/page.tsx @@ -24,6 +24,7 @@ import type { AudienceDisplayState, Award } from './graphql'; interface ParsedAudienceDisplayData { displayState: AudienceDisplayState; awards: Award[]; + awardsAssigned: boolean; } export default function AudienceDisplayPage() { @@ -61,7 +62,8 @@ export default function AudienceDisplayPage() { (rawData): ParsedAudienceDisplayData => { const displayState = parseAudienceDisplayData(rawData); const awards = rawData.division.field.judging?.awards ?? []; - return { displayState, awards }; + const awardsAssigned = rawData.division.awards_assigned; + return { displayState, awards, awardsAssigned }; }, subscriptions ); @@ -84,7 +86,11 @@ export default function AudienceDisplayPage() { }) || { slideIndex: 0, stepIndex: 0 }; return ( - + {activeDisplay === 'logo' && } {activeDisplay === 'message' && } {activeDisplay === 'sponsors' && }