Initial commit

This commit is contained in:
Alacris
2026-03-22 23:50:21 +02:00
parent 876de38ef4
commit 47a8a5857f
180 changed files with 2223 additions and 19265 deletions

View File

@@ -1,77 +0,0 @@
"use client";
import { useState } from "react";
import { vipnagorgialla } from "@/components/Vipnagorgialla";
import { scheduleData } from "@/data/timetable";
import SectionDivider from "@/components/SectionDivider";
import { useTranslations } from "next-intl";
const tabs = Object.keys(scheduleData);
export default function Timetable() {
const [activeTab, setActiveTab] = useState(tabs[0]);
const schedule = scheduleData[activeTab];
const t = useTranslations();
return (
<div>
<div className="flex flex-col min-h-[90vh] m-6 mt-16 md:m-16">
<h1
className={`text-4xl md:text-5xl lg:text-6xl ${vipnagorgialla.className} font-bold italic uppercase text-[#2A2C3F] dark:text-[#EEE5E5] mt-8 md:mt-16 mb-8`}
>
{t("schedule.title")}
</h1>
{/* Tab menu */}
<div className="flex gap-4 mb-8 flex-wrap">
{tabs.map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`${vipnagorgialla.className} cursor-pointer uppercase italic px-4 py-2 text-lg font-semibold ${
activeTab === tab
? "bg-[#00A3E0] text-white"
: "bg-[#007CAB] dark:bg-[#007CAB] text-[#EEE5E5] hover:bg-[#00A3E0] dark:hover:bg-[#007CAB]"
} transition-colors`}
>
{t(`schedule.${tab}`)}
</button>
))}
</div>
{/* Schedule entries */}
<div className="space-y-6">
{schedule.map((item, idx) => (
<div
key={idx}
className="border-l-3 border-[#007CAB] pl-4 flex flex-col sm:flex-row flex-wrap gap-2 sm:gap-5 items-stretch"
>
<div
className={`${vipnagorgialla.className} md:w-[180px] w-30 text-[#00A3E0] text-3xl md:text-4xl font-bold italic flex-shrink-0 flex items-center sm:justify-center`}
>
{item.time}
</div>
<div className="flex-1 flex flex-col justify-center min-w-0 sm:min-h-[120px]">
<div
className={`${vipnagorgialla.className} text-2xl md:text-3xl italic font-bold text-[#2A2C3F] dark:text-[#EEE5E5] text-balance`}
>
{t(item.titleKey)}
</div>
{item.description && (
<div className="text-xl md:text-2xl text-[#938BA1] dark:text-[#938BA1] text-balance">
{item.description}
</div>
)}
<div className="text-xl md:text-2xl text-[#938BA1] dark:text-[#938BA1] text-balance">
{t(item.locationKey)}
</div>
</div>
</div>
))}
</div>
</div>
<SectionDivider />
</div>
);
}

View File

@@ -1,114 +0,0 @@
// Fonts
import { vipnagorgialla } from "@/components/Vipnagorgialla";
// Database
import { db } from "@/db/drizzle";
// Types
import type { TeamWithMembers, MemberWithUser } from "@/types/database";
import { Link } from "@/i18n/routing";
import { getTranslations, setRequestLocale } from "next-intl/server";
// User interface
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
// Function to translate roles using i18n
function translateRole(role: string, t: (key: string) => string): string {
switch (role) {
case "CAPTAIN":
return t("admin.roles.captain");
case "TEAMMATE":
return t("admin.roles.teammate");
default:
return role;
}
}
export default async function AdminTeams({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations({ locale });
// Fetch teams with their members and member users
const teams = await db.query.teams.findMany({
with: {
members: {
with: {
user: true,
},
},
},
});
return (
<div className="flex flex-col min-h-[90vh] m-6 mt-16 md:m-16">
<div className="flex items-center gap-4">
<Link href={"/haldus"}>
<span className="material-symbols-outlined !text-[clamp(2rem,1.5rem+1.5vw,3.5rem)] !font-bold text-[#007CAB] dark:text-[#00A3E0] translate-y-2.5 hover:-translate-x-2 dark:hover:text-[#EEE5E5] hover:text-[#2A2C3F] transition">
arrow_left_alt
</span>
</Link>
<h1
className={`text-5xl sm:text-6xl ${vipnagorgialla.className} font-bold italic uppercase text-[#2A2C3F] dark:text-[#EEE5E5] mt-8 mb-4`}
>
{t("admin.title")} - {t("admin.teams")}
</h1>
</div>
<div className="text-2xl text-[#2A2C3F] dark:text-[#EEE5E5]">
<div>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]">ID</TableHead>
<TableHead>{t("admin.table.name")}</TableHead>
<TableHead>{t("admin.table.members")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{teams.map((team: TeamWithMembers) => (
<TableRow key={team.id}>
<TableCell className="font-medium">{team.id}</TableCell>
<TableCell>{team.name}</TableCell>
<TableCell>
<div className="flex flex-col gap-1">
{team.members && team.members.length > 0 ? (
team.members.map((member: MemberWithUser) => (
<div
key={member.id}
className="flex items-center gap-2 text-sm"
>
<span className="font-semibold">
{member.user.firstName} {member.user.lastName}
</span>
<span className="text-gray-500">
({translateRole(member.role, t)})
</span>
</div>
))
) : (
<span className="text-gray-500">
{t("admin.table.noMembers")}
</span>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</div>
);
}

View File

@@ -1,174 +0,0 @@
// Fonts
import { vipnagorgialla } from "@/components/Vipnagorgialla";
// Database
import { db } from "@/db/drizzle";
import { syncFientaEvent } from "@/lib/fienta";
// Enviornment variables
import("dotenv");
// User interface
import {
Users,
IdCardLanyard,
DatabaseBackup,
CheckCircle2Icon,
X,
} from "lucide-react";
import { getTranslations, setRequestLocale } from "next-intl/server";
import { revalidatePath } from "next/cache";
import { redirect, RedirectType } from "next/navigation";
import NextLink from "next/link";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { DataTable } from "@/components/haldus/data-table";
import { columns } from "@/components/haldus/columns";
async function syncAction() {
"use server";
await syncFientaEvent(process.env.EVENT_ID!, process.env.FIENTA_API_KEY!);
// Revalidate due to data change
revalidatePath("/haldus");
redirect("/haldus?success=true");
}
async function dismissAlert() {
"use server";
redirect("/haldus", RedirectType.replace);
}
const SuccessAlertDB = ({ t }: { t: (key: string) => string }) => {
return (
<Alert className="flex items-start mt-8">
<CheckCircle2Icon className="mt-0.5" />
<div className="flex-1">
<AlertTitle>{t("admin.success.title")}</AlertTitle>
<AlertDescription>{t("admin.success.description")}</AlertDescription>
</div>
<form action={dismissAlert} className="ml-2">
<Button
type="submit"
variant="ghost"
size="icon"
className="cursor-pointer"
>
<X className="" />
</Button>
</form>
</Alert>
);
};
export default async function Admin({
params,
searchParams,
}: {
params: Promise<{ locale: string }>;
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations({ locale });
const alarmStatus = await searchParams;
const showSuccess = alarmStatus.success === "true";
// Fetch users
const usersData = await db.query.users.findMany({
with: {
members: {
with: {
team: true,
},
},
},
});
const teamsData = await db.query.teams.findMany();
return (
<div className="flex flex-col min-h-[90vh] m-6 mt-16 md:m-16">
{showSuccess && <SuccessAlertDB t={t} />}
<div className="flex items-center gap-4">
<NextLink href={"/"}>
<span className="material-symbols-outlined !text-[clamp(2rem,1.5rem+1.5vw,3.5rem)] !font-bold text-[#007CAB] dark:text-[#00A3E0] translate-y-2.5 hover:-translate-x-2 dark:hover:text-[#EEE5E5] hover:text-[#2A2C3F] transition">
arrow_left_alt
</span>
</NextLink>
<h1
className={`text-5xl sm:text-6xl ${vipnagorgialla.className} font-bold italic uppercase text-[#2A2C3F] dark:text-[#EEE5E5] mt-8 mb-4`}
>
{t("admin.title")}
</h1>
</div>
<div className="text-2xl text-[#2A2C3F] dark:text-[#EEE5E5]">
<div className="pl-2 flex gap-8 pb-4">
<div className="flex text-lg md:text-2xl flex-row items-center">
<Users className="mr-2" />
{t("admin.users")}: {usersData.length}
</div>
<NextLink href="/haldus/meeskonnad" className="flex items-center">
<div className="flex text-lg md:text-2xl flex-row items-center">
<IdCardLanyard className="mr-2" />
{t("admin.teams")}: {teamsData.length}
</div>
</NextLink>
<AlertDialog>
<AlertDialogTrigger asChild>
<div className="ml-auto">
<Button
variant="ghost"
size="icon"
className="size-12 cursor-pointer"
>
<DatabaseBackup className="scale-150" />
</Button>
</div>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogTitle>{t("admin.sync.title")}</AlertDialogTitle>
<AlertDialogDescription>
{t("admin.sync.description1")}{" "}
<span className="text-red-600 font-semibold">
{t("admin.sync.all")}
</span>{" "}
{t("admin.sync.description2")}
<br />
<br />
{t("admin.sync.warning")}
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel className="cursor-pointer">
{t("common.cancel")}
</AlertDialogCancel>
<form action={syncAction}>
<AlertDialogAction type="submit" className="cursor-pointer">
{t("admin.sync.update")}
</AlertDialogAction>
</form>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<div>
<DataTable columns={columns} data={usersData} />
</div>
</div>
</div>
);
}

View File

@@ -1,64 +0,0 @@
// app/kodukord/page.tsx (App Router)
import ReactMarkdown, { Components } from "react-markdown";
import { vipnagorgialla } from "@/components/Vipnagorgialla";
import SectionDivider from "@/components/SectionDivider";
import { getTranslations, setRequestLocale } from "next-intl/server";
import { loadRulesBun } from "@/lib/loadRules";
export default async function Page({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations({ locale });
const content = await loadRulesBun("kodukord", locale as "et" | "en");
return (
<div>
<div className="flex flex-col min-h-[90vh] m-6 mt-16 md:m-16">
{/* Page title (separate from markdown headings) */}
<h1
className={`text-4xl md:text-5xl lg:text-6xl ${vipnagorgialla.className} font-bold italic text-[#2A2C3F] dark:text-[#EEE5E5] mt-8 md:mt-16 mb-4 uppercase`}
>
{t("rules.houseRules")}
</h1>
<div className="prose prose-lg dark:prose-invert max-w-none">
<ReactMarkdown
components={
{
h1: (props) => (
<h1 className="text-3xl md:text-4xl font-bold my-4">
{props.children}
</h1>
),
h2: (props) => (
<h2 className="text-2xl md:text-3xl font-semibold my-3">
{props.children}
</h2>
),
ol: (props) => (
<ol className="list-decimal ml-6 md:text-xl">
{props.children}
</ol>
),
ul: (props) => (
<ul className="list-disc ml-6 md:text-xl">
{props.children}
</ul>
),
p: (props) => <p className="md:text-xl">{props.children}</p>,
} as Components
}
>
{content}
</ReactMarkdown>
</div>
</div>
<SectionDivider />
</div>
);
}

View File

@@ -1,8 +1,5 @@
import { NextIntlClientProvider } from "next-intl";
import { setRequestLocale, getMessages } from "next-intl/server";
import { ThemeProvider } from "@/components/Theme-provider";
import SidebarParent from "@/components/SidebarParent";
import Footer from "@/components/Footer";
export default async function LocaleLayout({
children,
@@ -22,16 +19,7 @@ export default async function LocaleLayout({
return (
<div lang={locale}>
<NextIntlClientProvider messages={messages}>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<SidebarParent />
{children}
<Footer />
</ThemeProvider>
{children}
</NextIntlClientProvider>
</div>
);

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +1,14 @@
import { vipnagorgialla } from "@/components/Vipnagorgialla";
import { getTranslations } from "next-intl/server";
export default async function NotFound() {
const t = await getTranslations("notFound");
return (
<div className="flex flex-col min-h-[90vh] p-12 justify-center items-center">
<h1
className={`text-7xl ${vipnagorgialla.className} font-bold italic uppercase text-[#2A2C3F] dark:text-[#EEE5E5] mt-8 mb-4`}
>
<div className="flex flex-col min-h-dvh p-12 justify-center items-center bg-bg-dark text-text-light">
<h1 className="text-title">
{t("title")}
</h1>
<p className="text-2xl text-[#2A2C3F] dark:text-[#EEE5E5] mb-8">
<p className="text-p-lg mt-4">
{t("message")}
</p>
</div>

View File

@@ -1,8 +1,5 @@
import { vipnagorgialla } from "@/components/Vipnagorgialla";
import Sponsors from "@/components/Sponsors";
import { Link } from "@/i18n/routing";
import { getTranslations, setRequestLocale } from "next-intl/server";
import Image from "next/image";
import { setRequestLocale } from "next-intl/server";
import TeaserPage from "@/components/TeaserPage";
export default async function Home({
params,
@@ -11,150 +8,6 @@ export default async function Home({
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations({ locale });
return (
<div>
{/* Title */}
<div className="border-b-3 border-[#1F5673] grid grid-cols-1 md:grid-cols-[2fr_1fr] items-center justify-between mt-18 gap-12 py-8">
<Image
src="/tipilan-white.svg"
width={850}
height={120}
alt="TipiLAN Logo"
className="px-8 py-8 md:px-12 md:py-14 dark:hidden w-[max(300px,min(100%,850px))] h-auto"
/>
<Image
src="/tipilan-dark.svg"
width={850}
height={120}
alt="TipiLAN Logo"
className="px-8 py-8 md:px-12 md:py-14 not-dark:hidden w-[max(300px,min(100%,850px))] h-auto2"
/>
<div className="pr-12 hidden md:block text-right">
<h3
className={`text-[clamp(1.25rem,0.75rem+2.5vw,3.75rem)] ${vipnagorgialla.className} leading-[90%] font-bold italic uppercase dark:text-[#EEE5E5] text-[#2A2C3F]`}
>
{t("tournaments.prizePool")}
</h3>
<h2
className={`text-[clamp(2rem,1.2rem+4vw,6rem)] ${vipnagorgialla.className} leading-[90%] font-bold italic text-[#007CAB] dark:text-[#00A3E0]`}
>
10 000
</h2>
</div>
</div>
{/* Farewell message */}
<div>
<section
className={`p-8 md:p-12 flex flex-col ${vipnagorgialla.className} font-bold italic border-b-3 border-[#1F5673] hover:bg-[#007CAB] dark:hover:bg-[#00A3E0] group transition`}
>
<h2 className="text-[clamp(2rem,1.5rem+0.5vw,3rem)] text-[#007CAB] dark:text-[#00A3E0] dark:group-hover:text-[#EEE5E5] group-hover:text-[#EEE5E5]">
{t("home.sections.farewellMessage")} <span className="not-italic">🩵</span>
</h2>
</section>
</div>
{/* Grid of buttons */}
<div className="grid grid-cols-1 xl:grid-cols-3 border-[#1F5673]">
<Link
href="/ajakava"
className="px-8 md:px-12 py-8 flex flex-col gap-4 border-b-3 lg:border-r-3 group border-[#1F5673] hover:bg-[#007CAB] dark:hover:bg-[#00A3E0] transition"
>
<div className="cursor-pointer 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>
<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]">
event_note
</span>
<p className="text-[clamp(0.875rem,0.75rem+0.5vw,1.25rem)] tracking-[-0.045rem] dark:group-hover:text-[#2A2C3F] group-hover:text-black">
{t("home.sections.schedule.description")}
</p>
</div>
</Link>
<Link
href="/turniirid"
className="px-8 md:px-12 py-8 flex flex-col gap-4 border-b-3 lg:border-r-3 group border-[#1F5673] hover:bg-[#007CAB] dark:hover:bg-[#00A3E0] transition"
>
<div className="cursor-pointer flex flex-row justify-between gap-4 items-center">
<h2
className={`text-[clamp(2rem,1.8rem+1vw,3rem)] ${vipnagorgialla.className} font-bold italic break-normal uppercase dark:text-[#EEE5E5] text-[#2A2C3F] dark:group-hover:text-[#2A2C3F] group-hover:text-black`}
>
{t("navigation.tournaments")}
</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>
<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]">
trophy
</span>
<p className="text-[clamp(0.875rem,0.75rem+0.5vw,1.25rem)] tracking-[-0.045rem] dark:group-hover:text-[#2A2C3F] group-hover:text-black">
{t("home.sections.tournaments.description")}
</p>
</div>
</Link>
<Link
href="/messiala"
className="px-8 md:px-12 py-8 flex flex-col gap-4 border-b-3 border-[#1F5673] group hover:bg-[#007CAB] dark:hover:bg-[#00A3E0] transition-all"
>
<div className="cursor-pointer 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] dark:group-hover:text-[#2A2C3F] group-hover:text-black`}
>
{t("navigation.expo")}
</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>
<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]">
weekend
</span>
<p className="text-[clamp(0.875rem,0.75rem+0.5vw,1.25rem)] tracking-[-0.045rem] dark:group-hover:text-[#2A2C3F] group-hover:text-black">
{t("home.sections.expo.description")}
</p>
</div>
</Link>
</div>
{/* Section preserved for next year development */}
{/* Date */}
{/* <div>*/}
{/* <Link*/}
{/* href="/piletid"*/}
{/* className={`p-8 md:p-12 flex flex-col ${vipnagorgialla.className} font-bold italic border-b-3 border-[#1F5673] hover:bg-[#007CAB] dark:hover:bg-[#00A3E0] group transition`}*/}
{/* >*/}
{/* <div className="cursor-pointer text-left flex flex-row justify-between xl:justify-start gap-8">*/}
{/* <h3 className="text-4xl md:text-5xl dark:text-[#EEE5E5] dark:group-hover:text-[#2A2C3F] text-[#2A2C3F] group-hover:text-black">*/}
{/* {t("home.sections.reserveSpot")}*/}
{/* </h3>*/}
{/* <span*/}
{/* className="material-symbols-outlined !text-[clamp(2rem,1.5rem+1.5vw,3.5rem)] !font-bold text-[#007CAB] dark:text-[#00A3E0] hidden md:block group-hover:translate-x-2 group-hover:text-[#EEE5E5] dark:group-hover:text-[#EEE5E5] transition">*/}
{/* arrow_right_alt*/}
{/* </span>*/}
{/* </div>*/}
{/* <h2 className="text-[clamp(2.5rem,2.25rem+1.25vw,3.75rem)] text-[#007CAB] dark:text-[#00A3E0] dark:group-hover:text-[#EEE5E5] group-hover:text-[#EEE5E5]">*/}
{/* {t("home.sections.dateAndLocation")}*/}
{/* </h2>*/}
{/* </Link>*/}
{/* </div>*/}
{/* Sponsors */}
<Sponsors />
</div>
);
return <TeaserPage />;
}

View File

@@ -1,118 +0,0 @@
import { vipnagorgialla } from "@/components/Vipnagorgialla";
import Link from "next/link";
import SectionDivider from "@/components/SectionDivider";
import { getTranslations, setRequestLocale } from "next-intl/server";
export default async function Tickets({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations({ locale });
return (
<div>
<div className="flex flex-col min-h-[90vh] m-6 mt-16 md:m-16">
<h1
className={`text-4xl wrap-break-word md:text-5xl lg:text-6xl ${vipnagorgialla.className} font-bold italic text-[#2A2C3F] dark:text-[#EEE5E5] mt-8 md:mt-16 mb-4`}
>
{t("tickets.title")}
</h1>
<div className="flex justify-center lg:items-center flex-col lg:flex-row gap-8 md:gap-12 flex-grow mb-16 md:mt-8 lg:mt-0">
<div className="bg-[#007CAB] -skew-x-2 md:-skew-x-5 text-white italic px-8 md:px-12 py-16 hover:scale-103 transition-all duration-150 w-full md:w-xl lg:w-[400px]">
<h2
className={`text-6xl ${vipnagorgialla.className} font-bold text-[#EEE5E5] pb-2`}
>
{t("tickets.visitor.latePrice")}
</h2>
<h3
className={`text-3xl ${vipnagorgialla.className} font-bold text-[#EEE5E5] pb-4`}
>
{t("tickets.visitor.title")}
</h3>
<ul className="pl-4 mb-8 list-[square] marker:text-[#1F5673]">
{t
.raw("tickets.visitor.features")
.map((feature: string, index: number) => (
<li key={index} className="text-xl">
{feature}
</li>
))}
</ul>
<Link href="https://fienta.com/et/tipilan" target="_blank">
<button
className={`px-4 py-2 bg-[#1F5673] cursor-pointer ${vipnagorgialla.className} font-bold`}
>
{t("tickets.buyTicket")}
</button>
</Link>
</div>
<div className="bg-[#007CAB] -skew-x-2 md:-skew-x-5 text-white italic px-8 md:px-12 py-16 hover:scale-103 transition-all duration-150 w-full md:w-xl lg:w-[400px]">
<h2
className={`text-6xl ${vipnagorgialla.className} font-bold text-[#EEE5E5] pb-2`}
>
{t("tickets.computerParticipant.latePrice")}
</h2>
<h3
className={`text-3xl ${vipnagorgialla.className} font-bold text-[#EEE5E5] pb-4`}
>
{t("tickets.computerParticipant.title")}
</h3>
<ul className="pl-4 mb-8 list-[square] marker:text-[#1F5673]">
{t
.raw("tickets.computerParticipant.features")
.map((feature: string, index: number) => (
<li key={index} className="text-xl">
{feature}
</li>
))}
</ul>
<Link href="https://fienta.com/et/tipilan" target="_blank">
<button
className={`px-4 py-2 bg-[#1F5673] cursor-pointer ${vipnagorgialla.className} font-bold`}
>
{t("tickets.buyTicket")}
</button>
</Link>
</div>
<div className="bg-[#1F5673] -skew-x-2 md:-skew-x-5 text-gray-400 italic px-8 md:px-12 py-16 w-full md:w-xl lg:w-[400px]">
<h2
className={`text-4xl ${vipnagorgialla.className} font-bold pb-2`}
>
<s>{t("tickets.competitor.price")}</s>
</h2>
<h3
className={`text-2xl ${vipnagorgialla.className} font-bold pb-4`}
>
<s>{t("tickets.competitor.title")}</s>
</h3>
<ul className="pl-4 mb-8 list-[square] marker:text-[#007CAB]">
{t
.raw("tickets.competitor.features")
.map((feature: string, index: number) => (
<li key={index} className="text-sm">
{feature}
</li>
))}
</ul>
{/*<Link href="https://fienta.com/et/tipilan" target="_blank">*/}
<button
className={`px-4 py-2 bg-[#007CAB] text-white ${vipnagorgialla.className} font-bold text-xl uppercase opacity-55`}
>
{t("tickets.soldOut")}!
</button>
{/*</Link>*/}
</div>
</div>
</div>
<SectionDivider />
</div>
);
}

View File

@@ -1,110 +0,0 @@
import { notFound } from "next/navigation";
import ReactMarkdown, { Components } from "react-markdown";
import { vipnagorgialla } from "@/components/Vipnagorgialla";
import SectionDivider from "@/components/SectionDivider";
import { getTranslations, setRequestLocale } from "next-intl/server";
import { loadRulesBun } from "@/lib/loadRules";
// Map of valid slugs to their translation keys
const rulesMap = {
lol: {
titleKey: "rules.lolRules",
},
cs2: {
titleKey: "rules.cs2Rules",
},
} as const;
type RuleSlug = keyof typeof rulesMap;
interface PageProps {
params: Promise<{ slug: string; locale: string }>;
}
async function getRuleContent(slug: string, locale: string) {
if (!Object.keys(rulesMap).includes(slug)) {
return null;
}
const ruleConfig = rulesMap[slug as RuleSlug];
try {
const content = await loadRulesBun(
slug as "cs2" | "lol",
locale as "et" | "en",
);
return {
content,
titleKey: ruleConfig.titleKey,
};
} catch (error) {
console.error(
`Error reading rule file for slug ${slug} in locale ${locale}:`,
error,
);
return null;
}
}
export default async function RulePage({ params }: PageProps) {
const { slug, locale } = await params;
setRequestLocale(locale);
const t = await getTranslations({ locale });
const ruleData = await getRuleContent(slug, locale);
if (!ruleData) {
notFound();
}
const headingStyle = `text-5xl sm:text-6xl ${vipnagorgialla.className} font-bold uppercase italic text-[#2A2C3F] dark:text-[#EEE5E5]`;
return (
<div>
<div className="flex flex-col min-h-[90vh] m-6 mt-16 md:m-16">
<h1 className={`${headingStyle} mt-8 md:mt-16 mb-4`}>
{t(ruleData.titleKey)}
</h1>
<div className="prose prose-lg dark:prose-invert max-w-none">
<ReactMarkdown
components={
{
h1: (props) => (
<h1 className="text-3xl md:text-4xl font-bold my-4">
{props.children}
</h1>
),
h2: (props) => (
<h2 className="text-2xl md:text-3xl font-semibold my-3">
{props.children}
</h2>
),
ol: (props) => (
<ol className="list-none ml-6 md:text-xl">
{props.children}
</ol>
),
ul: (props) => (
<ul className="list-disc ml-6 md:text-xl">
{props.children}
</ul>
),
p: (props) => <p className="md:text-xl">{props.children}</p>,
} as Components
}
>
{ruleData.content}
</ReactMarkdown>
</div>
</div>
<SectionDivider />
</div>
);
}
export async function generateStaticParams() {
return Object.keys(rulesMap).map((slug) => ({
slug,
}));
}

View File

@@ -1,66 +0,0 @@
import { vipnagorgialla } from "@/components/Vipnagorgialla";
import SectionDivider from "@/components/SectionDivider";
import { getTranslations, setRequestLocale } from "next-intl/server";
import NextLink from "next/link";
export default async function RulesMenu({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations({ locale });
const headingStyle = `text-4xl md:text-5xl lg:text-6xl ${vipnagorgialla.className} font-bold italic text-[#2A2C3F] dark:text-[#EEE5E5] uppercase`;
const boxStyle = `-skew-x-2 md:-skew-x-5 text-white md:px-12 hover:scale-103 transition-all duration-150 w-full md:w-xl lg:w-[400px]`;
const boxTextStyle = `text-2xl md:text-3xl ${vipnagorgialla.className} font-bold uppercase text-[#EEE5E5] pb-2 break-normal whitespace-pre-line`;
return (
<div>
<div className="flex flex-col md:m-16">
<h1 className={`${headingStyle} ml-3 mt-24 md:ml-0 md:mt-16 mb-4 px-4`}>
{t("rules.title")}
</h1>
<div className="flex flex-wrap flex-row lg:mt-16 justify-center lg:items-start gap-12 flex-grow mb-8">
<NextLink href="/kodukord">
<div className={`${boxStyle} bg-[#007CAB] py-20 px-8`}>
<h2 className={`${boxTextStyle}`}>{t("rules.houseRules")}</h2>
</div>
</NextLink>
<NextLink href="/reeglid/cs2">
<div className={`${boxStyle} bg-[#1F5673] py-20 px-8`}>
<h2 className={`${boxTextStyle}`}>{t("rules.cs2Rules")}</h2>
</div>
</NextLink>
<NextLink href="reeglid/lol">
<div className={`${boxStyle} bg-[#007CAB] py-20 px-8`}>
<h2 className={`${boxTextStyle}`}>{t("rules.lolRules")}</h2>
</div>
</NextLink>
{/* Minitourn. link coming soon*/}
{/*<Link href="">*/}
{/* ajutine div. kui asendate lingiga, siis saab selle ära võtta */}
<div className="cursor-not-allowed">
<div
className={`${boxStyle} bg-[#1F5673] py-16 px-8 opacity-50 pointer-events-none`}
>
<h2 className={`${boxTextStyle}`}>{t("rules.miniRules")}</h2>
</div>
</div>
{/*</Link>*/}
</div>
</div>
<SectionDivider />
</div>
);
}

View File

@@ -1,140 +0,0 @@
import { vipnagorgialla } from "@/components/Vipnagorgialla";
import Sponsors from "@/components/Sponsors";
import { Link } from "@/i18n/routing";
import { getTranslations, setRequestLocale } from "next-intl/server";
import Image from "next/image";
export default async function Home({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations({ locale });
return (
<div>
<div className="grid grid-cols-1 md:grid-cols-2">
{/* Title */}
<div className="grid grid-cols-1 items-center justify-between mt-18 gap-12 pt-8">
<Image
src="/tipilan-white.svg"
width={850}
height={120}
alt="TipiLAN Logo"
className="px-8 py-8 md:px-12 md:py-14 dark:hidden w-[max(300px,min(100%,850px))] h-auto"
/>
<Image
src="/tipilan-dark.svg"
width={850}
height={120}
alt="TipiLAN Logo"
className="px-8 py-8 md:px-12 md:py-14 not-dark:hidden w-[max(300px,min(100%,850px))] h-auto2"
/>
<Link
href="/ajakava"
className="px-8 md:px-12 py-8 flex flex-col gap-4 border-b-3 border-t-3 group border-[#1F5673] hover:bg-[#007CAB] dark:hover:bg-[#00A3E0] transition"
>
<div className="cursor-pointer 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>
<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]">
event_note
</span>
<p className="text-[clamp(0.875rem,0.75rem+0.5vw,1.25rem)] tracking-[-0.045rem] dark:group-hover:text-[#2A2C3F] group-hover:text-black">
{t("home.sections.schedule.description")}
</p>
</div>
</Link>
</div>
{/* Stream iframe from Twitch */}
<div className="border-[#1F5673] -ml-0.75 border-l-0 md:border-l-3 border-b-3 h-full pt-0 md:pt-16">
<iframe
src="https://player.twitch.tv/?channel=tipilan_ee&parent=localhost&parent=tipilan.ee"
height="100%"
width="100%"
className="w-full h-full min-h-[400px]"
allow="autoplay; encrypted-media"
></iframe>
</div>
</div>
{/* Grid of buttons */}
<div className="grid grid-cols-1 xl:grid-cols-2 border-[#1F5673]">
<Link
href="/turniirid"
className="px-8 md:px-12 py-8 flex flex-col gap-4 border-b-3 lg:border-r-3 group border-[#1F5673] hover:bg-[#007CAB] dark:hover:bg-[#00A3E0] transition"
>
<div className="cursor-pointer flex flex-row justify-between gap-4 items-center">
<h2
className={`text-[clamp(2rem,1.8rem+1vw,3rem)] ${vipnagorgialla.className} font-bold italic break-normal uppercase dark:text-[#EEE5E5] text-[#2A2C3F] dark:group-hover:text-[#2A2C3F] group-hover:text-black`}
>
{t("navigation.tournaments")}
</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>
<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]">
trophy
</span>
<p className="text-[clamp(0.875rem,0.75rem+0.5vw,1.25rem)] tracking-[-0.045rem] dark:group-hover:text-[#2A2C3F] group-hover:text-black">
{t("home.sections.tournaments.description")}
</p>
</div>
</Link>
<Link
href="/messiala"
className="px-8 md:px-12 py-8 flex flex-col gap-4 border-b-3 border-[#1F5673] group hover:bg-[#007CAB] dark:hover:bg-[#00A3E0] transition-all"
>
<div className="cursor-pointer 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] dark:group-hover:text-[#2A2C3F] group-hover:text-black`}
>
{t("navigation.expo")}
</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>
<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]">
weekend
</span>
<p className="text-[clamp(0.875rem,0.75rem+0.5vw,1.25rem)] tracking-[-0.045rem] dark:group-hover:text-[#2A2C3F] group-hover:text-black">
{t("home.sections.expo.description")}
</p>
</div>
</Link>
</div>
{/* Date */}
<Link
href="/piletid"
className={`p-8 md:p-12 flex flex-col ${vipnagorgialla.className} font-bold italic border-b-3 border-[#1F5673] hover:bg-[#007CAB] dark:hover:bg-[#00A3E0] group transition`}
>
<div className="cursor-pointer text-left flex flex-row justify-between xl:justify-start gap-8">
<h3 className="text-4xl md:text-5xl dark:text-[#EEE5E5] dark:group-hover:text-[#2A2C3F] text-[#2A2C3F] group-hover:text-black">
{t("home.sections.reserveSpot")}
</h3>
<span className="material-symbols-outlined !text-[clamp(2rem,1.5rem+1.5vw,3.5rem)] !font-bold text-[#007CAB] dark:text-[#00A3E0] hidden md:block group-hover:translate-x-2 group-hover:text-[#EEE5E5] dark:group-hover:text-[#EEE5E5] transition">
arrow_right_alt
</span>
</div>
<h2 className="text-[clamp(2.5rem,2.25rem+1.25vw,3.75rem)] text-[#007CAB] dark:text-[#00A3E0] dark:group-hover:text-[#EEE5E5] group-hover:text-[#EEE5E5]">
{t("home.sections.dateAndLocation")}
</h2>
</Link>
{/* Sponsors */}
<Sponsors />
</div>
);
}

View File

@@ -1,264 +0,0 @@
import { vipnagorgialla } from "@/components/Vipnagorgialla";
import Link from "next/link";
import Image from "next/image";
import { getTranslations, setRequestLocale } from "next-intl/server";
export default async function Tourney({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations({ locale });
const headingStyle = `text-3xl md:text-5xl lg:text-5xl ${vipnagorgialla.className} font-bold uppercase text-[#2A2C3F] dark:text-[#EEE5E5] -skew-x-2 md:-skew-x-5 break-normal`;
const miniTournaments: {
name: string;
prize: string;
image: string;
objectPosition?: string;
bgClass?: string;
}[] = [
{
name: "Tekken 8",
prize: "200€",
image: "/images/miniturniirid/tekken8.jpg",
objectPosition: "object-center",
},
{
name: "WRC",
prize: "350€",
image: "/images/miniturniirid/wrc.jpg",
objectPosition: "object-center",
},
{
name: "Street Fighter 6",
prize: "150€",
image: "/images/miniturniirid/street_fighter.jpg",
objectPosition: "object-center",
},
{
name: "Gran Turismo",
prize: "200€",
image: "/images/miniturniirid/gran_turismo.jpg",
objectPosition: "object-center",
},
{
name: "FC 26",
prize: "100€",
image: "/images/miniturniirid/fc26.jpg",
objectPosition: "object-center",
},
{
name: "Dwarf Escape",
prize: "50€",
image: "/images/miniturniirid/dwarf_escape.png",
objectPosition: "object-center",
bgClass: "bg-black",
},
{
name: "Buckshot Roulette",
prize: "Merch",
image: "/images/miniturniirid/buckshot_tournament.png",
objectPosition: "object-center",
bgClass: "bg-black",
},
{
name: "2XKO",
prize: "100€",
image: "/images/miniturniirid/2xko.png",
objectPosition: "object-top",
},
{
name: "Super Smash Bros. Ultimate",
prize: "100€",
image: "/images/miniturniirid/super_smash_bros.jpg",
objectPosition: "object-top",
},
];
return (
<div className="flex flex-col min-h-[90vh] mt-16">
<h1
className={`text-4xl md:text-5xl lg:text-6xl ${vipnagorgialla.className} font-bold italic uppercase
text-[#2A2C3F] dark:text-[#EEE5E5] mt-8 md:mt-16 mb-8 m-6 md:m-16`}
>
{t("tournaments.title")}
</h1>
<div className="flex flex-col">
{/* Mini-turniirid */}
<div className="hover:bg-[#007CAB] py-8 md:py-16 border-b-[3px] border-[#1F5673] transition group">
<div className="mx-8 md:mx-16 lg:mx-32 xl:mx-48">
<div className="-skew-x-2 md:-skew-x-5 mb-8">
<h2 className={`${headingStyle}`}>
{t("tournaments.mini.title")}
</h2>
<p
className={
"text-2xl mb-4 text-neutral-500 group-hover:text-black"
}
>
{t("tournaments.mini.timing")}
</p>
<p className="text-balance">
{t("tournaments.mini.description1")}
</p>
<p className="text-balance">
{t("tournaments.mini.description2")}
</p>
<br />
<div className="flex flex-row flex-wrap gap-4 md:gap-8">
<Link href="/kodukord" target="_blank">
<button
className={`px-4 py-2 bg-[#1F5673] cursor-pointer ${vipnagorgialla.className} font-bold italic text-[#ECE5E5]`}
>
{t("tournaments.mini.readRules")}
</button>
</Link>
<a href="https://fienta.com/et/tipilan" target="_blank">
<button
className={`px-4 py-2 bg-[#00A3E0] group-hover:bg-black cursor-pointer ${vipnagorgialla.className} font-bold italic text-[#ECE5E5]`}
>
{t("tournaments.mini.buyTicket")}
</button>
</a>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-8">
{miniTournaments.map((tournament) => (
<div key={tournament.name} className="text-center">
<Image
src={tournament.image}
alt={tournament.name}
width={400}
height={300}
className={`outline-10 outline-[#007CAB] bg-black object-cover w-full aspect-video -skew-x-2 md:-skew-x-5 ${
tournament.objectPosition || "object-center"
}`}
/>
<div className="-skew-x-2 md:-skew-x-5">
<p className="mt-2 font-semibold">{tournament.name} - {tournament.prize}</p>
</div>
</div>
))}
</div>
</div>
</div>
{/* CS2 turniir */}
<div className="hover:bg-[#007CAB] py-8 md:py-16 transition group">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-16 items-center mx-8 md:mx-16 lg:mx-32 xl:mx-48">
<div className="-skew-x-2 md:-skew-x-5">
<h2 className={`${headingStyle}`}>
{t("tournaments.cs2.title")}
</h2>
<p
className={
"text-2xl mb-4 text-neutral-500 group-hover:text-black"
}
>
{t("tournaments.cs2.timing")}
</p>
<p className="text-balance">
{t("tournaments.cs2.description1")}
</p>
<br />
<p className="text-balance">
{t("tournaments.cs2.description2")}
</p>
<p className="text-balance">
{t("tournaments.cs2.description3")}
</p>
<br />
<div className={"flex flex-row flex-wrap gap-8"}>
<Link href="/reeglid/cs2" target="_blank">
<button
className={`px-4 py-2 bg-[#1F5673] cursor-pointer ${vipnagorgialla.className} font-bold italic text-[#ECE5E5]`}
>
{t("tournaments.cs2.readRules")}
</button>
</Link>
<a href="https://fienta.com/et/tipilan" target="_blank">
<button
className={`px-4 py-2 bg-[#00A3E0] group-hover:bg-black cursor-pointer ${vipnagorgialla.className} font-bold italic text-[#ECE5E5]`}
>
{t("tournaments.cs2.buyTicket")}
</button>
</a>
</div>
</div>
<div className="hidden md:block">
<div>
{/* Outside div needs to remain so that overflow won't occur*/}
<Image
src="/images/cs2_tournament_logo.png"
alt="CS2 tournament"
width={600}
height={400}
/>
</div>
</div>
</div>
</div>
{/* LoL turniir */}
<div className="hover:bg-[#007CAB] py-8 md:py-16 border-t-[3px] border-b-[3px] border-[#1F5673] transition group">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-16 items-center mx-8 md:mx-16 lg:mx-32 xl:mx-48">
<div className="hidden md:block">
<div>
{/* Outside div needs to remain so that overflow won't occur*/}
<Image
src="/images/lol_tournament_logo.png"
alt="LoL tournament"
width={600}
height={400}
/>
</div>
</div>
<div className="flex-auto text-right -skew-x-2 md:-skew-x-5">
<h2 className={`${headingStyle}`}>
{t("tournaments.lol.title")}
</h2>
<p
className={
"text-2xl mb-4 text-neutral-500 group-hover:text-black"
}
>
{t("tournaments.lol.timing")}
</p>
<p className="text-balance">
{t("tournaments.lol.description1")}
</p>
<br />
<p className="text-balance">
{t("tournaments.lol.description2")}
</p>
<br />
<div className="flex flex-row flex-wrap gap-4 md:gap-8 justify-end">
<Link href="/reeglid/lol" target="_blank">
<button
className={`px-4 py-2 bg-[#1F5673] cursor-pointer ${vipnagorgialla.className} font-bold italic text-[#ECE5E5]`}
>
{t("tournaments.lol.readRules")}
</button>
</Link>
<a href="https://fienta.com/et/tipilan" target="_blank">
<button
className={`px-4 py-2 bg-[#00A3E0] group-hover:bg-black cursor-pointer ${vipnagorgialla.className} font-bold italic text-[#ECE5E5]`}
>
{t("tournaments.lol.buyTicket")}
</button>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,137 +1,158 @@
@import 'tailwindcss';
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
/* ===== TipiLAN 2026 Design Tokens ===== */
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
@font-face {
font-family: 'Vipnagorgialla';
src: url('/fonts/vipnagorgialla/Vipnagorgialla-Rg.otf') format('opentype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@theme {
--breakpoint-xs: 30rem;
@font-face {
font-family: 'Vipnagorgialla';
src: url('/fonts/vipnagorgialla/Vipnagorgialla-Rg-It.otf') format('opentype');
font-weight: 400;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Vipnagorgialla';
src: url('/fonts/vipnagorgialla/Vipnagorgialla-Bd.otf') format('opentype');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Vipnagorgialla';
src: url('/fonts/vipnagorgialla/Vipnagorgialla-Bd-It.otf') format('opentype');
font-weight: 700;
font-style: italic;
font-display: swap;
}
/* ===== 2026 Color & Font Theme ===== */
@theme inline {
--color-bg-dark: #0A121F;
--color-primary: #00A3E0;
--color-primary-50: rgba(0, 163, 224, 0.5);
--color-text-light: #FFFFFF;
--color-stroke: #00A3E0;
--font-heading: 'Vipnagorgialla', sans-serif;
--font-body: 'Work Sans', Arial, Helvetica, sans-serif;
}
body {
font-family: "Work Sans", Arial, Helvetica, sans-serif;
font-family: var(--font-body);
max-width: 100vw;
padding: 0;
margin: 0;
background: var(--color-bg-dark);
color: var(--color-text-light);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
/* ===== 2026 Typography Utilities ===== */
@utility text-title {
font-family: var(--font-heading);
font-weight: 700;
font-style: italic;
font-size: clamp(48px, 8vw, 96px);
line-height: 100%;
text-transform: uppercase;
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
@utility text-subtitle {
font-family: var(--font-heading);
font-weight: 700;
font-style: italic;
font-size: clamp(36px, 5vw, 64px);
line-height: 100%;
text-transform: uppercase;
}
@layer base {
* {
@apply border-border outline-ring/50;
@utility text-h1 {
font-family: var(--font-heading);
font-weight: 700;
font-style: italic;
font-size: clamp(28px, 4vw, 48px);
line-height: 100%;
text-transform: uppercase;
}
@utility text-btn-lg {
font-family: var(--font-heading);
font-weight: 700;
font-style: italic;
font-size: 24px;
line-height: 100%;
text-transform: uppercase;
}
@utility text-p-lg {
font-family: var(--font-body);
font-weight: 400;
font-size: clamp(18px, 2.5vw, 28px);
line-height: 130%;
}
@utility text-p {
font-family: var(--font-body);
font-weight: 400;
font-size: 20px;
line-height: 100%;
}
@utility text-countdown {
font-family: var(--font-heading);
font-weight: 700;
font-style: italic;
font-size: clamp(32px, 5vw, 48px);
line-height: 100%;
text-transform: uppercase;
}
@utility text-countdown-label {
font-family: var(--font-heading);
font-weight: 700;
font-style: italic;
font-size: 16px;
line-height: 100%;
text-transform: uppercase;
}
/* ===== 2026 Effect Utilities ===== */
@utility shadow-teaser {
filter: drop-shadow(0px 0px 20px rgba(0, 0, 0, 0.5));
}
@utility text-shadow-teaser {
text-shadow: 0px 0px 20px rgba(0, 0, 0, 0.5);
}
/* ===== 2026 Button Utility ===== */
@utility btn-primary-lg {
background-color: var(--color-primary);
color: var(--color-bg-dark);
padding: 16px;
min-width: 56px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
cursor: pointer;
transition: opacity 0.2s;
}
/* ===== Scrollbar hidden utility ===== */
@utility scrollbar-hidden {
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
body {
@apply bg-background text-foreground;
}
}
.material-symbols-outlined {
font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 24;
}
}

View File

@@ -1,15 +1,14 @@
import type { Metadata } from "next";
import { Work_Sans } from "next/font/google";
import "./globals.css";
import "material-symbols";
const workSans = Work_Sans({
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "TipiLAN 2025",
description: "TipiLAN 2025 Eesti suurim tudengite korraldatud LAN!",
title: "TipiLAN 2026",
description: "TipiLAN 2026 Eesti suurim tudengite korraldatud LAN!",
};
export default function RootLayout({
@@ -18,9 +17,9 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<html suppressHydrationWarning>
<html lang="et">
<body
className={`${workSans.className} antialiased bg-[#EEE5E5] dark:bg-[#0E0F19]`}
className={`${workSans.className} antialiased bg-bg-dark text-text-light`}
>
{children}
</body>

View File

@@ -1,29 +1,11 @@
import { vipnagorgialla } from "@/components/Vipnagorgialla";
import { ThemeProvider } from "@/components/Theme-provider";
export default function NotFound() {
return (
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<div className="flex flex-col min-h-[90vh] p-12 justify-center items-center">
<h1
className={`text-7xl ${vipnagorgialla.className} font-bold italic uppercase text-[#2A2C3F] dark:text-[#EEE5E5] mt-8 mb-4`}
>
404
</h1>
<div className="text-center">
<p className="text-2xl text-[#2A2C3F] dark:text-[#EEE5E5] mb-2">
Lehte ei leitud!
</p>
<p className="text-lg text-[#2A2C3F]/80 dark:text-[#EEE5E5]/80">
Page not found!
</p>
</div>
<div className="flex flex-col min-h-dvh p-12 justify-center items-center bg-bg-dark text-text-light">
<h1 className="text-title">404</h1>
<div className="text-center mt-4">
<p className="text-p-lg">Lehte ei leitud!</p>
<p className="text-p mt-2 opacity-80">Page not found!</p>
</div>
</ThemeProvider>
</div>
);
}

View File

@@ -1,134 +0,0 @@
import { SiDiscord, SiInstagram, SiFacebook } from "react-icons/si";
import Image from "next/image";
import { useTranslations } from "next-intl";
// Fonts
import { vipnagorgialla } from "@/components/Vipnagorgialla";
const Footer = () => {
const t = useTranslations();
return (
<div className="flex flex-col justify-center sm:justify-between px-6 py-8 md:px-12 md:py-16 gap-4 md:gap-8">
<div className="flex md:items-center gap-8 md:gap-0 justify-between flex-col md:flex-row">
<div className="flex flex-col items-start md:items-center">
<Image
src="/tipilan-white.svg"
width={250}
height={36}
alt="TipiLAN Logo"
className="h-9 dark:hidden"
/>
<Image
src="/tipilan-dark.svg"
width={250}
height={36}
alt="TipiLAN Logo"
className="h-9 not-dark:hidden"
/>
</div>
{/* Social media */}
<div className="flex flex-row">
<a
href="https://discord.gg/pPhhatZAfA"
target="_blank"
className="mx-4 ml-0 md:ml-4"
rel="noopener noreferrer"
>
<SiDiscord
title="Discord"
size={"2em"}
className="text-[#2A2C3F] dark:text-[#EEE5E5] hover:text-[#007CAB] hover:dark:text-[#00A3E0] transition"
/>
</a>
<a
href="https://instagram.com/tipilan.ee"
target="_blank"
className="mx-4"
rel="noopener noreferrer"
>
<SiInstagram
title="Instagram"
size={"2em"}
className="text-[#2A2C3F] dark:text-[#EEE5E5] hover:text-[#007CAB] hover:dark:text-[#00A3E0] transition"
/>
</a>
<a
href="https://facebook.com/tipilan.ee"
target="_blank"
className="mx-4"
rel="noopener noreferrer"
>
<SiFacebook
title="Facebook"
size={"2em"}
className="text-[#2A2C3F] dark:text-[#EEE5E5] hover:text-[#007CAB] hover:dark:text-[#00A3E0] transition"
/>
</a>
</div>
</div>
<div className="flex flex-col">
<h2
className={`text-3xl sm:text-4xl ${vipnagorgialla.className} font-bold italic uppercase text-[#2A2C3F] dark:text-[#EEE5E5] mt-8 mb-4`}
>
{t("footer.contact")}
</h2>
<div className="flex flex-row justify-between gap-4 items-center">
<div>
<h3 className="text-xl font-bold">{t("footer.studentUnion")}</h3>
<div className="flex flex-col gap-2 mt-2">
<div className="flex flex-row gap-2">
<span className="material-symbols-outlined !font-bold text-[#007CAB] dark:text-[#00A3E0]">
mail
</span>
<a href="mailto:tipilan@ituk.ee" className="underline">
tipilan@ituk.ee
</a>
</div>
<div className="flex flex-row gap-2">
<span className="material-symbols-outlined !font-bold text-[#007CAB] dark:text-[#00A3E0]">
phone
</span>
<a href="tel:+37256931193" className="underline">
+372 5693 1193
</a>
</div>
</div>
<h3 className="text-xl font-bold pt-4">
{t("footer.organization")}
</h3>
<div>
<p>
{t("footer.registrationCode")}:{" "}
<span className="font-semibold text-[#007CAB] dark:text-[#00A3E0]">
80391807
</span>
</p>
<p className="">ICO-210, Raja tn 4c, Tallinn, Harjumaa, 12616</p>
</div>
</div>
</div>
<div className="block align-middle text-center pt-16">
{t("footer.madeBy")}{" "}
<a
target="_blank"
href="https://lapikud.ee/"
className="text-[#E3983E] font-bold"
>
MTÜ Lapikud
</a>{" "}
{t("footer.withHelpFrom")}{" "}
<a
target="_blank"
href="https://ituk.ee/"
className="bg-[#7B1642] font-bold not-dark:text-white"
>
MTÜ For Tsükkel/ITÜK
</a>
</div>
</div>
</div>
);
};
export default Footer;

View File

@@ -1,101 +0,0 @@
"use client";
// Icons
import {
MdClose,
MdMenu,
MdSunny,
MdModeNight,
MdComputer,
} from "react-icons/md";
// Theme Provider
import { useTheme } from "next-themes";
import LanguageSwitcher from "./LanguageSwitcher";
// Shadcn UI
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
// Fonts
// import { vipnagorgialla } from "@/components/Vipnagorgialla";
interface HeaderProps {
isOpen: boolean;
onToggle: () => void;
themeLabels: {
light: string;
dark: string;
system: string;
};
}
const Header = ({ isOpen, onToggle, themeLabels }: HeaderProps) => {
const { theme, setTheme } = useTheme();
return (
<header className="px-8 py-2 md:px-12 flex items-center bg-[#EEE5E5] dark:bg-[#0E0F19] border-b-3 border-[#1F5673] justify-between text-[#2A2C3F] dark:text-[#EEE5E5]">
<button onClick={onToggle}>
{isOpen ? (
<MdClose className="h-12 w-12 text-[#2A2C3F] dark:text-[#EEE5E5] cursor-pointer" />
) : (
<MdMenu className="h-12 w-12 text-[#2A2C3F] dark:text-[#EEE5E5] cursor-pointer" />
)}
</button>
<div className="flex items-center gap-2">
<LanguageSwitcher />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-10 cursor-pointer"
>
<MdSunny className="scale-135 text-[#2A2C3F] dark:hidden" />
<MdModeNight className="scale-135 dark:text-[#EEE5E5] not-dark:hidden" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48 translate-y-4">
<DropdownMenuItem
className={`text-lg ${theme === "light" ? "bg-accent/50 font-medium" : ""}`}
onClick={() => setTheme("light")}
disabled={theme === "light"}
>
<MdSunny className={theme === "light" ? "text-amber-500" : ""} />
<span>{themeLabels.light}</span>
</DropdownMenuItem>
<DropdownMenuItem
className={`text-lg ${theme === "dark" ? "bg-accent/50 font-medium" : ""}`}
onClick={() => setTheme("dark")}
disabled={theme === "dark"}
>
<MdModeNight
className={theme === "dark" ? "text-blue-500" : ""}
/>
<span>{themeLabels.dark}</span>
</DropdownMenuItem>
<DropdownMenuItem
className={`text-lg ${theme === "system" ? "bg-accent/50 font-medium" : ""}`}
onClick={() => setTheme("system")}
disabled={theme === "system"}
>
<MdComputer
className={theme === "system" ? "text-green-500" : ""}
/>
<span>{themeLabels.system}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
);
};
export default Header;

View File

@@ -3,8 +3,7 @@
import { useLocale } from "next-intl";
import { useRouter, usePathname } from "@/i18n/routing";
import { routing } from "@/i18n/routing";
import { Button } from "@/components/ui/button";
import { vipnagorgialla } from "@/components/Vipnagorgialla";
import Image from "next/image";
export default function LanguageSwitcher() {
const locale = useLocale();
@@ -17,31 +16,24 @@ export default function LanguageSwitcher() {
return routing.locales[nextIndex] as "et" | "en";
};
const getNextLanguageName = () => {
const nextLocale = getNextLocale();
switch (nextLocale) {
case "et":
return "EST";
case "en":
return "ENG";
default:
return nextLocale;
}
};
const handleLanguageSwitch = () => {
const nextLocale = getNextLocale();
router.replace(pathname, { locale: nextLocale });
};
return (
<Button
<button
onClick={handleLanguageSwitch}
variant="ghost"
size="lg"
className={`${vipnagorgialla.className} text-3xl font-bold italic uppercase cursor-pointer hover:bg-[#007CAB]/10 dark:hover:bg-[#00A3E0]/10 text-[#007CAB] dark:text-[#00A3E0] hover:text-[#2A2C3F] dark:hover:text-[#EEE5E5] transition-colors`}
className="relative size-[40px] cursor-pointer overflow-hidden"
aria-label="Switch language"
>
{getNextLanguageName()}
</Button>
<Image
src={`/images/flag-${locale === "et" ? "en" : "et"}.svg`}
alt={locale === "et" ? "Switch to English" : "Vaheta eesti keelele"}
width={40}
height={30}
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"
/>
</button>
);
}

View File

@@ -1,7 +0,0 @@
import React from 'react';
export default function SectionDivider() {
return (
<hr className="border-t-[3px] border-[#1F5673]" />
);
};

View File

@@ -1,84 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { usePathname } from "@/i18n/routing";
import { Link } from "@/i18n/routing";
import { vipnagorgialla } from "@/components/Vipnagorgialla";
import Header from "./Header";
interface NavItem {
href:
| "/"
| "/ajakava"
| "/haldus"
| "/kodukord"
| "/messiala"
| "/piletid"
| "/reeglid"
| "/striim"
| "/turniirid";
label: string;
}
interface SidebarLayoutClientProps {
themeLabels: {
light: string;
dark: string;
system: string;
};
navItems: NavItem[];
}
export default function SidebarLayoutClient({
themeLabels,
navItems,
}: SidebarLayoutClientProps) {
const [isOpen, setIsOpen] = useState(false);
const pathname = usePathname();
const toggleSidebar = () => setIsOpen(!isOpen);
// Close sidebar when route changes
useEffect(() => {
if (isOpen) {
setIsOpen(false);
}
}, [pathname]);
return (
<>
<Header
isOpen={isOpen}
onToggle={toggleSidebar}
themeLabels={themeLabels}
/>
{/* Sidebar */}
<>
<div
className="fixed inset-0 backdrop-blur mt-16 z-20"
style={{ display: isOpen ? "block" : "none" }}
onClick={() => setIsOpen(false)}
></div>
<div
className={`text-3xl md:text-4xl ${vipnagorgialla.className} font-bold break-all italic uppercase fixed flex items-start xs:pl-25 pl-20 sm:pl-20 md:pl-24 flex-col gap-8 pt-16 top-0 left-0 h-[99vh] mt-16 -skew-x-5 border-r-3 border-[#1F5673] w-screen sm:w-96 md:w-128 bg-[#EEE5E5] dark:bg-[#0E0F19] text-[#2A2C3F] dark:text-[#EEE5E5] transition-transform transform z-20`}
style={{
transform: isOpen
? "translateX(-13%) skewX(calc(5deg * -1)"
: "translateX(-150%) skewX(calc(5deg * -1)",
}}
>
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
className="hover:text-[#00A3E0] md:hover:translate-x-2 transition duration-150"
>
{item.label}
</Link>
))}
</div>
</>
</>
);
}

View File

@@ -1,26 +0,0 @@
import { getTranslations } from "next-intl/server";
import SidebarLayoutClient from "./SidebarLayoutClient";
export default async function SidebarLayoutServer() {
const t = await getTranslations("common");
const themeLabels = {
light: t("theme.light"),
dark: t("theme.dark"),
system: t("theme.system"),
};
const navT = await getTranslations("navigation");
const navItems = [
{ href: "/" as const, label: navT("home") },
{ href: "/messiala" as const, label: navT("expo") },
{ href: "/piletid" as const, label: navT("tickets") },
{ href: "/ajakava" as const, label: navT("schedule") },
{ href: "/turniirid" as const, label: navT("tournaments") },
{ href: "/kodukord" as const, label: navT("houserules") },
{ href: "/reeglid" as const, label: navT("rules") },
];
return <SidebarLayoutClient themeLabels={themeLabels} navItems={navItems} />;
}

View File

@@ -1,14 +0,0 @@
import SidebarLayoutServer from "./SidebarLayoutServer";
const SidebarParent = () => {
return (
<div className="fixed w-screen top-0 z-9999">
<SidebarLayoutServer />
</div>
);
};
// This component is responsible for rendering the sidebar and header together.
// Server-side translations are handled by SidebarLayoutServer.
export default SidebarParent;

View File

@@ -1,203 +0,0 @@
import { vipnagorgialla } from "@/components/Vipnagorgialla";
import { useTranslations } from "next-intl";
import Image from "next/image";
import NextLink from "next/link";
interface SponsorsProps {
showTitle?: boolean;
className?: string;
}
export default function Sponsors({
showTitle = true,
className = "",
}: SponsorsProps) {
const t = useTranslations();
return (
<div
className={`p-12 flex flex-col ${vipnagorgialla.className} font-bold italic border-b-3 border-[#1F5673] ${className}`}
>
<div className="text-left flex flex-col justify-between xl:justify-start">
{showTitle && (
<h3 className="text-4xl md:text-5xl dark:text-[#EEE5E5] text-[#2A2C3F] group-hover:text-black pb-8">
{t("home.sections.poweredBy")}
</h3>
)}
<div className="flex flex-col sm:flex-row flex-wrap gap-8 md:gap-18 items-center justify-center xl:justify-start">
<NextLink href="https://taltech.ee" target="_blank">
<Image
src="/sponsors/taltech-color.png"
alt="Taltech (Tallinna Tehnikaülikool)"
width={192}
height={192}
className="object-contain"
/>
</NextLink>
<NextLink href="https://www.redbull.com/ee-et/" target="_blank">
<Image
src="/sponsors/redbull.png"
alt="Redbull"
width={80}
height={80}
className="object-contain"
/>
</NextLink>
<NextLink href="https://www.alecoq.ee" target="_blank">
<Image
src="/sponsors/alecoq.svg"
alt="Alecoq"
width={200}
height={200}
className="object-contain"
/>
</NextLink>
<NextLink href="https://www.simracing.ee/" target="_blank">
<Image
src="/sponsors/EVAL.png"
alt="EVAL"
width={200}
height={200}
className="object-contain"
/>
</NextLink>
<NextLink href="https://balsnack.ee" target="_blank">
<Image
src="/sponsors/balsnack.svg"
alt="Balsnack"
width={200}
height={200}
className="object-contain"
/>
</NextLink>
<NextLink
href="https://www.rara.ee/sundmused/interaktiivne-videomangude-muuseum-lvlup/"
target="_blank"
>
<Image
src="/sponsors/lvlup_logo_export.svg"
alt="LVLup!"
width={192}
height={192}
className="object-contain"
/>
</NextLink>
<NextLink
href="https://www.facebook.com/bfglOfficial"
target="_blank"
>
<Image
src="/sponsors/BFGL.png"
alt="BFGL"
width={192}
height={192}
className="object-contain"
/>
</NextLink>
<NextLink href="https://www.tallinn.ee/et/haridus" target="_blank">
<Image
src="/sponsors/Tallinna_Haridusamet_logo_RGB.svg"
alt="Tallinna Haridusamet"
width={292}
height={292}
className="object-contain"
/>
</NextLink>
<NextLink href="https://www.militaarseiklus.ee/" target="_blank">
<Image
src="/sponsors/militaarseiklus.png"
alt="Militaarseiklus"
width={200}
height={200}
className="object-contain"
/>
</NextLink>
<NextLink
href="https://www.linkedin.com/company/gamedev-guild/"
target="_blank"
>
<Image
src="/sponsors/estonian_gamedev_guild.png"
alt="Estonian Gamedev Guild"
width={200}
height={200}
className="object-contain not-dark:invert"
/>
</NextLink>
<NextLink href="https://thotell.ee/" target="_blank">
<Image
src="/sponsors/thotell.png"
alt="Tahentorni Hotell (Tähentorni Hotel)"
width={200}
height={200}
className="object-contain"
/>
</NextLink>
<NextLink href="https://www.dominos.ee/" target="_blank">
<Image
src="/sponsors/dominos.png"
alt="Domino's Pizza"
width={250}
height={250}
className="object-contain"
/>
</NextLink>
<NextLink href="https://www.tomorrow.ee/" target="_blank">
<Image
src="/sponsors/nt.png"
alt="Network Tomorrow"
width={300}
height={200}
className="object-contain"
/>
</NextLink>
<NextLink href="https://driftikeskus.ee/" target="_blank">
<Image
src="/sponsors/driftikeskus.png"
alt="Driftikeskus"
width={300}
height={200}
className="object-contain"
/>
</NextLink>
<NextLink href="https://ingame.ee/" target="_blank">
<Image
src="/sponsors/ingame.png"
alt="Ingame"
width={200}
height={200}
className="object-contain"
/>
</NextLink>
<NextLink href="https://alzgamer.ee/" target="_blank">
<Image
src="/sponsors/alzgamer.png"
alt="AlzGamer"
width={200}
height={200}
className="object-contain"
/>
</NextLink>
<NextLink href="https://k-space.ee/" target="_blank">
<Image
src="/sponsors/k-space_ee-white.png"
alt="K-Space"
width={200}
height={200}
className="object-contain not-dark:invert"
/>
</NextLink>
<NextLink href="https://globalproductions.ee/" target="_blank">
<Image
src="/sponsors/Global-productions.png"
alt="Global Productions"
width={200}
height={200}
className="object-contain"
/>
</NextLink>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,29 @@
"use client";
import { useRef } from "react";
import HeroSection from "@/components/teaser/HeroSection";
import CarouselSection from "@/components/teaser/CarouselSection";
import EndSection from "@/components/teaser/EndSection";
import Footer from "@/components/teaser/Footer";
export default function TeaserPage() {
const carouselRef = useRef<HTMLElement>(null);
const handleScrollDown = () => {
if (carouselRef.current) {
carouselRef.current.scrollIntoView({ behavior: "smooth" });
}
};
return (
<div className="bg-bg-dark w-full">
{/* Main wrapper with bottom border (desktop) */}
<div className="lg:border-b-4 lg:border-primary-50">
<HeroSection onScrollDown={handleScrollDown} />
<CarouselSection sectionRef={carouselRef} />
<EndSection />
</div>
<Footer />
</div>
);
}

View File

@@ -1,11 +0,0 @@
"use client"
import * as React from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

View File

@@ -1,28 +0,0 @@
import localFont from 'next/font/local';
// Style 'only' has normal and italic for some reason.
// It uses the weight to determine the style used.
export const vipnagorgialla = localFont({
src: [
{
path: '../app/fonts/vipnagorgialla/Vipnagorgialla-Rg.otf',
weight: '400',
style: 'normal',
},
{
path: '../app/fonts/vipnagorgialla/Vipnagorgialla-Rg-It.otf',
weight: '400',
style: 'italic',
},
{
path: '../app/fonts/vipnagorgialla/Vipnagorgialla-Bd.otf',
weight: '700',
style: 'normal',
},
{
path: '../app/fonts/vipnagorgialla/Vipnagorgialla-Bd-It.otf',
weight: '700',
style: 'italic',
},
],
});

View File

@@ -1,148 +0,0 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import {
KeyIcon,
Mails,
Ticket,
IdCardLanyard,
ArrowUpDown,
} from "lucide-react";
import { Button } from "@/components/ui/button";
// Define the user type based on the database schema
export type User = {
id: string;
firstName: string;
lastName: string;
email: string;
steamId: string | null;
ticketId: string | null;
ticketType: string | null;
members: {
team: {
name: string;
} | null;
}[];
};
export const columns: ColumnDef<User>[] = [
{
accessorKey: "id",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="flex items-center gap-2 h-auto p-0"
>
<KeyIcon className="h-4 w-4" />
ID
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => <div className="font-medium">{row.getValue("id")}</div>,
},
{
accessorKey: "firstName",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="h-auto p-0"
>
Eesnimi
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => <div>{row.getValue("firstName")}</div>,
},
{
accessorKey: "lastName",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="h-auto p-0"
>
Perenimi
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => <div>{row.getValue("lastName")}</div>,
},
{
accessorKey: "email",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="flex items-center gap-2 h-auto p-0"
>
<Mails className="h-4 w-4" />
Email
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => <div>{row.getValue("email")}</div>,
},
{
accessorKey: "steamId",
header: "Steam ID",
cell: ({ row }) => {
const steamId = row.getValue("steamId") as string | null;
return <div>{steamId || "-"}</div>;
},
},
{
accessorKey: "ticketId",
header: () => (
<div className="flex items-center gap-2">
<Ticket className="h-4 w-4" />
Pileti ID
</div>
),
cell: ({ row }) => {
const ticketId = row.getValue("ticketId") as string | null;
return <div>{ticketId || "-"}</div>;
},
},
{
accessorKey: "ticketType",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="h-auto p-0"
>
Pileti tüüp
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => {
const ticketType = row.getValue("ticketType") as string | null;
return <div>{ticketType || "-"}</div>;
},
},
{
id: "team",
accessorFn: (user) =>
user.members && user.members.length > 0 && user.members[0].team
? user.members[0].team.name
: "",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="flex items-center gap-2 h-auto p-0"
>
<IdCardLanyard className="h-4 w-4" />
Meeskond
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => {
const teamName = row.getValue("team") as string;
return <div>{teamName || "-"}</div>;
},
},
];

View File

@@ -1,220 +0,0 @@
"use client";
import * as React from "react";
import {
ColumnDef,
ColumnFiltersState,
SortingState,
VisibilityState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import { Eraser, Funnel, Search } from "lucide-react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
}
// Search input placeholders
function getPlaceholderText(column: string): string {
const placeholders = {
email: "Filtreeri emaile...",
firstName: "Filtreeri eesnimesid...",
ticketType: "Filtreeri pileti tüüpi...",
team: "Filtreeri tiimi nimesid...",
};
return placeholders[column as keyof typeof placeholders] || "Otsi...";
}
export function DataTable<TData, TValue>({
columns,
data,
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = React.useState<SortingState>([]);
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
[],
);
const [columnVisibility, setColumnVisibility] =
React.useState<VisibilityState>({});
const [rowSelection, setRowSelection] = React.useState({});
const [searchColumn, setSearchColumn] = React.useState<string>("email");
const [searchValue, setSearchValue] = React.useState<string>("");
const table = useReactTable({
data,
columns,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
},
});
return (
<div className="w-full">
<div className="flex items-center py-4">
<Search className="h-6 w-6 mr-2" />
<Input
placeholder={getPlaceholderText(searchColumn)}
value={searchValue}
onChange={(event) => {
const value = event.target.value;
setSearchValue(value);
// Clear all column filters first
table.getColumn("email")?.setFilterValue("");
table.getColumn("firstName")?.setFilterValue("");
table.getColumn("ticketType")?.setFilterValue("");
table.getColumn("team")?.setFilterValue("");
// Set filter for selected column
table.getColumn(searchColumn)?.setFilterValue(value);
}}
className="max-w-sm rounded-r-none"
/>
<Select value={searchColumn} onValueChange={setSearchColumn}>
<SelectTrigger className="w-48 border-l-0 rounded-l-none">
<Funnel />
<SelectValue placeholder="Vali otsingu väli" />
</SelectTrigger>
<SelectContent>
<SelectItem value="email">Email</SelectItem>
<SelectItem value="firstName">Eesnimi</SelectItem>
<SelectItem value="ticketType">Pileti tüüp</SelectItem>
<SelectItem value="team">Tiim</SelectItem>
</SelectContent>
</Select>
{searchValue && (
<Button
variant="ghost"
size="lg"
onClick={() => {
setSearchValue("");
table.getColumn(searchColumn)?.setFilterValue("");
}}
className="ml-2"
>
<Eraser />
Tühjenda
</Button>
)}
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
Tulemusi ei leitud.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between space-x-2 py-4">
<div className="flex-1 text-sm text-muted-foreground">
Näidatakse {table.getFilteredRowModel().rows.length} rida(de) kokku{" "}
{data.length} reast.
</div>
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
<span>
Lehekülg {table.getState().pagination.pageIndex + 1} /{" "}
{table.getPageCount()}
</span>
</div>
<div className="space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Eelmine
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Järgmine
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,166 @@
"use client";
import { useEffect, useState } from "react";
import Image from "next/image";
import { useTranslations } from "next-intl";
import { SLIDES, type CarouselSlide } from "./constants";
function CarouselSlideComponent({
slide,
t,
isActive,
}: {
slide: CarouselSlide;
t: ReturnType<typeof useTranslations>;
isActive: boolean;
}) {
const isLeft = slide.layout === "left";
return (
<div className="relative h-dvh w-full overflow-hidden">
{/* Background image */}
<Image
src={slide.bgImage}
alt=""
fill
sizes="100vw"
className="object-cover pointer-events-none"
/>
{/* Content — top padding accounts for the floating heading */}
<div
className={`relative z-[1] flex h-full gap-8 xl:gap-16 pt-[120px] px-8 xl:px-16 ${isLeft ? "items-end" : "items-end flex-row-reverse"
}`}
>
{/* Text content */}
<div
className={`flex-1 flex h-full flex-col gap-6 xl:gap-12 justify-center pb-16 ${isLeft ? "items-start" : "items-end"
}`}
>
<h2 className="text-subtitle text-text-light shadow-teaser">{t(slide.titleKey)}</h2>
<p className={`text-p-lg text-text-light ${isLeft ? "text-left" : "text-right"}`}>
{t(slide.descKey)}
</p>
</div>
{/* Hero image — locked aspect ratio, enters from bottom, pinned to bottom */}
<div
className="relative shrink-0 w-[35vw] xl:w-[40vw] overflow-hidden shadow-teaser transition-transform duration-700 ease-out"
style={{
aspectRatio: "850 / 900",
transform: isActive ? "translateY(0)" : "translateY(110%)",
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={slide.heroImage}
alt=""
className="absolute inset-0 w-full h-full object-cover"
/>
</div>
</div>
</div>
);
}
interface CarouselSectionProps {
sectionRef: React.RefObject<HTMLElement | null>;
}
export default function CarouselSection({ sectionRef }: CarouselSectionProps) {
const t = useTranslations();
const [currentSlide, setCurrentSlide] = useState(0);
const [scrollProgress, setScrollProgress] = useState(0);
useEffect(() => {
const handleScroll = () => {
if (!sectionRef.current) return;
const rect = sectionRef.current.getBoundingClientRect();
const sectionHeight = sectionRef.current.offsetHeight;
const viewportHeight = window.innerHeight;
const scrolledInto = -rect.top;
const totalScrollable = sectionHeight - viewportHeight;
if (totalScrollable <= 0) return;
const progress = Math.max(0, Math.min(1, scrolledInto / totalScrollable));
setScrollProgress(progress);
const slideIndex = Math.min(SLIDES.length - 1, Math.floor(progress * SLIDES.length));
setCurrentSlide(slideIndex);
};
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, [sectionRef]);
// Heading follows slide layout: left on "left" slides, right on "right" slides
const headingOnRight = SLIDES[currentSlide]?.layout === "right";
return (
<section
ref={sectionRef}
className="relative hidden lg:block"
style={{ height: `${SLIDES.length * 100}dvh` }}
>
<div className="sticky top-0 h-dvh w-full overflow-hidden border-t-4 border-primary-50">
{/* Slides */}
{SLIDES.map((slide, i) => (
<div
key={i}
className="absolute inset-0 transition-opacity duration-700"
style={{ opacity: currentSlide === i ? 1 : 0 }}
>
<CarouselSlideComponent
slide={slide}
t={t}
isActive={currentSlide === i}
/>
</div>
))}
{/* Floating heading — slides between left/right via flex-grow spacers (ease-out) */}
<div className="absolute top-[72px] inset-x-0 px-8 xl:px-16 z-10 flex">
<div
className="transition-[flex-grow] duration-700 ease-out"
style={{ flexGrow: headingOnRight ? 1 : 0 }}
/>
<div className="text-subtitle text-text-light text-shadow-teaser whitespace-nowrap shrink-0">
<span>{t("teaser.carousel.heading")}</span>
<span className="text-primary">LAN</span>
<span>{t("teaser.carousel.headingSuffix")}</span>
</div>
<div
className="transition-[flex-grow] duration-700 ease-out"
style={{ flexGrow: headingOnRight ? 0 : 1 }}
/>
</div>
{/* Progress bar — 3 segments */}
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 z-10 flex gap-2 w-[240px]">
{SLIDES.map((_, i) => {
// Each segment: 0 = empty, 1 = full
const segmentStart = i / SLIDES.length;
const segmentEnd = (i + 1) / SLIDES.length;
const segmentProgress = Math.max(
0,
Math.min(1, (scrollProgress - segmentStart) / (segmentEnd - segmentStart))
);
return (
<div
key={i}
className="flex-1 h-2 rounded-full bg-white/30 overflow-hidden"
>
<div
className="h-full bg-primary rounded-full transition-[width] duration-300 ease-out"
style={{ width: `${segmentProgress * 100}%` }}
/>
</div>
);
})}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,110 @@
"use client";
import Image from "next/image";
import { useTranslations } from "next-intl";
import { useCountUp } from "@/hooks/useCountUp";
function formatK(n: number): string {
if (n < 1000) return String(n);
const k = n / 1000;
if (k % 1 === 0) return `${k}k`;
return `${k.toFixed(1)}k`;
}
function CountUpStat({
end,
suffix,
label,
format,
minWidth,
}: {
end: number;
suffix: string;
label: string;
format?: (n: number) => string;
minWidth?: string;
}) {
const { ref, count } = useCountUp({ end, duration: 2000 });
const display = format ? format(count) : String(count);
return (
<div className="flex flex-col items-center gap-4 lg:gap-8">
<p
className="text-title tabular-nums"
ref={ref as React.RefObject<HTMLParagraphElement>}
style={{ minWidth: minWidth ?? "auto" }}
>
{display}<span className="text-primary">{suffix}</span>
</p>
<p className="text-p-lg whitespace-pre-line">{label}</p>
</div>
);
}
export default function EndSection() {
const t = useTranslations();
return (
<section className="relative w-full border-t-4 border-primary-50 hidden lg:block">
<div className="flex flex-col md:flex-row min-h-dvh">
{/* Tickets side */}
<div className="relative flex-1 overflow-hidden min-h-[50dvh] md:min-h-0">
<Image
src="/images/tickets_teaser.png"
alt=""
fill
sizes="(min-width: 768px) 50vw, 100vw"
className="object-cover pointer-events-none"
/>
<div className="relative z-[1] flex flex-col items-center justify-center h-full gap-12 xl:gap-32 p-8 lg:p-16">
{/* Ticket stats */}
<div className="flex gap-8 xl:gap-16 text-center text-text-light">
<CountUpStat end={0} suffix="€" label={t("teaser.tickets.earlyVisitor")} />
<CountUpStat end={0} suffix="€" label={t("teaser.tickets.supporter")} />
</div>
{/* CTA */}
<div className="flex flex-col items-center gap-4 xl:gap-8 w-full">
<h2 className="text-h1 text-text-light text-center text-shadow-teaser">
{t("teaser.tickets.cta")}
</h2>
<a href="https://fienta.com/tipilan-2026" target="_blank" rel="noopener noreferrer" className="btn-primary-lg text-btn-lg hover:opacity-80">
{t("teaser.tickets.buyButton")}
</a>
</div>
</div>
</div>
{/* Separator — vertical on desktop, horizontal on stacked */}
<div className="hidden md:block relative w-[4px] bg-primary-50 shrink-0" />
<div className="block md:hidden relative h-[4px] bg-primary-50 shrink-0" />
{/* Sponsors side */}
<div className="relative flex-1 overflow-hidden min-h-[50dvh] md:min-h-0">
<Image
src="/images/sponsors_teaser.png"
alt=""
fill
sizes="(min-width: 768px) 50vw, 100vw"
className="object-cover pointer-events-none"
/>
<div className="relative z-[1] flex flex-col items-center justify-center h-full gap-12 xl:gap-32 p-8 lg:p-16">
{/* Sponsor stats */}
<div className="flex gap-8 xl:gap-16 text-center text-text-light">
<CountUpStat end={900} suffix="+" label={t("teaser.sponsors.visitors")} minWidth="5ch" />
<CountUpStat end={10000} suffix="+" label={t("teaser.sponsors.streamViewers")} format={formatK} minWidth="5ch" />
</div>
{/* CTA */}
<div className="flex flex-col items-center gap-4 xl:gap-8 w-full">
<h2 className="text-h1 text-text-light text-center text-shadow-teaser">
{t("teaser.sponsors.cta")}
</h2>
<a href="mailto:tipilan@ituk.ee" className="btn-primary-lg text-btn-lg hover:opacity-80">
{t("teaser.sponsors.contactButton")}
</a>
</div>
</div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,77 @@
"use client";
import { useTranslations } from "next-intl";
import { SiFacebook, SiInstagram, SiTiktok, SiDiscord, SiTwitch, SiYoutube } from "react-icons/si";
const SOCIAL_LINKS = [
{ icon: SiFacebook, href: "https://facebook.com/tipilan.ee", label: "Facebook" },
{ icon: SiInstagram, href: "https://instagram.com/tipilan.ee", label: "Instagram" },
{ icon: SiTiktok, href: "https://tiktok.com/@tipilan.ee", label: "TikTok" },
{ icon: SiDiscord, href: "https://discord.gg/pPhhatZAfA", label: "Discord" },
{ icon: SiTwitch, href: "https://twitch.tv/tipilan", label: "Twitch" },
{ icon: SiYoutube, href: "https://youtube.com/@tipilan", label: "YouTube" },
];
export default function Footer() {
const t = useTranslations();
return (
<footer className="bg-bg-dark w-full">
{/* Mobile: only social icons */}
<div className="flex lg:hidden items-center justify-center gap-6 py-8 px-6 flex-wrap">
{SOCIAL_LINKS.map(({ icon: Icon, href, label }) => (
<a
key={label}
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-text-light hover:text-primary transition-colors"
aria-label={label}
>
<Icon size={32} />
</a>
))}
</div>
{/* Desktop: full footer */}
<div className="hidden lg:flex flex-wrap gap-y-12 gap-x-8 xl:gap-x-0 items-start justify-between p-8 xl:p-16">
{/* Left: Organization info */}
<div className="flex flex-col gap-4 text-p font-bold text-text-light">
<p>{t("teaser.footer.organization")}</p>
<p>{t("teaser.footer.regCode")}</p>
<p>{t("teaser.footer.bankAccount")}</p>
<div className="flex items-center gap-2">
<span className="text-[16px]">©</span>
<p>{t("teaser.footer.copyright")}</p>
</div>
</div>
{/* Center: Contact info */}
<div className="flex flex-col gap-4 text-p font-bold text-text-light">
<p>{t("teaser.footer.studentUnion")}</p>
<a href="mailto:tipilan@ituk.ee" className="underline">{t("teaser.footer.email")}</a>
<p>{t("teaser.footer.phone")}</p>
<p>{t("teaser.footer.address")}</p>
</div>
{/* Right: Social links */}
<div className="flex flex-col items-end">
<div className="flex flex-wrap gap-6 items-center justify-center w-[169px]">
{SOCIAL_LINKS.map(({ icon: Icon, href, label }) => (
<a
key={label}
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-text-light hover:text-primary transition-colors"
aria-label={label}
>
<Icon size={40} />
</a>
))}
</div>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,111 @@
"use client";
import Image from "next/image";
import { useTranslations } from "next-intl";
import LanguageSwitcher from "@/components/LanguageSwitcher";
import { useCountdown } from "@/hooks/useCountdown";
import { EVENT_DATE } from "./constants";
interface HeroSectionProps {
onScrollDown: () => void;
}
export default function HeroSection({ onScrollDown }: HeroSectionProps) {
const t = useTranslations();
const countdown = useCountdown(EVENT_DATE);
const pad = (n: number) => String(n).padStart(2, "0");
return (
<section className="relative h-dvh w-full overflow-hidden">
{/* Background */}
<Image
src="/images/hero_teaser.png"
alt=""
fill
className="object-cover pointer-events-none"
sizes="100vw"
priority
/>
{/* Language switcher — top right */}
<div className="absolute top-6 right-6 lg:top-[40px] lg:right-[40px] z-10">
<LanguageSwitcher />
</div>
{/* Center content */}
<div className="relative z-[1] flex flex-col items-center justify-center h-full gap-8 lg:gap-16 px-6">
{/* Logo */}
<Image
src="/tipilan-dark.svg"
alt="TipiLAN"
width={524}
height={129}
className="w-[min(524px,80vw)] h-auto"
priority
/>
{/* Countdown */}
<div className="flex gap-6 sm:gap-8 text-center">
<div className="flex flex-col items-center gap-2 lg:gap-4 min-w-0 lg:w-[104px]">
<span className="text-countdown text-text-light">{countdown.days}</span>
<span className="text-countdown-label text-primary whitespace-nowrap">{t("teaser.countdown.days")}</span>
</div>
<div className="flex flex-col items-center gap-2 lg:gap-4 min-w-0 lg:w-[86px]">
<span className="text-countdown text-text-light">{pad(countdown.hours)}</span>
<span className="text-countdown-label text-primary whitespace-nowrap">{t("teaser.countdown.hours")}</span>
</div>
<div className="flex flex-col items-center gap-2 lg:gap-4 min-w-0 lg:w-[86px]">
<span className="text-countdown text-text-light">{pad(countdown.minutes)}</span>
<span className="text-countdown-label text-primary whitespace-nowrap">{t("teaser.countdown.minutes")}</span>
</div>
<div className="flex flex-col items-center gap-2 lg:gap-4 min-w-0 lg:w-[103px]">
<span className="text-countdown text-text-light">{pad(countdown.seconds)}</span>
<span className="text-countdown-label text-primary whitespace-nowrap">{t("teaser.countdown.seconds")}</span>
</div>
</div>
{/* YouTube embed — 16:9, constrained to logo width */}
<div className="w-[min(524px,80vw)] aspect-video">
<iframe
src="https://www.youtube.com/embed/p_xwlExVtIk"
title="TipiLAN 2026"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
className="w-full h-full rounded-sm"
/>
</div>
{/* Mobile CTA buttons — only visible on small screens */}
<div className="flex flex-col gap-4 w-full max-w-sm lg:hidden mt-4">
<a
href="https://fienta.com/tipilan-2026"
target="_blank"
rel="noopener noreferrer"
className="btn-primary-lg text-btn-lg hover:opacity-80 text-center"
>
{t("teaser.tickets.buyButton")}
</a>
<a
href="mailto:tipilan@ituk.ee"
className="btn-primary-lg text-btn-lg hover:opacity-80 text-center"
>
{t("teaser.sponsors.contactButton")}
</a>
</div>
</div>
{/* Scroll button — bottom center, hidden on mobile */}
<div className="absolute bottom-[68px] left-1/2 -translate-x-1/2 z-10 hidden lg:flex">
<button
onClick={onScrollDown}
className="bg-primary rounded-full size-[48px] flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity"
aria-label="Scroll down"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M12 4V20M12 20L6 14M12 20L18 14" stroke="#0A121F" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</div>
</section>
);
}

View File

@@ -0,0 +1,35 @@
// TipiLAN 2026 event date — September 11, 17:00 Estonian time (EEST = UTC+3)
export const EVENT_DATE = new Date("2026-09-11T17:00:00+03:00");
export interface CarouselSlide {
titleKey: string;
descKey: string;
bgImage: string;
heroImage: string;
layout: "left" | "right";
}
export const SLIDES: CarouselSlide[] = [
{
titleKey: "teaser.compete.title",
descKey: "teaser.compete.description",
bgImage: "/images/compete_teaser.png",
heroImage: "/images/compete_hero.png",
layout: "left",
},
{
titleKey: "teaser.play.title",
descKey: "teaser.play.description",
bgImage: "/images/play_teaser.png",
heroImage: "/images/play_hero.png",
layout: "right",
},
{
titleKey: "teaser.explore.title",
descKey: "teaser.explore.description",
bgImage: "/images/explore_teaser.png",
heroImage: "/images/explore_hero.png",
layout: "left",
},
];

View File

@@ -1,157 +0,0 @@
"use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@@ -1,66 +0,0 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }

View File

@@ -1,67 +0,0 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
if (asChild) {
return (
<Slot
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...(props as React.ComponentProps<typeof Slot>)}
/>
);
}
return (
<button
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };

View File

@@ -1,92 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-transparent text-card-foreground flex flex-col gap-6 rounded-xl border-3 border-[#1F5673] py-6 shadow-sm",
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className,
)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};

View File

@@ -1,257 +0,0 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@@ -1,21 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
)}
{...props}
/>
);
}
export { Input };

View File

@@ -1,185 +0,0 @@
"use client";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};

View File

@@ -1,13 +0,0 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -1,116 +0,0 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -1,61 +0,0 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -1,231 +0,0 @@
{
"games": [
{
"id": "broken-alliance",
"name": "Broken Alliance",
"logo": "/images/EXPO/GameDev logos/broken_alliance.png",
"developer": "Placeholder Gameworks",
"description": ""
},
{
"id": "buckshot-tournament",
"name": "Buckshot Tournament",
"logo": "/images/miniturniirid/buckshot_tournament.png",
"developer": "Mike Klubnika",
"description": ""
},
{
"id": "eleball",
"name": "EleBall",
"logo": "/images/EXPO/GameDev logos/Eleball.png",
"developer": "Pulsar Twin",
"description": ""
},
{
"id": "craftcraft-simulator",
"name": "CraftCraft Simulator",
"logo": "/images/EXPO/GameDev logos/craftcat_sim.png",
"developer": "Placeholder Gameworks",
"description": ""
},
{
"id": "cyber-doc-rogue",
"name": "CYBER DOC ROGUE",
"logo": "/images/EXPO/GameDev logos/Cyber_Doc_Rogue.png",
"developer": "HRA Interactive",
"description": ""
},
{
"id": "death-and-taxes",
"name": "Death and Taxes",
"logo": "/images/EXPO/GameDev logos/death_and_taxes.png",
"developer": "Placeholder Gameworks",
"description": ""
},
{
"id": "deep-pixel-melancholy",
"name": "Deep Pixel Melancholy",
"logo": "/images/EXPO/GameDev logos/deep_pixel_melancholy.svg",
"developer": "ok/no",
"description": ""
},
{
"id": "delusional",
"name": "Delusional",
"logo": "/images/EXPO/GameDev logos/DELUSIONAL_logo.svg",
"developer": "AUTOPLAY",
"description": ""
},
{
"id": "hardwired",
"name": "Hardwired",
"logo": "/images/EXPO/GameDev logos/Hardwired.png",
"developer": "Lostbyte",
"description": ""
},
{
"id": "hexwave",
"name": "HexWave",
"logo": "/images/EXPO/GameDev logos/Hexwave.png",
"developer": "HardBoyled Games",
"description": ""
},
{
"id": "immortal",
"name": "IMMORTAL: And the death that follows",
"logo": "/images/EXPO/GameDev logos/Immortal.png",
"developer": "Mishura Games",
"description": ""
},
{
"id": "kalawindow",
"name": "Kalawindow",
"logo": "/images/EXPO/GameDev logos/Kalawindow.png",
"developer": "Lost Empire Entertainment",
"description": ""
},
{
"id": "kortel-1996",
"name": "Kortel 1996",
"logo": "/images/EXPO/GameDev logos/Korter1996.png",
"developer": "Mari-Anna Lepasson",
"description": ""
},
{
"id": "midnight-souveneirs",
"name": "Midnight Souveneirs",
"logo": "/images/EXPO/GameDev logos/Midnight_Souveneirs.png",
"developer": "Path of Pixels",
"description": ""
},
{
"id": "planet-hoarders",
"name": "Planet Hoarders",
"logo": "/images/EXPO/GameDev logos/Planet_hoarders.png",
"developer": "Imago Games",
"description": ""
},
{
"id": "realm-hearts",
"name": "Realm Hearts",
"logo": "/images/EXPO/GameDev logos/realm_hearts.png",
"developer": "Dreamaster",
"description": ""
},
{
"id": "season-of-solitude",
"name": "Season of Solitude",
"logo": "/images/EXPO/GameDev logos/Seasons_of_Solitude.png",
"developer": "Ninjarithm Studio",
"description": ""
},
{
"id": "war-torn",
"name": "War-torn",
"logo": "/images/EXPO/GameDev logos/War_torn.png",
"developer": "KingOfTheEnd, ForgottenCup",
"description": ""
},
{
"id": "type-n-magic",
"name": "Type 'n Magic",
"logo": "/images/EXPO/ylikoolid/type_n_magic.png",
"developer": "Mikhail Fiadotau",
"description": ""
}
],
"universities": [
{
"id": "deltavr",
"name": "DeltaVR",
"logo": "/images/EXPO/ylikoolid/deltavr.png",
"university": "Tartu Ülikool",
"description": ""
},
{
"id": "tiksu-toksu",
"name": "Tiksu-Toksu",
"logo": "/images/EXPO/ylikoolid/tiksu-toksu.jpg",
"university": "Tartu Ülikool",
"description": ""
},
{
"id": "psyrreal",
"name": "Psyrreal",
"logo": "/images/EXPO/ylikoolid/psyrreal.png",
"university": "Tartu Ülikool",
"description": ""
},
{
"id": "blastronaut",
"name": "Blastronaut",
"logo": "/images/EXPO/ylikoolid/blastronaut.png",
"university": "Tartu Ülikool",
"description": ""
},
{
"id": "oh-crap",
"name": "Oh Crap!",
"logo": "/images/EXPO/ylikoolid/oh_crap.png",
"university": "Tallinna Tehnikaülikool",
"description": ""
},
{
"id": "dwarf-escape",
"name": "Dwarf Escape",
"logo": "/images/EXPO/ylikoolid/dwarf_escape.png",
"university": "Tallinna Tehnikaülikool",
"description": ""
},
{
"id": "void-of-hermes",
"name": "Void of Hermes",
"logo": "/images/EXPO/ylikoolid/void_of_hermes.png",
"university": "Tallinna Tehnikaülikool",
"description": ""
},
{
"id": "nullis",
"name": "Nullis",
"logo": "/images/EXPO/GameDev logos/Nullis.png",
"university": "Tartu Ülikool",
"description": ""
},
{
"id": "packet-tracers",
"name": "Packet Tracers",
"logo": "/images/EXPO/ylikoolid/packet_tracers.png",
"university": "Tallinna Tehnikaülikool",
"description": ""
},
{
"id": "a-bottles-journey",
"name": "A Bottle's Journey",
"logo": "/images/EXPO/ylikoolid/a_bottles_journey.png",
"university": "Tallinna Ülikool",
"description": ""
},
{
"id": "wildings",
"name": "Wildings",
"logo": "/images/EXPO/ylikoolid/wildings.png",
"university": "Tallinna Ülikool",
"description": ""
},
{
"id": "the-return",
"name": "The Return",
"logo": "/images/EXPO/ylikoolid/the_return.png",
"university": "Tallinna Ülikool",
"description": ""
},
{
"id": "magic-mineral",
"name": "Magic Mineral",
"logo": "/images/EXPO/ylikoolid/magic_mineral.png",
"university": "Tallinna Ülikool",
"description": ""
}
]
}

View File

@@ -1,218 +0,0 @@
export const roomNameKeys = [
"boardGames",
"bar",
"eval",
"simRacing",
"fighting",
"lvlup",
"redbull",
"kspace",
"photowall",
"buckshotroulette",
"chillArea",
"estoniagamedev",
"info",
"tartuyk",
"tly",
"gameup",
"ittk",
"wc",
"alzgamer",
"studentformula",
] as const;
export type RoomNameKey = (typeof roomNameKeys)[number];
/**
* Static room names that do not require translation.
* Centralizing here allows easy management and expansion.
*/
export const staticRoomNames: Partial<Record<RoomNameKey, string>> = {
eval: "EVAL",
redbull: "Red Bull",
kspace: "K-space.ee",
buckshotroulette: "Buckshot Roulette",
gameup: "GameUP! Academy",
wc: "WC",
alzgamer: "Alzgamer",
};
/**
* Room metadata for tudengimaja and fuajee rooms.
* Centralizes size, position, and color for easier management.
*/
export interface RoomMeta {
color: number;
size: { width: number; height: number; depth: number };
position: { x: number; y: number; z: number };
view: "tudengimaja" | "fuajee";
}
export const roomMeta: Partial<Record<RoomNameKey, RoomMeta[]>> = {
// tudengimaja rooms
lvlup: [
{
color: 0xd34e35,
size: { width: 7, height: 0.7, depth: 2 },
position: { x: 2.8, y: 0, z: 4.75 },
view: "tudengimaja",
},
],
kspace: [
{
color: 0x2c5da3,
size: { width: 5, height: 0.7, depth: 2 },
position: { x: -3.2, y: 0, z: 4.75 },
view: "tudengimaja",
},
],
bar: [
{
color: 0x4ecdc4,
size: { width: 2, height: 0.7, depth: 0.7 },
position: { x: -0.5, y: 0, z: 1 },
view: "tudengimaja",
},
],
eval: [
{
color: 0x4d86f7,
size: { width: 2, height: 0.7, depth: 1.5 },
position: { x: 1.7, y: 0, z: -3.8 },
view: "tudengimaja",
},
],
simRacing: [
{
color: 0xd8b43c,
size: { width: 1.5, height: 0.7, depth: 5 },
position: { x: -6.8, y: 0, z: -2.2 },
view: "tudengimaja",
},
],
redbull: [
{
color: 0xc02841,
size: { width: 2, height: 0.7, depth: 1.5 },
position: { x: -3.9, y: 0, z: -3.8 },
view: "tudengimaja",
},
],
fighting: [
{
color: 0xa8f494,
size: { width: 3.5, height: 0.7, depth: 1.5 },
position: { x: -1.1, y: 0, z: -3.8 },
view: "tudengimaja",
},
],
photowall: [
{
color: 0xd12e7d,
size: { width: 2, height: 0.7, depth: 1 },
position: { x: -6.6, y: 0, z: 1.9 },
view: "tudengimaja",
},
],
buckshotroulette: [
{
color: 0xedb4b1,
size: { width: 2, height: 0.7, depth: 1.5 },
position: { x: 3.7, y: 0, z: -3.8 },
view: "tudengimaja",
},
],
chillArea: [
{
color: 0x05512e,
size: { width: 1.5, height: 0.7, depth: 5 },
position: { x: 5.5, y: 0, z: -2.1 },
view: "tudengimaja",
},
{
color: 0x05512e,
size: { width: 3.8, height: 0.7, depth: 1.5 },
position: { x: 0.4, y: 0, z: -0.2 },
view: "tudengimaja",
},
],
alzgamer: [
{
color: 0xd08331,
size: { width: 3.5, height: 0.7, depth: 1.5 },
position: { x: -3.3, y: 0, z: -0.2 },
view: "tudengimaja",
},
],
wc: [
{
color: 0x332b5d,
size: { width: 2, height: 0.7, depth: 2 },
position: { x: 5.3, y: 0, z: 1.5 },
view: "tudengimaja",
},
],
// fuajee rooms
ittk: [
{
color: 0xd12e7d,
size: { width: 4.5, height: 0.5, depth: 3 },
position: { x: -3.8, y: 0, z: 3.3 },
view: "fuajee",
},
],
tartuyk: [
{
color: 0x365591,
size: { width: 5, height: 0.5, depth: 2.5 },
position: { x: 2.7, y: 0, z: -1.7 },
view: "fuajee",
},
],
estoniagamedev: [
{
color: 0x183bbf,
size: { width: 6, height: 0.5, depth: 2.5 },
position: { x: -5.8, y: 0, z: -1.7 },
view: "fuajee",
},
{
color: 0x183bbf,
size: { width: 2, height: 0.5, depth: 5.5 },
position: { x: -7.7, y: 0, z: 2.1 },
view: "fuajee",
},
],
info: [
{
color: 0xff6347,
size: { width: 2, height: 0.5, depth: 2 },
position: { x: -1, y: 0, z: -2 },
view: "fuajee",
},
],
tly: [
{
color: 0xa82838,
size: { width: 4, height: 0.5, depth: 2 },
position: { x: 7.5, y: 0, z: -1.8 },
view: "fuajee",
},
],
gameup: [
{
color: 0x228b22,
size: { width: 2, height: 0.5, depth: 1.5 },
position: { x: 10.7, y: 0, z: -2 },
view: "fuajee",
},
],
studentformula: [
{
color: 0x20b2aa,
size: { width: 2.5, height: 0.5, depth: 1.5 },
position: { x: 13, y: 0, z: -2 },
view: "fuajee",
},
],
};

View File

@@ -1,153 +0,0 @@
## 1. General Information
1. **1.1** The Counter-Strike 2 (hereinafter CS2) tournament takes place October 24-26, 2025 at Tallinn University of Technology (TalTech) premises, Ehitajate tee 5, Tallinn.
2. **1.2** The tournament prize pool is 5,750€, distributed as follows:
1. **1.2.1** TOP 3 prize pool is 5,000€:
- **1.2.1.1** First place team - 500€ per player
- **1.2.1.2** Second place team - 300€ per player
- **1.2.1.3** Third place team - 200€ per player
2. **1.2.2** LCQ prize pool is 750€:
- **1.2.2.1** First place team - 100€ per player
- **1.2.2.2** Second place team - 50€ per player
3. **1.3** Prize money will be paid to the player's bank account.
1. **1.3.1** For underage players, the prize will be paid to the parent/guardian's bank account.
4. **1.4** Throughout the tournament, all participants must comply with the laws of the Republic of Estonia, TipiLAN house rules, and event regulations.
5. **1.5** By purchasing a ticket, each participant gives permission to be photographed, filmed, and for all photographic, audio, and video material to be used for event documentation and marketing.
6. **1.6** The CS2 main tournament will be recorded and streamed on Twitch and YouTube platforms.
7. **1.7** All tournament-related communications between team members (e.g., in-game chat, voice communications, Discord conversations, etc.) will be recorded.
8. **1.8** When self-streaming the game, the stream delay must be at least 5 minutes.
9. **1.9** Organizers have the right to use participants' personal information solely within the framework of conducting the event.
10. **1.10** The organizing team has the right to modify and edit rules as needed without prior notice.
11. **1.11** The organizing team is impartial towards all participants.
## 2. CS2 Main Tournament Team and Team Composition
1. **2.1** A CS2 main tournament team (hereinafter team) core roster consists of five main members, one of whom is the team captain;
2. **2.2** The team captain is the team's representative who:
1. **2.2.1** Serves as the contact person for the organizing team;
2. **2.2.2** Registers the team for the tournament;
3. **2.2.3** Is responsible for the team's behavior and actions;
4. **2.2.4** Represents the team in cases of warnings, disqualifications, disputes, and timeouts.
3. **2.3** Each team may have one substitute player who is not part of the team's core roster:
1. **2.3.1** The substitute player must purchase a separate substitute player ticket;
2. **2.3.2** The substitute player can replace any member of the team's core roster during the tournament;
3. **2.3.3** The same rights and requirements apply to the substitute player as to the team's core roster.
4. **2.4** Team core roster members may be replaced before the team registration deadline:
**2.4.1** Player replacement is done through Fienta;
**2.4.2** When replacing a member, the team retains the right to a substitute player;
**2.4.3** The same rights and requirements apply to replacement players as to the team's core roster.
5. **2.5** When replacing the team captain (e.g., with a substitute or replacement player), the team decides internally who receives the team captain's rights and responsibilities;
6. **2.6** Teams must confirm their participation, final team core roster, and team name 2 weeks before the tournament in an email sent to the team captain. If a core member drops out after the final roster has been confirmed, the team must use their substitute player.
7. **2.7** If a team withdraws from tournament participation before the registration deadline, the participation fee will be refunded to the team.
8. **2.8** All team members (including core roster, substitute player, replacement player(s)) may only belong to one team at a time and represent only themselves (i.e., it is forbidden to have someone else play on their behalf);
9. **2.9** All team members (including core roster, substitute player, replacement player(s)) must be at least 16 years old by the tournament registration date;
10. **2.10** No team member may be a citizen of the Russian Federation or the Republic of Belarus.
11. **2.11** Teams (including core roster, substitute player, replacement player(s)) are not allowed to:
1. **2.11.1** Use coaches;
2. **2.11.2** Display team sponsors during the tournament;
3. **2.11.3** Play in the interests of another team or team member;
12. **2.12** Team name:
1. **2.12.1** Must not be offensive, vulgar, political, or otherwise inappropriate;
2. **2.12.2** Must not contain emoticons or other symbols that are not letters;
3. **2.12.3** Must be changed upon request from the organizing team.
## 3. Equipment
1. **3.1** The organizing team provides participants with internet, ethernet cable, extension cords, and a seat with a table.
2. **3.2** Tournament participants are responsible for bringing and ensuring the functionality of all other necessary equipment for participation.
## 4. Schedule
1. **4.1** All team members must be present one hour before the scheduled tournament start.
2. **4.2** Teams competing in a match round must be ready at their designated locations 10 minutes before the round begins. It is the team captain's responsibility to ensure their team is in the right place at the right time and ready to start.
3. **4.3** Match round start times are announced by the organizing team at the tournament start or upon completion of the previous match round.
4. **4.4** If a player experiences technical issues with equipment or the game, they must immediately notify the match referee or organizing team.
5. **4.5** The organizing team has the right to make changes to the schedule.
6. **4.6** The organizing team is obligated to keep all participants informed of any delays and changes.
## 5. Game Version and Settings
1. **5.1** The latest version of CS2 will be used throughout the tournament. If the organizing team deems the latest available version unplayable due to bugs or other changes, an older version may be used (if possible).
2. **5.2** The following settings will be used in the CS2 tournament:
1. **5.2.1** Best of 24 (mp_maxrounds 24)
2. **5.2.2** Round time: 1 minute 55 seconds (mp_roundtime 1.92)
3. **5.2.3** Starting money: $800 (mp_startmoney 800)
4. **5.2.4** Freeze time at round start: 20 seconds (mp_freezetime 20)
5. **5.2.5** Buy time: 20 seconds (mp_buytime 20)
6. **5.2.6** Bomb timer: 40 seconds (mp_c4timer 40)
7. **5.2.7** Overtime rounds: best of six (6) (mp_overtime_maxrounds 6)
8. **5.2.8** Overtime starting money: $12,500 (mp_overtime_startmoney 12500)
9. **5.2.9** Round restart delay: 5 seconds (mp_round_restart_delay 5)
10. **5.2.10** Prohibited items: none (mp_items_prohibited "")
3. **5.3** Overtime: if there is a tie after all 24 rounds, overtime will be played as best of six. At the start of each overtime, teams remain on the side they played in the previous half - sides are switched at halftime. Teams continue overtime until a winner is found.
4. **5.4** Pause: each team is allowed to call a timeout for thirty (30) seconds up to three (3) times during regulation rounds. Pauses can be called by participants typing "!pause" in the in-game chat. Players are allowed to use all three pauses consecutively. The match referee can call a pause unilaterally if necessary.
5. **5.5** Technical pause: each team has the right to use a technical pause when necessary. To start a pause, the command ".tech" must be entered in the in-game chat. Technical pauses may only be used for valid reasons, when technical problems occur, and organizers must be immediately notified via Discord after starting the pause.
## 6. Map Selection
1. **6.1** The tournament takes place in two stages:
1. **6.1.1** Swiss system: initial phase played in Bo1 format. The top 16 teams advance to the playoffs.
2. **6.1.2** Playoffs: played in double elimination format, where winners' bracket matches are Bo3 and losers' bracket matches are Bo1.
2. **6.2** Maps are selected from the currently active Valve Active Duty Map Group.
3. **6.3** Best of 1 (Bo1): coin flip winner decides whether they are Team A or Team B. Team A starts and the process is as follows:
1. **6.3.1** Team A removes two maps.
2. **6.3.2** Team B removes three maps.
3. **6.3.3** Team A removes one map.
4. **6.3.4** The remaining map is played.
4. **6.4** Best of 3 (Bo3): coin flip winner decides whether they are Team A or Team B. Team A starts and the process is as follows:
1. **6.4.1** Team A removes one map.
2. **6.4.2** Team B removes one map.
3. **6.4.3** Team A picks one map.
4. **6.4.4** Team B picks one map.
5. **6.4.5** Team B removes one map.
6. **6.4.6** Team A removes one map.
7. **6.4.7** The remaining map is the decider if needed.
## 7. Prohibited Activities in CS2 Tournament
1. **7.1** Any form of cheating, including methods not mentioned here, is prohibited.
2. **7.2** The use of scripts is prohibited (except for weapon/grenade buying, jump throwing).
3. **7.3** Movement through walls, floors, and ceilings, including sky-walking, is prohibited.
4. **7.4** "Pixel walking" - standing, crouching, walking, and other activities on invisible map boundaries is prohibited.
5. **7.5** Bombs must be placed so they can be defused. This does not include situations where multiple players are needed to defuse the bomb.
6. **7.6** Players are not allowed to place an armed bomb where it cannot be defused, where it doesn't touch a solid object, or where it doesn't make the normal "beeping" sound.
7. **7.7** Players are not allowed to give items names (nametags) that violate what is stated in the TipiLAN house rules.
8. **7.8** Custom game files/data/drivers are not allowed.
9. **7.9** The use of character model skins (agent skins) is not allowed.
10. **7.10** Exploiting in-game bugs is prohibited.
11. **7.11** Any form of match fixing, influencing, fraud, and manipulation is strictly prohibited and means immediate team disqualification. Organizers have the right to notify Estonian law enforcement agencies in case of suspicion.
## 8. Penalties
1. **8.1** Violation of in-game and out-of-game rules (see section 7) and house rules (see TipiLAN house rules) is punishable.
2. **8.2** A team member who violates rules will first receive a first verbal warning. After a second/repeated violation, the team member receives a second verbal warning. On the third occasion, the team member is disqualified from the tournament. The team has the right to use their substitute player.
3. **8.3** A team member who fails to appear for the tournament or match round or leaves during the tournament without valid reason receives a tournament disqualification. The team has the right to use their substitute player.
4. **8.4** A team member who is not present and ready 10 minutes before their match round begins (no-show situation) receives a tournament disqualification, except if the team member is late for valid reasons and has notified the organizing team. The team has the right to use their substitute player.
5. **8.5** If the organizing team determines that a team member has violated section 7, the entire team is immediately disqualified from the tournament. The rule-violating team member receives a permanent ban from TipiLAN tournaments.
6. **8.6** If during match review the organizing team has reasonable suspicion that a team member has violated section 7, the entire team is immediately disqualified from the tournament. The rule-violating team member receives a permanent ban from TipiLAN tournaments.
7. **8.7** Teams have the right to withdraw from tournament participation, i.e., give themselves a disqualification.
8. **8.8** In case of team disqualification, the opposing team automatically wins the current match round.
9. **8.9** Only the team captain can dispute a team member/team disqualification. The dispute must be submitted to the organizing team within 15 minutes of the team being notified of the disqualification.
1. **8.9.1** The organizing team has up to 25 minutes to make a decision regarding the dispute. During this time, the match round is paused and teams may not leave their positions.
10. **8.10** Teams have the right to file a protest in situations where there is a problem that may affect or affects the match round or team:
1. **8.10.1** Protests may be filed with the organizing team within 5 minutes of discovering the problem.
2. **8.10.2** The organizing team has up to 25 minutes to make a decision regarding the protest. During this time, the match round is paused and teams may not leave their positions.
11. **8.11** The match referee notifies the rule-violating team member, their team, and the opposing team of the violation, its content, and consequences.
12. **8.12** The organizing team has the right to pause a match round and end the pause at any time as needed.
13. **8.13** The organizing team is obligated to publicly announce all eliminations and calculations and further changes.
## 9. Contact
For any event-related questions, problems, concerns, etc., contact the TipiLAN organizing team.
## 10. References
- https://pro.eslgaming.com/csgo/proleague/rules/
- https://github.com/ValveSoftware/counter-strike_rules_and_regs/blob/main/tournament-operation-requirements.md
- https://www.hltv.org/events/8037/iem-dallas-2025
- https://www.hltv.org
- https://liquipedia.net/counterstrike/Portal:Maps/CS2

View File

@@ -1,29 +0,0 @@
Event participation house rules apply to everyone, both visitors and competitors. In case of violation of house rules, TipiLAN reserves the right to remove the participant from the event and, if necessary, notify the police. In the case of an underage participant, we will notify their parents or guardians in case of serious house rule violations.
# Participant Reminders
1. Upon arrival, exchange your ticket for a wristband.
2. Participants must be at least 16 years old. Ticket controllers may ask you for identification.
3. The event will be photographed and filmed, and event content will be covered in various media channels.
4. If you have TipiLAN-provided accommodation, notify us when exchanging your ticket for a wristband.
5. If you bring your own computer, you will be guided on where to set it up when receiving your wristband.
# Event House Rules
1. Participants are obligated to behave politely and with dignity and respect other event participants.
2. TipiLAN does not tolerate:
2.1. Hate speech based on national, racial, gender, sexual or religious affiliation, disabilities, appearance or age; harassment, threatening, offensive or aggressive behavior, incitement or support thereof
2.2. This applies both on the event premises (IRL) and in online environments related to the event.
3. Participants are obligated to treat the event building, inventory and furnishings with care. It is forbidden to break, stain or move items that do not belong to the participant.
3.1. If a participant has organizer-provided accommodation, then in the accommodation area the participant is obligated to be quiet and allow companions to rest.
3.2. Persons who are not provided accommodation there may not be invited to the accommodation area.
4. TipiLAN is not responsible for participants' personal property.
4.1. The organizer-provided accommodation area is lockable and unauthorized persons are not allowed there, but regardless of this, it is worth keeping an eye on your valuables.
4.2. If there is suspicion that theft has occurred, this must be immediately reported to the organizer.
4.3. When finding lost items, please give them to the organizer or take them to *lost & found* (Merchandise table).
5. Smoking and using vapes is prohibited on the event premises. There are designated smoking areas outside for this purpose.
6. Illegal substances or drugs, sharp objects, firearms, explosives or incendiary materials and other items that may harm participants or others may not be brought to the event.
7. Underage participants are prohibited from consuming alcohol or using nicotine-containing products.
7.1. When purchasing alcohol from the bar, participants are obligated to show identification upon request by bar staff.
8. Participants are obligated to behave responsibly regarding alcohol.
9. Any form of gambling for money or other benefits is prohibited.

View File

@@ -1,131 +0,0 @@
## 1. General Information
1. **1.1** The League of Legends (hereinafter LoL) tournament takes place as a two-day event on **October 24-25, 2025** at Tallinn University of Technology (TalTech) premises, Ehitajate tee 5, Tallinn.
2. **1.2** The tournament prize pool is **3500€**, distributed as follows:
1. **1.2.1** First place team **300€** per participant
2. **1.2.2** Second place team **200€** per participant
3. **1.2.3** Third place team **100€** per participant
3. **1.3** Prize money will be paid to the participant's bank account
1. **1.3.1** For underage participants, the prize will be paid to their parent/guardian's bank account
## 2. Teams and Players
1. **2.1** A team must have:
1. **2.1.1** Five members (each member hereinafter referred to individually as *Player*)
2. **2.1.2** One of the members is the team Captain, who also serves as the spokesperson for the entire team
3. **2.1.3** All members must be at least 16 years old at the time of team registration
4. **2.1.4** Players cannot be citizens of the Russian Federation or the Republic of Belarus
5. **2.1.5** Teams are not allowed to use a coach during the tournament
6. **2.1.6** One team member may be substituted, who must also be registered and physically present
2. **2.2** Players must provide only truthful information about themselves and be prepared to prove their identity to the Organizer.
3. **2.3** Team name and logo as well as player gaming alias and avatar must be appropriate, including not being offensive, containing profanity, vulgarity, political or religious messages or symbols, or references to alcohol, drugs, and other intoxicants.
4. **2.4** A player represents only themselves throughout the tournament (i.e., no one else may compete in their place).
5. **2.5** All players under 18 years of age are prepared to provide parental/guardian consent for tournament participation to the Organizer.
6. **2.6** Players must be polite throughout the tournament and respect fellow competitors, organizers, and visitors. TipiLAN does not tolerate hate speech based on national, racial, gender, sexual, or religious affiliation, disabilities, appearance, or age; harassment, threatening, insulting, or aggressive behavior, incitement or support thereof. It is forbidden to disclose another participant's personal data (doxing) or threaten to do so. It is also forbidden to spam or overwhelm streams (hijacking) and incite such behavior. In such cases, the organizer may remove the player from the tournament.
7. **2.7** Teams must be registered on both the TipiLAN website and the **[challengermode](https://www.challengermode.com/s/TipiLAN/tournaments/4b7d832b-7cf3-406a-8425-08ddd311ac8a)** tournament page.
## 3. Pre-Game
1. **3.1** Tournament participation, matches, and tournament bracket all operate through the **[challengermode](https://www.challengermode.com/s/TipiLAN/tournaments/4b7d832b-7cf3-406a-8425-08ddd311ac8a)** environment:
1. **3.1.1** The entire team, including substitute players, must be registered for the tournament
2. **3.1.2** Players must have their highest-ranked account (including from other servers) and the account they will participate with in tournament games linked in challengermode (Challengermode allows linking two regions simultaneously)
3. **3.1.3** The tournament takes place on the **EU West** server
4. **3.1.4** Players may not use any account other than those linked in challengermode
2. **3.2** Matches in the challengermode environment are automatic. For a new match, there are **10 minutes** to be ready. Then there are another **10 minutes** to join the generated lobby.
3. **3.3** Draft can begin when both sides have indicated their readiness:
1. **3.3.1** Placeholders are not allowed. If a champion is locked in draft, it must be played
2. **3.3.2** Before draft, players must be in role-appropriate order: **Top Jungle Mid Bot Support**
3. **3.3.3** Using Drafter.lol for external drafting is allowed, but only with consent from both sides
4. **3.3.4** Intentional delays are not allowed
4. **3.4** Only players, official streamers, and referees may join the match lobby
## 4. In-Game Procedures
1. **4.1** A game is officially started (game of record (hereinafter GOR)) when all 10 players are on the map and the game has reached the first real interaction (see below). Once the game has reached GOR status, it cannot be restarted. Game score will be officially tracked from this moment. After reaching GOR status, the game can only be restarted in cases where completing the current game is not possible for a valid reason. GOR conditions are as follows:
1. **4.1.1** Either team successfully attacks or uses an ability against minions, jungle creeps, structures, or enemies
2. **4.1.2** Enemies see each other (exception - Clairvoyance does not guarantee GOR)
3. **4.1.3** Entering enemy territory
4. **4.1.4** The game has lasted 2 minutes
2. **4.2** Game pausing:
1. **4.2.1** Players are not allowed to leave the match area during game pause, except when officially authorized
2. **4.2.2** Organizers may pause the game as needed
3. **4.2.3** Either team has the right to take a total of up to **15 minutes** of pause during a match for valid reasons (disconnect, software or hardware problems, something happens to a player)
3. **4.3** The game may only be resumed with consent from both sides or with referee permission
4. **4.4** If an obstacle arises in fair game conduct (gamebreaking bug, network connection, etc.), the referee will determine new instructions for game conduct
## 5. Match Completion
1. **5.1** The match winner is the team that has won the most games (in case of multi-game matches)
1. **5.1.1** The organizer presents team win and loss standings to all participants in an accessible manner and informs all players where they can be viewed
2. **5.1.2** After each match, the tournament bracket is updated in the challengermode environment and new matches are assigned from there at the first opportunity
## 6. Tournament Elimination
1. **6.1** A team may decide at any time to stop participating in the tournament by notifying the referee and/or organizer.
2. **6.2** Penalties earned until elimination remain in effect until the end of the tournament.
3. **6.3** If a team does not show up or is not ready by the agreed start time, the Organizer may eliminate the team from the tournament to ensure schedule adherence.
4. **6.4** The list of registered team members cannot be changed during the tournament, even if a player has left.
1. **6.4.1** If a member's departure causes the number of team participants to fall below what is needed to play, the Organizer must eliminate the team from the tournament.
5. **6.5** If a team wishes to drop out or is eliminated from the tournament during a match, the team must give a forfeit victory.
6. **6.6** All eliminations and removals must be made publicly known immediately.
## 7. Penalties
1. **7.1** Referees assign penalties following the guidelines provided in this document.
2. **7.2** Only referees may assign penalties.
3. **7.3** The referee informs both the content of the violation and the assigned penalty to the rule-violating player, their team, and the opposing team. Additional information may be added as needed.
4. **7.4** The referee must first confirm the violation and only then assign the penalty. A penalty cannot have a suitable violation "invented" for it.
5. **7.5** The referee must be impartial; team skill level should not be decisive in monitoring violations and penalties.
6. **7.6** Penalties may be assigned to both the entire team and individual team members.
7. **7.7** The content of a penalty may be valid for either one game or the entire tournament participation period.
8. **7.8** A team cannot protest penalties assigned to the opposing team.
9. **7.9** Penalties may be as follows:
1. **7.9.1 WARNING:** a warning is a recorded notice to a player or team for a minor violation.
2. **7.9.2 BAN LOSS:** The team may not ban a certain number of characters in the game following the penalty. In this case, the referee monitors that the team does not select the penalty-specified number of bans and instead lets the timer run to zero.
3. **7.9.3 GAME LOSS:** The team receives an automatic loss in one game.
4. **7.9.4 MATCH LOSS:** The team receives an automatic match loss.
5. **7.9.5 DISQUALIFICATION:** Disqualification applies to the entire team. In this case, the team forfeits all victories. If disqualification results from escalating violations, the team gets the portion of victories they had earned by that point.
- In some cases, the referee is allowed to disqualify only one player instead of the team. This is the case when the player's violation does not affect the opposing team in any way and was done without involving anyone from their own team. Generally, this is possible when the player's violation falls under the category of Inappropriate Behavior - Serious Violation. In this case, the remaining team may continue in the tournament if a substitute player is available. Otherwise, the entire team must also be eliminated from the tournament.
10. **7.10** Penalty severity levels are as follows:
Warning - warning - loss of ban selection right - game loss - match loss - disqualification
11. **7.11** Tournament violations are divided as follows:
1. **7.11.1 EXTERNAL ASSISTANCE USE:** this violation is recorded when a team communicates during the game with anyone other than their own team and, as a result, in the referee's judgment, gains an advantage in the game. For this violation, it is assumed that it was not an intentional attempt to cheat. Intentionally seeking unfair advantage falls under the Inappropriate Behavior - Cheating category. The penalty is a warning.
2. **7.11.2 IGNORING INSTRUCTIONS:** Every player has an obligation to follow Organizer and referee instructions. Ignoring them may result in delays and disputes. The penalty is loss of first selection right.
12. **7.12** Inappropriate behavior.
Inappropriate behavior disrupts tournament flow and may negatively affect safety, competitive spirit, game enjoyment, or tournament fairness and integrity. Inappropriate behavior is not the same as competitive behavior.
Inappropriate behavior violations are divided into:
1. **7.12.1 MINOR VIOLATION:** this includes behavior that is unpleasant, unethical, or disruptive, such as excessive profanity; demanding that the opponent receive a penalty after the referee has made their decision; trash talking; littering, etc.
The penalty for minor violations is a warning.
2. **7.12.2 MODERATE VIOLATION:** this includes three different types of violations
- ignores referee or Organizer instructions meant specifically for one team or one player
- uses hate speech publicly against someone
- is aggressive or violent, but it is not directed at another person
The penalty for moderate violations is game loss.
3. **7.12.3 SERIOUS VIOLATION:** this includes behavior that is clearly contrary to tournament conduct rules and good practices, such as intentionally breaking tournament equipment or dirtying or damaging the room. The penalty for serious violations is disqualification, removal from tournament premises, or in extreme cases, the Organizer informs the police.
4. **7.12.4 MATCH FIXING:** match fixing is considered an agreement between two teams to play unfairly against other teams and attempt to influence tournament results.
The penalty for match fixing is disqualification of both teams.
5. **7.12.5 BRIBERY AND BETTING:** teams are forbidden from withdrawing from the tournament or attempting to change match results for any favor (not necessarily just monetary). It is also forbidden to offer referees incentives to influence game results. Betting on game results is also forbidden.
The penalty for bribery and betting is disqualification.
6. **7.12.6 AGGRESSIVE BEHAVIOR:** this includes all manifestations of aggression directed at people, including threats and obviously real violence.
The penalty is disqualification and removal from premises, in extreme cases the Organizer informs the police.
7. **7.12.7 THEFT:** although every participant has an obligation to keep an eye on their property, there is still an expectation that participant behavior is consistent with good practices.
The penalty for theft is disqualification and removal from premises. If the situation cannot be resolved on-site, the Organizer informs the police.
8. **7.12.8 ALCOHOL AND INTOXICATION:** Alcohol consumption within the event is forbidden. In case of excessive intoxication, the Organizer has the right to remove the participant from the premises.
- if the intoxicated participant is underage, their parents and police will be informed.
9. **7.12.9 CHEATING:** this includes intentional player action to gain an advantage in the game. Cheating does not have to be successful to result in penalty.
Activities that fall under cheating include, for example:
- attempting to view one's own game in spectator mode or receiving information from someone who can watch the game in spectator mode
- any attempt to modify the game itself or use additional software not normally included with the game, including changing in-game zoom, using UI modifications that change attack range or make tower firing range visible, using spawn timers, etc. VOIP program use does not fall under this category.
- appearing as another player or under a false name, playing using another player's summoner name, or account sharing
- intentionally breaking or attempting to modify equipment to cause delays, get pauses, or otherwise influence game flow
- intentional exploitation of in-game bugs to gain advantage, i.e., using various glitches to one's benefit.
The penalty for cheating is disqualification.
## 8. Tournament Format
1. **8.1** The tournament operates on Fearless Draft principles. This means that champions picked during a series cannot be picked in subsequent games until the series ends.
2. **8.2** The tournament operates in Round Robin + Single Elimination format
This means the first round consists of 2 groups of 6 teams each, where all teams play each other once. This determines the 4 best teams, who advance to the next day's single elimination bracket.
1. **8.2.1** In case of a tie in groups, the team that won the match between the tied teams advances.
**Challengermode link:** https://www.challengermode.com/s/TipiLAN/tournaments/4b7d832b-7cf3-406a-8425-08ddd311ac8a

View File

@@ -1,141 +0,0 @@
## 1. Üldist
1. **1.1** Counter-Strike 2 (edaspidi CS2) turniir toimub 24.-26. Oktoober, 2025 Tallinna Tehnikaülikooli (TalTech) ruumides, Ehitajate tee 5, Tallinn.
2. **1.2** Turniiri auhinnafondiks on 5750€, mis jaguneb järgnevalt:
1. **1.2.1** TOP 3 auhinna found on 5000€:
- **1.2.1.1** Esimese koha saanud võistkond - 500€ võistleja kohta
- **1.2.1.2** Teise koha saanud võistkond - 300€ võistleja kohta
- **1.2.1.3** Kolmanda koha saanud võistkond - 200€ võistleja kohta
2. **1.2.2** LCQ auhinna fond on 750€:
- **1.2.2.1** Esimese koha saanud võistkond - 100€ võistleja kohta
- **1.2.2.2** Teise koha saanud võistkond - 50€ võistleja kohta
3. **1.3** Võidusumma makstakse välja võistleja pangakontole.
1. **1.3.1** Alaealise võistleja puhul makstakse võit vanema/eestkostja pangakontole.
4. **1.4** Terve turniiri vältel tuleb igal osalejal lähtuda Eesti Vabariigi seadusest, TipiLAN kodukorrast ja ürituse reeglistikust.
5. **1.5** Piletiostuga annab iga osaleja loa end pildistada, filmida ja kasutada kogu fotograafilist, audio- ja videomaterjali ürituse jäädvustamiseks ja turundamiseks.
6. **1.6** CS2 põhiturniiri salvestatakse ning kantakse üle voogedastusplatvormidel Twitch ja YouTube.
7. **1.7** Kõik turniiriga seotud suhtlused tiimiliikmete vahel (nt mängusisene chat, häälvestlused, Discordi vestlused jne) salvestatakse.
8. **1.8** Ise mängu üle kandmise puhul peab otseülekande viivis (delay) olema vähemalt 5 minutit.
9. **1.9** Korraldajatel on õigus kasutada osalejate isiklikku informatsiooni vaid ürituse läbiviimise raames.
10. **1.10** Korraldustiimil on õigus reegleid vajadusel muuta ja redigeerida etteteatamata.
11. **1.11** Korraldustiim on kõikide osalejate suhtes erapooletud.
## 2. CS2 põhiturniiri tiim ja tiimi koosseis
1. **2.1** CS2 põhiturniiri tiim (edaspidi tiim) põhikoosseisu kuulub viis põhiliiget, kellest üks on tiimikapten;
2. **2.2** Tiimikapten on tiimi esindaja, kes:
1. **2.2.1** On kontaktisikuks korraldustiimile;
2. **2.2.2** Registreerib tiimi turniirile;
3. **2.2.3** Vastutab tiimi käitumise ja tegude eest;
4. **2.2.4** Esindab tiimi hoiatuste, diskvalifikatsioonide, vaidlustuste ja timeout-ide korral.
3. **2.3** Igal tiimil võib olla üks varumängija, kes ei kuulu tiimi põhikoosseisu:
1. **2.3.1** Varumängija peab soetama endale eraldi varumängija pileti;
2. **2.3.2** Varumängija võib asendada ükskõik millist tiimi põhikoosseisu liiget turniiri toimumisel ajal;
3. **2.3.3** Varumängijale kehtivad samad õigused ja nõuded, mis tiimi põhikoosseisule.
4. **2.4** Tiimi põhikoosseisus on lubatud välja vahetada mängijaid enne tiimide registreerimiskuupäeva lõppemist:
1. **2.4.1** Mängijate väljavahetamine toimub läbi Fienta;
2. **2.4.2** Liikme välja vahetamisel jääb tiimil jätkuvalt õigus varumängijale;
3. **2.4.3** Asendusmängijale kehtivad samad õigused ja nõuded, mis tiimi põhikoosseisule.
5. **2.5** Tiimikapteni väljavahetamisel (nt varumängija või asendusmängija) otsustab tiim ise, kellele tiimikapteni õigused ja kohustused tiimisiseselt üle kanduvad;
6. **2.6** Tiim peab 2 nädalat enne turniiri toimumist kinnitama oma osaluse, lõpliku tiimi põhikoosseisu ja tiimi nime tiimikaptenile saadetud emailis. Juhul, kui põhiliige langeb tiimi põhikoosseisut välja pärast seda, kui tiimi lõplik koosseis on kinnitatud, peab tiim rakendama oma varumängijat.
7. **2.7** Kui tiim astub turniiril osalemisest tagasi enne registreerimistähtaega, makstakse osalemistasu tiimile tagasi.
8. **2.8** Kõik tiimiliikmed (k.a. põhikoosseis, varumängija, asendusmängija(d)) võivad turniiril kuulda vaid ühte tiimi korraga ja esindada vaid iseennast (st keelatud on lasta kellelgi teisel enda eest mängida);
9. **2.9** Kõik tiimiliikmed (k.a. põhikoosseis, varumängija, asendusmängija(d)) peavad turniirile registreerimise päevaks olema vähemalt 16-aastased;
10. **2.10** Mitte ükski tiimiliige ei tohi olla Venemaa Föderatsiooni ega Valgevene Rahvavabariigi kodanik.
11. **2.11** Tiimidel (k.a. põhikoosseis, varumängija, asendusmängija(d)) pole lubatud:
1. **2.11.1** Treenerite kasutamine;
2. **2.11.2** Tiimisponsorite kajastamine turniiril;
3. **2.11.3** Mängida teise tiimi või tiimiliikme huvides;
12. **2.12** Tiimi nimi:
1. **2.12.1** Ei tohi olla solvav, vulgaarne, poliitiline või muud moodi maitsetu;
2. **2.12.2** Ei tohi sisaldada emotikone ega muid sümboleid, mis pole tähemärgid;
3. **2.12.3** Tuleb korraldustiimi poolsel nõudel ära muuta.
## 3. Varustus
1. **3.1** Korraldustiimi poolt on turniiril osalejale tagatud internet, internetikaabel, pikendusjuhtmed ja istekoht lauaga.
2. **3.2** Turniiril osaleja vastutab selle eest, et temal on osalemiseks muu vajalik varustus kaasas ja töötab.
## 4. Ajakava
1. **4.1** Kõik tiimiliikmed peavad kohal olema tund aega enne ettenähtud turniiri algust.
2. **4.2** Mänguvoorus võistlevad tiimid peavad 10 minutit enne vooru algust olema valmis oma ettenähtud kohtadel. Tiimikapteni vastutus on tagada, et tema tiim on õigel ajal õiges kohas ja valmis alustama.
3. **4.3** Mänguvoorude algusajad on korraldustiimi välja pandud turniiri alguseks või eelmise mänguvooru lõpuks.
4. **4.4** Kui mängijal esinevad tehnika või mänguga seotud tehnilised probleemid, peab ta sellest koheselt teavitama mänguvana või korraldustiimi.
5. **4.5** Korraldustiimil on õigus teha ajakavas muudatusi.
6. **4.6** Korraldustiimil on kohustus hoida kõiki osalejaid kursis tekkinud viivituste ja muudatusega.
## 5. Mängu versioon ja seaded
1. **5.1** Terve turniiri jooksul kasutatakse CS2 kõige uuemat versiooni. Kui viimast saadaolevat versiooni peab korraldustiim mängitamatuks vigade või muude muutuste tõttu, võidakse kasutada vanemat versiooni (juhul, kui see on võimalik).
2. **5.2** CS2 turniiril kasutatakse järgnevaid seadeid:
1. **5.2.1** Parim 24st (mp_maxrounds 24)
2. **5.2.2** Raundi aeg: 1 minut 55 sekundit (mp_roundtime 1.92)
3. **5.2.3** Alustusraha: $ 800 (mp_startmoney 800)
4. **5.2.4** Liikumise keelu aeg raundi alguses: 20 sekundit (mp_freezetime 20)
5. **5.2.5** Aeg ostmiseks: 20 sekundit (mp_buytime 20)
6. **5.2.6** Pommi taimer: 40 sekundit (mp_c4timer 40)
7. **5.2.7** Lisaajal raunde: parim kuuest (6) (mp_overtime_maxrounds 6)
8. **5.2.8** Lisaaja alustusraha: $12,500 (mp_overtime_startmoney 12500)
9. **5.2.9** Raundi alguse viivis: 5 sekundit (mp_round_restart_delay 5)
10. **5.2.10** Keelatud esemed: ei ole(mp_items_prohibited "")
3. **5.3** Lisaaeg: juhul, kui pärast kõigi 24 raundi mängimist on viik, mängitakse lisaaega parim kuuest. Iga lisaaja alguses jäävad võistkonnad poolele, millelt nad eelmisel poolel mängisid - poolajal pooli vahetatakse. Võistkonnad jätkavad lisaaegu, kuni võitja on leitud.
4. **5.4** Paus: iga tiimil on lubatud kutsuda esile paus (timeout) kolmkümmend (30) sekundit kuni kolm (3) korda regulatsiooniraundide ajal. Pausi saavad kutsuda osalejad kirjutades mängusisesesse chatti “!pause”. Võistlejatel on lubatud võtta kõik kolm pausi korraga. Vajadusel saab mänguvana pausi esile kutsuda ühepoolselt.
5. **5.5** Tehniline paus: igal tiimil on vajadusel õigus kasutada tehnilist pausi. Pausi alustamiseks tuleb mängusisesesse chatti sisestada käsklus “.tech”. Tehnilist pausi tohib kasutada üksnes mõjuval põhjusel, tehniliste probleemide esinemisel, ning selle põhjusest tuleb korraldajaid viivitamatult teavitada Discordi kaudu pärast pausi alustamist.
## 6. Kaardivalik
1. **6.1** Turniir toimub kahes etapis:
1. **6.1.1** Swiss-süsteem: algfaasis mängitakse Bo1 formaadis. 16 parimat meeskonda pääsevad playoffidele.
2. **6.1.2** Playoffid: mängitakse double elimination formaadis, kus winners' bracket mängud on Bo3 ja losers' bracket mängud on Bo1.
2. **6.2** Mängitav kaart valitakse välja hetkel aktiivsete Valvei kaardigrupi (Valve Active Duty Map Group) kaartidest.
3. **6.3** Parim ühest (Bo1): mündiviske võitja otsustab, kas nad on võistkond A või võistkond B. Võistkond A alustab ning protsess on järgmine:
1. **6.3.1** Võistkond A eemaldab kaks kaarti.
2. **6.3.2** Võistkond B eemaldab kolm kaarti.
3. **6.3.3** Võistkond A eemaldab ühe kaardi.
4. **6.3.4** Järelejäänud kaarti mängitakse.
4. **6.4** Parim kolmest (Bo3): mündiviske võitja otsustab, kas nad on võistkond A või võistkond B. Võistkond A alustab ning protsess näeb välja järgmine:
1. **6.4.1** Võistkond A eemaldab ühe kaardi.
2. **6.4.2** Võistkond B eemaldab ühe kaardi.
3. **6.4.3** Võistkond A valib ühe kaardi.
4. **6.4.4** Võistkond B valib ühe kaardi.
5. **6.4.5** Võistkond B eemaldab ühe kaardi.
6. **6.4.6** Võistkond A eemaldab ühe kaardi.
7. **6.4.7** Järelejäänud kaart on vajadusel otsustav.
## 7. CS2 turniiril keelatud tegevused
1. **7.1** Igasugune sohitegemine, sealhulgas meetodid mis siin ei ole mainitud, on keelatud.
2. **7.2** Skriptide kasutamine on keelatud (v.a. relvade/granaatide ostmine, hüppeviskamine).
3. **7.3** Liikumine läbi seinte, põrandate ja katuste, k.a. taevas jalutamine (sky-walking) on keelatud.
4. **7.4** „Piksel-jalutamine” (Pixel walking) ehk seismine, kükitamine, kõndimine ja muud tegevused nähtamatutel kaardipiiridel on keelatud.
5. **7.5** Pomme tuleb asetada nii, et neid saaks desarmeerida. See ei hõlma olukordi, kus pommi desarmeerimiseks on vaja mitut mängijat.
6. **7.6** Mängijatel ei ole lubatud armeeritud pommi asetada kohta, kus seda ei saa desarmeerida, kohta, kus see ei puutu kokku tahke objektiga või kus see ei tee tavalist „piiksuvat” heli.
7. **7.7** Mängijatel ei ole lubatud panna esemetele nimesid (nametags), mis rikuvad TipiLAN kodukorras väljatoodut.
8. **7.8** Kohandatud mängufailid/andmed/draiverid ei ole lubatud.
9. **7.9** Mängukarakteri mudelite (agent skins) kasutamine ei ole lubatud.
10. **7.10** Mängusiseste vigade ära kasutamine on keelatud.
11. **7.11** Igasugune tulemuste kokkuleppimine, mõjutamine, pettus ja manipuleerimine on rangelt keelatud ning tähendab kohest tiimi diskvalifitseerimist. Korraldajatel on õigus kahtluse korral teavitada Eesti Vabariigi korrakaitseorganeid.
## 8. Karistused
1. **8.1** Karistatav on mängusiseste ja mänguväliste reeglite (vt punkti 7) ning kodukorra rikkumine (vaata TipiLAN kodukord).
2. **8.2** Reegleid rikkunud tiimiliikmele tehakse esmalt esimene verbaalne hoiatus. Teise/korduva rikkumisel järel tehakse tiimiliikmele teine verbaalne hoiatus. Kolmandal korral saab tiimiliige turniiril diskvalifikatsiooni. Tiimil on õigus rakendada varumängijat.
3. **8.3** Tiimiliige, kes ei ilmu turniiriks ega mänguvooruks kohale või lahkub turniiri ajal mõjuva põhjuseta, saab turniirilt diskvalifikatsiooni. Tiimil on õigus rakendada varumängijat.
4. **8.4** Tiimiliige, kes pole 10 minutit enne oma mänguvooru algust kohal ja valmis (no-show olukord), saab turniirilt diskvalifikatsiooni, v.a. juhul, kui tiimiliige hilineb mõjuvatel põhjustel ja on sellest teavitanud korraldustiimi. Tiimil on õigus rakendada varumängijat.
5. **8.5** Kui korraldustiim tuvastab, et tiimiliige on eksinud punktis 7 väljatoodu osas, siis diskvalifitseeritakse kogu tiimi turniirilt koheselt. Reegleid rikkunud tiimiliikmele antakse TipiLAN turniiridelt igavene mängukeeld.
6. **8.6** Kui mängukorduse läbivaatamisel tekib korraldustiimil põhjendatud kahtlus, et tiimiliige on eksinud punktis 7 väljatoodu osas, siis diskvalifitseeritakse kogu tiimi turniirilt koheselt. Reegleid rikkunud tiimiliikmele antakse TipiLAN turniiridelt igavene mängukeeld.
7. **8.7** Tiimil endal on õigus astuda turniiril osalemisest tagasi ehk anda iseendale diskvalifikatsioon.
8. **8.8** Tiimi diskvalifitseerimise korral võidab vastastiim automaatselt käesoleva mänguvooru.
9. **8.9** Ainult tiimikapten saab tiimiliikme/tiimi diskvalifitseerimist vaidlustada. Vaidlustus tuleb korraldustiimile esitada 15 minuti jooksul alates diskvalifikatsiooni teavitamisest tiimile.
1. **8.9.1** Korraldustiimil on aega kuni 25 minutit, et teha otsus vaidlustuse osas. Sellel ajal on mänguvoor pausil ning tiimid ei tohi oma kohtadelt lahkuda.
10. **8.10** Tiimidel on õigus esitada protest olukorras, kus esineb probleem, mis võib mõjutada või mõjutab mänguvooru või tiimi:
1. **8.10.1** Protesti on õigus esitada korraldustiimile 5 minuti jooksul alates probleemi avastamisest.
2. **8.10.2** Korraldustiimil on aega kuni 25 minutit, et teha otsus protesti osas. Sellel ajal on mänguvoor pausil ning tiimid ei tohi oma kohtadelt lahkuda.
11. **8.11** Mänguvana teavitab eksimusest, selle sisust ja tagajärjest reegleid rikkunud tiimiliiget, tema tiimi ja vastastiimi.
12. **8.12** Korraldustiimil on õigus panna mänguvoor pausile ja lõpetada paus ükskõik millise hetkel vastavalt vajadusele.
13. **8.13** Korraldustiimil on kohustus kõikidest väljalangemistest ja -arvamistest ning edasistest muudatustest avalikult teada anda.
## 9. Kontakt
Igasuguste üritust puudutavate küsimuste, probleemide, murede jne korral võtta ühendust TipiLAN korraldustiimiga:
(kontaktid)
## 10. Viited
- https://pro.eslgaming.com/csgo/proleague/rules/
- https://github.com/ValveSoftware/counter-strike_rules_and_regs/blob/main/tournament-operation-requirements.md
- https://www.hltv.org/events/8037/iem-dallas-2025
- https://www.hltv.org
- https://liquipedia.net/counterstrike/Portal:Maps/CS2

View File

@@ -1,30 +0,0 @@
Üritusel osalemise kodukord kehtib kõigile, nii külastajatele kui võistlejatele. Kodukorra rikkumisel jätab TipiLAN endale õiguse osaleja ürituselt eemaldada ning vajadusel teavitada politseid. Alaealise osaleja puhul teavitame raskema kodukorra eiramise puhul tema vanemaid või eestkostjaid.
# Osaleja meelespea
1. Tulles vaheta oma pilet käepaela vastu.
2. Osaleja peab olema vähemalt 16-aastane. Pileti kontrollija võib küsida Sinult dokumenti.
3. Üritust pildistatakse ning filmitakse ja ürituse sisu kajastatakse erinevates meediakanalites.
4. Kui Sul on ette nähtud TipiLANi poolne majutus, anna sellest teada pileti käepaela vastu vahetamisel.
5. Kui tuled oma arvutiga, juhendatakse Sind käepaela saades, kuhu saad selle üles panna.
# Ürituse kodukord
1. Osaleja kohustub käituma viisakalt ning väärikalt ja austama teisi üritusel osalejaid.
2. TipiLAN ei tolereeri:
2.1. Vihakõnet rahvusliku, rassilise, soolise, seksuaalse või religioosse kuuluvuse, puude, välimuse või vanuse kohta; ahistamist, ähvardavat, solvavat või agressiivset käitumist, sellele õhutamist või selle pooldamist
2.2. See kehtib nii ürituse alal (IRL) kui ka üritusega seotud online-keskkondades.
3. Osaleja kohustub käituma ürituse hoone, inventari ja sisustuse suhtes heaperemehelikult. Keelatud on lõhkuda, määrida või viia mujale esemeid, mis ei kuulu osalejale.
3.1. Kui osalejal on ette nähtud korraldajapoolne majutus, siis majutusalal on osalejal kohustus olla vaikselt ning lubada kaaslastel puhata.
3.2. Majutusalale ei või kaasa kutsuda isikuid, kellel ei ole seal majutust ette nähtud.
4. TipiLAN ei vastuta osaleja isikliku vara eest.
4.1. Korraldajapoolne majutusala on lukustatav ning kõrvalisi isikuid sinna ei lubata, kuid sellest sõltumata tasub oma väärisesemetel silma peal hoida.
4.2. Kui on tekkinud kahtlus, et on toimunud vargus, tuleb sellest koheselt teavitada korraldajat.
4.3. Kaotatud asjade leidmisel palume anda need korraldajale või viia need *lost & foundi* (Merchilauda).
5. Ürituse alal on keelatud suitsetada ning kasutada vapei. Selleks on õues ette nähtud suitsetamise kohad.
6. Üritusele ei tohi kaasa võtta illegaalseid aineid või ravimeid, terariistu, tulirelvi, lõhke- või süüteained ning muid esemeid, mis võivad osalejatele või teistele viga teha.
7. Alaealisel osalejal on keelatud tarbida alkoholi või kasutada nikotiini sisaldavaid tooteid.
7.1. Olles baarist alkoholi ostmas, on osalejal baaritöötaja nõudmisel kohustus näidata isikuttõendavat dokumenti.
8. Osaleja kohustub käituma alkoholi suhtes vastutustundlikult.
9. Keelatud on igasugune hasartmäng nii raha kui muude hüvede peale.

View File

@@ -1,129 +0,0 @@
## 1. Üldist
1. **1.1** League of Legends (edaspidi LoL) turniir toimub kahepäevase üritusena **24.25. oktoober 2025** Tallinna Tehnikaülikooli (TalTech) ruumides, Ehitajate tee 5, Tallinn.
2. **1.2** Turniiri auhinnafondiks on **3500€**, mis jaguneb järgnevalt:
1. **1.2.1** Esimese koha saanud võistkond **300€** võistleja kohta
2. **1.2.2** Teise koha saanud võistkond **200€** võistleja kohta
3. **1.2.3** Kolmanda koha saanud võistkond **100€** võistleja kohta
3. **1.3** Võidusumma makstakse välja võistleja pangakontole
1. **1.3.1** Alaealise võistleja puhul makstakse võit vanema/eestkostja pangakontole
## 2. Võistkonnad ja võistlejad
1. **2.1** Võistkonnas peab olema:
1. **2.1.1** Viis liiget (iga liige edaspidi eraldi kui *Võistleja*)
2. **2.1.2** Liikmetest üks on võistkonna Kapten, kes on ühtlasi kogu meeskonna eestkõnelejaks
3. **2.1.3** Kõik liikmed peavad olema võistkonna registreerumise hetkel vähemalt 16 aastat vanad
4. **2.1.4** Võistleja ei või olla Venemaa Föderatsiooni ega Valgevene Rahvavabariigi kodanik
5. **2.1.5** Võistkonnal pole lubatud kasutada turniiri jooksul treenerit
6. **2.1.6** Lubatud välja vahetada üks võistkonna liige, kes peab samuti olema registreeritud ja füüsiliselt kohal
2. **2.2** Võistleja peab esitama enda kohta ainult tõest informatsiooni ning valmis Korraldajale tõendama enda isikut.
3. **2.3** Võistkonna nimi ja logo ning Võistleja arvutimängu alias ja avatar peab olema sünnis, sh ei tohi olla kohatud, sisaldada roppusi, vulgaarsusi, poliitilisi või religioosseid sõnumeid ega sümboleid või viiteid alkoholile, narkootikumidele ning muudele mõnuainetele.
4. **2.4** Võistleja esindab terve Turniiri vältel ainult iseennast (st. enda asemel ei või lasta kellelgi teisel võistelda).
5. **2.5** Kõik alla 18-aastased Võistlejad on valmis Korraldajale esitama eestkostja nõusoleku turniiril osalemiseks.
6. **2.6** Võistleja peab olema kogu turniiri vältel viisakas ning austama kaasvõistlejaid, korraldajaid ning külastajaid, TipiLAN ei tolereeri vihakõne rahvusliku, rassilise, soolise, seksuaalse või religioosse kuuluvuse, puude, välimuse või vanuse kohta; ahistamist, ähvardavat, solvavat või agressiivset käitumist, sellele õhutamist või selle pooldamist. Keelatud on avalikustada teise osaleja isikuandmeid (doxing) või selle tegemisega ähvardada. Samuti on keelatud striimi spämmimine või ülekoormamine (hijacking) ja sellele õhutamine. Sel juhul võib korraldaja eemaldada võistleja turniirilt.
7. **2.7** Võistkond peab olema registreeritud nii TipiLAN lehel kui ka **[challengermode](https://www.challengermode.com/s/TipiLAN/tournaments/4b7d832b-7cf3-406a-8425-08ddd311ac8a)** turniirilehel.
## 3. Mängule eelnev
1. **3.1** Turniiril osalemine, matchid ning turniiripuu toimib kõik läbi **[challengermode](https://www.challengermode.com/s/TipiLAN/tournaments/4b7d832b-7cf3-406a-8425-08ddd311ac8a)** keskkonna:
1. **3.1.1** Turniirile peab olema registreeritud kogu meeskond, kaasa arvatud varumängija
2. **3.1.2** Mängijatel peab challengermodes olema linkitud kõige kõrgema rankiga kasutaja (ka teistest serveritest) ja kasutaja, millega osaletakse turniirimängudes (Challengermode lubab kahte regiooni korraga linkida)
3. **3.1.3** Turniir toimub **EU West** serveris
4. **3.1.4** Mängijad ei tohi kasutada ühtegi teist kasutajat peale challengermodes linkitud kasutajate
2. **3.2** Challengermode keskkonnas on matchid automaatsed. Uue matchi puhul on valmisolekuks aega **10 minutit**. Seejärel genereeritud lobby'sse jõudmiseks on uuesti aega **10 minutit**.
3. **3.3** Draft saab alata kui mõlemad pooled on andnud enda valmisolekust märku:
1. **3.3.1** Placeholderid ei ole lubatud. Kui champion on draftis lukustatud, peab seda ka mängima
2. **3.3.2** Enne drafti peavad mängijad olema rollidele vastavas järjekorras: **Top Jungle Mid Bot Support**
3. **3.3.3** Lubatud on Drafter.lol kasutamine väliseks draftimiseks, kuid seda ainult mõlema poole nõusolekul
4. **3.3.4** Sihilikult viivitamine ei ole lubatud
4. **3.4** Matchi lobbysse tohivad lisaks mängijatele liituda ainult ametlikuks streaerid ja kohtunikud
## 4. Mängusisesed protseduurid
1. **4.1** Mäng on ametlikult alanud (game of record (edaspidi GOR)) kui kõik 10 mängijat on kaardil ning mäng on jõudnud esimese reaalse interaktsioonini (vt. allpool). Hetkel kui mäng on jõudnud GOR staatuseni, ei või seda uuesti alustada. Mängu skoori hakatakse sellest hetkest ametlikult jälgima. Peale GOR staatuseni jõudmist on võimalik mängu restartida vaid juhtudel, kui käesoleva mängu lõpuni viimine ei osutu mõjuval põhjusel võimalikuks. GOR'i tingimused on järgnevad:
1. **4.1.1** Kummalgi tiimil õnnestub rünnak või võime (ability) kasutamine käsilaste, jungle creep'ide, ehitiste või vastaste vastu
2. **4.1.2** Vastased näevad teineteist (erandina - Clairvoyance ei taga GOR'i)
3. **4.1.3** Sisenetakse vastase territooriumile
4. **4.1.4** Mäng on kestnud 2 minutit
2. **4.2** Mängu seiskamine:
1. **4.2.1** Mängu pausile panemise ajal ei ole mängijatel lubatud lahkuda matši alalt, v.a juhul kui see on ametlikult autoriseeritud
2. **4.2.2** Korraldajad võivad mängu pausile panna vastavalt vajadusele
3. **4.2.3** Kummalgi tiimil on õigus matši jooksul võtta kokku kuni **15 minutit pausi** mõjuval põhjusel (disconnect, tark- või riistvara probleemid, mängija endaga juhtub midagi)
3. **4.3** Mängu võib uuesti käima panna ainult mõlema poole nõusolekul või kohtuniku loal
4. **4.4** Kui tekib mängu ausal läbiviimisel takistus (gamebreaking bug, netiühendus, etc.), määrab kohtunik uued juhised mängu läbiviimiseks
## 5. Match'i lõpetamine
1. **5.1** Match'i võitja on tiim, kellel on kõige rohkem mänge võidetud (mitmemängulise match'i puhul)
1. **5.1.1** Korraldaja esitab tiimide võitude ja kaotuste seisud kõigile osalejatele kättesaadaval viisil ning teavitab kõiki mängijaid sellest, kus on neid võimalik näha
2. **5.1.2** Peale igat match'i uuendatakse challengermode keskkonnas turniiripuud ning esimesel võimalusel määratakse sealtkaudu uued match'id
## 6. Turniirilt välja langemine
1. **6.1** Tiim võib igal ajal otsustada lõpetada turniiril osalemise andes sellest teada kohtunikule ja/või korraldajale.
2. **6.2** Kuni välja kukkumiseni teenitud karistused jäävad kehtima turniiri lõpuni.
3. **6.3** Kui tiim ei ilmu kohale või pole kokku lepitud algusajaks valmis, võib Korraldaja arvata tiimi turniirilt välja, et tagada graafikust kinni pidamine.
4. **6.4** Tiimi registreerunute nimekirja ei saa muuta turniiri vältel, isegi kui mängija on lahkunud.
1. **6.4.1** Kui liikme lahkumise tõttu langeb tiimis osalejate arv alla mängimiseks vajaliku, peab Korraldaja tiimi turniirilt välja arvama.
5. **6.5** Kui tiim soovib välja langeda või tiim arvatakse turniirilt välja match'i toimumise ajal, peab tiim andma loobumisvõidu.
6. **6.6** Kõik välja langemised ja välja arvamised tuleb teha koheselt avalikult teatavaks.
## 7. Karistused
1. **7.1** Kohtunikud määravad karistusi järgides selles dokumendis toodud juhiseid.
2. **7.2** Karistusi võivad määrata ainult kohtunikud.
3. **7.3** Kohtunik teavitab nii eksimuse sisu kui ka määratud karistuse nii reeglite vastu eksinud mängijale, tema tiimile kui ka vastasvõistkonnale. Vajaduse korral võib lisada ka muud infot.
4. **7.4** Kohtunik peab enne kinnitama eksimuse ning alles siis määrama karistuse. Karistusele ei tohi "välja mõelda" sobivat eksimust.
5. **7.5** Kohtunik peab olema erapooletu, tiimi oskuste tase ei tohi olla määravaks eksimuste ja karistuste jälgimisel.
6. **7.6** Karistusi võib määrata nii kogu tiimile kui ka ühele tiimiliikmele.
7. **7.7** Karistuse sisu võib olla kehtiv nii ühele mängule kui ka tervele turniiril osalemise ajale.
8. **7.8** Tiim ei saa protesteerida vastastiimile määratud karistuse vastu.
9. **7.9** Karistused võivad olla järgnevad:
1. **7.9.1 HOIATUS:** hoiatus on kirja pandud märguanne mängijale või tiimile väikese eksimuse eest.
2. **7.9.2 BAN'i KAOTUS:** Tiim ei või karistusele järgneval mängul ban'ida kindel arv tegelasi. Sel juhul kohtunik jälgib, et tiim ei valiks karistusena määratud arvu ban'e ning laseks selle asemel taimeril nulli joosta.
3. **7.9.3 MÄNGU KAOTUS:** Tiim saab automaatse kaotuse ühel mängul.
4. **7.9.4 MATCH'I KAOTUS:** Tiim saab automaatse match'i kaotuse.
5. **7.9.5 DISKVALIFITSEERIMINE:** Diskvalifikatsioon kehtib tervele tiimile. Sellel juhul loobub tiim kõigist võitudest. Kui disvalifikatsioon on saadud eskaleeruvate eksimuste tulemusel, saab tiim selle osa võitudest, mis neil oli selleks hetkeks välja teenitud.
- Mõningatel juhtudel on kohtunikul lubatud diskvalifitseerida ainult üks mängija tiimi asemel. See on sel juhul, kui mängija eksimus ei mõjuta mingil viisil vastasmeeskonda ning on tehtud kaasamata kedagi ka oma tiimist. Üldiselt on see võimalik juhul kui mängija eksimus kuulub kategooriasse Mittesobilik käitumine - Raske eksimus. Sel juhul võib ülejäänud tiim turniiril jätkata varumängija olemasolul. Vastasel korral peab ka kogu tiim turniirilt välja langema.
10. **7.10** Karistuse raskusastmed on järgnevad:
Hoiatus - hoiatus - ban'i valimise õiguse kaotus - mängu kaotus - match'i kaotus - diskvalifikatsioon
11. **7.11** Turniiri eksimused jagunevad järgmiselt:
1. **7.11.1 VÄLISE ABI KASUTAMINE:** see eksimus läheb kirja, kui tiim suhtleb mängu ajal ükskõik kelle muuga peale omaenda tiimi ning selle tagajärjel, kohtuniku otsustusel, saab mängus eelise. Eksimuse puhul eeldatakse, et tegu ei olnud tahtliku kavatsusega sohki teha. Tahtlikult ebaõiglase eelise otsimine läheb punkti Mittesobilik käitumine - Sohk alla. Karistuseks on hoiatus.
2. **7.11.2 JUHISTE EIRAMINE:** Igal mängijal on kohustus järgida Korraldaja ja kohtunike juhiseid. Nende eiramine võib endaga kaasa tuua viivitusi ning vaidlusi. Karistuseks on esimese valiku tegemise kaotus.
12. **7.12** Mittesobilik käitumine.
Mittesobilik käitumine on turniiri käiku häiriv ning võib negatiivselt mõjutada turvalisust, võistlushimu, mängurõõmu või turniiri ausameelsust ning terviklikkust. Mittesobilik käitumine ei ole sama, mis konkurentsihimuline käitumine.
Mittesobiliku käitumise eksimused jagunevad:
1. **7.12.1 KERGE EKSIMUS:** siia kuulub käitumine, mis on ebameeldiv, ebaeetiline või häiriv, näiteks liigne ropendamine; nõudmine, et vastane saaks karistuse peale seda, kui kohtunik on teinud oma otsuse; lõugamine; prügi maha loopimine jne.
Kerge eksimuse karistuseks on hoiatus.
2. **7.12.2 KESKMINE EKSIMUS:** selle alla kuulub kolme erinevat tüüpi eksimusi
- eirab kohtuniku või Korraldaja juhiseid, mis on mõeldud spetsiaalselt ühele tiimile või ühele mängijale
- kasutab avalikult vihakõne kellegi suunas
- on agressiivne või vägivaldne, kuid see ei ole suunatud teise inimese vastu
Keskmise eksimuse karistuseks on mängu kaotus.
3. **7.12.3 RASKE EKSIMUS:** siia alla kuulub käitumine, mis on selgelt vastuolus turniiril käitumise reeglite ning heade tavadega, näiteks tahtlikult turniiri vahendite lõhkumine või ruumi määrimine või lõhkumine. Raske eksimuse karistuseks on diskvalifikatsioon, turniiri toimumiskohast eemaldamine või ekstreemsematel juhtudel informeerib Korraldaja politseid.
4. **7.12.4 KOKKUMÄNG:** kokkumänguks loetakse kahe tiimi vahelist kokkulepet, et ebaausal teel teiste tiimide vastu mängida ning püüda mõjutada turniiri tulemusi.
Kokkumängu karistuseks on mõlema tiimi diskvalifitseerimine.
5. **7.12.5 ALTKÄEMAKS JA PANUSTAMINE:** tiimidel on keelatud mingi meelehea (ei pea olema ainult rahaline) nimel loobuda turniirist või püüda muuta match'ide tulemusi. Samuti on keelatud pakkuda kohtunikule stiimulit mängu tulemuse mõjutamiseks. Keelatud on ka mängude tulemuste peale panuseid teha.
Altkäemaksu ja panustamise karistuseks on diskvalifitseerimine.
6. **7.12.6 AGRESSIIVNE KÄITUMINE:** siia hulka kuuluvad kõik inimeste vastu suunatud agressiooni ilmingud, kaasa arvatud ähvardamine ning ilmselgelt reaalne vägivald.
Karistuseks on diskvalifitseerimine ja toimumiskohast eemaldamine, ekstreemsematel juhtudel informeerib Korraldaja politseid.
7. **7.12.7 VARGUS:** kuigi igal osalejal on kohustus oma varal silma peal hoida, on siiski eeldus, et osalejate käitumine on kooskõlas heade tavadega.
Varguse eest on karistuseks diskvalifitseerimine ja toimumiskohast eemaldamine. Kui situatsioon ei leia lahendust koha peal, informeerib Korraldaja politseid.
8. **7.12.8 ALKOHOL JA JOOVE:** Alkoholi tarbimine ürituse raames on keelatud. Liigse joobe korral on Korraldajal õigus osaleja toimumiskohast eemaldada.
- kui joobes osaleja on alaealine, informeeritakse sellest tema vanemaid ning politseid.
9. **7.12.9 SOHK:** selle alla kuulub mängijapoolne teadlik tegevus, et saavutada mängus eelis. Sohitegemine ei pea olema edukas selleks, et kaasa tuua karistust.
Sohi alla kuuluvad tegevused on näiteks:
- üritamine ise enda mängu spectator mode's näha või info saamine kelleltki, kes saab vaadata mängu spectator mode's
- igasugune mängu enda modifitseerimise üritamine või lisatarkvara kasutamine, mis ei ole tavapäraselt mänguga kaasas, sh. mängusisese zoomi muutmine, UI modifikatsioonide kasutamine, mis muudab löögiraadiust või muudab torni laskeraadiuse nähtavaks, spawn taimerite kasutamine jne. Siia alla ei kuulu VOIP programmide kasutamine.
- teise mängijana või vale nime all esinemine, mängimine kasutades teise mängija summoner nime või konto jagamine
- tahtlikult varustuse rikkumine või moonutada üritamine, et tekitada viivitusi, saada pause või mõnel muul moel mõjutada mängu käiku
- mängusiseste vigade tahtlik ära kasutamine, et saada eelist, st erinevate glitch'ide enda kasuks kasutamine.
Sohki tegemise eest on karistuseks diskvalifikatsioon.
## 8. Turniiri formaat
1. **8.1** Turniir toimub Fearless drafti põhimõtetel. See tähendab, et seeria jooksul pickitud champione ei saa pickida järgmistes mängudes kuni seeria lõpuni.
2. **8.2** Turniir toimub Round Robin + Single Elimination formaadis
See tähendab, et esimene round on 2 6-liimelist gruppi, kus kõik tiimid mängivad üksteisega korra läbi. Sellega selgitatakse 4 parimat, kes lähevad edasi järgmise päeva single elimination bracketisse.
1. **8.2.1** Gruppides tekkinud viigi korral pääseb edasi võistkond, kes viigistunud tiimide vahelise matši võitis.

View File

@@ -1,112 +0,0 @@
export type ScheduleItem = {
time?: string; // Aeg on ajutine praegu kuna pole 100% kindlalt paigas
titleKey: string;
locationKey: string;
description?: string;
};
export const scheduleData: Record<string, ScheduleItem[]> = {
oct24: [
{
titleKey: "schedule.events.doorsOpen",
locationKey: "schedule.locations.registrationSetup",
time: "17:00",
},
{
titleKey: "schedule.events.miniTournaments",
locationKey: "schedule.locations.studentHouse",
time: "18:00",
},
{
titleKey: "schedule.events.smashBrosUltimate",
locationKey: "schedule.locations.studentHouse",
time: "18:30",
},
{
titleKey: "schedule.events.cs2LolTournaments",
locationKey: "schedule.locations.auditorium",
time: "20:00",
},
{
titleKey: "schedule.events.doorsClose",
locationKey: "schedule.locations.auditoriumAndStudentHouse",
time: "*01:00",
},
],
oct25: [
{
titleKey: "schedule.events.doorsOpenSimple",
locationKey: "schedule.locations.auditorium",
time: "09:30",
},
{
titleKey: "schedule.events.cs2Continue",
locationKey: "schedule.locations.auditorium",
time: "11:00",
},
{
titleKey: "schedule.events.lolContinue",
locationKey: "schedule.locations.auditorium",
time: "12:00",
},
{
titleKey: "schedule.events.expoOpens",
locationKey: "schedule.locations.entranceHall",
time: "12:00",
},
{
titleKey: "schedule.events.2xkoTournament",
locationKey: "schedule.locations.studentHouse",
time: "13:30",
},
{
titleKey: "schedule.events.lolFinale",
locationKey: "schedule.locations.auditorium",
time: "19:30",
},
{
titleKey: "schedule.events.cs2lbsemifinals",
locationKey: "schedule.locations.auditorium",
time: "19:30",
},
{
titleKey: "schedule.events.cs2lbfinals",
locationKey: "schedule.locations.auditorium",
time: "21:30",
},
{
titleKey: "schedule.events.granTurismoFinale",
locationKey: "schedule.locations.studentHouse",
time: "20:00",
},
{
titleKey: "schedule.events.doorsClose",
locationKey: "schedule.locations.auditoriumAndStudentHouse",
time: "*01:00",
},
],
oct26: [
{
titleKey: "schedule.events.cs2semifinals",
locationKey: "schedule.locations.auditorium",
time: "11:00",
},
{
titleKey: "schedule.events.Cs2finals",
locationKey: "schedule.locations.auditorium",
time: "15:30",
},
{
titleKey: "schedule.events.AwardsCeremony",
locationKey: "schedule.locations.auditorium",
time: "18:00",
},
{
titleKey: "schedule.events.doorsClose",
locationKey: "schedule.locations.auditorium",
time: "19:00",
},
],
};

View File

@@ -1,6 +0,0 @@
import { drizzle } from "drizzle-orm/bun-sqlite";
import { Database } from "bun:sqlite";
import * as schema from "./schema/schema";
const sqlite = new Database("data/tipilan.db");
export const db = drizzle(sqlite, { schema });

View File

@@ -1,14 +0,0 @@
// Export all types
export * from "./types";
// Export all tables
export * from "./users";
export * from "./teams";
export * from "./members";
export * from "./tournaments";
// Export session table
// export * from ./session";
// Export all relations
export * from "./relations";

View File

@@ -1,31 +0,0 @@
import { sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
import { createId } from "@paralleldrive/cuid2";
import { users } from "./users";
import { teams } from "./teams";
import { RoleEnum } from "./types";
// Member table (join table for User and Team with role)
export const members = sqliteTable(
"member",
{
id: text("id")
.primaryKey()
.notNull()
.$defaultFn(() => createId()),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
teamId: text("team_id").references(() => teams.id, { onDelete: "cascade" }),
role: text("role", {
enum: Object.values(RoleEnum) as [string, ...string[]],
}).notNull(),
},
(table) => {
return {
userTeamUnique: uniqueIndex("user_team_unique").on(
table.userId,
table.teamId,
),
};
},
);

View File

@@ -1,42 +0,0 @@
import { relations } from "drizzle-orm";
import { users } from "./users";
import { teams } from "./teams";
import { members } from "./members";
import { tournaments, tournamentTeams } from "./tournaments";
// User relations
export const usersRelations = relations(users, ({ many }) => ({
members: many(members),
}));
// Team relations
export const teamsRelations = relations(teams, ({ many }) => ({
members: many(members),
tournamentTeams: many(tournamentTeams),
}));
// Member relations
export const membersRelations = relations(members, ({ one }) => ({
user: one(users, { fields: [members.userId], references: [users.id] }),
team: one(teams, { fields: [members.teamId], references: [teams.id] }),
}));
// Tournament relations
export const tournamentsRelations = relations(tournaments, ({ many }) => ({
tournamentTeams: many(tournamentTeams),
}));
// Tournament team relations
export const tournamentTeamsRelations = relations(
tournamentTeams,
({ one }) => ({
tournament: one(tournaments, {
fields: [tournamentTeams.tournamentId],
references: [tournaments.id],
}),
team: one(teams, {
fields: [tournamentTeams.teamId],
references: [teams.id],
}),
}),
);

View File

@@ -1,2 +0,0 @@
// Re-export all schema components from modular files
export * from "./index";

View File

@@ -1,11 +0,0 @@
import { sqliteTable, text } from "drizzle-orm/sqlite-core";
import { createId } from "@paralleldrive/cuid2";
// Team table
export const teams = sqliteTable("team", {
id: text("id")
.primaryKey()
.notNull()
.$defaultFn(() => createId()),
name: text("name").notNull(),
});

View File

@@ -1,46 +0,0 @@
import {
sqliteTable,
text,
integer,
uniqueIndex,
} from "drizzle-orm/sqlite-core";
import { createId } from "@paralleldrive/cuid2";
import { teams } from "./teams";
// Tournament table
export const tournaments = sqliteTable("tournament", {
id: text("id")
.primaryKey()
.notNull()
.$defaultFn(() => createId()),
name: text("name").notNull(),
});
// TournamentTeam join table
export const tournamentTeams = sqliteTable(
"tournament_team",
{
id: text("id")
.primaryKey()
.notNull()
.$defaultFn(() => createId()),
tournamentId: text("tournament_id")
.notNull()
.references(() => tournaments.id, { onDelete: "cascade" }),
teamId: text("team_id")
.notNull()
.references(() => teams.id, { onDelete: "cascade" }),
registrationDate: integer("registration_date", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
},
(table) => {
return {
tournamentTeamUnique: uniqueIndex("tournament_team_unique").on(
table.tournamentId,
table.teamId,
),
};
},
);

View File

@@ -1,10 +0,0 @@
// Roles enum equivalent
export const RoleEnum = {
VISITOR: "VISITOR",
PARTICIPANT: "PARTICIPANT",
TEAMMATE: "TEAMMATE",
CAPTAIN: "CAPTAIN",
ADMIN: "ADMIN",
} as const;
export type Role = (typeof RoleEnum)[keyof typeof RoleEnum];

View File

@@ -1,18 +0,0 @@
import { sqliteTable, text } from "drizzle-orm/sqlite-core";
import { createId } from "@paralleldrive/cuid2";
// User table
export const users = sqliteTable("user", {
id: text("id")
.primaryKey()
.notNull()
.$defaultFn(() => createId()),
email: text("email").notNull(),
// Other information
steamId: text("steam_id"),
firstName: text("first_name").notNull(),
lastName: text("last_name").notNull(),
ticketId: text("ticket_id").unique(),
ticketType: text("ticket_type"),
});

57
src/hooks/useCountUp.ts Normal file
View File

@@ -0,0 +1,57 @@
"use client";
import { useEffect, useRef, useState } from "react";
interface UseCountUpOptions {
end: number;
duration?: number;
suffix?: string;
prefix?: string;
}
export function useCountUp({ end, duration = 2000, suffix = "", prefix = "" }: UseCountUpOptions) {
const [count, setCount] = useState(0);
const [hasStarted, setHasStarted] = useState(false);
const ref = useRef<HTMLElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting && !hasStarted) {
setHasStarted(true);
}
},
{ threshold: 0.3 }
);
observer.observe(el);
return () => observer.disconnect();
}, [hasStarted]);
useEffect(() => {
if (!hasStarted) return;
const startTime = performance.now();
const animate = (currentTime: number) => {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
// ease-out cubic
const eased = 1 - Math.pow(1 - progress, 3);
setCount(Math.round(eased * end));
if (progress < 1) {
requestAnimationFrame(animate);
}
};
requestAnimationFrame(animate);
}, [hasStarted, end, duration]);
const display = `${prefix}${count}${suffix}`;
return { ref, display, count };
}

29
src/hooks/useCountdown.ts Normal file
View File

@@ -0,0 +1,29 @@
"use client";
import { useEffect, useState } from "react";
export function useCountdown(targetDate: Date) {
const [timeLeft, setTimeLeft] = useState({ days: 0, hours: 0, minutes: 0, seconds: 0 });
useEffect(() => {
const tick = () => {
const now = new Date().getTime();
const diff = targetDate.getTime() - now;
if (diff <= 0) {
setTimeLeft({ days: 0, hours: 0, minutes: 0, seconds: 0 });
return;
}
setTimeLeft({
days: Math.floor(diff / (1000 * 60 * 60 * 24)),
hours: Math.floor((diff / (1000 * 60 * 60)) % 24),
minutes: Math.floor((diff / (1000 * 60)) % 60),
seconds: Math.floor((diff / 1000) % 60),
});
};
tick();
const interval = setInterval(tick, 1000);
return () => clearInterval(interval);
}, [targetDate]);
return timeLeft;
}

View File

@@ -2,54 +2,12 @@ import { defineRouting } from "next-intl/routing";
import { createNavigation } from "next-intl/navigation";
export const routing = defineRouting({
// A list of all locales that are supported
locales: ["et", "en"],
// Used when no locale matches
defaultLocale: "et",
// The `pathnames` object holds pairs of internal and
// external paths. The external paths are shown in the URL.
pathnames: {
// If all locales use the same pathname, a single
// external path can be used for all locales
"/": "/",
"/ajakava": {
et: "/ajakava",
en: "/schedule",
},
"/haldus": {
et: "/haldus",
en: "/admin",
},
"/kodukord": {
et: "/kodukord",
en: "/houserules",
},
"/messiala": {
et: "/messiala",
en: "/expo",
},
"/piletid": {
et: "/piletid",
en: "/tickets",
},
"/reeglid": {
et: "/reeglid",
en: "/gamerules",
},
"/striim": {
et: "/striim",
en: "/stream",
},
"/turniirid": {
et: "/turniirid",
en: "/tournaments",
},
},
});
// Lightweight wrappers around Next.js' navigation APIs
// that will consider the routing configuration
export const { Link, redirect, usePathname, useRouter } =
createNavigation(routing);

View File

@@ -1,353 +0,0 @@
import { db } from "@/db/drizzle";
import { users } from "@/db/schema/users";
import { teams } from "@/db/schema/teams";
import { members } from "@/db/schema/members";
import { tournamentTeams } from "@/db/schema/tournaments";
import { eq, and, isNull } from "drizzle-orm";
import { RoleEnum, type Role } from "@/db/schema/types";
// Types based on the Fienta API response
export interface FientaApiResponse {
success: {
code: number;
user_message: string;
internal_message: string;
};
time: {
timestamp: number;
date: string;
time: string;
full_datetime: string;
timezone: string;
timezone_short: string;
gmt: string;
};
count: number;
tickets: FientaTicket[];
}
export interface FientaTicket {
id: number;
organizer_id: number;
event_id: number;
order_id: number;
code: string;
status: string;
used_at: string | null;
created_at: string;
updated_at: string;
validated_by_id: number | null;
ip: string;
is_parent: boolean;
parent_id: number | null;
order_email: string;
order_phone: string;
contents_html: string;
nametag_html: string;
qty: number;
permissions: {
update: boolean;
};
rows: FientaTicketRow[];
}
export interface FientaTicketRow {
ticket_type: {
id: number;
title: string;
attendance_mode: string;
};
attendee: {
first_name: string;
last_name: string;
full_name: string;
email: string;
[key: string]: string; // To handle dynamic field names
};
}
/**
* Fetches tickets from the Fienta API for a specific event
*/
export async function fetchFientaTickets(
eventId: string,
apiKey: string,
): Promise<FientaApiResponse> {
const response = await fetch(
`https://fienta.com/api/v1/events/${eventId}/tickets/?attendees`,
{
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: "application/json",
},
},
);
if (!response.ok) {
throw new Error(`Failed to fetch tickets: ${response.statusText}`);
}
return response.json();
}
/**
* Extracts Steam ID from a Steam profile URL
*/
export function extractSteamId(steamUrl: string): string | null {
if (!steamUrl) return null;
// Use regex to handle both escaped and unescaped URLs, with or without trailing slash
const regex = /(?:\/|\\\/)(id|profiles)\/([^\/\\]+)(?:\/|\\"\/)?$/;
const match = steamUrl.match(regex);
// Return the matched ID or null if no match
return match && match[2] ? match[2] : null;
}
/**
* Finds an attendee field by prefix in the attendee data
* Fienta's API can return custom form fields with dynamic names
*/
function findAttendeeFieldByPrefix(
attendee: { [key: string]: string },
prefix: string,
): string | null {
// Find the first field that starts with the given prefix
const fieldKey = Object.keys(attendee).find((key) => key.startsWith(prefix));
// Return the value if found, or null otherwise
return fieldKey ? attendee[fieldKey] : null;
}
/**
* Determines the appropriate user role based on ticket type and captain status
*/
function determineUserRole(
ticketType: string,
isCaptain: boolean,
isTeamMember: boolean,
): Role {
// Case-insensitive check for tournament participants
const isTournamentParticipant = ticketType
.toLowerCase()
.includes("põhiturniiri osaleja");
const isParticipant = ticketType.toLowerCase().includes("arvutiga osaleja");
if (!isTeamMember && isParticipant) {
return RoleEnum.PARTICIPANT;
}
if (isTournamentParticipant) {
if (isCaptain) {
return RoleEnum.CAPTAIN;
} else if (isTeamMember) {
return RoleEnum.TEAMMATE;
}
return RoleEnum.PARTICIPANT;
}
return RoleEnum.VISITOR;
}
/**
* Upserts a user in the database (create or update)
*/
async function upsertUser(userData: {
email: string;
firstName: string;
lastName: string;
steamId: string | null;
ticketId: string;
ticketType: string;
}) {
// Try to find existing user by ticket ID (primary unique identifier)
const existingUser = await db.query.users.findFirst({
where: eq(users.ticketId, userData.ticketId),
});
if (existingUser) {
// Update existing user
await db
.update(users)
.set({
email: userData.email,
firstName: userData.firstName,
lastName: userData.lastName,
steamId: userData.steamId,
ticketType: userData.ticketType,
})
.where(eq(users.id, existingUser.id));
return existingUser;
} else {
// Create new user
const [newUser] = await db.insert(users).values(userData).returning();
return newUser;
}
}
/**
* Upserts a team member relationship
*/
async function upsertMember(userId: string, teamId: string | null, role: Role) {
// Try to find existing member
const whereCondition = teamId
? and(eq(members.userId, userId), eq(members.teamId, teamId))
: and(eq(members.userId, userId), isNull(members.teamId));
const existingMember = await db.query.members.findFirst({
where: whereCondition,
});
if (existingMember) {
// Update existing member
await db
.update(members)
.set({ role })
.where(eq(members.id, existingMember.id));
} else {
// Create new member
await db.insert(members).values({
userId,
teamId,
role,
});
}
}
/**
* Processes tickets from Fienta and updates the database
*/
export async function syncFientaTickets(
tickets: FientaTicket[],
): Promise<void> {
// Process each ticket to extract user and team information
for (const ticket of tickets) {
// Skip tickets with CANCELLED status or empty rows
if (ticket.rows.length === 0 || ticket.status === "CANCELLED") continue;
const ticketRow = ticket.rows[0];
const attendee = ticketRow.attendee;
const ticketType = ticketRow.ticket_type.title;
// Extract data
const email = attendee.email || ticket.order_email;
const firstName = attendee.first_name;
const lastName = attendee.last_name;
const steamUrl = findAttendeeFieldByPrefix(attendee, "steam_konto_link");
const teamName = findAttendeeFieldByPrefix(attendee, "tiimi_nimi");
const captainName = findAttendeeFieldByPrefix(
attendee,
"tiimi_kapteni_nimi",
);
const steamId = steamUrl ? extractSteamId(steamUrl) : null;
// Check if user is captain - captain name must exist and match user's name
const isCaptain = captainName !== null && steamId === captainName;
// Create or update user
const user = await upsertUser({
email,
firstName,
lastName,
steamId,
ticketId: ticket.code,
ticketType,
});
// Handle team association if there is a team name
if (teamName) {
// Find the team by name first
let team = await db.query.teams.findFirst({
where: eq(teams.name, teamName),
});
// Create the team if it doesn't exist
if (!team) {
const [newTeam] = await db
.insert(teams)
.values({ name: teamName })
.returning();
team = newTeam;
}
// Determine appropriate role
const role = determineUserRole(ticketType, isCaptain, true);
// Create or update membership with appropriate role
await upsertMember(user.id, team.id, role);
} else {
// For users without a team, handle membership without team association
const role = determineUserRole(ticketType, false, false);
await upsertMember(user.id, null, role);
}
}
}
/**
* Main function to fetch and sync tickets from Fienta to the database
*/
export async function syncFientaEvent(
eventId: string,
apiKey: string,
): Promise<void> {
try {
const response = await fetchFientaTickets(eventId, apiKey);
await syncFientaTickets(response.tickets);
} catch (error) {
console.error("Error syncing Fienta tickets:", error);
throw error;
}
}
/**
* Gets teams with their members from the database
*/
export async function getTeamsWithMembers() {
return await db.query.teams.findMany({
with: {
members: {
with: {
user: true,
},
},
},
});
}
/**
* Gets a specific team with its members
*/
export async function getTeamWithMembers(teamId: string) {
return await db.query.teams.findFirst({
where: eq(teams.id, teamId),
with: {
members: {
with: {
user: true,
},
},
},
});
}
/**
* Gets tournament teams with related data
*/
export async function getTournamentTeams(tournamentId: string) {
return await db.query.tournamentTeams.findMany({
where: eq(tournamentTeams.tournamentId, tournamentId),
with: {
team: {
with: {
members: {
with: {
user: true,
},
},
},
},
},
});
}

View File

@@ -1,165 +0,0 @@
import fs from "fs";
import path from "path";
export type Locale = "et" | "en";
export type RuleType = "cs2" | "lol" | "kodukord";
/**
* Loads any rule content for a specific type and locale
* @param ruleType - The type of rules to load (cs2, lol, kodukord)
* @param locale - The locale to load rules for (et, en)
* @returns Promise<string> The markdown content of the rules file
*/
export async function loadRules(
ruleType: RuleType,
locale: Locale,
): Promise<string> {
// Try to load the file for the current locale first
const filePath = path.join(
process.cwd(),
"src",
"data",
"rules",
locale,
`${ruleType}.md`,
);
try {
// Check if file exists for current locale
if (fs.existsSync(filePath)) {
const file = await import("fs").then(() =>
fs.readFileSync(filePath, "utf8"),
);
return file;
}
// Fallback to Estonian if English version doesn't exist
if (locale !== "et") {
console.warn(
`Rules file not found for ${ruleType} in ${locale}, falling back to Estonian`,
);
const fallbackPath = path.join(
process.cwd(),
"src",
"data",
"rules",
"et",
`${ruleType}.md`,
);
if (fs.existsSync(fallbackPath)) {
const fallbackFile = fs.readFileSync(fallbackPath, "utf8");
return fallbackFile;
}
}
throw new Error(`Rules file not found for ${ruleType}`);
} 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"}`,
);
}
}
/**
* 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
*/
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"}`,
);
}
}
/**
* Gets all available rule types for a given locale
* @param locale - The locale to check for available rules
* @returns Promise<RuleType[]> Array of available rule types
*/
export async function getAvailableRules(locale: Locale): Promise<RuleType[]> {
const rulesDir = path.join(process.cwd(), "src", "data", "rules", locale);
try {
const files = fs.readdirSync(rulesDir);
const ruleTypes = files
.filter((file) => file.endsWith(".md"))
.map((file) => file.replace(".md", "") as RuleType);
return ruleTypes;
} catch (error) {
console.warn(`Could not read rules directory for locale ${locale}:`, error);
return [];
}
}
/**
* Checks if a specific rule file exists for a given locale
* @param ruleType - The type of rules to check
* @param locale - The locale to check
* @returns boolean indicating if the file exists
*/
export function ruleExists(ruleType: RuleType, locale: Locale): boolean {
const filePath = path.join(
process.cwd(),
"src",
"data",
"rules",
locale,
`${ruleType}.md`,
);
return fs.existsSync(filePath);
}
/**
* Gets the best available locale for a rule type
* Prefers the requested locale, falls back to Estonian
* @param ruleType - The type of rules to check
* @param preferredLocale - The preferred locale
* @returns Locale The best available locale for the rule type
*/
export function getBestAvailableLocale(
ruleType: RuleType,
preferredLocale: Locale,
): Locale {
if (ruleExists(ruleType, preferredLocale)) {
return preferredLocale;
}
if (ruleExists(ruleType, "et")) {
return "et";
}
// If neither exists, return preferred (will throw error when trying to load)
return preferredLocale;
}

View File

@@ -1,119 +0,0 @@
import fs from "fs";
import path from "path";
export type Locale = "et" | "en";
export type RuleType = "cs2" | "lol" | "kodukord";
/**
* Loads rules content for a specific game and locale
* @param ruleType - The type of rules to load (cs2, lol, kodukord)
* @param locale - The locale to load rules for (et, en)
* @returns The markdown content of the rules file
*/
export async function getRules(
ruleType: RuleType,
locale: Locale,
): Promise<string> {
const filePath = path.join(
process.cwd(),
"src",
"data",
"rules",
locale,
`${ruleType}.md`,
);
try {
const content = fs.readFileSync(filePath, "utf8");
return content;
} catch (error) {
// Fallback to Estonian if English version doesn't exist
if (locale === "en") {
console.warn(
`Rules file not found for ${ruleType} in ${locale}, falling back to Estonian`,
);
const fallbackPath = path.join(
process.cwd(),
"src",
"data",
"rules",
"et",
`${ruleType}.md`,
);
try {
const fallbackContent = fs.readFileSync(fallbackPath, "utf8");
return fallbackContent;
} catch (fallbackError) {
throw new Error(
`Rules file not found for ${ruleType} in either ${locale} or et locale: ${fallbackError}`,
);
}
}
throw new Error(
`Rules file not found for ${ruleType} in ${locale} locale: ${error}`,
);
}
}
/**
* Gets all available rule types
* @param locale - The locale to check for available rules
* @returns Array of available rule types
*/
export async function getAvailableRules(locale: Locale): Promise<RuleType[]> {
const rulesDir = path.join(process.cwd(), "src", "data", "rules", locale);
try {
const files = fs.readdirSync(rulesDir);
const ruleTypes = files
.filter((file) => file.endsWith(".md"))
.map((file) => file.replace(".md", "") as RuleType);
return ruleTypes;
} catch (error) {
console.warn(`Could not read rules directory for locale ${locale}:`, error);
return [];
}
}
/**
* Checks if a specific rule file exists for a given locale
* @param ruleType - The type of rules to check
* @param locale - The locale to check
* @returns Boolean indicating if the file exists
*/
export function ruleExists(ruleType: RuleType, locale: Locale): boolean {
const filePath = path.join(
process.cwd(),
"src",
"data",
"rules",
locale,
`${ruleType}.md`,
);
return fs.existsSync(filePath);
}
/**
* Gets the best available locale for a rule type
* Prefers the requested locale, falls back to Estonian
* @param ruleType - The type of rules to check
* @param preferredLocale - The preferred locale
* @returns The best available locale for the rule type
*/
export function getBestAvailableLocale(
ruleType: RuleType,
preferredLocale: Locale,
): Locale {
if (ruleExists(ruleType, preferredLocale)) {
return preferredLocale;
}
if (ruleExists(ruleType, "et")) {
return "et";
}
// If neither exists, return preferred (will throw error when trying to load)
return preferredLocale;
}

View File

@@ -1,6 +0,0 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -1,20 +0,0 @@
import createMiddleware from 'next-intl/middleware';
import {routing} from './i18n/routing';
export default createMiddleware(routing);
export const config = {
// Match only internationalized pathnames
matcher: [
// Enable a redirect to a matching locale at the root
'/',
// Set a cookie to remember the previous locale for
// all requests that have a locale prefix
'/(et|en)/:path*',
// Enable redirects that add missing locales
// (e.g. `/pathnames` -> `/en/pathnames`)
'/((?!_next|_vercel|.*\\..*).*)'
]
};

17
src/proxy.ts Normal file
View File

@@ -0,0 +1,17 @@
import createMiddleware from 'next-intl/middleware';
import { NextRequest } from 'next/server';
import { routing } from './i18n/routing';
const intlMiddleware = createMiddleware(routing);
export function proxy(request: NextRequest) {
return intlMiddleware(request);
}
export const config = {
matcher: [
'/',
'/(et|en)/:path*',
'/((?!_next|_vercel|.*\\..*).*)'
]
};

View File

@@ -1,24 +0,0 @@
import type { InferSelectModel } from "drizzle-orm";
import { users } from "@/db/schema/users";
import { teams } from "@/db/schema/teams";
import { members } from "@/db/schema/members";
// Base types from schema
export type User = InferSelectModel<typeof users>;
export type Team = InferSelectModel<typeof teams>;
export type Member = InferSelectModel<typeof members>;
// Extended types for queries with relations
export type TeamWithMembers = Team & {
members: (Member & {
user: User;
})[];
};
export type MemberWithUser = Member & {
user: User;
};
export type UserWithMembers = User & {
members: Member[];
};