# Person Page

Person detail page with bio, contact details, and Schema.org Person structured data.

## Installation

```bash
npx shadcn@latest add https://ui.uptoolkit.com/r/person-page.json
```

[Registry JSON](https://ui.uptoolkit.com/r/person-page.json)

## Preview

```tsx
import Page from "app/person/page";
export function Preview() {
  return <Page />;
}
```


## Source

### page.tsx

```tsx
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";

import { JsonLd } from "@/components/json-ld";
import {
  samplePerson,
  samplePersonExperience,
  samplePersonPublications,
  type PersonExperience,
  type PersonProfile,
  type PersonPublication,
} from "@/lib/person-data";

/**
 * Person detail page.
 *
 * A single `PersonProfile` record drives the header, bio, experience
 * timeline, and publications list, and is projected once more into a
 * Schema.org `Person` node so search engines get the same facts. Replace
 * `samplePerson` with a lookup and this stays a server component throughout.
 */
export default function Page() {
  const person = samplePerson;

  return (
    <main className="min-h-svh bg-muted/40">
      <JsonLd data={toPersonJsonLd(person)} />

      <div className="mx-auto flex max-w-4xl flex-col gap-6 px-4 py-8">
        <PersonHeaderCard person={person} />

        <div className="flex flex-col gap-6 sm:flex-row">
          <div className="min-w-0 flex-1">
            <ExperienceList experience={samplePersonExperience} />
          </div>

          <aside className="flex w-full shrink-0 flex-col gap-4 sm:w-72">
            <DetailsCard person={person} />
            <PublicationsCard publications={samplePersonPublications} />
          </aside>
        </div>
      </div>
    </main>
  );
}

function PersonHeaderCard({ person }: { person: PersonProfile }) {
  return (
    <Card className="gap-4 py-6">
      <CardContent className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
        <div className="flex items-start gap-4">
          <Avatar className="size-16">
            <AvatarImage src={person.avatarUrl} alt="" />
            <AvatarFallback>{person.initials}</AvatarFallback>
          </Avatar>
          <div className="flex flex-col gap-1">
            <h1 className="m-0 text-xl font-semibold tracking-tight">{person.name}</h1>
            <p className="m-0 text-sm text-muted-foreground">
              {person.jobTitle} at {person.worksFor}
            </p>
            <p className="m-0 max-w-xl text-sm leading-relaxed text-muted-foreground">
              {person.bio}
            </p>
            <div className="mt-1 flex flex-wrap gap-1.5">
              {person.skills.map((skill) => (
                <Badge key={skill} variant="secondary">
                  {skill}
                </Badge>
              ))}
            </div>
          </div>
        </div>

        <Button size="sm" nativeButton={false} render={<a href={`mailto:${person.email}`} />}>
          Contact
        </Button>
      </CardContent>
    </Card>
  );
}

function ExperienceList({ experience }: { experience: readonly PersonExperience[] }) {
  return (
    <Card>
      <CardHeader>
        <CardTitle className="text-base">Experience</CardTitle>
      </CardHeader>
      <CardContent>
        <ol className="m-0 flex list-none flex-col gap-0 p-0">
          {experience.map((entry, index) => (
            <li key={entry.id} className="flex gap-3">
              <div className="flex flex-col items-center">
                <span className="mt-1.5 size-2 shrink-0 rounded-full bg-primary" />
                {index < experience.length - 1 ? (
                  <span className="w-px flex-1 bg-border" aria-hidden />
                ) : null}
              </div>
              <div className="flex flex-col gap-1 pb-6">
                <div className="flex flex-wrap items-baseline gap-2">
                  <span className="text-sm font-medium">{entry.role}</span>
                  <span className="text-sm text-muted-foreground">
                    &middot; {entry.organization}
                  </span>
                </div>
                <span className="text-xs text-muted-foreground">{entry.period}</span>
                <p className="m-0 text-sm text-muted-foreground">{entry.description}</p>
              </div>
            </li>
          ))}
        </ol>
      </CardContent>
    </Card>
  );
}

function DetailsCard({ person }: { person: PersonProfile }) {
  const details = [
    { label: "Location", value: person.location },
    { label: "Email", value: person.email },
    { label: "Website", value: person.url },
  ];

  return (
    <Card className="gap-3 py-4">
      <CardHeader>
        <CardTitle className="text-base">Details</CardTitle>
      </CardHeader>
      <CardContent className="flex flex-col gap-3">
        <dl className="m-0 flex flex-col gap-3">
          {details.map((detail) => (
            <div key={detail.label} className="flex flex-col gap-0.5">
              <dt className="text-xs text-muted-foreground">{detail.label}</dt>
              <dd className="m-0 text-sm break-words">{detail.value}</dd>
            </div>
          ))}
        </dl>
        <Separator />
        <div className="flex flex-col gap-1">
          <span className="text-xs text-muted-foreground">Profiles</span>
          <ul className="m-0 flex list-none flex-col gap-1 p-0">
            {person.sameAs.map((href) => (
              <li key={href}>
                <a href={href} className="text-sm text-primary underline-offset-4 hover:underline">
                  {new URL(href).hostname.replace(/^www\./u, "")}
                </a>
              </li>
            ))}
          </ul>
        </div>
      </CardContent>
    </Card>
  );
}

function PublicationsCard({ publications }: { publications: readonly PersonPublication[] }) {
  return (
    <Card className="gap-3 py-4">
      <CardHeader>
        <CardTitle className="text-base">Publications</CardTitle>
      </CardHeader>
      <CardContent>
        <ul className="m-0 flex list-none flex-col gap-3 p-0">
          {publications.map((publication) => (
            <li key={publication.id} className="flex flex-col gap-0.5">
              <a
                href={publication.url}
                className="text-sm font-medium underline-offset-4 hover:underline"
              >
                {publication.title}
              </a>
              <span className="text-xs text-muted-foreground">
                {publication.venue} &middot; {publication.year}
              </span>
            </li>
          ))}
        </ul>
      </CardContent>
    </Card>
  );
}

/** `PersonProfile` and its experience → Schema.org `Person`. */
function toPersonJsonLd(person: PersonProfile) {
  return {
    "@context": "https://schema.org",
    "@type": "Person",
    name: person.name,
    jobTitle: person.jobTitle,
    email: person.email,
    url: person.url,
    image: person.avatarUrl,
    address: person.location,
    description: person.bio,
    knowsAbout: person.skills,
    sameAs: person.sameAs,
    worksFor: {
      "@type": "Organization",
      name: person.worksFor,
    },
  };
}
```


### lib/person-data.ts

```ts
/** The individual the page is about, modeled on schema.org/Person. */
export type PersonProfile = {
  name: string;
  jobTitle: string;
  worksFor: string;
  avatarUrl: string;
  initials: string;
  bio: string;
  email: string;
  location: string;
  url: string;
  sameAs: readonly string[];
  skills: readonly string[];
};

export type PersonExperience = {
  id: string;
  role: string;
  organization: string;
  period: string;
  description: string;
};

export type PersonPublication = {
  id: string;
  title: string;
  venue: string;
  year: string;
  url: string;
};

export const samplePerson: PersonProfile = {
  name: "Marisol Ferreira",
  jobTitle: "Principal Materials Scientist",
  worksFor: "Northbeam Labs",
  avatarUrl: "https://github.com/shadcn.png",
  initials: "MF",
  bio: "I research fatigue behavior in additively manufactured alloys and turn the findings into design guidelines engineers actually use. Fifteen years across aerospace and consumer hardware.",
  email: "marisol@example.com",
  location: "Porto, PT",
  url: "https://marisol.example.com",
  sameAs: [
    "https://orcid.org/0000-0002-1825-0097",
    "https://linkedin.com/in/marisol-example",
    "https://github.com/marisol-example",
  ],
  skills: ["Fatigue analysis", "Titanium alloys", "Additive manufacturing", "DOE", "Fractography"],
};

export const samplePersonExperience: readonly PersonExperience[] = [
  {
    id: "experience-1",
    role: "Principal Materials Scientist",
    organization: "Northbeam Labs",
    period: "2021 — Present",
    description:
      "Lead a five-person team characterizing fatigue life for printed titanium brackets used in flight hardware.",
  },
  {
    id: "experience-2",
    role: "Senior Materials Engineer",
    organization: "Halden Aerostructures",
    period: "2016 — 2021",
    description:
      "Built the qualification program for a new powder-bed fusion process, cutting certification time by a third.",
  },
  {
    id: "experience-3",
    role: "Materials Engineer",
    organization: "Voss Precision",
    period: "2011 — 2016",
    description:
      "Ran tensile, fatigue, and fracture toughness testing for automotive suspension components.",
  },
];

export const samplePersonPublications: readonly PersonPublication[] = [
  {
    id: "publication-1",
    title: "Surface roughness effects on high-cycle fatigue in laser powder-bed fusion Ti-6Al-4V",
    venue: "International Journal of Fatigue",
    year: "2024",
    url: "https://doi.org/10.1000/example-1",
  },
  {
    id: "publication-2",
    title: "A practical S-N correction for as-built additive titanium",
    venue: "Additive Manufacturing Letters",
    year: "2022",
    url: "https://doi.org/10.1000/example-2",
  },
];
```



## Usage

A bio header, an experience timeline, and a details sidebar with profile links and publications. The
page emits one Schema.org `Person` node built straight from the same record the page renders, so
there is nothing to keep in sync.

```sh
npx shadcn@latest add @uptoolkit/person-page
```

Installs `app/person/page.tsx` and the `person-data` fixtures.

### Wiring it to a real person

Move the page to a dynamic segment and resolve the record from the route:

```tsx
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const person = await db.person.findUniqueOrThrow({ where: { slug } });

  // ...pass person, experience, and publications into the same sections
}
```

### Structured data

`toPersonJsonLd` in `page.tsx` maps the `PersonProfile` record directly onto
[`schema.org/Person`](https://schema.org/Person): `jobTitle`, `worksFor`, `sameAs`, and `knowsAbout`
all come from fields the header and sidebar already render. Swap the inline mapper for the shared
[`schema-org`](/utilities/schema-org) helpers if this page also needs to represent a fediverse actor.

For an organization-scale version of the same idea, see
[`organization-page`](/pages/organization-page).

