init
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
|
||||
import { fetchStorageInfo } from '@/features/admin-center/admin-center.slice';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { SUBSCRIPTION_STATUS } from '@/shared/constants';
|
||||
import { IBillingAccountStorage } from '@/types/admin-center/admin-center.types';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { Card, Progress, Typography } from 'antd/es';
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface IAccountStorageProps {
|
||||
themeMode: string;
|
||||
}
|
||||
|
||||
const AccountStorage = ({ themeMode }: IAccountStorageProps) => {
|
||||
const { t } = useTranslation('admin-center/current-bill');
|
||||
const dispatch = useAppDispatch();
|
||||
const [subscriptionType, setSubscriptionType] = useState<string>(SUBSCRIPTION_STATUS.TRIALING);
|
||||
|
||||
const { loadingBillingInfo, billingInfo, storageInfo } = useAppSelector(state => state.adminCenterReducer);
|
||||
|
||||
const formatBytes = useMemo(
|
||||
() =>
|
||||
(bytes = 0, decimals = 2) => {
|
||||
if (!+bytes) return '0 MB';
|
||||
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
const formattedValue = parseFloat((bytes / Math.pow(k, i)).toFixed(dm));
|
||||
|
||||
return `${formattedValue} ${subscriptionType !== SUBSCRIPTION_STATUS.FREE ? sizes[i] : 'MB'}`;
|
||||
},
|
||||
[subscriptionType]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchStorageInfo());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setSubscriptionType(billingInfo?.status ?? SUBSCRIPTION_STATUS.TRIALING);
|
||||
}, [billingInfo?.status]);
|
||||
|
||||
const textColor = themeMode === 'dark' ? '#ffffffd9' : '#000000d9';
|
||||
|
||||
return (
|
||||
<Card
|
||||
style={{
|
||||
height: '100%',
|
||||
}}
|
||||
loading={loadingBillingInfo}
|
||||
title={
|
||||
<span
|
||||
style={{
|
||||
color: textColor,
|
||||
fontWeight: 500,
|
||||
fontSize: '16px',
|
||||
}}
|
||||
>
|
||||
{t('accountStorage')}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<div style={{ padding: '0 16px' }}>
|
||||
<Progress
|
||||
percent={billingInfo?.usedPercentage ?? 0}
|
||||
type="circle"
|
||||
format={percent => <span style={{ fontSize: '13px' }}>{percent}% Used</span>}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<Typography.Text>
|
||||
{t('used')} <strong>{formatBytes(storageInfo?.used ?? 0, 1)}</strong>
|
||||
</Typography.Text>
|
||||
<Typography.Text>
|
||||
{t('remaining')} <strong>{formatBytes(storageInfo?.remaining ?? 0, 1)}</strong>
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountStorage;
|
||||
@@ -0,0 +1,99 @@
|
||||
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
|
||||
import { IBillingCharge, IBillingChargesResponse } from '@/types/admin-center/admin-center.types';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { formatDate } from '@/utils/timeUtils';
|
||||
import { Table, TableProps, Tag } from 'antd';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const ChargesTable: React.FC = () => {
|
||||
const { t } = useTranslation('admin-center/current-bill');
|
||||
const [charges, setCharges] = useState<IBillingChargesResponse>({});
|
||||
const [loadingCharges, setLoadingCharges] = useState(false);
|
||||
|
||||
const fetchCharges = async () => {
|
||||
try {
|
||||
setLoadingCharges(true);
|
||||
const res = await adminCenterApiService.getCharges();
|
||||
if (res.done) {
|
||||
setCharges(res.body);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching charges:', error);
|
||||
} finally {
|
||||
setLoadingCharges(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: TableProps<IBillingCharge>['columns'] = [
|
||||
{
|
||||
title: t('description'),
|
||||
key: 'name',
|
||||
dataIndex: 'name',
|
||||
},
|
||||
{
|
||||
title: t('billingPeriod'),
|
||||
key: 'billingPeriod',
|
||||
render: record => {
|
||||
return `${formatDate(new Date(record.start_date))} - ${formatDate(new Date(record.end_date))}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('billStatus'),
|
||||
key: 'status',
|
||||
dataIndex: 'status',
|
||||
render: (_, record) => {
|
||||
return (
|
||||
<Tag
|
||||
color={
|
||||
record.status === 'success' ? 'green' : record.status === 'deleted' ? 'red' : 'blue'
|
||||
}
|
||||
>
|
||||
{record.status?.toUpperCase()}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('perUserValue'),
|
||||
key: 'perUserValue',
|
||||
dataIndex: 'perUserValue',
|
||||
render: (_, record) => (
|
||||
<span>
|
||||
{record.currency} {record.unit_price}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('users'),
|
||||
key: 'quantity',
|
||||
dataIndex: 'quantity',
|
||||
},
|
||||
{
|
||||
title: t('amount'),
|
||||
key: 'amount',
|
||||
dataIndex: 'amount',
|
||||
render: (_, record) => (
|
||||
<span>
|
||||
{record.currency} {record.amount}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
fetchCharges();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Table<IBillingCharge>
|
||||
columns={columns}
|
||||
dataSource={charges.plan_charges}
|
||||
pagination={false}
|
||||
loading={loadingCharges}
|
||||
rowKey="id"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChargesTable;
|
||||
@@ -0,0 +1,105 @@
|
||||
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
|
||||
import { IBillingTransaction } from '@/types/admin-center/admin-center.types';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { formatDate } from '@/utils/timeUtils';
|
||||
import { ContainerOutlined } from '@ant-design/icons';
|
||||
import { Button, Table, TableProps, Tag, Tooltip } from 'antd';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const InvoicesTable: React.FC = () => {
|
||||
const { t } = useTranslation('admin-center/current-bill');
|
||||
|
||||
const [transactions, setTransactions] = useState<IBillingTransaction[]>([]);
|
||||
const [loadingTransactions, setLoadingTransactions] = useState(false);
|
||||
|
||||
const fetchTransactions = async () => {
|
||||
try {
|
||||
setLoadingTransactions(true);
|
||||
const res = await adminCenterApiService.getTransactions();
|
||||
if (res.done) {
|
||||
setTransactions(res.body);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching transactions:', error);
|
||||
} finally {
|
||||
setLoadingTransactions(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInvoiceViewClick = (record: IBillingTransaction) => {
|
||||
if (!record.receipt_url) return;
|
||||
window.open(record.receipt_url, '_blank');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTransactions();
|
||||
}, []);
|
||||
|
||||
const columns: TableProps<IBillingTransaction>['columns'] = [
|
||||
{
|
||||
title: t('transactionId'),
|
||||
key: 'transactionId',
|
||||
dataIndex: 'subscription_payment_id',
|
||||
},
|
||||
{
|
||||
title: t('transactionDate'),
|
||||
key: 'transactionDate',
|
||||
render: record => `${formatDate(new Date(record.event_time))}`,
|
||||
},
|
||||
|
||||
{
|
||||
title: t('billingPeriod'),
|
||||
key: 'billingPeriod',
|
||||
render: record => {
|
||||
return `${formatDate(new Date(record.event_time))} - ${formatDate(new Date(record.next_bill_date))}`;
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
title: t('paymentMethod'),
|
||||
key: 'paymentMethod',
|
||||
dataIndex: 'payment_method',
|
||||
},
|
||||
{
|
||||
title: t('status'),
|
||||
key: 'status',
|
||||
dataIndex: 'status',
|
||||
render: (_, record) => (
|
||||
<Tag
|
||||
color={
|
||||
record.payment_status === 'success'
|
||||
? 'green'
|
||||
: record.payment_status === 'failed'
|
||||
? 'red'
|
||||
: 'blue'
|
||||
}
|
||||
>
|
||||
{record.payment_status?.toUpperCase()}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'button',
|
||||
render: (_, record) => (
|
||||
<Tooltip title={t('viewInvoice')}>
|
||||
<Button size="small">
|
||||
<ContainerOutlined onClick={() => handleInvoiceViewClick(record)} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={transactions}
|
||||
pagination={false}
|
||||
loading={loadingTransactions}
|
||||
rowKey="id"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default InvoicesTable;
|
||||
@@ -0,0 +1,12 @@
|
||||
.current-billing .ant-card-head-wrapper {
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.current-billing .ant-progress-inner {
|
||||
width: 75px !important;
|
||||
height: 75px !important;
|
||||
}
|
||||
|
||||
:where(.css-dev-only-do-not-override-ezht69).ant-modal .ant-modal-footer {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { Button, Card, Col, Modal, Row, Tooltip, Typography } from 'antd';
|
||||
import React, { useEffect } from 'react';
|
||||
import './current-bill.css';
|
||||
import { InfoCircleTwoTone } from '@ant-design/icons';
|
||||
import ChargesTable from './billing-tables/charges-table';
|
||||
import InvoicesTable from './billing-tables/invoices-table';
|
||||
import UpgradePlansLKR from './drawers/upgrade-plans-lkr/upgrade-plans-lkr';
|
||||
import UpgradePlans from './drawers/upgrade-plans/upgrade-plans';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useMediaQuery } from 'react-responsive';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
toggleDrawer,
|
||||
toggleUpgradeModal,
|
||||
} from '@/features/admin-center/billing/billing.slice';
|
||||
import { fetchBillingInfo, fetchFreePlanSettings } from '@/features/admin-center/admin-center.slice';
|
||||
import RedeemCodeDrawer from './drawers/redeem-code-drawer/redeem-code-drawer';
|
||||
import CurrentPlanDetails from './current-plan-details/current-plan-details';
|
||||
import AccountStorage from './account-storage/account-storage';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { ISUBSCRIPTION_TYPE } from '@/shared/constants';
|
||||
|
||||
const CurrentBill: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { t } = useTranslation('admin-center/current-bill');
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const { isUpgradeModalOpen } = useAppSelector(state => state.adminCenterReducer);
|
||||
const isTablet = useMediaQuery({ query: '(min-width: 1025px)' });
|
||||
const browserTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const currentSession = useAuthService().getCurrentSession();
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchBillingInfo());
|
||||
dispatch(fetchFreePlanSettings());
|
||||
}, [dispatch]);
|
||||
|
||||
const titleStyle = {
|
||||
color: themeMode === 'dark' ? '#ffffffd9' : '#000000d9',
|
||||
fontWeight: 500,
|
||||
fontSize: '16px',
|
||||
display: 'flex',
|
||||
gap: '4px',
|
||||
};
|
||||
|
||||
const renderMobileView = () => (
|
||||
<div>
|
||||
<Col span={24}>
|
||||
<Card
|
||||
style={{ height: '100%' }}
|
||||
title={<span style={titleStyle}>{t('currentPlanDetails')}</span>}
|
||||
extra={
|
||||
<div style={{ marginTop: '8px', marginRight: '8px' }}>
|
||||
<Button type="primary" onClick={() => dispatch(toggleUpgradeModal())}>
|
||||
{t('upgradePlan')}
|
||||
</Button>
|
||||
<Modal
|
||||
open={isUpgradeModalOpen}
|
||||
onCancel={() => dispatch(toggleUpgradeModal())}
|
||||
width={1000}
|
||||
centered
|
||||
okButtonProps={{ hidden: true }}
|
||||
cancelButtonProps={{ hidden: true }}
|
||||
>
|
||||
{browserTimeZone === 'Asia/Colombo' ? <UpgradePlansLKR /> : <UpgradePlans />}
|
||||
</Modal>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', width: '50%', padding: '0 12px' }}>
|
||||
<div style={{ marginBottom: '14px' }}>
|
||||
<Typography.Text style={{ fontWeight: 700 }}>{t('cardBodyText01')}</Typography.Text>
|
||||
<Typography.Text>{t('cardBodyText02')}</Typography.Text>
|
||||
</div>
|
||||
<Button
|
||||
type="link"
|
||||
style={{ margin: 0, padding: 0, width: '90px' }}
|
||||
onClick={() => dispatch(toggleDrawer())}
|
||||
>
|
||||
{t('redeemCode')}
|
||||
</Button>
|
||||
<RedeemCodeDrawer />
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={24} style={{ marginTop: '1.5rem' }}>
|
||||
<AccountStorage themeMode={themeMode} />
|
||||
</Col>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderChargesAndInvoices = () => (
|
||||
<>
|
||||
<div style={{ marginTop: '1.5rem' }}>
|
||||
<Card
|
||||
title={
|
||||
<span style={titleStyle}>
|
||||
<span>{t('charges')}</span>
|
||||
<Tooltip title={t('tooltip')}>
|
||||
<InfoCircleTwoTone />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
style={{ marginTop: '16px' }}
|
||||
>
|
||||
<ChargesTable />
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '1.5rem' }}>
|
||||
<Card
|
||||
title={<span style={titleStyle}>{t('invoices')}</span>}
|
||||
style={{ marginTop: '16px' }}
|
||||
>
|
||||
<InvoicesTable />
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%' }} className="current-billing">
|
||||
{isTablet ? (
|
||||
<Row>
|
||||
<Col span={16} style={{ paddingRight: '10px' }}>
|
||||
<CurrentPlanDetails />
|
||||
</Col>
|
||||
<Col span={8} style={{ paddingLeft: '10px' }}>
|
||||
<AccountStorage themeMode={themeMode} />
|
||||
</Col>
|
||||
</Row>
|
||||
) : (
|
||||
renderMobileView()
|
||||
)}
|
||||
{currentSession?.subscription_type === ISUBSCRIPTION_TYPE.PADDLE && renderChargesAndInvoices()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CurrentBill;
|
||||
@@ -0,0 +1,437 @@
|
||||
import React, { useState } from 'react';
|
||||
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
|
||||
import {
|
||||
evt_billing_pause_plan,
|
||||
evt_billing_resume_plan,
|
||||
evt_billing_add_more_seats,
|
||||
} from '@/shared/worklenz-analytics-events';
|
||||
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { Button, Card, Flex, Modal, Space, Tooltip, Typography, Statistic, Select, Form, Row, Col } from 'antd/es';
|
||||
import RedeemCodeDrawer from '../drawers/redeem-code-drawer/redeem-code-drawer';
|
||||
import {
|
||||
fetchBillingInfo,
|
||||
toggleRedeemCodeDrawer,
|
||||
toggleUpgradeModal,
|
||||
} from '@/features/admin-center/admin-center.slice';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { WarningTwoTone, PlusOutlined } from '@ant-design/icons';
|
||||
import { calculateTimeGap } from '@/utils/calculate-time-gap';
|
||||
import { formatDate } from '@/utils/timeUtils';
|
||||
import UpgradePlansLKR from '../drawers/upgrade-plans-lkr/upgrade-plans-lkr';
|
||||
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';
|
||||
|
||||
const CurrentPlanDetails = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { t } = useTranslation('admin-center/current-bill');
|
||||
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||
|
||||
const [pausingPlan, setPausingPlan] = useState(false);
|
||||
const [cancellingPlan, setCancellingPlan] = useState(false);
|
||||
const [addingSeats, setAddingSeats] = useState(false);
|
||||
const [isMoreSeatsModalVisible, setIsMoreSeatsModalVisible] = useState(false);
|
||||
const [selectedSeatCount, setSelectedSeatCount] = useState<number | string>(5);
|
||||
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const { loadingBillingInfo, billingInfo, freePlanSettings, isUpgradeModalOpen } = useAppSelector(
|
||||
state => state.adminCenterReducer
|
||||
);
|
||||
|
||||
const browserTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
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 isResume = action === 'resume';
|
||||
const setLoadingState = isResume ? setCancellingPlan : setPausingPlan;
|
||||
const apiMethod = isResume
|
||||
? adminCenterApiService.resumeSubscription
|
||||
: adminCenterApiService.pauseSubscription;
|
||||
const eventType = isResume ? evt_billing_resume_plan : evt_billing_pause_plan;
|
||||
|
||||
try {
|
||||
setLoadingState(true);
|
||||
const res = await apiMethod();
|
||||
if (res.done) {
|
||||
setTimeout(() => {
|
||||
setLoadingState(false);
|
||||
dispatch(fetchBillingInfo());
|
||||
trackMixpanelEvent(eventType);
|
||||
}, 8000);
|
||||
return; // Exit function to prevent finally block from executing
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error ${action}ing subscription`, error);
|
||||
setLoadingState(false); // Only set to false on error
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddMoreSeats = () => {
|
||||
setIsMoreSeatsModalVisible(true);
|
||||
};
|
||||
|
||||
const handlePurchaseMoreSeats = async () => {
|
||||
if (selectedSeatCount.toString() === '100+' || !billingInfo?.total_seats) return;
|
||||
|
||||
try {
|
||||
setAddingSeats(true);
|
||||
const totalSeats = Number(selectedSeatCount) + (billingInfo?.total_seats || 0);
|
||||
const res = await billingApiService.purchaseMoreSeats(totalSeats);
|
||||
if (res.done) {
|
||||
setIsMoreSeatsModalVisible(false);
|
||||
dispatch(fetchBillingInfo());
|
||||
trackMixpanelEvent(evt_billing_add_more_seats);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error adding more seats', error);
|
||||
} finally {
|
||||
setAddingSeats(false);
|
||||
}
|
||||
};
|
||||
|
||||
const calculateRemainingSeats = () => {
|
||||
if (billingInfo?.total_seats && billingInfo?.total_used) {
|
||||
return billingInfo.total_seats - billingInfo.total_used;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const checkSubscriptionStatus = (allowedStatuses: any[]) => {
|
||||
if (!billingInfo?.status || billingInfo.is_ltd_user) return false;
|
||||
return allowedStatuses.includes(billingInfo.status);
|
||||
};
|
||||
|
||||
const shouldShowRedeemButton = () => {
|
||||
if (billingInfo?.trial_in_progress) return true;
|
||||
return billingInfo?.ltd_users ? billingInfo.ltd_users < 50 : false;
|
||||
};
|
||||
|
||||
const showChangeButton = () => {
|
||||
return checkSubscriptionStatus([SUBSCRIPTION_STATUS.ACTIVE, SUBSCRIPTION_STATUS.PASTDUE]);
|
||||
};
|
||||
|
||||
const showPausePlanButton = () => {
|
||||
return checkSubscriptionStatus([SUBSCRIPTION_STATUS.ACTIVE, SUBSCRIPTION_STATUS.PASTDUE]);
|
||||
};
|
||||
|
||||
const showResumePlanButton = () => {
|
||||
return checkSubscriptionStatus([SUBSCRIPTION_STATUS.PAUSED]);
|
||||
};
|
||||
|
||||
const shouldShowAddSeats = () => {
|
||||
if (!billingInfo) return false;
|
||||
return billingInfo.subscription_type === ISUBSCRIPTION_TYPE.PADDLE &&
|
||||
billingInfo.status === SUBSCRIPTION_STATUS.ACTIVE;
|
||||
};
|
||||
|
||||
const renderExtra = () => {
|
||||
if (!billingInfo || billingInfo.is_custom) return null;
|
||||
|
||||
return (
|
||||
<Space>
|
||||
{showPausePlanButton() && (
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
loading={pausingPlan}
|
||||
onClick={() => handleSubscriptionAction('pause')}
|
||||
>
|
||||
{t('pausePlan')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showResumePlanButton() && (
|
||||
<Button
|
||||
type="primary"
|
||||
loading={cancellingPlan}
|
||||
onClick={() => handleSubscriptionAction('resume')}
|
||||
>
|
||||
{t('resumePlan')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{billingInfo.trial_in_progress && (
|
||||
<Button type="primary" onClick={() => dispatch(toggleUpgradeModal())}>
|
||||
{t('upgradePlan')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showChangeButton() && (
|
||||
<Button
|
||||
type="primary"
|
||||
loading={pausingPlan || cancellingPlan}
|
||||
onClick={() => dispatch(toggleUpgradeModal())}
|
||||
>
|
||||
{t('changePlan')}
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
const renderLtdDetails = () => {
|
||||
if (!billingInfo || billingInfo.is_custom) return null;
|
||||
return (
|
||||
<Flex vertical>
|
||||
<Typography.Text strong>{billingInfo.plan_name}</Typography.Text>
|
||||
<Typography.Text>{t('ltdUsers', { ltd_users: billingInfo.ltd_users })}</Typography.Text>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
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 getExpirationMessage = (expireDate: string) => {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0); // Set to start of day for comparison
|
||||
|
||||
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
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const isExpired = checkIfTrialExpired();
|
||||
const trialExpireDate = billingInfo?.trial_expire_date || '';
|
||||
|
||||
return (
|
||||
<Flex vertical>
|
||||
<Typography.Text strong>
|
||||
{t('trialPlan')}
|
||||
{isExpired && <WarningTwoTone twoToneColor="#faad14" style={{ marginLeft: 8 }} />}
|
||||
</Typography.Text>
|
||||
<Tooltip title={formatDate(new Date(trialExpireDate))}>
|
||||
<Typography.Text>
|
||||
{isExpired
|
||||
? t('trialExpired', {
|
||||
trial_expire_string: getExpirationMessage(trialExpireDate)
|
||||
})
|
||||
: t('trialInProgress', {
|
||||
trial_expire_string: getExpirationMessage(trialExpireDate)
|
||||
})
|
||||
}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
const renderFreePlan = () => (
|
||||
<Flex vertical>
|
||||
<Typography.Text strong>Free Plan</Typography.Text>
|
||||
<Typography.Text>
|
||||
<br />-{' '}
|
||||
{freePlanSettings?.team_member_limit === 0
|
||||
? t('unlimitedTeamMembers')
|
||||
: `${freePlanSettings?.team_member_limit} ${t('teamMembers')}`}
|
||||
<br />- {freePlanSettings?.projects_limit} {t('projects')}
|
||||
<br />- {freePlanSettings?.free_tier_storage} MB {t('storage')}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
const renderPaddleSubscriptionInfo = () => {
|
||||
return (
|
||||
<Flex vertical>
|
||||
<Typography.Text strong>{billingInfo?.plan_name}</Typography.Text>
|
||||
<Flex>
|
||||
<Typography.Text>{billingInfo?.default_currency}</Typography.Text>
|
||||
<Typography.Text>
|
||||
{billingInfo?.billing_type === 'year'
|
||||
? billingInfo.unit_price_per_month
|
||||
: billingInfo?.unit_price}
|
||||
|
||||
{t('perMonthPerUser')}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
|
||||
{shouldShowAddSeats() && billingInfo?.total_seats && (
|
||||
<div style={{ marginTop: '16px' }}>
|
||||
<Row gutter={16} align="middle">
|
||||
<Col span={6}>
|
||||
<Statistic
|
||||
title={t('totalSeats')}
|
||||
value={billingInfo.total_seats}
|
||||
valueStyle={{ fontSize: '24px', fontWeight: 'bold' }}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleAddMoreSeats}
|
||||
style={{ backgroundColor: '#1890ff', borderColor: '#1890ff' }}
|
||||
>
|
||||
{t('addMoreSeats')}
|
||||
</Button>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Statistic
|
||||
title={t('availableSeats')}
|
||||
value={calculateRemainingSeats()}
|
||||
valueStyle={{ fontSize: '24px', fontWeight: 'bold' }}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
const renderCreditSubscriptionInfo = () => {
|
||||
return <Flex vertical>
|
||||
<Typography.Text strong>Credit Plan</Typography.Text>
|
||||
</Flex>
|
||||
};
|
||||
|
||||
const renderCustomSubscriptionInfo = () => {
|
||||
return <Flex vertical>
|
||||
<Typography.Text strong>Custom Plan</Typography.Text>
|
||||
<Typography.Text>Your plan is valid till {billingInfo?.valid_till_date}</Typography.Text>
|
||||
</Flex>
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
style={{ height: '100%' }}
|
||||
title={
|
||||
<Typography.Text
|
||||
style={{
|
||||
|
||||
color: themeMode === 'dark' ? '#ffffffd9' : '#000000d9',
|
||||
fontWeight: 500,
|
||||
fontSize: '16px',
|
||||
}}
|
||||
>
|
||||
{t('currentPlanDetails')}
|
||||
</Typography.Text>
|
||||
}
|
||||
loading={loadingBillingInfo}
|
||||
extra={renderExtra()}
|
||||
>
|
||||
<Flex vertical>
|
||||
<div style={{ marginBottom: '14px' }}>
|
||||
{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()}
|
||||
</div>
|
||||
|
||||
{shouldShowRedeemButton() && (
|
||||
<>
|
||||
<Button
|
||||
type="link"
|
||||
style={{ margin: 0, padding: 0, width: '90px' }}
|
||||
onClick={() => dispatch(toggleRedeemCodeDrawer())}
|
||||
>
|
||||
{t('redeemCode')}
|
||||
</Button>
|
||||
<RedeemCodeDrawer />
|
||||
</>
|
||||
)}
|
||||
<Modal
|
||||
open={isUpgradeModalOpen}
|
||||
onCancel={() => dispatch(toggleUpgradeModal())}
|
||||
width={1000}
|
||||
centered
|
||||
okButtonProps={{ hidden: true }}
|
||||
cancelButtonProps={{ hidden: true }}
|
||||
>
|
||||
{browserTimeZone === 'Asia/Colombo' ? <UpgradePlansLKR /> : <UpgradePlans />}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={t('addMoreSeats')}
|
||||
open={isMoreSeatsModalVisible}
|
||||
onCancel={() => setIsMoreSeatsModalVisible(false)}
|
||||
footer={null}
|
||||
width={500}
|
||||
centered
|
||||
>
|
||||
<Flex vertical gap="middle" style={{ marginTop: '8px' }}>
|
||||
<Typography.Paragraph style={{ fontSize: '16px', margin: '0 0 16px 0', fontWeight: 500 }}>
|
||||
To continue, you'll need to purchase additional seats.
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Typography.Paragraph style={{ margin: '0 0 16px 0' }}>
|
||||
You currently have {billingInfo?.total_seats} seats available.
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Typography.Paragraph style={{ margin: '0 0 24px 0' }}>
|
||||
Please select the number of additional seats to purchase.
|
||||
</Typography.Paragraph>
|
||||
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<span style={{ color: '#ff4d4f', marginRight: '4px' }}>*</span>
|
||||
<span style={{ marginRight: '8px' }}>Seats:</span>
|
||||
<Select
|
||||
value={selectedSeatCount}
|
||||
onChange={setSelectedSeatCount}
|
||||
options={seatCountOptions}
|
||||
style={{ width: '300px' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Flex justify="end">
|
||||
{selectedSeatCount.toString() !== '100+' ? (
|
||||
<Button
|
||||
type="primary"
|
||||
loading={addingSeats}
|
||||
onClick={handlePurchaseMoreSeats}
|
||||
style={{
|
||||
minWidth: '100px',
|
||||
backgroundColor: '#1890ff',
|
||||
borderColor: '#1890ff',
|
||||
borderRadius: '2px'
|
||||
}}
|
||||
>
|
||||
Purchase
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="primary"
|
||||
size="middle"
|
||||
>
|
||||
Contact sales
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Modal>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default CurrentPlanDetails;
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Button, Drawer, Form, Input, notification, Typography } from 'antd';
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import {
|
||||
fetchBillingInfo,
|
||||
fetchStorageInfo,
|
||||
toggleRedeemCodeDrawer,
|
||||
} from '@features/admin-center/admin-center.slice';
|
||||
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { authApiService } from '@/api/auth/auth.api.service';
|
||||
import { setUser } from '@/features/user/userSlice';
|
||||
import { setSession } from '@/utils/session-helper';
|
||||
const RedeemCodeDrawer: React.FC = () => {
|
||||
const [form] = Form.useForm();
|
||||
const { t } = useTranslation('admin-center/current-bill');
|
||||
const { isRedeemCodeDrawerOpen } = useAppSelector(state => state.adminCenterReducer);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [redeemCode, setRedeemCode] = useState<string>('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleFormSubmit = async (values: any) => {
|
||||
if (!values.redeemCode) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const res = await adminCenterApiService.redeemCode(values.redeemCode);
|
||||
if (res.done) {
|
||||
form.resetFields();
|
||||
const authorizeResponse = await authApiService.verify();
|
||||
if (authorizeResponse.authenticated) {
|
||||
setSession(authorizeResponse.user);
|
||||
dispatch(setUser(authorizeResponse.user));
|
||||
}
|
||||
dispatch(toggleRedeemCodeDrawer());
|
||||
dispatch(fetchBillingInfo());
|
||||
dispatch(fetchStorageInfo());
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error redeeming code', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Drawer
|
||||
title={
|
||||
<Typography.Text style={{ fontWeight: 500, fontSize: 16 }}>
|
||||
{t('drawerTitle')}
|
||||
</Typography.Text>
|
||||
}
|
||||
open={isRedeemCodeDrawerOpen}
|
||||
onClose={() => {
|
||||
dispatch(toggleRedeemCodeDrawer());
|
||||
form.resetFields();
|
||||
}}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleFormSubmit}>
|
||||
<Form.Item
|
||||
name="redeemCode"
|
||||
label={t('label')}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: t('required'),
|
||||
},
|
||||
{
|
||||
pattern: /^[A-Za-z0-9]+$/,
|
||||
message: t('invalidCode'),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
placeholder={t('drawerPlaceholder')}
|
||||
onChange={e => setRedeemCode(e.target.value.toUpperCase())}
|
||||
count={{ show: true, max: 10 }}
|
||||
value={redeemCode}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
style={{ width: '100%' }}
|
||||
htmlType="submit"
|
||||
disabled={redeemCode.length !== 10}
|
||||
loading={isLoading}
|
||||
>
|
||||
{t('redeemSubmit')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RedeemCodeDrawer;
|
||||
@@ -0,0 +1,3 @@
|
||||
.upgrade-plans .ant-card-head-wrapper {
|
||||
padding: 16px 0;
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { Button, Card, Col, Form, Input, notification, Row, Tag, Typography } from 'antd';
|
||||
import React, { useState } from 'react';
|
||||
import './upgrade-plans-lkr.css';
|
||||
import { CheckCircleFilled } from '@ant-design/icons';
|
||||
import { RootState } from '@/app/store';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { timeZoneCurrencyMap } from '@/utils/timeZoneCurrencyMap';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { toggleUpgradeModal, fetchBillingInfo } from '@features/admin-center/admin-center.slice';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { setSession } from '@/utils/session-helper';
|
||||
import { authApiService } from '@/api/auth/auth.api.service';
|
||||
import { setUser } from '@/features/user/userSlice';
|
||||
|
||||
const UpgradePlansLKR: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const themeMode = useAppSelector((state: RootState) => state.themeReducer.mode);
|
||||
const [selectedPlan, setSelectedPlan] = useState(2);
|
||||
const { t } = useTranslation('admin-center/current-bill');
|
||||
const userTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const userCurrency = timeZoneCurrencyMap[userTimeZone] || 'USD';
|
||||
const [switchingToFreePlan, setSwitchingToFreePlan] = useState(false);
|
||||
const currentSession = useAuthService().getCurrentSession();
|
||||
|
||||
const cardStyles = {
|
||||
title: {
|
||||
color: themeMode === 'dark' ? '#ffffffd9' : '#000000d9',
|
||||
fontWeight: 500,
|
||||
fontSize: '16px',
|
||||
display: 'flex',
|
||||
gap: '4px',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
priceContainer: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'auto',
|
||||
rowGap: '10px',
|
||||
padding: '20px 30px 0',
|
||||
},
|
||||
featureList: {
|
||||
display: 'grid',
|
||||
gridTemplateRows: 'auto auto auto',
|
||||
gridTemplateColumns: '200px',
|
||||
rowGap: '7px',
|
||||
padding: '10px',
|
||||
justifyItems: 'start',
|
||||
alignItems: 'start',
|
||||
},
|
||||
checkIcon: { color: '#52c41a' },
|
||||
};
|
||||
|
||||
const handlePlanSelect = (planIndex: number) => {
|
||||
setSelectedPlan(planIndex);
|
||||
};
|
||||
|
||||
const handleSeatsChange = (values: { seats: number }) => {
|
||||
if (values.seats <= 15) {
|
||||
setSelectedPlan(2);
|
||||
} else if (values.seats > 15 && values.seats <= 200) {
|
||||
setSelectedPlan(3);
|
||||
} else if (values.seats > 200) {
|
||||
setSelectedPlan(4);
|
||||
}
|
||||
};
|
||||
|
||||
const isSelected = (planIndex: number) =>
|
||||
selectedPlan === planIndex ? { border: '2px solid #1890ff' } : {};
|
||||
|
||||
const handleSubmit = () => {
|
||||
notification.success({
|
||||
message: t('submitSuccess'),
|
||||
description: t('submitSuccessDescription'),
|
||||
placement: 'topRight',
|
||||
});
|
||||
dispatch(toggleUpgradeModal());
|
||||
};
|
||||
|
||||
const renderFeature = (text: string) => (
|
||||
<div>
|
||||
<CheckCircleFilled style={cardStyles.checkIcon} />
|
||||
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderPlanCard = (
|
||||
planIndex: number,
|
||||
title: string,
|
||||
price: string | number,
|
||||
subtitle: string,
|
||||
users: string,
|
||||
features: string[],
|
||||
tag?: string
|
||||
) => (
|
||||
<Col span={6} style={{ padding: '0 4px' }}>
|
||||
<Card
|
||||
style={{ ...isSelected(planIndex), height: '100%' }}
|
||||
hoverable
|
||||
title={
|
||||
<span style={cardStyles.title}>
|
||||
{title}
|
||||
{tag && <Tag color="volcano">{tag}</Tag>}
|
||||
</span>
|
||||
}
|
||||
onClick={() => handlePlanSelect(planIndex)}
|
||||
>
|
||||
<div style={cardStyles.priceContainer}>
|
||||
<Typography.Title level={1}>
|
||||
{userCurrency} {price}
|
||||
</Typography.Title>
|
||||
<span>{subtitle}</span>
|
||||
<Typography.Title level={5}>{users}</Typography.Title>
|
||||
</div>
|
||||
|
||||
<div style={cardStyles.featureList}>
|
||||
{features.map((feature, index) => renderFeature(t(feature)))}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
);
|
||||
|
||||
const switchToFreePlan = async () => {
|
||||
const teamId = currentSession?.team_id;
|
||||
if (!teamId) return;
|
||||
|
||||
try {
|
||||
setSwitchingToFreePlan(true);
|
||||
const res = await adminCenterApiService.switchToFreePlan(teamId);
|
||||
if (res.done) {
|
||||
dispatch(fetchBillingInfo());
|
||||
dispatch(toggleUpgradeModal());
|
||||
const authorizeResponse = await authApiService.verify();
|
||||
if (authorizeResponse.authenticated) {
|
||||
setSession(authorizeResponse.user);
|
||||
dispatch(setUser(authorizeResponse.user));
|
||||
window.location.href = '/worklenz/admin-center/billing';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error switching to free plan', error);
|
||||
} finally {
|
||||
setSwitchingToFreePlan(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="upgrade-plans" style={{ marginTop: '1.5rem', textAlign: 'center' }}>
|
||||
<Typography.Title level={2}>{t('modalTitle')}</Typography.Title>
|
||||
|
||||
{selectedPlan !== 1 && (
|
||||
<Row justify="center">
|
||||
<Form initialValues={{ seats: 15 }} onValuesChange={handleSeatsChange}>
|
||||
<Form.Item name="seats" label={t('seatLabel')}>
|
||||
<Input type="number" min={15} step={5} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
<Row>
|
||||
{renderPlanCard(1, t('freePlan'), 0.0, t('freeSubtitle'), t('freeUsers'), [
|
||||
'freeText01',
|
||||
'freeText02',
|
||||
'freeText03',
|
||||
])}
|
||||
|
||||
{renderPlanCard(2, t('startup'), 4990, t('startupSubtitle'), t('startupUsers'), [
|
||||
'startupText01',
|
||||
'startupText02',
|
||||
'startupText03',
|
||||
'startupText04',
|
||||
'startupText05',
|
||||
])}
|
||||
|
||||
{renderPlanCard(
|
||||
3,
|
||||
t('business'),
|
||||
300,
|
||||
t('businessSubtitle'),
|
||||
'16 - 200 users',
|
||||
['startupText01', 'startupText02', 'startupText03', 'startupText04', 'startupText05'],
|
||||
t('tag')
|
||||
)}
|
||||
|
||||
{renderPlanCard(4, t('enterprise'), 250, t('businessSubtitle'), t('enterpriseUsers'), [
|
||||
'startupText01',
|
||||
'startupText02',
|
||||
'startupText03',
|
||||
'startupText04',
|
||||
'startupText05',
|
||||
])}
|
||||
</Row>
|
||||
|
||||
{selectedPlan === 1 ? (
|
||||
<Row justify="center" style={{ marginTop: '1.5rem' }}>
|
||||
<Button type="primary" loading={switchingToFreePlan} onClick={switchToFreePlan}>
|
||||
{t('switchToFreePlan')}
|
||||
</Button>
|
||||
</Row>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: themeMode === 'dark' ? '#141414' : '#e2e3e5',
|
||||
padding: '1rem',
|
||||
marginTop: '1.5rem',
|
||||
}}
|
||||
>
|
||||
<Typography.Title level={4}>{t('footerTitle')}</Typography.Title>
|
||||
<Form onFinish={handleSubmit}>
|
||||
<Row justify="center" style={{ height: '32px' }}>
|
||||
<Form.Item
|
||||
style={{ margin: '0 24px 0 0' }}
|
||||
name="contactNumber"
|
||||
label={t('footerLabel')}
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<Input type="number" placeholder="07xxxxxxxx" maxLength={10} minLength={10} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
{t('footerButton')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Row>
|
||||
</Form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpgradePlansLKR;
|
||||
@@ -0,0 +1,523 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Card, Col, Flex, Form, Row, Select, Tag, Tooltip, Typography, message } from 'antd/es';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
|
||||
import {
|
||||
IPricingPlans,
|
||||
IUpgradeSubscriptionPlanResponse,
|
||||
} from '@/types/admin-center/admin-center.types';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { IPaddlePlans, SUBSCRIPTION_STATUS } from '@/shared/constants';
|
||||
import { CheckCircleFilled, InfoCircleOutlined } from '@ant-design/icons';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { fetchBillingInfo, toggleUpgradeModal } from '@/features/admin-center/admin-center.slice';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { billingApiService } from '@/api/admin-center/billing.api.service';
|
||||
import { authApiService } from '@/api/auth/auth.api.service';
|
||||
import { setUser } from '@/features/user/userSlice';
|
||||
import { setSession } from '@/utils/session-helper';
|
||||
|
||||
// Extend Window interface to include Paddle
|
||||
declare global {
|
||||
interface Window {
|
||||
Paddle?: {
|
||||
Environment: { set: (env: string) => void };
|
||||
Setup: (config: { vendor: number; eventCallback: (data: any) => void }) => void;
|
||||
Checkout: { open: (params: any) => void };
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
declare const Paddle: any;
|
||||
|
||||
const UpgradePlans = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { t } = useTranslation('admin-center/current-bill');
|
||||
const [plans, setPlans] = useState<IPricingPlans>({});
|
||||
const [selectedPlan, setSelectedCard] = useState(IPaddlePlans.ANNUAL);
|
||||
const [selectedSeatCount, setSelectedSeatCount] = useState(5);
|
||||
const [seatCountOptions, setSeatCountOptions] = useState<number[]>([]);
|
||||
const [switchingToFreePlan, setSwitchingToFreePlan] = useState(false);
|
||||
|
||||
const [switchingToPaddlePlan, setSwitchingToPaddlePlan] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const currentSession = useAuthService().getCurrentSession();
|
||||
const paddlePlans = IPaddlePlans;
|
||||
|
||||
const { billingInfo } = useAppSelector(state => state.adminCenterReducer);
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
const [paddleLoading, setPaddleLoading] = useState(false);
|
||||
const [paddleError, setPaddleError] = useState<string | null>(null);
|
||||
|
||||
const populateSeatCountOptions = (currentSeats: number) => {
|
||||
if (!currentSeats) return [];
|
||||
|
||||
const step = 5;
|
||||
const maxSeats = 90;
|
||||
const minValue = Math.min(currentSeats + 1);
|
||||
const rangeStart = Math.ceil(minValue / step) * step;
|
||||
const range = Array.from(
|
||||
{ length: Math.floor((maxSeats - rangeStart) / step) + 1 },
|
||||
(_, i) => rangeStart + i * step
|
||||
);
|
||||
|
||||
return currentSeats < step
|
||||
? [...Array.from({ length: rangeStart - minValue }, (_, i) => minValue + i), ...range]
|
||||
: range;
|
||||
};
|
||||
|
||||
const fetchPricingPlans = async () => {
|
||||
try {
|
||||
const res = await adminCenterApiService.getPlans();
|
||||
if (res.done) {
|
||||
setPlans(res.body);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching pricing plans', error);
|
||||
}
|
||||
};
|
||||
|
||||
const switchToFreePlan = async () => {
|
||||
const teamId = currentSession?.team_id;
|
||||
if (!teamId) return;
|
||||
|
||||
try {
|
||||
setSwitchingToFreePlan(true);
|
||||
const res = await adminCenterApiService.switchToFreePlan(teamId);
|
||||
if (res.done) {
|
||||
dispatch(fetchBillingInfo());
|
||||
dispatch(toggleUpgradeModal());
|
||||
const authorizeResponse = await authApiService.verify();
|
||||
if (authorizeResponse.authenticated) {
|
||||
setSession(authorizeResponse.user);
|
||||
dispatch(setUser(authorizeResponse.user));
|
||||
window.location.href = '/worklenz/admin-center/billing';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error switching to free plan', error);
|
||||
} finally {
|
||||
setSwitchingToFreePlan(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaddleCallback = (data: any) => {
|
||||
console.log('Paddle event:', data);
|
||||
|
||||
switch (data.event) {
|
||||
case 'Checkout.Loaded':
|
||||
setSwitchingToPaddlePlan(false);
|
||||
setPaddleLoading(false);
|
||||
break;
|
||||
case 'Checkout.Complete':
|
||||
message.success('Subscription updated successfully!');
|
||||
setPaddleLoading(true);
|
||||
setTimeout(() => {
|
||||
dispatch(fetchBillingInfo());
|
||||
dispatch(toggleUpgradeModal());
|
||||
setSwitchingToPaddlePlan(false);
|
||||
setPaddleLoading(false);
|
||||
}, 10000);
|
||||
break;
|
||||
case 'Checkout.Close':
|
||||
setSwitchingToPaddlePlan(false);
|
||||
setPaddleLoading(false);
|
||||
// User closed the checkout without completing
|
||||
// message.info('Checkout was closed without completing the subscription');
|
||||
break;
|
||||
case 'Checkout.Error':
|
||||
setSwitchingToPaddlePlan(false);
|
||||
setPaddleLoading(false);
|
||||
setPaddleError(data.error?.message || 'An error occurred during checkout');
|
||||
message.error('Error during checkout: ' + (data.error?.message || 'Unknown error'));
|
||||
logger.error('Paddle checkout error', data.error);
|
||||
break;
|
||||
default:
|
||||
// Handle other events if needed
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const initializePaddle = (data: IUpgradeSubscriptionPlanResponse) => {
|
||||
setPaddleLoading(true);
|
||||
setPaddleError(null);
|
||||
|
||||
// Check if Paddle is already loaded
|
||||
if (window.Paddle) {
|
||||
configurePaddle(data);
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdn.paddle.com/paddle/paddle.js';
|
||||
script.type = 'text/javascript';
|
||||
script.async = true;
|
||||
|
||||
script.onload = () => {
|
||||
configurePaddle(data);
|
||||
};
|
||||
|
||||
script.onerror = () => {
|
||||
setPaddleLoading(false);
|
||||
setPaddleError('Failed to load Paddle checkout');
|
||||
message.error('Failed to load payment processor');
|
||||
logger.error('Failed to load Paddle script');
|
||||
};
|
||||
|
||||
document.getElementsByTagName('head')[0].appendChild(script);
|
||||
};
|
||||
|
||||
const configurePaddle = (data: IUpgradeSubscriptionPlanResponse) => {
|
||||
try {
|
||||
if (data.sandbox) Paddle.Environment.set('sandbox');
|
||||
Paddle.Setup({
|
||||
vendor: parseInt(data.vendor_id),
|
||||
eventCallback: (eventData: any) => {
|
||||
void handlePaddleCallback(eventData);
|
||||
},
|
||||
});
|
||||
Paddle.Checkout.open(data.params);
|
||||
} catch (error) {
|
||||
setPaddleLoading(false);
|
||||
setPaddleError('Failed to initialize checkout');
|
||||
message.error('Failed to initialize checkout');
|
||||
logger.error('Error initializing Paddle', error);
|
||||
}
|
||||
};
|
||||
|
||||
const upgradeToPaddlePlan = async (planId: string) => {
|
||||
try {
|
||||
setSwitchingToPaddlePlan(true);
|
||||
setPaddleLoading(true);
|
||||
setPaddleError(null);
|
||||
|
||||
if (billingInfo?.trial_in_progress && billingInfo.status === SUBSCRIPTION_STATUS.TRIALING) {
|
||||
const res = await billingApiService.upgradeToPaidPlan(planId, selectedSeatCount);
|
||||
if (res.done) {
|
||||
initializePaddle(res.body);
|
||||
} else {
|
||||
setSwitchingToPaddlePlan(false);
|
||||
setPaddleLoading(false);
|
||||
setPaddleError('Failed to prepare checkout');
|
||||
message.error('Failed to prepare checkout');
|
||||
}
|
||||
} else if (billingInfo?.status === SUBSCRIPTION_STATUS.ACTIVE) {
|
||||
// For existing subscriptions, use changePlan endpoint
|
||||
const res = await adminCenterApiService.changePlan(planId);
|
||||
if (res.done) {
|
||||
message.success('Subscription plan changed successfully!');
|
||||
dispatch(fetchBillingInfo());
|
||||
dispatch(toggleUpgradeModal());
|
||||
setSwitchingToPaddlePlan(false);
|
||||
setPaddleLoading(false);
|
||||
} else {
|
||||
setSwitchingToPaddlePlan(false);
|
||||
setPaddleLoading(false);
|
||||
setPaddleError('Failed to change plan');
|
||||
message.error('Failed to change subscription plan');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
setSwitchingToPaddlePlan(false);
|
||||
setPaddleLoading(false);
|
||||
setPaddleError('Error upgrading to paid plan');
|
||||
message.error('Failed to upgrade to paid plan');
|
||||
logger.error('Error upgrading to paddle plan', error);
|
||||
}
|
||||
};
|
||||
|
||||
const continueWithPaddlePlan = async () => {
|
||||
if (selectedPlan && selectedSeatCount.toString() === '100+') {
|
||||
message.info('Please contact sales for custom pricing on large teams');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSwitchingToPaddlePlan(true);
|
||||
setPaddleError(null);
|
||||
let planId: string | null = null;
|
||||
|
||||
if (selectedPlan === paddlePlans.ANNUAL && plans.annual_plan_id) {
|
||||
planId = plans.annual_plan_id;
|
||||
} else if (selectedPlan === paddlePlans.MONTHLY && plans.monthly_plan_id) {
|
||||
planId = plans.monthly_plan_id;
|
||||
}
|
||||
|
||||
if (planId) {
|
||||
upgradeToPaddlePlan(planId);
|
||||
} else {
|
||||
setSwitchingToPaddlePlan(false);
|
||||
setPaddleError('Invalid plan selected');
|
||||
message.error('Invalid plan selected');
|
||||
}
|
||||
} catch (error) {
|
||||
setSwitchingToPaddlePlan(false);
|
||||
setPaddleError('Error processing request');
|
||||
message.error('Error processing request');
|
||||
logger.error('Error upgrading to paddle plan', error);
|
||||
}
|
||||
};
|
||||
|
||||
const isSelected = (cardIndex: IPaddlePlans) =>
|
||||
selectedPlan === cardIndex ? { border: '2px solid #1890ff' } : {};
|
||||
|
||||
|
||||
const cardStyles = {
|
||||
title: {
|
||||
color: themeMode === 'dark' ? '#ffffffd9' : '#000000d9',
|
||||
fontWeight: 500,
|
||||
fontSize: '16px',
|
||||
display: 'flex',
|
||||
gap: '4px',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
priceContainer: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'auto',
|
||||
rowGap: '10px',
|
||||
padding: '20px 20px 0',
|
||||
},
|
||||
featureList: {
|
||||
display: 'grid',
|
||||
gridTemplateRows: 'auto auto auto',
|
||||
gridTemplateColumns: '200px',
|
||||
rowGap: '7px',
|
||||
padding: '10px',
|
||||
justifyItems: 'start',
|
||||
alignItems: 'start',
|
||||
},
|
||||
checkIcon: { color: '#52c41a' },
|
||||
};
|
||||
|
||||
const calculateAnnualTotal = (price: string | undefined) => {
|
||||
if (!price) return;
|
||||
return (12 * parseFloat(price) * selectedSeatCount).toFixed(2);
|
||||
};
|
||||
|
||||
const calculateMonthlyTotal = (price: string | undefined) => {
|
||||
if (!price) return;
|
||||
return (parseFloat(price) * selectedSeatCount).toFixed(2);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchPricingPlans();
|
||||
if (billingInfo?.total_used) {
|
||||
setSeatCountOptions(populateSeatCountOptions(billingInfo.total_used));
|
||||
form.setFieldsValue({ seatCount: selectedSeatCount });
|
||||
}
|
||||
}, [billingInfo]);
|
||||
|
||||
const renderFeature = (text: string) => (
|
||||
<div>
|
||||
<CheckCircleFilled style={cardStyles.checkIcon} />
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Cleanup Paddle script when component unmounts
|
||||
return () => {
|
||||
const paddleScript = document.querySelector('script[src*="paddle.js"]');
|
||||
if (paddleScript) {
|
||||
paddleScript.remove();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Flex justify="center" align="center">
|
||||
<Typography.Title level={2}>
|
||||
{billingInfo?.status === SUBSCRIPTION_STATUS.TRIALING
|
||||
? t('selectPlan')
|
||||
: t('changeSubscriptionPlan')}
|
||||
</Typography.Title>
|
||||
</Flex>
|
||||
|
||||
<Flex justify="center" align="center">
|
||||
<Form form={form}>
|
||||
<Form.Item name="seatCount" label={t('noOfSeats')}>
|
||||
<Select
|
||||
style={{ width: 100 }}
|
||||
value={selectedSeatCount}
|
||||
options={seatCountOptions.map(option => ({
|
||||
value: option,
|
||||
text: option.toString(),
|
||||
}))}
|
||||
onChange={setSelectedSeatCount}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Flex>
|
||||
|
||||
<Flex>
|
||||
<Row className="w-full">
|
||||
{/* Free Plan */}
|
||||
<Col span={8} style={{ padding: '0 4px' }}>
|
||||
<Card
|
||||
style={{ ...isSelected(paddlePlans.FREE), height: '100%' }}
|
||||
hoverable
|
||||
title={<span style={cardStyles.title}>{t('freePlan')}</span>}
|
||||
onClick={() => setSelectedCard(paddlePlans.FREE)}
|
||||
>
|
||||
|
||||
<div style={cardStyles.priceContainer}>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Typography.Title level={1}>$ 0.00</Typography.Title>
|
||||
<Typography.Text>{t('freeForever')}</Typography.Text>
|
||||
</Flex>
|
||||
<Flex justify="center" align="center">
|
||||
<Typography.Text strong style={{ fontSize: '16px' }}>
|
||||
{t('bestForPersonalUse')}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
</div>
|
||||
|
||||
<div style={cardStyles.featureList}>
|
||||
{renderFeature(`${plans.free_tier_storage} ${t('storage')}`)}
|
||||
{renderFeature(`${plans.projects_limit} ${t('projects')}`)}
|
||||
{renderFeature(`${plans.team_member_limit} ${t('teamMembers')}`)}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* Annual Plan */}
|
||||
<Col span={8} style={{ padding: '0 4px' }}>
|
||||
<Card
|
||||
style={{ ...isSelected(paddlePlans.ANNUAL), height: '100%' }}
|
||||
hoverable
|
||||
|
||||
title={
|
||||
<span style={cardStyles.title}>
|
||||
{t('annualPlan')}{' '}
|
||||
<Tag color="volcano" style={{ lineHeight: '21px' }}>
|
||||
{t('tag')}
|
||||
</Tag>
|
||||
</span>
|
||||
}
|
||||
onClick={() => setSelectedCard(paddlePlans.ANNUAL)}
|
||||
>
|
||||
<div style={cardStyles.priceContainer}>
|
||||
|
||||
<Flex justify="space-between" align="center">
|
||||
<Typography.Title level={1}>$ {plans.annual_price}</Typography.Title>
|
||||
<Typography.Text>seat / month</Typography.Text>
|
||||
</Flex>
|
||||
<Flex justify="center" align="center">
|
||||
<Typography.Text strong style={{ fontSize: '16px' }}>
|
||||
Total ${calculateAnnualTotal(plans.annual_price)}/ year
|
||||
<Tooltip
|
||||
title={
|
||||
'$' + plans.annual_price + ' x 12 months x ' + selectedSeatCount + ' seats'
|
||||
}
|
||||
>
|
||||
<InfoCircleOutlined
|
||||
style={{ color: 'grey', fontSize: '16px', marginLeft: '4px' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
<Flex justify="center" align="center">
|
||||
<Typography.Text>{t('billedAnnually')}</Typography.Text>
|
||||
</Flex>
|
||||
</div>
|
||||
|
||||
<div style={cardStyles.featureList} className="mt-4">
|
||||
{renderFeature(t('startupText01'))}
|
||||
{renderFeature(t('startupText02'))}
|
||||
{renderFeature(t('startupText03'))}
|
||||
{renderFeature(t('startupText04'))}
|
||||
{renderFeature(t('startupText05'))}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* Monthly Plan */}
|
||||
<Col span={8} style={{ padding: '0 4px' }}>
|
||||
<Card
|
||||
style={{ ...isSelected(paddlePlans.MONTHLY), height: '100%' }}
|
||||
hoverable
|
||||
title={<span style={cardStyles.title}>{t('monthlyPlan')}</span>}
|
||||
onClick={() => setSelectedCard(paddlePlans.MONTHLY)}
|
||||
|
||||
>
|
||||
<div style={cardStyles.priceContainer}>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Typography.Title level={1}>$ {plans.monthly_price}</Typography.Title>
|
||||
<Typography.Text>seat / month</Typography.Text>
|
||||
</Flex>
|
||||
<Flex justify="center" align="center">
|
||||
<Typography.Text strong style={{ fontSize: '16px' }}>
|
||||
Total ${calculateMonthlyTotal(plans.monthly_price)}/ month
|
||||
<Tooltip
|
||||
title={'$' + plans.monthly_price + ' x ' + selectedSeatCount + ' seats'}
|
||||
>
|
||||
<InfoCircleOutlined
|
||||
style={{ color: 'grey', fontSize: '16px', marginLeft: '4px' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
<Flex justify="center" align="center">
|
||||
<Typography.Text>{t('billedMonthly')}</Typography.Text>
|
||||
</Flex>
|
||||
</div>
|
||||
|
||||
<div style={cardStyles.featureList}>
|
||||
{renderFeature(t('startupText01'))}
|
||||
{renderFeature(t('startupText02'))}
|
||||
{renderFeature(t('startupText03'))}
|
||||
{renderFeature(t('startupText04'))}
|
||||
{renderFeature(t('startupText05'))}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Flex>
|
||||
{paddleError && (
|
||||
<Row justify="center" className="mt-2">
|
||||
<Typography.Text type="danger">{paddleError}</Typography.Text>
|
||||
</Row>
|
||||
)}
|
||||
<Row justify="end" className="mt-4">
|
||||
{selectedPlan === paddlePlans.FREE && (
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={switchingToFreePlan}
|
||||
onClick={switchToFreePlan}
|
||||
>
|
||||
Try for free
|
||||
</Button>
|
||||
)}
|
||||
{selectedPlan === paddlePlans.ANNUAL && (
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={switchingToPaddlePlan || paddleLoading}
|
||||
onClick={continueWithPaddlePlan}
|
||||
disabled={billingInfo?.plan_id === plans.annual_plan_id}
|
||||
>
|
||||
{billingInfo?.status === SUBSCRIPTION_STATUS.ACTIVE ? t('changeToPlan', {plan: t('annualPlan')}) : t('continueWith', {plan: t('annualPlan')})}
|
||||
</Button>
|
||||
)}
|
||||
{selectedPlan === paddlePlans.MONTHLY && (
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={switchingToPaddlePlan || paddleLoading}
|
||||
onClick={continueWithPaddlePlan}
|
||||
disabled={billingInfo?.plan_id === plans.monthly_plan_id}
|
||||
>
|
||||
{billingInfo?.status === SUBSCRIPTION_STATUS.ACTIVE ? t('changeToPlan', {plan: t('monthlyPlan')}) : t('continueWith', {plan: t('monthlyPlan')})}
|
||||
</Button>
|
||||
)}
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpgradePlans;
|
||||
Reference in New Issue
Block a user