Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 1 addition & 25 deletions .github/workflows/frontend-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
39 changes: 35 additions & 4 deletions server/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -508,9 +508,23 @@ function parseAdminBackup(body: Record<string, unknown>): AdminBackup {
}

async function handleKids(request: ApiRequest, url: URL): Promise<ApiResponse> {
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();
Expand Down Expand Up @@ -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(
Expand Down
5 changes: 4 additions & 1 deletion src/components/ActivityHero.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Activity } from '../contexts/DataLayerContext';
import { useI18n } from '../i18n/I18nProvider';

type ActivityHeroProps = {
activity: Activity;
Expand All @@ -11,6 +12,8 @@ function getIssueLabel(issueUrl: string) {
}

export function ActivityHero({ activity, eyebrow, headingId }: ActivityHeroProps) {
const { t } = useI18n();

return (
<section className="activity-hero" aria-labelledby={headingId}>
<p className="eyebrow">{eyebrow}</p>
Expand All @@ -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)}
</a>
</h1>
{activity.details ? (
Expand Down
8 changes: 4 additions & 4 deletions src/components/FriendPassportView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -30,10 +31,9 @@ export function FriendPassportView({ kid }: FriendPassportViewProps) {
<section className="friend-passport-view" aria-labelledby="friend-passport-title">
<div className="friend-passport-header">
<div>
<span
className={`kid-gender-icon ${kid.gender}`}
aria-label={t(`registration.gender.${kid.gender}`)}
role="img"
<KidGenderIcon
gender={kid.gender}
label={t(`registration.gender.${kid.gender}`)}
/>
<h3 id="friend-passport-title">{kid.name}</h3>
<FriendStarButton kid={kid} />
Expand Down
15 changes: 2 additions & 13 deletions src/components/FriendStarButton.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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 (
<button
className={friendSelected ? 'friend-star-button active' : 'friend-star-button'}
Expand All @@ -36,7 +25,7 @@ export function FriendStarButton({ kid }: FriendStarButtonProps) {
title={label}
onClick={(event) => {
event.stopPropagation();
toggleFriend(kid.id);
toggleFriend(kid);
}}
>
{friendSelected ? (
Expand Down
29 changes: 24 additions & 5 deletions src/components/KidFinder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -77,10 +85,19 @@ export function KidFinder({
selectKid(matchingKid);
};

const searchManualKid = (event: React.FormEvent<HTMLFormElement>) => {
const searchManualKid = async (event: React.FormEvent<HTMLFormElement>) => {
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);
Expand Down Expand Up @@ -113,7 +130,9 @@ export function KidFinder({
stopScanner: t('scanner.stopScanner'),
}}
onError={(message) => setFormError(message)}
onRead={readKidQrPayload}
onRead={(qrPayload) => {
void readKidQrPayload(qrPayload);
}}
/>
<div className="manual-kid-panel">
<form className="manual-kid-search" onSubmit={searchManualKid}>
Expand Down
38 changes: 38 additions & 0 deletions src/components/KidGenderIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { KidGender } from '../utils/kid-registration';

type KidGenderIconProps = {
gender: KidGender;
label: string;
};

export function KidGenderIcon({ gender, label }: KidGenderIconProps) {
return (
<span
className={`kid-gender-icon ${gender}`}
aria-label={label}
role="img"
>
<svg viewBox="0 0 48 48" aria-hidden="true" focusable="false">
<path className="kid-gender-shoulders" d="M10 43c1.4-8.1 7.1-13 14-13s12.6 4.9 14 13" />
{gender === 'girl' ? (
<>
<path className="kid-gender-hair" d="M11 31c-2.2-7.5-1.1-17.7 3.9-23 2.4-2.6 5.5-4 9.1-4s6.7 1.4 9.1 4c5 5.3 6.1 15.5 3.9 23" />
<path className="kid-gender-face" d="M14 19c0-8.8 4.1-14 10-14s10 5.2 10 14c0 7.2-4.5 12-10 12S14 26.2 14 19Z" />
<path className="kid-gender-detail" d="M14.5 17.2c4.4-.3 8.1-2.2 10.8-6.1 1.7 3.4 4.5 5.4 8.2 6.1" />
</>
) : gender === 'boy' ? (
<>
<path className="kid-gender-face" d="M13 19c0-8.7 4.4-14 11-14s11 5.3 11 14c0 7.2-4.8 12-11 12S13 26.2 13 19Z" />
<path className="kid-gender-hair" d="M13.5 17.8c1.6-8.1 6.6-12.8 13-12.2 4.8.4 8.1 4.3 8.6 10.4-5.2.3-10.1-1.2-14.2-4.5-1.6 3.1-4.1 5.1-7.4 6.3Z" />
</>
) : (
<>
<path className="kid-gender-face" d="M13 19c0-8.6 4.2-14 11-14s11 5.4 11 14c0 7.1-4.7 12-11 12S13 26.1 13 19Z" />
<path className="kid-gender-hair" d="M14 16.2c1.2-7.1 5-11.2 10-11.2s8.8 4.1 10 11.2H14Z" />
<path className="kid-gender-detail" d="M18 22h12" />
</>
)}
</svg>
</span>
);
}
8 changes: 4 additions & 4 deletions src/components/KidList.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Kid } from '../contexts/DataLayerContext';
import { useI18n } from '../i18n/I18nProvider';
import { KidGenderIcon } from './KidGenderIcon';

type KidListProps = {
animatedKidId?: string;
Expand Down Expand Up @@ -28,10 +29,9 @@ export function KidList({
}
}}
>
<span
className={`kid-gender-icon ${kid.gender}`}
aria-label={t(`registration.gender.${kid.gender}`)}
role="img"
<KidGenderIcon
gender={kid.gender}
label={t(`registration.gender.${kid.gender}`)}
/>
<code>{kid.id}</code>
<strong>{kid.name}</strong>
Expand Down
56 changes: 42 additions & 14 deletions src/components/KidsSection.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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 (
<section className="welcome-friends-list" aria-label={t('friends.home.title')}>
<div className="welcome-friends-header">
<h2>{t('friends.home.title')}</h2>
{friends.length > 0 ? (
<button
className="welcome-friends-refresh-button"
type="button"
aria-label={t('friends.refresh')}
title={t('friends.refresh')}
disabled={isRefreshingFriends}
onClick={() => {
void refreshFriendPassports();
}}
>
<SyncIcon size={16} aria-hidden="true" />
</button>
) : null}
</div>
{friends.length > 0 ? (
<ol>
Expand All @@ -55,10 +84,9 @@ export function KidsSection({
type="button"
onClick={() => onKidSelected(kid)}
>
<span
className={`kid-gender-icon ${kid.gender}`}
aria-label={t(`registration.gender.${kid.gender}`)}
role="img"
<KidGenderIcon
gender={kid.gender}
label={t(`registration.gender.${kid.gender}`)}
/>
<strong>{kid.name}</strong>
<ProgressCounter
Expand Down
Loading
Loading