CoompanionApp/mobile/src/screens/AgencyJoinScreen.tsx

485 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",
},
});