diff --git a/.cursor/rules/antd-components.mdc b/.cursor/rules/antd-components.mdc
new file mode 100644
index 00000000..756179bd
--- /dev/null
+++ b/.cursor/rules/antd-components.mdc
@@ -0,0 +1,237 @@
+---
+alwaysApply: true
+---
+# Ant Design Import Rules for Worklenz
+
+## 🚨 CRITICAL: Always Use Centralized Imports
+
+**NEVER import Ant Design components directly from 'antd' or '@ant-design/icons'**
+
+### ✅ Correct Import Pattern
+```typescript
+import { Button, Input, Select, EditOutlined, PlusOutlined } from '@antd-imports';
+// or
+import { Button, Input, Select, EditOutlined, PlusOutlined } from '@/shared/antd-imports';
+```
+
+### ❌ Forbidden Import Patterns
+```typescript
+// NEVER do this:
+import { Button, Input, Select } from 'antd';
+import { EditOutlined, PlusOutlined } from '@ant-design/icons';
+```
+
+## Why This Rule Exists
+
+### Benefits of Centralized Imports:
+- **Better Tree-Shaking**: Optimized bundle size through centralized management
+- **Consistent React Context**: Proper context sharing across components
+- **Type Safety**: Centralized TypeScript definitions
+- **Maintainability**: Single source of truth for all Ant Design imports
+- **Performance**: Reduced bundle size and improved loading times
+
+## What's Available in `@antd-imports`
+
+### Core Components
+- **Layout**: Layout, Row, Col, Flex, Divider, Space
+- **Navigation**: Menu, Tabs, Breadcrumb, Pagination
+- **Data Entry**: Input, Select, DatePicker, TimePicker, Form, Checkbox, InputNumber
+- **Data Display**: Table, List, Card, Tag, Avatar, Badge, Progress, Statistic
+- **Feedback**: Modal, Drawer, Alert, Message, Notification, Spin, Skeleton, Result
+- **Other**: Button, Typography, Tooltip, Popconfirm, Dropdown, ConfigProvider
+
+### Icons
+Common icons including: EditOutlined, DeleteOutlined, PlusOutlined, MoreOutlined, CheckOutlined, CloseOutlined, CalendarOutlined, UserOutlined, TeamOutlined, and many more.
+
+### Utilities
+- **appMessage**: Centralized message utility
+- **appNotification**: Centralized notification utility
+- **antdConfig**: Default Ant Design configuration
+- **taskManagementAntdConfig**: Task-specific configuration
+
+## Implementation Guidelines
+
+### When Creating New Components:
+1. **Always** import from `@antd-imports` or `@/shared/antd-imports`
+2. Use `appMessage` and `appNotification` for user feedback
+3. Apply `antdConfig` for consistent styling
+4. Use `taskManagementAntdConfig` for task-related components
+
+### When Refactoring Existing Code:
+1. Replace direct 'antd' imports with `@antd-imports`
+2. Replace direct '@ant-design/icons' imports with `@antd-imports`
+3. Update any custom message/notification calls to use the utilities
+
+### File Location
+The centralized import file is located at: `worklenz-frontend/src/shared/antd-imports.ts`
+
+## Examples
+
+### Component Creation
+```typescript
+import React from 'react';
+import { Button, Input, Modal, EditOutlined, appMessage } from '@antd-imports';
+
+const MyComponent = () => {
+ const handleClick = () => {
+ appMessage.success('Operation completed!');
+ };
+
+ return (
+ } onClick={handleClick}>
+ Edit Item
+
+ );
+};
+```
+
+### Form Implementation
+```typescript
+import { Form, Input, Select, Button, DatePicker } from '@antd-imports';
+
+const MyForm = () => {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+};
+```
+
+## Enforcement
+
+This rule is **MANDATORY** and applies to:
+- All new component development
+- All code refactoring
+- All bug fixes
+- All feature implementations
+
+**Violations will result in code review rejection.**
+
+### File Path:
+The centralized file is located at: `worklenz-frontend/src/shared/antd-imports.ts`
+# Ant Design Import Rules for Worklenz
+
+## 🚨 CRITICAL: Always Use Centralized Imports
+
+**NEVER import Ant Design components directly from 'antd' or '@ant-design/icons'**
+
+### ✅ Correct Import Pattern
+```typescript
+import { Button, Input, Select, EditOutlined, PlusOutlined } from '@antd-imports';
+// or
+import { Button, Input, Select, EditOutlined, PlusOutlined } from '@/shared/antd-imports';
+```
+
+### ❌ Forbidden Import Patterns
+```typescript
+// NEVER do this:
+import { Button, Input, Select } from 'antd';
+import { EditOutlined, PlusOutlined } from '@ant-design/icons';
+```
+
+## Why This Rule Exists
+
+### Benefits of Centralized Imports:
+- **Better Tree-Shaking**: Optimized bundle size through centralized management
+- **Consistent React Context**: Proper context sharing across components
+- **Type Safety**: Centralized TypeScript definitions
+- **Maintainability**: Single source of truth for all Ant Design imports
+- **Performance**: Reduced bundle size and improved loading times
+
+## What's Available in `@antd-imports`
+
+### Core Components
+- **Layout**: Layout, Row, Col, Flex, Divider, Space
+- **Navigation**: Menu, Tabs, Breadcrumb, Pagination
+- **Data Entry**: Input, Select, DatePicker, TimePicker, Form, Checkbox, InputNumber
+- **Data Display**: Table, List, Card, Tag, Avatar, Badge, Progress, Statistic
+- **Feedback**: Modal, Drawer, Alert, Message, Notification, Spin, Skeleton, Result
+- **Other**: Button, Typography, Tooltip, Popconfirm, Dropdown, ConfigProvider
+
+### Icons
+Common icons including: EditOutlined, DeleteOutlined, PlusOutlined, MoreOutlined, CheckOutlined, CloseOutlined, CalendarOutlined, UserOutlined, TeamOutlined, and many more.
+
+### Utilities
+- **appMessage**: Centralized message utility
+- **appNotification**: Centralized notification utility
+- **antdConfig**: Default Ant Design configuration
+- **taskManagementAntdConfig**: Task-specific configuration
+
+## Implementation Guidelines
+
+### When Creating New Components:
+1. **Always** import from `@antd-imports` or `@/shared/antd-imports`
+2. Use `appMessage` and `appNotification` for user feedback
+3. Apply `antdConfig` for consistent styling
+4. Use `taskManagementAntdConfig` for task-related components
+
+### When Refactoring Existing Code:
+1. Replace direct 'antd' imports with `@antd-imports`
+2. Replace direct '@ant-design/icons' imports with `@antd-imports`
+3. Update any custom message/notification calls to use the utilities
+
+### File Location
+The centralized import file is located at: `worklenz-frontend/src/shared/antd-imports.ts`
+
+## Examples
+
+### Component Creation
+```typescript
+import React from 'react';
+import { Button, Input, Modal, EditOutlined, appMessage } from '@antd-imports';
+
+const MyComponent = () => {
+ const handleClick = () => {
+ appMessage.success('Operation completed!');
+ };
+
+ return (
+ } onClick={handleClick}>
+ Edit Item
+
+ );
+};
+```
+
+### Form Implementation
+```typescript
+import { Form, Input, Select, Button, DatePicker } from '@antd-imports';
+
+const MyForm = () => {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+};
+```
+
+## Enforcement
+
+This rule is **MANDATORY** and applies to:
+- All new component development
+- All code refactoring
+- All bug fixes
+- All feature implementations
+
+**Violations will result in code review rejection.**
+
+### File Path:
+The centralized file is located at: `worklenz-frontend/src/shared/antd-imports.ts`
\ No newline at end of file
diff --git a/worklenz-frontend/src/components/admin-center/billing/billing-tables/charges-table.tsx b/worklenz-frontend/src/components/admin-center/billing/billing-tables/charges-table.tsx
index 1ce40a72..24a483f8 100644
--- a/worklenz-frontend/src/components/admin-center/billing/billing-tables/charges-table.tsx
+++ b/worklenz-frontend/src/components/admin-center/billing/billing-tables/charges-table.tsx
@@ -27,19 +27,19 @@ const ChargesTable: React.FC = () => {
const columns: TableProps['columns'] = [
{
- title: t('description'),
+ title: t('description') as string,
key: 'name',
dataIndex: 'name',
},
{
- title: t('billingPeriod'),
+ title: t('billingPeriod') as string,
key: 'billingPeriod',
render: record => {
return `${formatDate(new Date(record.start_date))} - ${formatDate(new Date(record.end_date))}`;
},
},
{
- title: t('billStatus'),
+ title: t('billStatus') as string,
key: 'status',
dataIndex: 'status',
render: (_, record) => {
@@ -55,7 +55,7 @@ const ChargesTable: React.FC = () => {
},
},
{
- title: t('perUserValue'),
+ title: t('perUserValue') as string,
key: 'perUserValue',
dataIndex: 'perUserValue',
render: (_, record) => (
@@ -65,12 +65,12 @@ const ChargesTable: React.FC = () => {
),
},
{
- title: t('users'),
+ title: t('users') as string,
key: 'quantity',
dataIndex: 'quantity',
},
{
- title: t('amount'),
+ title: t('amount') as string,
key: 'amount',
dataIndex: 'amount',
render: (_, record) => (
diff --git a/worklenz-frontend/src/components/admin-center/billing/current-bill.tsx b/worklenz-frontend/src/components/admin-center/billing/current-bill.tsx
index 86252375..e255d00b 100644
--- a/worklenz-frontend/src/components/admin-center/billing/current-bill.tsx
+++ b/worklenz-frontend/src/components/admin-center/billing/current-bill.tsx
@@ -1,5 +1,5 @@
-import { Card, Col, Row, Tooltip, Typography } from 'antd';
-import React, { useEffect } from 'react';
+import { Card, Col, Row, Tooltip } from '@/shared/antd-imports';
+import React, { useEffect, useMemo, useCallback } from 'react';
import './current-bill.css';
import { InfoCircleTwoTone } from '@ant-design/icons';
import ChargesTable from './billing-tables/charges-table';
@@ -20,7 +20,7 @@ import AccountStorage from './account-storage/account-storage';
import { useAuthService } from '@/hooks/useAuth';
import { ISUBSCRIPTION_TYPE } from '@/shared/constants';
-const CurrentBill: React.FC = () => {
+const CurrentBill: React.FC = React.memo(() => {
const dispatch = useAppDispatch();
const { t } = useTranslation('admin-center/current-bill');
const themeMode = useAppSelector(state => state.themeReducer.mode);
@@ -32,70 +32,90 @@ const CurrentBill: React.FC = () => {
dispatch(fetchFreePlanSettings());
}, [dispatch]);
- const titleStyle = {
- color: themeMode === 'dark' ? '#ffffffd9' : '#000000d9',
- fontWeight: 500,
- fontSize: '16px',
- display: 'flex',
- gap: '4px',
- };
-
- const renderMobileView = () => (
-
+ const titleStyle = useMemo(
+ () => ({
+ color: themeMode === 'dark' ? '#ffffffd9' : '#000000d9',
+ fontWeight: 500,
+ fontSize: '16px',
+ display: 'flex',
+ gap: '4px',
+ }),
+ [themeMode]
);
- const renderChargesAndInvoices = () => (
- <>
-
-
- {t('charges')}
-
-
-
-
- }
- style={{ marginTop: '16px' }}
- >
-
-
-
+ const cardStyle = useMemo(() => ({ marginTop: '16px' }), []);
+ const colStyle = useMemo(() => ({ marginTop: '1.5rem' }), []);
+ const tabletColStyle = useMemo(() => ({ paddingRight: '10px' }), []);
+ const tabletColStyleRight = useMemo(() => ({ paddingLeft: '10px' }), []);
-
-
{t('invoices')}} style={{ marginTop: '16px' }}>
-
-
+ const renderMobileView = useCallback(
+ () => (
+
- >
+ ),
+ [colStyle, themeMode]
+ );
+
+ const renderChargesAndInvoices = useCallback(
+ () => (
+ <>
+
+
+ {t('charges')}
+
+
+
+
+ }
+ style={cardStyle}
+ >
+
+
+
+
+
+ {t('invoices')}} style={cardStyle}>
+
+
+
+ >
+ ),
+ [colStyle, titleStyle, cardStyle, t]
+ );
+
+ const shouldShowChargesAndInvoices = useMemo(
+ () => currentSession?.subscription_type === ISUBSCRIPTION_TYPE.PADDLE,
+ [currentSession?.subscription_type]
);
return (
{isTablet ? (
-
+
-
+
) : (
renderMobileView()
)}
- {currentSession?.subscription_type === ISUBSCRIPTION_TYPE.PADDLE &&
- renderChargesAndInvoices()}
+ {shouldShowChargesAndInvoices && renderChargesAndInvoices()}
);
-};
+});
+
+CurrentBill.displayName = 'CurrentBill';
export default CurrentBill;
diff --git a/worklenz-frontend/src/components/admin-center/billing/current-plan-details/current-plan-details.tsx b/worklenz-frontend/src/components/admin-center/billing/current-plan-details/current-plan-details.tsx
index 77de144e..ba2e09b0 100644
--- a/worklenz-frontend/src/components/admin-center/billing/current-plan-details/current-plan-details.tsx
+++ b/worklenz-frontend/src/components/admin-center/billing/current-plan-details/current-plan-details.tsx
@@ -1,4 +1,4 @@
-import React, { useState } from 'react';
+import React, { useState, useMemo, useCallback, useEffect } from 'react';
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
import {
evt_billing_pause_plan,
@@ -17,10 +17,9 @@ import {
Typography,
Statistic,
Select,
- Form,
Row,
Col,
-} from 'antd/es';
+} from '@/shared/antd-imports';
import RedeemCodeDrawer from '../drawers/redeem-code-drawer/redeem-code-drawer';
import {
fetchBillingInfo,
@@ -38,6 +37,21 @@ import UpgradePlans from '../drawers/upgrade-plans/upgrade-plans';
import { ISUBSCRIPTION_TYPE, SUBSCRIPTION_STATUS } from '@/shared/constants';
import { billingApiService } from '@/api/admin-center/billing.api.service';
+type SubscriptionAction = 'pause' | 'resume';
+type SeatOption = { label: string; value: number | string };
+
+const SEAT_COUNT_LIMIT = '100+';
+const BILLING_DELAY_MS = 8000;
+const LTD_USER_LIMIT = 50;
+const BUTTON_STYLE = {
+ backgroundColor: '#1890ff',
+ borderColor: '#1890ff',
+};
+const STATISTIC_VALUE_STYLE = {
+ fontSize: '24px',
+ fontWeight: 'bold' as const,
+};
+
const CurrentPlanDetails = () => {
const dispatch = useAppDispatch();
const { t } = useTranslation('admin-center/current-bill');
@@ -47,7 +61,7 @@ const CurrentPlanDetails = () => {
const [cancellingPlan, setCancellingPlan] = useState(false);
const [addingSeats, setAddingSeats] = useState(false);
const [isMoreSeatsModalVisible, setIsMoreSeatsModalVisible] = useState(false);
- const [selectedSeatCount, setSelectedSeatCount] = useState
(5);
+ const [selectedSeatCount, setSelectedSeatCount] = useState(1);
const themeMode = useAppSelector(state => state.themeReducer.mode);
const { loadingBillingInfo, billingInfo, freePlanSettings, isUpgradeModalOpen } = useAppSelector(
@@ -55,14 +69,16 @@ const CurrentPlanDetails = () => {
);
const browserTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
+
+ const seatCountOptions: SeatOption[] = useMemo(() => {
+ const options: SeatOption[] = [
+ 1, 2, 3, 4, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90,
+ ].map(value => ({ label: value.toString(), value }));
+ options.push({ label: SEAT_COUNT_LIMIT, value: SEAT_COUNT_LIMIT });
+ return options;
+ }, []);
- type SeatOption = { label: string; value: number | string };
- const seatCountOptions: SeatOption[] = [
- 1, 2, 3, 4, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90,
- ].map(value => ({ label: value.toString(), value }));
- seatCountOptions.push({ label: '100+', value: '100+' });
-
- const handleSubscriptionAction = async (action: 'pause' | 'resume') => {
+ const handleSubscriptionAction = useCallback(async (action: SubscriptionAction) => {
const isResume = action === 'resume';
const setLoadingState = isResume ? setCancellingPlan : setPausingPlan;
const apiMethod = isResume
@@ -78,21 +94,21 @@ const CurrentPlanDetails = () => {
setLoadingState(false);
dispatch(fetchBillingInfo());
trackMixpanelEvent(eventType);
- }, 8000);
- return; // Exit function to prevent finally block from executing
+ }, BILLING_DELAY_MS);
+ return;
}
} catch (error) {
logger.error(`Error ${action}ing subscription`, error);
- setLoadingState(false); // Only set to false on error
+ setLoadingState(false);
}
- };
+ }, [dispatch, trackMixpanelEvent]);
- const handleAddMoreSeats = () => {
+ const handleAddMoreSeats = useCallback(() => {
setIsMoreSeatsModalVisible(true);
- };
+ }, []);
- const handlePurchaseMoreSeats = async () => {
- if (selectedSeatCount.toString() === '100+' || !billingInfo?.total_seats) return;
+ const handlePurchaseMoreSeats = useCallback(async () => {
+ if (selectedSeatCount.toString() === SEAT_COUNT_LIMIT || !billingInfo?.total_seats) return;
try {
setAddingSeats(true);
@@ -108,51 +124,75 @@ const CurrentPlanDetails = () => {
} finally {
setAddingSeats(false);
}
- };
+ }, [selectedSeatCount, billingInfo?.total_seats, dispatch, trackMixpanelEvent]);
- const calculateRemainingSeats = () => {
+ const calculateRemainingSeats = useMemo(() => {
if (billingInfo?.total_seats && billingInfo?.total_used) {
return billingInfo.total_seats - billingInfo.total_used;
}
return 0;
- };
+ }, [billingInfo?.total_seats, billingInfo?.total_used]);
- const checkSubscriptionStatus = (allowedStatuses: any[]) => {
+ // Calculate intelligent default for seat selection based on current usage
+ const getDefaultSeatCount = useMemo(() => {
+ const currentUsed = billingInfo?.total_used || 0;
+ const availableSeats = calculateRemainingSeats;
+
+ // If only 1 user and no available seats, suggest 1 additional seat
+ if (currentUsed === 1 && availableSeats === 0) {
+ return 1;
+ }
+
+ // If they have some users but running low on seats, suggest enough for current users
+ if (availableSeats < currentUsed && currentUsed > 0) {
+ return Math.max(1, currentUsed - availableSeats);
+ }
+
+ // Default fallback
+ return Math.max(1, Math.min(5, currentUsed || 1));
+ }, [billingInfo?.total_used, calculateRemainingSeats]);
+
+ // Update selected seat count when billing info changes
+ useEffect(() => {
+ setSelectedSeatCount(getDefaultSeatCount);
+ }, [getDefaultSeatCount]);
+
+ const checkSubscriptionStatus = useCallback((allowedStatuses: string[]) => {
if (!billingInfo?.status || billingInfo.is_ltd_user) return false;
return allowedStatuses.includes(billingInfo.status);
- };
+ }, [billingInfo?.status, billingInfo?.is_ltd_user]);
- const shouldShowRedeemButton = () => {
+ const shouldShowRedeemButton = useMemo(() => {
if (billingInfo?.trial_in_progress) return true;
- return billingInfo?.ltd_users ? billingInfo.ltd_users < 50 : false;
- };
+ return billingInfo?.ltd_users ? billingInfo.ltd_users < LTD_USER_LIMIT : false;
+ }, [billingInfo?.trial_in_progress, billingInfo?.ltd_users]);
- const showChangeButton = () => {
+ const showChangeButton = useMemo(() => {
return checkSubscriptionStatus([SUBSCRIPTION_STATUS.ACTIVE, SUBSCRIPTION_STATUS.PASTDUE]);
- };
+ }, [checkSubscriptionStatus]);
- const showPausePlanButton = () => {
+ const showPausePlanButton = useMemo(() => {
return checkSubscriptionStatus([SUBSCRIPTION_STATUS.ACTIVE, SUBSCRIPTION_STATUS.PASTDUE]);
- };
+ }, [checkSubscriptionStatus]);
- const showResumePlanButton = () => {
+ const showResumePlanButton = useMemo(() => {
return checkSubscriptionStatus([SUBSCRIPTION_STATUS.PAUSED]);
- };
+ }, [checkSubscriptionStatus]);
- const shouldShowAddSeats = () => {
+ const shouldShowAddSeats = useMemo(() => {
if (!billingInfo) return false;
return (
billingInfo.subscription_type === ISUBSCRIPTION_TYPE.PADDLE &&
billingInfo.status === SUBSCRIPTION_STATUS.ACTIVE
);
- };
+ }, [billingInfo]);
- const renderExtra = () => {
+ const renderExtra = useCallback(() => {
if (!billingInfo || billingInfo.is_custom) return null;
return (
- {showPausePlanButton() && (
+ {showPausePlanButton && (
)}
- {showResumePlanButton() && (
+ {showResumePlanButton && (
)}
- {showChangeButton() && (
+ {showChangeButton && (
);
- };
+ }, [
+ billingInfo,
+ showPausePlanButton,
+ showResumePlanButton,
+ showChangeButton,
+ pausingPlan,
+ cancellingPlan,
+ handleSubscriptionAction,
+ dispatch,
+ t,
+ ]);
- const renderLtdDetails = () => {
+ const renderLtdDetails = useCallback(() => {
if (!billingInfo || billingInfo.is_custom) return null;
return (
@@ -200,41 +250,41 @@ const CurrentPlanDetails = () => {
{t('ltdUsers', { ltd_users: billingInfo.ltd_users })}
);
- };
+ }, [billingInfo, t]);
- const renderTrialDetails = () => {
- const checkIfTrialExpired = () => {
- if (!billingInfo?.trial_expire_date) return false;
- const today = new Date();
- today.setHours(0, 0, 0, 0); // Set to start of day for comparison
- const trialExpireDate = new Date(billingInfo.trial_expire_date);
- trialExpireDate.setHours(0, 0, 0, 0); // Set to start of day for comparison
- return today > trialExpireDate;
- };
+ const checkIfTrialExpired = useCallback(() => {
+ if (!billingInfo?.trial_expire_date) return false;
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
+ const trialExpireDate = new Date(billingInfo.trial_expire_date);
+ trialExpireDate.setHours(0, 0, 0, 0);
+ return today > trialExpireDate;
+ }, [billingInfo?.trial_expire_date]);
- const getExpirationMessage = (expireDate: string) => {
- const today = new Date();
- today.setHours(0, 0, 0, 0); // Set to start of day for comparison
+ const getExpirationMessage = useCallback((expireDate: string) => {
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
- const tomorrow = new Date(today);
- tomorrow.setDate(tomorrow.getDate() + 1);
+ const tomorrow = new Date(today);
+ tomorrow.setDate(tomorrow.getDate() + 1);
- const expDate = new Date(expireDate);
- expDate.setHours(0, 0, 0, 0); // Set to start of day for comparison
+ const expDate = new Date(expireDate);
+ expDate.setHours(0, 0, 0, 0);
- if (expDate.getTime() === today.getTime()) {
- return t('expirestoday', 'today');
- } else if (expDate.getTime() === tomorrow.getTime()) {
- return t('expirestomorrow', 'tomorrow');
- } else if (expDate < today) {
- const diffTime = Math.abs(today.getTime() - expDate.getTime());
- const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
- return t('expiredDaysAgo', '{{days}} days ago', { days: diffDays });
- } else {
- return calculateTimeGap(expireDate);
- }
- };
+ if (expDate.getTime() === today.getTime()) {
+ return t('expirestoday', 'today');
+ } else if (expDate.getTime() === tomorrow.getTime()) {
+ return t('expirestomorrow', 'tomorrow');
+ } else if (expDate < today) {
+ const diffTime = Math.abs(today.getTime() - expDate.getTime());
+ const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
+ return t('expiredDaysAgo', '{{days}} days ago', { days: diffDays });
+ } else {
+ return calculateTimeGap(expireDate);
+ }
+ }, [t]);
+ const renderTrialDetails = useCallback(() => {
const isExpired = checkIfTrialExpired();
const trialExpireDate = billingInfo?.trial_expire_date || '';
@@ -257,9 +307,9 @@ const CurrentPlanDetails = () => {
);
- };
+ }, [billingInfo?.trial_expire_date, checkIfTrialExpired, getExpirationMessage, t]);
- const renderFreePlan = () => (
+ const renderFreePlan = useCallback(() => (
{t('freePlan')}
@@ -271,9 +321,9 @@ const CurrentPlanDetails = () => {
- {freePlanSettings?.free_tier_storage} MB {t('storage')}
- );
+ ), [freePlanSettings, t]);
- const renderPaddleSubscriptionInfo = () => {
+ const renderPaddleSubscriptionInfo = useCallback(() => {
return (
{billingInfo?.plan_name}
@@ -287,14 +337,14 @@ const CurrentPlanDetails = () => {
- {shouldShowAddSeats() && billingInfo?.total_seats && (
+ {shouldShowAddSeats && billingInfo?.total_seats && (
@@ -302,16 +352,16 @@ const CurrentPlanDetails = () => {
type="primary"
icon={}
onClick={handleAddMoreSeats}
- style={{ backgroundColor: '#1890ff', borderColor: '#1890ff' }}
+ style={BUTTON_STYLE}
>
{t('addMoreSeats')}
@@ -319,17 +369,17 @@ const CurrentPlanDetails = () => {
)}
);
- };
+ }, [billingInfo, shouldShowAddSeats, handleAddMoreSeats, calculateRemainingSeats, t]);
- const renderCreditSubscriptionInfo = () => {
+ const renderCreditSubscriptionInfo = useCallback(() => {
return (
{t('creditPlan', 'Credit Plan')}
);
- };
+ }, [t]);
- const renderCustomSubscriptionInfo = () => {
+ const renderCustomSubscriptionInfo = useCallback(() => {
return (
{t('customPlan', 'Custom Plan')}
@@ -340,7 +390,36 @@ const CurrentPlanDetails = () => {
);
- };
+ }, [billingInfo?.valid_till_date, t]);
+
+ const renderSubscriptionContent = useCallback(() => {
+ if (!billingInfo) return null;
+
+ switch (billingInfo.subscription_type) {
+ case ISUBSCRIPTION_TYPE.LIFE_TIME_DEAL:
+ return renderLtdDetails();
+ case ISUBSCRIPTION_TYPE.TRIAL:
+ return renderTrialDetails();
+ case ISUBSCRIPTION_TYPE.FREE:
+ return renderFreePlan();
+ case ISUBSCRIPTION_TYPE.PADDLE:
+ return renderPaddleSubscriptionInfo();
+ case ISUBSCRIPTION_TYPE.CREDIT:
+ return renderCreditSubscriptionInfo();
+ case ISUBSCRIPTION_TYPE.CUSTOM:
+ return renderCustomSubscriptionInfo();
+ default:
+ return null;
+ }
+ }, [
+ billingInfo,
+ renderLtdDetails,
+ renderTrialDetails,
+ renderFreePlan,
+ renderPaddleSubscriptionInfo,
+ renderCreditSubscriptionInfo,
+ renderCustomSubscriptionInfo,
+ ]);
return (
{
>
- {billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.LIFE_TIME_DEAL &&
- renderLtdDetails()}
- {billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.TRIAL && renderTrialDetails()}
- {billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.FREE && renderFreePlan()}
- {billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.PADDLE &&
- renderPaddleSubscriptionInfo()}
- {billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.CREDIT &&
- renderCreditSubscriptionInfo()}
- {billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.CUSTOM &&
- renderCustomSubscriptionInfo()}
+ {renderSubscriptionContent()}
- {shouldShowRedeemButton() && (
+ {shouldShowRedeemButton && (
<>
- {renderFeature(t('startupText01'))}
- {renderFeature(t('startupText02'))}
- {renderFeature(t('startupText03'))}
- {renderFeature(t('startupText04'))}
- {renderFeature(t('startupText05'))}
+ {renderFeature(t('startupText01', 'Unlimited Projects'))}
+ {renderFeature(t('startupText02', 'Unlimited Team Members'))}
+ {renderFeature(t('startupText03', 'Unlimited Storage'))}
+ {renderFeature(t('startupText04', 'Priority Support'))}
+ {renderFeature(t('startupText05', 'Advanced Analytics'))}
diff --git a/worklenz-frontend/src/components/admin-center/configuration/configuration.tsx b/worklenz-frontend/src/components/admin-center/configuration/configuration.tsx
index afa5b51a..ffb1e806 100644
--- a/worklenz-frontend/src/components/admin-center/configuration/configuration.tsx
+++ b/worklenz-frontend/src/components/admin-center/configuration/configuration.tsx
@@ -1,5 +1,5 @@
-import { Button, Card, Col, Divider, Form, Input, notification, Row, Select } from 'antd';
-import React, { useEffect, useState } from 'react';
+import { Button, Card, Col, Divider, Form, Input, Row, Select } from '@/shared/antd-imports';
+import React, { useEffect, useState, useMemo, useCallback } from 'react';
import { RootState } from '../../../app/store';
import { useAppSelector } from '@/hooks/useAppSelector';
import { IBillingConfigurationCountry } from '@/types/admin-center/country.types';
@@ -7,14 +7,15 @@ import { adminCenterApiService } from '@/api/admin-center/admin-center.api.servi
import { IBillingConfiguration } from '@/types/admin-center/admin-center.types';
import logger from '@/utils/errorLogger';
-const Configuration: React.FC = () => {
+const Configuration: React.FC = React.memo(() => {
const themeMode = useAppSelector((state: RootState) => state.themeReducer.mode);
const [countries, setCountries] = useState([]);
const [configuration, setConfiguration] = useState();
const [loading, setLoading] = useState(false);
const [form] = Form.useForm();
- const fetchCountries = async () => {
+
+ const fetchCountries = useCallback(async () => {
try {
const res = await adminCenterApiService.getCountries();
if (res.done) {
@@ -23,61 +24,85 @@ const Configuration: React.FC = () => {
} catch (error) {
logger.error('Error fetching countries:', error);
}
- };
+ }, []);
- const fetchConfiguration = async () => {
+ const fetchConfiguration = useCallback(async () => {
const res = await adminCenterApiService.getBillingConfiguration();
if (res.done) {
setConfiguration(res.body);
form.setFieldsValue(res.body);
}
- };
+ }, [form]);
useEffect(() => {
fetchCountries();
fetchConfiguration();
- }, []);
+ }, [fetchCountries, fetchConfiguration]);
- const handleSave = async (values: any) => {
- try {
- setLoading(true);
- const res = await adminCenterApiService.updateBillingConfiguration(values);
- if (res.done) {
- fetchConfiguration();
+ const handleSave = useCallback(
+ async (values: any) => {
+ try {
+ setLoading(true);
+ const res = await adminCenterApiService.updateBillingConfiguration(values);
+ if (res.done) {
+ fetchConfiguration();
+ }
+ } catch (error) {
+ logger.error('Error updating configuration:', error);
+ } finally {
+ setLoading(false);
}
- } catch (error) {
- logger.error('Error updating configuration:', error);
- } finally {
- setLoading(false);
- }
- };
+ },
+ [fetchConfiguration]
+ );
- const countryOptions = countries.map(country => ({
- label: country.name,
- value: country.id,
- }));
+ const countryOptions = useMemo(
+ () =>
+ countries.map(country => ({
+ label: country.name,
+ value: country.id,
+ })),
+ [countries]
+ );
+
+ const titleStyle = useMemo(
+ () => ({
+ color: `${themeMode === 'dark' ? '#ffffffd9' : '#000000d9'}`,
+ fontWeight: 500,
+ fontSize: '16px',
+ display: 'flex',
+ gap: '4px',
+ }),
+ [themeMode]
+ );
+
+ const dividerTitleStyle = useMemo(
+ () => ({
+ color: `${themeMode === 'dark' ? '#ffffffd9' : '#000000d9'}`,
+ fontWeight: 600,
+ fontSize: '16px',
+ display: 'flex',
+ gap: '4px',
+ }),
+ [themeMode]
+ );
+
+ const cardStyle = useMemo(() => ({ marginTop: '16px' }), []);
+ const colStyle = useMemo(() => ({ padding: '0 12px', height: '86px' }), []);
+ const dividerStyle = useMemo(() => ({ margin: '16px 0' }), []);
+ const buttonColStyle = useMemo(() => ({ paddingLeft: '12px' }), []);
+
+ const handlePhoneInput = useCallback((e: React.FormEvent) => {
+ const input = e.target as HTMLInputElement;
+ input.value = input.value.replace(/[^0-9]/g, '');
+ }, []);
return (
-
- Billing Details
-
- }
- style={{ marginTop: '16px' }}
- >
+ Billing Details} style={cardStyle}>
);
-};
+});
+
+Configuration.displayName = 'Configuration';
export default Configuration;
diff --git a/worklenz-frontend/src/pages/admin-center/billing/billing.tsx b/worklenz-frontend/src/pages/admin-center/billing/billing.tsx
index b4eaa864..bc18dcbb 100644
--- a/worklenz-frontend/src/pages/admin-center/billing/billing.tsx
+++ b/worklenz-frontend/src/pages/admin-center/billing/billing.tsx
@@ -1,32 +1,39 @@
import { PageHeader } from '@ant-design/pro-components';
-import { Tabs, TabsProps } from 'antd';
-import React from 'react';
+import { Tabs, TabsProps } from '@/shared/antd-imports';
+import React, { useMemo } from 'react';
import CurrentBill from '@/components/admin-center/billing/current-bill';
import Configuration from '@/components/admin-center/configuration/configuration';
import { useTranslation } from 'react-i18next';
-const Billing: React.FC = () => {
+const Billing: React.FC = React.memo(() => {
const { t } = useTranslation('admin-center/current-bill');
- const items: TabsProps['items'] = [
- {
- key: '1',
- label: t('currentBill'),
- children: ,
- },
- {
- key: '2',
- label: t('configuration'),
- children: ,
- },
- ];
+ const items: TabsProps['items'] = useMemo(
+ () => [
+ {
+ key: '1',
+ label: t('currentBill'),
+ children: ,
+ },
+ {
+ key: '2',
+ label: t('configuration'),
+ children: ,
+ },
+ ],
+ [t]
+ );
+
+ const pageHeaderStyle = useMemo(() => ({ padding: '16px 0' }), []);
return (
-
{t('title')}} style={{ padding: '16px 0' }} />
-
+ {t('title')}} style={pageHeaderStyle} />
+
);
-};
+});
+
+Billing.displayName = 'Billing';
export default Billing;
diff --git a/worklenz-frontend/src/shared/antd-imports.ts b/worklenz-frontend/src/shared/antd-imports.ts
index bd0b31bd..253ff847 100644
--- a/worklenz-frontend/src/shared/antd-imports.ts
+++ b/worklenz-frontend/src/shared/antd-imports.ts
@@ -50,6 +50,7 @@ import {
message,
notification,
theme,
+ Statistic,
} from 'antd';
// Icons - Import commonly used ones
@@ -145,6 +146,7 @@ export {
message,
notification,
theme,
+ Statistic,
};
// TypeScript Types - Import commonly used ones