src/components/checkin/checkin-page-content.tsx
Rama main · réplica de lectura del repositorio original.
"use client";
import { useCallback, useEffect, useState } from "react";
import { useCheckinDetails } from "@/contexts/CheckinDetailsContext";
import { useAuthGuard } from "@/hooks/useAuthGuard";
import { useCheckinLink } from "@/hooks/useCheckinLink";
import { useUnits } from "@/hooks/useUnits";
import { useSavedElsewhere } from "@/hooks/useSavedElsewhere";
import { CheckInPageFrame } from "@/components/checkin/checkin-page-frame";
import {
fetchAllCertifications,
type Certification,
} from "@/lib/api/certifications";
import { diveStatsAPI, type UserDiveStats } from "@/lib/api/dive-stats";
import { divesAPI } from "@/lib/api/dives";
import { fetchAllContacts, type Contact } from "@/lib/api/contacts";
import { fetchAllPeople, type Person } from "@/lib/api/people";
import { ownCheckInDiver } from "@/lib/checkin";
import { PageSpinner } from "@/components/ui/page-spinner";
// The summary a diver hands to a dive shop. `CheckInPageFrame` draws it; this reads
// what the page does not already hold. The name and portrait arrive with the
// signed-in user, so they are on screen at the click; the check-in details come from
// the shared copy, which on a full load of this page is still in flight, and their
// sections hold their shape until it lands.
export function CheckInPageContent() {
const { user, isAuthenticated, isLoading } = useAuthGuard();
const checkin = useCheckinDetails();
const units = useUnits();
const [certifications, setCertifications] = useState<Certification[]>([]);
const [contacts, setContacts] = useState<Contact[]>([]);
const [people, setPeople] = useState<Person[]>([]);
const [stats, setStats] = useState<UserDiveStats | null>(null);
const [lastDiveAt, setLastDiveAt] = useState<string | null>(null);
const [isSummaryLoading, setIsSummaryLoading] = useState(true);
const [loadFailed, setLoadFailed] = useState(false);
const [figuresFailed, setFiguresFailed] = useState(false);
const [attempt, setAttempt] = useState(0);
// Keyed on the uuid rather than on `user`: the auth context replaces that object
// whenever anything on the account is saved, and a re-fetch of the whole summary
// on each of those would be a round of requests for values none of them changed.
//
// A plain effect, not `useEffectOnChange`: this one starts requests and abandons
// them in its cleanup, so guarding on deps would cancel on hide and skip on show
// and the page would never load. Re-reading when a hidden route comes back is what
// the data hooks do deliberately - a dive logged elsewhere has already made these
// three figures stale.
const userUuid = user?.uuid;
const sharing = useCheckinLink(!!userUuid);
useEffect(() => {
if (!userUuid) return;
const controller = new AbortController();
// Settled rather than `all`: they answer different questions, and one of them
// failing must not take the ones that arrived off the page with it. The c-cards
// are the half a desk actually reads, and they do not depend on the dive count.
const load = async () => {
const [cards, diveStats, recent, contactList, peopleList] =
await Promise.allSettled([
// Every page of them: a diver holds a handful of cards, and a summary that
// stopped at ten would leave one off the page at the desk.
fetchAllCertifications(controller.signal),
diveStatsAPI.getDiveStats(),
// The last dive is read off the list rather than stored: `GET /dives` is
// sorted by start time descending, so the first row of the first page is
// it. Same read the new-dive form makes to carry a dive forward.
divesAPI.getDives(1, 1),
// The dive centers and the instructors the cards name, which a card
// holds only by uuid. Each whole list in one read rather than one per
// card, and alongside the rest rather than after the cards: a diver
// keeps tens.
fetchAllContacts(controller.signal),
fetchAllPeople(controller.signal),
]);
if (controller.signal.aborted) return;
if (cards.status === "fulfilled") setCertifications(cards.value);
if (contactList.status === "fulfilled") setContacts(contactList.value);
if (peopleList.status === "fulfilled") setPeople(peopleList.value);
if (diveStats.status === "fulfilled") setStats(diveStats.value);
if (recent.status === "fulfilled") {
setLastDiveAt(recent.value.data[0]?.start_time ?? null);
}
// Said out loud rather than swallowed the way Home's supplementary
// cards swallow theirs: this page is handed to somebody else, and a summary
// quietly missing its certifications is worse than one that says so. What
// did arrive stays on screen regardless.
const failed = [cards, diveStats, recent, contactList, peopleList].filter(
(result) => result.status === "rejected",
);
for (const result of failed) {
console.error("Failed to load part of the check-in summary:", result);
}
setLoadFailed(failed.length > 0);
setFiguresFailed(
diveStats.status === "rejected" || recent.status === "rejected",
);
setIsSummaryLoading(false);
};
load();
return () => controller.abort();
}, [userUuid, attempt]);
// After a card is edited from the summary itself. Only the lists are re-read -
// the cards, and the contacts and people, since the edit may have made one to
// name - and the
// stats and the last dive cannot have moved. The loading flag is left alone,
// so the cards already on screen stay put rather than flashing back to skeletons.
// A re-read rather than patching the saved card in place: the order is
// `GET /certifications`' (`certified_on` descending, nulls last), and an edited
// `certified_on` has to land where that puts it.
const refreshCertifications = useCallback(async () => {
try {
const [cards, contactList, peopleList] = await Promise.all([
fetchAllCertifications(),
fetchAllContacts(),
fetchAllPeople(),
]);
setCertifications(cards);
setContacts(contactList);
setPeople(peopleList);
} catch (error) {
console.error("Failed to re-read the certifications:", error);
setLoadFailed(true);
}
}, []);
// The header's bell edits certifications too, from over this page.
useSavedElsewhere("certification", () => void refreshCertifications());
if (isLoading) {
return <PageSpinner variant="inset" />;
}
if (!isAuthenticated || !user) {
return null; // Will redirect to signin
}
const contactNames = namesByCertification(
certifications,
contacts,
(certification) => certification.contact_uuid,
);
const instructorNames = namesByCertification(
certifications,
people,
(certification) => certification.instructor_uuid,
);
return (
<CheckInPageFrame
diver={ownCheckInDiver(user, checkin.details)}
details={
checkin.details ? undefined : checkin.loadFailed ? "failed" : "loading"
}
units={units}
certifications={certifications}
contactNames={contactNames}
instructorNames={instructorNames}
stats={stats}
lastDiveAt={lastDiveAt}
isLoading={isSummaryLoading}
loadFailed={loadFailed || checkin.loadFailed}
figuresFailed={figuresFailed}
onCertificationsChanged={refreshCertifications}
sharing={sharing}
onRetry={() => {
if (checkin.loadFailed) checkin.reload();
setIsSummaryLoading(true);
setLoadFailed(false);
setFiguresFailed(false);
setAttempt((n) => n + 1);
}}
/>
);
}
// The name of the record each card names - its dive center, its instructor - by
// the card's own uuid, the shape the frame takes. A card naming one the list no
// longer holds simply has no row.
function namesByCertification(
certifications: Certification[],
records: { uuid: string; name: string }[],
uuidOf: (certification: Certification) => string | null | undefined,
): Record<string, string> {
const names = new Map(records.map((record) => [record.uuid, record.name]));
const byCard: Record<string, string> = {};
for (const certification of certifications) {
const uuid = uuidOf(certification);
const name = uuid ? names.get(uuid) : undefined;
if (name) byCard[certification.uuid] = name;
}
return byCard;
}