diff --git a/worklenz-backend/src/controllers/auth-controller.ts b/worklenz-backend/src/controllers/auth-controller.ts index 8ad790710..10176c487 100644 --- a/worklenz-backend/src/controllers/auth-controller.ts +++ b/worklenz-backend/src/controllers/auth-controller.ts @@ -114,8 +114,11 @@ export default class AuthController extends WorklenzControllerBase { public static async reset_password(req: IWorkLenzRequest, res: IWorkLenzResponse) { const {email} = req.body; - const q = `SELECT id, email, google_id, password FROM users WHERE email = $1;`; - const result = await db.query(q, [email || null]); + // Normalize email to lowercase for case-insensitive comparison + const normalizedEmail = email ? email.toLowerCase().trim() : null; + + const q = `SELECT id, email, google_id, password FROM users WHERE LOWER(email) = $1;`; + const result = await db.query(q, [normalizedEmail]); if (!result.rowCount) return res.status(200).send(new ServerResponse(false, null, "Account does not exists!")); @@ -297,15 +300,16 @@ export default class AuthController extends WorklenzControllerBase { } // Check for existing local account - const localAccountResult = await db.query("SELECT 1 FROM users WHERE email = $1 AND password IS NOT NULL AND is_deleted IS FALSE;", [profile.email]); + const normalizedProfileEmail = profile.email.toLowerCase().trim(); + const localAccountResult = await db.query("SELECT 1 FROM users WHERE LOWER(email) = $1 AND password IS NOT NULL AND is_deleted IS FALSE;", [normalizedProfileEmail]); if (localAccountResult.rowCount) { return res.status(400).send(new ServerResponse(false, null, `No Google account exists for email ${profile.email}.`)); } // Check if user exists const userResult = await db.query( - "SELECT id, google_id, name, email, active_team FROM users WHERE google_id = $1 OR email = $2;", - [profile.sub, profile.email] + "SELECT id, google_id, name, email, active_team FROM users WHERE google_id = $1 OR LOWER(email) = $2;", + [profile.sub, normalizedProfileEmail] ); let user: any; @@ -317,7 +321,7 @@ export default class AuthController extends WorklenzControllerBase { const googleUserData = { id: profile.sub, displayName: profile.name, - email: profile.email, + email: normalizedProfileEmail, picture: profile.picture }; diff --git a/worklenz-backend/src/passport/passport-strategies/passport-local-login.ts b/worklenz-backend/src/passport/passport-strategies/passport-local-login.ts index d71c4a366..4d64fbdcb 100644 --- a/worklenz-backend/src/passport/passport-strategies/passport-local-login.ts +++ b/worklenz-backend/src/passport/passport-strategies/passport-local-login.ts @@ -16,12 +16,15 @@ async function handleLogin(req: Request, email: string, password: string, done: } try { + // Normalize email to lowercase for case-insensitive comparison + const normalizedEmail = email.toLowerCase().trim(); + const q = `SELECT id, email, google_id, password FROM users - WHERE email = $1 + WHERE LOWER(email) = $1 AND google_id IS NULL AND is_deleted IS FALSE;`; - const result = await db.query(q, [email]); + const result = await db.query(q, [normalizedEmail]); const [data] = result.rows; @@ -33,7 +36,7 @@ async function handleLogin(req: Request, email: string, password: string, done: const passwordMatch = bcrypt.compareSync(password, data.password); - if (passwordMatch && email === data.email) { + if (passwordMatch) { delete data.password; const successMsg = "User successfully logged in"; req.flash(SUCCESS_KEY, successMsg); diff --git a/worklenz-backend/src/passport/passport-strategies/passport-local-signup.ts b/worklenz-backend/src/passport/passport-strategies/passport-local-signup.ts index fddad7f55..42b551f72 100644 --- a/worklenz-backend/src/passport/passport-strategies/passport-local-signup.ts +++ b/worklenz-backend/src/passport/passport-strategies/passport-local-signup.ts @@ -13,10 +13,10 @@ async function isGoogleAccountFound(email: string) { const q = ` SELECT 1 FROM users - WHERE email = $1 + WHERE LOWER(email) = $1 AND google_id IS NOT NULL; `; - const result = await db.query(q, [email]); + const result = await db.query(q, [email.toLowerCase().trim()]); return !!result.rowCount; } @@ -24,10 +24,10 @@ async function isAccountDeactivated(email: string) { const q = ` SELECT 1 FROM users - WHERE email = $1 + WHERE LOWER(email) = $1 AND is_deleted = TRUE; `; - const result = await db.query(q, [email]); + const result = await db.query(q, [email.toLowerCase().trim()]); return !!result.rowCount; } @@ -41,7 +41,7 @@ async function registerUser(password: string, team_id: string, name: string, tea const body = { name, team_name, - email, + email: email.toLowerCase().trim(), password: encryptedPassword, timezone, invited_team_id: teamId, diff --git a/worklenz-backend/src/views/_tawk-to.pug b/worklenz-backend/src/views/_tawk-to.pug deleted file mode 100644 index 4790d6521..000000000 --- a/worklenz-backend/src/views/_tawk-to.pug +++ /dev/null @@ -1,11 +0,0 @@ -if !isInternalServer() - script(type='text/javascript'). - var Tawk_API=Tawk_API||{}, Tawk_LoadStart=new Date(); - (function(){ - var s1=document.createElement("script"),s0=document.getElementsByTagName("script")[0]; - s1.async=true; - s1.src='https://embed.tawk.to/666fc2b29a809f19fb3e837a/1i0i912sj'; - s1.charset='UTF-8'; - s1.setAttribute('crossorigin','*'); - s0.parentNode.insertBefore(s1,s0); - })(); \ No newline at end of file diff --git a/worklenz-frontend/public/locales/alb/common.json b/worklenz-frontend/public/locales/alb/common.json index f6d934771..edd27224e 100644 --- a/worklenz-frontend/public/locales/alb/common.json +++ b/worklenz-frontend/public/locales/alb/common.json @@ -32,6 +32,12 @@ "trial-badge-hours": "{{hours}} orë të mbetura", "trial-alert-admin-note": "Ju mund të aksesoni ende Qendrën e Administrimit për të menaxhuar abonimin tuaj", "trial-alert-dismiss": "Hidhe për sot", + "switch-team-to-continue": "Ndërro skuadrën për të vazhduar", + "current-team": "Skuadra aktuale", + "select-team": "Zgjidh skuadrën", + "owned-by": "Në pronësi të", + "switch-team-active-subscription": "Kaloni në një skuadër me një abonim aktiv për të vazhduar punën", + "or": "ose", "license-expiring-soon": "Licenca juaj skadon në {{days}} ditë", "license-expiring-soon_plural": "Licenca juaj skadon në {{days}} ditë", "license-expiring-today": "Licenca juaj skadon sot!", diff --git a/worklenz-frontend/public/locales/de/common.json b/worklenz-frontend/public/locales/de/common.json index aaa8c93fa..79acf761e 100644 --- a/worklenz-frontend/public/locales/de/common.json +++ b/worklenz-frontend/public/locales/de/common.json @@ -42,6 +42,12 @@ "trial-badge-hours": "{{hours}}h übrig", "trial-alert-admin-note": "Sie können weiterhin auf das Admin Center zugreifen, um Ihr Abonnement zu verwalten", "trial-alert-dismiss": "Für heute ausblenden", + "switch-team-to-continue": "Team wechseln zum Fortfahren", + "current-team": "Aktuelles Team", + "select-team": "Team auswählen", + "owned-by": "Gehört", + "switch-team-active-subscription": "Wechseln Sie zu einem Team mit einem aktiven Abonnement, um weiterzuarbeiten", + "or": "oder", "license-expiring-soon": "Ihre Lizenz läuft in {{days}} Tag ab", "license-expiring-soon_plural": "Ihre Lizenz läuft in {{days}} Tagen ab", "license-expiring-today": "Ihre Lizenz läuft heute ab!", diff --git a/worklenz-frontend/public/locales/en/common.json b/worklenz-frontend/public/locales/en/common.json index 22b224847..e18b0a7be 100644 --- a/worklenz-frontend/public/locales/en/common.json +++ b/worklenz-frontend/public/locales/en/common.json @@ -42,6 +42,12 @@ "trial-badge-hours": "{{hours}}h left", "trial-alert-admin-note": "You can still access the Admin Center to manage your subscription", "trial-alert-dismiss": "Dismiss for today", + "switch-team-to-continue": "Switch Team to Continue", + "current-team": "Current Team", + "select-team": "Select Team", + "owned-by": "Owned by", + "switch-team-active-subscription": "Switch to a team with an active subscription to continue working", + "or": "or", "license-expiring-soon": "Your license expires in {{days}} day", "license-expiring-soon_plural": "Your license expires in {{days}} days", "license-expiring-today": "Your license expires today!", diff --git a/worklenz-frontend/public/locales/es/common.json b/worklenz-frontend/public/locales/es/common.json index d9b65732b..54f548603 100644 --- a/worklenz-frontend/public/locales/es/common.json +++ b/worklenz-frontend/public/locales/es/common.json @@ -42,6 +42,12 @@ "trial-badge-hours": "{{hours}}h restantes", "trial-alert-admin-note": "Aún puede acceder al Centro de administración para gestionar su suscripción", "trial-alert-dismiss": "Descartar por hoy", + "switch-team-to-continue": "Cambiar equipo para continuar", + "current-team": "Equipo actual", + "select-team": "Seleccionar equipo", + "owned-by": "Propiedad de", + "switch-team-active-subscription": "Cambie a un equipo con una suscripción activa para continuar trabajando", + "or": "o", "license-expiring-soon": "Su licencia expira en {{days}} día", "license-expiring-soon_plural": "Su licencia expira en {{days}} días", "license-expiring-today": "¡Su licencia expira hoy!", diff --git a/worklenz-frontend/public/locales/pt/common.json b/worklenz-frontend/public/locales/pt/common.json index d98fdaab7..997551287 100644 --- a/worklenz-frontend/public/locales/pt/common.json +++ b/worklenz-frontend/public/locales/pt/common.json @@ -32,6 +32,12 @@ "trial-badge-hours": "{{hours}}h restantes", "trial-alert-admin-note": "Você ainda pode acessar o Centro de administração para gerenciar sua assinatura", "trial-alert-dismiss": "Dispensar por hoje", + "switch-team-to-continue": "Trocar equipe para continuar", + "current-team": "Equipe atual", + "select-team": "Selecionar equipe", + "owned-by": "Propriedade de", + "switch-team-active-subscription": "Mude para uma equipe com uma assinatura ativa para continuar trabalhando", + "or": "ou", "license-expiring-soon": "Sua licença expira em {{days}} dia", "license-expiring-soon_plural": "Sua licença expira em {{days}} dias", "license-expiring-today": "Sua licença expira hoje!", diff --git a/worklenz-frontend/public/locales/zh/common.json b/worklenz-frontend/public/locales/zh/common.json index 7fa13278b..dc88d3833 100644 --- a/worklenz-frontend/public/locales/zh/common.json +++ b/worklenz-frontend/public/locales/zh/common.json @@ -32,6 +32,12 @@ "trial-badge-hours": "剩余{{hours}}小时", "trial-alert-admin-note": "您仍可以访问管理中心管理您的订阅", "trial-alert-dismiss": "今日暂不提醒", + "switch-team-to-continue": "切换团队以继续", + "current-team": "当前团队", + "select-team": "选择团队", + "owned-by": "拥有者", + "switch-team-active-subscription": "切换到有有效订阅的团队以继续工作", + "or": "或", "license-expiring-soon": "您的许可证还有 {{days}} 天到期", "license-expiring-soon_plural": "您的许可证还有 {{days}} 天到期", "license-expiring-today": "您的许可证今天到期!", diff --git a/worklenz-frontend/src/app/routes/index.tsx b/worklenz-frontend/src/app/routes/index.tsx index 669c58310..d5cba85aa 100644 --- a/worklenz-frontend/src/app/routes/index.tsx +++ b/worklenz-frontend/src/app/routes/index.tsx @@ -74,21 +74,15 @@ export const LicenseExpiryGuard = memo(({ children }: GuardProps) => { const currentSession = authService?.getCurrentSession(); const subscriptionType = currentSession?.subscription_type as ISUBSCRIPTION_TYPE; - // If license is expired and not on admin center, block the content entirely + // If license is expired and not on admin center, show modal overlay if (showModal) { return ( -
-
- {children} -
+ <> + {/* Render children normally */} + {children} + {/* Show modal as an overlay */} -
+ ); } diff --git a/worklenz-frontend/src/components/LicenseExpiredModal/LicenseExpiredModal.css b/worklenz-frontend/src/components/LicenseExpiredModal/LicenseExpiredModal.css new file mode 100644 index 000000000..9bd988bfc --- /dev/null +++ b/worklenz-frontend/src/components/LicenseExpiredModal/LicenseExpiredModal.css @@ -0,0 +1,36 @@ +.license-expired-modal-wrap .ant-modal-wrap { + z-index: 1050 !important; +} + +.license-expired-modal-wrap .ant-modal-mask { + z-index: 1049 !important; +} + +/* Ensure dropdowns in the modal appear above the modal */ +.switch-team-dropdown { + z-index: 1060 !important; +} + +/* Theme-aware dropdown styling */ +.switch-team-dropdown .ant-dropdown-menu { + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +[data-theme="dark"] .switch-team-dropdown .ant-dropdown-menu { + background-color: #262626; + border: 1px solid #434343; +} + +[data-theme="dark"] .switch-team-dropdown .ant-dropdown-menu-item { + color: #fff; +} + +[data-theme="dark"] .switch-team-dropdown .ant-dropdown-menu-item:hover { + background-color: #303030; +} + +/* Ensure the modal content is properly styled */ +.license-expired-modal-wrap .ant-modal-content { + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); +} \ No newline at end of file diff --git a/worklenz-frontend/src/components/LicenseExpiredModal/LicenseExpiredModal.tsx b/worklenz-frontend/src/components/LicenseExpiredModal/LicenseExpiredModal.tsx index 304b8a423..fb56ee25c 100644 --- a/worklenz-frontend/src/components/LicenseExpiredModal/LicenseExpiredModal.tsx +++ b/worklenz-frontend/src/components/LicenseExpiredModal/LicenseExpiredModal.tsx @@ -1,11 +1,20 @@ -import { Modal, Button, Typography, Space, Card, Tag } from '@/shared/antd-imports'; +import { Modal, Button, Typography, Space, Card, Tag, Dropdown, Flex, Divider } from '@/shared/antd-imports'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { useEffect, useState } from 'react'; -import { ClockCircleOutlined, CrownOutlined, CustomerServiceOutlined } from '@ant-design/icons'; +import { ClockCircleOutlined, CrownOutlined, CustomerServiceOutlined, BankOutlined, CaretDownFilled, CheckCircleFilled } from '@ant-design/icons'; import { ISUBSCRIPTION_TYPE } from '@/shared/constants'; import { supportApiService } from '@/api/support/support.api.service'; import { useAuthService } from '@/hooks/useAuth'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { useAppSelector } from '@/hooks/useAppSelector'; +import { fetchTeams, setActiveTeam } from '@/features/teams/teamSlice'; +import { verifyAuthentication } from '@/features/auth/authSlice'; +import { setUser } from '@/features/user/userSlice'; +import CustomAvatar from '@/components/CustomAvatar'; +import { colors } from '@/styles/colors'; +import { createAuthService } from '@/services/auth/auth.service'; +import './LicenseExpiredModal.css'; const { Title, Text, Paragraph } = Typography; @@ -16,43 +25,105 @@ interface LicenseExpiredModalProps { export const LicenseExpiredModal = ({ open, subscriptionType = ISUBSCRIPTION_TYPE.TRIAL }: LicenseExpiredModalProps) => { const navigate = useNavigate(); + const dispatch = useAppDispatch(); const { t } = useTranslation('common'); const authService = useAuthService(); + const authServiceInstance = createAuthService(navigate); const [visible, setVisible] = useState(open); const [isContactingSupport, setIsContactingSupport] = useState(false); const [messageSent, setMessageSent] = useState(false); + + // Team switching state + const teamsList = useAppSelector(state => state.teamReducer.teamsList); + const session = authService?.getCurrentSession(); + const themeMode = useAppSelector(state => state.themeReducer.mode); useEffect(() => { setVisible(open); - // Prevent scrolling when modal is open and add custom backdrop + // Fetch teams when modal opens if (open) { + dispatch(fetchTeams()); document.body.style.overflow = 'hidden'; - - // Create custom backdrop that excludes navbar - const backdrop = document.createElement('div'); - backdrop.id = 'license-modal-backdrop'; - backdrop.style.cssText = ` - position: fixed; - top: 64px; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.85); - backdrop-filter: blur(4px); - z-index: 999; - pointer-events: none; - `; - document.body.appendChild(backdrop); } return () => { document.body.style.overflow = 'unset'; - const backdrop = document.getElementById('license-modal-backdrop'); - if (backdrop) { - document.body.removeChild(backdrop); - } }; - }, [open]); + }, [open, dispatch]); + + const isActiveTeam = (teamId: string): boolean => { + if (!teamId || !session?.team_id) return false; + return teamId === session.team_id; + }; + + const handleVerifyAuth = async () => { + const result = await dispatch(verifyAuthentication()).unwrap(); + if (result.authenticated) { + dispatch(setUser(result.user)); + authServiceInstance.setCurrentSession(result.user); + } + }; + + const handleTeamSelect = async (id: string) => { + if (!id) return; + + await dispatch(setActiveTeam(id)); + await handleVerifyAuth(); + window.location.reload(); + }; + + const renderTeamCard = (team: any, index: number) => ( + handleTeamSelect(team.id)} + bordered={false} + style={{ + width: '100%', + cursor: 'pointer', + backgroundColor: themeMode === 'dark' ? '#262626' : '#fff', + color: themeMode === 'dark' ? '#fff' : '#000' + }} + > + + + + + + + {t('owned-by')} {team.owns_by} + + + {team.name} + + + + + + {index < teamsList.length - 1 && } + + + ); + + const dropdownItems = + teamsList?.map((team, index) => ({ + key: team.id || '', + label: renderTeamCard(team, index), + type: 'item' as const, + })) || []; const handleUpgrade = async () => { if (subscriptionType === ISUBSCRIPTION_TYPE.CUSTOM) { @@ -149,17 +220,22 @@ export const LicenseExpiredModal = ({ open, subscriptionType = ISUBSCRIPTION_TYP closable={false} footer={null} centered - width={600} + width={650} maskClosable={false} keyboard={false} - mask={false} + mask={true} + maskStyle={{ + backgroundColor: 'rgba(0, 0, 0, 0.85)', + backdropFilter: 'blur(4px)' + }} style={{ - zIndex: 1000 + zIndex: 1050 }} wrapClassName="license-expired-modal-wrap" >
+ {/* Icon and Title */}
@@ -225,6 +301,104 @@ export const LicenseExpiredModal = ({ open, subscriptionType = ISUBSCRIPTION_TYP } + {/* Team Switcher - Show below upgrade button if multiple teams exist */} + {teamsList && teamsList.length > 1 && ( + <> +
+
+ + {t('or')} + +
+ + + + {t('switch-team-to-continue')} + + + + + + {t('switch-team-active-subscription')} + + + + + )} + {/* Note */} Note diff --git a/worklenz-frontend/src/components/TawkTo.tsx b/worklenz-frontend/src/components/TawkTo.tsx deleted file mode 100644 index c447a0500..000000000 --- a/worklenz-frontend/src/components/TawkTo.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { useEffect } from 'react'; - -// Add TypeScript declarations for Tawk_API -declare global { - interface Window { - Tawk_API?: any; - Tawk_LoadStart?: Date; - } -} - -interface TawkToProps { - propertyId: string; - widgetId: string; -} - -const TawkTo: React.FC = ({ propertyId, widgetId }) => { - useEffect(() => { - // Initialize tawk.to chat - const s1 = document.createElement('script'); - s1.async = true; - s1.src = `https://embed.tawk.to/${propertyId}/${widgetId}`; - s1.setAttribute('crossorigin', '*'); - - const s0 = document.getElementsByTagName('script')[0]; - s0.parentNode?.insertBefore(s1, s0); - - return () => { - // Clean up when the component unmounts - // Remove the script tag - const tawkScript = document.querySelector(`script[src*="tawk.to/${propertyId}"]`); - if (tawkScript && tawkScript.parentNode) { - tawkScript.parentNode.removeChild(tawkScript); - } - - // Remove the tawk.to iframe - const tawkIframe = document.getElementById('tawk-iframe'); - if (tawkIframe) { - tawkIframe.remove(); - } - - // Reset Tawk globals - delete window.Tawk_API; - delete window.Tawk_LoadStart; - }; - }, [propertyId, widgetId]); - - return null; -}; - -export default TawkTo; diff --git a/worklenz-frontend/src/components/projects/project-create-button/project-create-button.tsx b/worklenz-frontend/src/components/projects/project-create-button/project-create-button.tsx index 2cb2729b2..795e79215 100644 --- a/worklenz-frontend/src/components/projects/project-create-button/project-create-button.tsx +++ b/worklenz-frontend/src/components/projects/project-create-button/project-create-button.tsx @@ -12,7 +12,7 @@ import { } from '@/features/project/project-drawer.slice'; import { IProjectViewModel } from '@/types/project/projectViewModel.types'; import { projectTemplatesApiService } from '@/api/project-templates/project-templates.api.service'; -import { evt_projects_create_click } from '@/shared/worklenz-analytics-events'; +import { evt_projects_create_click, evt_project_import_from_template_click } from '@/shared/worklenz-analytics-events'; import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; interface CreateProjectButtonProps { className?: string; @@ -36,6 +36,7 @@ const CreateProjectButton: React.FC = ({ className }) }, [location]); const handleTemplateDrawerOpen = () => { + trackMixpanelEvent(evt_project_import_from_template_click); setIsTemplateDrawerOpen(true); }; diff --git a/worklenz-frontend/src/components/task-management/CreateTaskModal.tsx b/worklenz-frontend/src/components/task-management/CreateTaskModal.tsx index 54d3ad16a..cc81ee08d 100644 --- a/worklenz-frontend/src/components/task-management/CreateTaskModal.tsx +++ b/worklenz-frontend/src/components/task-management/CreateTaskModal.tsx @@ -19,6 +19,8 @@ import { useSocket } from '@/socket/socketContext'; import { SocketEvents } from '@/shared/socket-events'; import { useAuthService } from '@/hooks/useAuth'; import { fetchTasksV3 } from '@/features/task-management/task-management.slice'; +import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; +import { evt_project_task_create } from '@/shared/worklenz-analytics-events'; import './CreateTaskModal.css'; const { Title, Text } = Typography; @@ -351,6 +353,7 @@ const CreateTaskModal: React.FC = ({ const [form] = Form.useForm(); const [activeTab, setActiveTab] = useState('task-info'); const dispatch = useAppDispatch(); + const { trackMixpanelEvent } = useMixpanelTracking(); // Redux state const isDarkMode = useAppSelector(state => state.themeReducer?.mode === 'dark'); @@ -381,6 +384,9 @@ const CreateTaskModal: React.FC = ({ reporter_id: user.id, }; + // Track analytics event + trackMixpanelEvent(evt_project_task_create); + // Create task via socket socket.emit(SocketEvents.QUICK_TASK.toString(), taskData); diff --git a/worklenz-frontend/src/features/navbar/navbar.tsx b/worklenz-frontend/src/features/navbar/navbar.tsx index 71f6fe2fb..cac66ec45 100644 --- a/worklenz-frontend/src/features/navbar/navbar.tsx +++ b/worklenz-frontend/src/features/navbar/navbar.tsx @@ -22,6 +22,7 @@ import { authApiService } from '@/api/auth/auth.api.service'; import { ISUBSCRIPTION_TYPE } from '@/shared/constants'; import logger from '@/utils/errorLogger'; import TimerButton from './timers/TimerButton'; +import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; const Navbar = () => { const [current, setCurrent] = useState('home'); @@ -32,6 +33,7 @@ const Navbar = () => { const { isDesktop, isMobile, isTablet } = useResponsive(); const { t } = useTranslation('navbar'); const authService = useAuthService(); + const { setIdentity } = useMixpanelTracking(); const [navRoutesList, setNavRoutesList] = useState(navRoutes); const [isOwnerOrAdmin, setIsOwnerOrAdmin] = useState(authService.isOwnerOrAdmin()); const showUpgradeTypes = [ @@ -44,6 +46,7 @@ const Navbar = () => { .then(authorizeResponse => { if (authorizeResponse.authenticated) { authService.setCurrentSession(authorizeResponse.user); + setIdentity(authorizeResponse.user); setIsOwnerOrAdmin(!!(authorizeResponse.user.is_admin || authorizeResponse.user.owner)); } }) diff --git a/worklenz-frontend/src/features/navbar/switch-team/SwitchTeamButton.tsx b/worklenz-frontend/src/features/navbar/switch-team/SwitchTeamButton.tsx index b2dddbc40..7f28afde0 100644 --- a/worklenz-frontend/src/features/navbar/switch-team/SwitchTeamButton.tsx +++ b/worklenz-frontend/src/features/navbar/switch-team/SwitchTeamButton.tsx @@ -18,6 +18,8 @@ import { useAuthService } from '@/hooks/useAuth'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { createAuthService } from '@/services/auth/auth.service'; +import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; +import { evt_common_switch_team } from '@/shared/worklenz-analytics-events'; // Components import CustomAvatar from '@/components/CustomAvatar'; @@ -34,6 +36,7 @@ const SwitchTeamButton = () => { const { getCurrentSession } = useAuthService(); const session = getCurrentSession(); const { t } = useTranslation('navbar'); + const { setIdentity, trackMixpanelEvent } = useMixpanelTracking(); // Selectors const teamsList = useAppSelector(state => state.teamReducer.teamsList); @@ -53,12 +56,14 @@ const SwitchTeamButton = () => { if (result.authenticated) { dispatch(setUser(result.user)); authService.setCurrentSession(result.user); + setIdentity(result.user); } }; const handleTeamSelect = async (id: string) => { if (!id) return; + trackMixpanelEvent(evt_common_switch_team); await dispatch(setActiveTeam(id)); await handleVerifyAuth(); window.location.reload(); diff --git a/worklenz-frontend/src/features/navbar/upgrade-plan/UpgradePlanButton.tsx b/worklenz-frontend/src/features/navbar/upgrade-plan/UpgradePlanButton.tsx index 804d9a9c3..c4eb7f2d5 100644 --- a/worklenz-frontend/src/features/navbar/upgrade-plan/UpgradePlanButton.tsx +++ b/worklenz-frontend/src/features/navbar/upgrade-plan/UpgradePlanButton.tsx @@ -23,7 +23,6 @@ const UpgradePlanButton = () => { // Calculate days remaining for expirable subscription types const expirableTypes = [ ISUBSCRIPTION_TYPE.TRIAL, - ISUBSCRIPTION_TYPE.LIFE_TIME_DEAL, ISUBSCRIPTION_TYPE.PADDLE, ISUBSCRIPTION_TYPE.CUSTOM ]; diff --git a/worklenz-frontend/src/hooks/useAuthStatus.ts b/worklenz-frontend/src/hooks/useAuthStatus.ts index 6c3c9e867..d67f0d71b 100644 --- a/worklenz-frontend/src/hooks/useAuthStatus.ts +++ b/worklenz-frontend/src/hooks/useAuthStatus.ts @@ -30,7 +30,6 @@ export const useAuthStatus = () => { // Check using valid_till_date for subscription types that can expire const expirableTypes = [ ISUBSCRIPTION_TYPE.TRIAL, - ISUBSCRIPTION_TYPE.LIFE_TIME_DEAL, ISUBSCRIPTION_TYPE.PADDLE, ISUBSCRIPTION_TYPE.CUSTOM ]; diff --git a/worklenz-frontend/src/hooks/useMixpanelTracking.tsx b/worklenz-frontend/src/hooks/useMixpanelTracking.tsx index d5cd64ca2..0ef85e009 100644 --- a/worklenz-frontend/src/hooks/useMixpanelTracking.tsx +++ b/worklenz-frontend/src/hooks/useMixpanelTracking.tsx @@ -7,39 +7,83 @@ import logger from '@/utils/errorLogger'; export const useMixpanelTracking = () => { const auth = useAuthService(); - const token = useMemo(() => { + const { token, isProductionEnvironment } = useMemo(() => { const host = window.location.host; - if (host === 'uat.worklenz.com' || host === 'dev.worklenz.com' || host === 'api.worklenz.com') { - return import.meta.env.VITE_MIXPANEL_TOKEN; - } - if (host === 'app.worklenz.com' || host === 'v2.worklenz.com') { - return import.meta.env.VITE_MIXPANEL_TOKEN; - } - return import.meta.env.VITE_MIXPANEL_TOKEN; + const isProduction = host === 'app.worklenz.com'; + + return { + token: isProduction ? import.meta.env.VITE_MIXPANEL_TOKEN : null, + isProductionEnvironment: isProduction + }; }, []); useEffect(() => { - initMixpanel(token); - }, [token]); + if (isProductionEnvironment && token) { + try { + initMixpanel(token); + logger.info('Mixpanel initialized successfully for production'); + + // Set identity if user is already authenticated on page load/reload + const currentUser = auth.getCurrentSession(); + if (currentUser?.id) { + mixpanel.identify(currentUser.id); + mixpanel.people.set({ + $user_id: currentUser.id, + $name: currentUser.name, + $email: currentUser.email, + $avatar: currentUser.avatar_url, + }); + logger.debug('Mixpanel identity set on initialization', currentUser.id); + } + } catch (error) { + logger.error('Failed to initialize Mixpanel:', error); + } + } else { + logger.info('Mixpanel not initialized - not in production environment or missing token'); + } + }, [token, isProductionEnvironment, auth]); const setIdentity = useCallback((user: any) => { + if (!isProductionEnvironment) { + logger.debug('Mixpanel setIdentity skipped - not in production environment'); + return; + } + if (user?.id) { - mixpanel.identify(user.id); - mixpanel.people.set({ - $user_id: user.id, - $name: user.name, - $email: user.email, - $avatar: user.avatar_url, - }); + try { + mixpanel.identify(user.id); + mixpanel.people.set({ + $user_id: user.id, + $name: user.name, + $email: user.email, + $avatar: user.avatar_url, + }); + } catch (error) { + logger.error('Error setting Mixpanel identity:', error); + } } - }, []); + }, [isProductionEnvironment]); const reset = useCallback(() => { - mixpanel.reset(); - }, []); + if (!isProductionEnvironment) { + logger.debug('Mixpanel reset skipped - not in production environment'); + return; + } + + try { + mixpanel.reset(); + } catch (error) { + logger.error('Error resetting Mixpanel:', error); + } + }, [isProductionEnvironment]); const trackMixpanelEvent = useCallback( (event: string, properties?: Dict) => { + if (!isProductionEnvironment) { + logger.debug(`Mixpanel tracking skipped - not in production environment. Event: ${event}`, properties); + return; + } + try { const currentUser = auth.getCurrentSession(); const props = { @@ -48,11 +92,12 @@ export const useMixpanelTracking = () => { }; mixpanel.track(event, props); + logger.debug(`Mixpanel event tracked: ${event}`, props); } catch (e) { logger.error('Error tracking mixpanel event', e); } }, - [auth.getCurrentSession] + [auth.getCurrentSession, isProductionEnvironment] ); return { diff --git a/worklenz-frontend/src/pages/admin-center/overview/overview.tsx b/worklenz-frontend/src/pages/admin-center/overview/overview.tsx index dd46d6057..5c2c9f9cc 100644 --- a/worklenz-frontend/src/pages/admin-center/overview/overview.tsx +++ b/worklenz-frontend/src/pages/admin-center/overview/overview.tsx @@ -12,6 +12,8 @@ import { adminCenterApiService } from '@/api/admin-center/admin-center.api.servi import { IOrganization, IOrganizationAdmin } from '@/types/admin-center/admin-center.types'; import logger from '@/utils/errorLogger'; import { tr } from 'date-fns/locale'; +import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; +import { evt_admin_center_overview_visit } from '@/shared/worklenz-analytics-events'; const { Text } = Typography; @@ -19,6 +21,7 @@ const Overview: React.FC = () => { const [organization, setOrganization] = useState(null); const [organizationAdmins, setOrganizationAdmins] = useState(null); const [loadingAdmins, setLoadingAdmins] = useState(false); + const { trackMixpanelEvent } = useMixpanelTracking(); const themeMode = useAppSelector((state: RootState) => state.themeReducer.mode); const { t } = useTranslation('admin-center/overview'); @@ -49,9 +52,10 @@ const Overview: React.FC = () => { }; useEffect(() => { + trackMixpanelEvent(evt_admin_center_overview_visit); getOrganizationDetails(); getOrganizationAdmins(); - }, []); + }, [trackMixpanelEvent]); return (
diff --git a/worklenz-frontend/src/pages/admin-center/projects/projects.tsx b/worklenz-frontend/src/pages/admin-center/projects/projects.tsx index 46eaf2149..d10a1d40e 100644 --- a/worklenz-frontend/src/pages/admin-center/projects/projects.tsx +++ b/worklenz-frontend/src/pages/admin-center/projects/projects.tsx @@ -11,6 +11,8 @@ import { formatDateTimeWithLocale } from '@/utils/format-date-time-with-locale'; import logger from '@/utils/errorLogger'; import { deleteProject } from '@features/projects/projectsSlice'; import './projects.css'; +import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; +import { evt_admin_center_projects_visit } from '@/shared/worklenz-analytics-events'; import { Button, Card, @@ -39,6 +41,7 @@ const Projects: React.FC = () => { order: 'desc', search: '', }); + const { trackMixpanelEvent } = useMixpanelTracking(); const dispatch = useAppDispatch(); @@ -70,6 +73,10 @@ const Projects: React.FC = () => { } }; + useEffect(() => { + trackMixpanelEvent(evt_admin_center_projects_visit); + }, [trackMixpanelEvent]); + useEffect(() => { fetchProjects(); }, [ diff --git a/worklenz-frontend/src/pages/admin-center/teams/teams.tsx b/worklenz-frontend/src/pages/admin-center/teams/teams.tsx index f5043392e..d5b317344 100644 --- a/worklenz-frontend/src/pages/admin-center/teams/teams.tsx +++ b/worklenz-frontend/src/pages/admin-center/teams/teams.tsx @@ -20,6 +20,8 @@ import logger from '@/utils/errorLogger'; import { RootState } from '@/app/store'; import { useTranslation } from 'react-i18next'; import AddTeamDrawer from '@/components/admin-center/teams/add-team-drawer/add-team-drawer'; +import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; +import { evt_admin_center_teams_visit } from '@/shared/worklenz-analytics-events'; export interface IRequestParams extends IOrganizationTeamRequestParams { total: number; @@ -28,6 +30,7 @@ export interface IRequestParams extends IOrganizationTeamRequestParams { const Teams: React.FC = () => { const themeMode = useAppSelector((state: RootState) => state.themeReducer.mode); const { t } = useTranslation('admin-center/teams'); + const { trackMixpanelEvent } = useMixpanelTracking(); const [showAddTeamDrawer, setShowAddTeamDrawer] = useState(false); @@ -64,6 +67,11 @@ const Teams: React.FC = () => { } }; + useEffect(() => { + trackMixpanelEvent(evt_admin_center_teams_visit); + fetchTeams(); + }, [trackMixpanelEvent]); + useEffect(() => { fetchTeams(); }, [requestParams.search]); diff --git a/worklenz-frontend/src/pages/admin-center/users/users.tsx b/worklenz-frontend/src/pages/admin-center/users/users.tsx index d8db586cf..cce10eaef 100644 --- a/worklenz-frontend/src/pages/admin-center/users/users.tsx +++ b/worklenz-frontend/src/pages/admin-center/users/users.tsx @@ -11,9 +11,12 @@ import { DEFAULT_PAGE_SIZE, PAGE_SIZE_OPTIONS } from '@/shared/constants'; import logger from '@/utils/errorLogger'; import { formatDateTimeWithLocale } from '@/utils/format-date-time-with-locale'; import SingleAvatar from '@/components/common/single-avatar/single-avatar'; +import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; +import { evt_admin_center_users_visit } from '@/shared/worklenz-analytics-events'; const Users: React.FC = () => { const { t } = useTranslation('admin-center/users'); + const { trackMixpanelEvent } = useMixpanelTracking(); const [isLoading, setIsLoading] = useState(false); const [users, setUsers] = useState([]); @@ -73,6 +76,10 @@ const Users: React.FC = () => { }, ]; + useEffect(() => { + trackMixpanelEvent(evt_admin_center_users_visit); + }, [trackMixpanelEvent]); + useEffect(() => { fetchUsers(); }, [requestParams.searchTerm, requestParams.page, requestParams.pageSize]); diff --git a/worklenz-frontend/src/pages/auth/ForgotPasswordPage.tsx b/worklenz-frontend/src/pages/auth/ForgotPasswordPage.tsx index da3e35636..e9c76ed86 100644 --- a/worklenz-frontend/src/pages/auth/ForgotPasswordPage.tsx +++ b/worklenz-frontend/src/pages/auth/ForgotPasswordPage.tsx @@ -64,7 +64,9 @@ const ForgotPasswordPage = () => { if (values.email.trim() === '') return; try { setIsLoading(true); - const result = await dispatch(resetPassword(values.email)).unwrap(); + // Normalize email to lowercase for case-insensitive comparison + const normalizedEmail = values.email.toLowerCase().trim(); + const result = await dispatch(resetPassword(normalizedEmail)).unwrap(); if (result.done) { trackMixpanelEvent(evt_reset_password_click); setIsSuccess(true); diff --git a/worklenz-frontend/src/pages/auth/LoggingOutPage.tsx b/worklenz-frontend/src/pages/auth/LoggingOutPage.tsx index c5e94c25b..1d6fa3292 100644 --- a/worklenz-frontend/src/pages/auth/LoggingOutPage.tsx +++ b/worklenz-frontend/src/pages/auth/LoggingOutPage.tsx @@ -6,16 +6,25 @@ import { useAuthService } from '@/hooks/useAuth'; import { useMediaQuery } from 'react-responsive'; import { authApiService } from '@/api/auth/auth.api.service'; import CacheCleanup from '@/utils/cache-cleanup'; +import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; +import { evt_common_logout } from '@/shared/worklenz-analytics-events'; const LoggingOutPage = () => { const navigate = useNavigate(); const auth = useAuthService(); const { t } = useTranslation('auth/auth-common'); const isMobile = useMediaQuery({ query: '(max-width: 576px)' }); + const { reset, trackMixpanelEvent } = useMixpanelTracking(); useEffect(() => { const logout = async () => { try { + // Track logout event + trackMixpanelEvent(evt_common_logout); + + // Reset Mixpanel identity + reset(); + // Clear local session await auth.signOut(); diff --git a/worklenz-frontend/src/pages/auth/LoginPage.tsx b/worklenz-frontend/src/pages/auth/LoginPage.tsx index 1f8b8824a..df4a4ab96 100644 --- a/worklenz-frontend/src/pages/auth/LoginPage.tsx +++ b/worklenz-frontend/src/pages/auth/LoginPage.tsx @@ -106,7 +106,13 @@ const LoginPage: React.FC = () => { // localStorage.setItem(WORKLENZ_REDIRECT_PROJ_KEY, teamId); // } - const result = await dispatch(login(values)).unwrap(); + // Normalize email to lowercase for case-insensitive comparison + const normalizedValues = { + ...values, + email: values.email.toLowerCase().trim() + }; + + const result = await dispatch(login(normalizedValues)).unwrap(); if (result.authenticated) { message.success(t('successMessage')); setSession(result.user); diff --git a/worklenz-frontend/src/pages/auth/SignupPage.tsx b/worklenz-frontend/src/pages/auth/SignupPage.tsx index 065989d23..fd3534802 100644 --- a/worklenz-frontend/src/pages/auth/SignupPage.tsx +++ b/worklenz-frontend/src/pages/auth/SignupPage.tsx @@ -215,7 +215,7 @@ const SignupPage = () => { const body = { name: values.name, - email: values.email, + email: values.email.toLowerCase().trim(), password: values.password, }; diff --git a/worklenz-frontend/src/pages/home/home-page.tsx b/worklenz-frontend/src/pages/home/home-page.tsx index 37b6b618c..c4f3047c3 100644 --- a/worklenz-frontend/src/pages/home/home-page.tsx +++ b/worklenz-frontend/src/pages/home/home-page.tsx @@ -129,6 +129,7 @@ const HomePage = memo(() => { {createPortal(, document.body, 'home-task-drawer')} {createPortal( {}} />, document.body, 'project-drawer')} + {createPortal(, document.body, 'survey-modal')}
); }); diff --git a/worklenz-frontend/src/pages/projects/project-list.tsx b/worklenz-frontend/src/pages/projects/project-list.tsx index 82c4b58e5..9a00a3f9a 100644 --- a/worklenz-frontend/src/pages/projects/project-list.tsx +++ b/worklenz-frontend/src/pages/projects/project-list.tsx @@ -48,6 +48,7 @@ import { PROJECT_SORT_FIELD, PROJECT_SORT_ORDER, } from '@/shared/constants'; + import { IProjectFilter } from '@/types/project/project.types'; import { IProjectViewModel } from '@/types/project/projectViewModel.types'; @@ -77,6 +78,11 @@ import { import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; import ProjectGroupList from '@/components/project-list/project-group/project-group-list'; +// Lazy load the survey modal +const SurveyPromptModal = React.lazy(() => + import('@/components/survey/SurveyPromptModal').then(m => ({ default: m.SurveyPromptModal })) +); + const createFilters = (items: { id: string; name: string }[]) => items.map(item => ({ text: item.name, value: item.id })) as ColumnFilterItem[]; @@ -893,6 +899,7 @@ const ProjectList: React.FC = () => { {createPortal(, document.body, 'project-drawer')} + {createPortal(, document.body, 'project-survey-modal')}
); }; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/roadmap/project-view-roadmap.tsx b/worklenz-frontend/src/pages/projects/project-view-1/roadmap/project-view-roadmap.tsx index cb7ca9141..7c8f53f50 100644 --- a/worklenz-frontend/src/pages/projects/project-view-1/roadmap/project-view-roadmap.tsx +++ b/worklenz-frontend/src/pages/projects/project-view-1/roadmap/project-view-roadmap.tsx @@ -1,4 +1,6 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; +import { useMixpanelTracking } from '../../../../hooks/useMixpanelTracking'; +import { evt_project_roadmap_visit } from '../../../../shared/worklenz-analytics-events'; import { ViewMode } from 'gantt-task-react'; import 'gantt-task-react/dist/index.css'; import './project-view-roadmap.css'; @@ -10,10 +12,15 @@ import RoadmapGrantChart from './roadmap-grant-chart'; const ProjectViewRoadmap = () => { const [view, setView] = useState(ViewMode.Day); + const { trackMixpanelEvent } = useMixpanelTracking(); // get theme details const themeMode = useAppSelector(state => state.themeReducer.mode); + useEffect(() => { + trackMixpanelEvent(evt_project_roadmap_visit); + }, [trackMixpanelEvent]); + return ( {/* time filter */} diff --git a/worklenz-frontend/src/pages/projects/project-view-1/roadmap/roadmap-grant-chart.tsx b/worklenz-frontend/src/pages/projects/project-view-1/roadmap/roadmap-grant-chart.tsx index 067d07231..2e2ce14ee 100644 --- a/worklenz-frontend/src/pages/projects/project-view-1/roadmap/roadmap-grant-chart.tsx +++ b/worklenz-frontend/src/pages/projects/project-view-1/roadmap/roadmap-grant-chart.tsx @@ -1,6 +1,8 @@ import { Gantt, Task, ViewMode } from 'gantt-task-react'; import React from 'react'; import { colors } from '../../../../styles/colors'; +import { useMixpanelTracking } from '../../../../hooks/useMixpanelTracking'; +import { evt_roadmap_drag_change_date, evt_roadmap_drag_move } from '../../../../shared/worklenz-analytics-events'; import { NewTaskType, updateTaskDate, @@ -17,6 +19,7 @@ type RoadmapGrantChartProps = { const RoadmapGrantChart = ({ view }: RoadmapGrantChartProps) => { // get task list from roadmap slice const tasks = useAppSelector(state => state.roadmapReducer.tasksList); + const { trackMixpanelEvent } = useMixpanelTracking(); const dispatch = useAppDispatch(); @@ -37,6 +40,7 @@ const RoadmapGrantChart = ({ view }: RoadmapGrantChartProps) => { // function to handle date change const handleTaskDateChange = (task: Task) => { + trackMixpanelEvent(evt_roadmap_drag_change_date); dispatch(updateTaskDate({ taskId: task.id, start: task.start, end: task.end })); }; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/workload/ProjectViewWorkload.tsx b/worklenz-frontend/src/pages/projects/project-view-1/workload/ProjectViewWorkload.tsx index c36ea1483..b0d8adaf3 100644 --- a/worklenz-frontend/src/pages/projects/project-view-1/workload/ProjectViewWorkload.tsx +++ b/worklenz-frontend/src/pages/projects/project-view-1/workload/ProjectViewWorkload.tsx @@ -1,6 +1,14 @@ -import React from 'react'; +import React, { useEffect } from 'react'; +import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; +import { evt_project_workload_visit } from '@/shared/worklenz-analytics-events'; const ProjectViewWorkload = () => { + const { trackMixpanelEvent } = useMixpanelTracking(); + + useEffect(() => { + trackMixpanelEvent(evt_project_workload_visit); + }, [trackMixpanelEvent]); + return
ProjectViewWorkload
; }; diff --git a/worklenz-frontend/src/pages/reporting/members-reports/members-reports.tsx b/worklenz-frontend/src/pages/reporting/members-reports/members-reports.tsx index 192f7dae8..6e2f306e7 100644 --- a/worklenz-frontend/src/pages/reporting/members-reports/members-reports.tsx +++ b/worklenz-frontend/src/pages/reporting/members-reports/members-reports.tsx @@ -18,12 +18,15 @@ import { import { useAuthService } from '@/hooks/useAuth'; import { reportingExportApiService } from '@/api/reporting/reporting-export.api.service'; import { useEffect } from 'react'; +import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; +import { evt_reporting_allocation } from '@/shared/worklenz-analytics-events'; const MembersReports = () => { const { t } = useTranslation('reporting-members'); const dispatch = useAppDispatch(); useDocumentTitle('Reporting - Members'); const currentSession = useAuthService().getCurrentSession(); + const { trackMixpanelEvent } = useMixpanelTracking(); const { archived, searchQuery, total } = useAppSelector(state => state.membersReportsReducer); const { duration, dateRange } = useAppSelector(state => state.reportingReducer); @@ -38,6 +41,10 @@ const MembersReports = () => { ); }; + useEffect(() => { + trackMixpanelEvent(evt_reporting_allocation); + }, [trackMixpanelEvent]); + useEffect(() => { dispatch(setDuration(duration)); dispatch(setDateRange(dateRange)); diff --git a/worklenz-frontend/src/pages/reporting/projects-reports/projects-reports.tsx b/worklenz-frontend/src/pages/reporting/projects-reports/projects-reports.tsx index 028e7bc1b..275f5590b 100644 --- a/worklenz-frontend/src/pages/reporting/projects-reports/projects-reports.tsx +++ b/worklenz-frontend/src/pages/reporting/projects-reports/projects-reports.tsx @@ -1,5 +1,7 @@ import { Button, Card, Checkbox, Dropdown, Flex, Space, Typography } from '@/shared/antd-imports'; -import { useMemo, useCallback, memo } from 'react'; +import { useMemo, useCallback, memo, useEffect } from 'react'; +import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; +import { evt_reporting_projects_overview } from '@/shared/worklenz-analytics-events'; import CustomPageHeader from '@/pages/reporting/page-header/custom-page-header'; import { DownOutlined } from '@/shared/antd-imports'; import ProjectReportsTable from './projects-reports-table/projects-reports-table'; @@ -16,11 +18,16 @@ const ProjectsReports = () => { const { t } = useTranslation('reporting-projects'); const dispatch = useAppDispatch(); const currentSession = useAuthService().getCurrentSession(); + const { trackMixpanelEvent } = useMixpanelTracking(); useDocumentTitle('Reporting - Projects'); const { total, archived } = useAppSelector(state => state.projectReportsReducer); + useEffect(() => { + trackMixpanelEvent(evt_reporting_projects_overview); + }, [trackMixpanelEvent]); + // Memoize the title to prevent recalculation on every render const pageTitle = useMemo(() => { return `${total === 1 ? `${total} ${t('projectCount')}` : `${total} ${t('projectCountPlural')}`} `; diff --git a/worklenz-frontend/src/pages/schedule/schedule.tsx b/worklenz-frontend/src/pages/schedule/schedule.tsx index aefe321ea..f78d9afb9 100644 --- a/worklenz-frontend/src/pages/schedule/schedule.tsx +++ b/worklenz-frontend/src/pages/schedule/schedule.tsx @@ -1,5 +1,7 @@ import { Button, DatePicker, DatePickerProps, Flex, Select, Space } from '@/shared/antd-imports'; -import React, { useRef } from 'react'; +import React, { useRef, useEffect } from 'react'; +import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; +import { evt_schedule_page_visit } from '@/shared/worklenz-analytics-events'; import { SettingOutlined } from '@ant-design/icons'; import { useDispatch } from 'react-redux'; import { setDate, setType, toggleSettingsDrawer } from '@/features/schedule/scheduleSlice'; @@ -31,9 +33,14 @@ const Schedule: React.FC = () => { const dispatch = useDispatch(); const granttChartRef = useRef(null); const { date, type } = useAppSelector(state => state.scheduleReducer); + const { trackMixpanelEvent } = useMixpanelTracking(); useDocumentTitle('Schedule'); + useEffect(() => { + trackMixpanelEvent(evt_schedule_page_visit); + }, [trackMixpanelEvent]); + const handleDateChange = (value: dayjs.Dayjs | null) => { if (!value) return; let selectedDate = value.toDate(); diff --git a/worklenz-frontend/src/pages/settings/categories/categories-settings.tsx b/worklenz-frontend/src/pages/settings/categories/categories-settings.tsx index 2f7b5362e..7c52d158f 100644 --- a/worklenz-frontend/src/pages/settings/categories/categories-settings.tsx +++ b/worklenz-frontend/src/pages/settings/categories/categories-settings.tsx @@ -19,10 +19,13 @@ import { categoriesApiService } from '@/api/settings/categories/categories.api.s import { IProjectCategory, IProjectCategoryViewModel } from '@/types/project/projectCategory.types'; import { useDocumentTitle } from '@/hooks/useDoumentTItle'; import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; +import { evt_settings_categories_visit } from '@/shared/worklenz-analytics-events'; const CategoriesSettings = () => { // localization const { t } = useTranslation('settings/categories'); + const { trackMixpanelEvent } = useMixpanelTracking(); useDocumentTitle('Manage Categories'); @@ -55,6 +58,10 @@ const CategoriesSettings = () => { }; }, []); + useEffect(() => { + trackMixpanelEvent(evt_settings_categories_visit); + }, [trackMixpanelEvent]); + useEffect(() => { getCategories(); }, [getCategories]); diff --git a/worklenz-frontend/src/pages/settings/clients/client-drawer.tsx b/worklenz-frontend/src/pages/settings/clients/client-drawer.tsx index d27f16b2d..0e62f511b 100644 --- a/worklenz-frontend/src/pages/settings/clients/client-drawer.tsx +++ b/worklenz-frontend/src/pages/settings/clients/client-drawer.tsx @@ -9,6 +9,8 @@ import { } from '@/features/settings/client/clientSlice'; import { IClient } from '@/types/client.types'; import { useTranslation } from 'react-i18next'; +import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; +import { evt_settings_clients_create } from '@/shared/worklenz-analytics-events'; type ClientDrawerProps = { client: IClient | null; @@ -20,6 +22,7 @@ const ClientDrawer = ({ client, drawerClosed }: ClientDrawerProps) => { const { isClientDrawerOpen } = useAppSelector(state => state.clientReducer); const dispatch = useAppDispatch(); const [form] = Form.useForm(); + const { trackMixpanelEvent } = useMixpanelTracking(); useEffect(() => { if (client?.name) { @@ -32,6 +35,7 @@ const ClientDrawer = ({ client, drawerClosed }: ClientDrawerProps) => { if (client && client.id) { await dispatch(updateClient({ id: client.id, body: { name: values.name } })); } else { + trackMixpanelEvent(evt_settings_clients_create); await dispatch(createClient({ name: values.name })); } dispatch(toggleClientDrawer()); diff --git a/worklenz-frontend/src/pages/settings/clients/clients-settings.tsx b/worklenz-frontend/src/pages/settings/clients/clients-settings.tsx index 161a3df70..7923c69d8 100644 --- a/worklenz-frontend/src/pages/settings/clients/clients-settings.tsx +++ b/worklenz-frontend/src/pages/settings/clients/clients-settings.tsx @@ -31,11 +31,14 @@ import { DEFAULT_PAGE_SIZE } from '@/shared/constants'; import ClientDrawer from './client-drawer'; import { useDocumentTitle } from '@/hooks/useDoumentTItle'; import logger from '@/utils/errorLogger'; +import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; +import { evt_settings_clients_visit } from '@/shared/worklenz-analytics-events'; const ClientsSettings: React.FC = () => { const { t } = useTranslation('settings/clients'); const { clients } = useAppSelector(state => state.clientReducer); const dispatch = useAppDispatch(); + const { trackMixpanelEvent } = useMixpanelTracking(); useDocumentTitle('Manage Clients'); @@ -62,6 +65,10 @@ const ClientsSettings: React.FC = () => { }; }, [pagination, searchQuery, dispatch]); + useEffect(() => { + trackMixpanelEvent(evt_settings_clients_visit); + }, [trackMixpanelEvent]); + useEffect(() => { getClients(); }, [searchQuery]); diff --git a/worklenz-frontend/src/pages/settings/job-titles/job-titles-drawer.tsx b/worklenz-frontend/src/pages/settings/job-titles/job-titles-drawer.tsx index 8f3b5f410..c58f69399 100644 --- a/worklenz-frontend/src/pages/settings/job-titles/job-titles-drawer.tsx +++ b/worklenz-frontend/src/pages/settings/job-titles/job-titles-drawer.tsx @@ -2,6 +2,8 @@ import { Button, Drawer, Form, Input, message, Typography } from '@/shared/antd- import { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { jobTitlesApiService } from '@/api/settings/job-titles/job-titles.api.service'; +import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; +import { evt_settings_job_titles_create } from '@/shared/worklenz-analytics-events'; type JobTitleDrawerProps = { drawerOpen: boolean; @@ -16,6 +18,7 @@ const JobTitleDrawer = ({ }: JobTitleDrawerProps) => { const { t } = useTranslation('settings/job-titles'); const [form] = Form.useForm(); + const { trackMixpanelEvent } = useMixpanelTracking(); useEffect(() => { if (jobTitleId) { @@ -46,6 +49,7 @@ const JobTitleDrawer = ({ drawerClosed(); } } else { + trackMixpanelEvent(evt_settings_job_titles_create); const response = await jobTitlesApiService.createJobTitle({ name: values.name }); if (response.done) { drawerClosed(); diff --git a/worklenz-frontend/src/pages/settings/job-titles/job-titles-settings.tsx b/worklenz-frontend/src/pages/settings/job-titles/job-titles-settings.tsx index 896e05bfd..2de0c8086 100644 --- a/worklenz-frontend/src/pages/settings/job-titles/job-titles-settings.tsx +++ b/worklenz-frontend/src/pages/settings/job-titles/job-titles-settings.tsx @@ -27,6 +27,8 @@ import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import JobTitleDrawer from './job-titles-drawer'; import logger from '@/utils/errorLogger'; +import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; +import { evt_settings_job_titles_visit } from '@/shared/worklenz-analytics-events'; interface PaginationType { current: number; @@ -41,6 +43,7 @@ interface PaginationType { const JobTitlesSettings = () => { const { t } = useTranslation('settings/job-titles'); const dispatch = useAppDispatch(); + const { trackMixpanelEvent } = useMixpanelTracking(); useDocumentTitle('Manage Job Titles'); const [selectedJobId, setSelectedJobId] = useState(null); @@ -73,6 +76,10 @@ const JobTitlesSettings = () => { }; }, [pagination.current, pagination.pageSize, pagination.field, pagination.order, searchQuery]); + useEffect(() => { + trackMixpanelEvent(evt_settings_job_titles_visit); + }, [trackMixpanelEvent]); + useEffect(() => { getJobTitles(); }, [getJobTitles]); diff --git a/worklenz-frontend/src/pages/settings/labels/LabelsSettings.tsx b/worklenz-frontend/src/pages/settings/labels/LabelsSettings.tsx index f35113c22..659a7d63e 100644 --- a/worklenz-frontend/src/pages/settings/labels/LabelsSettings.tsx +++ b/worklenz-frontend/src/pages/settings/labels/LabelsSettings.tsx @@ -23,9 +23,12 @@ import CustomColorLabel from '@components/task-list-common/labelsSelector/custom import { useDocumentTitle } from '@/hooks/useDoumentTItle'; import logger from '@/utils/errorLogger'; import LabelsDrawer from './labels-drawer'; +import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; +import { evt_settings_labels_visit } from '@/shared/worklenz-analytics-events'; const LabelsSettings = () => { const { t } = useTranslation('settings/labels'); + const { trackMixpanelEvent } = useMixpanelTracking(); useDocumentTitle(t('pageTitle', 'Manage Labels')); const [selectedLabelId, setSelectedLabelId] = useState(null); @@ -55,6 +58,10 @@ const LabelsSettings = () => { }; }, []); + useEffect(() => { + trackMixpanelEvent(evt_settings_labels_visit); + }, [trackMixpanelEvent]); + useEffect(() => { getLabels(); }, [getLabels]); diff --git a/worklenz-frontend/src/pages/settings/notifications/notifications-settings.tsx b/worklenz-frontend/src/pages/settings/notifications/notifications-settings.tsx index e1575562d..dc7cd3174 100644 --- a/worklenz-frontend/src/pages/settings/notifications/notifications-settings.tsx +++ b/worklenz-frontend/src/pages/settings/notifications/notifications-settings.tsx @@ -6,11 +6,14 @@ import { useDocumentTitle } from '@/hooks/useDoumentTItle'; import { INotificationSettings } from '@/types/settings/notifications.types'; import { profileSettingsApiService } from '@/api/settings/profile/profile-settings.api.service'; import logger from '@/utils/errorLogger'; +import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; +import { evt_settings_notifications_visit } from '@/shared/worklenz-analytics-events'; const NotificationsSettings = () => { const { t } = useTranslation('settings/notifications'); const [form] = Form.useForm(); const themeMode = useAppSelector(state => state.themeReducer.mode); + const { trackMixpanelEvent } = useMixpanelTracking(); const [notificationsSettings, setNotificationsSettings] = useState({}); const [isLoading, setIsLoading] = useState(false); @@ -69,8 +72,9 @@ const NotificationsSettings = () => { }; useEffect(() => { + trackMixpanelEvent(evt_settings_notifications_visit); fetchNotificationsSettings(); - }, []); + }, [trackMixpanelEvent]); return ( diff --git a/worklenz-frontend/src/pages/settings/task-templates/task-templates-settings.tsx b/worklenz-frontend/src/pages/settings/task-templates/task-templates-settings.tsx index 5bf3f18e1..8f59feb66 100644 --- a/worklenz-frontend/src/pages/settings/task-templates/task-templates-settings.tsx +++ b/worklenz-frontend/src/pages/settings/task-templates/task-templates-settings.tsx @@ -11,6 +11,8 @@ import { ITaskTemplatesGetResponse } from '@/types/settings/task-templates.types import logger from '@/utils/errorLogger'; import { taskTemplatesApiService } from '@/api/task-templates/task-templates.api.service'; import { calculateTimeGap } from '@/utils/calculate-time-gap'; +import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; +import { evt_settings_task_templates_visit } from '@/shared/worklenz-analytics-events'; const TaskTemplatesSettings = () => { const { t } = useTranslation('settings/task-templates'); @@ -20,6 +22,7 @@ const TaskTemplatesSettings = () => { const [isLoading, setIsLoading] = useState(false); const [templateId, setTemplateId] = useState(null); const [showDrawer, setShowDrawer] = useState(false); + const { trackMixpanelEvent } = useMixpanelTracking(); useDocumentTitle('Task Templates'); const fetchTaskTemplates = async () => { @@ -35,8 +38,9 @@ const TaskTemplatesSettings = () => { }; useEffect(() => { + trackMixpanelEvent(evt_settings_task_templates_visit); fetchTaskTemplates(); - }, []); + }, [trackMixpanelEvent]); const handleDeleteTemplate = async (id: string) => { try { diff --git a/worklenz-frontend/src/pages/settings/teams/teams-settings.tsx b/worklenz-frontend/src/pages/settings/teams/teams-settings.tsx index e6932d09f..7a7523847 100644 --- a/worklenz-frontend/src/pages/settings/teams/teams-settings.tsx +++ b/worklenz-frontend/src/pages/settings/teams/teams-settings.tsx @@ -11,9 +11,12 @@ import { fetchTeams } from '@features/teams/teamSlice'; import { useAppDispatch } from '@/hooks/useAppDispatch'; import { useDocumentTitle } from '@/hooks/useDoumentTItle'; import { ITeamGetResponse } from '@/types/teams/team.type'; +import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; +import { evt_settings_teams_visit } from '@/shared/worklenz-analytics-events'; const TeamsSettings = () => { const { t } = useTranslation('settings/teams'); + const { trackMixpanelEvent } = useMixpanelTracking(); useDocumentTitle(t('title')); const [selectedTeam, setSelectedTeam] = useState(null); @@ -22,8 +25,9 @@ const TeamsSettings = () => { const dispatch = useAppDispatch(); useEffect(() => { + trackMixpanelEvent(evt_settings_teams_visit); dispatch(fetchTeams()); - }, [dispatch]); + }, [trackMixpanelEvent, dispatch]); const columns: TableProps['columns'] = [ { diff --git a/worklenz-frontend/src/utils/mixpanelInit.ts b/worklenz-frontend/src/utils/mixpanelInit.ts index 809a176e7..b27f2d7fd 100644 --- a/worklenz-frontend/src/utils/mixpanelInit.ts +++ b/worklenz-frontend/src/utils/mixpanelInit.ts @@ -1,7 +1,12 @@ import { MixpanelConfig } from '@/types/mixpanel.types'; import mixpanel from 'mixpanel-browser'; -export const initMixpanel = (token: string, config: MixpanelConfig = {}): void => { +export const initMixpanel = (token: string | null, config: MixpanelConfig = {}): void => { + if (!token || token === 'mixpanel-token' || token.trim() === '') { + console.warn('Mixpanel initialization skipped: Invalid or missing token'); + return; + } + mixpanel.init(token, { debug: import.meta.env.VITE_APP_ENV !== 'production', track_pageview: true,