diff --git a/package-lock.json b/package-lock.json index 1cceff3e2..8461bfd51 100644 --- a/package-lock.json +++ b/package-lock.json @@ -78,6 +78,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.2.0", "@testing-library/user-event": "^14.6.1", + "@types/lodash": "^4.17.23", "@types/node": "^20.19.19", "@types/papaparse": "^5.3.15", "@types/react": "^19.0.0", @@ -2451,6 +2452,21 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/@commitlint/load/node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/@commitlint/message": { "version": "19.8.1", "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-19.8.1.tgz", @@ -5536,11 +5552,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.19", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.19.tgz", "integrity": "sha512-pb1Uqj5WJP7wrcbLU7Ru4QtA0+3kAXrkutGiD26wUKzSMgNNaPARTUDQmElUXp64kh3cWdou3Q0C7qwwxqSFmg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -5608,7 +5631,7 @@ "version": "19.2.0", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-brtBs0MnE9SMx7px208g39lRmC5uHZs96caOJfTjFcYSLHNamvaSMfJNagChVNkup2SdtOxKX1FDBkRSJe1ZAg==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -14801,7 +14824,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { diff --git a/package.json b/package.json index bdfbc12b7..c0add04d2 100644 --- a/package.json +++ b/package.json @@ -96,6 +96,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.2.0", "@testing-library/user-event": "^14.6.1", + "@types/lodash": "^4.17.23", "@types/node": "^20.19.19", "@types/papaparse": "^5.3.15", "@types/react": "^19.0.0", diff --git a/src/components/core/divider/divider.tsx b/src/components/core/divider/divider.tsx index 64e7fb4ff..14bb0be32 100644 --- a/src/components/core/divider/divider.tsx +++ b/src/components/core/divider/divider.tsx @@ -6,11 +6,11 @@ export const Divider = ({ text }: DividerProps) => { return (
-
+
{text}
-
+
); diff --git a/src/components/core/index.ts b/src/components/core/index.ts index e9b075284..96b1251a7 100644 --- a/src/components/core/index.ts +++ b/src/components/core/index.ts @@ -32,3 +32,5 @@ export { Notification } from './notification/component/notification/notification export { NotificationItem } from './notification/component/notification-item/notification-item'; export * from './notification/hooks/use-notification'; export { ExtensionBanner } from './extension-banner/extension-banner'; +export { ThemeSwitcher } from './theme-switcher/theme-switcher'; +export { OrgSwitcher } from './org-switcher/org-switcher'; diff --git a/src/components/core/notification/component/notification-item/notification-item.tsx b/src/components/core/notification/component/notification-item/notification-item.tsx index f2393ca97..bc664d768 100644 --- a/src/components/core/notification/component/notification-item/notification-item.tsx +++ b/src/components/core/notification/component/notification-item/notification-item.tsx @@ -50,7 +50,7 @@ export const NotificationItem = ({ notification }: Readonly -
+
diff --git a/src/components/core/org-switcher/org-switcher.tsx b/src/components/core/org-switcher/org-switcher.tsx new file mode 100644 index 000000000..de880ea50 --- /dev/null +++ b/src/components/core/org-switcher/org-switcher.tsx @@ -0,0 +1,167 @@ +import { useState, useMemo } from 'react'; +import { Building2, ChevronDown, ChevronUp } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui-kit/dropdown-menu'; +import { Skeleton } from '@/components/ui-kit/skeleton'; +import { useGetAccount } from '@/modules/profile/hooks/use-account'; +import { useGetMultiOrgs } from '@/lib/api/hooks/use-multi-orgs'; +import { switchOrganization } from '@/modules/auth/services/auth.service'; +import { useAuthStore } from '@/state/store/auth'; +import { useToast } from '@/hooks/use-toast'; +import { HttpError } from '@/lib/https'; +import { decodeJWT } from '@/lib/utils/decode-jwt-utils'; + +const projectKey = import.meta.env.VITE_X_BLOCKS_KEY || ''; + +export const OrgSwitcher = () => { + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [isSwitching, setIsSwitching] = useState(false); + const { t } = useTranslation(); + const { setTokens, accessToken } = useAuthStore(); + const { toast } = useToast(); + + const currentOrgId = useMemo(() => { + if (!accessToken) return null; + const decoded = decodeJWT(accessToken); + return decoded?.org_id ?? null; + }, [accessToken]); + + const { data, isLoading } = useGetAccount(); + const { data: orgsData, isLoading: isLoadingOrgs } = useGetMultiOrgs({ + ProjectKey: projectKey, + Page: 0, + PageSize: 10, + }); + + const organizations = orgsData?.organizations ?? []; + const enabledOrganizations = organizations.filter((org) => org.isEnable); + + const selectedOrg = currentOrgId + ? enabledOrganizations.find((org) => org.itemId === currentOrgId) + : enabledOrganizations[0]; + + const currentOrgRoles = useMemo(() => { + if (!data?.memberships?.length || !currentOrgId) return []; + const membership = data.memberships.find((m) => m.organizationId === currentOrgId); + return membership?.roles ?? []; + }, [data, currentOrgId]); + + const translatedRoles = currentOrgRoles + .map((role: string) => { + const roleKey = role.toUpperCase(); + return t(roleKey); + }) + .join(', '); + + const handleOrgSelect = async (orgId: string) => { + if (isSwitching || orgId === currentOrgId) { + return; + } + + try { + setIsSwitching(true); + setIsDropdownOpen(false); + + const response = await switchOrganization(orgId); + + setTokens({ + accessToken: response.access_token, + refreshToken: (response.refresh_token || useAuthStore.getState().refreshToken) ?? '', + }); + + localStorage.setItem('selected-org-id', orgId); + + window.location.reload(); + } catch (error) { + console.error('Failed to switch organization:', error); + setIsSwitching(false); + + let errorTitle = t('FAILED_TO_SWITCH_ORGANIZATION'); + let errorDescription = t('SOMETHING_WENT_WRONG'); + + if (error instanceof HttpError) { + const errorData = error.error; + + if (errorData?.error === 'user_inactive_or_not_verified') { + errorTitle = t('ACCESS_DENIED'); + errorDescription = + typeof errorData?.error_description === 'string' + ? errorData.error_description + : t('USER_NOT_EXIST_IN_ORGANIZATION'); + } else if (typeof errorData?.error_description === 'string') { + errorDescription = errorData.error_description; + } else if (typeof errorData?.error === 'string') { + errorDescription = errorData.error; + } + } + + toast({ + variant: 'destructive', + title: errorTitle, + description: errorDescription, + }); + } + }; + + const isComponentLoading = isLoading || isLoadingOrgs || isSwitching; + + return ( + + +
+
+ {isComponentLoading ? ( + + ) : ( + + )} +
+
+ {isComponentLoading ? ( + <> + + + + ) : ( + <> +

+ {selectedOrg?.name ?? '_'} +

+

{translatedRoles}

+ + )} +
+ {isDropdownOpen ? ( + + ) : ( + + )} +
+
+ + {enabledOrganizations.length > 0 ? ( + enabledOrganizations.map((org) => ( + handleOrgSelect(org.itemId)}> + {org.name} + + )) + ) : ( + No orgs found + )} + + {t('CREATE_NEW')} + +
+ ); +}; diff --git a/src/components/core/phone-input/phone-input.tsx b/src/components/core/phone-input/phone-input.tsx index e18d4549e..e2a2f9987 100644 --- a/src/components/core/phone-input/phone-input.tsx +++ b/src/components/core/phone-input/phone-input.tsx @@ -81,7 +81,7 @@ const UIPhoneInput = forwardRef( placeholder={placeholder} defaultCountry={defaultCountry} className={cn( - 'flex h-11 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50', + 'flex h-11 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50', className )} countryCallingCodeEditable={countryCallingCodeEditable} diff --git a/src/components/core/profile-menu/profile-menu.tsx b/src/components/core/profile-menu/profile-menu.tsx index 3fc24f070..30126e47f 100644 --- a/src/components/core/profile-menu/profile-menu.tsx +++ b/src/components/core/profile-menu/profile-menu.tsx @@ -1,6 +1,5 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; -import { ChevronDown, ChevronUp, Moon, Sun } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { DropdownMenu, @@ -13,7 +12,6 @@ import { useSignoutMutation } from '@/modules/auth/hooks/use-auth'; import { useAuthStore } from '@/state/store/auth'; import DummyProfile from '@/assets/images/dummy_profile.png'; import { Skeleton } from '@/components/ui-kit/skeleton'; -import { useTheme } from '@/styles/theme/theme-provider'; import { useGetAccount } from '@/modules/profile/hooks/use-account'; /** @@ -23,15 +21,12 @@ import { useGetAccount } from '@/modules/profile/hooks/use-account'; * navigation and account management options. * * Features: - * - Displays user profile image and name + * - Displays user profile image * - Shows loading states with skeleton placeholders * - Provides navigation to profile page - * - Includes theme toggling functionality * - Handles user logout with authentication state management - * - Responsive design with different spacing for mobile and desktop * * Dependencies: - * - Requires useTheme hook for theme management * - Requires useAuthStore for authentication state management * - Requires useSignoutMutation for API logout functionality * - Requires useGetAccount for fetching user account data @@ -45,7 +40,8 @@ import { useGetAccount } from '@/modules/profile/hooks/use-account'; export const ProfileMenu = () => { const [isDropdownOpen, setIsDropdownOpen] = useState(false); - const { theme, setTheme } = useTheme(); + const [isImageLoaded, setIsImageLoaded] = useState(false); + const imgRef = useRef(null); const { t } = useTranslation(); const { logout } = useAuthStore(); @@ -66,13 +62,8 @@ export const ProfileMenu = () => { }; const fullName = `${data?.firstName ?? ''} ${data?.lastName ?? ''}`.trim() ?? ' '; - - const translatedRoles = data?.roles - ?.map((role) => { - const roleKey = role.toUpperCase(); - return t(roleKey); - }) - .join(', '); + const profileImageUrl = + data?.profileImageUrl !== '' ? (data?.profileImageUrl ?? DummyProfile) : DummyProfile; useEffect(() => { if (data) { @@ -86,39 +77,35 @@ export const ProfileMenu = () => { } }, [data, fullName]); + useEffect(() => { + setIsImageLoaded(false); + }, [profileImageUrl]); + + useEffect(() => { + const img = imgRef.current; + if (img && img.complete && img.naturalHeight !== 0) { + setIsImageLoaded(true); + } + }, [profileImageUrl, isLoading]); + + const showSkeleton = isLoading || !isImageLoaded; + return (
-
- {isLoading ? ( - - ) : ( - profile - )} +
+ {showSkeleton && } + profile setIsImageLoaded(true)} + onError={() => setIsImageLoaded(true)} + />
-
- {isLoading ? ( - - ) : ( -

{fullName}

- )} -

{translatedRoles}

-
- {isDropdownOpen ? ( - - ) : ( - - )}
{ {t('ABOUT')} {t('PRIVACY_POLICY')} - setTheme(theme === 'dark' ? 'light' : 'dark')} - > - {t('THEME')} - - - {t('LOG_OUT')} diff --git a/src/components/core/theme-switcher/theme-switcher.tsx b/src/components/core/theme-switcher/theme-switcher.tsx new file mode 100644 index 000000000..f5fa0fe7f --- /dev/null +++ b/src/components/core/theme-switcher/theme-switcher.tsx @@ -0,0 +1,18 @@ +import { Moon, Sun } from 'lucide-react'; +import { useTheme } from '@/styles/theme/theme-provider'; +import { Button } from '@/components/ui-kit/button'; + +export const ThemeSwitcher = () => { + const { theme, setTheme } = useTheme(); + + return ( + + ); +}; diff --git a/src/components/ui-kit/badge.tsx b/src/components/ui-kit/badge.tsx index fb5a01343..adf60e7c9 100644 --- a/src/components/ui-kit/badge.tsx +++ b/src/components/ui-kit/badge.tsx @@ -3,7 +3,7 @@ import { cva, type VariantProps } from 'class-variance-authority'; import { cn } from '@/lib/utils'; const badgeVariants = cva( - 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', + 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', { variants: { variant: { diff --git a/src/components/ui-kit/breadcrumb.tsx b/src/components/ui-kit/breadcrumb.tsx index 053545814..1bad714a8 100644 --- a/src/components/ui-kit/breadcrumb.tsx +++ b/src/components/ui-kit/breadcrumb.tsx @@ -40,13 +40,7 @@ const BreadcrumbLink = React.forwardRef< >(({ asChild, className, ...props }, ref) => { const Comp = asChild ? Slot : 'a'; - return ( - - ); + return ; }); BreadcrumbLink.displayName = 'BreadcrumbLink'; diff --git a/src/components/ui-kit/button.tsx b/src/components/ui-kit/button.tsx index 84ee39abd..8480a194f 100644 --- a/src/components/ui-kit/button.tsx +++ b/src/components/ui-kit/button.tsx @@ -5,7 +5,7 @@ import { cn } from '../../lib/utils'; import { LoaderCircle } from 'lucide-react'; const buttonVariants = cva( - 'button-text-select inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-[6px] text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*="size-"])]:size-4 [&_svg]:shrink-0', + 'button-text-select inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-[6px] text-sm font-medium focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*="size-"])]:size-4 [&_svg]:shrink-0', { variants: { variant: { diff --git a/src/components/ui-kit/input.tsx b/src/components/ui-kit/input.tsx index 9462cec30..3e4960ed7 100644 --- a/src/components/ui-kit/input.tsx +++ b/src/components/ui-kit/input.tsx @@ -10,7 +10,7 @@ const Input = React.forwardRef( ))} diff --git a/src/components/ui-kit/table.tsx b/src/components/ui-kit/table.tsx index 519e4d307..babe23f57 100644 --- a/src/components/ui-kit/table.tsx +++ b/src/components/ui-kit/table.tsx @@ -47,10 +47,7 @@ const TableRow = React.forwardRef ( ) diff --git a/src/components/ui-kit/tabs.tsx b/src/components/ui-kit/tabs.tsx index 65860725a..1d728ad11 100644 --- a/src/components/ui-kit/tabs.tsx +++ b/src/components/ui-kit/tabs.tsx @@ -24,7 +24,7 @@ function TabsTrigger({ className, ...props }: React.ComponentProps( return (