486 lines
15 KiB
TypeScript
486 lines
15 KiB
TypeScript
|
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||
|
|
import {
|
||
|
|
Alert,
|
||
|
|
ScrollView,
|
||
|
|
StyleSheet,
|
||
|
|
Text,
|
||
|
|
TextInput,
|
||
|
|
TouchableOpacity,
|
||
|
|
View,
|
||
|
|
} from "react-native";
|
||
|
|
import { useNavigation, useRoute } from "@react-navigation/native";
|
||
|
|
import type { RouteProp } from "@react-navigation/native";
|
||
|
|
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||
|
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||
|
|
import BrandLockup from "../components/BrandLockup";
|
||
|
|
import { useI18n } from "../i18n";
|
||
|
|
import type { RootStackParamList } from "../navigation/types";
|
||
|
|
import { useSession } from "../state/session-context";
|
||
|
|
import type { SessionPreview } from "../shared/types";
|
||
|
|
import { useTheme, type AppTheme } from "../theme";
|
||
|
|
|
||
|
|
export default function AgencyJoinScreen() {
|
||
|
|
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||
|
|
const route = useRoute<RouteProp<RootStackParamList, "AgencyJoin">>();
|
||
|
|
const manager = useSession();
|
||
|
|
const { t } = useI18n();
|
||
|
|
const theme = useTheme();
|
||
|
|
const styles = useMemo(() => createStyles(theme), [theme]);
|
||
|
|
const placeholderColor = theme.colors.placeholder;
|
||
|
|
const handledLinkRef = useRef<string | null>(null);
|
||
|
|
const insets = useSafeAreaInsets();
|
||
|
|
const [joinCode, setJoinCode] = useState("");
|
||
|
|
const [joinStep, setJoinStep] = useState<"code" | "choice">("code");
|
||
|
|
const [joinPreview, setJoinPreview] = useState<SessionPreview | null>(null);
|
||
|
|
const [joinName, setJoinName] = useState("");
|
||
|
|
const [takeoverName, setTakeoverName] = useState("");
|
||
|
|
const [takeoverDummyId, setTakeoverDummyId] = useState("");
|
||
|
|
const [showDummyOptions, setShowDummyOptions] = useState(false);
|
||
|
|
const [takeoverToken, setTakeoverToken] = useState<string | null>(null);
|
||
|
|
const [takeoverWaiting, setTakeoverWaiting] = useState(false);
|
||
|
|
|
||
|
|
const dummyOptions = useMemo(
|
||
|
|
() => joinPreview?.players.filter((player) => player.isDummy) ?? [],
|
||
|
|
[joinPreview],
|
||
|
|
);
|
||
|
|
const storedPlayer = joinPreview?.players.find((player) => player.id === manager.playerId);
|
||
|
|
const takeoverDisabled = storedPlayer?.connected === true;
|
||
|
|
|
||
|
|
async function resolvePreview(code: string) {
|
||
|
|
const preview = await manager.fetchSessionPreview(code);
|
||
|
|
if (!preview) {
|
||
|
|
Alert.alert(t("entry.alert.sessionNotFound"));
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
setJoinPreview(preview);
|
||
|
|
setJoinStep("choice");
|
||
|
|
return preview;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function handleJoinPreview() {
|
||
|
|
const normalized = joinCode.trim().toUpperCase();
|
||
|
|
if (!normalized) {
|
||
|
|
Alert.alert(t("entry.alert.enterCode"));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
await resolvePreview(normalized);
|
||
|
|
}
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
const raw = route.params?.gameId;
|
||
|
|
if (typeof raw !== "string") {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const normalized = raw.trim().toUpperCase();
|
||
|
|
if (!normalized || handledLinkRef.current === normalized) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
handledLinkRef.current = normalized;
|
||
|
|
setJoinCode(normalized);
|
||
|
|
setJoinStep("code");
|
||
|
|
setJoinPreview(null);
|
||
|
|
setJoinName("");
|
||
|
|
setTakeoverName("");
|
||
|
|
setTakeoverDummyId("");
|
||
|
|
setTakeoverToken(null);
|
||
|
|
setTakeoverWaiting(false);
|
||
|
|
void resolvePreview(normalized);
|
||
|
|
}, [route.params?.gameId]);
|
||
|
|
|
||
|
|
async function handleJoinNew() {
|
||
|
|
if (!joinPreview) return;
|
||
|
|
const data = await manager.joinSession(joinPreview.code, joinName.trim());
|
||
|
|
if (data) {
|
||
|
|
navigation.replace("Lobby");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function handleTakeover() {
|
||
|
|
if (!joinPreview) return;
|
||
|
|
if (!takeoverDummyId) {
|
||
|
|
Alert.alert(t("entry.alert.selectDummy"));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
setTakeoverWaiting(true);
|
||
|
|
const selectedDummy = joinPreview.players.find((player) => player.id === takeoverDummyId);
|
||
|
|
const fallbackName = takeoverName.trim() || selectedDummy?.name || "";
|
||
|
|
const token = await manager.requestTakeoverToken(
|
||
|
|
joinPreview.code,
|
||
|
|
takeoverDummyId,
|
||
|
|
fallbackName,
|
||
|
|
);
|
||
|
|
if (!token) {
|
||
|
|
setTakeoverWaiting(false);
|
||
|
|
if (manager.error) {
|
||
|
|
Alert.alert(manager.error);
|
||
|
|
}
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
setTakeoverToken(token);
|
||
|
|
}
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (joinStep === "code" || !joinPreview) {
|
||
|
|
setShowDummyOptions(false);
|
||
|
|
setTakeoverToken(null);
|
||
|
|
setTakeoverWaiting(false);
|
||
|
|
}
|
||
|
|
}, [joinStep, joinPreview]);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (!takeoverToken || !joinPreview) return;
|
||
|
|
let cancelled = false;
|
||
|
|
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||
|
|
const poll = async () => {
|
||
|
|
const data = await manager.claimTakeover(joinPreview.code, takeoverToken);
|
||
|
|
if (cancelled) return;
|
||
|
|
if (data) {
|
||
|
|
setTakeoverWaiting(false);
|
||
|
|
setTakeoverToken(null);
|
||
|
|
navigation.replace("Lobby");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
timeout = setTimeout(poll, 2000);
|
||
|
|
};
|
||
|
|
void poll();
|
||
|
|
return () => {
|
||
|
|
cancelled = true;
|
||
|
|
if (timeout) clearTimeout(timeout);
|
||
|
|
};
|
||
|
|
}, [joinPreview, takeoverToken, manager, navigation]);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<ScrollView
|
||
|
|
style={styles.scroll}
|
||
|
|
contentContainerStyle={[
|
||
|
|
styles.container,
|
||
|
|
{
|
||
|
|
paddingTop: insets.top + 16,
|
||
|
|
paddingBottom: insets.bottom + 24,
|
||
|
|
paddingLeft: insets.left + 20,
|
||
|
|
paddingRight: insets.right + 20,
|
||
|
|
},
|
||
|
|
]}
|
||
|
|
>
|
||
|
|
<View style={styles.hero}>
|
||
|
|
<BrandLockup variant="hero" subtitle={t("entry.heroBadge")} onDark />
|
||
|
|
<Text style={styles.heroTitle}>{t("entry.joinStepTitle")}</Text>
|
||
|
|
<Text style={styles.heroBody}>{t("entry.joinStepSubtitle")}</Text>
|
||
|
|
</View>
|
||
|
|
|
||
|
|
<View style={styles.card}>
|
||
|
|
<Text style={styles.cardTitle}>{t("entry.joinTitle")}</Text>
|
||
|
|
<Text style={styles.cardSubtitle}>{t("entry.joinDescription")}</Text>
|
||
|
|
<TextInput
|
||
|
|
style={styles.input}
|
||
|
|
placeholder={t("entry.sessionCode")}
|
||
|
|
placeholderTextColor={placeholderColor}
|
||
|
|
autoCapitalize="characters"
|
||
|
|
value={joinCode}
|
||
|
|
onChangeText={(value) => {
|
||
|
|
setJoinCode(value.toUpperCase());
|
||
|
|
if (joinStep === "choice") {
|
||
|
|
setJoinStep("code");
|
||
|
|
setJoinPreview(null);
|
||
|
|
setJoinName("");
|
||
|
|
setTakeoverName("");
|
||
|
|
setTakeoverDummyId("");
|
||
|
|
setShowDummyOptions(false);
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
<TouchableOpacity style={styles.button} onPress={handleJoinPreview}>
|
||
|
|
<Text style={styles.buttonText}>{t("common.continue")}</Text>
|
||
|
|
</TouchableOpacity>
|
||
|
|
</View>
|
||
|
|
|
||
|
|
{joinStep === "choice" && joinPreview ? (
|
||
|
|
<>
|
||
|
|
<View style={styles.previewCard}>
|
||
|
|
<Text style={styles.previewEyebrow}>{t("entry.previewLabel")}</Text>
|
||
|
|
<Text style={styles.previewTitle}>
|
||
|
|
{t("entry.agencyCodeValue", { code: joinPreview.code })}
|
||
|
|
</Text>
|
||
|
|
<Text style={styles.previewBody}>
|
||
|
|
{t("entry.previewCustomers", { count: joinPreview.players.length })}
|
||
|
|
</Text>
|
||
|
|
</View>
|
||
|
|
|
||
|
|
<View style={styles.choiceCard}>
|
||
|
|
<Text style={styles.choiceTitle}>{t("entry.newPlayer")}</Text>
|
||
|
|
<Text style={styles.choiceBody}>{t("entry.newCustomerDescription")}</Text>
|
||
|
|
<TextInput
|
||
|
|
style={styles.input}
|
||
|
|
placeholder={t("entry.playerName")}
|
||
|
|
placeholderTextColor={placeholderColor}
|
||
|
|
value={joinName}
|
||
|
|
onChangeText={setJoinName}
|
||
|
|
/>
|
||
|
|
<TouchableOpacity style={styles.buttonSecondary} onPress={handleJoinNew}>
|
||
|
|
<Text style={styles.buttonSecondaryText}>{t("entry.joinAsCustomer")}</Text>
|
||
|
|
</TouchableOpacity>
|
||
|
|
</View>
|
||
|
|
|
||
|
|
<View style={styles.choiceCard}>
|
||
|
|
<Text style={styles.choiceTitle}>{t("entry.takeoverTitle")}</Text>
|
||
|
|
<Text style={styles.choiceBody}>{t("entry.recoverCustomerDescription")}</Text>
|
||
|
|
{takeoverDisabled ? (
|
||
|
|
<Text style={styles.helper}>{t("entry.alreadyConnected")}</Text>
|
||
|
|
) : takeoverWaiting ? (
|
||
|
|
<View style={styles.pendingBox}>
|
||
|
|
<Text style={styles.helper}>{t("entry.takeoverPending")}</Text>
|
||
|
|
<TouchableOpacity
|
||
|
|
style={styles.buttonSecondary}
|
||
|
|
onPress={() => {
|
||
|
|
setTakeoverToken(null);
|
||
|
|
setTakeoverWaiting(false);
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<Text style={styles.buttonSecondaryText}>{t("common.cancel")}</Text>
|
||
|
|
</TouchableOpacity>
|
||
|
|
</View>
|
||
|
|
) : (
|
||
|
|
<>
|
||
|
|
<View style={styles.dropdown}>
|
||
|
|
<TouchableOpacity
|
||
|
|
style={styles.dropdownButton}
|
||
|
|
onPress={() => {
|
||
|
|
if (dummyOptions.length === 0) return;
|
||
|
|
setShowDummyOptions((prev) => !prev);
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<Text style={styles.dropdownText}>
|
||
|
|
{dummyOptions.find((player) => player.id === takeoverDummyId)?.name
|
||
|
|
? `${dummyOptions.find((player) => player.id === takeoverDummyId)?.name} · ${takeoverDummyId}`
|
||
|
|
: t("entry.selectDummy")}
|
||
|
|
</Text>
|
||
|
|
</TouchableOpacity>
|
||
|
|
{showDummyOptions && dummyOptions.length > 0 ? (
|
||
|
|
<View style={styles.dropdownList}>
|
||
|
|
{dummyOptions.map((player) => (
|
||
|
|
<TouchableOpacity
|
||
|
|
key={player.id}
|
||
|
|
style={[
|
||
|
|
styles.dropdownItem,
|
||
|
|
player.id === takeoverDummyId ? styles.dropdownItemActive : null,
|
||
|
|
]}
|
||
|
|
onPress={() => {
|
||
|
|
setTakeoverDummyId(player.id);
|
||
|
|
setShowDummyOptions(false);
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<Text style={styles.dropdownItemText}>{player.name}</Text>
|
||
|
|
<Text style={styles.dropdownItemMeta}>{player.id}</Text>
|
||
|
|
</TouchableOpacity>
|
||
|
|
))}
|
||
|
|
</View>
|
||
|
|
) : null}
|
||
|
|
</View>
|
||
|
|
<TextInput
|
||
|
|
style={styles.input}
|
||
|
|
placeholder={t("entry.yourNameOptional")}
|
||
|
|
placeholderTextColor={placeholderColor}
|
||
|
|
value={takeoverName}
|
||
|
|
onChangeText={setTakeoverName}
|
||
|
|
/>
|
||
|
|
<TouchableOpacity style={styles.buttonSecondary} onPress={handleTakeover}>
|
||
|
|
<Text style={styles.buttonSecondaryText}>{t("entry.requestTakeover")}</Text>
|
||
|
|
</TouchableOpacity>
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
{!takeoverDisabled && dummyOptions.length === 0 ? (
|
||
|
|
<Text style={styles.helper}>{t("entry.noDummies")}</Text>
|
||
|
|
) : null}
|
||
|
|
</View>
|
||
|
|
</>
|
||
|
|
) : null}
|
||
|
|
|
||
|
|
<TouchableOpacity
|
||
|
|
style={styles.linkButton}
|
||
|
|
onPress={() => navigation.replace("AgencyCreate")}
|
||
|
|
>
|
||
|
|
<Text style={styles.linkText}>{t("entry.linkOpenNew")}</Text>
|
||
|
|
</TouchableOpacity>
|
||
|
|
</ScrollView>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
const createStyles = (theme: AppTheme) =>
|
||
|
|
StyleSheet.create({
|
||
|
|
scroll: {
|
||
|
|
flex: 1,
|
||
|
|
backgroundColor: theme.colors.background,
|
||
|
|
},
|
||
|
|
container: {
|
||
|
|
gap: 18,
|
||
|
|
backgroundColor: theme.colors.background,
|
||
|
|
},
|
||
|
|
hero: {
|
||
|
|
borderRadius: 28,
|
||
|
|
padding: 22,
|
||
|
|
gap: 12,
|
||
|
|
backgroundColor: theme.colors.brandSurface,
|
||
|
|
borderWidth: 1,
|
||
|
|
borderColor: theme.colors.brandSurfaceAlt,
|
||
|
|
},
|
||
|
|
heroTitle: {
|
||
|
|
color: theme.colors.brandText,
|
||
|
|
fontSize: 28,
|
||
|
|
fontWeight: "800",
|
||
|
|
letterSpacing: -0.8,
|
||
|
|
},
|
||
|
|
heroBody: {
|
||
|
|
color: theme.colors.brandTextMuted,
|
||
|
|
fontSize: 15,
|
||
|
|
lineHeight: 22,
|
||
|
|
},
|
||
|
|
card: {
|
||
|
|
backgroundColor: theme.colors.surface,
|
||
|
|
borderRadius: 24,
|
||
|
|
padding: 20,
|
||
|
|
gap: 12,
|
||
|
|
borderWidth: 1,
|
||
|
|
borderColor: theme.colors.border,
|
||
|
|
},
|
||
|
|
cardTitle: {
|
||
|
|
color: theme.colors.text,
|
||
|
|
fontSize: 22,
|
||
|
|
fontWeight: "700",
|
||
|
|
letterSpacing: -0.4,
|
||
|
|
},
|
||
|
|
cardSubtitle: {
|
||
|
|
color: theme.colors.textMuted,
|
||
|
|
fontSize: 14,
|
||
|
|
lineHeight: 21,
|
||
|
|
},
|
||
|
|
input: {
|
||
|
|
borderWidth: 1,
|
||
|
|
borderColor: theme.colors.border,
|
||
|
|
backgroundColor: theme.colors.inputBackground,
|
||
|
|
color: theme.colors.inputText,
|
||
|
|
borderRadius: 16,
|
||
|
|
paddingHorizontal: 14,
|
||
|
|
paddingVertical: 13,
|
||
|
|
},
|
||
|
|
button: {
|
||
|
|
backgroundColor: theme.colors.primary,
|
||
|
|
paddingVertical: 14,
|
||
|
|
borderRadius: 999,
|
||
|
|
alignItems: "center",
|
||
|
|
},
|
||
|
|
buttonText: {
|
||
|
|
color: theme.colors.primaryText,
|
||
|
|
fontWeight: "700",
|
||
|
|
},
|
||
|
|
previewCard: {
|
||
|
|
borderRadius: 24,
|
||
|
|
padding: 20,
|
||
|
|
gap: 8,
|
||
|
|
backgroundColor: theme.colors.accentSurface,
|
||
|
|
borderWidth: 1,
|
||
|
|
borderColor: theme.colors.borderMuted,
|
||
|
|
},
|
||
|
|
previewEyebrow: {
|
||
|
|
color: theme.colors.accent,
|
||
|
|
fontSize: 12,
|
||
|
|
fontWeight: "700",
|
||
|
|
letterSpacing: 1.1,
|
||
|
|
textTransform: "uppercase",
|
||
|
|
},
|
||
|
|
previewTitle: {
|
||
|
|
color: theme.colors.text,
|
||
|
|
fontSize: 22,
|
||
|
|
fontWeight: "800",
|
||
|
|
letterSpacing: -0.5,
|
||
|
|
},
|
||
|
|
previewBody: {
|
||
|
|
color: theme.colors.textMuted,
|
||
|
|
},
|
||
|
|
choiceCard: {
|
||
|
|
backgroundColor: theme.colors.surface,
|
||
|
|
borderRadius: 24,
|
||
|
|
padding: 20,
|
||
|
|
gap: 12,
|
||
|
|
borderWidth: 1,
|
||
|
|
borderColor: theme.colors.border,
|
||
|
|
},
|
||
|
|
choiceTitle: {
|
||
|
|
fontSize: 18,
|
||
|
|
fontWeight: "700",
|
||
|
|
color: theme.colors.text,
|
||
|
|
},
|
||
|
|
choiceBody: {
|
||
|
|
color: theme.colors.textMuted,
|
||
|
|
lineHeight: 20,
|
||
|
|
},
|
||
|
|
dropdown: {
|
||
|
|
gap: 6,
|
||
|
|
},
|
||
|
|
dropdownButton: {
|
||
|
|
borderWidth: 1,
|
||
|
|
borderColor: theme.colors.border,
|
||
|
|
backgroundColor: theme.colors.inputBackground,
|
||
|
|
borderRadius: 16,
|
||
|
|
paddingHorizontal: 14,
|
||
|
|
paddingVertical: 13,
|
||
|
|
},
|
||
|
|
dropdownText: {
|
||
|
|
color: theme.colors.inputText,
|
||
|
|
fontWeight: "600",
|
||
|
|
},
|
||
|
|
dropdownList: {
|
||
|
|
borderWidth: 1,
|
||
|
|
borderColor: theme.colors.border,
|
||
|
|
borderRadius: 16,
|
||
|
|
backgroundColor: theme.colors.surface,
|
||
|
|
overflow: "hidden",
|
||
|
|
},
|
||
|
|
pendingBox: {
|
||
|
|
gap: 10,
|
||
|
|
backgroundColor: theme.colors.surfaceAlt,
|
||
|
|
borderRadius: 16,
|
||
|
|
padding: 14,
|
||
|
|
borderWidth: 1,
|
||
|
|
borderColor: theme.colors.borderMuted,
|
||
|
|
},
|
||
|
|
dropdownItem: {
|
||
|
|
paddingHorizontal: 14,
|
||
|
|
paddingVertical: 12,
|
||
|
|
borderBottomWidth: 1,
|
||
|
|
borderBottomColor: theme.colors.borderMuted,
|
||
|
|
},
|
||
|
|
dropdownItemActive: {
|
||
|
|
backgroundColor: theme.colors.accentSurface,
|
||
|
|
},
|
||
|
|
dropdownItemText: {
|
||
|
|
fontWeight: "600",
|
||
|
|
color: theme.colors.text,
|
||
|
|
},
|
||
|
|
dropdownItemMeta: {
|
||
|
|
color: theme.colors.textMuted,
|
||
|
|
fontSize: 12,
|
||
|
|
},
|
||
|
|
buttonSecondary: {
|
||
|
|
backgroundColor: theme.colors.secondary,
|
||
|
|
paddingVertical: 14,
|
||
|
|
borderRadius: 999,
|
||
|
|
alignItems: "center",
|
||
|
|
},
|
||
|
|
buttonSecondaryText: {
|
||
|
|
color: theme.colors.secondaryText,
|
||
|
|
fontWeight: "700",
|
||
|
|
},
|
||
|
|
helper: {
|
||
|
|
fontSize: 12,
|
||
|
|
color: theme.colors.textMuted,
|
||
|
|
},
|
||
|
|
linkButton: {
|
||
|
|
paddingVertical: 12,
|
||
|
|
alignItems: "center",
|
||
|
|
},
|
||
|
|
linkText: {
|
||
|
|
color: theme.colors.textMuted,
|
||
|
|
fontWeight: "600",
|
||
|
|
},
|
||
|
|
});
|