bw-expo-app/components/auth/login-modal.tsx

413 lines
11 KiB
TypeScript

import { Feather, Ionicons } from "@expo/vector-icons";
import { LinearGradient } from "expo-linear-gradient";
import React, { useEffect, useRef, useState } from "react";
import {
ActivityIndicator,
Alert,
Animated,
Dimensions,
KeyboardAvoidingView,
Modal,
Platform,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useAuth } from "@/hooks/use-auth";
import { authClient } from "@/lib/auth/client";
import { storage } from "@/lib/storage";
interface LoginModalProps {
visible: boolean;
onClose: () => void;
}
export function LoginModal({ visible, onClose }: LoginModalProps) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [isPasswordVisible, setPasswordVisible] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const { isAuthenticated } = useAuth();
const slideAnim = useRef(new Animated.Value(Dimensions.get("window").height)).current;
const fadeAnim = useRef(new Animated.Value(0)).current;
useEffect(() => {
if (visible) {
Animated.parallel([
Animated.timing(slideAnim, {
toValue: 0,
duration: 300,
useNativeDriver: true,
}),
Animated.timing(fadeAnim, {
toValue: 0.5,
duration: 300,
useNativeDriver: true,
}),
]).start();
} else {
Animated.parallel([
Animated.timing(slideAnim, {
toValue: Dimensions.get("window").height,
duration: 300,
useNativeDriver: true,
}),
Animated.timing(fadeAnim, {
toValue: 0,
duration: 300,
useNativeDriver: true,
}),
]).start();
}
}, [visible]);
useEffect(() => {
if (isAuthenticated && visible) {
onClose();
setEmail("");
setPassword("");
}
}, [isAuthenticated, visible, onClose]);
const handleClose = () => {
Animated.parallel([
Animated.timing(slideAnim, {
toValue: Dimensions.get("window").height,
duration: 300,
useNativeDriver: true,
}),
Animated.timing(fadeAnim, {
toValue: 0,
duration: 300,
useNativeDriver: true,
}),
]).start(() => onClose());
};
const handleLogin = async () => {
if (!email.trim() || !password.trim()) {
Alert.alert("错误", "请输入邮箱和密码");
return;
}
setIsLoading(true);
try {
await new Promise<void>(async (resolve, reject) => {
await authClient.signIn.username(
{
username: email.trim(),
password,
},
{
onSuccess: async (ctx) => {
const authToken = ctx.response.headers.get("set-auth-token");
if (authToken) {
await storage.setItem(
`bestaibest.better-auth.session_token`,
authToken
);
resolve();
} else {
console.error("Bearer token not set");
reject();
}
},
}
);
});
Alert.alert("成功", "登录成功!");
} catch (error: any) {
Alert.alert("登录失败", error?.message || "邮箱或密码错误");
} finally {
setIsLoading(false);
}
};
const handleBackdropPress = () => {
handleClose();
};
return (
<Modal visible={visible} transparent animationType="none">
<Animated.View
style={[
styles.overlay,
{
opacity: fadeAnim,
},
]}
>
<TouchableOpacity
activeOpacity={1}
style={styles.backdrop}
onPress={handleBackdropPress}
>
<LinearGradient
colors={["rgba(0, 0, 0, 0)", "rgba(0, 0, 0, 0.5)"]}
style={StyleSheet.absoluteFillObject}
/>
</TouchableOpacity>
</Animated.View>
<Animated.View
style={[
styles.modalContainer,
{
transform: [{ translateY: slideAnim }],
},
]}
>
<TouchableOpacity activeOpacity={1} style={styles.modalContent}>
<View style={styles.sheetIndicator} />
<SafeAreaView style={styles.safeArea}>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
style={styles.flex}
>
<View style={styles.content}>
<View style={styles.header}>
<View style={styles.titleGroup}>
<View style={styles.iconBadge}>
<Ionicons name="mail-open" size={16} color="#ffffff" />
</View>
<Text style={styles.title}>Email Login</Text>
</View>
<TouchableOpacity
activeOpacity={0.7}
onPress={handleClose}
style={styles.closeButton}
>
<Feather name="x" size={20} color="#f2f4f8" />
</TouchableOpacity>
</View>
<View style={styles.form}>
<View style={styles.inputWrapper}>
<TextInput
style={styles.input}
placeholder="yours@example.com"
placeholderTextColor="#70737c"
value={email}
onChangeText={setEmail}
autoCapitalize="none"
autoCorrect={false}
keyboardType="email-address"
editable={!isLoading}
keyboardAppearance="dark"
/>
</View>
<View style={styles.inputWrapper}>
<TextInput
style={styles.input}
placeholder="email password"
placeholderTextColor="#70737c"
value={password}
onChangeText={setPassword}
secureTextEntry={!isPasswordVisible}
autoCorrect={false}
editable={!isLoading}
keyboardAppearance="dark"
/>
<TouchableOpacity
activeOpacity={0.7}
onPress={() => setPasswordVisible((visible) => !visible)}
style={styles.visibilityToggle}
>
<Feather
name={isPasswordVisible ? "eye-off" : "eye"}
size={18}
color="#8a8e97"
/>
</TouchableOpacity>
</View>
<TouchableOpacity
style={[styles.loginButton, isLoading && styles.loginButtonDisabled]}
onPress={handleLogin}
disabled={isLoading}
activeOpacity={0.85}
>
{isLoading ? (
<ActivityIndicator color="#0f100f" />
) : (
<Text style={styles.loginButtonText}>Log in</Text>
)}
</TouchableOpacity>
<View style={styles.termsRow}>
<Ionicons name="checkmark-circle" size={16} color="#d7ff1f" />
<Text style={styles.termsText}>
<Text style={styles.linkText}> </Text>
<Text style={styles.linkText}> </Text>
</Text>
</View>
</View>
</View>
</KeyboardAvoidingView>
</SafeAreaView>
</TouchableOpacity>
</Animated.View>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
},
backdrop: {
flex: 1,
},
modalContainer: {
position: "absolute",
left: 0,
right: 0,
bottom: 0,
},
modalContent: {
backgroundColor: "#14161c",
borderTopLeftRadius: 28,
borderTopRightRadius: 28,
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.08)",
shadowColor: "#000",
shadowOffset: { width: 0, height: -8 },
shadowRadius: 18,
shadowOpacity: 0.25,
elevation: 24,
maxHeight: Dimensions.get("window").height * 0.75,
},
sheetIndicator: {
width: 40,
height: 4,
backgroundColor: "rgba(255, 255, 255, 0.3)",
borderRadius: 2,
alignSelf: "center",
marginTop: 12,
marginBottom: 8,
},
safeArea: {
flex: 1,
},
flex: {
flex: 1,
},
content: {
flex: 1,
paddingHorizontal: 24,
paddingBottom: 34,
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
marginTop: 16,
marginBottom: 28,
},
titleGroup: {
flexDirection: "row",
alignItems: "center",
},
iconBadge: {
width: 40,
height: 40,
borderRadius: 16,
alignItems: "center",
justifyContent: "center",
backgroundColor: "#f6474d",
marginRight: 12,
},
title: {
color: "#f5f6f8",
fontSize: 22,
fontWeight: "700",
letterSpacing: 0.2,
},
closeButton: {
width: 40,
height: 40,
borderRadius: 20,
alignItems: "center",
justifyContent: "center",
backgroundColor: "rgba(255, 255, 255, 0.05)",
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.06)",
},
form: {
marginTop: 4,
},
inputWrapper: {
position: "relative",
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 18,
backgroundColor: "#1a1d23",
borderRadius: 18,
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.05)",
marginBottom: 18,
},
input: {
flex: 1,
paddingVertical: 16,
fontSize: 16,
color: "#f5f6f8",
letterSpacing: 0.3,
},
visibilityToggle: {
marginLeft: 12,
},
loginButton: {
height: 56,
borderRadius: 18,
alignItems: "center",
justifyContent: "center",
backgroundColor: "#d7ff1f",
shadowColor: "#d7ff1f",
shadowOpacity: 0.35,
shadowRadius: 16,
shadowOffset: { width: 0, height: 10 },
elevation: 10,
marginTop: 4,
},
loginButtonDisabled: {
opacity: 0.7,
},
loginButtonText: {
color: "#10120d",
fontSize: 17,
fontWeight: "700",
letterSpacing: 0.5,
},
termsRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
marginTop: 18,
},
termsText: {
color: "#a7abb5",
fontSize: 13,
lineHeight: 18,
marginLeft: 8,
},
linkText: {
color: "#f5f6f8",
fontWeight: "600",
},
});