diff --git a/shatter-mobile/app/(tabs)/EventsPage.tsx b/shatter-mobile/app/(tabs)/EventsPage.tsx index 9ee1a68..8634098 100644 --- a/shatter-mobile/app/(tabs)/EventsPage.tsx +++ b/shatter-mobile/app/(tabs)/EventsPage.tsx @@ -11,8 +11,8 @@ import { View, } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; -import AnimatedTab from "../../src/components/AnimatedTab"; import EventCard from "../../src/components/events/EventCard"; +import AnimatedTab from "../../src/components/general/AnimatedTab"; import EventIB from "../../src/interfaces/Event"; import { getUserEvents } from "../../src/services/event.service"; import { EventPageStyling as styles } from "../../src/styling/EventPage.styles"; diff --git a/shatter-mobile/app/(tabs)/JoinEventPage.tsx b/shatter-mobile/app/(tabs)/JoinEventPage.tsx index edb1382..11eb4e4 100644 --- a/shatter-mobile/app/(tabs)/JoinEventPage.tsx +++ b/shatter-mobile/app/(tabs)/JoinEventPage.tsx @@ -4,16 +4,16 @@ import { Ionicons } from "@expo/vector-icons"; import { router } from "expo-router"; import { useState } from "react"; import { - ActivityIndicator, - ImageBackground, - Text, - TextInput, - TouchableOpacity, - View, + ActivityIndicator, + ImageBackground, + Text, + TextInput, + TouchableOpacity, + View, } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; -import AnimatedTab from "../../src/components/AnimatedTab"; import { useAuth } from "../../src/components/context/AuthContext"; +import AnimatedTab from "../../src/components/general/AnimatedTab"; import QRScannerBox from "../../src/components/new-events/QRScannerBox"; import { JoinEventStyling as styles } from "../../src/styling/JoinEventPage.styles"; diff --git a/shatter-mobile/app/(tabs)/ProfilePage.tsx b/shatter-mobile/app/(tabs)/ProfilePage.tsx index cdb1ae4..3397963 100644 --- a/shatter-mobile/app/(tabs)/ProfilePage.tsx +++ b/shatter-mobile/app/(tabs)/ProfilePage.tsx @@ -1,5 +1,5 @@ -import { useFocusEffect, useRouter } from "expo-router"; -import { useCallback, useEffect, useState } from "react"; +import { useRouter } from "expo-router"; +import { useEffect } from "react"; import { Image, ImageBackground, @@ -10,23 +10,15 @@ import { } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { SvgUri } from "react-native-svg"; -import AnimatedTab from "../../src/components/AnimatedTab"; import { useAuth } from "../../src/components/context/AuthContext"; +import AnimatedTab from "../../src/components/general/AnimatedTab"; import { ProfilePageStyling as styles } from "../../src/styling/ProfilePage.styles"; export default function Profile() { const { user, logout } = useAuth(); const router = useRouter(); - const [socialLinks, setSocialLinks] = useState(user?.socialLinks || []); - //update local form - useFocusEffect( - useCallback(() => { - setSocialLinks(user?.socialLinks || []); - }, [user]), - ); - - //not logged in + // not logged in useEffect(() => { if (!user) { router.replace("/UserPages/Login"); @@ -34,9 +26,14 @@ export default function Profile() { }, [user]); if (!user) { - return null; //don't render profile content while redirecting + return null; } + const social = user.socialLinks; + + const hasSocialLinks = + !!social?.linkedin || !!social?.github || (social?.other?.length ?? 0) > 0; + //logged in if (user && !user.isGuest) { return ( @@ -53,6 +50,7 @@ export default function Profile() { Welcome back, {user.name || "Networker"}! + + {user.email} - {socialLinks.length === 0 && ( + {/* Empty */} + {!hasSocialLinks && ( No social links added yet. )} - {socialLinks.map((link, index) => ( + {/* LinkedIn */} + {social?.linkedin && ( + + LinkedIn + {social.linkedin} + + )} + + {/* GitHub */} + {social?.github && ( + + GitHub + {social.github} + + )} + + {/* Other Links */} + {social?.other?.map((link, index) => ( {link.label} {link.url} @@ -111,6 +128,7 @@ export default function Profile() { Welcome, {user.name || "Networker"}! + + You are logged in as a guest. Some features may be limited. + {!user._id && ( To upgrade your account, join an event and then come back @@ -133,22 +155,37 @@ export default function Profile() { )} - {socialLinks.length === 0 && ( + {/* Empty */} + {!hasSocialLinks && ( No social links added yet. )} - {socialLinks.map((link, index) => ( + {/* LinkedIn */} + {social?.linkedin && ( + + LinkedIn + {social.linkedin} + + )} + + {/* GitHub */} + {social?.github && ( + + GitHub + {social.github} + + )} + + {/* Other Links */} + {social?.other?.map((link, index) => ( - <> - {link.label} - {link.url} - + {link.label} + {link.url} ))} - {/* Guest user who has joined event / has userId */} {user._id && ( (); //avoid race conditions with initializeGame - const [status, setStatus] = useState(EventState.UPCOMING); + const { gameState } = useGame(); - //TODO: Websocket for game loading useEffect(() => { - if (!eventId) return; //don't start polling until eventId is set - - const interval = setInterval(async () => { - const res = await getEventById(eventId); - const event = res?.event; - if (!event) return; - - setStatus(event.currentState); + if (!eventId) return; + if (gameState.progress !== EventState.IN_PROGRESS) return; - if (event.currentState === EventState.IN_PROGRESS) { - clearInterval(interval); + console.log(gameState); - router.replace({ - pathname: "/GamePages/Game", - }); - } - }, POLL_INTERVAL); - - return () => clearInterval(interval); - }, [eventId]); + router.replace({ + pathname: "/GamePages/Game", + params: { eventId }, + }); + }, [gameState.progress, eventId]); return ( Successfully Joined! - - {status === EventState.UPCOMING && ( - <> - Waiting for the event to start... - - - )} + <> + Waiting for the event to start... + + {/* Leave Game Button */} { - + diff --git a/shatter-mobile/app/UserPages/Guest.tsx b/shatter-mobile/app/UserPages/Guest.tsx index 7481315..928648d 100644 --- a/shatter-mobile/app/UserPages/Guest.tsx +++ b/shatter-mobile/app/UserPages/Guest.tsx @@ -1,102 +1,272 @@ -import { SocialLink } from "@/src/interfaces/User"; +import SocialSpinner from "@/src/components/login-signup/SocialSpinner"; import { colors } from "@/src/styling/constants"; import { GuestStyling as styles } from "@/src/styling/Guest.styles"; -import { useRouter } from "expo-router"; +import { Stack, useRouter } from "expo-router"; import { useState } from "react"; import { ImageBackground, + Modal, Text, TextInput, TouchableOpacity, View, } from "react-native"; +import { FontAwesome, Feather, Entypo } from "@expo/vector-icons"; import { SafeAreaView } from "react-native-safe-area-context"; import { useAuth } from "../../src/components/context/AuthContext"; +import { SocialLinks } from "@/src/interfaces/User"; export default function GuestPage() { const { continueAsGuest } = useAuth(); const [name, setName] = useState(""); - const [contactLink, setContactLink] = useState(""); + const [selectedType, setSelectedType] = useState<"linkedin" | "github" | "other" | null>(null); + const [linkedin, setLinkedin] = useState(""); + const [github, setGithub] = useState(""); + const [other, setOther] = useState(""); + const [showConfirmModal, setShowConfirmModal] = useState(false); const [error, setError] = useState(""); const router = useRouter(); const handleContinue = async () => { - //need name and social link - if (!name.trim() || !contactLink) { - setError("Name and Social Link Cannot Be Empty"); + if (!name.trim()) { + setError("Name cannot be empty"); return; } - let socialLink: SocialLink | null = null; - - try { - const validUrl = new URL(contactLink); //throws if invalid - socialLink = { label: "Contact Link", url: validUrl.href }; - } catch { - console.log("Invalid URL:", contactLink); - setError("Please enter a valid contact link."); + //ensure at least one link exists + if (!linkedin.trim() && !github.trim() && !other.trim()) { + setError("Please provide at least one contact link."); return; } + const validateUrl = (url: string) => { + try { + return new URL(url).href; + } catch { + return null; + } + }; + + const socialLinks: SocialLinks = {}; + + if (linkedin.trim()) { + const valid = validateUrl(linkedin); + if (!valid) { + setError("Invalid LinkedIn URL"); + return; + } + socialLinks.linkedin = valid; + } + + if (github.trim()) { + const valid = validateUrl(github); + if (!valid) { + setError("Invalid GitHub URL"); + return; + } + socialLinks.github = valid; + } + + if (other.trim()) { + const valid = validateUrl(other); + if (!valid) { + setError("Invalid Other URL"); + return; + } + socialLinks.other?.push({ label: "Contact Link", url: valid }); + } + setError(""); - await continueAsGuest(name.trim(), socialLink); + + await continueAsGuest(name.trim(), socialLinks, ""); router.replace("/JoinEventPage"); }; return ( - - - - Guest Access - Enter your details to continue - - - - Your Name - - - Contact Link - - - Your contact link can be your LinkedIn profile URL, a portfolio - link, or another relevant personal link. - - - {error ? {error} : null} - - - Continue - - - router.push("/UserPages/Signup")} - > - Back - - - - + <> + + + + + Guest Access + Enter your details to continue + + + + Your Name + + + Contact Link + + + setSelectedType("linkedin")} + style={{ + padding: 12, + borderRadius: 12, + backgroundColor: selectedType === "linkedin" ? "#0A66C2" : colors.lightGrey2, + flex: 1, + marginRight: 8, + alignItems: "center", + }} + > + + + + setSelectedType("github")} + style={{ + padding: 12, + borderRadius: 12, + backgroundColor: selectedType === "github" ? "#24292e" : colors.lightGrey2, + flex: 1, + marginRight: 8, + alignItems: "center", + }} + > + + + + setSelectedType("other")} + style={{ + padding: 12, + borderRadius: 12, + backgroundColor: selectedType === "other" ? "#6c63ff" : colors.lightGrey2, + flex: 1, + alignItems: "center", + }} + > + + + + + {selectedType && ( + { + if (selectedType === "linkedin") setLinkedin(text); + else if (selectedType === "github") setGithub(text); + else setOther(text); + }} + autoCapitalize="none" + keyboardType="url" + /> + )} + + + Select a platform above, then enter your profile link. + + + {error ? {error} : null} + + + Continue + + + router.push("/UserPages/Signup")} + > + Back + + + { + setShowConfirmModal(true); + }} + > + No Contact Link? + + + + + + + + Continue Without a Contact Link? + + + + + + Users tend to connect better when you include a contact link + like LinkedIn or a portfolio. + + + + Adding a contact link helps others learn more about you and + improves networking during events. + + + {/* Add link */} + setShowConfirmModal(false)} + > + Add Contact Link + + + {/* Continue anyway */} + { + setShowConfirmModal(false); + router.push("/UserPages/GuestNoLink"); + }} + > + + Continue Without Link + + + + + + + + ); } diff --git a/shatter-mobile/app/UserPages/GuestNoLink.tsx b/shatter-mobile/app/UserPages/GuestNoLink.tsx new file mode 100644 index 0000000..3a45365 --- /dev/null +++ b/shatter-mobile/app/UserPages/GuestNoLink.tsx @@ -0,0 +1,92 @@ +import { colors } from "@/src/styling/constants"; +import { GuestStyling as styles } from "@/src/styling/Guest.styles"; +import { Stack, useRouter } from "expo-router"; +import { useState } from "react"; +import { + ImageBackground, + Text, + TextInput, + TouchableOpacity, + View, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { useAuth } from "../../src/components/context/AuthContext"; + +export default function GuestConfirm() { + const { continueAsGuest } = useAuth(); + const [name, setName] = useState(""); + const [organization, setOrganization] = useState(""); + const [error, setError] = useState(""); + const router = useRouter(); + + const handleContinue = async () => { + if (!name.trim() || !organization.trim()) { + setError("Name and Organization cannot be empty"); + return; + } + + setError(""); + + await continueAsGuest( + name.trim(), + {}, + organization.trim(), + ); + + router.replace("/JoinEventPage"); + }; + + return ( + <> + + + + + Guest Access + Continue without a contact link + + + + Your Name + + + Organization + + + {error ? {error} : null} + + + Continue + + + router.back()} + > + Back + + + + + + ); +} diff --git a/shatter-mobile/app/UserPages/UpdateProfile.tsx b/shatter-mobile/app/UserPages/UpdateProfile.tsx index 2b91bf1..a07b530 100644 --- a/shatter-mobile/app/UserPages/UpdateProfile.tsx +++ b/shatter-mobile/app/UserPages/UpdateProfile.tsx @@ -1,5 +1,7 @@ import { getStoredAuth } from "@/src/components/context/AsyncStorage"; -import { userUpdate } from "@/src/services/user.service"; +import { SocialLinksModal } from "@/src/components/general/SocialLinksModal"; +import { SocialLinks } from "@/src/interfaces/User"; +import { UserLinkedInLink, userUpdate } from "@/src/services/user.service"; import { colors } from "@/src/styling/constants"; import { useRouter } from "expo-router"; import { useEffect, useState } from "react"; @@ -35,31 +37,33 @@ export default function UpdateProfile() { const [name, setName] = useState(user?.name || ""); const [email, setEmail] = useState(user?.email || ""); const [password, setPassword] = useState(""); + const [title, setTitle] = useState(user?.title || ""); + const [organization, setOrganization] = useState(user?.organization || ""); const [bio, setBio] = useState(user?.bio || ""); const [profilePhoto, setProfilePhoto] = useState(user?.profilePhoto || ""); - const [socialLinks, setSocialLinks] = useState< - { label: string; url: string }[] - >(user?.socialLinks || []); + const [socialLinks, setSocialLinks] = useState( + user?.socialLinks || {}, + ); + const [socialModalVisible, setSocialModalVisible] = useState(false); useEffect(() => { if (!user) router.replace("/UserPages/Login"); }, [user]); - const handleLinkChange = ( - index: number, - field: "label" | "url", - value: string, - ) => { - const updated = [...socialLinks]; - updated[index] = { ...updated[index], [field]: value }; - setSocialLinks(updated); - }; - - const addNewLink = () => - setSocialLinks([...socialLinks, { label: "", url: "" }]); + const handleLinkedInLink = async () => { + if (!user?._id) { + alert("Failed to link LinkedIn"); + return; + } - const removeLink = (index: number) => - setSocialLinks(socialLinks.filter((_, i) => i !== index)); + try { + await UserLinkedInLink(user._id); + alert("LinkedIn link initiated"); + } catch (e) { + console.log(e); + alert("Failed to link LinkedIn"); + } + }; const handleSave = async () => { if (!user || !user._id) return; @@ -90,10 +94,12 @@ export default function UpdateProfile() { bio, profilePhoto, socialLinks, + organization, + title, }); //local update const res = await userUpdate( user._id, - { name, email, bio, profilePhoto, socialLinks }, + { name, email, bio, profilePhoto, socialLinks, organization, title }, stored.accessToken, ); //remote update @@ -126,6 +132,46 @@ export default function UpdateProfile() { showsVerticalScrollIndicator={false} contentContainerStyle={{ paddingBottom: 40 }} > + {/* Profile Photo Section */} + {!user?.isGuest && ( + + Profile Photo + + {/* Preview */} + {profilePhoto && profilePhoto.length > 0 ? ( + + ) : ( + + + No photo selected + + + )} + + {/* Avatar grid */} + + {AVATAR_OPTIONS.filter(Boolean).map((url) => ( + setProfilePhoto(url)} + style={[ + styles.avatarOption, + profilePhoto === url && styles.avatarOptionSelected, + ]} + > + + + ))} + + + )} + {/* Name */} Name + {/* Title */} + Title + + + Your title at your organization, like Project Manager + + + {/* Organization */} + Organization + + {/* Non-guest only */} {!user?.isGuest && ( <> @@ -182,81 +251,35 @@ export default function UpdateProfile() { placeholderTextColor={colors.lightGrey2} multiline /> - - Profile Photo - - {/* Current selection preview */} - {profilePhoto && profilePhoto.length > 0 ? ( - - ) : ( - - - No photo selected - - - )} - - {/* Avatar grid */} - - {AVATAR_OPTIONS.filter(Boolean).map((url) => ( - setProfilePhoto(url)} - style={[ - styles.avatarOption, - profilePhoto === url && styles.avatarOptionSelected, - ]} - > - - - ))} - )} - {/* Social Links */} - - Social Links - - {socialLinks.map((link, index) => ( - - - handleLinkChange(index, "label", text) - } - /> - - handleLinkChange(index, "url", text) - } - /> - removeLink(index)} - > - Remove - - - ))} + {/* TODO: Link LinkedIn to Account if Verified User and LinkedIn Not Set + {!user?.socialLinks?.linkedin && user?._id && ( + + Link LinkedIn + + )} + */} - - + Add Social Link + {/* Social link modal */} + setSocialModalVisible(true)} + > + Manage Social Links + + Save Changes diff --git a/shatter-mobile/app/_layout.tsx b/shatter-mobile/app/_layout.tsx index 65ced73..d9eb1eb 100644 --- a/shatter-mobile/app/_layout.tsx +++ b/shatter-mobile/app/_layout.tsx @@ -1,6 +1,6 @@ import { getStoredAuth } from "@/src/components/context/AsyncStorage"; import { GameProvider } from "@/src/components/context/GameContext"; -import FullPageLoader from "@/src/components/FullPageLoader"; +import FullPageLoader from "@/src/components/general/FullPageLoader"; import { Poppins_600SemiBold, useFonts } from "@expo-google-fonts/poppins"; import { WorkSans_400Regular } from "@expo-google-fonts/work-sans"; import { Asset } from "expo-asset"; @@ -22,16 +22,18 @@ export default function RootLayout() { const [assetReady, setAssetReady] = useState(false); const redirectTo = useRef("/GetStarted"); + //preload fonts const [fontsLoaded] = useFonts({ "Poppins-SemiBold": Poppins_600SemiBold, "WorkSans-Regular": WorkSans_400Regular, }); - // Preload background image + //preload background image useEffect(() => { Asset.loadAsync([BG_IMAGE]).finally(() => setAssetReady(true)); }, []); + //check if user is logged in useEffect(() => { const checkAuth = async () => { try { @@ -46,12 +48,14 @@ export default function RootLayout() { checkAuth(); }, []); + //hold user if not loaded yet useEffect(() => { if (!fontsLoaded || !authReady || !assetReady) return; router.replace(redirectTo.current as any); SplashScreen.hideAsync(); }, [fontsLoaded, authReady, assetReady]); + //user isn't loaded yet if (!fontsLoaded || !authReady || !assetReady) { return ( diff --git a/shatter-mobile/app/auth/callback.tsx b/shatter-mobile/app/auth/callback.tsx index 629dca8..22c60fa 100644 --- a/shatter-mobile/app/auth/callback.tsx +++ b/shatter-mobile/app/auth/callback.tsx @@ -28,7 +28,7 @@ export default function AuthCallback() { _id: response.userId, name: userData.user.name, email: userData.user.email, - socialLinks: userData.user.socialLinks ?? [], + socialLinks: userData.user.socialLinks ?? {}, profilePhoto: userData.user.profilePhoto, isGuest: false, }; diff --git a/shatter-mobile/package-lock.json b/shatter-mobile/package-lock.json index c703d1e..f0261d3 100644 --- a/shatter-mobile/package-lock.json +++ b/shatter-mobile/package-lock.json @@ -11,6 +11,7 @@ "@expo-google-fonts/poppins": "^0.4.1", "@expo-google-fonts/work-sans": "^0.4.2", "@expo/vector-icons": "^15.0.3", + "@pusher/pusher-websocket-react-native": "^1.3.5", "@react-native-async-storage/async-storage": "2.2.0", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", @@ -91,7 +92,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -3108,6 +3108,16 @@ "node": ">=12.4.0" } }, + "node_modules/@pusher/pusher-websocket-react-native": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@pusher/pusher-websocket-react-native/-/pusher-websocket-react-native-1.3.5.tgz", + "integrity": "sha512-ys4SbTx0aztfXKwm1PxAKGmw6AAWHVwt41ab3hKNpoQpW1f3aFNSYJQNVZWYiJlWlW9unoi4hDp4aHqSxtDkMA==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/@radix-ui/primitive": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", @@ -3601,7 +3611,6 @@ "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.1.31.tgz", "integrity": "sha512-+YCUwtfDgsux59Q0LDHc3Zid9ih93ecUCFWZOH6/+eNoUGnWx77wjS6ZfvBO/7E+EiIup11IVShDzCHR4of8hw==", "license": "MIT", - "peer": true, "dependencies": { "@react-navigation/core": "^7.15.1", "escape-string-regexp": "^4.0.0", @@ -3806,7 +3815,6 @@ "integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -3878,7 +3886,6 @@ "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/types": "8.46.2", @@ -4441,7 +4448,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5150,7 +5156,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6269,7 +6274,6 @@ "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6466,7 +6470,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -6699,7 +6702,6 @@ "resolved": "https://registry.npmjs.org/expo/-/expo-54.0.33.tgz", "integrity": "sha512-3yOEfAKqo+gqHcV8vKcnq0uA5zxlohnhA3fu4G43likN8ct5ZZ3LjAh9wDdKteEkoad3tFPvwxmXW711S5OHUw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "54.0.23", @@ -6787,7 +6789,6 @@ "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz", "integrity": "sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ==", "license": "MIT", - "peer": true, "dependencies": { "@expo/config": "~12.0.13", "@expo/env": "~2.0.8" @@ -6812,7 +6813,6 @@ "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.11.tgz", "integrity": "sha512-ga0q61ny4s/kr4k8JX9hVH69exVSIfcIc19+qZ7gt71Mqtm7xy2c6kwsPTCyhBW2Ro5yXTT8EaZOpuRi35rHbg==", "license": "MIT", - "peer": true, "dependencies": { "fontfaceobserver": "^2.1.0" }, @@ -6863,7 +6863,6 @@ "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.11.tgz", "integrity": "sha512-+VSaNL5om3kOp/SSKO5qe6cFgfSIWnnQDSbA7XLs3ECkYzXRquk5unxNS3pg7eK5kNUmQ4kgLI7MhTggAEUBLA==", "license": "MIT", - "peer": true, "dependencies": { "expo-constants": "~18.0.12", "invariant": "^2.2.4" @@ -10812,7 +10811,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -10832,7 +10830,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -10869,7 +10866,6 @@ "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.81.5.tgz", "integrity": "sha512-1w+/oSjEXZjMqsIvmkCRsOc8UBYv163bTWKTI8+1mxztvQPhCRYGTvZ/PL1w16xXHneIj/SLGfxWg2GWN2uexw==", "license": "MIT", - "peer": true, "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native/assets-registry": "0.81.5", @@ -10927,7 +10923,6 @@ "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.28.0.tgz", "integrity": "sha512-0msfJ1vRxXKVgTgvL+1ZOoYw3/0z1R+Ked0+udoJhyplC2jbVKIJ8Z1bzWdpQRCV3QcQ87Op0zJVE5DhKK2A0A==", "license": "MIT", - "peer": true, "dependencies": { "@egjs/hammerjs": "^2.0.17", "hoist-non-react-statics": "^3.3.0", @@ -10953,7 +10948,6 @@ "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.3.tgz", "integrity": "sha512-GP8wsi1u3nqvC1fMab/m8gfFwFyldawElCcUSBJQgfrXeLmsPPUOpDw44lbLeCpcwUuLa05WTVePdTEwCLTUZg==", "license": "MIT", - "peer": true, "dependencies": { "react-native-is-edge-to-edge": "^1.2.1", "semver": "7.7.2" @@ -10982,7 +10976,6 @@ "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz", "integrity": "sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg==", "license": "MIT", - "peer": true, "peerDependencies": { "react": "*", "react-native": "*" @@ -10993,7 +10986,6 @@ "resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.16.0.tgz", "integrity": "sha512-yIAyh7F/9uWkOzCi1/2FqvNvK6Wb9Y1+Kzn16SuGfN9YFJDTbwlzGRvePCNTOX0recpLQF3kc2FmvMUhyTCH1Q==", "license": "MIT", - "peer": true, "dependencies": { "react-freeze": "^1.0.0", "react-native-is-edge-to-edge": "^1.2.1", @@ -11024,7 +11016,6 @@ "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz", "integrity": "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.6", "@react-native/normalize-colors": "^0.74.1", @@ -11057,7 +11048,6 @@ "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.5.1.tgz", "integrity": "sha512-lJG6Uk9YuojjEX/tQrCbcbmpdLCSFxDK1rJlkDhgqkVi1KZzG7cdcBFQRqyNOOzR9Y0CXNuldmtWTGOyM0k0+w==", "license": "MIT", - "peer": true, "dependencies": { "@babel/plugin-transform-arrow-functions": "^7.0.0-0", "@babel/plugin-transform-class-properties": "^7.0.0-0", @@ -11168,7 +11158,6 @@ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -12521,7 +12510,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -12728,7 +12716,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/shatter-mobile/package.json b/shatter-mobile/package.json index 45181ef..bc07df7 100644 --- a/shatter-mobile/package.json +++ b/shatter-mobile/package.json @@ -14,6 +14,7 @@ "@expo-google-fonts/poppins": "^0.4.1", "@expo-google-fonts/work-sans": "^0.4.2", "@expo/vector-icons": "^15.0.3", + "@pusher/pusher-websocket-react-native": "^1.3.5", "@react-native-async-storage/async-storage": "2.2.0", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", diff --git a/shatter-mobile/src/api/events/event.api.tsx b/shatter-mobile/src/api/events/event.api.tsx index 92fde38..be3ad34 100644 --- a/shatter-mobile/src/api/events/event.api.tsx +++ b/shatter-mobile/src/api/events/event.api.tsx @@ -2,6 +2,7 @@ import JoinEventByIdGuestRequest from "@/src/interfaces/requests/JoinEventByIdGu import JoinEventByIdUserRequest from "@/src/interfaces/requests/JoinEventByIdUserRequest"; import EventResponse from "@/src/interfaces/responses/GetEventResponse"; import EventJoinIdResponse from "@/src/interfaces/responses/JoinEventIdResponse"; +import { SocialLinks } from "@/src/interfaces/User"; import axios, { AxiosError, AxiosResponse } from "axios"; const API_BASE = process.env.EXPO_PUBLIC_API_BASE; @@ -103,9 +104,11 @@ export async function JoinEventByIdUserApi( export async function JoinEventByIdGuestApi( eventId: string, name: string, + socialLinks: SocialLinks, + organization: string, ): Promise { try { - const body: JoinEventByIdGuestRequest = { name }; + const body: JoinEventByIdGuestRequest = { name, socialLinks, organization }; const response: AxiosResponse = await axios.post( `${API_BASE_URL_EVENT}/${eventId}/join/guest`, body, diff --git a/shatter-mobile/src/api/users/user.api.tsx b/shatter-mobile/src/api/users/user.api.tsx index edca881..859a784 100644 --- a/shatter-mobile/src/api/users/user.api.tsx +++ b/shatter-mobile/src/api/users/user.api.tsx @@ -92,14 +92,6 @@ export async function UserFetchApi( { headers: { Authorization: `Bearer ${token}` } }, ); - //TODO: Remove profile photo assigning here - if (!response.data.user.profilePhoto) { - const encodedName = encodeURIComponent( - response.data.user.name ?? "Unknown", - ); - response.data.user.profilePhoto = `https://api.dicebear.com/9.x/initials/svg?seed=${encodedName}`; - } - return response.data; } catch (error) { const err = error as AxiosError; @@ -290,6 +282,8 @@ export async function UserUpdateApi( bio: updates.bio, profilePhoto: updates.profilePhoto, socialLinks: updates.socialLinks, + organization: updates.organization, + title: updates.title, }; const response: AxiosResponse = await axios.put( @@ -354,3 +348,36 @@ export async function ExchangeLinkedInCodeApi( throw new Error("Network error. Check your connection."); } } + +export async function UserLinkedInLinkApi( + userId: string, +): Promise { + try { + const response: AxiosResponse = await axios.post( + `${API_BASE_URL_AUTH}/linkedin/link`, + { userId }, + ); + return response.data; + } catch (error) { + const err = error as AxiosError; + + if (err.response) { + switch (err.response.status) { + case 400: + throw new Error("Authentication required."); + case 401: + throw new Error("User not found. Please try again later."); + case 403: + throw new Error("This account is already a LinkedIn account."); + case 409: + throw new Error("This account is already linked to LinkedIn!"); + case 500: + throw new Error("Server error. Please try again later."); + default: + throw new Error("Authentication failed."); + } + } + + throw new Error("Network error. Check your connection."); + } +} diff --git a/shatter-mobile/src/components/context/AsyncStorage.tsx b/shatter-mobile/src/components/context/AsyncStorage.tsx index aa0ce1d..ca831fe 100644 --- a/shatter-mobile/src/components/context/AsyncStorage.tsx +++ b/shatter-mobile/src/components/context/AsyncStorage.tsx @@ -1,4 +1,4 @@ -import { SocialLink } from "@/src/interfaces/User"; +import { SocialLinks } from "@/src/interfaces/User"; import AsyncStorage from "@react-native-async-storage/async-storage"; const STORAGE_KEY = "AUTH_DATA"; @@ -7,7 +7,7 @@ export type AuthDataStorage = { userId: string | null; accessToken: string; isGuest: boolean; - guestInfo: { name: string; socialLinks: SocialLink[] }; + guestInfo: { name: string; socialLinks?: SocialLinks, organization?: string }; }; export const getStoredAuth = async (): Promise => { @@ -18,7 +18,7 @@ export const getStoredAuth = async (): Promise => { userId: "", accessToken: "", isGuest: false, - guestInfo: { name: "", socialLinks: [] }, + guestInfo: { name: "", socialLinks: {} }, }; }; diff --git a/shatter-mobile/src/components/context/AuthContext.tsx b/shatter-mobile/src/components/context/AuthContext.tsx index 031d484..af99916 100644 --- a/shatter-mobile/src/components/context/AuthContext.tsx +++ b/shatter-mobile/src/components/context/AuthContext.tsx @@ -1,4 +1,4 @@ -import { SocialLink, User } from "@/src/interfaces/User"; +import { SocialLinks, User } from "@/src/interfaces/User"; import { userFetch } from "@/src/services/user.service"; import AsyncStorage from "@react-native-async-storage/async-storage"; import React, { createContext, useContext, useEffect, useState } from "react"; @@ -12,7 +12,11 @@ type AuthContextType = { accessToken: string, isGuest: boolean, ) => Promise; - continueAsGuest: (name: string, socialLink: SocialLink) => Promise; + continueAsGuest: ( + name: string, + socialLinks: SocialLinks, + organization: string, + ) => Promise; logout: () => Promise; updateUser: (updates: Partial) => User | undefined; }; @@ -24,7 +28,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { userId: "", accessToken: "", isGuest: true, - guestInfo: { name: "", socialLinks: [] }, + guestInfo: { name: "", socialLinks: {}, organization: "" }, }); const [user, setUser] = useState(undefined); @@ -44,6 +48,8 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { email: res.user.email, isGuest: res.user.isGuest, socialLinks: res.user.socialLinks, + organization: res.user.organization, + title: res.user.title, profilePhoto: res.user.profilePhoto, }; @@ -59,6 +65,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { email: "", isGuest: savedData.isGuest, socialLinks: savedData.guestInfo.socialLinks, + organization: savedData.guestInfo.organization, }; setUser(mappedUser); } @@ -79,18 +86,31 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { userId: user?._id, accessToken, isGuest: isGuest, - guestInfo: { name: user.name, socialLinks: user.socialLinks }, + guestInfo: { + name: user.name, + socialLinks: user.socialLinks || {}, + organization: user.organization, + }, }; setAuthStorage(storageData); await saveStoredAuth(storageData); }; //when user initially creates a guest account - const continueAsGuest = async (name: string, socialLink: SocialLink) => { + const continueAsGuest = async ( + name: string, + socialLink: SocialLinks, + organization: string, + ) => { + const encodedName = encodeURIComponent(name ?? "Unknown"); + const profilePhoto = `https://api.dicebear.com/9.x/initials/svg?seed=${encodedName}`; + const guestUser: User = { _id: null, name: name, - socialLinks: [{ label: socialLink.label, url: socialLink.url }], + socialLinks: { linkedin: socialLink.linkedin, github: socialLink.github, other: socialLink.other }, + organization: organization, + profilePhoto: profilePhoto, isGuest: true, }; @@ -100,7 +120,11 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { userId: guestUser._id, accessToken: "", isGuest: true, - guestInfo: { name: guestUser.name, socialLinks: guestUser.socialLinks }, + guestInfo: { + name: name, + socialLinks: guestUser.socialLinks || {}, + organization: organization || "", + }, }; setAuthStorage(storageData); @@ -113,7 +137,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { userId: "", accessToken: "", isGuest: true, - guestInfo: { name: "", socialLinks: [] }, + guestInfo: { name: "", socialLinks: {}, organization: "" }, }); await AsyncStorage.clear(); }; diff --git a/shatter-mobile/src/components/context/GameContext.tsx b/shatter-mobile/src/components/context/GameContext.tsx index 70596ce..805b3fd 100644 --- a/shatter-mobile/src/components/context/GameContext.tsx +++ b/shatter-mobile/src/components/context/GameContext.tsx @@ -1,4 +1,4 @@ -import { EventState, GameType } from "@/src/interfaces/Event"; +import { EventState, GameType, Participant } from "@/src/interfaces/Event"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { createContext, @@ -7,6 +7,7 @@ import { useEffect, useState, } from "react"; +import { getPusherClient } from "./PusherClient"; export type GameState = { gameType: GameType; //"Game Bingo" @@ -15,6 +16,8 @@ export type GameState = { data: any; //generic, can hold cards, prompts, scores status: string | null; //"Bingo!", "Completed" progress: EventState; + participants: Participant[]; + viewingGame: boolean; }; type GameContextType = { @@ -30,6 +33,8 @@ type GameContextType = { setGameData: (data: any) => void; setGameStatus: (status: string | null) => void; setGameProgress: (progress: EventState) => void; + setGameParticipants: (participants: Participant[]) => void; + setGameViewing: (gameView: boolean) => void; resetGame: () => void; }; @@ -40,6 +45,8 @@ const defaultGameState: GameState = { data: "", status: null, progress: EventState.UPCOMING, + participants: [], + viewingGame: false, }; const GameContext = createContext(undefined); @@ -53,6 +60,54 @@ export const GameProvider = ({ children }: { children: ReactNode }) => { const storageKey = (eventId: string, gameType: GameType) => `game-${gameType}-${eventId}`; + //create event hook for each event joined + useEffect(() => { + if (!gameState.eventId) return; + + let isActive = true; + + let channel: any = null; + let pusherClient: any = null; + + const setup = async () => { + pusherClient = await getPusherClient(); + + if (!isActive) return; + + channel = await pusherClient.subscribe({ + channelName: `event-${gameState.eventId}`, + onEvent: (event: any) => { + if (event.eventName === "event") { + try { + const data = JSON.parse(event.data); + + setGameProgress(data.status); + setGameParticipants(data.participantIds); + } catch (err) { + console.error("Failed to parse event data", err); + } + } + }, + }); + }; + + setup(); + + return () => { + isActive = false; + + if (channel) { + channel.unbind_all?.(); + } + + if (pusherClient && gameState.eventId) { + pusherClient.unsubscribe({ + channelName: `event-${gameState.eventId}`, + }); + } + }; + }, [gameState.eventId]); + //load participantId on app start useEffect(() => { const loadParticipant = async () => { @@ -79,36 +134,19 @@ export const GameProvider = ({ children }: { children: ReactNode }) => { gameType: GameType, eventId: string, eventProgress: EventState, + eventParticipants: Participant[], initialData: any = {}, ): Promise => { setGameState({ gameType, eventId, - loading: true, + loading: false, data: initialData, status: null, progress: eventProgress, + participants: eventParticipants, + viewingGame: false, }); - - setGameType(GameType.NAME_BINGO); //TODO: Remove Hard Coded Game Type / change return of getEventById to include gameType - - //Load persisted state if exists - try { - const saved = await AsyncStorage.getItem(storageKey(eventId, gameType)); - if (saved) { - const parsed = JSON.parse(saved); - - setGameState((prev) => ({ - ...prev, - ...parsed, - progress: eventProgress, //always fetch live update for game progress - })); - } - } catch (err) { - console.log("Failed to load game state:", err); - } finally { - setGameState((prev) => ({ ...prev, loading: false })); - } }; const setGameData = async (data: any) => { @@ -156,9 +194,9 @@ export const GameProvider = ({ children }: { children: ReactNode }) => { } }; - const setGameType = async (gameType: GameType) => { + const setGameViewing = async (viewGame: boolean) => { if (!gameState) return; - const newState = { ...gameState, gameType }; + const newState = { ...gameState, viewGame }; setGameState(newState); try { @@ -167,7 +205,22 @@ export const GameProvider = ({ children }: { children: ReactNode }) => { JSON.stringify(newState), ); } catch (err) { - console.log("Failed to save game progress:", err); + console.log("Failed to save game viewing status:", err); + } + }; + + const setGameParticipants = async (participants: Participant[]) => { + if (!gameState) return; + const newState = { ...gameState, participants }; + setGameState(newState); + + try { + await AsyncStorage.setItem( + storageKey(gameState.eventId, gameState.gameType), + JSON.stringify(newState), + ); + } catch (err) { + console.log("Failed to save game participants:", err); } }; @@ -192,6 +245,8 @@ export const GameProvider = ({ children }: { children: ReactNode }) => { setGameData, setGameStatus, setGameProgress, + setGameParticipants, + setGameViewing, resetGame, }} > diff --git a/shatter-mobile/src/components/context/PusherClient.tsx b/shatter-mobile/src/components/context/PusherClient.tsx new file mode 100644 index 0000000..c9307d6 --- /dev/null +++ b/shatter-mobile/src/components/context/PusherClient.tsx @@ -0,0 +1,29 @@ +import { Pusher } from "@pusher/pusher-websocket-react-native"; + +let pusher: Pusher | null = null; +let initPromise: Promise | null = null; + +const API_KEY = process.env.PUSHER_KEY!; +const API_CLUSTER = process.env.PUSHER_CLUSTER!; + +export const getPusherClient = async (): Promise => { + if (pusher) return pusher; + + if (!initPromise) { + initPromise = (async () => { + const instance = Pusher.getInstance(); + + await instance.init({ + apiKey: API_KEY, + cluster: API_CLUSTER, + }); + + await instance.connect(); + + pusher = instance; + return pusher; + })(); + } + + return initPromise; +}; diff --git a/shatter-mobile/src/components/events/EventCard.tsx b/shatter-mobile/src/components/events/EventCard.tsx index 5bc78e7..53ff987 100644 --- a/shatter-mobile/src/components/events/EventCard.tsx +++ b/shatter-mobile/src/components/events/EventCard.tsx @@ -14,7 +14,7 @@ type EventCardProps = { const EventCard = ({ event, expanded, onPress }: EventCardProps) => { const router = useRouter(); - const { initializeGame } = useGame(); + const { initializeGame, setGameViewing } = useGame(); const [imageLoaded, setImageLoaded] = useState(false); const [modalVisible, setModalVisible] = useState(false); @@ -84,7 +84,7 @@ const EventCard = ({ event, expanded, onPress }: EventCardProps) => { if (!event.gameType) { event.gameType = GameType.NAME_BINGO; } - initializeGame(event.gameType, event._id, event.currentState); + initializeGame(event.gameType, event._id, event.currentState, event.participantIds); router.push({ pathname: "/EventPages/EventLobby", params: { eventId: event._id }, @@ -103,7 +103,7 @@ const EventCard = ({ event, expanded, onPress }: EventCardProps) => { if (!event.gameType) { event.gameType = GameType.NAME_BINGO; } - initializeGame(event.gameType, event._id, event.currentState); + initializeGame(event.gameType, event._id, event.currentState, event.participantIds); router.push({ pathname: "/GamePages/Game", params: { eventId: event._id }, @@ -122,7 +122,8 @@ const EventCard = ({ event, expanded, onPress }: EventCardProps) => { if (!event.gameType) { event.gameType = GameType.NAME_BINGO; } - initializeGame(event.gameType, event._id, event.currentState); + initializeGame(event.gameType, event._id, event.currentState, event.participantIds); + setGameViewing(true); //update viewing game flag in GameContext router.push({ pathname: "/GamePages/Game", params: { eventId: event._id }, diff --git a/shatter-mobile/src/components/events/UserModal.tsx b/shatter-mobile/src/components/events/UserModal.tsx index 4e60ef1..5ba0923 100644 --- a/shatter-mobile/src/components/events/UserModal.tsx +++ b/shatter-mobile/src/components/events/UserModal.tsx @@ -1,7 +1,9 @@ import { User } from "@/src/interfaces/User"; import { Linking, Modal, Pressable, Text, View } from "react-native"; +import { Feather } from "@expo/vector-icons"; import { SvgUri } from "react-native-svg"; import { UserModalStyling as styles } from "../../styling/UserModal.styles"; +import { LinkRow } from "../general/LinkRow"; type UserModalProps = { user: User; @@ -29,29 +31,29 @@ const UserModal = ({ user, onRequestClose }: UserModalProps) => { /> - {user.name} + + {user.name} + {user.title ? ` - ${user.title}` : ""} + + {user.organization && {user.organization}} + {user.bio && {user.bio}} - {user.socialLinks?.length > 0 && ( + {user.socialLinks && ( - {user.socialLinks.map((link, index) => ( - { - if (link.url) { - Linking.openURL(link.url).catch((err) => - console.log("Failed to open URL:", err), - ); - } - }} - style={{ marginBottom: 8 }} - > - {link.label} - {link.url} - + {user.socialLinks.linkedin && ( + + )} + + {user.socialLinks.github && ( + + )} + + {user.socialLinks.other?.map((link, index) => ( + ))} )} diff --git a/shatter-mobile/src/components/games/IcebreakerGame.tsx b/shatter-mobile/src/components/games/IcebreakerGame.tsx index f85a96f..c320a5b 100644 --- a/shatter-mobile/src/components/games/IcebreakerGame.tsx +++ b/shatter-mobile/src/components/games/IcebreakerGame.tsx @@ -18,34 +18,16 @@ type IcebreakerGameProps = { const IcebreakerGame = ({ event }: IcebreakerGameProps) => { const { user } = useAuth(); - const { setGameProgress } = useGame(); const { gameState, currentParticipantId } = useGame(); const router = useRouter(); - //TODO: Websocket for event progress useEffect(() => { if (!event._id) return; + if (gameState.progress !== EventState.COMPLETED) return; + if (!gameState.viewingGame) return; //if user is looking at game from Events page - const interval = setInterval(async () => { - try { - const res = await getEventById(event._id); - - if (res.event.currentState) { - setGameProgress(res.event.currentState); - } - - //when game is finised - if (res?.event.currentState === EventState.COMPLETED) { - clearInterval(interval); - router.push("/EventPages/EventComplete"); - } - } catch (err) { - console.log("Polling error:", err); - } - }, POLL_INTERVAL); - - return () => clearInterval(interval); - }, [event._id]); + router.push("/EventPages/EventComplete"); + }, [gameState.progress, event._id]); //Pick game-specific component const renderGame = () => { diff --git a/shatter-mobile/src/components/games/NameBingo.tsx b/shatter-mobile/src/components/games/NameBingo.tsx index 5b25d8a..3793f77 100644 --- a/shatter-mobile/src/components/games/NameBingo.tsx +++ b/shatter-mobile/src/components/games/NameBingo.tsx @@ -2,21 +2,21 @@ import { useGame } from "@/src/components/context/GameContext"; import { EventState, Participant } from "@/src/interfaces/Event"; import { BingoTile } from "@/src/interfaces/Game"; import { - getBingoCategories, - getParticipantsByEventId, + getBingoCategories, + getParticipantsByEventId, } from "@/src/services/game.service"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { useEffect, useState } from "react"; import { - DimensionValue, - ScrollView, - Text, - TextInput, - TouchableOpacity, - View, + DimensionValue, + ScrollView, + Text, + TextInput, + TouchableOpacity, + View, } from "react-native"; import { NameBingoStyling as styles } from "../../styling/NameBingo.styles"; -import FullPageLoader from "../FullPageLoader"; +import FullPageLoader from "../general/FullPageLoader"; type NameBingoProps = { eventId: string; @@ -161,8 +161,8 @@ const NameBingo = ({ eventId, onConnect }: NameBingoProps) => { const categoriesData = await getBingoCategories(eventId); setCategories(categoriesData.tiles); - const participantsData = await getParticipantsByEventId(eventId); - setParticipants(participantsData?.participants || []); + const participantsData = gameState.participants; + setParticipants(participantsData || []); const hasSaved = await loadSavedCards(); if (!hasSaved) { @@ -319,16 +319,22 @@ const NameBingo = ({ eventId, onConnect }: NameBingoProps) => { setActiveCardId(card.cardId); }} > - + - {card.tile?.shortQuestion || "?"} + {card.tile?.shortQuestion || "?"} {card.assignedParticipantId && ( - {card.assignedName} + {card.assignedName} )} diff --git a/shatter-mobile/src/components/AnimatedTab.tsx b/shatter-mobile/src/components/general/AnimatedTab.tsx similarity index 100% rename from shatter-mobile/src/components/AnimatedTab.tsx rename to shatter-mobile/src/components/general/AnimatedTab.tsx diff --git a/shatter-mobile/src/components/FullPageLoader.tsx b/shatter-mobile/src/components/general/FullPageLoader.tsx similarity index 100% rename from shatter-mobile/src/components/FullPageLoader.tsx rename to shatter-mobile/src/components/general/FullPageLoader.tsx diff --git a/shatter-mobile/src/components/general/LinkRow.tsx b/shatter-mobile/src/components/general/LinkRow.tsx new file mode 100644 index 0000000..bdb4a2b --- /dev/null +++ b/shatter-mobile/src/components/general/LinkRow.tsx @@ -0,0 +1,59 @@ +import { colors, fonts } from "@/src/styling/constants"; +import Feather from "@expo/vector-icons/build/Feather"; +import { StyleSheet, Linking, Pressable, View, Text } from "react-native"; + +type LinkRowProps = { + label: string; + url: string; +}; + +export const LinkRow = ({ label, url }: LinkRowProps) => { + return ( + { + Linking.openURL(url).catch((err) => + console.log("Failed to open URL:", err), + ); + }} + style={styles.linkRow} + > + {label} + + + + {url} + + + + + + ); +}; + +const styles = StyleSheet.create({ + linkRow: { + marginBottom: 12, + paddingVertical: 8, + }, + + linkRight: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + }, + + + linkLabel: { + fontFamily: fonts.title, + fontSize: 14, + color: colors.darkNavy, + fontWeight: "bold", + marginTop: 6, + }, + + link: { + flex: 1, + marginRight: 8, + color: "#666", + } +}); \ No newline at end of file diff --git a/shatter-mobile/src/components/general/SocialLinksModal.tsx b/shatter-mobile/src/components/general/SocialLinksModal.tsx new file mode 100644 index 0000000..898b2d1 --- /dev/null +++ b/shatter-mobile/src/components/general/SocialLinksModal.tsx @@ -0,0 +1,192 @@ +import { colors } from "@/src/styling/constants"; +import { + Modal, + ScrollView, + Text, + TextInput, + TouchableOpacity, + View, +} from "react-native"; +import { UpdateProfileStyling as styles } from "../../styling/UpdateProfile.styles"; +import { OtherLink, SocialLinks } from "@/src/interfaces/User"; + +type SocialLinkModalProps = { + socialModalVisible: boolean; + setSocialModalVisible: (visible: boolean) => void; + socialLinks: SocialLinks; + setSocialLinks: (links: SocialLinks) => void; +}; + +export function SocialLinksModal({ + socialModalVisible, + setSocialModalVisible, + socialLinks, + setSocialLinks, +}: SocialLinkModalProps) { + //fixed fields + const updateField = ( + field: "linkedin" | "github", + value: string + ) => { + setSocialLinks({ + ...socialLinks, + [field]: value, + }); + }; + + //other links + const updateOtherLink = ( + index: number, + field: keyof OtherLink, + value: string + ) => { + const updated = [...(socialLinks.other || [])]; + updated[index] = { + ...updated[index], + [field]: value, + }; + + setSocialLinks({ + ...socialLinks, + other: updated, + }); + }; + + const addOtherLink = () => { + setSocialLinks({ + ...socialLinks, + other: [ + ...(socialLinks.other || []), + { label: "", url: "" }, + ], + }); + }; + + const removeOtherLink = (index: number) => { + const updated = (socialLinks.other || []).filter( + (_, i) => i !== index + ); + + setSocialLinks({ + ...socialLinks, + other: updated, + }); + }; + + return ( + setSocialModalVisible(false)} + > + + + + Manage Social Links + + + + + {/* LinkedIn */} + LinkedIn + + updateField("linkedin", text) + } + autoCapitalize="none" + keyboardType="url" + /> + + {/* GitHub */} + GitHub + + updateField("github", text) + } + autoCapitalize="none" + keyboardType="url" + /> + + {/* Other */} + + Other Links + + + {(socialLinks.other || []).map((link, index) => ( + + + updateOtherLink(index, "label", text) + } + /> + + + updateOtherLink(index, "url", text) + } + autoCapitalize="none" + keyboardType="url" + /> + + removeOtherLink(index)} + > + + Remove + + + + ))} + + + + + Add Other Link + + + + + + setSocialModalVisible(false)} + > + Done + + + + + ); +} diff --git a/shatter-mobile/src/components/login-signup/LoginForm.tsx b/shatter-mobile/src/components/login-signup/LoginForm.tsx index 975634c..9822282 100644 --- a/shatter-mobile/src/components/login-signup/LoginForm.tsx +++ b/shatter-mobile/src/components/login-signup/LoginForm.tsx @@ -58,10 +58,12 @@ export default function LoginForm() { const user: User = { _id: userResponse.userId, - name: userData?.user.name, + name: userData.user.name, email, - socialLinks: userData?.user.socialLinks ?? [], + socialLinks: userData.user.socialLinks ?? {}, profilePhoto: userData.user.profilePhoto, + organization: userData.user.organization, + title: userData.user.title, isGuest: false, }; diff --git a/shatter-mobile/src/components/login-signup/SignupForm.tsx b/shatter-mobile/src/components/login-signup/SignupForm.tsx index e688c1e..fe9c5de 100644 --- a/shatter-mobile/src/components/login-signup/SignupForm.tsx +++ b/shatter-mobile/src/components/login-signup/SignupForm.tsx @@ -6,7 +6,6 @@ import * as WebBrowser from "expo-web-browser"; import { useState } from "react"; import { ActivityIndicator, - Button, ImageBackground, KeyboardAvoidingView, Platform, @@ -81,7 +80,7 @@ export default function SignUpForm() { _id: userResponse.userId, name, email, - socialLinks: [], + socialLinks: {}, profilePhoto: profilePhoto, isGuest: false, }; @@ -190,11 +189,17 @@ export default function SignUpForm() { Already have an Account?{" "} Log In -