diff --git a/web/default/src/components/data-table/layout/data-table-page.tsx b/web/default/src/components/data-table/layout/data-table-page.tsx index 7b1cfd2e..99883d1a 100644 --- a/web/default/src/components/data-table/layout/data-table-page.tsx +++ b/web/default/src/components/data-table/layout/data-table-page.tsx @@ -131,6 +131,17 @@ export type DataTablePageProps = { */ hideMobile?: boolean + /** + * Render the card view on mobile instead of the default {@link MobileCardList}. + * When enabled, the mobile layout reuses the same {@link DataTableCardGrid} + * (and therefore `renderCard` / `cardGridClassName`) as the desktop card view, + * stacked in a single column. Falls back to the generic card content when no + * `renderCard` is provided. Ignored when a custom `mobile` slot is supplied. + * + * Defaults to `false`, so existing pages keep the list-style mobile layout. + */ + mobileCardView?: boolean + /** * Row className resolver — applied to both desktop `TableRow` and mobile card. * Composes with the default `data-state="selected"` styling on desktop. @@ -247,7 +258,9 @@ export type DataTablePageProps = { viewModeStorageKey?: string /** - * Initial (uncontrolled) view mode. Defaults to `'table'`. + * Initial (uncontrolled) view mode. When unset, defaults to `'card'` if + * `enableCardView` is `true`, otherwise `'table'`. A persisted selection + * (via `viewModeStorageKey`) always takes precedence over this default. */ defaultViewMode?: DataTableViewMode @@ -292,7 +305,12 @@ export function DataTablePage(props: DataTablePageProps) { const [internalViewMode, setInternalViewMode] = useDataTableViewMode({ storageKey: props.viewModeStorageKey, - defaultMode: props.defaultViewMode, + // When card view is enabled, prefer it as the default unless the consumer + // explicitly opts into a different initial mode. A persisted choice (via + // `viewModeStorageKey`) still takes precedence over this default. + defaultMode: + props.defaultViewMode ?? + (props.enableCardView ? DATA_TABLE_VIEW_MODES.CARD : undefined), }) const viewMode = props.viewMode ?? internalViewMode const setViewMode = props.onViewModeChange ?? setInternalViewMode @@ -381,16 +399,33 @@ function renderMobile( (ownGetRowClassName ? (row: Row) => ownGetRowClassName(row, { isMobile: true }) : undefined) - const mobileContent = props.mobile ?? ( - - ) + + let mobileContent = props.mobile + if (mobileContent === undefined) { + mobileContent = props.mobileCardView ? ( + + ) : ( + + ) + } return
{mobileContent}
} diff --git a/web/default/src/components/data-table/toolbar/view-mode-toggle.tsx b/web/default/src/components/data-table/toolbar/view-mode-toggle.tsx index 7ab763fa..2f4df2f7 100644 --- a/web/default/src/components/data-table/toolbar/view-mode-toggle.tsx +++ b/web/default/src/components/data-table/toolbar/view-mode-toggle.tsx @@ -50,16 +50,16 @@ export function DataTableViewModeToggle(props: DataTableViewModeToggleProps) { const { t } = useTranslation() const segments: Segment[] = [ - { - value: DATA_TABLE_VIEW_MODES.TABLE, - icon: Table2, - tooltip: t('Table view'), - }, { value: DATA_TABLE_VIEW_MODES.CARD, icon: Grid2X2, tooltip: t('Card view'), }, + { + value: DATA_TABLE_VIEW_MODES.TABLE, + icon: Table2, + tooltip: t('Table view'), + }, ] return ( diff --git a/web/default/src/components/provider-badge.tsx b/web/default/src/components/provider-badge.tsx index f6885fe7..791f0dee 100644 --- a/web/default/src/components/provider-badge.tsx +++ b/web/default/src/components/provider-badge.tsx @@ -24,6 +24,8 @@ type ProviderBadgeProps = Omit & { iconKey?: string | null iconSize?: number label: string + /** Color the label text by provider name. Set false for a neutral label. */ + colorText?: boolean } export function ProviderBadge({ @@ -31,6 +33,7 @@ export function ProviderBadge({ iconKey, iconSize = 14, label, + colorText = true, ...badgeProps }: ProviderBadgeProps) { const icon = iconKey ? getLobeIcon(iconKey, iconSize) : null @@ -43,7 +46,8 @@ export function ProviderBadge({ {icon && {icon}} ('badge') const sizeMap = { - sm: 'h-5 gap-1 px-1.5 text-xs leading-none', - md: 'h-5 gap-1 px-1.5 text-xs leading-none', - lg: 'h-6 gap-1.5 px-2 text-xs leading-none', + sm: 'h-5 gap-1 px-1.5 text-sm leading-none', + md: 'h-5 gap-1 px-1.5 text-sm leading-none', + lg: 'h-6 gap-1.5 px-2 text-sm leading-none', } as const const textSizeMap = { - sm: 'gap-1 text-xs leading-none', - md: 'gap-1 text-xs leading-none', - lg: 'gap-1.5 text-xs leading-none', + sm: 'gap-1 text-sm leading-none', + md: 'gap-1 text-sm leading-none', + lg: 'gap-1.5 text-sm leading-none', } as const export interface StatusBadgeProps extends Omit< diff --git a/web/default/src/features/channels/components/channel-card.tsx b/web/default/src/features/channels/components/channel-card.tsx index 21b47e21..cc795fdf 100644 --- a/web/default/src/features/channels/components/channel-card.tsx +++ b/web/default/src/features/channels/components/channel-card.tsx @@ -18,35 +18,27 @@ For commercial licensing, please contact support@quantumnous.com */ import { flexRender, type Row } from '@tanstack/react-table' import { useTranslation } from 'react-i18next' -import { cn } from '@/lib/utils' -import { isTagAggregateRow } from '../lib' +import { GroupBadge } from '@/components/group-badge' +import { CHANNEL_STATUS } from '../constants' +import { isTagAggregateRow, parseGroupsList } from '../lib' import type { Channel } from '../types' +import { ChannelRowActionsLayoutContext } from './channel-row-actions-context' /** - * Field columns rendered in the card body (in display order). The header - * columns (`select`, `name`, `status`, `actions`) are laid out separately. - * `models` spans the full width because it can hold many badges. + * Field columns rendered in the labelled grid (in display order). The first + * row (`select`, `type`, `status`, `actions`), the channel name row + * (`#id` label + `name`, aligned with `priority`/`weight`), and the + * full-width `group` row are laid out separately around the grid. */ -const FIELD_COLUMN_IDS = [ - 'type', - 'id', - 'group', - 'balance', - 'priority', - 'weight', - 'response_time', - 'test_time', - 'tag', - 'models', -] as const +const FIELD_COLUMN_IDS = ['balance', 'response_time', 'test_time'] as const /** * Bespoke channel card for the card view. Reuses every column's existing cell - * renderer via `flexRender`, so all table information and interactions are - * preserved: row selection, name/remark + warning icons, status (with tooltips), - * provider/multi-key/IO.NET badges, groups, models, tag, inline priority/weight - * spinners, balance refresh, response/test times, tag expand-collapse, and the - * per-row (or per-tag) actions menu. + * renderer via `flexRender`, so the table's information and interactions are + * preserved: row selection, provider/multi-key/IO.NET type badge, id, + * name/remark + warning icons, status (with tooltips), groups, inline + * priority/weight spinners, balance refresh, response/test times, tag + * expand-collapse, and the per-row (or per-tag) actions menu. */ export function ChannelCard({ row }: { row: Row }) { const { t } = useTranslation() @@ -62,51 +54,76 @@ export function ChannelCard({ row }: { row: Row }) { } const fieldLabels: Record = { - type: t('Type'), - id: t('ID'), - group: t('Groups'), balance: t('Used / Remaining'), - priority: t('Priority'), - weight: t('Weight'), response_time: t('Response'), test_time: t('Last Tested'), - tag: t('Tag'), - models: t('Models'), } + const groups = parseGroupsList(row.original.group ?? '') + const selectCell = renderCell('select') + const typeCell = renderCell('type') const nameCell = renderCell('name') const statusCell = renderCell('status') const actionsCell = renderCell('actions') + const priorityCell = renderCell('priority') + const weightCell = renderCell('weight') + + // In card view the enable/disable state is already conveyed by the inline + // power toggle, so the plain "Enabled"/"Disabled" badge is redundant. Keep + // only the informative states (e.g. auto-disabled, unknown) and tag rows. + const showStatusBadge = + isTagRow || + (row.original.status !== CHANNEL_STATUS.ENABLED && + row.original.status !== CHANNEL_STATUS.MANUAL_DISABLED) return (
- {/* Header: selection + name/remark, with status badge + actions menu */} -
-
+ {/* Row 1: selection + type, with status badge + actions menu */} +
+
{!isTagRow && selectCell && ( -
{selectCell}
+ {selectCell} )} -
{nameCell}
+
{typeCell}
- {statusCell} - {actionsCell} + {showStatusBadge && statusCell} + + {actionsCell} +
- {/* Body: labelled fields for every remaining column */} -
+ {/* Row 2: channel id + name (left) aligned with priority/weight (right) */} +
+
+ {!isTagRow && ( +
+ #{row.original.id} +
+ )} + {nameCell} +
+
+ + {t('Priority')} + + + {t('Weight')} + +
{priorityCell}
+
{weightCell}
+
+
+ + {/* Body: labelled fields for the remaining columns. Three fields share a + single row at every width (the card is wide enough even on phones). */} +
{FIELD_COLUMN_IDS.map((id) => { const content = renderCell(id) return ( -
+
{fieldLabels[id]}
@@ -117,6 +134,19 @@ export function ChannelCard({ row }: { row: Row }) { ) })}
+ + {/* Last row: groups span the full width, showing every group (no label) */} +
+ {groups.length > 0 ? ( +
+ {groups.map((g) => ( + + ))} +
+ ) : ( + - + )} +
) } diff --git a/web/default/src/features/channels/components/channel-row-actions-context.ts b/web/default/src/features/channels/components/channel-row-actions-context.ts new file mode 100644 index 00000000..0c729f0b --- /dev/null +++ b/web/default/src/features/channels/components/channel-row-actions-context.ts @@ -0,0 +1,29 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { createContext } from 'react' + +/** + * Where the channel row actions are being rendered. In the card view we + * surface the "Test Connection" action as an inline button next to the quick + * test; in the table it stays inside the overflow menu. + */ +export type ChannelRowActionsLayout = 'table' | 'card' + +export const ChannelRowActionsLayoutContext = + createContext('table') diff --git a/web/default/src/features/channels/components/channels-columns.tsx b/web/default/src/features/channels/components/channels-columns.tsx index 41213f23..97e3e66d 100644 --- a/web/default/src/features/channels/components/channels-columns.tsx +++ b/web/default/src/features/channels/components/channels-columns.tsx @@ -30,7 +30,11 @@ import { } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' -import { getCurrencyLabel } from '@/lib/currency' +import { + formatCurrencyFromUSD, + formatQuotaWithCurrency, + getCurrencyLabel, +} from '@/lib/currency' import { formatTimestampToDate, formatQuota as formatQuotaValue, @@ -266,11 +270,17 @@ function WeightCell({ channel }: { channel: Channel }) { ) } +/** + * Inline balance/used values longer than this switch to locale-aware compact + * notation (e.g. "$28万"); the precise value stays available in the tooltip. + */ +const MAX_INLINE_BALANCE_CHARS = 8 + /** * Balance cell component with click to update */ function BalanceCell({ channel }: { channel: Channel }) { - const { t } = useTranslation() + const { t, i18n } = useTranslation() const queryClient = useQueryClient() const isTagRow = isTagAggregateRow(channel) const balance = channel.balance || 0 @@ -284,22 +294,43 @@ function BalanceCell({ channel }: { channel: Channel }) { const withSuffix = (value: string) => tokenSuffix && value !== '-' ? `${value}${tokenSuffix}` : value - const usedDisplay = withSuffix(formatQuotaValue(usedQuota)) - const remainingDisplay = withSuffix(formatBalance(balance)) - const usedLabel = `${t('Used:')} ${usedDisplay}` - const remainingLabel = `${t('Remaining:')} ${remainingDisplay}` + const locale = i18n.resolvedLanguage || i18n.language + // Precise values are kept for the tooltip; long values are shown compactly inline. + const usedFull = withSuffix(formatQuotaValue(usedQuota)) + const remainingFull = withSuffix(formatBalance(balance)) + const usedDisplay = + usedFull.length > MAX_INLINE_BALANCE_CHARS + ? withSuffix(formatQuotaWithCurrency(usedQuota, { compact: true, locale })) + : usedFull + const remainingDisplay = + remainingFull.length > MAX_INLINE_BALANCE_CHARS + ? withSuffix(formatCurrencyFromUSD(balance, { compact: true, locale })) + : remainingFull + const usedLabel = `${t('Used:')} ${usedFull}` + const remainingLabel = `${t('Remaining:')} ${remainingFull}` // Tag row: only show cumulative used quota if (isTagRow) { return ( - + + + + } + /> + +

{usedLabel}

+
+
+
) } @@ -424,7 +455,8 @@ function BalanceCell({ channel }: { channel: Channel }) { * Generate channels columns configuration */ export function useChannelsColumns(): ColumnDef[] { - const { t } = useTranslation() + const { t, i18n } = useTranslation() + const locale = i18n.resolvedLanguage || i18n.language return [ // Checkbox column { @@ -645,8 +677,10 @@ export function useChannelsColumns(): ColumnDef[] { } > [] { variant={config.variant} size='sm' copyable={false} - className='-ml-1.5' /> ) }, @@ -970,7 +1003,7 @@ export function useChannelsColumns(): ColumnDef[] { return - } - const timeText = formatRelativeTime(testTime) + const timeText = formatRelativeTime(testTime, locale) const fullDate = formatTimestampToDate(testTime) // For valid timestamps, show tooltip with full date diff --git a/web/default/src/features/channels/components/channels-table.tsx b/web/default/src/features/channels/components/channels-table.tsx index df2979e6..1e312e29 100644 --- a/web/default/src/features/channels/components/channels-table.tsx +++ b/web/default/src/features/channels/components/channels-table.tsx @@ -361,6 +361,7 @@ export function ChannelsTable() { viewModeStorageKey={CHANNELS_VIEW_MODE_STORAGE_KEY} renderCard={(row) => } cardGridClassName='grid grid-cols-1 gap-3 sm:gap-4 lg:grid-cols-2 2xl:grid-cols-3' + mobileCardView applyHeaderSize toolbarProps={{ searchPlaceholder: t('Filter by name, ID, or key...'), diff --git a/web/default/src/features/channels/components/data-table-row-actions.tsx b/web/default/src/features/channels/components/data-table-row-actions.tsx index 5ba56b31..32a49fa4 100644 --- a/web/default/src/features/channels/components/data-table-row-actions.tsx +++ b/web/default/src/features/channels/components/data-table-row-actions.tsx @@ -16,14 +16,14 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { useState } from 'react' +import { useContext, useState } from 'react' import { useQueryClient } from '@tanstack/react-query' import { type Row } from '@tanstack/react-table' import { MoreHorizontal, Boxes, Pencil, - TestTube, + PlugZap, Gauge, DollarSign, Download, @@ -62,6 +62,7 @@ import { } from '../lib' import { parseUpstreamUpdateMeta } from '../lib/upstream-update-utils' import type { Channel } from '../types' +import { ChannelRowActionsLayoutContext } from './channel-row-actions-context' import { useChannels } from './channels-provider' interface DataTableRowActionsProps { @@ -70,6 +71,7 @@ interface DataTableRowActionsProps { export function DataTableRowActions({ row }: DataTableRowActionsProps) { const { t } = useTranslation() + const layout = useContext(ChannelRowActionsLayoutContext) const channel = row.original const { setOpen, setCurrentRow, upstream } = useChannels() const queryClient = useQueryClient() @@ -166,6 +168,27 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) { {t('Test Connection')} + {layout === 'card' && ( + + { + e.stopPropagation() + handleTest() + }} + aria-label={t('Test Channel Connection')} + /> + } + > + + + {t('Test Channel Connection')} + + )} + } @@ -221,7 +244,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) { {t('Test Connection')} - + diff --git a/web/default/src/features/channels/components/numeric-spinner-input.tsx b/web/default/src/features/channels/components/numeric-spinner-input.tsx index 2a6d45cf..ebfd80b7 100644 --- a/web/default/src/features/channels/components/numeric-spinner-input.tsx +++ b/web/default/src/features/channels/components/numeric-spinner-input.tsx @@ -126,7 +126,7 @@ export function NumericSpinnerInput({ )}
. For commercial licensing, please contact support@quantumnous.com */ import { formatCurrencyFromUSD, formatQuotaWithCurrency } from '@/lib/currency' -import dayjs from '@/lib/dayjs' import { formatTimestampToDate } from '@/lib/format' import { CHANNEL_STATUS_CONFIG, @@ -361,14 +360,37 @@ export function getResponseTimeConfig(timeMs: number) { // ============================================================================ /** - * Format Unix timestamp to relative time - * e.g., "2 hours ago", "3 days ago" + * Format a Unix timestamp (seconds) as a compact, locale-aware relative time. + * Uses `Intl.RelativeTimeFormat` with the `narrow` style so the label stays + * short inside table cells, e.g. "4h ago" / "42m ago" (en) or "4小时前" (zh), + * instead of the verbose "4 hours ago". */ -export function formatRelativeTime(timestamp: number): string { +export function formatRelativeTime( + timestamp: number, + locale?: Intl.LocalesArgument +): string { if (!timestamp || timestamp === 0) return 'Never' try { - return dayjs(timestamp * 1000).fromNow() + const diffSec = timestamp - Date.now() / 1000 + const absSec = Math.abs(diffSec) + const rtf = new Intl.RelativeTimeFormat(locale, { + numeric: 'always', + style: 'narrow', + }) + + const MINUTE = 60 + const HOUR = 60 * MINUTE + const DAY = 24 * HOUR + const MONTH = 30 * DAY + const YEAR = 365 * DAY + + if (absSec < MINUTE) return rtf.format(Math.round(diffSec), 'second') + if (absSec < HOUR) return rtf.format(Math.round(diffSec / MINUTE), 'minute') + if (absSec < DAY) return rtf.format(Math.round(diffSec / HOUR), 'hour') + if (absSec < MONTH) return rtf.format(Math.round(diffSec / DAY), 'day') + if (absSec < YEAR) return rtf.format(Math.round(diffSec / MONTH), 'month') + return rtf.format(Math.round(diffSec / YEAR), 'year') } catch { return 'Unknown' } diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index 044c4a60..bff4db32 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -259,7 +259,7 @@ "AIGC2D": "AIGC2D", "AILS": "AILS", "AK/SK mode: use AccessKey|SecretAccessKey|Region": "AK/SK mode: use AccessKey|SecretAccessKey|Region", - "Ali": "Ali", + "Ali": "Alibaba Bailian", "Alipay": "Alipay", "All": "All", "All categories": "All categories", diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json index 73bf8cf2..1fe3696c 100644 --- a/web/default/src/i18n/locales/fr.json +++ b/web/default/src/i18n/locales/fr.json @@ -259,7 +259,7 @@ "AIGC2D": "AIGC2D", "AILS": "AILS", "AK/SK mode: use AccessKey|SecretAccessKey|Region": "Mode AK/SK : utiliser AccessKey|SecretAccessKey|Region", - "Ali": "Ali", + "Ali": "Alibaba Bailian", "Alipay": "Alipay", "All": "Tout", "All categories": "Toutes catégories", diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json index 07b33201..150af108 100644 --- a/web/default/src/i18n/locales/ja.json +++ b/web/default/src/i18n/locales/ja.json @@ -259,7 +259,7 @@ "AIGC2D": "AIGC2D", "AILS": "AILS", "AK/SK mode: use AccessKey|SecretAccessKey|Region": "AK/SKモード: AccessKey | SecretAccessKey | Regionを使用", - "Ali": "Ali", + "Ali": "Alibaba Bailian", "Alipay": "Alipay", "All": "すべて", "All categories": "すべてのカテゴリ", diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json index 398f7c56..c7dd05a7 100644 --- a/web/default/src/i18n/locales/ru.json +++ b/web/default/src/i18n/locales/ru.json @@ -259,7 +259,7 @@ "AIGC2D": "AIGC2D", "AILS": "AILS", "AK/SK mode: use AccessKey|SecretAccessKey|Region": "Режим AK/SK: используйте AccessKey|SecretAccessKey|Region", - "Ali": "Ali", + "Ali": "Alibaba Bailian", "Alipay": "Alipay", "All": "Все", "All categories": "Все категории", diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json index af79c2d8..d818a6c4 100644 --- a/web/default/src/i18n/locales/vi.json +++ b/web/default/src/i18n/locales/vi.json @@ -259,7 +259,7 @@ "AIGC2D": "AIGC2D", "AILS": "AILS", "AK/SK mode: use AccessKey|SecretAccessKey|Region": "Chế độ AK/SK: sử dụng AccessKey|SecretAccessKey|Region", - "Ali": "Ali", + "Ali": "Alibaba Bailian", "Alipay": "Alipay", "All": "All", "All categories": "Tất cả danh mục", diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index 9165dacd..893cf45d 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -259,7 +259,7 @@ "AIGC2D": "AIGC2D", "AILS": "AILS", "AK/SK mode: use AccessKey|SecretAccessKey|Region": "AK/SK 模式:使用 AccessKey|SecretAccessKey|Region", - "Ali": "阿里", + "Ali": "阿里百炼", "Alipay": "支付宝", "All": "全部", "All categories": "全部分类", @@ -4616,7 +4616,7 @@ "Visual indicator color for the API card": "API 卡的可视指示器颜色", "Visual Mode": "可视模式", "Visual Parameter Override": "可视化参数覆盖", - "VolcEngine": "字节火山方舟、豆包通用", + "VolcEngine": "火山方舟", "vs. previous": "相较上期", "Waffo Aggregator Gateway": "Waffo 聚合网关", "Waffo Pancake Dashboard": "Waffo Pancake 控制台", diff --git a/web/default/src/lib/currency.ts b/web/default/src/lib/currency.ts index f6fe87a3..d81119d8 100644 --- a/web/default/src/lib/currency.ts +++ b/web/default/src/lib/currency.ts @@ -94,6 +94,20 @@ export interface CurrencyFormatOptions { abbreviate?: boolean /** Minimal absolute value to display when rounding would produce zero */ minimumNonZero?: number + /** + * Use locale-aware compact notation for large values (e.g. "$28万" in zh, + * "$280K" in en). The currency symbol is preserved. + */ + compact?: boolean + /** Locale used for number formatting (defaults to the runtime locale) */ + locale?: Intl.LocalesArgument | undefined +} + +type ResolvedCurrencyFormatOptions = Omit< + Required, + 'locale' +> & { + locale: Intl.LocalesArgument | undefined } type DisplayMeta = @@ -114,11 +128,13 @@ type DisplayMeta = quotaPerUnit: number } -const DEFAULT_FORMAT_OPTIONS: Required = { +const DEFAULT_FORMAT_OPTIONS: ResolvedCurrencyFormatOptions = { digitsLarge: 2, digitsSmall: 4, abbreviate: true, minimumNonZero: 0, + compact: false, + locale: undefined, } const DISPLAY_TYPE_VALUES = ['USD', 'CNY', 'TOKENS', 'CUSTOM'] as const @@ -211,7 +227,7 @@ function getBillingDisplayMeta(config: CurrencyConfig): DisplayMeta { function mergeOptions( options?: CurrencyFormatOptions -): Required { +): ResolvedCurrencyFormatOptions { if (!options) return DEFAULT_FORMAT_OPTIONS return { digitsLarge: options.digitsLarge ?? DEFAULT_FORMAT_OPTIONS.digitsLarge, @@ -219,6 +235,8 @@ function mergeOptions( abbreviate: options.abbreviate ?? DEFAULT_FORMAT_OPTIONS.abbreviate, minimumNonZero: options.minimumNonZero ?? DEFAULT_FORMAT_OPTIONS.minimumNonZero, + compact: options.compact ?? DEFAULT_FORMAT_OPTIONS.compact, + locale: options.locale ?? DEFAULT_FORMAT_OPTIONS.locale, } } @@ -260,10 +278,16 @@ function adjustForMinimum( function formatCurrencyValue( value: number, - options: Required, + options: ResolvedCurrencyFormatOptions, meta: DisplayMeta ): string { if (meta.kind === 'tokens') { + if (options.compact) { + return new Intl.NumberFormat(options.locale, { + notation: 'compact', + maximumFractionDigits: 1, + }).format(value) + } return formatNumberWithSuffix( value, options.digitsLarge, @@ -277,19 +301,21 @@ function formatCurrencyValue( const adjustedValue = adjustForMinimum(value, digits, options.minimumNonZero) if (meta.kind === 'currency') { - const formatted = new Intl.NumberFormat(undefined, { + const formatted = new Intl.NumberFormat(options.locale, { style: 'currency', currency: meta.currencyCode, currencyDisplay: 'narrowSymbol', + notation: options.compact ? 'compact' : 'standard', minimumFractionDigits: 0, - maximumFractionDigits: digits, + maximumFractionDigits: options.compact ? 1 : digits, }).format(adjustedValue) return formatted } - const decimal = new Intl.NumberFormat(undefined, { + const decimal = new Intl.NumberFormat(options.locale, { + notation: options.compact ? 'compact' : 'standard', minimumFractionDigits: 0, - maximumFractionDigits: digits, + maximumFractionDigits: options.compact ? 1 : digits, }).format(adjustedValue) return `${meta.symbol} ${decimal}` @@ -358,6 +384,12 @@ export function formatCurrencyFromUSD( if (meta.kind === 'tokens') { const tokens = amountUSD * config.quotaPerUnit + if (merged.compact) { + return new Intl.NumberFormat(merged.locale, { + notation: 'compact', + maximumFractionDigits: 1, + }).format(tokens) + } return formatNumberWithSuffix( tokens, 0,