Northlight Summit: conference
A five-page conference site under a sunrise ridgeline: a hero with a live countdown, a schedule you can star into an agenda with clash warnings, speakers with bios, tickets with a live total and promo code, and a venue guide.
// Ballmac UI: Summit template fonts. https://ui.ballmac.com/templates/template-summit
import { Figtree, Unbounded } from "next/font/google"
export const summitDisplay = Unbounded({ variable: "--summit-display", subsets: ["latin"], display: "swap", preload: false })
export const summitSans = Figtree({ variable: "--summit-sans", subsets: ["latin"], display: "swap", preload: false })// Ballmac UI: Summit template data. https://ui.ballmac.com/templates/template-summit
export type Track = "Main stage" | "Craft" | "Systems"
export const tracks: Track[] = ["Main stage", "Craft", "Systems"]
export type Speaker = { id: string; name: string; role: string; company: string; bio: string; tone: number; track: Track; talk: string }
export const speakers: Speaker[] = [
{ id: "ingrid", name: "Ingrid Solheim", role: "Design director", company: "Fjord & Co", bio: "Ingrid leads a forty-person studio that designs ferries, hospitals and the signs between them. She believes the best interface is a good sign.", tone: 0, track: "Main stage", talk: "Wayfinding for the weary" },
{ id: "kofi", name: "Kofi Mensah", role: "Principal engineer", company: "Lattice", bio: "Kofi has run the on-call rota for a payments network for eight years and has opinions about alert fatigue that he is happy to share.", tone: 1, track: "Systems", talk: "Pagers are a design problem" },
{ id: "mira", name: "Mira Castellanos", role: "Type designer", company: "Letterform Foundry", bio: "Mira draws typefaces for screens that nobody has built yet. Her latest family ships with a hundred and forty weights.", tone: 2, track: "Craft", talk: "The weight between regular and bold" },
{ id: "jun", name: "Jun Watanabe", role: "Founder", company: "Tidepool", bio: "Jun built three companies in three countries and is writing a book about the quiet years between them.", tone: 3, track: "Main stage", talk: "The quiet years" },
{ id: "amara", name: "Amara Nwosu", role: "Research lead", company: "Open Lab", bio: "Amara studies how people actually use software when nobody is watching, and what they build around it.", tone: 4, track: "Craft", talk: "What users do with your product on Sunday" },
{ id: "leo", name: "Leo Bergstrom", role: "Staff engineer", company: "Northwind", bio: "Leo ripped out a two-million-line monolith one route at a time and kept a diary of every week.", tone: 0, track: "Systems", talk: "Deleting the monolith, a diary" },
{ id: "sana", name: "Sana Iqbal", role: "Head of product", company: "Parcel", bio: "Sana ships features that remove other features. Her team once shrank the settings page by sixty percent.", tone: 2, track: "Main stage", talk: "Subtraction as a roadmap" },
{ id: "tomas", name: "Tomáš Havel", role: "Motion designer", company: "Frame Rate", bio: "Tomáš animates interfaces that feel like physical objects, and has strong views on easing curves.", tone: 1, track: "Craft", talk: "Easing is a feeling" },
{ id: "freya", name: "Freya Lindqvist", role: "Security architect", company: "Vaultline", bio: "Freya designs the unglamorous parts: key rotation, audit trails and the recovery flow nobody wants to test.", tone: 3, track: "Systems", talk: "Design for the worst day" },
{ id: "dev", name: "Dev Raman", role: "Creative technologist", company: "Studio Nine", bio: "Dev builds installations out of sensors, shaders and a worrying amount of tape.", tone: 4, track: "Craft", talk: "Making light listen" },
{ id: "elena", name: "Elena Rossi", role: "Editor in chief", company: "Margin Magazine", bio: "Elena edits a magazine about slowness, which is published, appropriately, quarterly.", tone: 2, track: "Main stage", talk: "In praise of the long read" },
{ id: "noah", name: "Noah Fischer", role: "Platform lead", company: "Relay", bio: "Noah makes developer platforms people actually enjoy, mostly by deleting steps from the docs.", tone: 0, track: "Systems", talk: "The ten-minute quickstart" },
]
export type Session = { id: string; day: 1 | 2; start: string; end: string; title: string; track: Track; speaker: string; room: string; level: "All levels" | "Intermediate" | "Advanced"; blurb: string }
export const sessions: Session[] = [
{ id: "s1", day: 1, start: "09:30", end: "10:15", title: "Wayfinding for the weary", track: "Main stage", speaker: "ingrid", room: "Aurora Hall", level: "All levels", blurb: "What a hospital corridor teaches us about onboarding, and why the best signs say less." },
{ id: "s2", day: 1, start: "10:45", end: "11:30", title: "Pagers are a design problem", track: "Systems", speaker: "kofi", room: "Basalt Room", level: "Intermediate", blurb: "Eight years of on-call, distilled into six rules for alerts a human can act on at 3 a.m." },
{ id: "s3", day: 1, start: "10:45", end: "11:30", title: "The weight between regular and bold", track: "Craft", speaker: "mira", room: "Studio One", level: "Advanced", blurb: "A walk through variable fonts, optical sizes and the weights nobody ships but everybody feels." },
{ id: "s4", day: 1, start: "13:00", end: "13:45", title: "The quiet years", track: "Main stage", speaker: "jun", room: "Aurora Hall", level: "All levels", blurb: "Companies are not built in the launch week. A founder on the long, unglamorous middle." },
{ id: "s5", day: 1, start: "14:15", end: "15:00", title: "What users do with your product on Sunday", track: "Craft", speaker: "amara", room: "Studio One", level: "Intermediate", blurb: "Field research from forty living rooms, and the workarounds that became features." },
{ id: "s6", day: 1, start: "14:15", end: "15:00", title: "Deleting the monolith, a diary", track: "Systems", speaker: "leo", room: "Basalt Room", level: "Advanced", blurb: "Two million lines, one route at a time. What worked, what burned and what we would not do again." },
{ id: "s7", day: 1, start: "16:00", end: "16:45", title: "Subtraction as a roadmap", track: "Main stage", speaker: "sana", room: "Aurora Hall", level: "All levels", blurb: "How removing settings, steps and screens made a product feel twice as fast." },
{ id: "s8", day: 2, start: "09:30", end: "10:15", title: "In praise of the long read", track: "Main stage", speaker: "elena", room: "Aurora Hall", level: "All levels", blurb: "Why a slower publication found a faster audience." },
{ id: "s9", day: 2, start: "10:45", end: "11:30", title: "Easing is a feeling", track: "Craft", speaker: "tomas", room: "Studio One", level: "Intermediate", blurb: "Curves, springs and the physics of things that should feel heavy or light." },
{ id: "s10", day: 2, start: "10:45", end: "11:30", title: "Design for the worst day", track: "Systems", speaker: "freya", room: "Basalt Room", level: "Advanced", blurb: "Recovery flows, audit trails and the screens people only see when something has gone wrong." },
{ id: "s11", day: 2, start: "13:00", end: "13:45", title: "Making light listen", track: "Craft", speaker: "dev", room: "Studio One", level: "Advanced", blurb: "A live build of an installation that responds to a room full of people." },
{ id: "s12", day: 2, start: "13:00", end: "13:45", title: "The ten-minute quickstart", track: "Systems", speaker: "noah", room: "Basalt Room", level: "Intermediate", blurb: "A teardown of five onboarding docs and the step each one should lose." },
{ id: "s13", day: 2, start: "15:00", end: "16:00", title: "Closing conversation: what we will keep", track: "Main stage", speaker: "ingrid", room: "Aurora Hall", level: "All levels", blurb: "Four speakers, one table and the questions you send in during the two days." },
]
export type Tier = { id: string; name: string; price: number; blurb: string; perks: string[]; badge?: string }
export const tiers: Tier[] = [
{ id: "student", name: "Student", price: 190, blurb: "With a valid student ID at the door.", perks: ["Both days, all tracks", "Lunch and coffee", "Recordings next week"] },
{ id: "early", name: "Early bird", price: 490, blurb: "Until 15 January, or 200 tickets.", perks: ["Both days, all tracks", "Lunch, coffee and the Thursday dinner", "Recordings next week", "Speaker Q&A lounge"], badge: "Best value" },
{ id: "standard", name: "Standard", price: 690, blurb: "The full two days.", perks: ["Both days, all tracks", "Lunch, coffee and the Thursday dinner", "Recordings next week", "Speaker Q&A lounge"] },
{ id: "patron", name: "Patron", price: 1290, blurb: "Support the event and sit near the front.", perks: ["Everything in Standard", "Reserved seats in Aurora Hall", "Patrons’ breakfast with the speakers", "Your name on the wall"] },
]
export const faqs = [
{ q: "Will talks be recorded?", a: "Yes. Every main stage and track talk is recorded and sent to ticket holders within a week, with captions." },
{ q: "Is the venue accessible?", a: "All three rooms are step-free, with hearing loops and a quiet room on every floor. Tell us what you need when you buy and we will arrange it." },
{ q: "Can I get a refund?", a: "Full refund until 30 days before the event, then a transfer to another person for free. No questions." },
{ q: "Do you offer childcare?", a: "There is a free, staffed room for children aged three to ten on both days. Add it to your order." },
{ q: "What is the code of conduct?", a: "Be kind, ask before you photograph people and leave a seat for someone standing. Violations are handled by a named, trained team." },
{ q: "Where should I stay?", a: "We hold rooms at three hotels within ten minutes’ walk. Prices are on the venue page and the code is in your ticket email." },
]
export const sponsors = ["Northwind", "Relay", "Parcel", "Lattice", "Tidepool", "Vaultline", "Studio Nine", "Margin"]
export const EVENT_DATE = "2027-05-14T09:00:00Z"
export const speakerById = (id: string) => speakers.find((s) => s.id === id) ?? speakers[0]!// Ballmac UI: Summit template shell. https://ui.ballmac.com/templates/template-summit
"use client"
import * as React from "react"
import { Menu, X } from "lucide-react"
import { EVENT_DATE, type Speaker } from "@/components/ballmac/templates/summit/summit-data"
import { summitDisplay, summitSans } from "@/components/ballmac/templates/summit/summit-fonts"
import { cn } from "@/lib/utils"
type SummitPage = "home" | "schedule" | "speakers" | "tickets" | "venue"
type SummitHrefs = Record<SummitPage, string>
const defaultHrefs: SummitHrefs = { home: "/summit", schedule: "/summit/schedule", speakers: "/summit/speakers", tickets: "/summit/tickets", venue: "/summit/venue" }
/** Northlight's palette: a cream morning page with indigo ink; dark mode is the same mountain at night. The hero sky is the same in both. */
const summitCss = `
.summit-theme,body:not(:has([data-gallery])):has(.summit-theme){--background:oklch(0.975 0.015 80);--foreground:oklch(0.22 0.08 275);--card:oklch(0.99 0.01 80);--card-foreground:oklch(0.22 0.08 275);--popover:oklch(0.99 0.01 80);--popover-foreground:oklch(0.22 0.08 275);--primary:oklch(0.3 0.12 275);--primary-foreground:oklch(0.975 0.015 80);--secondary:oklch(0.94 0.025 80);--secondary-foreground:oklch(0.22 0.08 275);--muted:oklch(0.94 0.025 80);--muted-foreground:oklch(0.44 0.07 275);--accent:oklch(0.92 0.04 75);--accent-foreground:oklch(0.22 0.08 275);--border:oklch(0.22 0.08 275 / 14%);--input:oklch(0.22 0.08 275 / 22%);--ring:oklch(0.5 0.2 285);--surface:oklch(0.955 0.02 80);--destructive:oklch(0.52 0.21 27);--chart-1:oklch(0.7 0.18 38);--chart-2:oklch(0.84 0.15 85);--chart-3:oklch(0.62 0.11 190);--chart-4:oklch(0.52 0.2 295);--chart-5:oklch(0.7 0.16 350);--summit-on-sun:oklch(0.2 0.08 275);--summit-hero-fg:oklch(0.98 0.012 80);--summit-sky-1:oklch(0.2 0.09 280);--summit-sky-2:oklch(0.36 0.15 300);--summit-sky-3:oklch(0.62 0.19 20);--summit-sky-4:oklch(0.82 0.15 75);--summit-ridge-1:oklch(0.3 0.1 290);--summit-ridge-2:oklch(0.23 0.09 282);--summit-ridge-3:oklch(0.17 0.07 278);--radius:1.25rem}
.dark .summit-theme,.dark body:not(:has([data-gallery])):has(.summit-theme){--background:oklch(0.17 0.06 277);--foreground:oklch(0.96 0.015 80);--card:oklch(0.21 0.07 277);--card-foreground:oklch(0.96 0.015 80);--popover:oklch(0.23 0.07 277);--popover-foreground:oklch(0.96 0.015 80);--primary:oklch(0.85 0.14 85);--primary-foreground:oklch(0.2 0.07 277);--secondary:oklch(0.26 0.07 277);--secondary-foreground:oklch(0.96 0.015 80);--muted:oklch(0.25 0.07 277);--muted-foreground:oklch(0.75 0.05 280);--accent:oklch(0.3 0.08 280);--accent-foreground:oklch(0.96 0.015 80);--border:oklch(1 0 0 / 11%);--input:oklch(1 0 0 / 16%);--ring:oklch(0.85 0.14 85);--surface:oklch(0.195 0.065 277);--destructive:oklch(0.7 0.19 27);--chart-1:oklch(0.75 0.17 40);--chart-2:oklch(0.86 0.14 88);--chart-3:oklch(0.76 0.11 190);--chart-4:oklch(0.72 0.17 300);--chart-5:oklch(0.78 0.14 350)}
body:not(:has([data-gallery])):has(.summit-theme){font-family:var(--summit-sans),ui-sans-serif,system-ui,sans-serif}
@keyframes summit-float{0%,100%{transform:translateY(0)}50%{transform:translateY(-6px)}}
.summit-float{animation:summit-float 6s ease-in-out infinite}
@media (prefers-reduced-motion:reduce){.summit-float{animation:none}}
`
const display = "[font-family:var(--summit-display),ui-sans-serif,system-ui,sans-serif] font-semibold tracking-[-0.03em]"
const onSun = "text-[var(--summit-on-sun)]"
const tones = ["bg-chart-1", "bg-chart-2", "bg-chart-3", "bg-chart-4", "bg-chart-5"]
/** A portrait without a photo: a colour field, shoulders and a head. Replace it with an image when you have one. */
function Portrait({ speaker, className }: { speaker: Pick<Speaker, "tone" | "name">; className?: string }) {
return (
<div aria-hidden="true" className={cn("relative isolate aspect-[4/5] w-full overflow-hidden", tones[speaker.tone % tones.length], className)}>
<div className="bg-[var(--summit-ridge-3)] absolute bottom-[-30%] left-1/2 aspect-square w-[88%] -translate-x-1/2 rounded-full" />
<div className="bg-[var(--summit-hero-fg)] absolute top-[22%] left-1/2 aspect-square w-[34%] -translate-x-1/2 rounded-full" />
</div>
)
}
/** The mountain scene: a night-to-sunrise sky, a sun and three ridgelines. Decorative. */
function Ridge({ className }: { className?: string }) {
return (
<div aria-hidden="true" className={cn("pointer-events-none absolute inset-0 -z-10 overflow-hidden", className)} style={{ backgroundImage: "linear-gradient(to bottom, var(--summit-sky-1) 0%, var(--summit-sky-2) 38%, var(--summit-sky-3) 74%, var(--summit-sky-4) 100%)" }}>
<div className="bg-[var(--summit-sky-4)] summit-float absolute end-[12%] bottom-[24%] aspect-square w-[min(22vw,15rem)] rounded-full opacity-95 shadow-[0_0_120px_40px_var(--summit-sky-3)]" />
<svg viewBox="0 0 1440 420" preserveAspectRatio="none" className="absolute inset-x-0 bottom-0 h-[46%] w-full">
<path className="fill-[var(--summit-ridge-1)]" d="M0 250 L120 190 L210 230 L340 130 L470 210 L560 170 L700 250 L820 150 L930 220 L1060 120 L1190 210 L1300 160 L1440 230 L1440 420 L0 420 Z" />
<path className="fill-[var(--summit-ridge-2)]" d="M0 310 L90 270 L200 320 L330 240 L450 300 L590 230 L720 310 L860 250 L980 320 L1100 260 L1240 320 L1340 280 L1440 320 L1440 420 L0 420 Z" />
<path className="fill-[var(--summit-ridge-3)]" d="M0 370 L140 340 L260 375 L400 330 L540 372 L690 335 L820 378 L960 340 L1100 376 L1240 345 L1440 380 L1440 420 L0 420 Z" />
</svg>
</div>
)
}
/** Days, hours, minutes and seconds to the opening keynote. Computed after mount so server and client markup match. */
function useCountdown(target: string) {
const [left, setLeft] = React.useState<number | null>(null)
React.useEffect(() => {
const tick = () => setLeft(Math.max(0, Math.floor((new Date(target).getTime() - Date.now()) / 1000)))
tick()
const id = window.setInterval(tick, 1000)
return () => window.clearInterval(id)
}, [target])
if (left === null) return null
return { days: Math.floor(left / 86400), hours: Math.floor((left % 86400) / 3600), minutes: Math.floor((left % 3600) / 60), seconds: left % 60 }
}
const links: { key: SummitPage; label: string }[] = [
{ key: "schedule", label: "Schedule" },
{ key: "speakers", label: "Speakers" },
{ key: "venue", label: "Venue" },
]
type SummitShellProps = React.ComponentProps<"div"> & {
/** The page being shown, so its nav link is marked current. */
page: SummitPage
/** Override where pages live (used by previews). */
hrefs?: Partial<SummitHrefs>
}
/** Northlight's frame: a transparent header that settles on scroll, a ticket button that is always in reach and a mountain footer. */
function SummitShell({ page, hrefs: overrides, className, style, children, ...props }: SummitShellProps) {
const hrefs = { ...defaultHrefs, ...overrides }
const [open, setOpen] = React.useState(false)
React.useEffect(() => {
const classes = [summitDisplay.variable, summitSans.variable].filter(Boolean)
document.body.classList.add(...classes)
return () => document.body.classList.remove(...classes)
}, [])
return (
<div
data-slot="summit"
className={cn("summit-theme bg-background text-foreground relative min-h-dvh overflow-x-clip", summitDisplay.variable, summitSans.variable, className)}
style={{ fontFamily: "var(--summit-sans), ui-sans-serif, system-ui, sans-serif", ...style }}
{...props}
>
<style>{summitCss}</style>
<header className="bg-background/85 sticky top-0 z-40 border-b backdrop-blur-xl">
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between gap-4 px-4 sm:px-6">
<a href={hrefs.home} className="focus-visible:ring-ring/50 flex items-center gap-2.5 rounded-lg outline-none focus-visible:ring-[3px]">
<span aria-hidden="true" className="bg-chart-1 relative size-8 overflow-hidden rounded-full"><span className="bg-[var(--summit-ridge-2)] absolute -bottom-3 left-1/2 aspect-square w-[130%] -translate-x-1/2 rounded-[40%]" /></span>
<span className={cn("text-base", display)}>Northlight <span className="text-muted-foreground">’27</span></span>
</a>
<nav aria-label="Main" className="hidden items-center gap-1 md:flex">
{links.map((l) => <a key={l.key} href={hrefs[l.key]} aria-current={page === l.key ? "page" : undefined} className="hover:bg-accent aria-[current=page]:bg-accent focus-visible:ring-ring/50 rounded-full px-4 py-2 text-sm font-semibold outline-none transition-colors focus-visible:ring-[3px] motion-reduce:transition-none">{l.label}</a>)}
</nav>
<div className="flex items-center gap-2">
<a href={hrefs.tickets} aria-current={page === "tickets" ? "page" : undefined} className={cn("bg-chart-1 focus-visible:ring-ring/50 inline-flex h-10 items-center rounded-full px-5 text-sm font-bold outline-none transition-transform hover:-translate-y-0.5 focus-visible:ring-[3px] motion-reduce:transition-none motion-reduce:hover:translate-y-0", onSun)}>Get tickets</a>
<button type="button" aria-label={open ? "Close menu" : "Open menu"} aria-expanded={open} aria-controls="summit-mobile-menu" onClick={() => setOpen((v) => !v)} className="hover:bg-accent focus-visible:ring-ring/50 inline-flex size-10 items-center justify-center rounded-full outline-none focus-visible:ring-[3px] md:hidden">{open ? <X className="size-5" aria-hidden="true" /> : <Menu className="size-5" aria-hidden="true" />}</button>
</div>
</div>
{open && <nav id="summit-mobile-menu" aria-label="Mobile" className="px-4 pb-4 md:hidden">{links.map((l) => <a key={l.key} href={hrefs[l.key]} className="hover:bg-accent block rounded-2xl px-4 py-3 text-lg font-semibold">{l.label}</a>)}</nav>}
</header>
{children}
<footer className="relative isolate mt-24 overflow-hidden text-[var(--summit-hero-fg)]">
<Ridge />
<div aria-hidden="true" className="bg-[var(--summit-ridge-3)]/75 absolute inset-0 -z-10" />
<div className="mx-auto grid max-w-7xl gap-10 px-4 pt-24 pb-14 sm:px-6 md:grid-cols-[1.5fr_1fr_1fr]">
<div><p className={cn("text-[clamp(2rem,5vw,3.5rem)] leading-none", display)}>See you in the mountains.</p><p className="mt-4 max-w-sm text-pretty">14 and 15 May 2027 · Harpa, Reykjavik. Two days of talks about design, engineering and the long view.</p></div>
<div><h2 className="text-xs font-bold tracking-[0.14em] uppercase">The event</h2><ul className="mt-4 grid gap-2.5">{[["Schedule", hrefs.schedule], ["Speakers", hrefs.speakers], ["Venue and travel", hrefs.venue], ["Tickets", hrefs.tickets]].map(([l, h]) => <li key={l}><a href={h} className="hover:underline">{l}</a></li>)}</ul></div>
<div><h2 className="text-xs font-bold tracking-[0.14em] uppercase">Contact</h2><ul className="mt-4 grid gap-2.5"><li>hello@northlight.example</li><li>Press and partnerships</li><li>Code of conduct</li></ul></div>
</div>
<p className="mx-auto max-w-7xl px-4 pb-6 text-xs sm:px-6">© 2027 Northlight Summit. Printed on a very small number of trees.</p>
</footer>
</div>
)
}
export { Portrait, Ridge, SummitShell, defaultHrefs as summitDefaultHrefs, display as summitDisplayClass, onSun as summitOnSun, tones as summitTones, useCountdown, type SummitHrefs, type SummitPage, type SummitShellProps }// Ballmac UI: Summit home page. https://ui.ballmac.com/templates/template-summit
"use client"
import * as React from "react"
import { ArrowRight, CalendarDays, MapPin } from "lucide-react"
import { EVENT_DATE, sessions, speakerById, speakers, sponsors, tiers } from "@/components/ballmac/templates/summit/summit-data"
import { Portrait, Ridge, SummitShell, summitDisplayClass, summitOnSun, useCountdown, type SummitHrefs } from "@/components/ballmac/templates/summit/summit-theme"
import { cn } from "@/lib/utils"
const stats = [["2", "days"], ["24", "talks"], ["3", "stages"], ["900", "people"]]
const reasons = [
{ n: "01", title: "Talks with a point of view", body: "Every speaker is asked one question before they are booked: what do you believe that most of your industry does not?" },
{ n: "02", title: "Rooms that are not crowded", body: "Nine hundred seats in a hall built for three thousand. You will always find a place to sit and someone to talk to." },
{ n: "03", title: "Time to talk, on purpose", body: "Ninety minutes for lunch, a long Thursday dinner and a lounge where speakers sit down and answer the second question." },
]
function Tile({ value, unit }: { value: string; unit: string }) {
return (
<div className="bg-[var(--summit-ridge-3)]/55 min-w-[4.5rem] rounded-2xl border border-white/20 px-3 py-3 text-center backdrop-blur-sm sm:min-w-24 sm:px-5 sm:py-4">
<div className={cn("text-3xl tabular-nums sm:text-5xl", summitDisplayClass)}>{value}</div>
<div className="mt-1 text-xs font-semibold tracking-widest uppercase">{unit}</div>
</div>
)
}
type SummitHomeProps = React.ComponentProps<"div"> & { hrefs?: Partial<SummitHrefs> }
/** The Summit home page: a sunrise hero with a live countdown, numbers, speakers, a schedule teaser and tickets. */
function SummitHome({ hrefs, ...props }: SummitHomeProps) {
const link = { schedule: "/summit/schedule", speakers: "/summit/speakers", tickets: "/summit/tickets", venue: "/summit/venue", ...hrefs }
const left = useCountdown(EVENT_DATE)
const pad = (n: number) => String(n).padStart(2, "0")
return (
<SummitShell page="home" hrefs={hrefs} {...props}>
<main>
<section aria-labelledby="sh-title" className="relative isolate overflow-hidden text-[var(--summit-hero-fg)]">
<Ridge />
<div className="mx-auto max-w-7xl px-4 pt-16 pb-72 sm:px-6 sm:pt-24 sm:pb-80">
<p className="inline-flex flex-wrap items-center gap-x-4 gap-y-1 rounded-full border border-white/25 px-4 py-1.5 text-sm font-semibold"><span className="inline-flex items-center gap-1.5"><CalendarDays className="size-4" aria-hidden="true" />14–15 May 2027</span><span className="inline-flex items-center gap-1.5"><MapPin className="size-4" aria-hidden="true" />Harpa, Reykjavik</span></p>
<h1 id="sh-title" className={cn("mt-8 max-w-4xl text-[clamp(3.2rem,11vw,9.5rem)] leading-[0.92]", summitDisplayClass)}>Look further.</h1>
<p className="mt-6 max-w-xl text-xl text-pretty">Two days of talks about design, engineering and the long view, held where the sun barely sets. Twelve speakers, nine hundred seats.</p>
<div className="mt-9 flex flex-wrap gap-3">
<a href={link.tickets} className={cn("bg-chart-2 focus-visible:ring-ring inline-flex h-14 items-center gap-2 rounded-full px-8 text-lg font-bold outline-none transition-transform hover:-translate-y-0.5 focus-visible:ring-[3px] motion-reduce:transition-none motion-reduce:hover:translate-y-0", summitOnSun)}>Get tickets from $190 <ArrowRight className="size-5 rtl:rotate-180" aria-hidden="true" /></a>
<a href={link.schedule} className="focus-visible:ring-ring inline-flex h-14 items-center rounded-full border-2 border-white/60 px-8 text-lg font-bold outline-none transition-colors hover:bg-white/10 focus-visible:ring-[3px] motion-reduce:transition-none">See the schedule</a>
</div>
<div className="mt-12" role="group" aria-label="Time until the first talk">
<p className="mb-3 text-sm font-semibold tracking-widest uppercase">Doors open in</p>
<div className="flex gap-2 sm:gap-3" aria-hidden="true">
<Tile value={left ? String(left.days) : "—"} unit="days" /><Tile value={left ? pad(left.hours) : "—"} unit="hours" /><Tile value={left ? pad(left.minutes) : "—"} unit="min" /><Tile value={left ? pad(left.seconds) : "—"} unit="sec" />
</div>
<p className="sr-only">{left ? `${left.days} days until the doors open on 14 May 2027.` : "Doors open on 14 May 2027."}</p>
</div>
</div>
</section>
<section aria-label="Supporters" className="border-b">
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-center gap-x-10 gap-y-3 px-4 py-6 sm:px-6"><span className="text-muted-foreground text-xs font-bold tracking-[0.14em] uppercase">Supported by</span>{sponsors.map((s) => <span key={s} className={cn("text-muted-foreground text-lg", summitDisplayClass)}>{s}</span>)}</div>
</section>
<section aria-label="The event in numbers" className="mx-auto max-w-7xl px-4 py-16 sm:px-6">
<ul className="grid grid-cols-2 gap-4 md:grid-cols-4">{stats.map(([n, l], i) => <li key={l} className={cn("rounded-3xl border p-6 sm:p-8", ["bg-chart-1/15", "bg-chart-2/25", "bg-chart-3/15", "bg-chart-4/15"][i])}><span className={cn("block text-[clamp(2.8rem,7vw,5rem)] leading-none", summitDisplayClass)}>{n}</span><span className="text-muted-foreground mt-2 block text-sm font-semibold tracking-widest uppercase">{l}</span></li>)}</ul>
</section>
<section aria-labelledby="sh-speakers" className="mx-auto max-w-7xl px-4 pb-16 sm:px-6">
<div className="flex items-end justify-between gap-4"><h2 id="sh-speakers" className={cn("text-[clamp(2rem,5vw,3.5rem)] leading-none", summitDisplayClass)}>On stage</h2><a href={link.speakers} className="text-primary text-sm font-bold underline-offset-4 hover:underline">All 12 speakers</a></div>
<ul className="mt-8 grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
{speakers.slice(0, 6).map((s) => (
<li key={s.id}><a href={link.speakers} className="focus-visible:ring-ring/50 group block rounded-3xl outline-none focus-visible:ring-[3px]"><Portrait speaker={s} className="rounded-3xl transition-transform group-hover:-translate-y-1 motion-reduce:transition-none motion-reduce:group-hover:translate-y-0" /><span className="mt-3 block font-bold">{s.name}</span><span className="text-muted-foreground block text-sm">{s.role}, {s.company}</span></a></li>
))}
</ul>
</section>
<section aria-labelledby="sh-day" className="bg-surface border-y">
<div className="mx-auto grid max-w-7xl gap-10 px-4 py-16 sm:px-6 lg:grid-cols-[1fr_1.4fr]">
<div><h2 id="sh-day" className={cn("text-[clamp(2rem,5vw,3.5rem)] leading-none", summitDisplayClass)}>Day one, at a glance</h2><p className="text-muted-foreground mt-4 max-w-sm text-lg text-pretty">Three rooms, no overlap you will regret. Star the talks you want and we will build your day.</p><a href={link.schedule} className="bg-primary text-primary-foreground focus-visible:ring-ring/50 mt-6 inline-flex h-12 items-center gap-2 rounded-full px-6 font-bold outline-none focus-visible:ring-[3px]">Build my agenda <ArrowRight className="size-4 rtl:rotate-180" aria-hidden="true" /></a></div>
<ol className="divide-y rounded-3xl border bg-card">
{sessions.filter((s) => s.day === 1).slice(0, 5).map((s) => (
<li key={s.id} className="grid grid-cols-[4.5rem_minmax(0,1fr)] gap-4 p-4 sm:grid-cols-[5.5rem_minmax(0,1fr)_auto] sm:p-5">
<span className={cn("text-lg tabular-nums", summitDisplayClass)}>{s.start}</span>
<span className="min-w-0"><span className="block font-bold text-pretty">{s.title}</span><span className="text-muted-foreground block text-sm">{speakerById(s.speaker).name} · {s.room}</span></span>
<span className="bg-secondary hidden h-fit rounded-full px-3 py-1 text-xs font-bold sm:inline">{s.track}</span>
</li>
))}
</ol>
</div>
</section>
<section aria-labelledby="sh-why" className="mx-auto max-w-7xl px-4 py-16 sm:px-6">
<h2 id="sh-why" className={cn("max-w-2xl text-[clamp(2rem,5vw,3.5rem)] leading-none text-balance", summitDisplayClass)}>Why people come back</h2>
<ul className="mt-10 grid gap-4 md:grid-cols-3">{reasons.map((r) => <li key={r.n} className="bg-card rounded-3xl border p-8"><span className={cn("text-primary text-sm", summitDisplayClass)}>{r.n}</span><h3 className="mt-6 text-2xl font-bold text-balance">{r.title}</h3><p className="text-muted-foreground mt-3 text-pretty">{r.body}</p></li>)}</ul>
</section>
<section aria-labelledby="sh-tix" className="mx-auto max-w-7xl px-4 pb-8 sm:px-6">
<div className={cn("bg-chart-1 relative overflow-hidden rounded-[2rem] p-8 sm:p-14", summitOnSun)}>
<div aria-hidden="true" className="bg-chart-2 absolute -top-24 -end-16 aspect-square w-80 rounded-full" />
<div className="relative grid items-end gap-8 lg:grid-cols-[1.4fr_1fr]">
<div><h2 id="sh-tix" className={cn("text-[clamp(2rem,5vw,3.8rem)] leading-none text-balance", summitDisplayClass)}>The early bird is $200 cheaper.</h2><p className="mt-4 max-w-lg text-lg text-pretty">Early bird ends on 15 January or at 200 tickets, whichever comes first. Refundable until 30 days before.</p></div>
<div className="flex flex-wrap items-center gap-4 lg:justify-end"><span className={cn("text-6xl", summitDisplayClass)}>${tiers[1]!.price}</span><a href={link.tickets} className="bg-[var(--summit-ridge-3)] focus-visible:ring-ring inline-flex h-14 items-center gap-2 rounded-full px-8 text-lg font-bold text-[var(--summit-hero-fg)] outline-none focus-visible:ring-[3px]">Get tickets <ArrowRight className="size-5 rtl:rotate-180" aria-hidden="true" /></a></div>
</div>
</div>
</section>
</main>
</SummitShell>
)
}
export { SummitHome, type SummitHomeProps }// Ballmac UI: Summit schedule page. https://ui.ballmac.com/templates/template-summit
"use client"
import * as React from "react"
import { Clock, MapPin, Star, TriangleAlert } from "lucide-react"
import { sessions, speakerById, tracks, type Session, type Track } from "@/components/ballmac/templates/summit/summit-data"
import { Portrait, SummitShell, summitDisplayClass, type SummitHrefs } from "@/components/ballmac/templates/summit/summit-theme"
import { cn } from "@/lib/utils"
const trackDot: Record<Track, string> = { "Main stage": "bg-chart-1", Craft: "bg-chart-4", Systems: "bg-chart-3" }
const days = [{ n: 1 as const, label: "Day 1", date: "Friday 14 May" }, { n: 2 as const, label: "Day 2", date: "Saturday 15 May" }]
const minutes = (t: string) => Number(t.slice(0, 2)) * 60 + Number(t.slice(3))
const overlaps = (a: Session, b: Session) => a.id !== b.id && a.day === b.day && minutes(a.start) < minutes(b.end) && minutes(b.start) < minutes(a.end)
type SummitScheduleProps = React.ComponentProps<"div"> & { hrefs?: Partial<SummitHrefs>; defaultSaved?: string[] }
/** The schedule: day switch, track filters, one-tap agenda saving with clash warnings and a live summary. */
function SummitSchedule({ hrefs, defaultSaved = [], ...props }: SummitScheduleProps) {
const [day, setDay] = React.useState<1 | 2>(1)
const [active, setActive] = React.useState<Track[]>([...tracks])
const [saved, setSaved] = React.useState<string[]>(defaultSaved)
const toggleTrack = (t: Track) => setActive((a) => (a.includes(t) ? (a.length > 1 ? a.filter((x) => x !== t) : a) : [...a, t]))
const toggleSaved = (id: string) => setSaved((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id]))
const list = sessions.filter((s) => s.day === day && active.includes(s.track))
const slots = [...new Set(list.map((s) => s.start))].sort()
const mine = sessions.filter((s) => saved.includes(s.id)).sort((a, b) => a.day - b.day || minutes(a.start) - minutes(b.start))
const clashes = mine.filter((s) => mine.some((o) => overlaps(s, o)))
return (
<SummitShell page="schedule" hrefs={hrefs} {...props}>
<main className="mx-auto max-w-7xl px-4 pt-12 pb-8 sm:px-6 sm:pt-16">
<h1 className={cn("text-[clamp(2.8rem,9vw,7rem)] leading-[0.95]", summitDisplayClass)}>Schedule</h1>
<p className="text-muted-foreground mt-4 max-w-xl text-xl text-pretty">Three rooms run side by side. Star what you want to see and we will keep an eye out for clashes.</p>
<div className="mt-10 flex flex-wrap items-center justify-between gap-4 border-y py-4">
<div role="group" aria-label="Day" className="flex gap-2">
{days.map((d) => <button key={d.n} type="button" aria-pressed={day === d.n} onClick={() => setDay(d.n)} className="hover:bg-accent focus-visible:ring-ring/50 aria-pressed:bg-primary aria-pressed:text-primary-foreground rounded-2xl border px-5 py-2 text-start outline-none focus-visible:ring-[3px]"><span className="block text-sm font-bold">{d.label}</span><span className="block text-xs">{d.date}</span></button>)}
</div>
<div role="group" aria-label="Tracks" className="flex flex-wrap gap-2">
{tracks.map((t) => <button key={t} type="button" aria-pressed={active.includes(t)} onClick={() => toggleTrack(t)} className="hover:bg-accent focus-visible:ring-ring/50 aria-pressed:bg-secondary inline-flex h-10 items-center gap-2 rounded-full border px-4 text-sm font-semibold outline-none focus-visible:ring-[3px]"><span className={cn("size-2.5 rounded-full", trackDot[t])} aria-hidden="true" />{t}</button>)}
</div>
</div>
<p role="status" className="text-muted-foreground mt-4 text-sm">{list.length} {list.length === 1 ? "talk" : "talks"} on {days[day - 1]!.date}. {saved.length > 0 ? `${saved.length} saved to your agenda.` : "Nothing saved yet."}</p>
<div className="mt-6 grid gap-10">
{slots.map((slot) => {
const here = list.filter((s) => s.start === slot)
return (
<section key={slot} aria-label={`${slot} sessions`} className="grid gap-4 md:grid-cols-[6rem_minmax(0,1fr)]">
<h2 className={cn("flex items-center gap-2 text-2xl tabular-nums md:block md:pt-4", summitDisplayClass)}>{slot}<span className="text-muted-foreground text-xs font-medium md:mt-1 md:block">until {here[0]!.end}</span></h2>
<ul className={cn("grid gap-4", here.length > 1 && "lg:grid-cols-2", here.length > 2 && "xl:grid-cols-3")}>
{here.map((s) => {
const sp = speakerById(s.speaker)
const on = saved.includes(s.id)
return (
<li key={s.id} className={cn("bg-card grid grid-cols-[3.5rem_minmax(0,1fr)_auto] gap-4 rounded-3xl border p-5 transition-colors motion-reduce:transition-none", on && "border-primary bg-accent/50")}>
<Portrait speaker={sp} className="aspect-square h-14 w-14 rounded-2xl" />
<div className="min-w-0">
<p className="flex items-center gap-2 text-xs font-bold"><span className={cn("size-2.5 rounded-full", trackDot[s.track])} aria-hidden="true" />{s.track}</p>
<h3 className="mt-1 text-lg leading-snug font-bold text-balance">{s.title}</h3>
<p className="text-muted-foreground mt-1 text-sm">{sp.name}, {sp.company}</p>
<p className="text-muted-foreground mt-3 text-sm text-pretty">{s.blurb}</p>
<p className="text-muted-foreground mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs font-medium"><span className="inline-flex items-center gap-1"><MapPin className="size-3.5" aria-hidden="true" />{s.room}</span><span className="inline-flex items-center gap-1"><Clock className="size-3.5" aria-hidden="true" />{s.level}</span></p>
</div>
<button type="button" aria-pressed={on} aria-label={on ? `Remove ${s.title} from my agenda` : `Add ${s.title} to my agenda`} onClick={() => toggleSaved(s.id)} className="hover:bg-accent focus-visible:ring-ring/50 inline-flex size-10 items-center justify-center rounded-full border outline-none focus-visible:ring-[3px]"><Star className={cn("size-5", on && "fill-current")} aria-hidden="true" /></button>
</li>
)
})}
</ul>
</section>
)
})}
{slots.length === 0 && <p className="text-muted-foreground py-10 text-center">No talks match these tracks on this day.</p>}
</div>
<section aria-labelledby="ss-agenda" className="bg-surface mt-16 rounded-3xl border p-6 sm:p-10">
<div className="flex flex-wrap items-end justify-between gap-4"><h2 id="ss-agenda" className={cn("text-3xl", summitDisplayClass)}>My agenda</h2>{saved.length > 0 && <button type="button" onClick={() => setSaved([])} className="hover:bg-accent focus-visible:ring-ring/50 h-10 rounded-full border px-5 text-sm font-bold outline-none focus-visible:ring-[3px]">Clear all</button>}</div>
{mine.length === 0 ? (
<p className="text-muted-foreground mt-4 max-w-md text-pretty">Press the star on any talk and it lands here, sorted by day and time.</p>
) : (
<>
{clashes.length > 0 && <p className="bg-chart-2/25 mt-4 flex items-start gap-2 rounded-xl p-3 text-sm font-medium"><TriangleAlert className="mt-0.5 size-4 shrink-0" aria-hidden="true" />{clashes.length} of your talks run at the same time. Pick one for each slot marked below.</p>}
<ol className="mt-5 divide-y">
{mine.map((s) => (
<li key={s.id} className="flex flex-wrap items-center gap-x-5 gap-y-1 py-3">
<span className={cn("w-28 text-sm tabular-nums", summitDisplayClass)}>Day {s.day} · {s.start}</span>
<span className="min-w-0 flex-1 font-semibold">{s.title}</span>
<span className="text-muted-foreground text-sm">{s.room}</span>
{clashes.includes(s) && <span className="bg-chart-2/40 rounded-full px-2.5 py-0.5 text-xs font-bold">Clash</span>}
</li>
))}
</ol>
</>
)}
</section>
</main>
</SummitShell>
)
}
export { SummitSchedule, type SummitScheduleProps }// Ballmac UI: Summit speakers page. https://ui.ballmac.com/templates/template-summit
"use client"
import * as React from "react"
import { Search } from "lucide-react"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ballmac/dialog"
import { sessions, speakers, tracks, type Speaker } from "@/components/ballmac/templates/summit/summit-data"
import { Portrait, SummitShell, summitDisplayClass, type SummitHrefs } from "@/components/ballmac/templates/summit/summit-theme"
import { cn } from "@/lib/utils"
const filters = ["All", ...tracks] as const
type SummitSpeakersProps = React.ComponentProps<"div"> & { hrefs?: Partial<SummitHrefs> }
/** The speakers page: filter by track or search by name, open a bio with the talk and the time it happens. */
function SummitSpeakers({ hrefs, ...props }: SummitSpeakersProps) {
const [filter, setFilter] = React.useState<(typeof filters)[number]>("All")
const [query, setQuery] = React.useState("")
const [open, setOpen] = React.useState<Speaker | null>(null)
const q = query.trim().toLowerCase()
const list = speakers.filter((s) => (filter === "All" || s.track === filter) && (!q || `${s.name} ${s.company} ${s.talk} ${s.role}`.toLowerCase().includes(q)))
const session = open ? sessions.find((s) => s.speaker === open.id) : null
return (
<SummitShell page="speakers" hrefs={hrefs} {...props}>
<main className="mx-auto max-w-7xl px-4 pt-12 pb-8 sm:px-6 sm:pt-16">
<h1 className={cn("text-[clamp(2.8rem,9vw,7rem)] leading-[0.95]", summitDisplayClass)}>Speakers</h1>
<p className="text-muted-foreground mt-4 max-w-xl text-xl text-pretty">Twelve people who have spent years on one hard question. Each gets forty-five minutes and no slides they did not make.</p>
<div className="mt-10 flex flex-wrap items-center gap-3 border-y py-4">
<div className="relative min-w-52 flex-1 sm:max-w-xs">
<label htmlFor="sp-q" className="sr-only">Search speakers</label>
<Search className="text-muted-foreground pointer-events-none absolute top-1/2 start-4 size-4 -translate-y-1/2" aria-hidden="true" />
<input id="sp-q" type="search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search speakers or talks" className="bg-card focus-visible:ring-ring/50 h-11 w-full rounded-full border pe-4 ps-10 text-sm outline-none focus-visible:ring-[3px]" />
</div>
<div role="group" aria-label="Filter by track" className="flex flex-wrap gap-2">
{filters.map((f) => <button key={f} type="button" aria-pressed={filter === f} onClick={() => setFilter(f)} className="hover:bg-accent focus-visible:ring-ring/50 aria-pressed:bg-primary aria-pressed:text-primary-foreground h-11 rounded-full border px-5 text-sm font-semibold outline-none focus-visible:ring-[3px]">{f}</button>)}
</div>
</div>
<p role="status" className="text-muted-foreground mt-4 text-sm">{list.length} {list.length === 1 ? "speaker" : "speakers"}</p>
<ul className="mt-6 grid grid-cols-2 gap-x-4 gap-y-10 md:grid-cols-3 lg:grid-cols-4">
{list.map((s) => (
<li key={s.id}>
<button type="button" onClick={() => setOpen(s)} aria-haspopup="dialog" className="focus-visible:ring-ring/50 group block w-full rounded-3xl text-start outline-none focus-visible:ring-[3px]">
<Portrait speaker={s} className="rounded-3xl transition-transform group-hover:-translate-y-1 motion-reduce:transition-none motion-reduce:group-hover:translate-y-0" />
<span className="mt-4 block text-lg font-bold">{s.name}</span>
<span className="text-muted-foreground block text-sm">{s.role}, {s.company}</span>
<span className="mt-2 block text-sm font-semibold text-pretty">“{s.talk}”</span>
</button>
</li>
))}
</ul>
{list.length === 0 && <div className="bg-surface mt-4 rounded-3xl border p-12 text-center"><p className={cn("text-2xl", summitDisplayClass)}>No one by that name.</p><button type="button" onClick={() => { setQuery(""); setFilter("All") }} className="bg-primary text-primary-foreground focus-visible:ring-ring/50 mt-5 h-11 rounded-full px-6 font-bold outline-none focus-visible:ring-[3px]">Show everyone</button></div>}
</main>
<Dialog open={!!open} onOpenChange={(v) => !v && setOpen(null)}>
<DialogContent className="summit-theme bg-background max-w-2xl gap-0 overflow-hidden p-0">
{open && (
<div className="grid sm:grid-cols-[14rem_minmax(0,1fr)]">
<Portrait speaker={open} className="aspect-[4/3] sm:aspect-auto sm:h-full" />
<div className="p-6 sm:p-8">
<DialogHeader className="text-start"><DialogTitle className={cn("text-2xl", summitDisplayClass)}>{open.name}</DialogTitle><DialogDescription>{open.role}, {open.company}</DialogDescription></DialogHeader>
<p className="mt-4 text-pretty">{open.bio}</p>
{session && (
<div className="bg-surface mt-6 rounded-2xl border p-4">
<p className="text-muted-foreground text-xs font-bold tracking-[0.14em] uppercase">Talk</p>
<p className="mt-1 font-bold">{session.title}</p>
<p className="text-muted-foreground mt-1 text-sm">Day {session.day} · {session.start} · {session.room}</p>
</div>
)}
</div>
</div>
)}
</DialogContent>
</Dialog>
</SummitShell>
)
}
export { SummitSpeakers, type SummitSpeakersProps }// Ballmac UI: Summit tickets page. https://ui.ballmac.com/templates/template-summit
"use client"
import * as React from "react"
import { Check, Minus, PartyPopper, Plus } from "lucide-react"
import { faqs, tiers } from "@/components/ballmac/templates/summit/summit-data"
import { SummitShell, summitDisplayClass, summitOnSun, type SummitHrefs } from "@/components/ballmac/templates/summit/summit-theme"
import { cn } from "@/lib/utils"
const money = (n: number) => `$${n.toLocaleString("en-US", { minimumFractionDigits: n % 1 ? 2 : 0, maximumFractionDigits: 2 })}`
const field = "bg-background focus-visible:ring-ring/50 aria-invalid:border-destructive h-12 w-full rounded-xl border px-4 outline-none focus-visible:ring-[3px]"
type SummitTicketsProps = React.ComponentProps<"div"> & { hrefs?: Partial<SummitHrefs> }
/** Tickets: pick a tier and a quantity, see a live total with group and promo discounts, then a validated checkout form. */
function SummitTickets({ hrefs, ...props }: SummitTicketsProps) {
const [tier, setTier] = React.useState("early")
const [qty, setQty] = React.useState(1)
const [promo, setPromo] = React.useState("")
const [promoState, setPromoState] = React.useState<"idle" | "ok" | "bad">("idle")
const [form, setForm] = React.useState({ name: "", email: "", company: "" })
const [errors, setErrors] = React.useState<Record<string, string>>({})
const [done, setDone] = React.useState(false)
const errorRef = React.useRef<HTMLFormElement>(null)
const t = tiers.find((x) => x.id === tier)!
const subtotal = t.price * qty
const group = qty >= 5 ? subtotal * 0.15 : 0
const code = promoState === "ok" ? (subtotal - group) * 0.1 : 0
const total = subtotal - group - code
function applyPromo(e: React.FormEvent) {
e.preventDefault()
setPromoState(promo.trim().toUpperCase() === "NORTH10" ? "ok" : "bad")
}
function submit(e: React.FormEvent) {
e.preventDefault()
const next: Record<string, string> = {}
if (!form.name.trim()) next.name = "Tell us who the ticket is for."
if (!/^\S+@\S+\.\S+$/.test(form.email.trim())) next.email = "Enter an email like you@example.com."
setErrors(next)
const first = Object.keys(next)[0]
if (first) return void errorRef.current?.querySelector<HTMLInputElement>(`[name="${first}"]`)?.focus()
setDone(true)
}
const bind = (k: keyof typeof form) => ({ name: k, value: form[k], onChange: (e: React.ChangeEvent<HTMLInputElement>) => setForm((f) => ({ ...f, [k]: e.target.value })), "aria-invalid": !!errors[k], "aria-describedby": errors[k] ? `st-err-${k}` : undefined })
return (
<SummitShell page="tickets" hrefs={hrefs} {...props}>
<main className="mx-auto max-w-7xl px-4 pt-12 pb-8 sm:px-6 sm:pt-16">
<h1 className={cn("text-[clamp(2.8rem,9vw,7rem)] leading-[0.95]", summitDisplayClass)}>Tickets</h1>
<p className="text-muted-foreground mt-4 max-w-xl text-xl text-pretty">One ticket covers both days and every room. Buy five or more and take 15% off automatically.</p>
{done ? (
<section aria-labelledby="st-done" className="bg-chart-2/25 mt-10 rounded-[2rem] border p-8 text-center sm:p-14">
<PartyPopper className="mx-auto size-10" aria-hidden="true" />
<h2 id="st-done" className={cn("mt-4 text-3xl sm:text-5xl", summitDisplayClass)}>You are going, {form.name.split(" ")[0]}.</h2>
<p role="status" className="mx-auto mt-4 max-w-md text-lg text-pretty">{qty} × {t.name} for {money(total)}. A receipt and your ticket{qty > 1 ? "s are" : " is"} on the way to {form.email}.</p>
<button type="button" onClick={() => { setDone(false); setForm({ name: "", email: "", company: "" }) }} className="hover:bg-accent focus-visible:ring-ring/50 mt-8 h-12 rounded-full border-2 px-6 font-bold outline-none focus-visible:ring-[3px]">Buy more tickets</button>
</section>
) : (
<div className="mt-10 grid grid-cols-[minmax(0,1fr)] items-start gap-8 lg:grid-cols-[minmax(0,1fr)_24rem]">
<div className="grid gap-8">
<fieldset>
<legend className={cn("mb-4 text-2xl", summitDisplayClass)}>1. Choose a ticket</legend>
<div className="grid gap-3 sm:grid-cols-2">
{tiers.map((x) => (
<label key={x.id} className={cn("bg-card focus-within:ring-ring/50 relative flex cursor-pointer flex-col rounded-3xl border-2 p-6 transition-colors focus-within:ring-[3px] motion-reduce:transition-none", tier === x.id ? "border-primary bg-accent/40" : "hover:border-primary/40")}>
<input type="radio" name="tier" value={x.id} checked={tier === x.id} onChange={() => setTier(x.id)} className="sr-only" />
{x.badge && <span className={cn("bg-chart-2 absolute -top-3 start-6 rounded-full px-3 py-0.5 text-xs font-bold", summitOnSun)}>{x.badge}</span>}
<span className="flex items-baseline justify-between gap-2"><span className="text-lg font-bold">{x.name}</span><span className={cn("text-3xl tabular-nums", summitDisplayClass)}>${x.price}</span></span>
<span className="text-muted-foreground mt-1 text-sm">{x.blurb}</span>
<ul className="mt-4 grid gap-1.5 text-sm">{x.perks.map((p) => <li key={p} className="flex gap-2"><Check className="mt-0.5 size-4 shrink-0" aria-hidden="true" />{p}</li>)}</ul>
</label>
))}
</div>
</fieldset>
<form ref={errorRef} onSubmit={submit} noValidate aria-labelledby="st-details" className="bg-card grid gap-5 rounded-3xl border p-6 sm:p-8">
<h2 id="st-details" className={cn("text-2xl", summitDisplayClass)}>2. Your details</h2>
<div className="grid gap-2"><label htmlFor="st-name" className="text-sm font-semibold">Full name</label><input id="st-name" autoComplete="name" className={field} {...bind("name")} />{errors.name && <p id="st-err-name" role="alert" className="text-destructive text-sm">{errors.name}</p>}</div>
<div className="grid gap-2"><label htmlFor="st-email" className="text-sm font-semibold">Email for the receipt</label><input id="st-email" type="email" autoComplete="email" className={field} {...bind("email")} />{errors.email && <p id="st-err-email" role="alert" className="text-destructive text-sm">{errors.email}</p>}</div>
<div className="grid gap-2"><label htmlFor="st-company" className="text-sm font-semibold">Company <span className="text-muted-foreground font-normal">(optional)</span></label><input id="st-company" autoComplete="organization" className={field} {...bind("company")} /></div>
<button type="submit" className="bg-primary text-primary-foreground focus-visible:ring-ring/50 h-14 rounded-full text-lg font-bold outline-none transition-opacity hover:opacity-90 focus-visible:ring-[3px]">Pay {money(total)}</button>
<p className="text-muted-foreground text-center text-xs">Demo checkout: no card is taken. Refundable until 30 days before.</p>
</form>
</div>
<aside aria-labelledby="st-sum" className="bg-surface rounded-3xl border p-6 lg:sticky lg:top-24">
<h2 id="st-sum" className={cn("text-2xl", summitDisplayClass)}>Your order</h2>
<div className="mt-5 flex items-center justify-between gap-3">
<span className="font-semibold">{t.name}</span>
<div className="flex items-center gap-1 rounded-full border bg-card" role="group" aria-label="Number of tickets">
<button type="button" aria-label="Fewer tickets" disabled={qty <= 1} onClick={() => setQty((q) => Math.max(1, q - 1))} className="hover:bg-accent focus-visible:ring-ring/50 inline-flex size-10 items-center justify-center rounded-full outline-none focus-visible:ring-[3px] disabled:opacity-50"><Minus className="size-4" aria-hidden="true" /></button>
<output aria-live="polite" className="w-8 text-center font-bold tabular-nums">{qty}</output>
<button type="button" aria-label="More tickets" disabled={qty >= 20} onClick={() => setQty((q) => Math.min(20, q + 1))} className="hover:bg-accent focus-visible:ring-ring/50 inline-flex size-10 items-center justify-center rounded-full outline-none focus-visible:ring-[3px] disabled:opacity-50"><Plus className="size-4" aria-hidden="true" /></button>
</div>
</div>
<form onSubmit={applyPromo} className="mt-5 flex gap-2">
<label htmlFor="st-promo" className="sr-only">Promo code</label>
<input id="st-promo" value={promo} onChange={(e) => { setPromo(e.target.value); setPromoState("idle") }} placeholder="Promo code" aria-describedby="st-promo-msg" className="bg-background focus-visible:ring-ring/50 h-11 min-w-0 flex-1 rounded-xl border px-3 text-sm outline-none focus-visible:ring-[3px]" />
<button type="submit" className="hover:bg-accent focus-visible:ring-ring/50 h-11 rounded-xl border px-4 text-sm font-bold outline-none focus-visible:ring-[3px]">Apply</button>
</form>
<p id="st-promo-msg" role="status" className={cn("mt-2 min-h-5 text-sm", promoState === "bad" && "text-destructive")}>{promoState === "ok" ? "NORTH10 applied: 10% off." : promoState === "bad" ? "That code is not valid. Try NORTH10." : ""}</p>
<dl className="mt-3 grid gap-2 border-t pt-4 text-sm">
<div className="flex justify-between"><dt>{qty} × {t.name}</dt><dd className="tabular-nums">{money(subtotal)}</dd></div>
{group > 0 && <div className="flex justify-between"><dt>Group discount (15%)</dt><dd className="tabular-nums">−{money(group)}</dd></div>}
{code > 0 && <div className="flex justify-between"><dt>Promo NORTH10</dt><dd className="tabular-nums">−{money(code)}</dd></div>}
<div className="flex justify-between border-t pt-3 text-lg font-bold"><dt>Total</dt><dd className="tabular-nums">{money(total)}</dd></div>
</dl>
{qty < 5 && <p className="text-muted-foreground mt-4 text-xs">Add {5 - qty} more {5 - qty === 1 ? "ticket" : "tickets"} for 15% off.</p>}
</aside>
</div>
)}
<section aria-labelledby="st-faq" className="mt-20 grid gap-8 lg:grid-cols-[1fr_1.6fr]">
<h2 id="st-faq" className={cn("text-[clamp(2rem,5vw,3rem)] leading-none", summitDisplayClass)}>Before you buy</h2>
<div className="divide-y border-y">{faqs.slice(2, 5).map((f) => <details key={f.q} className="group py-4"><summary className="focus-visible:ring-ring/50 cursor-pointer list-none rounded-md text-lg font-bold outline-none focus-visible:ring-[3px]">{f.q}</summary><p className="text-muted-foreground mt-2 text-pretty">{f.a}</p></details>)}</div>
</section>
</main>
</SummitShell>
)
}
export { SummitTickets, type SummitTicketsProps }// Ballmac UI: Summit venue page. https://ui.ballmac.com/templates/template-summit
"use client"
import * as React from "react"
import { Accessibility, Bed, Plane, Train } from "lucide-react"
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ballmac/accordion"
import { faqs } from "@/components/ballmac/templates/summit/summit-data"
import { SummitShell, summitDisplayClass, type SummitHrefs } from "@/components/ballmac/templates/summit/summit-theme"
import { cn } from "@/lib/utils"
const ways = [
{ id: "fly", label: "Fly", icon: Plane, title: "Keflavík airport, 50 minutes away", body: "Flybus meets every arrival and drops you at the hotel row. We run a free shuttle from the airport at 8:00 and 12:00 on Thursday and Friday." },
{ id: "bus", label: "City bus", icon: Train, title: "Lines 1, 3 and 11 stop at the door", body: "Buses run every ten minutes until midnight. Your ticket doubles as a three-day city pass. Show the QR code to the driver." },
{ id: "walk", label: "On foot", icon: Accessibility, title: "Twelve minutes from the old harbour", body: "The route along the water is flat and lit. Taxis and wheelchair-accessible vehicles wait at the north entrance." },
]
const hotels = [["Hótel Esja", "3 min walk", 164], ["The Harbour House", "6 min walk", 142], ["Sól Apartments", "9 min walk", 118]] as const
/** A flat map of the harbour: water, land, a route and three pins. Decorative; the list beside it says the same in words. */
function VenueMap() {
return (
<svg viewBox="0 0 600 420" role="img" aria-label="Map: the airport is to the south east, the hotels sit along the harbour and Harpa is at the north end of the water" className="bg-chart-3/20 h-full w-full rounded-3xl">
<path className="fill-secondary" d="M0 0H600V420H0Z" />
<path className="fill-chart-3/35" d="M-10 150C80 110 140 190 240 160S400 80 470 120 590 150 610 130V-10H-10Z" />
<path className="fill-card" d="M0 200C90 170 170 240 270 215S420 150 500 190 580 220 600 210V420H0Z" />
<path className="stroke-primary fill-none" strokeWidth="3" strokeDasharray="2 10" strokeLinecap="round" d="M90 360C150 320 200 330 260 280S360 230 430 215" />
{[[90, 360, "Airport bus"], [260, 280, "Hotels"], [430, 215, "Harpa"]].map(([x, y, l]) => (
<g key={l as string}><circle cx={x as number} cy={y as number} r="13" className={l === "Harpa" ? "fill-chart-1" : "fill-primary"} /><circle cx={x as number} cy={y as number} r="5" className="fill-card" /><text x={(x as number) + 20} y={(y as number) + 5} className="fill-foreground text-[15px] font-bold">{l as string}</text></g>
))}
</svg>
)
}
type SummitVenueProps = React.ComponentProps<"div"> & { hrefs?: Partial<SummitHrefs> }
/** The venue page: map, three ways to arrive, nearby hotels with a held rate, access details and the full FAQ. */
function SummitVenue({ hrefs, ...props }: SummitVenueProps) {
const [way, setWay] = React.useState("fly")
const current = ways.find((w) => w.id === way)!
return (
<SummitShell page="venue" hrefs={hrefs} {...props}>
<main className="mx-auto max-w-7xl px-4 pt-12 pb-8 sm:px-6 sm:pt-16">
<h1 className={cn("text-[clamp(2.8rem,9vw,7rem)] leading-[0.95]", summitDisplayClass)}>Venue</h1>
<p className="text-muted-foreground mt-4 max-w-xl text-xl text-pretty">Harpa sits on the water at the edge of the old harbour. Three rooms, one long lunch hall and a lot of glass.</p>
<div className="mt-10 grid items-stretch gap-6 lg:grid-cols-[1.3fr_1fr]">
<div className="min-h-72"><VenueMap /></div>
<section aria-labelledby="sv-way" className="bg-card rounded-3xl border p-6 sm:p-8">
<h2 id="sv-way" className={cn("text-2xl", summitDisplayClass)}>Getting there</h2>
<div role="group" aria-label="How you travel" className="mt-4 flex flex-wrap gap-2">{ways.map((w) => <button key={w.id} type="button" aria-pressed={way === w.id} onClick={() => setWay(w.id)} className="hover:bg-accent focus-visible:ring-ring/50 aria-pressed:bg-primary aria-pressed:text-primary-foreground inline-flex h-10 items-center gap-2 rounded-full border px-4 text-sm font-semibold outline-none focus-visible:ring-[3px]"><w.icon className="size-4" aria-hidden="true" />{w.label}</button>)}</div>
<div aria-live="polite" className="mt-6"><h3 className="text-xl font-bold text-balance">{current.title}</h3><p className="text-muted-foreground mt-2 text-pretty">{current.body}</p></div>
<address className="text-muted-foreground mt-6 border-t pt-4 text-sm not-italic">Harpa Concert Hall<br />Austurbakki 2, 101 Reykjavik</address>
</section>
</div>
<section aria-labelledby="sv-hotels" className="mt-16">
<h2 id="sv-hotels" className={cn("text-[clamp(2rem,5vw,3rem)] leading-none", summitDisplayClass)}>Where to stay</h2>
<p className="text-muted-foreground mt-3 max-w-xl text-pretty">We hold rooms at three hotels until 1 April. Use the code NORTHLIGHT when you book.</p>
<ul className="mt-6 grid gap-4 md:grid-cols-3">{hotels.map(([n, d, p]) => <li key={n} className="bg-card rounded-3xl border p-6"><Bed className="text-primary size-6" aria-hidden="true" /><h3 className="mt-4 text-xl font-bold">{n}</h3><p className="text-muted-foreground text-sm">{d}</p><p className="mt-4"><span className={cn("text-3xl tabular-nums", summitDisplayClass)}>${p}</span><span className="text-muted-foreground text-sm"> a night</span></p></li>)}</ul>
</section>
<section aria-labelledby="sv-access" className="bg-surface mt-16 grid gap-6 rounded-3xl border p-6 sm:p-10 md:grid-cols-3">
<h2 id="sv-access" className={cn("text-[clamp(1.8rem,4vw,2.5rem)] leading-none md:col-span-3", summitDisplayClass)}>Access, for everyone</h2>
{[["Step-free", "Every room, restroom and the lunch hall. Lifts at both ends of the building."], ["Hearing and sight", "Hearing loops in all three rooms, live captions on every screen and large-print programmes."], ["Quiet and care", "A quiet room on each floor and a staffed children’s room, both open all day."]].map(([t, b]) => <div key={t}><h3 className="text-lg font-bold">{t}</h3><p className="text-muted-foreground mt-2 text-pretty">{b}</p></div>)}
</section>
<section aria-labelledby="sv-faq" className="mt-16 grid gap-8 lg:grid-cols-[1fr_1.6fr]">
<h2 id="sv-faq" className={cn("text-[clamp(2rem,5vw,3rem)] leading-none", summitDisplayClass)}>Questions</h2>
<Accordion type="single" collapsible className="border-y">{faqs.map((f) => <AccordionItem key={f.q} value={f.q}><AccordionTrigger className="text-lg font-bold">{f.q}</AccordionTrigger><AccordionContent className="text-muted-foreground text-pretty">{f.a}</AccordionContent></AccordionItem>)}</Accordion>
</section>
</main>
</SummitShell>
)
}
export { SummitVenue, type SummitVenueProps }// Ballmac UI: Summit template route. https://ui.ballmac.com/templates/template-summit
import type { Metadata } from "next"
import { SummitHome } from "@/components/ballmac/templates/summit/summit-home"
export const metadata: Metadata = {
title: "Northlight Summit 2027: look further",
description: "Two days of talks about design, engineering and the long view. Reykjavik, 14 and 15 May 2027.",
}
export default function Page() {
return <SummitHome />
}// Ballmac UI: Summit template route. https://ui.ballmac.com/templates/template-summit
import type { Metadata } from "next"
import { SummitSchedule } from "@/components/ballmac/templates/summit/summit-schedule"
export const metadata: Metadata = {
title: "Schedule · Northlight Summit",
description: "Three rooms, two days. Star talks to build your agenda.",
}
export default function Page() {
return <SummitSchedule />
}// Ballmac UI: Summit template route. https://ui.ballmac.com/templates/template-summit
import type { Metadata } from "next"
import { SummitSpeakers } from "@/components/ballmac/templates/summit/summit-speakers"
export const metadata: Metadata = {
title: "Speakers · Northlight Summit",
description: "Twelve speakers, forty-five minutes each.",
}
export default function Page() {
return <SummitSpeakers />
}// Ballmac UI: Summit template route. https://ui.ballmac.com/templates/template-summit
import type { Metadata } from "next"
import { SummitTickets } from "@/components/ballmac/templates/summit/summit-tickets"
export const metadata: Metadata = {
title: "Tickets · Northlight Summit",
description: "Early bird $490. Group discounts from five tickets.",
}
export default function Page() {
return <SummitTickets />
}// Ballmac UI: Summit template route. https://ui.ballmac.com/templates/template-summit
import type { Metadata } from "next"
import { SummitVenue } from "@/components/ballmac/templates/summit/summit-venue"
export const metadata: Metadata = {
title: "Venue · Northlight Summit",
description: "Harpa, Reykjavik. How to get there, where to stay and access details.",
}
export default function Page() {
return <SummitVenue />
}Installation
$ pnpm dlx shadcn@latest add @ballmac/template-summitInstall the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/dialog @ballmac/accordionCopy the source into your project.
components/ballmac/templates/summit/summit-fonts.ts// Ballmac UI: Summit template fonts. https://ui.ballmac.com/templates/template-summit import { Figtree, Unbounded } from "next/font/google" export const summitDisplay = Unbounded({ variable: "--summit-display", subsets: ["latin"], display: "swap", preload: false }) export const summitSans = Figtree({ variable: "--summit-sans", subsets: ["latin"], display: "swap", preload: false })components/ballmac/templates/summit/summit-data.ts// Ballmac UI: Summit template data. https://ui.ballmac.com/templates/template-summit export type Track = "Main stage" | "Craft" | "Systems" export const tracks: Track[] = ["Main stage", "Craft", "Systems"] export type Speaker = { id: string; name: string; role: string; company: string; bio: string; tone: number; track: Track; talk: string } export const speakers: Speaker[] = [ { id: "ingrid", name: "Ingrid Solheim", role: "Design director", company: "Fjord & Co", bio: "Ingrid leads a forty-person studio that designs ferries, hospitals and the signs between them. She believes the best interface is a good sign.", tone: 0, track: "Main stage", talk: "Wayfinding for the weary" }, { id: "kofi", name: "Kofi Mensah", role: "Principal engineer", company: "Lattice", bio: "Kofi has run the on-call rota for a payments network for eight years and has opinions about alert fatigue that he is happy to share.", tone: 1, track: "Systems", talk: "Pagers are a design problem" }, { id: "mira", name: "Mira Castellanos", role: "Type designer", company: "Letterform Foundry", bio: "Mira draws typefaces for screens that nobody has built yet. Her latest family ships with a hundred and forty weights.", tone: 2, track: "Craft", talk: "The weight between regular and bold" }, { id: "jun", name: "Jun Watanabe", role: "Founder", company: "Tidepool", bio: "Jun built three companies in three countries and is writing a book about the quiet years between them.", tone: 3, track: "Main stage", talk: "The quiet years" }, { id: "amara", name: "Amara Nwosu", role: "Research lead", company: "Open Lab", bio: "Amara studies how people actually use software when nobody is watching, and what they build around it.", tone: 4, track: "Craft", talk: "What users do with your product on Sunday" }, { id: "leo", name: "Leo Bergstrom", role: "Staff engineer", company: "Northwind", bio: "Leo ripped out a two-million-line monolith one route at a time and kept a diary of every week.", tone: 0, track: "Systems", talk: "Deleting the monolith, a diary" }, { id: "sana", name: "Sana Iqbal", role: "Head of product", company: "Parcel", bio: "Sana ships features that remove other features. Her team once shrank the settings page by sixty percent.", tone: 2, track: "Main stage", talk: "Subtraction as a roadmap" }, { id: "tomas", name: "Tomáš Havel", role: "Motion designer", company: "Frame Rate", bio: "Tomáš animates interfaces that feel like physical objects, and has strong views on easing curves.", tone: 1, track: "Craft", talk: "Easing is a feeling" }, { id: "freya", name: "Freya Lindqvist", role: "Security architect", company: "Vaultline", bio: "Freya designs the unglamorous parts: key rotation, audit trails and the recovery flow nobody wants to test.", tone: 3, track: "Systems", talk: "Design for the worst day" }, { id: "dev", name: "Dev Raman", role: "Creative technologist", company: "Studio Nine", bio: "Dev builds installations out of sensors, shaders and a worrying amount of tape.", tone: 4, track: "Craft", talk: "Making light listen" }, { id: "elena", name: "Elena Rossi", role: "Editor in chief", company: "Margin Magazine", bio: "Elena edits a magazine about slowness, which is published, appropriately, quarterly.", tone: 2, track: "Main stage", talk: "In praise of the long read" }, { id: "noah", name: "Noah Fischer", role: "Platform lead", company: "Relay", bio: "Noah makes developer platforms people actually enjoy, mostly by deleting steps from the docs.", tone: 0, track: "Systems", talk: "The ten-minute quickstart" }, ] export type Session = { id: string; day: 1 | 2; start: string; end: string; title: string; track: Track; speaker: string; room: string; level: "All levels" | "Intermediate" | "Advanced"; blurb: string } export const sessions: Session[] = [ { id: "s1", day: 1, start: "09:30", end: "10:15", title: "Wayfinding for the weary", track: "Main stage", speaker: "ingrid", room: "Aurora Hall", level: "All levels", blurb: "What a hospital corridor teaches us about onboarding, and why the best signs say less." }, { id: "s2", day: 1, start: "10:45", end: "11:30", title: "Pagers are a design problem", track: "Systems", speaker: "kofi", room: "Basalt Room", level: "Intermediate", blurb: "Eight years of on-call, distilled into six rules for alerts a human can act on at 3 a.m." }, { id: "s3", day: 1, start: "10:45", end: "11:30", title: "The weight between regular and bold", track: "Craft", speaker: "mira", room: "Studio One", level: "Advanced", blurb: "A walk through variable fonts, optical sizes and the weights nobody ships but everybody feels." }, { id: "s4", day: 1, start: "13:00", end: "13:45", title: "The quiet years", track: "Main stage", speaker: "jun", room: "Aurora Hall", level: "All levels", blurb: "Companies are not built in the launch week. A founder on the long, unglamorous middle." }, { id: "s5", day: 1, start: "14:15", end: "15:00", title: "What users do with your product on Sunday", track: "Craft", speaker: "amara", room: "Studio One", level: "Intermediate", blurb: "Field research from forty living rooms, and the workarounds that became features." }, { id: "s6", day: 1, start: "14:15", end: "15:00", title: "Deleting the monolith, a diary", track: "Systems", speaker: "leo", room: "Basalt Room", level: "Advanced", blurb: "Two million lines, one route at a time. What worked, what burned and what we would not do again." }, { id: "s7", day: 1, start: "16:00", end: "16:45", title: "Subtraction as a roadmap", track: "Main stage", speaker: "sana", room: "Aurora Hall", level: "All levels", blurb: "How removing settings, steps and screens made a product feel twice as fast." }, { id: "s8", day: 2, start: "09:30", end: "10:15", title: "In praise of the long read", track: "Main stage", speaker: "elena", room: "Aurora Hall", level: "All levels", blurb: "Why a slower publication found a faster audience." }, { id: "s9", day: 2, start: "10:45", end: "11:30", title: "Easing is a feeling", track: "Craft", speaker: "tomas", room: "Studio One", level: "Intermediate", blurb: "Curves, springs and the physics of things that should feel heavy or light." }, { id: "s10", day: 2, start: "10:45", end: "11:30", title: "Design for the worst day", track: "Systems", speaker: "freya", room: "Basalt Room", level: "Advanced", blurb: "Recovery flows, audit trails and the screens people only see when something has gone wrong." }, { id: "s11", day: 2, start: "13:00", end: "13:45", title: "Making light listen", track: "Craft", speaker: "dev", room: "Studio One", level: "Advanced", blurb: "A live build of an installation that responds to a room full of people." }, { id: "s12", day: 2, start: "13:00", end: "13:45", title: "The ten-minute quickstart", track: "Systems", speaker: "noah", room: "Basalt Room", level: "Intermediate", blurb: "A teardown of five onboarding docs and the step each one should lose." }, { id: "s13", day: 2, start: "15:00", end: "16:00", title: "Closing conversation: what we will keep", track: "Main stage", speaker: "ingrid", room: "Aurora Hall", level: "All levels", blurb: "Four speakers, one table and the questions you send in during the two days." }, ] export type Tier = { id: string; name: string; price: number; blurb: string; perks: string[]; badge?: string } export const tiers: Tier[] = [ { id: "student", name: "Student", price: 190, blurb: "With a valid student ID at the door.", perks: ["Both days, all tracks", "Lunch and coffee", "Recordings next week"] }, { id: "early", name: "Early bird", price: 490, blurb: "Until 15 January, or 200 tickets.", perks: ["Both days, all tracks", "Lunch, coffee and the Thursday dinner", "Recordings next week", "Speaker Q&A lounge"], badge: "Best value" }, { id: "standard", name: "Standard", price: 690, blurb: "The full two days.", perks: ["Both days, all tracks", "Lunch, coffee and the Thursday dinner", "Recordings next week", "Speaker Q&A lounge"] }, { id: "patron", name: "Patron", price: 1290, blurb: "Support the event and sit near the front.", perks: ["Everything in Standard", "Reserved seats in Aurora Hall", "Patrons’ breakfast with the speakers", "Your name on the wall"] }, ] export const faqs = [ { q: "Will talks be recorded?", a: "Yes. Every main stage and track talk is recorded and sent to ticket holders within a week, with captions." }, { q: "Is the venue accessible?", a: "All three rooms are step-free, with hearing loops and a quiet room on every floor. Tell us what you need when you buy and we will arrange it." }, { q: "Can I get a refund?", a: "Full refund until 30 days before the event, then a transfer to another person for free. No questions." }, { q: "Do you offer childcare?", a: "There is a free, staffed room for children aged three to ten on both days. Add it to your order." }, { q: "What is the code of conduct?", a: "Be kind, ask before you photograph people and leave a seat for someone standing. Violations are handled by a named, trained team." }, { q: "Where should I stay?", a: "We hold rooms at three hotels within ten minutes’ walk. Prices are on the venue page and the code is in your ticket email." }, ] export const sponsors = ["Northwind", "Relay", "Parcel", "Lattice", "Tidepool", "Vaultline", "Studio Nine", "Margin"] export const EVENT_DATE = "2027-05-14T09:00:00Z" export const speakerById = (id: string) => speakers.find((s) => s.id === id) ?? speakers[0]!components/ballmac/templates/summit/summit-theme.tsx// Ballmac UI: Summit template shell. https://ui.ballmac.com/templates/template-summit "use client" import * as React from "react" import { Menu, X } from "lucide-react" import { EVENT_DATE, type Speaker } from "@/components/ballmac/templates/summit/summit-data" import { summitDisplay, summitSans } from "@/components/ballmac/templates/summit/summit-fonts" import { cn } from "@/lib/utils" type SummitPage = "home" | "schedule" | "speakers" | "tickets" | "venue" type SummitHrefs = Record<SummitPage, string> const defaultHrefs: SummitHrefs = { home: "/summit", schedule: "/summit/schedule", speakers: "/summit/speakers", tickets: "/summit/tickets", venue: "/summit/venue" } /** Northlight's palette: a cream morning page with indigo ink; dark mode is the same mountain at night. The hero sky is the same in both. */ const summitCss = ` .summit-theme,body:not(:has([data-gallery])):has(.summit-theme){--background:oklch(0.975 0.015 80);--foreground:oklch(0.22 0.08 275);--card:oklch(0.99 0.01 80);--card-foreground:oklch(0.22 0.08 275);--popover:oklch(0.99 0.01 80);--popover-foreground:oklch(0.22 0.08 275);--primary:oklch(0.3 0.12 275);--primary-foreground:oklch(0.975 0.015 80);--secondary:oklch(0.94 0.025 80);--secondary-foreground:oklch(0.22 0.08 275);--muted:oklch(0.94 0.025 80);--muted-foreground:oklch(0.44 0.07 275);--accent:oklch(0.92 0.04 75);--accent-foreground:oklch(0.22 0.08 275);--border:oklch(0.22 0.08 275 / 14%);--input:oklch(0.22 0.08 275 / 22%);--ring:oklch(0.5 0.2 285);--surface:oklch(0.955 0.02 80);--destructive:oklch(0.52 0.21 27);--chart-1:oklch(0.7 0.18 38);--chart-2:oklch(0.84 0.15 85);--chart-3:oklch(0.62 0.11 190);--chart-4:oklch(0.52 0.2 295);--chart-5:oklch(0.7 0.16 350);--summit-on-sun:oklch(0.2 0.08 275);--summit-hero-fg:oklch(0.98 0.012 80);--summit-sky-1:oklch(0.2 0.09 280);--summit-sky-2:oklch(0.36 0.15 300);--summit-sky-3:oklch(0.62 0.19 20);--summit-sky-4:oklch(0.82 0.15 75);--summit-ridge-1:oklch(0.3 0.1 290);--summit-ridge-2:oklch(0.23 0.09 282);--summit-ridge-3:oklch(0.17 0.07 278);--radius:1.25rem} .dark .summit-theme,.dark body:not(:has([data-gallery])):has(.summit-theme){--background:oklch(0.17 0.06 277);--foreground:oklch(0.96 0.015 80);--card:oklch(0.21 0.07 277);--card-foreground:oklch(0.96 0.015 80);--popover:oklch(0.23 0.07 277);--popover-foreground:oklch(0.96 0.015 80);--primary:oklch(0.85 0.14 85);--primary-foreground:oklch(0.2 0.07 277);--secondary:oklch(0.26 0.07 277);--secondary-foreground:oklch(0.96 0.015 80);--muted:oklch(0.25 0.07 277);--muted-foreground:oklch(0.75 0.05 280);--accent:oklch(0.3 0.08 280);--accent-foreground:oklch(0.96 0.015 80);--border:oklch(1 0 0 / 11%);--input:oklch(1 0 0 / 16%);--ring:oklch(0.85 0.14 85);--surface:oklch(0.195 0.065 277);--destructive:oklch(0.7 0.19 27);--chart-1:oklch(0.75 0.17 40);--chart-2:oklch(0.86 0.14 88);--chart-3:oklch(0.76 0.11 190);--chart-4:oklch(0.72 0.17 300);--chart-5:oklch(0.78 0.14 350)} body:not(:has([data-gallery])):has(.summit-theme){font-family:var(--summit-sans),ui-sans-serif,system-ui,sans-serif} @keyframes summit-float{0%,100%{transform:translateY(0)}50%{transform:translateY(-6px)}} .summit-float{animation:summit-float 6s ease-in-out infinite} @media (prefers-reduced-motion:reduce){.summit-float{animation:none}} ` const display = "[font-family:var(--summit-display),ui-sans-serif,system-ui,sans-serif] font-semibold tracking-[-0.03em]" const onSun = "text-[var(--summit-on-sun)]" const tones = ["bg-chart-1", "bg-chart-2", "bg-chart-3", "bg-chart-4", "bg-chart-5"] /** A portrait without a photo: a colour field, shoulders and a head. Replace it with an image when you have one. */ function Portrait({ speaker, className }: { speaker: Pick<Speaker, "tone" | "name">; className?: string }) { return ( <div aria-hidden="true" className={cn("relative isolate aspect-[4/5] w-full overflow-hidden", tones[speaker.tone % tones.length], className)}> <div className="bg-[var(--summit-ridge-3)] absolute bottom-[-30%] left-1/2 aspect-square w-[88%] -translate-x-1/2 rounded-full" /> <div className="bg-[var(--summit-hero-fg)] absolute top-[22%] left-1/2 aspect-square w-[34%] -translate-x-1/2 rounded-full" /> </div> ) } /** The mountain scene: a night-to-sunrise sky, a sun and three ridgelines. Decorative. */ function Ridge({ className }: { className?: string }) { return ( <div aria-hidden="true" className={cn("pointer-events-none absolute inset-0 -z-10 overflow-hidden", className)} style={{ backgroundImage: "linear-gradient(to bottom, var(--summit-sky-1) 0%, var(--summit-sky-2) 38%, var(--summit-sky-3) 74%, var(--summit-sky-4) 100%)" }}> <div className="bg-[var(--summit-sky-4)] summit-float absolute end-[12%] bottom-[24%] aspect-square w-[min(22vw,15rem)] rounded-full opacity-95 shadow-[0_0_120px_40px_var(--summit-sky-3)]" /> <svg viewBox="0 0 1440 420" preserveAspectRatio="none" className="absolute inset-x-0 bottom-0 h-[46%] w-full"> <path className="fill-[var(--summit-ridge-1)]" d="M0 250 L120 190 L210 230 L340 130 L470 210 L560 170 L700 250 L820 150 L930 220 L1060 120 L1190 210 L1300 160 L1440 230 L1440 420 L0 420 Z" /> <path className="fill-[var(--summit-ridge-2)]" d="M0 310 L90 270 L200 320 L330 240 L450 300 L590 230 L720 310 L860 250 L980 320 L1100 260 L1240 320 L1340 280 L1440 320 L1440 420 L0 420 Z" /> <path className="fill-[var(--summit-ridge-3)]" d="M0 370 L140 340 L260 375 L400 330 L540 372 L690 335 L820 378 L960 340 L1100 376 L1240 345 L1440 380 L1440 420 L0 420 Z" /> </svg> </div> ) } /** Days, hours, minutes and seconds to the opening keynote. Computed after mount so server and client markup match. */ function useCountdown(target: string) { const [left, setLeft] = React.useState<number | null>(null) React.useEffect(() => { const tick = () => setLeft(Math.max(0, Math.floor((new Date(target).getTime() - Date.now()) / 1000))) tick() const id = window.setInterval(tick, 1000) return () => window.clearInterval(id) }, [target]) if (left === null) return null return { days: Math.floor(left / 86400), hours: Math.floor((left % 86400) / 3600), minutes: Math.floor((left % 3600) / 60), seconds: left % 60 } } const links: { key: SummitPage; label: string }[] = [ { key: "schedule", label: "Schedule" }, { key: "speakers", label: "Speakers" }, { key: "venue", label: "Venue" }, ] type SummitShellProps = React.ComponentProps<"div"> & { /** The page being shown, so its nav link is marked current. */ page: SummitPage /** Override where pages live (used by previews). */ hrefs?: Partial<SummitHrefs> } /** Northlight's frame: a transparent header that settles on scroll, a ticket button that is always in reach and a mountain footer. */ function SummitShell({ page, hrefs: overrides, className, style, children, ...props }: SummitShellProps) { const hrefs = { ...defaultHrefs, ...overrides } const [open, setOpen] = React.useState(false) React.useEffect(() => { const classes = [summitDisplay.variable, summitSans.variable].filter(Boolean) document.body.classList.add(...classes) return () => document.body.classList.remove(...classes) }, []) return ( <div data-slot="summit" className={cn("summit-theme bg-background text-foreground relative min-h-dvh overflow-x-clip", summitDisplay.variable, summitSans.variable, className)} style={{ fontFamily: "var(--summit-sans), ui-sans-serif, system-ui, sans-serif", ...style }} {...props} > <style>{summitCss}</style> <header className="bg-background/85 sticky top-0 z-40 border-b backdrop-blur-xl"> <div className="mx-auto flex h-16 max-w-7xl items-center justify-between gap-4 px-4 sm:px-6"> <a href={hrefs.home} className="focus-visible:ring-ring/50 flex items-center gap-2.5 rounded-lg outline-none focus-visible:ring-[3px]"> <span aria-hidden="true" className="bg-chart-1 relative size-8 overflow-hidden rounded-full"><span className="bg-[var(--summit-ridge-2)] absolute -bottom-3 left-1/2 aspect-square w-[130%] -translate-x-1/2 rounded-[40%]" /></span> <span className={cn("text-base", display)}>Northlight <span className="text-muted-foreground">’27</span></span> </a> <nav aria-label="Main" className="hidden items-center gap-1 md:flex"> {links.map((l) => <a key={l.key} href={hrefs[l.key]} aria-current={page === l.key ? "page" : undefined} className="hover:bg-accent aria-[current=page]:bg-accent focus-visible:ring-ring/50 rounded-full px-4 py-2 text-sm font-semibold outline-none transition-colors focus-visible:ring-[3px] motion-reduce:transition-none">{l.label}</a>)} </nav> <div className="flex items-center gap-2"> <a href={hrefs.tickets} aria-current={page === "tickets" ? "page" : undefined} className={cn("bg-chart-1 focus-visible:ring-ring/50 inline-flex h-10 items-center rounded-full px-5 text-sm font-bold outline-none transition-transform hover:-translate-y-0.5 focus-visible:ring-[3px] motion-reduce:transition-none motion-reduce:hover:translate-y-0", onSun)}>Get tickets</a> <button type="button" aria-label={open ? "Close menu" : "Open menu"} aria-expanded={open} aria-controls="summit-mobile-menu" onClick={() => setOpen((v) => !v)} className="hover:bg-accent focus-visible:ring-ring/50 inline-flex size-10 items-center justify-center rounded-full outline-none focus-visible:ring-[3px] md:hidden">{open ? <X className="size-5" aria-hidden="true" /> : <Menu className="size-5" aria-hidden="true" />}</button> </div> </div> {open && <nav id="summit-mobile-menu" aria-label="Mobile" className="px-4 pb-4 md:hidden">{links.map((l) => <a key={l.key} href={hrefs[l.key]} className="hover:bg-accent block rounded-2xl px-4 py-3 text-lg font-semibold">{l.label}</a>)}</nav>} </header> {children} <footer className="relative isolate mt-24 overflow-hidden text-[var(--summit-hero-fg)]"> <Ridge /> <div aria-hidden="true" className="bg-[var(--summit-ridge-3)]/75 absolute inset-0 -z-10" /> <div className="mx-auto grid max-w-7xl gap-10 px-4 pt-24 pb-14 sm:px-6 md:grid-cols-[1.5fr_1fr_1fr]"> <div><p className={cn("text-[clamp(2rem,5vw,3.5rem)] leading-none", display)}>See you in the mountains.</p><p className="mt-4 max-w-sm text-pretty">14 and 15 May 2027 · Harpa, Reykjavik. Two days of talks about design, engineering and the long view.</p></div> <div><h2 className="text-xs font-bold tracking-[0.14em] uppercase">The event</h2><ul className="mt-4 grid gap-2.5">{[["Schedule", hrefs.schedule], ["Speakers", hrefs.speakers], ["Venue and travel", hrefs.venue], ["Tickets", hrefs.tickets]].map(([l, h]) => <li key={l}><a href={h} className="hover:underline">{l}</a></li>)}</ul></div> <div><h2 className="text-xs font-bold tracking-[0.14em] uppercase">Contact</h2><ul className="mt-4 grid gap-2.5"><li>hello@northlight.example</li><li>Press and partnerships</li><li>Code of conduct</li></ul></div> </div> <p className="mx-auto max-w-7xl px-4 pb-6 text-xs sm:px-6">© 2027 Northlight Summit. Printed on a very small number of trees.</p> </footer> </div> ) } export { Portrait, Ridge, SummitShell, defaultHrefs as summitDefaultHrefs, display as summitDisplayClass, onSun as summitOnSun, tones as summitTones, useCountdown, type SummitHrefs, type SummitPage, type SummitShellProps }components/ballmac/templates/summit/summit-home.tsx// Ballmac UI: Summit home page. https://ui.ballmac.com/templates/template-summit "use client" import * as React from "react" import { ArrowRight, CalendarDays, MapPin } from "lucide-react" import { EVENT_DATE, sessions, speakerById, speakers, sponsors, tiers } from "@/components/ballmac/templates/summit/summit-data" import { Portrait, Ridge, SummitShell, summitDisplayClass, summitOnSun, useCountdown, type SummitHrefs } from "@/components/ballmac/templates/summit/summit-theme" import { cn } from "@/lib/utils" const stats = [["2", "days"], ["24", "talks"], ["3", "stages"], ["900", "people"]] const reasons = [ { n: "01", title: "Talks with a point of view", body: "Every speaker is asked one question before they are booked: what do you believe that most of your industry does not?" }, { n: "02", title: "Rooms that are not crowded", body: "Nine hundred seats in a hall built for three thousand. You will always find a place to sit and someone to talk to." }, { n: "03", title: "Time to talk, on purpose", body: "Ninety minutes for lunch, a long Thursday dinner and a lounge where speakers sit down and answer the second question." }, ] function Tile({ value, unit }: { value: string; unit: string }) { return ( <div className="bg-[var(--summit-ridge-3)]/55 min-w-[4.5rem] rounded-2xl border border-white/20 px-3 py-3 text-center backdrop-blur-sm sm:min-w-24 sm:px-5 sm:py-4"> <div className={cn("text-3xl tabular-nums sm:text-5xl", summitDisplayClass)}>{value}</div> <div className="mt-1 text-xs font-semibold tracking-widest uppercase">{unit}</div> </div> ) } type SummitHomeProps = React.ComponentProps<"div"> & { hrefs?: Partial<SummitHrefs> } /** The Summit home page: a sunrise hero with a live countdown, numbers, speakers, a schedule teaser and tickets. */ function SummitHome({ hrefs, ...props }: SummitHomeProps) { const link = { schedule: "/summit/schedule", speakers: "/summit/speakers", tickets: "/summit/tickets", venue: "/summit/venue", ...hrefs } const left = useCountdown(EVENT_DATE) const pad = (n: number) => String(n).padStart(2, "0") return ( <SummitShell page="home" hrefs={hrefs} {...props}> <main> <section aria-labelledby="sh-title" className="relative isolate overflow-hidden text-[var(--summit-hero-fg)]"> <Ridge /> <div className="mx-auto max-w-7xl px-4 pt-16 pb-72 sm:px-6 sm:pt-24 sm:pb-80"> <p className="inline-flex flex-wrap items-center gap-x-4 gap-y-1 rounded-full border border-white/25 px-4 py-1.5 text-sm font-semibold"><span className="inline-flex items-center gap-1.5"><CalendarDays className="size-4" aria-hidden="true" />14–15 May 2027</span><span className="inline-flex items-center gap-1.5"><MapPin className="size-4" aria-hidden="true" />Harpa, Reykjavik</span></p> <h1 id="sh-title" className={cn("mt-8 max-w-4xl text-[clamp(3.2rem,11vw,9.5rem)] leading-[0.92]", summitDisplayClass)}>Look further.</h1> <p className="mt-6 max-w-xl text-xl text-pretty">Two days of talks about design, engineering and the long view, held where the sun barely sets. Twelve speakers, nine hundred seats.</p> <div className="mt-9 flex flex-wrap gap-3"> <a href={link.tickets} className={cn("bg-chart-2 focus-visible:ring-ring inline-flex h-14 items-center gap-2 rounded-full px-8 text-lg font-bold outline-none transition-transform hover:-translate-y-0.5 focus-visible:ring-[3px] motion-reduce:transition-none motion-reduce:hover:translate-y-0", summitOnSun)}>Get tickets from $190 <ArrowRight className="size-5 rtl:rotate-180" aria-hidden="true" /></a> <a href={link.schedule} className="focus-visible:ring-ring inline-flex h-14 items-center rounded-full border-2 border-white/60 px-8 text-lg font-bold outline-none transition-colors hover:bg-white/10 focus-visible:ring-[3px] motion-reduce:transition-none">See the schedule</a> </div> <div className="mt-12" role="group" aria-label="Time until the first talk"> <p className="mb-3 text-sm font-semibold tracking-widest uppercase">Doors open in</p> <div className="flex gap-2 sm:gap-3" aria-hidden="true"> <Tile value={left ? String(left.days) : "—"} unit="days" /><Tile value={left ? pad(left.hours) : "—"} unit="hours" /><Tile value={left ? pad(left.minutes) : "—"} unit="min" /><Tile value={left ? pad(left.seconds) : "—"} unit="sec" /> </div> <p className="sr-only">{left ? `${left.days} days until the doors open on 14 May 2027.` : "Doors open on 14 May 2027."}</p> </div> </div> </section> <section aria-label="Supporters" className="border-b"> <div className="mx-auto flex max-w-7xl flex-wrap items-center justify-center gap-x-10 gap-y-3 px-4 py-6 sm:px-6"><span className="text-muted-foreground text-xs font-bold tracking-[0.14em] uppercase">Supported by</span>{sponsors.map((s) => <span key={s} className={cn("text-muted-foreground text-lg", summitDisplayClass)}>{s}</span>)}</div> </section> <section aria-label="The event in numbers" className="mx-auto max-w-7xl px-4 py-16 sm:px-6"> <ul className="grid grid-cols-2 gap-4 md:grid-cols-4">{stats.map(([n, l], i) => <li key={l} className={cn("rounded-3xl border p-6 sm:p-8", ["bg-chart-1/15", "bg-chart-2/25", "bg-chart-3/15", "bg-chart-4/15"][i])}><span className={cn("block text-[clamp(2.8rem,7vw,5rem)] leading-none", summitDisplayClass)}>{n}</span><span className="text-muted-foreground mt-2 block text-sm font-semibold tracking-widest uppercase">{l}</span></li>)}</ul> </section> <section aria-labelledby="sh-speakers" className="mx-auto max-w-7xl px-4 pb-16 sm:px-6"> <div className="flex items-end justify-between gap-4"><h2 id="sh-speakers" className={cn("text-[clamp(2rem,5vw,3.5rem)] leading-none", summitDisplayClass)}>On stage</h2><a href={link.speakers} className="text-primary text-sm font-bold underline-offset-4 hover:underline">All 12 speakers</a></div> <ul className="mt-8 grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6"> {speakers.slice(0, 6).map((s) => ( <li key={s.id}><a href={link.speakers} className="focus-visible:ring-ring/50 group block rounded-3xl outline-none focus-visible:ring-[3px]"><Portrait speaker={s} className="rounded-3xl transition-transform group-hover:-translate-y-1 motion-reduce:transition-none motion-reduce:group-hover:translate-y-0" /><span className="mt-3 block font-bold">{s.name}</span><span className="text-muted-foreground block text-sm">{s.role}, {s.company}</span></a></li> ))} </ul> </section> <section aria-labelledby="sh-day" className="bg-surface border-y"> <div className="mx-auto grid max-w-7xl gap-10 px-4 py-16 sm:px-6 lg:grid-cols-[1fr_1.4fr]"> <div><h2 id="sh-day" className={cn("text-[clamp(2rem,5vw,3.5rem)] leading-none", summitDisplayClass)}>Day one, at a glance</h2><p className="text-muted-foreground mt-4 max-w-sm text-lg text-pretty">Three rooms, no overlap you will regret. Star the talks you want and we will build your day.</p><a href={link.schedule} className="bg-primary text-primary-foreground focus-visible:ring-ring/50 mt-6 inline-flex h-12 items-center gap-2 rounded-full px-6 font-bold outline-none focus-visible:ring-[3px]">Build my agenda <ArrowRight className="size-4 rtl:rotate-180" aria-hidden="true" /></a></div> <ol className="divide-y rounded-3xl border bg-card"> {sessions.filter((s) => s.day === 1).slice(0, 5).map((s) => ( <li key={s.id} className="grid grid-cols-[4.5rem_minmax(0,1fr)] gap-4 p-4 sm:grid-cols-[5.5rem_minmax(0,1fr)_auto] sm:p-5"> <span className={cn("text-lg tabular-nums", summitDisplayClass)}>{s.start}</span> <span className="min-w-0"><span className="block font-bold text-pretty">{s.title}</span><span className="text-muted-foreground block text-sm">{speakerById(s.speaker).name} · {s.room}</span></span> <span className="bg-secondary hidden h-fit rounded-full px-3 py-1 text-xs font-bold sm:inline">{s.track}</span> </li> ))} </ol> </div> </section> <section aria-labelledby="sh-why" className="mx-auto max-w-7xl px-4 py-16 sm:px-6"> <h2 id="sh-why" className={cn("max-w-2xl text-[clamp(2rem,5vw,3.5rem)] leading-none text-balance", summitDisplayClass)}>Why people come back</h2> <ul className="mt-10 grid gap-4 md:grid-cols-3">{reasons.map((r) => <li key={r.n} className="bg-card rounded-3xl border p-8"><span className={cn("text-primary text-sm", summitDisplayClass)}>{r.n}</span><h3 className="mt-6 text-2xl font-bold text-balance">{r.title}</h3><p className="text-muted-foreground mt-3 text-pretty">{r.body}</p></li>)}</ul> </section> <section aria-labelledby="sh-tix" className="mx-auto max-w-7xl px-4 pb-8 sm:px-6"> <div className={cn("bg-chart-1 relative overflow-hidden rounded-[2rem] p-8 sm:p-14", summitOnSun)}> <div aria-hidden="true" className="bg-chart-2 absolute -top-24 -end-16 aspect-square w-80 rounded-full" /> <div className="relative grid items-end gap-8 lg:grid-cols-[1.4fr_1fr]"> <div><h2 id="sh-tix" className={cn("text-[clamp(2rem,5vw,3.8rem)] leading-none text-balance", summitDisplayClass)}>The early bird is $200 cheaper.</h2><p className="mt-4 max-w-lg text-lg text-pretty">Early bird ends on 15 January or at 200 tickets, whichever comes first. Refundable until 30 days before.</p></div> <div className="flex flex-wrap items-center gap-4 lg:justify-end"><span className={cn("text-6xl", summitDisplayClass)}>${tiers[1]!.price}</span><a href={link.tickets} className="bg-[var(--summit-ridge-3)] focus-visible:ring-ring inline-flex h-14 items-center gap-2 rounded-full px-8 text-lg font-bold text-[var(--summit-hero-fg)] outline-none focus-visible:ring-[3px]">Get tickets <ArrowRight className="size-5 rtl:rotate-180" aria-hidden="true" /></a></div> </div> </div> </section> </main> </SummitShell> ) } export { SummitHome, type SummitHomeProps }components/ballmac/templates/summit/summit-schedule.tsx// Ballmac UI: Summit schedule page. https://ui.ballmac.com/templates/template-summit "use client" import * as React from "react" import { Clock, MapPin, Star, TriangleAlert } from "lucide-react" import { sessions, speakerById, tracks, type Session, type Track } from "@/components/ballmac/templates/summit/summit-data" import { Portrait, SummitShell, summitDisplayClass, type SummitHrefs } from "@/components/ballmac/templates/summit/summit-theme" import { cn } from "@/lib/utils" const trackDot: Record<Track, string> = { "Main stage": "bg-chart-1", Craft: "bg-chart-4", Systems: "bg-chart-3" } const days = [{ n: 1 as const, label: "Day 1", date: "Friday 14 May" }, { n: 2 as const, label: "Day 2", date: "Saturday 15 May" }] const minutes = (t: string) => Number(t.slice(0, 2)) * 60 + Number(t.slice(3)) const overlaps = (a: Session, b: Session) => a.id !== b.id && a.day === b.day && minutes(a.start) < minutes(b.end) && minutes(b.start) < minutes(a.end) type SummitScheduleProps = React.ComponentProps<"div"> & { hrefs?: Partial<SummitHrefs>; defaultSaved?: string[] } /** The schedule: day switch, track filters, one-tap agenda saving with clash warnings and a live summary. */ function SummitSchedule({ hrefs, defaultSaved = [], ...props }: SummitScheduleProps) { const [day, setDay] = React.useState<1 | 2>(1) const [active, setActive] = React.useState<Track[]>([...tracks]) const [saved, setSaved] = React.useState<string[]>(defaultSaved) const toggleTrack = (t: Track) => setActive((a) => (a.includes(t) ? (a.length > 1 ? a.filter((x) => x !== t) : a) : [...a, t])) const toggleSaved = (id: string) => setSaved((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id])) const list = sessions.filter((s) => s.day === day && active.includes(s.track)) const slots = [...new Set(list.map((s) => s.start))].sort() const mine = sessions.filter((s) => saved.includes(s.id)).sort((a, b) => a.day - b.day || minutes(a.start) - minutes(b.start)) const clashes = mine.filter((s) => mine.some((o) => overlaps(s, o))) return ( <SummitShell page="schedule" hrefs={hrefs} {...props}> <main className="mx-auto max-w-7xl px-4 pt-12 pb-8 sm:px-6 sm:pt-16"> <h1 className={cn("text-[clamp(2.8rem,9vw,7rem)] leading-[0.95]", summitDisplayClass)}>Schedule</h1> <p className="text-muted-foreground mt-4 max-w-xl text-xl text-pretty">Three rooms run side by side. Star what you want to see and we will keep an eye out for clashes.</p> <div className="mt-10 flex flex-wrap items-center justify-between gap-4 border-y py-4"> <div role="group" aria-label="Day" className="flex gap-2"> {days.map((d) => <button key={d.n} type="button" aria-pressed={day === d.n} onClick={() => setDay(d.n)} className="hover:bg-accent focus-visible:ring-ring/50 aria-pressed:bg-primary aria-pressed:text-primary-foreground rounded-2xl border px-5 py-2 text-start outline-none focus-visible:ring-[3px]"><span className="block text-sm font-bold">{d.label}</span><span className="block text-xs">{d.date}</span></button>)} </div> <div role="group" aria-label="Tracks" className="flex flex-wrap gap-2"> {tracks.map((t) => <button key={t} type="button" aria-pressed={active.includes(t)} onClick={() => toggleTrack(t)} className="hover:bg-accent focus-visible:ring-ring/50 aria-pressed:bg-secondary inline-flex h-10 items-center gap-2 rounded-full border px-4 text-sm font-semibold outline-none focus-visible:ring-[3px]"><span className={cn("size-2.5 rounded-full", trackDot[t])} aria-hidden="true" />{t}</button>)} </div> </div> <p role="status" className="text-muted-foreground mt-4 text-sm">{list.length} {list.length === 1 ? "talk" : "talks"} on {days[day - 1]!.date}. {saved.length > 0 ? `${saved.length} saved to your agenda.` : "Nothing saved yet."}</p> <div className="mt-6 grid gap-10"> {slots.map((slot) => { const here = list.filter((s) => s.start === slot) return ( <section key={slot} aria-label={`${slot} sessions`} className="grid gap-4 md:grid-cols-[6rem_minmax(0,1fr)]"> <h2 className={cn("flex items-center gap-2 text-2xl tabular-nums md:block md:pt-4", summitDisplayClass)}>{slot}<span className="text-muted-foreground text-xs font-medium md:mt-1 md:block">until {here[0]!.end}</span></h2> <ul className={cn("grid gap-4", here.length > 1 && "lg:grid-cols-2", here.length > 2 && "xl:grid-cols-3")}> {here.map((s) => { const sp = speakerById(s.speaker) const on = saved.includes(s.id) return ( <li key={s.id} className={cn("bg-card grid grid-cols-[3.5rem_minmax(0,1fr)_auto] gap-4 rounded-3xl border p-5 transition-colors motion-reduce:transition-none", on && "border-primary bg-accent/50")}> <Portrait speaker={sp} className="aspect-square h-14 w-14 rounded-2xl" /> <div className="min-w-0"> <p className="flex items-center gap-2 text-xs font-bold"><span className={cn("size-2.5 rounded-full", trackDot[s.track])} aria-hidden="true" />{s.track}</p> <h3 className="mt-1 text-lg leading-snug font-bold text-balance">{s.title}</h3> <p className="text-muted-foreground mt-1 text-sm">{sp.name}, {sp.company}</p> <p className="text-muted-foreground mt-3 text-sm text-pretty">{s.blurb}</p> <p className="text-muted-foreground mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs font-medium"><span className="inline-flex items-center gap-1"><MapPin className="size-3.5" aria-hidden="true" />{s.room}</span><span className="inline-flex items-center gap-1"><Clock className="size-3.5" aria-hidden="true" />{s.level}</span></p> </div> <button type="button" aria-pressed={on} aria-label={on ? `Remove ${s.title} from my agenda` : `Add ${s.title} to my agenda`} onClick={() => toggleSaved(s.id)} className="hover:bg-accent focus-visible:ring-ring/50 inline-flex size-10 items-center justify-center rounded-full border outline-none focus-visible:ring-[3px]"><Star className={cn("size-5", on && "fill-current")} aria-hidden="true" /></button> </li> ) })} </ul> </section> ) })} {slots.length === 0 && <p className="text-muted-foreground py-10 text-center">No talks match these tracks on this day.</p>} </div> <section aria-labelledby="ss-agenda" className="bg-surface mt-16 rounded-3xl border p-6 sm:p-10"> <div className="flex flex-wrap items-end justify-between gap-4"><h2 id="ss-agenda" className={cn("text-3xl", summitDisplayClass)}>My agenda</h2>{saved.length > 0 && <button type="button" onClick={() => setSaved([])} className="hover:bg-accent focus-visible:ring-ring/50 h-10 rounded-full border px-5 text-sm font-bold outline-none focus-visible:ring-[3px]">Clear all</button>}</div> {mine.length === 0 ? ( <p className="text-muted-foreground mt-4 max-w-md text-pretty">Press the star on any talk and it lands here, sorted by day and time.</p> ) : ( <> {clashes.length > 0 && <p className="bg-chart-2/25 mt-4 flex items-start gap-2 rounded-xl p-3 text-sm font-medium"><TriangleAlert className="mt-0.5 size-4 shrink-0" aria-hidden="true" />{clashes.length} of your talks run at the same time. Pick one for each slot marked below.</p>} <ol className="mt-5 divide-y"> {mine.map((s) => ( <li key={s.id} className="flex flex-wrap items-center gap-x-5 gap-y-1 py-3"> <span className={cn("w-28 text-sm tabular-nums", summitDisplayClass)}>Day {s.day} · {s.start}</span> <span className="min-w-0 flex-1 font-semibold">{s.title}</span> <span className="text-muted-foreground text-sm">{s.room}</span> {clashes.includes(s) && <span className="bg-chart-2/40 rounded-full px-2.5 py-0.5 text-xs font-bold">Clash</span>} </li> ))} </ol> </> )} </section> </main> </SummitShell> ) } export { SummitSchedule, type SummitScheduleProps }components/ballmac/templates/summit/summit-speakers.tsx// Ballmac UI: Summit speakers page. https://ui.ballmac.com/templates/template-summit "use client" import * as React from "react" import { Search } from "lucide-react" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ballmac/dialog" import { sessions, speakers, tracks, type Speaker } from "@/components/ballmac/templates/summit/summit-data" import { Portrait, SummitShell, summitDisplayClass, type SummitHrefs } from "@/components/ballmac/templates/summit/summit-theme" import { cn } from "@/lib/utils" const filters = ["All", ...tracks] as const type SummitSpeakersProps = React.ComponentProps<"div"> & { hrefs?: Partial<SummitHrefs> } /** The speakers page: filter by track or search by name, open a bio with the talk and the time it happens. */ function SummitSpeakers({ hrefs, ...props }: SummitSpeakersProps) { const [filter, setFilter] = React.useState<(typeof filters)[number]>("All") const [query, setQuery] = React.useState("") const [open, setOpen] = React.useState<Speaker | null>(null) const q = query.trim().toLowerCase() const list = speakers.filter((s) => (filter === "All" || s.track === filter) && (!q || `${s.name} ${s.company} ${s.talk} ${s.role}`.toLowerCase().includes(q))) const session = open ? sessions.find((s) => s.speaker === open.id) : null return ( <SummitShell page="speakers" hrefs={hrefs} {...props}> <main className="mx-auto max-w-7xl px-4 pt-12 pb-8 sm:px-6 sm:pt-16"> <h1 className={cn("text-[clamp(2.8rem,9vw,7rem)] leading-[0.95]", summitDisplayClass)}>Speakers</h1> <p className="text-muted-foreground mt-4 max-w-xl text-xl text-pretty">Twelve people who have spent years on one hard question. Each gets forty-five minutes and no slides they did not make.</p> <div className="mt-10 flex flex-wrap items-center gap-3 border-y py-4"> <div className="relative min-w-52 flex-1 sm:max-w-xs"> <label htmlFor="sp-q" className="sr-only">Search speakers</label> <Search className="text-muted-foreground pointer-events-none absolute top-1/2 start-4 size-4 -translate-y-1/2" aria-hidden="true" /> <input id="sp-q" type="search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search speakers or talks" className="bg-card focus-visible:ring-ring/50 h-11 w-full rounded-full border pe-4 ps-10 text-sm outline-none focus-visible:ring-[3px]" /> </div> <div role="group" aria-label="Filter by track" className="flex flex-wrap gap-2"> {filters.map((f) => <button key={f} type="button" aria-pressed={filter === f} onClick={() => setFilter(f)} className="hover:bg-accent focus-visible:ring-ring/50 aria-pressed:bg-primary aria-pressed:text-primary-foreground h-11 rounded-full border px-5 text-sm font-semibold outline-none focus-visible:ring-[3px]">{f}</button>)} </div> </div> <p role="status" className="text-muted-foreground mt-4 text-sm">{list.length} {list.length === 1 ? "speaker" : "speakers"}</p> <ul className="mt-6 grid grid-cols-2 gap-x-4 gap-y-10 md:grid-cols-3 lg:grid-cols-4"> {list.map((s) => ( <li key={s.id}> <button type="button" onClick={() => setOpen(s)} aria-haspopup="dialog" className="focus-visible:ring-ring/50 group block w-full rounded-3xl text-start outline-none focus-visible:ring-[3px]"> <Portrait speaker={s} className="rounded-3xl transition-transform group-hover:-translate-y-1 motion-reduce:transition-none motion-reduce:group-hover:translate-y-0" /> <span className="mt-4 block text-lg font-bold">{s.name}</span> <span className="text-muted-foreground block text-sm">{s.role}, {s.company}</span> <span className="mt-2 block text-sm font-semibold text-pretty">“{s.talk}”</span> </button> </li> ))} </ul> {list.length === 0 && <div className="bg-surface mt-4 rounded-3xl border p-12 text-center"><p className={cn("text-2xl", summitDisplayClass)}>No one by that name.</p><button type="button" onClick={() => { setQuery(""); setFilter("All") }} className="bg-primary text-primary-foreground focus-visible:ring-ring/50 mt-5 h-11 rounded-full px-6 font-bold outline-none focus-visible:ring-[3px]">Show everyone</button></div>} </main> <Dialog open={!!open} onOpenChange={(v) => !v && setOpen(null)}> <DialogContent className="summit-theme bg-background max-w-2xl gap-0 overflow-hidden p-0"> {open && ( <div className="grid sm:grid-cols-[14rem_minmax(0,1fr)]"> <Portrait speaker={open} className="aspect-[4/3] sm:aspect-auto sm:h-full" /> <div className="p-6 sm:p-8"> <DialogHeader className="text-start"><DialogTitle className={cn("text-2xl", summitDisplayClass)}>{open.name}</DialogTitle><DialogDescription>{open.role}, {open.company}</DialogDescription></DialogHeader> <p className="mt-4 text-pretty">{open.bio}</p> {session && ( <div className="bg-surface mt-6 rounded-2xl border p-4"> <p className="text-muted-foreground text-xs font-bold tracking-[0.14em] uppercase">Talk</p> <p className="mt-1 font-bold">{session.title}</p> <p className="text-muted-foreground mt-1 text-sm">Day {session.day} · {session.start} · {session.room}</p> </div> )} </div> </div> )} </DialogContent> </Dialog> </SummitShell> ) } export { SummitSpeakers, type SummitSpeakersProps }components/ballmac/templates/summit/summit-tickets.tsx// Ballmac UI: Summit tickets page. https://ui.ballmac.com/templates/template-summit "use client" import * as React from "react" import { Check, Minus, PartyPopper, Plus } from "lucide-react" import { faqs, tiers } from "@/components/ballmac/templates/summit/summit-data" import { SummitShell, summitDisplayClass, summitOnSun, type SummitHrefs } from "@/components/ballmac/templates/summit/summit-theme" import { cn } from "@/lib/utils" const money = (n: number) => `$${n.toLocaleString("en-US", { minimumFractionDigits: n % 1 ? 2 : 0, maximumFractionDigits: 2 })}` const field = "bg-background focus-visible:ring-ring/50 aria-invalid:border-destructive h-12 w-full rounded-xl border px-4 outline-none focus-visible:ring-[3px]" type SummitTicketsProps = React.ComponentProps<"div"> & { hrefs?: Partial<SummitHrefs> } /** Tickets: pick a tier and a quantity, see a live total with group and promo discounts, then a validated checkout form. */ function SummitTickets({ hrefs, ...props }: SummitTicketsProps) { const [tier, setTier] = React.useState("early") const [qty, setQty] = React.useState(1) const [promo, setPromo] = React.useState("") const [promoState, setPromoState] = React.useState<"idle" | "ok" | "bad">("idle") const [form, setForm] = React.useState({ name: "", email: "", company: "" }) const [errors, setErrors] = React.useState<Record<string, string>>({}) const [done, setDone] = React.useState(false) const errorRef = React.useRef<HTMLFormElement>(null) const t = tiers.find((x) => x.id === tier)! const subtotal = t.price * qty const group = qty >= 5 ? subtotal * 0.15 : 0 const code = promoState === "ok" ? (subtotal - group) * 0.1 : 0 const total = subtotal - group - code function applyPromo(e: React.FormEvent) { e.preventDefault() setPromoState(promo.trim().toUpperCase() === "NORTH10" ? "ok" : "bad") } function submit(e: React.FormEvent) { e.preventDefault() const next: Record<string, string> = {} if (!form.name.trim()) next.name = "Tell us who the ticket is for." if (!/^\S+@\S+\.\S+$/.test(form.email.trim())) next.email = "Enter an email like you@example.com." setErrors(next) const first = Object.keys(next)[0] if (first) return void errorRef.current?.querySelector<HTMLInputElement>(`[name="${first}"]`)?.focus() setDone(true) } const bind = (k: keyof typeof form) => ({ name: k, value: form[k], onChange: (e: React.ChangeEvent<HTMLInputElement>) => setForm((f) => ({ ...f, [k]: e.target.value })), "aria-invalid": !!errors[k], "aria-describedby": errors[k] ? `st-err-${k}` : undefined }) return ( <SummitShell page="tickets" hrefs={hrefs} {...props}> <main className="mx-auto max-w-7xl px-4 pt-12 pb-8 sm:px-6 sm:pt-16"> <h1 className={cn("text-[clamp(2.8rem,9vw,7rem)] leading-[0.95]", summitDisplayClass)}>Tickets</h1> <p className="text-muted-foreground mt-4 max-w-xl text-xl text-pretty">One ticket covers both days and every room. Buy five or more and take 15% off automatically.</p> {done ? ( <section aria-labelledby="st-done" className="bg-chart-2/25 mt-10 rounded-[2rem] border p-8 text-center sm:p-14"> <PartyPopper className="mx-auto size-10" aria-hidden="true" /> <h2 id="st-done" className={cn("mt-4 text-3xl sm:text-5xl", summitDisplayClass)}>You are going, {form.name.split(" ")[0]}.</h2> <p role="status" className="mx-auto mt-4 max-w-md text-lg text-pretty">{qty} × {t.name} for {money(total)}. A receipt and your ticket{qty > 1 ? "s are" : " is"} on the way to {form.email}.</p> <button type="button" onClick={() => { setDone(false); setForm({ name: "", email: "", company: "" }) }} className="hover:bg-accent focus-visible:ring-ring/50 mt-8 h-12 rounded-full border-2 px-6 font-bold outline-none focus-visible:ring-[3px]">Buy more tickets</button> </section> ) : ( <div className="mt-10 grid grid-cols-[minmax(0,1fr)] items-start gap-8 lg:grid-cols-[minmax(0,1fr)_24rem]"> <div className="grid gap-8"> <fieldset> <legend className={cn("mb-4 text-2xl", summitDisplayClass)}>1. Choose a ticket</legend> <div className="grid gap-3 sm:grid-cols-2"> {tiers.map((x) => ( <label key={x.id} className={cn("bg-card focus-within:ring-ring/50 relative flex cursor-pointer flex-col rounded-3xl border-2 p-6 transition-colors focus-within:ring-[3px] motion-reduce:transition-none", tier === x.id ? "border-primary bg-accent/40" : "hover:border-primary/40")}> <input type="radio" name="tier" value={x.id} checked={tier === x.id} onChange={() => setTier(x.id)} className="sr-only" /> {x.badge && <span className={cn("bg-chart-2 absolute -top-3 start-6 rounded-full px-3 py-0.5 text-xs font-bold", summitOnSun)}>{x.badge}</span>} <span className="flex items-baseline justify-between gap-2"><span className="text-lg font-bold">{x.name}</span><span className={cn("text-3xl tabular-nums", summitDisplayClass)}>${x.price}</span></span> <span className="text-muted-foreground mt-1 text-sm">{x.blurb}</span> <ul className="mt-4 grid gap-1.5 text-sm">{x.perks.map((p) => <li key={p} className="flex gap-2"><Check className="mt-0.5 size-4 shrink-0" aria-hidden="true" />{p}</li>)}</ul> </label> ))} </div> </fieldset> <form ref={errorRef} onSubmit={submit} noValidate aria-labelledby="st-details" className="bg-card grid gap-5 rounded-3xl border p-6 sm:p-8"> <h2 id="st-details" className={cn("text-2xl", summitDisplayClass)}>2. Your details</h2> <div className="grid gap-2"><label htmlFor="st-name" className="text-sm font-semibold">Full name</label><input id="st-name" autoComplete="name" className={field} {...bind("name")} />{errors.name && <p id="st-err-name" role="alert" className="text-destructive text-sm">{errors.name}</p>}</div> <div className="grid gap-2"><label htmlFor="st-email" className="text-sm font-semibold">Email for the receipt</label><input id="st-email" type="email" autoComplete="email" className={field} {...bind("email")} />{errors.email && <p id="st-err-email" role="alert" className="text-destructive text-sm">{errors.email}</p>}</div> <div className="grid gap-2"><label htmlFor="st-company" className="text-sm font-semibold">Company <span className="text-muted-foreground font-normal">(optional)</span></label><input id="st-company" autoComplete="organization" className={field} {...bind("company")} /></div> <button type="submit" className="bg-primary text-primary-foreground focus-visible:ring-ring/50 h-14 rounded-full text-lg font-bold outline-none transition-opacity hover:opacity-90 focus-visible:ring-[3px]">Pay {money(total)}</button> <p className="text-muted-foreground text-center text-xs">Demo checkout: no card is taken. Refundable until 30 days before.</p> </form> </div> <aside aria-labelledby="st-sum" className="bg-surface rounded-3xl border p-6 lg:sticky lg:top-24"> <h2 id="st-sum" className={cn("text-2xl", summitDisplayClass)}>Your order</h2> <div className="mt-5 flex items-center justify-between gap-3"> <span className="font-semibold">{t.name}</span> <div className="flex items-center gap-1 rounded-full border bg-card" role="group" aria-label="Number of tickets"> <button type="button" aria-label="Fewer tickets" disabled={qty <= 1} onClick={() => setQty((q) => Math.max(1, q - 1))} className="hover:bg-accent focus-visible:ring-ring/50 inline-flex size-10 items-center justify-center rounded-full outline-none focus-visible:ring-[3px] disabled:opacity-50"><Minus className="size-4" aria-hidden="true" /></button> <output aria-live="polite" className="w-8 text-center font-bold tabular-nums">{qty}</output> <button type="button" aria-label="More tickets" disabled={qty >= 20} onClick={() => setQty((q) => Math.min(20, q + 1))} className="hover:bg-accent focus-visible:ring-ring/50 inline-flex size-10 items-center justify-center rounded-full outline-none focus-visible:ring-[3px] disabled:opacity-50"><Plus className="size-4" aria-hidden="true" /></button> </div> </div> <form onSubmit={applyPromo} className="mt-5 flex gap-2"> <label htmlFor="st-promo" className="sr-only">Promo code</label> <input id="st-promo" value={promo} onChange={(e) => { setPromo(e.target.value); setPromoState("idle") }} placeholder="Promo code" aria-describedby="st-promo-msg" className="bg-background focus-visible:ring-ring/50 h-11 min-w-0 flex-1 rounded-xl border px-3 text-sm outline-none focus-visible:ring-[3px]" /> <button type="submit" className="hover:bg-accent focus-visible:ring-ring/50 h-11 rounded-xl border px-4 text-sm font-bold outline-none focus-visible:ring-[3px]">Apply</button> </form> <p id="st-promo-msg" role="status" className={cn("mt-2 min-h-5 text-sm", promoState === "bad" && "text-destructive")}>{promoState === "ok" ? "NORTH10 applied: 10% off." : promoState === "bad" ? "That code is not valid. Try NORTH10." : ""}</p> <dl className="mt-3 grid gap-2 border-t pt-4 text-sm"> <div className="flex justify-between"><dt>{qty} × {t.name}</dt><dd className="tabular-nums">{money(subtotal)}</dd></div> {group > 0 && <div className="flex justify-between"><dt>Group discount (15%)</dt><dd className="tabular-nums">−{money(group)}</dd></div>} {code > 0 && <div className="flex justify-between"><dt>Promo NORTH10</dt><dd className="tabular-nums">−{money(code)}</dd></div>} <div className="flex justify-between border-t pt-3 text-lg font-bold"><dt>Total</dt><dd className="tabular-nums">{money(total)}</dd></div> </dl> {qty < 5 && <p className="text-muted-foreground mt-4 text-xs">Add {5 - qty} more {5 - qty === 1 ? "ticket" : "tickets"} for 15% off.</p>} </aside> </div> )} <section aria-labelledby="st-faq" className="mt-20 grid gap-8 lg:grid-cols-[1fr_1.6fr]"> <h2 id="st-faq" className={cn("text-[clamp(2rem,5vw,3rem)] leading-none", summitDisplayClass)}>Before you buy</h2> <div className="divide-y border-y">{faqs.slice(2, 5).map((f) => <details key={f.q} className="group py-4"><summary className="focus-visible:ring-ring/50 cursor-pointer list-none rounded-md text-lg font-bold outline-none focus-visible:ring-[3px]">{f.q}</summary><p className="text-muted-foreground mt-2 text-pretty">{f.a}</p></details>)}</div> </section> </main> </SummitShell> ) } export { SummitTickets, type SummitTicketsProps }components/ballmac/templates/summit/summit-venue.tsx// Ballmac UI: Summit venue page. https://ui.ballmac.com/templates/template-summit "use client" import * as React from "react" import { Accessibility, Bed, Plane, Train } from "lucide-react" import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ballmac/accordion" import { faqs } from "@/components/ballmac/templates/summit/summit-data" import { SummitShell, summitDisplayClass, type SummitHrefs } from "@/components/ballmac/templates/summit/summit-theme" import { cn } from "@/lib/utils" const ways = [ { id: "fly", label: "Fly", icon: Plane, title: "Keflavík airport, 50 minutes away", body: "Flybus meets every arrival and drops you at the hotel row. We run a free shuttle from the airport at 8:00 and 12:00 on Thursday and Friday." }, { id: "bus", label: "City bus", icon: Train, title: "Lines 1, 3 and 11 stop at the door", body: "Buses run every ten minutes until midnight. Your ticket doubles as a three-day city pass. Show the QR code to the driver." }, { id: "walk", label: "On foot", icon: Accessibility, title: "Twelve minutes from the old harbour", body: "The route along the water is flat and lit. Taxis and wheelchair-accessible vehicles wait at the north entrance." }, ] const hotels = [["Hótel Esja", "3 min walk", 164], ["The Harbour House", "6 min walk", 142], ["Sól Apartments", "9 min walk", 118]] as const /** A flat map of the harbour: water, land, a route and three pins. Decorative; the list beside it says the same in words. */ function VenueMap() { return ( <svg viewBox="0 0 600 420" role="img" aria-label="Map: the airport is to the south east, the hotels sit along the harbour and Harpa is at the north end of the water" className="bg-chart-3/20 h-full w-full rounded-3xl"> <path className="fill-secondary" d="M0 0H600V420H0Z" /> <path className="fill-chart-3/35" d="M-10 150C80 110 140 190 240 160S400 80 470 120 590 150 610 130V-10H-10Z" /> <path className="fill-card" d="M0 200C90 170 170 240 270 215S420 150 500 190 580 220 600 210V420H0Z" /> <path className="stroke-primary fill-none" strokeWidth="3" strokeDasharray="2 10" strokeLinecap="round" d="M90 360C150 320 200 330 260 280S360 230 430 215" /> {[[90, 360, "Airport bus"], [260, 280, "Hotels"], [430, 215, "Harpa"]].map(([x, y, l]) => ( <g key={l as string}><circle cx={x as number} cy={y as number} r="13" className={l === "Harpa" ? "fill-chart-1" : "fill-primary"} /><circle cx={x as number} cy={y as number} r="5" className="fill-card" /><text x={(x as number) + 20} y={(y as number) + 5} className="fill-foreground text-[15px] font-bold">{l as string}</text></g> ))} </svg> ) } type SummitVenueProps = React.ComponentProps<"div"> & { hrefs?: Partial<SummitHrefs> } /** The venue page: map, three ways to arrive, nearby hotels with a held rate, access details and the full FAQ. */ function SummitVenue({ hrefs, ...props }: SummitVenueProps) { const [way, setWay] = React.useState("fly") const current = ways.find((w) => w.id === way)! return ( <SummitShell page="venue" hrefs={hrefs} {...props}> <main className="mx-auto max-w-7xl px-4 pt-12 pb-8 sm:px-6 sm:pt-16"> <h1 className={cn("text-[clamp(2.8rem,9vw,7rem)] leading-[0.95]", summitDisplayClass)}>Venue</h1> <p className="text-muted-foreground mt-4 max-w-xl text-xl text-pretty">Harpa sits on the water at the edge of the old harbour. Three rooms, one long lunch hall and a lot of glass.</p> <div className="mt-10 grid items-stretch gap-6 lg:grid-cols-[1.3fr_1fr]"> <div className="min-h-72"><VenueMap /></div> <section aria-labelledby="sv-way" className="bg-card rounded-3xl border p-6 sm:p-8"> <h2 id="sv-way" className={cn("text-2xl", summitDisplayClass)}>Getting there</h2> <div role="group" aria-label="How you travel" className="mt-4 flex flex-wrap gap-2">{ways.map((w) => <button key={w.id} type="button" aria-pressed={way === w.id} onClick={() => setWay(w.id)} className="hover:bg-accent focus-visible:ring-ring/50 aria-pressed:bg-primary aria-pressed:text-primary-foreground inline-flex h-10 items-center gap-2 rounded-full border px-4 text-sm font-semibold outline-none focus-visible:ring-[3px]"><w.icon className="size-4" aria-hidden="true" />{w.label}</button>)}</div> <div aria-live="polite" className="mt-6"><h3 className="text-xl font-bold text-balance">{current.title}</h3><p className="text-muted-foreground mt-2 text-pretty">{current.body}</p></div> <address className="text-muted-foreground mt-6 border-t pt-4 text-sm not-italic">Harpa Concert Hall<br />Austurbakki 2, 101 Reykjavik</address> </section> </div> <section aria-labelledby="sv-hotels" className="mt-16"> <h2 id="sv-hotels" className={cn("text-[clamp(2rem,5vw,3rem)] leading-none", summitDisplayClass)}>Where to stay</h2> <p className="text-muted-foreground mt-3 max-w-xl text-pretty">We hold rooms at three hotels until 1 April. Use the code NORTHLIGHT when you book.</p> <ul className="mt-6 grid gap-4 md:grid-cols-3">{hotels.map(([n, d, p]) => <li key={n} className="bg-card rounded-3xl border p-6"><Bed className="text-primary size-6" aria-hidden="true" /><h3 className="mt-4 text-xl font-bold">{n}</h3><p className="text-muted-foreground text-sm">{d}</p><p className="mt-4"><span className={cn("text-3xl tabular-nums", summitDisplayClass)}>${p}</span><span className="text-muted-foreground text-sm"> a night</span></p></li>)}</ul> </section> <section aria-labelledby="sv-access" className="bg-surface mt-16 grid gap-6 rounded-3xl border p-6 sm:p-10 md:grid-cols-3"> <h2 id="sv-access" className={cn("text-[clamp(1.8rem,4vw,2.5rem)] leading-none md:col-span-3", summitDisplayClass)}>Access, for everyone</h2> {[["Step-free", "Every room, restroom and the lunch hall. Lifts at both ends of the building."], ["Hearing and sight", "Hearing loops in all three rooms, live captions on every screen and large-print programmes."], ["Quiet and care", "A quiet room on each floor and a staffed children’s room, both open all day."]].map(([t, b]) => <div key={t}><h3 className="text-lg font-bold">{t}</h3><p className="text-muted-foreground mt-2 text-pretty">{b}</p></div>)} </section> <section aria-labelledby="sv-faq" className="mt-16 grid gap-8 lg:grid-cols-[1fr_1.6fr]"> <h2 id="sv-faq" className={cn("text-[clamp(2rem,5vw,3rem)] leading-none", summitDisplayClass)}>Questions</h2> <Accordion type="single" collapsible className="border-y">{faqs.map((f) => <AccordionItem key={f.q} value={f.q}><AccordionTrigger className="text-lg font-bold">{f.q}</AccordionTrigger><AccordionContent className="text-muted-foreground text-pretty">{f.a}</AccordionContent></AccordionItem>)}</Accordion> </section> </main> </SummitShell> ) } export { SummitVenue, type SummitVenueProps }app/summit/page.tsx// Ballmac UI: Summit template route. https://ui.ballmac.com/templates/template-summit import type { Metadata } from "next" import { SummitHome } from "@/components/ballmac/templates/summit/summit-home" export const metadata: Metadata = { title: "Northlight Summit 2027: look further", description: "Two days of talks about design, engineering and the long view. Reykjavik, 14 and 15 May 2027.", } export default function Page() { return <SummitHome /> }app/summit/schedule/page.tsx// Ballmac UI: Summit template route. https://ui.ballmac.com/templates/template-summit import type { Metadata } from "next" import { SummitSchedule } from "@/components/ballmac/templates/summit/summit-schedule" export const metadata: Metadata = { title: "Schedule · Northlight Summit", description: "Three rooms, two days. Star talks to build your agenda.", } export default function Page() { return <SummitSchedule /> }app/summit/speakers/page.tsx// Ballmac UI: Summit template route. https://ui.ballmac.com/templates/template-summit import type { Metadata } from "next" import { SummitSpeakers } from "@/components/ballmac/templates/summit/summit-speakers" export const metadata: Metadata = { title: "Speakers · Northlight Summit", description: "Twelve speakers, forty-five minutes each.", } export default function Page() { return <SummitSpeakers /> }app/summit/tickets/page.tsx// Ballmac UI: Summit template route. https://ui.ballmac.com/templates/template-summit import type { Metadata } from "next" import { SummitTickets } from "@/components/ballmac/templates/summit/summit-tickets" export const metadata: Metadata = { title: "Tickets · Northlight Summit", description: "Early bird $490. Group discounts from five tickets.", } export default function Page() { return <SummitTickets /> }app/summit/venue/page.tsx// Ballmac UI: Summit template route. https://ui.ballmac.com/templates/template-summit import type { Metadata } from "next" import { SummitVenue } from "@/components/ballmac/templates/summit/summit-venue" export const metadata: Metadata = { title: "Venue · Northlight Summit", description: "Harpa, Reykjavik. How to get there, where to stay and access details.", } export default function Page() { return <SummitVenue /> }Update the import paths to match your project setup.
The page is added as a route in your app, with every block it uses.
What's inside
- Pages
- Home/summit
- Schedule/summit/schedule
- Speakers/summit/speakers
- Tickets/summit/tickets
- Venue/summit/venue
- Fonts
- Unbounded, Figtree (Google Fonts, loaded with next/font)
- Files
- 13 files · 2 Ballmac blocks and components
Built with
Dependencies
- npm
- lucide-react
Use with AI
Installs a five-page conference site with a countdown, a starrable schedule that warns about clashes, speaker dialogs, a ticket calculator with group and promo discounts, and a venue guide. Edit summit-data.ts and summitCss. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Northlight Summit: conference (@ballmac/template-summit) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Conferences, meetups and festivals
- Any event site with a schedule, speakers and ticket tiers
Not for
- Ticketing with real payments (wire the form to your processor first)
Registry JSON: https://ui.ballmac.com/r/template-summit.json
Free to use in personal and commercial projects.