Move plan/quota UI to Account; Gemini-style usage card (#73) #74
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { axiosInstance } from '../../../axiosApi';
|
||||
import {
|
||||
formatBillingDate,
|
||||
formatTokenCount,
|
||||
SubscriptionMe,
|
||||
} from '../../utils/finance';
|
||||
@@ -20,35 +21,72 @@ const GlassCard = styled.div`
|
||||
|
||||
const CardTitle = styled.h2`
|
||||
font-size: 1.8rem;
|
||||
margin-bottom: 1.5rem;
|
||||
margin: 0 0 0.35rem 0;
|
||||
color: ${({ theme }) => theme.colors.text};
|
||||
border-bottom: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
||||
padding-bottom: 1rem;
|
||||
`;
|
||||
|
||||
const SettingRow = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 0;
|
||||
border-bottom: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
||||
const PlanName = styled.p`
|
||||
margin: 0 0 1.75rem 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: ${({ theme }) => theme.colors.text};
|
||||
opacity: 0.85;
|
||||
`;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
const MeterBlock = styled.div`
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
&:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const SettingLabel = styled.span`
|
||||
font-size: 1.1rem;
|
||||
color: ${({ theme }) => theme.colors.text};
|
||||
font-weight: 500;
|
||||
const MeterHeader = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.55rem;
|
||||
`;
|
||||
|
||||
const SettingValue = styled.span`
|
||||
font-size: 1.1rem;
|
||||
const MeterLabel = styled.span`
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: ${({ theme }) => theme.colors.text};
|
||||
opacity: 0.9;
|
||||
text-align: right;
|
||||
`;
|
||||
|
||||
const MeterMeta = styled.span`
|
||||
font-size: 0.85rem;
|
||||
color: ${({ theme }) => theme.colors.text};
|
||||
opacity: 0.65;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const Track = styled.div`
|
||||
width: 100%;
|
||||
height: 0.55rem;
|
||||
border-radius: 999px;
|
||||
background: ${({ theme }) =>
|
||||
theme.darkMode ? 'rgba(255, 255, 255, 0.12)' : 'rgba(0, 0, 0, 0.1)'};
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const Fill = styled.div<{ $pct: number; $warn: boolean }>`
|
||||
height: 100%;
|
||||
width: ${({ $pct }) => `${Math.min(100, Math.max(0, $pct))}%`};
|
||||
border-radius: 999px;
|
||||
background: ${({ theme, $warn }) => ($warn ? '#e67e22' : theme.main)};
|
||||
transition: width 0.35s ease;
|
||||
`;
|
||||
|
||||
const MeterFooter = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-top: 0.4rem;
|
||||
font-size: 0.8rem;
|
||||
color: ${({ theme }) => theme.colors.text};
|
||||
opacity: 0.65;
|
||||
`;
|
||||
|
||||
const BodyText = styled.p`
|
||||
@@ -58,6 +96,49 @@ const BodyText = styled.p`
|
||||
line-height: 1.5;
|
||||
`;
|
||||
|
||||
const UpgradeHint = styled.p`
|
||||
margin: 1.5rem 0 0 0;
|
||||
padding-top: 1.25rem;
|
||||
border-top: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
||||
font-size: 0.9rem;
|
||||
color: ${({ theme }) => theme.colors.text};
|
||||
opacity: 0.7;
|
||||
line-height: 1.45;
|
||||
`;
|
||||
|
||||
function clampPct(used: number, quota: number): number {
|
||||
if (quota <= 0) return 0;
|
||||
return Math.min(100, Math.round((used / quota) * 1000) / 10);
|
||||
}
|
||||
|
||||
type MeterProps = {
|
||||
label: string;
|
||||
meta: string;
|
||||
usedLabel: string;
|
||||
pct: number | null;
|
||||
};
|
||||
|
||||
const UsageMeter = ({ label, meta, usedLabel, pct }: MeterProps): JSX.Element => {
|
||||
const showBar = pct !== null;
|
||||
const warn = showBar && pct >= 90;
|
||||
|
||||
return (
|
||||
<MeterBlock>
|
||||
<MeterHeader>
|
||||
<MeterLabel>{label}</MeterLabel>
|
||||
<MeterMeta>{meta}</MeterMeta>
|
||||
</MeterHeader>
|
||||
<Track aria-hidden={!showBar}>
|
||||
{showBar ? <Fill $pct={pct} $warn={warn} /> : null}
|
||||
</Track>
|
||||
<MeterFooter>
|
||||
<span>{usedLabel}</span>
|
||||
<span>{showBar ? `${pct}%` : '—'}</span>
|
||||
</MeterFooter>
|
||||
</MeterBlock>
|
||||
);
|
||||
};
|
||||
|
||||
const UsageSummaryCard = (): JSX.Element => {
|
||||
const [subscription, setSubscription] = useState<SubscriptionMe | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -79,35 +160,73 @@ const UsageSummaryCard = (): JSX.Element => {
|
||||
}, [load]);
|
||||
|
||||
const usage = subscription?.usage;
|
||||
const promptsLabel =
|
||||
!usage || usage.prompts_remaining === null || usage.prompt_quota === null
|
||||
? '—'
|
||||
: `${usage.prompts_remaining}/${usage.prompt_quota}`;
|
||||
const promptQuota = usage?.prompt_quota ?? null;
|
||||
const promptsUsed = usage?.prompts_in_window ?? 0;
|
||||
const promptPct =
|
||||
promptQuota !== null ? clampPct(promptsUsed, promptQuota) : null;
|
||||
|
||||
const tokenQuota = usage?.monthly_token_quota ?? null;
|
||||
const tokensUsed = usage?.tokens_total_period ?? null;
|
||||
const tokenPct =
|
||||
tokenQuota !== null && tokensUsed !== null
|
||||
? clampPct(tokensUsed, tokenQuota)
|
||||
: tokenQuota !== null
|
||||
? 0
|
||||
: null;
|
||||
|
||||
return (
|
||||
<GlassCard data-testid="usage-summary-card">
|
||||
<CardTitle>Plan & usage</CardTitle>
|
||||
<CardTitle>Usage limit</CardTitle>
|
||||
{loading ? (
|
||||
<BodyText>Loading usage…</BodyText>
|
||||
) : !subscription?.plan ? (
|
||||
<BodyText>No active plan yet.</BodyText>
|
||||
) : (
|
||||
<>
|
||||
<SettingRow>
|
||||
<SettingLabel>Plan</SettingLabel>
|
||||
<SettingValue>{subscription.plan.name}</SettingValue>
|
||||
</SettingRow>
|
||||
<SettingRow>
|
||||
<SettingLabel>Prompts left ({usage?.window_hours ?? '—'}h)</SettingLabel>
|
||||
<SettingValue>{promptsLabel}</SettingValue>
|
||||
</SettingRow>
|
||||
<SettingRow>
|
||||
<SettingLabel>Period tokens</SettingLabel>
|
||||
<SettingValue>
|
||||
in {formatTokenCount(usage?.tokens_in_period ?? null)} · out{' '}
|
||||
{formatTokenCount(usage?.tokens_out_period ?? null)}
|
||||
</SettingValue>
|
||||
</SettingRow>
|
||||
<PlanName>{subscription.plan.name}</PlanName>
|
||||
|
||||
<UsageMeter
|
||||
label="Prompts"
|
||||
meta={`Resets every ${usage?.window_hours ?? '—'}h`}
|
||||
usedLabel={
|
||||
promptQuota === null
|
||||
? `${promptsUsed.toLocaleString()} used`
|
||||
: `${promptsUsed.toLocaleString()} / ${promptQuota.toLocaleString()}`
|
||||
}
|
||||
pct={promptPct}
|
||||
/>
|
||||
|
||||
{tokenQuota !== null ? (
|
||||
<UsageMeter
|
||||
label="Tokens this month"
|
||||
meta={
|
||||
usage?.period_end
|
||||
? `Resets ${formatBillingDate(usage.period_end)}`
|
||||
: 'Calendar month'
|
||||
}
|
||||
usedLabel={
|
||||
tokensUsed === null
|
||||
? `— / ${tokenQuota.toLocaleString()}`
|
||||
: `${formatTokenCount(tokensUsed)} / ${tokenQuota.toLocaleString()}`
|
||||
}
|
||||
pct={tokenPct}
|
||||
/>
|
||||
) : (
|
||||
<UsageMeter
|
||||
label="Tokens this period"
|
||||
meta={
|
||||
usage?.period_end
|
||||
? `Period ends ${formatBillingDate(usage.period_end)}`
|
||||
: 'This month'
|
||||
}
|
||||
usedLabel={`in ${formatTokenCount(usage?.tokens_in_period ?? null)} · out ${formatTokenCount(usage?.tokens_out_period ?? null)}`}
|
||||
pct={null}
|
||||
/>
|
||||
)}
|
||||
|
||||
<UpgradeHint>
|
||||
Need more capacity? Manage or upgrade your plan in Billing below.
|
||||
</UpgradeHint>
|
||||
</>
|
||||
)}
|
||||
</GlassCard>
|
||||
|
||||
Reference in New Issue
Block a user