From 280fb51498d35c1df26ff6ca6838983fcb5d0385 Mon Sep 17 00:00:00 2001 From: toruiwasa <11441145+toruiwasa@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:00:43 +1000 Subject: [PATCH] Draw the three portfolio links on the profile page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `website_url`, `github_url` and `linkedin_url` have been in the model, the draft, the validation and the editor form since the columns landed, and nothing rendered them. This draws them on `/p/`, which is the last of #84's four scope items — the other three were already in `practitioners.ts:310-313`, `profile-draft.ts:112-114` and `profile-validation.ts:94-96`. ## Where they go A section of their own, after the focus tags and before the CTA row. The reading order becomes who somebody is, what Bluehex checked, what they work on, where else to find them, and then how to reach them. Below the credentials rather than beside the name, because the badge is what the page is read for and a row of exits above it invites a visitor to leave before reaching the part that was checked. Outside the credentials block for the opposite reason: nothing here is attested, and proximity alone would claim otherwise. That is not only the ADR's line — `clear_profile_verification()` has exactly three triggers, on `name`, on a claim, and on `contact_email` while unclaimed, and no link is among them. Separate from the CTA row because #84 calls these `a row of ordinary links rather than a call to action`, which is the distinction it was split from #85 along. So they are plain underlined links matching `See the certificate` in the same file, not the pill `Book a meeting` wears. An empty field renders nothing, and a profile with no links renders no section at all rather than a rule with nothing under it. ## portfolioLinks The list is built in `@/lib/practitioners` rather than inline, because nothing else can reach it: the page is a client component with no component test to run against it, and `e2e/routes.spec.ts` visits `/` and `/contact` and never `/p/[handle]`. As JSX the ordering and the drop-if-empty rule would be asserted by nobody. #131 states the same rule for the same file. It takes a `Pick` rather than a whole `Profile`, following `profilePath`. `bookingUrl` is absent from that type on purpose — leaving it out is cheaper than remembering to filter it at the call site, and it keeps the two rows from collapsing into one later. Named for the ticket's word rather than for the field docstring's, which calls all four of these columns "published links". Three of them are the portfolio and the fourth is a booking, so a helper called `publishedLinks` that returned three would have invited exactly the question the split exists to answer. ## Comments corrected Three comments described the links as undrawn, two of them naming #84 and #85 as the reason. All three would have shipped as false with this change: the header of `profile-detail.tsx`, `directory.ts` above `PROFILE_COLUMNS`, and `toProfile` in `directory-mapping.ts`. That leaves `availability` as the one column in the `anon` grant that no public surface draws yet. The new wording says that rather than claiming it is drawn nowhere at all, because the editor does draw it, through `my_profile()` rather than through this list. `services` is undrawn too, and both stay flagged. ## Checks `pnpm lint`, `pnpm test` (276, four of them new) and `pnpm build` are green. The four link patterns in `supabase/seed.sql` were opened at `/p/seed0001` through `/p/seed0004`, which covers two links, one link, none, and one alongside a booking button. No approved seed profile holds all three, so that combination was produced by editing a local row for the screenshot and putting it back, and it is pinned by a unit test as well. `pnpm test:e2e` passes 20/20 with no Supabase configured, which is the state the empty-directory test documents. With a local `.env.local` that one test fails on both projects because the seeded roster is not empty; that is true on `main` as well. --- src/app/p/_lib/profile-detail.tsx | 34 +++++++++++++++++---- src/lib/directory-mapping.ts | 4 +-- src/lib/directory.ts | 10 +++---- src/lib/practitioners.test.ts | 50 +++++++++++++++++++++++++++++++ src/lib/practitioners.ts | 27 +++++++++++++++++ 5 files changed, 113 insertions(+), 12 deletions(-) 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. *