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 (
-
- );
-};
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: {