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('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,