mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 12:19:35 +00:00
fix(accounts): refine codex plan badges
This commit is contained in:
@@ -162,7 +162,9 @@ export function formatAccountVariantLabel(accountId: string, email?: string): st
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (suffix && PERSONAL_PLAN_PARTS.has(suffix)) {
|
if (suffix && PERSONAL_PLAN_PARTS.has(suffix)) {
|
||||||
return ['Personal', formatAudienceDetail(parts.slice(0, -1))].filter(Boolean).join(' · ');
|
return ['Personal', formatVariantPart(suffix), formatAudienceDetail(parts.slice(0, -1))]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ');
|
||||||
}
|
}
|
||||||
|
|
||||||
return parts.map(formatVariantPart).filter(Boolean).join(' · ');
|
return parts.map(formatVariantPart).filter(Boolean).join(' · ');
|
||||||
|
|||||||
@@ -624,11 +624,12 @@ export async function fetchCodexQuota(
|
|||||||
|
|
||||||
// Extract plan type
|
// Extract plan type
|
||||||
const planTypeRaw = data.plan_type || data.planType;
|
const planTypeRaw = data.plan_type || data.planType;
|
||||||
let planType: 'free' | 'plus' | 'team' | null = null;
|
let planType: 'free' | 'plus' | 'pro' | 'team' | null = null;
|
||||||
if (planTypeRaw) {
|
if (planTypeRaw) {
|
||||||
const normalized = planTypeRaw.toLowerCase();
|
const normalized = planTypeRaw.toLowerCase();
|
||||||
if (normalized === 'free') planType = 'free';
|
if (normalized === 'free') planType = 'free';
|
||||||
else if (normalized === 'plus') planType = 'plus';
|
else if (normalized === 'plus') planType = 'plus';
|
||||||
|
else if (normalized === 'pro') planType = 'pro';
|
||||||
else if (normalized === 'team') planType = 'team';
|
else if (normalized === 'team') planType = 'team';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,8 +74,8 @@ export interface CodexQuotaResult extends QuotaErrorMetadata {
|
|||||||
windows: CodexQuotaWindow[];
|
windows: CodexQuotaWindow[];
|
||||||
/** Explicit core usage windows (5h + weekly) for easier reset display */
|
/** Explicit core usage windows (5h + weekly) for easier reset display */
|
||||||
coreUsage?: CodexCoreUsageSummary;
|
coreUsage?: CodexCoreUsageSummary;
|
||||||
/** Plan type: free, plus, team, or null if unknown */
|
/** Plan type: free, plus, pro, team, or null if unknown */
|
||||||
planType: 'free' | 'plus' | 'team' | null;
|
planType: 'free' | 'plus' | 'pro' | 'team' | null;
|
||||||
/** Timestamp of fetch */
|
/** Timestamp of fetch */
|
||||||
lastUpdated: number;
|
lastUpdated: number;
|
||||||
/** Error message if fetch failed */
|
/** Error message if fetch failed */
|
||||||
|
|||||||
@@ -100,24 +100,53 @@ function getCodexPlanAudience(quota: unknown): AccountAudience {
|
|||||||
|
|
||||||
if (planType === 'team') return 'business';
|
if (planType === 'team') return 'business';
|
||||||
if (planType === 'free') return 'free';
|
if (planType === 'free') return 'free';
|
||||||
if (planType === 'plus') return 'personal';
|
if (planType === 'plus' || planType === 'pro') return 'personal';
|
||||||
return 'unknown';
|
return 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getVariantDetailLabel(variant: {
|
function getCodexPlanDetailLabel(quota: unknown): string | null {
|
||||||
audience: AccountAudience;
|
if (!quota || typeof quota !== 'object' || !('planType' in quota)) {
|
||||||
detailLabel?: string | null;
|
return null;
|
||||||
compactDetailLabel?: string | null;
|
}
|
||||||
}) {
|
|
||||||
return variant.detailLabel ?? variant.compactDetailLabel ?? null;
|
const planType = (quota as { planType?: unknown }).planType;
|
||||||
|
if (typeof planType !== 'string' || planType.trim().length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (planType === 'free' || planType === 'team') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return planType === 'plus' || planType === 'pro'
|
||||||
|
? planType[0].toUpperCase() + planType.slice(1)
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getVariantCompactDetailLabel(variant: {
|
function getVariantDetailLabel(
|
||||||
audience: AccountAudience;
|
variant: {
|
||||||
compactDetailLabel?: string | null;
|
audience: AccountAudience;
|
||||||
detailLabel?: string | null;
|
detailLabel?: string | null;
|
||||||
}) {
|
compactDetailLabel?: string | null;
|
||||||
return variant.compactDetailLabel ?? variant.detailLabel ?? null;
|
},
|
||||||
|
quota?: unknown
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
variant.detailLabel ?? variant.compactDetailLabel ?? getCodexPlanDetailLabel(quota) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVariantCompactDetailLabel(
|
||||||
|
variant: {
|
||||||
|
audience: AccountAudience;
|
||||||
|
compactDetailLabel?: string | null;
|
||||||
|
detailLabel?: string | null;
|
||||||
|
},
|
||||||
|
quota?: unknown
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
variant.compactDetailLabel ?? variant.detailLabel ?? getCodexPlanDetailLabel(quota) ?? null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDetailedAudienceLabel(audience: AccountAudience): string | null {
|
function getDetailedAudienceLabel(audience: AccountAudience): string | null {
|
||||||
@@ -146,7 +175,7 @@ function getVariantInlineLabel(
|
|||||||
},
|
},
|
||||||
quota?: unknown
|
quota?: unknown
|
||||||
) {
|
) {
|
||||||
const detailLabel = getVariantDetailLabel(variant);
|
const detailLabel = getVariantDetailLabel(variant, quota);
|
||||||
const audienceLabel =
|
const audienceLabel =
|
||||||
variant.audienceLabel ?? getDetailedAudienceLabel(getVariantAudience(variant, quota));
|
variant.audienceLabel ?? getDetailedAudienceLabel(getVariantAudience(variant, quota));
|
||||||
const composedLabel = [audienceLabel, detailLabel].filter(Boolean).join(' · ');
|
const composedLabel = [audienceLabel, detailLabel].filter(Boolean).join(' · ');
|
||||||
@@ -164,7 +193,7 @@ function getVariantMarkerLabel(
|
|||||||
quota?: unknown
|
quota?: unknown
|
||||||
) {
|
) {
|
||||||
const audience = getVariantAudience(variant, quota);
|
const audience = getVariantAudience(variant, quota);
|
||||||
const compactDetailLabel = getVariantCompactDetailLabel(variant);
|
const compactDetailLabel = getVariantCompactDetailLabel(variant, quota);
|
||||||
if (audience === 'business') {
|
if (audience === 'business') {
|
||||||
const businessVariantCount = audienceCounts.get('business') ?? 0;
|
const businessVariantCount = audienceCounts.get('business') ?? 0;
|
||||||
return businessVariantCount > 1 && compactDetailLabel ? compactDetailLabel : 'Biz';
|
return businessVariantCount > 1 && compactDetailLabel ? compactDetailLabel : 'Biz';
|
||||||
@@ -197,7 +226,7 @@ function getGroupedVariantSummaryLabel(
|
|||||||
const audiences = new Set(variants.map((variant) => variant.audience));
|
const audiences = new Set(variants.map((variant) => variant.audience));
|
||||||
const hasDistinctDetails = variants.some((variant, index) =>
|
const hasDistinctDetails = variants.some((variant, index) =>
|
||||||
Boolean(
|
Boolean(
|
||||||
getVariantCompactDetailLabel(variant) ||
|
getVariantCompactDetailLabel(variant, quotas[index]) ||
|
||||||
getDetailedAudienceLabel(getVariantAudience(variant, quotas[index]))
|
getDetailedAudienceLabel(getVariantAudience(variant, quotas[index]))
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { ReactNode } from 'react';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||||
import type { UnifiedQuotaResult } from '@/hooks/use-cliproxy-stats';
|
import type { UnifiedQuotaResult } from '@/hooks/use-cliproxy-stats';
|
||||||
import { getAccountIdentityPresentation } from '@/lib/account-identity';
|
import { formatAccountVariantPart, getAccountIdentityPresentation } from '@/lib/account-identity';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Pause, Star, User } from 'lucide-react';
|
import { Pause, Star, User } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -109,10 +109,22 @@ function getCodexPlanAudience(quota: UnifiedQuotaResult | undefined): AccountAud
|
|||||||
|
|
||||||
if (quota.planType === 'team') return 'business';
|
if (quota.planType === 'team') return 'business';
|
||||||
if (quota.planType === 'free') return 'free';
|
if (quota.planType === 'free') return 'free';
|
||||||
if (quota.planType === 'plus') return 'personal';
|
if (quota.planType === 'plus' || quota.planType === 'pro') return 'personal';
|
||||||
return 'unknown';
|
return 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCodexPlanDetailLabel(quota: UnifiedQuotaResult | undefined): string | null {
|
||||||
|
if (!quota || !('planType' in quota) || !quota.planType) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quota.planType === 'free' || quota.planType === 'team') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatAccountVariantPart(quota.planType);
|
||||||
|
}
|
||||||
|
|
||||||
export function AccountSurfaceCard({
|
export function AccountSurfaceCard({
|
||||||
mode,
|
mode,
|
||||||
provider,
|
provider,
|
||||||
@@ -143,11 +155,13 @@ export function AccountSurfaceCard({
|
|||||||
const effectiveTier = resolveEffectiveTier(tier, quota);
|
const effectiveTier = resolveEffectiveTier(tier, quota);
|
||||||
const codexPlanAudience =
|
const codexPlanAudience =
|
||||||
normalizedProvider === 'codex' ? getCodexPlanAudience(quota) : 'unknown';
|
normalizedProvider === 'codex' ? getCodexPlanAudience(quota) : 'unknown';
|
||||||
|
const codexPlanDetailLabel =
|
||||||
|
normalizedProvider === 'codex' ? getCodexPlanDetailLabel(quota) : null;
|
||||||
const effectiveAudience = identity.audience !== 'unknown' ? identity.audience : codexPlanAudience;
|
const effectiveAudience = identity.audience !== 'unknown' ? identity.audience : codexPlanAudience;
|
||||||
const effectiveAudienceLabel =
|
const effectiveAudienceLabel =
|
||||||
identity.audienceLabel ?? getDetailedAudienceLabel(effectiveAudience);
|
identity.audienceLabel ?? getDetailedAudienceLabel(effectiveAudience);
|
||||||
const resolvedDetailLabel = identity.detailLabel;
|
const resolvedDetailLabel = identity.detailLabel ?? codexPlanDetailLabel;
|
||||||
const resolvedCompactDetailLabel = identity.compactDetailLabel;
|
const resolvedCompactDetailLabel = identity.compactDetailLabel ?? codexPlanDetailLabel;
|
||||||
const showTierBadge =
|
const showTierBadge =
|
||||||
(normalizedProvider === 'agy' ||
|
(normalizedProvider === 'agy' ||
|
||||||
normalizedProvider === 'antigravity' ||
|
normalizedProvider === 'antigravity' ||
|
||||||
|
|||||||
@@ -186,14 +186,16 @@ export function getAccountIdentityPresentation(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (suffix && PERSONAL_PLAN_PARTS.has(suffix)) {
|
if (suffix && PERSONAL_PLAN_PARTS.has(suffix)) {
|
||||||
const detailLabel = formatAudienceDetail(parts.slice(0, -1));
|
const detailLabel = [formatAccountVariantPart(suffix), formatAudienceDetail(parts.slice(0, -1))]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ');
|
||||||
const inlineLabel = ['Personal', detailLabel].filter(Boolean).join(' · '); // TODO i18n: missing key for Personal
|
const inlineLabel = ['Personal', detailLabel].filter(Boolean).join(' · '); // TODO i18n: missing key for Personal
|
||||||
return {
|
return {
|
||||||
email: resolvedEmail,
|
email: resolvedEmail,
|
||||||
audience: 'personal',
|
audience: 'personal',
|
||||||
audienceLabel: 'Personal',
|
audienceLabel: 'Personal',
|
||||||
detailLabel,
|
detailLabel: detailLabel || formatAccountVariantPart(suffix),
|
||||||
compactDetailLabel: detailLabel,
|
compactDetailLabel: detailLabel || formatAccountVariantPart(suffix),
|
||||||
inlineLabel,
|
inlineLabel,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -610,8 +610,8 @@ export interface CodexQuotaResult {
|
|||||||
windows: CodexQuotaWindow[];
|
windows: CodexQuotaWindow[];
|
||||||
/** Explicit core usage windows (5h + weekly) for easier reset display */
|
/** Explicit core usage windows (5h + weekly) for easier reset display */
|
||||||
coreUsage?: CodexCoreUsageSummary;
|
coreUsage?: CodexCoreUsageSummary;
|
||||||
/** Plan type: free, plus, team, or null if unknown */
|
/** Plan type: free, plus, pro, team, or null if unknown */
|
||||||
planType: 'free' | 'plus' | 'team' | null;
|
planType: 'free' | 'plus' | 'pro' | 'team' | null;
|
||||||
/** Timestamp of fetch */
|
/** Timestamp of fetch */
|
||||||
lastUpdated: number;
|
lastUpdated: number;
|
||||||
/** Upstream HTTP status when available */
|
/** Upstream HTTP status when available */
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ vi.mock('@/hooks/use-cliproxy-stats', async () => {
|
|||||||
const mockedUseAccountQuota = vi.mocked(useAccountQuota);
|
const mockedUseAccountQuota = vi.mocked(useAccountQuota);
|
||||||
const mockedUseAccountQuotas = vi.mocked(useAccountQuotas);
|
const mockedUseAccountQuotas = vi.mocked(useAccountQuotas);
|
||||||
|
|
||||||
function makeCodexQuota(planType: 'free' | 'plus' | 'team', fiveHour: number, weekly: number) {
|
function makeCodexQuota(
|
||||||
|
planType: 'free' | 'plus' | 'pro' | 'team',
|
||||||
|
fiveHour: number,
|
||||||
|
weekly: number
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
planType,
|
planType,
|
||||||
@@ -201,5 +205,6 @@ describe('AccountCard grouped quota tooltip', () => {
|
|||||||
expect(screen.getByTitle('Business · Workspace 04a0f049 • Personal · Pro')).toBeInTheDocument();
|
expect(screen.getByTitle('Business · Workspace 04a0f049 • Personal · Pro')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Personal · Pro')).toBeInTheDocument();
|
expect(screen.getByText('Personal · Pro')).toBeInTheDocument();
|
||||||
expect(screen.queryByText('Free')).not.toBeInTheDocument();
|
expect(screen.queryByText('Free')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Pro')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ describe('AccountSurfaceCard', () => {
|
|||||||
expect(screen.queryByText('Pers')).not.toBeInTheDocument();
|
expect(screen.queryByText('Pers')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the simplified personal audience when live quota planType is coarser', () => {
|
it('keeps token-derived personal detail when live quota planType is coarser', () => {
|
||||||
render(
|
render(
|
||||||
<AccountSurfaceCard
|
<AccountSurfaceCard
|
||||||
mode="compact"
|
mode="compact"
|
||||||
@@ -90,6 +90,7 @@ describe('AccountSurfaceCard', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText('Pers')).toBeInTheDocument();
|
expect(screen.getByText('Pers')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Pro')).toBeInTheDocument();
|
||||||
expect(screen.queryByText('Free')).not.toBeInTheDocument();
|
expect(screen.queryByText('Free')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -109,4 +110,21 @@ describe('AccountSurfaceCard', () => {
|
|||||||
expect(screen.getByText('Free')).toBeInTheDocument();
|
expect(screen.getByText('Free')).toBeInTheDocument();
|
||||||
expect(screen.queryByText('Pers')).not.toBeInTheDocument();
|
expect(screen.queryByText('Pers')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('falls back to plus or pro detail when Codex quota exposes a paid plan', () => {
|
||||||
|
render(
|
||||||
|
<AccountSurfaceCard
|
||||||
|
mode="compact"
|
||||||
|
provider="codex"
|
||||||
|
accountId="user@example.com"
|
||||||
|
email="user@example.com"
|
||||||
|
displayEmail="user@example.com"
|
||||||
|
quota={createCodexQuotaResult({ planType: 'pro' })}
|
||||||
|
showQuota={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Pers')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Pro')).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -58,14 +58,21 @@ describe('account identity presentation', () => {
|
|||||||
).toBe('kaidu.kd@gmail.com (Free)');
|
).toBe('kaidu.kd@gmail.com (Free)');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('collapses paid codex personal plans into a single personal audience label', () => {
|
it('keeps plus and pro codex personal plans distinct', () => {
|
||||||
expect(
|
expect(
|
||||||
formatAccountDisplayName(
|
formatAccountDisplayName(
|
||||||
'kaidu.kd@gmail.com',
|
'kaidu.kd@gmail.com',
|
||||||
'kaidu.kd@gmail.com',
|
'kaidu.kd@gmail.com',
|
||||||
'codex-kaidu.kd@gmail.com-plus.json'
|
'codex-kaidu.kd@gmail.com-plus.json'
|
||||||
)
|
)
|
||||||
).toBe('kaidu.kd@gmail.com (Personal)');
|
).toBe('kaidu.kd@gmail.com (Personal · Plus)');
|
||||||
|
expect(
|
||||||
|
formatAccountDisplayName(
|
||||||
|
'kaidu.kd@gmail.com',
|
||||||
|
'kaidu.kd@gmail.com',
|
||||||
|
'codex-kaidu.kd@gmail.com-pro.json'
|
||||||
|
)
|
||||||
|
).toBe('kaidu.kd@gmail.com (Personal · Pro)');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('leaves plain accounts without inferred state untouched', () => {
|
it('leaves plain accounts without inferred state untouched', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user