-
Notifications
You must be signed in to change notification settings - Fork 7
Give every profile a URL, and close the ?about= injection #63
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
a0d7f53
Redraw the directory as a roster against the credential model
davidtaing 26cf37a
Resolve the contact page's ?about= against the roster
davidtaing 54656cf
Give every profile a URL and point the roster at it
davidtaing 4ef5ef4
Resolve /p/ against the roster, not a prototype fixture
davidtaing d66112c
Draw a credential mark from one component, not two
davidtaing 5e04399
Say Verified on the roster badge, and let the search find a country
davidtaing 0be10d8
Stop a profile URL and a country chip rendering a wrong answer
davidtaing 3b1a33b
Percent-encode the mailto query, as RFC 6068 wants
davidtaing ab4fe03
Only say "Link copied" when something was copied
davidtaing File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ); | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.