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)); +} + + + + + + + +