Skip to content
Merged
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
14 changes: 7 additions & 7 deletions src/app/p/_lib/profile-detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -229,7 +227,7 @@ export function ProfileDetail({
<div className="min-w-0">
<p className="break-words">{credential.entry.label}</p>
<p className="mt-0.5 text-sm text-t-faint">
{credentialSource(credential.entry)} · {earnedLabel(credential)}
<CredentialWeight entry={credential.entry} /> · {earnedLabel(credential)}
</p>
{credential.evidenceUrl ? (
<a
Expand Down Expand Up @@ -277,7 +275,9 @@ export function ProfileDetail({
{unearned.map((entry) => (
<li key={entry.id} className="text-sm text-t-muted">
{entry.label}
<span className="ml-2 text-xs text-t-faint">{credentialSource(entry)}</span>
<span className="ml-2 text-xs">
<CredentialWeight entry={entry} />
</span>
</li>
))}
</ul>
Expand Down Expand Up @@ -316,7 +316,7 @@ export function ProfileDetail({
))}
</div>
) : null}

<div className="mt-9 flex flex-wrap gap-4">
<a
href={`/contact?about=${encodeURIComponent(person.id)}`}
Expand All @@ -337,7 +337,7 @@ export function ProfileDetail({
</a>
) : null}
</div>
{person.bookingUrl ? (
{person.bookingUrl ? (
<ExternalLinkDisclaimer>
Booking opens {person.name.split(" ")[0]}&apos;s own scheduling page.
Anything arranged there is between you and them, not through Bluehex.
Expand Down
6 changes: 4 additions & 2 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -135,6 +136,7 @@ export default async function HomePage() {
<PractitionerDirectory
practitioners={practitioners}
serviceCatalogue={serviceCatalogue}
credentialCatalogue={credentialCatalogue}
/>
</>
);
Expand Down
1 change: 1 addition & 0 deletions src/app/prototype/directory/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export default function DirectoryPrototypePage() {
<PractitionerDirectory
practitioners={launchPopulation}
serviceCatalogue={vocabularyServices}
credentialCatalogue={catalogue}
/>

{/* The page behind that link, drawn here because it cannot be reached:
Expand Down
34 changes: 33 additions & 1 deletion src/components/credential-mark.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -74,3 +74,35 @@ export function Tick({ className = "" }: { className?: string }) {
</svg>
);
}

export function Diamond({ className = "" }: { className?: string }) {
return (
<svg
viewBox="0 0 16 16"
className={className}
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M8 1.5L14.5 8L8 14.5L1.5 8L8 1.5Z"
stroke="currentColor"
strokeWidth="1.4"
strokeLinejoin="round"
/>
</svg>
);
}

export function CredentialWeight({ entry }: { entry: CatalogueEntry }) {
if (entry.kind !== "certification") {
return <span className="text-t-faint">{credentialSource(entry)}</span>;
}

return (
<span className="inline-flex items-center gap-1 font-medium text-t-bright">
<Diamond className="size-2.5" />
{credentialSource(entry)}
</span>
);
}
132 changes: 99 additions & 33 deletions src/components/practitioner-directory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -98,6 +100,7 @@ function matchesQuery(person: Profile, query: string) {
export function PractitionerDirectory({
practitioners,
serviceCatalogue,
credentialCatalogue,
}: {
practitioners: Profile[];
/**
Expand All @@ -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<string[]>([]);
const [serviceFilters, setServiceFilters] = useState<string[]>([]);
const [certificationFilters, setCertificationFilters] = useState<string[]>([]);
const searchBox = useRef<HTMLInputElement>(null);

/* Only services somebody actually offers get a chip, rather than the whole
Expand All @@ -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
Expand Down Expand Up @@ -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) =>
Expand All @@ -196,6 +242,7 @@ export function PractitionerDirectory({
setVerifiedOnly(false);
setCountryFilters([]);
setServiceFilters([]);
setCertificationFilters([]);
};

return (
Expand Down Expand Up @@ -289,6 +336,20 @@ export function PractitionerDirectory({
</FilterGroup>
) : null}

{certificationOptions.length > 0 ? (
<FilterGroup label="Certifications">
{certificationOptions.map((entry) => (
<FilterChip
key={entry.id}
pressed={certificationFilters.includes(entry.id)}
onClick={() => toggle(setCertificationFilters)(entry.id)}
>
{entry.label}
</FilterChip>
))}
</FilterGroup>
) : null}

{filtering ? (
<button
type="button"
Expand Down Expand Up @@ -343,7 +404,7 @@ export function PractitionerDirectory({
{results.map((person) => (
<li
key={person.id}
className="grid gap-5 border-b border-stroke px-6 py-6 last:border-b-0 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,1.4fr)_minmax(0,0.8fr)_auto] lg:items-start lg:gap-8 lg:px-8"
className="grid gap-5 border-b border-stroke px-6 py-6 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,1.4fr)_minmax(0,0.8fr)_auto] lg:items-start lg:gap-8 lg:px-8"
>
<PractitionerRow person={person} />
</li>
Expand All @@ -355,7 +416,7 @@ export function PractitionerDirectory({
{/* The invitation only belongs on an unfiltered view — under an active
search it would read as a result. */}
{!filtering ? (
<div className="flex flex-col items-start gap-3 border-t border-dashed border-stroke p-8 first:border-t-0 md:p-10">
<div className="flex flex-col items-start gap-3 border-t border-dashed border-stroke p-8 md:p-10">
<Sparkle className="size-6 text-t-faint" />
<p className="text-xl font-medium">Your profile here</p>
{/* "Working towards it" is a statement about people, not about a
Expand All @@ -379,7 +440,7 @@ function FilterGroup({ label, children }: { label: string; children: React.React
const labelId = useId();

return (
<div className="flex flex-wrap items-center gap-2">
<div className="flex flex-wrap items-center gap-5">
<span
id={labelId}
className="w-20 shrink-0 text-xs font-medium tracking-wide text-t-faint uppercase"
Expand Down Expand Up @@ -426,12 +487,12 @@ function PractitionerRow({ person }: { person: Profile }) {
what a flag would be drawn from; the flag asset itself is its own
ticket, so nothing renders it here yet. */}
<div className="min-w-0">
<h3 className="font-medium break-words">{person.name}</h3>
<h3 className="font-medium wrap-break-word">{person.name}</h3>
{person.headline ? (
<p className="mt-0.5 text-sm break-words text-t-muted">{person.headline}</p>
<p className="mt-0.5 text-sm wrap-break-word text-t-muted">{person.headline}</p>
) : null}
{person.location ? (
<p className="mt-1.5 text-xs break-words text-t-faint">{person.location}</p>
<p className="mt-1.5 text-xs wrap-break-word text-t-faint">{person.location}</p>
) : null}
</div>

Expand All @@ -448,31 +509,36 @@ function PractitionerRow({ person }: { person: Profile }) {
<p className="text-sm text-t-faint">No credentials listed.</p>
) : (
<ul className="flex flex-col gap-2">
{person.credentials.map((credential) => (
<li key={credential.entry.id} className="flex items-start gap-2 text-sm">
<CredentialMark credential={credential} />
<span className="min-w-0">
<span className="break-words text-t-medium">{credential.entry.label}</span>
<span className="ml-2 text-xs whitespace-nowrap text-t-faint">
{earnedLabel(credential)}
{[...person.credentials]
.sort((left, right) => byRosterOrder(left.entry, right.entry))
.map((credential) => (
<li key={credential.entry.id} className="flex items-start gap-2 text-sm">
<CredentialMark credential={credential} />
<span className="min-w-0">
<span className="wrap-break-word text-t-medium">{credential.entry.label}</span>
<span className="ml-2 text-xs whitespace-nowrap text-t-faint">
{earnedLabel(credential)}
</span>
<span className="block text-xs">
<CredentialWeight entry={credential.entry} />
</span>
{credential.evidenceUrl ? (
<a
href={credential.evidenceUrl}
target="_blank"
rel="noopener noreferrer"
className="ml-2 text-xs text-t-muted underline decoration-stroke underline-offset-4 transition-colors hover:text-t-bright hover:decoration-current"
>
Certificate
<span className="sr-only">
{" "}
for {credential.entry.label}, opens in a new tab
</span>
</a>
) : null}
</span>
{credential.evidenceUrl ? (
<a
href={credential.evidenceUrl}
target="_blank"
rel="noopener noreferrer"
className="ml-2 text-xs text-t-muted underline decoration-stroke underline-offset-4 transition-colors hover:text-t-bright hover:decoration-current"
>
Certificate
<span className="sr-only">
{" "}
for {credential.entry.label}, opens in a new tab
</span>
</a>
) : null}
</span>
</li>
))}
</li>
))}
</ul>
)}
</div>
Expand Down
Loading