@blocklet/payment-react
Version:
Reusable react components for payment kit v2
1,356 lines (1,204 loc) • 40.4 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 } 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);
}
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 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})`;
}
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
) => {
if (!currency) {
return '';
}
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,
},
];
}
export function formatLineItemPricing(
item: TLineItemExpanded,
currency: TPaymentCurrency,
{ trialEnd, trialInDays }: { trialEnd: number; trialInDays: number },
locale: string = 'en'
): { primary: string; secondary?: string; quantity: string } {
if (!currency) {
return { primary: '', secondary: '', quantity: '' };
}
const price = item.upsell_price || item.price;
let quantity = t('common.qty', locale, { count: item.quantity });
if (price.recurring?.usage_type === 'metered' || +item.quantity === 1) {
quantity = '';
}
const unitValue = new BN(getPriceUintAmountByCurrency(price, currency));
const total = `${fromUnitToToken(unitValue.mul(new BN(item.quantity)), currency.decimal)} ${currency.symbol}`;
const unit = `${fromUnitToToken(unitValue, currency.decimal)} ${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
) {
if (items.find((x) => (x.upsell_price || x.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) => {
if (x.custom_amount) {
return acc.add(new BN(x.custom_amount));
}
const price = upsell ? x.upsell_price || x.price : x.price;
const unitPrice = getPriceUintAmountByCurrency(price, currency);
if (price.custom_unit_amount) {
if (unitPrice) {
return acc.add(new BN(unitPrice).mul(new BN(x.quantity)));
}
}
if (price?.type === 'recurring') {
renew = renew.add(new BN(unitPrice).mul(new BN(x.quantity)));
if (trialing) {
return acc;
}
if (price.recurring?.usage_type === 'metered') {
return acc;
}
}
return acc.add(new BN(unitPrice).mul(new BN(x.quantity)));
}, new BN(0))
.toString();
return { subtotal: total, total, renew: renew.toString(), 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 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();
if (trialEnd > 0 && trialEnd > now) {
if (trialEnd - now < 3600) {
return {
count: Math.ceil((trialEnd - now) / 60),
interval: t('common.minute', locale),
};
}
if (trialEnd - now < 86400) {
return {
count: Math.ceil((trialEnd - now) / 3600),
interval: t('common.hour', locale),
};
}
return {
count: Math.ceil((trialEnd - now) / 86400),
interval: t('common.day', locale),
};
}
if (trialInDays > 0) {
return {
count: trialInDays,
interval: t('common.day', locale),
};
}
return {
count: 0,
interval: t('common.day', locale),
};
}
export function formatCheckoutHeadlines(
items: TLineItemExpanded[],
currency: TPaymentCurrency,
{ trialInDays, trialEnd }: { trialInDays: number; trialEnd: number },
locale: string = 'en'
): {
action: string;
amount: string;
then?: string;
secondary?: string;
showThen?: boolean;
actualAmount: string;
priceDisplay: string;
} {
const brand = getStatementDescriptor(items);
const { total } = getCheckoutAmount(items, currency, trialInDays > 0);
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) : '',
fromUnitToToken(
items.reduce((acc, x) => {
if (x.price.recurring?.usage_type === 'metered') {
return acc;
}
return acc.add(
new BN(getPriceUintAmountByCurrency(x.upsell_price || x.price, currency)).mul(new BN(x.quantity))
);
}, new BN(0)),
currency.decimal
),
currency.symbol,
]
.filter(Boolean)
.join(' ');
if (items.length > 1) {
if (trialResult.count > 0) {
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: formatMeteredThen(subscription, recurring, hasMetered && Number(subscription) === 0, locale),
showThen: true,
actualAmount: '0',
};
return {
...result,
priceDisplay: formatPriceDisplay(result, recurring, hasMetered, locale),
};
}
const result = {
action: t('payment.checkout.sub2', locale, { name, count: items.length - 1 }),
amount,
then: hasMetered ? t('payment.checkout.meteredThen', locale, { recurring }) : recurring,
showThen: hasMetered,
actualAmount,
};
return {
...result,
priceDisplay: formatPriceDisplay(result, recurring, hasMetered, locale),
};
}
if (trialResult.count > 0) {
const result = {
action: t('payment.checkout.try1', locale, { name }),
amount: t('payment.checkout.free', locale, { count: trialResult.count, interval: trialResult.interval }),
then: formatMeteredThen(subscription, recurring, hasMetered && Number(subscription) === 0, locale),
showThen: true,
actualAmount: '0',
};
return {
...result,
priceDisplay: formatPriceDisplay(result, recurring, hasMetered, locale),
};
}
const result = {
action: t('payment.checkout.sub1', locale, { name }),
amount,
then: hasMetered ? t('payment.checkout.meteredThen', locale, { recurring }) : recurring,
showThen: hasMetered && !differentRecurring,
actualAmount,
};
return {
...result,
priceDisplay: formatPriceDisplay(result, 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(new BN(getPriceUintAmountByCurrency(x.price, currency)).mul(new BN(x.quantity)));
}, new BN(0)),
currency.decimal
);
const result = {
action: t('payment.checkout.pay', locale, { payee: brand }),
amount,
then: formatMeteredThen(
`${subscription} ${currency.symbol}`,
recurring,
hasMetered && Number(subscription) === 0,
locale
),
showThen: !differentRecurring,
actualAmount,
};
return {
...result,
priceDisplay: formatPriceDisplay(result, recurring, hasMetered, locale),
};
}
export function formatAmount(amount: string, decimals: number) {
return fromUnitToToken(amount, decimals);
}
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) {
if (text.length <= maxLength) {
return text;
}
return `${text.substring(0, maxLength)}...`;
}
let width = 0;
let truncated = '';
for (let i = 0; i < text.length; i++) {
const charWidth = stringWidth(text.charAt(i));
if (width + charWidth > maxLength) {
break;
}
truncated += text.charAt(i);
width += charWidth;
}
if (truncated === text) {
return truncated;
}
return `${truncated}...`;
}
export function getCustomerAvatar(
did: string | undefined,
updated_at: string | number | undefined,
imageSize: number = 48
): string {
const updated = typeof updated_at === 'number' ? updated_at : dayjs(updated_at).unix();
return `/.well-known/service/user/avatar/${did}?imageFilter=resize&w=${imageSize}&h=${imageSize}&updateAt=${updated || dayjs().unix()}`;
}
// 判断是否存在txHash
export function hasDelegateTxHash(details: PaymentDetails, paymentMethod: TPaymentMethod) {
return (
paymentMethod?.type &&
['arcblock', 'ethereum', 'base'].includes(paymentMethod?.type) &&
// @ts-ignore
details?.[paymentMethod?.type]?.tx_hash
);
}
export function getInvoiceDescriptionAndReason(invoice: TInvoiceExpanded, locale = 'en') {
const { billing_reason: reason, description } = invoice;
const reasonMap = {
subscription_create: t('payment.invoice.reason.creation', locale),
subscription_cycle: t('payment.invoice.reason.cycle', locale),
subscription_update: t('payment.invoice.reason.update', locale),
subscription_recover: t('payment.invoice.reason.recover', locale),
subscription_threshold: t('payment.invoice.reason.threshold', locale),
subscription_cancel: t('payment.invoice.reason.cancel', locale),
manual: t('payment.invoice.reason.manual', locale),
upcoming: t('payment.invoice.reason.upcoming', locale),
slash_stake: t('payment.invoice.reason.slashStake', locale),
stake: t('payment.invoice.reason.stake', locale),
return_stake: t('payment.invoice.reason.returnStake', locale),
recharge: t('payment.invoice.reason.recharge', locale),
stake_overdraft_protection: t('payment.invoice.reason.stake', locale),
overdraft_protection: t('payment.invoice.reason.fee', locale),
auto_recharge: t('payment.invoice.reason.recharge', locale),
};
let invoiceType = t('payment.invoice.reason.payment', locale);
if (reason.includes('stake') || reason.includes('recharge') || reason === 'overdraft_protection') {
invoiceType = reasonMap[reason as keyof typeof reasonMap];
}
if (description?.startsWith('Subscription ') || description?.startsWith('Slash stake')) {
return {
description: reasonMap[reason as keyof typeof reasonMap],
reason: reasonMap[reason as keyof typeof reasonMap],
type: invoiceType,
};
}
const descMap = {
'Stake for subscription plan change': t('payment.invoice.reason.stakeForChangePlan', locale),
'Stake for subscription payment change': t('payment.invoice.reason.stakeForChangePayment', locale),
'Stake for subscription': t('payment.invoice.reason.staking', locale),
'Return Subscription staking': t('payment.invoice.reason.returnStake', locale),
'Recharge for subscription': t('payment.invoice.reason.rechargeForSubscription', locale),
'Add funds for subscription': t('payment.invoice.reason.rechargeForSubscription', locale),
'Overdraft protection': t('payment.invoice.reason.overdraftProtection', locale),
'Stake for subscription overdraft protection': t(
'payment.invoice.reason.stakeForSubscriptionOverdraftProtection',
locale
),
'Re-stake to resume subscription': t('payment.invoice.reason.reStakeToResumeSubscription', locale),
};
return {
description: descMap[description as keyof typeof descMap] || description,
reason: reasonMap[reason as keyof typeof reasonMap] || reason,
type: invoiceType,
};
}
export function getPaymentKitComponent() {
const paymentKit = window.blocklet?.componentMountPoints?.find((c: any) => c.did === PAYMENT_KIT_DID);
return paymentKit || null;
}
export function openDonationSettings(openInNewTab: boolean = false) {
const paymentKit = getPaymentKitComponent();
if (paymentKit) {
const mountPoint = paymentKit.mountPoint.endsWith('/') ? paymentKit.mountPoint.slice(0, -1) : paymentKit.mountPoint;
window.open(`${window.location.origin}${mountPoint}/integrations/donations`, openInNewTab ? '_blank' : '_self');
}
}
export function getUserProfileLink(userDid: string, locale = 'en') {
return joinURL(
window.location.origin,
withQuery('.well-known/service/user', {
locale,
did: userDid,
})
);
}
export function parseMarkedText(text: string): Array<{
type: 'text' | 'marked';
content: string;
}> {
if (!text) return [];
const parts = text.split(/(#([^#]*)#)/);
const result: { type: 'text' | 'marked'; content: string }[] = [];
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
// eslint-disable-next-line no-continue
if (!part) continue;
if (i % 3 === 0) {
result.push({ type: 'text', content: part });
} else if (i % 3 === 1 && part.startsWith('#') && part.endsWith('#')) {
const content = part.slice(1, -1);
if (content.length >= 0) {
result.push({ type: 'marked', content });
}
}
}
return result.filter((p) => p.content !== '');
}
export function getTokenBalanceLink(method: TPaymentMethod, address: string) {
if (!method || !address) {
return '';
}
const explorerHost = (method?.settings?.[method?.type as ChainType] as any)?.explorer_host || '';
if (method.type === 'arcblock' && address) {
return joinURL(explorerHost, 'accounts', address, 'tokens');
}
if (['ethereum', 'base'].includes(method.type) && address) {
return joinURL(explorerHost, 'address', address);
}
return '';
}
export function isCreditMetered(price: TPrice): boolean {
return !!(price.type === 'recurring' && price.recurring?.usage_type === 'metered' && price.recurring?.meter_id);
}
export function showStaking(method: TPaymentMethod, currency: TPaymentCurrency, noStake: boolean) {
if (noStake) {
return false;
}
if (method.type === 'arcblock') {
return currency.type !== 'credit';
}
return false;
}
export function formatLinkWithLocale(url: string, locale?: string) {
if (!locale || !url) {
return url;
}
try {
const urlObj = new URL(url);
urlObj.searchParams.set('locale', locale);
return urlObj.toString();
} catch (error) {
if (/[?&]locale=[^&]*/.test(url)) {
return url.replace(/([?&])locale=[^&]*/, `$1locale=${locale}`);
}
const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}locale=${locale}`;
}
}