diff --git a/src/app/p/_lib/profile-detail.tsx b/src/app/p/_lib/profile-detail.tsx index 3093e2a..edc8058 100644 --- a/src/app/p/_lib/profile-detail.tsx +++ b/src/app/p/_lib/profile-detail.tsx @@ -30,13 +30,11 @@ */ import { useId, useState } from "react"; -import { CredentialMark, earnedLabel } from "@/components/credential-mark"; +import { CredentialMark, CredentialWeight, earnedLabel } from "@/components/credential-mark"; import { Badge, ExternalLinkDisclaimer } from "@/components/ui"; import { ArrowUpRight } from "@/components/icons"; - import { byCatalogueOrder, - credentialSource, hasVerifiedBadge, portfolioLinks, profilePath, @@ -229,7 +227,7 @@ export function ProfileDetail({

{credential.entry.label}

- {credentialSource(credential.entry)} · {earnedLabel(credential)} + · {earnedLabel(credential)}

{credential.evidenceUrl ? ( (
  • {entry.label} - {credentialSource(entry)} + + +
  • ))} @@ -316,7 +316,7 @@ export function ProfileDetail({ ))}
    ) : null} - +
    ) : null}
    - {person.bookingUrl ? ( + {person.bookingUrl ? ( Booking opens {person.name.split(" ")[0]}'s own scheduling page. Anything arranged there is between you and them, not through Bluehex. diff --git a/src/app/page.tsx b/src/app/page.tsx index a1fc92f..e8ffe5d 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,7 +1,7 @@ import Image from "next/image"; import { PractitionerDirectory } from "@/components/practitioner-directory"; import { Card, Button, SectionLabel } from "@/components/ui"; -import { listProfiles, listServiceOptions } from "@/lib/directory"; +import { listCredentialCatalogue, listProfiles, listServiceOptions } from "@/lib/directory"; import { site } from "@/lib/site"; /** @@ -81,9 +81,10 @@ export default async function HomePage() { output per request; this one must not. It is cached for a day on purpose, and `await connection()` would opt the route out of prerendering entirely and undo the rendering decision rather than implement it. */ - const [practitioners, serviceCatalogue] = await Promise.all([ + const [practitioners, serviceCatalogue, credentialCatalogue] = await Promise.all([ listProfiles(), listServiceOptions(), + listCredentialCatalogue(), ]); return ( @@ -135,6 +136,7 @@ export default async function HomePage() { ); diff --git a/src/app/prototype/directory/page.tsx b/src/app/prototype/directory/page.tsx index 250142d..34c9fbc 100644 --- a/src/app/prototype/directory/page.tsx +++ b/src/app/prototype/directory/page.tsx @@ -73,6 +73,7 @@ export default function DirectoryPrototypePage() { {/* The page behind that link, drawn here because it cannot be reached: diff --git a/src/components/credential-mark.tsx b/src/components/credential-mark.tsx index 352c0a8..944ffe9 100644 --- a/src/components/credential-mark.tsx +++ b/src/components/credential-mark.tsx @@ -11,7 +11,7 @@ * must not point from production code into a route's `_lib`. */ -import type { Credential } from "@/lib/practitioners"; +import { credentialSource, type CatalogueEntry, type Credential } from "@/lib/practitioners"; /** `earnedAt` is a date, not a timestamp — read and formatted as one. */ export function earnedLabel(credential: Credential) { @@ -74,3 +74,35 @@ export function Tick({ className = "" }: { className?: string }) { ); } + +export function Diamond({ className = "" }: { className?: string }) { + return ( + + ); +} + +export function CredentialWeight({ entry }: { entry: CatalogueEntry }) { + if (entry.kind !== "certification") { + return {credentialSource(entry)}; + } + + return ( + + + {credentialSource(entry)} + + ); +} diff --git a/src/components/practitioner-directory.tsx b/src/components/practitioner-directory.tsx index 5dbf32e..582ff3c 100644 --- a/src/components/practitioner-directory.tsx +++ b/src/components/practitioner-directory.tsx @@ -2,14 +2,16 @@ import Link from "next/link"; import { useId, useMemo, useRef, useState } from "react"; -import { CredentialMark, Tick, earnedLabel } from "@/components/credential-mark"; +import { CredentialMark, CredentialWeight, Tick, earnedLabel } from "@/components/credential-mark"; import { Close, Search, Sparkle } from "@/components/icons"; import { Badge, Card } from "@/components/ui"; import { + byRosterOrder, countryName, credentialSource, hasVerifiedBadge, profilePath, + type CatalogueEntry, type Profile, type ServiceOption, } from "@/lib/practitioners"; @@ -98,6 +100,7 @@ function matchesQuery(person: Profile, query: string) { export function PractitionerDirectory({ practitioners, serviceCatalogue, + credentialCatalogue, }: { practitioners: Profile[]; /** @@ -110,11 +113,21 @@ export function PractitionerDirectory({ * draws this roster against invented people with no rows behind them. */ serviceCatalogue: ServiceOption[]; + /** + * The whole credential catalogue `listCredentialCatalogue()`, read by the + * Server Component above, the same query the profile page already uses. A + * prop for the same reason `serviceCatalogue` is: it is a query result, and + * the roster derives its Certifications chips from it rather than from a + * hardcoded list of four labels, which is exactly the reading this ticket + * exists to avoid. (See `certificationOptions` below.) + */ + credentialCatalogue: CatalogueEntry[]; }) { const [query, setQuery] = useState(""); const [verifiedOnly, setVerifiedOnly] = useState(false); const [countryFilters, setCountryFilters] = useState([]); const [serviceFilters, setServiceFilters] = useState([]); + const [certificationFilters, setCertificationFilters] = useState([]); const searchBox = useRef(null); /* Only services somebody actually offers get a chip, rather than the whole @@ -141,6 +154,27 @@ export function PractitionerDirectory({ [practitioners, serviceCatalogue], ); + /* The same "only a chip somebody actually holds" rule as `offered`, but + matched on catalogue **id** rather than label. Label matching is right for + services because a custom service arrives as free text with no catalogue + row behind it; a credential has no such escape hatch. It always + references `credential_catalogue`, so the id is always there and is the + correct key. Retired entries are excluded the same way the profile page's + `unearned` list excludes them: `active` filters the picker, not what + already-held rows render. */ + const certificationOptions = useMemo( + () => + credentialCatalogue + .filter((entry) => entry.kind === "certification" && entry.active) + .filter((entry) => + practitioners.some((person) => + person.credentials.some((credential) => credential.entry.id === entry.id), + ), + ) + .sort((left, right) => left.sortOrder - right.sortOrder || left.label.localeCompare(right.label)), + [practitioners, credentialCatalogue], + ); + /* The Verification group gates on its source data like the other two, rather than rendering unconditionally. It was the sole exception, and on the empty directory production ships it was the only chip on the page: one click @@ -178,13 +212,25 @@ export function PractitionerDirectory({ ) { return false; } + if ( + certificationFilters.length && + !certificationFilters.some((id) => + person.credentials.some((credential) => credential.entry.id === id), + ) + ) { + return false; + } return matchesQuery(person, query); }), - [practitioners, query, verifiedOnly, countryFilters, serviceFilters], + [practitioners, query, verifiedOnly, countryFilters, serviceFilters, certificationFilters], ); const filtering = - query.trim() !== "" || verifiedOnly || countryFilters.length > 0 || serviceFilters.length > 0; + query.trim() !== "" || + verifiedOnly || + countryFilters.length > 0 || + serviceFilters.length > 0 || + certificationFilters.length > 0; const toggle = (setter: typeof setServiceFilters) => (value: string) => setter((current) => @@ -196,6 +242,7 @@ export function PractitionerDirectory({ setVerifiedOnly(false); setCountryFilters([]); setServiceFilters([]); + setCertificationFilters([]); }; return ( @@ -289,6 +336,20 @@ export function PractitionerDirectory({ ) : null} + {certificationOptions.length > 0 ? ( + + {certificationOptions.map((entry) => ( + toggle(setCertificationFilters)(entry.id)} + > + {entry.label} + + ))} + + ) : null} + {filtering ? (