Skip to content
Merged
30 changes: 27 additions & 3 deletions src/app/contact/contact-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLFormElement>) => {
event.preventDefault();

Expand All @@ -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({
Comment thread
davidtaing marked this conversation as resolved.
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 (
<form onSubmit={onSubmit} className="mt-14 grid gap-x-8 gap-y-10 sm:grid-cols-2">
{about ? (
<p className="rounded-tight bg-surface px-5 py-4 text-sm text-t-muted sm:col-span-2">
Enquiring about <strong className="font-medium text-t-bright">{about}</strong>.
Bluehex passes it on — practitioners are not contacted directly.
</p>
) : null}

<label className="block">
<span className="sr-only">Your name</span>
<input
Expand Down
26 changes: 24 additions & 2 deletions src/app/contact/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { ContactForm } from "./contact-form";
import { ArrowRight, ArrowUpRight } from "@/components/icons";
import { Button, SectionLabel } from "@/components/ui";
import { practitioners } from "@/lib/practitioners";
import { site } from "@/lib/site";

export const metadata: Metadata = {
Expand All @@ -12,7 +13,28 @@ export const metadata: Metadata = {
const BOOKING_URL =
"https://calendar.google.com/calendar/u/0/appointments/schedules/AcZssZ2fXA9Mpyae3jbldYGLyeNFKUM4f--cA-W-w5v1WUV0BtWG6eq1paYGH4Q6gNtE_iyUPynhSCXF";

export default function ContactPage() {
export default async function ContactPage({ searchParams }: PageProps<"/contact">) {
/* The directory's Enquire button carries who the enquiry is about. Read here
rather than with `useSearchParams` in the form, which would need a Suspense
boundary and push the whole page to client rendering for one string.

It carries the profile **id**, not the display name, and the name shown is
looked up from it. Two reasons, and the second is the serious one:

1. Names are not identifiers. Two practitioners can share one, and the
enquiry would not say which — the same argument that rules the display
name out of a profile URL.
2. The previous version echoed the query string straight into the page, the
mailto subject and the mail body. That is unvalidated, attacker-supplied
text rendered as if Bluehex wrote it, so `?about=<anything>` produced a
page that appeared to endorse it. Resolving against the known set means
anything that does not match simply shows no banner. */
const requested = (await searchParams).about;
const about =
typeof requested === "string"
? practitioners.find((person) => person.id === requested)?.name
: undefined;

return (
<>
<section className="container-x pt-32 pb-20 md:pt-44 md:pb-28">
Expand All @@ -35,7 +57,7 @@ export default function ContactPage() {
anytime.
</p>

<ContactForm email={site.email} />
<ContactForm email={site.email} about={about} />
</div>
</div>
</section>
Expand Down
57 changes: 57 additions & 0 deletions src/app/p/[handle]/page.tsx
Original file line number Diff line number Diff line change
@@ -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<Metadata> {
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 (
<div className="container-x pt-32 pb-24 md:pt-40">
<p className="mx-auto mb-10 max-w-3xl text-sm text-t-muted">
<Link href="/" className="underline underline-offset-4">
Directory
</Link>{" "}
/ {person.name}
</p>

<ProfileDetail person={person} />
</div>
);
}
29 changes: 29 additions & 0 deletions src/app/p/_lib/handles.ts
Original file line number Diff line number Diff line change
@@ -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;
}
150 changes: 150 additions & 0 deletions src/app/p/_lib/profile-detail.tsx
Original file line number Diff line number Diff line change
@@ -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. */
<article className="mx-auto max-w-3xl rounded-card bg-surface p-8 md:p-12">
<div className="flex flex-wrap items-center gap-3">
{badged ? (
<p className="inline-flex items-center gap-2 rounded-full bg-ink px-4 py-1.5 text-sm font-medium text-t-invert">
✓ Verified by Bluehex
</p>
) : (
<p className="inline-flex items-center rounded-full border border-stroke px-4 py-1.5 text-sm font-medium text-t-muted">
Self-listed
</p>
)}

<button
type="button"
onClick={copy}
className="text-xs text-t-faint underline underline-offset-4 hover:text-t-bright"
>
{copied ? "Link copied" : "Copy link"}
</button>
</div>

<h1 className="display-2 mt-6 break-words">{person.name}</h1>
{person.headline ? (
<p className="mt-3 text-xl text-t-muted">{person.headline}</p>
) : null}
{person.location ? <p className="mt-1.5 text-sm text-t-faint">{person.location}</p> : null}

{person.bio ? (
<p className="mt-8 max-w-2xl leading-relaxed text-t-muted">{person.bio}</p>
) : null}

<div className="mt-9 border-t border-stroke pt-7">
<div className="flex flex-wrap items-baseline justify-between gap-3">
<h2 className="text-xs font-medium tracking-wide text-t-faint uppercase">
Credentials
</h2>
<p className="text-xs text-t-faint">
{badged
? "Opened and read by a human at Bluehex"
: "Not all of these have been checked"}
</p>
</div>

<ul className="mt-6 flex flex-col gap-5">
{person.credentials.map((credential) => (
<li key={`${credential.source}:${credential.label}`} className="flex items-start gap-3">
<CredentialMark credential={credential} />
<div className="min-w-0">
<p className="break-words">{credential.label}</p>
<p className="mt-0.5 text-sm text-t-faint">
{credential.source} · {earnedLabel(credential)}
</p>
{credential.evidenceUrl ? (
<a
href={credential.evidenceUrl}
target="_blank"
rel="noopener noreferrer"
className="mt-1.5 inline-block text-sm underline underline-offset-4"
>
See the certificate
</a>
) : (
<p className="mt-1.5 text-sm text-t-faint italic">
{credential.earnedAt ? "Certificate not published." : "Nothing to show yet."}
</p>
)}
</div>
</li>
))}
{person.credentials.length === 0 ? (
<li className="text-sm text-t-faint">
No credentials listed — here to be findable, not to be certified.
</li>
) : null}
</ul>
</div>

{person.focus.length > 0 ? (
<div className="mt-8 flex flex-wrap gap-2 border-t border-stroke pt-7">
{person.focus.map((item) => (
<Badge key={item}>{item}</Badge>
))}
</div>
) : 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. */}
<a
href={`/contact?about=${encodeURIComponent(person.id)}`}
className="mt-9 inline-flex h-13 items-center justify-center rounded-full bg-ink px-7 font-medium text-t-invert transition-colors hover:bg-ink-tint"
>
Enquire about {person.name.split(" ")[0]}
</a>
</article>
);
}
Loading