diff --git a/src/app/contact/contact-form.tsx b/src/app/contact/contact-form.tsx
index 3abfea8..859f612 100644
--- a/src/app/contact/contact-form.tsx
+++ b/src/app/contact/contact-form.tsx
@@ -15,8 +15,13 @@ const fieldClasses =
* looks like it worked and silently loses the enquiry. Replacing this with a
* route handler or a form service is tracked in issue #2 — when that lands,
* the mailto fallback should stay for anyone with JavaScript disabled.
+ *
+ * `about` is the practitioner a directory enquiry concerns. Enquiries route
+ * through Bluehex rather than to the practitioner directly — no address is
+ * ever published on a profile — so this only has to say who was meant, and the
+ * mail still comes here.
*/
-export function ContactForm({ email }: { email: string }) {
+export function ContactForm({ email, about }: { email: string; about?: string }) {
const onSubmit = (event: React.FormEvent) => {
event.preventDefault();
@@ -27,20 +32,39 @@ export function ContactForm({ email }: { email: string }) {
`Name: ${value("name")}`,
`Email: ${value("email")}`,
`Phone: ${value("phone")}`,
+ ...(about ? [`About: ${about}`] : []),
"",
value("message"),
].join("\n");
const query = new URLSearchParams({
- subject: `Enquiry from ${value("name") || "the website"}`,
+ subject: about
+ ? `Enquiry about ${about}, from ${value("name") || "the website"}`
+ : `Enquiry from ${value("name") || "the website"}`,
body,
});
- window.location.href = `mailto:${email}?${query}`;
+ /* `URLSearchParams.toString()` serialises as `application/x-www-form-
+ urlencoded`, which writes a space as `+`. A mailto query is not form
+ encoded — RFC 6068 wants percent-encoding, where `+` is a literal plus —
+ so clients split on it and the strict ones open a compose window reading
+ "Enquiry+about+Mara+Ellison". A literal plus in a field is already
+ `%2B` by this point, so replacing every remaining `+` is safe.
+
+ `URLSearchParams` is still what builds the query: it percent-encodes `&`,
+ `?`, CR and LF, so no field value can inject a second mailto header. */
+ window.location.href = `mailto:${email}?${query.toString().replace(/\+/g, "%20")}`;
};
return (
-
+
diff --git a/src/app/p/[handle]/page.tsx b/src/app/p/[handle]/page.tsx
new file mode 100644
index 0000000..c298095
--- /dev/null
+++ b/src/app/p/[handle]/page.tsx
@@ -0,0 +1,57 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { notFound, redirect } from "next/navigation";
+import { profilePath } from "@/lib/practitioners";
+import { findByHandle } from "../_lib/handles";
+import { ProfileDetail } from "../_lib/profile-detail";
+
+/**
+ * A profile at its real URL.
+ *
+ * It sits at `/p/` rather than under `/prototype/` because the directory links
+ * here from production code (`profilePath` in `@/lib/practitioners`), and the
+ * whole point of a profile having a URL is that the URL is real and shareable.
+ *
+ * Only the trailing short id resolves; the slug is decoration. A request whose
+ * slug no longer matches is redirected to the canonical path rather than served
+ * in both places, which is what keeps a link alive across a rename without
+ * splitting the profile across two URLs.
+ *
+ * Every arrival renders this — clicked from the directory, pasted from a CV, or
+ * found in search. An earlier version intercepted the click into a drawer over
+ * the directory so the visitor kept their search context; that was cut, because
+ * interception applies to soft navigation only and a link pasted from anywhere
+ * else is a cold arrival at this page regardless.
+ */
+
+export async function generateMetadata({
+ params,
+}: PageProps<"/p/[handle]">): Promise {
+ const person = findByHandle((await params).handle);
+
+ return {
+ title: person ? `${person.name} — Bluehex` : "Profile",
+ };
+}
+
+export default async function ProfilePage({ params }: PageProps<"/p/[handle]">) {
+ const handle = (await params).handle;
+ const person = findByHandle(handle);
+ if (!person) notFound();
+
+ const canonical = profilePath(person);
+ if (`/p/${handle}` !== canonical) redirect(canonical);
+
+ return (
+
+
+
+ Directory
+ {" "}
+ / {person.name}
+
+
+
+
+ );
+}
diff --git a/src/app/p/_lib/handles.ts b/src/app/p/_lib/handles.ts
new file mode 100644
index 0000000..ec8bd22
--- /dev/null
+++ b/src/app/p/_lib/handles.ts
@@ -0,0 +1,29 @@
+/**
+ * Resolving a profile handle back to a profile.
+ *
+ * The *generating* half of this lives in `@/lib/practitioners` as `profilePath`,
+ * because the directory needs it to render links. Only the lookup is here.
+ *
+ * There must be exactly one scheme. An earlier version of this file had its own
+ * `profileHandle` that hashed the *name* into a short id, which disagreed with
+ * production's "first six characters of the uuid" the moment both existed: the
+ * directory linked one way and the page resolved another. It is deleted rather
+ * than reconciled.
+ *
+ * The lookup reads only the trailing short id and ignores the slug, which is
+ * what makes a URL survive a rename — `/p/mara-ellison-9f3c1a` and
+ * `/p/her-new-name-9f3c1a` are the same profile. The route redirects a
+ * non-canonical slug to the canonical one rather than serving both.
+ *
+ * It resolves against `practitioners`, which is empty and stays empty until real
+ * people are in it — so every handle 404s today. That is the same emptiness the
+ * directory renders its invitation card for, not a missing case.
+ */
+
+import { practitioners } from "@/lib/practitioners";
+
+export function findByHandle(handle: string) {
+ const id = handle.split("-").at(-1);
+ if (!id) return null;
+ return practitioners.find((person) => person.id.slice(0, 6) === id) ?? null;
+}
diff --git a/src/app/p/_lib/profile-detail.tsx b/src/app/p/_lib/profile-detail.tsx
new file mode 100644
index 0000000..e6779bc
--- /dev/null
+++ b/src/app/p/_lib/profile-detail.tsx
@@ -0,0 +1,150 @@
+"use client";
+
+/**
+ * One profile.
+ *
+ * It rendered in two containers for a while — a drawer over the directory on a
+ * click, a page on a cold arrival, one component behind both. The drawer was cut
+ * along with the route interception that produced it. What is left is the page,
+ * which was always the half that had to work.
+ *
+ * What is here and not on the roster row: the bio, the earned dates, and the
+ * credential sources. Three fields. The page is not justified by that depth —
+ * it is justified by having a URL, which is a different argument and the one
+ * that actually held.
+ */
+
+import { useState } from "react";
+import { CredentialMark, earnedLabel } from "@/components/credential-mark";
+import { Badge } from "@/components/ui";
+import { hasVerifiedBadge, profilePath, type Practitioner } from "@/lib/practitioners";
+import { site } from "@/lib/site";
+
+export function ProfileDetail({ person }: { person: Practitioner }) {
+ const badged = hasVerifiedBadge(person.credentials);
+ const [copied, setCopied] = useState(false);
+
+ /* Absolute, because the point of the button is what a practitioner pastes
+ into an application. The origin lives in `site.ts` with the rest of the
+ site-wide facts rather than being spelled out here. */
+ const shareUrl = `${site.origin}${profilePath(person)}`;
+
+ /* Confirm after the fact, not before it. `navigator.clipboard` is undefined
+ on any non-secure origin — a dev server reached over a LAN address rather
+ than localhost — and where it does exist `writeText` rejects on a denied
+ permission or an unfocused document. Optional chaining and a discarded
+ promise both used to reach `setCopied(true)` regardless, so the button
+ claimed a copy that had not happened. Pasting a profile link into an
+ application is the reason this route exists, which makes that the one lie
+ it cannot afford. */
+ const copy = async () => {
+ try {
+ await navigator.clipboard.writeText(shareUrl);
+ setCopied(true);
+ window.setTimeout(() => setCopied(false), 1600);
+ } catch {
+ /* Leave the label alone — nothing was copied, and the URL is in the
+ address bar for anyone who needs it. */
+ }
+ };
+
+ return (
+ /* A white card, because `bg-page` is a warm off-white and body copy at
+ `text-t-muted` on it reads as grey on grey. */
+
+
+ {badged ? (
+
+ ✓ Verified by Bluehex
+
+ ) : (
+
+ Self-listed
+
+ )}
+
+
+
+
+
{person.name}
+ {person.headline ? (
+
{person.headline}
+ ) : null}
+ {person.location ?
{person.location}
: null}
+
+ {person.bio ? (
+
{person.bio}
+ ) : null}
+
+
+
+
+ Credentials
+
+
+ {badged
+ ? "Opened and read by a human at Bluehex"
+ : "Not all of these have been checked"}
+
+ {credential.earnedAt ? "Certificate not published." : "Nothing to show yet."}
+
+ )}
+
+
+ ))}
+ {person.credentials.length === 0 ? (
+
+ No credentials listed — here to be findable, not to be certified.
+
+ ) : null}
+
+
+
+ {person.focus.length > 0 ? (
+
+ {person.focus.map((item) => (
+ {item}
+ ))}
+
+ ) : null}
+
+ {/* The id, not the name — see the comment in `contact/page.tsx`, which
+ resolves it back to a name against the same roster this page was
+ resolved from. */}
+
+ Enquire about {person.name.split(" ")[0]}
+
+
+ );
+}
diff --git a/src/components/credential-mark.tsx b/src/components/credential-mark.tsx
new file mode 100644
index 0000000..826c09b
--- /dev/null
+++ b/src/components/credential-mark.tsx
@@ -0,0 +1,82 @@
+/**
+ * How a credential's state is drawn, wherever it is drawn.
+ *
+ * Two surfaces render credentials — the roster row and the profile page — and
+ * the three-state distinction they carry is the product itself, so it is stated
+ * once here rather than once per surface. It lived in both for a commit and had
+ * already disagreed with itself about the date format; the states are what
+ * cannot be allowed to drift, and the only way to guarantee that is one copy.
+ *
+ * In `src/components/` rather than beside either surface because the dependency
+ * must not point from production code into a route's `_lib`.
+ */
+
+import type { Credential } from "@/lib/practitioners";
+
+/** `earnedAt` is a date, not a timestamp — read and formatted as one. */
+export function earnedLabel(credential: Credential) {
+ if (!credential.earnedAt) return "Working towards";
+ const date = new Date(`${credential.earnedAt}T00:00:00Z`);
+ return `Earned ${date.toLocaleDateString("en-AU", {
+ month: "short",
+ year: "numeric",
+ timeZone: "UTC",
+ })}`;
+}
+
+/**
+ * The per-credential state, which is where all the nuance lives — the profile
+ * badge stays binary and everything else is said here. Three states, and they
+ * have to be distinguishable at a glance down a column:
+ *
+ * verified — a human at Bluehex read the evidence
+ * earned — claimed, not yet checked
+ * towards — no `earnedAt`; unverifiable, and outside the badge rollup
+ *
+ * Distinguished by shape rather than by colour alone, and each carries its own
+ * screen-reader text — the difference between the second and third is the
+ * entire product.
+ */
+export function CredentialMark({ credential }: { credential: Credential }) {
+ if (!credential.earnedAt) {
+ return (
+
+ Working towards.
+
+ );
+ }
+
+ if (!credential.verified) {
+ return (
+
+
+ Earned, not yet checked by Bluehex.
+
+ );
+ }
+
+ return (
+
+
+ Verified by Bluehex.
+
+ );
+}
+
+/* Exported because the roster's profile-level badge draws the same tick, and a
+ second copy of the path data is how the marks drifted the first time. On a
+ 10-unit grid rather than the 24 the rest of `icons.tsx` uses, because it is
+ only ever drawn at 10px inside a 16px dot. */
+export function Tick({ className = "" }: { className?: string }) {
+ return (
+
+ );
+}
diff --git a/src/components/practitioner-directory.tsx b/src/components/practitioner-directory.tsx
index adcc39c..708dba7 100644
--- a/src/components/practitioner-directory.tsx
+++ b/src/components/practitioner-directory.tsx
@@ -1,27 +1,57 @@
"use client";
-import { useMemo, useRef, useState } from "react";
+import Link from "next/link";
+import { useId, useMemo, useRef, useState } from "react";
+import { CredentialMark, Tick, earnedLabel } from "@/components/credential-mark";
import { Close, Search, Sparkle } from "@/components/icons";
import { Badge, Card } from "@/components/ui";
-import type { Practitioner } from "@/lib/practitioners";
+import { countryName, hasVerifiedBadge, profilePath, type Practitioner } from "@/lib/practitioners";
/**
- * The practitioner directory: a search box, a row of filters and the grid of
- * profiles underneath.
+ * The practitioner directory: a search box, filters, and a roster of profiles
+ * underneath.
+ *
+ * A roster of rows rather than a grid of cards, because the job someone does
+ * here is *comparing* practitioners, and a grid is a poor shape for that — each
+ * card is read on its own and nothing lines up between them. In rows the
+ * credentials sit in a column, so "who has actually been checked" is a
+ * vertical scan rather than eight separate readings.
*
* Everything matches client-side against the practitioner list, which ships
* with the page — the directory is small enough that a round trip per keystroke
* would only add latency. If the list outgrows that, this is the seam to move
* behind a route handler; the props stay the same.
+ *
+ * Two things here are load-bearing rather than stylistic:
+ *
+ * - **The badge sits with the credentials, never beside the name.** It
+ * attests to evidence a human read — the credentials, and the name they
+ * are attached to — and never to `bio`, `headline`, `focus` or `location`.
+ * Placed beside the name it reads as a whole-profile endorsement, which is
+ * a claim Bluehex has no method for. No schema rule can fix that reading,
+ * so the placement is the mitigation. See the spec.
+ * - **The badge is derived, not read.** There is no profile-level `verified`
+ * column. `hasVerifiedBadge` computes it from the credential rows, and the
+ * "Verified only" filter computes it too.
*/
-/** Every field a query is matched against, flattened once per practitioner. */
+/**
+ * Every field a query is matched against, flattened once per practitioner.
+ *
+ * The country goes in as its *name*, because that is the word a visitor types
+ * and the same word the Location chips are labelled with. Leaving it out let
+ * the search box and the filter disagree about one fact: "Australia" matched
+ * nobody while the chip built from the same `countryCode` selected them. The
+ * raw code is deliberately not indexed — two-letter queries would hit far more
+ * than they were aimed at.
+ */
function searchIndex(person: Practitioner) {
return [
person.name,
- person.role,
- person.location,
- person.bio,
+ person.headline ?? "",
+ person.location ?? "",
+ person.countryCode ? countryName(person.countryCode) : "",
+ person.bio ?? "",
...person.focus,
...person.credentials.flatMap((credential) => [credential.label, credential.source]),
]
@@ -43,6 +73,7 @@ function matchesQuery(person: Practitioner, query: string) {
export function PractitionerDirectory({ practitioners }: { practitioners: Practitioner[] }) {
const [query, setQuery] = useState("");
const [verifiedOnly, setVerifiedOnly] = useState(false);
+ const [countryFilters, setCountryFilters] = useState([]);
const [focusFilters, setFocusFilters] = useState([]);
const searchBox = useRef(null);
@@ -53,28 +84,46 @@ export function PractitionerDirectory({ practitioners }: { practitioners: Practi
[practitioners],
);
+ /* Location filtering groups on `countryCode`, not on `location`. `location`
+ is free text at whatever granularity the practitioner chose — "Sydney"
+ next to "Bengaluru, Karnataka, India (remote)" — so it will never collapse
+ into a usable set of chips. The country code exists for exactly this. */
+ const countries = useMemo(
+ () =>
+ /* flatMap over `?? []` drops the nulls and narrows the type in one step. */
+ [...new Set(practitioners.flatMap((person) => person.countryCode ?? []))]
+ .map((code) => ({ code, name: countryName(code) }))
+ .sort((a, b) => a.name.localeCompare(b.name)),
+ [practitioners],
+ );
+
const results = useMemo(
() =>
practitioners.filter((person) => {
- if (verifiedOnly && !person.verified) return false;
+ if (verifiedOnly && !hasVerifiedBadge(person.credentials)) return false;
+ if (countryFilters.length) {
+ if (!person.countryCode || !countryFilters.includes(person.countryCode)) return false;
+ }
if (focusFilters.length && !focusFilters.some((item) => person.focus.includes(item))) {
return false;
}
return matchesQuery(person, query);
}),
- [practitioners, query, verifiedOnly, focusFilters],
+ [practitioners, query, verifiedOnly, countryFilters, focusFilters],
);
- const filtering = query.trim() !== "" || verifiedOnly || focusFilters.length > 0;
+ const filtering =
+ query.trim() !== "" || verifiedOnly || countryFilters.length > 0 || focusFilters.length > 0;
- const toggleFocus = (area: string) =>
- setFocusFilters((current) =>
- current.includes(area) ? current.filter((item) => item !== area) : [...current, area],
+ const toggle = (setter: typeof setFocusFilters) => (value: string) =>
+ setter((current) =>
+ current.includes(value) ? current.filter((item) => item !== value) : [...current, value],
);
const clearAll = () => {
setQuery("");
setVerifiedOnly(false);
+ setCountryFilters([]);
setFocusFilters([]);
};
@@ -83,8 +132,9 @@ export function PractitionerDirectory({ practitioners }: { practitioners: Practi
Find a Claude practitioner.
- Anyone in the community can publish a profile. Verified{" "}
- means Bluehex has checked the credentials on it against the certificates that issued them.
+ Anyone in the community can publish a profile.{" "}
+ Verified means Bluehex has
+ checked that credential against the certificate that issued it.
- setVerifiedOnly(!verifiedOnly)}>
- Verified only
-
-
- {focusAreas.map((area) => (
- toggleFocus(area)}
- >
- {area}
+ {/* Grouped rather than one flat row of chips: "Verified", a country and a
+ focus area are three different kinds of claim, and mixing them makes
+ the row read as one undifferentiated pile. Groups whose source data is
+ empty render nothing at all. */}
+
+ );
+}
+
function FilterChip({
pressed,
onClick,
@@ -219,46 +330,97 @@ function FilterChip({
);
}
-function PractitionerCard({ person }: { person: Practitioner }) {
+function PractitionerRow({ person }: { person: Practitioner }) {
return (
-
- {/* min-w-0 so a long name or role wraps instead of shoving the badge off
- the card — profile text is user-supplied and unbounded. */}
-
-
-
{person.name}
-
{person.role}
-
{person.location}
-
- {/* The badge is only ever about the Bluehex check. Where someone is up
- to with certification shows per credential in the list below. */}
-
- {person.verified ? "Verified" : "Self-listed"}
-
+ <>
+ {/* Practitioner. No badge in this column, deliberately — see the note at
+ the top of the file. `countryCode` drives the location filter and is
+ what a flag would be drawn from; the flag asset itself is its own
+ ticket, so nothing renders it here yet. */}
+
+
{person.name}
+ {person.headline ? (
+
{person.headline}
+ ) : null}
+ {person.location ? (
+
{person.location}
+ ) : null}
-
{person.bio}
+ {/* Credentials, and the badge that belongs to them. */}
+