UNPKG

@blocklet/payment-react

Version:

Reusable react components for payment kit v2

1,368 lines (1,367 loc) 48 kB
import { BN, fromUnitToToken, fromTokenToUnit } from "@ocap/util"; import omit from "lodash/omit"; import trimEnd from "lodash/trimEnd"; import numbro from "numbro"; import stringWidth from "string-width"; import { defaultCountries } from "react-international-phone"; import { joinURL, withQuery } from "ufo"; import { t } from "../locales/index.js"; import dayjs from "./dayjs.js"; export const PAYMENT_KIT_DID = "z2qaCNvKMv5GjouKdcDWexv6WqtHbpNPQDnAk"; export const formatCouponTerms = (coupon, currency, locale = "en") => { 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) => x.did === PAYMENT_KIT_DID); }; export const getPrefix = () => { const prefix = window.blocklet?.prefix || "/"; const baseUrl = window.location?.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) => 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, locale = "en", format = "YYYY-MM-DD HH:mm:ss") { if (!date) { return "-"; } return dayjs(date).locale(formatLocale(locale)).format(format); } export function formatToDatetime(date, locale = "en") { if (!date) { return "-"; } return dayjs(date).locale(formatLocale(locale)).format("lll"); } export function formatTime(date, format = "YYYY-MM-DD HH:mm:ss", locale = "en") { if (!date) { return "-"; } return dayjs(date).locale(formatLocale(locale)).format(format); } export function formatDateTime(date, 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) => locale === "zh" ? "zh_CN" : "en_US"; export const formatError = (err) => { if (!err) { return "Unknown error"; } const { details, errors, response } = err; if (Array.isArray(errors)) { return errors.map((x) => x.message).join("\n"); } 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(";")}`; } if (response) { return response.data?.error || `${err.message}: ${JSON.stringify(response.data)}`; } return err.message; }; export function formatBNStr(str = "", decimals = 18, precision = 6, trim = true, thousandSeparated = true) { if (!str) { return "0"; } return formatNumber(fromUnitToToken(str, decimals), precision, trim, thousandSeparated); } const CURRENCY_SYMBOLS = { USD: "$", EUR: "\u20AC", GBP: "\xA3", JPY: "\xA5", CNY: "\xA5", KRW: "\u20A9", INR: "\u20B9", AUD: "A$", CAD: "C$", CHF: "Fr", HKD: "HK$", SGD: "S$", NZD: "NZ$" }; export function formatCreditAmount(formattedAmount, currencySymbol, showUnit = true) { const mappedSymbol = CURRENCY_SYMBOLS[currencySymbol]; if (mappedSymbol) { return `${mappedSymbol}${formattedAmount}`; } return `${formattedAmount} ${showUnit ? currencySymbol : ""}`; } export function formatCreditForCheckout(formattedAmount, currencySymbol, locale = "en", showUnit = true) { const mappedSymbol = CURRENCY_SYMBOLS[currencySymbol]; if (mappedSymbol) { return `${mappedSymbol}${formattedAmount} ${showUnit ? t("common.credits", locale) : ""}`; } return `${formattedAmount} ${showUnit ? currencySymbol : ""}`; } export function formatNumber(n, precision = 6, trim = true, thousandSeparated = 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, isDynamic, precision = 6, trim = true, thousandSeparated = 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, quantity, scale = BASE_AMOUNT_SCALE) { if (amount === void 0 || amount === null || !quantity || quantity <= 0) { return null; } const scaled = toScaledBaseAmount(amount, quantity, scale); return formatScaledAmount(scaled, scale); } export function getUsdAmountFromTokenUnits(tokenAmount, tokenDecimals, exchangeRate) { if (!exchangeRate && exchangeRate !== "0") { return null; } if (tokenDecimals === void 0 || tokenDecimals === null) { return null; } try { const amountBN = tokenAmount instanceof BN ? tokenAmount : new BN(tokenAmount || "0"); const tokenScale = new BN(10).pow(new BN(tokenDecimals)); if (tokenScale.isZero()) { return null; } 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)); const usdPrecisionScale = new BN(10).pow(new BN(USD_DECIMALS)); const usdUnit = amountBN.mul(rateBN).mul(usdPrecisionScale).div(tokenScale.mul(rateScale)); return fromUnitToToken(usdUnit.toString(), USD_DECIMALS); } catch { return null; } } export function formatUsdAmount(amount, locale = "en") { 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) { if (!amount && amount !== "0") { return null; } const value = String(amount); const num = Number(value); if (!Number.isFinite(num)) { return null; } return value; } export function formatExchangeRateDisplay(rate, currency = "USD", decimals = 2) { if (rate === null || rate === void 0) { 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, currency, unit_label, quantity = 1, bn = true, locale = "en") => { if (!currency) { return ""; } if (price.custom_unit_amount) { return `Custom (${currency.symbol})`; } if (price.pricing_type === "dynamic" && price.base_amount && price.base_currency === "USD") { const baseAmount = price.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}`; } 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, currency, unit_label, quantity = 1, bn = true, locale = "en") => { if (!currency) { return ""; } if (price.pricing_type === "dynamic" && price.base_amount && price.base_currency === "USD") { const baseAmount = price.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}`; } 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) { 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, translate = true, separator = "per", locale = "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); return translate ? t(`common.${intervals[recurring.interval]}`, locale) : separator ? t(`common.${separator}`, locale, { interval }) : interval; } 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, currency) { 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) { 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, 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, quantity, 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, currency, { useUpsell = true, exchangeRate = null } = {}) { if (!currency) { return { unitAmount: new BN(0), totalAmount: new BN(0), isDynamicQuote: false }; } const price = useUpsell ? item.upsell_price || item.price : item.price; const quantity = new BN(item.quantity || 0); const quoteAmount = item?.quoted_amount; const quoteCurrencyId = item?.quote_currency_id; const isDynamicPrice = price?.pricing_type === "dynamic"; const isQuoteValidForCurrency = isDynamicPrice && quoteAmount && quoteCurrencyId === currency.id; if (isQuoteValidForCurrency) { const totalAmount = new BN(quoteAmount || "0"); const unitAmount2 = quantity.gt(new BN(0)) ? totalAmount.add(quantity).sub(new BN(1)).div(quantity) : new BN(0); return { unitAmount: unitAmount2, totalAmount, isDynamicQuote: true }; } if (isDynamicPrice && exchangeRate && price?.base_amount) { const rate = Number(exchangeRate); if (rate > 0 && Number.isFinite(rate)) { const baseAmountUsd = Number(price.base_amount); if (baseAmountUsd > 0 && Number.isFinite(baseAmountUsd)) { const tokenAmount = baseAmountUsd / rate; const unitAmount2 = fromTokenToUnit(tokenAmount.toFixed(currency.decimal || 8), currency.decimal || 8); return { unitAmount: unitAmount2, totalAmount: unitAmount2.mul(quantity), isDynamicQuote: false }; } } } const unitAmount = new BN(getPriceUintAmountByCurrency(price, currency)); return { unitAmount, totalAmount: unitAmount.mul(quantity), isDynamicQuote: false }; } export function getQuoteLockInfo(items, currency) { if (!items?.length || !currency) { return null; } const dynamicItems = items.filter((item) => { const price = item.upsell_price || item.price; return price?.pricing_type === "dynamic" && item?.quoted_amount; }); if (!dynamicItems.length) { return null; } let totalBaseAmount = new BN(0); let totalTokenAmount = new BN(0); let expiresAt = null; dynamicItems.forEach((item) => { const price = item.upsell_price || item.price; const quoteAmount = new BN(item?.quoted_amount || "0"); totalTokenAmount = totalTokenAmount.add(quoteAmount); const baseAmount = price?.base_amount; if (baseAmount) { totalBaseAmount = totalBaseAmount.add(toScaledBaseAmount(baseAmount, item.quantity)); } if (item?.expires_at) { expiresAt = expiresAt === null ? item?.expires_at : Math.min(expiresAt, item?.expires_at); } }); return { baseAmount: formatScaledAmount(totalBaseAmount, BASE_AMOUNT_SCALE), baseCurrency: (dynamicItems[0]?.upsell_price || dynamicItems[0]?.price)?.base_currency || "USD", tokenAmount: fromUnitToToken(totalTokenAmount, currency.decimal).toString(), tokenSymbol: currency.symbol, expiresAt }; } export function formatLineItemPricing(item, currency, { trialEnd, trialInDays, exchangeRate = null }, locale = "en") { 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?.pricing_type === "dynamic"; const formatLineItemAmount = (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, alt) => { 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) { 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) { 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) { switch (status) { case "succeeded": return "success"; case "requires_action": return "warning"; case "canceled": case "pending": default: return "default"; } } export function getPayoutStatusColor(status) { 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) { 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) { switch (status) { case "enabled": return "success"; case "disabled": default: return "default"; } } export function getCheckoutAmount(items, currency, trialing = false, upsell = true, { exchangeRate = 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) => { const quoteCurrencyId = x?.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) { const { interval } = recurring; const count = +recurring.interval_count || 1; const dayInMs = 24 * 60 * 60 * 1e3; switch (interval) { case "hour": return 60 * 60 * 1e3; 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, currency) { 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 })), currency, false, true ); const fromRecurring = items[0].price?.recurring; const toRecurring = items[0].price?.upsell?.upsells_to?.recurring; 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, recurring, hasMetered, locale = "en") { if (hasMetered) { return t("payment.checkout.meteredThen", locale, { subscription, recurring }); } return t("payment.checkout.then", locale, { subscription, recurring }); } export function formatThenValue(subscription, recurring, hasMetered, locale = "en") { if (hasMetered) { return t("payment.checkout.metered", locale, { recurring }); } return [subscription, recurring].filter(Boolean).join(" "); } export function formatPriceDisplay({ amount, then, actualAmount, showThen }, recurring, hasMetered, locale = "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) { const intervals = /* @__PURE__ */ new Set(); 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 }, locale = "en") { const now = dayjs().unix(); const plural = (singular, count) => t(`common.${count > 1 ? `${singular}s` : singular}`, locale); if (trialEnd > 0 && trialEnd > now) { if (trialEnd - now < 3600) { const count2 = Math.ceil((trialEnd - now) / 60); return { count: count2, interval: plural("minute", count2) }; } if (trialEnd - now < 86400) { const count2 = Math.ceil((trialEnd - now) / 3600); return { count: count2, interval: plural("hour", count2) }; } 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, currency, { trialInDays, trialEnd }, locale = "en", { exchangeRate = null } = {}) { 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); 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: "" }; 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, false, "per", locale ); const hasMetered = items.some((x) => x.price?.type === "recurring" && x.price?.recurring?.usage_type === "metered"); const differentRecurring = hasMultipleRecurringIntervals(items); if (items.every((x) => x.price?.type === "recurring")) { const subscription2 = [ hasMetered ? t("payment.checkout.least", locale) : "", formatDynamicPrice( fromUnitToToken( items.reduce((acc, x) => { const price = x.upsell_price || x.price; 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 thenDisplay4 = formatMeteredThen( subscription2, recurring, hasMetered && Number(subscription2) === 0, locale ); const thenValue4 = formatThenValue(subscription2, recurring, hasMetered && Number(subscription2) === 0, locale); const result4 = { action: t("payment.checkout.try2", locale, { name, count: items.length - 1 }), amount: t("payment.checkout.free", locale, { count: trialResult.count, interval: trialResult.interval }), then: thenDisplay4, thenValue: thenValue4, showThen: true, actualAmount: "0" }; return { ...result4, priceDisplay: formatPriceDisplay( { amount: result4.amount, then: thenDisplay4, actualAmount: result4.actualAmount, showThen: result4.showThen }, recurring, hasMetered, locale ) }; } const thenDisplay3 = hasMetered ? t("payment.checkout.meteredThen", locale, { recurring }) : recurring; const thenValue3 = hasMetered ? t("payment.checkout.metered", locale, { recurring }) : recurring; const result3 = { action: t("payment.checkout.sub2", locale, { name, count: items.length - 1 }), amount, then: thenDisplay3, thenValue: thenValue3, showThen: hasMetered, actualAmount }; return { ...result3, priceDisplay: formatPriceDisplay( { amount: result3.amount, then: thenDisplay3, actualAmount: result3.actualAmount, showThen: result3.showThen }, recurring, hasMetered, locale ) }; } if (trialResult.count > 0) { const thenDisplay3 = formatMeteredThen(subscription2, recurring, hasMetered && Number(subscription2) === 0, locale); const thenValue3 = formatThenValue(subscription2, recurring, hasMetered && Number(subscription2) === 0, locale); const result3 = { action: t("payment.checkout.try1", locale, { name }), amount: t("payment.checkout.free", locale, { count: trialResult.count, interval: trialResult.interval }), then: thenDisplay3, thenValue: thenValue3, showThen: true, actualAmount: "0" }; return { ...result3, priceDisplay: formatPriceDisplay( { amount: result3.amount, then: thenDisplay3, actualAmount: result3.actualAmount, showThen: result3.showThen }, recurring, hasMetered, locale ) }; } const thenDisplay2 = hasMetered ? t("payment.checkout.meteredThen", locale, { recurring }) : recurring; const thenValue2 = hasMetered ? t("payment.checkout.metered", locale, { recurring }) : recurring; const result2 = { action: t("payment.checkout.sub1", locale, { name }), amount, then: thenDisplay2, thenValue: thenValue2, showThen: hasMetered && !differentRecurring, actualAmount }; return { ...result2, priceDisplay: formatPriceDisplay( { amount: result2.amount, then: thenDisplay2, actualAmount: result2.actualAmount, showThen: result2.showThen }, recurring, hasMetered, locale ) }; } 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, decimals, precision = 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, currencyId) { 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) { return defaultCountries.some((x) => x[1] === code); } export function stopEvent(e) { try { e.stopPropagation(); e.preventDefault(); } catch { } } export function sleep(ms) { return new Promise((resolve) => { setTimeout(resolve, ms); }); } export function formatSubscriptionProduct(items, 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, 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, locale = "en") => { if (subscription.status === "active" || subscription.status === "trialing") { if (subscription.cancel_at) { const endTime = subscription.cancel_at * 1e3; const { time: time2, isToday: isToday2 } = getLineTimeInfo(endTime, locale); return { action: endTime > Date.now() ? "willEnd" : "ended", time: time2, isToday: isToday2 }; } if (subscription.cancel_at_period_end) { const endTime = subscription.current_period_end * 1e3; const { time: time2, isToday: isToday2 } = getLineTimeInfo(endTime, locale); return { action: "willEnd", time: time2, isToday: isToday2 }; } const { time, isToday } = getLineTimeInfo(subscription.current_period_end * 1e3, locale); return { action: "renew", time, isToday }; } if (subscription.status === "past_due") { const endTime = (subscription.cancel_at || subscription.current_period_end) * 1e3; 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 * 1e3, locale); return { action: "ended", time, isToday }; } return null; }; export const getSubscriptionAction = (subscription, actionProps) => { 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 = {}) => { const params = new URLSearchParams(window.location.search); Object.keys(extra).forEach((key) => { params.set(key, extra[key]); }); return params.toString(); }; export const flattenPaymentMethods = (methods = []) => { const out = []; methods.forEach((method) => { const currencies = method.paymentCurrencies || method.payment_currencies || []; currencies.forEach((currency) => { out.push({ ...currency, method }); }); }); return out; }; export const getTxLink = (method, details) => { if (!details) { return { text: "N/A", link: "", gas: "" }; } if (method.type === "arcblock" && details.arcblock?.tx_hash) { return { link: joinURL(method.settings.arcblock?.explorer_host, "/txs", details.arcblock?.tx_hash), text: details.arcblock?.tx_hash, gas: "" }; } if (method.type === "bitcoin" && details.bitcoin?.tx_hash) { return { link: joinURL(method.settings.bitcoin?.explorer_host, "/tx", details.bitcoin?.tx_hash), text: details.bitcoin?.tx_hash, gas: "" }; } if (method.type === "ethereum" && details.ethereum?.tx_hash) { return { link: joinURL(method.settings.ethereum?.explorer_host, "/tx", details.ethereum?.tx_hash), text: (details.ethereum?.tx_hash).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, "/tx", details.base?.tx_hash), text: (details.base?.tx_hash).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 ), text: details.stripe?.payment_intent_id, gas: "" }; } return { text: "N/A", link: "", gas: "" }; }; export function getQueryParams(url) { const queryParams = {}; const urlObj = new URL(url); urlObj.searchParams.forEach((value, key) => { queryParams[key] = value; }); return queryParams; } export function lazyLoad(lazyRun) { if ("requestIdleCallback" in window) { window.requestIdleCallback(() => { lazyRun(); }); return; } if (document.readyState === "complete") { lazyRun(); return; } if ("onload" in window) { window.onload = () => { lazyRun(); }; return; } setTimeout(() => { lazyRun(); }, 0); } export function formatTotalPrice({ product, quantity = 1, priceId, locale = "en" }) { 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 = 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, alt) => { 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, quantity, 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) { if (status === "canceled") { return "Ended"; } return status; } export function formatAmountPrecisionLimit(amount, locale = "en", precision = 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) { 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, maxLength, useWidth = false) { 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, updated_at, imageSize = 48) { if (!did) return ""; 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()}`; } export function hasDelegateTxHash(details, paymentMethod) { return paymentMethod?.type && ["arcblock", "ethereum", "base"].includes(paymentMethod?.type) && // @ts-ignore details?.[paymentMethod?.type]?.tx_hash; } export function getInvoiceDescriptionAndReason(invoice, 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]; } if (description?.startsWith("Subscription ") || description?.startsWith("Slash stake")) { return { description: reasonMap[reason], reason: reasonMap[reason], 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] || description, reason: reasonMap[reason] || reason, type: invoiceType }; } export function getPaymentKitComponent() { const paymentKit = window.blocklet?.componentMountPoints?.find((c) => c.did === PAYMENT_KIT_DID); return paymentKit || null; } export function openDonationSettings(openInNewTab = 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, locale = "en") { return joinURL( window.location.origin, withQuery(".well-known/service/user", { locale, did: userDid }) ); } export function parseMarkedText(text) { if (!text) return []; const parts = text.split(/(#([^#]*)#)/); const result = []; for (let i = 0; i < parts.length; i++) { const part = parts[i]; 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, address) { if (!method || !address) { return ""; } const explorerHost = method?.settings?.[method?.type]?.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) { return !!(price.type === "recurring" && price.recurring?.usage_type === "metered" && price.recurring?.meter_id); } export function showStaking(method, currency, noStake) { if (noStake) { return false; } if (method && method.type === "arcblock") { return currency.type !== "credit"; } return false; } export function formatLinkWithLocale(url, locale) { 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}`; } } export function primaryContrastColor(theme) { return theme.palette.getContrastText(theme.palette.primary.main); }