feat: initialize mobile blackjack app with authentication and game features
This commit is contained in:
3
.env.example
Normal file
3
.env.example
Normal file
@@ -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_...
|
||||
65
App.js
Normal file
65
App.js
Normal file
@@ -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 (
|
||||
<View style={styles.loading}>
|
||||
<ActivityIndicator color={colors.goldBright} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
<StripeProvider publishableKey={process.env.EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY || ''}>
|
||||
{!token || !user ? (
|
||||
<LoginScreen onLogin={login} loading={loading} />
|
||||
) : selectedTable ? (
|
||||
<TableScreen
|
||||
token={token}
|
||||
tableId={selectedTable}
|
||||
user={user}
|
||||
onLeave={() => setSelectedTable(null)}
|
||||
/>
|
||||
) : (
|
||||
<LobbyScreen
|
||||
user={user}
|
||||
token={token}
|
||||
onLogout={logout}
|
||||
onSelectTable={setSelectedTable}
|
||||
onRefreshUser={refreshUser}
|
||||
/>
|
||||
)}
|
||||
</StripeProvider>
|
||||
</SafeAreaProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
loading: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.backgroundTop
|
||||
}
|
||||
});
|
||||
35
app.json
Normal file
35
app.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
assets/icon.png
Normal file
BIN
assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 68 B |
BIN
assets/splash.png
Normal file
BIN
assets/splash.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 68 B |
6
babel.config.js
Normal file
6
babel.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ['babel-preset-expo']
|
||||
};
|
||||
};
|
||||
24
package.json
Normal file
24
package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
36
src/api.js
Normal file
36
src/api.js
Normal file
@@ -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();
|
||||
}
|
||||
81
src/components/Card.js
Normal file
81
src/components/Card.js
Normal file
@@ -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 (
|
||||
<View style={[styles.card, styles.cardBack]}>
|
||||
<View style={styles.backPattern} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const symbol = suitSymbols[suit] || '?';
|
||||
const color = suitColors[suit] || colors.text;
|
||||
|
||||
return (
|
||||
<View style={styles.card}>
|
||||
<Text style={[styles.corner, { color }]}>{rank}</Text>
|
||||
<Text style={[styles.corner, { color }]}>{symbol}</Text>
|
||||
<Text style={[styles.center, { color }]}>{symbol}</Text>
|
||||
<View style={styles.cornerBottom}>
|
||||
<Text style={[styles.corner, { color }]}>{rank}</Text>
|
||||
<Text style={[styles.corner, { color }]}>{symbol}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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' }]
|
||||
}
|
||||
});
|
||||
55
src/components/CasinoButton.js
Normal file
55
src/components/CasinoButton.js
Normal file
@@ -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 (
|
||||
<Pressable onPress={onPress} disabled={disabled} style={styles.wrapper}>
|
||||
<LinearGradient
|
||||
colors={gradients[variant] || gradients.gold}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={[styles.button, disabled && styles.disabled]}
|
||||
>
|
||||
<View style={styles.inner}>
|
||||
<Text style={[styles.text, { color: textColor }]}>{label}</Text>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
});
|
||||
45
src/components/Chip.js
Normal file
45
src/components/Chip.js
Normal file
@@ -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 (
|
||||
<View style={[styles.chip, { backgroundColor: chipColors[color] || colors.chipBlue }]}>
|
||||
<View style={styles.inner}>
|
||||
<Text style={styles.text}>{label}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
});
|
||||
33
src/components/DealerArea.js
Normal file
33
src/components/DealerArea.js
Normal file
@@ -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 (
|
||||
<View style={styles.wrapper}>
|
||||
<Text style={styles.label}>Osztó</Text>
|
||||
<View style={styles.hand}>
|
||||
{hand.map((card, idx) => (
|
||||
<Card key={`${card.rank}-${card.suit}-${idx}`} rank={card.rank} suit={card.suit} hidden={card.hidden} />
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
});
|
||||
73
src/components/Seat.js
Normal file
73
src/components/Seat.js
Normal file
@@ -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 (
|
||||
<View style={[styles.seat, highlight && styles.highlight]}>
|
||||
<Text style={styles.name}>{isEmpty ? 'Üres hely' : seat.username}</Text>
|
||||
{!isEmpty && seat.bet > 0 && (
|
||||
<Text style={styles.bet}>Tet: {seat.bet} Ft</Text>
|
||||
)}
|
||||
{!isEmpty && seat.hand?.length > 0 && (
|
||||
<View style={styles.hand}>
|
||||
{seat.hand.map((card, idx) => (
|
||||
<Card key={`${card.rank}-${card.suit}-${idx}`} rank={card.rank} suit={card.suit} hidden={card.hidden} />
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
{!isEmpty && seat.result && (
|
||||
<Text style={styles.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'}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
});
|
||||
54
src/components/TableBackground.js
Normal file
54
src/components/TableBackground.js
Normal file
@@ -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 (
|
||||
<View style={styles.wrapper}>
|
||||
<LinearGradient
|
||||
colors={[colors.tableEdge, '#3e2a10']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.edge}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={[colors.tableFelt, colors.tableFeltDark]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.felt}
|
||||
>
|
||||
<View style={styles.innerRing} />
|
||||
{children}
|
||||
</LinearGradient>
|
||||
</LinearGradient>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
});
|
||||
75
src/hooks/useAuth.js
Normal file
75
src/hooks/useAuth.js
Normal file
@@ -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
|
||||
};
|
||||
}
|
||||
196
src/screens/LobbyScreen.js
Normal file
196
src/screens/LobbyScreen.js
Normal file
@@ -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 (
|
||||
<LinearGradient colors={[colors.backgroundTop, colors.backgroundBottom]} style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<View>
|
||||
<Text style={styles.title}>Lobbi</Text>
|
||||
<Text style={styles.balance}>Egyenleg: {user.balance} Ft</Text>
|
||||
</View>
|
||||
<CasinoButton label="Kilépés" onPress={onLogout} variant="red" />
|
||||
</View>
|
||||
|
||||
<View style={styles.depositRow}>
|
||||
<Text style={styles.sectionTitle}>Feltöltés</Text>
|
||||
<View style={styles.chips}>
|
||||
<CasinoButton label="50 Ft" onPress={() => handleDeposit(50)} variant="gold" disabled={depositLoading} />
|
||||
<CasinoButton label="100 Ft" onPress={() => handleDeposit(100)} variant="gold" disabled={depositLoading} />
|
||||
</View>
|
||||
{depositError ? <Text style={styles.error}>{depositError}</Text> : null}
|
||||
</View>
|
||||
|
||||
<Text style={styles.sectionTitle}>Asztalok</Text>
|
||||
{loading ? (
|
||||
<ActivityIndicator color={colors.goldBright} />
|
||||
) : (
|
||||
<View style={styles.tableList}>
|
||||
{tables.map((table) => {
|
||||
const free = table.seatCount - table.occupied;
|
||||
return (
|
||||
<View key={table.id} style={styles.tableCard}>
|
||||
<View>
|
||||
<Text style={styles.tableName}>Asztal {table.id}</Text>
|
||||
<Text style={styles.tableMeta}>Szabad hely: {free} / {table.seatCount}</Text>
|
||||
<View style={styles.seatRow}>
|
||||
{Array.from({ length: table.seatCount }).map((_, idx) => (
|
||||
<View key={`${table.id}-${idx}`} style={idx < table.occupied ? styles.seatFilled : styles.seatEmpty} />
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
<CasinoButton
|
||||
label={free > 0 ? 'Beülök' : 'Tele'}
|
||||
onPress={() => free > 0 && onSelectTable(table.id)}
|
||||
variant="green"
|
||||
disabled={free === 0}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
</LinearGradient>
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
});
|
||||
46
src/screens/LoginScreen.js
Normal file
46
src/screens/LoginScreen.js
Normal file
@@ -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 (
|
||||
<LinearGradient
|
||||
colors={[colors.backgroundTop, colors.backgroundBottom]}
|
||||
style={styles.container}
|
||||
>
|
||||
<View style={styles.hero}>
|
||||
<Text style={styles.title}>VEGAS BLACKJACK</Text>
|
||||
<Text style={styles.subtitle}>Multiplayer asztalok, igazi kaszinóhangulat.</Text>
|
||||
</View>
|
||||
<CasinoButton label={loading ? 'Tölt' : 'Discord bejelentkezés'} onPress={onLogin} disabled={loading} />
|
||||
</LinearGradient>
|
||||
);
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
});
|
||||
256
src/screens/TableScreen.js
Normal file
256
src/screens/TableScreen.js
Normal file
@@ -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 (
|
||||
<LinearGradient colors={[colors.backgroundTop, colors.backgroundBottom]} style={styles.container}>
|
||||
<View style={styles.topBar}>
|
||||
<View>
|
||||
<Text style={styles.tableTitle}>Asztal {tableId}</Text>
|
||||
<Text style={styles.balance}>Egyenleg: {balance} Ft</Text>
|
||||
</View>
|
||||
<CasinoButton label="Kilépek" onPress={onLeave} variant="red" />
|
||||
</View>
|
||||
|
||||
<TableBackground>
|
||||
<View style={styles.dealerArea}>
|
||||
<DealerArea hand={table?.dealerHand || []} />
|
||||
</View>
|
||||
|
||||
<View style={styles.seatsLayer}>
|
||||
{table?.seats?.map((seat) => {
|
||||
const position = seatPositions[seat.index];
|
||||
if (!position) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<View key={seat.index} style={[styles.seatPosition, position]}>
|
||||
<Seat seat={seat} highlight={table.currentSeatIndex === seat.index} />
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</TableBackground>
|
||||
|
||||
<View style={styles.controls}>
|
||||
<View style={styles.betRow}>
|
||||
<Text style={styles.sectionLabel}>Tét</Text>
|
||||
<View style={styles.betControls}>
|
||||
<Pressable onPress={() => adjustBet(-10)} style={[styles.betAdjust, bettingLocked && styles.betAdjustDisabled]} disabled={bettingLocked}>
|
||||
<Text style={styles.betAdjustText}>-</Text>
|
||||
</Pressable>
|
||||
<Chip label={`${betAmount}`} color="red" />
|
||||
<Pressable onPress={() => adjustBet(10)} style={[styles.betAdjust, bettingLocked && styles.betAdjustDisabled]} disabled={bettingLocked}>
|
||||
<Text style={styles.betAdjustText}>+</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<CasinoButton label="Tét" onPress={() => send({ type: 'bet', amount: betAmount })} variant="gold" disabled={bettingLocked} />
|
||||
<CasinoButton label="Kész" onPress={() => send({ type: 'ready' })} variant="green" disabled={bettingLocked} />
|
||||
</View>
|
||||
|
||||
<Animated.View style={[styles.actionRow, isMyTurn && pulseStyle]}>
|
||||
<CasinoButton label="Hit" onPress={() => send({ type: 'action', action: 'hit' })} variant="gold" disabled={!isMyTurn} />
|
||||
<CasinoButton label="Stand" onPress={() => send({ type: 'action', action: 'stand' })} variant="gold" disabled={!isMyTurn} />
|
||||
<CasinoButton label="Double" onPress={() => send({ type: 'action', action: 'double' })} variant="gold" disabled={!isMyTurn} />
|
||||
</Animated.View>
|
||||
{message ? <Text style={styles.message}>{message}</Text> : null}
|
||||
</View>
|
||||
</LinearGradient>
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
});
|
||||
24
src/theme.js
Normal file
24
src/theme.js
Normal file
@@ -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' })
|
||||
};
|
||||
Reference in New Issue
Block a user