diff --git a/public/static/avatars/cat.svg b/public/static/avatars/cat.svg new file mode 100644 index 000000000..ac9a7b56f --- /dev/null +++ b/public/static/avatars/cat.svg @@ -0,0 +1,103 @@ + + + + + + diff --git a/public/static/avatars/dog.svg b/public/static/avatars/dog.svg new file mode 100644 index 000000000..3de6e334d --- /dev/null +++ b/public/static/avatars/dog.svg @@ -0,0 +1,40 @@ + + diff --git a/public/static/avatars/elephant.svg b/public/static/avatars/elephant.svg new file mode 100644 index 000000000..65c8fa815 --- /dev/null +++ b/public/static/avatars/elephant.svg @@ -0,0 +1,40 @@ + + + + + + diff --git a/src/AppLayout.js b/src/AppLayout.js index cb1069501..465a454bb 100644 --- a/src/AppLayout.js +++ b/src/AppLayout.js @@ -14,6 +14,12 @@ import useExercisesCounterNotification from "./hooks/useExercisesCounterNotifica import useStreakMilestone from "./hooks/useStreakMilestone"; import TopBar from "./components/TopBar"; import { MOBILE_WIDTH } from "./components/MainNav/screenSize"; +import DailyFeedbackBanner from "./components/DailyFeedbackBanner"; +import Feature from "./features/Feature"; +import useBadgeCounterNotification from "@/hooks/useBadgeCounterNotification"; +import { BadgeCounterContext } from "@/badges/BadgeCounterContext"; +import useFriendRequestNotification from "./hooks/useFriendRequestNotification"; +import { FriendRequestContext } from "./contexts/FriendRequestContext"; // Desktop (flex row): Mobile (flex column): // ┌──────────┬──────────────────┐ ┌──────────────────┐ @@ -41,6 +47,10 @@ export default function AppLayout(props) { hideExerciseCounter, } = useExercisesCounterNotification(); + + const badgeCounter = useBadgeCounterNotification(); + const friendRequestNotification = useFriendRequestNotification(); + const path = useLocation().pathname; useStreakMilestone(); @@ -83,22 +93,26 @@ export default function AppLayout(props) { hideExerciseCounter: hideExerciseCounter, }} > - - - {screenWidth > MOBILE_WIDTH && } - - {screenWidth <= MOBILE_WIDTH && } - {appContent} - - {screenWidth <= MOBILE_WIDTH && } - - + + + + + {screenWidth > MOBILE_WIDTH && } + + {screenWidth <= MOBILE_WIDTH && } + {appContent} + + {screenWidth <= MOBILE_WIDTH && } + + + + ); diff --git a/src/MainAppRouter.js b/src/MainAppRouter.js index 4c076613e..1eaaecc94 100644 --- a/src/MainAppRouter.js +++ b/src/MainAppRouter.js @@ -45,6 +45,7 @@ import { PrivateRouteWithLayout } from "./PrivateRouteWithLayout"; import { PrivateRoute } from "./PrivateRoute"; import DeleteAccount from "./pages/DeleteAccount/DeleteAccount"; import SettingsRouter from "./pages/Settings/_SettingsRouter"; +import ProfileRouter from "./profile/_ProfileRouter"; import ExercisesForArticle from "./exercises/ExercisesForArticle"; import { WEB_READER } from "./reader/ArticleReader"; import VideoPlayer from "./videos/VideoPlayer"; @@ -52,6 +53,7 @@ import DailyAudioRouter from "./dailyAudio/_DailyAudioRouter"; import IndividualExercise from "./pages/IndividualExercise"; import Swiper from "./swiper/Swiper"; import KeyboardTest from "./pages/KeyboardTest"; +import Feature from "./features/Feature"; // Helper to detect if we're in a Capacitor native app const isCapacitor = () => { @@ -105,6 +107,7 @@ export default function MainAppRouter({ hasExtension, handleSuccessfulLogIn }) { + @@ -132,6 +135,12 @@ export default function MainAppRouter({ hasExtension, handleSuccessfulLogIn }) { + {/* Only include profile router if gamification is enabled */} + {Feature.has_gamification() ? ( + + ) : ( + + )} diff --git a/src/api/Zeeguu_API.js b/src/api/Zeeguu_API.js index 456fb5d3b..22a846c7e 100644 --- a/src/api/Zeeguu_API.js +++ b/src/api/Zeeguu_API.js @@ -24,5 +24,8 @@ import "./userVideos"; import "./watching_sessions"; import "./dailyAudio"; import "./sessionHistory"; +import "./badges"; +import "./friends"; +import "./leaderboards"; export default Zeeguu_API; diff --git a/src/api/badges.js b/src/api/badges.js new file mode 100644 index 000000000..6c45aaac6 --- /dev/null +++ b/src/api/badges.js @@ -0,0 +1,17 @@ +import { Zeeguu_API } from "./classDef"; + +Zeeguu_API.prototype.getBadgesForUser = function(callback) { + this._getJSON(`/badges`, callback); +}; + +Zeeguu_API.prototype.getBadgesForFriend = function(username, callback) { + this._getJSON(`/badges/${username}`, callback); +}; + +Zeeguu_API.prototype.getNotShownUserBadges = function (callback) { + this._getJSON(`/badges/count_not_shown`, callback); +}; + +Zeeguu_API.prototype.updateNotShownForUser = function (callback, onError) { + this._post(`/badges/update_not_shown`, "", callback, onError); +}; diff --git a/src/api/friends.js b/src/api/friends.js new file mode 100644 index 000000000..d40510dfa --- /dev/null +++ b/src/api/friends.js @@ -0,0 +1,57 @@ +import { Zeeguu_API } from "./classDef"; + + +Zeeguu_API.prototype.getFriends = function(callback) { + this._getJSON(`get_friends`, (data) => { + callback(data); + }); +} + +Zeeguu_API.prototype.getFriendsForUser = function(username, callback) { + this._getJSON(`get_friends/${username}`, (data) => { + callback(data); + }); +} + +Zeeguu_API.prototype.getNumberOfReceivedFriendRequests = function(callback) { + this._getJSON(`get_number_of_received_friend_requests`, (data) => { + callback(data); + }); +} + +Zeeguu_API.prototype.getReceivedFriendRequests = function(callback) { + this._getJSON(`get_received_friend_requests`, (data) => { + callback(data); + }); +} + +Zeeguu_API.prototype.searchUsers = function(search_term, callback) { + this._getJSON(`search_users?query=${encodeURIComponent(search_term)}`, (data) => { + callback(data); + }); +} + +Zeeguu_API.prototype.getFriendDetails = function(friend_username, callback) { + this._getJSON(`get_user_details/${friend_username}`, (data) => { + callback(data); + }); +} + +Zeeguu_API.prototype.sendFriendRequest = function(receiver_username) { + return this.apiPost("/send_friend_request", { receiver_username }, false); +} + +Zeeguu_API.prototype.deleteFriendRequest = function(receiver_username) { + return this.apiPost("/delete_friend_request", { receiver_username }, false); +} + +Zeeguu_API.prototype.acceptFriendRequest = function(sender_username) { + return this.apiPost(`/accept_friend_request`, { sender_username }, false); +} + +Zeeguu_API.prototype.rejectFriendRequest = function(sender_username) { + return this.apiPost(`/reject_friend_request`, { sender_username }, false); +} +Zeeguu_API.prototype.unfriend = function(receiver_username) { + return this.apiPost("/unfriend", { receiver_username }, false); +} \ No newline at end of file diff --git a/src/api/leaderboards.js b/src/api/leaderboards.js new file mode 100644 index 000000000..7e8b6e122 --- /dev/null +++ b/src/api/leaderboards.js @@ -0,0 +1,46 @@ +import { Zeeguu_API } from "./classDef"; + +function fetchFriendsLeaderboard(api, metric, fromDate, toDate, callback) { + if (!metric || !fromDate || !toDate) { + console.error("Leaderboard requires metric, fromDate, and toDate"); + callback([]); + return; + } + + const params = new URLSearchParams({ + metric, + from_date: fromDate, + to_date: toDate, + }); + + api._getJSON(`friends_leaderboard?${params.toString()}`, (data) => { + callback(data); + }); +} + +function fetchCohortLeaderboard(api, cohortId, metric, fromDate, toDate, callback) { + if (!metric || !fromDate || !toDate) { + console.error("Leaderboard requires metric, fromDate, and toDate"); + callback([]); + return; + } + + const params = new URLSearchParams({ + metric, + from_date: fromDate, + to_date: toDate, + }); + + api._getJSON(`cohort_leaderboard/${cohortId}?${params.toString()}`, (data) => { + callback(data); + }); +} + + +Zeeguu_API.prototype.getFriendsLeaderboard = function(metric, fromDate, toDate, callback) { + fetchFriendsLeaderboard(this, metric, fromDate, toDate, callback); +}; + +Zeeguu_API.prototype.getCohortLeaderboard = function(cohortId, metric, fromDate, toDate, callback) { + fetchCohortLeaderboard(this, cohortId, metric, fromDate, toDate, callback); +}; \ No newline at end of file diff --git a/src/api/userStats.js b/src/api/userStats.js index 9ce9607dc..e745f87aa 100644 --- a/src/api/userStats.js +++ b/src/api/userStats.js @@ -11,3 +11,11 @@ Zeeguu_API.prototype.getDailyStreak = function (callback) { Zeeguu_API.prototype.getAllLanguageStreaks = function (callback) { this._getJSON("all_language_streaks", callback); }; + +Zeeguu_API.prototype.getAllDailyStreakForUser = function (callback) { + this._getJSON("all_language_streaks_detailed", callback); +}; + +Zeeguu_API.prototype.getAllDailyStreakForFriend = function (username, callback) { + this._getJSON(`all_language_streaks_detailed/${username}`, callback); +}; diff --git a/src/badges/BadgeCounterContext.js b/src/badges/BadgeCounterContext.js new file mode 100644 index 000000000..e79d88a00 --- /dev/null +++ b/src/badges/BadgeCounterContext.js @@ -0,0 +1,3 @@ +import { createContext } from "react"; + +export const BadgeCounterContext = createContext(0); diff --git a/src/badges/Badges.js b/src/badges/Badges.js new file mode 100644 index 000000000..598f5030b --- /dev/null +++ b/src/badges/Badges.js @@ -0,0 +1,121 @@ +import React, { useContext, useEffect, useState } from "react"; +import { APIContext } from "../contexts/APIContext"; +import * as s from "./Badges.sc.js"; + +export default function Badges({ username }) { + const api = useContext(APIContext); + + const iconBasePath = "static/badges/"; + const defaultLogoPath = "static/images/zeeguuLogo.svg"; + + const [levels, setLevels] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchBadgesCallback = (data) => { + if (!data || data.error) { + setError(data?.error || "Could not load badges."); + setIsLoading(false); + return; + } + + const allLevels = []; + let hasNewBadges = false; + + data.forEach((badge) => { + badge.levels.forEach((lvl) => { + const level = { + ...lvl, + badgeName: badge.name, + current_value: badge.current_value, + description: lvl.description + ? lvl.description.replace("{target_value}", lvl.target_value) + : badge.description.replace("{target_value}", lvl.target_value), + }; + + if (level.achieved && !level.is_shown) { + hasNewBadges = true; + } + + allLevels.push(level); + }); + }); + + setLevels(allLevels); + + if (!username && hasNewBadges) { + api.updateNotShownForUser(); + } + + setIsLoading(false); + setError(null); + }; + + useEffect(() => { + setIsLoading(true); + setError(null); + + if (username) { + api.getBadgesForFriend(username, fetchBadgesCallback); + } else { + api.getBadgesForUser(fetchBadgesCallback); + } + }, [api, username]); + + const getIcon = (level) => (level.icon_name ? iconBasePath + level.icon_name : defaultLogoPath); + + const iconStyle = (achieved) => ({ + filter: achieved ? "none" : "grayscale(100%)", + opacity: achieved ? 1 : 0.5, + }); + + const formatDateTime = (iso) => + iso + ? new Date(iso) + .toLocaleString(undefined, { + dateStyle: "short", + timeStyle: "short", + }) + .replace(",", "") + : "—"; + + return ( + <> + {isLoading &&

Loading badges...

} + {!isLoading && error &&

{error}

} + + {!isLoading && !error && ( + + {levels.map((level, index) => ( + + {!username && !level.is_shown && level.achieved && NEW} + + + + {level.name || `Level ${level.badge_level}`} + {level.description} + + {level.achieved ? ( + {formatDateTime(level.achieved_at)} + ) : ( + + + + + + + {level.current_value} / {level.target_value} + + + )} + + ))} + + )} + + ); +} diff --git a/src/badges/Badges.sc.js b/src/badges/Badges.sc.js new file mode 100644 index 000000000..49de6fbb2 --- /dev/null +++ b/src/badges/Badges.sc.js @@ -0,0 +1,144 @@ +import styled from "styled-components"; +import { orange500, zeeguuOrange } from "../components/colors"; + +export const BadgeContainer = styled.div` + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 2.5rem; + padding: 2rem; + + @media (max-width: 768px) { + gap: 1rem; + padding: 1rem; + } +`; + +export const BadgeCard = styled.div` + position: relative; + overflow: visible; + display: flex; + flex-direction: column; + align-items: center; + + min-width: 140px; + padding: 1.2em; + + background: var(--card-bg); + border-radius: 8px; + + box-shadow: 0 1px 3px var(--shadow-color); + transition: transform 0.15s ease; + gap: 10px; + cursor: default; + + @media (max-width: 768px) { + min-width: auto; + padding: 0.8em; + gap: 6px; + + .icon-container { + margin-bottom: 0.25rem; + } + + h3 { + font-size: 0.9rem; + margin: 3px 0; + } + } +`; + +export const IconContainer = styled.div` + width: 80px; + height: 80px; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 0.5rem; +`; + +export const BadgeIcon = styled.img` + width: 80px; + height: 80px; + object-fit: contain; +`; + +export const BadgeTitle = styled.h3` + text-align: center; + font-size: 0.9rem; + margin: 6px 0; + color: var(--text-primary); +`; + +export const BadgeDescription = styled.div` + text-align: center; + font-size: 0.85rem; + color: var(--text-secondary); +`; + +export const AchievedAtBox = styled.div` + margin-top: auto; + display: inline-block; + background-color: var(--achieved-bg); + color: var(--text-secondary); + padding: 0.3rem 0.6rem; + border-radius: 12px; + font-size: 0.85rem; + + @media (max-width: 768px) { + padding: 0.2rem 0.4rem; + } +`; + +export const ProgressWrapper = styled.div` + width: 100%; + margin-top: auto; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +`; + +export const ProgressBar = styled.div` + width: 60%; + height: 10px; + background-color: var(--progress-bar-bg); + border-radius: 6px; + overflow: hidden; + + @media (max-width: 768px) { + height: 6px; + } +`; + +export const ProgressFill = styled.div` + height: 100%; + background-color: ${zeeguuOrange}; + transition: width 0.3s ease; +`; + +export const ProgressText = styled.div` + margin-top: 6px; + font-size: 0.85rem; + text-align: right; + color: var(--text-muted); + + @media (max-width: 768px) { + margin-top: 3px; + } +`; + +export const NewTag = styled.div` + position: absolute; + top: 0; + right: 0; + z-index: 2; + + background: ${orange500}; + color: var(--card-bg); + + padding: 0.2em 0.6em; + font-size: 0.7rem; + font-weight: 700; + + border-radius: 0 8px 0 8px; +`; diff --git a/src/components/CenteredContainer.js b/src/components/CenteredContainer.js new file mode 100644 index 000000000..68f3b22b9 --- /dev/null +++ b/src/components/CenteredContainer.js @@ -0,0 +1,17 @@ +// src/components/CenteredContainer.js +import React from "react"; + +const CenteredContainer = ({ children }) => ( +
+ {children} +
+); + +export default CenteredContainer; \ No newline at end of file diff --git a/src/components/DynamicFlagImage.js b/src/components/DynamicFlagImage.js index 5b7aa1f60..ede539eb3 100644 --- a/src/components/DynamicFlagImage.js +++ b/src/components/DynamicFlagImage.js @@ -1,7 +1,7 @@ import * as s from "./DynamicFlagImage.sc"; -export default function DynamicFlagImage({ languageCode }) { +export default function DynamicFlagImage({ languageCode, size }) { return ( - + ); } diff --git a/src/components/DynamicFlagImage.sc.js b/src/components/DynamicFlagImage.sc.js index 4c82ce8a9..004191f21 100644 --- a/src/components/DynamicFlagImage.sc.js +++ b/src/components/DynamicFlagImage.sc.js @@ -1,8 +1,8 @@ import styled from "styled-components"; const DynamicFlagImage = styled.img` - width: 1.75rem; - height: 1.75rem; + width: ${({ $size }) => $size ?? "1.75rem"}; + height: ${({ $size }) => $size ?? "1.75rem"}; vertical-align: middle; border-radius: 50%; object-fit: cover; diff --git a/src/components/FeedbackConstants.js b/src/components/FeedbackConstants.js index 16c48fa13..dda4d66cf 100644 --- a/src/components/FeedbackConstants.js +++ b/src/components/FeedbackConstants.js @@ -11,6 +11,7 @@ const FEEDBACK_CATEGORIES = [ { id: 6, name: "Extension", key: "EXTENSION", urlPatterns: [] }, { id: 7, name: "Other", key: "OTHER", urlPatterns: [] }, { id: 8, name: "Daily Audio", key: "DAILY_AUDIO", urlPatterns: ["/daily-audio"] }, + { id: 9, name: "Profile", key: "PROFILE", urlPatterns: ["/profile"] }, ]; // Auto-generated from single source of truth diff --git a/src/components/MainNav/BottomNav/MoreOptionsPanel.js b/src/components/MainNav/BottomNav/MoreOptionsPanel.js index aa0f0e96e..0b789d500 100644 --- a/src/components/MainNav/BottomNav/MoreOptionsPanel.js +++ b/src/components/MainNav/BottomNav/MoreOptionsPanel.js @@ -1,4 +1,4 @@ -import { useContext, useState, useMemo } from "react"; +import { useContext, useMemo, useState } from "react"; import { UserContext } from "../../../contexts/UserContext"; import { MainNavContext } from "../../../contexts/MainNavContext"; import { useLocation } from "react-router-dom/cjs/react-router-dom"; @@ -10,6 +10,7 @@ import NavIcon from "../NavIcon"; import LanguageModal from "../LanguageModal"; import navLanguages from "../navLanguages"; import * as s from "./MoreOptionsPanel.sc"; +import SideNavProfileOption from "../SideNav/SideNavProfileOption"; export default function MoreOptionsPanel({ overlayTransition, @@ -46,10 +47,7 @@ export default function MoreOptionsPanel({ }} > - + @@ -57,23 +55,11 @@ export default function MoreOptionsPanel({ {isOnStudentSide && ( <> - + - + - + {userDetails.is_teacher && ( + )} {isOnStudentSide && ( @@ -102,13 +84,10 @@ export default function MoreOptionsPanel({ /> )} - + + {isOnStudentSide && } diff --git a/src/components/MainNav/NavIcon.js b/src/components/MainNav/NavIcon.js index b7d206b09..27aa90cdc 100644 --- a/src/components/MainNav/NavIcon.js +++ b/src/components/MainNav/NavIcon.js @@ -18,6 +18,7 @@ import ArticleIcon from '@mui/icons-material/Article'; import SchoolIcon from "@mui/icons-material/School"; import AbcIcon from "@mui/icons-material/Abc"; import MenuBookRoundedIcon from "@mui/icons-material/MenuBookRounded"; +import PeopleAltRoundedIcon from "@mui/icons-material/PeopleAltRounded"; export default function NavIcon({ name, color, size }) { @@ -49,7 +50,8 @@ const iconProps = { language: , headerArticles: , headerStreak: , - school: + school: , + profile: , }; return navIcons[name] || ""; } diff --git a/src/components/MainNav/NavOption.js b/src/components/MainNav/NavOption.js index d26a4b62b..1533bfad9 100644 --- a/src/components/MainNav/NavOption.js +++ b/src/components/MainNav/NavOption.js @@ -15,6 +15,7 @@ export default function NavOption({ screenWidth, ariaHasPopup, ariaLabel, + overflowEnabled = false, }) { const Component = linkTo ? s.RouterLink : s.OptionButton; const isActive = isNavOptionActive(linkTo, currentPath); @@ -33,14 +34,14 @@ export default function NavOption({ aria-haspopup={ariaHasPopup} aria-label={ariaLabel} > - + {icon && ( {icon} {isCollapsed && notification && React.cloneElement(notification, { isActive, position: "top-absolute", sidebar: false })} )} - {text} + {text} {!isCollapsed && notification && React.cloneElement(notification, { isActive })} diff --git a/src/components/MainNav/NavOption.sc.js b/src/components/MainNav/NavOption.sc.js index aac694d51..cbeddd932 100644 --- a/src/components/MainNav/NavOption.sc.js +++ b/src/components/MainNav/NavOption.sc.js @@ -104,6 +104,12 @@ const TextWrapper = styled.span` opacity: 0; width: 0; `} + + ${({ $overflowEnabled }) => + $overflowEnabled && + css` + min-width: 0; + `} `; const IconContainer = styled.span` @@ -123,10 +129,10 @@ const OptionContentWrapper = styled.span` display: flex; flex-direction: row; align-items: center; - justify-content: center; + justify-content: ${({ $overflowEnabled }) => ($overflowEnabled ? "left" : "center")}; gap: 0.76rem; - width: fit-content; - padding: 0 1.8rem 0 0; + width: "fit-content"; + padding: ${({ $overflowEnabled }) => ($overflowEnabled ? "0 0.5rem 0 0" : "0 1.8rem 0 0")}; height: 2rem; ${({ $screenWidth }) => @@ -134,6 +140,19 @@ const OptionContentWrapper = styled.span` css` padding: 0; `} + + ${({ $overflowEnabled }) => + $overflowEnabled && + css` + max-width: 100%; + `} + + ${({ $screenWidth, $overflowEnabled }) => + $screenWidth <= MEDIUM_WIDTH && + $overflowEnabled && + css` + gap: 0; + `} `; export { diff --git a/src/components/MainNav/SideNav/SideNav.js b/src/components/MainNav/SideNav/SideNav.js index 2e6689229..3ae67d2c5 100644 --- a/src/components/MainNav/SideNav/SideNav.js +++ b/src/components/MainNav/SideNav/SideNav.js @@ -4,6 +4,7 @@ import { UserContext } from "../../../contexts/UserContext"; import { MainNavContext } from "../../../contexts/MainNavContext"; import SideNavOptionsForStudent from "./SideNavOptionsForStudent"; import SideNavOptionsForTeacher from "./SideNavOptionsForTeacher"; +import SideNavProfileOption from "./SideNavProfileOption"; import SideNavProgressStats from "./SideNavProgressStats"; import NavOption from "../NavOption"; import FeedbackButton from "../../FeedbackButton"; @@ -50,6 +51,7 @@ export default function SideNav({ screenWidth }) { /> + {isOnStudentSide && } {isOnStudentSide && } diff --git a/src/components/MainNav/SideNav/SideNav.sc.js b/src/components/MainNav/SideNav/SideNav.sc.js index 3537ebe6d..f5f67db73 100644 --- a/src/components/MainNav/SideNav/SideNav.sc.js +++ b/src/components/MainNav/SideNav/SideNav.sc.js @@ -12,7 +12,7 @@ const BottomSection = styled.div` flex-shrink: 0; box-sizing: border-box; background-color: ${({ theme }) => theme.navBg}; - padding: 1rem 0.5rem 1rem 0.5rem; + padding: 1rem 0; `; const SideNav = styled.nav` diff --git a/src/components/MainNav/SideNav/SideNavProfileOption.js b/src/components/MainNav/SideNav/SideNavProfileOption.js new file mode 100644 index 000000000..8480db4dd --- /dev/null +++ b/src/components/MainNav/SideNav/SideNavProfileOption.js @@ -0,0 +1,81 @@ +import { useContext, useEffect, useState } from "react"; +import { useLocation } from "react-router-dom/cjs/react-router-dom"; +import styled from "styled-components"; + +import { UserContext } from "../../../contexts/UserContext"; +import NavOption from "../NavOption"; +import NavigationOptions, { isNavOptionActive } from "../navigationOptions"; + +import { AvatarBackground, AvatarImage } from "../../../profile/UserProfile.sc"; +import { + AVATAR_IMAGE_MAP, + validatedAvatarBackgroundColor, + validatedAvatarCharacterColor, + validatedAvatarCharacterId, +} from "../../../profile/avatarOptions"; +import { BadgeCounterContext } from "../../../badges/BadgeCounterContext"; +import NotificationIcon from "../../../components/NotificationIcon"; +import { FriendRequestContext } from "../../../contexts/FriendRequestContext"; +import Feature from "../../../features/Feature"; + +const NavAvatar = styled(AvatarBackground)` + width: 2rem; + height: 2rem; + padding: 3px; +`; + +export default function SideNavProfileOption({ screenWidth }) { + const { userDetails } = useContext(UserContext); + const path = useLocation().pathname; + const isActive = isNavOptionActive(NavigationOptions.profile.linkTo, path); + const { hasBadgeNotification, totalNumberOfBadges } = useContext(BadgeCounterContext); + const { hasFriendRequestNotification, friendRequestCount } = useContext(FriendRequestContext); + const [avatarCharacterId, setAvatarCharacterId] = useState(); + const [avatarCharacterColor, setAvatarCharacterColor] = useState(); + const [avatarBackgroundColor, setAvatarBackgroundColor] = useState(); + + useEffect(() => { + setAvatarCharacterId(validatedAvatarCharacterId(userDetails.user_avatar?.image_name)); + setAvatarCharacterColor(validatedAvatarCharacterColor(userDetails.user_avatar?.character_color)); + setAvatarBackgroundColor(validatedAvatarBackgroundColor(userDetails.user_avatar?.background_color)); + }, [userDetails]); + + if (!Feature.has_gamification()) { + return null; + } + + return ( + + + + } + text={ +
+ {userDetails?.username || "Profile"} +
+ } + currentPath={path} + screenWidth={screenWidth} + notification={ + (hasBadgeNotification || hasFriendRequestNotification) && ( + + ) + } + /> + ); +} diff --git a/src/components/MainNav/navigationOptions.js b/src/components/MainNav/navigationOptions.js index 60e8eb4a7..216674023 100644 --- a/src/components/MainNav/navigationOptions.js +++ b/src/components/MainNav/navigationOptions.js @@ -104,4 +104,9 @@ export default class NavigationOptions { icon: , text: strings.settings, }); + + static profile = Object.freeze({ + linkTo: "/profile", + text: strings.titleOwnProfile, + }); } diff --git a/src/components/SearchBar.js b/src/components/SearchBar.js new file mode 100644 index 000000000..c6fc4fbcb --- /dev/null +++ b/src/components/SearchBar.js @@ -0,0 +1,25 @@ +import React from "react"; +import * as s from "./SearchBar.sc"; + +const SearchBar = ({ value, onChange, placeholder, onSearch }) => { + const handleKeyUp = (e) => { + if (e.key === "Enter") { + onSearch && onSearch(); + } + }; + + return ( + + + + ); +}; + +export default SearchBar; diff --git a/src/components/SearchBar.sc.js b/src/components/SearchBar.sc.js new file mode 100644 index 000000000..eddace5c7 --- /dev/null +++ b/src/components/SearchBar.sc.js @@ -0,0 +1,18 @@ +import styled from "styled-components"; +import { Input } from "./InputField.sc"; +import { zeeguuOrange } from "./colors"; + +export const SearchBarContainer = styled.div` + display: flex; + gap: 0.5em; + width: 100%; + max-width: 400px; + margin-bottom: 1em; +`; + +export const SearchInput = styled(Input)` + &:focus { + outline: transparent; + border: 1.5px solid ${zeeguuOrange} + } +`; diff --git a/src/components/Selector.sc.js b/src/components/Selector.sc.js index da4a03874..d5366a2b8 100644 --- a/src/components/Selector.sc.js +++ b/src/components/Selector.sc.js @@ -36,9 +36,9 @@ const Select = styled.select` box-sizing: border-box; height: 2.69rem; appearance: none; - border: 1.5px solid ${lightGrey}; + border: 1.5px solid var(--input-border); border-radius: 0.3rem; - background-color: transparent; + background-color: var(--input-bg); padding: 0 2.25rem 0 1rem; margin: 0; width: 100%; diff --git a/src/components/UserBaseInfo.js b/src/components/UserBaseInfo.js new file mode 100644 index 000000000..5be7fce2d --- /dev/null +++ b/src/components/UserBaseInfo.js @@ -0,0 +1,35 @@ +import React, { useEffect, useState } from "react"; +import { AvatarImage } from "../profile/UserProfile.sc"; +import { + AVATAR_IMAGE_MAP, + validatedAvatarBackgroundColor, + validatedAvatarCharacterColor, + validatedAvatarCharacterId, +} from "../profile/avatarOptions"; +import * as s from "./UserBaseInfo.sc"; + +export default function UserBaseInfo({ user }) { + const [selectedAvatarCharacterId, setSelectedAvatarCharacterId] = useState(); + const [selectedAvatarCharacterColor, setSelectedAvatarCharacterColor] = useState(); + const [selectedAvatarBackgroundColor, setSelectedAvatarBackgroundColor] = useState(); + + useEffect(() => { + if (user) { + setSelectedAvatarCharacterId(validatedAvatarCharacterId(user.avatar?.image_name)); + setSelectedAvatarCharacterColor(validatedAvatarCharacterColor(user.avatar?.character_color)); + setSelectedAvatarBackgroundColor(validatedAvatarBackgroundColor(user.avatar?.background_color)); + } + }, [user]); + + return ( + <> + + + + + {user?.username} + {user?.name && ({user.name})} + + + ); +} diff --git a/src/components/UserBaseInfo.sc.js b/src/components/UserBaseInfo.sc.js new file mode 100644 index 000000000..fcb0bc12a --- /dev/null +++ b/src/components/UserBaseInfo.sc.js @@ -0,0 +1,37 @@ +import styled from "styled-components"; +import { AvatarBackground} from "../profile/UserProfile.sc"; + +export const Avatar = styled(AvatarBackground)` + width: 2.5rem; + height: 2.5rem; + padding: 3px; +`; + +export const UserNameWrapper = styled.div` + display: flex; + gap: 0.1rem 1rem; + min-width: 0; + flex-wrap: wrap; + + @media (max-width: 768px) { + flex-direction: column; + } +`; + +export const Username = styled.span` + font-weight: 600; + margin-left: 0.5rem; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + max-width: 100%; +`; + +export const Name = styled.span` + margin-left: 0.25rem; + color: #777; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + max-width: 100%; +`; \ No newline at end of file diff --git a/src/components/progress_tracking/ProgressItems.sc.js b/src/components/progress_tracking/ProgressItems.sc.js index fad69951c..22e31d55b 100644 --- a/src/components/progress_tracking/ProgressItems.sc.js +++ b/src/components/progress_tracking/ProgressItems.sc.js @@ -91,7 +91,6 @@ const ProgressItemsContainer = styled.div` flex-direction: column; width: 70%; padding-bottom: 1em; - margin-left: 10em; @media (max-width: 768px) { width: 80%; diff --git a/src/config.js b/src/config.js index 936a65dc0..593208dd4 100644 --- a/src/config.js +++ b/src/config.js @@ -1,7 +1,7 @@ // Development configuration for local testing const isDev = import.meta.env.DEV; const WEB_URL = isDev ? "http://localhost:3000" : "https://www.zeeguu.org"; -const API_URL = isDev ? "http://localhost:9001" : "https://api.zeeguu.org"; +const API_URL = isDev ? "http://localhost:8080" : "https://api.zeeguu.org"; const WEB_LOGIN_URL = `${WEB_URL}/log_in`; export { WEB_URL, API_URL, WEB_LOGIN_URL, isDev }; diff --git a/src/contexts/FriendRequestContext.js b/src/contexts/FriendRequestContext.js new file mode 100644 index 000000000..f60e73d11 --- /dev/null +++ b/src/contexts/FriendRequestContext.js @@ -0,0 +1,7 @@ +import { createContext } from "react"; + +export const FriendRequestContext = createContext({ + friendRequestCount: 0, + hasFriendRequestNotification: false, + updateFriendRequestCounter: () => {}, +}); diff --git a/src/features/Feature.js b/src/features/Feature.js index 50cef6268..bf2fd2914 100644 --- a/src/features/Feature.js +++ b/src/features/Feature.js @@ -20,6 +20,10 @@ const Feature = { return this.is_enabled("audio_exercises"); }, + has_gamification: function () { + return this.is_enabled("gamification"); + }, + no_audio_exercises: function () { return this.is_enabled("no_audio_exercises"); }, diff --git a/src/friends/FriendRow.js b/src/friends/FriendRow.js new file mode 100644 index 000000000..5b13fc8b1 --- /dev/null +++ b/src/friends/FriendRow.js @@ -0,0 +1,151 @@ +import Stack from "@mui/material/Stack"; +import LocalFireDepartmentIcon from "@mui/icons-material/LocalFireDepartment"; +import PersonAddIcon from "@mui/icons-material/PersonAdd"; +import SendIcon from "@mui/icons-material/Send"; +import PersonIcon from "@mui/icons-material/Person"; +import CancelScheduleSendIcon from "@mui/icons-material/CancelScheduleSend"; +import CheckIcon from "@mui/icons-material/Check"; +import ClearIcon from "@mui/icons-material/Clear"; +import DynamicFlagImage from "../components/DynamicFlagImage"; +import { LanguageOverflowBubble } from "../profile/UserProfile.sc"; +import UserBaseInfo from "../components/UserBaseInfo"; +import * as s from "./FriendRow.sc"; +import useScreenWidth from "../hooks/useScreenWidth"; +import { streakFireOrange } from "../components/colors.js"; + +export default function FriendRow({ + user, + rowType = "friend", + friendRequestAccepted = false, + isSending = false, + isSent = false, + onSendRequest, + onCancelRequest, + onAcceptRequest, + onRejectRequest, + onViewProfile, +}) { + const { isMobile } = useScreenWidth(); + const maxVisibleLanguages = isMobile ? 1 : 3; + const languages = user.languages ?? [] + const visibleLanguages = languages.slice(0, maxVisibleLanguages); + const overflowCount = languages.length > maxVisibleLanguages ? languages.length - maxVisibleLanguages : 0; + + const renderActions = () => { + if (rowType === "view-only" || rowType === "friend") return null; + + if (rowType === "search") { + if (user.friendship) { + return ( + + + Already friends + + ); + } + + if (user.friend_request) { + if (user.friend_request.sender.username === user.username) { + return They sent you a request; + } + return ( + onCancelRequest?.(event, user.username)}> + + Cancel + + ); + } + + if (isSent) { + return ( + onCancelRequest?.(event, user.username)}> + + Cancel + + ); + } + + return ( + onSendRequest?.(event, user.username)} + disabled={isSending} + > + {isSending ? ( + <> + + Sending... + + ) : ( + <> + + Add + + )} + + ); + } + + if (rowType === "request") { + return friendRequestAccepted ? ( + + + Accepted + + ) : ( + <> + onAcceptRequest?.(event, user.username)}> + + Accept + + onRejectRequest?.(event, user.username)}> + + Reject + + + ); + } + + return null; + }; + + const actions = renderActions(); + + return ( + onViewProfile?.(user.username)} $clickable={Boolean(onViewProfile)}> + {rowType === "friend" && ( + + + + + + {user.friendship.friend_streak ?? 0} + + )} + + + + {rowType === "friend" && ( + + {visibleLanguages.map((entry) => ( + + ))} + {overflowCount > 0 && ( + +{overflowCount} + )} + + )} + {actions && {actions}} + + ); +} diff --git a/src/friends/FriendRow.sc.js b/src/friends/FriendRow.sc.js new file mode 100644 index 000000000..378989bf0 --- /dev/null +++ b/src/friends/FriendRow.sc.js @@ -0,0 +1,133 @@ +import styled from "styled-components"; + +const actionVariantStyles = { + add: { + lightBg: "#e6f5ff", + lightBorder: "#b7daf6", + lightText: "#1f6fb2", + darkBg: "#20364d", + darkBorder: "#3a628a", + darkText: "#8bc7ff", + }, + cancel: { + lightBg: "#ffe9e7", + lightBorder: "#f5c4be", + lightText: "#b4493f", + darkBg: "#4a2b2b", + darkBorder: "#8e4747", + darkText: "#ff9f94", + }, + accept: { + lightBg: "#e8f8ea", + lightBorder: "#bbe6c0", + lightText: "#2d8a4a", + darkBg: "#223a2b", + darkBorder: "#3f7c52", + darkText: "#87d9a0", + }, + reject: { + lightBg: "#ffe9e7", + lightBorder: "#f5c4be", + lightText: "#b4493f", + darkBg: "#4a2b2b", + darkBorder: "#8e4747", + darkText: "#ff9f94", + }, +}; + +const actionVariantValue = (variant, tone, fallback) => actionVariantStyles[variant]?.[tone] ?? fallback; + +export const FriendRowLi = styled.li` + display: flex; + align-items: center; + gap: 1em; + padding: 0.5em 0; + cursor: ${({ $clickable }) => ($clickable ? "pointer" : "default")}; + + &:hover:not(:has(button:hover)) { + background: var(--row-hover-bg); + } + + @media (max-width: 768px) { + font-size: 0.85rem; + } +`; + +export const LanguagesMeta = styled.div` + display: flex; + align-items: center; + gap: 0.35rem; + margin-left: auto; +`; + +export const StreakContainer = styled.span` + display: flex; + align-items: center; + gap: 0.3em; + color: #d97f00; + font-weight: 500; + + :root[data-theme="dark"] & { + color: #ffb74d; + } +`; + +export const ActionsContainer = styled.div` + margin-left: auto; + display: flex; + align-items: center; + gap: 0.5em; +`; + +export const AlreadyFriends = styled.span` + color: #2d8a4a; + display: flex; + align-items: center; + gap: 0.4em; + + :root[data-theme="dark"] & { + color: #87d9a0; + } +`; + +export const RequestSent = styled.span` + color: #b56b00; + + :root[data-theme="dark"] & { + color: #ffbb54; + } +`; + +export const FriendActionButton = styled.button` + font-size: 0.9rem; + min-width: 7rem; + padding: 0.5em 0.8em; + border-radius: 4px; + border: 1px solid ${({ $variant }) => actionVariantValue($variant, "lightBorder", "var(--border-light)")}; + display: flex; + justify-content: center; + align-items: center; + gap: 0.4em; + background: ${({ $variant }) => actionVariantValue($variant, "lightBg", "var(--card-bg)")}; + color: ${({ $variant }) => actionVariantValue($variant, "lightText", "var(--text-primary)")}; + cursor: ${({ disabled, $variant }) => (disabled || $variant === "accepted" ? "default" : "pointer")}; + transition: + background 0.2s, + border-color 0.2s, + color 0.2s, + opacity 0.2s; + + &:disabled { + opacity: 0.8; + } + + &:hover:not(:disabled) { + filter: brightness(95%); + } + + :root[data-theme="dark"] & { + border-color: ${({ $variant }) => actionVariantValue($variant, "darkBorder", "var(--border-light)")}; + background: ${({ $variant }) => actionVariantValue($variant, "darkBg", "var(--card-bg)")}; + color: ${({ $variant }) => actionVariantValue($variant, "darkText", "var(--text-primary)")}; + } +`; diff --git a/src/friends/Friends.js b/src/friends/Friends.js new file mode 100644 index 000000000..4e714fdc7 --- /dev/null +++ b/src/friends/Friends.js @@ -0,0 +1,320 @@ +import { useContext, useEffect, useState } from "react"; +import { toast } from "react-toastify"; +import SearchBar from "../components/SearchBar"; +import { APIContext } from "../contexts/APIContext"; +import FriendRow from "./FriendRow"; +import { FriendRequestContext } from "../contexts/FriendRequestContext"; + +export default function Friends({ friendUsername, navigationHandler }) { + const api = useContext(APIContext); + const [friends, setFriends] = useState([]); + const [loadingFriends, setLoadingFriends] = useState(true); + const [friendsError, setFriendsError] = useState(null); + + const [friendRequests, setFriendRequests] = useState([]); + const [loadingRequests, setLoadingRequests] = useState(true); + const [requestsError, setRequestsError] = useState(null); + + const [pendingSearch, setPendingSearch] = useState(""); + const [searchResults, setSearchResults] = useState([]); + const [newFriendError, setNewFriendError] = useState(null); + const [searchingNewFriends, setSearchingNewFriends] = useState(null); + const [sendingRequestUsername, setSendingRequestUsername] = useState(null); + const [sentRequests, setSentRequests] = useState([]); + + // Friend-of-friend state (used when viewing someone else's profile) + const [friendsFriends, setFriendsFriends] = useState([]); + const [loadingFriendsFriends, setLoadingFriendsFriends] = useState(false); + const [friendsFriendsError, setFriendsFriendsError] = useState(null); + + const { updateFriendRequestCounter } = useContext(FriendRequestContext); + + useEffect(() => { + if (friendUsername) return; + api.getFriends((data) => { + if (!data) { + setFriendsError("Failed to fetch friends"); + setLoadingFriends(false); + return; + } + setFriends(data); + setLoadingFriends(false); + }); + }, [api, friendUsername]); + + useEffect(() => { + if (friendUsername) return; + api.getReceivedFriendRequests((data) => { + if (!data) { + setRequestsError("Failed to fetch friend requests"); + setLoadingRequests(false); + return; + } + setFriendRequests(data); + setLoadingRequests(false); + }); + }, [api, friendUsername]); + + useEffect(() => { + if (!friendUsername) return; + setLoadingFriendsFriends(true); + setFriendsFriendsError(null); + api.getFriendsForUser(friendUsername, (data) => { + if (!data) { + setFriendsFriendsError("Failed to fetch friends."); + setLoadingFriendsFriends(false); + return; + } + setFriendsFriends(data); + setLoadingFriendsFriends(false); + }); + }, [api, friendUsername]); + + useEffect(() => { + if (pendingSearch === "") { + setSearchResults([]); + setNewFriendError(null); + setSearchingNewFriends(null); + } + const searchTimeout = handlePendingSearchChange(false); + return () => clearTimeout(searchTimeout); + }, [pendingSearch, api]); + + const handlePendingSearchChange = (isEnterSearch) => { + const query = pendingSearch.trim(); + + if (query.length < 2 && !isEnterSearch) { + setSearchResults([]); + setNewFriendError(null); + setSearchingNewFriends(null); + return; + } + + return setTimeout(() => { + setSearchingNewFriends(true); + setNewFriendError(null); + + api.searchUsers(query, (results) => { + setSearchingNewFriends(false); + + if (!results) { + setNewFriendError("Failed to search for users"); + setSearchResults([]); + return; + } + + setSearchResults(results); + }); + }, 300); + }; + + const handleSendFriendRequest = (event, receiverUsername) => { + event.stopPropagation(); + setSendingRequestUsername(receiverUsername); + api + .sendFriendRequest(receiverUsername) + .then((response) => { + setSendingRequestUsername(null); + if (response.status === 200) { + setSentRequests((prev) => [...prev, receiverUsername]); + } else { + response.json().then((json) => { + toast.error(json.error || "Failed to send friend request."); + }); + } + }) + .catch(() => { + setSendingRequestUsername(null); + toast.error("Failed to send friend request."); + }); + }; + + const handleCancelFriendRequest = (event, receiverUsername) => { + event.stopPropagation(); + api + .deleteFriendRequest(receiverUsername) + .then((response) => { + if (response.status === 200) { + setSentRequests((prev) => prev.filter((username) => username !== receiverUsername)); + setSearchResults((prev) => + prev.map((user) => + user.username === receiverUsername + ? { + ...user, + friend_request: null, + friendship: null, + } + : user, + ), + ); + } else { + response.json().then((json) => { + toast.error(json.message || "Failed to cancel friend request."); + }); + } + }) + .catch(() => { + toast.error("Failed to cancel friend request."); + }); + }; + + const handleAcceptFriendRequest = (event, senderUsername) => { + event.stopPropagation(); + api + .acceptFriendRequest(senderUsername) + .then((response) => { + if (response.status === 200) { + setFriendRequests((prev) => + prev.map((req) => + req.sender.username === senderUsername ? { ...req, is_accepted: true } : req, + ), + ); + updateFriendRequestCounter(); + } else { + response.json().then((json) => { + toast.error(json.message || "Failed to accept friend request."); + }); + } + }) + .catch(() => { + toast.error("Failed to accept friend request."); + }); + }; + + const handleRejectFriendRequest = (event, senderUsername) => { + event.stopPropagation(); + api + .rejectFriendRequest(senderUsername) + .then((response) => { + if (response.status === 200) { + setFriendRequests((prev) => prev.filter((req) => req.sender.username !== senderUsername)); + updateFriendRequestCounter(); + } else { + response.json().then((json) => { + toast.error(json.message || "Failed to reject friend request."); + }); + } + }) + .catch(() => { + toast.error("Failed to reject friend request."); + }); + }; + + const handleViewFriendProfile = (friendUsername) => { + if (!friendUsername || !navigationHandler) { + return; + } + navigationHandler(friendUsername); + }; + + return ( +
+ {/* Friend-of-friend read-only view */} + {friendUsername ? ( + <> +

Friends

+ {loadingFriendsFriends &&

Loading friends...

} + {friendsFriendsError &&

{friendsFriendsError}

} + {!loadingFriendsFriends && !friendsFriendsError && friendsFriends.length === 0 && ( +

This user has no friends yet.

+ )} + {friendsFriends.length > 0 && ( +
    + {friendsFriends.map((friend, index) => ( + + ))} +
+ )} + + ) : ( +
+
+ setPendingSearch(e.target.value)} + placeholder="Search for users..." + onSearch={() => handlePendingSearchChange(true)} + /> +
+ + {newFriendError &&

{newFriendError}

} + + {/* Only show Users section when searching */} + {pendingSearch ? ( +
+

Users

+ {searchResults.length === 0 && searchingNewFriends === null && ( +

Continue typing or press Enter to search...

+ )} + {searchResults.length === 0 && searchingNewFriends === true &&

Searching...

} + {searchResults.length === 0 && searchingNewFriends === false &&

No users...

} + {searchResults.length > 0 && ( +
    + {searchResults.map((searchResult, index) => { + return ( + + ); + })} +
+ )} +
+ ) : ( + <> + {/* Friend Requests section only if there are requests */} + {!loadingRequests && friendRequests.length > 0 && ( +
+

Friend Requests

+ {requestsError &&

{requestsError}

} +
    + {friendRequests.map((req, index) => ( + + ))} +
+
+ )} + +

Friends

+ {loadingFriends &&

Loading friends...

} + {friendsError &&

{friendsError}

} + {!loadingFriends && !friendsError && friends.length === 0 &&

You have no friends yet.

} + {!loadingFriends && friends.length > 0 && ( +
    + {friends.map((friend, index) => ( + + ))} +
+ )} + + )} +
+ )} +
+ ); +} diff --git a/src/hooks/useBadgeCounterNotification.js b/src/hooks/useBadgeCounterNotification.js new file mode 100644 index 000000000..37e4afe9d --- /dev/null +++ b/src/hooks/useBadgeCounterNotification.js @@ -0,0 +1,30 @@ +import { useContext, useEffect, useState } from "react"; +import { useLocation } from "react-router-dom/cjs/react-router-dom"; +import { APIContext } from "../contexts/APIContext"; + +export default function useBadgeCounterNotification() { + const path = useLocation().pathname; + const api = useContext(APIContext); + + const [hasBadgeNotification, setHasBadgeNotification] = useState(false); + const [totalNumberOfBadges, setTotalNumberOfBadges] = useState(0); + + useEffect(() => { + updateBadgeCounter(); + + // eslint-disable-next-line + }, [path]); + + function updateBadgeCounter() { + api.getNotShownUserBadges((badgeCount) => { + setTotalNumberOfBadges(badgeCount); + + setHasBadgeNotification(badgeCount >= 1); + }); + } + + return { + hasBadgeNotification, + totalNumberOfBadges, + }; +} diff --git a/src/hooks/useFriendRequestNotification.js b/src/hooks/useFriendRequestNotification.js new file mode 100644 index 000000000..ed112467d --- /dev/null +++ b/src/hooks/useFriendRequestNotification.js @@ -0,0 +1,28 @@ +import { useContext, useEffect, useState } from "react"; +import { useLocation } from "react-router-dom/cjs/react-router-dom"; +import { APIContext } from "../contexts/APIContext"; + +export default function useFriendRequestNotification() { + const path = useLocation().pathname; + const api = useContext(APIContext); + + const [friendRequestCount, setFriendRequestCount] = useState(0); + const hasFriendRequestNotification = friendRequestCount > 0; + + useEffect(() => { + updateFriendRequestCounter(); + // eslint-disable-next-line + }, [path]); + + function updateFriendRequestCounter() { + api.getNumberOfReceivedFriendRequests((count) => { + setFriendRequestCount(count); + }); + } + + return { + friendRequestCount, + hasFriendRequestNotification, + updateFriendRequestCounter, + }; +} diff --git a/src/i18n/definitions.js b/src/i18n/definitions.js index 90848e461..8add73e76 100644 --- a/src/i18n/definitions.js +++ b/src/i18n/definitions.js @@ -35,6 +35,10 @@ let strings = new LocalizedStrings( emailPlaceholder: "example@email.com", zeeguuTeamEmail: "zeeguu.team@gmail.com", name: "Name", + displayName: "Display Name", + displayNamePlaceholder: "Your name", + username: "Username", + usernamePlaceholder: "Your username", fullName: "Full Name", fullNamePlaceholder: "First and last name", learnedLanguage: "I want to learn", @@ -583,6 +587,11 @@ let strings = new LocalizedStrings( wordsIncorrect: "Words you didn't get correct in exercises", wordsCorrect: "Words you got correct in exercises", + // Profile + titleOwnProfile: "Your Profile", + titleUserProfilePostfix: "Profile", + titleUserProfileDefault: "User's Profile", + //Settings //Settings categories myAccount: "My Account", @@ -909,6 +918,10 @@ let strings = new LocalizedStrings( exercisesInTheLast: " exercises in the last ", wordsNotStudiedInZeeguu: "Words Not Studied in Zeeguu", systemLanguage: "System Language", + + // Badges + badges: "Badges", + myBadges: "My Badges", }, da: { diff --git a/src/index.css b/src/index.css index bb49d7078..3ab87fa23 100644 --- a/src/index.css +++ b/src/index.css @@ -39,8 +39,10 @@ --action-btn-secondary-color-text: #ffffff; --badge-bg: #e3f2fd; + --light-badge-bg: #e3f2fd; --badge-text: hsl(216, 100%, 57%); --tag-bg: hsl(216, 100%, 97%); + --light-tag-bg: hsl(216, 100%, 97%); --search-tag-bg: #fef3c7; --search-tag-border: #b45309; @@ -57,6 +59,10 @@ --success-bg: #f1f7f2; --success-border: #99e47f; + --achieved-bg: rgba(90,189,92,0.35); + + --row-hover-bg: rgba(255, 187, 84, 0.2); + color-scheme: light; } @@ -100,8 +106,10 @@ --action-btn-secondary-color-text: #3f3360; --badge-bg: #1a2540; + --light-badge-bg: #2c3760; --badge-text: #81b4ff; --tag-bg: #1e2540; + --light-tag-bg: #2f375c; --search-tag-bg: #2a2520; --search-tag-border: #b45309; @@ -131,6 +139,10 @@ --progress-circle-bg: #3a3a55; --progress-circle-border: #50507a; + --achieved-bg: rgba(53, 193, 55, 0.35); + + --row-hover-bg: #353555; + color-scheme: dark; } diff --git a/src/leaderboards/LeaderboardRow.js b/src/leaderboards/LeaderboardRow.js new file mode 100644 index 000000000..bede2865c --- /dev/null +++ b/src/leaderboards/LeaderboardRow.js @@ -0,0 +1,35 @@ +import React from "react"; +import UserBaseInfo from "../components/UserBaseInfo"; +import * as s from "./LeaderboardRow.sc"; + +export default function LeaderboardRow({ + rank, + user, + metrics = [], + emphasizeTopRanks = 3, + highlight = false, + onViewProfile, +}) { + return ( + onViewProfile?.(user.username)} + $clickable={Boolean(onViewProfile)} + > + + {rank} + + + + + {highlight && YOU} + + + {metrics.map((metric) => ( + + {metric.value} + + ))} + + ); +} diff --git a/src/leaderboards/LeaderboardRow.sc.js b/src/leaderboards/LeaderboardRow.sc.js new file mode 100644 index 000000000..8dc14434e --- /dev/null +++ b/src/leaderboards/LeaderboardRow.sc.js @@ -0,0 +1,46 @@ +import styled from "styled-components"; +import { streakFireOrange } from "../components/colors"; + +export const StyledTableRow = styled.tr` + border-bottom: 1px solid #ddd; + color: var(--text-primary); + cursor: ${({ $clickable }) => ($clickable ? "pointer" : "default")}; + + & td { + background: ${({ $highlight }) => ($highlight ? "var(--row-hover-bg)" : "none")}; + } + + &:hover td { + background: var(--row-hover-bg); + } +`; + +export const RankCell = styled.td` + padding: 1em; + text-align: center; + font-weight: ${({ $rank, $emphasizeTopRanks }) => ($rank <= $emphasizeTopRanks ? 600 : 400)}; +`; + +export const UserDataCell = styled.td` + padding: 0.5em; + display: flex; + align-items: center; + gap: 0.5em; + min-width: 0; +`; + +export const SelfLabel = styled.span` + font-size: 0.8em; + font-weight: 600; + padding: 0.2em 0.5em; + border-radius: 6px; + background: ${streakFireOrange}; + color: white; + margin-left: 0.25em; +`; + +export const MetricCell = styled.td` + padding: 0.5em; + text-align: ${({ $textAlign }) => $textAlign ?? "center"}; + color: ${({ $color }) => $color ?? "inherit"}; +`; diff --git a/src/leaderboards/Leaderboards.js b/src/leaderboards/Leaderboards.js new file mode 100644 index 000000000..c3ccf5df9 --- /dev/null +++ b/src/leaderboards/Leaderboards.js @@ -0,0 +1,271 @@ +import React, { useContext, useEffect, useMemo, useState } from "react"; +import ChevronLeftRoundedIcon from "@mui/icons-material/ChevronLeftRounded"; +import ChevronRightRoundedIcon from "@mui/icons-material/ChevronRightRounded"; +import LeaderboardRow from "./LeaderboardRow"; +import { APIContext } from "../contexts/APIContext"; +import { UserContext } from "../contexts/UserContext"; +import * as s from "./Leaderboards.sc"; +import { LEADERBOARD_SCOPES, LEADERBOARD_TYPES } from "./leaderboardTypes"; +import Selector from "../components/Selector"; +import { endOfWeek, getISOWeek, getISOWeekYear, startOfWeek } from "date-fns"; + +function getMetricValue(entry) { + return Number(entry.value || 0); +} + +function formatDateLabel(date) { + return date.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + }); +} + +function computeWeeklyPeriod(weekShift = 0) { + const now = new Date(); + const now_shifted = new Date(now.getTime() + weekShift * 7 * 24 * 60 * 60 * 1000); + + const monday = startOfWeek(now_shifted, { weekStartsOn: 1 }); + const sunday = endOfWeek(now_shifted, { weekStartsOn: 1 }); + + const week = getISOWeek(now_shifted); + const year = getISOWeekYear(now_shifted); + + const pad = (n) => String(n).padStart(2, "0"); + + const fromStr = `${monday.getFullYear()}-${pad(monday.getMonth() + 1)}-${pad(monday.getDate())}T00:00:00`; + const toStr = `${sunday.getFullYear()}-${pad(sunday.getMonth() + 1)}-${pad(sunday.getDate())}T23:59:59`; + + return { + from: monday, + to: sunday, + fromStr, + toStr, + week, + year, + }; +} + +export default function Leaderboards({ + emptyMessage = "No leaderboard data available yet.", + errorMessage = "Could not load leaderboard.", + scope, + leaderboardTypes = LEADERBOARD_TYPES, + navigationHandler, +}) { + const api = useContext(APIContext); + const { userDetails } = useContext(UserContext); + const [periodShiftInWeeks, setPeriodShiftInWeeks] = useState(0); + + const period = useMemo(() => computeWeeklyPeriod(periodShiftInWeeks), [periodShiftInWeeks]); + + const [selectedLeaderboardKey, setSelectedLeaderboardKey] = useState(() => leaderboardTypes[0]?.key || "default"); + const [cohorts, setCohorts] = useState([]); + const [selectedCohort, setSelectedCohort] = useState(null); + const [leaderboardData, setLeaderboardData] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const activeLeaderboard = useMemo( + () => leaderboardTypes.find((item) => item.key === selectedLeaderboardKey) || leaderboardTypes[0], + [leaderboardTypes, selectedLeaderboardKey], + ); + + function handleData(data) { + if (!Array.isArray(data)) { + setError(errorMessage); + setLeaderboardData([]); + setIsLoading(false); + return; + } + + const rankedData = assignRanks(data); + setLeaderboardData(rankedData); + setIsLoading(false); + } + + useEffect(() => { + if (!selectedLeaderboardKey) { + setError(errorMessage); + setLeaderboardData([]); + setIsLoading(false); + return; + } + + setIsLoading(true); + setError(null); + + if (scope === LEADERBOARD_SCOPES.FRIENDS) { + api.getFriendsLeaderboard(selectedLeaderboardKey, period.fromStr, period.toStr, handleData); + } else if (scope === LEADERBOARD_SCOPES.COHORT) { + if (!selectedCohort) { + setLeaderboardData([]); + setIsLoading(false); + return; + } + api.getCohortLeaderboard(selectedCohort, selectedLeaderboardKey, period.fromStr, period.toStr, handleData); + } + }, [api, selectedLeaderboardKey, period.fromStr, period.toStr, scope, selectedCohort]); + + useEffect(() => { + if (scope === LEADERBOARD_SCOPES.COHORT) { + api.getStudent((data) => { + if (data?.cohorts?.length) { + setCohorts(data.cohorts); + setSelectedCohort(data.cohorts[0].id); + } + }); + } + }, [api, scope]); + + function assignRanks(data) { + if (!Array.isArray(data)) return []; + + const sorted = [...data].sort((a, b) => { + const valueDiff = getMetricValue(b) - getMetricValue(a); + if (valueDiff !== 0) return valueDiff; + const usernameA = a.user?.username.toLowerCase(); + const usernameB = b.user?.username.toLowerCase(); + return usernameA.localeCompare(usernameB); + }); + + let lastValue = null; + let lastRank = 0; + + return sorted.map((entry, index) => { + const value = getMetricValue(entry); + let rank; + + if (value === lastValue) { + rank = lastRank; + } else { + rank = index + 1; + lastValue = value; + lastRank = rank; + } + + return { ...entry, rank }; + }); + } + + const handleViewFriendProfile = (friendId) => { + if (!friendId || !navigationHandler) { + return; + } + navigationHandler(friendId); + }; + + return ( + +
+ setPeriodShiftInWeeks((prev) => prev - 1)} + aria-label="Previous period" + > + + + + + Week {period.week}, {period.year} + + + {formatDateLabel(period.from)} - {formatDateLabel(period.to)} + + + {periodShiftInWeeks < 0 && ( + setPeriodShiftInWeeks((prev) => Math.min(prev + 1, 0))} + aria-label="Next period" + > + + + )} + {periodShiftInWeeks >= 0 &&
+ + {leaderboardTypes.length > 1 && ( + + {leaderboardTypes.map((item) => { + const isActive = item.key === selectedLeaderboardKey; + + return ( + setSelectedLeaderboardKey(item.key)}> + {item.icon && React.createElement(item.icon, { fontSize: "small" })} + {item.tabLabel} + + ); + })} + + )} + + {scope === LEADERBOARD_SCOPES.COHORT && cohorts.length > 1 && ( +
+ ({ value: c.id, label: c.name }))} + selectedValue={selectedCohort} + onChange={(e) => setSelectedCohort(Number(e.target.value))} + optionLabel={(option) => option.label} + optionValue={(option) => option.value} + placeholder="Select a classroom" + /> +
+ )} + + {isLoading &&

Loading leaderboard...

} + {!isLoading && error &&

{error}

} + {!isLoading && !error && leaderboardData.length === 0 &&

{emptyMessage}

} + + {!isLoading && !error && leaderboardData.length > 0 && ( + + + + Rank + User + {activeLeaderboard?.metricLabel} + + + + {leaderboardData.map((entry, index) => { + const metricValue = getMetricValue(entry); + const userEntry = entry?.user; + + const isCurrentUser = + userDetails?.username && + userEntry?.username && + userDetails.username.toLowerCase() === userEntry.username.toLowerCase(); + + return ( + + ); + })} + + + )} +
+ ); +} diff --git a/src/leaderboards/Leaderboards.sc.js b/src/leaderboards/Leaderboards.sc.js new file mode 100644 index 000000000..9585e92fc --- /dev/null +++ b/src/leaderboards/Leaderboards.sc.js @@ -0,0 +1,100 @@ +import styled from "styled-components"; +import { streakFireOrange } from "../components/colors"; + +export const PeriodNavButton = styled.button` + padding: 0; + border: none; + background: transparent; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--text-primary); + cursor: ${({ disabled }) => (disabled ? "not-allowed" : "pointer")}; + opacity: ${({ disabled }) => (disabled ? 0.55 : 1)}; +`; + +export const PeriodNavSpacer = styled.div` + width: 40px; + height: 40px; + flex-shrink: 0; +`; + +export const Container = styled.section` + width: 100%; + max-width: 760px; +`; + +export const PeriodContainer = styled.div` + display: flex; + flex-direction: column; + margin-left: 0.5em; + gap: 0.1em; +`; + +export const WeekLabel = styled.p` + margin: 0; + font-size: 1em; + font-weight: 600; + color: var(--text-primary); + text-align: center; +`; + +export const PeriodLabel = styled.p` + margin: 0; + font-size: 0.85em; + color: var(--text-secondary); + text-align: center; +`; + +export const TabsWrapper = styled.div` + display: inline-flex; + border-radius: 10px; + overflow: hidden; + margin-top: 1em; + margin-bottom: 1em; + width: 100%; + + @media (max-width: 768px) { + font-size: 0.85rem; + overflow-x: auto; + overflow-y: hidden; + } +`; + +export const TabButton = styled.button` + display: flex; + font-size: 1em; + align-items: center; + justify-content: center; + gap: 0.35em; + padding: 0.75em; + border: none; + cursor: pointer; + flex: 1; + + background: ${({ $active }) => ($active ? streakFireOrange : "var(--active-bg)")}; + color: ${({ $active }) => ($active ? "var(--text-primary)" : "var(--text-secondary)")}; + font-weight: ${({ $active }) => ($active ? 500 : 400)}; + + transition: background 0.2s; + box-shadow: ${({ $active }) => ($active ? "0 4px 10px var(--shadow-color)" : "0 1px 2px var(--shadow-color)")}; + + transform: ${({ $active }) => ($active ? "translateY(-1px)" : "none")}; +`; + +export const Table = styled.table` + width: 100%; + border-collapse: collapse; + table-layout: fixed; + + @media (max-width: 768px) { + font-size: 0.85rem; + } +`; + +export const TableHeadCell = styled.th` + padding: 0.5em; +`; diff --git a/src/leaderboards/leaderboardTypes.js b/src/leaderboards/leaderboardTypes.js new file mode 100644 index 000000000..13da9ac2e --- /dev/null +++ b/src/leaderboards/leaderboardTypes.js @@ -0,0 +1,55 @@ +import FitnessCenterRoundedIcon from "@mui/icons-material/FitnessCenterRounded"; +import MenuBookRoundedIcon from "@mui/icons-material/MenuBookRounded"; +import AccessAlarmsRoundedIcon from "@mui/icons-material/AccessAlarmsRounded"; + +export const LEADERBOARD_TYPES = [ + { + key: "reading_time", + tabLabel: "Reading Time", + icon: AccessAlarmsRoundedIcon, + metricLabel: "Time Spent", + formatMetric: (value) => `${formatDuration(value)}` + }, + { + key: "read_articles", + tabLabel: "Read Articles", + icon: MenuBookRoundedIcon, + metricLabel: "Read Articles", + formatMetric: (value) => `${value}` + }, + { + key: "exercises_done", + tabLabel: "Correct Exercises", + icon: FitnessCenterRoundedIcon, + metricLabel: "Exercises Done", + formatMetric: (value) => `${value}` + }, + { + key: "listening_time", + tabLabel: "Listening Time", + icon: AccessAlarmsRoundedIcon, + metricLabel: "Time Spent", + formatMetric: (value) => `${formatDuration(value)}` + }, +]; + +export const LEADERBOARD_SCOPES = { + FRIENDS: "friends", + COHORT: "cohort", +}; + +function formatDuration(ms) { + if (!ms || ms <= 0) return "0s"; + + const totalSeconds = Math.floor(ms / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + const parts = []; + if (hours) parts.push(`${hours}h`); + if (minutes) parts.push(`${minutes}m`); + if (seconds || parts.length === 0) parts.push(`${seconds}s`); + + return parts.join(" "); +} diff --git a/src/pages/Settings/ProfileDetails.js b/src/pages/Settings/ProfileDetails.js index d4a4c24eb..30da63675 100644 --- a/src/pages/Settings/ProfileDetails.js +++ b/src/pages/Settings/ProfileDetails.js @@ -1,10 +1,9 @@ -import { useHistory } from "react-router-dom"; -import { useState, useEffect, useContext, useRef } from "react"; +import { Link, useHistory } from "react-router-dom"; +import React, { Fragment, useContext, useEffect, useRef, useState } from "react"; import { UserContext } from "../../contexts/UserContext"; import { saveSharedUserInfo } from "../../utils/cookies/userInfo"; import { setTitle } from "../../assorted/setTitle"; import { APIContext } from "../../contexts/APIContext"; -import { Link } from "react-router-dom"; import LocalStorage from "../../assorted/LocalStorage"; import strings from "../../i18n/definitions"; import Form from "../_pages_shared/Form.sc"; @@ -20,26 +19,54 @@ import BackArrow from "./settings_pages_shared/BackArrow"; import FullWidthErrorMsg from "../../components/FullWidthErrorMsg.sc"; import LoadingAnimation from "../../components/LoadingAnimation"; import useFormField from "../../hooks/useFormField"; -import { EmailValidator, NonEmptyValidator } from "../../utils/ValidatorRule/Validator"; +import { EmailValidator, NonEmptyOrWhitespaceOnlyValidator } from "../../utils/ValidatorRule/Validator"; import validateRules from "../../assorted/validateRules"; import { useLocation } from "react-router-dom/cjs/react-router-dom"; import FullWidthConfirmMsg from "../../components/FullWidthConfirmMsg.sc"; +import Modal from "../../components/modal_shared/Modal"; +import { + AVATAR_BACKGROUND_COLORS, + AVATAR_CHARACTER_COLORS, + AVATAR_CHARACTER_IDS, + AVATAR_IMAGE_MAP, + validatedAvatarBackgroundColor, + validatedAvatarCharacterColor, + validatedAvatarCharacterId, +} from "../../profile/avatarOptions"; +import { AvatarBackground, AvatarImage } from "../../profile/UserProfile.sc"; +import Feature from "../../features/Feature"; +import * as s from "./ProfileDetails.sc"; +import EditIcon from "@mui/icons-material/Edit"; +import CheckIcon from "@mui/icons-material/Check"; export default function ProfileDetails() { const api = useContext(APIContext); + const isGamificationEnabled = Feature.has_gamification(); const state = useLocation().state || {}; - const successfulyChangedPassword = "passwordChanged" in state ? state.passwordChanged : false; + const fallbackRedirectPath = isGamificationEnabled ? "/profile" : "/account_settings"; + const redirectPath = ["/profile", "/account_settings"].includes(state.from) ? state.from : fallbackRedirectPath; + const successfullyChangedPassword = "passwordChanged" in state ? state.passwordChanged : false; const [errorMessage, setErrorMessage] = useState(""); const { userDetails, setUserDetails } = useContext(UserContext); const history = useHistory(); const isPageMounted = useRef(true); + const [showAvatarModal, setShowAvatarModal] = useState(false); + const [selectedAvatarCharacterId, setSelectedAvatarCharacterId] = useState(); + const [selectedAvatarCharacterColor, setSelectedAvatarCharacterColor] = useState(); + const [selectedAvatarBackgroundColor, setSelectedAvatarBackgroundColor] = useState(); - const [userName, setUserName, validateUserName, isUserNameValid, userErrorMessage] = useFormField( + const default_error_message = "Unable to save profile details. Please try again."; + + const [displayName, setDisplayName, validateDisplayName, isDisplayNameValid, displayNameErrorMessage] = useFormField( + "", + NonEmptyOrWhitespaceOnlyValidator("Please provide a display name."), + ); + const [username, setUsername, validateUsername, isUsernameValid, usernameErrorMessage] = useFormField( "", - NonEmptyValidator("Please provide a name."), + NonEmptyOrWhitespaceOnlyValidator("Please provide a username."), ); - const [userEmail, setUserEmail, validateEmail, isEmailValid, emailErrorMessage] = useFormField("", [ - NonEmptyValidator("Please provide an email."), + const [email, setEmail, validateEmail, isEmailValid, emailErrorMessage] = useFormField("", [ + NonEmptyOrWhitespaceOnlyValidator("Please provide an email."), EmailValidator, ]); @@ -50,8 +77,13 @@ export default function ProfileDetails() { useEffect(() => { isPageMounted.current = true; if (isPageMounted.current) { - setUserEmail(userDetails.email); - setUserName(userDetails.name); + setDisplayName(userDetails.name); + setUsername(userDetails.username); + setEmail(userDetails.email); + + setSelectedAvatarCharacterId(validatedAvatarCharacterId(userDetails.user_avatar?.image_name)); + setSelectedAvatarCharacterColor(validatedAvatarCharacterColor(userDetails.user_avatar?.character_color)); + setSelectedAvatarBackgroundColor(validatedAvatarBackgroundColor(userDetails.user_avatar?.background_color)); } return () => { @@ -63,19 +95,40 @@ export default function ProfileDetails() { function handleSave(e) { e.preventDefault(); setErrorMessage(""); - if (!validateRules([validateUserName, validateEmail])) return; + if (!validateRules([validateDisplayName, validateUsername, validateEmail])) return; - const newUserDetails = { + const updatedFormValues = { + name: displayName.trim(), + username: username.trim(), + email: email.trim(), + }; + const updatedAvatarValues = { + image_name: selectedAvatarCharacterId, + character_color: selectedAvatarCharacterColor, + background_color: selectedAvatarBackgroundColor, + }; + const payload = { ...userDetails, - name: userName, - email: userEmail, + ...updatedFormValues, + ...(isGamificationEnabled + ? Object.fromEntries(Object.entries(updatedAvatarValues).map(([key, value]) => [`avatar_${key}`, value])) + : {}), }; - api.saveUserDetails(newUserDetails, setErrorMessage, () => { - setUserDetails(newUserDetails); - LocalStorage.setUserInfo(newUserDetails); - saveSharedUserInfo(newUserDetails); - history.push("/account_settings"); - }); + api.saveUserDetails( + payload, + (error) => setErrorMessage(error ?? default_error_message), + () => { + const newUserDetails = { + ...userDetails, + ...updatedFormValues, + ...(isGamificationEnabled ? { user_avatar: updatedAvatarValues } : {}), + }; + setUserDetails(newUserDetails); + LocalStorage.setUserInfo(newUserDetails); + saveSharedUserInfo(newUserDetails); + history.push(redirectPath); + }, + ); } if (!userDetails) { @@ -83,12 +136,12 @@ export default function ProfileDetails() { } return ( - +
{strings.profileDetails} - {successfulyChangedPassword && ( + {successfullyChangedPassword && ( <> - Password changed successfuly! + Password changed successfully! )}
@@ -96,17 +149,47 @@ export default function ProfileDetails() {
{errorMessage && {errorMessage}} + {isGamificationEnabled && ( + + setShowAvatarModal(true)} + $backgroundColor={selectedAvatarBackgroundColor} + > + + setShowAvatarModal(true)}> + + + + + )} { - setUserName(e.target.value); + setDisplayName(e.target.value); + }} + /> + { + setUsername(e.target.value); }} /> { - setUserEmail(e.target.value); + setEmail(e.target.value); }} /> Change password @@ -139,6 +222,88 @@ export default function ProfileDetails() { + + {isGamificationEnabled && ( + setShowAvatarModal(false)}> +
+ Choose Your Avatar +
+
+ + Character + + {AVATAR_CHARACTER_IDS.map((id) => ( + { + setSelectedAvatarCharacterId(id); + }} + > + + {selectedAvatarCharacterId === id && ( + + + + )} + + ))} + + + + + Character Color + + {AVATAR_CHARACTER_COLORS.map((color) => ( + { + setSelectedAvatarCharacterColor(color); + }} + > + {selectedAvatarCharacterColor === color && ( + + + + )} + + ))} + + + + + Background Color + + {AVATAR_BACKGROUND_COLORS.map((color) => ( + { + setSelectedAvatarBackgroundColor(color); + }} + > + {selectedAvatarBackgroundColor === color && ( + + + + )} + + ))} + + + + + + +
+
+ )}
); } diff --git a/src/pages/Settings/ProfileDetails.sc.js b/src/pages/Settings/ProfileDetails.sc.js new file mode 100644 index 000000000..7b5e3cf1c --- /dev/null +++ b/src/pages/Settings/ProfileDetails.sc.js @@ -0,0 +1,115 @@ +import styled from "styled-components"; +import { orange500, streakFireOrange } from "../../components/colors"; + +export const AvatarWrapper = styled.div` + display: flex; + justify-content: center; +`; + +export const EditAvatarButton = styled.button` + position: absolute; + top: 0; + right: 0; + width: 2rem; + height: 2rem; + border-radius: 50%; + border: none; + background: var(--streak-banner-border); + color: var(--text-secondary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + background: var(--streak-banner-hover); + color: ${orange500}; + } +`; + +export const PickerSection = styled.div` + margin-bottom: 1.5rem; + + .picker-label { + display: block; + font-weight: 600; + font-size: 0.9rem; + color: var(--text-secondary); + margin-bottom: 0.5rem; + text-align: center; + } +`; + +export const PickerGrid = styled.div` + display: flex; + flex-wrap: wrap; + gap: 0.7rem; +`; + +export const AvatarOption = styled.button` + width: 5rem; + height: 5rem; + background: ${({ $backgroundColor }) => $backgroundColor}; + border-radius: 50%; + border: none; + cursor: pointer; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + transition: + border-color 0.2s, + transform 0.15s; + transform: ${({ $selected }) => ($selected ? "scale(1.1)" : "none")}; + position: relative; + + &:hover { + transform: scale(1.1); + } + + img { + width: 70%; + height: 70%; + } +`; + +export const ColorOption = styled.button` + width: 2.5rem; + height: 2.5rem; + background: ${({ $backgroundColor }) => $backgroundColor}; + border-radius: 50%; + border: none; + cursor: pointer; + padding: 0; + transition: + border-color 0.2s, + transform 0.15s; + transform: ${({ $selected }) => ($selected ? "scale(1.2)" : "none")}; + position: relative; + + &:hover { + transform: scale(1.2); + } +`; + +export const SelectionCheckmark = styled.div` + position: absolute; + top: 0; + right: 0; + width: ${({ $mini }) => ($mini ? "0.6rem" : "1rem")}; + height: ${({ $mini }) => ($mini ? "0.6rem" : "1rem")}; + font-size: ${({ $mini }) => ($mini ? "0.5rem" : "0.8rem")}; + padding: 2px; + border-radius: 50%; + background: var(--streak-banner-border); + stroke: ${streakFireOrange}; + stroke-width: 3; + display: flex; + justify-content: center; + align-items: center; +`; + +export const ConfirmButtonWrapper = styled.div` + display: flex; + justify-content: center; +`; diff --git a/src/pages/Settings/Settings.js b/src/pages/Settings/Settings.js index 193d3ed1c..bb850fe2a 100644 --- a/src/pages/Settings/Settings.js +++ b/src/pages/Settings/Settings.js @@ -49,7 +49,9 @@ export default function Settings() { {!isAnonymous && ( - {strings.profileDetails} + + {strings.profileDetails} + )} {strings.languageSettings} diff --git a/src/pages/Settings/settings_pages_shared/SettingsItem.js b/src/pages/Settings/settings_pages_shared/SettingsItem.js index 7f85d37f9..2ab7ae14d 100644 --- a/src/pages/Settings/settings_pages_shared/SettingsItem.js +++ b/src/pages/Settings/settings_pages_shared/SettingsItem.js @@ -2,10 +2,13 @@ import * as s from "./SettingsItem.sc"; import RoundedForwardArrow from "@mui/icons-material/ArrowForwardRounded"; import { NavLink } from "react-router-dom"; -export default function SettingsItem({ children, path }) { +export default function SettingsItem({ children, path, state }) { + const targetPath = path || "/"; + const to = state ? { pathname: targetPath, state } : targetPath; + return ( - + {children}{" "} diff --git a/src/profile/UserProfile.js b/src/profile/UserProfile.js new file mode 100644 index 000000000..5f7aed881 --- /dev/null +++ b/src/profile/UserProfile.js @@ -0,0 +1,477 @@ +import React, { useContext, useEffect, useState } from "react"; +import { useHistory, useParams } from "react-router-dom"; +import ArrowBackIcon from "@mui/icons-material/ArrowBack"; +import LocalFireDepartmentIcon from "@mui/icons-material/LocalFireDepartment"; +import PersonRemoveIcon from "@mui/icons-material/PersonRemove"; +import PersonAddIcon from "@mui/icons-material/PersonAdd"; +import CancelScheduleSendIcon from "@mui/icons-material/CancelScheduleSend"; +import CheckIcon from "@mui/icons-material/Check"; +import ClearIcon from "@mui/icons-material/Clear"; +import { setTitle } from "../assorted/setTitle"; +import { UserContext } from "../contexts/UserContext"; +import strings from "../i18n/definitions"; +import { APIContext } from "../contexts/APIContext"; +import DynamicFlagImage from "../components/DynamicFlagImage"; +import { ProgressContext } from "../contexts/ProgressContext"; +import Friends from "../friends/Friends"; +import { FriendRequestContext } from "../contexts/FriendRequestContext"; +import Badges from "../badges/Badges"; +import * as s from "./UserProfile.sc"; +import EditIcon from "@mui/icons-material/Edit"; +import Modal from "../components/modal_shared/Modal"; +import Header from "../components/modal_shared/Header.sc"; +import Heading from "../components/modal_shared/Heading.sc"; +import Main from "../components/modal_shared/Main.sc"; +import { + AVATAR_IMAGE_MAP, + validatedAvatarBackgroundColor, + validatedAvatarCharacterColor, + validatedAvatarCharacterId, +} from "./avatarOptions"; +import { BadgeCounterContext } from "../badges/BadgeCounterContext"; +import LoadingAnimation from "../components/LoadingAnimation"; +import Button from "../pages/_pages_shared/Button.sc"; +import Stack from "@mui/material/Stack"; +import { FriendActionButton } from "../friends/FriendRow.sc"; +import Leaderboards from "../leaderboards/Leaderboards"; +import { LEADERBOARD_SCOPES } from "../leaderboards/leaderboardTypes"; +import { streakFireOrange } from "../components/colors.js"; + +export default function UserProfile() { + const api = useContext(APIContext); + const history = useHistory(); + const [profileData, setProfileData] = useState(null); + const { userDetails } = useContext(UserContext); + const { daysPracticed } = useContext(ProgressContext); + const [activeLanguages, setActiveLanguages] = useState([]); + const maxVisibleLanguages = 3; + const visibleLanguages = activeLanguages.slice(0, maxVisibleLanguages); + const overflowCount = Math.max(0, activeLanguages.length - maxVisibleLanguages); + const [languagesModalOpen, setLanguagesModalOpen] = useState(false); + const avatarCharacterId = validatedAvatarCharacterId(profileData?.user_avatar?.image_name); + const avatarCharacterColor = validatedAvatarCharacterColor(profileData?.user_avatar?.character_color); + const avatarBackgroundColor = validatedAvatarBackgroundColor(profileData?.user_avatar?.background_color); + const [activeTab, setActiveTab] = useState("badges"); + const { friendRequestCount } = useContext(FriendRequestContext); + const { hasBadgeNotification, totalNumberOfBadges } = useContext(BadgeCounterContext); + const { friendUsername } = useParams(); + const [isOwnProfile, setIsOwnProfile] = useState(!friendUsername); + const [loadingProfileDetails, setLoadingProfileDetails] = useState(true); + const [friendDetailsError, setFriendDetailsError] = useState(null); + const [unfriendModalOpen, setUnfriendModalOpen] = useState(false); + const friendship = profileData?.friendship; + const isFriendAccepted = friendship?.is_accepted === true; + const pendingFromMe = + friendship?.is_accepted === false && friendship?.sender_username !== friendUsername; + const pendingFromThem = + friendship?.is_accepted === false && friendship?.sender_username === friendUsername; + const streakValue = (isOwnProfile ? daysPracticed : friendship?.friend_streak) ?? 0; + + const resetProfileState = () => { + setProfileData(null); + setActiveLanguages([]); + setActiveTab("badges"); + setLoadingProfileDetails(true); + setFriendDetailsError(null); + }; + + const updateProfileView = (profileData, errorMsg, title) => { + setProfileData(profileData); + setFriendDetailsError(errorMsg); + setTitle(title); + }; + + const activeLanguagesCallback = (data) => { + setLoadingProfileDetails(false); + if (!data) { + setActiveLanguages([]); + return; + } + setActiveLanguages(data); + }; + + const handleUserProfileNavigation = (target) => { + setLoadingProfileDetails(true); + history.push(target ? `/profile/${encodeURIComponent(target)}` : "/profile"); + }; + + useEffect(() => { + resetProfileState(); + + if (!api) return; + + if (!friendUsername) { + setIsOwnProfile(true); + updateProfileView(userDetails, null, strings.titleOwnProfile); + api.getAllDailyStreakForUser(activeLanguagesCallback); + return; + } + + setIsOwnProfile(false); + setFriendDetailsError(null); + api.getFriendDetails(friendUsername, (data) => { + if (!data || data.error) { + updateProfileView({}, data?.error || "Failed to fetch profile.", strings.titleUserProfileDefault); + setLoadingProfileDetails(false); + return; + } + + const isSameUser = data.username === userDetails?.username; + if (isSameUser) { + handleUserProfileNavigation(null); + } else { + updateProfileView(data, null, `${data.username}'s ${strings.titleUserProfilePostfix}`); + api.getAllDailyStreakForFriend(friendUsername, activeLanguagesCallback); + } + }); + }, [api, userDetails, friendUsername]); + + const updateProfileFriendship = (newFriendship) => { + setProfileData((prev) => ({ ...prev, friendship: newFriendship })); + }; + + const handleSendFriendRequest = () => { + api + .sendFriendRequest(friendUsername) + .then((response) => { + if (response.status === 200) { + updateProfileFriendship({ is_accepted: false, sender_username: null }); + } else { + response.json().then((json) => { + setFriendDetailsError(json.error || "Failed to send friend request."); + }); + } + }) + .catch(() => { + setFriendDetailsError("Failed to send friend request."); + }); + }; + + const handleCancelFriendRequest = () => { + api + .deleteFriendRequest(friendUsername) + .then((response) => { + if (response.status === 200) { + updateProfileFriendship(null); + } else { + response.json().then((json) => { + setFriendDetailsError(json.message || "Failed to cancel friend request."); + }); + } + }) + .catch(() => { + setFriendDetailsError("Failed to cancel friend request."); + }); + }; + + const handleAcceptFriendRequest = () => { + api + .acceptFriendRequest(friendUsername) + .then((response) => { + if (response.status === 200) { + updateProfileFriendship({ is_accepted: true }); + } else { + response.json().then((json) => { + setFriendDetailsError(json.message || "Failed to accept friend request."); + }); + } + }) + .catch(() => { + setFriendDetailsError("Failed to accept friend request."); + }); + }; + + const handleRejectFriendRequest = () => { + api + .rejectFriendRequest(friendUsername) + .then((response) => { + if (response.status === 200) { + updateProfileFriendship(null); + } else { + response.json().then((json) => { + setFriendDetailsError(json.message || "Failed to reject friend request."); + }); + } + }) + .catch(() => { + setFriendDetailsError("Failed to reject friend request."); + }); + }; + + const handleUnfriend = () => { + api + .unfriend(friendUsername) + .then((response) => { + if (response.status === 200) { + setUnfriendModalOpen(false); + updateProfileFriendship(null); + } else { + response.json().then((json) => { + setFriendDetailsError(json.message || "Failed to unfriend user."); + }); + } + }) + .catch(() => { + setFriendDetailsError("Failed to unfriend user."); + }); + }; + + const tabs = [ + { + key: "badges", + label: `Badges${isOwnProfile && hasBadgeNotification ? ` (${totalNumberOfBadges})` : ""}`, + }, + { + key: "friends", + label: `Friends${isOwnProfile && friendRequestCount > 0 ? ` (${friendRequestCount})` : ""}`, + }, + ...(isOwnProfile + ? [ + { key: "friendLeaderboards", label: "Friend Leaderboards" }, + { key: "cohortLeaderboards", label: "Classroom Leaderboards" }, + ] + : []), + ]; + + const renderTabContent = () => { + if (activeTab === "badges") { + return ; + } + + if (activeTab === "friends") { + return ; + } + + if (activeTab === "friendLeaderboards") { + return ; + } + + if (activeTab === "cohortLeaderboards") { + return ; + } + + return null; + }; + + const formatDate = (date) => { + return date ? new Date(date).toLocaleDateString() : "—"; + }; + + return ( + + {!isOwnProfile && ( + <> + + + + + )} + + {loadingProfileDetails && ( + +

Loading {!isOwnProfile ? "friend" : ""} profile...

+
+ )} + + {!isOwnProfile && <>{friendDetailsError && {friendDetailsError}}} + + {!loadingProfileDetails && !friendDetailsError && ( + <> + + {isOwnProfile ? ( + + history.push({ + pathname: "/account_settings/profile_details", + state: { from: "/profile" }, + }) + } + > + + + ) : ( + + {isFriendAccepted && ( + setUnfriendModalOpen(true)}> + + Unfriend + + )} + {!isFriendAccepted && !pendingFromMe && !pendingFromThem && ( + + + Add + + )} + {pendingFromMe && ( + + + Cancel + + )} + {pendingFromThem && ( + <> + + + Accept + + + + Reject + + + )} + + )} + + + + + +
+
+

{profileData?.username ?? "—"}

+ {profileData?.name &&

({profileData.name})

} +
+ +
+ Active languages: + {visibleLanguages.length > 0 ? ( + <> + {visibleLanguages.map((languageInfo, index) => ( + setLanguagesModalOpen(true)} + > + + + ))} + {overflowCount > 0 && ( + setLanguagesModalOpen(true)}> + +{overflowCount} + + )} + + ) : ( + "—" + )} +
+ +
+ Member since: + {formatDate(profileData?.created_at)} +
+ + {!isOwnProfile && isFriendAccepted && ( +
+ Friends since: + {formatDate(profileData?.friendship?.created_at)} +
+ )} + + {(isOwnProfile || isFriendAccepted) && ( + +
+
+ {isFriendAccepted ? ( + + + + + ) : ( + + )} + {streakValue} + {isFriendAccepted ? "day friend streak" : "day streak"} +
+
+
+ )} +
+
+ + {(isOwnProfile || isFriendAccepted) && ( + + + {tabs.map((tab) => ( + + ))} + + {renderTabContent()} + + )} + + setUnfriendModalOpen(false)}> +
+ Confirm Unfriend +
+
+
+ Are you sure you want to unfriend {profileData?.name ?? profileData?.username}? +
+ + + + +
+
+ + setLanguagesModalOpen(false)}> +
+ {isOwnProfile ? "Your" : `${profileData?.username}'s`} Languages +
+
+ + {activeLanguages.map((languageInfo) => ( + + + {languageInfo.language} + {(isOwnProfile || isFriendAccepted) && ( +
+
+ + {languageInfo.daily_streak} +
+
+ + {languageInfo.max_streak} +
+
+ )} +
+ ))} +
+
+
+ + )} +
+ ); +} diff --git a/src/profile/UserProfile.sc.js b/src/profile/UserProfile.sc.js new file mode 100644 index 000000000..d107504cc --- /dev/null +++ b/src/profile/UserProfile.sc.js @@ -0,0 +1,363 @@ +import styled from "styled-components"; +import { orange500, streakFireOrange } from "../components/colors"; + +export const ProfileWrapper = styled.div` + display: flex; + flex-direction: column; + margin-top: 1rem; +`; + +export const BackNavigation = styled.div` + margin-bottom: 0.8rem; +`; + +export const ErrorText = styled.p` + margin: 0.5rem 0; + color: #c0392b; + + :root[data-theme="dark"] & { + color: #ff8a80; + } +`; + +export const HeaderCard = styled.div` + position: relative; + background: var(--card-bg); + border-radius: 8px; + padding: 1rem; + margin-bottom: 1.5rem; + box-shadow: 0 1px 3px var(--shadow-color); + display: flex; + align-items: center; + gap: 2rem; + + @media (max-width: 768px) { + flex-direction: column; + text-align: center; + padding: 1.5rem; + } + + .name-wrapper { + display: flex; + column-gap: 1rem; + flex-wrap: wrap; + margin: 1rem 0; + padding-right: 2rem; + + @media (max-width: 768px) { + flex-direction: column; + gap: 0.5rem; + margin-top: 0; + } + } + + .username, + .display-name { + margin: 0; + font-size: 1.4rem; + font-weight: 700; + + @media (max-width: 768px) { + justify-content: center; + font-size: 1.25rem; + } + } + + .username { + color: var(--text-primary); + + @media (max-width: 768px) { + margin-bottom: 0; + } + } + + .display-name { + color: var(--text-secondary); + } + + .meta { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--text-secondary); + font-size: 0.9rem; + margin-top: 0.4rem; + + @media (max-width: 768px) { + justify-content: center; + } + + .flag-image-wrapper { + cursor: pointer; + } + } + + .label { + font-weight: 600; + color: var(--text-secondary); + } +`; + +export const StatsRow = styled.div` + display: flex; + gap: 1rem; + margin-top: 1.2rem; + + @media (max-width: 768px) { + justify-content: center; + } + + .stat { + display: flex; + align-items: center; + gap: 0.5rem; + background: var(--streak-banner-border); + border-radius: 8px; + padding: 0.6rem 1rem; + } + + .stat-streak-wrapper { + display: flex; + align-items: center; + gap: 0.1rem; + } + + .stat-value { + color: ${streakFireOrange}; + margin-right: 0.3rem; + } + + .stat-label { + color: var(--text-secondary); + } + + .stat-value, + .stat-label { + font-size: 1rem; + font-weight: 600; + } +`; + +export const EditProfileButton = styled.button` + position: absolute; + top: 1rem; + right: 1rem; + width: 2rem; + height: 2rem; + border-radius: 50%; + border: none; + background: var(--streak-banner-border); + color: var(--text-secondary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + background: var(--streak-banner-hover); + color: ${orange500}; + } +`; + +export const FriendActionsContainer = styled.div` + position: absolute; + bottom: 1rem; + right: 1rem; + display: flex; + flex-direction: column; + gap: 0.4rem; + align-items: flex-end; + + @media (max-width: 768px) { + position: static; + width: 100%; + align-items: center; + margin-bottom: 0.75rem; + } +`; + +export const TabsSection = styled.div` + background: var(--card-bg); + border-radius: 8px; + box-shadow: 0 1px 3px var(--shadow-color); + padding: 1rem; +`; + +export const TabBar = styled.div` + display: flex; + gap: 0.25rem; + border-bottom: 2px solid var(--border-light); + margin-bottom: 1rem; + + button { + padding: 0.6rem 1.2rem; + font-size: 1rem; + border: none; + border-bottom: 2px solid transparent; + background: none; + color: var(--text-secondary); + cursor: pointer; + font-weight: 500; + margin-bottom: -2px; + transition: color 0.3s, + border-color 0.5s; + + &.active { + color: ${streakFireOrange}; + border-bottom-color: ${streakFireOrange}; + font-weight: 600; + } + + &:hover { + color: ${orange500}; + } + + &.active:hover { + border-bottom-color: ${orange500}; + } + + @media (max-width: 768px) { + padding-left: 0.8rem; + padding-right: 0.8rem; + } + } + + @media (max-width: 768px) { + overflow-x: auto; + overflow-y: hidden; + } +`; + +export const TabContent = styled.div` + min-height: 120px; + padding: 1rem 0 0; +`; + +export const LanguageOverflowBubble = styled.button` + box-sizing: content-box; + width: ${({ $size }) => $size ?? "1.75rem"}; + height: ${({ $size }) => $size ?? "1.75rem"}; + padding: 0; + border-radius: 50%; + background: var(--streak-banner-border); + border: 0.08rem solid var(--border-light); + color: var(--text-primary); + font-size: 0.75rem; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + background: var(--streak-banner-hover); + color: ${orange500}; + } +`; + +export const UnfriendModalButtonWrapper = styled.div` + display: flex; + justify-content: center; + gap: 1rem; +`; + +export const LanguagesGrid = styled.div` + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-content: flex-start; + gap: 0.5rem; + padding: 0; + width: 100%; + + @media (max-width: 768px) { + flex-direction: column; + align-items: center; + max-height: none; + overflow-y: visible; + } +`; + +export const LanguageCard = styled.div` + display: flex; + flex-direction: row; + align-items: center; + gap: 0.5rem; + font-size: 1rem; + font-weight: 600; + padding: 0.1rem 0.75rem 0.1rem 0.5rem; + min-height: 2.75rem; + min-width: 11rem; + border-radius: 2rem; + border: solid 0.1rem var(--border-color); + box-shadow: 0 0.1rem var(--border-color); + white-space: nowrap; + background: var(--card-bg); + margin-bottom: 0.2rem; + + .language-name { + font-weight: 600; + font-size: 1rem; + color: var(--text-primary); + text-transform: capitalize; + flex: 1; + white-space: wrap; + } + + .streaks-info { + display: flex; + align-items: center; + margin-left: auto; + padding-left: 0.75em; + border-left: 1px solid var(--border-light); + height: 1.5em; + gap: 0.4rem; + justify-content: end; + } + + .streak-item { + display: flex; + align-items: center; + gap: 0.1rem; + font-weight: 600; + font-size: 0.9rem; + justify-content: center; + } +`; + +export const AvatarBackground = styled.div` + width: 9rem; + height: 9rem; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + border: none; + background: ${({ $backgroundColor }) => $backgroundColor}; + position: relative; + + &.clickable { + border: 3px solid transparent; + cursor: pointer; + + &:hover button { + background: var(--streak-banner-hover); + color: ${orange500}; + } + } +`; + +export const AvatarImage = styled.div` + width: 70%; + height: 70%; + background-color: ${({ $color }) => $color}; + mask-image: url(${({ $imageSource }) => $imageSource}); + mask-size: contain; + mask-repeat: no-repeat; + mask-position: center; + -webkit-mask-image: url(${({ $imageSource }) => $imageSource}); + -webkit-mask-size: contain; + -webkit-mask-repeat: no-repeat; + -webkit-mask-position: center; +`; diff --git a/src/profile/_ProfileRouter.js b/src/profile/_ProfileRouter.js new file mode 100644 index 000000000..0d52eec8f --- /dev/null +++ b/src/profile/_ProfileRouter.js @@ -0,0 +1,15 @@ +import { PrivateRoute } from "../PrivateRoute"; +import { Switch } from "react-router-dom"; +import * as s from "../components/ColumnWidth.sc"; +import UserProfile from "./UserProfile"; + +export default function ProfileRouter() { + return ( + + + + + + + ); +} diff --git a/src/profile/avatarOptions.js b/src/profile/avatarOptions.js new file mode 100644 index 000000000..f970270e5 --- /dev/null +++ b/src/profile/avatarOptions.js @@ -0,0 +1,27 @@ +const AVATAR_ASSETS_PATH = "/static/avatars/"; +export const AVATAR_CHARACTER_IDS = ["elephant", "cat", "dog"]; +export const AVATAR_IMAGE_MAP = Object.fromEntries( + AVATAR_CHARACTER_IDS.map((id) => { + return [id, `${AVATAR_ASSETS_PATH}${id}.svg`]; + }), +); + +export const AVATAR_CHARACTER_COLORS = ["#F6D110", "#f09000", "#EA2F14", "#6367FF", "#0D1A63", "#008BFF", "#005F02"]; + +export const AVATAR_BACKGROUND_COLORS = ["#FFF9C7", "#ffe0b3", "#ffc3b3", "#C9BEFF", "#81A6C6", "#9CD5FF", "#BCD9A2"]; + +export const DEFAULT_AVATAR_CHARACTER_ID = AVATAR_CHARACTER_IDS[0]; +export const DEFAULT_AVATAR_CHARACTER_COLOR = AVATAR_CHARACTER_COLORS[0]; +export const DEFAULT_AVATAR_BACKGROUND_COLOR = AVATAR_BACKGROUND_COLORS[0]; + +export function validatedAvatarCharacterId(id) { + return id && AVATAR_CHARACTER_IDS.includes(id) ? id : DEFAULT_AVATAR_CHARACTER_ID; +} + +export function validatedAvatarCharacterColor(color) { + return color && AVATAR_CHARACTER_COLORS.includes(color) ? color : DEFAULT_AVATAR_CHARACTER_COLOR; +} + +export function validatedAvatarBackgroundColor(color) { + return color && AVATAR_BACKGROUND_COLORS.includes(color) ? color : DEFAULT_AVATAR_BACKGROUND_COLOR; +} diff --git a/src/userDashboard/userDashboard_Styled/UserDashboard.sc.js b/src/userDashboard/userDashboard_Styled/UserDashboard.sc.js index 35ad66fe8..874a1bb95 100644 --- a/src/userDashboard/userDashboard_Styled/UserDashboard.sc.js +++ b/src/userDashboard/userDashboard_Styled/UserDashboard.sc.js @@ -5,7 +5,9 @@ import "react-datepicker/dist/react-datepicker.css"; import { OrangeButton } from "../../reader/ArticleReader.sc"; const UserDashboardTopContainer = styled.div` - text-align: center; + display: flex; + flex-direction: column; + align-items: center; `; const UserDashboardHelperText = styled(s.YellowMessageBox)` diff --git a/src/utils/ValidatorRule/Validator.js b/src/utils/ValidatorRule/Validator.js index 53cd0192b..295edc180 100644 --- a/src/utils/ValidatorRule/Validator.js +++ b/src/utils/ValidatorRule/Validator.js @@ -26,6 +26,12 @@ function NonEmptyValidator(msg = "Field cannot be empty.") { }, msg); } +function NonEmptyOrWhitespaceOnlyValidator(msg = "Field cannot be empty.") { + return new Validator((value) => { + return value !== null && value !== undefined && value.trim() !== ""; + }, msg); +} + function PositiveIntegerValidator(msg = "Please provide a positive number") { return new Validator((value) => { return value >= 1; @@ -56,6 +62,7 @@ export { Validator, EmailValidator, NonEmptyValidator, + NonEmptyOrWhitespaceOnlyValidator, MinimumLengthValidator, validateMultipleRules, PositiveIntegerValidator,