add scheduled livestreams and ticket availability

This commit is contained in:
Alacris
2026-09-06 02:46:34 +03:00
parent 4bc982385a
commit 3cf9fb60ae
17 changed files with 235 additions and 78 deletions

View File

@@ -4,7 +4,7 @@ import { vipnagorgialla } from "@/components/Vipnagorgialla";
import RulesLastChanged from "@/components/RulesLastChanged";
import RuleNav from "@/components/RuleNav";
import { getTranslations, setRequestLocale } from "next-intl/server";
import { loadRulesBun } from "@/lib/loadRules";
import { loadRules } from "@/lib/loadRules";
const sectionKeys = [
{ id: "reminder", labelKey: "rules.reminder" },
@@ -19,7 +19,7 @@ export default async function Page({
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations({ locale });
const content = await loadRulesBun("kodukord", locale as "et" | "en");
const content = await loadRules("kodukord", locale as "et" | "en");
const sections = sectionKeys.map((section) => ({
id: section.id,

View File

@@ -2,6 +2,7 @@ import { vipnagorgialla } from "@/components/Vipnagorgialla";
import Sponsors from "@/components/Sponsors";
import HeroSection from "@/components/HeroSection";
import TeaserCarousel from "@/components/TeaserCarousel";
import Livestreams from "@/components/Livestreams";
import { Link } from "@/i18n/routing";
import { getTranslations, setRequestLocale } from "next-intl/server";
@@ -21,8 +22,11 @@ export default async function Home({
<HeroSection />
</div>
{/* Nav cards: Piletid + Turniirid */}
<div className="grid grid-cols-1 md:grid-cols-2 md:h-65 border-b-3 border-[#1F5673]">
{/* Livestreams — shown automatically when the event begins */}
<Livestreams />
{/* Nav cards: Piletid + Ajakava + Turniirid */}
<div className="grid grid-cols-1 md:grid-cols-3 md:h-65 border-b-3 border-[#1F5673]">
<Link
href="/piletid"
className="px-8 md:px-12 py-8 flex flex-col justify-center gap-4 border-b-3 md:border-b-0 md:border-r-3 group border-[#1F5673] hover:bg-[#007CAB] dark:hover:bg-[#00A3E0] transition"
@@ -50,6 +54,33 @@ export default async function Home({
</div>
</Link>
<Link
href="/ajakava"
className="px-8 md:px-12 py-8 flex flex-col justify-center gap-4 border-b-3 md:border-b-0 md:border-r-3 group border-[#1F5673] hover:bg-[#007CAB] dark:hover:bg-[#00A3E0] transition"
>
<div className="flex flex-col gap-4">
<span
className="material-symbols-outlined text-[clamp(2rem,1.5rem+1.5vw,3.5rem)]! text-[#007CAB] dark:text-[#00A3E0] dark:group-hover:text-[#EEE5E5] group-hover:text-[#EEE5E5]"
style={{
fontVariationSettings:
'"FILL" 0, "wght" 700, "GRAD" 0, "opsz" 24',
}}
>
calendar_month
</span>
</div>
<div className="flex flex-row justify-between gap-4 items-center">
<h2
className={`text-[clamp(2rem,1.8rem+1vw,3rem)] ${vipnagorgialla.className} font-bold italic uppercase dark:text-[#EEE5E5] text-[#2A2C3F] group-hover:text-black dark:group-hover:text-[#2A2C3F]`}
>
{t("navigation.schedule")}
</h2>
<span className="material-symbols-outlined text-[clamp(2rem,1.5rem+1.5vw,3.5rem)]! font-bold! text-[#007CAB] dark:text-[#00A3E0] group-hover:translate-x-2 dark:group-hover:text-[#EEE5E5] group-hover:text-[#EEE5E5] transition">
arrow_right_alt
</span>
</div>
</Link>
<Link
href="/turniirid"
className="px-8 md:px-12 py-8 flex flex-col justify-center gap-4 group border-[#1F5673] hover:bg-[#007CAB] dark:hover:bg-[#00A3E0] transition"

View File

@@ -2,8 +2,17 @@ import { vipnagorgialla } from "@/components/Vipnagorgialla";
import Link from "next/link";
import Image from "next/image";
import { getTranslations, setRequestLocale } from "next-intl/server";
import SoldOutOverlay from "@/components/SoldOutOverlay";
import SwitchableTicketCard from "@/components/SwitchableTicketCard";
const ticketSoldOut = {
visitor: false,
supporter: false,
lan: false,
lol: true,
cs2: true,
} as const;
interface TicketCardProps {
title: string;
subtitle: string;
@@ -14,6 +23,8 @@ interface TicketCardProps {
backgroundImage?: string;
backgroundOpacity?: number;
blueTinge?: boolean;
soldOut?: boolean;
soldOutText?: string;
className?: string;
}
@@ -27,17 +38,21 @@ function TicketCard({
backgroundImage,
backgroundOpacity = 40,
blueTinge = false,
soldOut = false,
soldOutText = "",
className = "",
}: TicketCardProps) {
return (
<div
className={`relative bg-[#0E0F19] border-[#1F5673] px-12 py-16 flex flex-col min-h-87.5 h-full ${className}`}
className={`relative bg-[#0E0F19] border-[#1F5673] px-12 py-16 flex flex-col min-h-87.5 h-full overflow-hidden ${className}`}
aria-disabled={soldOut || undefined}
>
{backgroundImage && (
<Image
src={backgroundImage}
alt=""
fill
sizes="(min-width: 1024px) 50vw, 100vw"
className="object-cover object-center"
style={{ opacity: backgroundOpacity / 100 }}
/>
@@ -83,14 +98,19 @@ function TicketCard({
</li>
))}
</ul>
<Link href={buttonHref} target="_blank" className="w-fit">
<button
className={`px-4 py-2 border-4 border-transparent bg-[#00A3E0] hover:bg-[#E5E5EE] text-[#0A121F] cursor-pointer ${vipnagorgialla.className} font-bold italic leading-none uppercase transition`}
{!soldOut && (
<Link
href={buttonHref}
target="_blank"
rel="noopener noreferrer"
className={`w-fit px-4 py-2 border-4 border-transparent bg-[#00A3E0] hover:bg-[#E5E5EE] text-[#0A121F] cursor-pointer ${vipnagorgialla.className} font-bold italic leading-none uppercase transition`}
>
{buttonText}
</button>
</Link>
</Link>
)}
</div>
{soldOut && <SoldOutOverlay text={soldOutText} />}
</div>
);
}
@@ -129,6 +149,9 @@ export default async function Tickets({
backgroundImageB="/images/landing/main_supporter.jpg"
backgroundOpacityA={30}
backgroundOpacityB={40}
soldOutA={ticketSoldOut.visitor}
soldOutB={ticketSoldOut.supporter}
soldOutText={t("tickets.soldOut")}
className="border-b-[3px] lg:border-r-[3px]"
/>
@@ -142,6 +165,8 @@ export default async function Tickets({
buttonHref="https://fienta.com/et/tipilan"
backgroundImage="/images/landing/explore_teaser.png"
backgroundOpacity={100}
soldOut={ticketSoldOut.lan}
soldOutText={t("tickets.soldOut")}
className="border-b-[3px]"
/>
@@ -154,6 +179,8 @@ export default async function Tickets({
buttonText={t("tickets.buyButton")}
buttonHref="https://fienta.com/et/tipilan"
backgroundImage="/images/landing/league_ticket.jpg"
soldOut={ticketSoldOut.lol}
soldOutText={t("tickets.soldOut")}
className="border-b-[3px] lg:border-b-0 lg:border-r-[3px]"
/>
@@ -167,6 +194,8 @@ export default async function Tickets({
buttonHref="https://fienta.com/et/tipilan"
backgroundImage="/images/landing/compete_teaser.jpg"
backgroundOpacity={30}
soldOut={ticketSoldOut.cs2}
soldOutText={t("tickets.soldOut")}
className="border-b-0"
/>
</div>

View File

@@ -45,13 +45,6 @@ export default async function CS2Tournament({
{/* Buttons */}
<div className="flex flex-wrap gap-4 mb-12">
<Link
href="https://fienta.com/et/tipilan"
target="_blank"
className={`${vipnagorgialla.className} font-bold italic leading-none text-lg px-4 py-3 border-4 border-transparent bg-[#00A3E0] hover:bg-[#E5E5EE] text-[#0A121F] uppercase transition`}
>
{t("cs2page.buyTicket")}
</Link>
<Link
href="https://git.edunaut.ee/slunk/TipiLAN_reeglistik_ruleset/src/branch/main/CS2%20tournament"
target="_blank"

View File

@@ -2,7 +2,6 @@ import { vipnagorgialla } from "@/components/Vipnagorgialla";
import RuleNav from "@/components/RuleNav";
import RulesContent from "@/components/RulesContent";
import RulesLastChanged from "@/components/RulesLastChanged";
import Link from "next/link";
import { getTranslations, setRequestLocale } from "next-intl/server";
const sectionKeys = [
@@ -42,17 +41,6 @@ export default async function LoLTournament({
</h1>
<RulesLastChanged locale={locale as "et" | "en"} ruleType="lol" />
{/* Buttons */}
<div className="flex flex-wrap gap-4 mb-12">
<Link
href="https://fienta.com/et/tipilan"
target="_blank"
className={`${vipnagorgialla.className} font-bold italic leading-none text-lg px-4 py-3 border-4 border-transparent bg-[#00A3E0] hover:bg-[#E5E5EE] text-[#0A121F] uppercase transition`}
>
{t("lolpage.buyTicket")}
</Link>
</div>
{/* SISSEJUHATUS */}
<section id="intro" className="mb-12 scroll-mt-24 md:scroll-mt-28 text-justify">
<h2

View File

@@ -28,6 +28,7 @@ function TournamentCard({
src={backgroundImage}
alt=""
fill
sizes="(min-width: 768px) 50vw, 100vw"
className="object-cover opacity-50 transition-opacity duration-200 group-hover:opacity-40 group-focus-visible:opacity-40"
style={{ objectPosition }}
/>

View File

@@ -89,8 +89,8 @@ const Header = ({ navItems }: HeaderProps) => {
<Image
src="/tipilan-icon-white.svg"
alt="TipiLAN"
width={40}
height={40}
width={350}
height={300}
className="h-auto w-10"
/>
</Link>

View File

@@ -14,6 +14,7 @@ export default function HeroSection() {
src="/images/landing/main_teaser.jpg"
alt=""
fill
sizes="100vw"
className="object-cover object-center"
priority
/>
@@ -56,10 +57,10 @@ export default function HeroSection() {
<div className="flex flex-col sm:flex-row items-center justify-center md:justify-end gap-3 sm:gap-0 mt-2">
<Image
src="/images/landing/student_award.webp"
width={180}
height={180}
width={512}
height={439}
alt="TalTech student award"
className="object-contain"
className="h-auto w-45 shrink-0 object-contain"
/>
<p
className={`text-[clamp(1.25rem,1rem+1.5vw,2rem)] leading-none tracking-normal uppercase text-center md:text-right align-middle text-[#EEE5E5] ${vipnagorgialla.className} font-bold italic px-2 sm:px-0`}

View File

@@ -0,0 +1,90 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { vipnagorgialla } from "@/components/Vipnagorgialla";
const livestreamsStartAt = new Date("2026-09-11T00:00:00+03:00").getTime();
const maximumTimeout = 2_147_483_647;
const streams = [
{ channel: "tipilan_ee", translationKey: "cs2" },
{ channel: "tipilan_ee2", translationKey: "lol" },
] as const;
export default function Livestreams() {
const t = useTranslations("home.livestreams");
const [twitchParent, setTwitchParent] = useState("");
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
let timeoutId: number | undefined;
const revealWhenReady = () => {
const timeUntilStart = livestreamsStartAt - Date.now();
if (timeUntilStart <= 0) {
setTwitchParent(window.location.hostname);
setIsVisible(true);
return;
}
timeoutId = window.setTimeout(
revealWhenReady,
Math.min(timeUntilStart, maximumTimeout),
);
};
revealWhenReady();
return () => {
if (timeoutId !== undefined) {
window.clearTimeout(timeoutId);
}
};
}, []);
if (!isVisible || !twitchParent) {
return null;
}
return (
<section
className={`mx-auto flex w-full flex-col items-center gap-10 border-b-4 border-[#00A3E0]/50 px-4 py-11 sm:gap-12 sm:px-8 sm:py-14 lg:gap-16 lg:px-12 lg:py-[68px] ${vipnagorgialla.className} font-bold italic`}
>
<h2 className="text-center text-2xl leading-none text-white uppercase sm:text-3xl lg:text-[40px]">
{t("title")}
</h2>
<div className="grid w-full max-w-[1216px] grid-cols-1 gap-8 lg:grid-cols-2 lg:gap-12">
{streams.map((stream) => {
const query = new URLSearchParams({
channel: stream.channel,
parent: twitchParent,
muted: "true",
});
return (
<article
key={stream.channel}
className="overflow-hidden border-3 border-[#1F5673] bg-[#0E0F19]"
>
<h3 className="px-5 py-4 text-xl leading-none text-[#00A3E0] uppercase sm:px-6 sm:text-2xl">
{t(stream.translationKey)}
</h3>
<div className="relative aspect-video w-full">
<iframe
src={`https://player.twitch.tv/?${query.toString()}`}
title={`${t(stream.translationKey)} — Twitch`}
className="absolute inset-0 size-full"
allow="autoplay; fullscreen"
allowFullScreen
/>
</div>
</article>
);
})}
</div>
</section>
);
}

View File

@@ -0,0 +1,17 @@
import { vipnagorgialla } from "@/components/Vipnagorgialla";
interface SoldOutOverlayProps {
text: string;
}
export default function SoldOutOverlay({ text }: SoldOutOverlayProps) {
return (
<div className="absolute inset-0 z-20 flex cursor-not-allowed select-none items-center justify-center overflow-hidden bg-[#0E0F19]/40">
<p
className={`${vipnagorgialla.className} -rotate-[15deg] whitespace-nowrap text-[clamp(2.5rem,5vw,5.5rem)] leading-none font-bold italic text-[#00A3E0] uppercase`}
>
{text}
</p>
</div>
);
}

View File

@@ -310,7 +310,7 @@ export default function Sponsors({
return (
<section
className={`mx-auto flex w-full flex-col items-center gap-10 border-t-4 border-[#00A3E0]/50 px-4 pb-10 pt-11 sm:gap-12 sm:px-8 sm:pb-12 sm:pt-14 lg:gap-16 lg:px-12 lg:pb-16 lg:pt-[68px] ${vipnagorgialla.className} font-bold italic ${className}`}
className={`mx-auto flex w-full flex-col items-center gap-10 px-4 pb-10 pt-11 sm:gap-12 sm:px-8 sm:pb-12 sm:pt-14 lg:gap-16 lg:px-12 lg:pb-16 lg:pt-[68px] ${vipnagorgialla.className} font-bold italic ${className}`}
>
{showTitle && (
<h2 className="text-center text-2xl leading-none text-white uppercase sm:text-3xl lg:text-[40px]">

View File

@@ -3,6 +3,7 @@
import { useState } from "react";
import Image from "next/image";
import Link from "next/link";
import SoldOutOverlay from "@/components/SoldOutOverlay";
import { vipnagorgialla } from "@/components/Vipnagorgialla";
interface TicketData {
@@ -22,6 +23,9 @@ interface SwitchableTicketCardProps {
backgroundOpacity?: number;
backgroundOpacityA?: number;
backgroundOpacityB?: number;
soldOutA?: boolean;
soldOutB?: boolean;
soldOutText: string;
className?: string;
}
@@ -35,11 +39,15 @@ export default function SwitchableTicketCard({
backgroundOpacity = 40,
backgroundOpacityA,
backgroundOpacityB,
soldOutA = false,
soldOutB = false,
soldOutText,
className = "",
}: SwitchableTicketCardProps) {
const [isA, setIsA] = useState(true);
const titleOnRight = !isA;
const soldOut = isA ? soldOutA : soldOutB;
function renderPrice(price: string) {
return Array.from(price).map((char, index) => (
@@ -97,6 +105,7 @@ export default function SwitchableTicketCard({
src={backgroundImage}
alt=""
fill
sizes="(min-width: 1024px) 50vw, 100vw"
className="object-cover object-center transition-opacity duration-700 ease-out"
style={{ opacity: isA ? (backgroundOpacityA ?? backgroundOpacity) / 100 : 0 }}
/>
@@ -106,6 +115,7 @@ export default function SwitchableTicketCard({
src={backgroundImageB}
alt=""
fill
sizes="(min-width: 1024px) 50vw, 100vw"
className="object-cover object-center transition-opacity duration-700 ease-out"
style={{ opacity: isA ? 0 : (backgroundOpacityB ?? backgroundOpacity) / 100 }}
/>
@@ -163,13 +173,16 @@ export default function SwitchableTicketCard({
</div>
</div>
<div className="pb-16 flex-shrink-0">
<Link href={buttonHref} target="_blank" className="w-fit">
<button
className={`px-4 py-2 border-4 border-transparent bg-[#00A3E0] hover:bg-[#E5E5EE] text-[#0A121F] cursor-pointer ${vipnagorgialla.className} font-bold italic leading-none uppercase transition`}
{!soldOut && (
<Link
href={buttonHref}
target="_blank"
rel="noopener noreferrer"
className={`w-fit px-4 py-2 border-4 border-transparent bg-[#00A3E0] hover:bg-[#E5E5EE] text-[#0A121F] cursor-pointer ${vipnagorgialla.className} font-bold italic leading-none uppercase transition`}
>
{buttonText}
</button>
</Link>
</Link>
)}
</div>
</div>
@@ -275,33 +288,38 @@ export default function SwitchableTicketCard({
</div>
</div>
<Link href={buttonHref} target="_blank" className="w-fit">
<button
className={`px-4 py-2 border-4 border-transparent bg-[#00A3E0] hover:bg-[#E5E5EE] text-[#0A121F] cursor-pointer ${vipnagorgialla.className} font-bold italic leading-none uppercase transition`}
{!soldOut && (
<Link
href={buttonHref}
target="_blank"
rel="noopener noreferrer"
className={`w-fit px-4 py-2 border-4 border-transparent bg-[#00A3E0] hover:bg-[#E5E5EE] text-[#0A121F] cursor-pointer ${vipnagorgialla.className} font-bold italic leading-none uppercase transition`}
>
{buttonText}
</button>
</Link>
</Link>
)}
</div>
</div>
{soldOut && <SoldOutOverlay text={soldOutText} />}
{/* Chevron buttons on left/right sides */}
<button
onClick={() => setIsA(false)}
className={`${isA ? "flex" : "hidden"} absolute right-0 top-1/2 -translate-y-1/2 w-10 h-10 items-center justify-center cursor-pointer bg-[#0E0F19]/50 hover:bg-[#007CAB] text-[#EEE5E5] transition-all duration-300 z-20`}
className={`${isA ? "flex" : "hidden"} absolute right-0 top-1/2 -translate-y-1/2 w-10 h-10 items-center justify-center cursor-pointer bg-[#0E0F19]/50 hover:bg-[#007CAB] text-[#EEE5E5] transition-all duration-300 z-30`}
aria-label={optionB.title}
>
<span className="material-symbols-outlined">chevron_right</span>
</button>
<button
onClick={() => setIsA(true)}
className={`${!isA ? "flex" : "hidden"} absolute left-0 top-1/2 -translate-y-1/2 w-10 h-10 items-center justify-center cursor-pointer bg-[#0E0F19]/50 hover:bg-[#007CAB] text-[#EEE5E5] transition-all duration-300 z-20`}
className={`${!isA ? "flex" : "hidden"} absolute left-0 top-1/2 -translate-y-1/2 w-10 h-10 items-center justify-center cursor-pointer bg-[#0E0F19]/50 hover:bg-[#007CAB] text-[#EEE5E5] transition-all duration-300 z-30`}
aria-label={optionA.title}
>
<span className="material-symbols-outlined">chevron_left</span>
</button>
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 flex justify-center gap-2 pb-2 z-20">
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 flex justify-center gap-2 pb-2 z-30">
<button
onClick={() => setIsA(true)}
className={`w-3 h-3 rounded-full transition ${

View File

@@ -112,6 +112,7 @@ export default function TeaserCarousel() {
src={slide.image}
alt={slide.imageAlt}
fill
sizes="100vw"
className="object-cover object-center"
/>
@@ -162,6 +163,7 @@ export default function TeaserCarousel() {
src={slide.hero}
alt={slide.imageAlt}
fill
sizes="50vw"
className="object-contain object-bottom"
/>
</div>

View File

@@ -68,40 +68,15 @@ export async function loadRules(
}
/**
* Loads rules using Bun.file (for Bun runtime environments)
* @param ruleType - The type of rules to load
* @param locale - The locale to load rules for
* @returns Promise<string> The markdown content
* Backwards-compatible alias for callers that still use the old Bun-specific
* function name. Rule files are loaded through Node so this also works in the
* Next.js runtime.
*/
export async function loadRulesBun(
ruleType: RuleType,
locale: Locale,
): Promise<string> {
// Try to load the file for the current locale first
let filePath = `src/data/rules/${locale}/${ruleType}.md`;
let file = Bun.file(filePath);
// Check if file exists, if not fallback to Estonian
if (!(await file.exists()) && locale !== "et") {
console.warn(
`Rules file not found for ${ruleType} in ${locale}, falling back to Estonian`,
);
filePath = `src/data/rules/et/${ruleType}.md`;
file = Bun.file(filePath);
}
try {
const content = await file.text();
return content;
} catch (error) {
console.error(
`Error loading rules for ${ruleType} in locale ${locale}:`,
error,
);
throw new Error(
`Failed to load rules for ${ruleType}: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
return loadRules(ruleType, locale);
}
// TODO review unused method

View File

@@ -67,6 +67,11 @@
"link": "/messiala"
}
},
"livestreams": {
"title": "TipiLAN livestreams",
"cs2": "Counter-Strike 2",
"lol": "League of Legends"
},
"sections": {
"schedule": {
"description": "TipiLAN is packed with exciting tournaments, mini-competitions and much more."
@@ -87,6 +92,7 @@
"title": "TICKETS",
"subtitle": "TICKET",
"buyButton": "BUY TICKET FROM FIENTA",
"soldOut": "SOLD OUT",
"visitor": {
"name": "VISITOR",
"price": "10€",

View File

@@ -67,6 +67,11 @@
"link": "/messiala"
}
},
"livestreams": {
"title": "TipiLANi otseülekanded",
"cs2": "Counter-Strike 2",
"lol": "League of Legends"
},
"sections": {
"schedule": {
"description": "TipiLAN on pungil põnevatest turniiridest, mini-võistlustest ja paljust muust."
@@ -87,6 +92,7 @@
"title": "PILETID",
"subtitle": "PILET",
"buyButton": "OSTA PILET FIENTAST",
"soldOut": "VÄLJA MÜÜDUD",
"visitor": {
"name": "KÜLASTAJA",
"price": "10€",

View File

@@ -24,7 +24,7 @@
],
"types": [
"node",
"bun-types"
"bun"
],
"paths": {
"@/*": [