UNPKG

@blocklet/payment-react

Version:

Reusable react components for payment kit v2

1,356 lines (1,204 loc) 40.4 kB
/* 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}`; } }