diff --git a/.changeset/spicy-taxes-kick.md b/.changeset/spicy-taxes-kick.md new file mode 100644 index 00000000000..a970786c2f4 --- /dev/null +++ b/.changeset/spicy-taxes-kick.md @@ -0,0 +1,7 @@ +--- +'@clerk/localizations': patch +'@clerk/clerk-js': patch +'@clerk/types': patch +--- + +Add payment source section to `UserProfile` diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 4a4d92918c4..ac9865b0bd8 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -1,7 +1,7 @@ { "files": [ - { "path": "./dist/clerk.js", "maxSize": "582.6kB" }, - { "path": "./dist/clerk.browser.js", "maxSize": "81kB" }, + { "path": "./dist/clerk.js", "maxSize": "584.6kB" }, + { "path": "./dist/clerk.browser.js", "maxSize": "81KB" }, { "path": "./dist/clerk.headless*.js", "maxSize": "55KB" }, { "path": "./dist/ui-common*.js", "maxSize": "96KB" }, { "path": "./dist/vendors*.js", "maxSize": "30KB" }, @@ -19,9 +19,11 @@ { "path": "./dist/onetap*.js", "maxSize": "1KB" }, { "path": "./dist/waitlist*.js", "maxSize": "1.3KB" }, { "path": "./dist/keylessPrompt*.js", "maxSize": "5.9KB" }, - { "path": "./dist/pricingTable*.js", "maxSize": "5.5KB" }, - { "path": "./dist/checkout*.js", "maxSize": "9KB" }, + { "path": "./dist/pricingTable*.js", "maxSize": "5KB" }, + { "path": "./dist/checkout*.js", "maxSize": "3KB" }, + { "path": "./dist/paymentSources*.js", "maxSize": "8KB" }, { "path": "./dist/up-billing-page*.js", "maxSize": "1KB" }, + { "path": "./dist/op-billing-page*.js", "maxSize": "1KB" }, { "path": "./dist/sessionTasks*.js", "maxSize": "1KB" } ] } diff --git a/packages/clerk-js/rspack.config.js b/packages/clerk-js/rspack.config.js index ba490c9eadc..d94c135932f 100644 --- a/packages/clerk-js/rspack.config.js +++ b/packages/clerk-js/rspack.config.js @@ -93,12 +93,12 @@ const common = ({ mode, disableRHC = false }) => { name: 'signup', test: module => module.resource && module.resource.includes('/ui/components/SignUp'), }, - checkout: { + paymentSources: { minChunks: 1, - name: 'checkout', + name: 'paymentSources', test: module => module.resource && - (module.resource.includes('/ui/components/Checkout') || + (module.resource.includes('/ui/components/PaymentSources') || // Include `@stripe/react-stripe-js` and `@stripe/stripe-js` in the checkout chunk module.resource.includes('/node_modules/@stripe')), }, diff --git a/packages/clerk-js/src/core/modules/commerce/Commerce.ts b/packages/clerk-js/src/core/modules/commerce/Commerce.ts index 5a0c5c8afd1..02d22c14984 100644 --- a/packages/clerk-js/src/core/modules/commerce/Commerce.ts +++ b/packages/clerk-js/src/core/modules/commerce/Commerce.ts @@ -1,12 +1,19 @@ import type { __experimental_AddPaymentSourceParams, __experimental_CommerceBillingNamespace, + __experimental_CommerceInitializedPaymentSourceJSON, __experimental_CommerceNamespace, __experimental_CommercePaymentSourceJSON, + __experimental_GetPaymentSourcesParams, + __experimental_InitializePaymentSourceParams, ClerkPaginatedResponse, } from '@clerk/types'; -import { __experimental_CommercePaymentSource, BaseResource } from '../../resources/internal'; +import { + __experimental_CommerceInitializedPaymentSource, + __experimental_CommercePaymentSource, + BaseResource, +} from '../../resources/internal'; import { __experimental_CommerceBilling } from './CommerceBilling'; export class __experimental_Commerce implements __experimental_CommerceNamespace { @@ -19,6 +26,17 @@ export class __experimental_Commerce implements __experimental_CommerceNamespace return __experimental_Commerce._billing; } + initializePaymentSource = async (params: __experimental_InitializePaymentSourceParams) => { + const json = ( + await BaseResource._fetch({ + path: `/me/commerce/payment_sources/initialize`, + method: 'POST', + body: params as any, + }) + )?.response as unknown as __experimental_CommerceInitializedPaymentSourceJSON; + return new __experimental_CommerceInitializedPaymentSource(json); + }; + addPaymentSource = async (params: __experimental_AddPaymentSourceParams) => { const json = ( await BaseResource._fetch({ @@ -30,10 +48,11 @@ export class __experimental_Commerce implements __experimental_CommerceNamespace return new __experimental_CommercePaymentSource(json); }; - getPaymentSources = async () => { + getPaymentSources = async (params: __experimental_GetPaymentSourcesParams) => { return await BaseResource._fetch({ path: `/me/commerce/payment_sources`, method: 'GET', + search: { orgId: params.orgId || '' }, }).then(res => { const { data: paymentSources, total_count } = res as unknown as ClerkPaginatedResponse<__experimental_CommercePaymentSourceJSON>; diff --git a/packages/clerk-js/src/core/resources/CommercePaymentSource.ts b/packages/clerk-js/src/core/resources/CommercePaymentSource.ts index b7faf86881c..bcd0aca4bd0 100644 --- a/packages/clerk-js/src/core/resources/CommercePaymentSource.ts +++ b/packages/clerk-js/src/core/resources/CommercePaymentSource.ts @@ -1,9 +1,13 @@ import type { + __experimental_CommerceInitializedPaymentSourceJSON, + __experimental_CommerceInitializedPaymentSourceResource, __experimental_CommercePaymentSourceJSON, __experimental_CommercePaymentSourceResource, + __experimental_CommercePaymentSourceStatus, + DeletedObjectJSON, } from '@clerk/types'; -import { BaseResource } from './internal'; +import { BaseResource, DeletedObject } from './internal'; export class __experimental_CommercePaymentSource extends BaseResource @@ -13,6 +17,8 @@ export class __experimental_CommercePaymentSource last4!: string; paymentMethod!: string; cardType!: string; + isDefault!: boolean; + status!: __experimental_CommercePaymentSourceStatus; constructor(data: __experimental_CommercePaymentSourceJSON) { super(); @@ -28,6 +34,42 @@ export class __experimental_CommercePaymentSource this.last4 = data.last4; this.paymentMethod = data.payment_method; this.cardType = data.card_type; + this.isDefault = false; + this.status = data.status; + return this; + } + + public async remove() { + const json = ( + await BaseResource._fetch({ + path: `/me/commerce/payment_sources/${this.id}`, + method: 'DELETE', + }) + )?.response as unknown as DeletedObjectJSON; + + return new DeletedObject(json); + } +} + +export class __experimental_CommerceInitializedPaymentSource + extends BaseResource + implements __experimental_CommerceInitializedPaymentSourceResource +{ + externalClientSecret!: string; + externalGatewayId!: string; + + constructor(data: __experimental_CommerceInitializedPaymentSourceJSON) { + super(); + this.fromJSON(data); + } + + protected fromJSON(data: __experimental_CommerceInitializedPaymentSourceJSON | null): this { + if (!data) { + return this; + } + + this.externalClientSecret = data.external_client_secret; + this.externalGatewayId = data.external_gateway_id; return this; } diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx index dea80762f82..b75f027e55f 100644 --- a/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx @@ -5,6 +5,8 @@ import { Box, Button, descriptors, Heading, Icon, localizationKeys, Span, Text } import { Drawer, LineItems } from '../../elements'; import { Check } from '../../icons'; +const capitalize = (name: string) => name[0].toUpperCase() + name.slice(1); + export const CheckoutComplete = ({ checkout }: { checkout: __experimental_CommerceCheckoutResource }) => { const { setIsOpen } = useCheckoutContext(); @@ -77,6 +79,7 @@ export const CheckoutComplete = ({ checkout }: { checkout: __experimental_Commer as='h2' textVariant='h2' > + {/* TODO(@COMMERCE): needs localization */} Payment was successful! diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx index f5b88296c5d..37902f1afb9 100644 --- a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx @@ -6,48 +6,27 @@ import type { ClerkAPIError, ClerkRuntimeError, } from '@clerk/types'; -import { Elements, PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js'; -import type { Appearance as StripeAppearance, Stripe } from '@stripe/stripe-js'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; -import { Box, Button, Col, descriptors, Flex, Form, Icon, Text, useAppearance } from '../../customizables'; +import { Box, Button, Col, descriptors, Flex, Form, Icon, localizationKeys, Text } from '../../customizables'; import { Alert, Disclosure, Divider, Drawer, LineItems, Select, SelectButton, SelectOptionList } from '../../elements'; import { useFetch } from '../../hooks'; import { ArrowUpDown, CreditCard } from '../../icons'; import { animations } from '../../styledSystem'; -import { handleError, normalizeColorString } from '../../utils'; +import { handleError } from '../../utils'; +import { AddPaymentSource } from '../PaymentSources'; + +const capitalize = (name: string) => name[0].toUpperCase() + name.slice(1); export const CheckoutForm = ({ - stripe, checkout, onCheckoutComplete, }: { - stripe: Stripe | null; checkout: __experimental_CommerceCheckoutResource; onCheckoutComplete: (checkout: __experimental_CommerceCheckoutResource) => void; }) => { const { plan, planPeriod, totals } = checkout; - const { colors, fontWeights, fontSizes, radii, space } = useAppearance().parsedInternalTheme; - const elementsAppearance: StripeAppearance = { - variables: { - colorPrimary: normalizeColorString(colors.$primary500), - colorBackground: normalizeColorString(colors.$colorInputBackground), - colorText: normalizeColorString(colors.$colorText), - colorTextSecondary: normalizeColorString(colors.$colorTextSecondary), - colorSuccess: normalizeColorString(colors.$success500), - colorDanger: normalizeColorString(colors.$danger500), - colorWarning: normalizeColorString(colors.$warning500), - fontWeightNormal: fontWeights.$normal.toString(), - fontWeightMedium: fontWeights.$medium.toString(), - fontWeightBold: fontWeights.$bold.toString(), - fontSizeXl: fontSizes.$xl, - fontSizeLg: fontSizes.$lg, - fontSizeSm: fontSizes.$md, - fontSizeXs: fontSizes.$sm, - borderRadius: radii.$md, - spacingUnit: space.$1, - }, - }; + return ( - {stripe && ( - - - - )} + ); }; @@ -117,8 +89,6 @@ const CheckoutFormElements = ({ checkout: __experimental_CommerceCheckoutResource; onCheckoutComplete: (checkout: __experimental_CommerceCheckoutResource) => void; }) => { - const stripe = useStripe(); - const elements = useElements(); const { __experimental_commerce } = useClerk(); const [openAccountFundsDropDown, setOpenAccountFundsDropDown] = useState(true); const [openAddNewSourceDropDown, setOpenAddNewSourceDropDown] = useState(true); @@ -160,39 +130,8 @@ const CheckoutFormElements = ({ } }; - const onStripeSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (!stripe || !elements) { - return; - } - setIsSubmitting(true); - setSubmitError(undefined); - - try { - const { setupIntent, error } = await stripe.confirmSetup({ - elements, - confirmParams: { - return_url: '', // TODO(@COMMERCE): need to figure this out - }, - redirect: 'if_required', - }); - if (error) { - return; - } - - const paymentSource = await __experimental_commerce.addPaymentSource({ - gateway: 'stripe', - paymentMethod: 'card', - paymentToken: setupIntent.payment_method as string, - }); - - await confirmCheckout({ paymentSourceId: paymentSource.id }); - } catch (error) { - console.log(error); - handleError(error, [], setSubmitError); - } finally { - setIsSubmitting(false); - } + const onAddPaymentSourceSuccess = async (paymentSource: __experimental_CommercePaymentSourceResource) => { + await confirmCheckout({ paymentSourceId: paymentSource.id }); }; return ( @@ -241,11 +180,16 @@ const CheckoutFormElements = ({ {/* TODO(@Commerce): needs localization */} - @@ -272,7 +216,7 @@ const PaymentSourceMethods = ({ return paymentSources.map(source => { return { value: source.id, - label: `${source.cardType} ⋯ ${source.last4}`, + label: `${capitalize(source.cardType)} ⋯ ${source.last4}`, }; }); }, [paymentSources]); @@ -319,7 +263,7 @@ const PaymentSourceMethods = ({ as='span' colorScheme='body' > - {selectedPaymentSource.cardType} ⋯ {selectedPaymentSource.last4} + {capitalize(selectedPaymentSource.cardType)} ⋯ {selectedPaymentSource.last4} )} @@ -348,94 +292,3 @@ const PaymentSourceMethods = ({ ); }; - -const StripePaymentMethods = ({ - totalDueNow, - onStripeSubmit, - onExpand, - isSubmitting, -}: { - totalDueNow: __experimental_CommerceMoney; - onStripeSubmit: React.FormEventHandler; - onExpand: () => void; - isSubmitting: boolean; -}) => { - const [collapsed, setCollapsed] = useState(true); - - useEffect(() => { - if (!collapsed) { - onExpand(); - } - }, [collapsed, onExpand]); - - return ( -
({ - display: 'flex', - flexDirection: 'column', - rowGap: t.space.$3, - })} - > - {collapsed ? ( - <> - - - - - ) : ( - <> - - - - )} - - ); -}; diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx index 8920920f09f..a24982f2e80 100644 --- a/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx @@ -1,9 +1,5 @@ import type { __experimental_CheckoutProps, __experimental_CommerceCheckoutResource } from '@clerk/types'; -import type { Stripe } from '@stripe/stripe-js'; -import { loadStripe } from '@stripe/stripe-js'; -import { useEffect, useRef, useState } from 'react'; -import { useEnvironment } from '../../contexts'; import { Alert, Spinner } from '../../customizables'; import { useCheckout } from '../../hooks'; import { CheckoutComplete } from './CheckoutComplete'; @@ -11,9 +7,6 @@ import { CheckoutForm } from './CheckoutForm'; export const CheckoutPage = (props: __experimental_CheckoutProps) => { const { planId, planPeriod, orgId, onSubscriptionComplete } = props; - const stripePromiseRef = useRef | null>(null); - const [stripe, setStripe] = useState(null); - const { __experimental_commerceSettings } = useEnvironment(); const { checkout, updateCheckout, isLoading } = useCheckout({ planId, @@ -21,21 +14,6 @@ export const CheckoutPage = (props: __experimental_CheckoutProps) => { orgId, }); - useEffect(() => { - if ( - !stripePromiseRef.current && - checkout?.externalGatewayId && - __experimental_commerceSettings.stripePublishableKey - ) { - stripePromiseRef.current = loadStripe(__experimental_commerceSettings.stripePublishableKey, { - stripeAccount: checkout.externalGatewayId, - }); - void stripePromiseRef.current.then(stripeInstance => { - setStripe(stripeInstance); - }); - } - }, [checkout?.externalGatewayId, __experimental_commerceSettings]); - const onCheckoutComplete = (newCheckout: __experimental_CommerceCheckoutResource) => { updateCheckout(newCheckout); onSubscriptionComplete?.(); @@ -73,7 +51,6 @@ export const CheckoutPage = (props: __experimental_CheckoutProps) => { return ( diff --git a/packages/clerk-js/src/ui/components/PaymentSources/AddPaymentSource.tsx b/packages/clerk-js/src/ui/components/PaymentSources/AddPaymentSource.tsx new file mode 100644 index 00000000000..93f6a870091 --- /dev/null +++ b/packages/clerk-js/src/ui/components/PaymentSources/AddPaymentSource.tsx @@ -0,0 +1,275 @@ +import { useClerk } from '@clerk/shared/react'; +import type { + __experimental_CommerceCheckoutResource, + __experimental_CommercePaymentSourceResource, + ClerkAPIError, + ClerkRuntimeError, +} from '@clerk/types'; +import { Elements, PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js'; +import type { Appearance as StripeAppearance, Stripe } from '@stripe/stripe-js'; +import { loadStripe } from '@stripe/stripe-js'; +import { useEffect, useRef, useState } from 'react'; + +import { useEnvironment } from '../../contexts'; +import { Button, descriptors, Flex, localizationKeys, Spinner, useAppearance } from '../../customizables'; +import { Alert, Form, FormButtons, FormContainer, withCardStateProvider } from '../../elements'; +import { useFetch } from '../../hooks/useFetch'; +import type { LocalizationKey } from '../../localization'; +import { animations } from '../../styledSystem'; +import { handleError, normalizeColorString } from '../../utils'; + +type AddPaymentSourceProps = { + onSuccess: (paymentSource: __experimental_CommercePaymentSourceResource) => Promise; + checkout?: __experimental_CommerceCheckoutResource; + submitLabel?: LocalizationKey; + cancelAction?: () => void; + cancelButtonText?: string; + onExpand?: () => void; +}; + +type AddPaymentSourceFormProps = { + isCheckout?: boolean; +} & Omit; + +export const AddPaymentSource = (props: AddPaymentSourceProps) => { + const { checkout, submitLabel, onSuccess, onExpand, cancelAction, cancelButtonText } = props; + const { __experimental_commerce } = useClerk(); + const { __experimental_commerceSettings } = useEnvironment(); + + const stripePromiseRef = useRef | null>(null); + const [stripe, setStripe] = useState(null); + + const { colors, fontWeights, fontSizes, radii, space } = useAppearance().parsedInternalTheme; + const elementsAppearance: StripeAppearance = { + variables: { + colorPrimary: normalizeColorString(colors.$primary500), + colorBackground: normalizeColorString(colors.$colorInputBackground), + colorText: normalizeColorString(colors.$colorText), + colorTextSecondary: normalizeColorString(colors.$colorTextSecondary), + colorSuccess: normalizeColorString(colors.$success500), + colorDanger: normalizeColorString(colors.$danger500), + colorWarning: normalizeColorString(colors.$warning500), + fontWeightNormal: fontWeights.$normal.toString(), + fontWeightMedium: fontWeights.$medium.toString(), + fontWeightBold: fontWeights.$bold.toString(), + fontSizeXl: fontSizes.$xl, + fontSizeLg: fontSizes.$lg, + fontSizeSm: fontSizes.$md, + fontSizeXs: fontSizes.$sm, + borderRadius: radii.$md, + spacingUnit: space.$1, + }, + }; + + // if we have a checkout, we can use the checkout's client secret and gateway id + // otherwise, we need to initialize a new payment source + const { data: initializedPaymentSource, invalidate } = useFetch( + !checkout ? __experimental_commerce.initializePaymentSource : undefined, + { + gateway: 'stripe', + }, + ); + + const externalGatewayId = checkout?.externalGatewayId ?? initializedPaymentSource?.externalGatewayId; + const externalClientSecret = checkout?.externalClientSecret ?? initializedPaymentSource?.externalClientSecret; + + useEffect(() => { + if (!stripePromiseRef.current && externalGatewayId && __experimental_commerceSettings.stripePublishableKey) { + stripePromiseRef.current = loadStripe(__experimental_commerceSettings.stripePublishableKey, { + stripeAccount: externalGatewayId, + }); + void stripePromiseRef.current.then(stripeInstance => { + setStripe(stripeInstance); + }); + } + }, [externalGatewayId, __experimental_commerceSettings]); + + // invalidate the initialized payment source when the component unmounts + useEffect(() => { + return invalidate; + }, [invalidate]); + + if (!stripe || !externalClientSecret) { + return ( + ({ + width: '100%', + minHeight: t.sizes.$60, + })} + > + + + ); + } + + return ( + + + + ); +}; + +const AddPaymentSourceForm = withCardStateProvider( + ({ submitLabel, onSuccess, onExpand, cancelAction, cancelButtonText, isCheckout }: AddPaymentSourceFormProps) => { + const { __experimental_commerce } = useClerk(); + const stripe = useStripe(); + const elements = useElements(); + const [collapsed, setCollapsed] = useState(true); + const [submitError, setSubmitError] = useState(); + + useEffect(() => { + if (!collapsed) { + onExpand?.(); + } + }, [collapsed, onExpand]); + + const onSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!stripe || !elements) { + return; + } + setSubmitError(undefined); + + try { + const { setupIntent, error } = await stripe.confirmSetup({ + elements, + confirmParams: { + return_url: '', // TODO(@COMMERCE): need to figure this out + }, + redirect: 'if_required', + }); + if (error) { + return; // just return, since stripe will handle the error + } + + const paymentSource = await __experimental_commerce.addPaymentSource({ + gateway: 'stripe', + paymentToken: setupIntent.payment_method as string, + }); + + void onSuccess(paymentSource); + } catch (error) { + void handleError(error, [], setSubmitError); + } + }; + + return ( + + ({ + display: 'flex', + flexDirection: 'column', + rowGap: t.space.$3, + })} + > + {collapsed ? ( + <> + + + + {cancelAction ? ( + + ) : null} + + ) : ( + <> + + {submitError && ( + ({ + animation: `${animations.textInBig} ${t.transitionDuration.$slow}`, + })} + > + {typeof submitError === 'string' ? submitError : submitError.message} + + )} + + + )} + + + ); + }, +); diff --git a/packages/clerk-js/src/ui/components/PaymentSources/PaymentSources.tsx b/packages/clerk-js/src/ui/components/PaymentSources/PaymentSources.tsx new file mode 100644 index 00000000000..5e6d0676ebb --- /dev/null +++ b/packages/clerk-js/src/ui/components/PaymentSources/PaymentSources.tsx @@ -0,0 +1,176 @@ +import { useClerk } from '@clerk/shared/react'; +import type { __experimental_CommercePaymentSourceResource, __experimental_PaymentSourcesProps } from '@clerk/types'; +import { Fragment, useRef } from 'react'; + +import { RemoveResourceForm } from '../../common'; +import { Badge, Flex, Icon, localizationKeys, Text } from '../../customizables'; +import { ProfileSection, ThreeDotsMenu, useCardState } from '../../elements'; +import { Action } from '../../elements/Action'; +import { useActionContext } from '../../elements/Action/ActionRoot'; +import { useFetch } from '../../hooks'; +import { CreditCard } from '../../icons'; +import { handleError } from '../../utils'; +import { AddPaymentSource } from './AddPaymentSource'; + +const AddScreen = ({ onSuccess }: { onSuccess: () => void }) => { + const { close } = useActionContext(); + + const onAddPaymentSourceSuccess = (_: __experimental_CommercePaymentSourceResource) => { + onSuccess(); + close(); + return Promise.resolve(); + }; + + return ( + + ); +}; + +const RemoveScreen = ({ + paymentSource, + revalidate, +}: { + paymentSource: __experimental_CommercePaymentSourceResource; + revalidate: () => void; +}) => { + const { close } = useActionContext(); + const card = useCardState(); + const ref = useRef( + `${paymentSource.paymentMethod === 'card' ? paymentSource.cardType : paymentSource.paymentMethod} ${paymentSource.paymentMethod === 'card' ? `⋯ ${paymentSource.last4}` : '-'}`, + ); + + if (!ref.current) { + return null; + } + + const removePaymentSource = async () => { + await paymentSource + .remove() + .then(revalidate) + .catch((error: Error) => { + handleError(error, [], card.setError); + }); + }; + + return ( + + ); +}; + +export const __experimental_PaymentSources = (props: __experimental_PaymentSourcesProps) => { + const { orgId } = props; + const { __experimental_commerce } = useClerk(); + + const { data, revalidate } = useFetch(__experimental_commerce?.getPaymentSources, { orgId }); + const { data: paymentSources } = data || { data: [] }; + + return ( + + + + {paymentSources.map(paymentSource => ( + + + + {paymentSource.paymentMethod === 'card' && ( + + )} + ({ color: t.colors.$colorText, textTransform: 'capitalize' })} + truncate + > + {paymentSource.paymentMethod === 'card' ? paymentSource.cardType : paymentSource.paymentMethod} + + ({ color: t.colors.$colorTextSecondary })} + variant='caption' + truncate + > + {paymentSource.paymentMethod === 'card' ? `⋯ ${paymentSource.last4}` : '-'} + + {paymentSource.isDefault && } + {paymentSource.status === 'expired' && ( + + )} + + + + + + + + + + + ))} + + + + + + + + + + + + ); +}; + +const PaymentSourceMenu = ({ paymentSource }: { paymentSource: __experimental_CommercePaymentSourceResource }) => { + const { open } = useActionContext(); + + const actions = [ + { + label: localizationKeys('userProfile.__experimental_billingPage.paymentSourcesSection.actionLabel__remove'), + isDestructive: true, + onClick: () => open(`remove-${paymentSource.id}`), + }, + ]; + + return ; +}; diff --git a/packages/clerk-js/src/ui/components/PaymentSources/index.ts b/packages/clerk-js/src/ui/components/PaymentSources/index.ts new file mode 100644 index 00000000000..74008535779 --- /dev/null +++ b/packages/clerk-js/src/ui/components/PaymentSources/index.ts @@ -0,0 +1,2 @@ +export * from './AddPaymentSource'; +export * from './PaymentSources'; diff --git a/packages/clerk-js/src/ui/components/UserProfile/BillingPage.tsx b/packages/clerk-js/src/ui/components/UserProfile/BillingPage.tsx index 05a217f280b..824dbce854a 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/BillingPage.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/BillingPage.tsx @@ -11,6 +11,7 @@ import { useCardState, withCardStateProvider, } from '../../elements'; +import { __experimental_PaymentSources } from '../PaymentSources'; import { __experimental_PricingTable } from '../PricingTable'; export const BillingPage = withCardStateProvider(() => { @@ -56,7 +57,9 @@ export const BillingPage = withCardStateProvider(() => { Invoices - Payment Sources + + <__experimental_PaymentSources /> + diff --git a/packages/clerk-js/src/ui/elements/FormButtons.tsx b/packages/clerk-js/src/ui/elements/FormButtons.tsx index c580a78b991..e343f62e92b 100644 --- a/packages/clerk-js/src/ui/elements/FormButtons.tsx +++ b/packages/clerk-js/src/ui/elements/FormButtons.tsx @@ -8,12 +8,13 @@ import { Form } from './Form'; type FormButtonsProps = PropsOfComponent & { isDisabled?: boolean; onReset?: () => void; + hideReset?: boolean; submitLabel?: LocalizationKey; resetLabel?: LocalizationKey; }; export const FormButtons = (props: FormButtonsProps) => { - const { isDisabled, onReset, submitLabel, resetLabel, ...rest } = props; + const { isDisabled, onReset, submitLabel, resetLabel, hideReset, ...rest } = props; const { navigateToFlowStart } = useNavigateToFlowStart(); return ( @@ -23,11 +24,13 @@ export const FormButtons = (props: FormButtonsProps) => { localizationKey={submitLabel || localizationKeys('userProfile.formButtonPrimary__save')} {...rest} /> - + {!hideReset && ( + + )} ); }; diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 87c6b76844f..3a3013d7d86 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -13,6 +13,7 @@ export const enUS: LocalizationResource = { backButton: 'Back', badge__currentPlan: 'Current Plan', badge__default: 'Default', + badge__expired: 'Expired', badge__otherImpersonatorDevice: 'Other impersonator device', badge__primary: 'Primary', badge__requiresAction: 'Requires action', @@ -668,6 +669,23 @@ export const enUS: LocalizationResource = { headerTitle__plans: 'Plans', }, title: 'Billing & Payments', + paymentSourcesSection: { + title: 'Available options', + add: 'Add new payment source', + addSubtitle: 'Add a new payment source to your account.', + cancelButton: 'Cancel', + actionLabel__default: 'Make default', + actionLabel__remove: 'Remove', + formButtonPrimary__add: 'Add Payment Method', + formButtonPrimary__pay: 'Pay {{amount}}', + removeResource: { + title: 'Remove payment source', + messageLine1: '{{identifier}} will be removed from this account.', + messageLine2: + 'You will no longer be able to use this payment source and any recurring subscriptions dependent on it will no longer work.', + successMessage: '{{paymentSource}} has been removed from your account.', + }, + }, }, backupCodePage: { actionLabel__copied: 'Copied!', diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 5adc305614b..cedf64f508d 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -1510,6 +1510,10 @@ export type __experimental_CheckoutProps = { onSubscriptionComplete?: () => void; }; +export type __experimental_PaymentSourcesProps = { + orgId?: string; +}; + export interface HandleEmailLinkVerificationParams { /** * Full URL or path to navigate to after successful magic link verification diff --git a/packages/types/src/commerce.ts b/packages/types/src/commerce.ts index 9a25b09cde9..320b2cea108 100644 --- a/packages/types/src/commerce.ts +++ b/packages/types/src/commerce.ts @@ -4,7 +4,12 @@ import type { ClerkResource } from './resource'; export interface __experimental_CommerceNamespace { __experimental_billing: __experimental_CommerceBillingNamespace; - getPaymentSources: () => Promise>; + getPaymentSources: ( + params: __experimental_GetPaymentSourcesParams, + ) => Promise>; + initializePaymentSource: ( + params: __experimental_InitializePaymentSourceParams, + ) => Promise<__experimental_CommerceInitializedPaymentSourceResource>; addPaymentSource: ( params: __experimental_AddPaymentSourceParams, ) => Promise<__experimental_CommercePaymentSourceResource>; @@ -62,17 +67,34 @@ export interface __experimental_CommerceFeatureResource extends ClerkResource { avatarUrl: string; } +export type __experimental_CommercePaymentSourceStatus = 'active' | 'expired' | 'disconnected'; + +export interface __experimental_InitializePaymentSourceParams { + gateway: 'stripe' | 'paypal'; +} + export interface __experimental_AddPaymentSourceParams { gateway: 'stripe' | 'paypal'; - paymentMethod: string; paymentToken: string; } +export interface __experimental_GetPaymentSourcesParams { + orgId?: string; +} + export interface __experimental_CommercePaymentSourceResource extends ClerkResource { id: string; last4: string; paymentMethod: string; cardType: string; + isDefault: boolean; + status: __experimental_CommercePaymentSourceStatus; + remove: () => Promise; +} + +export interface __experimental_CommerceInitializedPaymentSourceResource extends ClerkResource { + externalClientSecret: string; + externalGatewayId: string; } export interface __experimental_CommerceInvoiceResource extends ClerkResource { diff --git a/packages/types/src/elementIds.ts b/packages/types/src/elementIds.ts index 6bca990b02a..9b9872070c7 100644 --- a/packages/types/src/elementIds.ts +++ b/packages/types/src/elementIds.ts @@ -38,7 +38,8 @@ export type ProfileSectionId = | 'organizationProfile' | 'organizationDanger' | 'organizationDomains' - | 'manageVerifiedDomains'; + | 'manageVerifiedDomains' + | 'paymentSources'; export type ProfilePageId = 'account' | 'security' | 'organizationGeneral' | 'organizationMembers' | 'billing'; export type UserPreviewId = 'userButton' | 'personalWorkspace'; diff --git a/packages/types/src/json.ts b/packages/types/src/json.ts index 07f9dc43251..8b9e7b941c9 100644 --- a/packages/types/src/json.ts +++ b/packages/types/src/json.ts @@ -3,6 +3,7 @@ */ import type { + __experimental_CommercePaymentSourceStatus, __experimental_CommerceSubscriptionPlanPeriod, __experimental_CommerceSubscriptionStatus, } from './commerce'; @@ -625,6 +626,13 @@ export interface __experimental_CommercePaymentSourceJSON extends ClerkResourceJ last4: string; payment_method: string; card_type: string; + status: __experimental_CommercePaymentSourceStatus; +} + +export interface __experimental_CommerceInitializedPaymentSourceJSON extends ClerkResourceJSON { + object: 'commerce_payment_source_initialize'; + external_client_secret: string; + external_gateway_id: string; } export interface __experimental_CommerceInvoiceJSON extends ClerkResourceJSON { diff --git a/packages/types/src/localization.ts b/packages/types/src/localization.ts index 3a5f816f67c..0e45ebc5b07 100644 --- a/packages/types/src/localization.ts +++ b/packages/types/src/localization.ts @@ -88,6 +88,7 @@ type _LocalizationResource = { badge__requiresAction: LocalizationValue; badge__you: LocalizationValue; badge__currentPlan: LocalizationValue; + badge__expired: LocalizationValue; footerPageLink__help: LocalizationValue; footerPageLink__privacy: LocalizationValue; footerPageLink__terms: LocalizationValue; @@ -661,6 +662,22 @@ type _LocalizationResource = { headerTitle__invoices: LocalizationValue; headerTitle__paymentSources: LocalizationValue; }; + paymentSourcesSection: { + title: LocalizationValue; + add: LocalizationValue; + addSubtitle: LocalizationValue; + cancelButton: LocalizationValue; + actionLabel__default: LocalizationValue; + actionLabel__remove: LocalizationValue; + formButtonPrimary__add: LocalizationValue; + formButtonPrimary__pay: LocalizationValue; + removeResource: { + title: LocalizationValue; + messageLine1: LocalizationValue; + messageLine2: LocalizationValue; + successMessage: LocalizationValue; + }; + }; }; }; userButton: {