commit 1160c3a71331611779f9afaf222406bad8c5fc1f Author: b3ni15 Date: Sat Dec 20 23:10:06 2025 +0100 feat: initialize mobile blackjack app with authentication and game features diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..26238db --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +EXPO_PUBLIC_API_URL=http://localhost:4000 +EXPO_PUBLIC_WS_URL=ws://localhost:4000 +EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_... diff --git a/App.js b/App.js new file mode 100644 index 0000000..820c26b --- /dev/null +++ b/App.js @@ -0,0 +1,65 @@ +import { useCallback, useState } from 'react'; +import { ActivityIndicator, StyleSheet, View } from 'react-native'; +import { StripeProvider } from '@stripe/stripe-react-native'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; +import { colors } from './src/theme'; +import { apiFetch } from './src/api'; +import { useAuth } from './src/hooks/useAuth'; +import LoginScreen from './src/screens/LoginScreen'; +import LobbyScreen from './src/screens/LobbyScreen'; +import TableScreen from './src/screens/TableScreen'; + +export default function App() { + const { token, user, loading, login, logout, setUser } = useAuth(); + const [selectedTable, setSelectedTable] = useState(null); + + const refreshUser = useCallback(async () => { + if (!token) { + return; + } + const data = await apiFetch('/api/me', token); + setUser(data); + }, [token, setUser]); + + if (loading) { + return ( + + + + ); + } + + return ( + + + {!token || !user ? ( + + ) : selectedTable ? ( + setSelectedTable(null)} + /> + ) : ( + + )} + + + ); +} + +const styles = StyleSheet.create({ + loading: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.backgroundTop + } +}); diff --git a/app.json b/app.json new file mode 100644 index 0000000..10097cb --- /dev/null +++ b/app.json @@ -0,0 +1,35 @@ +{ + "expo": { + "name": "Vegas Blackjack", + "slug": "vegas-blackjack", + "scheme": "blackjack", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/icon.png", + "splash": { + "image": "./assets/splash.png", + "resizeMode": "contain", + "backgroundColor": "#0b1f17" + }, + "updates": { + "fallbackToCacheTimeout": 0 + }, + "assetBundlePatterns": [ + "**/*" + ], + "plugins": [ + [ + "@stripe/stripe-react-native", + { + "merchantIdentifier": "merchant.com.blackjack.vegas", + "enableGooglePay": true + } + ] + ], + "extra": { + "eas": { + "projectId": "00000000-0000-0000-0000-000000000000" + } + } + } +} diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000..cfb1c46 Binary files /dev/null and b/assets/icon.png differ diff --git a/assets/splash.png b/assets/splash.png new file mode 100644 index 0000000..cfb1c46 Binary files /dev/null and b/assets/splash.png differ diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000..33acf98 --- /dev/null +++ b/babel.config.js @@ -0,0 +1,6 @@ +module.exports = function (api) { + api.cache(true); + return { + presets: ['babel-preset-expo'] + }; +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..ef6807e --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "blackjack-mobile", + "version": "0.1.0", + "private": true, + "main": "node_modules/expo/AppEntry.js", + "scripts": { + "start": "expo start", + "android": "expo start --android", + "ios": "expo start --ios", + "web": "expo start --web" + }, + "dependencies": { + "@react-native-async-storage/async-storage": "1.23.1", + "@stripe/stripe-react-native": "0.38.3", + "expo": "~51.0.0", + "expo-auth-session": "~5.0.2", + "expo-linear-gradient": "~12.7.2", + "expo-linking": "~6.3.1", + "expo-web-browser": "~13.0.3", + "react": "18.2.0", + "react-native": "0.74.0", + "react-native-safe-area-context": "4.10.8" + } +} diff --git a/src/api.js b/src/api.js new file mode 100644 index 0000000..7e72685 --- /dev/null +++ b/src/api.js @@ -0,0 +1,36 @@ +const API_URL = process.env.EXPO_PUBLIC_API_URL || 'http://localhost:4000'; +const WS_URL = process.env.EXPO_PUBLIC_WS_URL || 'ws://localhost:4000'; + +export { API_URL, WS_URL }; + +export async function apiFetch(path, token, options = {}) { + const headers = { + 'Content-Type': 'application/json', + ...(options.headers || {}) + }; + + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + const response = await fetch(`${API_URL}${path}`, { + ...options, + headers + }); + + if (!response.ok) { + let message = 'Hiba tortent.'; + try { + const payload = await response.json(); + message = payload.error || message; + } catch (err) { + const text = await response.text(); + if (text) { + message = text; + } + } + throw new Error(message); + } + + return response.json(); +} diff --git a/src/components/Card.js b/src/components/Card.js new file mode 100644 index 0000000..1a610ac --- /dev/null +++ b/src/components/Card.js @@ -0,0 +1,81 @@ +import { StyleSheet, Text, View } from 'react-native'; +import { colors, fonts } from '../theme'; + +const suitSymbols = { + S: '♠', + H: '♥', + D: '♦', + C: '♣' +}; + +const suitColors = { + S: '#1c1c1c', + C: '#1c1c1c', + H: colors.red, + D: colors.red +}; + +export default function Card({ rank, suit, hidden }) { + if (hidden || rank === 'X') { + return ( + + + + ); + } + + const symbol = suitSymbols[suit] || '?'; + const color = suitColors[suit] || colors.text; + + return ( + + {rank} + {symbol} + {symbol} + + {rank} + {symbol} + + + ); +} + +const styles = StyleSheet.create({ + card: { + width: 54, + height: 78, + borderRadius: 8, + backgroundColor: '#fdf8f0', + padding: 6, + marginRight: 6, + borderWidth: 1, + borderColor: '#d2c1a4', + justifyContent: 'space-between' + }, + cardBack: { + backgroundColor: '#152d52', + borderColor: '#0d1e38', + alignItems: 'center', + justifyContent: 'center' + }, + backPattern: { + width: 32, + height: 46, + borderRadius: 6, + borderWidth: 2, + borderColor: 'rgba(255,255,255,0.4)' + }, + corner: { + fontSize: 12, + fontFamily: fonts.body, + fontWeight: '700' + }, + center: { + fontSize: 24, + fontFamily: fonts.display, + textAlign: 'center' + }, + cornerBottom: { + transform: [{ rotate: '180deg' }] + } +}); diff --git a/src/components/CasinoButton.js b/src/components/CasinoButton.js new file mode 100644 index 0000000..b778987 --- /dev/null +++ b/src/components/CasinoButton.js @@ -0,0 +1,55 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { LinearGradient } from 'expo-linear-gradient'; +import { colors, fonts } from '../theme'; + +const gradients = { + gold: [colors.goldBright, colors.gold], + red: ['#f05a4f', colors.red], + green: ['#39c377', '#1f7a44'] +}; + +export default function CasinoButton({ label, onPress, variant = 'gold', disabled }) { + const textColor = variant === 'gold' ? '#2b1d0b' : '#f7f2e6'; + return ( + + + + {label} + + + + ); +} + +const styles = StyleSheet.create({ + wrapper: { + shadowColor: colors.shadow, + shadowOpacity: 0.4, + shadowRadius: 6, + shadowOffset: { width: 0, height: 4 } + }, + button: { + borderRadius: 999, + paddingVertical: 12, + paddingHorizontal: 24, + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.2)' + }, + inner: { + alignItems: 'center' + }, + text: { + fontSize: 16, + fontFamily: fonts.body, + letterSpacing: 1, + textTransform: 'uppercase' + }, + disabled: { + opacity: 0.5 + } +}); diff --git a/src/components/Chip.js b/src/components/Chip.js new file mode 100644 index 0000000..88b1775 --- /dev/null +++ b/src/components/Chip.js @@ -0,0 +1,45 @@ +import { StyleSheet, Text, View } from 'react-native'; +import { colors, fonts } from '../theme'; + +const chipColors = { + blue: colors.chipBlue, + red: colors.chipRed, + green: colors.chipGreen +}; + +export default function Chip({ label, color = 'blue' }) { + return ( + + + {label} + + + ); +} + +const styles = StyleSheet.create({ + chip: { + width: 48, + height: 48, + borderRadius: 24, + alignItems: 'center', + justifyContent: 'center', + borderWidth: 3, + borderColor: '#f2f1e8' + }, + inner: { + width: 28, + height: 28, + borderRadius: 14, + borderWidth: 2, + borderColor: 'rgba(255,255,255,0.7)', + alignItems: 'center', + justifyContent: 'center' + }, + text: { + color: '#f7f2e6', + fontWeight: '700', + fontSize: 12, + fontFamily: fonts.mono + } +}); diff --git a/src/components/DealerArea.js b/src/components/DealerArea.js new file mode 100644 index 0000000..0f8c814 --- /dev/null +++ b/src/components/DealerArea.js @@ -0,0 +1,33 @@ +import { StyleSheet, Text, View } from 'react-native'; +import Card from './Card'; +import { colors, fonts } from '../theme'; + +export default function DealerArea({ hand }) { + return ( + + Osztó + + {hand.map((card, idx) => ( + + + ); +} + +const styles = StyleSheet.create({ + wrapper: { + alignItems: 'center' + }, + label: { + color: colors.goldBright, + letterSpacing: 2, + textTransform: 'uppercase', + fontSize: 12, + fontFamily: fonts.body + }, + hand: { + flexDirection: 'row', + marginTop: 6 + } +}); diff --git a/src/components/Seat.js b/src/components/Seat.js new file mode 100644 index 0000000..3e2dfa9 --- /dev/null +++ b/src/components/Seat.js @@ -0,0 +1,73 @@ +import { StyleSheet, Text, View } from 'react-native'; +import Card from './Card'; +import { colors, fonts } from '../theme'; + +export default function Seat({ seat, highlight }) { + const isEmpty = !seat.username; + + return ( + + {isEmpty ? 'Üres hely' : seat.username} + {!isEmpty && seat.bet > 0 && ( + Tet: {seat.bet} Ft + )} + {!isEmpty && seat.hand?.length > 0 && ( + + {seat.hand.map((card, idx) => ( + + )} + {!isEmpty && seat.result && ( + + {seat.result.outcome === 'win' && 'Nyereség'} + {seat.result.outcome === 'blackjack' && 'Blackjack!'} + {seat.result.outcome === 'push' && 'Döntetlen'} + {seat.result.outcome === 'bust' && 'Bukás'} + {seat.result.outcome === 'lose' && 'Vesztettél'} + {seat.result.outcome === 'left' && 'Kilépett'} + {seat.result.outcome === 'disconnect' && 'Eltűnt'} + {seat.result.outcome === 'moved' && 'Átült'} + + )} + + ); +} + +const styles = StyleSheet.create({ + seat: { + padding: 8, + borderRadius: 12, + backgroundColor: 'rgba(0,0,0,0.3)', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.1)' + }, + highlight: { + borderColor: colors.goldBright, + shadowColor: colors.goldBright, + shadowOpacity: 0.6, + shadowRadius: 8 + }, + name: { + color: colors.text, + fontSize: 12, + fontFamily: fonts.body, + fontWeight: '600' + }, + bet: { + color: colors.goldBright, + fontSize: 11, + marginTop: 2, + fontFamily: fonts.mono + }, + hand: { + flexDirection: 'row', + marginTop: 4 + }, + result: { + marginTop: 4, + color: colors.muted, + fontSize: 10, + fontFamily: fonts.body + } +}); diff --git a/src/components/TableBackground.js b/src/components/TableBackground.js new file mode 100644 index 0000000..16225b7 --- /dev/null +++ b/src/components/TableBackground.js @@ -0,0 +1,54 @@ +import { StyleSheet, View } from 'react-native'; +import { LinearGradient } from 'expo-linear-gradient'; +import { colors } from '../theme'; + +export default function TableBackground({ children }) { + return ( + + + + + {children} + + + + ); +} + +const styles = StyleSheet.create({ + wrapper: { + flex: 1, + padding: 12 + }, + edge: { + flex: 1, + borderRadius: 220, + padding: 10 + }, + felt: { + flex: 1, + borderRadius: 200, + padding: 20, + overflow: 'hidden' + }, + innerRing: { + position: 'absolute', + top: 16, + bottom: 16, + left: 16, + right: 16, + borderWidth: 2, + borderColor: 'rgba(255,255,255,0.15)', + borderRadius: 180 + } +}); diff --git a/src/hooks/useAuth.js b/src/hooks/useAuth.js new file mode 100644 index 0000000..3ab937f --- /dev/null +++ b/src/hooks/useAuth.js @@ -0,0 +1,75 @@ +import { useCallback, useEffect, useState } from 'react'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as WebBrowser from 'expo-web-browser'; +import * as Linking from 'expo-linking'; +import { API_URL, apiFetch } from '../api'; + +WebBrowser.maybeCompleteAuthSession(); + +const TOKEN_KEY = 'bj_token'; + +export function useAuth() { + const [token, setToken] = useState(null); + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + const loadUser = useCallback(async (nextToken) => { + const data = await apiFetch('/api/me', nextToken); + setUser(data); + }, []); + + useEffect(() => { + const init = async () => { + try { + const stored = await AsyncStorage.getItem(TOKEN_KEY); + if (stored) { + setToken(stored); + await loadUser(stored); + } + } catch (err) { + setToken(null); + } finally { + setLoading(false); + } + }; + + init(); + }, [loadUser]); + + const login = useCallback(async () => { + const redirectUri = Linking.createURL('auth'); + const response = await fetch( + `${API_URL}/auth/discord/url?redirect=${encodeURIComponent(redirectUri)}` + ); + const payload = await response.json(); + const result = await WebBrowser.openAuthSessionAsync(payload.url, redirectUri); + + if (result.type === 'success' && result.url) { + const parsed = Linking.parse(result.url); + const nextToken = parsed.queryParams?.token; + if (typeof nextToken === 'string') { + await AsyncStorage.setItem(TOKEN_KEY, nextToken); + setToken(nextToken); + await loadUser(nextToken); + return; + } + } + + throw new Error('Sikertelen bejelentkezes.'); + }, [loadUser]); + + const logout = useCallback(async () => { + await AsyncStorage.removeItem(TOKEN_KEY); + setToken(null); + setUser(null); + }, []); + + return { + token, + user, + loading, + login, + logout, + setUser + }; +} diff --git a/src/screens/LobbyScreen.js b/src/screens/LobbyScreen.js new file mode 100644 index 0000000..5a285c5 --- /dev/null +++ b/src/screens/LobbyScreen.js @@ -0,0 +1,196 @@ +import { useEffect, useState } from 'react'; +import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'; +import { LinearGradient } from 'expo-linear-gradient'; +import { useStripe } from '@stripe/stripe-react-native'; +import { apiFetch } from '../api'; +import { colors, fonts } from '../theme'; +import CasinoButton from '../components/CasinoButton'; + +export default function LobbyScreen({ user, token, onLogout, onSelectTable, onRefreshUser }) { + const [tables, setTables] = useState([]); + const [loading, setLoading] = useState(true); + const [depositLoading, setDepositLoading] = useState(false); + const [depositError, setDepositError] = useState(''); + const { initPaymentSheet, presentPaymentSheet } = useStripe(); + + const loadTables = async () => { + try { + const data = await apiFetch('/api/tables', token); + setTables(data.tables); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadTables(); + const interval = setInterval(loadTables, 4000); + return () => clearInterval(interval); + }, []); + + const handleDeposit = async (amount) => { + try { + setDepositLoading(true); + setDepositError(''); + const data = await apiFetch('/api/wallet/deposit-intent', token, { + method: 'POST', + body: JSON.stringify({ amount }) + }); + + const init = await initPaymentSheet({ + merchantDisplayName: 'Vegas Blackjack', + paymentIntentClientSecret: data.clientSecret + }); + + if (init.error) { + throw new Error(init.error.message); + } + + const result = await presentPaymentSheet(); + if (result.error) { + throw new Error(result.error.message); + } + + await onRefreshUser(); + } catch (err) { + setDepositError(err.message || 'Nem sikerult a feltoltes.'); + } finally { + setDepositLoading(false); + } + }; + + return ( + + + + Lobbi + Egyenleg: {user.balance} Ft + + + + + + Feltöltés + + handleDeposit(50)} variant="gold" disabled={depositLoading} /> + handleDeposit(100)} variant="gold" disabled={depositLoading} /> + + {depositError ? {depositError} : null} + + + Asztalok + {loading ? ( + + ) : ( + + {tables.map((table) => { + const free = table.seatCount - table.occupied; + return ( + + + Asztal {table.id} + Szabad hely: {free} / {table.seatCount} + + {Array.from({ length: table.seatCount }).map((_, idx) => ( + + ))} + + + 0 ? 'Beülök' : 'Tele'} + onPress={() => free > 0 && onSelectTable(table.id)} + variant="green" + disabled={free === 0} + /> + + ); + })} + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + padding: 20 + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 16 + }, + title: { + color: colors.goldBright, + fontSize: 26, + fontFamily: fonts.display, + letterSpacing: 2 + }, + balance: { + color: colors.muted, + marginTop: 4, + fontFamily: fonts.mono + }, + sectionTitle: { + color: colors.text, + fontSize: 16, + fontFamily: fonts.body, + marginBottom: 12, + letterSpacing: 1 + }, + depositRow: { + marginBottom: 20 + }, + chips: { + flexDirection: 'row', + gap: 12 + }, + tableList: { + gap: 12 + }, + tableCard: { + padding: 16, + borderRadius: 16, + backgroundColor: 'rgba(10, 20, 16, 0.7)', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.1)', + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center' + }, + tableName: { + color: colors.goldBright, + fontSize: 18, + fontFamily: fonts.display + }, + tableMeta: { + color: colors.muted, + fontSize: 12, + fontFamily: fonts.body, + marginTop: 4 + }, + seatRow: { + flexDirection: 'row', + marginTop: 8, + gap: 4 + }, + seatFilled: { + width: 10, + height: 10, + borderRadius: 5, + backgroundColor: colors.goldBright + }, + seatEmpty: { + width: 10, + height: 10, + borderRadius: 5, + backgroundColor: 'rgba(255,255,255,0.2)' + }, + error: { + color: colors.red, + marginTop: 8, + fontFamily: fonts.body + } +}); diff --git a/src/screens/LoginScreen.js b/src/screens/LoginScreen.js new file mode 100644 index 0000000..7547da8 --- /dev/null +++ b/src/screens/LoginScreen.js @@ -0,0 +1,46 @@ +import { StyleSheet, Text, View } from 'react-native'; +import { LinearGradient } from 'expo-linear-gradient'; +import { colors, fonts } from '../theme'; +import CasinoButton from '../components/CasinoButton'; + +export default function LoginScreen({ onLogin, loading }) { + return ( + + + VEGAS BLACKJACK + Multiplayer asztalok, igazi kaszinóhangulat. + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + padding: 24 + }, + hero: { + marginBottom: 32, + alignItems: 'center' + }, + title: { + color: colors.goldBright, + fontSize: 32, + fontFamily: fonts.display, + letterSpacing: 4, + textAlign: 'center', + marginBottom: 12 + }, + subtitle: { + color: colors.muted, + fontSize: 14, + fontFamily: fonts.body, + textAlign: 'center' + } +}); diff --git a/src/screens/TableScreen.js b/src/screens/TableScreen.js new file mode 100644 index 0000000..7aedacf --- /dev/null +++ b/src/screens/TableScreen.js @@ -0,0 +1,256 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Animated, Pressable, StyleSheet, Text, View } from 'react-native'; +import { LinearGradient } from 'expo-linear-gradient'; +import { WS_URL } from '../api'; +import { colors, fonts } from '../theme'; +import CasinoButton from '../components/CasinoButton'; +import Chip from '../components/Chip'; +import DealerArea from '../components/DealerArea'; +import Seat from '../components/Seat'; +import TableBackground from '../components/TableBackground'; + +export default function TableScreen({ token, tableId, user, onLeave }) { + const [table, setTable] = useState(null); + const [balance, setBalance] = useState(user.balance); + const [message, setMessage] = useState(''); + const [betAmount, setBetAmount] = useState(10); + const wsRef = useRef(null); + const pulse = useRef(new Animated.Value(0)).current; + + useEffect(() => { + const ws = new WebSocket(WS_URL); + wsRef.current = ws; + + ws.onopen = () => { + ws.send(JSON.stringify({ type: 'hello', token })); + ws.send(JSON.stringify({ type: 'join', tableId })); + }; + + ws.onmessage = (event) => { + const payload = JSON.parse(event.data); + if (payload.type === 'table_state') { + setTable(payload.table); + if (payload.table.minBet) { + setBetAmount((prev) => { + const clamped = Math.max(payload.table.minBet, Math.min(prev, payload.table.maxBet)); + return clamped; + }); + } + } + if (payload.type === 'balance') { + setBalance(payload.balance); + } + if (payload.type === 'error') { + setMessage(payload.message); + setTimeout(() => setMessage(''), 2400); + } + }; + + ws.onclose = () => { + wsRef.current = null; + }; + + return () => { + if (ws.readyState === 1) { + ws.send(JSON.stringify({ type: 'leave' })); + } + ws.close(); + }; + }, [tableId, token]); + + const mySeat = useMemo(() => table?.seats?.find((seat) => seat.isYou), [table]); + const isMyTurn = table?.phase === 'playing' && table?.currentSeatIndex === mySeat?.index; + const bettingLocked = ['playing', 'dealer', 'payout'].includes(table?.phase); + + useEffect(() => { + if (!isMyTurn) { + pulse.stopAnimation(); + pulse.setValue(0); + return; + } + + Animated.loop( + Animated.sequence([ + Animated.timing(pulse, { toValue: 1, duration: 700, useNativeDriver: true }), + Animated.timing(pulse, { toValue: 0, duration: 700, useNativeDriver: true }) + ]) + ).start(); + }, [isMyTurn, pulse]); + + const pulseStyle = { + transform: [ + { + scale: pulse.interpolate({ + inputRange: [0, 1], + outputRange: [1, 1.04] + }) + } + ], + opacity: pulse.interpolate({ inputRange: [0, 1], outputRange: [0.85, 1] }) + }; + + const send = (payload) => { + if (wsRef.current?.readyState === 1) { + wsRef.current.send(JSON.stringify(payload)); + } + }; + + const adjustBet = (delta) => { + if (!table) { + return; + } + setBetAmount((prev) => { + const next = prev + delta; + return Math.max(table.minBet, Math.min(table.maxBet, next)); + }); + }; + + return ( + + + + Asztal {tableId} + Egyenleg: {balance} Ft + + + + + + + + + + + {table?.seats?.map((seat) => { + const position = seatPositions[seat.index]; + if (!position) { + return null; + } + return ( + + + + ); + })} + + + + + + Tét + + adjustBet(-10)} style={[styles.betAdjust, bettingLocked && styles.betAdjustDisabled]} disabled={bettingLocked}> + - + + + adjustBet(10)} style={[styles.betAdjust, bettingLocked && styles.betAdjustDisabled]} disabled={bettingLocked}> + + + + + send({ type: 'bet', amount: betAmount })} variant="gold" disabled={bettingLocked} /> + send({ type: 'ready' })} variant="green" disabled={bettingLocked} /> + + + + send({ type: 'action', action: 'hit' })} variant="gold" disabled={!isMyTurn} /> + send({ type: 'action', action: 'stand' })} variant="gold" disabled={!isMyTurn} /> + send({ type: 'action', action: 'double' })} variant="gold" disabled={!isMyTurn} /> + + {message ? {message} : null} + + + ); +} + +const seatPositions = { + 0: { bottom: 10, left: '38%' }, + 1: { bottom: 20, left: '8%' }, + 2: { bottom: 20, right: '8%' }, + 3: { top: '52%', left: 0 }, + 4: { top: '52%', right: 0 }, + 5: { top: '22%', left: '6%' }, + 6: { top: '22%', right: '6%' } +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + paddingTop: 40 + }, + topBar: { + paddingHorizontal: 20, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center' + }, + tableTitle: { + color: colors.goldBright, + fontSize: 20, + fontFamily: fonts.display, + letterSpacing: 2 + }, + balance: { + color: colors.muted, + marginTop: 4, + fontFamily: fonts.mono + }, + dealerArea: { + alignItems: 'center' + }, + seatsLayer: { + flex: 1, + position: 'relative' + }, + seatPosition: { + position: 'absolute', + width: 140 + }, + controls: { + paddingHorizontal: 20, + paddingBottom: 24 + }, + betRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: 8, + marginBottom: 12 + }, + betControls: { + flexDirection: 'row', + alignItems: 'center', + gap: 6 + }, + betAdjust: { + width: 32, + height: 32, + borderRadius: 16, + backgroundColor: 'rgba(255,255,255,0.12)', + alignItems: 'center', + justifyContent: 'center' + }, + betAdjustText: { + color: colors.text, + fontSize: 18, + fontWeight: '700' + }, + betAdjustDisabled: { + opacity: 0.4 + }, + actionRow: { + flexDirection: 'row', + justifyContent: 'space-between', + gap: 10 + }, + sectionLabel: { + color: colors.text, + fontSize: 14, + fontFamily: fonts.body + }, + message: { + color: colors.goldBright, + textAlign: 'center', + marginTop: 10, + fontFamily: fonts.body + } +}); diff --git a/src/theme.js b/src/theme.js new file mode 100644 index 0000000..a5e61c0 --- /dev/null +++ b/src/theme.js @@ -0,0 +1,24 @@ +import { Platform } from 'react-native'; + +export const colors = { + backgroundTop: '#0b1f17', + backgroundBottom: '#02130d', + tableFelt: '#0f5c42', + tableFeltDark: '#0b4a35', + tableEdge: '#6d4a1c', + gold: '#d6b26d', + goldBright: '#f7d488', + red: '#d33b2f', + text: '#f6f1e4', + muted: '#b9b0a0', + chipBlue: '#2f7dd3', + chipRed: '#d94a3d', + chipGreen: '#2f9e5f', + shadow: '#000000' +}; + +export const fonts = { + display: Platform.select({ ios: 'Georgia', android: 'serif' }), + body: Platform.select({ ios: 'AvenirNextCondensed-DemiBold', android: 'sans-serif-condensed' }), + mono: Platform.select({ ios: 'Menlo', android: 'monospace' }) +};