diff --git a/web/sdk/react/components/organization/billing/index.tsx b/web/sdk/react/components/organization/billing/index.tsx index 47f1b6fc3..7d575ad57 100644 --- a/web/sdk/react/components/organization/billing/index.tsx +++ b/web/sdk/react/components/organization/billing/index.tsx @@ -1,290 +1,14 @@ -import { - Button, - Skeleton, - Text, - Flex, - toast, - Tooltip, - Link -} from '@raystack/apsara'; -import { Outlet } from '@tanstack/react-router'; -import { PageHeader } from '~/react/components/common/page-header'; -import { useFrontier } from '~/react/contexts/FrontierContext'; -import { useCallback, useEffect, useMemo } from 'react'; -import { - BillingAccount, - ListInvoicesRequestSchema, - FrontierServiceQueries, - CreateCheckoutRequestSchema -} from '@raystack/proton/frontier'; -import { useQuery as useConnectQuery } from '@connectrpc/connect-query'; -import { create } from '@bufbuild/protobuf'; -import { useMutation } from '~hooks'; -import Invoices from './invoices'; -import qs from 'query-string'; -import sharedStyles from '../styles.module.css'; -import billingStyles from './billing.module.css'; +'use client'; -import { UpcomingBillingCycle } from './upcoming-billing-cycle'; -import { PaymentIssue } from './payment-issue'; -import { UpcomingPlanChangeBanner } from '../../common/upcoming-plan-change-banner'; -import { PaymentMethod } from './payment-method'; -import { useBillingPermission } from '~/react/hooks/useBillingPermission'; - -interface BillingHeaderProps { - billingSupportEmail?: string; - isLoading?: boolean; -} - -const BillingHeader = ({ - billingSupportEmail, - isLoading -}: BillingHeaderProps) => { - if (isLoading) { - return ( - - - - - ); - } - - return ( - - Oversee your billing and invoices.{' '} - For more details, contact{' '} - - {billingSupportEmail} - - - ) : ( - 'Oversee your billing and invoices.' - ) - } - /> - ); -}; - - -interface BillingDetailsProps { - billingAccount?: BillingAccount; - onAddDetailsClick?: () => void; - isLoading: boolean; - isAllowed: boolean; - disabled?: boolean; -} - -const BillingDetails = ({ - billingAccount, - onAddDetailsClick = () => {}, - isLoading, - isAllowed, - disabled = false -}: BillingDetailsProps) => { - const btnText = - billingAccount?.email || billingAccount?.name ? 'Update' : 'Add details'; - const isButtonDisabled = isLoading || disabled; - return ( -
- - Billing Details - {isAllowed ? ( - - - - ) : null} - - - Name - - {isLoading ? : billingAccount?.name || 'N/A'} - - - - Email - - {isLoading ? : billingAccount?.email || 'N/A'} - - -
- ); -}; +import { useNavigate } from '@tanstack/react-router'; +import { BillingPage } from '~/react/views/billing'; export default function Billing() { - const { - billingAccount, - isBillingAccountLoading, - config, - activeSubscription, - isActiveSubscriptionLoading, - paymentMethod, - organizationKyc, - isOrganizationKycLoading, - activeOrganization - } = useFrontier(); - - const { isAllowed, isFetching } = useBillingPermission(); - - const { - data: invoicesData, - isLoading: isInvoicesLoading, - error: invoicesError - } = useConnectQuery( - FrontierServiceQueries.listInvoices, - create(ListInvoicesRequestSchema, { - orgId: activeOrganization?.id || '', - nonzeroAmountOnly: true - }), - { - enabled: !!activeOrganization?.id - } - ); - - const invoices = useMemo(() => invoicesData?.invoices || [], [invoicesData]); - - useEffect(() => { - if (invoicesError) { - toast.error('Failed to load invoices', { - description: invoicesError?.message - }); - } - }, [invoicesError]); - - const { mutateAsync: createCheckoutMutation } = useMutation( - FrontierServiceQueries.createCheckout, - { - onError: (err: Error) => { - console.error(err); - toast.error('Something went wrong', { - description: err?.message - }); - } - } - ); - - const onAddDetailsClick = useCallback(async () => { - const orgId = activeOrganization?.id || ''; - if (!orgId) return; - - try { - const query = qs.stringify( - { - details: btoa( - qs.stringify({ - organization_id: activeOrganization?.id || '', - type: 'billing' - }) - ), - checkout_id: '{{.CheckoutID}}' - }, - { encode: false } - ); - const cancel_url = `${config?.billing?.cancelUrl}?${query}`; - const success_url = `${config?.billing?.successUrl}?${query}`; - - const resp = await createCheckoutMutation( - create(CreateCheckoutRequestSchema, { - orgId: activeOrganization?.id || '', - cancelUrl: cancel_url, - successUrl: success_url, - setupBody: { - paymentMethod: false, - customerPortal: true - } - }) - ); - const checkoutUrl = resp?.checkoutSession?.checkoutUrl; - if (checkoutUrl) { - window.location.href = checkoutUrl; - } - } catch (err) { - console.error(err); - toast.error('Something went wrong'); - } - }, [ - activeOrganization?.id, - createCheckoutMutation, - config?.billing?.cancelUrl, - config?.billing?.successUrl - ]); - - const isLoading = - isBillingAccountLoading || - isActiveSubscriptionLoading || - isInvoicesLoading || - isFetching || - isOrganizationKycLoading; - - const isOrganizationKycCompleted = organizationKyc?.status === true; + const navigate = useNavigate({ from: '/billing' }); return ( - - - - - - - - - - - - - - - - - - - - - + navigate({ to: '/plans' })} + /> ); } diff --git a/web/sdk/react/components/organization/routes.tsx b/web/sdk/react/components/organization/routes.tsx index ef6d0eb19..6ce6e2ad4 100644 --- a/web/sdk/react/components/organization/routes.tsx +++ b/web/sdk/react/components/organization/routes.tsx @@ -28,7 +28,6 @@ import { DeleteDomain } from './domain/delete'; import Billing from './billing'; import Tokens from './tokens'; import { AddTokens } from './tokens/add-tokens'; -import { ConfirmCycleSwitch } from './billing/cycle-switch'; import Plans from './plans'; import APIKeys from './api-keys'; import { AddServiceAccount } from './api-keys/add'; @@ -216,12 +215,6 @@ const billingRoute = createRoute({ component: Billing }); -const switchBillingCycleModalRoute = createRoute({ - getParentRoute: () => billingRoute, - path: '/cycle-switch/$planId', - component: ConfirmCycleSwitch -}); - const plansRoute = createRoute({ getParentRoute: () => rootRoute, path: '/plans', @@ -313,7 +306,7 @@ export function getRootTree({ customScreens = [] }: getRootTreeOptions) { projectPageRoute, profileRoute, preferencesRoute, - billingRoute.addChildren([switchBillingCycleModalRoute]), + billingRoute, plansRoute, tokensRoute.addChildren([addTokensRoute]), apiKeysRoute.addChildren([ diff --git a/web/sdk/react/views/billing/billing-page.tsx b/web/sdk/react/views/billing/billing-page.tsx new file mode 100644 index 000000000..b7f85fd0c --- /dev/null +++ b/web/sdk/react/views/billing/billing-page.tsx @@ -0,0 +1,317 @@ +import { + Button, + Skeleton, + Text, + Flex, + toast, + Tooltip, + Link +} from '@raystack/apsara'; +import { PageHeader } from '~/react/components/common/page-header'; +import { useFrontier } from '~/react/contexts/FrontierContext'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + BillingAccount, + ListInvoicesRequestSchema, + FrontierServiceQueries, + CreateCheckoutRequestSchema +} from '@raystack/proton/frontier'; +import { useQuery as useConnectQuery } from '@connectrpc/connect-query'; +import { create } from '@bufbuild/protobuf'; +import { useMutation } from '~hooks'; +import Invoices from './invoices'; +import qs from 'query-string'; +import sharedStyles from '../../components/organization/styles.module.css'; +import billingStyles from './billing.module.css'; + +import { UpcomingBillingCycle } from './upcoming-billing-cycle'; +import { PaymentIssue } from './payment-issue'; +import { UpcomingPlanChangeBanner } from '~/react/components/common/upcoming-plan-change-banner'; +import { PaymentMethod } from './payment-method'; +import { useBillingPermission } from '~/react/hooks/useBillingPermission'; +import { ConfirmCycleSwitchDialog } from './confirm-cycle-switch-dialog'; + +interface BillingHeaderProps { + billingSupportEmail?: string; + isLoading?: boolean; +} + +const BillingHeader = ({ + billingSupportEmail, + isLoading +}: BillingHeaderProps) => { + if (isLoading) { + return ( + + + + + ); + } + + return ( + + Oversee your billing and invoices.{' '} + For more details, contact{' '} + + {billingSupportEmail} + + + ) : ( + 'Oversee your billing and invoices.' + ) + } + /> + ); +}; + + +interface BillingDetailsProps { + billingAccount?: BillingAccount; + onAddDetailsClick?: () => void; + isLoading: boolean; + isAllowed: boolean; + disabled?: boolean; +} + +const BillingDetails = ({ + billingAccount, + onAddDetailsClick = () => {}, + isLoading, + isAllowed, + disabled = false +}: BillingDetailsProps) => { + const btnText = + billingAccount?.email || billingAccount?.name ? 'Update' : 'Add details'; + const isButtonDisabled = isLoading || disabled; + return ( +
+ + Billing Details + {isAllowed ? ( + + + + ) : null} + + + Name + + {isLoading ? : billingAccount?.name || 'N/A'} + + + + Email + + {isLoading ? : billingAccount?.email || 'N/A'} + + +
+ ); +}; + +export interface BillingPageProps { + onNavigateToPlans?: () => void; +} + +export default function BillingPage({ onNavigateToPlans }: BillingPageProps) { + const { + billingAccount, + isBillingAccountLoading, + config, + activeSubscription, + isActiveSubscriptionLoading, + paymentMethod, + organizationKyc, + isOrganizationKycLoading, + activeOrganization + } = useFrontier(); + + const { isAllowed, isFetching } = useBillingPermission(); + + const { + data: invoicesData, + isLoading: isInvoicesLoading, + error: invoicesError + } = useConnectQuery( + FrontierServiceQueries.listInvoices, + create(ListInvoicesRequestSchema, { + orgId: activeOrganization?.id || '', + nonzeroAmountOnly: true + }), + { + enabled: !!activeOrganization?.id + } + ); + + const invoices = useMemo(() => invoicesData?.invoices || [], [invoicesData]); + + useEffect(() => { + if (invoicesError) { + toast.error('Failed to load invoices', { + description: invoicesError?.message + }); + } + }, [invoicesError]); + + const { mutateAsync: createCheckoutMutation } = useMutation( + FrontierServiceQueries.createCheckout, + { + onError: (err: Error) => { + console.error(err); + toast.error('Something went wrong', { + description: err?.message + }); + } + } + ); + + const onAddDetailsClick = useCallback(async () => { + const orgId = activeOrganization?.id || ''; + if (!orgId) return; + + try { + const query = qs.stringify( + { + details: btoa( + qs.stringify({ + organization_id: activeOrganization?.id || '', + type: 'billing' + }) + ), + checkout_id: '{{.CheckoutID}}' + }, + { encode: false } + ); + const cancel_url = `${config?.billing?.cancelUrl}?${query}`; + const success_url = `${config?.billing?.successUrl}?${query}`; + + const resp = await createCheckoutMutation( + create(CreateCheckoutRequestSchema, { + orgId: activeOrganization?.id || '', + cancelUrl: cancel_url, + successUrl: success_url, + setupBody: { + paymentMethod: false, + customerPortal: true + } + }) + ); + const checkoutUrl = resp?.checkoutSession?.checkoutUrl; + if (checkoutUrl) { + window.location.href = checkoutUrl; + } + } catch (err) { + console.error(err); + toast.error('Something went wrong'); + } + }, [ + activeOrganization?.id, + createCheckoutMutation, + config?.billing?.cancelUrl, + config?.billing?.successUrl + ]); + + const isLoading = + isBillingAccountLoading || + isActiveSubscriptionLoading || + isInvoicesLoading || + isFetching || + isOrganizationKycLoading; + + const isOrganizationKycCompleted = organizationKyc?.status === true; + + const [cycleSwitchState, setCycleSwitchState] = useState({ + open: false, + planId: '' + }); + + const handleCycleSwitchOpenChange = (value: boolean) => { + if (!value) { + setCycleSwitchState({ open: false, planId: '' }); + } else { + setCycleSwitchState(prev => ({ ...prev, open: value })); + } + }; + + const handleCycleSwitchClick = (planId: string) => { + setCycleSwitchState({ open: true, planId }); + }; + + return ( + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/web/sdk/react/components/organization/billing/billing.module.css b/web/sdk/react/views/billing/billing.module.css similarity index 100% rename from web/sdk/react/components/organization/billing/billing.module.css rename to web/sdk/react/views/billing/billing.module.css diff --git a/web/sdk/react/components/organization/billing/cycle-switch/index.tsx b/web/sdk/react/views/billing/confirm-cycle-switch-dialog.tsx similarity index 89% rename from web/sdk/react/components/organization/billing/cycle-switch/index.tsx rename to web/sdk/react/views/billing/confirm-cycle-switch-dialog.tsx index 1c1252966..8488bbd6d 100644 --- a/web/sdk/react/components/organization/billing/cycle-switch/index.tsx +++ b/web/sdk/react/views/billing/confirm-cycle-switch-dialog.tsx @@ -8,17 +8,26 @@ import { Flex, Dialog } from '@raystack/apsara'; -import { useNavigate, useParams } from '@tanstack/react-router'; import { useFrontier } from '~/react/contexts/FrontierContext'; import { getPlanIntervalName, getPlanPrice } from '~/react/utils'; import * as _ from 'lodash'; import { DEFAULT_DATE_FORMAT } from '~/react/utils/constants'; import cross from '~/react/assets/cross.svg'; -import styles from '../../organization.module.css'; +import orgStyles from '../../components/organization/organization.module.css'; import { timestampToDayjs } from '~/utils/timestamp'; import { usePlans } from '~/react/views/plans/hooks/usePlans'; -export function ConfirmCycleSwitch() { +export interface ConfirmCycleSwitchDialogProps { + open: boolean; + onOpenChange?: (value: boolean) => void; + planId: string; +} + +export function ConfirmCycleSwitchDialog({ + open, + onOpenChange, + planId +}: ConfirmCycleSwitchDialogProps) { const { activePlan, paymentMethod, @@ -27,13 +36,11 @@ export function ConfirmCycleSwitch() { allPlans, isAllPlansLoading } = useFrontier(); - const navigate = useNavigate({ from: '/billing/cycle-switch/$planId' }); - const { planId } = useParams({ from: '/billing/cycle-switch/$planId' }); const dateFormat = config?.dateFormat || DEFAULT_DATE_FORMAT; - const closeModal = useCallback( - () => navigate({ to: '/billing' }), - [navigate] + const handleClose = useCallback( + () => onOpenChange?.(false), + [onOpenChange] ); const { @@ -85,7 +92,7 @@ export function ConfirmCycleSwitch() { planId: nextPlanId }); if (planPhase) { - closeModal(); + handleClose(); const changeDate = timestampToDayjs(planPhase?.effectiveAt)?.format( dateFormat ); @@ -105,9 +112,9 @@ export function ConfirmCycleSwitch() { : 'the next billing cycle'; return ( - + @@ -121,7 +128,7 @@ export function ConfirmCycleSwitch() { alt="cross" style={{ cursor: 'pointer' }} src={cross as unknown as string} - onClick={closeModal} + onClick={handleClose} /> @@ -164,7 +171,7 @@ export function ConfirmCycleSwitch() {