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() {
+
+ {isGamificationEnabled && (
+ setShowAvatarModal(false)}>
+
+
+
+ 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)}>
+
+
+
+ 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,