diff --git a/src/app/p/_lib/profile-detail.tsx b/src/app/p/_lib/profile-detail.tsx index f6daeef..17406e8 100644 --- a/src/app/p/_lib/profile-detail.tsx +++ b/src/app/p/_lib/profile-detail.tsx @@ -21,11 +21,9 @@ * progress against the whole catalogue belongs, because there the reader is the * practitioner looking at their own record. * - * **`services`, `availability` and the four published links are in the model - * and are not drawn here yet.** The links have been unrendered since the - * columns landed; `services` and `availability` join them with this change. - * All six are in the `anon` grant, so this page is where they belong — it is - * the only surface a visitor reads before deciding to enquire, and + * **`services` and `availability` are in the model and are not drawn here + * yet.** Both are in the `anon` grant, so this page is where they belong — it + * is the only surface a visitor reads before deciding to enquire, and * `availability` in particular is read exactly once, right there. Drawing them * is a design decision rather than a mechanical follow-on, which is why it is * flagged rather than guessed at. @@ -38,6 +36,7 @@ import { byCatalogueOrder, credentialSource, hasVerifiedBadge, + portfolioLinks, profilePath, type CatalogueEntry, type Profile, @@ -74,6 +73,7 @@ export function ProfileDetail({ catalogue?: CatalogueEntry[]; }) { const badged = hasVerifiedBadge(person.credentials); + const links = portfolioLinks(person); const [copied, setCopied] = useState(false); const [view, setView] = useState("earned"); const viewLabelId = useId(); @@ -291,6 +291,30 @@ export function ProfileDetail({ ) : null} + {/* Below the credentials, and outside that block. The badge is what the + page is read for, so a row of exits above it would invite a visitor to + leave before reaching the part Bluehex checked; putting them inside it + would instead suggest these were checked too, and nothing here is + attested. + + Plain links rather than an icon-and-label affordance: that piece is + #133's. */} + {links.length > 0 ? ( +
+ {links.map((link) => ( + + {link.label} + + ))} +
+ ) : null} +
{ }); }); +describe("the portfolio links", () => { + it("carries nothing for a profile that published none — Devon", () => { + /* An empty list rather than three entries with null hrefs, so the caller's + only decision is whether to draw a section at all. A profile with no links + is an ordinary profile, the same way one with no credentials is. */ + expect(portfolioLinks({ websiteUrl: null, githubUrl: null, linkedinUrl: null })).toEqual([]); + }); + + it("drops an absent field rather than labelling an empty address — Mara", () => { + expect( + portfolioLinks({ + websiteUrl: "https://example.invalid/mara", + githubUrl: "https://github.example.invalid/mara-ellison", + linkedinUrl: null, + }), + ).toEqual([ + { label: "Website", url: "https://example.invalid/mara" }, + { label: "GitHub", url: "https://github.example.invalid/mara-ellison" }, + ]); + }); + + it("carries a field set on its own, wherever it sits in the order — Toby", () => { + /* The last of the three and the only one Toby published. A list built by + position rather than by presence would leave two holes in front of it. */ + expect( + portfolioLinks({ + websiteUrl: null, + githubUrl: null, + linkedinUrl: "https://www.linkedin.example.invalid/in/toby-nakamura", + }), + ).toEqual([ + { label: "LinkedIn", url: "https://www.linkedin.example.invalid/in/toby-nakamura" }, + ]); + }); + + it("puts Website first and LinkedIn last, whatever a row holds", () => { + /* Website, GitHub, LinkedIn — the order they sit in on `Profile` and the + order they are drawn in. No approved profile in the seed holds all three, + so this is the case a reader cannot check by opening the page. */ + expect( + portfolioLinks({ + websiteUrl: "https://example.invalid/w", + githubUrl: "https://example.invalid/g", + linkedinUrl: "https://example.invalid/l", + }).map((link) => link.label), + ).toEqual(["Website", "GitHub", "LinkedIn"]); + }); +}); + describe("where a profile lives", () => { it("is the handle and nothing else", () => { expect(profilePath({ handle: "seed0001" })).toBe("/p/seed0001"); diff --git a/src/lib/practitioners.ts b/src/lib/practitioners.ts index e9b0c1c..3195486 100644 --- a/src/lib/practitioners.ts +++ b/src/lib/practitioners.ts @@ -368,6 +368,33 @@ export function isCertified(credentials: Credential[]) { reached for. It lives with the editor, in `catalogueProgress` in `@/lib/profile-draft`. */ +/** + * The three portfolio links, in the order a surface draws them. + * + * **`bookingUrl` is not one of these**, which is why it is absent from the + * parameter type rather than filtered out inside. These three are routes to + * somebody's work and read as ordinary links; a booking URL is a route to their + * calendar and reads as a button. Keeping the two apart in the type is what + * stops the two rows collapsing into one. + * + * An absent field is dropped rather than carried with an empty `href`: a link + * labelled `Website` that goes nowhere is worse than no link. + * + * A `Pick` rather than the whole `Profile`, following `profilePath` below. + * Nothing here has any business reading the rest of a profile. + */ +export function portfolioLinks( + person: Pick, +) { + /* The `https://` shape is the database's — all three columns are the + `public.https_url` domain — so nothing here re-checks it. */ + const links: { label: string; url: string }[] = []; + if (person.websiteUrl) links.push({ label: "Website", url: person.websiteUrl }); + if (person.githubUrl) links.push({ label: "GitHub", url: person.githubUrl }); + if (person.linkedinUrl) links.push({ label: "LinkedIn", url: person.linkedinUrl }); + return links; +} + /** * Where a profile lives: `/p/`, and nothing else. *