Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 29 additions & 5 deletions src/app/p/_lib/profile-detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -38,6 +36,7 @@ import {
byCatalogueOrder,
credentialSource,
hasVerifiedBadge,
portfolioLinks,
profilePath,
type CatalogueEntry,
type Profile,
Expand Down Expand Up @@ -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<View>("earned");
const viewLabelId = useId();
Expand Down Expand Up @@ -291,6 +291,30 @@ export function ProfileDetail({
</div>
) : 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 ? (
<div className="mt-8 flex flex-wrap gap-4 border-t border-stroke pt-7">
{links.map((link) => (
<a
key={link.label}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="text-sm underline underline-offset-4"
>
{link.label}
</a>
))}
</div>
) : null}

<div className="mt-9 flex flex-wrap gap-4">
<a
href={`/contact?about=${encodeURIComponent(person.id)}`}
Expand Down
4 changes: 2 additions & 2 deletions src/lib/directory-mapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,8 +186,8 @@ export function toServiceLabels(rows: ServiceRow[] | null): string[] {
* rule somebody has to remember.
*
* `availability` and the four link columns *do* land here. They are in the
* `anon` grant and in the model; only `bookingUrl` is drawn today, and drawing
* the rest is #84 and #85 rather than an oversight.
* `anon` grant and in the model; all four links are drawn on the profile page,
* and `availability` is read without being rendered rather than by oversight.
*/
export function toProfile(row: ProfileRow): Profile {
return {
Expand Down
10 changes: 5 additions & 5 deletions src/lib/directory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,11 @@ import { getClient } from "@/lib/supabase/anon";
import { supabaseEnvOrNull } from "@/lib/supabase/env";
import { toCatalogueEntry, toProfile, toServiceOptions } from "@/lib/directory-mapping";

/* The `anon` grant on `practitioners`, in full. `availability` and the four
links are read and carried into the model; only `bookingUrl` is drawn today,
and drawing the rest is #84 and #85. Reading a column the page does not render
costs one field in a payload and keeps the model and the grant the same list;
naming a subset would make the next person diff two lists to find out why. */
/* The `anon` grant on `practitioners`, in full. All four links are drawn on the
profile page; `availability` is the one column here that no public surface
draws yet. Reading a column the page does not render costs one field in a
payload and keeps the model and the grant the same list; naming a subset would
make the next person diff two lists to find out why. */
const PROFILE_COLUMNS =
"id,handle,name,headline,location,country_code,bio,focus,availability,website_url,github_url,linkedin_url,booking_url" as const;

Expand Down
50 changes: 50 additions & 0 deletions src/lib/practitioners.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
hasVerifiedBadge,
isCertified,
isProfileHandle,
portfolioLinks,
profilePath,
services,
vocabularyServices,
Expand Down Expand Up @@ -145,6 +146,55 @@ describe("the built-in service vocabulary", () => {
});
});

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");
Expand Down
27 changes: 27 additions & 0 deletions src/lib/practitioners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Profile, "websiteUrl" | "githubUrl" | "linkedinUrl">,
) {
/* 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/<handle>`, and nothing else.
*
Expand Down