diff --git a/src/platform/src/modules/api-client/ApiClientPage.tsx b/src/platform/src/modules/api-client/ApiClientPage.tsx index cf9233f9ce..934bde92de 100644 --- a/src/platform/src/modules/api-client/ApiClientPage.tsx +++ b/src/platform/src/modules/api-client/ApiClientPage.tsx @@ -15,7 +15,6 @@ import CreateClientDialog from './components/CreateClientDialog'; import EditClientDialog from './components/EditClientDialog'; import TokenDisplay from './components/TokenDisplay'; import type { Client } from '@/shared/types/api'; -import UsageStats from '../billing/components/UsageStats'; import { trackApiClientAction } from '@/shared/utils/enhancedAnalytics'; type TableClient = Client & { id: string }; @@ -324,8 +323,6 @@ const ApiClientPage: React.FC = () => { return (
- - {/* API Clients Table */} { const searchParams = useSearchParams(); const [activeSubTab, setActiveSubTab] = useState< - 'subscription' | 'transactions' + 'subscription' | 'usage' | 'transactions' >('subscription'); const checkoutState = searchParams.get('checkout'); @@ -36,6 +37,7 @@ const BillingPage: React.FC = () => { const subTabs = [ { id: 'subscription' as const, label: 'Subscription & Tiers' }, + { id: 'usage' as const, label: 'Usage & Limits' }, { id: 'transactions' as const, label: 'Transactions' }, ]; @@ -43,10 +45,11 @@ const BillingPage: React.FC = () => {

- API Subscription & Billing + Subscription & Billing

- Review your current plan, renewal settings, and billing activity. + Review your current plan, switch tiers, and monitor API usage from one + place.

@@ -75,6 +78,7 @@ const BillingPage: React.FC = () => {
{activeSubTab === 'subscription' && } + {activeSubTab === 'usage' && } {activeSubTab === 'transactions' && }
); diff --git a/src/platform/src/modules/billing/components/CheckoutDialog.tsx b/src/platform/src/modules/billing/components/CheckoutDialog.tsx index 005b18b989..77b88a52a6 100644 --- a/src/platform/src/modules/billing/components/CheckoutDialog.tsx +++ b/src/platform/src/modules/billing/components/CheckoutDialog.tsx @@ -2,14 +2,19 @@ import React from 'react'; import ReusableDialog from '@/shared/components/ui/dialog'; -import { AqCheck } from '@airqo/icons-react'; +import { AqArrowUp, AqCheck } from '@airqo/icons-react'; import type { SubscriptionPlan, SubscriptionTier } from '@/shared/types/api'; +type PlanActionMode = 'checkout' | 'upgrade' | 'downgrade'; + interface CheckoutDialogProps { isOpen: boolean; onClose: () => void; plan: SubscriptionPlan | null; currentTier: SubscriptionTier; + currentPlan?: SubscriptionPlan | null; + mode?: PlanActionMode; + accessUntilLabel?: string | null; loading?: boolean; onConfirm: () => void; } @@ -19,73 +24,150 @@ const CheckoutDialog: React.FC = ({ onClose, plan, currentTier, + currentPlan, + mode = 'checkout', + accessUntilLabel, loading = false, onConfirm, }) => { const billingCycleLabel = 'monthly'; + const isCheckout = mode === 'checkout'; + const isDowngrade = mode === 'downgrade'; + + const title = plan + ? isCheckout + ? `Get ${plan.name}?` + : isDowngrade + ? `Downgrade to ${plan.name}?` + : `Upgrade to ${plan.name}?` + : 'Review plan change'; + + const subtitle = isCheckout + ? 'Confirm this plan and continue in secure checkout.' + : isDowngrade + ? 'Your current tier remains active until the next billing period.' + : 'Your new rate limits become available right away.'; + + const primaryLabel = loading + ? isCheckout + ? 'Opening...' + : 'Saving...' + : isCheckout + ? 'Continue to Checkout' + : isDowngrade + ? 'Confirm Downgrade' + : 'Confirm Upgrade'; + + const summaryMessage = isCheckout + ? null + : isDowngrade + ? 'Switching to a lower tier schedules the change for your next billing period. Your current limits stay in place until then.' + : 'Switching to a higher tier applies immediately. Your updated API limits and access are available as soon as the request succeeds.'; + + const nextPeriodMessage = accessUntilLabel + ? `You keep ${currentTier} access through ${accessUntilLabel}.` + : `You keep ${currentTier} access until the end of your current billing period.`; return ( -
-
-

- Checkout opens in our secure billing window. Payment details are - handled by our billing provider and are never stored in this - application. -

-
- -
-
-

- Current Plan -

-

- {currentTier} +

+ {summaryMessage && ( +
+

+ {summaryMessage}

-
-

- Plan To Activate -

-

- {plan?.name || '--'} -

+ )} + +
+
+
+

+ From +

+

+ {currentTier} +

+

+ {currentPlan + ? `${currentPlan.currency} ${currentPlan.price} / ${billingCycleLabel}` + : '--'} +

+
+ +
+ + to + +
+ +
+

+ {isDowngrade + ? 'Scheduled Tier' + : isCheckout + ? 'Plan To Start' + : 'Tier To Activate'} +

+

+ {plan?.name || '--'} +

+

+ {plan + ? `${plan.currency} ${plan.price} / ${billingCycleLabel}` + : '--'} +

+
+ {isDowngrade && ( +
+

+ {nextPeriodMessage} +

+
+ )} + {plan && ( -
-

- Plan highlights +

+

+ Included with {plan.name}

    {plan.features.slice(0, 4).map(feature => (
  • - - + + {feature}
  • diff --git a/src/platform/src/modules/billing/components/SubscriptionSection.tsx b/src/platform/src/modules/billing/components/SubscriptionSection.tsx index cc6d83686d..ad6c2e9f82 100644 --- a/src/platform/src/modules/billing/components/SubscriptionSection.tsx +++ b/src/platform/src/modules/billing/components/SubscriptionSection.tsx @@ -8,14 +8,8 @@ import React, { useState, } from 'react'; import { useSession } from 'next-auth/react'; -import { AqCheck } from '@airqo/icons-react'; -import { - Banner, - Button, - Card, - LoadingSpinner, - toast, -} from '@/shared/components/ui'; +import { AqAlertTriangle, AqCheck } from '@airqo/icons-react'; +import { Button, Card, LoadingSpinner, toast } from '@/shared/components/ui'; import ReusableDialog from '@/shared/components/ui/dialog'; import { PADDLE_CHECKOUT_COMPLETED_EVENT } from '@/shared/lib/paddle'; import { formatDate } from '@/shared/utils'; @@ -32,36 +26,118 @@ const BILLING_SERVICE_UNAVAILABLE_MESSAGE = const CHECKOUT_STATUS_POLL_INTERVAL_MS = 2000; const CHECKOUT_STATUS_MAX_ATTEMPTS = 6; +const ACCENT_FILLED_BUTTON_CLASS = + 'border border-[#DA7B00] bg-[#DA7B00] text-white hover:!border-[#B86500] hover:!bg-[#B86500] focus:ring-[#DA7B00] disabled:opacity-50'; +const ACCENT_OUTLINED_BUTTON_CLASS = + 'border-[#DA7B00] text-[#DA7B00] hover:!border-[#DA7B00] hover:!bg-[#DA7B00] hover:!text-white focus:ring-[#DA7B00]'; +const NEUTRAL_OUTLINED_BUTTON_CLASS = + 'border-slate-300 text-slate-700 hover:!border-slate-900 hover:!bg-slate-900 hover:!text-white dark:border-slate-700 dark:text-slate-100 dark:hover:!border-slate-200 dark:hover:!bg-slate-200 dark:hover:!text-slate-950'; type ConfirmationAction = 'enableAutoRenew' | 'disableAutoRenew' | 'cancel'; -type RunningAction = 'checkout' | ConfirmationAction | null; +type PlanActionMode = 'checkout' | 'upgrade' | 'downgrade'; +type RunningAction = 'checkout' | 'changeTier' | ConfirmationAction | null; +type ActivePaidTier = Extract; + +interface PendingTierChange { + previousTier: ActivePaidTier; + newTier: ActivePaidTier; + effectiveFrom: 'immediately' | 'next_billing_period'; +} + +interface PlanActionConfig { + label: string; + mode: PlanActionMode; + disabled: boolean; + variant: 'filled' | 'outlined'; + helperText: string; +} interface ExtendedSessionUser { _id?: string; } -const statusBadgeStyles: Record = { - active: - 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-200', - inactive: 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-200', - trialing: 'bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-200', - past_due: - 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200', - cancelled: - 'bg-slate-100 text-slate-800 dark:bg-slate-800 dark:text-slate-200', - paused: - 'bg-violet-100 text-violet-800 dark:bg-violet-900/40 dark:text-violet-200', +const statusBadgeStyles: Record< + UserSubscription['status'], + { container: string; dot: string; label: string } +> = { + active: { + container: + 'border border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-800/60 dark:bg-emerald-950/30 dark:text-emerald-200', + dot: 'bg-emerald-500', + label: 'Active', + }, + inactive: { + container: + 'border border-slate-200 bg-slate-50 text-slate-600 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300', + dot: 'bg-slate-400', + label: 'Inactive', + }, + trialing: { + container: + 'border border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-800/60 dark:bg-sky-950/30 dark:text-sky-200', + dot: 'bg-sky-500', + label: 'Trialing', + }, + past_due: { + container: + 'border border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-800/60 dark:bg-amber-950/30 dark:text-amber-200', + dot: 'bg-amber-500', + label: 'Past due', + }, + cancelled: { + container: + 'border border-slate-200 bg-slate-50 text-slate-700 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200', + dot: 'bg-slate-500', + label: 'Ended', + }, + paused: { + container: + 'border border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-800/60 dark:bg-violet-950/30 dark:text-violet-200', + dot: 'bg-violet-500', + label: 'Paused', + }, }; -const statusLabels: Record = { - active: 'Active', - inactive: 'Inactive', - trialing: 'Trialing', - past_due: 'Past due', - cancelled: 'Ended', - paused: 'Paused', +const noticeToneStyles = { + warning: { + container: + 'border-[#F4C267] bg-[#FFF7E6] dark:border-amber-800/60 dark:bg-amber-950/20', + icon: 'text-[#DA7B00] dark:text-amber-300', + title: 'text-[#9A4D00] dark:text-amber-100', + body: 'text-[#B56200] dark:text-amber-200', + }, + info: { + container: + 'border-sky-200 bg-sky-50 dark:border-sky-800/60 dark:bg-sky-950/20', + icon: 'text-sky-600 dark:text-sky-300', + title: 'text-sky-900 dark:text-sky-100', + body: 'text-sky-700 dark:text-sky-200', + }, + critical: { + container: + 'border-rose-200 bg-rose-50 dark:border-rose-800/60 dark:bg-rose-950/20', + icon: 'text-rose-600 dark:text-rose-300', + title: 'text-rose-900 dark:text-rose-100', + body: 'text-rose-700 dark:text-rose-200', + }, +} as const; + +const getPaidTier = (tier: SubscriptionTier): ActivePaidTier | null => { + if (tier === 'Standard' || tier === 'Premium') { + return tier; + } + + return null; }; +const isSwitchablePaidStatus = ( + status: UserSubscription['status'], + hasAccessWindow: boolean +): boolean => + status === 'active' || + status === 'trialing' || + (status === 'cancelled' && hasAccessWindow); + const getBillingErrorLogMessage = (error: unknown): string => { if (error instanceof Error && error.message.trim()) { return error.message.trim(); @@ -93,9 +169,13 @@ const SubscriptionSection: React.FC = () => { const [selectedPlan, setSelectedPlan] = useState( null ); + const [planActionMode, setPlanActionMode] = + useState('checkout'); const [runningAction, setRunningAction] = useState(null); const [pendingConfirmation, setPendingConfirmation] = useState(null); + const [pendingTierChange, setPendingTierChange] = + useState(null); const isMountedRef = useRef(true); const userId = (session?.user as ExtendedSessionUser | null)?._id?.trim() || ''; @@ -115,10 +195,12 @@ const SubscriptionSection: React.FC = () => { }, [subscription?.nextBillingDate]); const currentStatusLabel = useMemo(() => { if (currentStatus === 'cancelled' && accessDateText) { - return 'Scheduled to end'; + return 'Ending soon'; } - return statusLabels[currentStatus] || currentStatus.replace('_', ' '); + return ( + statusBadgeStyles[currentStatus]?.label || currentStatus.replace('_', ' ') + ); }, [accessDateText, currentStatus]); const summaryDateLabel = useMemo(() => { if (currentTier === 'Free' && currentStatus !== 'cancelled') { @@ -199,11 +281,32 @@ const SubscriptionSection: React.FC = () => { () => plans.find(plan => plan.tier === currentTier), [plans, currentTier] ); + + useEffect(() => { + if (!pendingTierChange) { + return; + } + + if (currentTier === pendingTierChange.newTier || currentTier === 'Free') { + setPendingTierChange(null); + } + }, [currentTier, pendingTierChange]); + const statusNotice = useMemo(() => { + if (pendingTierChange?.effectiveFrom === 'next_billing_period') { + const accessWindow = + accessDateText || 'the end of your current billing period'; + + return { + tone: 'info' as const, + title: `Downgrade to ${pendingTierChange.newTier} is scheduled`, + message: `You will keep ${pendingTierChange.previousTier} access until ${accessWindow}.`, + }; + } + if (currentStatus === 'past_due') { return { - severity: 'warning' as const, - eyebrow: 'Action required', + tone: 'critical' as const, title: 'We could not complete your latest renewal', message: 'Retry billing or update your payment setup to keep API access active without interruption.', @@ -213,17 +316,15 @@ const SubscriptionSection: React.FC = () => { if (currentStatus === 'cancelled') { if (accessDateText) { return { - severity: 'info' as const, - eyebrow: 'Renewal off', - title: `Paid access remains available through ${accessDateText}`, + tone: 'warning' as const, + title: 'Automatic renewal is turned off', message: - 'Automatic renewal is off for this subscription. Resume renewal before that date to continue service without interruption.', + 'This plan will remain available until the current access period ends and will not renew automatically.', }; } return { - severity: 'info' as const, - eyebrow: 'Subscription ended', + tone: 'info' as const, title: 'This paid plan is no longer active', message: 'Your account is currently on the Free tier. Choose a paid plan below whenever you need higher API capacity again.', @@ -232,8 +333,7 @@ const SubscriptionSection: React.FC = () => { if (currentStatus === 'paused') { return { - severity: 'info' as const, - eyebrow: 'Subscription paused', + tone: 'info' as const, title: 'Billing is paused for this plan', message: 'Resume renewal when you are ready to restart scheduled billing and higher-rate access.', @@ -242,13 +342,10 @@ const SubscriptionSection: React.FC = () => { if (currentTier !== 'Free' && !subscription?.automaticRenewal) { return { - severity: 'info' as const, - eyebrow: 'Renewal off', - title: accessDateText - ? `Your plan stays active through ${accessDateText}` - : 'Automatic renewal is turned off', + tone: 'warning' as const, + title: 'Automatic renewal is turned off', message: accessDateText - ? 'Resume renewal before that date to continue service without interruption.' + ? 'This plan will remain available until the current access period ends and will not renew automatically.' : 'This plan will remain available until the current access period ends and will not renew automatically.', }; } @@ -258,6 +355,7 @@ const SubscriptionSection: React.FC = () => { accessDateText, currentStatus, currentTier, + pendingTierChange, subscription?.automaticRenewal, ]); const confirmationDialog = useMemo(() => { @@ -312,6 +410,21 @@ const SubscriptionSection: React.FC = () => { currentTier !== 'Free' && currentStatus !== 'inactive' && currentStatus !== 'cancelled'; + const hasSwitchablePaidSubscription = + currentTier !== 'Free' && + isSwitchablePaidStatus(currentStatus, Boolean(accessDateText)); + const isPlanActionLoading = + runningAction === 'checkout' || runningAction === 'changeTier'; + + const closePlanDialog = useCallback(() => { + if (isPlanActionLoading) { + return; + } + + setDialogOpen(false); + setSelectedPlan(null); + setPlanActionMode('checkout'); + }, [isPlanActionLoading]); const syncSubscriptionAfterCheckout = useCallback(async () => { let latestSubscription: UserSubscription | null = null; @@ -376,6 +489,16 @@ const SubscriptionSection: React.FC = () => { const handleOpenCheckout = (plan: SubscriptionPlan) => { setSelectedPlan(plan); + setPlanActionMode('checkout'); + setDialogOpen(true); + }; + + const handleOpenPlanDialog = ( + plan: SubscriptionPlan, + mode: PlanActionMode + ) => { + setSelectedPlan(plan); + setPlanActionMode(mode); setDialogOpen(true); }; @@ -432,6 +555,80 @@ const SubscriptionSection: React.FC = () => { setRunningAction(null); setDialogOpen(false); setSelectedPlan(null); + setPlanActionMode('checkout'); + } + }; + + const handleChangeTierConfirm = async () => { + if (!selectedPlan) { + return; + } + + if (selectedPlan.tier !== 'Standard' && selectedPlan.tier !== 'Premium') { + toast.error('Only Standard and Premium tiers can be switched here.'); + return; + } + + try { + setRunningAction('changeTier'); + + const payload = await subscriptionService.changeTier(selectedPlan.tier); + + if (!payload.success) { + if (payload.comingSoon) { + toast.warning(BILLING_SERVICE_UNAVAILABLE_MESSAGE); + return; + } + + throw new Error( + payload.message || 'Failed to change subscription tier' + ); + } + + const previousTier = + payload.data?.previousTier || getPaidTier(currentTier); + const effectiveFrom = + payload.data?.effectiveFrom || + (currentPlan && selectedPlan.price > currentPlan.price + ? 'immediately' + : 'next_billing_period'); + + if ( + effectiveFrom === 'next_billing_period' && + previousTier && + selectedPlan.tier !== previousTier + ) { + setPendingTierChange({ + previousTier, + newTier: selectedPlan.tier, + effectiveFrom, + }); + toast.success( + 'Downgrade scheduled', + payload.message || + `Downgrade to ${selectedPlan.tier} will apply next billing period.` + ); + } else { + setPendingTierChange(null); + toast.success( + 'Plan updated', + payload.message || `You are now on the ${selectedPlan.tier} plan.` + ); + } + + await refreshData(); + } catch (error) { + console.error(`Change tier error: ${getBillingErrorLogMessage(error)}`); + toast.error( + error instanceof Error + ? error.message + : 'Failed to change subscription tier' + ); + } finally { + setRunningAction(null); + setDialogOpen(false); + setSelectedPlan(null); + setPlanActionMode('checkout'); } }; @@ -543,6 +740,131 @@ const SubscriptionSection: React.FC = () => { } }; + const getPlanActionConfig = useCallback( + (plan: SubscriptionPlan): PlanActionConfig => { + const isCurrent = plan.tier === currentTier; + const isScheduledTarget = + pendingTierChange?.effectiveFrom === 'next_billing_period' && + pendingTierChange.newTier === plan.tier; + + if (isCurrent) { + if (plan.tier === 'Free') { + return { + label: 'Included plan', + mode: 'checkout', + disabled: true, + variant: 'outlined', + helperText: 'This is your current access level.', + }; + } + + if (currentStatus === 'past_due') { + return { + label: 'Retry billing', + mode: 'checkout', + disabled: false, + variant: 'filled', + helperText: 'Open secure checkout to restore this plan.', + }; + } + + if (currentStatus === 'paused') { + return { + label: 'Resume plan', + mode: 'checkout', + disabled: false, + variant: 'filled', + helperText: 'Open secure checkout to resume this plan.', + }; + } + + if (currentStatus === 'cancelled' && !accessDateText) { + return { + label: 'Reactivate plan', + mode: 'checkout', + disabled: false, + variant: 'filled', + helperText: 'Start a new checkout session for this plan.', + }; + } + + return { + label: 'Current plan', + mode: 'checkout', + disabled: true, + variant: 'outlined', + helperText: pendingTierChange + ? `Downgrade to ${pendingTierChange.newTier} is already scheduled.` + : 'This is the tier your account is using right now.', + }; + } + + if (plan.tier === 'Free') { + return { + label: 'Included plan', + mode: 'checkout', + disabled: true, + variant: 'outlined', + helperText: + 'Use Cancel Plan if you want to end paid access and return to Free.', + }; + } + + if (isScheduledTarget) { + return { + label: 'Downgrade scheduled', + mode: 'downgrade', + disabled: true, + variant: 'outlined', + helperText: accessDateText + ? `${plan.name} starts after ${accessDateText}.` + : `${plan.name} starts next billing period.`, + }; + } + + if (hasSwitchablePaidSubscription && currentPlan) { + const isUpgrade = plan.price > currentPlan.price; + + return { + label: isUpgrade + ? `Upgrade to ${plan.name}` + : `Downgrade to ${plan.name}`, + mode: isUpgrade ? 'upgrade' : 'downgrade', + disabled: false, + variant: isUpgrade ? 'filled' : 'outlined', + helperText: isUpgrade + ? 'Applies immediately when confirmed.' + : 'Keeps your current tier until the next billing period.', + }; + } + + return { + label: + currentTier === 'Free' + ? `Get ${plan.name}` + : currentStatus === 'past_due' + ? `Choose ${plan.name}` + : currentStatus === 'paused' + ? `Resume with ${plan.name}` + : currentStatus === 'cancelled' + ? `Restart with ${plan.name}` + : `Continue with ${plan.name}`, + mode: 'checkout', + disabled: false, + variant: 'filled', + helperText: 'Secure checkout opens in a billing window.', + }; + }, + [ + accessDateText, + currentPlan, + currentStatus, + currentTier, + hasSwitchablePaidSubscription, + pendingTierChange, + ] + ); + if (loading) { return (
    @@ -554,66 +876,87 @@ const SubscriptionSection: React.FC = () => { return ( <>
    - -
    -
    + +

    Active Access Tier

    -

    - {currentTier} -

    -

    - {currentPlan - ? `${currentPlan.currency} ${currentPlan.price}/month` - : 'Subscription status from your account profile'} -

    +
    +

    + {currentTier} +

    +

    + {currentPlan + ? `${currentPlan.currency} ${currentPlan.price} / month` + : currentTier === 'Free' + ? 'USD 0 / month' + : 'Subscription status from your account profile'} +

    +
    + {currentStatusLabel} - {subscription?.automaticRenewal && ( - + + {subscription?.automaticRenewal && currentTier !== 'Free' && ( + Renewal on )} + {!subscription?.automaticRenewal && currentTier !== 'Free' && ( - + Renewal off )} + + {pendingTierChange?.effectiveFrom === 'next_billing_period' && ( + + {`Downgrade to ${pendingTierChange.newTier} scheduled`} + + )}
    {statusNotice && ( - -

    - {statusNotice.eyebrow} -

    -

    +

    +
    + + +
    +

    {statusNotice.title}

    -

    {statusNotice.message}

    +

    + {statusNotice.message} +

    - } - /> +
    +
    )} -
    +

    {summaryDateLabel}

    -

    +

    {summaryDateValue}

    @@ -622,11 +965,11 @@ const SubscriptionSection: React.FC = () => {

    Last Renewal

    -

    +

    {subscription?.lastRenewalDate ? formatDate(subscription.lastRenewalDate, { year: 'numeric', - month: 'short', + month: 'long', day: 'numeric', }) : 'No renewals yet'} @@ -634,13 +977,14 @@ const SubscriptionSection: React.FC = () => {

    -
    +
    {canManageAutoRenew && !subscription?.automaticRenewal && ( @@ -652,6 +996,7 @@ const SubscriptionSection: React.FC = () => { onClick={() => setPendingConfirmation('disableAutoRenew')} disabled={runningAction === 'disableAutoRenew'} loading={runningAction === 'disableAutoRenew'} + className={ACCENT_OUTLINED_BUTTON_CLASS} > Turn Off Renewal @@ -663,64 +1008,43 @@ const SubscriptionSection: React.FC = () => { onClick={() => setPendingConfirmation('cancel')} disabled={runningAction === 'cancel'} loading={runningAction === 'cancel'} - className="border-rose-600 text-rose-600 hover:!border-rose-600 hover:!bg-rose-600 hover:!text-white dark:hover:!border-rose-500 dark:hover:!bg-rose-500 dark:hover:!text-white" > Cancel Plan )} -
    -

    - Included request limits are listed in the plan cards below for quick - comparison. -

    +

    + Request limits are listed in the plan cards below. +

    +
    -

    - Available Plans -

    -

    - Choose the API access tier that matches your usage and traffic - profile. -

    +
    +

    + Available Plans +

    +

    + Switch tiers here when you already have paid access, or start a + new checkout session when you are moving from Free or recovering a + paused plan. +

    +
    {hasPlans ? (
    {plans.map(plan => { const isCurrent = plan.tier === currentTier; - const isCurrentFreePlan = isCurrent && plan.tier === 'Free'; - const isCurrentPlanPendingExpiry = - isCurrent && - currentStatus === 'cancelled' && - Boolean(accessDateText); - const allowCheckout = - plan.tier !== 'Free' && - (!isCurrent || - currentStatus === 'past_due' || - (currentStatus === 'cancelled' && - !isCurrentPlanPendingExpiry) || - currentStatus === 'paused'); - const isUpgrade = - currentTier === 'Free' || - (currentPlan ? plan.price > currentPlan.price : false); - const actionLabel = isCurrent - ? isCurrentFreePlan - ? 'Included plan' - : currentStatus === 'past_due' - ? 'Retry billing' - : currentStatus === 'cancelled' - ? isCurrentPlanPendingExpiry - ? 'Current plan' - : 'Reactivate plan' - : currentStatus === 'paused' - ? 'Resume plan' - : 'Current plan' - : allowCheckout - ? isUpgrade - ? `Upgrade to ${plan.name}` - : `Switch to ${plan.name}` - : 'Unavailable'; + const isScheduledTarget = + pendingTierChange?.effectiveFrom === 'next_billing_period' && + pendingTierChange.newTier === plan.tier; + const action = getPlanActionConfig(plan); + const actionButtonClassName = + action.variant === 'filled' + ? ACCENT_FILLED_BUTTON_CLASS + : isCurrent || isScheduledTarget + ? NEUTRAL_OUTLINED_BUTTON_CLASS + : ACCENT_OUTLINED_BUTTON_CLASS; return ( { : 'hover:shadow-md transition-shadow' }`} > -
    +

    {plan.name} @@ -740,11 +1064,18 @@ const SubscriptionSection: React.FC = () => { {plan.currency} {plan.price}/month

    - {isCurrent && ( - - Current - - )} +
    + {isCurrent && ( + + Current + + )} + {isScheduledTarget && ( + + Scheduled + + )} +
    @@ -771,19 +1102,23 @@ const SubscriptionSection: React.FC = () => {
); })}
) : ( - + Subscription plans are unavailable right now. Please refresh the page or try again later. @@ -843,14 +1178,21 @@ const SubscriptionSection: React.FC = () => { { - setDialogOpen(false); - setSelectedPlan(null); - }} + onClose={closePlanDialog} plan={selectedPlan} currentTier={currentTier} - loading={runningAction === 'checkout'} - onConfirm={handleCheckoutConfirm} + currentPlan={currentPlan} + mode={planActionMode} + accessUntilLabel={accessDateText} + loading={isPlanActionLoading} + onConfirm={() => { + if (planActionMode === 'checkout') { + void handleCheckoutConfirm(); + return; + } + + void handleChangeTierConfirm(); + }} /> ); diff --git a/src/platform/src/modules/feedback/components/FeedbackLauncher.tsx b/src/platform/src/modules/feedback/components/FeedbackLauncher.tsx index 0cd102cb7e..bca355fc7d 100644 --- a/src/platform/src/modules/feedback/components/FeedbackLauncher.tsx +++ b/src/platform/src/modules/feedback/components/FeedbackLauncher.tsx @@ -54,6 +54,24 @@ const SENSITIVE_QUERY_KEYS = new Set([ const isValidEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value); +const FEEDBACK_SOURCE_TAG = 'From Analytics'; + +const appendFeedbackSourceTag = (message: string): string => { + const trimmedMessage = message.trim(); + + if (!trimmedMessage) { + return trimmedMessage; + } + + if ( + trimmedMessage.toLowerCase().endsWith(FEEDBACK_SOURCE_TAG.toLowerCase()) + ) { + return trimmedMessage; + } + + return `${trimmedMessage}\n\n${FEEDBACK_SOURCE_TAG}`; +}; + const getBrowserLabel = (): string => { if (typeof navigator === 'undefined') { return 'Unknown browser'; @@ -181,6 +199,7 @@ export const FeedbackLauncher: React.FC = () => { const trimmedEmail = email.trim(); const trimmedSubject = subject.trim(); const trimmedMessage = message.trim(); + const submissionMessage = appendFeedbackSourceTag(trimmedMessage); if (!trimmedEmail || !trimmedSubject || !trimmedMessage) { toast.error('Please complete the email, subject, and message fields.'); @@ -200,10 +219,10 @@ export const FeedbackLauncher: React.FC = () => { await feedbackService.submitFeedback({ email: trimmedEmail, subject: trimmedSubject, - message: trimmedMessage, + message: submissionMessage, rating, category, - platform: 'Analytics', + platform: 'web', metadata, }); diff --git a/src/platform/src/shared/services/subscriptionService.ts b/src/platform/src/shared/services/subscriptionService.ts index 07a34495df..d1bcab0c73 100644 --- a/src/platform/src/shared/services/subscriptionService.ts +++ b/src/platform/src/shared/services/subscriptionService.ts @@ -83,6 +83,18 @@ interface MutationResponse { data?: Record; } +interface ChangeTierResponse { + success: boolean; + message: string; + comingSoon?: boolean; + data?: { + previousTier?: Extract; + newTier?: Extract; + effectiveFrom?: 'immediately' | 'next_billing_period'; + apiLimits?: ApiRateLimitsPayload; + }; +} + type BackendTransaction = { _id?: string; id?: string; @@ -1000,6 +1012,63 @@ export class SubscriptionService { } } + async changeTier( + tier: Extract + ): Promise { + await this.ensureAuthenticated(); + + try { + const response = await this.authenticatedClient.patch( + '/users/transactions/change-tier', + { tier }, + { + params: this.withTenant(), + } + ); + + const data = extractEnvelopeData<{ + previousTier?: string; + newTier?: string; + effectiveFrom?: string; + apiLimits?: ApiRateLimitsPayload | null; + }>(response.data); + + const previousTier = data?.previousTier + ? normalizeTier(data.previousTier) + : undefined; + const newTier = data?.newTier ? normalizeTier(data.newTier) : undefined; + const effectiveFrom = + data?.effectiveFrom === 'next_billing_period' + ? 'next_billing_period' + : data?.effectiveFrom === 'immediately' + ? 'immediately' + : undefined; + + return { + success: true, + message: extractMessage(response.data, 'Subscription tier updated'), + data: { + previousTier: previousTier === 'Free' ? undefined : previousTier, + newTier: newTier === 'Free' ? undefined : newTier, + effectiveFrom, + apiLimits: data?.apiLimits || undefined, + }, + }; + } catch (error) { + const response = ( + error as { response?: { status?: number; data?: unknown } } + ).response; + const status = response?.status || 500; + const errorPayload = response?.data; + + return { + success: false, + message: extractMessage(errorPayload, 'Failed to change tier'), + comingSoon: isPaymentProviderUnavailable(status, errorPayload), + }; + } + } + async enableAutoRenewal(): Promise { await this.ensureAuthenticated();