Skip to content

Commit e3e752b

Browse files
h4rklclaude
andcommitted
feat: Add highlighted events section to Breakpoint homepage
Pulls highlighted side events from the Breakpoint 2026 Luma calendar (plus pinned event URLs) and renders them as a card carousel with Luma cover images, matching the BP26 pre-event homepage design. Community events link out to the Luma side-events calendar. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 345b256 commit e3e752b

10 files changed

Lines changed: 407 additions & 0 deletions

File tree

apps/breakpoint/.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,6 @@ AIRTABLE_VIEW_ID_SPEAKERS=viwK6atCbxFZWGyvk
77
AIRTABLE_BASE_ID_AGENDA=apph4y5MDXBxJ2uZy
88
AIRTABLE_TABLE_ID_AGENDA=tbl802700wD64jBNI
99
AIRTABLE_VIEW_ID_AGENDA=viw9MdyVxScVNZaZX
10+
11+
# Optional: Luma API key for the highlighted events feed (public calendars work without it)
12+
LUMA_PRIVATE_API_KEY=

apps/breakpoint/next.config.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,22 @@ const nextConfig: NextConfig = {
1313
env: {
1414
NEXT_PUBLIC_APP_NAME: "breakpoint",
1515
},
16+
images: {
17+
remotePatterns: [
18+
{
19+
protocol: "https",
20+
hostname: "**.lumacdn.com",
21+
},
22+
{
23+
protocol: "https",
24+
hostname: "**.lu.ma",
25+
},
26+
{
27+
protocol: "https",
28+
hostname: "cdn.lu.ma",
29+
},
30+
],
31+
},
1632
experimental: {
1733
externalDir: true,
1834
},

apps/breakpoint/src/app/[locale]/page.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import WhyAttendSection from "@/components/sections/WhyAttendSection";
88
import GallerySection from "@/components/sections/GallerySection";
99
import StatsSection from "@/components/sections/StatsSection";
1010
import SponsorsSection from "@/components/sections/SponsorsSection";
11+
import EventsSection from "@/components/sections/EventsSection";
1112
import HighlightsSection from "@/components/sections/HighlightsSection";
1213
import AnnouncementsSection from "@/components/sections/AnnouncementsSection";
1314
import FAQSection from "@/components/sections/FAQSection";
@@ -47,6 +48,7 @@ export default async function HomePage({
4748
<GallerySection />
4849
<StatsSection />
4950
<Marquee />
51+
<EventsSection />
5052
<HighlightsSection />
5153
<AnnouncementsSection />
5254
<FAQSection />
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
"use client";
2+
3+
import React, { useCallback, useId, useRef } from "react";
4+
import Image from "next/image";
5+
import { useLocale } from "next-intl";
6+
import Button from "@/components/Button";
7+
import CarouselControls from "@/components/CarouselControls";
8+
import type { HighlightedEvent } from "@/content/events/types";
9+
import { SIDE_EVENTS_HREF } from "@/content/links";
10+
import { getAnchorLinkProps } from "@/lib/links";
11+
12+
interface EventsCarouselProps {
13+
headline: string;
14+
scheduleCta: string;
15+
communityCta: string;
16+
items: HighlightedEvent[];
17+
}
18+
19+
function getDateParts(date: Date, locale: string, timeZone: string) {
20+
const parts = new Intl.DateTimeFormat(locale, {
21+
day: "numeric",
22+
month: "long",
23+
year: "numeric",
24+
timeZone,
25+
}).formatToParts(date);
26+
const get = (type: string) =>
27+
parts.find((part) => part.type === type)?.value ?? "";
28+
return { day: get("day"), month: get("month"), year: get("year") };
29+
}
30+
31+
function formatEventMeta(
32+
event: HighlightedEvent,
33+
locale: string,
34+
): { time: string | null; date: string } {
35+
const start = new Date(event.startAt);
36+
const end = event.endAt ? new Date(event.endAt) : null;
37+
const timeZone = event.timezone ?? "Europe/London";
38+
39+
const timeFormat = new Intl.DateTimeFormat(locale, {
40+
hour: "2-digit",
41+
minute: "2-digit",
42+
hourCycle: "h23",
43+
timeZone,
44+
});
45+
46+
const startParts = getDateParts(start, locale, timeZone);
47+
const endParts = end ? getDateParts(end, locale, timeZone) : null;
48+
const sameDay =
49+
!endParts ||
50+
(startParts.day === endParts.day &&
51+
startParts.month === endParts.month &&
52+
startParts.year === endParts.year);
53+
54+
if (sameDay) {
55+
return {
56+
time: end
57+
? `${timeFormat.format(start)}${timeFormat.format(end)}`
58+
: timeFormat.format(start),
59+
date: `${startParts.day} ${startParts.month}, ${startParts.year}`.toUpperCase(),
60+
};
61+
}
62+
63+
const dayRange =
64+
startParts.month === endParts.month && startParts.year === endParts.year
65+
? `${startParts.day}${endParts.day} ${endParts.month}`
66+
: `${startParts.day} ${startParts.month}${endParts.day} ${endParts.month}`;
67+
68+
return {
69+
time: null,
70+
date: `${dayRange}, ${endParts.year}`.toUpperCase(),
71+
};
72+
}
73+
74+
export default function EventsCarousel({
75+
headline,
76+
scheduleCta,
77+
communityCta,
78+
items,
79+
}: EventsCarouselProps) {
80+
const locale = useLocale();
81+
const scrollRef = useRef<HTMLUListElement>(null);
82+
const headingId = useId();
83+
84+
const scrollBy = useCallback((direction: number) => {
85+
if (!scrollRef.current) return;
86+
87+
const firstCard =
88+
scrollRef.current.querySelector<HTMLElement>("[data-event-card]");
89+
const scrollAmount = firstCard
90+
? firstCard.offsetWidth + 32
91+
: Math.min(scrollRef.current.clientWidth, 440);
92+
const maxScroll =
93+
scrollRef.current.scrollWidth - scrollRef.current.clientWidth;
94+
const nextScroll = scrollRef.current.scrollLeft + direction * scrollAmount;
95+
96+
if (nextScroll < 0) {
97+
scrollRef.current.scrollTo({ left: maxScroll, behavior: "smooth" });
98+
return;
99+
}
100+
101+
if (nextScroll > maxScroll) {
102+
scrollRef.current.scrollTo({ left: 0, behavior: "smooth" });
103+
return;
104+
}
105+
106+
scrollRef.current.scrollBy({
107+
left: direction * scrollAmount,
108+
behavior: "smooth",
109+
});
110+
}, []);
111+
112+
return (
113+
<div
114+
aria-labelledby={headingId}
115+
aria-roledescription="carousel"
116+
className="flex flex-col gap-m md:gap-l"
117+
role="region"
118+
>
119+
<div className="flex flex-col gap-s md:flex-row md:items-end md:justify-between">
120+
<div className="flex flex-col items-start gap-s">
121+
<h2 id={headingId} className="type-h3 max-w-[560px] text-white">
122+
{headline}
123+
</h2>
124+
<div className="flex w-full flex-col gap-xs sm:w-auto sm:flex-row">
125+
<Button
126+
label={scheduleCta}
127+
variant="primary"
128+
href="/schedule"
129+
arrow
130+
className="w-full sm:w-auto"
131+
/>
132+
<Button
133+
label={communityCta}
134+
variant="secondary"
135+
href={SIDE_EVENTS_HREF}
136+
arrow
137+
className="w-full sm:w-auto"
138+
/>
139+
</div>
140+
</div>
141+
<CarouselControls
142+
className="shrink-0"
143+
labelPrefix={headline}
144+
onPrev={() => scrollBy(-1)}
145+
onNext={() => scrollBy(1)}
146+
/>
147+
</div>
148+
149+
<ul
150+
ref={scrollRef}
151+
aria-label={headline}
152+
className="unstyled-list scrollbar-hidden -mr-[20px] flex touch-pan-x snap-x snap-mandatory gap-m overflow-x-auto overscroll-x-contain p-0 pr-[20px] [-webkit-overflow-scrolling:touch] md:mr-0 md:pr-0"
153+
role="list"
154+
>
155+
{items.map((event) => {
156+
const { time, date } = formatEventMeta(event, locale);
157+
158+
return (
159+
<li
160+
key={event.id}
161+
data-event-card
162+
className="block aspect-[3/2] w-[280px] shrink-0 snap-start md:w-[calc((100%-64px)/3)] md:min-w-[300px]"
163+
>
164+
<a
165+
href={event.url}
166+
className="group relative flex h-full w-full flex-col justify-end overflow-hidden border border-neutral-700 p-s focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-white"
167+
{...getAnchorLinkProps({ href: event.url })}
168+
>
169+
{event.coverUrl && (
170+
<>
171+
<Image
172+
src={event.coverUrl}
173+
alt=""
174+
fill
175+
sizes="(min-width: 768px) 33vw, 280px"
176+
className="object-cover transition-transform duration-300 group-hover:scale-105"
177+
/>
178+
<span
179+
aria-hidden="true"
180+
className="absolute inset-0 bg-gradient-to-t from-black via-black/60 to-black/20"
181+
/>
182+
</>
183+
)}
184+
<span className="relative z-10 flex flex-col items-start">
185+
{time && (
186+
<span className="type-eyebrow text-white">{time}</span>
187+
)}
188+
<span className="type-eyebrow mt-3xs text-white">{date}</span>
189+
<span className="type-h5 mt-2xs text-white">
190+
{event.title}
191+
</span>
192+
<span className="sr-only">(opens in a new tab)</span>
193+
</span>
194+
</a>
195+
</li>
196+
);
197+
})}
198+
</ul>
199+
</div>
200+
);
201+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import React from "react";
2+
import { getTranslations } from "@workspace/i18n/server";
3+
import EventsCarousel from "@/components/sections/EventsCarousel";
4+
import { getHighlightedEvents } from "@/content/events/luma";
5+
6+
export default async function EventsSection() {
7+
const t = await getTranslations("breakpoint");
8+
const events = await getHighlightedEvents();
9+
10+
if (events.length === 0) {
11+
return null;
12+
}
13+
14+
return (
15+
<section className="pt-20 md:pt-[120px]">
16+
<div className="container">
17+
<EventsCarousel
18+
headline={t("events.headline")}
19+
scheduleCta={t("events.scheduleCta")}
20+
communityCta={t("events.communityCta")}
21+
items={events}
22+
/>
23+
</div>
24+
</section>
25+
);
26+
}

0 commit comments

Comments
 (0)