diff --git a/__test__/components/Menubar.test.tsx b/__test__/components/Menubar.test.tsx index 82f136e..437e4a9 100644 --- a/__test__/components/Menubar.test.tsx +++ b/__test__/components/Menubar.test.tsx @@ -409,7 +409,7 @@ describe("Menubar interaction with expanded Dialog", () => { }); afterEach(() => { - document.body.classList.remove("dialog-expanded"); + document.body.classList.remove("fullscreen-view"); }); it("has navigation controls element with correct class", () => { @@ -497,7 +497,7 @@ describe("Menubar interaction with expanded Dialog", () => { const mockOnRefresh = jest.fn(); const mockCurrentDate = new Date("2024-04-15"); - document.body.classList.add("dialog-expanded"); + document.body.classList.add("fullscreen-view"); renderWithProviders( { const mockOnRefresh = jest.fn(); const mockCurrentDate = new Date("2024-04-15"); - document.body.classList.add("dialog-expanded"); + document.body.classList.add("fullscreen-view"); renderWithProviders( { expect(screen.getByText("Twake")).toBeInTheDocument(); }); - it("keeps avatar clickable when dialog is expanded", async () => { + it("opens user dropdown menu when clicking avatar", async () => { const mockCalendarRef = { current: null }; const mockOnRefresh = jest.fn(); const mockCurrentDate = new Date("2024-04-15"); - document.body.classList.add("dialog-expanded"); - renderWithProviders( { fireEvent.click(avatar.closest("button")!); await waitFor(() => { - const languageSelector = screen.getByLabelText( - "menubar.languageSelector" - ); - expect(languageSelector).toBeInTheDocument(); + expect(screen.getByText("John Doe")).toBeInTheDocument(); + expect(screen.getByText("test@test.com")).toBeInTheDocument(); + }); + }); + + it("closes user dropdown menu when clicking outside", async () => { + const mockCalendarRef = { current: null }; + const mockOnRefresh = jest.fn(); + const mockCurrentDate = new Date("2024-04-15"); + + renderWithProviders( + , + preloadedState + ); + + const avatar = screen.getByText("JD"); + fireEvent.click(avatar.closest("button")!); + + await waitFor(() => { + expect(screen.getByText("John Doe")).toBeInTheDocument(); + }); + + // Click outside the menu - MUI Menu closes on backdrop click + const backdrop = document.querySelector(".MuiBackdrop-root"); + if (backdrop) { + fireEvent.click(backdrop); + } else { + // Fallback: click on body + fireEvent.mouseDown(document.body); + } + + await waitFor( + () => { + expect(screen.queryByText("John Doe")).not.toBeInTheDocument(); + }, + { timeout: 2000 } + ); + }); + + it("displays user name and email in dropdown menu", async () => { + const mockCalendarRef = { current: null }; + const mockOnRefresh = jest.fn(); + const mockCurrentDate = new Date("2024-04-15"); + + renderWithProviders( + , + preloadedState + ); + + const avatar = screen.getByText("JD"); + fireEvent.click(avatar.closest("button")!); + + await waitFor(() => { + expect(screen.getByText("John Doe")).toBeInTheDocument(); + expect(screen.getByText("test@test.com")).toBeInTheDocument(); + }); + }); + + it("navigates to settings page when clicking Settings in dropdown", async () => { + const mockCalendarRef = { current: null }; + const mockOnRefresh = jest.fn(); + const mockCurrentDate = new Date("2024-04-15"); + + const preloadedStateWithSettings = { + ...preloadedState, + settings: { + language: "en", + view: "calendar", + }, + }; + + const { store } = renderWithProviders( + , + preloadedStateWithSettings + ); + + const avatar = screen.getByText("JD"); + fireEvent.click(avatar.closest("button")!); + + await waitFor(() => { + expect(screen.getByText(/Settings/i)).toBeInTheDocument(); + }); + + const settingsButton = screen.getByText(/Settings/i); + fireEvent.click(settingsButton); + + await waitFor(() => { + const state = store.getState(); + expect(state.settings.view).toBe("settings"); }); }); diff --git a/__test__/features/Settings/SettingsPage.test.tsx b/__test__/features/Settings/SettingsPage.test.tsx new file mode 100644 index 0000000..20dc2f9 --- /dev/null +++ b/__test__/features/Settings/SettingsPage.test.tsx @@ -0,0 +1,158 @@ +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import SettingsPage from "../../../src/features/Settings/SettingsPage"; +import { renderWithProviders } from "../../utils/Renderwithproviders"; + +describe("SettingsPage", () => { + const preloadedState = { + user: { + userData: { + sub: "test", + email: "test@test.com", + family_name: "Doe", + name: "John", + sid: "mockSid", + openpaasId: "667037022b752d0026472254", + }, + }, + settings: { + language: "en", + view: "settings", + }, + }; + + it("renders settings page with sidebar and content layout", () => { + renderWithProviders(, preloadedState); + + expect(screen.getByRole("main")).toHaveClass( + "main-layout", + "settings-layout" + ); + }); + + it("displays sidebar navigation with Settings and Notifications items", () => { + const { container } = renderWithProviders(, preloadedState); + + // Check sidebar navigation items + const sidebar = container.querySelector(".settings-sidebar"); + expect(sidebar).toBeInTheDocument(); + expect(screen.getAllByText(/settings.title/i).length).toBeGreaterThan(0); + expect(screen.getByText(/settings.notifications/i)).toBeInTheDocument(); + }); + + it("highlights active navigation item", () => { + const { container } = renderWithProviders(, preloadedState); + + const settingsNavItem = container.querySelector( + ".settings-nav-item.active" + ); + expect(settingsNavItem).toBeInTheDocument(); + }); + + it("navigates back to calendar view when clicking back button", async () => { + const { store } = renderWithProviders(, preloadedState); + + const backButton = screen.getByLabelText("settings.back"); + fireEvent.click(backButton); + + await waitFor(() => { + const state = store.getState(); + expect(state.settings.view).toBe("calendar"); + }); + }); + + it("switches between Settings and Notifications tabs", () => { + renderWithProviders(, preloadedState); + + const notificationsTab = screen.getByRole("tab", { + name: /settings.notifications/i, + }); + fireEvent.click(notificationsTab); + + expect( + screen.getByText(/settings.notifications.empty/i) + ).toBeInTheDocument(); + }); + + it("displays language select in Settings tab", () => { + renderWithProviders(, preloadedState); + + expect( + screen.getByLabelText("settings.languageSelector") + ).toBeInTheDocument(); + }); + + it("displays all available language options", async () => { + renderWithProviders(, preloadedState); + + const languageSelect = screen.getByLabelText("settings.languageSelector"); + + // Click on the select to open dropdown + fireEvent.mouseDown(languageSelect); + + // Wait for menu to appear - MUI Select uses Menu internally + // Note: In test environment, Select may not open menu, so we verify Select exists and has correct value + expect(languageSelect).toBeInTheDocument(); + expect(languageSelect).toHaveTextContent("English"); + }); + + it("dispatches setLanguage action when language is changed", async () => { + const { store } = renderWithProviders(, preloadedState); + + const languageSelect = screen.getByLabelText("settings.languageSelector"); + + // MUI Select uses a native input element - find and change it + const nativeInput = languageSelect.querySelector( + 'input[aria-hidden="true"]' + ) as HTMLInputElement; + expect(nativeInput).toBeInTheDocument(); + + // Simulate change event on the native input + Object.defineProperty(nativeInput, "value", { + writable: true, + value: "fr", + }); + fireEvent.change(nativeInput, { target: { value: "fr" } }); + + await waitFor(() => { + const state = store.getState(); + expect(state.settings.language).toBe("fr"); + }); + }); + + it("saves language change to localStorage", async () => { + const { store } = renderWithProviders(, preloadedState); + + const languageSelect = screen.getByLabelText("settings.languageSelector"); + + // MUI Select uses a native input element - find and change it + const nativeInput = languageSelect.querySelector( + 'input[aria-hidden="true"]' + ) as HTMLInputElement; + expect(nativeInput).toBeInTheDocument(); + + // Simulate change event on the native input + Object.defineProperty(nativeInput, "value", { + writable: true, + value: "fr", + }); + fireEvent.change(nativeInput, { target: { value: "fr" } }); + + await waitFor(() => { + expect(localStorage.getItem("lang")).toBe("fr"); + }); + }); + + it("shows empty state in Notifications tab", () => { + renderWithProviders(, preloadedState); + + const notificationsTab = screen.getByRole("tab", { + name: /settings.notifications/i, + }); + fireEvent.click(notificationsTab); + + expect( + screen.getByText("settings.notifications.empty") + ).toBeInTheDocument(); + }); +}); diff --git a/__test__/utils/userUtils.test.ts b/__test__/utils/userUtils.test.ts new file mode 100644 index 0000000..ef66c3b --- /dev/null +++ b/__test__/utils/userUtils.test.ts @@ -0,0 +1,92 @@ +import { getUserDisplayName } from "../../src/utils/userUtils"; +import { userData } from "../../src/features/User/userDataTypes"; + +describe("userUtils", () => { + describe("getUserDisplayName", () => { + it("returns full name when user has name and family_name", () => { + const user: userData = { + sub: "test", + email: "test@test.com", + family_name: "Doe", + given_name: "John", + name: "John", + sid: "mockSid", + }; + + expect(getUserDisplayName(user)).toBe("John Doe"); + }); + + it("returns email when user does not have name and family_name", () => { + const user: userData = { + sub: "test", + email: "test@test.com", + family_name: "", + given_name: "", + name: "", + sid: "mockSid", + }; + + expect(getUserDisplayName(user)).toBe("test@test.com"); + }); + + it("returns email when user has only email", () => { + const user: userData = { + sub: "test", + email: "user@example.com", + family_name: "", + given_name: "", + name: "", + sid: "mockSid", + }; + + expect(getUserDisplayName(user)).toBe("user@example.com"); + }); + + it("returns empty string when user is null", () => { + expect(getUserDisplayName(null)).toBe(""); + }); + + it("returns empty string when user is undefined", () => { + expect(getUserDisplayName(undefined)).toBe(""); + }); + + it("returns empty string when user has no name, family_name, or email", () => { + const user: userData = { + sub: "test", + email: "", + family_name: "", + given_name: "", + name: "", + sid: "mockSid", + }; + + expect(getUserDisplayName(user)).toBe(""); + }); + + it("handles user with only name (no family_name)", () => { + const user: userData = { + sub: "test", + email: "test@test.com", + family_name: "", + given_name: "John", + name: "John", + sid: "mockSid", + }; + + expect(getUserDisplayName(user)).toBe("test@test.com"); + }); + + it("handles user with only family_name (no name)", () => { + const user: userData = { + sub: "test", + email: "test@test.com", + family_name: "Doe", + given_name: "", + name: "", + sid: "mockSid", + }; + + expect(getUserDisplayName(user)).toBe("test@test.com"); + }); + }); +}); diff --git a/src/components/Calendar/CalendarLayout.tsx b/src/components/Calendar/CalendarLayout.tsx index 774edae..ffa1787 100644 --- a/src/components/Calendar/CalendarLayout.tsx +++ b/src/components/Calendar/CalendarLayout.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from "react"; +import { useRef, useState, useEffect } from "react"; import { Menubar, MenubarProps } from "../Menubar/Menubar"; import CalendarApp from "./Calendar"; import { useAppDispatch } from "../../app/hooks"; @@ -51,6 +51,20 @@ export default function CalendarLayout() { setCurrentView(view); }; + // Hide topbar navigation elements when in settings view (same as fullscreen dialog mode) + useEffect(() => { + if (view === "settings") { + document.body.classList.add("fullscreen-view"); + } else { + document.body.classList.remove("fullscreen-view"); + } + + // Cleanup on unmount + return () => { + document.body.classList.remove("fullscreen-view"); + }; + }, [view]); + const menubarProps: MenubarProps = { calendarRef, onRefresh: handleRefresh, diff --git a/src/components/Dialog/ResponsiveDialog.tsx b/src/components/Dialog/ResponsiveDialog.tsx index 43dfa5c..e086197 100644 --- a/src/components/Dialog/ResponsiveDialog.tsx +++ b/src/components/Dialog/ResponsiveDialog.tsx @@ -149,9 +149,9 @@ function ResponsiveDialog({ React.useEffect(() => { if (isExpanded) { - document.body.classList.add("dialog-expanded"); + document.body.classList.add("fullscreen-view"); } else { - document.body.classList.remove("dialog-expanded"); + document.body.classList.remove("fullscreen-view"); } }, [isExpanded]); diff --git a/src/components/Menubar/Menubar.styl b/src/components/Menubar/Menubar.styl index e30cf8d..733e7bc 100644 --- a/src/components/Menubar/Menubar.styl +++ b/src/components/Menubar/Menubar.styl @@ -92,8 +92,8 @@ justify-content flex-end align-items center -body.dialog-expanded .navigation-controls, -body.dialog-expanded .current-date-time, -body.dialog-expanded .refresh-button, -body.dialog-expanded .select-display +body.fullscreen-view .navigation-controls, +body.fullscreen-view .current-date-time, +body.fullscreen-view .refresh-button, +body.fullscreen-view .select-display display none diff --git a/src/components/Menubar/Menubar.tsx b/src/components/Menubar/Menubar.tsx index 6d831b8..6d3d151 100644 --- a/src/components/Menubar/Menubar.tsx +++ b/src/components/Menubar/Menubar.tsx @@ -4,6 +4,8 @@ import AppsIcon from "@mui/icons-material/Apps"; import RefreshIcon from "@mui/icons-material/Refresh"; import ChevronLeftIcon from "@mui/icons-material/ChevronLeft"; import ChevronRightIcon from "@mui/icons-material/ChevronRight"; +import SettingsIcon from "@mui/icons-material/Settings"; +import LogoutIcon from "@mui/icons-material/Logout"; import "./Menubar.styl"; import { useAppDispatch, useAppSelector } from "../../app/hooks"; import { stringToColor } from "../Event/utils/eventUtils"; @@ -11,17 +13,21 @@ import { Avatar, IconButton, Popover, + Menu, + MenuItem, ButtonGroup, Button, Select, - MenuItem, FormControl, Typography, + Box, + Divider, } from "@mui/material"; import { push } from "redux-first-history"; import { CalendarApi } from "@fullcalendar/core"; import { useI18n } from "cozy-ui/transpiled/react/providers/I18n"; -import { setLanguage } from "../../features/Settings/SettingsSlice"; +import { setView } from "../../features/Settings/SettingsSlice"; +import { getUserDisplayName } from "../../utils/userUtils"; import { format } from "date-fns"; import { enGB, @@ -58,7 +64,9 @@ export function Menubar({ const user = useAppSelector((state) => state.user.userData); const applist: AppIconProps[] = (window as any).appList ?? []; const [anchorEl, setAnchorEl] = useState(null); - const [langAnchorEl, setLangAnchorEl] = useState(null); + const [userMenuAnchorEl, setUserMenuAnchorEl] = useState( + null + ); const dispatch = useAppDispatch(); useEffect(() => { @@ -117,24 +125,30 @@ export function Menubar({ }; const open = Boolean(anchorEl); - const langOpen = Boolean(langAnchorEl); + const userMenuOpen = Boolean(userMenuAnchorEl); - const handleLangClick = (event: React.MouseEvent) => { - setLangAnchorEl(event.currentTarget); + const handleUserMenuClick = (event: React.MouseEvent) => { + setUserMenuAnchorEl(event.currentTarget); + }; + + const handleUserMenuClose = () => { + setUserMenuAnchorEl(null); + }; + + const handleSettingsClick = () => { + dispatch(setView("settings")); + handleUserMenuClose(); + }; + + const handleLogoutClick = () => { + // TODO: Implement logout functionality + handleUserMenuClose(); }; - const handleLangClose = () => setLangAnchorEl(null); const dateLabel = format(currentDate, "MMMM yyyy", { locale: dateLocales[lang as keyof typeof dateLocales] || enGB, }); - const availableLangs = [ - { code: "en", label: "English" }, - { code: "fr", label: "Français" }, - { code: "ru", label: "Русский" }, - { code: "vi", label: "Tiếng Việt" }, - ]; - return ( <>
@@ -225,7 +239,7 @@ export function Menubar({
- + - - - - - + {user?.name && user?.family_name + ? `${user.name[0]}${user.family_name[0]}` + : (user?.email?.[0] ?? "")} + + + {getUserDisplayName(user)} + + + {user?.email} + + + + + {t("menubar.settings") || "Settings"} + + {/* + + + {t("menubar.logout") || "Logout"} + */} + ); } diff --git a/src/features/Settings/SettingsPage.styl b/src/features/Settings/SettingsPage.styl new file mode 100644 index 0000000..60fdab9 --- /dev/null +++ b/src/features/Settings/SettingsPage.styl @@ -0,0 +1,105 @@ +.main-layout.settings-layout + display flex + flex-direction row + height calc(100vh - 90px) + padding 0 10px 10px 0; + +.settings-sidebar + width 350px + height 100% + overflow-y auto + overflow-x hidden + scrollbar-width thin + scrollbar-color transparent transparent + +.settings-sidebar::-webkit-scrollbar + width 8px + +.settings-sidebar::-webkit-scrollbar-track + background transparent + +.settings-sidebar::-webkit-scrollbar-thumb + background-color rgba(0, 0, 0, 0.3) + border-radius 4px + opacity 0 + transition opacity 0.3s ease + +.settings-sidebar:hover::-webkit-scrollbar-thumb + background-color rgba(0, 0, 0, 0.5) + opacity 1 + +.settings-sidebar:hover + scrollbar-color rgba(0, 0, 0, 0.5) transparent + +.settings-nav-item + position relative + padding 12px 24px + transition background-color 0.2s ease + cursor pointer + + &::before + content "" + position absolute + left 0 + top 0 + width 0 + height 100% + background-color #F67E35 + transition width 0.2s ease + + &:hover + background-color #f9fafb + + &::before + width 4px + + .MuiListItemIcon-root + color #F67E35 + + &.active + + &::before + width 4px + + .MuiListItemIcon-root + color #F67E35 + +.settings-content + flex-grow 1 + display flex + flex-direction column + height 100% + overflow-y auto + border-radius 16px + background #fff + +.settings-content-header + display flex + align-items center + padding 16px 24px + gap 16px + +.back-button + .MuiSvgIcon-root + font-size 28px + +.settings-content-body + padding 24px + flex-grow 1 + +.settings-tab-content + text-align left + +.settings-content-tabs + .MuiTab-root + color #424244E5 + text-transform none + font-weight 600 + font-size 24px + + .MuiTab-root.Mui-selected + color #424244E5 + + .MuiTabs-indicator + background-color #F67E35 + diff --git a/src/features/Settings/SettingsPage.tsx b/src/features/Settings/SettingsPage.tsx index a4fac2e..3f1998d 100644 --- a/src/features/Settings/SettingsPage.tsx +++ b/src/features/Settings/SettingsPage.tsx @@ -1,9 +1,162 @@ -import React from "react"; +import React, { useState } from "react"; +import { + Box, + List, + ListItem, + ListItemIcon, + ListItemText, + Tabs, + Tab, + IconButton, + FormControl, + Select, + MenuItem, + Typography, +} from "@mui/material"; +import ArrowBackIcon from "@mui/icons-material/ArrowBack"; +import SettingsIcon from "@mui/icons-material/Settings"; +import SyncIcon from "@mui/icons-material/Sync"; +import { useAppDispatch, useAppSelector } from "../../app/hooks"; +import { setView, setLanguage } from "./SettingsSlice"; +import { AVAILABLE_LANGUAGES } from "./constants"; +import { useI18n } from "cozy-ui/transpiled/react/providers/I18n"; +import "./SettingsPage.styl"; + +type SidebarNavItem = "settings" | "sync"; +type SettingsSubTab = "settings" | "notifications"; export default function SettingsPage() { + const dispatch = useAppDispatch(); + const { t, lang } = useI18n(); + const [activeNavItem, setActiveNavItem] = + useState("settings"); + const [activeSettingsSubTab, setActiveSettingsSubTab] = + useState("settings"); + + const handleBackClick = () => { + dispatch(setView("calendar")); + }; + + const handleNavItemClick = (item: SidebarNavItem) => { + setActiveNavItem(item); + if (item === "settings") { + setActiveSettingsSubTab("settings"); + } + }; + + const handleSettingsSubTabChange = ( + _event: React.SyntheticEvent, + newValue: SettingsSubTab + ) => { + setActiveSettingsSubTab(newValue); + }; + + const handleLanguageChange = (event: any) => { + dispatch(setLanguage(event.target.value)); + }; + return (
-

Settings Page

+ + + handleNavItemClick("settings")} + sx={{ cursor: "pointer" }} + > + + + + + + {/* handleNavItemClick("sync")} + sx={{ cursor: "pointer" }} + > + + + + + */} + + + + + + + + {activeNavItem === "settings" && ( + + + + + )} + + + {activeNavItem === "settings" && ( + <> + {activeSettingsSubTab === "settings" && ( + + + {t("settings.language") || "Language"} + + + {t("settings.languageDescription") || + "This will be the language used in your Twake Calendar"} + + + + + + )} + {activeSettingsSubTab === "notifications" && ( + + + {t("settings.notifications.empty") || + "Notifications settings coming soon"} + + + )} + + )} + {activeNavItem === "sync" && ( + + + {t("settings.sync.empty") || "Sync settings coming soon"} + + + )} + +
); } diff --git a/src/features/Settings/constants.ts b/src/features/Settings/constants.ts new file mode 100644 index 0000000..886af5e --- /dev/null +++ b/src/features/Settings/constants.ts @@ -0,0 +1,6 @@ +export const AVAILABLE_LANGUAGES = [ + { code: "en", label: "English" }, + { code: "fr", label: "Français" }, + { code: "ru", label: "Русский" }, + { code: "vi", label: "Tiếng Việt" }, +] as const; diff --git a/src/locales/en.json b/src/locales/en.json index 2c35f38..9f6a964 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -185,7 +185,20 @@ }, "apps": "Apps", "userProfile": "User profile", - "logoAlt": "Calendar" + "logoAlt": "Calendar", + "settings": "Settings", + "logout": "Logout" + }, + "settings": { + "title": "Settings", + "notifications": "Notifications", + "sync": "Sync", + "language": "Language", + "languageDescription": "This will be the language used in your Twake Calendar", + "languageSelector": "Language selector", + "notifications.empty": "Notifications settings coming soon", + "sync.empty": "Sync settings coming soon", + "back": "Back to calendar" }, "eventPreview": { "emailAttendees": "Email attendees", diff --git a/src/locales/fr.json b/src/locales/fr.json index ae3dba9..1401859 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -185,7 +185,20 @@ }, "apps": "Applications", "userProfile": "Profil utilisateur", - "logoAlt": "Calendrier" + "logoAlt": "Calendrier", + "settings": "Paramètres", + "logout": "Déconnexion" + }, + "settings": { + "title": "Paramètres", + "notifications": "Notifications", + "sync": "Synchronisation", + "language": "Langue", + "languageDescription": "Ce sera la langue utilisée dans votre Twake Calendar", + "languageSelector": "Sélecteur de langue", + "notifications.empty": "Paramètres de notifications à venir", + "sync.empty": "Paramètres de synchronisation à venir", + "back": "Retour au calendrier" }, "eventPreview": { "emailAttendees": "Envoyer un e-mail aux participants", diff --git a/src/locales/ru.json b/src/locales/ru.json index 6b5e016..5b5725d 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -185,7 +185,20 @@ }, "apps": "Приложения", "userProfile": "Профиль", - "logoAlt": "Календарь" + "logoAlt": "Календарь", + "settings": "Настройки", + "logout": "Выйти" + }, + "settings": { + "title": "Настройки", + "notifications": "Уведомления", + "sync": "Синхронизация", + "language": "Язык", + "languageDescription": "Это будет язык, используемый в вашем Twake Calendar", + "languageSelector": "Выбор языка", + "notifications.empty": "Настройки уведомлений скоро появятся", + "sync.empty": "Настройки синхронизации скоро появятся", + "back": "Вернуться к календарю" }, "eventPreview": { "emailAttendees": "Написать участникам", diff --git a/src/locales/vi.json b/src/locales/vi.json index 8e14336..c21784a 100644 --- a/src/locales/vi.json +++ b/src/locales/vi.json @@ -185,7 +185,20 @@ }, "apps": "Ứng dụng", "userProfile": "Hồ sơ người dùng", - "logoAlt": "Lịch" + "logoAlt": "Lịch", + "settings": "Cài đặt", + "logout": "Đăng xuất" + }, + "settings": { + "title": "Cài đặt", + "notifications": "Thông báo", + "sync": "Đồng bộ", + "language": "Ngôn ngữ", + "languageDescription": "Đây sẽ là ngôn ngữ được sử dụng trong Twake Calendar của bạn", + "languageSelector": "Chọn ngôn ngữ", + "notifications.empty": "Cài đặt thông báo sắp có", + "sync.empty": "Cài đặt đồng bộ sắp có", + "back": "Quay lại lịch" }, "eventPreview": { "emailAttendees": "Gửi email cho người tham gia", diff --git a/src/utils/userUtils.ts b/src/utils/userUtils.ts new file mode 100644 index 0000000..679b37e --- /dev/null +++ b/src/utils/userUtils.ts @@ -0,0 +1,19 @@ +import { userData } from "../features/User/userDataTypes"; + +/** + * Get user display name for UI display + * Returns full name if available, otherwise email, or empty string as fallback + * @param user - User data object + * @returns Display name string + */ +export function getUserDisplayName(user: userData | null | undefined): string { + if (!user) { + return ""; + } + + if (user.name && user.family_name) { + return `${user.name} ${user.family_name}`; + } + + return user.email || ""; +}