From 335346c94ff043778d35aa2c95839e523fd723e4 Mon Sep 17 00:00:00 2001 From: pablonete Date: Thu, 25 Jun 2026 10:39:21 +0200 Subject: [PATCH 01/20] Fix public kid search and friends Load kid search results on demand, cache favorite kids locally, refresh friend passport progress in a single batch request, update kid gender icons, and stop deploying the public gh-pages demo from main pushes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/frontend-ci.yml | 26 +------ server/api.ts | 39 +++++++++- src/components/FriendPassportView.tsx | 8 +- src/components/FriendStarButton.tsx | 15 +--- src/components/KidFinder.tsx | 29 +++++-- src/components/KidGenderIcon.tsx | 38 ++++++++++ src/components/KidList.tsx | 8 +- src/components/KidsSection.tsx | 56 ++++++++++---- src/contexts/DataLayerContext.tsx | 94 +++++++++++++++++++++-- src/contexts/LocalDataLayerContext.tsx | 101 +++++++++++++++++++++++-- src/data/remote-data-client.ts | 43 ++++++++++- src/i18n/messages.ts | 2 + src/styles.css | 96 +++++++++++------------ 13 files changed, 416 insertions(+), 139 deletions(-) create mode 100644 src/components/KidGenderIcon.tsx diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml index 0f0e5f7..2471c47 100644 --- a/.github/workflows/frontend-ci.yml +++ b/.github/workflows/frontend-ci.yml @@ -2,15 +2,12 @@ name: Frontend CI on: pull_request: - push: - branches: - - main permissions: contents: read concurrency: - group: pages-${{ github.ref }} + group: frontend-ci-${{ github.ref }} cancel-in-progress: true jobs: @@ -34,24 +31,3 @@ jobs: - name: Build run: npm run build - - - name: Upload Pages artifact - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - uses: actions/upload-pages-artifact@v3 - with: - path: dist - - deploy: - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - needs: build - runs-on: ubuntu-latest - permissions: - pages: write - id-token: write - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 diff --git a/server/api.ts b/server/api.ts index 85eda1f..fc6d643 100644 --- a/server/api.ts +++ b/server/api.ts @@ -508,9 +508,23 @@ function parseAdminBackup(body: Record): AdminBackup { } async function handleKids(request: ApiRequest, url: URL): Promise { - void url; - if (request.method === 'GET') { + const searchedKid = url.searchParams.get('kid')?.trim(); + + if (searchedKid) { + const snapshot = await readSnapshot(); + const normalizedKidId = normalizeKidId(searchedKid, snapshot); + const kid = snapshot.kids.find( + (entry) => entry.id.toLowerCase() === normalizedKidId.toLowerCase(), + ); + + if (!kid) { + throw new HttpError(404, `Unknown kid: ${normalizedKidId}`); + } + + return jsonResponse(request, 200, kid); + } + await requireMagicLink(request, url, [...staffRoles]); const snapshot = await readSnapshot(); @@ -646,9 +660,26 @@ async function handlePassport( } if (request.method === 'GET') { - await requireMagicLink(request, url, [...staffRoles]); - const snapshot = await readSnapshot(); + const batchKidIds = url.searchParams + .getAll('kids') + .flatMap((kidIds) => kidIds.split(',')) + .map((kidId) => kidId.trim()) + .filter(Boolean); + + if (batchKidIds.length > 0) { + const passportsByKid = Object.fromEntries( + [...new Set(batchKidIds)] + .map((kidId) => normalizeKidId(kidId, snapshot)) + .map((kidId) => [ + kidId, + passportResponse(snapshot.passportActivitiesByKid, kidId), + ]), + ); + + return jsonResponse(request, 200, passportsByKid); + } + const kidId = normalizeKidId(url.searchParams.get('kid'), snapshot); return jsonResponse( diff --git a/src/components/FriendPassportView.tsx b/src/components/FriendPassportView.tsx index d65c7d5..999b125 100644 --- a/src/components/FriendPassportView.tsx +++ b/src/components/FriendPassportView.tsx @@ -6,6 +6,7 @@ import { } from '../contexts/DataLayerContext'; import { useI18n } from '../i18n/I18nProvider'; import { FriendStarButton } from './FriendStarButton'; +import { KidGenderIcon } from './KidGenderIcon'; import { PassportActivityMosaic } from './PassportActivityMosaic'; import { ProgressCounter } from './ProgressCounter'; @@ -30,10 +31,9 @@ export function FriendPassportView({ kid }: FriendPassportViewProps) {
-

{kid.name}

diff --git a/src/components/FriendStarButton.tsx b/src/components/FriendStarButton.tsx index ab169ed..a91ac5e 100644 --- a/src/components/FriendStarButton.tsx +++ b/src/components/FriendStarButton.tsx @@ -1,8 +1,5 @@ import { StarFillIcon, StarIcon } from '@primer/octicons-react'; -import { - useCurrentUser, - type Kid, -} from '../contexts/DataLayerContext'; +import type { Kid } from '../contexts/DataLayerContext'; import { useIsFriend, useToggleFriend } from '../contexts/LocalDataLayerContext'; import { useI18n } from '../i18n/I18nProvider'; @@ -11,22 +8,14 @@ type FriendStarButtonProps = { }; export function FriendStarButton({ kid }: FriendStarButtonProps) { - const currentUser = useCurrentUser(); const isFriend = useIsFriend(); const toggleFriend = useToggleFriend(); const { t } = useI18n(); - const canToggleFriend = - currentUser.role === 'guest' || - (currentUser.role === 'kid' && currentUser.id !== kid.id); const friendSelected = isFriend(kid.id); const label = friendSelected ? t('friends.star.remove').replace('{name}', kid.name) : t('friends.star.add').replace('{name}', kid.name); - if (!canToggleFriend) { - return null; - } - return ( + ) : null}
{friends.length > 0 ? (
    @@ -55,10 +84,9 @@ export function KidsSection({ type="button" onClick={() => onKidSelected(kid)} > - {kid.name} PrizeAward; conference: ConferenceData; currentUser: CurrentUser; - findKidByManualNumber: (rawSearchValue: string) => Kid | undefined; - findKidByQrIdData: (qrPayload: string) => Kid | undefined; + findKidById: (kidId: string) => Promise; + findKidByManualNumber: (rawSearchValue: string) => Promise; + findKidByQrIdData: (qrPayload: string) => Promise; getPassportForKid: (kidId: string) => PassportData; getWheelShotSummaryForKid: (kidId: string) => WheelShotSummary; kids: Kid[]; @@ -95,6 +98,7 @@ type DataLayerContextValue = { prizes: Prize[]; refreshPrizes: () => Prize[]; reloadPassportActivities: (kidId?: string) => void; + reloadPassportActivitiesForKids: (kidIds: string[]) => Promise; reloadPrizeAwardsForKid: (kidId: string) => void; users: User[]; resetCurrentUser: () => void; @@ -267,6 +271,19 @@ export function DataLayerProvider({ children }: PropsWithChildren) { [kidId]: activities.map((activity) => ({ ...activity })), })); }; + const applyRemotePassports = ( + passportsByKid: Record, + ) => { + setPassportActivitiesByUser((currentPassportActivities) => ({ + ...currentPassportActivities, + ...Object.fromEntries( + Object.entries(passportsByKid).map(([kidId, activities]) => [ + kidId, + activities.map((activity) => ({ ...activity })), + ]), + ), + })); + }; const applyRemotePrizeAwards = (kidId: string, awards: PrizeAward[]) => { setPrizeAwards((currentPrizeAwards) => [ ...currentPrizeAwards.filter((award) => award.kidId !== kidId), @@ -498,21 +515,54 @@ export function DataLayerProvider({ children }: PropsWithChildren) { usedShots, }; }; - const findKidByManualNumber = (rawSearchValue: string) => { + const rememberKid = (kid: Kid) => { + setKidList((currentKids) => mergeKid(currentKids, kid)); + + return kid; + }; + const findRemoteKid = async (rawKid: string) => { + const remoteKid = await fetchRemoteKid(rawKid); + + return remoteKid ? rememberKid(remoteKid) : undefined; + }; + const findKidById = async (kidId: string) => { + const trimmedKidId = kidId.trim(); + + if (!trimmedKidId) { + return undefined; + } + + const knownKid = kidList.find( + (kid) => kid.id.toLowerCase() === trimmedKidId.toLowerCase(), + ); + + if (knownKid) { + return knownKid; + } + + return isRemoteDataLayer ? findRemoteKid(trimmedKidId) : undefined; + }; + const findKidByManualNumber = async (rawSearchValue: string) => { const searchedNumber = Number(rawSearchValue); if (!Number.isInteger(searchedNumber)) { return undefined; } - return kidList.find((kid) => getKidSequenceNumber(kid.id) === searchedNumber); + const knownKid = kidList.find( + (kid) => getKidSequenceNumber(kid.id) === searchedNumber, + ); + + if (knownKid) { + return knownKid; + } + + return isRemoteDataLayer ? findRemoteKid(rawSearchValue) : undefined; }; - const findKidByQrIdData = (qrPayload: string) => { + const findKidByQrIdData = async (qrPayload: string) => { const kidId = parseKidQrPayload(qrPayload); - return kidId - ? kidList.find((kid) => kid.id.toLowerCase() === kidId.toLowerCase()) - : undefined; + return kidId ? findKidById(kidId) : undefined; }; const reloadPassportActivities = (kidId?: string) => { if (isRemoteDataLayer) { @@ -530,6 +580,24 @@ export function DataLayerProvider({ children }: PropsWithChildren) { clonePassportActivities(currentPassportActivities), ); }; + const reloadPassportActivitiesForKids = async (kidIds: string[]) => { + const uniqueKidIds = [...new Set(kidIds.map((kidId) => kidId.trim()))].filter( + Boolean, + ); + + if (uniqueKidIds.length === 0) { + return; + } + + if (isRemoteDataLayer) { + applyRemotePassports(await fetchRemotePassports(uniqueKidIds)); + return; + } + + setPassportActivitiesByUser((currentPassportActivities) => + clonePassportActivities(currentPassportActivities), + ); + }; const reloadPrizeAwardsForKid = (kidId: string) => { if (isRemoteDataLayer) { loadRemotePrizeAwardsForKid(kidId); @@ -767,6 +835,7 @@ export function DataLayerProvider({ children }: PropsWithChildren) { awardPrizeToKid, conference: conferenceJson, currentUser, + findKidById, findKidByManualNumber, findKidByQrIdData, getPassportForKid, @@ -782,6 +851,7 @@ export function DataLayerProvider({ children }: PropsWithChildren) { prizes, refreshPrizes, reloadPassportActivities, + reloadPassportActivitiesForKids, reloadPrizeAwardsForKid, resetCurrentUser, setCurrentUser, @@ -837,6 +907,10 @@ export function useFindKidByQrIdData() { return useDataLayer().findKidByQrIdData; } +export function useFindKidById() { + return useDataLayer().findKidById; +} + export function useKidsData() { return useDataLayer().kids; } @@ -849,6 +923,10 @@ export function useReloadPassportActivities() { return useDataLayer().reloadPassportActivities; } +export function useReloadPassportActivitiesForKids() { + return useDataLayer().reloadPassportActivitiesForKids; +} + export function useGetPassportForKid() { return useDataLayer().getPassportForKid; } diff --git a/src/contexts/LocalDataLayerContext.tsx b/src/contexts/LocalDataLayerContext.tsx index 536d070..8a76832 100644 --- a/src/contexts/LocalDataLayerContext.tsx +++ b/src/contexts/LocalDataLayerContext.tsx @@ -6,14 +6,17 @@ import { useState, type PropsWithChildren, } from 'react'; +import type { Kid } from '../data/data-model'; type LocalDataLayerContextValue = { getFriendIds: () => string[]; + getFriendKids: () => Kid[]; isFriend: (friendKidId: string) => boolean; - toggleFriend: (friendKidId: string) => void; + toggleFriend: (friendKid: Kid) => void; }; const friendsStorageKey = 'kid-a:local:friends'; +const friendKidsStorageKey = 'kid-a:local:friend-kids'; const LocalDataLayerContext = createContext< LocalDataLayerContextValue | undefined @@ -53,18 +56,82 @@ function writeStoredFriends(friends: string[]) { window.localStorage.setItem(friendsStorageKey, JSON.stringify(friends)); } +function isStoredKid(value: unknown): value is Kid { + return ( + typeof value === 'object' && + value !== null && + typeof (value as Kid).age === 'number' && + typeof (value as Kid).gender === 'string' && + typeof (value as Kid).id === 'string' && + typeof (value as Kid).language === 'string' && + typeof (value as Kid).name === 'string' + ); +} + +function readStoredFriendKids(): Record { + const storedFriendKids = window.localStorage.getItem(friendKidsStorageKey); + + if (!storedFriendKids) { + return {}; + } + + try { + const parsedFriendKids: unknown = JSON.parse(storedFriendKids); + + if (Array.isArray(parsedFriendKids)) { + return Object.fromEntries( + parsedFriendKids + .filter(isStoredKid) + .map((kid) => [kid.id, kid]), + ); + } + + if (parsedFriendKids && typeof parsedFriendKids === 'object') { + return Object.fromEntries( + Object.values(parsedFriendKids) + .filter(isStoredKid) + .map((kid) => [kid.id, kid]), + ); + } + + console.warn('Ignoring invalid local friend kids data.'); + return {}; + } catch (error) { + console.warn('Ignoring unreadable local friend kids data.', error); + return {}; + } +} + +function writeStoredFriendKids(friendKidsById: Record) { + window.localStorage.setItem( + friendKidsStorageKey, + JSON.stringify(Object.values(friendKidsById)), + ); +} + export function LocalDataLayerProvider({ children }: PropsWithChildren) { const [friendIds, setFriendIds] = useState(() => readStoredFriends()); + const [friendKidsById, setFriendKidsById] = useState>( + () => readStoredFriendKids(), + ); useEffect(() => { writeStoredFriends(friendIds); }, [friendIds]); + useEffect(() => { + writeStoredFriendKids(friendKidsById); + }, [friendKidsById]); + useEffect(() => { const readFriendsFromAnotherTab = (event: StorageEvent) => { if (event.key === friendsStorageKey) { setFriendIds(readStoredFriends()); } + + if (event.key === friendKidsStorageKey) { + setFriendKidsById(readStoredFriendKids()); + } }; window.addEventListener('storage', readFriendsFromAnotherTab); @@ -75,17 +142,35 @@ export function LocalDataLayerProvider({ children }: PropsWithChildren) { const value = useMemo( () => ({ getFriendIds: () => friendIds, + getFriendKids: () => + friendIds + .map((friendId) => friendKidsById[friendId]) + .filter((kid): kid is Kid => kid !== undefined), isFriend: (friendKidId) => friendIds.includes(friendKidId), - toggleFriend: (friendKidId) => { + toggleFriend: (friendKid) => { + const isCurrentFriend = friendIds.includes(friendKid.id); + setFriendIds((currentFriendIds) => { - const isCurrentFriend = currentFriendIds.includes(friendKidId); return isCurrentFriend - ? currentFriendIds.filter((friendId) => friendId !== friendKidId) - : [...currentFriendIds, friendKidId]; + ? currentFriendIds.filter((friendId) => friendId !== friendKid.id) + : dedupeFriendIds([...currentFriendIds, friendKid.id]); + }); + setFriendKidsById((currentFriendKids) => { + if (isCurrentFriend) { + const remainingFriendKids = { ...currentFriendKids }; + delete remainingFriendKids[friendKid.id]; + + return remainingFriendKids; + } + + return { + ...currentFriendKids, + [friendKid.id]: friendKid, + }; }); }, }), - [friendIds], + [friendIds, friendKidsById], ); return ( @@ -109,6 +194,10 @@ export function useGetFriendIds() { return useLocalDataLayer().getFriendIds; } +export function useGetFriendKids() { + return useLocalDataLayer().getFriendKids; +} + export function useIsFriend() { return useLocalDataLayer().isFriend; } diff --git a/src/data/remote-data-client.ts b/src/data/remote-data-client.ts index 4f7d44b..2388cce 100644 --- a/src/data/remote-data-client.ts +++ b/src/data/remote-data-client.ts @@ -14,6 +14,8 @@ export type RemoteDataSnapshot = { prizes: Prize[]; }; +export type RemotePassportsByKid = Record; + type RemotePrizeResponse = { prize?: Prize; prizes: Prize[]; @@ -54,13 +56,32 @@ export function isRemoteDataLayerEnabled() { return import.meta.env.VITE_DATA_LAYER === 'remote'; } +export async function fetchRemoteKid(rawKid: string): Promise { + const response = await fetch(buildApiUrl(`/kids?kid=${encodeURIComponent(rawKid)}`), { + cache: 'no-store', + }); + + if (response.status === 404) { + return undefined; + } + + return readJsonResponse(response); +} + +async function fetchRemoteKidList(headers: HeadersInit): Promise { + const response = await fetch(buildApiUrl('/kids'), { + cache: 'no-store', + headers, + }); + + return readJsonResponse(response); +} + export async function fetchRemoteDataSnapshot(): Promise { const headers = magicLinkRequestHeaders(); const requestInit = { cache: 'no-store', headers } satisfies RequestInit; const [kids, prizes] = await Promise.all([ - fetch(buildApiUrl('/kids'), requestInit).then((response) => - readJsonResponse(response), - ), + fetchRemoteKidList(headers), fetch(buildApiUrl('/wheel-prizes'), requestInit).then((response) => readJsonResponse(response), ), @@ -84,6 +105,22 @@ export async function fetchRemotePassport(kidId: string) { return readJsonResponse(response); } +export async function fetchRemotePassports(kidIds: string[]) { + const uniqueKidIds = [...new Set(kidIds.map((kidId) => kidId.trim()))].filter( + Boolean, + ); + const query = new URLSearchParams(); + + query.set('kids', uniqueKidIds.join(',')); + + const response = await fetch(buildApiUrl(`/passport?${query.toString()}`), { + cache: 'no-store', + headers: magicLinkRequestHeaders(), + }); + + return readJsonResponse(response); +} + export async function fetchRemotePrizeAwardsForKid(kidId: string) { const response = await fetch( buildApiUrl(`/prizes-kid?kid=${encodeURIComponent(kidId)}`), diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts index 396febd..b8dce96 100644 --- a/src/i18n/messages.ts +++ b/src/i18n/messages.ts @@ -153,6 +153,7 @@ export const messages = { 'friends.star.add': 'Add {name} as a friend', 'friends.star.remove': 'Remove {name} from friends', 'friends.dialog.close': 'Close friends', + 'friends.refresh': 'Refresh friends progress', 'friends.add.open': 'Find', 'friends.add.close': 'Close finder', 'friends.add.title': 'Find a friend', @@ -367,6 +368,7 @@ export const messages = { 'friends.star.add': 'Añadir a {name} como amistad', 'friends.star.remove': 'Quitar a {name} de amistades', 'friends.dialog.close': 'Cerrar amistades', + 'friends.refresh': 'Actualizar progreso de amistades', 'friends.add.open': 'Buscar', 'friends.add.close': 'Cerrar buscador', 'friends.add.title': 'Buscar una amistad', diff --git a/src/styles.css b/src/styles.css index f22f72c..781152d 100644 --- a/src/styles.css +++ b/src/styles.css @@ -110,9 +110,34 @@ h2 { } .welcome-friends-header { + display: flex; + gap: 8px; + align-items: center; margin-bottom: 10px; } +.welcome-friends-refresh-button { + display: inline-grid; + width: 30px; + height: 30px; + place-items: center; + border: 0; + border-radius: 999px; + background: rgb(90 66 40 / 10%); + color: #5a4228; + cursor: pointer; +} + +.welcome-friends-refresh-button:hover, +.welcome-friends-refresh-button:focus-visible { + background: rgb(90 66 40 / 18%); +} + +.welcome-friends-refresh-button:disabled { + cursor: wait; + opacity: 0.55; +} + .welcome-activity-list h2 { margin-bottom: 10px; color: #5a4228; @@ -1498,67 +1523,32 @@ h2 { } .kid-gender-icon { - position: relative; - display: inline-block; - width: 30px; - height: 30px; + display: inline-grid; + width: 34px; + height: 34px; flex: 0 0 auto; + place-items: center; transform: translateY(6px); - overflow: hidden; - border: 2px solid #4e6f2b; - border-radius: 50%; - background: #e5f0d0; -} - -.kid-gender-icon::before { - position: absolute; - z-index: 1; - right: 6px; - bottom: 3px; - left: 6px; - height: 19px; - border-radius: 50% 50% 45% 45%; - background: #ccefb8; - content: ""; -} - -.kid-gender-icon::after { - position: absolute; - content: ""; + color: #5a4228; } -.kid-gender-icon.boy::after { - top: 4px; - right: 6px; - left: 6px; - height: 9px; - border-radius: 12px 12px 5px 5px; - background: - radial-gradient(circle at 28% 80%, #d9482f 0 3px, transparent 4px), - radial-gradient(circle at 50% 82%, #d9482f 0 3px, transparent 4px), - radial-gradient(circle at 72% 80%, #d9482f 0 3px, transparent 4px), - #d9482f; -} - -.kid-gender-icon.girl::after { - top: 3px; - right: 4px; - bottom: 2px; - left: 4px; - border-radius: 14px 14px 9px 9px; - background: - radial-gradient(ellipse at 18% 58%, #ffd84d 0 5px, transparent 6px), - radial-gradient(ellipse at 82% 58%, #ffd84d 0 5px, transparent 6px), - linear-gradient(#ffd84d 0 48%, transparent 49%); +.kid-gender-icon svg { + width: 100%; + height: 100%; + overflow: visible; + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 2.8; } -.kid-gender-icon.preferNotToSay { - background: transparent; +.kid-gender-shoulders { + stroke-width: 3; } -.kid-gender-icon.preferNotToSay::before, -.kid-gender-icon.preferNotToSay::after { - display: none; +.kid-gender-hair { + stroke-width: 3.2; } .kid-count-card { From ced227a5cffb4cc305aa98aaa64b242309c45655 Mon Sep 17 00:00:00 2001 From: pablonete Date: Thu, 25 Jun 2026 10:48:08 +0200 Subject: [PATCH 02/20] Hide home admin access Remove the home admin access button and guest avatar. Show a logged-in user avatar on the home page only when a kid or staff role is active, and make it navigate back to that role's page. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/components/SampleAccessDialog.tsx | 12 ++--------- src/components/TopBar.tsx | 30 +++++++++++++++++++++++++-- src/pages/WelcomePage.tsx | 6 +++++- 3 files changed, 35 insertions(+), 13 deletions(-) diff --git a/src/components/SampleAccessDialog.tsx b/src/components/SampleAccessDialog.tsx index 4a9bdf2..b6a0984 100644 --- a/src/components/SampleAccessDialog.tsx +++ b/src/components/SampleAccessDialog.tsx @@ -61,15 +61,7 @@ export function SampleAccessDialog() { return (
    - {isRemoteDataLayer ? ( - - ) : ( + {!isRemoteDataLayer ? ( - )} + ) : null} - {isUserMenuOpen ? ( + {isUserMenuOpen && !navigateOnAvatar ? (
    {t('user.nameLabel')} diff --git a/src/pages/WelcomePage.tsx b/src/pages/WelcomePage.tsx index 05d3bcc..2476807 100644 --- a/src/pages/WelcomePage.tsx +++ b/src/pages/WelcomePage.tsx @@ -21,7 +21,11 @@ export function WelcomePage() { return ( <> - +

    {conference.shortName}

    From eae8c067f344b6c4f260c54a386a22b882d28fd1 Mon Sep 17 00:00:00 2001 From: pablonete Date: Thu, 25 Jun 2026 10:51:03 +0200 Subject: [PATCH 03/20] Update initial activities Refresh the initial activities seed data from opensouthcode/2026#4 with the current 16 activity titles and their GitHub issue URLs, without lead names. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/data/activities.json | 41 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/src/data/activities.json b/src/data/activities.json index 5216bf8..bbc99af 100644 --- a/src/data/activities.json +++ b/src/data/activities.json @@ -1,8 +1,7 @@ [ { "id": "1", - "title": "Micro:bit Luces", - "details": "Let's learn about Micro:bit.", + "title": "Micro:bit Coches", "issueUrl": "https://github.com/opensouthcode/2026/issues/38" }, { @@ -13,57 +12,57 @@ { "id": "3", "title": "Desenchufao", - "issueUrl": "https://github.com/opensouthcode/2026/issues/4" + "issueUrl": "https://github.com/opensouthcode/2026/issues/45" }, { "id": "4", "title": "Makey Makey Dance Dance", - "issueUrl": "https://github.com/opensouthcode/2026/issues/4" + "issueUrl": "https://github.com/opensouthcode/2026/issues/47" }, { "id": "5", "title": "Anima tu dibujo", - "issueUrl": "https://github.com/opensouthcode/2026/issues/4" + "issueUrl": "https://github.com/opensouthcode/2026/issues/48" }, { "id": "6", "title": "Aprende Git", - "issueUrl": "https://github.com/opensouthcode/2026/issues/4" + "issueUrl": "https://github.com/opensouthcode/2026/issues/49" }, { "id": "7", - "title": "Magnific workflow", + "title": "Magnific flow", "issueUrl": "https://github.com/opensouthcode/2026/issues/11" }, { "id": "8", - "title": "Comic AI - Elige tu aventura", - "issueUrl": "https://github.com/opensouthcode/2026/issues/4" + "title": "ComicIA - Elige tu aventura", + "issueUrl": "https://github.com/opensouthcode/2026/issues/46" }, { "id": "9", - "title": "NFC Cinexin", + "title": "NFC Jukebox", "issueUrl": "https://github.com/opensouthcode/2026/issues/9" }, { "id": "10", - "title": "TinkerCAD", - "issueUrl": "https://github.com/opensouthcode/2026/issues/4" + "title": "TinkerCAD 3D", + "issueUrl": "https://github.com/opensouthcode/2026/issues/44" }, { "id": "11", - "title": "YWT 1 y 2", - "issueUrl": "https://github.com/opensouthcode/2026/issues/4" + "title": "YesWeTech - Detective digital", + "issueUrl": "https://github.com/opensouthcode/2026/issues/42" }, { "id": "12", - "title": "YWT 3 y 4", - "issueUrl": "https://github.com/opensouthcode/2026/issues/4" + "title": "YesWeTech - LEDs", + "issueUrl": "https://github.com/opensouthcode/2026/issues/43" }, { "id": "13", - "title": "Mural digital", - "issueUrl": "https://github.com/opensouthcode/2026/issues/6" + "title": "OpenSouthQuiz", + "issueUrl": "https://github.com/opensouthcode/2026/issues/10" }, { "id": "14", @@ -72,12 +71,12 @@ }, { "id": "15", - "title": "Ada & Zangemann: Manualidades", - "issueUrl": "https://github.com/opensouthcode/2026/issues/3" + "title": "Ada Kraft", + "issueUrl": "https://github.com/opensouthcode/2026/issues/41" }, { "id": "16", - "title": "Ada & Zangemann: Cine", + "title": "Cine Zangemann", "issueUrl": "https://github.com/opensouthcode/2026/issues/3" } ] From c001e394840889563b977ed2a937103c1ea42460 Mon Sep 17 00:00:00 2001 From: pablonete Date: Thu, 25 Jun 2026 10:57:36 +0200 Subject: [PATCH 04/20] Add activity descriptions Add short initial descriptions for all 16 activities from their GitHub issues and label issue links as more details in the activity UI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/components/ActivityHero.tsx | 5 ++++- src/data/activities.json | 16 ++++++++++++++++ src/i18n/messages.ts | 2 ++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/components/ActivityHero.tsx b/src/components/ActivityHero.tsx index a2cb56b..785e36b 100644 --- a/src/components/ActivityHero.tsx +++ b/src/components/ActivityHero.tsx @@ -1,4 +1,5 @@ import type { Activity } from '../contexts/DataLayerContext'; +import { useI18n } from '../i18n/I18nProvider'; type ActivityHeroProps = { activity: Activity; @@ -11,6 +12,8 @@ function getIssueLabel(issueUrl: string) { } export function ActivityHero({ activity, eyebrow, headingId }: ActivityHeroProps) { + const { t } = useI18n(); + return (

    {eyebrow}

    @@ -22,7 +25,7 @@ export function ActivityHero({ activity, eyebrow, headingId }: ActivityHeroProps rel="noreferrer" target="_blank" > - {getIssueLabel(activity.issueUrl)} + {t('activity.detail.moreDetails')} {getIssueLabel(activity.issueUrl)}

    {activity.details ? ( diff --git a/src/data/activities.json b/src/data/activities.json index bbc99af..b061375 100644 --- a/src/data/activities.json +++ b/src/data/activities.json @@ -2,81 +2,97 @@ { "id": "1", "title": "Micro:bit Coches", + "details": "Construye y programa un coche con Micro:bit para experimentar con movimiento, sensores y pequeñas pruebas de conducción.", "issueUrl": "https://github.com/opensouthcode/2026/issues/38" }, { "id": "2", "title": "Hackea el Zombie", + "details": "Modifica un videojuego de zombis hecho con Godot y cambia reglas, personajes o detalles para hacerlo a tu gusto.", "issueUrl": "https://github.com/opensouthcode/2026/issues/31" }, { "id": "3", "title": "Desenchufao", + "details": "Resuelve retos de pensamiento computacional sin pantallas, usando lógica, juego y colaboración.", "issueUrl": "https://github.com/opensouthcode/2026/issues/45" }, { "id": "4", "title": "Makey Makey Dance Dance", + "details": "Crea un mando de baile con Makey Makey y materiales cotidianos para controlar música o un juego con el cuerpo.", "issueUrl": "https://github.com/opensouthcode/2026/issues/47" }, { "id": "5", "title": "Anima tu dibujo", + "details": "Convierte un dibujo propio en una pequeña animación y descubre cómo dar vida digital a tus ideas.", "issueUrl": "https://github.com/opensouthcode/2026/issues/48" }, { "id": "6", "title": "Aprende Git", + "details": "Da tus primeros pasos con Git mediante una actividad práctica para guardar cambios y colaborar como en un proyecto real.", "issueUrl": "https://github.com/opensouthcode/2026/issues/49" }, { "id": "7", "title": "Magnific flow", + "details": "Crea imágenes con IA generativa combinando referencias visuales, texto y prompts para dirigir el resultado.", "issueUrl": "https://github.com/opensouthcode/2026/issues/11" }, { "id": "8", "title": "ComicIA - Elige tu aventura", + "details": "Añade una viñeta a un cómic colaborativo usando creatividad e imágenes generadas con IA.", "issueUrl": "https://github.com/opensouthcode/2026/issues/46" }, { "id": "9", "title": "NFC Jukebox", + "details": "Explora cómo las etiquetas NFC pueden activar canciones o sonidos y monta una pequeña jukebox interactiva.", "issueUrl": "https://github.com/opensouthcode/2026/issues/9" }, { "id": "10", "title": "TinkerCAD 3D", + "details": "Diseña una pieza sencilla en 3D con TinkerCAD y prepárala para poder imprimirla.", "issueUrl": "https://github.com/opensouthcode/2026/issues/44" }, { "id": "11", "title": "YesWeTech - Detective digital", + "details": "Conviértete en detective digital y descifra códigos aprendiendo conceptos básicos de criptografía.", "issueUrl": "https://github.com/opensouthcode/2026/issues/42" }, { "id": "12", "title": "YesWeTech - LEDs", + "details": "Programa señales luminosas y pequeñas secuencias con LEDs para entender cómo se controla la luz desde el código.", "issueUrl": "https://github.com/opensouthcode/2026/issues/43" }, { "id": "13", "title": "OpenSouthQuiz", + "details": "Participa en un juego de preguntas en tiempo real con rondas por edades, puntuación rápida y ranking final.", "issueUrl": "https://github.com/opensouthcode/2026/issues/10" }, { "id": "14", "title": "Si fueras un Octogato", + "details": "Diseña tu propio Octogato personalizado, ideal para imaginar un avatar divertido y compartirlo.", "issueUrl": "https://github.com/opensouthcode/2026/issues/7" }, { "id": "15", "title": "Ada Kraft", + "details": "Haz manualidades inspiradas en Ada & Zangemann y en la tecnología libre.", "issueUrl": "https://github.com/opensouthcode/2026/issues/41" }, { "id": "16", "title": "Cine Zangemann", + "details": "Disfruta la historia de Ada & Zangemann y descubre ideas sobre software libre, creatividad y tecnología.", "issueUrl": "https://github.com/opensouthcode/2026/issues/3" } ] diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts index b8dce96..f0b15e9 100644 --- a/src/i18n/messages.ts +++ b/src/i18n/messages.ts @@ -16,6 +16,7 @@ export const messages = { 'app.access': 'Access', 'activity.list.title': 'Activities', 'activity.detail.eyebrow': 'Activity', + 'activity.detail.moreDetails': 'More details', 'activity.detail.notFound': 'Activity not found', 'access.title': 'Choose User', 'access.role.desk': 'Desk', @@ -230,6 +231,7 @@ export const messages = { 'app.access': 'Acceder', 'activity.list.title': 'Actividades', 'activity.detail.eyebrow': 'Actividad', + 'activity.detail.moreDetails': 'Más detalles', 'activity.detail.notFound': 'Actividad no encontrada', 'access.title': 'Elegir usuario', 'access.role.desk': 'Mesa', From 749b8b1098376e28b77a5e4219bcb807884df43e Mon Sep 17 00:00:00 2001 From: pablonete Date: Thu, 25 Jun 2026 11:00:47 +0200 Subject: [PATCH 05/20] Update drawing activity description Apply review feedback to simplify the Anima tu dibujo activity description. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/data/activities.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/data/activities.json b/src/data/activities.json index b061375..fa18e8e 100644 --- a/src/data/activities.json +++ b/src/data/activities.json @@ -26,7 +26,7 @@ { "id": "5", "title": "Anima tu dibujo", - "details": "Convierte un dibujo propio en una pequeña animación y descubre cómo dar vida digital a tus ideas.", + "details": "Haz que tu dibujo baile", "issueUrl": "https://github.com/opensouthcode/2026/issues/48" }, { From c95db598fdab2384d7d4edd40b646cc4db4aa1ae Mon Sep 17 00:00:00 2001 From: pablonete Date: Thu, 25 Jun 2026 23:19:23 +0200 Subject: [PATCH 06/20] Add activity details QR Move the More details label outside the issue pill and add a readable QR code for each activity issue URL in the activity header. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/components/ActivityHero.tsx | 35 ++++++++++++++++++--- src/i18n/messages.ts | 2 ++ src/styles.css | 54 +++++++++++++++++++++++++++++++-- 3 files changed, 83 insertions(+), 8 deletions(-) diff --git a/src/components/ActivityHero.tsx b/src/components/ActivityHero.tsx index 785e36b..db8e3cf 100644 --- a/src/components/ActivityHero.tsx +++ b/src/components/ActivityHero.tsx @@ -1,3 +1,5 @@ +import QRCode from 'qrcode'; +import { useEffect, useState } from 'react'; import type { Activity } from '../contexts/DataLayerContext'; import { useI18n } from '../i18n/I18nProvider'; @@ -13,21 +15,44 @@ function getIssueLabel(issueUrl: string) { export function ActivityHero({ activity, eyebrow, headingId }: ActivityHeroProps) { const { t } = useI18n(); + const [issueQrCodeUrl, setIssueQrCodeUrl] = useState(''); + + useEffect(() => { + QRCode.toDataURL(activity.issueUrl, { + errorCorrectionLevel: 'M', + margin: 2, + width: 160, + }) + .then(setIssueQrCodeUrl) + .catch(() => setIssueQrCodeUrl('')); + }, [activity.issueUrl]); return (

    {eyebrow}

    -

    - {activity.id.padStart(2, '0')} {activity.title} +
    +

    + {activity.id.padStart(2, '0')} {activity.title} +

    - {t('activity.detail.moreDetails')} {getIssueLabel(activity.issueUrl)} + + {t('activity.detail.moreDetails')} + + {issueQrCodeUrl ? ( + {t('activity.detail.moreDetailsQr')} + ) : null} + {getIssueLabel(activity.issueUrl)} -

    + {activity.details ? (

    {activity.details}

    ) : null} diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts index f0b15e9..586481a 100644 --- a/src/i18n/messages.ts +++ b/src/i18n/messages.ts @@ -17,6 +17,7 @@ export const messages = { 'activity.list.title': 'Activities', 'activity.detail.eyebrow': 'Activity', 'activity.detail.moreDetails': 'More details', + 'activity.detail.moreDetailsQr': 'QR code for more activity details', 'activity.detail.notFound': 'Activity not found', 'access.title': 'Choose User', 'access.role.desk': 'Desk', @@ -232,6 +233,7 @@ export const messages = { 'activity.list.title': 'Actividades', 'activity.detail.eyebrow': 'Actividad', 'activity.detail.moreDetails': 'Más detalles', + 'activity.detail.moreDetailsQr': 'Código QR para más detalles de la actividad', 'activity.detail.notFound': 'Actividad no encontrada', 'access.title': 'Elegir usuario', 'access.role.desk': 'Mesa', diff --git a/src/styles.css b/src/styles.css index 781152d..40975bd 100644 --- a/src/styles.css +++ b/src/styles.css @@ -255,6 +255,16 @@ h2 { margin-top: 24px; } +.activity-heading-row { + display: flex; + gap: 24px; + align-items: flex-start; +} + +.activity-heading-row .activity-heading { + flex: 1 1 auto; +} + .activity-heading span { color: #4e6f2b; font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; @@ -279,9 +289,47 @@ h2 { background: #dfecc2; } -.title-issue-link { - margin-left: 12px; - vertical-align: middle; +.activity-details-link { + display: grid; + flex: 0 0 auto; + justify-items: center; + gap: 8px; + max-width: 132px; + color: #5a4228; + font-size: 0.82rem; + font-weight: 900; + letter-spacing: 0.06em; + text-align: center; + text-decoration: none; + text-transform: uppercase; +} + +.activity-details-link:hover .issue-link, +.activity-details-link:focus-visible .issue-link { + background: #dfecc2; +} + +.activity-details-label { + line-height: 1.1; +} + +.activity-details-qr { + width: 112px; + height: 112px; + border: 1px solid #8c6b3f; + border-radius: 10px; + background: #fff; + image-rendering: pixelated; +} + +@media (max-width: 720px) { + .activity-heading-row { + display: grid; + } + + .activity-details-link { + justify-self: start; + } } .user-toolbar { From 645156d0e9f7594b62560ed302301c342ae7c192 Mon Sep 17 00:00:00 2001 From: pablonete Date: Fri, 26 Jun 2026 06:58:24 +0200 Subject: [PATCH 07/20] Allow public kid passports Let /passport?kid=... and /passport?id=... render a read-only kid passport without requiring a logged-in kid, password, or magic link. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/i18n/messages.ts | 2 ++ src/pages/PassportPage.tsx | 54 +++++++++++++++++++++++++++++++++++--- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts index 586481a..5af3cac 100644 --- a/src/i18n/messages.ts +++ b/src/i18n/messages.ts @@ -136,6 +136,7 @@ export const messages = { 'lead.error.activityCompleted': 'This kid already has this activity completed.', 'kid.title': 'Passport', + 'kid.notFound': 'Kid passport not found', 'kid.qr.title': 'Kid passport QR', 'kid.qr.alt': 'Kid passport QR code', 'kid.qr.instructions': 'Show this QR to the activity lead.', @@ -353,6 +354,7 @@ export const messages = { 'lead.error.activityCompleted': 'Este kid ya tiene esta actividad completada.', 'kid.title': 'Pasaporte', + 'kid.notFound': 'Pasaporte infantil no encontrado', 'kid.qr.title': 'QR de pasaporte infantil', 'kid.qr.alt': 'Código QR de pasaporte infantil', 'kid.qr.instructions': 'Enseña este QR al equipo de actividad.', diff --git a/src/pages/PassportPage.tsx b/src/pages/PassportPage.tsx index 6ea0327..c54c819 100644 --- a/src/pages/PassportPage.tsx +++ b/src/pages/PassportPage.tsx @@ -6,8 +6,9 @@ import { } from '@primer/octicons-react'; import QRCode from 'qrcode'; import { useEffect, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import { FriendsDialog } from '../components/FriendsDialog'; +import { FriendPassportView } from '../components/FriendPassportView'; import { PassportActivityMosaic } from '../components/PassportActivityMosaic'; import { ProgressCounter } from '../components/ProgressCounter'; import { TopBar } from '../components/TopBar'; @@ -15,10 +16,12 @@ import { useActivitiesData, useConferenceData, useCurrentUser, + useFindKidById, useGetWheelShotSummaryForKid, usePassportData, useReloadPassportActivities, useReloadPrizeAwardsForKid, + type Kid, } from '../contexts/DataLayerContext'; import { useI18n } from '../i18n/I18nProvider'; import type { MessageKey } from '../i18n/messages'; @@ -44,12 +47,19 @@ export function PassportPage() { const activities = useActivitiesData(); const conference = useConferenceData(); const currentUser = useCurrentUser(); + const findKidById = useFindKidById(); const getWheelShotSummaryForKid = useGetWheelShotSummaryForKid(); const passport = usePassportData(); const reloadPassportActivities = useReloadPassportActivities(); const reloadPrizeAwardsForKid = useReloadPrizeAwardsForKid(); const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const { t } = useI18n(); + const publicKidId = searchParams.get('kid') ?? searchParams.get('id') ?? ''; + const [publicKid, setPublicKid] = useState(); + const [publicKidStatus, setPublicKidStatus] = useState< + 'idle' | 'loading' | 'notFound' + >('idle'); const [qrCodeUrl, setQrCodeUrl] = useState(''); const [qrError, setQrError] = useState(''); const [isQrExpanded, setIsQrExpanded] = useState(false); @@ -70,10 +80,32 @@ export function PassportPage() { }; useEffect(() => { - if (currentUser.role !== 'kid') { + if (!publicKidId && currentUser.role !== 'kid') { navigate('/', { replace: true }); } - }, [currentUser.role, navigate]); + }, [currentUser.role, navigate, publicKidId]); + + useEffect(() => { + const trimmedPublicKidId = publicKidId.trim(); + + if (!trimmedPublicKidId) { + setPublicKid(undefined); + setPublicKidStatus('idle'); + return; + } + + setPublicKidStatus('loading'); + findKidById(trimmedPublicKidId) + .then((kid) => { + setPublicKid(kid); + setPublicKidStatus(kid ? 'idle' : 'notFound'); + }) + .catch((error) => { + console.error(`Unable to load public passport for ${trimmedPublicKidId}.`, error); + setPublicKid(undefined); + setPublicKidStatus('notFound'); + }); + }, [findKidById, publicKidId]); useEffect(() => { if (currentUser.role === 'kid') { @@ -119,6 +151,22 @@ export function PassportPage() { return () => window.removeEventListener('keydown', handleKeyDown); }, [isQrExpanded]); + if (publicKidId) { + return ( + <> + +
    +

    {conference.title}

    + {publicKid ? ( + + ) : publicKidStatus === 'notFound' ? ( +

    {t('kid.notFound')}

    + ) : null} +
    + + ); + } + if (currentUser.role !== 'kid') { return null; } From fd6189157f56740fae664b677da68dd7ebc18a8d Mon Sep 17 00:00:00 2001 From: pablonete Date: Fri, 26 Jun 2026 07:02:31 +0200 Subject: [PATCH 08/20] Move QR printing to desk menu Place the passport QR sheet printing action inside the desk user's profile popup and label it Print QRs sheet. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/components/TopBar.tsx | 3 +++ src/i18n/messages.ts | 2 ++ src/pages/DeskPage.tsx | 27 ++++++++++++++++----------- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/components/TopBar.tsx b/src/components/TopBar.tsx index 442be8d..1efc021 100644 --- a/src/components/TopBar.tsx +++ b/src/components/TopBar.tsx @@ -7,6 +7,7 @@ import { isSupportedLocale, supportedLocales, type MessageKey } from '../i18n/me type TopBarProps = { customButtons?: ReactNode; + menuActions?: ReactNode; navigateOnAvatar?: boolean; onLogout?: () => void; showGuestAvatar?: boolean; @@ -53,6 +54,7 @@ function LanguageSwitcher() { export function TopBar({ customButtons, + menuActions, navigateOnAvatar = false, onLogout, showGuestAvatar = false, @@ -157,6 +159,7 @@ export function TopBar({ {t('language.label')}
    + {menuActions} diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts index 5af3cac..4e7bd06 100644 --- a/src/i18n/messages.ts +++ b/src/i18n/messages.ts @@ -91,6 +91,7 @@ export const messages = { 'desk.scanQrShort': 'Scan QR', 'desk.confirm': 'Confirm registration', 'desk.printQr': 'Print QRs', + 'desk.printQr.sheetButton': 'Print QRs sheet', 'desk.printQr.back': 'Back to desk', 'desk.printQr.generating': 'Generating QRs...', 'desk.printQr.error': 'The passport QRs could not be generated.', @@ -308,6 +309,7 @@ export const messages = { 'desk.scanQrShort': 'Escanear QR', 'desk.confirm': 'Confirmar registro', 'desk.printQr': 'Imprimir QRs', + 'desk.printQr.sheetButton': 'Imprimir hoja de QRs', 'desk.printQr.back': 'Volver a la mesa', 'desk.printQr.generating': 'Generando QRs...', 'desk.printQr.error': 'No se pudieron generar los QRs de pasaporte.', diff --git a/src/pages/DeskPage.tsx b/src/pages/DeskPage.tsx index 94fd118..77e4c89 100644 --- a/src/pages/DeskPage.tsx +++ b/src/pages/DeskPage.tsx @@ -234,7 +234,22 @@ export function DeskPage() { return ( <> - navigate('/')} /> + + {isGeneratingPrintQrs + ? t('desk.printQr.generating') + : t('desk.printQr.sheetButton')} + + } + onLogout={() => navigate('/')} + />

    {t('desk.eyebrow')}

    {t('desk.title')}

    @@ -281,16 +296,6 @@ export function DeskPage() { ) : null} {printQrError ?

    {printQrError}

    : null}
    - - ); - })} -

    {conference.title}

    {t('kid.title')}

    @@ -334,10 +286,16 @@ export function PassportPage() { } />
    - setIsFriendsDialogOpen(false)} + + {selectedFriendKid ? ( + setSelectedFriendKid(undefined)} + /> + ) : null}
    ); From 8490990abd45176228c9762afecf54e86a9b1179 Mon Sep 17 00:00:00 2001 From: pablonete Date: Fri, 26 Jun 2026 07:12:03 +0200 Subject: [PATCH 10/20] Show passport QR modal Render a clickable QR for the currently displayed kid passport URL and reuse the modal on both logged-in and public passport views. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/pages/PassportPage.tsx | 84 +++++++++++++++++++++----------------- 1 file changed, 46 insertions(+), 38 deletions(-) diff --git a/src/pages/PassportPage.tsx b/src/pages/PassportPage.tsx index 1e910ee..085d342 100644 --- a/src/pages/PassportPage.tsx +++ b/src/pages/PassportPage.tsx @@ -49,8 +49,11 @@ export function PassportPage() { const completedActivities = passport.activities.filter( (activity) => activity.completedAt, ).length; - const kidPassportPayload = - currentUser.role === 'kid' ? createKidPassportUrl(currentUser.id) : ''; + const displayedKidId = + publicKid?.id ?? (currentUser.role === 'kid' ? currentUser.id : ''); + const kidPassportPayload = displayedKidId + ? createKidPassportUrl(displayedKidId) + : ''; const wheelShotSummary = currentUser.role === 'kid' ? getWheelShotSummaryForKid(currentUser.id) @@ -133,6 +136,43 @@ export function PassportPage() { return () => window.removeEventListener('keydown', handleKeyDown); }, [isQrExpanded]); + const passportQrButton = qrCodeUrl ? ( + + ) : null; + const passportQrModal = isQrExpanded ? ( +
    setIsQrExpanded(false)} + > +
    event.stopPropagation()} + > +

    {t('kid.qr.title')}

    + {t('kid.qr.alt')} +

    {t('kid.qr.instructions')}

    + +
    +
    + ) : null; + if (publicKidId) { return ( <> @@ -144,7 +184,9 @@ export function PassportPage() { ) : publicKidStatus === 'notFound' ? (

    {t('kid.notFound')}

    ) : null} + {passportQrButton}
+ {passportQrModal} ); } @@ -236,43 +278,9 @@ export function PassportPage() { ) : null} - {qrCodeUrl ? ( - - ) : null} + {passportQrButton} {qrError ?

{qrError}

: null} - {isQrExpanded ? ( -
setIsQrExpanded(false)} - > -
event.stopPropagation()} - > -

{t('kid.qr.title')}

- {t('kid.qr.alt')} -

{t('kid.qr.instructions')}

- -
-
- ) : null} + {passportQrModal}
Date: Fri, 26 Jun 2026 07:15:09 +0200 Subject: [PATCH 11/20] Show friends on public passports Render the Kids section on public passport pages too, with the viewed kid blocked and the same friend picker/list overlay behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/pages/PassportPage.tsx | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/pages/PassportPage.tsx b/src/pages/PassportPage.tsx index 085d342..f32eb41 100644 --- a/src/pages/PassportPage.tsx +++ b/src/pages/PassportPage.tsx @@ -180,12 +180,24 @@ export function PassportPage() {

{conference.title}

{publicKid ? ( - + <> + + + ) : publicKidStatus === 'notFound' ? (

{t('kid.notFound')}

) : null} - {passportQrButton} + {passportQrButton}
+ {selectedFriendKid ? ( + setSelectedFriendKid(undefined)} + /> + ) : null} {passportQrModal} ); From 11733b413a7d0c83bc3369d1d3578360e7c09e1d Mon Sep 17 00:00:00 2001 From: pablonete Date: Fri, 26 Jun 2026 07:20:10 +0200 Subject: [PATCH 12/20] Move passport QR to header Place the passport QR button at the top-right of the passport header area instead of floating near the bottom of the page. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/pages/PassportPage.tsx | 8 +++++--- src/styles.css | 30 ++++++++++++++++++++++++------ 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/src/pages/PassportPage.tsx b/src/pages/PassportPage.tsx index f32eb41..3eeeccb 100644 --- a/src/pages/PassportPage.tsx +++ b/src/pages/PassportPage.tsx @@ -181,7 +181,10 @@ export function PassportPage() {

{conference.title}

{publicKid ? ( <> - +
+ + {passportQrButton} +
{t('kid.notFound')} ) : null} - {passportQrButton}
{selectedFriendKid ? ( {conference.title}

{t('kid.title')}

+ {passportQrButton}
) : null}
- {passportQrButton} {qrError ?

{qrError}

: null} {passportQrModal} diff --git a/src/styles.css b/src/styles.css index 40975bd..eac1d1f 100644 --- a/src/styles.css +++ b/src/styles.css @@ -656,6 +656,26 @@ h2 { margin-right: auto; } +.passport-public-header { + display: flex; + gap: 18px; + align-items: flex-start; +} + +.passport-public-header .friend-passport-view { + flex: 1 1 auto; +} + +@media (max-width: 620px) { + .passport-public-header { + display: grid; + } + + .passport-public-header .kid-qr-corner-button { + justify-self: end; + } +} + .friend-star-button { display: inline-grid; width: 46px; @@ -743,10 +763,8 @@ h2 { } .kid-qr-corner-button { - position: absolute; - right: clamp(24px, 4vw, 44px); - bottom: clamp(24px, 4vw, 44px); - z-index: 10; + flex: 0 0 auto; + margin-left: auto; border: 1px solid #8c6b3f; border-radius: 18px; padding: 6px; @@ -764,8 +782,8 @@ h2 { .kid-qr-corner-button img { display: block; - width: 86px; - height: 86px; + width: 82px; + height: 82px; } .kid-qr-overlay { From f164670057ecf2bb83f3595dca8aa047fe7905c7 Mon Sep 17 00:00:00 2001 From: pablonete Date: Fri, 26 Jun 2026 07:30:55 +0200 Subject: [PATCH 13/20] Add lead activity kids endpoint Add /activity-kids?activity=NN for lead pages to fetch the recent five completions plus total count without loading the full kids list, and skip the remote full snapshot for lead sessions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- server/api.ts | 54 ++++++++++++++++++++++++ src/contexts/DataLayerContext.tsx | 45 +++++++++++++++++++- src/data/remote-data-client.ts | 24 +++++++++++ src/pages/ActivityLeadPage.tsx | 69 +++++++++++++++++++------------ 4 files changed, 164 insertions(+), 28 deletions(-) diff --git a/server/api.ts b/server/api.ts index fc6d643..13d8c8d 100644 --- a/server/api.ts +++ b/server/api.ts @@ -63,6 +63,7 @@ const apiPaths = new Set([ '/admin/export', '/admin/import', '/auth/session', + '/activity-kids', '/kids', '/passport', '/wheel-prizes', @@ -354,6 +355,33 @@ function passportTemplate( ); } +function getActivityCompletionSummary(snapshot: StoreData, activityId: number) { + const completedEntries = snapshot.kids + .map((kid) => { + const completedAt = snapshot.passportActivitiesByKid[kid.id]?.find( + (activity) => activity.id === activityId, + )?.completedAt; + + return completedAt ? { completedAt, kid } : undefined; + }) + .filter((entry): entry is { completedAt: string; kid: StoreData['kids'][number] } => + Boolean(entry), + ) + .sort( + (firstEntry, secondEntry) => + new Date(secondEntry.completedAt).getTime() - + new Date(firstEntry.completedAt).getTime(), + ); + + return { + count: completedEntries.length, + kids: completedEntries.slice(0, 5).map((entry) => ({ + completedAt: entry.completedAt, + kid: entry.kid, + })), + }; +} + function normalizePrizeKind(value: unknown) { if (value === undefined) { return undefined; @@ -650,6 +678,28 @@ async function handleAuth( return jsonResponse(request, 200, session); } +async function handleActivityKids( + request: ApiRequest, + url: URL, +): Promise { + if (request.method !== 'GET') { + throw new HttpError(405, 'Method not allowed'); + } + + const session = await requireMagicLink(request, url, ['lead']); + const activityId = parsePositiveInteger(url.searchParams.get('activity'), 'activity'); + + if (session.activityId !== activityId) { + throw new HttpError(403, 'Lead magic link cannot view this activity'); + } + + return jsonResponse( + request, + 200, + getActivityCompletionSummary(await readSnapshot(), activityId), + ); +} + async function handlePassport( request: ApiRequest, url: URL, @@ -874,6 +924,10 @@ export async function handleApiRequest(request: ApiRequest): Promise Promise; findKidByManualNumber: (rawSearchValue: string) => Promise; findKidByQrIdData: (qrPayload: string) => Promise; + getActivityCompletedKids: (activityId: number) => Promise; getPassportForKid: (kidId: string) => PassportData; getWheelShotSummaryForKid: (kidId: string) => WheelShotSummary; kids: Kid[]; @@ -338,7 +341,11 @@ export function DataLayerProvider({ children }: PropsWithChildren) { }, [isRemoteDataLayer]); useEffect(() => { - if (!isRemoteDataLayer || accessSessionStatus.state !== 'ready') { + if ( + !isRemoteDataLayer || + accessSessionStatus.state !== 'ready' || + selectedCurrentUser.role === 'lead' + ) { return; } @@ -347,7 +354,7 @@ export function DataLayerProvider({ children }: PropsWithChildren) { .catch((error) => { console.error('Unable to load remote event data.', error); }); - }, [accessSessionStatus.state, isRemoteDataLayer]); + }, [accessSessionStatus.state, isRemoteDataLayer, selectedCurrentUser.role]); const persistRemoteSnapshot = (snapshotPromise: Promise) => { snapshotPromise.then(applyRemoteSnapshot).catch((error) => { @@ -465,6 +472,35 @@ export function DataLayerProvider({ children }: PropsWithChildren) { const getPassportForKid = (kidId: string): PassportData => ({ activities: passportActivitiesByUser[kidId] ?? [], }); + const getActivityCompletedKids = async ( + activityId: number, + ): Promise => { + if (isRemoteDataLayer) { + return fetchRemoteActivityKids(activityId); + } + + const completedEntries = kidList + .map((kid) => { + const completedAt = passportActivitiesByUser[kid.id]?.find( + (activity) => activity.id === activityId, + )?.completedAt; + + return completedAt ? { completedAt, kid } : undefined; + }) + .filter( + (entry): entry is { completedAt: string; kid: Kid } => entry !== undefined, + ) + .sort( + (firstEntry, secondEntry) => + new Date(secondEntry.completedAt).getTime() - + new Date(firstEntry.completedAt).getTime(), + ); + + return { + count: completedEntries.length, + kids: completedEntries.slice(0, 5), + }; + }; const getWheelShotSummaryForKid = (kidId: string): WheelShotSummary => { const kidActivities = passportActivitiesByUser[kidId]; const awards = prizeAwards @@ -838,6 +874,7 @@ export function DataLayerProvider({ children }: PropsWithChildren) { findKidById, findKidByManualNumber, findKidByQrIdData, + getActivityCompletedKids, getPassportForKid, getWheelShotSummaryForKid, kids: kidList, @@ -931,6 +968,10 @@ export function useGetPassportForKid() { return useDataLayer().getPassportForKid; } +export function useGetActivityCompletedKids() { + return useDataLayer().getActivityCompletedKids; +} + export function useUsersData() { return useDataLayer().users; } diff --git a/src/data/remote-data-client.ts b/src/data/remote-data-client.ts index 2388cce..151c0d6 100644 --- a/src/data/remote-data-client.ts +++ b/src/data/remote-data-client.ts @@ -16,6 +16,14 @@ export type RemoteDataSnapshot = { export type RemotePassportsByKid = Record; +export type RemoteActivityKidsSummary = { + count: number; + kids: Array<{ + completedAt: string; + kid: Kid; + }>; +}; + type RemotePrizeResponse = { prize?: Prize; prizes: Prize[]; @@ -121,6 +129,22 @@ export async function fetchRemotePassports(kidIds: string[]) { return readJsonResponse(response); } +export async function fetchRemoteActivityKids(activityId: number) { + const response = await fetch( + buildApiUrl( + `/activity-kids?activity=${encodeURIComponent( + String(activityId).padStart(2, '0'), + )}`, + ), + { + cache: 'no-store', + headers: magicLinkRequestHeaders(), + }, + ); + + return readJsonResponse(response); +} + export async function fetchRemotePrizeAwardsForKid(kidId: string) { const response = await fetch( buildApiUrl(`/prizes-kid?kid=${encodeURIComponent(kidId)}`), diff --git a/src/pages/ActivityLeadPage.tsx b/src/pages/ActivityLeadPage.tsx index e01d04d..7c6abe3 100644 --- a/src/pages/ActivityLeadPage.tsx +++ b/src/pages/ActivityLeadPage.tsx @@ -1,5 +1,5 @@ import { AlertIcon } from '@primer/octicons-react'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { ActivityHero } from '../components/ActivityHero'; import { KidFinder } from '../components/KidFinder'; @@ -10,8 +10,8 @@ import { useAccessSessionStatus, useActivitiesData, useCurrentUser, + useGetActivityCompletedKids, useGetPassportForKid, - useKidsData, useMarkPassportActivityDone, useReloadPassportActivities, type Kid, @@ -22,8 +22,8 @@ export function ActivityLeadPage() { const accessSessionStatus = useAccessSessionStatus(); const currentUser = useCurrentUser(); const activities = useActivitiesData(); + const getActivityCompletedKids = useGetActivityCompletedKids(); const getPassportForKid = useGetPassportForKid(); - const kids = useKidsData(); const markPassportActivityDone = useMarkPassportActivityDone(); const reloadPassportActivities = useReloadPassportActivities(); const navigate = useNavigate(); @@ -31,9 +31,13 @@ export function ActivityLeadPage() { const leadActivityId = currentUser.role === 'lead' ? currentUser.activityId : undefined; const activityId = leadActivityId ?? 1; const [confirmedKidId, setConfirmedKidId] = useState(''); + const [confirmedKid, setConfirmedKid] = useState(); const [passportCompletedKidId, setPassportCompletedKidId] = useState(''); const [lastCompletedKidId, setLastCompletedKidId] = useState(''); - const confirmedKid = kids.find((kid) => kid.id === confirmedKidId); + const [activityCompletedKids, setActivityCompletedKids] = useState<{ + count: number; + kids: Array<{ completedAt: string; kid: Kid }>; + }>({ count: 0, kids: [] }); const passport = confirmedKid ? getPassportForKid(confirmedKid.id) : { activities: [] }; @@ -54,29 +58,11 @@ export function ActivityLeadPage() { hour: '2-digit', minute: '2-digit', }).format(new Date(completedAt)); - const getKidActivity = (kid: Kid) => - getPassportForKid(kid.id).activities.find( - (activity) => activity.id === activityId, - ); - const activityCompletedKids = kids - .map((kid) => ({ - completedAt: getKidActivity(kid)?.completedAt, - kid, - })) - .filter( - (entry): entry is { completedAt: string; kid: Kid } => - typeof entry.completedAt === 'string', - ) - .sort( - (firstEntry, secondEntry) => - new Date(secondEntry.completedAt).getTime() - - new Date(firstEntry.completedAt).getTime(), - ); const lastCompletedKids = activityCompletedKids - .slice(0, 3) + .kids .map((entry) => entry.kid); const completedAtByKidId = Object.fromEntries( - activityCompletedKids.map((entry) => [ + activityCompletedKids.kids.map((entry) => [ entry.kid.id, formatCompletionTime(entry.completedAt), ]), @@ -106,7 +92,18 @@ export function ActivityLeadPage() { if (confirmedKidId) { reloadPassportActivities(confirmedKidId); } - }, [confirmedKidId]); + }, [confirmedKidId, reloadPassportActivities]); + + const refreshActivityCompletedKids = useCallback(() => + getActivityCompletedKids(activityId) + .then(setActivityCompletedKids) + .catch((error) => { + console.error('Unable to load activity completed kids.', error); + }), [activityId, getActivityCompletedKids]); + + useEffect(() => { + refreshActivityCompletedKids(); + }, [refreshActivityCompletedKids]); if (accessSessionStatus.state === 'loading' || currentUser.role !== 'lead') { return null; @@ -114,16 +111,19 @@ export function ActivityLeadPage() { const confirmKid = (kid: Kid) => { setConfirmedKidId(kid.id); + setConfirmedKid(kid); setPassportCompletedKidId(''); setLastCompletedKidId(''); }; const clearConfirmedKid = () => { setConfirmedKidId(''); + setConfirmedKid(undefined); setPassportCompletedKidId(''); setLastCompletedKidId(''); }; const returnToKidScan = () => { setConfirmedKidId(''); + setConfirmedKid(undefined); }; const markActivity = () => { @@ -140,6 +140,23 @@ export function ActivityLeadPage() { leadActivity.id, ); setLastCompletedKidId(confirmedKid.id); + setActivityCompletedKids((currentActivityCompletedKids) => { + const alreadyCompleted = currentActivityCompletedKids.kids.some( + (entry) => entry.kid.id === confirmedKid.id, + ); + + if (alreadyCompleted) { + return currentActivityCompletedKids; + } + + return { + count: currentActivityCompletedKids.count + 1, + kids: [ + { completedAt: new Date().toISOString(), kid: confirmedKid }, + ...currentActivityCompletedKids.kids, + ].slice(0, 5), + }; + }); if (nextCompletedActivities === passport.activities.length) { setPassportCompletedKidId(confirmedKid.id); @@ -245,7 +262,7 @@ export function ActivityLeadPage() {

{t('lead.summary.title')}

- {activityCompletedKids.length} + {activityCompletedKids.count}

{lastCompletedKids.length > 0 ? ( From 168c3e03a2f6429d5a547d7165bfebe4b97b7e35 Mon Sep 17 00:00:00 2001 From: pablonete Date: Fri, 26 Jun 2026 07:33:51 +0200 Subject: [PATCH 14/20] Show activity completion counts Add a public /activity-count endpoint and render completion count badges in the home activity grid without loading the full kids list. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- server/api.ts | 30 ++++++++++++++++++++++++++++++ src/contexts/DataLayerContext.tsx | 25 +++++++++++++++++++++++++ src/data/remote-data-client.ts | 10 ++++++++++ src/i18n/messages.ts | 2 ++ src/pages/WelcomePage.tsx | 21 ++++++++++++++++++++- src/styles.css | 19 +++++++++++++++++++ 6 files changed, 106 insertions(+), 1 deletion(-) diff --git a/server/api.ts b/server/api.ts index 13d8c8d..55a2011 100644 --- a/server/api.ts +++ b/server/api.ts @@ -63,6 +63,7 @@ const apiPaths = new Set([ '/admin/export', '/admin/import', '/auth/session', + '/activity-count', '/activity-kids', '/kids', '/passport', @@ -382,6 +383,19 @@ function getActivityCompletionSummary(snapshot: StoreData, activityId: number) { }; } +function getActivityCompletionCounts(snapshot: StoreData) { + const counts: Record = {}; + + Object.values(snapshot.passportActivitiesByKid).forEach((passportActivities) => { + passportActivities.forEach((activity) => { + const activityId = String(activity.id); + counts[activityId] = (counts[activityId] ?? 0) + 1; + }); + }); + + return counts; +} + function normalizePrizeKind(value: unknown) { if (value === undefined) { return undefined; @@ -700,6 +714,18 @@ async function handleActivityKids( ); } +async function handleActivityCount(request: ApiRequest): Promise { + if (request.method !== 'GET') { + throw new HttpError(405, 'Method not allowed'); + } + + return jsonResponse( + request, + 200, + getActivityCompletionCounts(await readSnapshot()), + ); +} + async function handlePassport( request: ApiRequest, url: URL, @@ -924,6 +950,10 @@ export async function handleApiRequest(request: ApiRequest): Promise Promise; findKidByManualNumber: (rawSearchValue: string) => Promise; findKidByQrIdData: (qrPayload: string) => Promise; + getActivityCounts: () => Promise; getActivityCompletedKids: (activityId: number) => Promise; getPassportForKid: (kidId: string) => PassportData; getWheelShotSummaryForKid: (kidId: string) => WheelShotSummary; @@ -472,6 +475,23 @@ export function DataLayerProvider({ children }: PropsWithChildren) { const getPassportForKid = (kidId: string): PassportData => ({ activities: passportActivitiesByUser[kidId] ?? [], }); + const getActivityCounts = async (): Promise => { + if (isRemoteDataLayer) { + return fetchRemoteActivityCounts(); + } + + return Object.values(passportActivitiesByUser).reduce( + (counts, passportActivities) => { + passportActivities.forEach((activity) => { + const activityId = String(activity.id); + counts[activityId] = (counts[activityId] ?? 0) + 1; + }); + + return counts; + }, + {}, + ); + }; const getActivityCompletedKids = async ( activityId: number, ): Promise => { @@ -874,6 +894,7 @@ export function DataLayerProvider({ children }: PropsWithChildren) { findKidById, findKidByManualNumber, findKidByQrIdData, + getActivityCounts, getActivityCompletedKids, getPassportForKid, getWheelShotSummaryForKid, @@ -972,6 +993,10 @@ export function useGetActivityCompletedKids() { return useDataLayer().getActivityCompletedKids; } +export function useGetActivityCounts() { + return useDataLayer().getActivityCounts; +} + export function useUsersData() { return useDataLayer().users; } diff --git a/src/data/remote-data-client.ts b/src/data/remote-data-client.ts index 151c0d6..2bf094d 100644 --- a/src/data/remote-data-client.ts +++ b/src/data/remote-data-client.ts @@ -24,6 +24,8 @@ export type RemoteActivityKidsSummary = { }>; }; +export type RemoteActivityCounts = Record; + type RemotePrizeResponse = { prize?: Prize; prizes: Prize[]; @@ -145,6 +147,14 @@ export async function fetchRemoteActivityKids(activityId: number) { return readJsonResponse(response); } +export async function fetchRemoteActivityCounts() { + const response = await fetch(buildApiUrl('/activity-count'), { + cache: 'no-store', + }); + + return readJsonResponse(response); +} + export async function fetchRemotePrizeAwardsForKid(kidId: string) { const response = await fetch( buildApiUrl(`/prizes-kid?kid=${encodeURIComponent(kidId)}`), diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts index 4e7bd06..aad4de9 100644 --- a/src/i18n/messages.ts +++ b/src/i18n/messages.ts @@ -15,6 +15,7 @@ export const messages = { 'A playful space inside OpenSouthCode where kids can explore activities, collect progress with their QR passport, and get ready for surprises.', 'app.access': 'Access', 'activity.list.title': 'Activities', + 'activity.count.badge': 'kids completed', 'activity.detail.eyebrow': 'Activity', 'activity.detail.moreDetails': 'More details', 'activity.detail.moreDetailsQr': 'QR code for more activity details', @@ -233,6 +234,7 @@ export const messages = { 'Un espacio divertido dentro de OpenSouthCode donde kids pueden explorar actividades, guardar su progreso con su pasaporte QR y prepararse para sorpresas.', 'app.access': 'Acceder', 'activity.list.title': 'Actividades', + 'activity.count.badge': 'kids completaron', 'activity.detail.eyebrow': 'Actividad', 'activity.detail.moreDetails': 'Más detalles', 'activity.detail.moreDetailsQr': 'Código QR para más detalles de la actividad', diff --git a/src/pages/WelcomePage.tsx b/src/pages/WelcomePage.tsx index 2476807..0a994f0 100644 --- a/src/pages/WelcomePage.tsx +++ b/src/pages/WelcomePage.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; import { KidsSection, SelectedKidPassport } from '../components/KidsSection'; import { SampleAccessDialog } from '../components/SampleAccessDialog'; @@ -7,6 +7,7 @@ import { useActivitiesData, useConferenceData, useCurrentUser, + useGetActivityCounts, type Kid, } from '../contexts/DataLayerContext'; import { useI18n } from '../i18n/I18nProvider'; @@ -15,10 +16,20 @@ export function WelcomePage() { const activities = useActivitiesData(); const conference = useConferenceData(); const currentUser = useCurrentUser(); + const getActivityCounts = useGetActivityCounts(); const { t } = useI18n(); + const [activityCounts, setActivityCounts] = useState>({}); const [selectedFriendKid, setSelectedFriendKid] = useState(); const blockedKidId = currentUser.role === 'kid' ? currentUser.id : ''; + useEffect(() => { + getActivityCounts() + .then(setActivityCounts) + .catch((error) => { + console.error('Unable to load activity completion counts.', error); + }); + }, [getActivityCounts]); + return ( <> {activity.id.padStart(2, '0')} {activity.title} + + {activityCounts[activity.id] ?? 0} + ))} diff --git a/src/styles.css b/src/styles.css index eac1d1f..c5767cd 100644 --- a/src/styles.css +++ b/src/styles.css @@ -223,6 +223,7 @@ h2 { } .welcome-activity-list a { + position: relative; display: grid; min-height: 78px; gap: 4px; @@ -234,6 +235,24 @@ h2 { text-decoration: none; } +.activity-count-badge { + position: absolute; + top: 8px; + right: 8px; + display: inline-grid; + min-width: 28px; + height: 28px; + place-items: center; + border: 1px solid rgb(78 111 43 / 28%); + border-radius: 999px; + padding: 0 8px; + background: #f4d35e; + color: #5a4228; + font-size: 0.82rem; + font-weight: 900; + line-height: 1; +} + .welcome-activity-list a:hover, .welcome-activity-list a:focus-visible { background: rgb(78 111 43 / 12%); From 2be3f8aabcdacc66a7a95b5f32d8f92e436d1a22 Mon Sep 17 00:00:00 2001 From: pablonete Date: Fri, 26 Jun 2026 07:39:58 +0200 Subject: [PATCH 15/20] Show wheel shots on passports Include wheel shot usage and availability in passport API responses and display a compact wheel-shot pill beside the activity counter on kid and friend passport headers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- server/api.ts | 48 +++++++++++++++++--- src/components/FriendPassportView.tsx | 16 +++++-- src/components/WheelShotPill.tsx | 35 +++++++++++++++ src/contexts/DataLayerContext.tsx | 64 +++++++++++++++++++++------ src/data/data-model.ts | 6 +++ src/data/remote-data-client.ts | 7 ++- src/i18n/messages.ts | 4 ++ src/pages/PassportPage.tsx | 2 + src/styles.css | 27 +++++++++++ 9 files changed, 182 insertions(+), 27 deletions(-) create mode 100644 src/components/WheelShotPill.tsx diff --git a/server/api.ts b/server/api.ts index 55a2011..136cd4c 100644 --- a/server/api.ts +++ b/server/api.ts @@ -339,11 +339,45 @@ function normalizeOptionalLastKnownKidId(value: unknown) { return value.trim().toLowerCase(); } -function passportResponse( - passportActivitiesByKid: PassportActivitiesByKid, - kidId: string, -): PassportActivity[] { - return passportActivitiesByKid[kidId] ?? passportTemplate(passportActivitiesByKid); +type PassportApiResponse = { + activities: PassportActivity[]; + wheelShotSummary: { + availableShots: number; + earnedShots: number; + usedShots: number; + }; +}; + +function getPassportWheelShotSummary(snapshot: StoreData, kidId: string) { + const passportActivities = + snapshot.passportActivitiesByKid[kidId] ?? + passportTemplate(snapshot.passportActivitiesByKid); + const completedActivities = passportActivities.filter( + (activity) => activity.completedAt, + ).length; + const spinEligibleActivities = Math.min( + completedActivities, + Math.max(passportActivities.length - 1, 0), + ); + const earnedShots = Math.floor(spinEligibleActivities / 4); + const usedShots = snapshot.prizeAwards.filter( + (award) => award.kidId === kidId && award.source === 'wheel', + ).length; + + return { + availableShots: Math.max(earnedShots - usedShots, 0), + earnedShots, + usedShots, + }; +} + +function passportResponse(snapshot: StoreData, kidId: string): PassportApiResponse { + return { + activities: + snapshot.passportActivitiesByKid[kidId] ?? + passportTemplate(snapshot.passportActivitiesByKid), + wheelShotSummary: getPassportWheelShotSummary(snapshot, kidId), + }; } function passportTemplate( @@ -749,7 +783,7 @@ async function handlePassport( .map((kidId) => normalizeKidId(kidId, snapshot)) .map((kidId) => [ kidId, - passportResponse(snapshot.passportActivitiesByKid, kidId), + passportResponse(snapshot, kidId), ]), ); @@ -761,7 +795,7 @@ async function handlePassport( return jsonResponse( request, 200, - passportResponse(snapshot.passportActivitiesByKid, kidId), + passportResponse(snapshot, kidId), ); } diff --git a/src/components/FriendPassportView.tsx b/src/components/FriendPassportView.tsx index 999b125..ded5893 100644 --- a/src/components/FriendPassportView.tsx +++ b/src/components/FriendPassportView.tsx @@ -1,6 +1,7 @@ import { useEffect } from 'react'; import { useGetPassportForKid, + useGetWheelShotSummaryForKid, useReloadPassportActivities, type Kid, } from '../contexts/DataLayerContext'; @@ -9,6 +10,7 @@ import { FriendStarButton } from './FriendStarButton'; import { KidGenderIcon } from './KidGenderIcon'; import { PassportActivityMosaic } from './PassportActivityMosaic'; import { ProgressCounter } from './ProgressCounter'; +import { WheelShotPill } from './WheelShotPill'; type FriendPassportViewProps = { kid: Kid; @@ -16,9 +18,12 @@ type FriendPassportViewProps = { export function FriendPassportView({ kid }: FriendPassportViewProps) { const getPassportForKid = useGetPassportForKid(); + const getWheelShotSummaryForKid = useGetWheelShotSummaryForKid(); const reloadPassportActivities = useReloadPassportActivities(); const { t } = useI18n(); const passport = getPassportForKid(kid.id); + const wheelShotSummary = + passport.wheelShotSummary ?? getWheelShotSummaryForKid(kid.id); const completedActivities = passport.activities.filter( (activity) => activity.completedAt, ).length; @@ -38,10 +43,13 @@ export function FriendPassportView({ kid }: FriendPassportViewProps) {

{kid.name}

- +
+ + +
0 + ? 'wheel-shot-pill available' + : 'wheel-shot-pill' + } + aria-label={label} + title={label} + > +