add mlb page
2
.gitignore
vendored
@@ -1,5 +1,5 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
.thing
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
|
||||
@@ -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)
|
||||
@@ -2,6 +2,7 @@
|
||||
"pages": [
|
||||
"index",
|
||||
"getting-started",
|
||||
"multiplayer-content",
|
||||
"rulesets",
|
||||
"ranked-matchmaking",
|
||||
"advanced"
|
||||
|
||||
5
content/docs/multiplayer-content/jokers.mdx
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
title: Jokers
|
||||
description: Multiplayer-specific Jokers
|
||||
---
|
||||
|
||||
4
content/docs/multiplayer-content/meta.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"pages": ["jokers"],
|
||||
"defaultOpen": true
|
||||
}
|
||||
7
content/docs/ranked-matchmaking/faq.mdx
Normal 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?
|
||||
|
||||
15
content/docs/ranked-matchmaking/introduction.mdx
Normal 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.
|
||||
|
||||
5
content/docs/ranked-matchmaking/meta.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"title": "Ranked Matchmaking",
|
||||
"pages": ["introduction", "multiplayer-balance-changes", "faq"],
|
||||
"defaultOpen": true
|
||||
}
|
||||
281
content/docs/ranked-matchmaking/multiplayer-balance-changes.mdx
Normal 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)
|
||||
BIN
public/images/players/bear.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
public/images/players/doc.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
public/images/players/edzy.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
public/images/players/gothic.png
Normal file
|
After Width: | Height: | Size: 114 KiB |
BIN
public/images/players/haelian.png
Normal file
|
After Width: | Height: | Size: 138 KiB |
BIN
public/images/players/malf.png
Normal file
|
After Width: | Height: | Size: 686 KiB |
BIN
public/images/players/nandre.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
public/images/players/neato.jpg
Normal file
|
After Width: | Height: | Size: 704 KiB |
BIN
public/images/players/roffle.png
Normal file
|
After Width: | Height: | Size: 114 KiB |
BIN
public/images/players/seadubbs.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
public/images/players/skoottie.jpg
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
public/images/players/zaino.png
Normal file
|
After Width: | Height: | Size: 6.4 MiB |
@@ -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>
|
||||
)
|
||||
}
|
||||
68
src/app/(home)/major-league-balatro/_components/hero.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
386
src/app/(home)/major-league-balatro/_components/schedule.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
456
src/app/(home)/major-league-balatro/_constants/matches.ts
Normal 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',
|
||||
// },
|
||||
]
|
||||
123
src/app/(home)/major-league-balatro/_constants/players.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
}
|
||||
76
src/app/(home)/major-league-balatro/page.tsx
Normal 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'>
|
||||
© {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>
|
||||
)
|
||||
}
|
||||
43
src/app/(home)/major-league-balatro/types.ts
Normal 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
|
||||
}
|
||||
BIN
src/app/_assets/fonts/m6x11.ttf
Normal file
@@ -18,6 +18,16 @@ const links = [
|
||||
url: '/about',
|
||||
icon: <Info />,
|
||||
},
|
||||
{
|
||||
type: 'menu' as const,
|
||||
text: 'Major League Balatro',
|
||||
items: [
|
||||
{
|
||||
text: 'About',
|
||||
url: '/major-league-balatro',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Support Mod Development',
|
||||
url: 'https://ko-fi.com/virtualized/shop',
|
||||
|
||||
@@ -8,7 +8,7 @@ import { NextIntlClientProvider } from 'next-intl'
|
||||
import { getLocale } from 'next-intl/server'
|
||||
import PlausibleProvider from 'next-plausible'
|
||||
import { Geist } from 'next/font/google'
|
||||
|
||||
import localFont from 'next/font/local'
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
template: '%s | Balatro Multiplayer',
|
||||
@@ -24,6 +24,12 @@ const geist = Geist({
|
||||
variable: '--font-geist-sans',
|
||||
})
|
||||
|
||||
const m6x11 = localFont({
|
||||
src: './_assets/fonts/m6x11.ttf',
|
||||
display: 'swap',
|
||||
variable: '--font-m6x11',
|
||||
})
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
@@ -31,7 +37,7 @@ export default async function RootLayout({
|
||||
return (
|
||||
<html
|
||||
lang={locale}
|
||||
className={`${geist.variable}`}
|
||||
className={`${geist.variable} ${m6x11.variable}`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<head>
|
||||
|
||||
@@ -50,6 +50,7 @@ function getPlayerData(
|
||||
meaningful_games > 0 ? Math.floor((losses / meaningful_games) * 100) : 0,
|
||||
rank: playerLeaderboardEntry.rank,
|
||||
mmr: Math.round(playerLeaderboardEntry.mmr),
|
||||
mmrChangeRaw: lastGame?.mmrChange,
|
||||
mmrChange: `${(lastGame?.mmrChange ?? 0) >= 0 ? '+' : ''}${Math.round(
|
||||
lastGame?.mmrChange ?? 0
|
||||
)}`,
|
||||
@@ -93,8 +94,11 @@ export function StreamCardClient() {
|
||||
const isQueuing = playerState?.status === 'queuing'
|
||||
const opponentId = playerState?.currentMatch?.opponentId
|
||||
return (
|
||||
<div className={'flex items-center gap-2'} style={{ zoom: '200%' }}>
|
||||
<PlayerInfo playerData={playerData}>
|
||||
<div
|
||||
className={'flex items-center justify-between gap-2 font-m6x11'}
|
||||
style={{ zoom: '200%' }}
|
||||
>
|
||||
<PlayerInfo playerData={playerData} isInBattle={!!opponentId}>
|
||||
{isQueuing && playerState.queueStartTime && (
|
||||
<QueueTimer startTime={playerState.queueStartTime} />
|
||||
)}
|
||||
@@ -160,7 +164,7 @@ function Opponent({ id }: { id: string }) {
|
||||
}
|
||||
|
||||
const playerData = getPlayerData(rankedUserRank, games)
|
||||
return <PlayerInfo playerData={playerData} isReverse />
|
||||
return <PlayerInfo playerData={playerData} isReverse isInBattle />
|
||||
}
|
||||
|
||||
function PlayerInfo({
|
||||
@@ -168,10 +172,12 @@ function PlayerInfo({
|
||||
className,
|
||||
children,
|
||||
isReverse = false,
|
||||
isInBattle = false,
|
||||
...rest
|
||||
}: {
|
||||
playerData: ReturnType<typeof getPlayerData>
|
||||
isReverse?: boolean
|
||||
isInBattle?: boolean
|
||||
} & ComponentPropsWithoutRef<'div'>) {
|
||||
return (
|
||||
<div
|
||||
@@ -200,16 +206,27 @@ function PlayerInfo({
|
||||
<div className='flex items-center gap-1.5 border-slate-700 border-r px-2'>
|
||||
<div>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>
|
||||
|
||||
{/* Win Rate */}
|
||||
{!isInBattle && (
|
||||
<div className='flex items-center gap-1.5 text-nowrap border-slate-700 border-r px-2'>
|
||||
<div className='text-nowrap'>Win Rate:</div>
|
||||
<div className='text-nowrap'>WR:</div>
|
||||
<div className='text font-bold text-emerald-400'>
|
||||
{playerData.winRate}%
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Win/Loss */}
|
||||
<div className='flex items-center gap-0.5 border-slate-700 border-r px-2'>
|
||||
@@ -227,6 +244,7 @@ function PlayerInfo({
|
||||
</div>
|
||||
|
||||
{/* Streak */}
|
||||
{!isInBattle && (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 border-slate-700 px-2',
|
||||
@@ -236,6 +254,7 @@ function PlayerInfo({
|
||||
<div>Streak:</div>
|
||||
<div className='font-bold text-emerald-400'>{playerData.streak}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</div>
|
||||
|
||||
69
src/components/countdown-timer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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({
|
||||
className,
|
||||
@@ -11,9 +11,9 @@ function Avatar({
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
data-slot='avatar'
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
|
||||
'relative flex size-8 shrink-0 overflow-hidden rounded-full',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -27,8 +27,8 @@ function AvatarImage({
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn("aspect-square size-full", className)}
|
||||
data-slot='avatar-image'
|
||||
className={cn('aspect-square size-full object-cover!', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -40,9 +40,9 @@ function AvatarFallback({
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
data-slot='avatar-fallback'
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme {
|
||||
--font-m6x11: var(--font-m6x11);
|
||||
--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";
|
||||
}
|
||||
|
||||