Show plan quotas and token usage in chat + billing UI (#72)
## Summary Frontend companion to backend [#16](ai_ml_operations/chat_backend#16) / [#17](ai_ml_operations/chat_backend#17) / [#36](ai_ml_operations/chat_backend#36). - **Token display (#16)**: assistant messages show `Tokens in … · out …`; missing usage renders as `—` (never fabricated 0). - **Plan + quota UI (#17/#36)**: Account `BillingSection` loads `GET /finance/subscription/` (plan name, prompt remaining, period tokens). Chat dashboard `UsageSummaryBar` shows the same snapshot. - **Backer path**: Sign-up skips Stripe checkout when `needs_checkout` is false. Requires backend PR on `feature/plans-quotas-token-usage`. ## Test plan - [ ] With subscribed user: usage bar + billing show plan and prompt remaining - [ ] Assistant messages with null tokens show `—`; known counts render numbers - [ ] Backer signup does not redirect to Stripe - [ ] Founders signup still starts checkout - [ ] Jest: `BillingSection`, `ConversationDetailCard`, `finance` helpers passReviewed-on: #72
This commit was merged in pull request #72.
This commit is contained in:
@@ -52,6 +52,87 @@ const paidInvoice = {
|
|||||||
last_modified: '2026-07-01T12:00:00Z',
|
last_modified: '2026-07-01T12:00:00Z',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const foundersSubscription = {
|
||||||
|
plan: {
|
||||||
|
slug: 'founders',
|
||||||
|
name: 'Founders',
|
||||||
|
description: '',
|
||||||
|
price_cents: 1000,
|
||||||
|
currency: 'usd',
|
||||||
|
interval: 'month',
|
||||||
|
is_public: true,
|
||||||
|
is_selectable: true,
|
||||||
|
features: {
|
||||||
|
text_generation: true,
|
||||||
|
image_generation: true,
|
||||||
|
all_future_features: true,
|
||||||
|
},
|
||||||
|
prompt_quota_per_window: 300,
|
||||||
|
prompt_window_hours: 6,
|
||||||
|
monthly_token_quota: null,
|
||||||
|
sort_order: 10,
|
||||||
|
},
|
||||||
|
status: 'active',
|
||||||
|
source: 'stripe',
|
||||||
|
needs_checkout: false,
|
||||||
|
stripe_subscription_id: 'sub_test',
|
||||||
|
usage: {
|
||||||
|
prompts_in_window: 2,
|
||||||
|
prompt_quota: 300,
|
||||||
|
prompts_remaining: 298,
|
||||||
|
window_hours: 6,
|
||||||
|
tokens_in_period: null,
|
||||||
|
tokens_out_period: null,
|
||||||
|
tokens_total_period: null,
|
||||||
|
turns_missing_token_usage: 0,
|
||||||
|
monthly_token_quota: null,
|
||||||
|
tokens_remaining: null,
|
||||||
|
period_start: null,
|
||||||
|
period_end: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptySubscription = {
|
||||||
|
plan: null,
|
||||||
|
status: 'none',
|
||||||
|
source: 'none',
|
||||||
|
needs_checkout: true,
|
||||||
|
stripe_subscription_id: '',
|
||||||
|
usage: {
|
||||||
|
prompts_in_window: 0,
|
||||||
|
prompt_quota: null,
|
||||||
|
prompts_remaining: null,
|
||||||
|
window_hours: 6,
|
||||||
|
tokens_in_period: null,
|
||||||
|
tokens_out_period: null,
|
||||||
|
tokens_total_period: null,
|
||||||
|
turns_missing_token_usage: 0,
|
||||||
|
monthly_token_quota: null,
|
||||||
|
tokens_remaining: null,
|
||||||
|
period_start: null,
|
||||||
|
period_end: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockFinanceGets = ({
|
||||||
|
invoices = [] as unknown[],
|
||||||
|
payments = [] as unknown[],
|
||||||
|
subscription = emptySubscription,
|
||||||
|
} = {}) => {
|
||||||
|
mockGet.mockImplementation((url: string) => {
|
||||||
|
if (url === '/finance/invoices/') {
|
||||||
|
return Promise.resolve({ data: invoices });
|
||||||
|
}
|
||||||
|
if (url === '/finance/payments/') {
|
||||||
|
return Promise.resolve({ data: payments });
|
||||||
|
}
|
||||||
|
if (url === '/finance/subscription/') {
|
||||||
|
return Promise.resolve({ data: subscription });
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(`unexpected GET ${url}`));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const renderBilling = () =>
|
const renderBilling = () =>
|
||||||
render(
|
render(
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
@@ -85,21 +166,17 @@ describe('BillingSection', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders plan summary and invoice history from finance APIs', async () => {
|
it('renders plan summary and invoice history from finance APIs', async () => {
|
||||||
mockGet.mockImplementation((url: string) => {
|
mockFinanceGets({
|
||||||
if (url === '/finance/invoices/') {
|
invoices: [paidInvoice],
|
||||||
return Promise.resolve({ data: [paidInvoice] });
|
subscription: foundersSubscription,
|
||||||
}
|
|
||||||
if (url === '/finance/payments/') {
|
|
||||||
return Promise.resolve({ data: [] });
|
|
||||||
}
|
|
||||||
return Promise.reject(new Error(`unexpected GET ${url}`));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
renderBilling();
|
renderBilling();
|
||||||
|
|
||||||
expect(await screen.findByRole('button', { name: /Manage subscription/i })).toBeInTheDocument();
|
expect(await screen.findByRole('button', { name: /Manage subscription/i })).toBeInTheDocument();
|
||||||
expect(screen.getAllByText('Chat Subscription').length).toBeGreaterThanOrEqual(1);
|
expect(screen.getByText('Founders')).toBeInTheDocument();
|
||||||
expect(screen.getAllByText('Paid').length).toBeGreaterThanOrEqual(1);
|
expect(screen.getByText(/298 \/ 300/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/in — · out —/)).toBeInTheDocument();
|
||||||
expect(screen.getByText('View')).toHaveAttribute(
|
expect(screen.getByText('View')).toHaveAttribute(
|
||||||
'href',
|
'href',
|
||||||
'https://invoice.stripe.com/i/test'
|
'https://invoice.stripe.com/i/test'
|
||||||
@@ -107,11 +184,9 @@ describe('BillingSection', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('redirects to Stripe Customer Portal on manage billing', async () => {
|
it('redirects to Stripe Customer Portal on manage billing', async () => {
|
||||||
mockGet.mockImplementation((url: string) => {
|
mockFinanceGets({
|
||||||
if (url === '/finance/invoices/') {
|
invoices: [paidInvoice],
|
||||||
return Promise.resolve({ data: [paidInvoice] });
|
subscription: foundersSubscription,
|
||||||
}
|
|
||||||
return Promise.resolve({ data: [] });
|
|
||||||
});
|
});
|
||||||
mockPost.mockResolvedValue({
|
mockPost.mockResolvedValue({
|
||||||
data: { portal_url: 'https://billing.stripe.com/p/session/test' },
|
data: { portal_url: 'https://billing.stripe.com/p/session/test' },
|
||||||
@@ -135,7 +210,7 @@ describe('BillingSection', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('shows empty unpaid state and starts checkout', async () => {
|
it('shows empty unpaid state and starts checkout', async () => {
|
||||||
mockGet.mockResolvedValue({ data: [] });
|
mockFinanceGets();
|
||||||
mockPost.mockResolvedValue({
|
mockPost.mockResolvedValue({
|
||||||
data: { checkout_url: 'https://checkout.stripe.com/c/pay/cs_test' },
|
data: { checkout_url: 'https://checkout.stripe.com/c/pay/cs_test' },
|
||||||
});
|
});
|
||||||
@@ -176,11 +251,9 @@ describe('BillingSection', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('shows visible error when portal open fails', async () => {
|
it('shows visible error when portal open fails', async () => {
|
||||||
mockGet.mockImplementation((url: string) => {
|
mockFinanceGets({
|
||||||
if (url === '/finance/invoices/') {
|
invoices: [paidInvoice],
|
||||||
return Promise.resolve({ data: [paidInvoice] });
|
subscription: foundersSubscription,
|
||||||
}
|
|
||||||
return Promise.resolve({ data: [] });
|
|
||||||
});
|
});
|
||||||
mockPost.mockRejectedValue({
|
mockPost.mockRejectedValue({
|
||||||
response: { data: { detail: 'No Stripe customer found' } },
|
response: { data: { detail: 'No Stripe customer found' } },
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import {
|
|||||||
FinancePayment,
|
FinancePayment,
|
||||||
formatBillingDate,
|
formatBillingDate,
|
||||||
formatMoneyCents,
|
formatMoneyCents,
|
||||||
|
formatTokenCount,
|
||||||
humanizeStatus,
|
humanizeStatus,
|
||||||
pickPrimaryInvoice,
|
pickPrimaryInvoice,
|
||||||
|
SubscriptionMe,
|
||||||
} from '../../utils/finance';
|
} from '../../utils/finance';
|
||||||
|
|
||||||
const GlassCard = styled.div`
|
const GlassCard = styled.div`
|
||||||
@@ -155,6 +157,7 @@ function apiErrorMessage(error: unknown, fallback: string): string {
|
|||||||
const BillingSection = (): JSX.Element => {
|
const BillingSection = (): JSX.Element => {
|
||||||
const [invoices, setInvoices] = useState<FinanceInvoice[]>([]);
|
const [invoices, setInvoices] = useState<FinanceInvoice[]>([]);
|
||||||
const [payments, setPayments] = useState<FinancePayment[]>([]);
|
const [payments, setPayments] = useState<FinancePayment[]>([]);
|
||||||
|
const [subscription, setSubscription] = useState<SubscriptionMe | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [listError, setListError] = useState('');
|
const [listError, setListError] = useState('');
|
||||||
const [actionError, setActionError] = useState('');
|
const [actionError, setActionError] = useState('');
|
||||||
@@ -165,15 +168,18 @@ const BillingSection = (): JSX.Element => {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setListError('');
|
setListError('');
|
||||||
try {
|
try {
|
||||||
const [invoiceResponse, paymentResponse] = await Promise.all([
|
const [invoiceResponse, paymentResponse, subscriptionResponse] = await Promise.all([
|
||||||
axiosInstance.get<FinanceInvoice[]>('/finance/invoices/'),
|
axiosInstance.get<FinanceInvoice[]>('/finance/invoices/'),
|
||||||
axiosInstance.get<FinancePayment[]>('/finance/payments/'),
|
axiosInstance.get<FinancePayment[]>('/finance/payments/'),
|
||||||
|
axiosInstance.get<SubscriptionMe>('/finance/subscription/'),
|
||||||
]);
|
]);
|
||||||
setInvoices(Array.isArray(invoiceResponse.data) ? invoiceResponse.data : []);
|
setInvoices(Array.isArray(invoiceResponse.data) ? invoiceResponse.data : []);
|
||||||
setPayments(Array.isArray(paymentResponse.data) ? paymentResponse.data : []);
|
setPayments(Array.isArray(paymentResponse.data) ? paymentResponse.data : []);
|
||||||
|
setSubscription(subscriptionResponse.data || null);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
setInvoices([]);
|
setInvoices([]);
|
||||||
setPayments([]);
|
setPayments([]);
|
||||||
|
setSubscription(null);
|
||||||
setListError(apiErrorMessage(error, 'Could not load billing information.'));
|
setListError(apiErrorMessage(error, 'Could not load billing information.'));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -267,34 +273,72 @@ const BillingSection = (): JSX.Element => {
|
|||||||
<BodyText>Loading billing information…</BodyText>
|
<BodyText>Loading billing information…</BodyText>
|
||||||
) : listError ? (
|
) : listError ? (
|
||||||
<ErrorText role="alert">{listError}</ErrorText>
|
<ErrorText role="alert">{listError}</ErrorText>
|
||||||
) : primaryInvoice ? (
|
) : subscription?.plan || primaryInvoice ? (
|
||||||
<>
|
<>
|
||||||
<SettingRow>
|
<SettingRow>
|
||||||
<SettingLabel>Plan</SettingLabel>
|
<SettingLabel>Plan</SettingLabel>
|
||||||
<SettingValue>
|
<SettingValue>
|
||||||
{primaryInvoice.description || 'Chat Subscription'}
|
{subscription?.plan?.name ||
|
||||||
|
primaryInvoice?.description ||
|
||||||
|
'Chat Subscription'}
|
||||||
</SettingValue>
|
</SettingValue>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow>
|
<SettingRow>
|
||||||
<SettingLabel>Status</SettingLabel>
|
<SettingLabel>Status</SettingLabel>
|
||||||
<SettingValue>{humanizeStatus(primaryInvoice.status)}</SettingValue>
|
|
||||||
</SettingRow>
|
|
||||||
<SettingRow>
|
|
||||||
<SettingLabel>Amount</SettingLabel>
|
|
||||||
<SettingValue>
|
<SettingValue>
|
||||||
{formatMoneyCents(
|
{humanizeStatus(subscription?.status || primaryInvoice?.status || 'none')}
|
||||||
primaryInvoice.amount_paid || primaryInvoice.amount_due,
|
|
||||||
primaryInvoice.currency
|
|
||||||
)}
|
|
||||||
{primaryInvoice.period_end ? ' / period' : ''}
|
|
||||||
</SettingValue>
|
|
||||||
</SettingRow>
|
|
||||||
<SettingRow>
|
|
||||||
<SettingLabel>Period end</SettingLabel>
|
|
||||||
<SettingValue>
|
|
||||||
{formatBillingDate(primaryInvoice.period_end)}
|
|
||||||
</SettingValue>
|
</SettingValue>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
|
{subscription?.plan ? (
|
||||||
|
<SettingRow>
|
||||||
|
<SettingLabel>Price</SettingLabel>
|
||||||
|
<SettingValue>
|
||||||
|
{formatMoneyCents(
|
||||||
|
subscription.plan.price_cents,
|
||||||
|
subscription.plan.currency
|
||||||
|
)}
|
||||||
|
{subscription.plan.interval ? ` / ${subscription.plan.interval}` : ''}
|
||||||
|
</SettingValue>
|
||||||
|
</SettingRow>
|
||||||
|
) : primaryInvoice ? (
|
||||||
|
<SettingRow>
|
||||||
|
<SettingLabel>Amount</SettingLabel>
|
||||||
|
<SettingValue>
|
||||||
|
{formatMoneyCents(
|
||||||
|
primaryInvoice.amount_paid || primaryInvoice.amount_due,
|
||||||
|
primaryInvoice.currency
|
||||||
|
)}
|
||||||
|
{primaryInvoice.period_end ? ' / period' : ''}
|
||||||
|
</SettingValue>
|
||||||
|
</SettingRow>
|
||||||
|
) : null}
|
||||||
|
{subscription?.usage ? (
|
||||||
|
<>
|
||||||
|
<SettingRow>
|
||||||
|
<SettingLabel>Prompts remaining</SettingLabel>
|
||||||
|
<SettingValue>
|
||||||
|
{subscription.usage.prompts_remaining === null
|
||||||
|
? '—'
|
||||||
|
: `${subscription.usage.prompts_remaining} / ${subscription.usage.prompt_quota} (${subscription.usage.window_hours}h)`}
|
||||||
|
</SettingValue>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow>
|
||||||
|
<SettingLabel>Tokens this period</SettingLabel>
|
||||||
|
<SettingValue>
|
||||||
|
in {formatTokenCount(subscription.usage.tokens_in_period)} · out{' '}
|
||||||
|
{formatTokenCount(subscription.usage.tokens_out_period)}
|
||||||
|
</SettingValue>
|
||||||
|
</SettingRow>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{primaryInvoice ? (
|
||||||
|
<SettingRow>
|
||||||
|
<SettingLabel>Period end</SettingLabel>
|
||||||
|
<SettingValue>
|
||||||
|
{formatBillingDate(primaryInvoice.period_end)}
|
||||||
|
</SettingValue>
|
||||||
|
</SettingRow>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<BodyText>
|
<BodyText>
|
||||||
@@ -313,6 +357,10 @@ const BillingSection = (): JSX.Element => {
|
|||||||
>
|
>
|
||||||
{portalLoading ? 'Opening…' : 'Manage subscription'}
|
{portalLoading ? 'Opening…' : 'Manage subscription'}
|
||||||
</StyledButton>
|
</StyledButton>
|
||||||
|
) : subscription?.needs_checkout === false ? (
|
||||||
|
<BodyText style={{ margin: 0 }}>
|
||||||
|
Complimentary access — no payment required.
|
||||||
|
</BodyText>
|
||||||
) : (
|
) : (
|
||||||
<StyledButton
|
<StyledButton
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { ThemeProvider } from 'styled-components';
|
||||||
|
import ConversationDetailCard from './ConversationDetailCard';
|
||||||
|
|
||||||
|
const theme = {
|
||||||
|
main: '#336699',
|
||||||
|
focus: '#224466',
|
||||||
|
darkMode: true,
|
||||||
|
colors: {
|
||||||
|
text: '#ffffff',
|
||||||
|
cardBackground: 'rgba(0,0,0,0.3)',
|
||||||
|
cardBorder: 'rgba(255,255,255,0.1)',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderCard = (props: {
|
||||||
|
message: string;
|
||||||
|
user_created: boolean;
|
||||||
|
tokens_in?: number | null;
|
||||||
|
tokens_out?: number | null;
|
||||||
|
}) =>
|
||||||
|
render(
|
||||||
|
<ThemeProvider theme={theme as never}>
|
||||||
|
<ConversationDetailCard {...props} />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('ConversationDetailCard token usage', () => {
|
||||||
|
it('shows em dash when assistant tokens are missing', () => {
|
||||||
|
renderCard({
|
||||||
|
message: 'Hello from the model',
|
||||||
|
user_created: false,
|
||||||
|
tokens_in: null,
|
||||||
|
tokens_out: null,
|
||||||
|
});
|
||||||
|
expect(screen.getByTestId('message-token-usage')).toHaveTextContent(
|
||||||
|
'Tokens in — · out —'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows reported token counts for assistant messages', () => {
|
||||||
|
renderCard({
|
||||||
|
message: 'Hello from the model',
|
||||||
|
user_created: false,
|
||||||
|
tokens_in: 12,
|
||||||
|
tokens_out: 34,
|
||||||
|
});
|
||||||
|
expect(screen.getByTestId('message-token-usage')).toHaveTextContent(
|
||||||
|
'Tokens in 12 · out 34'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides token meta on user messages', () => {
|
||||||
|
renderCard({
|
||||||
|
message: 'Hi',
|
||||||
|
user_created: true,
|
||||||
|
tokens_in: 1,
|
||||||
|
tokens_out: 2,
|
||||||
|
});
|
||||||
|
expect(screen.queryByTestId('message-token-usage')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import Markdown from "markdown-to-jsx";
|
import Markdown from "markdown-to-jsx";
|
||||||
import styled, { keyframes } from "styled-components";
|
import styled, { keyframes } from "styled-components";
|
||||||
|
import { formatTokenCount } from "../../utils/finance";
|
||||||
|
|
||||||
const fadeIn = keyframes`
|
const fadeIn = keyframes`
|
||||||
from { opacity: 0; transform: translateY(10px); }
|
from { opacity: 0; transform: translateY(10px); }
|
||||||
@@ -9,7 +10,8 @@ const fadeIn = keyframes`
|
|||||||
|
|
||||||
const MessageContainer = styled.div<{ $isUser: boolean }>`
|
const MessageContainer = styled.div<{ $isUser: boolean }>`
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: ${(props) => (props.$isUser ? "flex-end" : "flex-start")};
|
flex-direction: column;
|
||||||
|
align-items: ${(props) => (props.$isUser ? "flex-end" : "flex-start")};
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
animation: ${fadeIn} 0.3s ease-out;
|
animation: ${fadeIn} 0.3s ease-out;
|
||||||
@@ -26,8 +28,8 @@ const Bubble = styled.div<{ $isUser: boolean }>`
|
|||||||
? `linear-gradient(135deg, ${props.theme.main} 0%, ${props.theme.focus} 100%)`
|
? `linear-gradient(135deg, ${props.theme.main} 0%, ${props.theme.focus} 100%)`
|
||||||
: props.theme.darkMode
|
: props.theme.darkMode
|
||||||
? "rgba(255, 255, 255, 0.1)"
|
? "rgba(255, 255, 255, 0.1)"
|
||||||
: "rgba(0, 0, 0, 0.7)"}; // Dark background for AI in light mode as requested
|
: "rgba(0, 0, 0, 0.7)"};
|
||||||
color: #fff; // Text stays white as background is always dark/colored
|
color: #fff;
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
border: 1px solid ${(props) => props.theme.darkMode ? "rgba(255, 255, 255, 0.1)" : "rgba(0, 0, 0, 0.1)"};
|
border: 1px solid ${(props) => props.theme.darkMode ? "rgba(255, 255, 255, 0.1)" : "rgba(0, 0, 0, 0.1)"};
|
||||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
|
||||||
@@ -48,7 +50,7 @@ const Bubble = styled.div<{ $isUser: boolean }>`
|
|||||||
font-family: 'Fira Code', monospace;
|
font-family: 'Fira Code', monospace;
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
}
|
}
|
||||||
|
|
||||||
& a {
|
& a {
|
||||||
color: #a0c4ff;
|
color: #a0c4ff;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
@@ -66,6 +68,15 @@ const Bubble = styled.div<{ $isUser: boolean }>`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const TokenMeta = styled.div<{ $isUser: boolean }>`
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
max-width: 80%;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
opacity: 0.65;
|
||||||
|
color: ${({ theme }) => theme.colors.text};
|
||||||
|
text-align: ${(props) => (props.$isUser ? "right" : "left")};
|
||||||
|
`;
|
||||||
|
|
||||||
const LoadingDot = styled.div`
|
const LoadingDot = styled.div`
|
||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
@@ -76,7 +87,7 @@ const LoadingDot = styled.div`
|
|||||||
|
|
||||||
&:nth-child(1) { animation-delay: -0.32s; }
|
&:nth-child(1) { animation-delay: -0.32s; }
|
||||||
&:nth-child(2) { animation-delay: -0.16s; }
|
&:nth-child(2) { animation-delay: -0.16s; }
|
||||||
|
|
||||||
@keyframes bounce {
|
@keyframes bounce {
|
||||||
0%, 80%, 100% { transform: scale(0); }
|
0%, 80%, 100% { transform: scale(0); }
|
||||||
40% { transform: scale(1); }
|
40% { transform: scale(1); }
|
||||||
@@ -93,9 +104,10 @@ const LoadingContainer = styled.div`
|
|||||||
type ConversationDetailCardProps = {
|
type ConversationDetailCardProps = {
|
||||||
message: string;
|
message: string;
|
||||||
user_created: boolean;
|
user_created: boolean;
|
||||||
|
tokens_in?: number | null;
|
||||||
|
tokens_out?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Custom component for rendering plots
|
|
||||||
const MyPlot = ({ format, image }: { format: string; image: string }) => {
|
const MyPlot = ({ format, image }: { format: string; image: string }) => {
|
||||||
const imageSrc = `data:image/${format};base64,${image}`;
|
const imageSrc = `data:image/${format};base64,${image}`;
|
||||||
return (
|
return (
|
||||||
@@ -107,7 +119,6 @@ const MyPlot = ({ format, image }: { format: string; image: string }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Custom component for rendering errors
|
|
||||||
const MyError = ({ content }: { content: string }) => {
|
const MyError = ({ content }: { content: string }) => {
|
||||||
return (
|
return (
|
||||||
<span style={{ color: "#ff6b6b", fontWeight: "bold", display: "block", marginTop: "0.5rem" }}>
|
<span style={{ color: "#ff6b6b", fontWeight: "bold", display: "block", marginTop: "0.5rem" }}>
|
||||||
@@ -119,6 +130,8 @@ const MyError = ({ content }: { content: string }) => {
|
|||||||
const ConversationDetailCard = ({
|
const ConversationDetailCard = ({
|
||||||
message,
|
message,
|
||||||
user_created,
|
user_created,
|
||||||
|
tokens_in = null,
|
||||||
|
tokens_out = null,
|
||||||
}: ConversationDetailCardProps): JSX.Element => {
|
}: ConversationDetailCardProps): JSX.Element => {
|
||||||
if (message.length === 0) {
|
if (message.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -158,6 +171,8 @@ const ConversationDetailCard = ({
|
|||||||
}
|
}
|
||||||
} catch { }
|
} catch { }
|
||||||
|
|
||||||
|
const showTokens = !user_created;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MessageContainer $isUser={user_created}>
|
<MessageContainer $isUser={user_created}>
|
||||||
<Bubble $isUser={user_created}>
|
<Bubble $isUser={user_created}>
|
||||||
@@ -178,6 +193,11 @@ const ConversationDetailCard = ({
|
|||||||
{contentToAdd}
|
{contentToAdd}
|
||||||
</Markdown>
|
</Markdown>
|
||||||
</Bubble>
|
</Bubble>
|
||||||
|
{showTokens ? (
|
||||||
|
<TokenMeta $isUser={user_created} data-testid="message-token-usage">
|
||||||
|
Tokens in {formatTokenCount(tokens_in)} · out {formatTokenCount(tokens_out)}
|
||||||
|
</TokenMeta>
|
||||||
|
) : null}
|
||||||
</MessageContainer>
|
</MessageContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import { axiosInstance } from '../../../axiosApi';
|
||||||
|
import {
|
||||||
|
formatTokenCount,
|
||||||
|
SubscriptionMe,
|
||||||
|
} from '../../utils/finance';
|
||||||
|
|
||||||
|
const Bar = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem 1.25rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.65rem 1rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: ${({ theme }) => theme.colors.cardBackground};
|
||||||
|
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
||||||
|
color: ${({ theme }) => theme.colors.text};
|
||||||
|
font-size: 0.85rem;
|
||||||
|
opacity: 0.95;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Chip = styled.span`
|
||||||
|
white-space: nowrap;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const UsageSummaryBar = (): JSX.Element | null => {
|
||||||
|
const [subscription, setSubscription] = useState<SubscriptionMe | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await axiosInstance.get<SubscriptionMe>('/finance/subscription/');
|
||||||
|
setSubscription(response.data || null);
|
||||||
|
} catch {
|
||||||
|
setSubscription(null);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
if (!subscription?.plan) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const usage = subscription.usage;
|
||||||
|
const promptsLabel =
|
||||||
|
usage.prompts_remaining === null || usage.prompt_quota === null
|
||||||
|
? '—'
|
||||||
|
: `${usage.prompts_remaining}/${usage.prompt_quota}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Bar data-testid="usage-summary-bar">
|
||||||
|
<Chip>
|
||||||
|
<strong>{subscription.plan.name}</strong>
|
||||||
|
</Chip>
|
||||||
|
<Chip>
|
||||||
|
Prompts left ({usage.window_hours}h): {promptsLabel}
|
||||||
|
</Chip>
|
||||||
|
<Chip>
|
||||||
|
Period tokens in {formatTokenCount(usage.tokens_in_period)} · out{' '}
|
||||||
|
{formatTokenCount(usage.tokens_out_period)}
|
||||||
|
</Chip>
|
||||||
|
</Bar>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UsageSummaryBar;
|
||||||
@@ -72,9 +72,12 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
|||||||
const tempConversations: ConversationPrompt[] = data.map(
|
const tempConversations: ConversationPrompt[] = data.map(
|
||||||
(item) =>
|
(item) =>
|
||||||
new ConversationPrompt({
|
new ConversationPrompt({
|
||||||
|
id: item.id,
|
||||||
message: item.message,
|
message: item.message,
|
||||||
user_created: item.user_created,
|
user_created: item.user_created,
|
||||||
created_timestamp: item.created_timestamp,
|
created_timestamp: item.created_timestamp,
|
||||||
|
tokens_in: item.tokens_in ?? null,
|
||||||
|
tokens_out: item.tokens_out ?? null,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
if (tempConversations.length === 1) {
|
if (tempConversations.length === 1) {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ export interface ConversationPromptType {
|
|||||||
message: string,
|
message: string,
|
||||||
user_created: boolean,
|
user_created: boolean,
|
||||||
created_timestamp: Date,
|
created_timestamp: Date,
|
||||||
|
tokens_in?: number | null,
|
||||||
|
tokens_out?: number | null,
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ConversationPrompt{
|
export class ConversationPrompt{
|
||||||
@@ -11,7 +13,8 @@ export class ConversationPrompt{
|
|||||||
message: string = '';
|
message: string = '';
|
||||||
user_created: boolean = false;
|
user_created: boolean = false;
|
||||||
created_timestamp: Date = new Date();
|
created_timestamp: Date = new Date();
|
||||||
// TODO: add a date time stamp
|
tokens_in: number | null = null;
|
||||||
|
tokens_out: number | null = null;
|
||||||
|
|
||||||
constructor(initializer?: any){
|
constructor(initializer?: any){
|
||||||
if(!initializer) return;
|
if(!initializer) return;
|
||||||
@@ -19,6 +22,8 @@ export class ConversationPrompt{
|
|||||||
if (initializer.message) this.message = initializer.message;
|
if (initializer.message) this.message = initializer.message;
|
||||||
if (initializer.user_created) this.user_created = initializer.user_created;
|
if (initializer.user_created) this.user_created = initializer.user_created;
|
||||||
if (initializer.created_timestamp) this.created_timestamp = initializer.created_timestamp;
|
if (initializer.created_timestamp) this.created_timestamp = initializer.created_timestamp;
|
||||||
|
if (initializer.tokens_in !== undefined) this.tokens_in = initializer.tokens_in;
|
||||||
|
if (initializer.tokens_out !== undefined) this.tokens_out = initializer.tokens_out;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -54,6 +59,8 @@ export interface ConversationType {
|
|||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
conversationDetail: ConversationPrompt[];
|
conversationDetail: ConversationPrompt[];
|
||||||
|
tokens_in?: number | null;
|
||||||
|
tokens_out?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Conversation {
|
export class Conversation {
|
||||||
@@ -61,6 +68,8 @@ export class Conversation {
|
|||||||
title: string ='';
|
title: string ='';
|
||||||
conversationDetail: ConversationPrompt[] = [];
|
conversationDetail: ConversationPrompt[] = [];
|
||||||
account: Account | undefined;
|
account: Account | undefined;
|
||||||
|
tokens_in: number | null = null;
|
||||||
|
tokens_out: number | null = null;
|
||||||
|
|
||||||
constructor(initializer?: any){
|
constructor(initializer?: any){
|
||||||
if(!initializer) return;
|
if(!initializer) return;
|
||||||
@@ -68,6 +77,8 @@ export class Conversation {
|
|||||||
if (initializer.title) this.title = initializer.title;
|
if (initializer.title) this.title = initializer.title;
|
||||||
if (initializer.conversationDetail) this.conversationDetail = initializer.conversationDetail;
|
if (initializer.conversationDetail) this.conversationDetail = initializer.conversationDetail;
|
||||||
if (initializer.account) this.account = initializer.account;
|
if (initializer.account) this.account = initializer.account;
|
||||||
|
if (initializer.tokens_in !== undefined) this.tokens_in = initializer.tokens_in;
|
||||||
|
if (initializer.tokens_out !== undefined) this.tokens_out = initializer.tokens_out;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { MessageContext } from "../../contexts/MessageContext";
|
|||||||
import ParticleBackground from "../../components/ParticleBackground/ParticleBackground";
|
import ParticleBackground from "../../components/ParticleBackground/ParticleBackground";
|
||||||
|
|
||||||
import Header2 from "../../components/Header2/Header2";
|
import Header2 from "../../components/Header2/Header2";
|
||||||
|
import UsageSummaryBar from "../../components/UsageSummaryBar/UsageSummaryBar";
|
||||||
import { AnalyticsEvents, trackEvent } from "../../utils/analytics";
|
import { AnalyticsEvents, trackEvent } from "../../utils/analytics";
|
||||||
|
|
||||||
// Styled Components
|
// Styled Components
|
||||||
@@ -516,6 +517,7 @@ const AsyncDashboardInner = (): JSX.Element => {
|
|||||||
</Sidebar>
|
</Sidebar>
|
||||||
|
|
||||||
<MainContent>
|
<MainContent>
|
||||||
|
<UsageSummaryBar />
|
||||||
<ChatArea>
|
<ChatArea>
|
||||||
{conversationDetails.length > 0 ? (
|
{conversationDetails.length > 0 ? (
|
||||||
conversationDetails.map((convo_detail, index) =>
|
conversationDetails.map((convo_detail, index) =>
|
||||||
@@ -523,12 +525,16 @@ const AsyncDashboardInner = (): JSX.Element => {
|
|||||||
<ConversationDetailCard
|
<ConversationDetailCard
|
||||||
message={convo_detail.message}
|
message={convo_detail.message}
|
||||||
user_created={convo_detail.user_created}
|
user_created={convo_detail.user_created}
|
||||||
|
tokens_in={convo_detail.tokens_in}
|
||||||
|
tokens_out={convo_detail.tokens_out}
|
||||||
key={convo_detail.id || index}
|
key={convo_detail.id || index}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ConversationDetailCard
|
<ConversationDetailCard
|
||||||
message={stateMessage}
|
message={stateMessage}
|
||||||
user_created={convo_detail.user_created}
|
user_created={convo_detail.user_created}
|
||||||
|
tokens_in={convo_detail.tokens_in}
|
||||||
|
tokens_out={convo_detail.tokens_out}
|
||||||
key={convo_detail.id || index}
|
key={convo_detail.id || index}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import React, { useContext, useEffect, useState } from 'react';
|
|||||||
import { applyAccessToken, axiosInstance } from '../../../axiosApi';
|
import { applyAccessToken, axiosInstance } from '../../../axiosApi';
|
||||||
import { setTokens } from '../../auth/tokenStorage';
|
import { setTokens } from '../../auth/tokenStorage';
|
||||||
import { AuthContext } from '../../contexts/AuthContext';
|
import { AuthContext } from '../../contexts/AuthContext';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { AccountContext } from '../../contexts/AccountContext';
|
import { AccountContext } from '../../contexts/AccountContext';
|
||||||
import { AxiosResponse } from 'axios';
|
import { AxiosResponse } from 'axios';
|
||||||
import { Account, AccountType } from '../../data';
|
import { Account, AccountType } from '../../data';
|
||||||
@@ -179,6 +179,7 @@ function checkoutReturnUrls(): { success_url: string; cancel_url: string } {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SignUp = (): JSX.Element => {
|
const SignUp = (): JSX.Element => {
|
||||||
|
const navigate = useNavigate();
|
||||||
const { setAuthentication, setNeedsNewPassword } = useContext(AuthContext);
|
const { setAuthentication, setNeedsNewPassword } = useContext(AuthContext);
|
||||||
const { setAccount } = useContext(AccountContext);
|
const { setAccount } = useContext(AccountContext);
|
||||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||||
@@ -250,6 +251,12 @@ const SignUp = (): JSX.Element => {
|
|||||||
identifyAccount(account);
|
identifyAccount(account);
|
||||||
|
|
||||||
const { success_url, cancel_url } = checkoutReturnUrls();
|
const { success_url, cancel_url } = checkoutReturnUrls();
|
||||||
|
const needsCheckout = registerResponse.data?.needs_checkout !== false;
|
||||||
|
if (!needsCheckout) {
|
||||||
|
navigate('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
trackEvent(AnalyticsEvents.CHECKOUT_STARTED, { source: 'signup' });
|
trackEvent(AnalyticsEvents.CHECKOUT_STARTED, { source: 'signup' });
|
||||||
const checkoutResponse = await axiosInstance.post('/finance/checkout/', {
|
const checkoutResponse = await axiosInstance.post('/finance/checkout/', {
|
||||||
success_url,
|
success_url,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
canOpenBillingPortal,
|
canOpenBillingPortal,
|
||||||
formatMoneyCents,
|
formatMoneyCents,
|
||||||
|
formatTokenCount,
|
||||||
humanizeStatus,
|
humanizeStatus,
|
||||||
pickPrimaryInvoice,
|
pickPrimaryInvoice,
|
||||||
} from './finance';
|
} from './finance';
|
||||||
@@ -30,6 +31,13 @@ describe('finance helpers', () => {
|
|||||||
expect(formatMoneyCents(1000, 'usd')).toMatch(/10/);
|
expect(formatMoneyCents(1000, 'usd')).toMatch(/10/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows em dash for missing token usage', () => {
|
||||||
|
expect(formatTokenCount(null)).toBe('—');
|
||||||
|
expect(formatTokenCount(undefined)).toBe('—');
|
||||||
|
expect(formatTokenCount(0)).toBe('0');
|
||||||
|
expect(formatTokenCount(1200)).toMatch(/1/);
|
||||||
|
});
|
||||||
|
|
||||||
it('humanizes status labels', () => {
|
it('humanizes status labels', () => {
|
||||||
expect(humanizeStatus('past_due')).toBe('Past Due');
|
expect(humanizeStatus('past_due')).toBe('Past Due');
|
||||||
});
|
});
|
||||||
@@ -41,14 +49,13 @@ describe('finance helpers', () => {
|
|||||||
id: 2,
|
id: 2,
|
||||||
status: 'paid',
|
status: 'paid',
|
||||||
stripe_subscription_id: 'sub_1',
|
stripe_subscription_id: 'sub_1',
|
||||||
description: 'Active Plan',
|
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
expect(pickPrimaryInvoice(invoices)?.description).toBe('Active Plan');
|
expect(pickPrimaryInvoice(invoices)?.id).toBe(2);
|
||||||
expect(canOpenBillingPortal(invoices)).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('denies portal when no paid or subscription invoice', () => {
|
it('detects portal access from paid or subscribed invoices', () => {
|
||||||
expect(canOpenBillingPortal([baseInvoice()])).toBe(false);
|
expect(canOpenBillingPortal([baseInvoice({ status: 'open' })])).toBe(false);
|
||||||
|
expect(canOpenBillingPortal([baseInvoice({ status: 'paid' })])).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,6 +31,58 @@ export type FinancePayment = {
|
|||||||
last_modified: string;
|
last_modified: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PlanFeatures = {
|
||||||
|
text_generation: boolean;
|
||||||
|
image_generation: boolean;
|
||||||
|
all_future_features: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SubscriptionPlanInfo = {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
price_cents: number;
|
||||||
|
currency: string;
|
||||||
|
interval: string;
|
||||||
|
is_public: boolean;
|
||||||
|
is_selectable: boolean;
|
||||||
|
features: PlanFeatures;
|
||||||
|
prompt_quota_per_window: number;
|
||||||
|
prompt_window_hours: number;
|
||||||
|
monthly_token_quota: number | null;
|
||||||
|
sort_order: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SubscriptionUsage = {
|
||||||
|
prompts_in_window: number;
|
||||||
|
prompt_quota: number | null;
|
||||||
|
prompts_remaining: number | null;
|
||||||
|
window_hours: number;
|
||||||
|
tokens_in_period: number | null;
|
||||||
|
tokens_out_period: number | null;
|
||||||
|
tokens_total_period: number | null;
|
||||||
|
turns_missing_token_usage: number;
|
||||||
|
monthly_token_quota: number | null;
|
||||||
|
tokens_remaining: number | null;
|
||||||
|
period_start: string | null;
|
||||||
|
period_end: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SubscriptionMe = {
|
||||||
|
plan: SubscriptionPlanInfo | null;
|
||||||
|
status: string;
|
||||||
|
source: string;
|
||||||
|
needs_checkout: boolean;
|
||||||
|
stripe_subscription_id: string;
|
||||||
|
usage: SubscriptionUsage;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Display provider-reported token counts; never invent 0 for missing usage. */
|
||||||
|
export function formatTokenCount(value: number | null | undefined): string {
|
||||||
|
if (value === null || value === undefined) return '—';
|
||||||
|
return value.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
export function formatMoneyCents(amountCents: number, currency: string): string {
|
export function formatMoneyCents(amountCents: number, currency: string): string {
|
||||||
const code = (currency || 'usd').toUpperCase();
|
const code = (currency || 'usd').toUpperCase();
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user