diff --git a/package.json b/package.json
index 1bfccb3..e81df25 100644
--- a/package.json
+++ b/package.json
@@ -8,8 +8,7 @@
"start": "next start",
"lint": "next lint --fix",
"type-check": "tsc --noEmit",
- "test": "echo \"No tests yet\"",
- "prepare": "husky install && (chmod -R +x .husky || echo 'Skipping chmod')"
+ "test": "echo \"No tests yet\""
},
"dependencies": {
"@emotion/react": "^11.14.0",
diff --git a/src/app/admin/billing/components/BillingCard.tsx b/src/app/admin/billing/components/BillingCard.tsx
index 508d172..e922c6d 100644
--- a/src/app/admin/billing/components/BillingCard.tsx
+++ b/src/app/admin/billing/components/BillingCard.tsx
@@ -1,7 +1,6 @@
'use client';
import { styled } from '@mui/material/styles';
-import { padding } from '@mui/system';
import Image from 'next/image';
import CommonButton from '@/components/ui/CommonButton';
@@ -226,7 +225,9 @@ export default function PricingCard({
? 'green'
: btn.variant === 'cancel'
? 'cancel'
- : 'disabled'
+ : btn.variant === 'retry'
+ ? 'retry'
+ : 'disabled'
}
onClick={
btn.variant === 'disabled'
diff --git a/src/app/admin/billing/components/BillingHistorySection.tsx b/src/app/admin/billing/components/BillingHistorySection.tsx
index 1af6d42..cb22e63 100644
--- a/src/app/admin/billing/components/BillingHistorySection.tsx
+++ b/src/app/admin/billing/components/BillingHistorySection.tsx
@@ -77,9 +77,9 @@ const BillingHistorySection = () => {
timestamp: new Date(ref.date).getTime(),
}));
- const combined: BillingEntry[] = [...invoiceEntries, ...refundEntries].sort(
- (a, b) => b.timestamp - a.timestamp,
- );
+ const combined: BillingEntry[] = [...invoiceEntries, ...refundEntries]
+ .filter(entry => ['paid', 'unpaid', 'refunded'].includes(entry.status))
+ .sort((a, b) => b.timestamp - a.timestamp);
return (
diff --git a/src/app/admin/billing/components/BillingSection.tsx b/src/app/admin/billing/components/BillingSection.tsx
index 010f570..031e0a1 100644
--- a/src/app/admin/billing/components/BillingSection.tsx
+++ b/src/app/admin/billing/components/BillingSection.tsx
@@ -7,11 +7,13 @@ import { useKeenSlider } from 'keen-slider/react';
import { useEffect, useState } from 'react';
import CancelConfirmModal from '@/components/ui/CancelConfirmModal';
+import PaymentFailedModal from '@/components/ui/PaymentFailedModal';
import { useGetPlansQuery } from '@/features/public/publicApiSlice';
import {
useChangePlan,
useCreateSubscription,
useDowngradeToFree,
+ useRetryPayment,
useSubscription,
} from '@/features/subscription/useSubscription';
import type { Plan, PlanButton } from '@/types/plan.types';
@@ -38,21 +40,64 @@ function getPrice(pricing: { rrule: string; price: number }[]) {
function getButtonsByPlan(
plan: Plan,
currentPlanId: string,
+ pendingPlanId: string | undefined,
isSubscribed: boolean,
isCancelled: boolean,
+ isPendingCancellation: boolean,
+ isPendingDowngrade: boolean,
+ isFailed: boolean,
): PlanButton[] {
const isCurrent = plan._id === currentPlanId;
+ const isPendingDowngradeToPlan =
+ isPendingDowngrade && plan._id === pendingPlanId;
+
if (isCancelled) {
if (plan.tier === 'FREE')
return [{ label: 'Your current plan', variant: 'disabled' }];
return [{ label: `Go with ${plan.tier}`, variant: 'primary' }];
}
+
+ if (isFailed) {
+ // For failed subscriptions, show retry payment button for current plan
+ if (isCurrent) {
+ return [{ label: 'Retry Payment', variant: 'retry' }];
+ }
+ // For other plans, disable them during payment failure
+ return [{ label: `Go with ${plan.tier}`, variant: 'disabled' }];
+ }
+
if (isSubscribed) {
+ // Current plan display
if (isCurrent) {
+ if (isPendingCancellation) {
+ return [{ label: 'Cancels at period end', variant: 'disabled' }];
+ }
+ if (isPendingDowngrade) {
+ // Current plan during pending downgrade - show cancel downgrade option
+ return [{ label: 'Cancel downgrade', variant: 'primary' }];
+ }
return [{ label: 'Cancel Subscription', variant: 'cancel' }];
}
+
+ // Special handling for pending downgrade
+ if (isPendingDowngrade) {
+ if (isPendingDowngradeToPlan) {
+ // Target plan (Basic) - show downgrade info
+ return [{ label: 'Downgrades next cycle', variant: 'disabled' }];
+ } else {
+ // Other plans during pending downgrade - show switch option
+ return [{ label: `Go with ${plan.tier}`, variant: 'primary' }];
+ }
+ }
+
+ // If pending cancellation, show "Go with" to allow reactivation
+ if (isPendingCancellation) {
+ return [{ label: `Go with ${plan.tier}`, variant: 'primary' }];
+ }
+
return [{ label: `Switch to ${plan.tier}`, variant: 'primary' }];
}
+
return [{ label: 'Try for Free', variant: 'primary' }];
}
@@ -69,8 +114,18 @@ export default function BillingSection() {
const { create } = useCreateSubscription();
const { change } = useChangePlan();
const { downgrade } = useDowngradeToFree();
- const { subscription, isSubscribed, isCancelled, currentPlanId } =
- useSubscription();
+ const { retryPayment } = useRetryPayment();
+ const {
+ subscription,
+ isSubscribed,
+ isCancelled,
+ isFailed,
+ isPendingCancellation,
+ isPendingDowngrade,
+ currentPlanId,
+ } = useSubscription();
+
+ const pendingPlanId = subscription?.pendingPlanId?._id;
const tierOrder = { FREE: 0, BASIC: 1, PRO: 2 };
const sortedPlans = [...plans].sort(
@@ -80,7 +135,6 @@ export default function BillingSection() {
{
/* slide */
}
- const [currentSlide, setCurrentSlide] = useState(0);
const [sliderRef, slider] = useKeenSlider({
slides: { perView: 'auto', spacing: 0, origin: 'center' },
rubberband: false,
@@ -89,9 +143,6 @@ export default function BillingSection() {
'(min-width: 1000px)': { slides: { perView: 2, spacing: 0 } },
'(min-width: 1420px)': { slides: { perView: 2, spacing: 0 } },
},
- slideChanged(sliderInstance) {
- setCurrentSlide(sliderInstance.track.details.rel);
- },
});
useEffect(() => {
@@ -102,6 +153,7 @@ export default function BillingSection() {
}, [slider]);
const [showCancelModal, setShowCancelModal] = useState(false);
+ const [showPaymentFailedModal, setShowPaymentFailedModal] = useState(false);
const handleClick = async (
label: string,
@@ -111,19 +163,39 @@ export default function BillingSection() {
if (label.startsWith('Go with')) {
if (!subscription || subscription.status === 'cancelled') {
await create(planId);
+ } else if (
+ subscription.status === 'pending_cancellation' ||
+ subscription.status === 'pending_downgrade'
+ ) {
+ // If pending cancellation or downgrade, use change to cancel the pending change and switch plan
+ await change(planId);
+ window.location.reload();
} else if (subscription.planId._id !== planId) {
await change(planId);
}
}
- if (label === 'Cancel Subscription') {
+ if (label === 'Cancel Subscription' || label === 'Cancel Instead') {
setShowCancelModal(true);
return;
}
+ if (label === 'Retry Payment') {
+ setShowPaymentFailedModal(true);
+ return;
+ }
+ if (label === 'Cancel downgrade') {
+ // Cancel the pending downgrade by switching back to current plan
+ if (subscription?.planId._id) {
+ await change(subscription.planId._id);
+ window.location.reload();
+ }
+ return;
+ }
if (label.startsWith('Switch to')) {
if (tier === 'FREE') await downgrade();
else await change(planId);
window.location.reload();
}
+ // Do nothing for disabled states ('Cancels at period end', 'Downgrades next cycle', 'Your current plan')
};
const handleConfirmCancel = async () => {
@@ -136,6 +208,15 @@ export default function BillingSection() {
}
};
+ const handleRetryPayment = async () => {
+ try {
+ await retryPayment();
+ setShowPaymentFailedModal(false);
+ } catch {
+ // Handle error silently
+ }
+ };
+
return (
void handleClick(label, plan.tier, plan._id)
@@ -204,6 +289,12 @@ export default function BillingSection() {
onClose={() => setShowCancelModal(false)}
onConfirm={handleConfirmCancel}
/>
+
+ setShowPaymentFailedModal(false)}
+ onRetryPayment={handleRetryPayment}
+ />
);
}
diff --git a/src/app/admin/overview/components/ActivitySection.tsx b/src/app/admin/overview/components/ActivitySection.tsx
index 21e9b64..2e2f999 100644
--- a/src/app/admin/overview/components/ActivitySection.tsx
+++ b/src/app/admin/overview/components/ActivitySection.tsx
@@ -7,11 +7,15 @@ import Image from 'next/image';
import HalfCircleProgress from '@/components/ui/HalfCircleProgress';
import { useGetTodayMetricsQuery } from '@/features/callog/calllogApi';
-import { useGetTwilioPhoneNumberQuery } from '@/features/overview/overviewApi';
import { useGetBookingsQuery } from '@/features/service/serviceBookingApi';
import { useSubscription } from '@/features/subscription/useSubscription';
+import { useGetTwilioPhoneNumberQuery } from '@/features/twilio-phone-number/twilioPhoneNumberApi';
import { useAppSelector } from '@/redux/hooks';
import { getPlanTier, isFreeOrBasicPlan } from '@/utils/planUtils';
+import {
+ getRemainingMinutes,
+ getTotalMinutes,
+} from '@/utils/subscriptionUtils';
function formatSubscriptionPeriod(
start?: string | Date,
@@ -160,6 +164,18 @@ export default function ActivitySection() {
const planTier = getPlanTier(subscription);
const shouldHideBookingFeatures = isFreeOrBasicPlan(planTier);
+ // Calculate minutes data for the progress circle
+ const remainingMinutes = getRemainingMinutes(subscription);
+ const totalMinutes = getTotalMinutes(subscription?.planId);
+
+ // Determine display values and unit text
+ const isUnlimited = totalMinutes === Number.MAX_SAFE_INTEGER;
+ const displayValue = remainingMinutes; // Always show remaining minutes
+ const displayMaxValue = isUnlimited
+ ? Math.max(remainingMinutes, 1000)
+ : totalMinutes;
+ const unitText = isUnlimited ? '/Unlimited' : `/${totalMinutes}`;
+
const { data: bookings } = useGetBookingsQuery({ userId }, { skip: !userId });
const todayBookings = (bookings ?? []).filter(booking => {
@@ -262,9 +278,9 @@ export default function ActivitySection() {
diff --git a/src/app/auth/callback/AuthCallbackContent.tsx b/src/app/auth/callback/AuthCallbackContent.tsx
index 12afc63..51636c3 100644
--- a/src/app/auth/callback/AuthCallbackContent.tsx
+++ b/src/app/auth/callback/AuthCallbackContent.tsx
@@ -49,8 +49,6 @@ export default function AuthCallbackContent() {
// Clear any persisted auth state to prevent old user ID from being used
localStorage.removeItem('persist:root');
- console.log('[AuthCallback] Setting user with ID:', parsedUser._id);
-
dispatch(
setCredentials({
csrfToken,
diff --git a/src/components/ui/CommonButton.tsx b/src/components/ui/CommonButton.tsx
index b8c02c5..cb1fd25 100644
--- a/src/components/ui/CommonButton.tsx
+++ b/src/components/ui/CommonButton.tsx
@@ -5,7 +5,7 @@ import { Button } from '@mui/material';
import { styled } from '@mui/material/styles';
import React from 'react';
-type ButtonVariant = 'black' | 'green' | 'disabled' | 'cancel';
+type ButtonVariant = 'black' | 'green' | 'disabled' | 'cancel' | 'retry';
interface CommonButtonProps extends Omit {
children: React.ReactNode;
@@ -50,6 +50,17 @@ const StyledButton = styled(Button, {
};
}
+ if (buttonVariant === 'retry') {
+ return {
+ ...baseStyle,
+ backgroundColor: '#ff4444',
+ color: '#ffffff',
+ '&:hover': {
+ backgroundColor: '#cc3333',
+ },
+ };
+ }
+
const isBlack = buttonVariant === 'black';
return {
diff --git a/src/components/ui/HalfCircleProgress.tsx b/src/components/ui/HalfCircleProgress.tsx
index 63ab226..2b337de 100644
--- a/src/components/ui/HalfCircleProgress.tsx
+++ b/src/components/ui/HalfCircleProgress.tsx
@@ -63,7 +63,7 @@ export default function HalfCircleProgress({
marginLeft: '3px',
}}
>
- {unitText}
+ {unitText} min
diff --git a/src/components/ui/PaymentFailedModal.tsx b/src/components/ui/PaymentFailedModal.tsx
new file mode 100644
index 0000000..8189269
--- /dev/null
+++ b/src/components/ui/PaymentFailedModal.tsx
@@ -0,0 +1,76 @@
+// components/ui/PaymentFailedModal.tsx
+'use client';
+
+import CloseIcon from '@mui/icons-material/Close';
+import { Box, IconButton, Modal, Typography } from '@mui/material';
+
+import CommonButton from '@/components/ui/CommonButton';
+
+interface Props {
+ open: boolean;
+ onClose: () => void;
+ onRetryPayment: () => Promise;
+}
+
+export default function PaymentFailedModal({
+ open,
+ onClose,
+ onRetryPayment,
+}: Props) {
+ return (
+
+
+
+
+
+
+
+ Payment Failed
+
+
+ Your subscription payment failed. Please update your payment method to
+ continue using our service.
+
+
+
+
+ Close
+
+ {
+ void onRetryPayment();
+ }}
+ >
+ Update Payment Method
+
+
+
+
+ );
+}
diff --git a/src/components/ui/StatusChip.tsx b/src/components/ui/StatusChip.tsx
index 37282f0..4762e82 100644
--- a/src/components/ui/StatusChip.tsx
+++ b/src/components/ui/StatusChip.tsx
@@ -25,7 +25,13 @@ const statusStyles = {
};
const StatusChip = ({ status }: Props) => {
- const { bg, dot, text } = statusStyles[status];
+ const statusConfig = statusStyles[status] || {
+ bg: '#f5f5f5',
+ dot: '#999999',
+ text: status || 'Unknown',
+ };
+
+ const { bg, dot, text } = statusConfig;
return (
({
- query: userId => ({
- url: `/users/${userId}`,
- method: 'GET',
- }),
- transformResponse: (response: User): TwilioPhoneNumberResponse => ({
- twilioPhoneNumber: response.twilioPhoneNumber ?? '',
- }),
- }),
}),
});
-export const { useGetRecentServicesQuery, useGetTwilioPhoneNumberQuery } =
- overviewApi;
+export const { useGetRecentServicesQuery } = overviewApi;
diff --git a/src/features/subscription/useSubscription.ts b/src/features/subscription/useSubscription.ts
index 70f4442..e914fd4 100644
--- a/src/features/subscription/useSubscription.ts
+++ b/src/features/subscription/useSubscription.ts
@@ -21,9 +21,14 @@ export const useSubscription = () => {
refetch,
} = useGetSubscriptionByUserQuery(userId!, { skip: !userId });
- const isSubscribed = subscription?.status === 'active';
+ const isSubscribed =
+ subscription?.status === 'active' ||
+ subscription?.status === 'pending_cancellation' ||
+ subscription?.status === 'pending_downgrade';
const isCancelled = subscription?.status === 'cancelled' || !subscription;
const isFailed = subscription?.status === 'failed';
+ const isPendingCancellation = subscription?.status === 'pending_cancellation';
+ const isPendingDowngrade = subscription?.status === 'pending_downgrade';
const currentPlanId = subscription?.planId._id ?? '';
return {
@@ -31,6 +36,8 @@ export const useSubscription = () => {
isSubscribed,
isCancelled,
isFailed,
+ isPendingCancellation,
+ isPendingDowngrade,
currentPlanId,
isLoading,
isError,
diff --git a/src/features/twilio-phone-number/twilioPhoneNumberApi.ts b/src/features/twilio-phone-number/twilioPhoneNumberApi.ts
new file mode 100644
index 0000000..a4a718d
--- /dev/null
+++ b/src/features/twilio-phone-number/twilioPhoneNumberApi.ts
@@ -0,0 +1,40 @@
+import { createApi } from '@reduxjs/toolkit/query/react';
+
+import { axiosBaseQuery } from '@/lib/axiosBaseQuery';
+
+export interface TwilioPhoneNumberResponse {
+ twilioPhoneNumber: string;
+}
+
+interface User {
+ _id: string;
+ firstName: string;
+ lastName: string;
+ email: string;
+ twilioPhoneNumber?: string;
+}
+
+export const twilioPhoneNumberApi = createApi({
+ reducerPath: 'twilioPhoneNumberApi',
+ baseQuery: axiosBaseQuery(),
+ tagTypes: ['TwilioPhoneNumber'],
+ endpoints: builder => ({
+ getTwilioPhoneNumber: builder.query({
+ query: userId => ({
+ url: `/users/${userId}`,
+ method: 'GET',
+ }),
+ transformResponse: (response: User): TwilioPhoneNumberResponse => ({
+ twilioPhoneNumber: response.twilioPhoneNumber ?? '',
+ }),
+ providesTags: ['TwilioPhoneNumber'],
+ }),
+ }),
+});
+
+// Export hooks
+export const { useGetTwilioPhoneNumberQuery } = twilioPhoneNumberApi;
+
+// Export raw endpoints
+export const getTwilioPhoneNumber =
+ twilioPhoneNumberApi.endpoints.getTwilioPhoneNumber.initiate;
diff --git a/src/redux/root-reducer.ts b/src/redux/root-reducer.ts
index 214c408..6be7dcc 100644
--- a/src/redux/root-reducer.ts
+++ b/src/redux/root-reducer.ts
@@ -17,6 +17,7 @@ import { subscriptionApi } from '@/features/subscription/subscriptionApi';
import { testApi } from '@/features/test/testApiSlice';
import { transcriptApi } from '@/features/transcript/transcriptApi';
import { transcriptChunksApi } from '@/features/transcript-chunk/transcriptChunksApi';
+import { twilioPhoneNumberApi } from '@/features/twilio-phone-number/twilioPhoneNumberApi';
export const rootReducer = combineReducers({
auth: authReducer,
@@ -35,6 +36,7 @@ export const rootReducer = combineReducers({
[serviceBookingApi.reducerPath]: serviceBookingApi.reducer,
[serviceApi.reducerPath]: serviceApi.reducer,
[serviceManagementApi.reducerPath]: serviceManagementApi.reducer,
+ [twilioPhoneNumberApi.reducerPath]: twilioPhoneNumberApi.reducer,
});
export type RootState = ReturnType;
diff --git a/src/redux/store.ts b/src/redux/store.ts
index 1c14f03..b17cdca 100644
--- a/src/redux/store.ts
+++ b/src/redux/store.ts
@@ -26,6 +26,7 @@ import { subscriptionApi } from '@/features/subscription/subscriptionApi';
import { testApi } from '@/features/test/testApiSlice';
import { transcriptApi } from '@/features/transcript/transcriptApi';
import { transcriptChunksApi } from '@/features/transcript-chunk/transcriptChunksApi';
+import { twilioPhoneNumberApi } from '@/features/twilio-phone-number/twilioPhoneNumberApi';
import { rootReducer } from './root-reducer';
@@ -60,6 +61,7 @@ export const store = configureStore({
serviceBookingApi.middleware,
serviceApi.middleware,
serviceManagementApi.middleware,
+ twilioPhoneNumberApi.middleware,
),
});
diff --git a/src/types/plan.types.ts b/src/types/plan.types.ts
index 4daa458..9786f15 100644
--- a/src/types/plan.types.ts
+++ b/src/types/plan.types.ts
@@ -1,5 +1,10 @@
export type PlanTier = 'FREE' | 'BASIC' | 'PRO';
-export type ButtonVariant = 'primary' | 'secondary' | 'disabled' | 'cancel';
+export type ButtonVariant =
+ | 'primary'
+ | 'secondary'
+ | 'disabled'
+ | 'cancel'
+ | 'retry';
export interface PlanButton {
label: string;
diff --git a/src/types/subscription.d.ts b/src/types/subscription.d.ts
index e19361f..b938cd0 100644
--- a/src/types/subscription.d.ts
+++ b/src/types/subscription.d.ts
@@ -13,13 +13,20 @@ export interface Subscription {
_id: string;
userId: string;
planId: Plan;
+ pendingPlanId?: Plan;
subscriptionId: string;
stripeCustomerId: string;
chargeId: string;
- status: 'active' | 'cancelled' | 'failed';
+ status:
+ | 'active'
+ | 'cancelled'
+ | 'failed'
+ | 'pending_cancellation'
+ | 'pending_downgrade';
startAt: string;
endAt: string;
createdAt: string;
+ secondsLeft: number;
}
export interface RawInvoice {
diff --git a/src/utils/subscriptionUtils.ts b/src/utils/subscriptionUtils.ts
new file mode 100644
index 0000000..8dade1c
--- /dev/null
+++ b/src/utils/subscriptionUtils.ts
@@ -0,0 +1,83 @@
+/**
+ * Extract numeric minutes from callMinutes string
+ * Handles various formats like "100 Min/Month", "Unlimited", "100", etc.
+ */
+export function extractMinutesFromCallMinutes(
+ callMinutes: string | number,
+): number {
+ // If it's already a number, return it
+ if (typeof callMinutes === 'number') {
+ return callMinutes;
+ }
+
+ // Handle string formats
+ const str = callMinutes.toString().toLowerCase();
+
+ // Check for unlimited
+ if (str.includes('unlimited') || str.includes('∞')) {
+ return Number.MAX_SAFE_INTEGER; // Use a very large number for unlimited
+ }
+
+ // Extract number from string using regex
+ const match = /(\d+)/.exec(str);
+ if (match) {
+ return parseInt(match[1], 10);
+ }
+
+ // Default fallback
+ return 0;
+}
+
+/**
+ * Convert seconds to minutes
+ */
+export function secondsToMinutes(seconds: number): number {
+ return Math.floor(seconds / 60);
+}
+
+/**
+ * Get remaining minutes from subscription
+ */
+export function getRemainingMinutes(
+ subscription: { secondsLeft: number } | null | undefined,
+): number {
+ if (!subscription) return 0;
+ return secondsToMinutes(subscription.secondsLeft);
+}
+
+/**
+ * Get total minutes from plan
+ */
+export function getTotalMinutes(
+ plan: { features: { callMinutes: string | number } } | null | undefined,
+): number {
+ if (!plan) return 0;
+ return extractMinutesFromCallMinutes(plan.features.callMinutes);
+}
+
+/**
+ * Get usage percentage (used minutes / total minutes)
+ */
+export function getUsagePercentage(
+ subscription: { secondsLeft: number } | null | undefined,
+ plan: { features: { callMinutes: string | number } } | null | undefined,
+): number {
+ if (!subscription || !plan) return 0;
+
+ const totalMinutes = getTotalMinutes(plan);
+ const remainingMinutes = getRemainingMinutes(subscription);
+ const usedMinutes = totalMinutes - remainingMinutes;
+
+ if (totalMinutes === 0) return 0;
+ if (totalMinutes === Number.MAX_SAFE_INTEGER) return 0; // Unlimited plan
+
+ return Math.max(0, Math.min(100, (usedMinutes / totalMinutes) * 100));
+}
+
+
+
+
+
+
+
+