src/components/checkin/checkin-page-frame.tsx
Rama main · réplica de lectura del repositorio original.
"use client";
import {
useCallback,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import {
AlertTriangle,
Cake,
FileText,
Mail,
Phone,
Printer,
Share2,
UserSquare,
type LucideIcon,
} from "lucide-react";
import type { CheckinLinkControls } from "@/hooks/useCheckinLink";
import {
certificationAgencyLabel,
certificationFile,
type Certification,
} from "@/lib/api/certifications";
import {
fetchSharedPicture,
sharedCardFrontUrl,
sharedPortraitUrl,
type SharedCheckInCertification,
} from "@/lib/api/checkin-links";
import type { UserDiveStats } from "@/lib/api/dive-stats";
import {
hasDivingFigures,
loggedDivingFigures,
type CheckInDiver,
type DivingFigures,
} from "@/lib/checkin";
import { formatDateOnly, formatDateTime } from "@/lib/date-time";
import { todayIsoDate } from "@/lib/date-time";
import { formatDepth, type UnitSystem } from "@/lib/units";
import { shrinkPicture } from "@/lib/shrink-picture";
import { cn } from "@/lib/utils";
import { useAuthedBlobUrl } from "@/hooks/useAuthedBlobUrl";
import {
CertificationCardFrame,
CertificationCardImage,
} from "@/components/certifications/certification-card-image";
import { CertificationDialog } from "@/components/certifications/certification-dialog";
import { CheckinDetailsDialog } from "@/components/checkin/checkin-details-dialog";
import { CheckInLinkPanel } from "@/components/checkin/checkin-share";
import { DivingFiguresDialog } from "@/components/checkin/diving-figures-dialog";
import { Logo } from "@/components/logo";
import { PortraitFrame, PortraitImage } from "@/components/user/portrait-image";
import { Button } from "@/components/ui/button";
import { EditControl } from "@/components/ui/edit-control";
import { Card, CardContent } from "@/components/ui/card";
import { HERO_BODY, IndexHero } from "@/components/ui/map-hero";
import { Skeleton } from "@/components/ui/skeleton";
import { IconTooltip } from "@/components/ui/tooltip";
// A printed page comes off a browser with background colours dropped and text
// colours kept, so a summary printed from the dark theme would be near-white ink on
// white paper. Every string that carries a colour is forced black in print media;
// there is no `@media print` block anywhere in this app, and this is why there does
// not need to be one.
const INK = "print:text-black";
const MUTED = `text-sm text-muted-foreground ${INK}`;
// The column a c-card's picture sits in, beside the lines that describe it.
//
// Width only, deliberately: a card's height comes from the one aspect ratio the app
// draws every c-card in (`CertificationCardFrame`), and a height set here would
// fight it.
//
// Its `print:` twin repeats its `sm:` value. Tailwind's `sm:` is a min-width query,
// and under print media the width is the paper's - so a sheet printed from a phone,
// or a browser scaling one down, would otherwise drop the picture to its phone size
// on paper alone.
const SLOT = "w-16 shrink-0 sm:w-24 print:w-24";
// The pixels a picture in `SLOT` is redrawn to on its long edge: about 300 dpi across
// the inch it prints at, and three device pixels to each of its 96 on screen.
const PICTURE_EDGE = 384;
// What separates a picture from the name beside it, and it is a derived number
// rather than a chosen one: `SLOT` plus this has to come to `DetailList`'s label
// track plus that list's own column gap, or a certification's name and the values
// underneath it start a few pixels apart and the eye catches it. 96 + 16 = 96 + 16:
// the picture's column and the label track are one width, so the gaps are one too.
//
// Below `sm` the label track is sized by its content and there is no fixed edge to
// meet, so the same gap stands for want of one.
const NAME_BESIDE_PICTURE = "gap-4";
// Every pairing on this page: the diver beside their diving, the policy beside the
// person to ring, and the c-cards two to a row. One column on a phone, where two of
// these lists side by side would each be too narrow to hold a value on its label's
// line.
//
// `print:` for the same reason `SLOT` has one - `md:` under print media is a query
// against the paper, so a sheet printed from a phone would come off as one long
// column on paper with room for two, and a summary that fits on one sheet is the
// whole point of the page.
const TWO_COLUMNS =
"grid gap-x-8 lg:gap-x-12 md:grid-cols-2 print:gap-x-12 print:grid-cols-2";
// What a page break may not fall inside. Each of these is read as one thing - an
// emergency contact split over a fold is a name on one sheet and the number to ring
// on another. The Certifications *section* is deliberately not one of them: a diver
// with a handful of cards is taller than a page, and `break-inside: avoid` on
// something that cannot fit only moves the break to the top and wastes the page. Its
// unit is the individual card, which carries this itself.
const KEEP_TOGETHER = "break-inside-avoid";
// The height of every heading row - each section's and the diver's name - so the
// gap from a heading to its rows is one gap, and the lists either side of a row
// start level. The edit button would set it on the diver's own screen, but it is
// off the paper and off a link's page, where each row would shrink to its own text.
const HEADING_ROW = "min-h-9";
/**
* What the browser offers as the filename when this page is saved as a PDF.
*
* There is no API for it: the name comes from `document.title` at the moment the
* print dialog opens, and the tab's own title is the site's. So the swap happens on
* `beforeprint` and is undone on `afterprint` - which also means Cmd+P gets the same
* name as the Print button, rather than only the route the button takes.
*
* The date is ISO so a diver's saved sheets sort chronologically in a folder, and the
* separators a filesystem would choke on are replaced rather than left to the
* browser - a name is a diver's own text and may hold anything.
*/
function printedFileName(name: string): string {
return `${name} - diver check-in - ${todayIsoDate()}`.replace(
/[/\\:*?"<>|]/g,
"-",
);
}
/**
* A card as the sheet prints it: the signed-in list's own record, or what a link
* answers with, which names the front's type instead of carrying its file record.
*/
export type CheckInCard = Certification | SharedCheckInCertification;
const isOwnCard = (card: CheckInCard): card is Certification =>
!("front_content_type" in card);
/** The sheet as a check-in link shows it to whoever holds the link. */
export interface CheckInLinkView {
token: string;
expiresAt: string;
/** The figures the link was made with, which it shows for its whole life. */
diving: DivingFigures;
}
export interface CheckInPageFrameProps {
/**
* Whose sheet this is: the session's name and portrait with the shared check-in
* details on the diver's page, the summary's on a link's. Handed in, so the frame
* reads no session of its own.
*/
diver: CheckInDiver;
/**
* Where the diver's own page has got to with the check-in details, which arrive
* after the name: `"loading"` holds their sections' shape with placeholders, and
* `"failed"` leaves them unclaimed rather than calling them empty. Absent once they
* are in hand, and always on a link's page, whose summary carries them.
*/
details?: "loading" | "failed";
/** The system the depth prints in - the diver's own, whoever is reading. */
units: UnitSystem;
/** Every card the diver holds, in the list endpoint's own order. */
certifications?: CheckInCard[];
/**
* The name of each card's dive center, by certification uuid. Handed in rather
* than looked up here: the frame makes no request of its own for what it
* prints, so whatever renders it decides where the names come from.
*/
contactNames?: Readonly<Record<string, string>>;
/**
* The name of each card's instructor, by certification uuid, on the same terms:
* the diver's own page resolves the instructor it holds by uuid, and a link's
* page hands over the name the link prints.
*/
instructorNames?: Readonly<Record<string, string>>;
/** Null while the stats request is in flight. */
stats?: UserDiveStats | null;
/** The most recent dive's `start_time`, or null when there is no dive. */
lastDiveAt?: string | null;
/** True until the certifications, the stats, the last dive, the contacts and the people have all landed. */
isLoading?: boolean;
/** True when at least one of the page's requests failed and its part is missing. */
loadFailed?: boolean;
/**
* True when the stats or the last-dive read failed, so the diving figures on screen
* are blanks rather than the log's.
*/
figuresFailed?: boolean;
onRetry?: () => void;
/**
* Re-reads the card list after one was edited here, so the summary keeps
* `GET /certifications`' own order rather than an order this page invented.
* Editing is all this page offers - a card is added where cards are kept.
*/
onCertificationsChanged?: () => void;
/** The diver's own check-in link, which the Share control beside Print makes. */
sharing?: CheckinLinkControls;
/**
* Set when the sheet is drawn for whoever holds a check-in link, and then it is
* read-only: no control that edits, no dialog, and every picture from the link's
* own routes rather than through the signed-in API client.
*/
link?: CheckInLinkView;
}
const noop = () => {};
const NO_NAMES: Readonly<Record<string, string>> = {};
// Everything `/checkin` draws before its requests answer, kept apart from the data render so
// the page's first render is this frame. Every prop the page's requests fill is
// optional, and the defaults are that first render.
//
// A link's page draws the same frame once its summary has landed, so a field printed
// here prints on both or on neither.
export function CheckInPageFrame({
diver,
units,
certifications = [],
contactNames = NO_NAMES,
instructorNames = NO_NAMES,
stats = null,
lastDiveAt = null,
isLoading = true,
loadFailed = false,
figuresFailed = false,
onRetry = noop,
onCertificationsChanged = noop,
sharing,
link,
details,
}: CheckInPageFrameProps) {
// The diver's own correction to the three diving figures, held for this visit - and
// sent with a check-in link made while it stands, which keeps it for its day. See
// `DivingFiguresDialog` for why it is not saved.
const [corrected, setCorrected] = useState<DivingFigures | null>(null);
// One at a time, and named for the section it sits in: each control opens exactly
// the group it is beside.
const [editing, setEditing] = useState<
"about" | "insurance" | "emergency" | "diving" | "certification" | null
>(null);
const [editingCertification, setEditingCertification] =
useState<Certification | null>(null);
// A plain effect on purpose, unlike every form reset in the app: this one only
// registers listeners, and its cleanup removes them. `useEffectOnChange` would drop
// them when the route is hidden and skip re-adding them when it comes back, so the
// filename would quietly stop working after a diver navigated away and returned.
const printedTitle = printedFileName(diver.name);
useEffect(() => {
let previous: string | null = null;
const before = () => {
previous = document.title;
document.title = printedTitle;
};
const after = () => {
if (previous !== null) document.title = previous;
previous = null;
};
window.addEventListener("beforeprint", before);
window.addEventListener("afterprint", after);
return () => {
window.removeEventListener("beforeprint", before);
window.removeEventListener("afterprint", after);
after();
};
}, [printedTitle]);
// Memoised because `DivingFiguresDialog` resets its form whenever this changes: a
// fresh object each render would re-seed the boxes under the diver's keystrokes.
const logged = useMemo(
() => loggedDivingFigures(stats, lastDiveAt),
[stats, lastDiveAt],
);
// Neither page draws this before a client-side effect has settled - the diver's
// waits on the auth check, a link's on its summary - so it never renders
// server-side, and the print date below has no hydration to disagree with.
const contacts = diver.emergency_contacts ?? [];
const policies = diver.insurance_policies ?? [];
// The three sections the check-in details fill. While those are on their way, each
// holds its shape on screen and on paper; when they failed, none of them claims to
// be empty. Neither offers an edit: a dialog seeded from a copy that never arrived
// would save its group empty.
const detailsPending = details === "loading";
const detailsReady = details === undefined;
const hasEmergencyContact = detailsPending || contacts.length > 0;
const hasInsurance = detailsPending || policies.length > 0;
const hasAboutYou =
detailsPending || !!diver.date_of_birth || !!diver.phone || !!diver.email;
const diving = link ? link.diving : (corrected ?? logged);
const hasFigures = isLoading || hasDivingFigures(diving);
// What a section with nothing to print does on screen. On the diver's own page it
// stays, beside the control that fills it; a link's has no such control, so the desk
// sees the sheet as it prints.
const offSheet = link ? "hidden" : "print:hidden";
const emptyNote = (text: string) =>
link ? null : <EmptyNote>{text}</EmptyNote>;
const detailsEmptyNote = () =>
detailsReady ? emptyNote("Not filled in yet.") : null;
const editControl = (
label: string,
onClick: () => void,
className?: string,
) =>
link ? null : (
<EditControl label={label} onClick={onClick} className={className} />
);
const openCertification = (certification: Certification) => {
setEditingCertification(certification);
setEditing("certification");
};
// The page's own padding is breathing room on a screen and dead space on paper,
// where the printer's margin already sits outside all four edges - and it is not
// free: 32px at the foot is enough to push a sheet that fits onto a second page,
// and what comes off the sides is width the two columns get back.
return (
<div>
{/* "Diver" earns its place on the printed sheet rather than on screen: the
line under the heading is `print:hidden`, so the heading is the only thing
naming the document a shop is handed, and "Check-in" alone above a
stranger's name and card numbers leaves them to infer what it is. The
nav stays "Check-in" - there the reader is the diver, and "Diver" would
be telling them whose page it is. */}
<IndexHero
title="Diver Check-in"
className={cn(
INK,
"print:px-0 print:pb-4 print:[&_h1]:flex-1 print:[&_h1]:text-center print:[&_h1~div]:p-0",
)}
// The link's end is the shop's to see, where the diver's own page only
// says what it is for.
subtitle={
link && (
<span className="print:hidden">
This link stops working on {formatDateTime(link.expiresAt)}.
</span>
)
}
description={
!link && (
<>
<p>
What a dive shop asks for at the desk, on one page you can hand
over.
</p>
<p className="mt-2">
Your browser’s print dialog can save this as a PDF too
— worth keeping on your phone for a desk with no signal.
</p>
</>
)
}
actions={
<>
{/* Waits for the diving figures: the link keeps the ones it is made with
for its whole life, so a click before they land - or after a read
that failed, which leaves the same nulls - would publish blanks that
Try again cannot reach. A correction the diver typed is theirs to
share either way. */}
{sharing && !link && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => void sharing.mint(diving)}
disabled={
isLoading || (figuresFailed && !corrected) || sharing.busy
}
>
<Share2 className="h-4 w-4 mr-2" />
Share
</Button>
)}
{/* The browser's own print, which is also its save-as-PDF: no generator in
either repo, and nothing is uploaded to produce it. */}
<Button type="button" size="sm" onClick={() => window.print()}>
<Printer className="h-4 w-4 mr-2" />
Print
</Button>
</>
}
/>
<div
className={cn(
HERO_BODY,
"space-y-6 max-sm:space-y-2.5 print:px-0 print:pb-0",
)}
>
{sharing && !link && <CheckInLinkPanel sharing={sharing} />}
{/* On screen only: a sheet handed across a desk should not carry this app's
troubles, but the diver about to print one has to know it is short. */}
{loadFailed && (
<div className="flex flex-wrap items-center justify-between gap-4 rounded-md border border-destructive/40 px-4 py-3 print:hidden">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
Some of this didn’t load, so the summary below is
incomplete.
</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={onRetry}>
Try again
</Button>
</div>
)}
<Card
className={`print:border-0 print:shadow-none print:bg-white ${INK}`}
>
{/* `print:p-0` for the same reason, and it is what lets the heading above the
card sit on the sheet's own left edge without an offset of its own. */}
<CardContent className="pt-(--card-pad) space-y-6 print:p-0">
<div className={cn(TWO_COLUMNS, "gap-y-6")}>
{/* Headed by the diver's own name rather than by a label: the name is
what the sheet is about, and "Personal information" written above
somebody's name tells a desk nothing the name did not. */}
<section className={KEEP_TOGETHER}>
<div className={cn("flex items-start", NAME_BESIDE_PICTURE)}>
{/* The portrait, never the avatar: a desk is looking at the diver's
face, and the avatar is whatever the diver shows the app. With none
stored, nothing prints - no initials, which identify nobody - and
the screen offers the place to add one.
The column stays either way, so the name meets the same edge as
every certification's. A link's page has the column and no offer,
the offer being the diver's own control. */}
<div className={SLOT}>
{link ? (
diver.portrait_sha256 && (
<PortraitFrame className="w-full">
<LinkPicture
src={sharedPortraitUrl(link.token)}
alt={`Portrait of ${diver.name}`}
className="h-full w-full object-cover"
/>
</PortraitFrame>
)
) : diver.portrait_sha256 ? (
<PortraitImage
name={diver.name}
portraitSha={diver.portrait_sha256}
maxEdge={PICTURE_EDGE}
className="w-full"
/>
) : (
<PortraitFrame empty className="w-full print:hidden">
<IconTooltip label="Add a portrait">
<Button
type="button"
variant="ghost"
className="h-full w-full rounded-none"
disabled={!detailsReady}
onClick={() => setEditing("about")}
>
<UserSquare className="h-6 w-6 text-muted-foreground" />
</Button>
</IconTooltip>
</PortraitFrame>
)}
</div>
<div className="min-w-0 flex-1 space-y-2">
<div className={cn("flex items-center gap-2", HEADING_ROW)}>
<h2
className={`min-w-0 flex-1 text-2xl font-semibold ${INK}`}
>
{diver.name}
</h2>
{detailsReady &&
editControl(
"Edit your name, portrait, date of birth, phone number and email",
() => setEditing("about"),
)}
</div>
{/* Always on screen, so the diver sees what is missing beside the
control that fills it, and dropped from the print when it
holds nothing: a `<dl>` with every row absent is blank page on
a sheet handed to somebody. */}
<div
aria-busy={detailsPending || undefined}
className={cn(!hasAboutYou && offSheet)}
>
{detailsPending ? (
<div className="space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-40" />
</div>
) : hasAboutYou ? (
<dl className="space-y-1">
<IconDetail
icon={Cake}
label="Date of birth"
value={
diver.date_of_birth &&
formatDateOnly(diver.date_of_birth)
}
/>
<IconDetail
icon={Phone}
label="Phone"
value={diver.phone}
/>
<IconDetail
icon={Mail}
label="Email"
value={diver.email}
/>
</dl>
) : (
detailsEmptyNote()
)}
</div>
</div>
</div>
</section>
<Section
title="Diving"
busy={isLoading}
// A diver who cleared every figure has said to leave the diving off the
// sheet, and the sheet obeys - heading and all. The section stays on
// screen regardless, because the control that emptied it is the only
// way back to "Use logged figures", and a section that removed itself
// would leave a correction in force with nothing on screen saying so.
className={cn(KEEP_TOGETHER, !hasFigures && offSheet)}
action={editControl("Correct these figures", () =>
setEditing("diving"),
)}
>
{hasFigures && (
<DetailList>
<Detail
label="Dives logged"
value={
diving.totalDives !== null
? String(diving.totalDives)
: null
}
pending={isLoading && !stats}
/>
<Detail
label="Max depth"
value={
diving.maxDepth !== null
? formatDepth(diving.maxDepth, units)
: null
}
pending={isLoading && !stats}
/>
<Detail
label="Last dive"
value={
diving.lastDiveOn && formatDateOnly(diving.lastDiveOn)
}
pending={isLoading && !lastDiveAt}
/>
</DetailList>
)}
{/* Two states are empty here without being unfilled, and neither is
visible from `hasFigures` alone: a rejected `/user/dive-stats` leaves
`stats` null, and a diver who cleared all three boxes leaves a
`corrected` whose every field is null. The second would otherwise
read "Not filled in yet." directly above "Corrected for this
summary", which is the page contradicting itself to the one diver who
knows better. */}
{!hasFigures &&
!loadFailed &&
!corrected &&
emptyNote("Not filled in yet.")}
{corrected && (
<p className="text-xs text-muted-foreground print:hidden">
Corrected for this summary. Nothing was saved to your log.
</p>
)}
</Section>
{/* Insurance and the emergency contacts make the second row rather
than the first: a desk works down who the diver is and what they
have actually dived, and reaches for the policy to quote and the
person to call only if something goes wrong. Each policy and each
contact is kept off a fold rather than the section, which can run
to several of them. */}
<Section
title="Dive Insurance"
busy={detailsPending}
className={cn(!hasInsurance && offSheet)}
action={
detailsReady &&
editControl("Edit your insurance policies", () =>
setEditing("insurance"),
)
}
>
{detailsPending ? (
<DetailList>
<Detail label="Provider" pending />
<Detail label="Policy number" pending />
</DetailList>
) : hasInsurance ? (
<div className="space-y-3">
{policies.map((policy, index) => (
<div key={index} className={KEEP_TOGETHER}>
<DetailList>
<Detail label="Provider" value={policy.provider} />
<Detail label="Policy number" value={policy.number} />
<Detail
label="Expires"
value={
policy.expires_on &&
formatDateOnly(policy.expires_on)
}
/>
</DetailList>
</div>
))}
</div>
) : (
detailsEmptyNote()
)}
</Section>
<Section
title="Emergency Contacts"
busy={detailsPending}
className={cn(!hasEmergencyContact && offSheet)}
action={
detailsReady &&
editControl("Edit your emergency contacts", () =>
setEditing("emergency"),
)
}
>
{detailsPending ? (
<DetailList>
<Detail label="Name" pending />
<Detail label="Phone" pending />
</DetailList>
) : hasEmergencyContact ? (
// In call order: the first is the one to ring first.
<div className="space-y-3">
{contacts.map((contact, index) => (
<div key={index} className={KEEP_TOGETHER}>
<DetailList>
<Detail label="Name" value={contact.name} />
<Detail label="Phone" value={contact.phone} />
<Detail
label="Relationship"
value={contact.relationship}
/>
</DetailList>
</div>
))}
</div>
) : (
detailsEmptyNote()
)}
</Section>
</div>
{/* Last, and across the full width: a diver holds any number of cards, and
a list that can run to a second page has to be the thing the fold falls
in rather than something a fixed-height pair of columns above it pushes
there. */}
<Section
title="Certifications"
busy={isLoading}
className={cn(
!isLoading && certifications.length === 0 && offSheet,
)}
>
{isLoading ? (
// Same geometry as `CertificationSummary`, down to the two-column
// grid, the image slot and the list under it: a placeholder that sits
// where its row will not is a list that jumps and resizes the moment
// the fetch lands.
<div aria-hidden className={cn(TWO_COLUMNS, "gap-y-4")}>
{[0, 1].map((row) => (
<div key={row} className="space-y-2">
<div
className={cn("flex items-center", NAME_BESIDE_PICTURE)}
>
<Skeleton
className={cn(SLOT, "h-12 sm:h-16 print:h-16")}
/>
<Skeleton className="h-5 w-48" />
</div>
<div className="space-y-1">
<Skeleton className="h-4 w-56" />
<Skeleton className="h-4 w-48" />
</div>
</div>
))}
</div>
) : certifications.length === 0 ? (
// Silent rather than "No certifications yet." when the list never
// arrived: an empty array is what a rejected fetch leaves behind too,
// and telling a diver who holds six cards that they hold none is the
// page inventing a fact about the account out of a network failure.
// The banner above already says what happened and offers the retry.
!loadFailed && emptyNote("No certifications yet.")
) : (
<div className={cn(TWO_COLUMNS, "gap-y-4")}>
{/* The list endpoint's own order, taken as it arrives rather
than re-imposed here: `GET /certifications` sorts by
`certified_on` descending with nulls last, tie-broken by
uuid, so the card a diver is most often asked to show leads.
Sorting again here could only disagree with
`/certifications`. */}
{certifications.map((certification) => (
<CertificationSummary
key={certification.uuid}
certification={certification}
contactName={contactNames[certification.uuid]}
instructorName={instructorNames[certification.uuid]}
linkToken={link?.token}
onEdit={
!link && isOwnCard(certification)
? () => openCertification(certification)
: undefined
}
/>
))}
</div>
)}
</Section>
{/* The mark rides the footnote at the footnote's own size, rather than
heading the sheet. The sentence it sits in front of says this page
verifies nothing, and a logo set above a stranger's card numbers says
the opposite - that something vouches for them. What it is here for is
provenance: this is the one page of the app that leaves it on paper,
and a desk handed it twice should recognise the format. No address
beside it, deliberately - a self-hosted instance would have to print
its own, and `siteUrl` is server-side. The name stays in the
sentence's own type rather than `font-wordmark`: here it is running
text, and a display face mid-line would head the sheet after all. */}
<p
className={cn(
"text-xs text-muted-foreground",
INK,
KEEP_TOGETHER,
)}
>
<Logo className="mr-1 inline h-3.5 w-3.5 align-[-0.2em]" />
<span className="font-medium">OpenDiving</span> · Printed{" "}
{formatDateOnly(todayIsoDate())} from {diver.name}’s own
dive log. These are entries this diver made; a certification is
verified with the agency that issued it, not here.
</p>
</CardContent>
</Card>
{/* One instance of each, hosted here rather than reached through
`useQuickCreate`: that provider's certification dialog navigates to
`/certifications` on save, and a diver correcting a card at a desk wants
the summary they were about to print, not another page. Correcting is all
this page offers - a card is added where cards are kept. None of them on
a link's page, which only shows. */}
{!link && (
<>
<CheckinDetailsDialog
open={editing === "about"}
onOpenChange={(open) => setEditing(open ? "about" : null)}
group="about"
withName
picture="portrait"
/>
<CheckinDetailsDialog
open={editing === "insurance"}
onOpenChange={(open) => setEditing(open ? "insurance" : null)}
group="insurance"
/>
<CheckinDetailsDialog
open={editing === "emergency"}
onOpenChange={(open) => setEditing(open ? "emergency" : null)}
group="emergency"
/>
<DivingFiguresDialog
open={editing === "diving"}
onOpenChange={(open) => setEditing(open ? "diving" : null)}
units={units}
logged={logged}
corrected={corrected}
onChange={setCorrected}
/>
<CertificationDialog
open={editing === "certification"}
onOpenChange={(open) => setEditing(open ? "certification" : null)}
certification={editingCertification}
onSaved={onCertificationsChanged}
/>
</>
)}
</div>
</div>
);
}
// One certification as the desk reads it: the agency in words, the level, and the
// numbers under it, beside the front of the card where the diver stored one.
function CertificationSummary({
certification,
contactName,
instructorName,
linkToken,
onEdit,
}: {
certification: CheckInCard;
contactName?: string;
instructorName?: string;
/** Set on a link's page, whose card fronts come from the link's own route. */
linkToken?: string;
/** Absent on a link's page, which offers no control. */
onEdit?: () => void;
}) {
const ownFront = isOwnCard(certification)
? certificationFile(certification, "front")
: undefined;
const frontType = isOwnCard(certification)
? ownFront?.content_type
: certification.front_content_type;
const isPdf = frontType === "application/pdf";
const agency = certificationAgencyLabel(
certification.agency,
certification.agency_other,
);
return (
// Keeps a card off a page boundary: the alternative is a printed summary whose
// last certification is cut in half, which is the one thing a desk cannot read.
<div className="space-y-2 break-inside-avoid">
{/* The picture and the name on one line, and the list under it at the cell's
full width - which is what lines these values up with the ones in the
sections above, and what keeps a date off a second line in half a row of
A4. */}
<div className={cn("flex items-center", NAME_BESIDE_PICTURE)}>
{/* The column stands even for a card whose picture the diver never stored:
the name beside it has to meet the same edge as the values underneath
it. */}
<div className={SLOT}>
{isPdf ? (
<CertificationCardFrame
className={cn(
"w-full",
"flex-col gap-1 px-1 text-center text-muted-foreground print:bg-white",
INK,
)}
>
<FileText className="h-4 w-4" aria-hidden />
<span className="text-[10px] leading-tight">
card on file as PDF
</span>
</CertificationCardFrame>
) : ownFront ? (
<CertificationCardImage
certificationUuid={certification.uuid}
side="front"
file={ownFront}
compact
maxEdge={PICTURE_EDGE}
className="w-full"
/>
) : (
frontType &&
linkToken && (
<CertificationCardFrame className="w-full">
<LinkPicture
src={sharedCardFrontUrl(linkToken, certification.uuid)}
alt="front of certification card"
/>
</CertificationCardFrame>
)
)}
</div>
<div className={`min-w-0 flex-1 font-medium ${INK}`}>
{agency ? `${agency} ${certification.name}` : certification.name}
</div>
{onEdit && (
<EditControl
label={`Edit ${certification.name}`}
className="-my-1"
onClick={onEdit}
/>
)}
</div>
<DetailList>
<Detail label="Number" value={certification.certification_number} />
<Detail
label="Certified"
value={
certification.certified_on &&
formatDateOnly(certification.certified_on)
}
/>
<Detail
label="Expires"
value={
certification.expires_on && formatDateOnly(certification.expires_on)
}
/>
<Detail label="Instructor" value={instructorName} />
<Detail label="Dive center" value={contactName} />
</DetailList>
</div>
);
}
// A link's picture, drawn from a blob for the same reason the diver's own are: so it
// can be shrunk before the page prints it.
function LinkPicture({
src,
alt,
className,
}: {
src: string;
alt: string;
className?: string;
}) {
const fetchBlob = useCallback(
async () => shrinkPicture(await fetchSharedPicture(src), PICTURE_EDGE),
[src],
);
const { url } = useAuthedBlobUrl(fetchBlob);
// eslint-disable-next-line @next/next/no-img-element
return url && <img src={url} alt={alt} className={className} />;
}
// What a section with nothing in it says on screen. It never prints: the section
// around it is already dropped from the page when it is showing this, and a sheet
// that announced its own gaps is the opposite of what a diver hands over.
function EmptyNote({ children }: { children: ReactNode }) {
return <p className={`${MUTED} print:hidden`}>{children}</p>;
}
function Section({
title,
busy = false,
action,
className,
children,
}: {
title: string;
busy?: boolean;
/** The control this section is edited through. On screen only. */
action?: ReactNode;
className?: string;
children: ReactNode;
}) {
return (
<section
aria-busy={busy || undefined}
className={cn("space-y-2", className)}
>
{/* A heading stranded at the foot of a page, with its rows over the fold, is
the one break a reader has to work around. */}
<div
className={cn(
"flex items-center justify-between gap-2 break-after-avoid",
HEADING_ROW,
)}
>
<h3
className={`text-sm font-semibold uppercase tracking-wide ${MUTED}`}
>
{title}
</h3>
{action && <div className="print:hidden">{action}</div>}
</div>
{children}
</section>
);
}
// Labels down one column and values down the other, rather than each pair running
// inline: a desk reads this by scanning for the value it was asked for, and a ragged
// left edge on the values is what makes that a search rather than a glance.
//
// The floor on the label track is what lines every list on the page up with every
// other, each being a list of its own: a track sized purely by content puts
// "Provider" and "Name" in columns 14px apart, and those two sit a grid row apart.
// It lifts from `sm` because on a phone a fixed track of that width leaves a dive
// centre's name wrapping in what is left, and the `print:` twin is the paper's case
// of the same - `sm:` under print media is a query against the sheet.
//
// 6rem is the shortest floor that still does the job, and the width is doing two
// things at once. It clears "Policy number", the longest label on the page at just
// under 5.9rem in Inter, so no label wraps; and it clears every *other* list's
// longest label too, which is what holds every list on the sheet to one width rather
// than each to its own content. Every rem above that is white space between a label
// and the value it belongs to - a gap to read across even on "Policy number", and a
// gulf on "Name".
//
// The floor is also why a certification's details sit under its card rather than
// beside it: a label track this wide in what is left of half a row leaves a value
// column too narrow to hold a date.
//
// `Detail` renders its `<dt>` and `<dd>` as a fragment so both are direct children of
// this grid; wrapping each pair in a `<div>` would put the pair in one cell and take
// the alignment back.
function DetailList({ children }: { children: ReactNode }) {
return (
<dl className="grid grid-cols-[auto_1fr] items-baseline gap-x-4 gap-y-1 sm:grid-cols-[minmax(6rem,auto)_1fr] print:grid-cols-[minmax(6rem,auto)_1fr]">
{children}
</dl>
);
}
// A field the diver has not filled in is absent, never a labelled blank: this is a
// page somebody hands to a stranger, and an empty "Insurance policy number:" line
// reads as a thing withheld rather than a thing not held.
//
// `pending` is the one exception, and only while the request that would fill it is
// still in flight - a placeholder there holds the line's height instead of letting
// the summary grow under the reader.
function Detail({
label,
value,
pending = false,
}: {
label: string;
value?: string | null;
pending?: boolean;
}) {
if (!value && !pending) return null;
return (
<>
<dt className={MUTED}>{label}</dt>
<dd className={`text-sm font-medium ${INK}`}>
{value ?? <Skeleton className="h-4 w-16" />}
</dd>
</>
);
}
// A `Detail` under the diver's name, where a label track would push the value off
// the name's edge: the icon stands for the label, and a screen reader hears the label.
function IconDetail({
icon: Icon,
label,
value,
}: {
icon: LucideIcon;
label: string;
value?: string | null;
}) {
if (!value) return null;
return (
<div className="flex items-center gap-2">
<dt className={MUTED}>
<Icon aria-hidden className="h-4 w-4" />
<span className="sr-only">{label}</span>
</dt>
<dd className={`text-sm font-medium ${INK}`}>{value}</dd>
</div>
);
}