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/netlify/database/migrations/0002_remove_null_passport_activities.sql b/netlify/database/migrations/0002_remove_null_passport_activities.sql new file mode 100644 index 0000000..560d8a5 --- /dev/null +++ b/netlify/database/migrations/0002_remove_null_passport_activities.sql @@ -0,0 +1,2 @@ +DELETE FROM passport_activities +WHERE completed_at IS NULL; diff --git a/server/api.ts b/server/api.ts index 85eda1f..1ce3683 100644 --- a/server/api.ts +++ b/server/api.ts @@ -63,6 +63,8 @@ const apiPaths = new Set([ '/admin/export', '/admin/import', '/auth/session', + '/activity-count', + '/activity-kids', '/kids', '/passport', '/wheel-prizes', @@ -337,23 +339,107 @@ 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; + }; +}; + +const passportActivityIds = Array.from({ length: 16 }, (_, index) => index + 1); + +function getPassportWheelShotSummary(snapshot: StoreData, kidId: string) { + const passportActivities = mergePassportTemplate( + snapshot.passportActivitiesByKid[kidId], + ); + 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 passportTemplate( - passportActivitiesByKid: PassportActivitiesByKid, +function passportResponse(snapshot: StoreData, kidId: string): PassportApiResponse { + return { + activities: mergePassportTemplate(snapshot.passportActivitiesByKid[kidId]), + wheelShotSummary: getPassportWheelShotSummary(snapshot, kidId), + }; +} + +function passportTemplate(): PassportActivity[] { + return passportActivityIds.map((id) => ({ id })); +} + +function mergePassportTemplate( + passportActivities: PassportActivity[] | undefined, ): PassportActivity[] { - return ( - Object.values(passportActivitiesByKid)[0]?.map((activity) => ({ - id: activity.id, - })) ?? [] + const completedActivitiesById = Object.fromEntries( + (passportActivities ?? []).map((activity) => [activity.id, activity]), + ); + + return passportTemplate().map( + (activity) => completedActivitiesById[activity.id] ?? activity, ); } +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 getActivityCompletionCounts(snapshot: StoreData) { + const counts: Record = {}; + + Object.values(snapshot.passportActivitiesByKid).forEach((passportActivities) => { + passportActivities.forEach((activity) => { + if (!activity.completedAt) { + return; + } + + const activityId = String(activity.id); + counts[activityId] = (counts[activityId] ?? 0) + 1; + }); + }); + + return counts; +} + function normalizePrizeKind(value: unknown) { if (value === undefined) { return undefined; @@ -508,9 +594,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(); @@ -636,6 +736,40 @@ 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 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, @@ -646,15 +780,32 @@ 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, kidId), + ]), + ); + + return jsonResponse(request, 200, passportsByKid); + } + const kidId = normalizeKidId(url.searchParams.get('kid'), snapshot); return jsonResponse( request, 200, - passportResponse(snapshot.passportActivitiesByKid, kidId), + passportResponse(snapshot, kidId), ); } @@ -672,13 +823,17 @@ async function handlePassport( const snapshot = await readSnapshot(); const kidId = normalizeKidId(url.searchParams.get('kid'), snapshot); - const passport = await completePassportActivity({ + await completePassportActivity({ activityId, completedAt: new Date().toISOString(), kidId, }); - return jsonResponse(request, 200, passport); + return jsonResponse( + request, + 200, + passportResponse(await readSnapshot(), kidId), + ); } async function handleWheelPrizes( @@ -843,6 +998,14 @@ export async function handleApiRequest(request: ApiRequest): Promise index + 1); function isPostgresUrl(value: string | undefined): value is string { return value?.startsWith('postgres://') === true || @@ -222,11 +223,9 @@ function mapMagicToken(row: Row): MagicLinkTokenRecord { } function getPassportTemplate(passportActivitiesByKid: PassportActivitiesByKid) { - return ( - Object.values(passportActivitiesByKid)[0]?.map((activity) => ({ - id: activity.id, - })) ?? [] - ); + void passportActivitiesByKid; + + return passportActivityIds.map((id) => ({ id })); } function buildPassports(kids: Kid[], passportRows: Row[]): PassportActivitiesByKid { @@ -277,12 +276,14 @@ function passportQueries( ) { return [ tx`DELETE FROM passport_activities WHERE kid_id = ${kidId}`, - ...passport.map( - (activity) => tx` - INSERT INTO passport_activities (kid_id, activity_id, completed_at) - VALUES (${kidId}, ${activity.id}, ${activity.completedAt ?? null}::timestamptz) - `, - ), + ...passport + .filter((activity) => activity.completedAt) + .map( + (activity) => tx` + INSERT INTO passport_activities (kid_id, activity_id, completed_at) + VALUES (${kidId}, ${activity.id}, ${activity.completedAt}::timestamptz) + `, + ), ]; } @@ -338,6 +339,7 @@ export function createDbStore(sql: SqlClient = createSqlClient()): StoreAdapter sql` SELECT kid_id, activity_id, completed_at::text AS completed_at FROM passport_activities + WHERE completed_at IS NOT NULL ORDER BY kid_id, activity_id `, sql` @@ -414,21 +416,6 @@ export function createDbStore(sql: SqlClient = createSqlClient()): StoreAdapter WHERE lower(candidate.id) <> ${command.lastKnownKidId ?? ''} ON CONFLICT DO NOTHING RETURNING id, name, age, gender, language - ), - template_kid AS ( - SELECT kid_id - FROM passport_activities - ORDER BY kid_id - LIMIT 1 - ), - inserted_passport AS ( - INSERT INTO passport_activities (kid_id, activity_id) - SELECT inserted_kid.id, passport_activities.activity_id - FROM inserted_kid - JOIN template_kid ON true - JOIN passport_activities - ON passport_activities.kid_id = template_kid.kid_id - ON CONFLICT DO NOTHING ) SELECT id, name, age, gender, language FROM inserted_kid diff --git a/src/components/ActivityHero.tsx b/src/components/ActivityHero.tsx index a2cb56b..db8e3cf 100644 --- a/src/components/ActivityHero.tsx +++ b/src/components/ActivityHero.tsx @@ -1,4 +1,7 @@ +import QRCode from 'qrcode'; +import { useEffect, useState } from 'react'; import type { Activity } from '../contexts/DataLayerContext'; +import { useI18n } from '../i18n/I18nProvider'; type ActivityHeroProps = { activity: Activity; @@ -11,20 +14,45 @@ 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} +

- {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/components/FriendPassportView.tsx b/src/components/FriendPassportView.tsx index d65c7d5..ded5893 100644 --- a/src/components/FriendPassportView.tsx +++ b/src/components/FriendPassportView.tsx @@ -1,13 +1,16 @@ import { useEffect } from 'react'; import { useGetPassportForKid, + useGetWheelShotSummaryForKid, useReloadPassportActivities, type Kid, } from '../contexts/DataLayerContext'; import { useI18n } from '../i18n/I18nProvider'; import { FriendStarButton } from './FriendStarButton'; +import { KidGenderIcon } from './KidGenderIcon'; import { PassportActivityMosaic } from './PassportActivityMosaic'; import { ProgressCounter } from './ProgressCounter'; +import { WheelShotPill } from './WheelShotPill'; type FriendPassportViewProps = { kid: Kid; @@ -15,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; @@ -30,18 +36,20 @@ export function FriendPassportView({ kid }: FriendPassportViewProps) {
-

{kid.name}

- +
+ + +
{ event.stopPropagation(); - toggleFriend(kid.id); + toggleFriend(kid); }} > {friendSelected ? ( diff --git a/src/components/KidFinder.tsx b/src/components/KidFinder.tsx index 5872e05..aedeb37 100644 --- a/src/components/KidFinder.tsx +++ b/src/components/KidFinder.tsx @@ -61,8 +61,16 @@ export function KidFinder({ onKidSelected(kid); }; - const readKidQrPayload = (qrPayload: string) => { - const matchingKid = findKidByQrIdData(qrPayload); + const readKidQrPayload = async (qrPayload: string) => { + let matchingKid: Kid | undefined; + + try { + matchingKid = await findKidByQrIdData(qrPayload); + } catch (error) { + console.error('Unable to find kid from QR data.', error); + setFormError(t(messages.kidNotFound)); + return; + } if (!matchingKid) { setFormError(t(messages.invalidKidQr)); @@ -77,10 +85,19 @@ export function KidFinder({ selectKid(matchingKid); }; - const searchManualKid = (event: React.FormEvent) => { + const searchManualKid = async (event: React.FormEvent) => { event.preventDefault(); - const matchingKid = findKidByManualNumber(manualKidNumber); + let matchingKid: Kid | undefined; + + try { + matchingKid = await findKidByManualNumber(manualKidNumber); + } catch (error) { + console.error('Unable to find kid by manual number.', error); + setPendingKid(undefined); + setFormError(t(messages.kidNotFound)); + return; + } if (!matchingKid) { setPendingKid(undefined); @@ -113,7 +130,9 @@ export function KidFinder({ stopScanner: t('scanner.stopScanner'), }} onError={(message) => setFormError(message)} - onRead={readKidQrPayload} + onRead={(qrPayload) => { + void readKidQrPayload(qrPayload); + }} />
diff --git a/src/components/KidGenderIcon.tsx b/src/components/KidGenderIcon.tsx new file mode 100644 index 0000000..ccf6aee --- /dev/null +++ b/src/components/KidGenderIcon.tsx @@ -0,0 +1,38 @@ +import type { KidGender } from '../utils/kid-registration'; + +type KidGenderIconProps = { + gender: KidGender; + label: string; +}; + +export function KidGenderIcon({ gender, label }: KidGenderIconProps) { + return ( + + + + ); +} diff --git a/src/components/KidList.tsx b/src/components/KidList.tsx index 50e6e4e..f2b63c7 100644 --- a/src/components/KidList.tsx +++ b/src/components/KidList.tsx @@ -1,5 +1,6 @@ import type { Kid } from '../contexts/DataLayerContext'; import { useI18n } from '../i18n/I18nProvider'; +import { KidGenderIcon } from './KidGenderIcon'; type KidListProps = { animatedKidId?: string; @@ -28,10 +29,9 @@ export function KidList({ } }} > - {kid.id} {kid.name} diff --git a/src/components/KidsSection.tsx b/src/components/KidsSection.tsx index 8082ae9..fca8014 100644 --- a/src/components/KidsSection.tsx +++ b/src/components/KidsSection.tsx @@ -1,14 +1,15 @@ +import { SyncIcon } from '@primer/octicons-react'; import { useEffect, useState } from 'react'; import { useGetPassportForKid, - useKidsData, - useReloadPassportActivities, + useReloadPassportActivitiesForKids, type Kid, } from '../contexts/DataLayerContext'; -import { useGetFriendIds } from '../contexts/LocalDataLayerContext'; +import { useGetFriendKids } from '../contexts/LocalDataLayerContext'; import { useI18n } from '../i18n/I18nProvider'; import { FriendPassportView } from './FriendPassportView'; import { KidFinder } from './KidFinder'; +import { KidGenderIcon } from './KidGenderIcon'; import { ProgressCounter } from './ProgressCounter'; type KidsSectionProps = { @@ -20,25 +21,53 @@ export function KidsSection({ blockedKidId = '', onKidSelected, }: KidsSectionProps) { - const getFriendIds = useGetFriendIds(); + const getFriendKids = useGetFriendKids(); const getPassportForKid = useGetPassportForKid(); - const kids = useKidsData(); - const reloadPassportActivities = useReloadPassportActivities(); + const reloadPassportActivitiesForKids = useReloadPassportActivitiesForKids(); const { t } = useI18n(); const [isFriendPickerOpen, setIsFriendPickerOpen] = useState(false); - const friends = getFriendIds() - .map((friendId) => kids.find((kid) => kid.id === friendId)) - .filter((kid): kid is Kid => kid !== undefined && kid.id !== blockedKidId); + const [isRefreshingFriends, setIsRefreshingFriends] = useState(false); + const friends = getFriendKids().filter((kid) => kid.id !== blockedKidId); const friendKidIds = friends.map((kid) => kid.id).join(','); + const refreshFriendPassports = async () => { + if (!friendKidIds) { + return; + } + + setIsRefreshingFriends(true); + + try { + await reloadPassportActivitiesForKids(friends.map((kid) => kid.id)); + } catch (error) { + console.error('Unable to refresh friends passport progress.', error); + } finally { + setIsRefreshingFriends(false); + } + }; + useEffect(() => { - friends.forEach((kid) => reloadPassportActivities(kid.id)); + void refreshFriendPassports(); }, [friendKidIds]); return (

{t('friends.home.title')}

+ {friends.length > 0 ? ( + + ) : null}
{friends.length > 0 ? (
    @@ -55,10 +84,9 @@ export function KidsSection({ type="button" onClick={() => onKidSelected(kid)} > - {kid.name} - {isRemoteDataLayer ? ( - - ) : ( + {!isRemoteDataLayer ? ( - )} + ) : null} - {isUserMenuOpen ? ( + {isUserMenuOpen && !navigateOnAvatar ? (
    {t('user.nameLabel')} @@ -131,6 +159,7 @@ export function TopBar({ {t('language.label')}
    + {menuActions} diff --git a/src/components/WheelShotPill.tsx b/src/components/WheelShotPill.tsx new file mode 100644 index 0000000..e1a35e4 --- /dev/null +++ b/src/components/WheelShotPill.tsx @@ -0,0 +1,35 @@ +import { IterationsIcon } from '@primer/octicons-react'; +import type { PassportWheelShotSummary } from '../data/data-model'; +import { useI18n } from '../i18n/I18nProvider'; + +type WheelShotPillProps = { + summary?: PassportWheelShotSummary; +}; + +export function WheelShotPill({ summary }: WheelShotPillProps) { + const { t } = useI18n(); + const safeSummary = summary ?? { + availableShots: 0, + earnedShots: 0, + usedShots: 0, + }; + const label = t('kid.wheel.shotsMade') + .replace('{used}', String(safeSummary.usedShots)) + .replace('{earned}', String(safeSummary.earnedShots)) + .replace('{available}', String(safeSummary.availableShots)); + + return ( + 0 + ? 'wheel-shot-pill available' + : 'wheel-shot-pill' + } + aria-label={label} + title={label} + > + + ); +} diff --git a/src/contexts/DataLayerContext.tsx b/src/contexts/DataLayerContext.tsx index 313c3a6..877dc31 100644 --- a/src/contexts/DataLayerContext.tsx +++ b/src/contexts/DataLayerContext.tsx @@ -1,6 +1,7 @@ import { createContext, useEffect, + useCallback, useContext, useMemo, useState, @@ -27,6 +28,7 @@ import { type Kid, type PassportActivitiesByKid, type PassportData, + type PassportWheelShotSummary, type Prize, type PrizeAward, type PrizeSettingsUpdate, @@ -41,14 +43,20 @@ import { } from '../access/magic-links'; import { fetchRemoteDataSnapshot, + fetchRemoteActivityKids, + fetchRemoteActivityCounts, + fetchRemoteKid, fetchRemoteMagicLinkSession, fetchRemotePassport, + fetchRemotePassports, fetchRemotePrizeAwardsForKid, isRemoteDataLayerEnabled, saveRemotePassportActivity, saveRemotePrize, saveRemotePrizeAward, saveRemoteRegisteredKid, + type RemoteActivityKidsSummary, + type RemoteActivityCounts, type RemoteDataSnapshot, } from '../data/remote-data-client'; import { @@ -85,8 +93,11 @@ type DataLayerContextValue = { awardPrizeToKid: (kidId: string, prizeId: string) => 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; + getActivityCounts: () => Promise; + getActivityCompletedKids: (activityId: number) => Promise; getPassportForKid: (kidId: string) => PassportData; getWheelShotSummaryForKid: (kidId: string) => WheelShotSummary; kids: Kid[]; @@ -95,6 +106,7 @@ type DataLayerContextValue = { prizes: Prize[]; refreshPrizes: () => Prize[]; reloadPassportActivities: (kidId?: string) => void; + reloadPassportActivitiesForKids: (kidIds: string[]) => Promise; reloadPrizeAwardsForKid: (kidId: string) => void; users: User[]; resetCurrentUser: () => void; @@ -237,6 +249,8 @@ export function DataLayerProvider({ children }: PropsWithChildren) { ? {} : clonePassportActivities(initialPassportActivitiesByUser), ); + const [remoteWheelShotSummariesByKid, setRemoteWheelShotSummariesByKid] = + useState>({}); const [selectedCurrentUser, setSelectedCurrentUser] = useState( () => getInitialMagicLinkUser(isRemoteDataLayer) ?? guestUser, ); @@ -261,10 +275,39 @@ export function DataLayerProvider({ children }: PropsWithChildren) { setKidList(snapshot.kids); setPrizeList(clonePrizes(snapshot.prizes)); }; - const applyRemotePassport = (kidId: string, activities: PassportData['activities']) => { + const applyRemotePassport = (kidId: string, passport: PassportData) => { setPassportActivitiesByUser((currentPassportActivities) => ({ ...currentPassportActivities, - [kidId]: activities.map((activity) => ({ ...activity })), + [kidId]: passport.activities.map((activity) => ({ ...activity })), + })); + setRemoteWheelShotSummariesByKid((currentSummaries) => + passport.wheelShotSummary + ? { + ...currentSummaries, + [kidId]: passport.wheelShotSummary, + } + : currentSummaries, + ); + }; + const applyRemotePassports = (passportsByKid: Record) => { + setPassportActivitiesByUser((currentPassportActivities) => ({ + ...currentPassportActivities, + ...Object.fromEntries( + Object.entries(passportsByKid).map(([kidId, passport]) => [ + kidId, + passport.activities.map((activity) => ({ ...activity })), + ]), + ), + })); + setRemoteWheelShotSummariesByKid((currentSummaries) => ({ + ...currentSummaries, + ...Object.fromEntries( + Object.entries(passportsByKid).flatMap(([kidId, passport]) => + passport.wheelShotSummary + ? [[kidId, passport.wheelShotSummary]] + : [], + ), + ), })); }; const applyRemotePrizeAwards = (kidId: string, awards: PrizeAward[]) => { @@ -321,7 +364,11 @@ export function DataLayerProvider({ children }: PropsWithChildren) { }, [isRemoteDataLayer]); useEffect(() => { - if (!isRemoteDataLayer || accessSessionStatus.state !== 'ready') { + if ( + !isRemoteDataLayer || + accessSessionStatus.state !== 'ready' || + selectedCurrentUser.role === 'lead' + ) { return; } @@ -330,7 +377,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) => { @@ -339,7 +386,7 @@ export function DataLayerProvider({ children }: PropsWithChildren) { }; const loadRemotePassportForKid = (kidId: string) => { fetchRemotePassport(kidId) - .then((activities) => applyRemotePassport(kidId, activities)) + .then((passport) => applyRemotePassport(kidId, passport)) .catch((error) => { console.error(`Unable to load remote passport for ${kidId}.`, error); }); @@ -447,7 +494,58 @@ export function DataLayerProvider({ children }: PropsWithChildren) { }; const getPassportForKid = (kidId: string): PassportData => ({ activities: passportActivitiesByUser[kidId] ?? [], + wheelShotSummary: remoteWheelShotSummariesByKid[kidId], }); + const getActivityCounts = useCallback(async (): Promise => { + if (isRemoteDataLayer) { + return fetchRemoteActivityCounts(); + } + + return Object.values(passportActivitiesByUser).reduce( + (counts, passportActivities) => { + passportActivities.forEach((activity) => { + if (!activity.completedAt) { + return; + } + + const activityId = String(activity.id); + counts[activityId] = (counts[activityId] ?? 0) + 1; + }); + + return counts; + }, + {}, + ); + }, [isRemoteDataLayer, passportActivitiesByUser]); + 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 @@ -461,18 +559,29 @@ export function DataLayerProvider({ children }: PropsWithChildren) { prizeTitle: prize?.title ?? award.prizeId, }; }); + const remoteSummary = remoteWheelShotSummariesByKid[kidId]; - if (!kidActivities) { - if (isRemoteDataLayer) { + if (isRemoteDataLayer && remoteSummary) { return { - availableShots: 0, + ...remoteSummary, awards, completionAward: awards.find( (award) => award.source === 'passportCompletion', ), - earnedShots: 0, - usedShots: awards.filter(isWheelAward).length, }; + } + + if (!kidActivities) { + if (isRemoteDataLayer) { + return { + availableShots: 0, + awards, + completionAward: awards.find( + (award) => award.source === 'passportCompletion', + ), + earnedShots: 0, + usedShots: awards.filter(isWheelAward).length, + }; } throw new Error(`Unknown passport kid: ${kidId}`); @@ -498,21 +607,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 +672,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); @@ -578,8 +738,8 @@ export function DataLayerProvider({ children }: PropsWithChildren) { if (isRemoteDataLayer) { saveRemotePassportActivity(kidId, activityId) - .then((remotePassportActivities) => { - applyRemotePassport(kidId, remotePassportActivities); + .then((remotePassport) => { + applyRemotePassport(kidId, remotePassport); }) .catch((error) => { console.error('Unable to save remote passport activity.', error); @@ -767,8 +927,11 @@ export function DataLayerProvider({ children }: PropsWithChildren) { awardPrizeToKid, conference: conferenceJson, currentUser, + findKidById, findKidByManualNumber, findKidByQrIdData, + getActivityCounts, + getActivityCompletedKids, getPassportForKid, getWheelShotSummaryForKid, kids: kidList, @@ -778,10 +941,15 @@ export function DataLayerProvider({ children }: PropsWithChildren) { currentUser.role === 'kid' ? (passportActivitiesByUser[currentUser.id] ?? []) : [], + wheelShotSummary: + currentUser.role === 'kid' + ? remoteWheelShotSummariesByKid[currentUser.id] + : undefined, }, prizes, refreshPrizes, reloadPassportActivities, + reloadPassportActivitiesForKids, reloadPrizeAwardsForKid, resetCurrentUser, setCurrentUser, @@ -792,6 +960,7 @@ export function DataLayerProvider({ children }: PropsWithChildren) { currentUser, kidList, passportActivitiesByUser, + remoteWheelShotSummariesByKid, prizes, userList, accessSessionStatus, @@ -837,6 +1006,10 @@ export function useFindKidByQrIdData() { return useDataLayer().findKidByQrIdData; } +export function useFindKidById() { + return useDataLayer().findKidById; +} + export function useKidsData() { return useDataLayer().kids; } @@ -849,10 +1022,22 @@ export function useReloadPassportActivities() { return useDataLayer().reloadPassportActivities; } +export function useReloadPassportActivitiesForKids() { + return useDataLayer().reloadPassportActivitiesForKids; +} + export function useGetPassportForKid() { return useDataLayer().getPassportForKid; } +export function useGetActivityCompletedKids() { + return useDataLayer().getActivityCompletedKids; +} + +export function useGetActivityCounts() { + return useDataLayer().getActivityCounts; +} + export function useUsersData() { return useDataLayer().users; } 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/activities.json b/src/data/activities.json index 5216bf8..fa18e8e 100644 --- a/src/data/activities.json +++ b/src/data/activities.json @@ -1,83 +1,98 @@ [ { "id": "1", - "title": "Micro:bit Luces", - "details": "Let's learn about Micro:bit.", + "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", - "issueUrl": "https://github.com/opensouthcode/2026/issues/4" + "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", - "issueUrl": "https://github.com/opensouthcode/2026/issues/4" + "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", - "issueUrl": "https://github.com/opensouthcode/2026/issues/4" + "details": "Haz que tu dibujo baile", + "issueUrl": "https://github.com/opensouthcode/2026/issues/48" }, { "id": "6", "title": "Aprende Git", - "issueUrl": "https://github.com/opensouthcode/2026/issues/4" + "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 workflow", + "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": "Comic AI - Elige tu aventura", - "issueUrl": "https://github.com/opensouthcode/2026/issues/4" + "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 Cinexin", + "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", - "issueUrl": "https://github.com/opensouthcode/2026/issues/4" + "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": "YWT 1 y 2", - "issueUrl": "https://github.com/opensouthcode/2026/issues/4" + "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": "YWT 3 y 4", - "issueUrl": "https://github.com/opensouthcode/2026/issues/4" + "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": "Mural digital", - "issueUrl": "https://github.com/opensouthcode/2026/issues/6" + "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 & Zangemann: Manualidades", - "issueUrl": "https://github.com/opensouthcode/2026/issues/3" + "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": "Ada & Zangemann: Cine", + "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/data/data-model.ts b/src/data/data-model.ts index 1691edd..f878fc2 100644 --- a/src/data/data-model.ts +++ b/src/data/data-model.ts @@ -21,6 +21,7 @@ export type PassportActivity = { export type PassportData = { activities: PassportActivity[]; + wheelShotSummary?: PassportWheelShotSummary; }; export type PassportActivitiesByKid = Record; @@ -59,6 +60,11 @@ export type WheelShotSummary = { usedShots: number; }; +export type PassportWheelShotSummary = Pick< + WheelShotSummary, + 'availableShots' | 'earnedShots' | 'usedShots' +>; + export type UserRole = 'desk' | 'wheel' | 'lead'; export type User = { diff --git a/src/data/remote-data-client.ts b/src/data/remote-data-client.ts index 4f7d44b..fd3ce52 100644 --- a/src/data/remote-data-client.ts +++ b/src/data/remote-data-client.ts @@ -1,6 +1,6 @@ import type { Kid, - PassportActivity, + PassportData, Prize, PrizeAward, PrizeAwardSource, @@ -14,6 +14,22 @@ export type RemoteDataSnapshot = { prizes: Prize[]; }; +export type RemotePassport = PassportData; + +export type RemotePassportsByKid = Record; + +export type RemoteActivityKidsSummary = { + count: number; + kids: Array<{ + completedAt: string; + kid: Kid; + }>; +}; + +export type RemoteActivityCounts = Record; + +let activityCountsRequest: Promise | undefined; + type RemotePrizeResponse = { prize?: Prize; prizes: Prize[]; @@ -54,13 +70,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), ), @@ -81,7 +116,51 @@ export async function fetchRemotePassport(kidId: string) { }, ); - return readJsonResponse(response); + 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 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 fetchRemoteActivityCounts() { + activityCountsRequest ??= fetch(buildApiUrl('/activity-count'), { + cache: 'no-store', + }) + .then((response) => readJsonResponse(response)) + .finally(() => { + activityCountsRequest = undefined; + }); + + return activityCountsRequest; } export async function fetchRemotePrizeAwardsForKid(kidId: string) { @@ -156,7 +235,7 @@ export async function saveRemotePassportActivity( }, ); - return readJsonResponse(response); + return readJsonResponse(response); } export async function saveRemoteRegisteredKid( diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts index 396febd..1393cb5 100644 --- a/src/i18n/messages.ts +++ b/src/i18n/messages.ts @@ -15,7 +15,10 @@ 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', 'activity.detail.notFound': 'Activity not found', 'access.title': 'Choose User', 'access.role.desk': 'Desk', @@ -89,6 +92,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.', @@ -134,6 +138,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.', @@ -153,6 +158,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', @@ -173,6 +179,8 @@ export const messages = { 'kid.wheel.availablePlural': 'You have {count} shots available. Go to the wheel desk to spin them.', 'kid.wheel.none': 'Complete 4 activities to earn your next wheel shot.', + 'kid.wheel.shotsMade': + '{used}/{earned} wheel shots used. {available} available.', 'kid.wheel.prizes': 'Prizes won', 'wheel.eyebrow': 'Wheel role', 'wheel.title': 'Prize wheel', @@ -228,7 +236,10 @@ 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', 'activity.detail.notFound': 'Actividad no encontrada', 'access.title': 'Elegir usuario', 'access.role.desk': 'Mesa', @@ -302,6 +313,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.', @@ -348,6 +360,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.', @@ -367,6 +380,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', @@ -387,6 +401,8 @@ export const messages = { 'kid.wheel.availablePlural': 'Tienes {count} tiradas disponibles. Ve a la mesa de ruleta para usarlas.', 'kid.wheel.none': 'Completa 4 actividades para ganar tu próxima tirada.', + 'kid.wheel.shotsMade': + '{used}/{earned} tiradas de ruleta usadas. {available} disponibles.', 'kid.wheel.prizes': 'Premios ganados', 'wheel.eyebrow': 'Rol de ruleta', 'wheel.title': 'Ruleta de premios', diff --git a/src/pages/ActivityLeadPage.tsx b/src/pages/ActivityLeadPage.tsx index e01d04d..9f21409 100644 --- a/src/pages/ActivityLeadPage.tsx +++ b/src/pages/ActivityLeadPage.tsx @@ -10,8 +10,8 @@ import { useAccessSessionStatus, useActivitiesData, useCurrentUser, + useGetActivityCompletedKids, useGetPassportForKid, - useKidsData, useMarkPassportActivityDone, useReloadPassportActivities, type Kid, @@ -22,24 +22,29 @@ 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(); const { locale, t } = useI18n(); const leadActivityId = currentUser.role === 'lead' ? currentUser.activityId : undefined; - const activityId = leadActivityId ?? 1; + const activityId = leadActivityId ?? 0; 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: [] }; - const leadActivity = passport.activities.find( + const storedLeadActivity = passport.activities.find( (activity) => activity.id === activityId, ); + const leadActivity = activityId ? (storedLeadActivity ?? { id: activityId }) : undefined; const activity = activities.find((entry) => entry.id === String(activityId)); const completedActivities = passport.activities.filter( (activity) => activity.completedAt, @@ -54,29 +59,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), ]), @@ -103,10 +90,26 @@ export function ActivityLeadPage() { }, [accessSessionStatus.state, currentUser.role, navigate]); useEffect(() => { - if (confirmedKidId) { + if (confirmedKidId && currentUser.role === 'lead') { reloadPassportActivities(confirmedKidId); } - }, [confirmedKidId]); + }, [confirmedKidId, currentUser.role]); + + useEffect(() => { + if ( + accessSessionStatus.state !== 'ready' || + currentUser.role !== 'lead' || + !leadActivityId + ) { + return; + } + + getActivityCompletedKids(leadActivityId) + .then(setActivityCompletedKids) + .catch((error) => { + console.error('Unable to load activity completed kids.', error); + }); + }, [accessSessionStatus.state, currentUser.role, leadActivityId]); if (accessSessionStatus.state === 'loading' || currentUser.role !== 'lead') { return null; @@ -114,16 +117,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 +146,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); @@ -227,7 +250,7 @@ export function ActivityLeadPage() { + } + onLogout={() => navigate('/')} + />

    {t('desk.eyebrow')}

    {t('desk.title')}

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

    {printQrError}

    : null}
    - + ) : null; + const passportQrModal = isQrExpanded ? ( +
    setIsQrExpanded(false)} + > +
    event.stopPropagation()} + > +

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

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

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

    + +
    +
    + ) : null; + + if (publicKidId) { + return ( + <> + +
    +

    {conference.title}

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

    {t('kid.notFound')}

    + ) : null} +
    + {selectedFriendKid ? ( + setSelectedFriendKid(undefined)} + /> + ) : null} + {passportQrModal} + + ); + } + if (currentUser.role !== 'kid') { return null; } @@ -149,45 +236,17 @@ export function PassportPage() { total={passport.activities.length} /> + } showUserMenu onLogout={() => navigate('/')} />
    -

    {conference.title}

    {t('kid.title')}

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

    {qrError}

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

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

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

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

    - -
    -
    - ) : null} + {passportQrModal}
    - setIsFriendsDialogOpen(false)} + + {selectedFriendKid ? ( + setSelectedFriendKid(undefined)} + /> + ) : null}
    ); diff --git a/src/pages/WelcomePage.tsx b/src/pages/WelcomePage.tsx index 05d3bcc..d5be384 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,13 +16,32 @@ export function WelcomePage() { const activities = useActivitiesData(); const conference = useConferenceData(); const currentUser = useCurrentUser(); + const getActivityCounts = useGetActivityCounts(); const { t } = useI18n(); + const [activityCounts, setActivityCounts] = useState>({}); + const [hasLoadedActivityCounts, setHasLoadedActivityCounts] = useState(false); const [selectedFriendKid, setSelectedFriendKid] = useState(); const blockedKidId = currentUser.role === 'kid' ? currentUser.id : ''; + useEffect(() => { + getActivityCounts() + .then((counts) => { + setActivityCounts(counts); + setHasLoadedActivityCounts(true); + }) + .catch((error) => { + console.error('Unable to load activity completion counts.', error); + setHasLoadedActivityCounts(true); + }); + }, [getActivityCounts]); + return ( <> - +

    {conference.shortName}

    @@ -37,6 +57,16 @@ export function WelcomePage() { {activity.id.padStart(2, '0')} {activity.title} + {hasLoadedActivityCounts ? ( + + {activityCounts[activity.id] ?? 0} + + ) : null} ))} diff --git a/src/styles.css b/src/styles.css index f22f72c..990da4f 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; @@ -198,6 +223,7 @@ h2 { } .welcome-activity-list a { + position: relative; display: grid; min-height: 78px; gap: 4px; @@ -209,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%); @@ -230,6 +274,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; @@ -254,9 +308,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 { @@ -583,6 +675,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; @@ -670,10 +782,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; @@ -691,8 +801,8 @@ h2 { .kid-qr-corner-button img { display: block; - width: 86px; - height: 86px; + width: 82px; + height: 82px; } .kid-qr-overlay { @@ -1498,67 +1608,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 { @@ -1765,6 +1840,33 @@ h2 { font-size: 1rem; } +.passport-header-counters { + display: inline-flex; + gap: 8px; + align-items: center; +} + +.wheel-shot-pill { + display: inline-flex; + min-height: 34px; + gap: 6px; + align-items: center; + border: 1px solid rgb(90 66 40 / 22%); + border-radius: 999px; + padding: 6px 10px; + background: rgb(90 66 40 / 6%); + color: #5a4228; + font-size: 0.9rem; + font-weight: 900; + line-height: 1; +} + +.wheel-shot-pill.available { + border-color: rgb(78 111 43 / 45%); + background: #dfecc2; + color: #34511c; +} + .lead-empty-summary { margin: 0; color: #5a4228;