first commit
This commit is contained in:
6
app.json
6
app.json
@@ -8,7 +8,8 @@
|
|||||||
"scheme": "gumble",
|
"scheme": "gumble",
|
||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "automatic",
|
||||||
"ios": {
|
"ios": {
|
||||||
"icon": "./assets/expo.icon"
|
"icon": "./assets/expo.icon",
|
||||||
|
"bundleIdentifier": "com.anonymous.gumble"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
@@ -17,7 +18,8 @@
|
|||||||
"backgroundImage": "./assets/images/android-icon-background.png",
|
"backgroundImage": "./assets/images/android-icon-background.png",
|
||||||
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
||||||
},
|
},
|
||||||
"predictiveBackGestureEnabled": false
|
"predictiveBackGestureEnabled": false,
|
||||||
|
"package": "com.anonymous.gumble"
|
||||||
},
|
},
|
||||||
"web": {
|
"web": {
|
||||||
"output": "static",
|
"output": "static",
|
||||||
|
|||||||
30
package-lock.json
generated
30
package-lock.json
generated
@@ -8,6 +8,8 @@
|
|||||||
"name": "gumble",
|
"name": "gumble",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@expo-google-fonts/montserrat": "^0.4.2",
|
||||||
|
"@expo-google-fonts/poppins": "^0.4.1",
|
||||||
"@expo/ui": "~56.0.18",
|
"@expo/ui": "~56.0.18",
|
||||||
"expo": "~56.0.12",
|
"expo": "~56.0.12",
|
||||||
"expo-constants": "~56.0.18",
|
"expo-constants": "~56.0.18",
|
||||||
@@ -15,6 +17,7 @@
|
|||||||
"expo-font": "~56.0.7",
|
"expo-font": "~56.0.7",
|
||||||
"expo-glass-effect": "~56.0.4",
|
"expo-glass-effect": "~56.0.4",
|
||||||
"expo-image": "~56.0.11",
|
"expo-image": "~56.0.11",
|
||||||
|
"expo-linear-gradient": "~56.0.4",
|
||||||
"expo-linking": "~56.0.14",
|
"expo-linking": "~56.0.14",
|
||||||
"expo-router": "~56.2.11",
|
"expo-router": "~56.2.11",
|
||||||
"expo-splash-screen": "~56.0.10",
|
"expo-splash-screen": "~56.0.10",
|
||||||
@@ -23,13 +26,13 @@
|
|||||||
"expo-system-ui": "~56.0.5",
|
"expo-system-ui": "~56.0.5",
|
||||||
"expo-web-browser": "~56.0.5",
|
"expo-web-browser": "~56.0.5",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
"react-native": "0.85.3",
|
"react-native": "0.85.3",
|
||||||
"react-native-gesture-handler": "~2.31.1",
|
"react-native-gesture-handler": "~2.31.1",
|
||||||
"react-native-reanimated": "4.3.1",
|
"react-native-reanimated": "4.3.1",
|
||||||
"react-native-safe-area-context": "~5.7.0",
|
"react-native-safe-area-context": "~5.7.0",
|
||||||
"react-native-screens": "4.25.2",
|
"react-native-screens": "4.25.2",
|
||||||
"react-native-web": "~0.21.0",
|
"react-native-web": "^0.21.2",
|
||||||
"react-native-worklets": "0.8.3"
|
"react-native-worklets": "0.8.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -1154,6 +1157,18 @@
|
|||||||
"integrity": "sha512-IJkBtN1o8u9BW5fvSii1MyHPQ7Q0HxbWcVBvOrOzgMLpVtZw7R2w94wBTVR7kZwv3w1JNTESMmLA5Sqn1+Z36A==",
|
"integrity": "sha512-IJkBtN1o8u9BW5fvSii1MyHPQ7Q0HxbWcVBvOrOzgMLpVtZw7R2w94wBTVR7kZwv3w1JNTESMmLA5Sqn1+Z36A==",
|
||||||
"license": "MIT AND Apache-2.0"
|
"license": "MIT AND Apache-2.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/@expo-google-fonts/montserrat": {
|
||||||
|
"version": "0.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@expo-google-fonts/montserrat/-/montserrat-0.4.2.tgz",
|
||||||
|
"integrity": "sha512-Xyq7rKJhhZOE1Xk4wKSSVoUeOkFmQEMhtZ3pdFrgYckGZMs115Apmld1AI8Ju+yrl5GzDdfRCCqRr33dvEDYUA==",
|
||||||
|
"license": "MIT AND OFL-1.1"
|
||||||
|
},
|
||||||
|
"node_modules/@expo-google-fonts/poppins": {
|
||||||
|
"version": "0.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@expo-google-fonts/poppins/-/poppins-0.4.1.tgz",
|
||||||
|
"integrity": "sha512-LiX5oEryJqDt3UmWwr4oqf11hGMz/JEffkkNcZ3UtGwyoMFzXmBmTwcrAWrEttxypxmLNmPykDg+CY2VjKve9A==",
|
||||||
|
"license": "MIT AND OFL-1.1"
|
||||||
|
},
|
||||||
"node_modules/@expo/code-signing-certificates": {
|
"node_modules/@expo/code-signing-certificates": {
|
||||||
"version": "0.0.6",
|
"version": "0.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz",
|
||||||
@@ -3790,6 +3805,17 @@
|
|||||||
"react": "*"
|
"react": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-linear-gradient": {
|
||||||
|
"version": "56.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-linear-gradient/-/expo-linear-gradient-56.0.4.tgz",
|
||||||
|
"integrity": "sha512-KUp1dNSRtuMyiExhf6FJf5YUtmw2cRaPytl10HQi7isj5Yac38udmD55T2tglNYTZlvgT5+oflpyFoH15hmOcw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*",
|
||||||
|
"react": "*",
|
||||||
|
"react-native": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expo-linking": {
|
"node_modules/expo-linking": {
|
||||||
"version": "56.0.14",
|
"version": "56.0.14",
|
||||||
"resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-56.0.14.tgz",
|
"resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-56.0.14.tgz",
|
||||||
|
|||||||
11
package.json
11
package.json
@@ -3,6 +3,8 @@
|
|||||||
"main": "expo-router/entry",
|
"main": "expo-router/entry",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@expo-google-fonts/montserrat": "^0.4.2",
|
||||||
|
"@expo-google-fonts/poppins": "^0.4.1",
|
||||||
"@expo/ui": "~56.0.18",
|
"@expo/ui": "~56.0.18",
|
||||||
"expo": "~56.0.12",
|
"expo": "~56.0.12",
|
||||||
"expo-constants": "~56.0.18",
|
"expo-constants": "~56.0.18",
|
||||||
@@ -10,6 +12,7 @@
|
|||||||
"expo-font": "~56.0.7",
|
"expo-font": "~56.0.7",
|
||||||
"expo-glass-effect": "~56.0.4",
|
"expo-glass-effect": "~56.0.4",
|
||||||
"expo-image": "~56.0.11",
|
"expo-image": "~56.0.11",
|
||||||
|
"expo-linear-gradient": "~56.0.4",
|
||||||
"expo-linking": "~56.0.14",
|
"expo-linking": "~56.0.14",
|
||||||
"expo-router": "~56.2.11",
|
"expo-router": "~56.2.11",
|
||||||
"expo-splash-screen": "~56.0.10",
|
"expo-splash-screen": "~56.0.10",
|
||||||
@@ -18,13 +21,13 @@
|
|||||||
"expo-system-ui": "~56.0.5",
|
"expo-system-ui": "~56.0.5",
|
||||||
"expo-web-browser": "~56.0.5",
|
"expo-web-browser": "~56.0.5",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
"react-native": "0.85.3",
|
"react-native": "0.85.3",
|
||||||
"react-native-gesture-handler": "~2.31.1",
|
"react-native-gesture-handler": "~2.31.1",
|
||||||
"react-native-reanimated": "4.3.1",
|
"react-native-reanimated": "4.3.1",
|
||||||
"react-native-safe-area-context": "~5.7.0",
|
"react-native-safe-area-context": "~5.7.0",
|
||||||
"react-native-screens": "4.25.2",
|
"react-native-screens": "4.25.2",
|
||||||
"react-native-web": "~0.21.0",
|
"react-native-web": "^0.21.2",
|
||||||
"react-native-worklets": "0.8.3"
|
"react-native-worklets": "0.8.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -34,8 +37,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
"reset-project": "node ./scripts/reset-project.js",
|
"reset-project": "node ./scripts/reset-project.js",
|
||||||
"android": "expo start --android",
|
"android": "expo run:android",
|
||||||
"ios": "expo start --ios",
|
"ios": "expo run:ios",
|
||||||
"web": "expo start --web",
|
"web": "expo start --web",
|
||||||
"lint": "expo lint"
|
"lint": "expo lint"
|
||||||
},
|
},
|
||||||
|
|||||||
32
src/app/(auth)/_layout.tsx
Normal file
32
src/app/(auth)/_layout.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { Stack } from 'expo-router';
|
||||||
|
import { StatusBar } from 'expo-status-bar';
|
||||||
|
import { StyleSheet, View } from 'react-native';
|
||||||
|
|
||||||
|
import { Brand } from '@/constants/theme';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stack layout for the gumble auth / onboarding flow. The header is hidden
|
||||||
|
* and the background is forced to the brand dark, so the flow always renders
|
||||||
|
* in its intended dark theme regardless of the OS color scheme.
|
||||||
|
*/
|
||||||
|
export default function AuthLayout() {
|
||||||
|
return (
|
||||||
|
<View style={styles.root}>
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
contentStyle: { backgroundColor: Brand.bg },
|
||||||
|
animation: 'slide_from_right',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<StatusBar style="light" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: Brand.bg,
|
||||||
|
},
|
||||||
|
});
|
||||||
283
src/app/(auth)/login.tsx
Normal file
283
src/app/(auth)/login.tsx
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
import { BrandPressable } from '@/components/brand-pressable';
|
||||||
|
import { BrandTextInput } from '@/components/brand-text-input';
|
||||||
|
import { Brand, BrandFont } from '@/constants/theme';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Frame `61-5` in the Figma ("Login"). The hero art in the Figma is replaced
|
||||||
|
* with a magenta gradient placeholder; the rest of the form — title, tabs,
|
||||||
|
* inputs, social row — is reproduced.
|
||||||
|
*/
|
||||||
|
export default function LoginScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const [tab, setTab] = useState<'signup' | 'login'>('login');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.root, { paddingTop: insets.top, paddingBottom: insets.bottom }]}>
|
||||||
|
<View style={styles.hero}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={['#5a0a3a', '#3a0a25', Brand.bg]}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
/>
|
||||||
|
<HeroSilhouette />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.body}>
|
||||||
|
<View style={styles.titleBlock}>
|
||||||
|
<Text style={styles.title}>Welcome back!</Text>
|
||||||
|
<Text style={styles.subtitle}>Log in to your account</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.tabs}>
|
||||||
|
<Pressable onPress={() => setTab('signup')} style={styles.tab}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.tabLabel,
|
||||||
|
tab === 'signup' ? styles.tabLabelActive : styles.tabLabelInactive,
|
||||||
|
]}>
|
||||||
|
Sign Up
|
||||||
|
</Text>
|
||||||
|
{tab === 'signup' ? <View style={styles.tabIndicator} /> : null}
|
||||||
|
</Pressable>
|
||||||
|
<Pressable onPress={() => setTab('login')} style={styles.tab}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.tabLabel,
|
||||||
|
tab === 'login' ? styles.tabLabelActive : styles.tabLabelInactive,
|
||||||
|
]}>
|
||||||
|
Log In
|
||||||
|
</Text>
|
||||||
|
{tab === 'login' ? <View style={styles.tabIndicator} /> : null}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.form}>
|
||||||
|
<BrandTextInput
|
||||||
|
placeholder="Email"
|
||||||
|
placeholderTextColor={Brand.textFaint}
|
||||||
|
autoCapitalize="none"
|
||||||
|
keyboardType="email-address"
|
||||||
|
value={email}
|
||||||
|
onChangeText={setEmail}
|
||||||
|
leadingIcon="envelope"
|
||||||
|
/>
|
||||||
|
<BrandTextInput
|
||||||
|
placeholder="Password"
|
||||||
|
placeholderTextColor={Brand.textFaint}
|
||||||
|
secureToggle
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
leadingIcon="lock"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Pressable style={styles.forgotRow}>
|
||||||
|
<Text style={styles.forgotText}>Forgot password?</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<BrandPressable
|
||||||
|
label={tab === 'login' ? 'Log In' : 'Create Account'}
|
||||||
|
onPress={() => router.push('/(auth)/select-games')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.dividerRow}>
|
||||||
|
<View style={styles.dividerLine} />
|
||||||
|
<Text style={styles.dividerText}>or continue with</Text>
|
||||||
|
<View style={styles.dividerLine} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.socialRow}>
|
||||||
|
<SocialButton label="Apple" />
|
||||||
|
<SocialButton label="Google" />
|
||||||
|
<SocialButton label="Facebook" />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HeroSilhouette() {
|
||||||
|
return (
|
||||||
|
<View style={silStyles.root}>
|
||||||
|
<View style={silStyles.ring} />
|
||||||
|
<View style={silStyles.disc} />
|
||||||
|
<View style={[silStyles.orb, silStyles.orbA]} />
|
||||||
|
<View style={[silStyles.orb, silStyles.orbB]} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SocialButton({ label }: { label: string }) {
|
||||||
|
return (
|
||||||
|
<Pressable accessibilityRole="button" style={({ pressed }) => [styles.socialButton, pressed && styles.socialPressed]}>
|
||||||
|
<Text style={styles.socialLabel}>{label}</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: Brand.bg,
|
||||||
|
},
|
||||||
|
hero: {
|
||||||
|
height: 220,
|
||||||
|
overflow: 'hidden',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
flex: 1,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingTop: 24,
|
||||||
|
gap: 16,
|
||||||
|
},
|
||||||
|
titleBlock: {
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
color: Brand.text,
|
||||||
|
fontSize: 28,
|
||||||
|
lineHeight: 34,
|
||||||
|
fontFamily: BrandFont.montserratBold,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
color: Brand.textMuted,
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
fontFamily: BrandFont.poppinsRegular,
|
||||||
|
},
|
||||||
|
tabs: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 24,
|
||||||
|
paddingTop: 8,
|
||||||
|
},
|
||||||
|
tab: {
|
||||||
|
paddingBottom: 8,
|
||||||
|
},
|
||||||
|
tabLabel: {
|
||||||
|
fontSize: 15,
|
||||||
|
lineHeight: 20,
|
||||||
|
fontFamily: BrandFont.poppinsSemiBold,
|
||||||
|
},
|
||||||
|
tabLabelActive: {
|
||||||
|
color: Brand.text,
|
||||||
|
},
|
||||||
|
tabLabelInactive: {
|
||||||
|
color: Brand.textMuted,
|
||||||
|
},
|
||||||
|
tabIndicator: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: 2,
|
||||||
|
backgroundColor: Brand.accent,
|
||||||
|
borderRadius: 1,
|
||||||
|
},
|
||||||
|
form: {
|
||||||
|
gap: 12,
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
forgotRow: {
|
||||||
|
alignSelf: 'flex-end',
|
||||||
|
paddingVertical: 4,
|
||||||
|
},
|
||||||
|
forgotText: {
|
||||||
|
color: Brand.accent,
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 18,
|
||||||
|
fontFamily: BrandFont.poppinsMedium,
|
||||||
|
},
|
||||||
|
dividerRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
dividerLine: {
|
||||||
|
flex: 1,
|
||||||
|
height: 1,
|
||||||
|
backgroundColor: Brand.border,
|
||||||
|
},
|
||||||
|
dividerText: {
|
||||||
|
color: Brand.textMuted,
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 16,
|
||||||
|
fontFamily: BrandFont.poppinsRegular,
|
||||||
|
},
|
||||||
|
socialRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 16,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
socialButton: {
|
||||||
|
width: 52,
|
||||||
|
height: 52,
|
||||||
|
borderRadius: 26,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: Brand.border,
|
||||||
|
backgroundColor: Brand.surface,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
socialPressed: {
|
||||||
|
opacity: 0.7,
|
||||||
|
},
|
||||||
|
socialLabel: {
|
||||||
|
color: Brand.text,
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 16,
|
||||||
|
fontFamily: BrandFont.poppinsSemiBold,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const silStyles = StyleSheet.create({
|
||||||
|
root: {
|
||||||
|
width: 200,
|
||||||
|
height: 200,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
ring: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: 180,
|
||||||
|
height: 180,
|
||||||
|
borderRadius: 90,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor: 'rgba(255,255,255,0.2)',
|
||||||
|
},
|
||||||
|
disc: {
|
||||||
|
width: 110,
|
||||||
|
height: 110,
|
||||||
|
borderRadius: 55,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.06)',
|
||||||
|
},
|
||||||
|
orb: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
borderRadius: 11,
|
||||||
|
backgroundColor: Brand.accent,
|
||||||
|
},
|
||||||
|
orbA: {
|
||||||
|
top: 30,
|
||||||
|
left: 50,
|
||||||
|
},
|
||||||
|
orbB: {
|
||||||
|
bottom: 40,
|
||||||
|
right: 40,
|
||||||
|
backgroundColor: '#f3c',
|
||||||
|
},
|
||||||
|
});
|
||||||
206
src/app/(auth)/onboarding.tsx
Normal file
206
src/app/(auth)/onboarding.tsx
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
import { BrandPressable } from '@/components/brand-pressable';
|
||||||
|
import { Brand, BrandFont } from '@/constants/theme';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Frame `52-54` in the Figma ("UI variation - 2"). The opening splash that
|
||||||
|
* introduces Gumble and offers Sign Up / Log In. The hero artwork in the
|
||||||
|
* Figma is a custom illustration that we approximate with a magenta-to-pink
|
||||||
|
* gradient and a stylised game-pad silhouette drawn from primitives.
|
||||||
|
*/
|
||||||
|
export default function OnboardingScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.root, { paddingTop: insets.top, paddingBottom: insets.bottom }]}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<View style={styles.logoMark}>
|
||||||
|
<Text style={styles.logoMarkText}>G</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.brandWordmark}>Gumble</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.hero}>
|
||||||
|
<View style={styles.heroFrame}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={['rgba(233,46,186,0.18)', 'rgba(255,255,255,0.02)']}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
/>
|
||||||
|
<GamePadIllustration />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.copy}>
|
||||||
|
<Text style={styles.title}>Gamble your games, gamble your skills</Text>
|
||||||
|
<Text style={styles.subtitle}>
|
||||||
|
Play mini-games, complete tasks, and earn rewards — all powered by your favorite games.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<BrandPressable label="Sign Up" onPress={() => router.push('/(auth)/login')} />
|
||||||
|
<BrandPressable
|
||||||
|
label="Log In"
|
||||||
|
variant="outline"
|
||||||
|
onPress={() => router.push('/(auth)/login')}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GamePadIllustration() {
|
||||||
|
return (
|
||||||
|
<View style={illustStyles.root}>
|
||||||
|
<View style={illustStyles.pad}>
|
||||||
|
<View style={illustStyles.dpad}>
|
||||||
|
<View style={[illustStyles.dpadArm, illustStyles.dpadHorizontal]} />
|
||||||
|
<View style={[illustStyles.dpadArm, illustStyles.dpadVertical]} />
|
||||||
|
</View>
|
||||||
|
<View style={illustStyles.buttons}>
|
||||||
|
<View style={[illustStyles.button, { backgroundColor: '#e92eba' }]} />
|
||||||
|
<View style={[illustStyles.button, { backgroundColor: '#f3c' }]} />
|
||||||
|
</View>
|
||||||
|
<View style={illustStyles.stick} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: Brand.bg,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
paddingTop: 12,
|
||||||
|
},
|
||||||
|
logoMark: {
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: Brand.accent,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
logoMarkText: {
|
||||||
|
color: Brand.text,
|
||||||
|
fontSize: 16,
|
||||||
|
lineHeight: 20,
|
||||||
|
fontFamily: BrandFont.montserratBold,
|
||||||
|
},
|
||||||
|
brandWordmark: {
|
||||||
|
color: Brand.accent,
|
||||||
|
fontSize: 18,
|
||||||
|
lineHeight: 22,
|
||||||
|
fontFamily: BrandFont.montserratBold,
|
||||||
|
},
|
||||||
|
hero: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 16,
|
||||||
|
},
|
||||||
|
heroFrame: {
|
||||||
|
width: '100%',
|
||||||
|
aspectRatio: 1,
|
||||||
|
maxHeight: 320,
|
||||||
|
borderRadius: 16,
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: Brand.surfaceAlt,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
copy: {
|
||||||
|
gap: 12,
|
||||||
|
paddingBottom: 24,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
color: Brand.text,
|
||||||
|
fontSize: 26,
|
||||||
|
lineHeight: 32,
|
||||||
|
fontFamily: BrandFont.montserratBold,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
color: Brand.textMuted,
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
fontFamily: BrandFont.poppinsRegular,
|
||||||
|
textAlign: 'center',
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
gap: 12,
|
||||||
|
paddingBottom: 8,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const illustStyles = StyleSheet.create({
|
||||||
|
root: {
|
||||||
|
width: '70%',
|
||||||
|
aspectRatio: 1.2,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
pad: {
|
||||||
|
width: '100%',
|
||||||
|
height: '60%',
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.05)',
|
||||||
|
borderRadius: 24,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255,255,255,0.08)',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-around',
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
},
|
||||||
|
dpad: {
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
dpadArm: {
|
||||||
|
position: 'absolute',
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.5)',
|
||||||
|
borderRadius: 3,
|
||||||
|
},
|
||||||
|
dpadHorizontal: {
|
||||||
|
width: 44,
|
||||||
|
height: 10,
|
||||||
|
},
|
||||||
|
dpadVertical: {
|
||||||
|
width: 10,
|
||||||
|
height: 44,
|
||||||
|
},
|
||||||
|
buttons: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
borderRadius: 9,
|
||||||
|
},
|
||||||
|
stick: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 14,
|
||||||
|
alignSelf: 'center',
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.3)',
|
||||||
|
},
|
||||||
|
});
|
||||||
117
src/app/(auth)/search-games.tsx
Normal file
117
src/app/(auth)/search-games.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
import { BrandTextInput } from '@/components/brand-text-input';
|
||||||
|
import { GameTile } from '@/components/game-tile';
|
||||||
|
import { Brand, BrandFont } from '@/constants/theme';
|
||||||
|
import { useGameSearch } from '@/hooks/use-game-search';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Frames `24-101` (empty search + Most Popular), `29-3` ("Pubg|" + Most
|
||||||
|
* Popular), and `29-37` ("Pubg|" + Searched Results). All three are the same
|
||||||
|
* screen with different local search state; the section header swaps based on
|
||||||
|
* whether the query has results that aren't the popular set.
|
||||||
|
*/
|
||||||
|
export default function SearchGamesScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const results = useGameSearch(query);
|
||||||
|
const isEmpty = query.trim().length === 0;
|
||||||
|
const sectionTitle = isEmpty ? 'Most Popular' : 'Searched Results';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.root, { paddingTop: insets.top, paddingBottom: insets.bottom }]}>
|
||||||
|
<View style={styles.searchRow}>
|
||||||
|
<BrandTextInput
|
||||||
|
autoFocus
|
||||||
|
placeholder="Search games by name"
|
||||||
|
value={query}
|
||||||
|
onChangeText={setQuery}
|
||||||
|
leadingIcon="magnifyingglass"
|
||||||
|
returnKeyType="search"
|
||||||
|
autoCapitalize="none"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.headerRow}>
|
||||||
|
<Text style={styles.sectionTitle}>{sectionTitle}</Text>
|
||||||
|
<Text onPress={() => router.back()} style={styles.cancel}>
|
||||||
|
Cancel
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={styles.grid}
|
||||||
|
showsVerticalScrollIndicator={false}>
|
||||||
|
{results.length === 0 ? (
|
||||||
|
<Text style={styles.empty}>No games match “{query}”.</Text>
|
||||||
|
) : (
|
||||||
|
results.map((game) => (
|
||||||
|
<GameTile
|
||||||
|
key={game.id}
|
||||||
|
id={game.id}
|
||||||
|
name={game.name}
|
||||||
|
genre={game.genre}
|
||||||
|
gradient={game.gradient}
|
||||||
|
onPress={() => {
|
||||||
|
// Demo: a tap on a result just clears the query so the user
|
||||||
|
// can try another search. Real apps would add the game.
|
||||||
|
setQuery('');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: Brand.bg,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
},
|
||||||
|
searchRow: {
|
||||||
|
paddingTop: 8,
|
||||||
|
paddingBottom: 12,
|
||||||
|
},
|
||||||
|
headerRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingBottom: 12,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
color: Brand.text,
|
||||||
|
fontSize: 18,
|
||||||
|
lineHeight: 24,
|
||||||
|
fontFamily: BrandFont.poppinsSemiBold,
|
||||||
|
},
|
||||||
|
cancel: {
|
||||||
|
color: Brand.textMuted,
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 18,
|
||||||
|
fontFamily: BrandFont.poppinsMedium,
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingBottom: 24,
|
||||||
|
rowGap: 16,
|
||||||
|
},
|
||||||
|
empty: {
|
||||||
|
color: Brand.textMuted,
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
fontFamily: BrandFont.poppinsRegular,
|
||||||
|
paddingTop: 32,
|
||||||
|
textAlign: 'center',
|
||||||
|
alignSelf: 'center',
|
||||||
|
},
|
||||||
|
});
|
||||||
116
src/app/(auth)/select-games.tsx
Normal file
116
src/app/(auth)/select-games.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
import { BrandPressable } from '@/components/brand-pressable';
|
||||||
|
import { GameTile } from '@/components/game-tile';
|
||||||
|
import { Brand, BrandFont } from '@/constants/theme';
|
||||||
|
import { GAMES } from '@/data/games';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Frames `20-21` (initial, nothing selected) and `24-46` (2 games selected).
|
||||||
|
* The two Figma frames are the same screen with different local state, so we
|
||||||
|
* model them as a single screen with a `Set<string>` of selected ids.
|
||||||
|
*/
|
||||||
|
export default function SelectGamesScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const toggle = useCallback((id: string) => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) {
|
||||||
|
next.delete(id);
|
||||||
|
} else {
|
||||||
|
next.add(id);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.root, { paddingTop: insets.top, paddingBottom: insets.bottom }]}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>Which games do you play?</Text>
|
||||||
|
<Text style={styles.subtitle}>
|
||||||
|
Pick the games you play the most to personalize your experience.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={styles.grid}
|
||||||
|
showsVerticalScrollIndicator={false}>
|
||||||
|
{GAMES.map((game) => (
|
||||||
|
<GameTile
|
||||||
|
key={game.id}
|
||||||
|
id={game.id}
|
||||||
|
name={game.name}
|
||||||
|
genre={game.genre}
|
||||||
|
gradient={game.gradient}
|
||||||
|
selected={selected.has(game.id)}
|
||||||
|
onPress={() => toggle(game.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<View style={styles.footer}>
|
||||||
|
<BrandPressable
|
||||||
|
label={`Continue${selected.size > 0 ? ` (${selected.size})` : ''}`}
|
||||||
|
disabled={selected.size === 0}
|
||||||
|
onPress={() => router.push('/(auth)/search-games')}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
onPress={() => router.push('/(auth)/search-games')}
|
||||||
|
style={styles.link}>
|
||||||
|
Search for a game
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: Brand.bg,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
paddingTop: 16,
|
||||||
|
paddingBottom: 16,
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
color: Brand.text,
|
||||||
|
fontSize: 22,
|
||||||
|
lineHeight: 28,
|
||||||
|
fontFamily: BrandFont.montserratBold,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
color: Brand.textMuted,
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 18,
|
||||||
|
fontFamily: BrandFont.poppinsRegular,
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingVertical: 8,
|
||||||
|
rowGap: 16,
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
paddingTop: 16,
|
||||||
|
gap: 8,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
color: Brand.accent,
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 18,
|
||||||
|
fontFamily: BrandFont.poppinsMedium,
|
||||||
|
paddingVertical: 6,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,15 +1,52 @@
|
|||||||
import { DarkTheme, DefaultTheme, ThemeProvider } from 'expo-router';
|
import {
|
||||||
import { useColorScheme } from 'react-native';
|
Montserrat_400Regular,
|
||||||
|
Montserrat_500Medium,
|
||||||
|
Montserrat_600SemiBold,
|
||||||
|
Montserrat_700Bold,
|
||||||
|
useFonts as useMontserratFonts,
|
||||||
|
} from "@expo-google-fonts/montserrat";
|
||||||
|
import {
|
||||||
|
Poppins_400Regular,
|
||||||
|
Poppins_500Medium,
|
||||||
|
Poppins_600SemiBold,
|
||||||
|
Poppins_700Bold,
|
||||||
|
useFonts as usePoppinsFonts,
|
||||||
|
} from "@expo-google-fonts/poppins";
|
||||||
|
import { DarkTheme, DefaultTheme, ThemeProvider, Stack } from "expo-router";
|
||||||
|
import { useColorScheme } from "react-native";
|
||||||
|
|
||||||
import { AnimatedSplashOverlay } from '@/components/animated-icon';
|
import { AnimatedSplashOverlay } from "@/components/animated-icon";
|
||||||
import AppTabs from '@/components/app-tabs';
|
|
||||||
|
|
||||||
export default function TabLayout() {
|
export default function RootLayout() {
|
||||||
const colorScheme = useColorScheme();
|
const colorScheme = useColorScheme();
|
||||||
|
|
||||||
|
const [poppinsLoaded] = usePoppinsFonts({
|
||||||
|
Poppins_400Regular,
|
||||||
|
Poppins_500Medium,
|
||||||
|
Poppins_600SemiBold,
|
||||||
|
Poppins_700Bold,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [montserratLoaded] = useMontserratFonts({
|
||||||
|
Montserrat_400Regular,
|
||||||
|
Montserrat_500Medium,
|
||||||
|
Montserrat_600SemiBold,
|
||||||
|
Montserrat_700Bold,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!poppinsLoaded || !montserratLoaded) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
<ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
|
||||||
<AnimatedSplashOverlay />
|
<AnimatedSplashOverlay />
|
||||||
<AppTabs />
|
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,98 +1,14 @@
|
|||||||
import * as Device from 'expo-device';
|
import { useEffect } from "react";
|
||||||
import { Platform, StyleSheet } from 'react-native';
|
import { useRouter } from "expo-router";
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
||||||
|
|
||||||
import { AnimatedIcon } from '@/components/animated-icon';
|
// Redirect the root route to the onboarding flow on app start.
|
||||||
import { HintRow } from '@/components/hint-row';
|
export default function IndexRedirect() {
|
||||||
import { ThemedText } from '@/components/themed-text';
|
const router = useRouter();
|
||||||
import { ThemedView } from '@/components/themed-view';
|
useEffect(() => {
|
||||||
import { WebBadge } from '@/components/web-badge';
|
// Use replace so the welcome route doesn't remain in the navigation stack.
|
||||||
import { BottomTabInset, MaxContentWidth, Spacing } from '@/constants/theme';
|
router.replace("/(auth)/onboarding");
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
function getDevMenuHint() {
|
// Nothing to render here while we redirect.
|
||||||
if (Platform.OS === 'web') {
|
return null;
|
||||||
return <ThemedText type="small">use browser devtools</ThemedText>;
|
|
||||||
}
|
|
||||||
if (Device.isDevice) {
|
|
||||||
return (
|
|
||||||
<ThemedText type="small">
|
|
||||||
shake device or press <ThemedText type="code">m</ThemedText> in terminal
|
|
||||||
</ThemedText>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const shortcut = Platform.OS === 'android' ? 'cmd+m (or ctrl+m)' : 'cmd+d';
|
|
||||||
return (
|
|
||||||
<ThemedText type="small">
|
|
||||||
press <ThemedText type="code">{shortcut}</ThemedText>
|
|
||||||
</ThemedText>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HomeScreen() {
|
|
||||||
return (
|
|
||||||
<ThemedView style={styles.container}>
|
|
||||||
<SafeAreaView style={styles.safeArea}>
|
|
||||||
<ThemedView style={styles.heroSection}>
|
|
||||||
<AnimatedIcon />
|
|
||||||
<ThemedText type="title" style={styles.title}>
|
|
||||||
Welcome to Expo
|
|
||||||
</ThemedText>
|
|
||||||
</ThemedView>
|
|
||||||
|
|
||||||
<ThemedText type="code" style={styles.code}>
|
|
||||||
get started
|
|
||||||
</ThemedText>
|
|
||||||
|
|
||||||
<ThemedView type="backgroundElement" style={styles.stepContainer}>
|
|
||||||
<HintRow
|
|
||||||
title="Try editing"
|
|
||||||
hint={<ThemedText type="code">src/app/index.tsx</ThemedText>}
|
|
||||||
/>
|
|
||||||
<HintRow title="Dev tools" hint={getDevMenuHint()} />
|
|
||||||
<HintRow
|
|
||||||
title="Fresh start"
|
|
||||||
hint={<ThemedText type="code">npm run reset-project</ThemedText>}
|
|
||||||
/>
|
|
||||||
</ThemedView>
|
|
||||||
|
|
||||||
{Platform.OS === 'web' && <WebBadge />}
|
|
||||||
</SafeAreaView>
|
|
||||||
</ThemedView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
justifyContent: 'center',
|
|
||||||
flexDirection: 'row',
|
|
||||||
},
|
|
||||||
safeArea: {
|
|
||||||
flex: 1,
|
|
||||||
paddingHorizontal: Spacing.four,
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: Spacing.three,
|
|
||||||
paddingBottom: BottomTabInset + Spacing.three,
|
|
||||||
maxWidth: MaxContentWidth,
|
|
||||||
},
|
|
||||||
heroSection: {
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
flex: 1,
|
|
||||||
paddingHorizontal: Spacing.four,
|
|
||||||
gap: Spacing.four,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
textAlign: 'center',
|
|
||||||
},
|
|
||||||
code: {
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
},
|
|
||||||
stepContainer: {
|
|
||||||
gap: Spacing.three,
|
|
||||||
alignSelf: 'stretch',
|
|
||||||
paddingHorizontal: Spacing.three,
|
|
||||||
paddingVertical: Spacing.four,
|
|
||||||
borderRadius: Spacing.four,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|||||||
108
src/components/brand-pressable.tsx
Normal file
108
src/components/brand-pressable.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { Pressable, StyleSheet, Text, type StyleProp, type ViewStyle } from 'react-native';
|
||||||
|
|
||||||
|
import { Brand, BrandFont } from '@/constants/theme';
|
||||||
|
|
||||||
|
type Variant = 'solid' | 'outline';
|
||||||
|
|
||||||
|
export type BrandPressableProps = {
|
||||||
|
label: string;
|
||||||
|
onPress?: () => void;
|
||||||
|
variant?: Variant;
|
||||||
|
disabled?: boolean;
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-width pill button used throughout the gumble flow. The `solid` variant
|
||||||
|
* uses the magenta→pink brand gradient. The `outline` variant renders an
|
||||||
|
* outlined button on a dark background, used for the secondary "Log In" call
|
||||||
|
* to action on the onboarding screen.
|
||||||
|
*/
|
||||||
|
export function BrandPressable({
|
||||||
|
label,
|
||||||
|
onPress,
|
||||||
|
variant = 'solid',
|
||||||
|
disabled = false,
|
||||||
|
style,
|
||||||
|
}: BrandPressableProps) {
|
||||||
|
if (variant === 'outline') {
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityState={{ disabled }}
|
||||||
|
disabled={disabled}
|
||||||
|
onPress={onPress}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.base,
|
||||||
|
styles.outline,
|
||||||
|
disabled && styles.disabled,
|
||||||
|
pressed && styles.pressed,
|
||||||
|
style,
|
||||||
|
]}>
|
||||||
|
<Text style={[styles.label, styles.outlineLabel]}>{label}</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityState={{ disabled }}
|
||||||
|
disabled={disabled}
|
||||||
|
onPress={onPress}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.base,
|
||||||
|
disabled && styles.disabled,
|
||||||
|
pressed && styles.pressed,
|
||||||
|
style,
|
||||||
|
]}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={[...Brand.gradient]}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 0 }}
|
||||||
|
style={styles.gradient}>
|
||||||
|
<Text style={[styles.label, styles.solidLabel]}>{label}</Text>
|
||||||
|
</LinearGradient>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
base: {
|
||||||
|
height: 55,
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: 'hidden',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
alignSelf: 'stretch',
|
||||||
|
},
|
||||||
|
gradient: {
|
||||||
|
flex: 1,
|
||||||
|
width: '100%',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 16,
|
||||||
|
lineHeight: 22,
|
||||||
|
fontFamily: BrandFont.poppinsSemiBold,
|
||||||
|
},
|
||||||
|
solidLabel: {
|
||||||
|
color: Brand.text,
|
||||||
|
},
|
||||||
|
outline: {
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: Brand.border,
|
||||||
|
},
|
||||||
|
outlineLabel: {
|
||||||
|
color: Brand.text,
|
||||||
|
},
|
||||||
|
pressed: {
|
||||||
|
opacity: 0.8,
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
opacity: 0.4,
|
||||||
|
},
|
||||||
|
});
|
||||||
107
src/components/brand-text-input.tsx
Normal file
107
src/components/brand-text-input.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { SymbolView } from 'expo-symbols';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Pressable,
|
||||||
|
StyleSheet,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
type TextInputProps,
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
import { Brand, BrandFont } from '@/constants/theme';
|
||||||
|
|
||||||
|
export type BrandTextInputProps = Omit<TextInputProps, 'style'> & {
|
||||||
|
/** SF Symbol name to render on the leading edge. */
|
||||||
|
leadingIcon?: string;
|
||||||
|
/** When true, renders a show/hide eye toggle on the right edge. */
|
||||||
|
secureToggle?: boolean;
|
||||||
|
/** Optional helper text rendered below the input. */
|
||||||
|
helperText?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Themed dark text input used for email, password, and the search bar. The
|
||||||
|
* secureToggle is for password fields; everything else uses the same
|
||||||
|
* `BrandTextInput` so the styling stays consistent.
|
||||||
|
*/
|
||||||
|
export function BrandTextInput({
|
||||||
|
leadingIcon,
|
||||||
|
secureToggle = false,
|
||||||
|
helperText,
|
||||||
|
secureTextEntry,
|
||||||
|
...rest
|
||||||
|
}: BrandTextInputProps) {
|
||||||
|
const [hidden, setHidden] = useState(secureTextEntry ?? false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.wrapper}>
|
||||||
|
<View style={styles.inputRow}>
|
||||||
|
{leadingIcon ? (
|
||||||
|
<SymbolView
|
||||||
|
name={leadingIcon as Parameters<typeof SymbolView>[0]['name']}
|
||||||
|
tintColor={Brand.textMuted}
|
||||||
|
size={18}
|
||||||
|
style={styles.leadingIcon}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<TextInput
|
||||||
|
{...rest}
|
||||||
|
secureTextEntry={hidden}
|
||||||
|
placeholderTextColor={Brand.textFaint}
|
||||||
|
selectionColor={Brand.accent}
|
||||||
|
style={styles.input}
|
||||||
|
/>
|
||||||
|
{secureToggle ? (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={hidden ? 'Show password' : 'Hide password'}
|
||||||
|
hitSlop={8}
|
||||||
|
onPress={() => setHidden((v) => !v)}
|
||||||
|
style={styles.trailingIcon}>
|
||||||
|
<SymbolView
|
||||||
|
name={
|
||||||
|
(hidden ? 'eye' : 'eye.slash') as Parameters<
|
||||||
|
typeof SymbolView
|
||||||
|
>[0]['name']
|
||||||
|
}
|
||||||
|
tintColor={Brand.textMuted}
|
||||||
|
size={18}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
{helperText ? <></> : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
wrapper: {
|
||||||
|
alignSelf: 'stretch',
|
||||||
|
},
|
||||||
|
inputRow: {
|
||||||
|
height: 55,
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: Brand.inputBg,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: Brand.border,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
leadingIcon: {
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
trailingIcon: {
|
||||||
|
marginLeft: 8,
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
flex: 1,
|
||||||
|
color: Brand.text,
|
||||||
|
fontSize: 15,
|
||||||
|
lineHeight: 20,
|
||||||
|
fontFamily: BrandFont.poppinsRegular,
|
||||||
|
padding: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
128
src/components/game-tile.tsx
Normal file
128
src/components/game-tile.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { SymbolView } from 'expo-symbols';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { Brand, BrandFont } from '@/constants/theme';
|
||||||
|
|
||||||
|
export type GameTileProps = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
genre: string;
|
||||||
|
gradient: readonly [string, string];
|
||||||
|
/** When true, draws the magenta selection border + checkmark. */
|
||||||
|
selected?: boolean;
|
||||||
|
/** Optional image source; falls back to the gradient placeholder. */
|
||||||
|
imageSource?: string;
|
||||||
|
onPress?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 166×246 game card used on the "Which games do you play?" and "Search games"
|
||||||
|
* screens. The card is pressable; when `selected`, it gets a 4px magenta
|
||||||
|
* border and a small white checkmark in the top-right corner (frame 2 in the
|
||||||
|
* Figma).
|
||||||
|
*/
|
||||||
|
export function GameTile({
|
||||||
|
name,
|
||||||
|
genre,
|
||||||
|
gradient,
|
||||||
|
selected = false,
|
||||||
|
imageSource,
|
||||||
|
onPress,
|
||||||
|
}: GameTileProps) {
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityState={{ selected }}
|
||||||
|
accessibilityLabel={`${name} (${genre})`}
|
||||||
|
onPress={onPress}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.card,
|
||||||
|
selected && styles.cardSelected,
|
||||||
|
pressed && styles.pressed,
|
||||||
|
]}>
|
||||||
|
<View style={styles.artwork}>
|
||||||
|
{imageSource ? (
|
||||||
|
<Image source={{ uri: imageSource }} style={StyleSheet.absoluteFill} contentFit="cover" />
|
||||||
|
) : (
|
||||||
|
<LinearGradient
|
||||||
|
colors={[...gradient]}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View style={styles.body}>
|
||||||
|
<Text numberOfLines={1} style={styles.name}>
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
<Text numberOfLines={1} style={styles.genre}>
|
||||||
|
{genre}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{selected ? (
|
||||||
|
<View style={styles.checkmark}>
|
||||||
|
<SymbolView
|
||||||
|
name="checkmark"
|
||||||
|
tintColor={Brand.text}
|
||||||
|
size={12}
|
||||||
|
weight="bold"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
card: {
|
||||||
|
width: 166,
|
||||||
|
height: 246,
|
||||||
|
backgroundColor: Brand.surface,
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderWidth: 4,
|
||||||
|
borderColor: 'transparent',
|
||||||
|
},
|
||||||
|
cardSelected: {
|
||||||
|
borderColor: Brand.accent,
|
||||||
|
},
|
||||||
|
pressed: {
|
||||||
|
opacity: 0.85,
|
||||||
|
},
|
||||||
|
artwork: {
|
||||||
|
height: 170,
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
flex: 1,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingTop: 10,
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
color: Brand.text,
|
||||||
|
fontSize: 15,
|
||||||
|
lineHeight: 20,
|
||||||
|
fontFamily: BrandFont.montserratSemiBold,
|
||||||
|
},
|
||||||
|
genre: {
|
||||||
|
color: Brand.textMuted,
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 16,
|
||||||
|
fontFamily: BrandFont.poppinsRegular,
|
||||||
|
},
|
||||||
|
checkmark: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 12,
|
||||||
|
right: 12,
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
borderRadius: 9,
|
||||||
|
backgroundColor: Brand.accent,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -35,7 +35,7 @@ export const Fonts = Platform.select({
|
|||||||
/** iOS `UIFontDescriptorSystemDesignRounded` */
|
/** iOS `UIFontDescriptorSystemDesignRounded` */
|
||||||
rounded: 'ui-rounded',
|
rounded: 'ui-rounded',
|
||||||
/** iOS `UIFontDescriptorSystemDesignMonospaced` */
|
/** iOS `UIFontDescriptorSystemDesignMonospaced` */
|
||||||
mono: 'ui-monospace',
|
mono: 'ui-monospaced',
|
||||||
},
|
},
|
||||||
default: {
|
default: {
|
||||||
sans: 'normal',
|
sans: 'normal',
|
||||||
@@ -63,3 +63,43 @@ export const Spacing = {
|
|||||||
|
|
||||||
export const BottomTabInset = Platform.select({ ios: 50, android: 80 }) ?? 0;
|
export const BottomTabInset = Platform.select({ ios: 50, android: 80 }) ?? 0;
|
||||||
export const MaxContentWidth = 800;
|
export const MaxContentWidth = 800;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Brand design tokens for the gumble onboarding / auth flow.
|
||||||
|
* These are dark-only and override the light/dark `Colors` scheme on the screens
|
||||||
|
* that use the gumble brand (login, onboarding, game selection, search).
|
||||||
|
*/
|
||||||
|
export const Brand = {
|
||||||
|
bg: '#141414',
|
||||||
|
surface: '#2c2c2c',
|
||||||
|
surfaceAlt: '#1a1a1a',
|
||||||
|
border: 'rgba(255,255,255,0.1)',
|
||||||
|
borderStrong: 'rgba(255,255,255,0.2)',
|
||||||
|
text: '#ffffff',
|
||||||
|
textMuted: 'rgba(255,255,255,0.6)',
|
||||||
|
textFaint: 'rgba(255,255,255,0.4)',
|
||||||
|
accent: '#e92eba',
|
||||||
|
accentSoft: 'rgba(233,46,186,0.12)',
|
||||||
|
accentLight: '#f3c',
|
||||||
|
inputBg: 'rgba(255,255,255,0.03)',
|
||||||
|
gradient: ['#f3c', '#e62eb8'] as const,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Font family names loaded by `@expo-google-fonts/montserrat` and
|
||||||
|
* `@expo-google-fonts/poppins`. Use as `fontFamily: BrandFont.X`.
|
||||||
|
* These are the same names that the loader exposes as keys in its
|
||||||
|
* `useFonts({...})` map.
|
||||||
|
*/
|
||||||
|
export const BrandFont = {
|
||||||
|
// Poppins
|
||||||
|
poppinsRegular: 'Poppins_400Regular',
|
||||||
|
poppinsMedium: 'Poppins_500Medium',
|
||||||
|
poppinsSemiBold: 'Poppins_600SemiBold',
|
||||||
|
poppinsBold: 'Poppins_700Bold',
|
||||||
|
// Montserrat
|
||||||
|
montserratRegular: 'Montserrat_400Regular',
|
||||||
|
montserratMedium: 'Montserrat_500Medium',
|
||||||
|
montserratSemiBold: 'Montserrat_600SemiBold',
|
||||||
|
montserratBold: 'Montserrat_700Bold',
|
||||||
|
} as const;
|
||||||
|
|||||||
72
src/data/games.ts
Normal file
72
src/data/games.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* In-memory catalogue of games shown on the "Which games do you play?" and
|
||||||
|
* "Search games" screens. Each entry provides the visual identity used by the
|
||||||
|
* placeholder tile; the real game artwork can be dropped in later by passing
|
||||||
|
* a custom `image` to `<GameTile>`.
|
||||||
|
*/
|
||||||
|
export type Game = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
genre: string;
|
||||||
|
/** Two-stop gradient used as the tile's placeholder artwork. */
|
||||||
|
gradient: readonly [string, string];
|
||||||
|
/** Marks a tile as one of the "Most Popular" picks shown in search. */
|
||||||
|
popular?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GAMES: Game[] = [
|
||||||
|
{
|
||||||
|
id: 'mobile-legends',
|
||||||
|
name: 'Mobile Legends',
|
||||||
|
genre: 'Strategy',
|
||||||
|
gradient: ['#1a0033', '#4d0066'],
|
||||||
|
popular: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pubg-mobile',
|
||||||
|
name: 'PUBG MOBILE',
|
||||||
|
genre: 'Battle Royale',
|
||||||
|
gradient: ['#332200', '#664400'],
|
||||||
|
popular: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'free-fire',
|
||||||
|
name: 'Free Fire',
|
||||||
|
genre: 'Battle Royale',
|
||||||
|
gradient: ['#3a1010', '#7a2222'],
|
||||||
|
popular: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'brawl-stars',
|
||||||
|
name: 'Brawl Stars',
|
||||||
|
genre: 'Action',
|
||||||
|
gradient: ['#1f2a00', '#3f5600'],
|
||||||
|
popular: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'genshin-impact',
|
||||||
|
name: 'Genshin Impact',
|
||||||
|
genre: 'Adventure',
|
||||||
|
gradient: ['#002a33', '#005566'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'honor-of-kings',
|
||||||
|
name: 'Honor of Kings',
|
||||||
|
genre: 'MOBA',
|
||||||
|
gradient: ['#2a0014', '#560028'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'clash-royale',
|
||||||
|
name: 'Clash Royale',
|
||||||
|
genre: 'Strategy',
|
||||||
|
gradient: ['#2c1a00', '#583400'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cod-mobile',
|
||||||
|
name: 'Call of Duty Mobile',
|
||||||
|
genre: 'Shooter',
|
||||||
|
gradient: ['#001a2a', '#003355'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const POPULAR_GAMES = GAMES.filter((g) => g.popular);
|
||||||
25
src/hooks/use-game-search.ts
Normal file
25
src/hooks/use-game-search.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { GAMES, type Game } from '@/data/games';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the games that match a free-text query, or the popular list when the
|
||||||
|
* query is empty. Match is case-insensitive and substring-based on the game
|
||||||
|
* name. The popular ordering puts the most popular games first; when the user
|
||||||
|
* has typed a query, results are sorted by name length (shortest first) so the
|
||||||
|
* closest match surfaces.
|
||||||
|
*/
|
||||||
|
export function filterGames(query: string): Game[] {
|
||||||
|
const trimmed = query.trim();
|
||||||
|
if (trimmed.length === 0) {
|
||||||
|
return GAMES.filter((g) => g.popular);
|
||||||
|
}
|
||||||
|
const needle = trimmed.toLowerCase();
|
||||||
|
return GAMES.filter((g) => g.name.toLowerCase().includes(needle)).sort(
|
||||||
|
(a, b) => a.name.length - b.name.length,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGameSearch(query: string): Game[] {
|
||||||
|
return useMemo(() => filterGames(query), [query]);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user