diff --git a/.vscode/settings.json b/.vscode/settings.json index d35e09e..2d31296 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,5 +2,6 @@ "js/ts.preferences.importModuleSpecifier": "non-relative", "files.associations": { "*.css": "tailwindcss" - } + }, + "tailwindCSS.lint.suggestCanonicalClasses": "ignore" } diff --git a/apps/checkin/next.config.ts b/apps/checkin/next.config.ts index 8c125ca..e8be3c8 100644 --- a/apps/checkin/next.config.ts +++ b/apps/checkin/next.config.ts @@ -3,6 +3,9 @@ import type { NextConfig } from 'next'; const nextConfig: NextConfig = { // モノレポ内 workspace パッケージを Next の transpile 対象にする transpilePackages: ['@tecnova/shared', '@tecnova/ui'], + experimental: { + viewTransition: true, + }, }; export default nextConfig; diff --git a/apps/checkin/src/app/first-time/page.tsx b/apps/checkin/src/app/first-time/page.tsx index 508a985..6354c30 100644 --- a/apps/checkin/src/app/first-time/page.tsx +++ b/apps/checkin/src/app/first-time/page.tsx @@ -29,10 +29,15 @@ import { Input } from '@tecnova/ui/components/input'; import { Skeleton } from '@tecnova/ui/components/skeleton'; import { Table, TableBody, TableCell, TableRow } from '@tecnova/ui/components/table'; import { apiFetch, readErrorMessage } from '@tecnova/ui/lib/api-client'; +import { motion, useReducedMotion } from 'motion/react'; import Link from 'next/link'; import { useCallback, useEffect, useMemo, useState } from 'react'; +import { AnimatedNumber } from '@/components/animated-number'; +import { PageShell } from '@/components/page-shell'; import { PanelHeader } from '@/components/panel-header'; +import { Reveal } from '@/components/reveal'; import { formatJapaneseDate } from '@/lib/format'; +import { listItemTransition } from '@/lib/motion'; type State = | { kind: 'loading' } @@ -73,6 +78,7 @@ function ParticipantDetails({ item }: { item: PreRegisteredParticipant }) { } function RegistrationSteps() { + const prefersReduced = useReducedMotion(); const steps = [ { number: '1', @@ -93,8 +99,14 @@ function RegistrationSteps() { return (
    - {steps.map((step) => ( -
  1. + {steps.map((step, index) => ( + {step.number} @@ -104,13 +116,14 @@ function RegistrationSteps() { {step.description} -
  2. + ))}
); } export default function FirstTimePage() { + const prefersReduced = useReducedMotion(); const [state, setState] = useState({ kind: 'loading' }); const [query, setQuery] = useState(''); @@ -151,7 +164,7 @@ export default function FirstTimePage() { if (state.kind === 'loading') { return ( -
+
@@ -170,7 +183,7 @@ export default function FirstTimePage() {
-
+ ); } @@ -201,158 +214,170 @@ export default function FirstTimePage() { } return ( -
+
- - } title="初回登録" tone="emerald" /> - -
- - IDカードをまだ持っていない人を、事前登録の一覧から選んでください。 - - - 未登録 {state.items.length}人 - -
+ + + } + title="初回登録" + tone="emerald" + /> + +
+ + IDカードをまだ持っていない人を、事前登録の一覧から選んでください。 + + + 未登録 + 人 + +
- + - {state.items.length === 0 ? ( - - - ) : ( - <> -
-
-
-
- {query ? ( + {state.items.length === 0 ? ( + + + ) : ( + <> +
+
+
+
+ {query ? ( + + ) : null} - ) : null} - +
-
- {filteredItems.length === 0 ? ( - - - ) : ( -
    - {filteredItems.map((item) => ( -
  • - - - - - - - - - {item.nickname}さんで進みますか - - 次に参加ガイドラインを確認します。最後に同意するとIDが発行されます。 - - - - - キャンセル - - - - -
  • - ))} -
- )} - - )} - - + + + + + + {item.nickname}さんで進みますか + + 次に参加ガイドラインを確認します。最後に同意するとIDが発行されます。 + + + + + キャンセル + + + + + + ))} + + )} + + )} + + +
-
+ ); } diff --git a/apps/checkin/src/app/guideline/page.tsx b/apps/checkin/src/app/guideline/page.tsx index ab5bf62..95557dd 100644 --- a/apps/checkin/src/app/guideline/page.tsx +++ b/apps/checkin/src/app/guideline/page.tsx @@ -42,12 +42,15 @@ import { Skeleton } from '@tecnova/ui/components/skeleton'; import { Table, TableBody, TableCell, TableRow } from '@tecnova/ui/components/table'; import { apiFetch, readErrorMessage } from '@tecnova/ui/lib/api-client'; import { cn } from '@tecnova/ui/lib/utils'; +import { AnimatePresence, motion, useReducedMotion } from 'motion/react'; import Link from 'next/link'; import { useSearchParams } from 'next/navigation'; import { type ReactNode, Suspense, useCallback, useEffect, useMemo, useState } from 'react'; +import { PageShell } from '@/components/page-shell'; import { PanelHeader } from '@/components/panel-header'; import { ResultSummaryCard } from '@/components/result-summary-card'; import { formatJapaneseDate, formatJapaneseDateTime } from '@/lib/format'; +import { popAnimate, popInitial, popTransition } from '@/lib/motion'; type State = | { kind: 'loading' } @@ -376,7 +379,7 @@ function ParticipantStatusChip({ item }: { item: PreRegisteredParticipant }) { function LoadingScreen() { return ( -
+
@@ -389,7 +392,7 @@ function LoadingScreen() {
-
+ ); } @@ -449,7 +452,7 @@ function ErrorScreen({ function ActivatingScreen({ item }: { item: PreRegisteredParticipant }) { return ( -
+ } @@ -463,7 +466,7 @@ function ActivatingScreen({ item }: { item: PreRegisteredParticipant }) {

-
+ ); } @@ -474,6 +477,7 @@ function GuidelineSlideView({ total, isLast, agreed, + direction, onAgreeChange, onPrev, onNext, @@ -485,6 +489,7 @@ function GuidelineSlideView({ total: number; isLast: boolean; agreed: boolean; + direction: number; onAgreeChange: (checked: boolean) => void; onPrev: () => void; onNext: () => void; @@ -492,9 +497,21 @@ function GuidelineSlideView({ }) { const tone = guidelineToneClasses[slide.tone]; const progress = `${(current / total) * 100}%`; + const prefersReduced = useReducedMotion(); + const offset = 40; + + // 矢印キーでスライドを前後に移動できるようにする(iPad の外付けキーボード運用を想定)。 + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'ArrowRight' && !isLast) onNext(); + if (e.key === 'ArrowLeft' && current > 1) onPrev(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [current, isLast, onNext, onPrev]); return ( -
+
-
-
-
- -

{slide.visual}

-
+
+ +

{slide.visual}

+
-
-

{slide.group}

-

- {slide.title} -

-

- {slide.rule} -

-

- {slide.explanation} -

-
-
+
+

{slide.group}

+

+ {slide.title} +

+

+ {slide.rule} +

+

+ {slide.explanation} +

+
+ + + {isLast ? (
@@ -620,7 +659,7 @@ function GuidelineSlideView({
-
+ ); } @@ -630,6 +669,16 @@ function GuidelinePageContent() { const [state, setState] = useState({ kind: 'loading' }); const [slideIndex, setSlideIndex] = useState(0); const [agreed, setAgreed] = useState(false); + const [direction, setDirection] = useState(1); + + const goPrev = () => { + setDirection(-1); + setSlideIndex((index) => Math.max(0, index - 1)); + }; + const goNext = () => { + setDirection(1); + setSlideIndex((index) => Math.min(GUIDELINE_SLIDES.length - 1, index + 1)); + }; const loadTarget = useCallback(async () => { if (!preRegistrationId) { @@ -719,22 +768,12 @@ function GuidelinePageContent() { tone="emerald" icon={} rows={[ - { - label: 'ID', - value: state.data.participantId, - valueClassName: 'tabular-nums', - }, + { label: 'ID', value: state.data.participantId, valueClassName: 'tabular-nums' }, { label: '氏名', value: state.data.fullName }, { label: 'ニックネーム', value: state.data.nickname }, { label: '学年', value: state.data.grade }, - { - label: '初回チェックイン', - value: formatJapaneseDateTime(state.data.checkedInAt), - }, - { - label: '事前登録日', - value: formatJapaneseDate(state.registeredAt), - }, + { label: '初回チェックイン', value: formatJapaneseDateTime(state.data.checkedInAt) }, + { label: '事前登録日', value: formatJapaneseDate(state.registeredAt) }, ]} note="表示されたIDでカードを作ってください" footer={ @@ -763,9 +802,10 @@ function GuidelinePageContent() { total={GUIDELINE_SLIDES.length} isLast={slideIndex === GUIDELINE_SLIDES.length - 1} agreed={agreed} + direction={direction} onAgreeChange={setAgreed} - onPrev={() => setSlideIndex((index) => Math.max(0, index - 1))} - onNext={() => setSlideIndex((index) => Math.min(GUIDELINE_SLIDES.length - 1, index + 1))} + onPrev={goPrev} + onNext={goNext} onSubmit={() => void activate()} /> ); diff --git a/apps/checkin/src/app/history/page.tsx b/apps/checkin/src/app/history/page.tsx index 71520d0..94f5ba3 100644 --- a/apps/checkin/src/app/history/page.tsx +++ b/apps/checkin/src/app/history/page.tsx @@ -43,15 +43,22 @@ import { } from '@tecnova/ui/components/table'; import { TermBadge, UncountedBadge } from '@tecnova/ui/components/term-badge'; import { apiFetch, readErrorMessage } from '@tecnova/ui/lib/api-client'; +import { motion, useReducedMotion } from 'motion/react'; import Link from 'next/link'; import { useCallback, useEffect, useMemo, useState } from 'react'; +import { AnimatedNumber } from '@/components/animated-number'; +import { LiveDot } from '@/components/live-dot'; +import { PageShell } from '@/components/page-shell'; import { PanelHeader } from '@/components/panel-header'; +import { Reveal } from '@/components/reveal'; +import { StatTile } from '@/components/stat-tile'; import { formatDuration, formatJapaneseDate, formatJapaneseDateTime, formatJapaneseDateTimeWithYear, } from '@/lib/format'; +import { listItemTransition } from '@/lib/motion'; import { participantProfilePath } from '@/lib/participant-id'; const fetchTodayHistory = async (): Promise => { @@ -83,7 +90,7 @@ const getSessionStayDurationMinutes = (session: TodaySessionItem, nowMs: number) function LoadingScreen() { return ( -
+
@@ -97,7 +104,7 @@ function LoadingScreen() {
-
+ ); } @@ -177,6 +184,7 @@ function CheckoutDialog({ } export default function HistoryPage() { + const prefersReduced = useReducedMotion(); const [data, setData] = useState(null); const [isLoading, setIsLoading] = useState(true); const [isSubmitting, setIsSubmitting] = useState(false); @@ -302,227 +310,255 @@ export default function HistoryPage() { const eventLabel = data?.event ? formatJapaneseDate(data.event.date) : '今日'; return ( -
+
- - } - title="受付りれき" - tone="sky" - /> - -
-
- - {eventLabel}の受付履歴と参加者の状態を確認できます。 - 「滞在中全員をチェックアウト」は12:00や各タームの終わりに締めるときに使います。 - -
-
- - void checkoutParticipants(presentIds)} - /> + + + } + title="受付りれき" + tone="sky" + /> + +
+
+ + {eventLabel}の受付履歴と参加者の状態を確認できます。 + 「滞在中全員をチェックアウト」は12:00や各タームの終わりに締めるときに使います。 + +
+
+ + void checkoutParticipants(presentIds)} + /> +
-
-
-
-

今日の受付

-

- {summary.totalCheckedIn} - -

-
-
-

滞在中

-

- {summary.currentlyPresent} - -

-
-
-

退室済み

-

- {summary.checkedOut} - -

+
+ + + + + } + /> + + 0} /> + 滞在中 + + } + value={ + <> + + + + } + /> + + + + + } + />
-
- {lastResult && ( - - - )} + {lastResult && ( + + + )} - {error && ( - - - )} + {error && ( + + + )} -
- setQuery(e.target.value)} - placeholder="ID・ニックネーム・氏名・学年で検索" - className="h-14 rounded-lg bg-white px-5 text-lg" - /> -
- - void checkoutParticipants(selectedPresentIds)} +
+ setQuery(e.target.value)} + placeholder="ID・ニックネーム・氏名・学年で検索" + className="h-14 rounded-lg bg-white px-5 text-lg" /> +
+ + void checkoutParticipants(selectedPresentIds)} + /> +
-
- - + + + - - - {filteredSessions.length === 0 ? ( -
-
-
- ) : ( -
- - - - - toggleFilteredPresent(checked === true)} - className="size-6 rounded-md" - /> - - 参加者 - 状態 - 受付時刻 - 滞在時間 - 操作 - - - - {filteredSessions.map((session) => { - const stayDurationMinutes = getSessionStayDurationMinutes(session, nowMs); - return ( - - - {session.isPresent ? ( - - toggleParticipant(session.participantId, checked === true) - } - className="size-6 rounded-md" - /> - ) : null} - - -
- - {session.nickname} - - - {session.fullName} - - - ID {session.participantId} - / - {session.grade} - -
-
- -
- - {session.isPresent ? '滞在中' : '退室済み'} - - {session.term ? ( - + ) : ( +
+
+ + + + toggleFilteredPresent(checked === true)} + className="size-6 rounded-md" + /> + + 参加者 + 状態 + 受付時刻 + 滞在時間 + 操作 + + + + {filteredSessions.map((session, index) => { + const stayDurationMinutes = getSessionStayDurationMinutes(session, nowMs); + return ( + + + {session.isPresent ? ( + + toggleParticipant(session.participantId, checked === true) + } + className="size-6 rounded-md" + /> ) : null} - {!session.counted && } - - - - {formatJapaneseDateTimeWithYear(session.checkedInAt)} - - - {session.isPresent - ? `${formatDuration(stayDurationMinutes)} 経過` - : formatDuration(stayDurationMinutes)} - - - - - - ); - })} - -
-
- )} - - + + +
+ + {session.nickname} + + + {session.fullName} + + + ID {session.participantId} + / + {session.grade} + +
+
+ +
+ + {session.isPresent ? '滞在中' : '退室済み'} + + {session.term ? ( + + ) : null} + {!session.counted && } +
+
+ + {formatJapaneseDateTimeWithYear(session.checkedInAt)} + + + {session.isPresent + ? `${formatDuration(stayDurationMinutes)} 経過` + : formatDuration(stayDurationMinutes)} + + + + + + ); + })} + + +
+ )} +
+
+
-
+ ); } diff --git a/apps/checkin/src/app/layout.tsx b/apps/checkin/src/app/layout.tsx index 4e729d0..678a33f 100644 --- a/apps/checkin/src/app/layout.tsx +++ b/apps/checkin/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata, Viewport } from 'next'; import { LINE_Seed_JP } from 'next/font/google'; import '@tecnova/ui/globals.css'; +import './view-transitions.css'; import { cn } from '@tecnova/ui/lib/utils'; import { AppShell } from '@/components/app-shell'; diff --git a/apps/checkin/src/app/login/page.tsx b/apps/checkin/src/app/login/page.tsx index 9365ce5..829577a 100644 --- a/apps/checkin/src/app/login/page.tsx +++ b/apps/checkin/src/app/login/page.tsx @@ -3,20 +3,19 @@ import { IconBrandGoogleFilled } from '@tabler/icons-react'; import { Alert, AlertDescription, AlertTitle } from '@tecnova/ui/components/alert'; import { Button } from '@tecnova/ui/components/button'; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from '@tecnova/ui/components/card'; +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@tecnova/ui/components/card'; +import { motion, useReducedMotion } from 'motion/react'; +import Image from 'next/image'; import { useState } from 'react'; +import { PageShell } from '@/components/page-shell'; +import { Reveal } from '@/components/reveal'; import { authClient } from '@/lib/auth-client'; +import { tapScale } from '@/lib/motion'; export default function LoginPage() { const [error, setError] = useState(null); const [busy, setBusy] = useState(false); + const prefersReduced = useReducedMotion(); const signIn = async () => { setBusy(true); @@ -39,27 +38,54 @@ export default function LoginPage() { }; return ( -
- - - うけつけシステム - 許可リストに登録されたメンターのみ利用できます - - {error && ( - - - ログインエラー - {error} - - - )} - - - - -
+ + + + +
+ TECNOVA +
+ ようこそ +

+ うけつけシステムにサインインしてください +

+
+
+
+ {error && ( + + + ログインエラー + {error} + + + )} + + + + +

+ 許可リストに登録されたメンターのみ利用できます +

+
+
+
+
); } diff --git a/apps/checkin/src/app/manual/page.tsx b/apps/checkin/src/app/manual/page.tsx index 06781a9..b78c74e 100644 --- a/apps/checkin/src/app/manual/page.tsx +++ b/apps/checkin/src/app/manual/page.tsx @@ -3,7 +3,6 @@ import { IconAlertCircle, IconArrowRight, - IconBug, IconChevronRight, IconKeyboard, IconSearch, @@ -18,73 +17,45 @@ import { Input } from '@tecnova/ui/components/input'; import { Skeleton } from '@tecnova/ui/components/skeleton'; import { apiFetch, readErrorMessage } from '@tecnova/ui/lib/api-client'; import { cn } from '@tecnova/ui/lib/utils'; +import { AnimatePresence, motion, useReducedMotion } from 'motion/react'; import { useRouter } from 'next/navigation'; -import { type FormEvent, type ReactNode, useEffect, useState } from 'react'; +import { type FormEvent, useEffect, useState } from 'react'; +import { PageShell } from '@/components/page-shell'; import { PanelHeader } from '@/components/panel-header'; +import { SegmentedControl } from '@/components/segmented-control'; +import { listItemTransition } from '@/lib/motion'; import { PARTICIPANT_ID_PATTERN, participantProfilePath } from '@/lib/participant-id'; type Mode = 'id' | 'name'; export default function ManualPage() { const [mode, setMode] = useState('id'); - + const prefersReduced = useReducedMotion(); return ( -
+
- - {mode === 'id' ? : } + }, + { value: 'name', label: '名前で探す', icon: }, + ]} + /> + + + {mode === 'id' ? : } + +
-
- ); -} - -function ModeToggle({ mode, onChange }: { mode: Mode; onChange: (mode: Mode) => void }) { - return ( -
- onChange('id')} - icon={} - label="IDで入力" - /> - onChange('name')} - icon={} - label="名前で探す" - /> -
- ); -} - -function ToggleButton({ - active, - onClick, - icon, - label, -}: { - active: boolean; - onClick: () => void; - icon: ReactNode; - label: string; -}) { - return ( - + ); } @@ -103,7 +74,11 @@ function IdEntryPanel() { return (
- } title="マニュアル入力" tone="slate" /> + } + title="マニュアル入力" + tone="slate" + /> 参加者IDがわかる場合は、5桁の数字を入力してください。 @@ -242,6 +217,7 @@ function SearchResults({ navigatingId: string | null; onSelect: (id: string) => void; }) { + const prefersReduced = useReducedMotion(); if (state.kind === 'idle') { return (

@@ -286,15 +262,20 @@ function SearchResults({ {state.results.length}件の候補(タップして開く)

    - {state.results.map((participant) => ( -
  • + {state.results.map((participant, index) => ( + onSelect(participant.id)} /> -
  • + ))}
diff --git a/apps/checkin/src/app/page.tsx b/apps/checkin/src/app/page.tsx index f080874..0407721 100644 --- a/apps/checkin/src/app/page.tsx +++ b/apps/checkin/src/app/page.tsx @@ -2,10 +2,11 @@ import { IconArrowRight, - IconBug, IconCamera, IconCameraRotate, + IconCircleCheck, IconClipboardCheck, + IconKeyboard, IconQrcode, IconRefresh, IconUserPlus, @@ -13,13 +14,18 @@ import { import { Button } from '@tecnova/ui/components/button'; import { Card, CardContent, CardDescription } from '@tecnova/ui/components/card'; import { BrowserMultiFormatReader, type IScannerControls } from '@zxing/browser'; +import { motion, useReducedMotion } from 'motion/react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; -import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'; +import { type ReactNode, useCallback, useEffect, useRef, useState, ViewTransition } from 'react'; +import { PageShell } from '@/components/page-shell'; import { PanelHeader, type PanelTone } from '@/components/panel-header'; +import { Reveal } from '@/components/reveal'; +import { ScanReticle } from '@/components/scan-reticle'; import { PARTICIPANT_ID_PATTERN, participantProfilePath } from '@/lib/participant-id'; function ActionPanel({ + index, title, description, icon, @@ -28,6 +34,7 @@ function ActionPanel({ action, buttonVariant = 'default', }: { + index: number; title: string; description: string; icon: ReactNode; @@ -37,26 +44,29 @@ function ActionPanel({ buttonVariant?: 'default' | 'outline'; }) { return ( - - - - - {description} - -
- - - + + + + + + {description} + +
+ + + + ); } export default function Home() { const router = useRouter(); + const prefersReduced = useReducedMotion(); const [cameraError, setCameraError] = useState(null); const [scannerAttempt, setScannerAttempt] = useState(0); const [videoDevices, setVideoDevices] = useState([]); @@ -65,6 +75,7 @@ export default function Home() { const videoRef = useRef(null); const controlsRef = useRef(null); const navigatingRef = useRef(false); + const navTimerRef = useRef(null); const refreshVideoDevices = useCallback(async () => { if (!navigator.mediaDevices?.enumerateDevices) return []; @@ -98,7 +109,9 @@ export default function Home() { navigatingRef.current = true; controlsRef.current?.stop(); setNavigatingId(value); - router.push(participantProfilePath(value)); + navTimerRef.current = window.setTimeout(() => { + router.push(participantProfilePath(value)); + }, 450); }) .then((controls) => { if (cancelled) { @@ -125,6 +138,16 @@ export default function Home() { }; }, [navigatingId, refreshVideoDevices, router, scannerAttempt, selectedDeviceId]); + // ナビゲーション遅延タイマーはアンマウント時のみ掃除する。 + // スキャナ effect の cleanup(navigatingId 変更で毎回走る)で消すと遷移がキャンセルされるため分離する。 + useEffect(() => { + return () => { + if (navTimerRef.current) { + window.clearTimeout(navTimerRef.current); + } + }; + }, []); + const switchCamera = async () => { const devices = videoDevices.length > 0 ? videoDevices : await refreshVideoDevices(); if (devices.length <= 1) { @@ -140,63 +163,88 @@ export default function Home() { }; return ( -
+
- - } - title="QRコードをかざしてね" - tone="sky" - /> - -
-
-
- - -
-
-
+ + + } + title="QRコードをかざしてね" + tone="sky" + /> + +
+
+
+ + +
+
+
+
} @@ -204,8 +252,8 @@ export default function Home() { href="/first-time" action="初回登録へ" /> - } @@ -213,11 +261,11 @@ export default function Home() { href="/history" action="履歴を見る" /> - } + icon={} tone="slate" href="/manual" action="入力へ" @@ -226,6 +274,6 @@ export default function Home() {
-
+ ); } diff --git a/apps/checkin/src/app/reception/participants/[id]/page.tsx b/apps/checkin/src/app/reception/participants/[id]/page.tsx index 74457b7..9682c81 100644 --- a/apps/checkin/src/app/reception/participants/[id]/page.tsx +++ b/apps/checkin/src/app/reception/participants/[id]/page.tsx @@ -38,7 +38,7 @@ import { apiFetch, readErrorMessage } from '@tecnova/ui/lib/api-client'; import { motion, useReducedMotion } from 'motion/react'; import Link from 'next/link'; import { useParams } from 'next/navigation'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState, ViewTransition } from 'react'; import { AnimatedNumber } from '@/components/animated-number'; import { PanelHeader } from '@/components/panel-header'; import { ResultSummaryCard } from '@/components/result-summary-card'; @@ -350,27 +350,20 @@ export default function ReceptionParticipantPage() { { label: '滞在時間', value: formatDuration(data.stayDurationMinutes) }, ]; return ( - - : } - rows={resultRows} - footer={ - - } - /> - + : } + rows={resultRows} + footer={ + + } + /> ); } @@ -380,122 +373,126 @@ export default function ReceptionParticipantPage() {
- - - -
-
-
-
-
- - {profile.participant.nickname} - -

- {profile.participant.fullName} -

-
- - ID {profile.participant.id} - - - {profile.participant.grade} - + + + + +
+
+
+
+
+ + {profile.participant.nickname} + +

+ {profile.participant.fullName} +

+
+ + ID {profile.participant.id} + + + {profile.participant.grade} + +
+
+ {profile.current.isPresent ? ( +
-
- {profile.current.isPresent ? ( -
-
-
-

-

-

- {profile.current.checkedInAt - ? formatJapaneseDateTime(profile.current.checkedInAt) - : 'まだありません'} -

-
- -
-
-

-

+

+

-

- - +

+ {profile.current.checkedInAt + ? formatJapaneseDateTime(profile.current.checkedInAt) + : 'まだありません'}

-
- {participationBreakdown.map((item) => ( -
-
-
-
- {item.value} -
-
- ))} -
- {stats.map((item) => ( -
-

-

+
+

+

-

- {item.value} +

+ +

+
+ {participationBreakdown.map((item) => ( +
+
+
+
+ {item.value} +
+
+ ))} +
- ))} -
- - - + {stats.map((item) => ( +
+

+

+

+ {item.value} +

+
+ ))} +
+ + + +
+
- - -
-
-
- - - {error && ( - - ログアウトエラー - {error} - - )} -
- - - - 項目 - 内容 - - - - {rows.map((row) => ( - - - {row.label} - - - {row.value} - + + + {error && ( + + ログアウトエラー + {error} + + )} +
+
+ + + 項目 + 内容 - ))} - -
-
-
- - - - - - - - - ログアウトしますか - - 受付システムを使うには、もう一度 Google ログインが必要です。 - - - - - キャンセル - - + + {rows.map((row) => ( + + + {row.label} + + + {row.value} + + + ))} + + +
+
+ + +
-
- + + ログアウト + + + + + + + ログアウトしますか + + 受付システムを使うには、もう一度 Google ログインが必要です。 + + + + + キャンセル + + void signOut()} + > + {isSigningOut ? 'ログアウト中' : 'ログアウトする'} + + + + +
+ +
+
-
+ ); } diff --git a/apps/checkin/src/app/view-transitions.css b/apps/checkin/src/app/view-transitions.css new file mode 100644 index 0000000..63bebd3 --- /dev/null +++ b/apps/checkin/src/app/view-transitions.css @@ -0,0 +1,22 @@ +/* View Transitions(experimental.viewTransition)の調整。 + ルート間の既定クロスフェードと、スキャン→プロフィールの共有要素モーフの時間を整える。 */ +::view-transition-old(root), +::view-transition-new(root) { + animation-duration: 0.28s; +} + +/* スキャン→プロフィールの共有要素モーフ(name="participant-portal") */ +::view-transition-group(participant-portal) { + animation-duration: 0.4s; + animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1); +} + +/* アクセシビリティ: モーション低減時はビュー遷移アニメーションを無効化する */ +@media (prefers-reduced-motion: reduce) { + ::view-transition-group(*), + ::view-transition-old(*), + ::view-transition-new(*) { + /* biome-ignore lint/complexity/noImportantStyles: モーション低減時のアクセシビリティ上書き。上の named morph 規則(participant-portal)に確実に勝つため !important が必要。 */ + animation-duration: 0s !important; + } +} diff --git a/apps/checkin/src/components/live-dot.tsx b/apps/checkin/src/components/live-dot.tsx new file mode 100644 index 0000000..ff10452 --- /dev/null +++ b/apps/checkin/src/components/live-dot.tsx @@ -0,0 +1,28 @@ +'use client'; + +import { cn } from '@tecnova/ui/lib/utils'; +import { motion, useReducedMotion } from 'motion/react'; + +// 滞在中=emerald の脈動、未滞在=slate の静止。プロフィール画面の在席ドットと同じ演出。 +export function LiveDot({ active, className }: { active: boolean; className?: string }) { + const prefersReduced = useReducedMotion(); + + if (!active) { + return ( +