@blocklet/payment-react
Version:
Reusable react components for payment kit v2
213 lines (192 loc) β’ 7.09 kB
text/typescript
import { fromUnitToToken } from '@ocap/util';
import type { TPaymentCurrency } from '@blocklet/payment-types';
export { primaryContrastColor } from '../../libs/util';
// Interval key β locale key mapping
export const INTERVAL_LOCALE_KEY: Record<string, string> = {
day: 'common.daily',
week: 'common.weekly',
month: 'common.monthly',
year: 'common.yearly',
};
// Convert ISO2 country code to flag emoji (e.g., "us" β "πΊπΈ")
export function countryCodeToFlag(code: string): string {
if (!code || code.length !== 2) return '';
return String.fromCodePoint(
...code
.toUpperCase()
.split('')
.map((c) => 127397 + c.charCodeAt(0))
);
}
export function formatTokenAmount(unitAmount: string | number | bigint, currency: TPaymentCurrency | null): string {
if (!unitAmount || !currency) return '0';
try {
const tokenAmount = fromUnitToToken(String(unitAmount), currency.decimal || 0);
const num = Number(tokenAmount);
if (!Number.isFinite(num)) return '0';
const abs = Math.abs(num);
const precision = abs > 0 && abs < 0.01 ? 6 : 2;
const formatted = num.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: precision });
return formatted.replace(/(\.\d*?)0+$/, '$1').replace(/\.$/, '') || '0';
} catch {
return '0';
}
}
interface CurrencyOption {
currency_id: string;
unit_amount: string;
custom_unit_amount?: { preset?: string; presets?: string[] } | null;
}
interface PriceWithDynamic {
pricing_type?: string;
base_amount?: string;
unit_amount?: string;
base_currency?: string;
currency_id?: string;
currency_options?: CurrencyOption[];
price_currency_options?: CurrencyOption[];
}
// Get the unit_amount for a specific currency, checking currency_options first
export function getUnitAmountForCurrency(
price: PriceWithDynamic | null | undefined,
currency: TPaymentCurrency | null
): string {
if (!price || !currency) return '0';
const options = price.price_currency_options || price.currency_options || [];
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] || option.unit_amount;
}
return option.unit_amount;
}
if (price.currency_id === currency.id) {
return price.unit_amount || '0';
}
return '0';
}
// Format price with exchange rate for dynamic pricing
export function formatDynamicUnitPrice(
price: PriceWithDynamic | null | undefined,
currency: TPaymentCurrency | null,
exchangeRate: string | null
): string | null {
if (!price || !currency) return null;
const isDynamic = price.pricing_type === 'dynamic';
if (isDynamic && exchangeRate && price.base_amount) {
const rate = Number(exchangeRate);
if (rate > 0 && Number.isFinite(rate)) {
const baseUsd = Number(price.base_amount);
if (baseUsd > 0 && Number.isFinite(baseUsd)) {
const tokenAmount = baseUsd / rate;
const abs = Math.abs(tokenAmount);
const precision = abs > 0 && abs < 0.01 ? 6 : 2;
return (
tokenAmount
.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: precision })
.replace(/(\.\d*?)0+$/, '$1')
.replace(/\.$/, '') || '0'
);
}
}
}
// Fiat/Stripe fallback: use base_amount when no exchange rate
// (unit_amount may be in crypto denomination, not fiat cents)
if (!exchangeRate && price.base_amount != null) {
const baseUsd = Number(price.base_amount);
if (baseUsd >= 0 && Number.isFinite(baseUsd)) {
return baseUsd.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
}
// Fallback: look up unit_amount from currency_options for the selected currency
return formatTokenAmount(getUnitAmountForCurrency(price, currency), currency);
}
// Safe translation with fallback for missing keys (returns fallback if t() returns the raw key path)
export function tSafe(t: (key: string, params?: any) => string, key: string, fallback: string): string {
const v = t(key);
return v && !v.includes('.') ? v : fallback;
}
// Shared white tooltip style for checkout-v2
export const whiteTooltipSx = {
'& .MuiTooltip-tooltip': {
bgcolor: 'background.paper',
color: 'text.primary',
border: '1px solid',
borderColor: 'divider',
borderRadius: '10px',
boxShadow: '0 8px 24px rgba(0,0,0,0.12)',
p: 1.5,
maxWidth: 280,
fontSize: 12,
},
'& .MuiTooltip-arrow': {
color: 'background.paper',
'&::before': { border: '1px solid', borderColor: 'divider' },
},
};
// Helper: format trial text
export function formatTrialText(
t: (key: string, params?: Record<string, any>) => string,
days: number,
interval: string
): string {
const key = interval || 'day';
const intervalKey = days > 1 ? `common.${key}s` : `common.${key}`;
return t('payment.checkout.free', { count: days, interval: t(intervalKey) });
}
// ββ Item meta: badge / title / subtitle for the header area ββ
type TFn = (key: string, params?: Record<string, any>) => string;
export type ItemTypeBadge = 'subscription' | 'topup' | 'oneTime';
interface ItemMeta {
badge: ItemTypeBadge;
badgeLabel: string;
title: string;
subtitle: string;
}
/**
* Derive structured badge / title / subtitle for a checkout session header.
* Works for the "primary product" header above the item list.
*/
export function getSessionHeaderMeta(t: TFn, session: any, product: any, items: any[]): ItemMeta {
const mode = session?.mode || 'payment';
const isSubscription = ['subscription', 'setup'].includes(mode);
const isCreditTopup =
items.length === 1 &&
mode === 'payment' &&
items[0]?.price?.product?.type === 'credit' &&
items[0]?.price?.metadata?.credit_config;
// Badge
let badge: ItemTypeBadge;
if (isCreditTopup) {
badge = 'topup';
} else if (isSubscription) {
badge = 'subscription';
} else {
badge = 'oneTime';
}
const badgeLabel = t(`payment.checkout.typeBadge.${badge}`);
// Title: action + product name based on type
const productName = product?.name || items[0]?.price?.product?.name || '';
let title = productName;
if (productName) {
if (isSubscription) {
title = t('payment.checkout.headerTitle.subscribe', { name: productName });
} else if (!isCreditTopup) {
title = t('payment.checkout.headerTitle.purchase', { name: productName });
}
}
// Subtitle: system-generated, not product.description
let subtitle = '';
if (isCreditTopup) {
subtitle = t('payment.checkout.subtitle.creditsTopup');
} else if (isSubscription) {
// Use plan name if available; skip generic interval labels like "daily subscription"
const planName = product?.metadata?.plan_name || product?.metadata?.plan_display_name;
if (planName) {
subtitle = planName;
}
} else {
subtitle = t('payment.checkout.subtitle.oneTime');
}
return { badge, badgeLabel, title, subtitle };
}