fieldhouse source

App.tsx

import { useEffect, useState } from 'react';
import { StatusBar, useWindowDimensions } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { Chip, Dialog, Input, ScrollArea, SafeArea, Block, Button, Icon, IconButton, PlocksProvider, Tabs, Text } from '@plocks/ui';

const C = {
  navy: '#0D1D3B',
  blue: '#1767D5',
  sky: '#EAF3FF',
  white: '#FFF',
  ink: '#17243C',
  muted: '#6D7C93',
  line: '#E5EAF1',
  green: '#21815E',
};
const GAMES = [
  {
    id: 'hoops1',
    league: 'Basketball',
    away: 'Metro Comets',
    home: 'Harbor Waves',
    time: 'Tonight · 7:30 PM',
    awayOdds: 125,
    homeOdds: -145,
    live: true,
    score: '62 – 58',
    emoji: '🏀',
  },
  {
    id: 'hoops2',
    league: 'Basketball',
    away: 'Canyon Solars',
    home: 'Summit Bears',
    time: 'Tomorrow · 6:00 PM',
    awayOdds: -110,
    homeOdds: 105,
    live: false,
    score: '',
    emoji: '🏀',
  },
  {
    id: 'football1',
    league: 'Football',
    away: 'Coastal Hawks',
    home: 'Valley Foxes',
    time: 'Sunday · 1:00 PM',
    awayOdds: 115,
    homeOdds: -135,
    live: false,
    score: '',
    emoji: '🏈',
  },
  {
    id: 'football2',
    league: 'Football',
    away: 'Northern Lights',
    home: 'Desert Owls',
    time: 'Sunday · 4:25 PM',
    awayOdds: -120,
    homeOdds: 110,
    live: false,
    score: '',
    emoji: '🏈',
  },
  {
    id: 'hockey1',
    league: 'Hockey',
    away: 'Ice Harbor',
    home: 'River Wolves',
    time: 'Tonight · 8:00 PM',
    awayOdds: 135,
    homeOdds: -155,
    live: true,
    score: '2 – 2',
    emoji: '🏒',
  },
  {
    id: 'soccer1',
    league: 'Soccer',
    away: 'Union City',
    home: 'Blue Coast FC',
    time: 'Saturday · 5:00 PM',
    awayOdds: -105,
    homeOdds: 120,
    live: false,
    score: '',
    emoji: '⚽',
  },
];
type Game = (typeof GAMES)[number];
type Selection = { gameId: string; side: 'away' | 'home' };
type Pick = { id: string; selections: Selection[]; stake: number; payout: number; placed: string };
type Tab = 'sports' | 'live' | 'picks';
const STORAGE = 'plocks-fieldhouse-example:v1';
const money = (value: number) => '$' + value.toFixed(2);
const oddsText = (value: number) => (value > 0 ? `+${value}` : String(value));
const decimalOdds = (value: number) => (value > 0 ? 1 + value / 100 : 1 + 100 / Math.abs(value));
const selectionOdds = (selection: Selection) => {
  const game = GAMES.find((item) => item.id === selection.gameId)!;
  return selection.side === 'away' ? game.awayOdds : game.homeOdds;
};

function FieldhouseScreen() {
  const { width } = useWindowDimensions();
  const wide = width >= 960;
  const [tab, setTab] = useState<Tab>('sports');
  const [league, setLeague] = useState('All');
  const [slip, setSlip] = useState<Selection[]>([]);
  const [stake, setStake] = useState('10');
  const [balance, setBalance] = useState(1000);
  const [picks, setPicks] = useState<Pick[]>([]);
  const [showSlip, setShowSlip] = useState(false);
  const [ready, setReady] = useState(false);
  const amount = Number(stake);
  const combined = slip.reduce((result, selection) => result * decimalOdds(selectionOdds(selection)), 1);
  const payout = amount * combined;
  const valid = slip.length > 0 && Number.isFinite(amount) && amount > 0 && amount <= balance;
  const visible = GAMES.filter((game) => (tab !== 'live' || game.live) && (league === 'All' || game.league === league));

  useEffect(() => {
    AsyncStorage.getItem(STORAGE)
      .then((raw) => {
        if (!raw) return;
        const data = JSON.parse(raw) as { balance?: number; picks?: Pick[] };
        if (typeof data.balance === 'number') setBalance(data.balance);
        if (Array.isArray(data.picks)) setPicks(data.picks);
      })
      .catch(() => {})
      .finally(() => setReady(true));
  }, []);
  useEffect(() => {
    if (ready) AsyncStorage.setItem(STORAGE, JSON.stringify({ balance, picks })).catch(() => {});
  }, [balance, picks, ready]);
  function choose(gameId: string, side: Selection['side']) {
    setSlip((old) =>
      old.some((item) => item.gameId === gameId && item.side === side)
        ? old.filter((item) => item.gameId !== gameId)
        : [...old.filter((item) => item.gameId !== gameId), { gameId, side }],
    );
    if (!wide) setShowSlip(true);
  }
  function placePick() {
    if (!valid) return;
    setBalance((old) => old - amount);
    setPicks((old) => [
      { id: `pick-${Date.now()}`, selections: slip, stake: amount, payout, placed: new Date().toLocaleString() },
      ...old,
    ]);
    setSlip([]);
    setStake('10');
    setShowSlip(false);
    setTab('picks');
  }
  const nav = (
    <Tabs
      navigationOnly
      variant="chip"
      value={tab}
      onChange={(item) => setTab(item as Tab)}
      color={C.blue}
      activeTabTextColor={C.white}
      textStyle={{ color: '#C4D5EF' }}
      labelProps={{ size: wide ? 15 : 13 }}
      items={[
        { key: 'sports', label: 'Sports', content: null },
        { key: 'live', label: 'Live', content: null },
        { key: 'picks', label: 'My Picks', content: null },
      ]}
    />
  );
  const market = (game: Game, side: 'away' | 'home') => {
    const chosen = slip.some((item) => item.gameId === game.id && item.side === side);
    const team = side === 'away' ? game.away : game.home;
    const odds = side === 'away' ? game.awayOdds : game.homeOdds;
    return (
      <Block
        onPress={() => choose(game.id, side)}
        accessibilityRole="button"
        accessibilityLabel={`Pick ${team} at ${oddsText(odds)}`}
        flex={1}
        bg={chosen ? C.blue : C.sky}
        radius={9}
        p={11}
        align="center"
        gap={3}
      >
        <Text c={chosen ? C.white : C.ink} fw="semibold" size={12} numberOfLines={1}>
          {team}
        </Text>
        <Text c={chosen ? C.white : C.blue} fw="bold" size={16}>
          {oddsText(odds)}
        </Text>
      </Block>
    );
  };
  const gameCard = (game: Game) => (
    <Block key={game.id} bg={C.white} borderWidth={1} borderColor={C.line} radius={15} p={16} mb={12} gap={12}>
      <Block gap={0} direction="row" justify="space-between" align="center">
        <Text c={C.muted} fw="bold" size={11}>
          {game.emoji} {game.league.toUpperCase()} · {game.time}
        </Text>
        {game.live && (
          <Text c="#D44545" fw="bold" size={11}>
            ● LIVE
          </Text>
        )}
      </Block>
      <Block gap={0} direction="row" justify="space-between" align="center">
        <Block gap={0} flex={1}>
          <Text c={C.ink} fw="bold" size={16}>
            {game.away}
          </Text>
          <Text c={C.ink} fw="bold" size={16}>
            {game.home}
          </Text>
        </Block>
        {!!game.score && (
          <Text c={C.blue} fw="bold" size={19}>
            {game.score}
          </Text>
        )}
      </Block>
      <Text c={C.muted} size={11}>
        Moneyline · choose the winner
      </Text>
      <Block direction="row" gap={9}>
        {market(game, 'away')}
        {market(game, 'home')}
      </Block>
    </Block>
  );
  const slipContent = (
    <Block gap={13}>
      <Block gap={0} direction="row" justify="space-between" align="center">
        <Text c={C.ink} fw="bold" size={21}>
          Pick slip
        </Text>
        <Text c={C.muted} size={12}>
          {slip.length} {slip.length === 1 ? 'selection' : 'selections'}
        </Text>
      </Block>
      {slip.length === 0 ? (
        <Block align="center" gap={8} py={40}>
          <Text size={40}>🎟️</Text>
          <Text c={C.muted} ta="center">
            Choose a moneyline to start a demo pick.
          </Text>
        </Block>
      ) : (
        <>
          {slip.map((selection) => {
            const game = GAMES.find((item) => item.id === selection.gameId)!;
            const team = selection.side === 'away' ? game.away : game.home;
            return (
              <Block key={game.id} borderBottomWidth={1} borderBottomColor={C.line} pb={11} direction="row" gap={8}>
                <Block gap={0} flex={1}>
                  <Text c={C.ink} fw="bold" size={13}>
                    {team}
                  </Text>
                  <Text c={C.muted} size={11}>
                    {game.away} vs {game.home}
                  </Text>
                </Block>
                <Text c={C.blue} fw="bold">
                  {oddsText(selectionOdds(selection))}
                </Text>
                <Block
                  gap={0}
                  onPress={() => setSlip((old) => old.filter((item) => item.gameId !== game.id))}
                  accessibilityLabel={`Remove ${team}`}
                >
                  <Icon name="x" color={C.muted} size={17} />
                </Block>
              </Block>
            );
          })}
          <Text c={C.ink} fw="bold" size={13}>
            Demo stake
          </Text>
          <Input
            variant="outline"
            value={stake}
            onChangeText={setStake}
            keyboardType="decimal-pad"
            placeholder="Amount"
            mb={0}
            radius={9}
            inputColor={C.ink}
            inputFontSize={16}
          />
          <Block gap={0} direction="row" justify="space-between">
            <Text c={C.muted} size={13}>
              Potential return
            </Text>
            <Text c={C.green} fw="bold" size={17}>
              {Number.isFinite(payout) ? money(payout) : '—'}
            </Text>
          </Block>
          <Text c={C.muted} size={12}>
            Demo credits available: {money(balance)}
          </Text>
          <Button title="Place simulated pick" color={C.blue} disabled={!valid} onPress={placePick} />
          <Text c={C.muted} size={11}>
            Fictional games and odds. No real money or wager is involved.
          </Text>
        </>
      )}
    </Block>
  );

  return (
    <SafeArea gap={0} flex={1} bg="#F6F8FB">
      <StatusBar barStyle="light-content" />
      <Block gap={0} w="100%" maw={1300} alignSelf="center" flex={1}>
        <Block gap={0} bg={C.navy} px={wide ? 28 : 17} pt={11} direction="row" align="center" justify="space-between">
          <Text c={C.white} fw="bold" size={wide ? 26 : 20}>
            FIELDHOUSE
          </Text>
          <Text c={C.white} fw="bold" size={wide ? 14 : 12}>
            ◈ {money(balance)} demo
          </Text>
        </Block>
        <Block gap={0} bg={C.navy} px={wide ? 28 : 17}>
          {nav}
        </Block>
        <Block gap={0} flex={1} direction="row">
          <ScrollArea flex={1} contentProps={{ p: wide ? 24 : 16, pb: 50 }}>
            <Block gap={0} maw={840} w="100%" alignSelf="center">
              {tab === 'picks' ? (
                <>
                  <Text c={C.ink} fw="bold" size={27}>
                    My Picks
                  </Text>
                  <Text c={C.muted} size={13} mt={4} mb={20}>
                    Your simulated picks stay on this device.
                  </Text>
                  {picks.length === 0 && (
                    <Block bg={C.white} p={30} radius={14} align="center" gap={10}>
                      <Text size={50}>🎟️</Text>
                      <Text c={C.ink} fw="bold" size={18}>
                        No picks yet
                      </Text>
                      <Button title="Explore games" color={C.blue} onPress={() => setTab('sports')} />
                    </Block>
                  )}
                  {picks.map((pick) => (
                    <Block key={pick.id} bg={C.white} p={17} radius={13} mb={11} gap={8}>
                      <Block gap={0} direction="row" justify="space-between">
                        <Text c={C.ink} fw="bold">
                          Demo pick
                        </Text>
                        <Text c={C.blue} fw="bold" size={12}>
                          PENDING
                        </Text>
                      </Block>
                      {pick.selections.map((selection) => {
                        const game = GAMES.find((item) => item.id === selection.gameId)!;
                        return (
                          <Text key={game.id} c={C.ink} size={13}>
                            {selection.side === 'away' ? game.away : game.home} · {oddsText(selectionOdds(selection))}
                          </Text>
                        );
                      })}
                      <Block gap={0} h={1} bg={C.line} />
                      <Text c={C.muted} size={12}>
                        Stake {money(pick.stake)} · Potential return {money(pick.payout)}
                      </Text>
                      <Text c={C.muted} size={11}>
                        {pick.placed}
                      </Text>
                    </Block>
                  ))}
                </>
              ) : (
                <>
                  <Block gap={0} bg={C.navy} radius={16} p={21} mb={21}>
                    <Text c="#76B6FF" fw="bold" size={11}>
                      FIELDHOUSE SPORTSBOOK · DEMO
                    </Text>
                    <Text c={C.white} fw="bold" size={27} mt={5}>
                      Make your picks.
                    </Text>
                    <Text c="#CDD9EB" size={13} mt={5}>
                      Explore fictional matchups with demo credits.
                    </Text>
                  </Block>
                  <ScrollArea horizontal showsHorizontalScrollIndicator={false} contentProps={{ gap: 8, mb: 20 }}>
                    {['All', 'Basketball', 'Football', 'Hockey', 'Soccer'].map((item) => (
                      <Chip
                        key={item}
                        onPress={() => setLeague(item)}
                        pressed={league === item}
                        variant={league === item ? 'filled' : 'surface'}
                        color={C.blue}
                        size="md"
                      >
                        {item}
                      </Chip>
                    ))}
                  </ScrollArea>
                  <Text c={C.ink} fw="bold" size={21} mb={12}>
                    {tab === 'live' ? 'Live demo games' : 'Featured games'}
                  </Text>
                  {visible.map(gameCard)}
                </>
              )}
            </Block>
          </ScrollArea>
          {wide && (
            <Block gap={0} w={310} bg={C.white} borderLeftWidth={1} borderLeftColor={C.line} p={20}>
              {slipContent}
            </Block>
          )}
        </Block>
        {!wide && slip.length > 0 && !showSlip && (
          <Block
            gap={0}
            onPress={() => setShowSlip(true)}
            bg={C.blue}
            m={12}
            radius={11}
            p={13}
            direction="row"
            justify="space-between"
          >
            <Text c={C.white} fw="bold">
              Pick slip · {slip.length}
            </Text>
            <Text c={C.white} fw="bold">
              View ↑
            </Text>
          </Block>
        )}
      </Block>
      {!wide && showSlip && (
        <Dialog opened variant="bottomsheet" accessibilityLabel="Pick slip" onClose={() => setShowSlip(false)}>
          <Block gap={0}>
            <Block gap={0} alignSelf="flex-end">
              <IconButton
                icon="x"
                variant="ghost"
                iconColor={C.ink}
                accessibilityLabel="Close pick slip"
                onPress={() => setShowSlip(false)}
              />
            </Block>
            <ScrollArea>{slipContent}</ScrollArea>
          </Block>
        </Dialog>
      )}
    </SafeArea>
  );
}
export default function App() {
  return (
    <SafeAreaProvider>
      <PlocksProvider theme={{ colorScheme: 'light' }}>
        <FieldhouseScreen />
      </PlocksProvider>
    </SafeAreaProvider>
  );
}

index.ts

import { registerRootComponent } from 'expo';
import App from './App';

registerRootComponent(App);