add mlb page

This commit is contained in:
2025-04-25 16:35:53 +02:00
parent 0abc4d32aa
commit 78dff4ae2f
44 changed files with 2208 additions and 84 deletions

2
.gitignore vendored
View File

@@ -1,5 +1,5 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
.thing
# dependencies # dependencies
/node_modules /node_modules
/.pnp /.pnp

View File

@@ -1,53 +0,0 @@
---
title: Balance Changes
description: Learn about the multiplayer balance changes in Balatro MP, including new jokers, planet cards, and adjustments to glass cards and tarot cards.
---
## Jokers
### Multiplayer Jokers
There are **10 added multiplayer jokers**. All jokers added in the multiplayer mod are designed
to **interact with your opponent** and will not show up in single player games.
See page 11 for a list of added jokers.
<Callout>
These jokers can be removed by unchecking the **"Enable Multiplayer Cards"** option in the lobby options.
</Callout>
### Disabled Jokers
The following jokers are **unavailable** in multiplayer due to their interaction with boss blinds:
- **Chicot**
- **Matador**
- **Mr. Bones**
- **Luchador**
### Changes
#### Hanging Chad
Hanging Chad has been reworked to **retrigger the first 2 cards once** instead of the first card twice.
## Planet cards
### Asteroid
**Removes one level** from your opponent's highest levelled planet.
<Callout>
These planet cards can be removed by unchecking the **"Enable Multiplayer Cards"** option in the lobby options.
</Callout>
## Tarot Cards
### Justice
This card is not available in the standard ruleset.
## Enhancements
### Glass Cards
Glass cards are now **1.5x** instead of 2x multiplier and can **only** be found in:
- Standard packs
- Spectral cards that spawn cards (Grim, Incantation, and Familiar)

View File

@@ -2,6 +2,7 @@
"pages": [ "pages": [
"index", "index",
"getting-started", "getting-started",
"multiplayer-content",
"rulesets", "rulesets",
"ranked-matchmaking", "ranked-matchmaking",
"advanced" "advanced"

View File

@@ -0,0 +1,5 @@
---
title: Jokers
description: Multiplayer-specific Jokers
---

View File

@@ -0,0 +1,4 @@
{
"pages": ["jokers"],
"defaultOpen": true
}

View File

@@ -0,0 +1,7 @@
---
title: FAQ
description: Answers to common questions about the Ranked Matchmaking.
---
## what happens if you take a rare skip? does your opponent get that rare in the next shop?

View File

@@ -0,0 +1,15 @@
---
title: Introduction
description: Learn how to play ranked matches in Balatro Multiplayer.
---
If you want to compete in ranked matches in Balatro Multiplayer and make your way to the leaderboards you have two options:
## Ranked Queue
The **Ranked Queue** is the default queue in Balatro Multiplayer. It is a queue where players can compete against other players in a ranked matchmaking system.
One of the main differentiators of the Ranked Queue is that
**it includes changes to the base game balance.**
These changes are meant to make the game **less RNG-heavy and more balanced** for competitive play.

View File

@@ -0,0 +1,5 @@
{
"title": "Ranked Matchmaking",
"pages": ["introduction", "multiplayer-balance-changes", "faq"],
"defaultOpen": true
}

View File

@@ -0,0 +1,281 @@
---
title: Balance Changes
description: Learn about the multiplayer balance changes in Balatro MP, including new jokers, planet cards, and adjustments to glass cards and tarot cards.
---
## Jokers
### Multiplayer Jokers
There are **10 added multiplayer jokers**. All jokers added in the multiplayer mod are designed
to **interact with your opponent** and will not show up in single player games.
<div className={'relative overflow-auto'}>
<table>
<thead>
<tr>
<th className={'text-center'}>Joker</th>
<th>Description</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
<tr>
<td>
#### Defensive Joker [toc]
<JokerCard name={'Defensive Joker'} img={'/cards/j_defensive_joker.png'}/>
</td>
<td>
<Chips>+125</Chips> Chips for every life less than your Nemesis.
</td>
<td>
There is a bug where this joker might appear in single player mode. To fix this, disable the multiplayer
mod when playing single player.
</td>
</tr>
<tr>
<td>
#### Skip-Off [toc]
<JokerCard name={'Skip-Off'} img={'/cards/j_skip_off.png'}/>
</td>
<td>
<Hands>+1</Hands> Hands and <Hands>+1</Hands> Discards per additional blind skipped compared to your
Nemesis.
</td>
<td></td>
</tr>
<tr>
<td>
#### Let's Go Gambling [toc]
<JokerCard name={'Let\'s Go Gambling'} img={'/cards/j_lets_go_gambling.png'}/>
</td>
<td>
<div className={'flex flex-col gap-1'}>
<span><Chance>1 in 4</Chance> chance for <Xmult>X4</Xmult> Mult and <Money>$10</Money></span>
<span><Chance>1 in 8</Chance> chance to give your Nemesis <Money>$5</Money></span>
</div>
</td>
<td>
The effect applies on each hand played.
</td>
</tr>
<tr>
<td>
#### Speedrun [toc]
<JokerCard name={'Speedrun'} img={'/cards/j_speedrun.png'}/>
</td>
<td>
If you reach a <Hands>PvP Blind</Hands> before your Nemesis, create a
random <Spectral>Spectral</Spectral> card. Must have room.
</td>
<td>
There is a bug where the spectral card might not be created, but is is very rare. We're working on a
fix.
</td>
</tr>
<tr>
<td>
#### Conjoined Joker [toc]
<JokerCard name={'Conjoined Joker'} img={'/cards/j_conjoined_joker.png'}/>
</td>
<td>
While in a <Hands>PvP Blind</Hands>, gain
<Xmult>X1</Xmult> Mult for every <Hands>Hand</Hands>
your Nemesis has left
(Max <Xmult>X5</Xmult> Mult)
</td>
<td></td>
</tr>
<tr>
<td>
#### Penny Pincher [toc]
<JokerCard name={'Penny Pincher'} img={'/cards/j_penny_pincher.png'}/>
</td>
<td>
At start of shop, gain
<Money>$1</Money> for every <Money>$3</Money> your Nemesis spent last shop
</td>
<td></td>
</tr>
<tr>
<td>
#### Taxes [toc]
<JokerCard name={'Taxes'} img={'/cards/j_taxes.png'}/>
</td>
<td>
This Joker gains <Mult>+5</Mult> Mult each time your Nemesis sells a card
</td>
<td></td>
</tr>
<tr>
<td>
#### Magnet [toc]
<JokerCard name={'Magnet'} img={'/cards/j_magnet.png'}/>
</td>
<td>
After <Hands>2</Hands> rounds, sell this card to Copy your Nemesis' highest sell cost Joker. Does not
copy Joker state.
</td>
<td>
If your Nemesis has multiple Jokers that have the highest sell cost, a random one will be copied.
</td>
</tr>
<tr>
<td>
#### Pizza [toc]
<JokerCard name={'Pizza'} img={'/cards/j_pizza.png'}/>
</td>
<td>
<div className={'flex flex-col gap-1'}>
<span><Mult>+6</Mult> Discards for all players</span>
<span><Mult>-1</Mult> Discard when any player selects a blind</span>
<span>Eaten when your Nemesis skips</span>
</div>
</td>
<td></td>
</tr>
<tr>
<td>
#### Pacifist [toc]
<JokerCard name={'Pacifist'} img={'/cards/j_pacifist.png'}/>
</td>
<td>
<div className={'flex flex-col gap-1'}>
<Xmult>X10</Xmult> Mult while not in a <Hands>PvP Blind</Hands>
</div>
</td>
<td></td>
</tr>
</tbody>
</table>
</div>
<Callout>
These jokers can be removed by unchecking the **"Enable Multiplayer Cards"** option in the lobby options.
</Callout>
### Disabled Jokers
The following jokers are **unavailable** in multiplayer due to their interaction with boss blinds:
<div>
<table>
<thead>
<tr>
<th className={'text-center'}>Joker</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>
#### Chicot [toc]
<JokerCard name={'Chicot'} img={'/cards/j_chicot.png'} h={4}/>
</td>
<td>
Disables effect of every <Hands>Boss Blind</Hands>
</td>
</tr>
<tr>
<td>
#### Matador [toc]
<JokerCard name={'Matador'} img={'/cards/j_matador.png'} h={4}/>
</td>
<td>
Earn <Money>$8</Money> if played hand triggers the <Hands>Boss Blind</Hands> ability
</td>
</tr>
<tr>
<td>
#### Mr. Bones [toc]
<JokerCard name={'Mr. Bones'} img={'/cards/j_mr_bones.png'} h={4}/>
</td>
<td>
Prevents Death if chips scored are at least <Hands>25%</Hands> of required chips.
<Mult>Self destructs</Mult>
</td>
</tr>
<tr>
<td>
#### Luchador [toc]
<JokerCard name={'Luchador'} img={'/cards/j_luchador.png'} h={4}/>
</td>
<td>
Sell this card to disable the current <Hands>Boss Blind</Hands>
</td>
</tr>
</tbody>
</table>
</div>
### Modified Jokers
These jokers are modified versions of the original jokers.
<div>
<table>
<thead>
<tr>
<th className={'text-center'}>Joker</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>
#### Hanging Chad [toc]
<JokerCard name={'Hanging Chad'} img={'/cards/j_hanging_chad.png'} h={4}/>
</td>
<td>
Retrigger <Hands>first</Hands> and <Hands>second</Hands> played cards used in
scoring <Hands>1</Hands> additional time
</td>
</tr>
</tbody>
</table>
</div>
## Planet cards
<div>
<table>
<thead>
<tr>
<th className={'text-center'}>Joker</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>
### Asteroid [toc]
<JokerCard name={'Asteroid'} img={'/cards/c_asteroid.png'}/>
</td>
<td>
Removes <Hands>one level</Hands> from your Nemesis' highest level <Hands>poker hand</Hands>.
</td>
</tr>
</tbody>
</table>
</div>
<Callout>
These planet cards can be removed by unchecking the **"Enable Multiplayer Cards"** option in the lobby options.
</Callout>
## Tarot Cards
### Justice
This card is not available in the standard ruleset.
## Enhancements
### Glass Cards
Glass cards are now **1.5x** instead of 2x multiplier and can **only** be found in:
- Standard packs
- Spectral cards that spawn cards (Grim, Incantation, and Familiar)

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 686 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 MiB

View File

@@ -0,0 +1,55 @@
import { PlayerCard } from '@/app/(home)/major-league-balatro/_components/player-card'
import { players } from '@/app/(home)/major-league-balatro/_constants/players'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
export function Competitors() {
return (
<section className='container py-8 md:py-12'>
<div className='mx-auto flex max-w-[58rem] flex-col items-center space-y-4 text-center'>
<h2 className='font-bold text-3xl leading-tight tracking-tighter md:text-4xl'>
The Competitors
</h2>
<p className='max-w-[85%] text-muted-foreground leading-normal sm:text-lg sm:leading-7'>
12 talented creators split into Blue and Red divisions.
</p>
</div>
<Tabs defaultValue='blue' className='mx-auto mt-8 max-w-5xl'>
<TabsList className='grid w-full grid-cols-2'>
<TabsTrigger value='blue'>Blue Division</TabsTrigger>
<TabsTrigger value='red'>Red Division</TabsTrigger>
</TabsList>
<TabsContent value='blue' className='mt-6'>
<div className='grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3'>
{Object.values(players)
.filter((player) => player.division === 'Blue')
.map((creator) => (
<PlayerCard
name={creator.name}
socials={creator.socials}
picture={creator.picture}
key={creator.name}
/>
))}
</div>
</TabsContent>
<TabsContent value='red' className='mt-6'>
<div className='grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3'>
{Object.values(players)
.filter(
(player) => player.division === 'Red' && player.id !== 'tbd'
)
.map((creator) => (
<PlayerCard
name={creator.name}
socials={creator.socials}
picture={creator.picture}
key={creator.name}
/>
))}
</div>
</TabsContent>
</Tabs>
</section>
)
}

View File

@@ -0,0 +1,68 @@
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Calendar, ChevronRight } from 'lucide-react'
import Link from 'next/link'
export function HeroSection() {
return (
<section>
<div className='container relative z-10 flex flex-col items-center py-16 text-center md:py-24'>
<Badge className='mb-4 w-full bg-red-600 text-sm text-white hover:bg-red-700 sm:w-auto'>
Season 1
</Badge>
<h1 className='mb-4 font-extrabold text-4xl tracking-tight md:text-6xl'>
Major League Balatro
</h1>
<p className='mb-8 max-w-[42rem] text-lg text-muted-foreground md:text-xl'>
12 creators. 2 divisions. 1 champion. The ultimate Balatro multiplayer
tournament.
</p>
<div className='mb-8 grid w-full max-w-3xl grid-cols-2 gap-4 md:grid-cols-4 md:gap-8'>
<div className='flex flex-col items-center rounded-lg border bg-card p-4'>
<span className='font-bold text-2xl text-red-500 md:text-3xl'>
12
</span>
<span className='text-muted-foreground text-sm'>Creators</span>
</div>
<div className='flex flex-col items-center rounded-lg border bg-card p-4'>
<span className='font-bold text-2xl text-red-500 md:text-3xl'>
5
</span>
<span className='text-muted-foreground text-sm'>Weeks</span>
</div>
<div className='flex flex-col items-center rounded-lg border bg-card p-4'>
<span className='font-bold text-2xl text-red-500 md:text-3xl'>
$500+
</span>
<span className='text-muted-foreground text-sm'>Prize Pool</span>
</div>
<div className='flex flex-col items-center rounded-lg border bg-card p-4'>
<span className='font-bold text-2xl text-red-500 md:text-3xl'>
May 17
</span>
<span className='text-muted-foreground text-sm'>Finals</span>
</div>
</div>
<div className='flex flex-col gap-4 sm:flex-row'>
<Link href='#schedule'>
<Button
size='lg'
className='bg-red-600 text-white hover:bg-red-700'
>
View Schedule
<Calendar className='ml-2 h-4 w-4' />
</Button>
</Link>
<Link href='#format'>
<Button variant='outline' size='lg'>
Tournament Format
<ChevronRight className='ml-2 h-4 w-4' />
</Button>
</Link>
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,91 @@
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { SiTwitch } from '@icons-pack/react-simple-icons'
import { Twitch } from 'lucide-react'
import Link from 'next/link'
import type { Player } from '../types'
import { PlayerAvatar } from './player-avatar'
type NextMatchInfoCardProps = {
player1: Player
player2: Player
week: number | string
bestOf: number
}
export function NextMatchInfoCard({
player1,
player2,
week,
bestOf,
}: NextMatchInfoCardProps) {
return (
<div className='mt-10 overflow-hidden rounded-xl border bg-card/60 shadow-lg backdrop-blur-sm'>
<div className='flex items-center justify-between gap-4 px-6 py-4'>
<div className={'flex flex-col gap-1'}>
<div className='text-center text-muted-foreground text-sm'>
{typeof week === 'string' ? week : `Week ${week}`}
</div>
<div className={'font-bold text-muted-foreground text-sm'}>
Best of {bestOf}
</div>
</div>
<div>
<h3 className='flex items-center gap-2 font-bold text-xl md:text-2xl'>
<div className={'flex items-center gap-2'}>
<PlayerAvatar
className={'size-16'}
playerName={player1.name}
img={player1.picture}
/>
{player1.name}
</div>
<span className='text-red-500'>vs</span>
<div className={'flex items-center gap-2'}>
{player2.name}
<PlayerAvatar
className={'size-16'}
playerName={player2.name}
img={player2.picture}
/>
</div>
</h3>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className='w-full gap-2 bg-red-600 text-white hover:bg-red-700 md:w-auto'>
<SiTwitch className='h-4 w-4' />
Watch Live
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem asChild>
<Link
href={`https://twitch.tv/${player1.socials.twitch}`}
target='_blank'
rel='noopener noreferrer'
>
{player1.name}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
href={`https://twitch.tv/${player2.socials.twitch}`}
target='_blank'
rel='noopener noreferrer'
>
{player2.name}
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)
}

View File

@@ -0,0 +1,85 @@
import { NextMatchInfoCard } from '@/app/(home)/major-league-balatro/_components/next-match-info-card'
import { CountdownTimer } from '@/components/countdown-timer'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Calendar, Clock } from 'lucide-react'
import type { PropsWithChildren } from 'react'
import { players } from '../_constants/players'
import type { Match } from '../types'
export type NextMatchInfoProps = {
nextMatch: Match | undefined
}
export function NextMatchInfo({ nextMatch }: NextMatchInfoProps) {
if (!nextMatch) {
return (
<SectionContainer>
<Card className='border-2 border-red-500/50'>
<CardHeader>
<CardTitle className='text-center text-2xl'>
Tournament Schedule
</CardTitle>
</CardHeader>
<CardContent className='py-8 text-center'>
<div className='mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted'>
<Calendar className='h-8 w-8 text-muted-foreground' />
</div>
<p className='text-muted-foreground'>
No upcoming matches scheduled at this time.
</p>
<p className='mt-2 text-muted-foreground text-sm'>
Check back later for updates or view past matches below.
</p>
</CardContent>
</Card>
</SectionContainer>
)
}
const nextMatchPlayer1 = players[nextMatch.player1Id]
const nextMatchPlayer2 = players[nextMatch.player2Id]
if (!nextMatchPlayer1) {
throw new Error(`Player ${nextMatch.player1Id} not found`)
}
if (!nextMatchPlayer2) {
throw new Error(`Player ${nextMatch.player2Id} not found`)
}
return (
<SectionContainer>
<div className='relative overflow-hidden rounded-xl border-2 bg-gradient-to-b from-background '>
<div className='relative z-10 px-6 py-8 md:py-10'>
<div className='mb-8 text-center'>
<h2 className='mb-2 font-bold text-2xl md:text-3xl'>
Next Match Countdown
</h2>
<div className='flex items-center justify-center gap-2 text-red-500'>
<Clock className='h-5 w-5 animate-pulse' />
<p className='text-sm md:text-base'>
{nextMatch.date} - {nextMatch.time}
</p>
</div>
</div>
<CountdownTimer nextMatch={nextMatch} />
<NextMatchInfoCard
week={nextMatch.week}
bestOf={5}
player1={nextMatchPlayer1}
player2={nextMatchPlayer2}
/>
</div>
</div>
</SectionContainer>
)
}
function SectionContainer({ children }: PropsWithChildren) {
return (
<section className='container py-8 md:py-12'>
<div className='mx-auto w-full max-w-4xl'>{children}</div>
</section>
)
}

View File

@@ -0,0 +1,53 @@
import { Button } from '@/components/ui/button'
import { ExternalLink, Twitch, Users, Youtube } from 'lucide-react'
import Link from 'next/link'
export function Organizer() {
return (
<section className='container py-8 md:py-12'>
<div className='mx-auto flex max-w-[58rem] flex-col items-center space-y-4 text-center'>
<h2 className='font-bold text-3xl leading-tight tracking-tighter md:text-4xl'>
About the Organizer
</h2>
</div>
<div className='mx-auto mt-8 flex max-w-3xl flex-col items-center gap-8 md:flex-row'>
<div className='relative h-32 w-32 flex-shrink-0 rounded-full bg-muted'>
<div className='absolute inset-0 flex items-center justify-center'>
<Users className='h-16 w-16 text-muted-foreground/50' />
</div>
</div>
<div>
<h3 className='mb-2 font-bold text-2xl'>ZainoTV</h3>
<p className='mb-4 text-muted-foreground'>
ZainoTV is the creator and organizer of Major League Balatro. With a
passion for competitive gaming and the Balatro community, Zaino has
brought together 12 talented creators for this exciting tournament.
</p>
<div className='flex gap-4'>
<Link
href='https://twitch.tv/zainotv'
target='_blank'
rel='noopener noreferrer'
>
<Button variant='outline' size='sm' className='gap-2'>
<Twitch className='h-4 w-4' />
Twitch
</Button>
</Link>
<Link
href='https://www.youtube.com/@ZainoTVLive'
target='_blank'
rel='noopener noreferrer'
>
<Button variant='outline' size='sm' className='gap-2'>
<Youtube className='h-4 w-4' />
YouTube
</Button>
</Link>
</div>
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,30 @@
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
interface PlayerAvatarProps {
playerName: string
img?: string
className?: string
}
export function PlayerAvatar({
playerName,
img,
className,
}: PlayerAvatarProps) {
const initials = getInitials(playerName)
return (
<Avatar className={className}>
<AvatarImage src={img} alt={playerName} />
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
)
}
function getInitials(name: string) {
if (!name || name === 'TBD') return '?'
const parts = name.split(' ')
if (parts.length === 1) return name.substring(0, 2).toUpperCase()
return (
(parts?.[0]?.[0] ?? '') + (parts[parts.length - 1]?.[0] ?? '')
).toUpperCase()
}

View File

@@ -0,0 +1,54 @@
import type { Player } from '@/app/(home)/major-league-balatro/types'
import { OptimizedImage } from '@/components/optimized-image'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { SiTwitch, SiYoutube } from '@icons-pack/react-simple-icons'
import Link from 'next/link'
export type PlayerCardProps = {
picture: string
name: string
socials: Player['socials']
}
export function PlayerCard(player: PlayerCardProps) {
return (
<Card className='overflow-hidden py-0'>
<div className='relative aspect-square bg-muted'>
<div className='absolute inset-0 flex items-center justify-center'>
<OptimizedImage
src={player.picture}
alt={player.name}
className={'h-full w-full object-cover'}
/>
</div>
</div>
<CardContent className='p-4'>
<h3 className='font-bold text-lg'>{player.name}</h3>
<div className={'flex items-center justify-between gap-1'}>
<div>@{player.socials.twitch || player.socials.youtube || ''}</div>
<div className={'flex items-center gap-3'}>
{player.socials.twitch && (
<Link
href={`https://twitch.tv/${player.socials.twitch}`}
target={'_blank'}
rel={'noopener noreferrer'}
>
<SiTwitch className={'size-4'} />
</Link>
)}
{player.socials.youtube && (
<Link
href={`https://youtube.com/@${player.socials.youtube}`}
target={'_blank'}
rel={'noopener noreferrer'}
>
<SiYoutube className={'size-4'} />
</Link>
)}
</div>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,45 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator'
import { Heart } from 'lucide-react'
export function PrizePool() {
return (
<section className='container py-8 md:py-12'>
<div className='mx-auto flex max-w-[58rem] flex-col items-center space-y-4 text-center'>
<h2 className='font-bold text-3xl leading-tight tracking-tighter md:text-4xl'>
Prize Pool
</h2>
<p className='max-w-[85%] text-muted-foreground leading-normal sm:text-lg sm:leading-7'>
Playing for charity and glory.
</p>
</div>
<div className='mx-auto mt-8 max-w-3xl'>
<Card>
<CardHeader>
<CardTitle className='flex items-center justify-center gap-2'>
<Heart className='h-5 w-5 text-red-500' />
Charity Prize Pool
</CardTitle>
</CardHeader>
<CardContent className='text-center'>
<div className='mb-4 font-bold text-5xl'>
$500<span className='text-red-500'>+</span>
</div>
<p className='mb-6 text-muted-foreground'>
The prize pool is creator-funded and will be donated to the
winner's charity of choice. We're hoping to add to this amount as
the tournament progresses.
</p>
<Separator className='my-6' />
<div className='text-muted-foreground text-sm'>
Each creator is playing for a charity of their choice. The full
amount will be donated to the champion's selected charity at the
conclusion of the tournament.
</div>
</CardContent>
</Card>
</div>
</section>
)
}

View File

@@ -0,0 +1,386 @@
import { players } from '@/app/(home)/major-league-balatro/_constants/players'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { cn } from '@/lib/utils'
import { SiTwitch, SiYoutube } from '@icons-pack/react-simple-icons'
import {
Calendar,
Camera,
Clock,
TvMinimalPlay,
Twitch,
Youtube,
} from 'lucide-react'
import Link from 'next/link'
import { useMemo } from 'react'
import type { BadgeProps, Match, WeekConfig } from '../types'
import { PlayerAvatar } from './player-avatar'
const WEEK_CONFIG: Record<string | number, WeekConfig> = {
1: {
label: 'Week 1: April 6-12, 2025',
badgeProps: {
variant: 'outline',
className: 'border-green-500 bg-green-500/10 text-green-500',
text: 'Completed',
},
},
2: {
label: 'Week 2: April 13-19, 2025',
badgeProps: {
variant: 'outline',
className: 'border-blue-500 bg-blue-500/10 text-blue-500',
text: 'Current Week',
},
},
3: {
label: 'Week 3: April 20-26, 2025',
},
4: {
label: 'Week 4: April 27-May 3, 2025',
},
// 5: {
// label: 'Week 5: May 4-10, 2025',
// },
// 'Play-in': {
// label: 'Play-in Week: May 11-16, 2025',
// status: 'playoff',
// badgeProps: {
// variant: 'outline',
// className: 'border-purple-500 bg-purple-500/10 text-purple-500',
// text: 'Playoff',
// },
// },
// Finals: {
// label: 'League Finals: May 17, 2025',
// status: 'finals',
// badgeProps: {
// variant: 'outline',
// className: 'border-red-500 bg-red-500/10 text-red-500',
// text: 'Championship',
// },
// },
}
const DEFAULT_BADGE_PROPS: BadgeProps = {
variant: 'outline',
className: 'bg-muted text-muted-foreground',
text: 'Upcoming',
}
type StatusBadgeProps = {
status: 'completed' | 'current' | 'upcoming'
}
const StatusBadge = ({ status }: StatusBadgeProps) => {
const { variant, className } =
WEEK_CONFIG[status]?.badgeProps || DEFAULT_BADGE_PROPS
const text =
status === 'completed'
? 'Completed'
: status === 'current'
? 'Current Week'
: 'Upcoming'
return (
<Badge
variant={variant}
className={cn(
status === 'completed'
? 'border-green-500 bg-green-500/10 text-green-500'
: status === 'current'
? 'border-blue-500 bg-blue-500/10 text-blue-500'
: 'bg-muted text-muted-foreground'
)}
>
{text}
</Badge>
)
}
type MatchDivisionProps = {
division: 'Blue' | 'Red'
}
const MatchDivision = ({ division }: MatchDivisionProps) => {
const bgColor = division === 'Blue' ? 'bg-blue-950/20' : 'bg-red-950/20'
const textColor =
division === 'Blue'
? 'border-blue-500 text-blue-500'
: 'border-red-500 text-red-500'
return (
<div className={`flex items-center justify-center p-4 sm:w-1/4 ${bgColor}`}>
<Badge variant='outline' className={textColor}>
{division} Division
</Badge>
</div>
)
}
type MatchDateTimeProps = {
date: string
time: string
}
const MatchDateTime = ({ date, time }: MatchDateTimeProps) => (
<div className='flex items-center gap-2 text-muted-foreground text-sm'>
<Calendar className='h-4 w-4' />
<span>{date}</span>
<Clock className='ml-2 h-4 w-4' />
<span>{time}</span>
</div>
)
type VodButtonProps = {
url: string
player: string
}
const VodButton = ({ url, player }: VodButtonProps) => (
<Link href={url} target='_blank' rel='noopener noreferrer'>
<Button variant='outline' size='sm' className='gap-1'>
<Youtube className='h-4 w-4' />
{player}
</Button>
</Link>
)
type LiveButtonProps = {
username: string
}
const LiveButton = ({ username }: LiveButtonProps) => (
<Link
href={`https://twitch.tv/${username.toLowerCase()}`}
target='_blank'
rel='noopener noreferrer'
>
<Button variant='outline' size='sm' className='gap-1'>
<Twitch className='h-4 w-4' />
Watch Live
</Button>
</Link>
)
type MatchCardProps = {
match: Match
}
const MatchCard = ({ match }: MatchCardProps) => {
const { player1Id, player2Id, date, time, completed, vod1, vod2 } = match
const player1 = players[player1Id]
const player2 = players[player2Id]
if (!player1) {
throw new Error(`Player ${player1Id} not found`)
}
if (!player2) {
throw new Error(`Player ${player2Id} not found`)
}
return (
<Card className='overflow-hidden'>
<div className='flex flex-col sm:flex-row'>
<div className='flex items-center justify-center p-4 sm:w-1/4'>
<div className='text-muted-foreground text-sm'>
{date} {time}
</div>
</div>
<div className='p-4 sm:w-3/4'>
<div className='flex flex-col justify-between gap-4 sm:flex-row sm:items-center'>
<div className='flex items-center gap-3'>
<div className='-space-x-4 flex'>
<PlayerAvatar playerName={player1.name} img={player1.picture} />
<PlayerAvatar playerName={player2.name} img={player2.picture} />
</div>
<div>
<h3 className='font-bold text-lg'>
{player1.name} vs {player2.name}
</h3>
</div>
</div>
<div className='flex gap-2'>
{completed ? (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant='outline' size='sm' className='gap-1'>
<TvMinimalPlay className='size-4' />
Watch
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{vod1 && (
<DropdownMenuItem asChild>
<Link
href={vod1}
target='_blank'
rel='noopener noreferrer'
>
<SiYoutube className='h-4 w-4' />
{player1.name}
</Link>
</DropdownMenuItem>
)}
{vod2 && (
<DropdownMenuItem asChild>
<Link
href={vod2}
target='_blank'
rel='noopener noreferrer'
>
<SiYoutube className='h-4 w-4' />
{player2.name}
</Link>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</>
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant='outline' size='sm' className='gap-1'>
<SiTwitch className='size-4' />
Watch Live
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem asChild>
<Link
href={`https://twitch.tv/${player1.socials.twitch}`}
target='_blank'
rel='noopener noreferrer'
>
{player1.name}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
href={`https://twitch.tv/${player2.socials.twitch}`}
target='_blank'
rel='noopener noreferrer'
>
{player2.name}
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
</div>
</div>
</Card>
)
}
type WeekTabProps = {
week: string | number
matches: Match[]
status: 'current' | 'completed' | 'upcoming'
}
const WeekTab = ({ week, matches, status }: WeekTabProps) => {
const weekConfig = WEEK_CONFIG[week]
if (!weekConfig) {
throw new Error(`Week ${week} not found in WEEK_CONFIG`)
}
const filteredMatches = useMemo(
() =>
matches.filter(
(m) => m.week === week || m.week === Number.parseInt(String(week))
),
[matches, week]
)
return (
<TabsContent value={`week${week}`} className='space-y-6'>
<div className='mb-4 flex items-center justify-between'>
<h3 className='font-bold text-xl'>{weekConfig.label}</h3>
<StatusBadge status={status} />
</div>
{filteredMatches.map((match, index) => (
<MatchCard key={index} match={match} />
))}
</TabsContent>
)
}
type MlbScheduleProps = {
matches: Match[]
}
export function MlbSchedule({ matches }: MlbScheduleProps) {
const sortedMatches = useMemo(
() =>
[...matches].sort((a, b) => {
if (typeof a.week === 'string' || typeof b.week === 'string') {
return String(a.week).localeCompare(String(b.week))
}
return a.week - b.week
}),
[matches]
)
const currentWeek = useMemo(() => {
const now = new Date()
return (
sortedMatches.find((match) => new Date(match.datetime) > now)?.week || 1
)
}, [sortedMatches])
console.log(currentWeek)
return (
<section id='schedule' className='container py-8 md:py-12'>
<div className='mx-auto flex max-w-[58rem] flex-col items-center space-y-4 text-center'>
<h2 className='font-bold text-3xl leading-tight tracking-tighter md:text-4xl'>
Tournament Schedule
</h2>
<p className='max-w-[85%] text-muted-foreground leading-normal sm:text-lg sm:leading-7'>
All matches from April to May 2025
</p>
</div>
<div className='mx-auto mt-8 max-w-5xl'>
<Tabs defaultValue={`week${currentWeek}`}>
<TabsList className='mb-4 flex flex-wrap justify-center'>
{Object.keys(WEEK_CONFIG).map((week) => (
<TabsTrigger key={week} value={`week${week}`}>
Week {week}
</TabsTrigger>
))}
</TabsList>
{Object.keys(WEEK_CONFIG).map((week) => {
const weekNumber = Number.parseInt(week)
const status =
!Number.isNaN(weekNumber) && weekNumber === currentWeek
? 'current'
: // @ts-ignore
weekNumber > currentWeek
? 'upcoming'
: 'completed'
return (
<WeekTab
key={week}
week={week}
matches={sortedMatches}
status={status}
/>
)
})}
</Tabs>
</div>
</section>
)
}

View File

@@ -0,0 +1,38 @@
'use client'
import { Button } from '@/components/ui/button'
import { Eye, EyeOff } from 'lucide-react'
import { useState } from 'react'
export function Standings() {
const [showResults, setShowResults] = useState(false)
return (
<section id='results' className='container py-8 md:py-12'>
<div className='mx-auto flex max-w-[58rem] flex-col items-center space-y-4 text-center'>
<h2 className='font-bold text-3xl leading-tight tracking-tighter md:text-4xl'>
Tournament Standings
</h2>
<p className='max-w-[85%] text-muted-foreground leading-normal sm:text-lg sm:leading-7'>
Current standings after Week 1
</p>
<Button
variant='outline'
onClick={() => setShowResults(!showResults)}
className='mt-4'
>
{showResults ? (
<>
<EyeOff className='mr-2 h-4 w-4' />
Hide Results
</>
) : (
<>
<Eye className='mr-2 h-4 w-4' />
Show Results
</>
)}
</Button>
</div>
</section>
)
}

View File

@@ -0,0 +1,44 @@
import { Button } from '@/components/ui/button'
import { SiBluesky, SiYoutube } from '@icons-pack/react-simple-icons'
import Link from 'next/link'
export function StayUpdated() {
return (
<section className='bg-muted py-12 md:py-16'>
<div className='container flex flex-col items-center justify-center gap-4 text-center'>
<h2 className='font-bold text-3xl tracking-tighter sm:text-4xl'>
Stay Updated
</h2>
<p className='max-w-[600px] text-muted-foreground md:text-xl/relaxed'>
Follow the official channels for weekly recaps, schedules, and
standings.
</p>
<div className='mt-4 flex flex-col gap-4 sm:flex-row'>
<Link
href='https://youtube.com/@ZainoTVLive'
target='_blank'
rel='noopener noreferrer'
>
<Button
size='lg'
className='w-full bg-red-600 text-white hover:bg-red-700 sm:w-auto'
>
<SiYoutube className='ml-2 h-4 w-4' />
YouTube Channel
</Button>
</Link>
<Link
href='https://bsky.app/profile/majorleaguebalatro.bsky.social'
target='_blank'
rel='noopener noreferrer'
>
<Button variant='outline' size='lg' className='w-full sm:w-auto'>
<SiBluesky className='ml-2 h-4 w-4' />
Bluesky Updates
</Button>
</Link>
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,107 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Trophy, Users } from 'lucide-react'
export function TournamentFormat() {
return (
<section id='format' className='container py-8 md:py-12'>
<div className='mx-auto flex max-w-[58rem] flex-col items-center space-y-4 text-center'>
<h2 className='font-bold text-3xl leading-tight tracking-tighter md:text-4xl'>
Tournament Format
</h2>
<p className='max-w-[85%] text-muted-foreground leading-normal sm:text-lg sm:leading-7'>
Major League Balatro features 12 creators competing in a structured
league format.
</p>
</div>
<div className='mx-auto mt-8 grid max-w-5xl gap-8 md:grid-cols-2'>
<Card>
<CardHeader>
<CardTitle className='flex items-center gap-2'>
<Users className='h-5 w-5 text-red-500' />
League Structure
</CardTitle>
</CardHeader>
<CardContent className='space-y-4'>
<ul className='space-y-2'>
<li className='flex items-start gap-2'>
<span className='font-medium'>
12 creators split into 2 divisions (Blue & Red)
</span>
</li>
<li className='flex items-start gap-2'>
<span className='font-medium'>
Each creator plays 5 matches (one against each division
member)
</span>
</li>
<li className='flex items-start gap-2'>
<span className='font-medium'>
1st place in each division advances to Final Four
</span>
</li>
<li className='flex items-start gap-2'>
<span className='font-medium'>
2nd and 3rd place enter a playoff round robin
</span>
</li>
<li className='flex items-start gap-2'>
<span className='font-medium'>
Top 2 from playoff round robin advance to Final Four
</span>
</li>
<li className='flex items-start gap-2'>
<span className='font-medium'>
Finals on Saturday, May 17th, 2025
</span>
</li>
</ul>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className='flex items-center gap-2'>
<Trophy className='h-5 w-5 text-red-500' />
Match Format
</CardTitle>
</CardHeader>
<CardContent className='space-y-4'>
<ul className='space-y-2'>
<li className='flex items-start gap-2'>
<span className='font-medium'>
League matches: Best of 3 games
</span>
</li>
<li className='flex items-start gap-2'>
<span className='font-medium'>
Playoff matches: Best of 5 games
</span>
</li>
<li className='flex items-start gap-2'>
<span className='font-medium'>
1 point per game win + 1 point for match win
</span>
</li>
<li className='flex items-start gap-2'>
<span className='font-medium'>
Alternating deck and stake selection
</span>
</li>
<li className='flex items-start gap-2'>
<span className='font-medium'>
Vanilla ruleset (no multiplayer jokers)
</span>
</li>
<li className='flex items-start gap-2'>
<span className='font-medium'>
PvP battles after Ante-1 where lower score loses a life
</span>
</li>
</ul>
</CardContent>
</Card>
</div>
</section>
)
}

View File

@@ -0,0 +1,456 @@
import type { Match } from '../types'
export const matches: Match[] = [
// Week 1
{
id: 1,
division: 'Blue',
player1Id: 'roffle',
player2Id: 'haelian',
date: 'April 9, 2025',
time: '3:00 PM EDT',
datetime: new Date('2025-04-09T15:00:00-04:00'),
vod1: 'https://www.twitch.tv/videos/2428370737?t=01h26m15s',
vod2: 'https://www.twitch.tv/videos/2428334601?t=02h10m32s',
completed: true,
week: 1,
},
{
id: 2,
division: 'Blue',
player1Id: 'zainotv',
player2Id: 'bear',
date: 'April 7, 2025',
time: '8:00 PM EDT',
datetime: new Date('2025-04-07T20:00:00-04:00'),
vod1: 'https://youtu.be/NCsQ3PePAdc',
vod2: 'https://www.twitch.tv/videos/2426932234?t=00h20m47s',
completed: true,
week: 1,
},
{
id: 3,
division: 'Blue',
player1Id: 'gothic',
player2Id: 'neato',
date: 'April 9, 2025',
time: '1:00 PM EDT',
datetime: new Date('2025-04-09T13:00:00-04:00'),
vod1: 'https://youtu.be/BAcx4fih1V4',
vod2: 'https://www.twitch.tv/videos/2428289132?t=01h56m48s',
completed: true,
week: 1,
},
{
id: 4,
division: 'Red',
player1Id: 'drspectred',
player2Id: 'malf',
date: 'April 7, 2025',
time: '11:00 AM EDT',
datetime: new Date('2025-04-07T11:00:00-04:00'),
vod1: 'https://www.twitch.tv/videos/2426535670?t=01h08m29s',
vod2: 'https://www.twitch.tv/videos/2426478651?t=02h52m39s',
completed: true,
week: 1,
},
{
id: 5,
division: 'Red',
player1Id: 'skoottie',
player2Id: 'nandre',
date: 'April 7, 2025',
time: '9:00 PM EDT',
datetime: new Date('2025-04-07T21:00:00-04:00'),
vod1: 'https://www.twitch.tv/videos/2426992443?t=00h15m19s',
vod2: 'https://www.twitch.tv/videos/2426985590?t=00h22m44s',
completed: true,
week: 1,
},
{
id: 6,
division: 'Red',
player1Id: 'edzy',
player2Id: 'seadubbs',
date: 'April 8, 2025',
time: '6:30 PM EDT',
datetime: new Date('2025-04-08T18:30:00-04:00'),
vod1: 'https://www.twitch.tv/videos/2427881454?t=01h13m54s',
vod2: 'https://www.twitch.tv/videos/2427647925?t=01h51m03s',
completed: true,
week: 1,
},
// Week 2
{
id: 7,
division: 'Blue',
player1Id: 'roffle',
player2Id: 'zainotv',
date: 'April 15, 2025',
time: '5:00 PM EDT',
datetime: new Date('2025-04-15T17:00:00-04:00'),
vod1: 'https://www.twitch.tv/videos/2433708429?t=2h31m42s',
vod2: 'https://www.twitch.tv/videos/2433805983?t=0h29m52s',
completed: true,
week: 2,
},
{
id: 8,
division: 'Blue',
player1Id: 'haelian',
player2Id: 'gothic',
date: 'April 16, 2025',
time: '3:00 PM EDT',
datetime: new Date('2025-04-16T15:00:00-04:00'),
vod1: 'https://www.twitch.tv/videos/2434505224?t=2h4m35s',
vod2: 'https://www.twitch.tv/videos/2434576019?t=0h38m11s',
completed: true,
week: 2,
},
{
id: 9,
division: 'Blue',
player1Id: 'bear',
player2Id: 'neato',
date: 'April 16, 2025',
time: '1:00 PM EDT',
datetime: new Date('2025-04-16T13:00:00-04:00'),
vod1: 'https://www.twitch.tv/videos/2434505033?t=0h9m42s',
vod2: 'https://www.twitch.tv/videos/2434499724?t=0h16m5s',
completed: true,
week: 2,
},
{
id: 10,
division: 'Red',
player1Id: 'drspectred',
player2Id: 'skoottie',
date: 'April 14, 2025',
time: '1:00 PM EDT',
datetime: new Date('2025-04-14T13:00:00-04:00'),
vod1: 'https://www.twitch.tv/videos/2432780721?t=0h6m52s',
vod2: 'https://www.twitch.tv/videos/2432776421?t=0h12m29s',
completed: true,
week: 2,
},
{
id: 11,
division: 'Red',
player1Id: 'malf',
player2Id: 'edzy',
date: 'April 17, 2025',
time: '2:00 PM EDT',
datetime: new Date('2025-04-17T14:00:00-04:00'),
vod1: 'https://www.twitch.tv/videos/2435238972?t=4h54m48s',
vod2: 'https://www.twitch.tv/videos/2435320190?t=2h37m18s',
completed: true,
week: 2,
},
{
id: 12,
division: 'Red',
player1Id: 'nandre',
player2Id: 'seadubbs',
date: 'April 15, 2025',
time: '6:30 PM EDT',
datetime: new Date('2025-04-15T18:30:00-04:00'),
vod1: 'https://www.twitch.tv/videos/2433882453?t=0h11m14s',
vod2: 'https://www.twitch.tv/videos/2433878480?t=0h16m39s',
completed: true,
week: 2,
},
// Week 3
{
id: 13,
division: 'Blue',
player1Id: 'roffle',
player2Id: 'bear',
date: 'April 21, 2025',
time: '5:00 PM EDT',
datetime: new Date('2025-04-21T17:00:00-04:00'),
vod1: 'https://www.twitch.tv/videos/2439002527?t=2h35m39s',
vod2: 'https://www.twitch.tv/videos/2439115722?t=0h9m57s',
completed: true,
week: 3,
},
{
id: 14,
division: 'Blue',
player1Id: 'zainotv',
player2Id: 'gothic',
date: 'April 21, 2025',
time: '4:00 PM EDT',
datetime: new Date('2025-04-21T16:00:00-04:00'),
vod1: 'https://www.twitch.tv/videos/2439061383?t=0h19m32s',
vod2: 'https://www.twitch.tv/videos/2439069703?t=0h8m18s',
completed: true,
week: 3,
},
{
id: 15,
division: 'Blue',
player1Id: 'haelian',
player2Id: 'neato',
date: 'April 23, 2025',
time: '1:00 PM EDT',
datetime: new Date('2025-04-23T13:00:00-04:00'),
vod1: 'https://www.twitch.tv/videos/2440603518?t=0h42m56s',
vod2: 'https://www.twitch.tv/videos/2440622793?t=0h15m35s',
completed: true,
week: 3,
},
{
id: 16,
division: 'Red',
player1Id: 'drspectred',
player2Id: 'nandre',
date: 'April 22, 2025',
time: '1:00 PM EDT',
datetime: new Date('2025-04-22T13:00:00-04:00'),
vod1: 'https://www.twitch.tv/videos/2439955669?t=0h26m44s',
vod2: 'https://www.twitch.tv/videos/2439965151?t=0h13m20s',
completed: true,
week: 3,
},
{
id: 17,
division: 'Red',
player1Id: 'malf',
player2Id: 'seadubbs',
date: 'April 25, 2025',
time: '11:00 AM EDT',
datetime: new Date('2025-04-25T11:00:00-04:00'),
completed: false,
week: 3,
},
{
id: 18,
division: 'Red',
player1Id: 'skoottie',
player2Id: 'edzy',
date: 'April 25, 2025',
time: '3:00 PM EDT',
datetime: new Date('2025-04-25T15:00:00-04:00'),
completed: false,
week: 3,
},
// Week 4
{
id: 19,
division: 'Blue',
player1Id: 'roffle',
player2Id: 'gothic',
date: 'April 29, 2025',
time: '5:00 PM EDT',
datetime: new Date('2025-04-29T17:00:00-04:00'),
completed: false,
week: 4,
},
{
id: 20,
division: 'Blue',
player1Id: 'haelian',
player2Id: 'bear',
date: 'April 28, 2025',
time: '5:00 PM EDT',
datetime: new Date('2025-04-28T17:00:00-04:00'),
completed: false,
week: 4,
},
{
id: 21,
division: 'Blue',
player1Id: 'zainotv',
player2Id: 'neato',
date: 'May 1, 2025',
time: '5:00 PM EDT',
datetime: new Date('2025-05-01T17:00:00-04:00'),
completed: false,
week: 4,
},
{
id: 22,
division: 'Red',
player1Id: 'drspectred',
player2Id: 'edzy',
date: 'April 30, 2025',
time: '1:00 PM EDT',
datetime: new Date('2025-04-30T13:00:00-04:00'),
completed: false,
week: 4,
},
{
id: 23,
division: 'Red',
player1Id: 'malf',
player2Id: 'nandre',
date: 'April 30, 2025',
time: '2:00 PM EDT',
datetime: new Date('2025-04-30T14:00:00-04:00'),
completed: false,
week: 4,
},
{
id: 24,
division: 'Red',
player1Id: 'skoottie',
player2Id: 'seadubbs',
date: 'May 2, 2025',
time: '3:00 PM EDT',
datetime: new Date('2025-05-02T15:00:00-04:00'),
completed: false,
week: 4,
},
// // Week 5
// {
// id: 25,
// division: 'Blue',
// player1Id: 'roffle',
// player2Id: 'neato',
// date: 'May 5, 2025',
// time: '5:00 PM EDT',
// datetime: new Date('2025-05-05T17:00:00-04:00'),
// completed: false,
// week: 5,
// },
// {
// id: 26,
// division: 'Blue',
// player1Id: 'haelian',
// player2Id: 'zainotv',
// date: 'May 7, 2025',
// time: '3:00 PM EDT',
// datetime: new Date('2025-05-07T15:00:00-04:00'),
// completed: false,
// week: 5,
// },
// {
// id: 27,
// division: 'Blue',
// player1Id: 'bear',
// player2Id: 'gothic',
// date: 'May 8, 2025',
// time: '1:00 PM EDT',
// datetime: new Date('2025-05-08T13:00:00-04:00'),
// completed: false,
// week: 5,
// },
// {
// id: 28,
// division: 'Red',
// player1Id: 'drspectred',
// player2Id: 'seadubbs',
// date: 'May 5, 2025',
// time: '1:00 PM EDT',
// datetime: new Date('2025-05-05T13:00:00-04:00'),
// completed: false,
// week: 5,
// },
// {
// id: 29,
// division: 'Red',
// player1Id: 'malf',
// player2Id: 'skoottie',
// date: 'May 6, 2025',
// time: '2:00 PM EDT',
// datetime: new Date('2025-05-06T14:00:00-04:00'),
// completed: false,
// week: 5,
// },
// {
// id: 30,
// division: 'Red',
// player1Id: 'nandre',
// player2Id: 'edzy',
// date: 'May 9, 2025',
// time: '6:30 PM EDT',
// datetime: new Date('2025-05-09T18:30:00-04:00'),
// completed: false,
// week: 5,
// },
//
// // Play-in Week
// {
// id: 31,
// division: 'Playoff',
// player1Id: 'tbd',
// player2Id: 'tbd',
// date: 'May 12, 2025',
// time: '5:00 PM EDT',
// datetime: new Date('2025-05-12T17:00:00-04:00'),
// completed: false,
// week: 'Play-in',
// },
// {
// id: 32,
// division: 'Playoff',
// player1Id: 'tbd',
// player2Id: 'tbd',
// date: 'May 13, 2025',
// time: '5:00 PM EDT',
// datetime: new Date('2025-05-13T17:00:00-04:00'),
// completed: false,
// week: 'Play-in',
// },
// {
// id: 33,
// division: 'Playoff',
// player1Id: 'tbd',
// player2Id: 'tbd',
// date: 'May 14, 2025',
// time: '5:00 PM EDT',
// datetime: new Date('2025-05-14T17:00:00-04:00'),
// completed: false,
// week: 'Play-in',
// },
// {
// id: 34,
// division: 'Playoff',
// player1Id: 'tbd',
// player2Id: 'tbd',
// date: 'May 15, 2025',
// time: '5:00 PM EDT',
// datetime: new Date('2025-05-15T17:00:00-04:00'),
// completed: false,
// week: 'Play-in',
// },
//
// // Finals
// {
// id: 35,
// division: 'Finals',
// player1Id: 'tbd',
// player2Id: 'tbd',
// date: 'May 17, 2025',
// time: '11:00 AM EDT',
// datetime: new Date('2025-05-17T11:00:00-04:00'),
// completed: false,
// week: 'Finals',
// },
// {
// id: 36,
// division: 'Finals',
// player1Id: 'tbd',
// player2Id: 'tbd',
// date: 'May 17, 2025',
// time: '1:00 PM EDT',
// datetime: new Date('2025-05-17T13:00:00-04:00'),
// completed: false,
// week: 'Finals',
// },
// {
// id: 37,
// division: 'Finals',
// player1Id: 'tbd',
// player2Id: 'tbd',
// date: 'May 17, 2025',
// time: '3:00 PM EDT',
// datetime: new Date('2025-05-17T15:00:00-04:00'),
// completed: false,
// week: 'Finals',
// },
]

View File

@@ -0,0 +1,123 @@
import type { Player } from '../types'
export const players: Record<string, Player> = {
roffle: {
id: 'roffle',
name: 'Roffle',
division: 'Blue',
picture: '/images/players/roffle.png',
socials: {
twitch: 'roffle',
youtube: 'RoffleLite',
},
},
haelian: {
id: 'haelian',
name: 'Haelian',
division: 'Blue',
picture: '/images/players/haelian.png',
socials: {
twitch: 'haelian',
youtube: 'Haelian',
},
},
zainotv: {
id: 'zainotv',
name: 'ZainoTV',
division: 'Blue',
picture: '/images/players/zaino.png',
socials: {
twitch: 'zainotv',
youtube: 'ZainoTVLive',
},
},
bear: {
id: 'bear',
name: 'Bear',
division: 'Blue',
picture: '/images/players/bear.png',
socials: {
twitch: 'belenosbear',
youtube: 'BelenosBear',
},
},
gothic: {
id: 'gothic',
name: 'Gothic',
division: 'Blue',
picture: '/images/players/gothic.png',
socials: {
twitch: 'gothiclorduk',
youtube: 'GothicLordUK',
},
},
neato: {
id: 'neato',
name: 'NEATO',
division: 'Blue',
picture: '/images/players/neato.jpg',
socials: {
twitch: 'neato',
youtube: 'neatoqueen',
},
},
drspectred: {
id: 'drspectred',
name: 'DrSpectred',
division: 'Red',
picture: '/images/players/doc.png',
socials: {
twitch: 'drspectred',
youtube: 'drspectred',
},
},
malf: {
id: 'malf',
name: 'MALF',
division: 'Red',
picture: '/images/players/malf.png',
socials: {
twitch: 'michaelalfox',
youtube: 'michaelalfox',
},
},
skoottie: {
id: 'skoottie',
name: 'Skoottie',
division: 'Red',
picture: '/images/players/skoottie.jpg',
socials: {
twitch: 'skoottie',
youtube: 'Skoottie',
},
},
nandre: {
id: 'nandre',
name: 'Nandre',
division: 'Red',
picture: '/images/players/nandre.jpg',
socials: {
twitch: 'nandre',
},
},
edzy: {
id: 'edzy',
name: 'Edzy',
division: 'Red',
picture: '/images/players/edzy.png',
socials: {
twitch: 'edzyttv',
youtube: 'EdzyTTV',
},
},
seadubbs: {
id: 'seadubbs',
name: 'Seadubbs',
division: 'Red',
picture: '/images/players/seadubbs.png',
socials: {
twitch: 'seadubbs11',
youtube: 'seadubbs11',
},
},
}

View File

@@ -0,0 +1,76 @@
import { Standings } from '@/app/(home)/major-league-balatro/_components/standings'
import Link from 'next/link'
import { Competitors } from './_components/competitors'
import { HeroSection } from './_components/hero'
import { NextMatchInfo } from './_components/next-match-info'
import { Organizer } from './_components/organizer'
import { PrizePool } from './_components/prize-pool'
import { MlbSchedule } from './_components/schedule'
import { StayUpdated } from './_components/stay-updated'
import { TournamentFormat } from './_components/tournament-format'
import { matches } from './_constants/matches'
export default function MLBPage() {
const currentDate = new Date()
// Update the next match calculation to filter out completed matches
const nextMatch = matches
.filter((match) => !match.completed && match.datetime > currentDate)
.sort((a, b) => a.datetime.getTime() - b.datetime.getTime())[0]
if (!nextMatch) {
return 'All matches have been played'
}
return (
<div className='flex min-h-screen flex-col'>
<main className='flex-1'>
<HeroSection />
<NextMatchInfo nextMatch={nextMatch} />
{/*<Standings />*/}
<TournamentFormat />
<Competitors />
<MlbSchedule matches={matches} />
<PrizePool />
<Organizer />
<StayUpdated />
</main>
<footer className='border-t py-6 md:py-0'>
<div className='container flex flex-col items-center justify-between gap-4 md:h-24 md:flex-row'>
<p className='text-center text-muted-foreground text-sm leading-loose md:text-left'>
&copy; {new Date().getFullYear()} Major League Balatro. All rights
reserved.
</p>
<div className='flex gap-4'>
<Link
href='/'
className='text-muted-foreground text-sm underline-offset-4 hover:underline'
>
Balatro Multiplayer
</Link>
<Link
href='/docs'
className='text-muted-foreground text-sm underline-offset-4 hover:underline'
>
Documentation
</Link>
<Link
href='/about'
className='text-muted-foreground text-sm underline-offset-4 hover:underline'
>
About
</Link>
</div>
</div>
</footer>
</div>
)
}

View File

@@ -0,0 +1,43 @@
import type { players } from './_constants/players'
export type Player = {
id: string
name: string
division: 'Blue' | 'Red'
picture: string
socials: {
twitch?: string
youtube?: string
}
}
export type Match = {
id: number
division: 'Blue' | 'Red' | 'Playoff' | 'Finals'
player1Id: keyof typeof players
player2Id: keyof typeof players
date: string
time: string
datetime: Date
vod1?: string
vod2?: string
completed: boolean
week: number | 'Play-in' | 'Finals'
}
export type WeekStatus =
| 'completed'
| 'current'
| 'upcoming'
| 'playoff'
| 'finals'
export type BadgeProps = {
variant: 'outline'
className: string
text: string
}
export type WeekConfig = {
label: string
badgeProps?: BadgeProps
}

Binary file not shown.

View File

@@ -18,6 +18,16 @@ const links = [
url: '/about', url: '/about',
icon: <Info />, icon: <Info />,
}, },
{
type: 'menu' as const,
text: 'Major League Balatro',
items: [
{
text: 'About',
url: '/major-league-balatro',
},
],
},
{ {
text: 'Support Mod Development', text: 'Support Mod Development',
url: 'https://ko-fi.com/virtualized/shop', url: 'https://ko-fi.com/virtualized/shop',

View File

@@ -8,7 +8,7 @@ import { NextIntlClientProvider } from 'next-intl'
import { getLocale } from 'next-intl/server' import { getLocale } from 'next-intl/server'
import PlausibleProvider from 'next-plausible' import PlausibleProvider from 'next-plausible'
import { Geist } from 'next/font/google' import { Geist } from 'next/font/google'
import localFont from 'next/font/local'
export const metadata: Metadata = { export const metadata: Metadata = {
title: { title: {
template: '%s | Balatro Multiplayer', template: '%s | Balatro Multiplayer',
@@ -24,6 +24,12 @@ const geist = Geist({
variable: '--font-geist-sans', variable: '--font-geist-sans',
}) })
const m6x11 = localFont({
src: './_assets/fonts/m6x11.ttf',
display: 'swap',
variable: '--font-m6x11',
})
export default async function RootLayout({ export default async function RootLayout({
children, children,
}: Readonly<{ children: React.ReactNode }>) { }: Readonly<{ children: React.ReactNode }>) {
@@ -31,7 +37,7 @@ export default async function RootLayout({
return ( return (
<html <html
lang={locale} lang={locale}
className={`${geist.variable}`} className={`${geist.variable} ${m6x11.variable}`}
suppressHydrationWarning suppressHydrationWarning
> >
<head> <head>

View File

@@ -50,6 +50,7 @@ function getPlayerData(
meaningful_games > 0 ? Math.floor((losses / meaningful_games) * 100) : 0, meaningful_games > 0 ? Math.floor((losses / meaningful_games) * 100) : 0,
rank: playerLeaderboardEntry.rank, rank: playerLeaderboardEntry.rank,
mmr: Math.round(playerLeaderboardEntry.mmr), mmr: Math.round(playerLeaderboardEntry.mmr),
mmrChangeRaw: lastGame?.mmrChange,
mmrChange: `${(lastGame?.mmrChange ?? 0) >= 0 ? '+' : ''}${Math.round( mmrChange: `${(lastGame?.mmrChange ?? 0) >= 0 ? '+' : ''}${Math.round(
lastGame?.mmrChange ?? 0 lastGame?.mmrChange ?? 0
)}`, )}`,
@@ -93,8 +94,11 @@ export function StreamCardClient() {
const isQueuing = playerState?.status === 'queuing' const isQueuing = playerState?.status === 'queuing'
const opponentId = playerState?.currentMatch?.opponentId const opponentId = playerState?.currentMatch?.opponentId
return ( return (
<div className={'flex items-center gap-2'} style={{ zoom: '200%' }}> <div
<PlayerInfo playerData={playerData}> className={'flex items-center justify-between gap-2 font-m6x11'}
style={{ zoom: '200%' }}
>
<PlayerInfo playerData={playerData} isInBattle={!!opponentId}>
{isQueuing && playerState.queueStartTime && ( {isQueuing && playerState.queueStartTime && (
<QueueTimer startTime={playerState.queueStartTime} /> <QueueTimer startTime={playerState.queueStartTime} />
)} )}
@@ -160,7 +164,7 @@ function Opponent({ id }: { id: string }) {
} }
const playerData = getPlayerData(rankedUserRank, games) const playerData = getPlayerData(rankedUserRank, games)
return <PlayerInfo playerData={playerData} isReverse /> return <PlayerInfo playerData={playerData} isReverse isInBattle />
} }
function PlayerInfo({ function PlayerInfo({
@@ -168,10 +172,12 @@ function PlayerInfo({
className, className,
children, children,
isReverse = false, isReverse = false,
isInBattle = false,
...rest ...rest
}: { }: {
playerData: ReturnType<typeof getPlayerData> playerData: ReturnType<typeof getPlayerData>
isReverse?: boolean isReverse?: boolean
isInBattle?: boolean
} & ComponentPropsWithoutRef<'div'>) { } & ComponentPropsWithoutRef<'div'>) {
return ( return (
<div <div
@@ -200,16 +206,27 @@ function PlayerInfo({
<div className='flex items-center gap-1.5 border-slate-700 border-r px-2'> <div className='flex items-center gap-1.5 border-slate-700 border-r px-2'>
<div>MMR:</div> <div>MMR:</div>
<div className='font-bold'>{playerData.mmr}</div> <div className='font-bold'>{playerData.mmr}</div>
<div className='text-emerald-400 text-sm'>{playerData.mmrChange}</div> <div
className={cn(
'!text-emerald-400 text-sm',
playerData.mmrChangeRaw &&
playerData.mmrChangeRaw < 0 &&
'!text-rose-400'
)}
>
{playerData.mmrChange}
</div>
</div> </div>
{/* Win Rate */} {/* Win Rate */}
<div className='flex items-center gap-1.5 text-nowrap border-slate-700 border-r px-2'> {!isInBattle && (
<div className='text-nowrap'>Win Rate:</div> <div className='flex items-center gap-1.5 text-nowrap border-slate-700 border-r px-2'>
<div className='text font-bold text-emerald-400'> <div className='text-nowrap'>WR:</div>
{playerData.winRate}% <div className='text font-bold text-emerald-400'>
{playerData.winRate}%
</div>
</div> </div>
</div> )}
{/* Win/Loss */} {/* Win/Loss */}
<div className='flex items-center gap-0.5 border-slate-700 border-r px-2'> <div className='flex items-center gap-0.5 border-slate-700 border-r px-2'>
@@ -227,15 +244,17 @@ function PlayerInfo({
</div> </div>
{/* Streak */} {/* Streak */}
<div {!isInBattle && (
className={cn( <div
'flex items-center gap-1.5 border-slate-700 px-2', className={cn(
(children || isReverse) && 'border-r' 'flex items-center gap-1.5 border-slate-700 px-2',
)} (children || isReverse) && 'border-r'
> )}
<div>Streak:</div> >
<div className='font-bold text-emerald-400'>{playerData.streak}</div> <div>Streak:</div>
</div> <div className='font-bold text-emerald-400'>{playerData.streak}</div>
</div>
)}
{children} {children}
</div> </div>

View File

@@ -0,0 +1,69 @@
'use client'
import { useEffect, useState } from 'react'
export function CountdownTimer({ nextMatch }: { nextMatch: any }) {
const [timeLeft, setTimeLeft] = useState({
days: 0,
hours: 0,
minutes: 0,
seconds: 0,
})
useEffect(() => {
const calculateTimeLeft = () => {
const now = new Date()
const matchTime = new Date(nextMatch.datetime)
const difference = matchTime.getTime() - now.getTime()
if (difference <= 0) {
return {
days: 0,
hours: 0,
minutes: 0,
seconds: 0,
}
}
return {
days: Math.floor(difference / (1000 * 60 * 60 * 24)),
hours: Math.floor(
(difference % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)
),
minutes: Math.floor((difference % (1000 * 60 * 60)) / (1000 * 60)),
seconds: Math.floor((difference % (1000 * 60)) / 1000),
}
}
setTimeLeft(calculateTimeLeft())
const timer = setInterval(() => {
setTimeLeft(calculateTimeLeft())
}, 1000)
return () => clearInterval(timer)
}, [nextMatch])
return (
<div className='flex justify-center'>
<div className='grid w-full max-w-xl grid-cols-4 gap-2 md:gap-4'>
{[
{ value: timeLeft.days, label: 'Days' },
{ value: timeLeft.hours, label: 'Hours' },
{ value: timeLeft.minutes, label: 'Minutes' },
{ value: timeLeft.seconds, label: 'Seconds' },
].map((item, index) => (
<div key={index}>
<div className='relative flex flex-col items-center justify-center rounded-lg border bg-card p-3 shadow-sm md:p-4'>
<span className='font-bold text-2xl tabular-nums md:text-4xl lg:text-5xl'>
{item.value.toString().padStart(2, '0')}
</span>
<span className='mt-1 text-muted-foreground text-xs md:text-sm'>
{item.label}
</span>
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -1,9 +1,9 @@
"use client" 'use client'
import * as React from "react" import * as AvatarPrimitive from '@radix-ui/react-avatar'
import * as AvatarPrimitive from "@radix-ui/react-avatar" import type * as React from 'react'
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils'
function Avatar({ function Avatar({
className, className,
@@ -11,9 +11,9 @@ function Avatar({
}: React.ComponentProps<typeof AvatarPrimitive.Root>) { }: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return ( return (
<AvatarPrimitive.Root <AvatarPrimitive.Root
data-slot="avatar" data-slot='avatar'
className={cn( className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full", 'relative flex size-8 shrink-0 overflow-hidden rounded-full',
className className
)} )}
{...props} {...props}
@@ -27,8 +27,8 @@ function AvatarImage({
}: React.ComponentProps<typeof AvatarPrimitive.Image>) { }: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return ( return (
<AvatarPrimitive.Image <AvatarPrimitive.Image
data-slot="avatar-image" data-slot='avatar-image'
className={cn("aspect-square size-full", className)} className={cn('aspect-square size-full object-cover!', className)}
{...props} {...props}
/> />
) )
@@ -40,9 +40,9 @@ function AvatarFallback({
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) { }: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return ( return (
<AvatarPrimitive.Fallback <AvatarPrimitive.Fallback
data-slot="avatar-fallback" data-slot='avatar-fallback'
className={cn( className={cn(
"bg-muted flex size-full items-center justify-center rounded-full", 'flex size-full items-center justify-center rounded-full bg-muted',
className className
)} )}
{...props} {...props}

View File

@@ -8,6 +8,7 @@
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@theme { @theme {
--font-m6x11: var(--font-m6x11);
--font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif, --font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
} }