@blocklet/payment-react
Version:
Reusable react components for payment kit v2
1,626 lines (1,447 loc) • 57.1 kB
text/typescript
/* eslint-disable no-nested-ternary */
/* eslint-disable @typescript-eslint/indent */
import type {
ChainType,
PaymentDetails,
PriceCurrency,
PriceRecurring,
TCoupon,
TInvoiceExpanded,
TLineItemExpanded,
TPaymentCurrency,
TPaymentCurrencyExpanded,
TPaymentMethod,
TPaymentMethodExpanded,
TPrice,
TProductExpanded,
TSubscriptionExpanded,
TSubscriptionItemExpanded,
} from '@blocklet/payment-types';
import { BN, fromUnitToToken, fromTokenToUnit } from '@ocap/util';
import omit from 'lodash/omit';
import trimEnd from 'lodash/trimEnd';
import numbro from 'numbro';
// eslint-disable-next-line import/no-extraneous-dependencies
import stringWidth from 'string-width';
import { defaultCountries } from 'react-international-phone';
import { joinURL, withQuery } from 'ufo';
import { t } from '../locales';
import dayjs from './dayjs';
import type { ActionProps, PricingRenderProps } from '../types';
export const PAYMENT_KIT_DID = 'z2qaCNvKMv5GjouKdcDWexv6WqtHbpNPQDnAk';
/**
* Format coupon discount terms for display
*/
export const formatCouponTerms = (coupon: TCoupon, currency: TPaymentCurrency, locale: string = 'en'): string => {
let couponOff = '';
if (coupon.percent_off && coupon.percent_off > 0) {
couponOff = t('payment.checkout.coupon.percentage', locale, { percent: coupon.percent_off });
}
if (coupon.amount_off && coupon.amount_off !== '0') {
const { symbol } = currency;
couponOff =
coupon.currency_id === currency.id
? coupon.amount_off || ''
: coupon.currency_options?.[currency.id]?.amount_off || '';
if (couponOff) {
couponOff = t('payment.checkout.coupon.fixedAmount', locale, {
amount: formatAmount(couponOff, currency.decimal),
symbol,
});
}
}
if (!couponOff) {
return t('payment.checkout.coupon.noDiscount');
}
return t(`payment.checkout.coupon.terms.${coupon.duration}`, locale, {
couponOff,
months: coupon.duration_in_months || 0,
});
};
export const isPaymentKitMounted = () => {
return (window.blocklet?.componentMountPoints || []).some((x: any) => x.did === PAYMENT_KIT_DID);
};
export const getPrefix = (): string => {
const prefix = window.blocklet?.prefix || '/';
const baseUrl = window.location?.origin; // required when use payment feature cross origin
if (window.__PAYMENT_KIT_BASE_URL) {
try {
const tmp = new URL(window.__PAYMENT_KIT_BASE_URL);
if (tmp.origin !== window.location.origin) {
return window.__PAYMENT_KIT_BASE_URL;
}
} catch (err) {
console.warn('Invalid baseUrl for PaymentProvider', window.__PAYMENT_KIT_BASE_URL);
}
}
const componentId = (window.blocklet?.componentId || '').split('/').pop();
if (componentId === PAYMENT_KIT_DID) {
return joinURL(baseUrl, prefix);
}
const component = (window.blocklet?.componentMountPoints || []).find((x: any) => x?.did === PAYMENT_KIT_DID);
if (component) {
return joinURL(baseUrl, component.mountPoint);
}
return joinURL(baseUrl, prefix);
};
export function isCrossOrigin() {
try {
const prefix = getPrefix();
const prefixOrigin = new URL(prefix).origin;
const currentOrigin = window.location.origin;
return prefixOrigin !== currentOrigin;
} catch (error) {
return false;
}
}
export function formatToDate(date: Date | string | number, locale = 'en', format = 'YYYY-MM-DD HH:mm:ss') {
if (!date) {
return '-';
}
return dayjs(date).locale(formatLocale(locale)).format(format);
}
export function formatToDatetime(date: Date | string | number, locale = 'en') {
if (!date) {
return '-';
}
return dayjs(date).locale(formatLocale(locale)).format('lll');
}
export function formatTime(date: Date | string | number, format = 'YYYY-MM-DD HH:mm:ss', locale = 'en') {
if (!date) {
return '-';
}
return dayjs(date).locale(formatLocale(locale)).format(format);
}
export function formatDateTime(date: Date | string | number, locale = 'en') {
return dayjs(date).locale(formatLocale(locale)).format('YYYY-MM-DD HH:mm');
}
export const formatLocale = (locale = 'en') => {
if (locale === 'tw') {
return 'zh';
}
return locale;
};
export const formatPrettyMsLocale = (locale: string) => (locale === 'zh' ? 'zh_CN' : 'en_US');
export const formatError = (err: any) => {
if (!err) {
return 'Unknown error';
}
const { details, errors, response } = err;
// graphql error
if (Array.isArray(errors)) {
return errors.map((x) => x.message).join('\n');
}
// joi validate error
if (Array.isArray(details)) {
const formatted = details.map((e) => {
const errorMessage = e.message.replace(/["]/g, "'");
const errorPath = e.path.join('.');
return `${errorPath}: ${errorMessage}`;
});
return `Validate failed: ${formatted.join(';')}`;
}
// axios error
if (response) {
return response.data?.error || `${err.message}: ${JSON.stringify(response.data)}`;
}
return err.message;
};
export function formatBNStr(
str: string = '',
decimals: number = 18,
precision: number = 6,
trim: boolean = true,
thousandSeparated: boolean = true
) {
if (!str) {
return '0';
}
return formatNumber(fromUnitToToken(str, decimals), precision, trim, thousandSeparated);
}
const CURRENCY_SYMBOLS: Record<string, string> = {
USD: '$',
EUR: '€',
GBP: '£',
JPY: '¥',
CNY: '¥',
KRW: '₩',
INR: '₹',
AUD: 'A$',
CAD: 'C$',
CHF: 'Fr',
HKD: 'HK$',
SGD: 'S$',
NZD: 'NZ$',
};
/**
* Format credit amount for display (data contexts: balances, transactions, notifications)
* - If currency has symbol mapping (USD -> $): shows "$20"
* - If no symbol mapping: shows "20 minutes" or "20 arcsphere credits"
*
* @param formattedAmount - Already formatted amount string (e.g., "20", "1,000.50")
* @param currencySymbol - Currency symbol (USD, EUR, minutes, etc.)
* @param showUnit - Whether to show the unit (default: true)
*/
export function formatCreditAmount(formattedAmount: string, currencySymbol: string, showUnit: boolean = true): string {
const mappedSymbol = CURRENCY_SYMBOLS[currencySymbol];
if (mappedSymbol) {
return `${mappedSymbol}${formattedAmount}`;
}
return `${formattedAmount} ${showUnit ? currencySymbol : ''}`;
}
/**
* Format credit amount for checkout/purchase scenarios
* - If currency has symbol mapping (USD -> $): shows "$20 of credits" (en) or "$20 的信用额度" (zh)
* - If no symbol mapping: shows "20 minutes" or "20 arcsphere credits"
*
* @param formattedAmount - Already formatted amount string (e.g., "20", "1,000.50")
* @param currencySymbol - Currency symbol (USD, EUR, minutes, etc.)
* @param locale - Locale for translation (default: 'en')
* @param showUnit - Whether to show the unit (default: true)
*/
export function formatCreditForCheckout(
formattedAmount: string,
currencySymbol: string,
locale: string = 'en',
showUnit: boolean = true
): string {
const mappedSymbol = CURRENCY_SYMBOLS[currencySymbol];
if (mappedSymbol) {
return `${mappedSymbol}${formattedAmount} ${showUnit ? t('common.credits', locale) : ''}`;
}
return `${formattedAmount} ${showUnit ? currencySymbol : ''}`;
}
export function formatNumber(
n: number | string,
precision: number = 6,
trim: boolean = true,
thousandSeparated: boolean = true
) {
if (!n || n === '0') {
return '0';
}
const num = numbro(n);
const options = {
thousandSeparated,
...((precision || precision === 0) && { mantissa: precision }),
};
const result = num.format(options);
if (!trim) {
return result;
}
const [left, right] = result.split('.');
return right ? [left, trimEnd(right, '0')].filter(Boolean).join('.') : left;
}
export function formatDynamicPrice(
n: number | string,
isDynamic: boolean,
precision: number = 6,
trim: boolean = true,
thousandSeparated: boolean = true
) {
if (!isDynamic) {
return formatNumber(n, precision, trim, thousandSeparated);
}
const num = Number(n);
if (!Number.isFinite(num)) {
return formatNumber(n, precision, trim, thousandSeparated);
}
const abs = Math.abs(num);
const targetPrecision = abs > 0 && abs < 0.01 ? precision : 2;
return formatNumber(n, targetPrecision, trim, thousandSeparated);
}
const USD_DECIMALS = 8;
export function getUsdAmountFromBaseAmount(
amount: string | number | undefined,
quantity: number,
scale = BASE_AMOUNT_SCALE
): string | null {
if (amount === undefined || amount === null || !quantity || quantity <= 0) {
return null;
}
const scaled = toScaledBaseAmount(amount, quantity, scale);
return formatScaledAmount(scaled, scale);
}
export function getUsdAmountFromTokenUnits(
tokenAmount: BN | string,
tokenDecimals: number,
exchangeRate?: string | null
): string | null {
if (!exchangeRate && exchangeRate !== '0') {
return null;
}
if (tokenDecimals === undefined || tokenDecimals === null) {
return null;
}
try {
// exchangeRate is in token format (e.g., "0.25535177995959574"), may have more than 8 decimal places
// tokenAmount is in unit format (smallest unit)
// We need to calculate: (tokenAmount / 10^tokenDecimals) * exchangeRate
// To avoid precision loss, we use: (tokenAmount * exchangeRate) / 10^tokenDecimals
// But exchangeRate is a decimal string, so we need to handle it carefully
const amountBN = tokenAmount instanceof BN ? tokenAmount : new BN(tokenAmount || '0');
const tokenScale = new BN(10).pow(new BN(tokenDecimals));
if (tokenScale.isZero()) {
return null;
}
// Convert exchangeRate (token format) to BN with proper precision
// exchangeRate may have more than 8 decimal places, so we need to preserve all precision
// We'll use a higher precision scale for the rate calculation
const rateBN = new BN(exchangeRate.replace('.', ''));
const rateDecimalPlaces = exchangeRate.includes('.') ? exchangeRate.split('.')[1]?.length || 0 : 0;
const rateScale = new BN(10).pow(new BN(rateDecimalPlaces));
// Calculate: (amountBN * rateBN * USD_PRECISION_SCALE) / (tokenScale * rateScale)
// The USD_PRECISION_SCALE keeps enough trailing digits so the final conversion via fromUnitToToken
// produces a value with 8 decimal places.
const usdPrecisionScale = new BN(10).pow(new BN(USD_DECIMALS));
const usdUnit = amountBN.mul(rateBN).mul(usdPrecisionScale).div(tokenScale.mul(rateScale));
// Convert from unit format to token format with 8 decimals
return fromUnitToToken(usdUnit.toString(), USD_DECIMALS);
} catch {
return null;
}
}
export function formatUsdAmount(amount: string | null, locale: string = 'en'): string | null {
if (!amount && amount !== '0') {
return null;
}
const num = Number(amount);
if (!Number.isFinite(num)) {
return null;
}
return new Intl.NumberFormat(locale, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(num);
}
export function formatExchangeRate(amount: string | null): string | null {
if (!amount && amount !== '0') {
return null;
}
const value = String(amount);
const num = Number(value);
if (!Number.isFinite(num)) {
return null;
}
return value;
}
/**
* Format exchange rate with currency symbol for display
* @param rate - The exchange rate value
* @param currency - The currency code (default: 'USD')
* @param decimals - Number of decimal places (default: 2, use 4 for live exchange rates)
* @returns Formatted string like "$0.12" for USD, or "0.12 EUR" for other currencies
*/
export function formatExchangeRateDisplay(
rate: string | number | null | undefined,
currency: string = 'USD',
decimals: number = 2
): string | null {
if (rate === null || rate === undefined) {
return null;
}
const num = Number(rate);
if (!Number.isFinite(num)) {
return null;
}
const formattedValue = num.toFixed(decimals);
if (currency === 'USD') {
return `$${formattedValue}`;
}
return `${formattedValue} ${currency}`;
}
export const formatPrice = (
price: TPrice,
currency: TPaymentCurrency,
unit_label?: string,
quantity: number = 1,
bn: boolean = true,
locale: string = 'en'
) => {
if (!currency) {
return '';
}
if (price.custom_unit_amount) {
return `Custom (${currency.symbol})`;
}
// For dynamic pricing, display USD reference price
if (
(price as any).pricing_type === 'dynamic' &&
(price as any).base_amount &&
(price as any).base_currency === 'USD'
) {
const baseAmount = (price as any).base_amount;
const usdAmount = Number(baseAmount) * quantity;
const formattedUsd = formatUsdAmount(usdAmount.toString(), locale);
if (price?.type === 'recurring' && price.recurring) {
const recurring = formatRecurring(price.recurring, false, 'slash', locale);
if (unit_label) {
return `$${formattedUsd} / ${unit_label} ${recurring}`;
}
if (price.recurring.usage_type === 'metered') {
return `$${formattedUsd} / unit ${recurring}`;
}
return `$${formattedUsd} ${recurring}`;
}
return `$${formattedUsd}`;
}
// For fixed pricing, display token amount
const unit = getPriceUintAmountByCurrency(price, currency);
const amount = bn
? fromUnitToToken(new BN(unit).mul(new BN(quantity)), currency.decimal).toString()
: +unit * quantity;
if (price?.type === 'recurring' && price.recurring) {
const recurring = formatRecurring(price.recurring, false, 'slash', locale);
if (unit_label) {
return `${amount} ${currency.symbol} / ${unit_label} ${recurring}`;
}
if (price.recurring.usage_type === 'metered') {
return `${amount} ${currency.symbol} / unit ${recurring}`;
}
return `${amount} ${currency.symbol} ${recurring}`;
}
return `${amount} ${currency.symbol}`;
};
export const formatPriceAmount = (
price: TPrice,
currency: TPaymentCurrency,
unit_label?: string,
quantity: number = 1,
bn: boolean = true,
locale: string = 'en'
) => {
if (!currency) {
return '';
}
// For dynamic pricing, display USD reference price
if (
(price as any).pricing_type === 'dynamic' &&
(price as any).base_amount &&
(price as any).base_currency === 'USD'
) {
const baseAmount = (price as any).base_amount;
const usdAmount = Number(baseAmount) * quantity;
const formattedUsd = formatUsdAmount(usdAmount.toString(), locale);
if (price?.type === 'recurring' && price.recurring) {
if (unit_label) {
return `$${formattedUsd} / ${unit_label}`;
}
if (price.recurring.usage_type === 'metered') {
return `$${formattedUsd} / unit`;
}
return `$${formattedUsd}`;
}
return `$${formattedUsd}`;
}
// For fixed pricing, display token amount
const unit = getPriceUintAmountByCurrency(price, currency);
const amount = bn
? fromUnitToToken(new BN(unit).mul(new BN(quantity)), currency.decimal).toString()
: +unit * quantity;
if (price?.type === 'recurring' && price.recurring) {
if (unit_label) {
return `${amount} ${currency.symbol} / ${unit_label}`;
}
if (price.recurring.usage_type === 'metered') {
return `${amount} ${currency.symbol} / unit`;
}
return `${amount} ${currency.symbol}`;
}
return `${amount} ${currency.symbol}`;
};
export function getStatementDescriptor(items: any[]) {
for (const item of items) {
if (item.price?.product?.statement_descriptor) {
return item.price?.product?.statement_descriptor;
}
}
return window.blocklet?.appName;
}
export function formatRecurring(
recurring: PriceRecurring,
translate: boolean = true,
separator: string = 'per',
locale: string = 'en'
) {
const intervals = {
hour: 'hourly',
day: 'daily',
week: 'weekly',
month: 'monthly',
year: 'yearly',
};
if (!recurring) {
return '';
}
if (+recurring.interval_count === 1) {
const interval = t(`common.${recurring.interval}`, locale);
// @ts-ignore
return translate ? t(`common.${intervals[recurring.interval]}`, locale) : separator ? t(`common.${separator}`, locale, { interval }) : interval; // prettier-ignore
}
if (recurring.interval === 'month') {
if (recurring.interval_count === 3) {
return t('common.month3', locale);
}
if (recurring.interval_count === 6) {
return t('common.month6', locale);
}
}
return t('common.recurring', locale, {
count: recurring.interval_count,
interval: t(`common.${recurring.interval}s`, locale),
});
}
export function getPriceUintAmountByCurrency(price: TPrice, currency: TPaymentCurrency) {
const options = getPriceCurrencyOptions(price);
const option = options.find((x) => x.currency_id === currency?.id);
if (option) {
if (option.custom_unit_amount) {
return option.custom_unit_amount.preset || option.custom_unit_amount.presets[0];
}
return option.unit_amount;
}
if (price.currency_id === currency?.id) {
if (price.custom_unit_amount) {
return price.custom_unit_amount.preset || price.custom_unit_amount.presets[0];
}
return price.unit_amount;
}
console.warn(`Currency ${currency?.id} not configured for price`, price);
return '0';
}
export function getPriceCurrencyOptions(price: TPrice): PriceCurrency[] {
if (Array.isArray(price.currency_options)) {
return price.currency_options;
}
return [
{
currency_id: price.currency_id,
unit_amount: price.unit_amount,
custom_unit_amount: price.custom_unit_amount || null,
tiers: null,
},
];
}
const BASE_AMOUNT_SCALE = 8;
function formatScaledAmount(value: BN, scale = BASE_AMOUNT_SCALE) {
const isNegative = value.isNeg();
const absValue = value.abs();
const str = absValue.toString().padStart(scale + 1, '0');
const integerPart = str.slice(0, str.length - scale) || '0';
const fraction = str.slice(-scale).replace(/0+$/, '');
const formatted = fraction ? `${integerPart}.${fraction}` : integerPart;
return isNegative ? `-${formatted}` : formatted;
}
function toScaledBaseAmount(amount: string | number | undefined, quantity: number, scale = BASE_AMOUNT_SCALE) {
if (!amount || !quantity) {
return new BN(0);
}
const normalized = String(amount);
const [integer = '0', fraction = ''] = normalized.split('.');
const frac = `${fraction}`.padEnd(scale, '0').slice(0, scale);
const digits = `${integer.replace('-', '')}${frac}` || '0';
const scaled = new BN(digits).mul(new BN(quantity));
return normalized.startsWith('-') ? scaled.neg() : scaled;
}
export function getLineItemAmounts(
item: TLineItemExpanded,
currency: TPaymentCurrency,
{ useUpsell = true, exchangeRate = null }: { useUpsell?: boolean; exchangeRate?: string | null } = {}
): { unitAmount: BN; totalAmount: BN; isDynamicQuote: boolean } {
if (!currency) {
return { unitAmount: new BN(0), totalAmount: new BN(0), isDynamicQuote: false };
}
const price = (useUpsell ? item.upsell_price || item.price : item.price) as any;
const quantity = new BN(item.quantity || 0);
const quoteAmount = (item as any)?.quoted_amount;
const quoteCurrencyId = (item as any)?.quote_currency_id;
const isDynamicPrice = price?.pricing_type === 'dynamic';
// For dynamic pricing, only use quoted_amount if the quote was created for the current currency
// This prevents using a quote with wrong decimal precision when user switches currencies
// e.g., ABT quote (decimal=18) should not be used with USD (decimal=2)
const isQuoteValidForCurrency = isDynamicPrice && quoteAmount && quoteCurrencyId === currency.id;
if (isQuoteValidForCurrency) {
const totalAmount = new BN(quoteAmount || '0');
const unitAmount = quantity.gt(new BN(0)) ? totalAmount.add(quantity).sub(new BN(1)).div(quantity) : new BN(0);
return { unitAmount, totalAmount, isDynamicQuote: true };
}
// For dynamic pricing without valid quote, calculate from base_amount and exchange rate
if (isDynamicPrice && exchangeRate && price?.base_amount) {
const rate = Number(exchangeRate);
if (rate > 0 && Number.isFinite(rate)) {
// base_amount is stored in dollar format (e.g., "1.00" = $1.00)
const baseAmountUsd = Number(price.base_amount);
if (baseAmountUsd > 0 && Number.isFinite(baseAmountUsd)) {
// Calculate token amount: USD / rate
const tokenAmount = baseAmountUsd / rate;
const unitAmount = fromTokenToUnit(tokenAmount.toFixed(currency.decimal || 8), currency.decimal || 8);
return {
unitAmount,
totalAmount: unitAmount.mul(quantity),
isDynamicQuote: false,
};
}
}
}
// For all other cases (non-dynamic, or quote not valid for current currency), use currency_options
const unitAmount = new BN(getPriceUintAmountByCurrency(price, currency));
return {
unitAmount,
totalAmount: unitAmount.mul(quantity),
isDynamicQuote: false,
};
}
export type QuoteLockInfo = {
baseAmount: string;
baseCurrency: string;
tokenAmount: string;
tokenSymbol: string;
expiresAt: number | null;
};
export function getQuoteLockInfo(items: TLineItemExpanded[], currency?: TPaymentCurrency | null): QuoteLockInfo | null {
if (!items?.length || !currency) {
return null;
}
const dynamicItems = items.filter((item) => {
const price = (item.upsell_price || item.price) as any;
return price?.pricing_type === 'dynamic' && (item as any)?.quoted_amount;
});
if (!dynamicItems.length) {
return null;
}
let totalBaseAmount = new BN(0);
let totalTokenAmount = new BN(0);
let expiresAt: number | null = null;
dynamicItems.forEach((item) => {
const price = (item.upsell_price || item.price) as any;
const quoteAmount = new BN((item as any)?.quoted_amount || '0');
totalTokenAmount = totalTokenAmount.add(quoteAmount);
const baseAmount = price?.base_amount;
if (baseAmount) {
totalBaseAmount = totalBaseAmount.add(toScaledBaseAmount(baseAmount, item.quantity));
}
if ((item as any)?.expires_at) {
expiresAt = expiresAt === null ? (item as any)?.expires_at : Math.min(expiresAt, (item as any)?.expires_at);
}
});
return {
baseAmount: formatScaledAmount(totalBaseAmount, BASE_AMOUNT_SCALE),
baseCurrency: ((dynamicItems[0]?.upsell_price || dynamicItems[0]?.price) as any)?.base_currency || 'USD',
tokenAmount: fromUnitToToken(totalTokenAmount, currency.decimal).toString(),
tokenSymbol: currency.symbol,
expiresAt,
};
}
export function formatLineItemPricing(
item: TLineItemExpanded,
currency: TPaymentCurrency,
{
trialEnd,
trialInDays,
exchangeRate = null,
}: { trialEnd: number; trialInDays: number; exchangeRate?: string | null },
locale: string = 'en'
): { primary: string; secondary?: string; quantity: string } {
if (!currency) {
return { primary: '', secondary: '', quantity: '' };
}
const price = item.upsell_price || item.price;
if (!price) {
return { primary: '', secondary: '', quantity: '' };
}
let quantity = t('common.qty', locale, { count: item.quantity });
if (price.recurring?.usage_type === 'metered' || +item.quantity === 1) {
quantity = '';
}
const { unitAmount, totalAmount } = getLineItemAmounts(item, currency, { exchangeRate });
const isDynamic = (price as any)?.pricing_type === 'dynamic';
const formatLineItemAmount = (bn: BN) => {
const value = fromUnitToToken(bn, currency.decimal);
return formatDynamicPrice(value, isDynamic, 6);
};
const total = `${formatLineItemAmount(totalAmount)} ${currency.symbol}`;
const unit = `${formatLineItemAmount(unitAmount)} ${currency.symbol}`;
const trialResult = getFreeTrialTime({ trialInDays, trialEnd }, locale);
const appendUnit = (v: string, alt: string) => {
if (price.product.unit_label) {
return `${v}/${price.product.unit_label}`;
}
if (price.recurring?.usage_type === 'metered' || item.quantity === 1) {
return alt;
}
return quantity ? t('common.each', locale, { unit }) : '';
};
if (price.type === 'recurring' && price.recurring) {
if (trialResult.count > 0) {
return {
primary: t('common.trial', locale, { count: trialResult.count, interval: trialResult.interval }),
secondary: `${appendUnit(total, total)} ${formatRecurring(price.recurring, false, 'slash', locale)}`,
quantity,
};
}
return {
primary: total,
secondary: appendUnit(total, ''),
quantity,
};
}
return {
primary: total,
secondary: appendUnit(total, ''),
quantity,
};
}
export function getSubscriptionStatusColor(status: string) {
switch (status) {
case 'active':
return 'success';
case 'trialing':
return 'primary';
case 'incomplete':
case 'incomplete_expired':
case 'paused':
return 'warning';
case 'past_due':
case 'unpaid':
return 'error';
case 'canceled':
default:
return 'default';
}
}
export function getPaymentIntentStatusColor(status: string) {
switch (status) {
case 'succeeded':
return 'success';
case 'requires_payment_method':
case 'requires_confirmation':
case 'requires_action':
case 'requires_capture':
return 'warning';
case 'canceled':
case 'processing':
default:
return 'default';
}
}
export function getRefundStatusColor(status: string) {
switch (status) {
case 'succeeded':
return 'success';
case 'requires_action':
return 'warning';
case 'canceled':
case 'pending':
default:
return 'default';
}
}
export function getPayoutStatusColor(status: string) {
switch (status) {
case 'paid':
return 'success';
case 'deferred':
return 'warning';
case 'failed':
return 'warning';
case 'canceled':
case 'pending':
case 'in_transit':
default:
return 'default';
}
}
export function getInvoiceStatusColor(status: string) {
switch (status) {
case 'paid':
return 'success';
case 'open':
return 'secondary';
case 'uncollectible':
return 'warning';
case 'draft':
case 'void':
default:
return 'default';
}
}
export function getWebhookStatusColor(status: string) {
switch (status) {
case 'enabled':
return 'success';
case 'disabled':
default:
return 'default';
}
}
export function getCheckoutAmount(
items: TLineItemExpanded[],
currency: TPaymentCurrency,
trialing = false,
upsell = true,
{ exchangeRate = null }: { exchangeRate?: string | null } = {}
) {
if (
items.find((x) => {
const price = upsell ? x.upsell_price || x.price : x.price;
return price?.custom_unit_amount;
}) &&
items.length > 1
) {
throw new Error('Multiple items with custom unit amount are not supported');
}
let renew = new BN(0);
const total = items
.filter((x) => {
const price = upsell ? x.upsell_price || x.price : x.price;
return price != null;
})
.reduce((acc, x) => {
// For custom_amount, we need to check if it was created for the current currency
// custom_amount can be set from: 1) quoted_amount when a quote is applied, or 2) user input for custom pricing
// If quote_currency_id exists and doesn't match current currency, we should NOT use custom_amount
// because the quote was created for a different currency (e.g., ABT quote shouldn't be used for USD)
const quoteCurrencyId = (x as any)?.quote_currency_id;
const isQuoteForDifferentCurrency = quoteCurrencyId && quoteCurrencyId !== currency.id;
if (x.custom_amount && !isQuoteForDifferentCurrency) {
return acc.add(new BN(x.custom_amount));
}
const price = upsell ? x.upsell_price || x.price : x.price;
const { totalAmount } = getLineItemAmounts(x, currency, { useUpsell: upsell, exchangeRate });
if (price?.type === 'recurring') {
renew = renew.add(totalAmount);
if (trialing) {
return acc;
}
if (price.recurring?.usage_type === 'metered') {
return acc;
}
}
return acc.add(totalAmount);
}, new BN(0))
.toString();
return {
subtotal: total,
total,
renew: formatDynamicPrice(renew.toString(), !!exchangeRate),
discount: '0',
shipping: '0',
tax: '0',
};
}
export function getRecurringPeriod(recurring: PriceRecurring) {
const { interval } = recurring;
const count = +recurring.interval_count || 1;
const dayInMs = 24 * 60 * 60 * 1000;
switch (interval) {
case 'hour':
return 60 * 60 * 1000;
case 'day':
return count * dayInMs;
case 'week':
return count * 7 * dayInMs;
case 'month':
return count * 30 * dayInMs;
case 'year':
return count * 365 * dayInMs;
default:
throw new Error(`Unsupported recurring interval: ${interval}`);
}
}
export function formatUpsellSaving(items: TLineItemExpanded[], currency: TPaymentCurrency) {
if (items[0]?.upsell_price_id) {
return '0';
}
if (!items[0]?.price?.upsell?.upsells_to) {
return '0';
}
const from = getCheckoutAmount(items, currency, false, false);
const to = getCheckoutAmount(
items.map((x) => ({
...x,
upsell_price_id: x.price.upsell?.upsells_to_id,
upsell_price: x.price.upsell?.upsells_to,
})) as TLineItemExpanded[],
currency,
false,
true
);
const fromRecurring = items[0].price?.recurring as PriceRecurring;
const toRecurring = items[0].price?.upsell?.upsells_to?.recurring as PriceRecurring;
if (!fromRecurring || !toRecurring) {
return '0';
}
const factor = Math.floor(getRecurringPeriod(toRecurring) / getRecurringPeriod(fromRecurring));
const before = new BN(from.total).mul(new BN(factor));
const after = new BN(to.total);
return Number(before.sub(after).mul(new BN(100)).div(before).toString()).toFixed(0);
}
export function formatMeteredThen(
subscription: string,
recurring: string,
hasMetered: boolean,
locale: string = 'en'
): string {
if (hasMetered) {
return t('payment.checkout.meteredThen', locale, { subscription, recurring });
}
return t('payment.checkout.then', locale, { subscription, recurring });
}
export function formatThenValue(
subscription: string,
recurring: string,
hasMetered: boolean,
locale: string = 'en'
): string {
if (hasMetered) {
return t('payment.checkout.metered', locale, { recurring });
}
return [subscription, recurring].filter(Boolean).join(' ');
}
export function formatPriceDisplay(
{ amount, then, actualAmount, showThen }: { amount: string; then?: string; actualAmount: string; showThen?: boolean },
recurring: string,
hasMetered: boolean,
locale: string = 'en'
) {
if (Number(actualAmount) === 0 && hasMetered) {
return t('payment.checkout.metered', locale, { recurring });
}
if (showThen) {
return [amount, then].filter(Boolean).join(', ');
}
return [amount, then].filter(Boolean).join(' ');
}
export function hasMultipleRecurringIntervals(items: TLineItemExpanded[]): boolean {
const intervals = new Set<string>();
for (const item of items) {
if (item.price?.recurring?.interval && item.price?.type === 'recurring') {
intervals.add(`${item.price.recurring.interval}-${item.price.recurring.interval_count}`);
if (intervals.size > 1) {
return true;
}
}
}
return false;
}
export function getFreeTrialTime(
{ trialInDays, trialEnd }: { trialInDays: number; trialEnd: number },
locale: string = 'en'
): {
count: number;
interval: string;
} {
const now = dayjs().unix();
const plural = (singular: string, count: number) => t(`common.${count > 1 ? `${singular}s` : singular}`, locale);
if (trialEnd > 0 && trialEnd > now) {
if (trialEnd - now < 3600) {
const count = Math.ceil((trialEnd - now) / 60);
return { count, interval: plural('minute', count) };
}
if (trialEnd - now < 86400) {
const count = Math.ceil((trialEnd - now) / 3600);
return { count, interval: plural('hour', count) };
}
const count = Math.ceil((trialEnd - now) / 86400);
return { count, interval: plural('day', count) };
}
if (trialInDays > 0) {
return { count: trialInDays, interval: plural('day', trialInDays) };
}
return { count: 0, interval: t('common.day', locale) };
}
export function formatCheckoutHeadlines(
items: TLineItemExpanded[],
currency: TPaymentCurrency,
{ trialInDays, trialEnd }: { trialInDays: number; trialEnd: number },
locale: string = 'en',
{ exchangeRate = null }: { exchangeRate?: string | null } = {}
): {
action: string;
amount: string;
then?: string;
thenValue?: string;
secondary?: string;
showThen?: boolean;
actualAmount: string;
priceDisplay: string;
} {
const brand = getStatementDescriptor(items);
const { total } = getCheckoutAmount(items, currency, trialInDays > 0, true, { exchangeRate });
const actualAmount = fromUnitToToken(total, currency.decimal);
const amount = `${fromUnitToToken(total, currency.decimal)} ${currency.symbol}`;
const trialResult = getFreeTrialTime({ trialInDays, trialEnd }, locale);
// empty
if (items.length === 0) {
return {
action: t('payment.checkout.empty', locale),
amount: '0',
then: '',
actualAmount: '0',
priceDisplay: '0',
};
}
const { name } = items[0]?.price?.product || { name: '' };
// all one time
if (items.every((x) => x.price?.type === 'one_time')) {
const action = t('payment.checkout.pay', locale, { payee: brand });
if (items.length > 1) {
return { action, amount, actualAmount, priceDisplay: amount };
}
return { action, amount, then: '', actualAmount, priceDisplay: amount };
}
const item = items.find((x) => x.price?.type === 'recurring');
const recurring = formatRecurring(
(item?.upsell_price || item?.price)?.recurring as PriceRecurring,
false,
'per',
locale
);
const hasMetered = items.some((x) => x.price?.type === 'recurring' && x.price?.recurring?.usage_type === 'metered');
const differentRecurring = hasMultipleRecurringIntervals(items);
// all recurring
if (items.every((x) => x.price?.type === 'recurring')) {
// check if there has different recurring price
const subscription = [
hasMetered ? t('payment.checkout.least', locale) : '',
formatDynamicPrice(
fromUnitToToken(
items.reduce((acc, x) => {
const price = (x.upsell_price || x.price) as any;
if (price?.recurring?.usage_type === 'metered') {
return acc;
}
return acc.add(getLineItemAmounts(x, currency, { exchangeRate }).totalAmount);
}, new BN(0)),
currency.decimal
),
!!exchangeRate
),
currency.symbol,
]
.filter(Boolean)
.join(' ');
if (items.length > 1) {
if (trialResult.count > 0) {
const thenDisplay = formatMeteredThen(
subscription,
recurring,
hasMetered && Number(subscription) === 0,
locale
);
const thenValue = formatThenValue(subscription, recurring, hasMetered && Number(subscription) === 0, locale);
const result = {
action: t('payment.checkout.try2', locale, { name, count: items.length - 1 }),
amount: t('payment.checkout.free', locale, { count: trialResult.count, interval: trialResult.interval }),
then: thenDisplay,
thenValue,
showThen: true,
actualAmount: '0',
};
return {
...result,
priceDisplay: formatPriceDisplay(
{ amount: result.amount, then: thenDisplay, actualAmount: result.actualAmount, showThen: result.showThen },
recurring,
hasMetered,
locale
),
};
}
const thenDisplay = hasMetered ? t('payment.checkout.meteredThen', locale, { recurring }) : recurring;
const thenValue = hasMetered ? t('payment.checkout.metered', locale, { recurring }) : recurring;
const result = {
action: t('payment.checkout.sub2', locale, { name, count: items.length - 1 }),
amount,
then: thenDisplay,
thenValue,
showThen: hasMetered,
actualAmount,
};
return {
...result,
priceDisplay: formatPriceDisplay(
{ amount: result.amount, then: thenDisplay, actualAmount: result.actualAmount, showThen: result.showThen },
recurring,
hasMetered,
locale
),
};
}
if (trialResult.count > 0) {
const thenDisplay = formatMeteredThen(subscription, recurring, hasMetered && Number(subscription) === 0, locale);
const thenValue = formatThenValue(subscription, recurring, hasMetered && Number(subscription) === 0, locale);
const result = {
action: t('payment.checkout.try1', locale, { name }),
amount: t('payment.checkout.free', locale, { count: trialResult.count, interval: trialResult.interval }),
then: thenDisplay,
thenValue,
showThen: true,
actualAmount: '0',
};
return {
...result,
priceDisplay: formatPriceDisplay(
{ amount: result.amount, then: thenDisplay, actualAmount: result.actualAmount, showThen: result.showThen },
recurring,
hasMetered,
locale
),
};
}
const thenDisplay = hasMetered ? t('payment.checkout.meteredThen', locale, { recurring }) : recurring;
const thenValue = hasMetered ? t('payment.checkout.metered', locale, { recurring }) : recurring;
const result = {
action: t('payment.checkout.sub1', locale, { name }),
amount,
then: thenDisplay,
thenValue,
showThen: hasMetered && !differentRecurring,
actualAmount,
};
return {
...result,
priceDisplay: formatPriceDisplay(
{ amount: result.amount, then: thenDisplay, actualAmount: result.actualAmount, showThen: result.showThen },
recurring,
hasMetered,
locale
),
};
}
// mixed
const subscription = fromUnitToToken(
items
.filter((x) => x.price?.type === 'recurring')
.reduce((acc, x) => {
if (x.price?.recurring?.usage_type === 'metered') {
return acc;
}
return acc.add(getLineItemAmounts(x, currency, { useUpsell: false, exchangeRate }).totalAmount);
}, new BN(0)),
currency.decimal
);
const thenDisplay = formatMeteredThen(
`${subscription} ${currency.symbol}`,
recurring,
hasMetered && Number(subscription) === 0,
locale
);
const thenValue = formatThenValue(
`${subscription} ${currency.symbol}`,
recurring,
hasMetered && Number(subscription) === 0,
locale
);
const result = {
action: t('payment.checkout.pay', locale, { payee: brand }),
amount,
then: thenDisplay,
thenValue,
showThen: !differentRecurring,
actualAmount,
};
return {
...result,
priceDisplay: formatPriceDisplay(
{ amount: result.amount, then: thenDisplay, actualAmount: result.actualAmount, showThen: result.showThen },
recurring,
hasMetered,
locale
),
};
}
export function formatAmount(amount: string, decimals: number, precision: number = 2) {
const tokenAmount = fromUnitToToken(amount, decimals);
const numericValue = Number(tokenAmount);
if (!Number.isFinite(numericValue)) {
return formatBNStr(amount, decimals, precision, true, false);
}
const abs = Math.abs(numericValue);
const targetPrecision = abs > 0 && abs < 0.01 ? decimals : 2;
return formatNumber(tokenAmount, targetPrecision, true, false);
}
export function findCurrency(methods: TPaymentMethodExpanded[], currencyId: string): TPaymentCurrencyExpanded | null {
for (const method of methods) {
for (const currency of method.payment_currencies) {
if (currency.id === currencyId) {
return { object: 'payment_currency', ...currency, paymentMethod: omit(method, ['payment_currencies']) };
}
}
}
return null;
}
export function isValidCountry(code: string) {
return defaultCountries.some((x: any) => x[1] === code);
}
export function stopEvent(e: React.SyntheticEvent<any>) {
try {
e.stopPropagation();
e.preventDefault();
// eslint-disable-next-line no-empty
} catch {
// Do nothing
}
}
export function sleep(ms: number) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
export function formatSubscriptionProduct(items: TSubscriptionItemExpanded[], maxLength = 2) {
const names = items.map((x) => x.price?.product?.name).filter(Boolean);
return (
names.slice(0, maxLength).join(', ') + (names.length > maxLength ? ` and ${names.length - maxLength} more` : '')
);
}
export const getLineTimeInfo = (time: number, locale = 'en') => {
const isToday = dayjs().isSame(dayjs(time), 'day');
const timeFormat = isToday ? 'HH:mm:ss' : 'YYYY-MM-DD';
return {
time: formatToDate(time, locale, timeFormat),
isToday,
};
};
export const getSubscriptionTimeSummary = (subscription: TSubscriptionExpanded, locale = 'en') => {
if (subscription.status === 'active' || subscription.status === 'trialing') {
if (subscription.cancel_at) {
const endTime = subscription.cancel_at * 1000;
const { time, isToday } = getLineTimeInfo(endTime, locale);
return { action: endTime > Date.now() ? 'willEnd' : 'ended', time, isToday };
}
if (subscription.cancel_at_period_end) {
const endTime = subscription.current_period_end * 1000;
const { time, isToday } = getLineTimeInfo(endTime, locale);
return { action: 'willEnd', time, isToday };
}
const { time, isToday } = getLineTimeInfo(subscription.current_period_end * 1000, locale);
return { action: 'renew', time, isToday };
}
if (subscription.status === 'past_due') {
const endTime = (subscription.cancel_at || subscription.current_period_end) * 1000;
const { time, isToday } = getLineTimeInfo(endTime, locale);
return { action: endTime > Date.now() ? 'willEnd' : 'ended', time, isToday };
}
if (subscription.status === 'canceled') {
const { time, isToday } = getLineTimeInfo(subscription.canceled_at * 1000, locale);
return { action: 'ended', time, isToday };
}
return null;
};
export const getSubscriptionAction = (
subscription: TSubscriptionExpanded,
actionProps: ActionProps
): {
action: string;
variant: string;
color: string;
canRenew: boolean;
text?: string;
sx?: any;
} | null => {
if (subscription.status === 'active' || subscription.status === 'trialing') {
if (subscription.cancel_at_period_end) {
if (subscription.cancelation_details?.reason === 'payment_failed') {
return null;
}
return { action: 'recover', variant: 'contained', color: 'primary', canRenew: false, ...actionProps?.recover };
}
if (subscription.cancel_at && subscription.cancel_at !== subscription.current_period_end) {
return null;
}
return { action: 'cancel', variant: 'outlined', color: 'inherit', canRenew: false, ...actionProps?.cancel };
}
if (subscription.status === 'past_due') {
const canRenew = subscription.cancel_at && subscription.cancel_at !== subscription.current_period_end;
return { action: 'pastDue', variant: 'contained', color: 'primary', canRenew, ...actionProps?.pastDue };
}
if (subscription.status !== 'canceled' && subscription.cancel_at_period_end) {
return { action: 'recover', variant: 'contained', color: 'primary', canRenew: false, ...actionProps?.recover };
}
return null;
};
export const mergeExtraParams = (extra: Record<string, any> = {}) => {
const params = new URLSearchParams(window.location.search);
Object.keys(extra).forEach((key) => {
params.set(key, extra[key]);
});
return params.toString();
};
export const flattenPaymentMethods = (methods: TPaymentMethodExpanded[] = []): TPaymentCurrency[] => {
const out: TPaymentCurrency[] = [];
methods.forEach((method: any) => {
const currencies = method.paymentCurrencies || method.payment_currencies || [];
currencies.forEach((currency: any) => {
out.push({
...currency,
method,
});
});
});
return out;
};
export const getTxLink = (method: TPaymentMethod, details: PaymentDetails) => {
if (!details) {
return { text: 'N/A', link: '', gas: '' };
}
if (method.type === 'arcblock' && details.arcblock?.tx_hash) {
return {
link: joinURL(method.settings.arcblock?.explorer_host as string, '/txs', details.arcblock?.tx_hash as string),
text: details.arcblock?.tx_hash as string,
gas: '',
};
}
if (method.type === 'bitcoin' && details.bitcoin?.tx_hash) {
return {
link: joinURL(method.settings.bitcoin?.explorer_host as string, '/tx', details.bitcoin?.tx_hash as string),
text: details.bitcoin?.tx_hash as string,
gas: '',
};
}
if (method.type === 'ethereum' && details.ethereum?.tx_hash) {
return {
link: joinURL(method.settings.ethereum?.explorer_host as string, '/tx', details.ethereum?.tx_hash as string),
text: (details.ethereum?.tx_hash as string).toUpperCase(),
gas: new BN(details.ethereum.gas_price).mul(new BN(details.ethereum.gas_used)).toString(),
};
}
if (method.type === 'base' && details.base?.tx_hash) {
return {
link: joinURL(method.settings.base?.explorer_host as string, '/tx', details.base?.tx_hash as string),
text: (details.base?.tx_hash as string).toUpperCase(),
gas: '',
};
}
if (method.type === 'stripe') {
const dashboard = method.livemode ? 'https://dashboard.stripe.com' : 'https://dashboard.stripe.com/test';
return {
link: joinURL(
method.settings.stripe?.dashboard || dashboard,
'payments',
details.stripe?.payment_intent_id as string
),
text: details.stripe?.payment_intent_id as string,
gas: '',
};
}
return { text: 'N/A', link: '', gas: '' };
};
export function getQueryParams(url: string): Record<string, string> {
const queryParams: Record<string, string> = {};
const urlObj = new URL(url);
urlObj.searchParams.forEach((value, key) => {
queryParams[key] = value;
});
return queryParams;
}
export function lazyLoad(lazyRun: () => void) {
if ('requestIdleCallback' in window) {
(window as any).requestIdleCallback(() => {
lazyRun();
});
return;
}
if (document.readyState === 'complete') {
lazyRun();
return;
}
if ('onload' in window) {
(window as any).onload = () => {
lazyRun();
};
return;
}
setTimeout(() => {
lazyRun();
}, 0);
}
export function formatTotalPrice({
product,
quantity = 1,
priceId,
locale = 'en',
}: {
product: TProductExpanded;
quantity: number;
priceId?: string;
locale: string;
}): PricingRenderProps {
const { prices = [], default_price_id: defaultPriceId } = product ?? { prices: [], default_price_id: '' };
const price = prices?.find((x) => x.id === (priceId || defaultPriceId));
if (!price || !product) {
return {
totalPrice: '0',
unitPrice: '',
quantity: t('common.qty', locale, { count: quantity }),
totalAmount: '0',
};
}
const currency: TPaymentCurrency = price?.currency ?? {};
const unitValue = new BN(getPriceUintAmountByCurrency(price, currency));
const total = `${fromUnitToToken(unitValue.mul(new BN(quantity)), currency.decimal)} ${currency.symbol} `;
const unit = `${fromUnitToToken(unitValue, currency.decimal)} ${currency.symbol} `;
const appendUnit = (v: string, alt: string) => {
if (product.unit_label) {
return `${v}/${price.product.unit_label}`;
}
if (price.recurring?.usage_type === 'metered' || quantity === 1) {
return alt;
}
return quantity ? t('common.each', locale, { unit }) : '';
};
return {
totalPrice: total,
unitPrice: appendUnit(total, ''),
quantity: t('common.qty', locale, { count: quantity }),
totalAmount: unitValue.mul(new BN(quantity)).toString(),
};
}
export function formatQuantityInventory(price: TPrice, quantity: string | number, locale = 'en') {
const q = Number(quantity);
const {
quantity_available: quantityAvailable = 0,
quantity_sold: quantitySold = 0,
quantity_limit_per_checkout: quantityLimitPerCheckout = 0,
} = price || {};
if (quantityAvailable > 0 && quantitySold + q > quantityAvailable) {
return t('common.quantityNotEnough', locale);
}
if (quantityLimitPerCheckout > 0 && quantityLimitPerCheckout < q) {
return t('common.quantityLimitPerCheckout', locale);
}
return '';
}
export function formatSubscriptionStatus(status: string) {
if (status === 'canceled') {
return 'Ended';
}
return status;
}
export function formatAmountPrecisionLimit(amount: string, locale = 'en', precision: number = 6) {
if (!amount) {
return '';
}
const [, decimal] = String(amount).split('.');
if (decimal && decimal.length > precision) {
return t('common.amountPrecisionLimit', locale, { precision });
}
return '';
}
export function getWordBreakStyle(value: any): 'break-word' | 'break-all' {
if (typeof value === 'string' && /\s/.test(value)) {
return 'break-word';
}
return 'break-all';
}
export function isMobileSafari() {
const ua = navigator.userAgent.toLowerCase();
const isSafari = ua.indexOf('safari') > -1 && ua.indexOf('chrome') === -1;
const isMobile = ua.indexOf('mobile') > -1 || /iphone|ipad|ipod/.test(ua);
const isIOS = /iphone|ipad|ipod/.test(ua);
return isSafari && isMobile && isIOS;
}
export function truncateText(text: string, maxLength: number, useWidth: boolean = false): string {
if (!text || !maxLength) {
return text;
}
if (!useWidth)