22
33import { useState } from "react"
44import Image from "next/image"
5+ import type { StaticImageData } from "next/image"
56import clsx from "clsx"
67
78import { Tag } from "@/app/conf/_design-system/tag"
@@ -13,29 +14,43 @@ import {
1314 SocialIconType ,
1415} from "@/app/conf/_design-system/social-icon"
1516import { formatDescription } from "@/app/conf/2026/schedule/[id]/format-description"
16- import ArrowDownIcon from "@/app/conf/_design-system/pixelarticons/arrow-down.svg?svgr"
1717
18- import {
19- AmsterdamSession ,
20- AmsterdamSpeaker ,
21- amsterdamSessions ,
22- tagColors ,
23- } from "./schedule-data"
24-
25- const TIME_RANGE = new Intl . DateTimeFormat ( "en-US" , {
26- hour : "2-digit" ,
27- minute : "2-digit" ,
28- hour12 : false ,
29- timeZone : "Europe/Amsterdam" ,
30- } )
18+ export interface EventSpeaker {
19+ id : number
20+ name : string
21+ company : string
22+ jobtitle : string
23+ avatar ?: StaticImageData
24+ socialurls : { service : string ; url : string } [ ]
25+ }
3126
32- const DATE_FORMAT = new Intl . DateTimeFormat ( "en-US" , {
33- day : "numeric" ,
34- month : "long" ,
35- timeZone : "Europe/Amsterdam" ,
36- } )
27+ export interface EventSession {
28+ id : number
29+ uuid : string
30+ title : string
31+ /** ISO 8601 in venue local time */
32+ start : string
33+ /** ISO 8601 in venue local time */
34+ end : string
35+ /** Topic tags derived from the session description. */
36+ tags : string [ ]
37+ /** HTML */
38+ description : string
39+ venue : string
40+ speakers : EventSpeaker [ ]
41+ }
3742
38- export function ScheduleSection ( ) {
43+ export function EventScheduleSection ( {
44+ sessions,
45+ timezone,
46+ timezoneLabel,
47+ tagColors,
48+ } : {
49+ sessions : EventSession [ ]
50+ timezone : string
51+ timezoneLabel : string
52+ tagColors : Record < string , string >
53+ } ) {
3954 return (
4055 < section
4156 id = "schedule"
@@ -46,16 +61,16 @@ export function ScheduleSection() {
4661 < div className = "border-neu-200 dark:border-neu-100 xs:border-x" >
4762 < div className = "flex flex-wrap items-baseline justify-between gap-4 px-2 py-8 sm:px-3 lg:py-12 2xl:py-16" >
4863 < h2 className = "typography-h2" > Schedule</ h2 >
49- < p className = "typography-body-md text-neu-700" >
50- All times in Amsterdam Time (CET, UTC+2)
51- </ p >
64+ < p className = "typography-body-md text-neu-700" > { timezoneLabel } </ p >
5265 </ div >
5366
54- { amsterdamSessions . map ( ( session , i ) => (
67+ { sessions . map ( ( session , i ) => (
5568 < SessionBlock
5669 key = { session . id }
5770 session = { session }
5871 isFirst = { i === 0 }
72+ timezone = { timezone }
73+ tagColors = { tagColors }
5974 />
6075 ) ) }
6176 </ div >
@@ -68,21 +83,27 @@ export function ScheduleSection() {
6883function SessionBlock ( {
6984 session,
7085 isFirst,
86+ timezone,
87+ tagColors,
7188} : {
72- session : AmsterdamSession
89+ session : EventSession
7390 isFirst : boolean
91+ timezone : string
92+ tagColors : Record < string , string >
7493} ) {
75- // On xl+ with a single speaker we slot the card next to the last two
76- // paragraphs of the description so it sits in the bottom-right corner.
77- // Multi-speaker sessions keep the regular "speakers below" layout.
7894 const sideSpeaker = session . speakers . length === 1 ? session . speakers [ 0 ] : null
7995
8096 return (
8197 < article >
8298 < Hr
8399 className = { isFirst ? "mt-8 lg:mt-12 xl:mt-0" : "mt-12 lg:mt-16 xl:mt-0" }
84100 />
85- < SessionHeader session = { session } className = "px-2 py-8 sm:px-3 lg:py-12" />
101+ < SessionHeader
102+ session = { session }
103+ timezone = { timezone }
104+ tagColors = { tagColors }
105+ className = "px-2 py-8 sm:px-3 lg:py-12"
106+ />
86107 { session . description && (
87108 < >
88109 < Hr className = "mt-0 lg:mt-10 xl:mt-0 2xl:mt-16" />
@@ -110,7 +131,7 @@ function SessionDescription({
110131 sideSpeaker,
111132} : {
112133 description : string
113- sideSpeaker : AmsterdamSpeaker | null
134+ sideSpeaker : EventSpeaker | null
114135} ) {
115136 const [ expanded , setExpanded ] = useState ( false )
116137 const paragraphs = parseParagraphs ( description )
@@ -165,12 +186,6 @@ function SessionDescription({
165186 )
166187}
167188
168- /**
169- * Split FOST description HTML (a sequence of `<p>...</p>` blocks) into the
170- * inner HTML of each paragraph so we can render them as real React `<p>`
171- * siblings — needed so we can splice the speaker card in alongside the last
172- * couple of paragraphs at xl+.
173- */
174189function parseParagraphs ( html : string ) : string [ ] {
175190 const formatted = formatDescription ( html )
176191 const matches = formatted . match ( / < p > [ \s \S ] * ?< \/ p > / g)
@@ -180,11 +195,26 @@ function parseParagraphs(html: string): string[] {
180195
181196function SessionHeader ( {
182197 session,
198+ timezone,
199+ tagColors,
183200 className,
184201} : {
185- session : AmsterdamSession
202+ session : EventSession
203+ timezone : string
204+ tagColors : Record < string , string >
186205 className ?: string
187206} ) {
207+ const timeRange = new Intl . DateTimeFormat ( "en-US" , {
208+ hour : "2-digit" ,
209+ minute : "2-digit" ,
210+ hour12 : false ,
211+ timeZone : timezone ,
212+ } )
213+ const dateFormat = new Intl . DateTimeFormat ( "en-US" , {
214+ day : "numeric" ,
215+ month : "long" ,
216+ timeZone : timezone ,
217+ } )
188218 const start = new Date ( session . start )
189219 const end = new Date ( session . end )
190220
@@ -196,9 +226,9 @@ function SessionHeader({
196226 < div className = "flex items-center gap-2" >
197227 < CalendarIcon className = "size-5 text-sec-darker dark:text-sec-light/90 sm:size-6" />
198228 < time dateTime = { session . start } >
199- { DATE_FORMAT . format ( start ) }
229+ { dateFormat . format ( start ) }
200230 { ", " }
201- { TIME_RANGE . formatRange ( start , end ) }
231+ { timeRange . formatRange ( start , end ) }
202232 </ time >
203233 </ div >
204234 { session . venue && (
@@ -229,7 +259,7 @@ function SessionSpeakers({
229259 speakers,
230260 className,
231261} : {
232- speakers : AmsterdamSpeaker [ ]
262+ speakers : EventSpeaker [ ]
233263 className ?: string
234264} ) {
235265 return (
@@ -265,7 +295,7 @@ function SpeakerCard({
265295 speaker,
266296 index,
267297} : {
268- speaker : AmsterdamSpeaker
298+ speaker : EventSpeaker
269299 index : number
270300} ) {
271301 const variant = STRIPE_VARIANTS [ index % STRIPE_VARIANTS . length ]
@@ -280,15 +310,21 @@ function SpeakerCard({
280310 < article className = "group relative overflow-hidden border border-t-0 border-neu-200 bg-transparent @container dark:border-neu-100" >
281311 < div className = "flex h-full flex-col gap-4 p-4 @[420px]:flex-row md:gap-6 md:p-6" >
282312 < div className = "relative aspect-square h-full overflow-hidden @[420px]:w-[176px] @[420px]:shrink-0" >
283- < div className = "absolute inset-0 z-[1] bg-sec-light mix-blend-multiply" />
284- < Image
285- src = { speaker . avatar }
286- alt = ""
287- width = { 176 }
288- height = { 176 }
289- placeholder = "blur"
290- className = "size-full object-cover saturate-[.1]"
291- />
313+ { speaker . avatar && (
314+ < div className = "absolute inset-0 z-[1] bg-sec-light mix-blend-multiply" />
315+ ) }
316+ { speaker . avatar ? (
317+ < Image
318+ src = { speaker . avatar }
319+ alt = ""
320+ width = { 176 }
321+ height = { 176 }
322+ placeholder = "blur"
323+ className = "size-full object-cover saturate-[.1]"
324+ />
325+ ) : (
326+ < div className = "size-full bg-neu-200 dark:bg-neu-100" />
327+ ) }
292328 < div
293329 role = "presentation"
294330 className = "pointer-events-none absolute inset-0 inset-y-[-20px]"
@@ -322,11 +358,7 @@ function SpeakerCard({
322358 )
323359}
324360
325- function SpeakerSocialLinks ( {
326- links,
327- } : {
328- links : AmsterdamSpeaker [ "socialurls" ]
329- } ) {
361+ function SpeakerSocialLinks ( { links } : { links : EventSpeaker [ "socialurls" ] } ) {
330362 const ordered = SocialIconType . all
331363 . map ( service =>
332364 links . find ( l => l . service . toLowerCase ( ) === service . toLowerCase ( ) ) ,
0 commit comments