') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); feat(clerk-js, localizations, ui): Add credits information in the user/org profile by l-armstrong · Pull Request #8977 · clerk/javascript · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/fancy-rats-stick.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
---
'@clerk/localizations': minor
'@clerk/clerk-js': minor
'@clerk/shared': minor
'@clerk/ui': minor
---

Add account credits section and credit history page to the billing tab for payers with an existing credit balance.
33 changes: 33 additions & 0 deletions packages/clerk-js/src/core/modules/billing/namespace.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
import type {
BillingCheckoutJSON,
BillingCreditBalanceJSON,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❔ Should it be BillingAccountCreditBalance?
cc @dstaley

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, the internal type is CommerceCreditBalanceResponse, so BillingCreditBalanceJSON would be the correct version of the JavaScript type.

BillingCreditBalanceResource,
BillingCreditLedgerJSON,
BillingCreditLedgerResource,
BillingNamespace,
BillingPaymentJSON,
BillingPaymentResource,
Expand All@@ -11,6 +15,8 @@ import type {
BillingSubscriptionResource,
ClerkPaginatedResponse,
CreateCheckoutParams,
GetCreditBalanceParams,
GetCreditHistoryParams,
GetPaymentAttemptsParams,
GetPlansParams,
GetStatementsParams,
Expand All@@ -21,6 +27,8 @@ import { convertPageToOffsetSearchParams } from '../../../utils/convertPageToOff
import {
BaseResource,
BillingCheckout,
BillingCreditBalance,
BillingCreditLedger,
BillingPayment,
BillingPlan,
BillingStatement,
Expand DownExpand Up@@ -140,4 +148,29 @@ export class Billing implements BillingNamespace {

return new BillingCheckout(json);
};

getCreditBalance = async (params: GetCreditBalanceParams): Promise<BillingCreditBalanceResource> => {
return await BaseResource._fetch({
path: Billing.path('/credits', { orgId: params.orgId }),
method: 'GET',
}).then(res => new BillingCreditBalance(res?.response as unknown as BillingCreditBalanceJSON));
};

getCreditHistory = async (
params: GetCreditHistoryParams,
): Promise<ClerkPaginatedResponse<BillingCreditLedgerResource>> => {
return await BaseResource._fetch({
path: Billing.path('/credits/history', { orgId: params.orgId }),
method: 'GET',
}).then(res => {
const { data, total_count } = res?.response as unknown as {
data: BillingCreditLedgerJSON[];
total_count: number;
};
return {
total_count,
data: data.map(item => new BillingCreditLedger(item)),
};
});
};
}
11 changes: 11 additions & 0 deletions packages/clerk-js/src/core/resources/BillingCreditBalance.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
import type { BillingCreditBalanceJSON, BillingCreditBalanceResource, BillingMoneyAmount } from '@clerk/shared/types';

import { billingMoneyAmountFromJSON } from '../../utils';

export class BillingCreditBalance implements BillingCreditBalanceResource {
balance: BillingMoneyAmount | null;

constructor(data: BillingCreditBalanceJSON) {
this.balance = data.balance ? billingMoneyAmountFromJSON(data.balance) : null;
}
}
32 changes: 32 additions & 0 deletions packages/clerk-js/src/core/resources/BillingCreditLedger.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
import type { BillingCreditLedgerJSON, BillingCreditLedgerResource, BillingMoneyAmount } from '@clerk/shared/types';

import { billingMoneyAmountFromJSON } from '@/utils/billing';
import { unixEpochToDate } from '@/utils/date';

import { BaseResource } from './internal';

export class BillingCreditLedger extends BaseResource implements BillingCreditLedgerResource {
id!: string;
amount!: BillingMoneyAmount;
sourceType!: string;
sourceId!: string;
createdAt!: Date;

constructor(data: BillingCreditLedgerJSON) {
super();
this.fromJSON(data);
}

protected fromJSON(data: BillingCreditLedgerJSON | null): this {
if (!data) {
return this;
}

this.id = data.id;
this.amount = billingMoneyAmountFromJSON(data.amount);
this.sourceType = data.source_type;
this.sourceId = data.source_id;
this.createdAt = unixEpochToDate(data.created_at);
return this;
}
}
2 changes: 2 additions & 0 deletions packages/clerk-js/src/core/resources/internal.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,8 @@ export * from './Base';
export * from './APIKey';
export * from './AuthConfig';
export * from './BillingCheckout';
export * from './BillingCreditBalance';
export * from './BillingCreditLedger';
export * from './BillingPayment';
export * from './BillingPaymentMethod';
export * from './BillingPlan';
Expand Down
18 changes: 18 additions & 0 deletions packages/localizations/src/en-US.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -903,6 +903,15 @@ export const enUS: LocalizationResource = {
badge__manualInvitation: 'No automatic enrollment',
badge__unverified: 'Unverified',
billingPage: {
accountCreditsSection: {
title: 'Account credits',
viewHistory: 'View credit history',
},
creditHistoryPage: {
title: 'Account credit history',
tableHeader__amount: 'Amount',
tableHeader__date: 'Date',
},
paymentHistorySection: {
empty: 'No payment history',
notFound: 'Payment attempt not found',
Expand DownExpand Up@@ -1784,6 +1793,15 @@ export const enUS: LocalizationResource = {
title__codelist: 'Backup codes',
},
billingPage: {
accountCreditsSection: {
title: 'Account credits',
viewHistory: 'View credit history',
},
creditHistoryPage: {
title: 'Account credit history',
tableHeader__amount: 'Amount',
tableHeader__date: 'Date',
},
paymentHistorySection: {
empty: 'No payment history',
notFound: 'Payment attempt not found',
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/react/hooks/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,13 +23,15 @@ export { usePaymentMethods as __experimental_usePaymentMethods } from './usePaym
export { usePlans as __experimental_usePlans } from './usePlans';
export { useSubscription as __experimental_useSubscription } from './useSubscription';
export { useCheckout as __experimental_useCheckout } from './useCheckout';
export { __internal_useCreditBalanceQuery } from './useCreditBalance';

/**
* Internal hooks to be consumed only by `@clerk/clerk-js`.
* These are not considered part of the public API and their query keys can change without notice.
*
* These exist here in order to keep React Query implementations in a centralized place.
*/
export { __internal_useCreditHistoryQuery } from './useCreditHistory';
export { __internal_useStatementQuery } from './useStatementQuery';
export { __internal_usePlanDetailsQuery } from './usePlanDetailsQuery';
export { __internal_usePaymentAttemptQuery } from './usePaymentAttemptQuery';
Expand Down
103 changes: 103 additions & 0 deletions packages/shared/src/react/hooks/useCreditBalance.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';

import { eventMethodCalled } from '../../telemetry/events';
import type { BillingCreditBalanceResource, ForPayerType } from '../../types';
import { useAssertWrappedByClerkProvider, useClerkInstanceContext } from '../contexts';
import { defineKeepPreviousDataFn } from '../query/keep-previous-data';
import { useClerkQueryClient } from '../query/use-clerk-query-client';
import { useClerkQuery } from '../query/useQuery';
import { STABLE_KEYS } from '../stable-keys';
import { useOrganizationBase } from './base/useOrganizationBase';
import { useUserBase } from './base/useUserBase';
import { createCacheKeys } from './createCacheKeys';
import { useBillingIsEnabled } from './useBillingIsEnabled';
import { useClearQueriesOnSignOut } from './useClearQueriesOnSignOut';

const HOOK_NAME = 'useCreditBalance';

export type UseCreditBalanceParams = {
for?: ForPayerType;
keepPreviousData?: boolean;
enabled?: boolean;
Comment thread
dstaley marked this conversation as resolved.
};

export type CreditBalanceResult = {
data: BillingCreditBalanceResource | undefined | null;
error: Error | undefined;
isLoading: boolean;
isFetching: boolean;
revalidate: () => Promise<void> | void;
};

/**
* @internal
*/
export function __internal_useCreditBalanceQuery(params?: UseCreditBalanceParams): CreditBalanceResult {
useAssertWrappedByClerkProvider(HOOK_NAME);

const clerk = useClerkInstanceContext();
const user = useUserBase();
const organization = useOrganizationBase();

const billingEnabled = useBillingIsEnabled(params);

const recordedRef = useRef(false);
useEffect(() => {
if (!recordedRef.current && clerk?.telemetry) {
clerk.telemetry.record(eventMethodCalled(HOOK_NAME));
recordedRef.current = true;
}
}, [clerk]);

const keepPreviousData = params?.keepPreviousData ?? false;

const [queryClient] = useClerkQueryClient();

const { queryKey, invalidationKey, stableKey, authenticated } = useMemo(() => {
const isOrganization = params?.for === 'organization';
const safeOrgId = isOrganization ? organization?.id : undefined;

return createCacheKeys({
stablePrefix: STABLE_KEYS.CREDIT_BALANCE_KEY,
authenticated: true,
tracked: {
userId: user?.id,
orgId: safeOrgId,
},
untracked: {
args: { orgId: safeOrgId },
},
});
}, [user?.id, organization?.id, params?.for]);

const queriesEnabled = Boolean(user?.id && billingEnabled && (params?.enabled ?? true));
useClearQueriesOnSignOut({
isSignedOut: user === null,
authenticated,
stableKeys: stableKey,
});

const query = useClerkQuery({
queryKey,
queryFn: ({ queryKey }) => {
const obj = queryKey[3];
return clerk.billing.getCreditBalance(obj.args);
},
staleTime: 1_000 * 60,
enabled: queriesEnabled,
placeholderData: defineKeepPreviousDataFn(keepPreviousData && queriesEnabled),
});

const revalidate = useCallback(
() => queryClient.invalidateQueries({ queryKey: invalidationKey }),
[queryClient, invalidationKey],
);

return {
data: query.data,
error: query.error ?? undefined,
isLoading: query.isLoading,
isFetching: query.isFetching,
revalidate,
};
}
98 changes: 98 additions & 0 deletions packages/shared/src/react/hooks/useCreditHistory.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';

import { eventMethodCalled } from '../../telemetry/events';
import type { BillingCreditLedgerResource, ClerkPaginatedResponse, ForPayerType } from '../../types';
import { useAssertWrappedByClerkProvider, useClerkInstanceContext } from '../contexts';
import { useClerkQueryClient } from '../query/use-clerk-query-client';
import { useClerkQuery } from '../query/useQuery';
import { INTERNAL_STABLE_KEYS } from '../stable-keys';
import { useOrganizationBase } from './base/useOrganizationBase';
import { useUserBase } from './base/useUserBase';
import { createCacheKeys } from './createCacheKeys';
import { useBillingIsEnabled } from './useBillingIsEnabled';
import { useClearQueriesOnSignOut } from './useClearQueriesOnSignOut';

const HOOK_NAME = 'useCreditHistory';

export type UseCreditHistoryParams = {
for?: ForPayerType;
enabled?: boolean;
};

export type CreditHistoryResult = {
data: ClerkPaginatedResponse<BillingCreditLedgerResource> | undefined;
error: Error | undefined;
isLoading: boolean;
isFetching: boolean;
revalidate: () => Promise<void> | void;
};

/**
* @internal
*/
export function __internal_useCreditHistoryQuery(params?: UseCreditHistoryParams): CreditHistoryResult {
useAssertWrappedByClerkProvider(HOOK_NAME);

const clerk = useClerkInstanceContext();
const user = useUserBase();
const organization = useOrganizationBase();

const billingEnabled = useBillingIsEnabled(params);

const recordedRef = useRef(false);
useEffect(() => {
if (!recordedRef.current && clerk?.telemetry) {
clerk.telemetry.record(eventMethodCalled(HOOK_NAME));
recordedRef.current = true;
}
}, [clerk]);

const [queryClient] = useClerkQueryClient();

const { queryKey, invalidationKey, stableKey, authenticated } = useMemo(() => {
const isOrganization = params?.for === 'organization';
const safeOrgId = isOrganization ? organization?.id : undefined;

return createCacheKeys({
stablePrefix: INTERNAL_STABLE_KEYS.CREDIT_HISTORY_KEY,
authenticated: true,
tracked: {
userId: user?.id,
orgId: safeOrgId,
},
untracked: {
args: { orgId: safeOrgId },
},
});
}, [user?.id, organization?.id, params?.for]);

const queriesEnabled = Boolean(user?.id && billingEnabled && (params?.enabled ?? true));
useClearQueriesOnSignOut({
isSignedOut: user === null,
authenticated,
stableKeys: stableKey,
});

const query = useClerkQuery({
queryKey,
queryFn: ({ queryKey }) => {
const obj = queryKey[3];
return clerk.billing.getCreditHistory(obj.args);
},
staleTime: 1_000 * 60,
enabled: queriesEnabled,
});

const revalidate = useCallback(
() => queryClient.invalidateQueries({ queryKey: invalidationKey }),
[queryClient, invalidationKey],
);

return {
data: query.data,
error: query.error ?? undefined,
isLoading: query.isLoading,
isFetching: query.isFetching,
revalidate,
};
}
Loading
Loading