mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-19 22:18:26 +00:00
fix(accounts): simplify codex free tier badges
This commit is contained in:
@@ -1,6 +1,9 @@
|
|||||||
import type { CLIProxyProvider } from '../types';
|
import type { CLIProxyProvider } from '../types';
|
||||||
|
|
||||||
const DUPLICATE_EMAIL_ACCOUNT_PROVIDERS = new Set<string>(['codex']);
|
const DUPLICATE_EMAIL_ACCOUNT_PROVIDERS = new Set<string>(['codex']);
|
||||||
|
const FREE_PLAN_PARTS = new Set(['free']);
|
||||||
|
const PERSONAL_PLAN_PARTS = new Set(['plus', 'pro']);
|
||||||
|
const BUSINESS_PLAN_PARTS = new Set(['team']);
|
||||||
|
|
||||||
// Keep variant parsing aligned with ui/src/lib/account-identity.ts. The UI copy is
|
// Keep variant parsing aligned with ui/src/lib/account-identity.ts. The UI copy is
|
||||||
// separate because the browser bundle cannot import this server module directly.
|
// separate because the browser bundle cannot import this server module directly.
|
||||||
@@ -49,6 +52,20 @@ function formatVariantPart(value: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatWorkspaceLabel(parts: string[]): string | null {
|
||||||
|
const workspaceId = parts.find((part) => /^[a-f0-9]{8}$/i.test(part));
|
||||||
|
if (workspaceId) {
|
||||||
|
return `Workspace ${workspaceId.toLowerCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.map(formatVariantPart).filter(Boolean).join(' · ') || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAudienceDetail(parts: string[]): string | null {
|
||||||
|
const label = parts.map(formatVariantPart).filter(Boolean).join(' · ');
|
||||||
|
return label || null;
|
||||||
|
}
|
||||||
|
|
||||||
export function supportsDuplicateEmailAccounts(provider: CLIProxyProvider | string): boolean {
|
export function supportsDuplicateEmailAccounts(provider: CLIProxyProvider | string): boolean {
|
||||||
return DUPLICATE_EMAIL_ACCOUNT_PROVIDERS.has(normalizeProvider(provider));
|
return DUPLICATE_EMAIL_ACCOUNT_PROVIDERS.has(normalizeProvider(provider));
|
||||||
}
|
}
|
||||||
@@ -136,10 +153,16 @@ export function formatAccountVariantLabel(accountId: string, email?: string): st
|
|||||||
}
|
}
|
||||||
|
|
||||||
const suffix = parts[parts.length - 1]?.toLowerCase();
|
const suffix = parts[parts.length - 1]?.toLowerCase();
|
||||||
if (suffix && ['team', 'free', 'plus', 'pro'].includes(suffix)) {
|
if (suffix && BUSINESS_PLAN_PARTS.has(suffix)) {
|
||||||
return [formatVariantPart(suffix), ...parts.slice(0, -1).map(formatVariantPart)]
|
return ['Business', formatWorkspaceLabel(parts.slice(0, -1))].filter(Boolean).join(' · ');
|
||||||
.filter(Boolean)
|
}
|
||||||
.join(' · ');
|
|
||||||
|
if (suffix && FREE_PLAN_PARTS.has(suffix)) {
|
||||||
|
return ['Free', formatAudienceDetail(parts.slice(0, -1))].filter(Boolean).join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (suffix && PERSONAL_PLAN_PARTS.has(suffix)) {
|
||||||
|
return ['Personal', formatAudienceDetail(parts.slice(0, -1))].filter(Boolean).join(' · ');
|
||||||
}
|
}
|
||||||
|
|
||||||
return parts.map(formatVariantPart).filter(Boolean).join(' · ');
|
return parts.map(formatVariantPart).filter(Boolean).join(' · ');
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
getProviderResetTime,
|
getProviderResetTime,
|
||||||
getQuotaFailureInfo,
|
getQuotaFailureInfo,
|
||||||
} from '@/lib/utils';
|
} from '@/lib/utils';
|
||||||
import { formatAccountVariantPart } from '@/lib/account-identity';
|
|
||||||
import { GripVertical, Loader2, Pause, Play } from 'lucide-react';
|
import { GripVertical, Loader2, Pause, Play } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
useAccountQuota,
|
useAccountQuota,
|
||||||
@@ -28,6 +27,7 @@ import { AccountCardStats } from './account-card-stats';
|
|||||||
import { cleanEmail } from './utils';
|
import { cleanEmail } from './utils';
|
||||||
|
|
||||||
type Zone = 'left' | 'right' | 'top' | 'bottom';
|
type Zone = 'left' | 'right' | 'top' | 'bottom';
|
||||||
|
type AccountAudience = 'business' | 'free' | 'personal' | 'unknown';
|
||||||
|
|
||||||
const QUOTA_PROVIDER_ALIASES = [
|
const QUOTA_PROVIDER_ALIASES = [
|
||||||
'antigravity',
|
'antigravity',
|
||||||
@@ -88,46 +88,57 @@ function getCompactQuotaColor(percentage: number) {
|
|||||||
return 'bg-red-500';
|
return 'bg-red-500';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCodexPlanDetailLabel(quota: unknown): string | null {
|
function getCodexPlanAudience(quota: unknown): AccountAudience {
|
||||||
if (!quota || typeof quota !== 'object' || !('planType' in quota)) {
|
if (!quota || typeof quota !== 'object' || !('planType' in quota)) {
|
||||||
return null;
|
return 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
const planType = (quota as { planType?: unknown }).planType;
|
const planType = (quota as { planType?: unknown }).planType;
|
||||||
if (typeof planType !== 'string' || planType.trim().length === 0) {
|
if (typeof planType !== 'string' || planType.trim().length === 0) {
|
||||||
return null;
|
return 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
return formatAccountVariantPart(planType);
|
if (planType === 'team') return 'business';
|
||||||
|
if (planType === 'free') return 'free';
|
||||||
|
if (planType === 'plus') return 'personal';
|
||||||
|
return 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getVariantDetailLabel(
|
function getVariantDetailLabel(variant: {
|
||||||
|
audience: AccountAudience;
|
||||||
|
detailLabel?: string | null;
|
||||||
|
compactDetailLabel?: string | null;
|
||||||
|
}) {
|
||||||
|
return variant.detailLabel ?? variant.compactDetailLabel ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVariantCompactDetailLabel(variant: {
|
||||||
|
audience: AccountAudience;
|
||||||
|
compactDetailLabel?: string | null;
|
||||||
|
detailLabel?: string | null;
|
||||||
|
}) {
|
||||||
|
return variant.compactDetailLabel ?? variant.detailLabel ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDetailedAudienceLabel(audience: AccountAudience): string | null {
|
||||||
|
if (audience === 'business') return 'Business';
|
||||||
|
if (audience === 'free') return 'Free';
|
||||||
|
if (audience === 'personal') return 'Personal';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVariantAudience(
|
||||||
variant: {
|
variant: {
|
||||||
audience: string;
|
audience: AccountAudience;
|
||||||
detailLabel?: string | null;
|
|
||||||
compactDetailLabel?: string | null;
|
|
||||||
},
|
},
|
||||||
quota?: unknown
|
quota?: unknown
|
||||||
) {
|
) {
|
||||||
const codexPlanDetail = getCodexPlanDetailLabel(quota);
|
return variant.audience !== 'unknown' ? variant.audience : getCodexPlanAudience(quota);
|
||||||
return variant.detailLabel ?? variant.compactDetailLabel ?? codexPlanDetail;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getVariantCompactDetailLabel(
|
|
||||||
variant: {
|
|
||||||
audience: string;
|
|
||||||
compactDetailLabel?: string | null;
|
|
||||||
detailLabel?: string | null;
|
|
||||||
},
|
|
||||||
quota?: unknown
|
|
||||||
) {
|
|
||||||
const codexPlanDetail = getCodexPlanDetailLabel(quota);
|
|
||||||
return variant.compactDetailLabel ?? variant.detailLabel ?? codexPlanDetail;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getVariantInlineLabel(
|
function getVariantInlineLabel(
|
||||||
variant: {
|
variant: {
|
||||||
audience: string;
|
audience: AccountAudience;
|
||||||
audienceLabel?: string | null;
|
audienceLabel?: string | null;
|
||||||
detailLabel?: string | null;
|
detailLabel?: string | null;
|
||||||
compactDetailLabel?: string | null;
|
compactDetailLabel?: string | null;
|
||||||
@@ -135,14 +146,16 @@ function getVariantInlineLabel(
|
|||||||
},
|
},
|
||||||
quota?: unknown
|
quota?: unknown
|
||||||
) {
|
) {
|
||||||
const detailLabel = getVariantDetailLabel(variant, quota);
|
const detailLabel = getVariantDetailLabel(variant);
|
||||||
const composedLabel = [variant.audienceLabel, detailLabel].filter(Boolean).join(' · ');
|
const audienceLabel =
|
||||||
|
variant.audienceLabel ?? getDetailedAudienceLabel(getVariantAudience(variant, quota));
|
||||||
|
const composedLabel = [audienceLabel, detailLabel].filter(Boolean).join(' · ');
|
||||||
return composedLabel || variant.inlineLabel || null;
|
return composedLabel || variant.inlineLabel || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getVariantMarkerLabel(
|
function getVariantMarkerLabel(
|
||||||
variant: {
|
variant: {
|
||||||
audience: string;
|
audience: AccountAudience;
|
||||||
audienceLabel?: string | null;
|
audienceLabel?: string | null;
|
||||||
detailLabel?: string | null;
|
detailLabel?: string | null;
|
||||||
compactDetailLabel?: string | null;
|
compactDetailLabel?: string | null;
|
||||||
@@ -150,23 +163,30 @@ function getVariantMarkerLabel(
|
|||||||
audienceCounts: Map<string, number>,
|
audienceCounts: Map<string, number>,
|
||||||
quota?: unknown
|
quota?: unknown
|
||||||
) {
|
) {
|
||||||
const compactDetailLabel = getVariantCompactDetailLabel(variant, quota);
|
const audience = getVariantAudience(variant, quota);
|
||||||
if (variant.audience === 'business') {
|
const compactDetailLabel = getVariantCompactDetailLabel(variant);
|
||||||
|
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';
|
||||||
}
|
}
|
||||||
if (variant.audience === 'personal') {
|
if (audience === 'free') {
|
||||||
|
return compactDetailLabel ?? 'Free';
|
||||||
|
}
|
||||||
|
if (audience === 'personal') {
|
||||||
return compactDetailLabel ?? 'Pers';
|
return compactDetailLabel ?? 'Pers';
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedFallback =
|
const normalizedFallback =
|
||||||
compactDetailLabel?.trim() || variant.audienceLabel?.trim() || variant.detailLabel?.trim();
|
compactDetailLabel?.trim() ||
|
||||||
|
variant.audienceLabel?.trim() ||
|
||||||
|
getDetailedAudienceLabel(audience) ||
|
||||||
|
variant.detailLabel?.trim();
|
||||||
return normalizedFallback?.[0]?.toUpperCase() ?? '?';
|
return normalizedFallback?.[0]?.toUpperCase() ?? '?';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getGroupedVariantSummaryLabel(
|
function getGroupedVariantSummaryLabel(
|
||||||
variants: Array<{
|
variants: Array<{
|
||||||
audience: string;
|
audience: AccountAudience;
|
||||||
audienceLabel?: string | null;
|
audienceLabel?: string | null;
|
||||||
detailLabel?: string | null;
|
detailLabel?: string | null;
|
||||||
compactDetailLabel?: string | null;
|
compactDetailLabel?: string | null;
|
||||||
@@ -176,7 +196,10 @@ 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(getVariantCompactDetailLabel(variant, quotas[index]))
|
Boolean(
|
||||||
|
getVariantCompactDetailLabel(variant) ||
|
||||||
|
getDetailedAudienceLabel(getVariantAudience(variant, quotas[index]))
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -274,9 +297,11 @@ export function AccountCard({
|
|||||||
index > 0 && 'border-l border-border/50',
|
index > 0 && 'border-l border-border/50',
|
||||||
variant.audience === 'business'
|
variant.audience === 'business'
|
||||||
? 'bg-sky-500/12 text-sky-700 dark:bg-sky-500/20 dark:text-sky-300'
|
? 'bg-sky-500/12 text-sky-700 dark:bg-sky-500/20 dark:text-sky-300'
|
||||||
: variant.audience === 'personal'
|
: variant.audience === 'free'
|
||||||
? 'bg-emerald-500/12 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300'
|
? 'bg-slate-200/70 text-slate-700 dark:bg-slate-700/40 dark:text-slate-200'
|
||||||
: 'bg-muted text-muted-foreground'
|
: variant.audience === 'personal'
|
||||||
|
? 'bg-emerald-500/12 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300'
|
||||||
|
: 'bg-muted text-muted-foreground'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{getVariantMarkerLabel(
|
{getVariantMarkerLabel(
|
||||||
|
|||||||
@@ -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 { formatAccountVariantPart, getAccountIdentityPresentation } from '@/lib/account-identity';
|
import { 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';
|
||||||
@@ -10,6 +10,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { AccountQuotaPanel } from './account-quota-panel';
|
import { AccountQuotaPanel } from './account-quota-panel';
|
||||||
|
|
||||||
type AccountSurfaceMode = 'compact' | 'detailed';
|
type AccountSurfaceMode = 'compact' | 'detailed';
|
||||||
|
type AccountAudience = 'business' | 'free' | 'personal' | 'unknown';
|
||||||
type AccountTier = 'free' | 'pro' | 'ultra' | 'unknown';
|
type AccountTier = 'free' | 'pro' | 'ultra' | 'unknown';
|
||||||
|
|
||||||
interface AccountSurfaceCardProps {
|
interface AccountSurfaceCardProps {
|
||||||
@@ -36,11 +37,15 @@ interface AccountSurfaceCardProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAudienceBadgeClass(audience: 'business' | 'personal' | 'unknown') {
|
function getAudienceBadgeClass(audience: AccountAudience) {
|
||||||
if (audience === 'business') {
|
if (audience === 'business') {
|
||||||
return 'bg-sky-500/12 text-sky-700 dark:text-sky-300';
|
return 'bg-sky-500/12 text-sky-700 dark:text-sky-300';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (audience === 'free') {
|
||||||
|
return 'bg-slate-200/70 text-slate-700 dark:bg-slate-700/40 dark:text-slate-200';
|
||||||
|
}
|
||||||
|
|
||||||
if (audience === 'personal') {
|
if (audience === 'personal') {
|
||||||
return 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-300';
|
return 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-300';
|
||||||
}
|
}
|
||||||
@@ -54,20 +59,29 @@ function getTierBadgeClass(tier: AccountTier | undefined) {
|
|||||||
: 'bg-yellow-500/15 text-yellow-700 dark:bg-yellow-500/20 dark:text-yellow-400';
|
: 'bg-yellow-500/15 text-yellow-700 dark:bg-yellow-500/20 dark:text-yellow-400';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCompactAudienceBadgeLabel(
|
function getCompactAudienceBadgeLabel(audience: AccountAudience, t: (key: string) => string) {
|
||||||
audience: 'business' | 'personal' | 'unknown',
|
|
||||||
t: (key: string) => string
|
|
||||||
) {
|
|
||||||
if (audience === 'business') return t('accountSurfaceCard.business');
|
if (audience === 'business') return t('accountSurfaceCard.business');
|
||||||
|
if (audience === 'free') return t('accountSurfaceCard.free');
|
||||||
if (audience === 'personal') return t('accountSurfaceCard.personal');
|
if (audience === 'personal') return t('accountSurfaceCard.personal');
|
||||||
return '?';
|
return '?';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCompactDetailBadgeClass(audience: 'business' | 'personal' | 'unknown') {
|
function getDetailedAudienceLabel(audience: AccountAudience): string | null {
|
||||||
|
if (audience === 'business') return 'Business';
|
||||||
|
if (audience === 'free') return 'Free';
|
||||||
|
if (audience === 'personal') return 'Personal';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCompactDetailBadgeClass(audience: AccountAudience) {
|
||||||
if (audience === 'business') {
|
if (audience === 'business') {
|
||||||
return 'border-sky-500/30 bg-sky-500/10 text-sky-700 dark:border-sky-400/30 dark:bg-sky-500/15 dark:text-sky-200';
|
return 'border-sky-500/30 bg-sky-500/10 text-sky-700 dark:border-sky-400/30 dark:bg-sky-500/15 dark:text-sky-200';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (audience === 'free') {
|
||||||
|
return 'border-slate-300/70 bg-slate-100/80 text-slate-700 dark:border-slate-500/40 dark:bg-slate-700/30 dark:text-slate-200';
|
||||||
|
}
|
||||||
|
|
||||||
if (audience === 'personal') {
|
if (audience === 'personal') {
|
||||||
return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:border-emerald-400/30 dark:bg-emerald-500/15 dark:text-emerald-200';
|
return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:border-emerald-400/30 dark:bg-emerald-500/15 dark:text-emerald-200';
|
||||||
}
|
}
|
||||||
@@ -88,12 +102,15 @@ function resolveEffectiveTier(
|
|||||||
return tier;
|
return tier;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCodexPlanDetailLabel(quota: UnifiedQuotaResult | undefined): string | null {
|
function getCodexPlanAudience(quota: UnifiedQuotaResult | undefined): AccountAudience {
|
||||||
if (!quota || !('planType' in quota) || !quota.planType) {
|
if (!quota || !('planType' in quota) || !quota.planType) {
|
||||||
return null;
|
return 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
return formatAccountVariantPart(quota.planType);
|
if (quota.planType === 'team') return 'business';
|
||||||
|
if (quota.planType === 'free') return 'free';
|
||||||
|
if (quota.planType === 'plus') return 'personal';
|
||||||
|
return 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AccountSurfaceCard({
|
export function AccountSurfaceCard({
|
||||||
@@ -124,10 +141,13 @@ export function AccountSurfaceCard({
|
|||||||
const title = displayEmail || identity.email || accountId;
|
const title = displayEmail || identity.email || accountId;
|
||||||
const normalizedProvider = provider.toLowerCase();
|
const normalizedProvider = provider.toLowerCase();
|
||||||
const effectiveTier = resolveEffectiveTier(tier, quota);
|
const effectiveTier = resolveEffectiveTier(tier, quota);
|
||||||
const codexPlanDetailLabel =
|
const codexPlanAudience =
|
||||||
normalizedProvider === 'codex' ? getCodexPlanDetailLabel(quota) : null;
|
normalizedProvider === 'codex' ? getCodexPlanAudience(quota) : 'unknown';
|
||||||
const resolvedDetailLabel = identity.detailLabel ?? codexPlanDetailLabel;
|
const effectiveAudience = identity.audience !== 'unknown' ? identity.audience : codexPlanAudience;
|
||||||
const resolvedCompactDetailLabel = identity.compactDetailLabel ?? codexPlanDetailLabel;
|
const effectiveAudienceLabel =
|
||||||
|
identity.audienceLabel ?? getDetailedAudienceLabel(effectiveAudience);
|
||||||
|
const resolvedDetailLabel = identity.detailLabel;
|
||||||
|
const resolvedCompactDetailLabel = identity.compactDetailLabel;
|
||||||
const showTierBadge =
|
const showTierBadge =
|
||||||
(normalizedProvider === 'agy' ||
|
(normalizedProvider === 'agy' ||
|
||||||
normalizedProvider === 'antigravity' ||
|
normalizedProvider === 'antigravity' ||
|
||||||
@@ -148,17 +168,15 @@ export function AccountSurfaceCard({
|
|||||||
{effectiveTier}
|
{effectiveTier}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{identity.audienceLabel && (
|
{effectiveAudienceLabel && (
|
||||||
<span
|
<span
|
||||||
title={identity.audienceLabel}
|
title={effectiveAudienceLabel}
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-[8px] font-semibold px-1.5 py-0.5 rounded-md shrink-0',
|
'text-[8px] font-semibold px-1.5 py-0.5 rounded-md shrink-0',
|
||||||
identity.audience === 'business'
|
getAudienceBadgeClass(effectiveAudience)
|
||||||
? 'bg-sky-500/15 text-sky-700 dark:bg-sky-500/25 dark:text-sky-300'
|
|
||||||
: 'bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/25 dark:text-emerald-300'
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{getCompactAudienceBadgeLabel(identity.audience, t)}
|
{getCompactAudienceBadgeLabel(effectiveAudience, t)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{resolvedCompactDetailLabel && (
|
{resolvedCompactDetailLabel && (
|
||||||
@@ -166,7 +184,7 @@ export function AccountSurfaceCard({
|
|||||||
title={resolvedDetailLabel ?? resolvedCompactDetailLabel}
|
title={resolvedDetailLabel ?? resolvedCompactDetailLabel}
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-[8px] font-semibold px-1.5 py-0.5 rounded-md border shrink-0',
|
'text-[8px] font-semibold px-1.5 py-0.5 rounded-md border shrink-0',
|
||||||
getCompactDetailBadgeClass(identity.audience)
|
getCompactDetailBadgeClass(effectiveAudience)
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{resolvedCompactDetailLabel}
|
{resolvedCompactDetailLabel}
|
||||||
@@ -227,15 +245,15 @@ export function AccountSurfaceCard({
|
|||||||
{title}
|
{title}
|
||||||
</span>
|
</span>
|
||||||
{isCompact && (compactMetaBadges ?? defaultCompactMetaBadges)}
|
{isCompact && (compactMetaBadges ?? defaultCompactMetaBadges)}
|
||||||
{!isCompact && identity.audienceLabel && (
|
{!isCompact && effectiveAudienceLabel && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-[10px] h-4 px-1.5 border-transparent',
|
'text-[10px] h-4 px-1.5 border-transparent',
|
||||||
getAudienceBadgeClass(identity.audience)
|
getAudienceBadgeClass(effectiveAudience)
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{identity.audienceLabel}
|
{effectiveAudienceLabel}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{!isCompact && resolvedDetailLabel && (
|
{!isCompact && resolvedDetailLabel && (
|
||||||
|
|||||||
@@ -53,7 +53,9 @@ export function AccountStep({
|
|||||||
'text-[10px] h-4 px-1.5 border-transparent',
|
'text-[10px] h-4 px-1.5 border-transparent',
|
||||||
identity.audience === 'business'
|
identity.audience === 'business'
|
||||||
? 'bg-sky-500/12 text-sky-700 dark:text-sky-300'
|
? 'bg-sky-500/12 text-sky-700 dark:text-sky-300'
|
||||||
: 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-300'
|
: identity.audience === 'free'
|
||||||
|
? 'bg-slate-200/70 text-slate-700 dark:bg-slate-700/40 dark:text-slate-200'
|
||||||
|
: 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-300'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{identity.audienceLabel}
|
{identity.audienceLabel}
|
||||||
|
|||||||
@@ -87,7 +87,9 @@ export function VariantStep({
|
|||||||
'text-[10px] h-4 px-1.5 border-transparent',
|
'text-[10px] h-4 px-1.5 border-transparent',
|
||||||
selectedAccountIdentity.audience === 'business'
|
selectedAccountIdentity.audience === 'business'
|
||||||
? 'bg-sky-500/12 text-sky-700 dark:text-sky-300'
|
? 'bg-sky-500/12 text-sky-700 dark:text-sky-300'
|
||||||
: 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-300'
|
: selectedAccountIdentity.audience === 'free'
|
||||||
|
? 'bg-slate-200/70 text-slate-700 dark:bg-slate-700/40 dark:text-slate-200'
|
||||||
|
: 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-300'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{selectedAccountIdentity.audienceLabel}
|
{selectedAccountIdentity.audienceLabel}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
const PERSONAL_PLAN_PARTS = new Set(['free', 'plus', 'pro']);
|
const FREE_PLAN_PARTS = new Set(['free']);
|
||||||
|
const PERSONAL_PLAN_PARTS = new Set(['plus', 'pro']);
|
||||||
const BUSINESS_PLAN_PARTS = new Set(['team']);
|
const BUSINESS_PLAN_PARTS = new Set(['team']);
|
||||||
|
|
||||||
// Keep variant parsing aligned with src/cliproxy/accounts/email-account-identity.ts.
|
// Keep variant parsing aligned with src/cliproxy/accounts/email-account-identity.ts.
|
||||||
// This browser copy stays local because the server module is not bundle-safe for the UI.
|
// This browser copy stays local because the server module is not bundle-safe for the UI.
|
||||||
|
|
||||||
export type AccountAudience = 'business' | 'personal' | 'unknown';
|
export type AccountAudience = 'business' | 'free' | 'personal' | 'unknown';
|
||||||
|
|
||||||
export interface AccountIdentityPresentation {
|
export interface AccountIdentityPresentation {
|
||||||
email: string;
|
email: string;
|
||||||
@@ -105,11 +106,16 @@ function formatWorkspaceLabel(parts: string[]): {
|
|||||||
|
|
||||||
const extraLabel = parts.map(formatAccountVariantPart).filter(Boolean).join(' · ');
|
const extraLabel = parts.map(formatAccountVariantPart).filter(Boolean).join(' · ');
|
||||||
return {
|
return {
|
||||||
detailLabel: extraLabel || 'Team', // TODO i18n: missing key for team fallback
|
detailLabel: extraLabel || null,
|
||||||
compactDetailLabel: extraLabel || 'Team',
|
compactDetailLabel: extraLabel || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatAudienceDetail(parts: string[]): string | null {
|
||||||
|
const label = parts.map(formatAccountVariantPart).filter(Boolean).join(' · ');
|
||||||
|
return label || null;
|
||||||
|
}
|
||||||
|
|
||||||
export function extractAccountVariantKey(
|
export function extractAccountVariantKey(
|
||||||
accountId: string,
|
accountId: string,
|
||||||
email?: string,
|
email?: string,
|
||||||
@@ -166,14 +172,21 @@ export function getAccountIdentityPresentation(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (suffix && FREE_PLAN_PARTS.has(suffix)) {
|
||||||
|
const detailLabel = formatAudienceDetail(parts.slice(0, -1));
|
||||||
|
const inlineLabel = ['Free', detailLabel].filter(Boolean).join(' · '); // TODO i18n: missing key for Free
|
||||||
|
return {
|
||||||
|
email: resolvedEmail,
|
||||||
|
audience: 'free',
|
||||||
|
audienceLabel: 'Free',
|
||||||
|
detailLabel,
|
||||||
|
compactDetailLabel: detailLabel,
|
||||||
|
inlineLabel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (suffix && PERSONAL_PLAN_PARTS.has(suffix)) {
|
if (suffix && PERSONAL_PLAN_PARTS.has(suffix)) {
|
||||||
const detailParts = [
|
const detailLabel = formatAudienceDetail(parts.slice(0, -1));
|
||||||
formatAccountVariantPart(suffix),
|
|
||||||
...parts.slice(0, -1).map(formatAccountVariantPart),
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' · ');
|
|
||||||
const detailLabel = detailParts || formatAccountVariantPart(suffix);
|
|
||||||
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,
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ export interface AccountVisualGroup {
|
|||||||
const AUDIENCE_ORDER: Record<AccountAudience, number> = {
|
const AUDIENCE_ORDER: Record<AccountAudience, number> = {
|
||||||
business: 0,
|
business: 0,
|
||||||
personal: 1,
|
personal: 1,
|
||||||
unknown: 2,
|
free: 2,
|
||||||
|
unknown: 3,
|
||||||
};
|
};
|
||||||
|
|
||||||
function getLatestTimestamp(current?: string, candidate?: string): string | undefined {
|
function getLatestTimestamp(current?: string, candidate?: string): string | undefined {
|
||||||
|
|||||||
@@ -1701,6 +1701,7 @@ const resources = {
|
|||||||
},
|
},
|
||||||
accountSurfaceCard: {
|
accountSurfaceCard: {
|
||||||
business: 'Biz',
|
business: 'Biz',
|
||||||
|
free: 'Free',
|
||||||
personal: 'Pers',
|
personal: 'Pers',
|
||||||
variant: 'Variant',
|
variant: 'Variant',
|
||||||
},
|
},
|
||||||
@@ -4049,6 +4050,7 @@ const resources = {
|
|||||||
},
|
},
|
||||||
accountSurfaceCard: {
|
accountSurfaceCard: {
|
||||||
business: '企业',
|
business: '企业',
|
||||||
|
free: '免费',
|
||||||
personal: '个人',
|
personal: '个人',
|
||||||
variant: '变体',
|
variant: '变体',
|
||||||
},
|
},
|
||||||
@@ -6469,6 +6471,7 @@ const resources = {
|
|||||||
},
|
},
|
||||||
accountSurfaceCard: {
|
accountSurfaceCard: {
|
||||||
business: 'Biz',
|
business: 'Biz',
|
||||||
|
free: 'Miễn phí',
|
||||||
personal: 'Cá nhân',
|
personal: 'Cá nhân',
|
||||||
variant: 'Biến thể',
|
variant: 'Biến thể',
|
||||||
},
|
},
|
||||||
@@ -8671,6 +8674,7 @@ const resources = {
|
|||||||
},
|
},
|
||||||
accountSurfaceCard: {
|
accountSurfaceCard: {
|
||||||
business: 'ビジネス',
|
business: 'ビジネス',
|
||||||
|
free: '無料',
|
||||||
personal: '個人',
|
personal: '個人',
|
||||||
variant: 'バリアント',
|
variant: 'バリアント',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -87,11 +87,11 @@ const groupedAccount: AccountData = {
|
|||||||
isDefault: true,
|
isDefault: true,
|
||||||
successCount: 4,
|
successCount: 4,
|
||||||
failureCount: 1,
|
failureCount: 1,
|
||||||
audience: 'personal',
|
audience: 'free',
|
||||||
audienceLabel: 'Personal',
|
audienceLabel: 'Free',
|
||||||
detailLabel: 'Free',
|
detailLabel: null,
|
||||||
compactDetailLabel: 'Free',
|
compactDetailLabel: null,
|
||||||
inlineLabel: 'Personal · Free',
|
inlineLabel: 'Free',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -99,9 +99,11 @@ const groupedAccount: AccountData = {
|
|||||||
const groupedAccountWithProPersonal: AccountData = {
|
const groupedAccountWithProPersonal: AccountData = {
|
||||||
...groupedAccount,
|
...groupedAccount,
|
||||||
variants: groupedAccount.variants?.map((variant) =>
|
variants: groupedAccount.variants?.map((variant) =>
|
||||||
variant.audience === 'personal'
|
variant.audience === 'free'
|
||||||
? {
|
? {
|
||||||
...variant,
|
...variant,
|
||||||
|
audience: 'personal',
|
||||||
|
audienceLabel: 'Personal',
|
||||||
detailLabel: 'Pro',
|
detailLabel: 'Pro',
|
||||||
compactDetailLabel: 'Pro',
|
compactDetailLabel: 'Pro',
|
||||||
inlineLabel: 'Personal · Pro',
|
inlineLabel: 'Personal · Pro',
|
||||||
@@ -148,9 +150,7 @@ describe('AccountCard grouped quota tooltip', () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(
|
expect(screen.getByTitle('Business · Workspace 04a0f049 • Free')).toBeInTheDocument();
|
||||||
screen.getByTitle('Business · Workspace 04a0f049 • Personal · Free')
|
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Biz')).toBeInTheDocument();
|
expect(screen.getByText('Biz')).toBeInTheDocument();
|
||||||
|
|
||||||
await userEvent.hover(screen.getByText('Business · Workspace 04a0f049'));
|
await userEvent.hover(screen.getByText('Business · Workspace 04a0f049'));
|
||||||
@@ -164,7 +164,14 @@ describe('AccountCard grouped quota tooltip', () => {
|
|||||||
expect(tooltipContent?.className).toContain('text-popover-foreground');
|
expect(tooltipContent?.className).toContain('text-popover-foreground');
|
||||||
expect(tooltipContent?.className).toContain('max-w-[calc(100vw-2rem)]');
|
expect(tooltipContent?.className).toContain('max-w-[calc(100vw-2rem)]');
|
||||||
|
|
||||||
await userEvent.hover(screen.getByText('Personal · Free'));
|
const freeLabels = screen.getAllByText('Free');
|
||||||
|
const quotaLabel = freeLabels[freeLabels.length - 1];
|
||||||
|
expect(quotaLabel).toBeDefined();
|
||||||
|
if (!quotaLabel) {
|
||||||
|
throw new Error('Expected a Free quota label');
|
||||||
|
}
|
||||||
|
|
||||||
|
await userEvent.hover(quotaLabel);
|
||||||
const personalPlan = (await screen.findAllByText('Plan: free')).find((node) =>
|
const personalPlan = (await screen.findAllByText('Plan: free')).find((node) =>
|
||||||
node.closest('[data-slot="tooltip-content"]')
|
node.closest('[data-slot="tooltip-content"]')
|
||||||
);
|
);
|
||||||
@@ -193,6 +200,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('Personal · Free')).not.toBeInTheDocument();
|
expect(screen.queryByText('Free')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ describe('AccountSurfaceCard', () => {
|
|||||||
expect(screen.getByText('pro')).toBeInTheDocument();
|
expect(screen.getByText('pro')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows both personal identity and free-tier detail for compact Codex cards', () => {
|
it('shows free Codex accounts as a single standalone audience badge', () => {
|
||||||
render(
|
render(
|
||||||
<AccountSurfaceCard
|
<AccountSurfaceCard
|
||||||
mode="compact"
|
mode="compact"
|
||||||
@@ -70,12 +70,12 @@ describe('AccountSurfaceCard', () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText('Pers')).toBeInTheDocument();
|
|
||||||
expect(screen.getByTitle('Personal')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Free')).toBeInTheDocument();
|
expect(screen.getByText('Free')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTitle('Free')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Pers')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps richer token-derived Codex personal detail when live quota planType is coarser', () => {
|
it('keeps the simplified personal audience when live quota planType is coarser', () => {
|
||||||
render(
|
render(
|
||||||
<AccountSurfaceCard
|
<AccountSurfaceCard
|
||||||
mode="compact"
|
mode="compact"
|
||||||
@@ -89,7 +89,24 @@ describe('AccountSurfaceCard', () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText('Pro')).toBeInTheDocument();
|
expect(screen.getByText('Pers')).toBeInTheDocument();
|
||||||
expect(screen.queryByText('Free')).not.toBeInTheDocument();
|
expect(screen.queryByText('Free')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('falls back to a single free badge when Codex quota detects a free plan', () => {
|
||||||
|
render(
|
||||||
|
<AccountSurfaceCard
|
||||||
|
mode="compact"
|
||||||
|
provider="codex"
|
||||||
|
accountId="user@example.com"
|
||||||
|
email="user@example.com"
|
||||||
|
displayEmail="user@example.com"
|
||||||
|
quota={createCodexQuotaResult({ planType: 'free' })}
|
||||||
|
showQuota={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Free')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Pers')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,14 +39,33 @@ describe('account identity presentation', () => {
|
|||||||
).toBe('Business · Workspace 04a0f049');
|
).toBe('Business · Workspace 04a0f049');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('formats personal codex plans deliberately instead of leaking raw free suffixes', () => {
|
it('classifies free codex accounts as a standalone audience', () => {
|
||||||
|
const presentation = getAccountIdentityPresentation(
|
||||||
|
'kaidu.kd@gmail.com',
|
||||||
|
'kaidu.kd@gmail.com',
|
||||||
|
'codex-kaidu.kd@gmail.com-free.json'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(presentation.audience).toBe('free');
|
||||||
|
expect(presentation.audienceLabel).toBe('Free');
|
||||||
|
expect(presentation.detailLabel).toBeNull();
|
||||||
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-free.json'
|
'codex-kaidu.kd@gmail.com-free.json'
|
||||||
)
|
)
|
||||||
).toBe('kaidu.kd@gmail.com (Personal · Free)');
|
).toBe('kaidu.kd@gmail.com (Free)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses paid codex personal plans into a single personal audience label', () => {
|
||||||
|
expect(
|
||||||
|
formatAccountDisplayName(
|
||||||
|
'kaidu.kd@gmail.com',
|
||||||
|
'kaidu.kd@gmail.com',
|
||||||
|
'codex-kaidu.kd@gmail.com-plus.json'
|
||||||
|
)
|
||||||
|
).toBe('kaidu.kd@gmail.com (Personal)');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('leaves plain accounts without inferred state untouched', () => {
|
it('leaves plain accounts without inferred state untouched', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user