@@ -127,7 +139,7 @@ const TeamMemberGrid = ({
{members.map((member, index) => (
{
};
return (
-
-
-
-
- {t("blocks.team.eyebrow")}
-
-
- {t("blocks.team.title")}
-
-
- {t("blocks.team.subtitle")}
-
-
-
-
-
- {volunteerMembers.length > 0 ? (
-
-
-
- {t("blocks.team.volunteers.title")}
-
-
-
- getVolunteerRole(member as VolunteerMember)}
- />
-
- ) : null}
-
-
+
+
+
+
+
+ {t("blocks.team.eyebrow")}
+
+
+ {t("blocks.team.title")}
+
+
+ {t("blocks.team.subtitle")}
+
+
+
+
+
+ {volunteerMembers.length > 0 ? (
+
+
+
+ {t("blocks.team.volunteers.title")}
+
+
+
+
+ getVolunteerRole(member as VolunteerMember)
+ }
+ />
+
+ ) : null}
+
+
+
);
};
diff --git a/src/components/shadcn-studio/blocks/user-schedule-02/user-schedule-02.tsx b/src/components/shadcn-studio/blocks/user-schedule-02/user-schedule-02.tsx
index 56b8478..0e84395 100644
--- a/src/components/shadcn-studio/blocks/user-schedule-02/user-schedule-02.tsx
+++ b/src/components/shadcn-studio/blocks/user-schedule-02/user-schedule-02.tsx
@@ -17,9 +17,8 @@ import {
type ScheduleEvent,
scheduleDays,
} from "@/assets/data/schedule";
-import { speakers } from "@/assets/data/speakers";
import SpeakerImage from "@/components/blocks/speakers/speaker-image";
-import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { Avatar, AvatarFallback } 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";
@@ -44,13 +43,18 @@ import {
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useLanguage, useTranslations } from "@/contexts/language-context";
import { getKeynoteHref, getLocalizedKeynote } from "@/lib/keynotes";
-import { resolveSpeakerImageUrl } from "@/lib/speaker-image-url";
import {
- getTalkByTalkKey,
- getTalkHref,
- getTalkHrefForSpeaker,
- getTalkSpeakers,
-} from "@/lib/talks";
+ resolveSpeakersForScheduleEvent,
+ shouldShowScheduleSpeakers,
+} from "@/lib/schedule-speakers";
+import {
+ getSponsorHref,
+ isSponsorSpaceEvent,
+ resolveSponsorForScheduleEvent,
+} from "@/lib/schedule-sponsors";
+import { resolveSpeakerImageUrl } from "@/lib/speaker-image-url";
+import type { SponsorWithTier } from "@/lib/sponsors";
+import { getTalkByTalkKey, getTalkHref } from "@/lib/talks";
import { assetPath, cn } from "@/lib/utils";
type ScheduleCardProps = {
@@ -59,18 +63,6 @@ type ScheduleCardProps = {
type ScheduleTab = "all" | "talks" | "keynotes" | "workshops";
-const speakerByName = new Map(
- speakers.map((speaker) => [speaker.name.toLowerCase(), speaker]),
-);
-
-function getSpeakerForEvent(event: ScheduleEvent) {
- const direct = speakerByName.get(event.speaker.toLowerCase());
- if (direct) return direct;
-
- const firstSpeaker = event.speaker.split("/")[0]?.trim().toLowerCase();
- return firstSpeaker ? speakerByName.get(firstSpeaker) : undefined;
-}
-
function parseHourStart(hour: string) {
const match = hour.match(/^(\d{1,2}):(\d{2})/);
if (!match) return 0;
@@ -141,26 +133,109 @@ type ScheduleEventCardProps = {
locale: "en" | "es";
};
+type ScheduleSpeakerRowProps = {
+ speaker: {
+ name: string;
+ slug?: string;
+ image?: string;
+ href?: string;
+ };
+ useKeynoteImage?: boolean;
+};
+
+function ScheduleSpeakerRow({
+ speaker,
+ useKeynoteImage = false,
+}: ScheduleSpeakerRowProps) {
+ const imageUrl = resolveSpeakerImageUrl(speaker.image);
+ const content = (
+ <>
+ {imageUrl ? (
+ useKeynoteImage ? (
+
+ ) : (
+
+ )
+ ) : (
+
+
+
+
+
+ )}
+
+ {speaker.name}
+
+ >
+ );
+
+ if (speaker.href) {
+ return (
+
+ {content}
+
+ );
+ }
+
+ return {content}
;
+}
+
+function ScheduleSponsorRow({ sponsor }: { sponsor: SponsorWithTier }) {
+ if (!sponsor.slug || !sponsor.logo) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+}
+
function ScheduleEventCard({ event, t, locale }: ScheduleEventCardProps) {
const category = getScheduleEventCategory(event);
+ const sponsor = resolveSponsorForScheduleEvent(event);
const keynote = event.keynoteSlug
? getLocalizedKeynote(event.keynoteSlug, locale)
: undefined;
const talk = event.talkKey
? getTalkByTalkKey(event.talkKey, locale)
: undefined;
- const talkSpeakers = talk ? getTalkSpeakers(talk, locale) : [];
- const fallbackSpeaker =
- talkSpeakers.length === 0 ? getSpeakerForEvent(event) : undefined;
- const speakerImage = resolveSpeakerImageUrl(
- talkSpeakers[0]?.image ?? fallbackSpeaker?.image,
- );
- const talkHref = talk
- ? getTalkHref(talk.id)
- : fallbackSpeaker
- ? getTalkHrefForSpeaker(fallbackSpeaker.slug, locale)
- : undefined;
+ const eventSpeakers = resolveSpeakersForScheduleEvent(event, locale);
+ const showSpeakers =
+ shouldShowScheduleSpeakers(event, eventSpeakers) &&
+ !(sponsor && isSponsorSpaceEvent(event));
const keynoteHref = keynote ? getKeynoteHref(keynote.slug) : undefined;
+ const talkHref = talk ? getTalkHref(talk.id) : undefined;
const sessionHref = keynoteHref ?? talkHref;
const showSessionLink = sessionHref !== undefined && category !== "other";
const sessionLinkLabel = keynote
@@ -220,58 +295,15 @@ function ScheduleEventCard({ event, t, locale }: ScheduleEventCardProps) {
{event.room}
- {talkSpeakers.length > 0 ? (
+ {showSpeakers ? (
- {talkSpeakers.map((speaker) => (
-
-
-
-
- {speaker.name}
-
-
-
- ))}
- {showSessionLink ? (
-
- {sessionLinkLabel}
-
- ) : null}
-
- ) : keynote ? (
-
-
-
(
+
-
- {keynote.name}
-
-
+ ))}
{showSessionLink ? (
) : null}
- ) : fallbackSpeaker ? (
-
-
-
-
- {event.speaker}
-
-
- {showSessionLink ? (
-
- {sessionLinkLabel}
-
- ) : null}
-
- ) : (
-
-
- {speakerImage ? (
-
- ) : null}
-
-
-
-
-
{event.speaker}
-
- )}
+ ) : null}
+
+ {sponsor ?
: null}
);
diff --git a/src/components/ui/country-flag.tsx b/src/components/ui/country-flag.tsx
index 0214c24..85d2be7 100644
--- a/src/components/ui/country-flag.tsx
+++ b/src/components/ui/country-flag.tsx
@@ -18,9 +18,9 @@ type CountryFlagProps = {
};
const sizeClasses = {
- sm: "size-7 text-xl",
- md: "size-9 text-2xl",
- lg: "size-11 text-3xl",
+ sm: "size-7",
+ md: "size-9",
+ lg: "size-11",
} as const;
const imageSizes = {
@@ -38,36 +38,21 @@ export function CountryFlag({
const flag = getCountryFlag(country);
const alt = `${t("blocks.benefits.flagAltPrefix")} ${country}`;
- if (flag.image) {
- return (
-
-
-
- );
- }
-
return (
- {flag.emoji}
+
);
}
diff --git a/src/components/ui/share-link-button.tsx b/src/components/ui/share-link-button.tsx
new file mode 100644
index 0000000..b4d1d9a
--- /dev/null
+++ b/src/components/ui/share-link-button.tsx
@@ -0,0 +1,73 @@
+"use client";
+
+import { Link2Icon } from "lucide-react";
+import { useEffect, useState } from "react";
+
+import { Button } from "@/components/ui/button";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { useTranslations } from "@/contexts/language-context";
+import { getSiteUrl } from "@/lib/site-seo";
+import { cn } from "@/lib/utils";
+
+type ShareLinkButtonProps = {
+ path: string;
+ className?: string;
+};
+
+const ShareLinkButton = ({ path, className }: ShareLinkButtonProps) => {
+ const { t } = useTranslations();
+ const [copied, setCopied] = useState(false);
+
+ useEffect(() => {
+ if (!copied) {
+ return;
+ }
+
+ const timeoutId = window.setTimeout(() => setCopied(false), 2000);
+
+ return () => window.clearTimeout(timeoutId);
+ }, [copied]);
+
+ const handleShare = async (event: React.MouseEvent
) => {
+ event.preventDefault();
+ event.stopPropagation();
+
+ const url = `${getSiteUrl()}${path}`;
+
+ try {
+ await navigator.clipboard.writeText(url);
+ setCopied(true);
+ } catch {
+ window.prompt(t("blocks.common.share"), url);
+ }
+ };
+
+ return (
+
+
+
+
+
+ {copied ? t("blocks.common.shareCopied") : t("blocks.common.share")}
+
+
+ );
+};
+
+export default ShareLinkButton;
diff --git a/src/lib/country-flags.ts b/src/lib/country-flags.ts
index 8a0a975..a0ba83f 100644
--- a/src/lib/country-flags.ts
+++ b/src/lib/country-flags.ts
@@ -1,25 +1,38 @@
export type CountryFlagSource = {
- emoji: string;
- image?: string;
+ image: string;
};
const COUNTRY_FLAG_MAP: Record = {
- Colombia: { emoji: "🇨🇴" },
- Ecuador: { emoji: "🇪🇨" },
- Canada: { emoji: "🇨🇦" },
- Bolivia: { emoji: "🇧🇴" },
- Chile: { emoji: "🇨🇱" },
- Peru: { emoji: "🇵🇪" },
- Guatemala: { emoji: "🇬🇹" },
- Mexico: { emoji: "🇲🇽" },
- Uruguay: { emoji: "🇺🇾" },
- Venezuela: { emoji: "🇻🇪" },
- "United States": {
- emoji: "🇺🇸",
- image: "/images/flags/flag-us.svg",
- },
+ Colombia: { image: "/images/flags/flag-co.svg" },
+ Ecuador: { image: "/images/flags/flag-ec.svg" },
+ Canada: { image: "/images/flags/flag-ca.svg" },
+ Bolivia: { image: "/images/flags/flag-bo.svg" },
+ Chile: { image: "/images/flags/flag-cl.svg" },
+ Peru: { image: "/images/flags/flag-pe.svg" },
+ Guatemala: { image: "/images/flags/flag-gt.svg" },
+ Mexico: { image: "/images/flags/flag-mx.svg" },
+ Uruguay: { image: "/images/flags/flag-uy.svg" },
+ Venezuela: { image: "/images/flags/flag-ve.svg" },
+ "United States": { image: "/images/flags/flag-us.svg" },
+ India: { image: "/images/flags/flag-in.svg" },
+ Romania: { image: "/images/flags/flag-ro.svg" },
+ "Czech Republic": { image: "/images/flags/flag-cz.svg" },
+ "United Kingdom": { image: "/images/flags/flag-gb.svg" },
+ Brazil: { image: "/images/flags/flag-br.svg" },
+ // Spanish locale aliases
+ Rumanía: { image: "/images/flags/flag-ro.svg" },
+ "República Checa": { image: "/images/flags/flag-cz.svg" },
+ "Estados Unidos": { image: "/images/flags/flag-us.svg" },
+ "Reino Unido": { image: "/images/flags/flag-gb.svg" },
+ Brasil: { image: "/images/flags/flag-br.svg" },
+ Perú: { image: "/images/flags/flag-pe.svg" },
+ México: { image: "/images/flags/flag-mx.svg" },
+};
+
+const DEFAULT_FLAG: CountryFlagSource = {
+ image: "/images/flags/flag-world.svg",
};
export function getCountryFlag(country: string): CountryFlagSource {
- return COUNTRY_FLAG_MAP[country.trim()] ?? { emoji: "🌎" };
+ return COUNTRY_FLAG_MAP[country.trim()] ?? DEFAULT_FLAG;
}
diff --git a/src/lib/i18n/blocks-en.ts b/src/lib/i18n/blocks-en.ts
index a10c22a..6feeb12 100644
--- a/src/lib/i18n/blocks-en.ts
+++ b/src/lib/i18n/blocks-en.ts
@@ -1,4 +1,8 @@
export const blocksEn = {
+ common: {
+ share: "Share",
+ shareCopied: "Copied!",
+ },
benefits: {
title: "Keynote Speakers",
subtitle: "Meet the voices shaping PyCon Colombia 2026.",
@@ -97,10 +101,19 @@ export const blocksEn = {
"A community-driven team working to bring speakers, sponsors, volunteers, and attendees together.",
cardDescription:
"Part of the team bringing speakers, sponsors, volunteers, and attendees together for PyCon Colombia 2026.",
+ detail: {
+ back: "Back to team",
+ about: "About",
+ },
volunteers: {
title: "Volunteers",
roles: {
"valerie-arismendy": "Auditorium Co-host",
+ "jesus-mena": "Auditorium Co-host",
+ "yurley-sanchez-florez": "Auditorium Co-host",
+ "veruzka-borges": "Auditorium Co-host",
+ "juan-camilo-velasquez": "Keynotes Patron",
+ "mateo-usme-valencia": "Keynotes Patron",
},
},
},
diff --git a/src/lib/i18n/blocks-es.ts b/src/lib/i18n/blocks-es.ts
index 1554578..285b52a 100644
--- a/src/lib/i18n/blocks-es.ts
+++ b/src/lib/i18n/blocks-es.ts
@@ -1,6 +1,10 @@
import type { BlocksMessages } from "@/lib/i18n/blocks-en";
export const blocksEs: BlocksMessages = {
+ common: {
+ share: "Compartir",
+ shareCopied: "¡Copiado!",
+ },
benefits: {
title: "Oradores principales",
subtitle: "Conoce las voces que dan forma a PyCon Colombia 2026.",
@@ -101,10 +105,19 @@ export const blocksEs: BlocksMessages = {
"Un equipo impulsado por la comunidad que une ponentes, patrocinadores, voluntarios y asistentes.",
cardDescription:
"Parte del equipo que acerca ponentes, patrocinadores, voluntarios y asistentes a PyCon Colombia 2026.",
+ detail: {
+ back: "Volver al equipo",
+ about: "Acerca de",
+ },
volunteers: {
title: "Voluntarios",
roles: {
"valerie-arismendy": "Co-host de auditorio",
+ "jesus-mena": "Co-host de auditorio",
+ "yurley-sanchez-florez": "Co-host de auditorio",
+ "veruzka-borges": "Co-host de auditorio",
+ "juan-camilo-velasquez": "Padrino Keynotes",
+ "mateo-usme-valencia": "Padrino Keynotes",
},
},
},
diff --git a/src/lib/schedule-speakers.ts b/src/lib/schedule-speakers.ts
new file mode 100644
index 0000000..ca8c0f3
--- /dev/null
+++ b/src/lib/schedule-speakers.ts
@@ -0,0 +1,106 @@
+import type { ScheduleEvent } from "@/assets/data/schedule";
+import { speakers } from "@/assets/data/speakers";
+import { getLocalizedKeynote } from "@/lib/keynotes";
+import type { SiteLocale } from "@/lib/site-messages";
+import { getTalkByTalkKey, getTalkSpeakers } from "@/lib/talks";
+
+export type ScheduleEventSpeaker = {
+ name: string;
+ slug?: string;
+ image?: string;
+ href?: string;
+};
+
+function normalizeSpeakerName(name: string) {
+ return name
+ .normalize("NFD")
+ .replace(/\p{Diacritic}/gu, "")
+ .toLowerCase()
+ .replace(/\./g, "")
+ .replace(/\s+/g, " ")
+ .trim();
+}
+
+const speakersByNormalizedName = new Map(
+ speakers.map((speaker) => [normalizeSpeakerName(speaker.name), speaker]),
+);
+
+function resolveSpeakerByName(name: string): ScheduleEventSpeaker {
+ const match = speakersByNormalizedName.get(normalizeSpeakerName(name));
+
+ if (!match) {
+ return { name };
+ }
+
+ return {
+ name: match.name,
+ slug: match.slug,
+ image: match.image,
+ href: `/speakers/${match.slug}`,
+ };
+}
+
+export function resolveSpeakersForScheduleEvent(
+ event: ScheduleEvent,
+ locale: SiteLocale,
+): ScheduleEventSpeaker[] {
+ if (event.keynoteSlug) {
+ const keynote = getLocalizedKeynote(event.keynoteSlug, locale);
+
+ if (keynote) {
+ return [
+ {
+ name: keynote.name,
+ slug: keynote.slug,
+ image: keynote.image,
+ href: `/keynotes/${keynote.slug}`,
+ },
+ ];
+ }
+ }
+
+ if (event.talkKey) {
+ const talk = getTalkByTalkKey(event.talkKey, locale);
+
+ if (talk) {
+ return getTalkSpeakers(talk, locale).map((speaker) => ({
+ name: speaker.name,
+ slug: speaker.slug,
+ image: speaker.image,
+ href: `/speakers/${speaker.slug}`,
+ }));
+ }
+ }
+
+ return event.speaker
+ .split("/")
+ .map((name) => name.trim())
+ .filter(Boolean)
+ .map((name) => resolveSpeakerByName(name));
+}
+
+export function shouldShowScheduleSpeakers(
+ event: ScheduleEvent,
+ speakersForEvent: ScheduleEventSpeaker[],
+) {
+ if (speakersForEvent.length === 0) {
+ return false;
+ }
+
+ if (speakersForEvent.some((speaker) => speaker.slug)) {
+ return true;
+ }
+
+ const normalizedEventSpeaker = normalizeSpeakerName(event.speaker);
+
+ return ![
+ "opening",
+ "closing",
+ "coffee break",
+ "coffee break and snacks",
+ "morning snacks",
+ "lunch time",
+ "attendees arrival",
+ "attendees arrival and kits delivery",
+ ].some((label) => normalizedEventSpeaker.includes(label));
+}
diff --git a/src/lib/schedule-sponsors.ts b/src/lib/schedule-sponsors.ts
new file mode 100644
index 0000000..7e08918
--- /dev/null
+++ b/src/lib/schedule-sponsors.ts
@@ -0,0 +1,75 @@
+import type { ScheduleEvent } from "@/assets/data/schedule";
+import {
+ getAllSponsors,
+ getSponsorBySlug,
+ type SponsorWithTier,
+} from "@/lib/sponsors";
+
+function normalizeSponsorKey(value: string) {
+ return value
+ .normalize("NFD")
+ .replace(/\p{Diacritic}/gu, "")
+ .toLowerCase()
+ .replace(/\s+/g, " ")
+ .trim();
+}
+
+const sponsorsByKey = new Map();
+
+for (const sponsor of getAllSponsors()) {
+ if (!sponsor.slug) {
+ continue;
+ }
+
+ sponsorsByKey.set(normalizeSponsorKey(sponsor.slug), sponsor);
+ sponsorsByKey.set(normalizeSponsorKey(sponsor.name), sponsor);
+
+ const firstToken = sponsor.name.split(/\s+/)[0];
+
+ if (firstToken) {
+ sponsorsByKey.set(normalizeSponsorKey(firstToken), sponsor);
+ }
+
+ if (sponsor.name.includes("EAFIT")) {
+ sponsorsByKey.set("eafit", sponsor);
+ }
+}
+
+function resolveSponsorByKey(value: string): SponsorWithTier | undefined {
+ return sponsorsByKey.get(normalizeSponsorKey(value));
+}
+
+export function getSponsorHref(slug: string): string {
+ return `/sponsors/${slug}`;
+}
+
+export function resolveSponsorForScheduleEvent(
+ event: ScheduleEvent,
+): SponsorWithTier | undefined {
+ if (
+ event.label === "Sponsor Space" ||
+ event.title.startsWith("Sponsor Space:")
+ ) {
+ const fromTitle = event.title.replace(/^Sponsor Space:\s*/i, "").trim();
+
+ return resolveSponsorByKey(fromTitle) ?? resolveSponsorByKey(event.speaker);
+ }
+
+ if (event.label?.endsWith(" Sponsor")) {
+ const sponsorName = event.label.replace(/\s+Sponsor$/i, "").trim();
+
+ return resolveSponsorByKey(sponsorName);
+ }
+
+ if (event.label === "EAFIT") {
+ return getSponsorBySlug("eafit");
+ }
+
+ return undefined;
+}
+
+export function isSponsorSpaceEvent(event: ScheduleEvent): boolean {
+ return (
+ event.label === "Sponsor Space" || event.title.startsWith("Sponsor Space:")
+ );
+}
diff --git a/src/lib/team-seo.ts b/src/lib/team-seo.ts
new file mode 100644
index 0000000..af60531
--- /dev/null
+++ b/src/lib/team-seo.ts
@@ -0,0 +1,102 @@
+import type { Metadata } from "next";
+
+import type { SiteLocale } from "@/lib/site-messages";
+import { siteMessages } from "@/lib/site-messages";
+import {
+ absoluteAssetUrl,
+ getSiteUrl,
+ SITE_KEYWORDS,
+ SITE_NAME,
+} from "@/lib/site-seo";
+import { getTeamMemberBySlug } from "@/lib/team";
+
+const DEFAULT_OG_IMAGE = "/images/cfp.jpg";
+
+export function getTeamMemberShareImageUrl(slug: string): string {
+ const member = getTeamMemberBySlug(slug);
+
+ if (member?.image) {
+ return absoluteAssetUrl(member.image);
+ }
+
+ return absoluteAssetUrl(DEFAULT_OG_IMAGE);
+}
+
+export function buildTeamMemberPageMetadata(
+ slug: string,
+ locale: SiteLocale,
+): Metadata {
+ const member = getTeamMemberBySlug(slug);
+
+ if (!member) {
+ return {};
+ }
+
+ const messages = siteMessages[locale];
+ const description =
+ member.kind === "volunteer"
+ ? `${member.name} — ${messages.blocks.team.volunteers.title}, PyCon Colombia 2026.`
+ : `${member.name} — ${messages.pageMeta.team.description}`;
+
+ const canonical = `${getSiteUrl()}/team/${slug}`;
+ const ogTitle = `${member.name} — ${messages.pageMeta.team.title} | PyCon Colombia 2026`;
+ const imageUrl = getTeamMemberShareImageUrl(slug);
+ const imageAlt = `${member.name} — PyCon Colombia 2026`;
+
+ return {
+ title: `${member.name} — ${messages.pageMeta.team.title}`,
+ description,
+ keywords: [
+ ...SITE_KEYWORDS,
+ member.name,
+ messages.pageMeta.team.title,
+ "PyCon organizers",
+ "Python Colombia",
+ ],
+ alternates: {
+ canonical,
+ },
+ openGraph: {
+ title: ogTitle,
+ description,
+ url: canonical,
+ type: "profile",
+ siteName: SITE_NAME,
+ locale: locale === "es" ? "es_CO" : "en_US",
+ images: [
+ {
+ url: imageUrl,
+ alt: imageAlt,
+ },
+ ],
+ },
+ twitter: {
+ card: "summary_large_image",
+ title: ogTitle,
+ description,
+ images: [imageUrl],
+ },
+ };
+}
+
+export function buildTeamMemberJsonLd(slug: string, url: string) {
+ const member = getTeamMemberBySlug(slug);
+
+ if (!member) {
+ return undefined;
+ }
+
+ return {
+ "@type": "Person",
+ name: member.name,
+ url,
+ image: member.image ? absoluteAssetUrl(member.image) : undefined,
+ jobTitle: member.role,
+ worksFor: {
+ "@type": "Organization",
+ name: SITE_NAME,
+ url: getSiteUrl(),
+ },
+ sameAs: [member.linkedin, member.github, member.x].filter(Boolean),
+ };
+}
diff --git a/src/lib/team.ts b/src/lib/team.ts
new file mode 100644
index 0000000..dd67e6e
--- /dev/null
+++ b/src/lib/team.ts
@@ -0,0 +1,41 @@
+import {
+ type TeamMember,
+ teamMembers,
+ volunteerMembers,
+} from "@/assets/data/team";
+
+export type TeamMemberKind = "organizer" | "volunteer";
+
+export type TeamMemberWithKind = TeamMember & {
+ kind: TeamMemberKind;
+};
+
+export function getAllTeamMemberSlugs(): string[] {
+ return [...teamMembers, ...volunteerMembers].map((member) => member.slug);
+}
+
+export function isTeamMemberSlug(slug: string): boolean {
+ return getAllTeamMemberSlugs().includes(slug);
+}
+
+export function getTeamMemberBySlug(
+ slug: string,
+): TeamMemberWithKind | undefined {
+ const organizer = teamMembers.find((member) => member.slug === slug);
+
+ if (organizer) {
+ return { ...organizer, kind: "organizer" };
+ }
+
+ const volunteer = volunteerMembers.find((member) => member.slug === slug);
+
+ if (volunteer) {
+ return { ...volunteer, kind: "volunteer" };
+ }
+
+ return undefined;
+}
+
+export function getTeamMemberHref(slug: string): string {
+ return `/team/${slug}`;
+}