mediasfu-reactnative-expo
Version:
mediasfu-reactnative-expo – Expo-managed React Native WebRTC SDK for video conferencing, webinars, live streaming, broadcast, screen sharing, whiteboard, chat, recording, live subtitles, translation, and AI agent rooms on iOS, Android, and web. Prebuilt r
895 lines (891 loc) • 34.2 kB
JavaScript
import React, { useState, useEffect, useRef } from "react";
import { View, Text, TextInput, Pressable, Image, StyleSheet, ScrollView, KeyboardAvoidingView, Platform, } from "react-native";
import Orientation from "react-native-orientation-locker";
import AsyncStorage from '@react-native-async-storage/async-storage';
import RNPickerSelect from "react-native-picker-select";
import { checkLimitsAndMakeRequest } from "../../methods/utils/checkLimitsAndMakeRequest";
import { createRoomOnMediaSFU } from "../../methods/utils/createRoomOnMediaSFU";
import { joinRoomOnMediaSFU } from "../../methods/utils/joinRoomOnMediaSFU";
import { validateAlphanumeric } from "../../methods/utils/validateAlphanumeric";
/**
* PreJoinPage - Room creation/join interface with media preview
*
* PreJoinPage is a React Native component that provides the entry point for creating
* new meeting rooms or joining existing ones. It offers two modes:
* 1. **UI Mode**: Full interface with room configuration options
* 2. **Headless Mode**: Programmatic room creation/join without UI
*
* **Key Features:**
* - Create new rooms with custom settings (duration, capacity, event type)
* - Join existing rooms by event ID
* - Media device preview (camera/microphone)
* - Recording configuration options
* - Waiting room and secure code settings
* - Local (Community Edition) and cloud (MediaSFU) server support
* - Headless/programmatic mode for automated workflows
* - Input validation and error feedback
* - Persistent storage of preferences
*
* **Room Configuration Options:**
* - Event type (chat, broadcast, webinar, conference)
* - Duration (up to 24 hours)
* - Participant capacity
* - Recording parameters (videoParticipants, videoOptions, etc.)
* - Waiting room enable/disable
* - Secure access codes
*
* **UI Customization:**
* The PreJoinPage layout and styling are fixed but can be customized by creating
* a custom pre-join component and using it instead of this default one.
*
* @component
* @param {PreJoinPageOptions} props - Configuration options
*
* @returns {JSX.Element} Rendered pre-join page or null (headless mode)
*
* @example
* ```tsx
* // Basic usage with UI
* import React, { useState } from 'react';
* import { PreJoinPage } from 'mediasfu-reactnative-expo';
* import { connectSocket } from './sockets/SocketManager';
*
* function App() {
* const [socket, setSocket] = useState(null);
* const [validated, setValidated] = useState(false);
*
* const parameters = {
* imgSrc: 'https://example.com/logo.png',
* showAlert: ({ message, type }) => alert(message),
* updateIsLoadingModalVisible: setLoading,
* connectSocket: connectSocket,
* updateSocket: setSocket,
* updateValidated: setValidated,
* updateApiUserName: setApiUserName,
* updateApiToken: setApiToken,
* updateLink: setLink,
* updateRoomName: setRoomName,
* updateMember: setMember,
* };
*
* const credentials = {
* apiUserName: 'your-api-username',
* apiKey: 'your-api-key',
* };
*
* if (validated) {
* return <MeetingRoom socket={socket} />;
* }
*
* return (
* <PreJoinPage
* parameters={parameters}
* credentials={credentials}
* connectMediaSFU={true}
* />
* );
* }
* ```
*
* @example
* ```tsx
* // Headless mode - programmatic room creation
* const headlessOptions = {
* action: 'create',
* capacity: 50,
* duration: 60, // minutes
* eventType: 'webinar',
* userName: 'Host Name',
* recordingParams: {
* recordingVideoParticipantsFullRoomSupport: true,
* recordingAllParticipantsSupport: false,
* },
* };
*
* return (
* <PreJoinPage
* parameters={parameters}
* credentials={credentials}
* returnUI={false}
* noUIPreJoinOptions={headlessOptions}
* />
* );
* ```
*
* @example
* ```tsx
* // Community Edition (local server)
* return (
* <PreJoinPage
* parameters={parameters}
* localLink="http://localhost:3000"
* connectMediaSFU={false}
* />
* );
*
*
* export default App;
* ```
*/
const PreJoinPage = ({ localLink = "", connectMediaSFU = true, parameters, credentials, returnUI = false, noUIPreJoinOptions, autoProceedPreJoin, createMediaSFURoom = createRoomOnMediaSFU, joinMediaSFURoom = joinRoomOnMediaSFU, }) => {
// State variables
const [isCreateMode, setIsCreateMode] = useState(false);
const [name, setName] = useState("");
const [duration, setDuration] = useState("");
const [eventType, setEventType] = useState("");
const [capacity, setCapacity] = useState("");
const [eventID, setEventID] = useState("");
const [error, setError] = useState("");
const pending = useRef(false);
const lastAutoProceedKey = useRef(null);
const localConnected = useRef(false);
const localData = useRef(undefined);
const initSocket = useRef(undefined);
const shouldAutoProceed = autoProceedPreJoin ?? !returnUI;
const autoProceedKey = !shouldAutoProceed || !noUIPreJoinOptions
? null
: noUIPreJoinOptions.action === "create"
? `create:${noUIPreJoinOptions.userName}:${noUIPreJoinOptions.eventType}:${noUIPreJoinOptions.duration}:${noUIPreJoinOptions.capacity}`
: `join:${noUIPreJoinOptions.userName}:${noUIPreJoinOptions.meetingID}`;
const { showAlert, updateIsLoadingModalVisible, connectLocalSocket, updateSocket, updateValidated, updateApiUserName, updateApiToken, updateLink, updateRoomName, updateMember, } = parameters;
const validateDisplayName = async (displayName) => {
const isValidDisplayName = displayName.length >= 2 &&
displayName.length <= 10 &&
(await validateAlphanumeric({ str: displayName }));
if (isValidDisplayName) {
return true;
}
const message = "Display Name must be alphanumeric and between 2 and 10 characters.";
pending.current = false;
if (returnUI) {
setError(message);
return false;
}
throw new Error(message);
};
const handleCreateRoom = async (providedPayload) => {
if (pending.current) {
return;
}
pending.current = true;
setError("");
let payload = {};
if (providedPayload) {
payload = providedPayload;
}
else if (returnUI) {
if (!name || !duration || !eventType || !capacity) {
setError("Please fill all the fields.");
pending.current = false;
return;
}
payload = {
action: "create",
duration: parseInt(duration, 10),
capacity: parseInt(capacity, 10),
eventType: eventType,
userName: name,
recordOnly: false,
};
}
else {
if (noUIPreJoinOptions &&
"action" in noUIPreJoinOptions &&
noUIPreJoinOptions.action === "create") {
payload = noUIPreJoinOptions;
}
else {
pending.current = false;
throw new Error("Invalid options provided for creating a room without UI.");
}
}
if (!(await validateDisplayName(payload.userName))) {
return;
}
updateIsLoadingModalVisible(true);
if (localLink.length > 0) {
const secureCode = Math.random().toString(30).substring(2, 14) +
Math.random().toString(30).substring(2, 14);
let eventID = new Date().getTime().toString(30) +
new Date().getUTCMilliseconds() +
Math.floor(10 + Math.random() * 99).toString();
eventID = "m" + eventID;
const eventRoomParams = localData.current?.meetingRoomParams_;
eventRoomParams.type = eventType;
const createData = {
eventID: eventID,
duration: payload.duration,
capacity: payload.capacity,
userName: payload.userName,
scheduledDate: new Date(),
secureCode: secureCode,
waitRoom: false,
recordingParams: localData.current?.recordingParams_,
eventRoomParams: eventRoomParams,
videoPreference: null,
audioPreference: null,
audioOutputPreference: null,
mediasfuURL: "",
};
// socket in main window is required and for no local room, no use of initSocket
// for local room, initSocket becomes the local socket, and localSocket is the connection to MediaSFU (if connectMediaSFU is true)
// else localSocket is the same as initSocket
if (connectMediaSFU &&
initSocket.current &&
localData.current &&
localData.current.apiUserName &&
localData.current.apiKey) {
// Store references to prevent race conditions
const apiUserName = localData.current.apiUserName;
const apiKey = localData.current.apiKey;
// Build a unique identifier for this create request
const roomIdentifier = `local_create_${payload.userName}_${payload.duration}_${payload.capacity}`;
const pendingKey = `prejoin_pending_${roomIdentifier}`;
const PENDING_TIMEOUT = 30 * 1000; // 30 seconds
// Check pending status to prevent duplicate requests
try {
const pendingRequest = await AsyncStorage.getItem(pendingKey);
if (pendingRequest) {
const pendingData = JSON.parse(pendingRequest);
const timeSincePending = Date.now() - (pendingData?.timestamp ?? 0);
if (timeSincePending < PENDING_TIMEOUT) {
pending.current = false;
updateIsLoadingModalVisible(false);
setError('Room creation already in progress');
return;
}
else {
// Stale lock, clear it
await AsyncStorage.removeItem(pendingKey).catch(() => { });
}
}
}
catch {
// Ignore AsyncStorage read/JSON errors
}
// Mark request as pending
try {
await AsyncStorage.setItem(pendingKey, JSON.stringify({
timestamp: Date.now(),
payload: {
action: 'create',
userName: payload.userName,
duration: payload.duration,
capacity: payload.capacity,
},
}));
// Auto-clear the pending flag after timeout to avoid stale locks
setTimeout(() => {
AsyncStorage.removeItem(pendingKey).catch(() => { });
}, PENDING_TIMEOUT);
}
catch {
// Ignore AsyncStorage write errors
}
payload.recordOnly = true; // allow production to mediasfu only; no consumption
try {
const response = await roomCreator({
payload,
apiUserName: apiUserName,
apiKey: apiKey,
validate: false,
});
// Clear pending status on completion
try {
await AsyncStorage.removeItem(pendingKey);
}
catch {
/* ignore */
}
if (response &&
response.success &&
response.data &&
"roomName" in response.data) {
createData.eventID = response.data.roomName;
createData.secureCode = response.data.secureCode || "";
createData.mediasfuURL = response.data.publicURL;
await createLocalRoom({
createData: createData,
link: response.data.link,
});
}
else {
pending.current = false;
updateIsLoadingModalVisible(false);
setError(`Unable to create room on MediaSFU.`);
}
}
catch (error) {
// Clear pending status on error
try {
await AsyncStorage.removeItem(pendingKey);
}
catch {
/* ignore */
}
pending.current = false;
updateIsLoadingModalVisible(false);
setError(`Unable to create room on MediaSFU. ${error}`);
}
}
else {
try {
updateSocket(initSocket.current);
await createLocalRoom({ createData: createData });
pending.current = false;
}
catch (error) {
pending.current = false;
updateIsLoadingModalVisible(false);
setError(`Unable to create room. ${error}`);
}
}
}
else {
// Build a unique identifier for this create request (non-local)
const roomIdentifier = `mediasfu_create_${payload.userName}_${payload.duration}_${payload.capacity}`;
const pendingKey = `prejoin_pending_${roomIdentifier}`;
const PENDING_TIMEOUT = 30 * 1000; // 30 seconds
// Check pending status to prevent duplicate requests
try {
const pendingRequest = await AsyncStorage.getItem(pendingKey);
if (pendingRequest) {
const pendingData = JSON.parse(pendingRequest);
const timeSincePending = Date.now() - (pendingData?.timestamp ?? 0);
if (timeSincePending < PENDING_TIMEOUT) {
pending.current = false;
updateIsLoadingModalVisible(false);
setError('Room creation already in progress');
return;
}
else {
// Stale lock, clear it
await AsyncStorage.removeItem(pendingKey).catch(() => { });
}
}
}
catch {
// Ignore AsyncStorage read/JSON errors
}
// Mark request as pending
try {
await AsyncStorage.setItem(pendingKey, JSON.stringify({
timestamp: Date.now(),
payload: {
action: 'create',
userName: payload.userName,
duration: payload.duration,
capacity: payload.capacity,
},
}));
// Auto-clear the pending flag after timeout to avoid stale locks
setTimeout(() => {
AsyncStorage.removeItem(pendingKey).catch(() => { });
}, PENDING_TIMEOUT);
}
catch {
// Ignore AsyncStorage write errors
}
try {
await roomCreator({
payload,
apiUserName: credentials.apiUserName,
apiKey: credentials.apiKey,
validate: true,
});
// Clear pending status on completion
try {
await AsyncStorage.removeItem(pendingKey);
}
catch {
/* ignore */
}
pending.current = false;
}
catch (error) {
// Clear pending status on error
try {
await AsyncStorage.removeItem(pendingKey);
}
catch {
/* ignore */
}
pending.current = false;
updateIsLoadingModalVisible(false);
setError(`Unable to create room. ${error}`);
}
}
};
const handleJoinRoom = async (providedPayload) => {
if (pending.current) {
return;
}
pending.current = true;
setError("");
let payload = {};
if (providedPayload) {
payload = providedPayload;
}
else if (returnUI) {
if (!name || !eventID) {
setError("Please fill all the fields.");
pending.current = false;
return;
}
payload = {
action: "join",
meetingID: eventID,
userName: name,
};
}
else {
if (noUIPreJoinOptions &&
"action" in noUIPreJoinOptions &&
noUIPreJoinOptions.action === "join") {
payload = noUIPreJoinOptions;
}
else {
pending.current = false;
throw new Error("Invalid options provided for joining a room without UI.");
}
}
if (!(await validateDisplayName(payload.userName))) {
return;
}
if (localLink.length > 0 && !localLink.includes("mediasfu.com")) {
const joinData = {
eventID: payload.meetingID,
userName: payload.userName,
secureCode: "",
videoPreference: null,
audioPreference: null,
audioOutputPreference: null,
};
await joinLocalRoom({ joinData: joinData });
pending.current = false;
return;
}
updateIsLoadingModalVisible(true);
const response = await joinMediaSFURoom({
payload,
apiUserName: credentials.apiUserName,
apiKey: credentials.apiKey,
localLink: localLink,
});
if (response.success && response.data && "roomName" in response.data) {
await checkLimitsAndMakeRequest({
apiUserName: response.data.roomName,
apiToken: response.data.secret,
link: response.data.link,
userName: payload.userName,
parameters: parameters,
});
setError("");
pending.current = false;
}
else {
pending.current = false;
updateIsLoadingModalVisible(false);
const joinErrorMessage = response.data && "error" in response.data ? response.data.error : "";
setError(formatRequestError("Unable to join room", joinErrorMessage));
}
};
const joinLocalRoom = async ({ joinData, link = localLink, }) => {
initSocket.current?.emit("joinEventRoom", joinData, (response) => {
if (response.success) {
updateSocket(initSocket.current);
updateApiUserName(localData.current?.apiUserName || "");
updateApiToken(response.secret);
updateLink(link);
updateRoomName(joinData.eventID);
updateMember(joinData.userName);
updateIsLoadingModalVisible(false);
updateValidated(true);
}
else {
updateIsLoadingModalVisible(false);
setError(`Unable to join room. ${response.reason}`);
}
});
};
const createLocalRoom = async ({ createData, link = localLink, }) => {
initSocket.current?.emit("createRoom", createData, (response) => {
if (response.success) {
updateSocket(initSocket.current);
updateApiUserName(localData.current?.apiUserName || "");
updateApiToken(response.secret);
updateLink(link);
updateRoomName(createData.eventID);
// local needs islevel updated from here
// we update member as `userName` + "_2" and split it in the room
updateMember(createData.userName + "_2");
updateIsLoadingModalVisible(false);
updateValidated(true);
}
else {
updateIsLoadingModalVisible(false);
setError(`Unable to create room. ${response.reason}`);
}
});
};
const roomCreator = async ({ payload, apiUserName, apiKey, validate = true, }) => {
const response = await createMediaSFURoom({
payload,
apiUserName: apiUserName,
apiKey: apiKey,
localLink: localLink,
});
if (response.success && response.data && "roomName" in response.data) {
await checkLimitsAndMakeRequest({
apiUserName: response.data.roomName,
apiToken: response.data.secret,
link: response.data.link,
userName: payload.userName,
parameters: parameters,
validate: validate,
});
return response;
}
else {
updateIsLoadingModalVisible(false);
const createErrorMessage = response.data && "error" in response.data ? response.data.error : "";
setError(formatRequestError("Unable to create room", createErrorMessage));
}
};
const checkProceed = async ({ noUIPreJoinOptions, }) => {
if (shouldAutoProceed && noUIPreJoinOptions) {
if ("action" in noUIPreJoinOptions &&
noUIPreJoinOptions.action === "create") {
// update all the required parameters and call
const createOptions = noUIPreJoinOptions;
if (!createOptions.userName ||
!createOptions.duration ||
!createOptions.eventType ||
!createOptions.capacity) {
throw new Error("Please provide all the required parameters: userName, duration, eventType, capacity");
}
await handleCreateRoom(createOptions);
}
else if ("action" in noUIPreJoinOptions &&
noUIPreJoinOptions.action === "join") {
// update all the required parameters and call
const joinOptions = noUIPreJoinOptions;
if (!joinOptions.userName || !joinOptions.meetingID) {
throw new Error("Please provide all the required parameters: userName, meetingID");
}
await handleJoinRoom(joinOptions);
}
else {
throw new Error("Invalid options provided for creating/joining a room without UI.");
}
}
};
useEffect(() => {
if (localLink.length > 0 &&
!localConnected.current &&
!initSocket.current) {
try {
connectLocalSocket?.({ link: localLink })
.then((response) => {
localData.current = response.data;
initSocket.current = response.socket;
localConnected.current = true;
})
.catch((error) => {
showAlert?.({
message: `Unable to connect to ${localLink}. ${error}`,
type: "danger",
duration: 3000,
});
});
}
catch {
showAlert?.({
message: `Unable to connect to ${localLink}. Something went wrong.`,
type: "danger",
duration: 3000,
});
}
}
}, []);
useEffect(() => {
if (!autoProceedKey || !noUIPreJoinOptions) {
return;
}
if (lastAutoProceedKey.current === autoProceedKey) {
return;
}
if (localLink.length > 0 && !localConnected.current) {
return;
}
lastAutoProceedKey.current = autoProceedKey;
void checkProceed({ noUIPreJoinOptions }).catch((caughtError) => {
pending.current = false;
lastAutoProceedKey.current = null;
const message = caughtError instanceof Error
? caughtError.message
: "Unable to proceed with the room request.";
if (returnUI) {
setError(message);
}
else {
showAlert?.({
message,
type: "danger",
duration: 3000,
});
}
});
}, [autoProceedKey, localLink, noUIPreJoinOptions, returnUI, showAlert]);
const handleToggleMode = () => {
setIsCreateMode(!isCreateMode);
setError("");
};
const formatRequestError = (prefix, errorMessage) => {
if (!errorMessage) {
return `${prefix}.`;
}
return errorMessage.startsWith(prefix)
? errorMessage
: `${prefix}. ${errorMessage}`;
};
/**
* Locks the orientation to portrait mode when the component mounts and unlocks on unmount.
*/
useEffect(() => {
Orientation.lockToPortrait();
return () => {
Orientation.unlockAllOrientations();
};
}, []);
if (!returnUI) {
return <></>;
}
const content = (<ScrollView contentContainerStyle={styles.scrollContainer} keyboardShouldPersistTaps="handled">
<View style={[
styles.container,
Platform.OS === "web" && {
width: "100%",
maxWidth: isCreateMode ? 500 : 440,
alignSelf: "center",
},
]}>
{/* Logo */}
<View style={styles.logoContainer}>
<Image source={{
uri: parameters.imgSrc ||
"https://mediasfu.com/images/logo192.png",
}} style={styles.logoImage}/>
</View>
{/* Input Fields */}
<View style={styles.inputContainer}>
{isCreateMode ? (<>
<TextInput style={styles.inputField} placeholder="Display Name" value={name} onChangeText={setName} autoCapitalize="none" autoCorrect={false} accessibilityLabel="Display Name" placeholderTextColor="gray"/>
<TextInput style={styles.inputField} placeholder="Duration (minutes)" value={duration} onChangeText={setDuration} keyboardType="numeric" autoCapitalize="none" autoCorrect={false} accessibilityLabel="Duration (minutes)" placeholderTextColor="gray"/>
<RNPickerSelect onValueChange={(value) => {
setEventType(value);
}} items={[
{ label: "Chat", value: "chat" },
{ label: "Broadcast", value: "broadcast" },
{ label: "Webinar", value: "webinar" },
{ label: "Conference", value: "conference" },
]} value={eventType} style={pickerSelectStyles} placeholder={{
label: "Select Event Type",
value: "",
color: "gray",
}} useNativeAndroidPickerStyle={false}/>
<View style={styles.gap}/>
<TextInput style={styles.inputField} placeholder="Room Capacity" value={capacity} onChangeText={setCapacity} keyboardType="numeric" autoCapitalize="none" autoCorrect={false} accessibilityLabel="Room Capacity" placeholderTextColor="gray"/>
<Pressable style={styles.actionButton} onPress={() => {
void handleCreateRoom();
}} accessibilityRole="button" accessibilityLabel="Create Room">
<Text style={styles.actionButtonText}>Create Room</Text>
</Pressable>
</>) : (<>
<TextInput style={styles.inputField} placeholder="Display Name" value={name} onChangeText={setName} autoCapitalize="none" autoCorrect={false} accessibilityLabel="Display Name" placeholderTextColor="gray"/>
<TextInput style={styles.inputField} placeholder="Event ID" value={eventID} onChangeText={setEventID} autoCapitalize="none" autoCorrect={false} accessibilityLabel="Event ID" placeholderTextColor="gray"/>
<Pressable style={styles.actionButton} onPress={() => {
void handleJoinRoom();
}} accessibilityRole="button" accessibilityLabel="Join Room">
<Text style={styles.actionButtonText}>Join Room</Text>
</Pressable>
</>)}
{error !== "" && <Text style={styles.errorText}>{error}</Text>}
</View>
{/* OR Separator */}
<View style={styles.orContainer}>
<Text style={styles.orText}>OR</Text>
</View>
{/* Toggle Mode Button */}
<View style={styles.toggleContainer}>
<Pressable style={styles.toggleButton} onPress={handleToggleMode} accessibilityRole="button" accessibilityLabel={isCreateMode ? "Switch to Join Mode" : "Switch to Create Mode"}>
<Text style={styles.toggleButtonText}>
{isCreateMode ? "Switch to Join Mode" : "Switch to Create Mode"}
</Text>
</Pressable>
</View>
</View>
</ScrollView>);
return Platform.OS === "ios" ? (<KeyboardAvoidingView behavior="padding" style={styles.keyboardAvoidingContainer}>
{content}
</KeyboardAvoidingView>) : (<View style={styles.keyboardAvoidingContainer}>{content}</View>);
};
export default PreJoinPage;
/**
* Stylesheet for the PreJoinPage component.
*/
const styles = StyleSheet.create({
keyboardAvoidingContainer: {
flex: 1,
},
scrollContainer: {
flexGrow: 1,
justifyContent: "center",
backgroundColor: "#eef5ff",
paddingVertical: 24,
paddingHorizontal: 16,
maxHeight: "100%",
},
container: {
width: "100%",
paddingHorizontal: 20,
paddingVertical: 24,
justifyContent: "center",
alignItems: "center",
backgroundColor: "rgba(248, 251, 255, 0.96)",
borderRadius: 22,
borderWidth: 1,
borderColor: "rgba(71, 85, 105, 0.22)",
shadowColor: "#0f172a",
shadowOpacity: 0.16,
shadowOffset: { width: 0, height: 16 },
shadowRadius: 24,
elevation: 10,
},
logoContainer: {
marginBottom: 24,
alignItems: "center",
justifyContent: "center",
width: 116,
height: 116,
borderRadius: 58,
backgroundColor: "#ffffff",
borderWidth: 1,
borderColor: "rgba(37, 99, 235, 0.18)",
},
logoImage: {
width: 84,
height: 84,
borderRadius: 42,
},
inputContainer: {
width: "100%",
marginBottom: 4,
gap: 2,
},
inputField: {
height: 46,
width: "100%",
borderColor: "rgba(71, 85, 105, 0.24)",
borderWidth: 1,
marginBottom: 12,
paddingHorizontal: 15,
borderRadius: 12,
backgroundColor: "#ffffff",
fontSize: 16,
color: "#0f172a",
},
actionButton: {
backgroundColor: "#2563eb",
paddingVertical: 12,
borderRadius: 12,
alignItems: "center",
marginBottom: 8,
},
actionButtonText: {
color: "white",
fontWeight: "bold",
fontSize: 14,
},
toggleContainer: {
alignItems: "center",
justifyContent: "center",
},
toggleButton: {
backgroundColor: "#ffffff",
paddingVertical: 10,
paddingHorizontal: 25,
borderRadius: 999,
alignItems: "center",
justifyContent: "center",
borderWidth: 1,
borderColor: "rgba(37, 99, 235, 0.28)",
},
toggleButtonText: {
color: "#1d4ed8",
fontWeight: "bold",
fontSize: 15,
},
errorText: {
color: "#dc2626",
textAlign: "center",
marginTop: 10,
fontSize: 14,
},
orContainer: {
flexDirection: "row",
alignItems: "center",
marginVertical: 20,
},
orText: {
color: "#475569",
fontSize: 16,
fontWeight: "bold",
marginHorizontal: 10,
},
gap: {
marginBottom: 10,
},
});
const pickerSelectStyles = StyleSheet.create({
inputIOS: {
height: 46,
borderColor: "rgba(71, 85, 105, 0.24)",
borderWidth: 1,
marginBottom: 12,
paddingHorizontal: 12,
borderRadius: 12,
backgroundColor: "#ffffff",
fontSize: 16,
color: "#0f172a",
paddingRight: 20,
},
inputAndroid: {
height: 46,
borderColor: "rgba(71, 85, 105, 0.24)",
borderWidth: 1,
marginBottom: 12,
paddingHorizontal: 12,
borderRadius: 12,
backgroundColor: "#ffffff",
fontSize: 16,
color: "#0f172a",
paddingRight: 20,
},
inputWeb: {
height: 46,
borderColor: "rgba(71, 85, 105, 0.24)",
borderWidth: 1,
marginBottom: 12,
paddingHorizontal: 12,
borderRadius: 12,
backgroundColor: "#ffffff",
fontSize: 16,
color: "#0f172a",
paddingRight: 20,
},
placeholder: {
color: "gray",
},
});
//# sourceMappingURL=PreJoinPage.js.map