diff --git a/.changeset/silent-beds-invite.md b/.changeset/silent-beds-invite.md new file mode 100644 index 00000000000..2f30bbfaea0 --- /dev/null +++ b/.changeset/silent-beds-invite.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Add invoices data fetching and invoice UI to org and user profile. diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index accfa9230b9..668550577df 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -1,9 +1,9 @@ { "files": [ - { "path": "./dist/clerk.js", "maxSize": "590kB" }, - { "path": "./dist/clerk.browser.js", "maxSize": "73.88KB" }, + { "path": "./dist/clerk.js", "maxSize": "592.5kB" }, + { "path": "./dist/clerk.browser.js", "maxSize": "74KB" }, { "path": "./dist/clerk.headless*.js", "maxSize": "55KB" }, - { "path": "./dist/ui-common*.js", "maxSize": "99.2KB" }, + { "path": "./dist/ui-common*.js", "maxSize": "100KB" }, { "path": "./dist/vendors*.js", "maxSize": "36KB" }, { "path": "./dist/coinbase*.js", "maxSize": "35.5KB" }, { "path": "./dist/createorganization*.js", "maxSize": "5KB" }, @@ -14,7 +14,7 @@ { "path": "./dist/signin*.js", "maxSize": "12.5KB" }, { "path": "./dist/signup*.js", "maxSize": "6.75KB" }, { "path": "./dist/userbutton*.js", "maxSize": "5KB" }, - { "path": "./dist/userprofile*.js", "maxSize": "15KB" }, + { "path": "./dist/userprofile*.js", "maxSize": "16KB" }, { "path": "./dist/userverification*.js", "maxSize": "5KB" }, { "path": "./dist/onetap*.js", "maxSize": "1KB" }, { "path": "./dist/waitlist*.js", "maxSize": "1.3KB" }, @@ -22,8 +22,8 @@ { "path": "./dist/pricingTable*.js", "maxSize": "5.28KB" }, { "path": "./dist/checkout*.js", "maxSize": "3.05KB" }, { "path": "./dist/paymentSources*.js", "maxSize": "8.2KB" }, - { "path": "./dist/up-billing-page*.js", "maxSize": "1KB" }, - { "path": "./dist/op-billing-page*.js", "maxSize": "1KB" }, + { "path": "./dist/up-billing-page*.js", "maxSize": "2.5KB" }, + { "path": "./dist/op-billing-page*.js", "maxSize": "2.5KB" }, { "path": "./dist/sessionTasks*.js", "maxSize": "1KB" } ] } diff --git a/packages/clerk-js/src/core/modules/commerce/CommerceBilling.ts b/packages/clerk-js/src/core/modules/commerce/CommerceBilling.ts index 4166a0b9d91..dac75b71cbf 100644 --- a/packages/clerk-js/src/core/modules/commerce/CommerceBilling.ts +++ b/packages/clerk-js/src/core/modules/commerce/CommerceBilling.ts @@ -1,11 +1,14 @@ import type { __experimental_CommerceBillingNamespace, __experimental_CommerceCheckoutJSON, + __experimental_CommerceInvoiceJSON, + __experimental_CommerceInvoiceResource, __experimental_CommercePlanResource, __experimental_CommerceProductJSON, __experimental_CommerceSubscriptionJSON, __experimental_CommerceSubscriptionResource, __experimental_CreateCheckoutParams, + __experimental_GetInvoicesParams, __experimental_GetPlansParams, __experimental_GetSubscriptionsParams, ClerkPaginatedResponse, @@ -14,6 +17,7 @@ import type { import { convertPageToOffsetSearchParams } from '../../../utils/convertPageToOffsetSearchParams'; import { __experimental_CommerceCheckout, + __experimental_CommerceInvoice, __experimental_CommercePlan, __experimental_CommerceSubscription, BaseResource, @@ -51,6 +55,26 @@ export class __experimental_CommerceBilling implements __experimental_CommerceBi }); }; + getInvoices = async ( + params: __experimental_GetInvoicesParams, + ): Promise> => { + const { orgId, ...rest } = params; + + return await BaseResource._fetch({ + path: orgId ? `/organizations/${orgId}/invoices` : `/me/commerce/invoices`, + method: 'GET', + search: convertPageToOffsetSearchParams(rest), + }).then(res => { + const { data: invoices, total_count } = + res?.response as unknown as ClerkPaginatedResponse<__experimental_CommerceInvoiceJSON>; + + return { + total_count, + data: invoices.map(invoice => new __experimental_CommerceInvoice(invoice)), + }; + }); + }; + startCheckout = async (params: __experimental_CreateCheckoutParams) => { const { orgId, ...rest } = params; const json = ( diff --git a/packages/clerk-js/src/core/resources/CommerceInvoice.ts b/packages/clerk-js/src/core/resources/CommerceInvoice.ts index c718b231cec..e4c6654944b 100644 --- a/packages/clerk-js/src/core/resources/CommerceInvoice.ts +++ b/packages/clerk-js/src/core/resources/CommerceInvoice.ts @@ -1,6 +1,7 @@ import type { __experimental_CommerceInvoiceJSON, __experimental_CommerceInvoiceResource, + __experimental_CommerceInvoiceStatus, __experimental_CommerceTotals, } from '@clerk/types'; @@ -13,7 +14,7 @@ export class __experimental_CommerceInvoice extends BaseResource implements __ex planId!: string; paymentDueOn!: number; paidOn!: number; - status!: string; + status!: __experimental_CommerceInvoiceStatus; totals!: __experimental_CommerceTotals; constructor(data: __experimental_CommerceInvoiceJSON) { diff --git a/packages/clerk-js/src/ui/components/Invoices/InvoicePage.tsx b/packages/clerk-js/src/ui/components/Invoices/InvoicePage.tsx new file mode 100644 index 00000000000..fcd9f09343e --- /dev/null +++ b/packages/clerk-js/src/ui/components/Invoices/InvoicePage.tsx @@ -0,0 +1,185 @@ +import { useInvoicesContext } from '../../contexts'; +import { Badge, Box, Dd, descriptors, Dl, Dt, Heading, Spinner, Text } from '../../customizables'; +import { Header, LineItems } from '../../elements'; +import { useRouter } from '../../router'; +import { common } from '../../styledSystem'; +import { colors } from '../../utils'; +import { truncateWithEndVisible } from '../../utils/truncateTextWithEndVisible'; + +export const InvoicePage = () => { + const { params, navigate } = useRouter(); + const { getInvoiceById, isLoading } = useInvoicesContext(); + const invoice = params.invoiceId ? getInvoiceById(params.invoiceId) : null; + + if (isLoading) { + return ( + + + + ); + } + + return ( + <> + + void navigate('../../', { searchParams: new URLSearchParams('tab=invoices') })}> + + + + ({ + display: 'flex', + flexDirection: 'column', + gap: t.space.$4, + borderTopWidth: t.borderWidths.$normal, + borderTopStyle: t.borderStyles.$solid, + borderTopColor: t.colors.$neutralAlpha100, + marginBlockStart: t.space.$4, + paddingBlockStart: t.space.$4, + })} + > + {!invoice ? ( + + Invoice not found + + ) : ( + ({ + borderWidth: t.borderWidths.$normal, + borderStyle: t.borderStyles.$solid, + borderColor: t.colors.$neutralAlpha100, + borderRadius: t.radii.$lg, + overflow: 'hidden', + })} + > + ({ + padding: t.space.$4, + background: common.mergedColorsBackground( + colors.setAlpha(t.colors.$colorBackground, 1), + t.colors.$neutralAlpha50, + ), + borderBlockEndWidth: t.borderWidths.$normal, + borderBlockEndStyle: t.borderStyles.$solid, + borderBlockEndColor: t.colors.$neutralAlpha100, + })} + > + + + {truncateWithEndVisible(invoice.id)} + + + {invoice.status} + + +
({ + display: 'flex', + justifyContent: 'space-between', + marginBlockStart: t.space.$3, + })} + > + +
+ + Created on + +
+
+ {new Date(invoice.paymentDueOn).toLocaleDateString()} +
+
+ +
+ + Due on + +
+
+ {new Date(invoice.paymentDueOn).toLocaleDateString()} +
+
+
+
+ ({ + padding: t.space.$4, + })} + > + + + + + + + + + + + + + + + + + + + +
+ )} +
+ + ); +}; diff --git a/packages/clerk-js/src/ui/components/Invoices/InvoicesList.tsx b/packages/clerk-js/src/ui/components/Invoices/InvoicesList.tsx new file mode 100644 index 00000000000..b8ca2a6544a --- /dev/null +++ b/packages/clerk-js/src/ui/components/Invoices/InvoicesList.tsx @@ -0,0 +1,228 @@ +import type { __experimental_CommerceInvoiceResource, __experimental_CommerceInvoiceStatus } from '@clerk/types'; +import React from 'react'; + +import { useInvoicesContext } from '../../contexts'; +import type { LocalizationKey } from '../../customizables'; +import { + Badge, + Col, + descriptors, + Flex, + Link, + Spinner, + Table, + Tbody, + Td, + Text, + Th, + Thead, + Tr, +} from '../../customizables'; +import { Pagination } from '../../elements'; +import { useRouter } from '../../router'; +import type { PropsOfComponent } from '../../styledSystem'; +import { truncateWithEndVisible } from '../../utils/truncateTextWithEndVisible'; + +/* ------------------------------------------------------------------------------------------------- + * InvoicesList + * -----------------------------------------------------------------------------------------------*/ + +export const InvoicesList = () => { + const { invoices, isLoading, totalCount } = useInvoicesContext(); + + return ( + {}} + itemCount={totalCount} + pageCount={1} + itemsPerPage={10} + isLoading={isLoading} + emptyStateLocalizationKey='No invoices to display' + headers={['Date/Invoice', 'Status', 'Total']} + rows={invoices.map(i => ( + + ))} + /> + ); +}; + +const InvoicesListRow = ({ invoice }: { invoice: __experimental_CommerceInvoiceResource }) => { + const { + paymentDueOn, + id, + status, + totals: { grandTotal }, + } = invoice; + const { navigate } = useRouter(); + const badgeColorSchemeMap: Record<__experimental_CommerceInvoiceStatus, 'success' | 'warning' | 'danger'> = { + paid: 'success', + unpaid: 'warning', + past_due: 'danger', + }; + const handleClick = () => { + void navigate(`invoice/${id}`); + }; + return ( + + + + {new Date(paymentDueOn).toLocaleDateString()} + + ({ marginTop: t.space.$0x5, textTransform: 'uppercase' })} + > + {truncateWithEndVisible(id)} + + + + + {status} + + + + + {grandTotal.currencySymbol} + {grandTotal.amountFormatted} + + + + ); +}; + +/* ------------------------------------------------------------------------------------------------- + * DataTable + * -----------------------------------------------------------------------------------------------*/ + +type DataTableProps = { + headers: (LocalizationKey | string)[]; + rows: React.ReactNode[]; + isLoading?: boolean; + page: number; + onPageChange: (page: number) => void; + itemCount: number; + emptyStateLocalizationKey: LocalizationKey | string; + pageCount: number; + itemsPerPage: number; +}; + +const DataTable = (props: DataTableProps) => { + const { + headers, + page, + onPageChange, + rows, + isLoading, + itemCount, + itemsPerPage, + pageCount, + emptyStateLocalizationKey, + } = props; + + const startingRow = itemCount > 0 ? Math.max(0, (page - 1) * itemsPerPage) + 1 : 0; + const endingRow = Math.min(page * itemsPerPage, itemCount); + + return ( + + ({ overflowX: 'auto', padding: t.space.$1 })}> + + + + {headers.map((h, index) => ( + + + + {isLoading ? ( + + + + ) : !rows.length ? ( + + ) : ( + rows + )} + +
+ ))} +
+ +
+
+ {pageCount > 1 && ( + + )} + + ); +}; + +const DataTableEmptyRow = (props: { localizationKey: LocalizationKey | string }) => { + return ( + + + + + + ); +}; + +const DataTableRow = (props: PropsOfComponent) => { + return ( + ({ ':hover': { backgroundColor: t.colors.$neutralAlpha50 } })} + /> + ); +}; diff --git a/packages/clerk-js/src/ui/components/Invoices/index.ts b/packages/clerk-js/src/ui/components/Invoices/index.ts new file mode 100644 index 00000000000..cfa41fe6c43 --- /dev/null +++ b/packages/clerk-js/src/ui/components/Invoices/index.ts @@ -0,0 +1,2 @@ +export * from './InvoicesList'; +export * from './InvoicePage'; diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationBillingPage.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationBillingPage.tsx index 80cfacdc8b8..0096c609c6b 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationBillingPage.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationBillingPage.tsx @@ -1,6 +1,7 @@ import { __experimental_PaymentSourcesContext, __experimental_PricingTableContext, + InvoicesContextProvider, usePlansContext, withPlans, } from '../../contexts'; @@ -16,13 +17,22 @@ import { useCardState, withCardStateProvider, } from '../../elements'; +import { useTabState } from '../../hooks/useTabState'; +import { InvoicesList } from '../Invoices'; import { __experimental_PaymentSources } from '../PaymentSources/PaymentSources'; import { __experimental_PricingTable } from '../PricingTable'; +const orgTabMap = { + 0: 'plans', + 1: 'invoices', + 2: 'payment-sources', +} as const; + export const OrganizationBillingPage = withPlans( withCardStateProvider(() => { const card = useCardState(); const { subscriptions } = usePlansContext(); + const { selectedTab, handleTabChange } = useTabState(orgTabMap); return ( {card.error} - + ({ gap: t.space.$6 })}> - Invoices + + + + + <__experimental_PaymentSourcesContext.Provider value={{ componentName: 'PaymentSources', subscriberType: 'org' }} diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx index 284e3609e71..f2d6fba63f2 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx @@ -2,8 +2,9 @@ import { lazy, Suspense } from 'react'; import { Protect } from '../../common'; import { CustomPageContentContainer } from '../../common/CustomPageContentContainer'; -import { useEnvironment, useOptions, useOrganizationProfileContext } from '../../contexts'; +import { InvoicesContextProvider, useEnvironment, useOptions, useOrganizationProfileContext } from '../../contexts'; import { Route, Switch } from '../../router'; +import { InvoicePage } from '../Invoices/InvoicePage'; import { OrganizationGeneralPage } from './OrganizationGeneralPage'; import { OrganizationMembers } from './OrganizationMembers'; @@ -69,6 +70,13 @@ export const OrganizationProfileRoutes = () => { + + + + + + + )} diff --git a/packages/clerk-js/src/ui/components/UserProfile/BillingPage.tsx b/packages/clerk-js/src/ui/components/UserProfile/BillingPage.tsx index 072fbc56ca5..b7afc8a7ba7 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/BillingPage.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/BillingPage.tsx @@ -1,6 +1,7 @@ import { __experimental_PaymentSourcesContext, __experimental_PricingTableContext, + InvoicesContextProvider, usePlansContext, withPlans, } from '../../contexts'; @@ -16,13 +17,22 @@ import { useCardState, withCardStateProvider, } from '../../elements'; +import { useTabState } from '../../hooks/useTabState'; +import { InvoicesList } from '../Invoices'; import { __experimental_PaymentSources } from '../PaymentSources'; import { __experimental_PricingTable } from '../PricingTable'; +const tabMap = { + 0: 'plans', + 1: 'invoices', + 2: 'payment-sources', +} as const; + export const BillingPage = withPlans( withCardStateProvider(() => { const card = useCardState(); const { subscriptions } = usePlansContext(); + const { selectedTab, handleTabChange } = useTabState(tabMap); return ( {card.error} - + ({ gap: t.space.$6 })}> - Invoices + + + + + <__experimental_PaymentSourcesContext.Provider value={{ componentName: 'PaymentSources' }}> <__experimental_PaymentSources /> diff --git a/packages/clerk-js/src/ui/components/UserProfile/UserProfileRoutes.tsx b/packages/clerk-js/src/ui/components/UserProfile/UserProfileRoutes.tsx index 7dba5d3df5a..25a094c40b8 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/UserProfileRoutes.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/UserProfileRoutes.tsx @@ -2,8 +2,9 @@ import { lazy, Suspense } from 'react'; import { CustomPageContentContainer } from '../../common/CustomPageContentContainer'; import { USER_PROFILE_NAVBAR_ROUTE_ID } from '../../constants'; -import { useEnvironment, useOptions, useUserProfileContext } from '../../contexts'; +import { InvoicesContextProvider, useEnvironment, useOptions, useUserProfileContext } from '../../contexts'; import { Route, Switch } from '../../router'; +import { InvoicePage } from '../Invoices/InvoicePage'; import { AccountPage } from './AccountPage'; import { SecurityPage } from './SecurityPage'; @@ -66,6 +67,13 @@ export const UserProfileRoutes = () => { + + + + + + + )} diff --git a/packages/clerk-js/src/ui/contexts/components/Invoices.tsx b/packages/clerk-js/src/ui/contexts/components/Invoices.tsx new file mode 100644 index 00000000000..681d38e81ac --- /dev/null +++ b/packages/clerk-js/src/ui/contexts/components/Invoices.tsx @@ -0,0 +1,63 @@ +import { useClerk, useOrganization } from '@clerk/shared/react'; +import type { __experimental_CommerceSubscriberType } from '@clerk/types'; +import type { ReactNode } from 'react'; +import { createContext, useContext } from 'react'; + +import { useFetch } from '../../hooks'; +import type { __experimental_InvoicesCtx } from '../../types'; + +const InvoicesContext = createContext<__experimental_InvoicesCtx | null>(null); + +export const InvoicesContextProvider = ({ + subscriberType, + children, +}: { + subscriberType?: __experimental_CommerceSubscriberType; + children: ReactNode; +}) => { + const { __experimental_commerce } = useClerk(); + const { organization } = useOrganization(); + + const { data, isLoading, revalidate } = useFetch( + __experimental_commerce?.__experimental_billing.getInvoices, + { ...(subscriberType === 'org' ? { orgId: organization?.id } : {}) }, + undefined, + 'commerce-invoices', + ); + const { data: invoices, total_count: totalCount } = data || { data: [], totalCount: 0 }; + + const getInvoiceById = (invoiceId: string) => { + return invoices.find(invoice => invoice.id === invoiceId); + }; + + return ( + + {children} + + ); +}; + +export const useInvoicesContext = () => { + const context = useContext(InvoicesContext); + + if (!context || context.componentName !== 'Invoices') { + throw new Error('Clerk: usePaymentSourcesContext called outside PaymentSources.'); + } + + const { componentName, ...ctx } = context; + + return { + ...ctx, + componentName, + }; +}; diff --git a/packages/clerk-js/src/ui/contexts/components/index.ts b/packages/clerk-js/src/ui/contexts/components/index.ts index 8f283510ab8..a9800df623b 100644 --- a/packages/clerk-js/src/ui/contexts/components/index.ts +++ b/packages/clerk-js/src/ui/contexts/components/index.ts @@ -13,4 +13,5 @@ export * from './Waitlist'; export * from './PricingTable'; export * from './Checkout'; export * from './PaymentSources'; +export * from './Invoices'; export * from './Plans'; diff --git a/packages/clerk-js/src/ui/customizables/elementDescriptors.ts b/packages/clerk-js/src/ui/customizables/elementDescriptors.ts index cf0a826ba51..f91058baef1 100644 --- a/packages/clerk-js/src/ui/customizables/elementDescriptors.ts +++ b/packages/clerk-js/src/ui/customizables/elementDescriptors.ts @@ -52,6 +52,7 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([ 'header', 'headerTitle', 'headerSubtitle', + 'headerBackLink', 'main', @@ -339,6 +340,17 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([ 'paymentSourceRowValue', 'paymentSourceRowBadge', + 'invoiceRoot', + 'invoiceCard', + 'invoiceHeader', + 'invoiceTitle', + 'invoiceBadge', + 'invoiceDetails', + 'invoiceDetailsItem', + 'invoiceDetailsItemTitle', + 'invoiceDetailsItemValue', + 'invoiceContent', + 'menuButton', 'menuButtonEllipsis', 'menuList', diff --git a/packages/clerk-js/src/ui/elements/Header.tsx b/packages/clerk-js/src/ui/elements/Header.tsx index fdbea981d5e..6c74288fc2c 100644 --- a/packages/clerk-js/src/ui/elements/Header.tsx +++ b/packages/clerk-js/src/ui/elements/Header.tsx @@ -1,6 +1,7 @@ import React from 'react'; -import { Col, descriptors, Heading, Text, useAppearance } from '../customizables'; +import { Col, descriptors, Heading, Icon, Link, Text, useAppearance } from '../customizables'; +import { ArrowLeftIcon } from '../icons'; import type { PropsOfComponent, ThemableCssProp } from '../styledSystem'; import { ApplicationLogo } from './ApplicationLogo'; @@ -67,8 +68,30 @@ const Subtitle = React.memo((props: PropsOfComponent): JSX.Element ); }); +const BackLink = React.memo((props: PropsOfComponent): JSX.Element => { + const { sx, children, ...rest } = props; + return ( + [ + { + display: 'inline-flex', + alignItems: 'center', + gap: t.space.$2, + }, + sx, + ]} + {...rest} + > + + {children} + + ); +}); + export const Header = { Root, Title, Subtitle, + BackLink, }; diff --git a/packages/clerk-js/src/ui/elements/Tabs.tsx b/packages/clerk-js/src/ui/elements/Tabs.tsx index 3db31f0c511..45728153ec2 100644 --- a/packages/clerk-js/src/ui/elements/Tabs.tsx +++ b/packages/clerk-js/src/ui/elements/Tabs.tsx @@ -23,16 +23,41 @@ const TabsContextProvider = (props: React.PropsWithChildren<{ value: TabsContext }; type TabsProps = PropsWithChildren<{ + /** The index of the selected tab (controlled mode) */ + value?: number; + /** Callback fired when the selected tab changes (controlled mode) */ + onChange?: (index: number) => void; + /** The index of the tab to be selected on initial render (uncontrolled mode) */ defaultIndex?: number; }>; export const Tabs = (props: TabsProps) => { - const { defaultIndex = 0, children } = props; + const { value, onChange, defaultIndex = 0, children } = props; const [selectedIndex, setSelectedIndex] = React.useState(defaultIndex); const [focusedIndex, setFocusedIndex] = React.useState(-1); + const handleSelectedIndexChange = React.useCallback( + (newIndex: number) => { + if (onChange) { + onChange(newIndex); + } else { + setSelectedIndex(newIndex); + } + }, + [onChange], + ); + + const currentIndex = value !== undefined ? value : selectedIndex; + return ( - + {children} ); diff --git a/packages/clerk-js/src/ui/hooks/useTabState.ts b/packages/clerk-js/src/ui/hooks/useTabState.ts new file mode 100644 index 00000000000..e6706058f0f --- /dev/null +++ b/packages/clerk-js/src/ui/hooks/useTabState.ts @@ -0,0 +1,30 @@ +import React from 'react'; + +import { useRouter } from '../router'; + +type TabMap = { + [key: number]: string | undefined; +}; + +export const useTabState = (tabMap: TabMap, defaultTab = 0) => { + const router = useRouter(); + + const getInitialTab = () => { + const tab = router.queryParams.tab; + const tabIndex = Object.entries(tabMap).find(([_, value]) => value === tab)?.[0]; + return tabIndex ? parseInt(tabIndex, 10) : defaultTab; + }; + + const [selectedTab, setSelectedTab] = React.useState(getInitialTab()); + + const handleTabChange = (index: number) => { + setSelectedTab(index); + const currentPath = router.currentPath; + void router.navigate(currentPath, { searchParams: new URLSearchParams({ tab: tabMap[index] || '' }) }); + }; + + return { + selectedTab, + handleTabChange, + }; +}; diff --git a/packages/clerk-js/src/ui/types.ts b/packages/clerk-js/src/ui/types.ts index 4294f253c1d..bc00740a219 100644 --- a/packages/clerk-js/src/ui/types.ts +++ b/packages/clerk-js/src/ui/types.ts @@ -1,5 +1,6 @@ import type { __experimental_CheckoutProps, + __experimental_CommerceInvoiceResource, __experimental_CommercePlanResource, __experimental_CommerceSubscriberType, __experimental_CommerceSubscriptionResource, @@ -118,6 +119,16 @@ export type __experimental_PaymentSourcesCtx = { subscriberType?: __experimental_CommerceSubscriberType; }; +export type __experimental_InvoicesCtx = { + componentName: 'Invoices'; + subscriberType: __experimental_CommerceSubscriberType; + invoices: __experimental_CommerceInvoiceResource[]; + totalCount: number; + isLoading: boolean; + revalidate: () => void; + getInvoiceById: (invoiceId: string) => __experimental_CommerceInvoiceResource | undefined; +}; + export type __experimental_PlansCtx = { componentName: 'Plans'; subscriberType: __experimental_CommerceSubscriberType; diff --git a/packages/types/src/appearance.ts b/packages/types/src/appearance.ts index c488bb523a2..03903d1ca4d 100644 --- a/packages/types/src/appearance.ts +++ b/packages/types/src/appearance.ts @@ -170,6 +170,7 @@ export type ElementsConfig = { header: WithOptions; headerTitle: WithOptions; headerSubtitle: WithOptions; + headerBackLink: WithOptions; backRow: WithOptions; backLink: WithOptions; @@ -463,6 +464,17 @@ export type ElementsConfig = { paymentSourceRowValue: WithOptions; paymentSourceRowBadge: WithOptions<'default' | 'expired'>; + invoiceRoot: WithOptions; + invoiceCard: WithOptions; + invoiceHeader: WithOptions; + invoiceTitle: WithOptions; + invoiceBadge: WithOptions; + invoiceDetails: WithOptions; + invoiceDetailsItem: WithOptions; + invoiceDetailsItemTitle: WithOptions; + invoiceDetailsItemValue: WithOptions; + invoiceContent: WithOptions; + menuButton: WithOptions; menuButtonEllipsis: WithOptions; menuList: WithOptions; diff --git a/packages/types/src/commerce.ts b/packages/types/src/commerce.ts index 978e8f64949..e122c6f97d2 100644 --- a/packages/types/src/commerce.ts +++ b/packages/types/src/commerce.ts @@ -23,6 +23,9 @@ export interface __experimental_CommerceBillingNamespace { getSubscriptions: ( params: __experimental_GetSubscriptionsParams, ) => Promise>; + getInvoices: ( + params: __experimental_GetInvoicesParams, + ) => Promise>; startCheckout: (params: __experimental_CreateCheckoutParams) => Promise<__experimental_CommerceCheckoutResource>; } @@ -103,6 +106,10 @@ export interface __experimental_CommerceInitializedPaymentSourceResource extends externalGatewayId: string; } +export type __experimental_GetInvoicesParams = WithOptionalOrgType; + +export type __experimental_CommerceInvoiceStatus = 'paid' | 'unpaid' | 'past_due'; + export interface __experimental_CommerceInvoiceResource extends ClerkResource { id: string; planId: string; @@ -110,7 +117,7 @@ export interface __experimental_CommerceInvoiceResource extends ClerkResource { totals: __experimental_CommerceTotals; paymentDueOn: number; paidOn: number; - status: string; + status: __experimental_CommerceInvoiceStatus; } export type __experimental_GetSubscriptionsParams = WithOptionalOrgType; diff --git a/packages/types/src/json.ts b/packages/types/src/json.ts index 7e09b6fd11f..0dd4d481a03 100644 --- a/packages/types/src/json.ts +++ b/packages/types/src/json.ts @@ -3,6 +3,7 @@ */ import type { + __experimental_CommerceInvoiceStatus, __experimental_CommercePaymentSourceStatus, __experimental_CommerceSubscriptionPlanPeriod, __experimental_CommerceSubscriptionStatus, @@ -646,7 +647,7 @@ export interface __experimental_CommerceInvoiceJSON extends ClerkResourceJSON { payment_due_on: number; payment_source_id: string; plan_id: string; - status: string; + status: __experimental_CommerceInvoiceStatus; totals: __experimental_CommerceTotalsJSON; }