Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
282bc45b3a | ||
|
|
4501ee8f3d | ||
|
|
6c96c50d99 | ||
|
|
d5a885f42e | ||
|
|
bfed056a0e | ||
|
|
4b685df489 | ||
|
|
70dfa85d22 | ||
|
|
19698658b1 |
+7
-1
@@ -20,7 +20,13 @@ Page views: always via `Tracker` / `tracker.js` on prod + beta.
|
|||||||
| Conversation Created | `CONVERSATION_CREATED` | MessageContext | `{ conversationId }` |
|
| Conversation Created | `CONVERSATION_CREATED` | MessageContext | `{ conversationId }` |
|
||||||
| Message Sent | `MESSAGE_SENT` | AsyncDashboard2 | `{ hasConversation, hasAttachment }` |
|
| Message Sent | `MESSAGE_SENT` | AsyncDashboard2 | `{ hasConversation, hasAttachment }` |
|
||||||
| ToS Acknowledged | `TOS_ACKNOWLEDGED` | TermsOfService | — |
|
| ToS Acknowledged | `TOS_ACKNOWLEDGED` | TermsOfService | — |
|
||||||
| Billing Portal Opened | `BILLING_PORTAL_OPENED` | BillingSection (Account) | — |
|
| Billing Portal Opened | `BILLING_PORTAL_OPENED` | BillingSection (Account) | `{ intent? }` |
|
||||||
|
| Subscription Upgrade Started | `SUBSCRIPTION_UPGRADE_STARTED` | BillingSection, UsageSummaryCard | `{ source, plan_slug? }` |
|
||||||
|
| Plan Change Started | `PLAN_CHANGE_STARTED` | BillingSection | `{ source, plan_slug? }` |
|
||||||
|
| Subscription Cancel Started | `SUBSCRIPTION_CANCEL_STARTED` | BillingSection | `{ source: 'portal' }` |
|
||||||
|
| Account Delete Started | `ACCOUNT_DELETE_STARTED` | DeleteAccountSection | — |
|
||||||
|
| Account Delete Success | `ACCOUNT_DELETE_SUCCESS` | DeleteAccountSection | — |
|
||||||
|
| Account Delete Failed | `ACCOUNT_DELETE_FAILED` | DeleteAccountSection | — |
|
||||||
|
|
||||||
## Identify
|
## Identify
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
/**
|
/**
|
||||||
* Absolute backend OAuth start URL.
|
* Absolute backend OAuth start URL.
|
||||||
* @param {'google'|'microsoft'} provider
|
* @param {'google'|'microsoft'} provider
|
||||||
* @param {'login'|'signup'} intent
|
* @param {'login'|'signup'|'link_drive'|'link_company_drive'} intent
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
export function oauthStartUrl(provider, intent = 'login') {
|
export function oauthStartUrl(provider, intent = 'login') {
|
||||||
@@ -18,7 +18,7 @@ export function oauthStartUrl(provider, intent = 'login') {
|
|||||||
/**
|
/**
|
||||||
* Begin browser redirect to IdP via backend.
|
* Begin browser redirect to IdP via backend.
|
||||||
* @param {'google'|'microsoft'} provider
|
* @param {'google'|'microsoft'} provider
|
||||||
* @param {'login'|'signup'} intent
|
* @param {'login'|'signup'|'link_drive'|'link_company_drive'} intent
|
||||||
*/
|
*/
|
||||||
export function startOAuth(provider, intent = 'login') {
|
export function startOAuth(provider, intent = 'login') {
|
||||||
window.location.assign(oauthStartUrl(provider, intent));
|
window.location.assign(oauthStartUrl(provider, intent));
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ jest.mock('../../utils/analytics', () => ({
|
|||||||
AnalyticsEvents: {
|
AnalyticsEvents: {
|
||||||
BILLING_PORTAL_OPENED: 'Billing Portal Opened',
|
BILLING_PORTAL_OPENED: 'Billing Portal Opened',
|
||||||
CHECKOUT_STARTED: 'Checkout Started',
|
CHECKOUT_STARTED: 'Checkout Started',
|
||||||
|
SUBSCRIPTION_UPGRADE_STARTED: 'Subscription Upgrade Started',
|
||||||
|
PLAN_CHANGE_STARTED: 'Plan Change Started',
|
||||||
|
SUBSCRIPTION_CANCEL_STARTED: 'Subscription Cancel Started',
|
||||||
},
|
},
|
||||||
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
|
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
|
||||||
}));
|
}));
|
||||||
@@ -52,8 +55,7 @@ const paidInvoice = {
|
|||||||
last_modified: '2026-07-01T12:00:00Z',
|
last_modified: '2026-07-01T12:00:00Z',
|
||||||
};
|
};
|
||||||
|
|
||||||
const foundersSubscription = {
|
const foundersPlan = {
|
||||||
plan: {
|
|
||||||
slug: 'founders',
|
slug: 'founders',
|
||||||
name: 'Founders',
|
name: 'Founders',
|
||||||
description: '',
|
description: '',
|
||||||
@@ -71,11 +73,16 @@ const foundersSubscription = {
|
|||||||
prompt_window_hours: 6,
|
prompt_window_hours: 6,
|
||||||
monthly_token_quota: null,
|
monthly_token_quota: null,
|
||||||
sort_order: 10,
|
sort_order: 10,
|
||||||
},
|
};
|
||||||
|
|
||||||
|
const foundersSubscription = {
|
||||||
|
plan: foundersPlan,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
source: 'stripe',
|
source: 'stripe',
|
||||||
needs_checkout: false,
|
needs_checkout: false,
|
||||||
stripe_subscription_id: 'sub_test',
|
stripe_subscription_id: 'sub_test',
|
||||||
|
cancel_at_period_end: false,
|
||||||
|
current_period_end: '2026-08-01T00:00:00Z',
|
||||||
usage: {
|
usage: {
|
||||||
prompts_in_window: 2,
|
prompts_in_window: 2,
|
||||||
prompt_quota: 300,
|
prompt_quota: 300,
|
||||||
@@ -92,12 +99,20 @@ const foundersSubscription = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const complimentarySubscription = {
|
||||||
|
...foundersSubscription,
|
||||||
|
source: 'backer',
|
||||||
|
stripe_subscription_id: '',
|
||||||
|
};
|
||||||
|
|
||||||
const emptySubscription = {
|
const emptySubscription = {
|
||||||
plan: null,
|
plan: null,
|
||||||
status: 'none',
|
status: 'none',
|
||||||
source: 'none',
|
source: 'none',
|
||||||
needs_checkout: true,
|
needs_checkout: true,
|
||||||
stripe_subscription_id: '',
|
stripe_subscription_id: '',
|
||||||
|
cancel_at_period_end: false,
|
||||||
|
current_period_end: null,
|
||||||
usage: {
|
usage: {
|
||||||
prompts_in_window: 0,
|
prompts_in_window: 0,
|
||||||
prompt_quota: null,
|
prompt_quota: null,
|
||||||
@@ -120,10 +135,12 @@ const mockFinanceGets = ({
|
|||||||
invoices = [] as unknown[],
|
invoices = [] as unknown[],
|
||||||
payments = [] as unknown[],
|
payments = [] as unknown[],
|
||||||
subscription = emptySubscription as SubscriptionMock,
|
subscription = emptySubscription as SubscriptionMock,
|
||||||
|
plans = [foundersPlan] as unknown[],
|
||||||
}: {
|
}: {
|
||||||
invoices?: unknown[];
|
invoices?: unknown[];
|
||||||
payments?: unknown[];
|
payments?: unknown[];
|
||||||
subscription?: SubscriptionMock;
|
subscription?: SubscriptionMock;
|
||||||
|
plans?: unknown[];
|
||||||
} = {}) => {
|
} = {}) => {
|
||||||
mockGet.mockImplementation((url: string) => {
|
mockGet.mockImplementation((url: string) => {
|
||||||
if (url === '/finance/invoices/') {
|
if (url === '/finance/invoices/') {
|
||||||
@@ -135,6 +152,9 @@ const mockFinanceGets = ({
|
|||||||
if (url === '/finance/subscription/') {
|
if (url === '/finance/subscription/') {
|
||||||
return Promise.resolve({ data: subscription });
|
return Promise.resolve({ data: subscription });
|
||||||
}
|
}
|
||||||
|
if (url === '/finance/plans/') {
|
||||||
|
return Promise.resolve({ data: plans });
|
||||||
|
}
|
||||||
return Promise.reject(new Error(`unexpected GET ${url}`));
|
return Promise.reject(new Error(`unexpected GET ${url}`));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -171,7 +191,7 @@ describe('BillingSection', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders plan summary and invoice history from finance APIs', async () => {
|
it('renders plan summary and paid subscription management CTAs', async () => {
|
||||||
mockFinanceGets({
|
mockFinanceGets({
|
||||||
invoices: [paidInvoice],
|
invoices: [paidInvoice],
|
||||||
subscription: foundersSubscription,
|
subscription: foundersSubscription,
|
||||||
@@ -179,7 +199,10 @@ describe('BillingSection', () => {
|
|||||||
|
|
||||||
renderBilling();
|
renderBilling();
|
||||||
|
|
||||||
expect(await screen.findByRole('button', { name: /Manage subscription/i })).toBeInTheDocument();
|
expect(await screen.findByRole('button', { name: /^Upgrade$/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /Change plan/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /^Cancel$/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /Manage subscription/i })).toBeInTheDocument();
|
||||||
expect(screen.getByText('Founders')).toBeInTheDocument();
|
expect(screen.getByText('Founders')).toBeInTheDocument();
|
||||||
expect(screen.getByText(/298 \/ 300/)).toBeInTheDocument();
|
expect(screen.getByText(/298 \/ 300/)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/in — · out —/)).toBeInTheDocument();
|
expect(screen.getByText(/in — · out —/)).toBeInTheDocument();
|
||||||
@@ -189,10 +212,11 @@ describe('BillingSection', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('redirects to Stripe Customer Portal on manage billing', async () => {
|
it('opens Stripe portal for change plan when no alternate public plans', async () => {
|
||||||
mockFinanceGets({
|
mockFinanceGets({
|
||||||
invoices: [paidInvoice],
|
invoices: [paidInvoice],
|
||||||
subscription: foundersSubscription,
|
subscription: foundersSubscription,
|
||||||
|
plans: [foundersPlan],
|
||||||
});
|
});
|
||||||
mockPost.mockResolvedValue({
|
mockPost.mockResolvedValue({
|
||||||
data: { portal_url: 'https://billing.stripe.com/p/session/test' },
|
data: { portal_url: 'https://billing.stripe.com/p/session/test' },
|
||||||
@@ -201,20 +225,67 @@ describe('BillingSection', () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderBilling();
|
renderBilling();
|
||||||
|
|
||||||
await screen.findByRole('button', { name: /Manage subscription/i });
|
await screen.findByRole('button', { name: /Change plan/i });
|
||||||
await user.click(screen.getByRole('button', { name: /Manage subscription/i }));
|
await user.click(screen.getByRole('button', { name: /Change plan/i }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockPost).toHaveBeenCalledWith('/finance/portal/', {
|
expect(mockPost).toHaveBeenCalledWith('/finance/portal/', {
|
||||||
return_url: 'http://localhost/account/',
|
return_url: 'http://localhost/account/',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
expect(mockTrackEvent).toHaveBeenCalledWith('Billing Portal Opened');
|
expect(mockTrackEvent).toHaveBeenCalledWith('Plan Change Started', {
|
||||||
|
source: 'account_billing',
|
||||||
|
});
|
||||||
|
expect(mockTrackEvent).toHaveBeenCalledWith('Billing Portal Opened', {
|
||||||
|
intent: 'change',
|
||||||
|
});
|
||||||
expect(assignMock).toHaveBeenCalledWith(
|
expect(assignMock).toHaveBeenCalledWith(
|
||||||
'https://billing.stripe.com/p/session/test'
|
'https://billing.stripe.com/p/session/test'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('confirms cancel then opens portal', async () => {
|
||||||
|
mockFinanceGets({
|
||||||
|
invoices: [paidInvoice],
|
||||||
|
subscription: foundersSubscription,
|
||||||
|
});
|
||||||
|
mockPost.mockResolvedValue({
|
||||||
|
data: { portal_url: 'https://billing.stripe.com/p/session/cancel' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderBilling();
|
||||||
|
|
||||||
|
await screen.findByRole('button', { name: /^Cancel$/i });
|
||||||
|
await user.click(screen.getByRole('button', { name: /^Cancel$/i }));
|
||||||
|
expect(await screen.findByTestId('cancel-confirm-modal')).toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole('button', { name: /Continue to cancel/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockTrackEvent).toHaveBeenCalledWith('Subscription Cancel Started', {
|
||||||
|
source: 'portal',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(assignMock).toHaveBeenCalledWith(
|
||||||
|
'https://billing.stripe.com/p/session/cancel'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows complimentary messaging without cancel', async () => {
|
||||||
|
mockFinanceGets({
|
||||||
|
invoices: [],
|
||||||
|
subscription: complimentarySubscription,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderBilling();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByTestId('complimentary-billing-message')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', { name: /^Cancel$/i })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', { name: /^Upgrade$/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('shows empty unpaid state and starts checkout', async () => {
|
it('shows empty unpaid state and starts checkout', async () => {
|
||||||
mockFinanceGets();
|
mockFinanceGets();
|
||||||
mockPost.mockResolvedValue({
|
mockPost.mockResolvedValue({
|
||||||
|
|||||||
@@ -10,9 +10,13 @@ import {
|
|||||||
formatBillingDate,
|
formatBillingDate,
|
||||||
formatMoneyCents,
|
formatMoneyCents,
|
||||||
formatTokenCount,
|
formatTokenCount,
|
||||||
|
higherSelectablePlans,
|
||||||
humanizeStatus,
|
humanizeStatus,
|
||||||
|
isComplimentarySubscription,
|
||||||
|
otherSelectablePlans,
|
||||||
pickPrimaryInvoice,
|
pickPrimaryInvoice,
|
||||||
SubscriptionMe,
|
SubscriptionMe,
|
||||||
|
SubscriptionPlanInfo,
|
||||||
} from '../../utils/finance';
|
} from '../../utils/finance';
|
||||||
|
|
||||||
const GlassCard = styled.div`
|
const GlassCard = styled.div`
|
||||||
@@ -69,6 +73,14 @@ const BodyText = styled.p`
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const NoticeText = styled.p`
|
||||||
|
color: ${({ theme }) => theme.colors.text};
|
||||||
|
opacity: 0.85;
|
||||||
|
margin: 1rem 0 0 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
`;
|
||||||
|
|
||||||
const ErrorText = styled.p`
|
const ErrorText = styled.p`
|
||||||
color: #ff6b6b;
|
color: #ff6b6b;
|
||||||
margin: 0.75rem 0 0 0;
|
margin: 0.75rem 0 0 0;
|
||||||
@@ -116,6 +128,81 @@ const SecondaryButton = styled(StyledButton)`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const DangerButton = styled(SecondaryButton)`
|
||||||
|
border-color: #ff6b6b66;
|
||||||
|
color: #ff6b6b;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #ff6b6b;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PlanList = styled.ul`
|
||||||
|
list-style: none;
|
||||||
|
margin: 1rem 0 0 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PlanOption = styled.li`
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PlanMeta = styled.div`
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PlanName = styled.div`
|
||||||
|
font-weight: 600;
|
||||||
|
color: ${({ theme }) => theme.colors.text};
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PlanDesc = styled.div`
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: ${({ theme }) => theme.colors.text};
|
||||||
|
opacity: 0.7;
|
||||||
|
line-height: 1.4;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ModalBackdrop = styled.div`
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 1.5rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ModalCard = styled.div`
|
||||||
|
background: ${({ theme }) => theme.colors.cardBackground};
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
||||||
|
border-radius: 1rem;
|
||||||
|
padding: 1.75rem;
|
||||||
|
max-width: 480px;
|
||||||
|
width: 100%;
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ModalTitle = styled.h3`
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
color: ${({ theme }) => theme.colors.text};
|
||||||
|
font-size: 1.35rem;
|
||||||
|
`;
|
||||||
|
|
||||||
const StyledTable = styled.table`
|
const StyledTable = styled.table`
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
@@ -154,32 +241,42 @@ function apiErrorMessage(error: unknown, fallback: string): string {
|
|||||||
return axiosError.response?.data?.detail || axiosError.message || fallback;
|
return axiosError.response?.data?.detail || axiosError.message || fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PortalIntent = 'manage' | 'upgrade' | 'change' | 'cancel';
|
||||||
|
|
||||||
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 [subscription, setSubscription] = useState<SubscriptionMe | null>(null);
|
||||||
|
const [plans, setPlans] = useState<SubscriptionPlanInfo[]>([]);
|
||||||
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('');
|
||||||
const [portalLoading, setPortalLoading] = useState(false);
|
const [portalLoading, setPortalLoading] = useState(false);
|
||||||
const [checkoutLoading, setCheckoutLoading] = useState(false);
|
const [checkoutLoadingSlug, setCheckoutLoadingSlug] = useState<string | null>(null);
|
||||||
|
const [showPlanPicker, setShowPlanPicker] = useState(false);
|
||||||
|
const [planPickerMode, setPlanPickerMode] = useState<'upgrade' | 'change'>('change');
|
||||||
|
const [cancelConfirmOpen, setCancelConfirmOpen] = useState(false);
|
||||||
|
|
||||||
const loadBilling = useCallback(async () => {
|
const loadBilling = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setListError('');
|
setListError('');
|
||||||
try {
|
try {
|
||||||
const [invoiceResponse, paymentResponse, subscriptionResponse] = await Promise.all([
|
const [invoiceResponse, paymentResponse, subscriptionResponse, plansResponse] =
|
||||||
|
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/'),
|
axiosInstance.get<SubscriptionMe>('/finance/subscription/'),
|
||||||
|
axiosInstance.get<SubscriptionPlanInfo[]>('/finance/plans/'),
|
||||||
]);
|
]);
|
||||||
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);
|
setSubscription(subscriptionResponse.data || null);
|
||||||
|
setPlans(Array.isArray(plansResponse.data) ? plansResponse.data : []);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
setInvoices([]);
|
setInvoices([]);
|
||||||
setPayments([]);
|
setPayments([]);
|
||||||
setSubscription(null);
|
setSubscription(null);
|
||||||
|
setPlans([]);
|
||||||
setListError(apiErrorMessage(error, 'Could not load billing information.'));
|
setListError(apiErrorMessage(error, 'Could not load billing information.'));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -192,6 +289,23 @@ const BillingSection = (): JSX.Element => {
|
|||||||
|
|
||||||
const primaryInvoice = useMemo(() => pickPrimaryInvoice(invoices), [invoices]);
|
const primaryInvoice = useMemo(() => pickPrimaryInvoice(invoices), [invoices]);
|
||||||
const hasPortalAccess = useMemo(() => canOpenBillingPortal(invoices), [invoices]);
|
const hasPortalAccess = useMemo(() => canOpenBillingPortal(invoices), [invoices]);
|
||||||
|
const complimentary = useMemo(
|
||||||
|
() => isComplimentarySubscription(subscription, hasPortalAccess),
|
||||||
|
[subscription, hasPortalAccess]
|
||||||
|
);
|
||||||
|
const upgradePlans = useMemo(
|
||||||
|
() => higherSelectablePlans(plans, subscription?.plan),
|
||||||
|
[plans, subscription?.plan]
|
||||||
|
);
|
||||||
|
const changePlans = useMemo(
|
||||||
|
() => otherSelectablePlans(plans, subscription?.plan?.slug),
|
||||||
|
[plans, subscription?.plan?.slug]
|
||||||
|
);
|
||||||
|
const periodEndLabel = useMemo(() => {
|
||||||
|
const end =
|
||||||
|
subscription?.current_period_end || primaryInvoice?.period_end || null;
|
||||||
|
return formatBillingDate(end);
|
||||||
|
}, [subscription?.current_period_end, primaryInvoice?.period_end]);
|
||||||
|
|
||||||
const historyRows = useMemo(() => {
|
const historyRows = useMemo(() => {
|
||||||
if (invoices.length) {
|
if (invoices.length) {
|
||||||
@@ -217,7 +331,7 @@ const BillingSection = (): JSX.Element => {
|
|||||||
}));
|
}));
|
||||||
}, [invoices, payments]);
|
}, [invoices, payments]);
|
||||||
|
|
||||||
const handleManageBilling = async () => {
|
const openPortal = async (intent: PortalIntent) => {
|
||||||
setActionError('');
|
setActionError('');
|
||||||
setPortalLoading(true);
|
setPortalLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -231,7 +345,7 @@ const BillingSection = (): JSX.Element => {
|
|||||||
setActionError('Billing portal could not be opened. Try again.');
|
setActionError('Billing portal could not be opened. Try again.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
trackEvent(AnalyticsEvents.BILLING_PORTAL_OPENED);
|
trackEvent(AnalyticsEvents.BILLING_PORTAL_OPENED, { intent });
|
||||||
window.location.assign(portalUrl);
|
window.location.assign(portalUrl);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
setActionError(
|
setActionError(
|
||||||
@@ -242,15 +356,22 @@ const BillingSection = (): JSX.Element => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStartCheckout = async () => {
|
const handleStartCheckout = async (planSlug?: string, source = 'account_billing') => {
|
||||||
setActionError('');
|
setActionError('');
|
||||||
setCheckoutLoading(true);
|
setCheckoutLoadingSlug(planSlug || '__default__');
|
||||||
try {
|
try {
|
||||||
const { success_url, cancel_url } = checkoutReturnUrls();
|
const { success_url, cancel_url } = checkoutReturnUrls();
|
||||||
trackEvent(AnalyticsEvents.CHECKOUT_STARTED, { source: 'account_billing' });
|
trackEvent(AnalyticsEvents.CHECKOUT_STARTED, {
|
||||||
|
source,
|
||||||
|
...(planSlug ? { plan_slug: planSlug } : {}),
|
||||||
|
});
|
||||||
const response = await axiosInstance.post<{ checkout_url: string }>(
|
const response = await axiosInstance.post<{ checkout_url: string }>(
|
||||||
'/finance/checkout/',
|
'/finance/checkout/',
|
||||||
{ success_url, cancel_url }
|
{
|
||||||
|
success_url,
|
||||||
|
cancel_url,
|
||||||
|
...(planSlug ? { plan_slug: planSlug } : {}),
|
||||||
|
}
|
||||||
);
|
);
|
||||||
const checkoutUrl = response.data?.checkout_url;
|
const checkoutUrl = response.data?.checkout_url;
|
||||||
if (!checkoutUrl) {
|
if (!checkoutUrl) {
|
||||||
@@ -261,10 +382,59 @@ const BillingSection = (): JSX.Element => {
|
|||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
setActionError(apiErrorMessage(error, 'Could not start checkout. Try again.'));
|
setActionError(apiErrorMessage(error, 'Could not start checkout. Try again.'));
|
||||||
} finally {
|
} finally {
|
||||||
setCheckoutLoading(false);
|
setCheckoutLoadingSlug(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleUpgradeClick = () => {
|
||||||
|
trackEvent(AnalyticsEvents.SUBSCRIPTION_UPGRADE_STARTED, {
|
||||||
|
source: 'account_billing',
|
||||||
|
});
|
||||||
|
if (upgradePlans.length > 0) {
|
||||||
|
setPlanPickerMode('upgrade');
|
||||||
|
setShowPlanPicker(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void openPortal('upgrade');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChangePlanClick = () => {
|
||||||
|
trackEvent(AnalyticsEvents.PLAN_CHANGE_STARTED, { source: 'account_billing' });
|
||||||
|
if (changePlans.length > 0) {
|
||||||
|
setPlanPickerMode('change');
|
||||||
|
setShowPlanPicker(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void openPortal('change');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmCancel = async () => {
|
||||||
|
trackEvent(AnalyticsEvents.SUBSCRIPTION_CANCEL_STARTED, { source: 'portal' });
|
||||||
|
setCancelConfirmOpen(false);
|
||||||
|
await openPortal('cancel');
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickerPlans = planPickerMode === 'upgrade' ? upgradePlans : changePlans;
|
||||||
|
|
||||||
|
const selectPlanFromPicker = async (plan: SubscriptionPlanInfo) => {
|
||||||
|
if (planPickerMode === 'upgrade') {
|
||||||
|
trackEvent(AnalyticsEvents.SUBSCRIPTION_UPGRADE_STARTED, {
|
||||||
|
source: 'plan_picker',
|
||||||
|
plan_slug: plan.slug,
|
||||||
|
});
|
||||||
|
// New higher tier via Checkout when selectable; portal otherwise.
|
||||||
|
await handleStartCheckout(plan.slug, 'account_upgrade');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
trackEvent(AnalyticsEvents.PLAN_CHANGE_STARTED, {
|
||||||
|
source: 'plan_picker',
|
||||||
|
plan_slug: plan.slug,
|
||||||
|
});
|
||||||
|
// Existing subscribers change plans in the Stripe portal (proration / PCI).
|
||||||
|
setShowPlanPicker(false);
|
||||||
|
await openPortal('change');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<GlassCard data-testid="billing-section">
|
<GlassCard data-testid="billing-section">
|
||||||
@@ -331,13 +501,19 @@ const BillingSection = (): JSX.Element => {
|
|||||||
</SettingRow>
|
</SettingRow>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{primaryInvoice ? (
|
|
||||||
<SettingRow>
|
<SettingRow>
|
||||||
<SettingLabel>Period end</SettingLabel>
|
<SettingLabel>Period end</SettingLabel>
|
||||||
<SettingValue>
|
<SettingValue>{periodEndLabel}</SettingValue>
|
||||||
{formatBillingDate(primaryInvoice.period_end)}
|
|
||||||
</SettingValue>
|
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
|
{subscription?.cancel_at_period_end ? (
|
||||||
|
<NoticeText data-testid="cancel-scheduled-notice">
|
||||||
|
Cancellation scheduled. You keep access until {periodEndLabel}.
|
||||||
|
</NoticeText>
|
||||||
|
) : null}
|
||||||
|
{subscription?.status === 'canceled' ? (
|
||||||
|
<NoticeText>
|
||||||
|
Subscription canceled. Renew via Complete payment when you are ready.
|
||||||
|
</NoticeText>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -349,25 +525,52 @@ const BillingSection = (): JSX.Element => {
|
|||||||
|
|
||||||
{!loading && !listError && (
|
{!loading && !listError && (
|
||||||
<ButtonRow>
|
<ButtonRow>
|
||||||
{hasPortalAccess ? (
|
{hasPortalAccess && !complimentary ? (
|
||||||
|
<>
|
||||||
<StyledButton
|
<StyledButton
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleManageBilling}
|
onClick={handleUpgradeClick}
|
||||||
|
disabled={portalLoading || Boolean(checkoutLoadingSlug)}
|
||||||
|
>
|
||||||
|
Upgrade
|
||||||
|
</StyledButton>
|
||||||
|
<SecondaryButton
|
||||||
|
type="button"
|
||||||
|
onClick={handleChangePlanClick}
|
||||||
|
disabled={portalLoading || Boolean(checkoutLoadingSlug)}
|
||||||
|
>
|
||||||
|
Change plan
|
||||||
|
</SecondaryButton>
|
||||||
|
{!subscription?.cancel_at_period_end &&
|
||||||
|
subscription?.status !== 'canceled' ? (
|
||||||
|
<DangerButton
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCancelConfirmOpen(true)}
|
||||||
|
disabled={portalLoading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</DangerButton>
|
||||||
|
) : null}
|
||||||
|
<SecondaryButton
|
||||||
|
type="button"
|
||||||
|
onClick={() => openPortal('manage')}
|
||||||
disabled={portalLoading}
|
disabled={portalLoading}
|
||||||
>
|
>
|
||||||
{portalLoading ? 'Opening…' : 'Manage subscription'}
|
{portalLoading ? 'Opening…' : 'Manage subscription'}
|
||||||
</StyledButton>
|
</SecondaryButton>
|
||||||
) : subscription?.needs_checkout === false ? (
|
</>
|
||||||
<BodyText style={{ margin: 0 }}>
|
) : complimentary ? (
|
||||||
Complimentary access — no payment required.
|
<BodyText style={{ margin: 0 }} data-testid="complimentary-billing-message">
|
||||||
|
Complimentary access — no payment required. Plan changes and
|
||||||
|
cancellation are not available for this account.
|
||||||
</BodyText>
|
</BodyText>
|
||||||
) : (
|
) : (
|
||||||
<StyledButton
|
<StyledButton
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleStartCheckout}
|
onClick={() => handleStartCheckout()}
|
||||||
disabled={checkoutLoading}
|
disabled={Boolean(checkoutLoadingSlug)}
|
||||||
>
|
>
|
||||||
{checkoutLoading ? 'Starting…' : 'Complete payment'}
|
{checkoutLoadingSlug ? 'Starting…' : 'Complete payment'}
|
||||||
</StyledButton>
|
</StyledButton>
|
||||||
)}
|
)}
|
||||||
<SecondaryButton type="button" onClick={loadBilling} disabled={loading}>
|
<SecondaryButton type="button" onClick={loadBilling} disabled={loading}>
|
||||||
@@ -375,6 +578,52 @@ const BillingSection = (): JSX.Element => {
|
|||||||
</SecondaryButton>
|
</SecondaryButton>
|
||||||
</ButtonRow>
|
</ButtonRow>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showPlanPicker && pickerPlans.length > 0 ? (
|
||||||
|
<div data-testid="plan-picker">
|
||||||
|
<BodyText style={{ marginTop: '1.25rem', marginBottom: 0 }}>
|
||||||
|
{planPickerMode === 'upgrade'
|
||||||
|
? 'Choose a higher plan. Checkout opens securely in Stripe.'
|
||||||
|
: 'Select another plan, then confirm the change in the Stripe customer portal (price and quotas update there).'}
|
||||||
|
</BodyText>
|
||||||
|
<PlanList>
|
||||||
|
{pickerPlans.map((plan) => (
|
||||||
|
<PlanOption key={plan.slug}>
|
||||||
|
<PlanMeta>
|
||||||
|
<PlanName>{plan.name}</PlanName>
|
||||||
|
<PlanDesc>
|
||||||
|
{formatMoneyCents(plan.price_cents, plan.currency)}
|
||||||
|
{plan.interval ? ` / ${plan.interval}` : ''}
|
||||||
|
{plan.description ? ` — ${plan.description}` : ''}
|
||||||
|
{` · ${plan.prompt_quota_per_window} prompts / ${plan.prompt_window_hours}h`}
|
||||||
|
</PlanDesc>
|
||||||
|
</PlanMeta>
|
||||||
|
<StyledButton
|
||||||
|
type="button"
|
||||||
|
onClick={() => selectPlanFromPicker(plan)}
|
||||||
|
disabled={
|
||||||
|
portalLoading ||
|
||||||
|
checkoutLoadingSlug === plan.slug ||
|
||||||
|
checkoutLoadingSlug === '__default__'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{checkoutLoadingSlug === plan.slug
|
||||||
|
? 'Starting…'
|
||||||
|
: planPickerMode === 'upgrade'
|
||||||
|
? 'Upgrade'
|
||||||
|
: 'Select'}
|
||||||
|
</StyledButton>
|
||||||
|
</PlanOption>
|
||||||
|
))}
|
||||||
|
</PlanList>
|
||||||
|
<ButtonRow>
|
||||||
|
<SecondaryButton type="button" onClick={() => setShowPlanPicker(false)}>
|
||||||
|
Close
|
||||||
|
</SecondaryButton>
|
||||||
|
</ButtonRow>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{actionError ? <ErrorText role="alert">{actionError}</ErrorText> : null}
|
{actionError ? <ErrorText role="alert">{actionError}</ErrorText> : null}
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
|
|
||||||
@@ -421,6 +670,44 @@ const BillingSection = (): JSX.Element => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
|
|
||||||
|
{cancelConfirmOpen ? (
|
||||||
|
<ModalBackdrop
|
||||||
|
role="presentation"
|
||||||
|
onClick={() => setCancelConfirmOpen(false)}
|
||||||
|
data-testid="cancel-confirm-modal"
|
||||||
|
>
|
||||||
|
<ModalCard
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="cancel-subscription-title"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<ModalTitle id="cancel-subscription-title">Cancel subscription?</ModalTitle>
|
||||||
|
<BodyText>
|
||||||
|
You will finish canceling in the Stripe customer portal. Access typically
|
||||||
|
continues until the end of the current billing period
|
||||||
|
{periodEndLabel !== '—' ? ` (${periodEndLabel})` : ''}.
|
||||||
|
</BodyText>
|
||||||
|
<ButtonRow>
|
||||||
|
<DangerButton
|
||||||
|
type="button"
|
||||||
|
onClick={handleConfirmCancel}
|
||||||
|
disabled={portalLoading}
|
||||||
|
>
|
||||||
|
{portalLoading ? 'Opening…' : 'Continue to cancel'}
|
||||||
|
</DangerButton>
|
||||||
|
<SecondaryButton
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCancelConfirmOpen(false)}
|
||||||
|
disabled={portalLoading}
|
||||||
|
>
|
||||||
|
Keep subscription
|
||||||
|
</SecondaryButton>
|
||||||
|
</ButtonRow>
|
||||||
|
</ModalCard>
|
||||||
|
</ModalBackdrop>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+76
-3
@@ -1,9 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
import { ThemeProvider } from 'styled-components';
|
import { ThemeProvider } from 'styled-components';
|
||||||
import ConversationDetailCard from './ConversationDetailCard';
|
import ConversationDetailCard from './ConversationDetailCard';
|
||||||
|
|
||||||
const theme = {
|
const darkTheme = {
|
||||||
main: '#336699',
|
main: '#336699',
|
||||||
focus: '#224466',
|
focus: '#224466',
|
||||||
darkMode: true,
|
darkMode: true,
|
||||||
@@ -14,14 +15,30 @@ const theme = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderCard = (props: {
|
const lightTheme = {
|
||||||
|
main: '#336699',
|
||||||
|
focus: '#224466',
|
||||||
|
darkMode: false,
|
||||||
|
colors: {
|
||||||
|
text: '#111111',
|
||||||
|
cardBackground: 'rgba(255,255,255,0.8)',
|
||||||
|
cardBorder: 'rgba(0,0,0,0.1)',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderCard = (
|
||||||
|
props: {
|
||||||
message: string;
|
message: string;
|
||||||
user_created: boolean;
|
user_created: boolean;
|
||||||
}) =>
|
},
|
||||||
|
theme: typeof darkTheme = darkTheme
|
||||||
|
) =>
|
||||||
render(
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
<ThemeProvider theme={theme as never}>
|
<ThemeProvider theme={theme as never}>
|
||||||
<ConversationDetailCard {...props} />
|
<ConversationDetailCard {...props} />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
</MemoryRouter>
|
||||||
);
|
);
|
||||||
|
|
||||||
describe('ConversationDetailCard', () => {
|
describe('ConversationDetailCard', () => {
|
||||||
@@ -43,4 +60,60 @@ describe('ConversationDetailCard', () => {
|
|||||||
expect(screen.getByText('Hi')).toBeInTheDocument();
|
expect(screen.getByText('Hi')).toBeInTheDocument();
|
||||||
expect(screen.queryByTestId('message-token-usage')).not.toBeInTheDocument();
|
expect(screen.queryByTestId('message-token-usage')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows a friendly upgrade bubble for RAG feature_not_allowed errors (#85)', () => {
|
||||||
|
renderCard({
|
||||||
|
message: JSON.stringify({
|
||||||
|
type: 'error',
|
||||||
|
code: 'feature_not_allowed',
|
||||||
|
content: 'Your plan does not include rag document search.',
|
||||||
|
details: { feature: 'rag' },
|
||||||
|
}),
|
||||||
|
user_created: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText('Your plan does not include rag document search.')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
const upgradeLink = screen.getByRole('link', { name: /upgrade your plan/i });
|
||||||
|
expect(upgradeLink).toBeInTheDocument();
|
||||||
|
expect(upgradeLink).toHaveAttribute('href', '/account/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders unrelated feature_not_allowed errors as a normal inline error, not the upgrade bubble', () => {
|
||||||
|
renderCard({
|
||||||
|
message: JSON.stringify({
|
||||||
|
type: 'error',
|
||||||
|
code: 'feature_not_allowed',
|
||||||
|
content: 'Your plan does not include image generation.',
|
||||||
|
details: { feature: 'image_generation' },
|
||||||
|
}),
|
||||||
|
user_created: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.queryByRole('link', { name: /upgrade your plan/i })).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/image generation/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses theme text color for agent bubble in light mode (#88)', () => {
|
||||||
|
renderCard(
|
||||||
|
{ message: 'Light mode reply', user_created: false },
|
||||||
|
lightTheme
|
||||||
|
);
|
||||||
|
const message = screen.getByText('Light mode reply');
|
||||||
|
const agentBubble = message.closest('div');
|
||||||
|
expect(agentBubble).not.toBeNull();
|
||||||
|
expect(getComputedStyle(agentBubble!).color).toBe('rgb(17, 17, 17)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps light agent text in dark mode (#88)', () => {
|
||||||
|
renderCard(
|
||||||
|
{ message: 'Dark mode reply', user_created: false },
|
||||||
|
darkTheme
|
||||||
|
);
|
||||||
|
const message = screen.getByText('Dark mode reply');
|
||||||
|
const agentBubble = message.closest('div');
|
||||||
|
expect(agentBubble).not.toBeNull();
|
||||||
|
expect(getComputedStyle(agentBubble!).color).toBe('rgb(255, 255, 255)');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import Markdown from "markdown-to-jsx";
|
import Markdown from "markdown-to-jsx";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
import styled, { keyframes } from "styled-components";
|
import styled, { keyframes } from "styled-components";
|
||||||
|
import { isRagFeatureNotAllowed, parseChatErrorPayload } from "../../utils/chatErrors";
|
||||||
|
|
||||||
const fadeIn = keyframes`
|
const fadeIn = keyframes`
|
||||||
from { opacity: 0; transform: translateY(10px); }
|
from { opacity: 0; transform: translateY(10px); }
|
||||||
@@ -27,18 +29,32 @@ 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)"};
|
: "rgba(0, 0, 0, 0.06)"};
|
||||||
color: #fff;
|
color: ${(props) =>
|
||||||
|
props.$isUser || props.theme.darkMode ? "#fff" : props.theme.colors.text};
|
||||||
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) =>
|
||||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
|
props.$isUser
|
||||||
|
? props.theme.darkMode
|
||||||
|
? "rgba(255, 255, 255, 0.1)"
|
||||||
|
: "rgba(0, 0, 0, 0.1)"
|
||||||
|
: props.theme.darkMode
|
||||||
|
? "rgba(255, 255, 255, 0.1)"
|
||||||
|
: "rgba(0, 0, 0, 0.08)"};
|
||||||
|
box-shadow: ${(props) =>
|
||||||
|
props.$isUser || props.theme.darkMode
|
||||||
|
? "0 4px 15px rgba(0, 0, 0, 0.2)"
|
||||||
|
: "0 2px 10px rgba(0, 0, 0, 0.08)"};
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
border-bottom-right-radius: ${(props) => (props.$isUser ? "0.2rem" : "1.2rem")};
|
border-bottom-right-radius: ${(props) => (props.$isUser ? "0.2rem" : "1.2rem")};
|
||||||
border-bottom-left-radius: ${(props) => (props.$isUser ? "1.2rem" : "0.2rem")};
|
border-bottom-left-radius: ${(props) => (props.$isUser ? "1.2rem" : "0.2rem")};
|
||||||
|
|
||||||
& pre {
|
& pre {
|
||||||
background: rgba(0, 0, 0, 0.3);
|
background: ${(props) =>
|
||||||
|
props.$isUser || props.theme.darkMode
|
||||||
|
? "rgba(0, 0, 0, 0.3)"
|
||||||
|
: "rgba(0, 0, 0, 0.06)"};
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
@@ -51,7 +67,8 @@ const Bubble = styled.div<{ $isUser: boolean }>`
|
|||||||
}
|
}
|
||||||
|
|
||||||
& a {
|
& a {
|
||||||
color: #a0c4ff;
|
color: ${(props) =>
|
||||||
|
props.$isUser || props.theme.darkMode ? "#a0c4ff" : props.theme.main};
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +87,7 @@ const Bubble = styled.div<{ $isUser: boolean }>`
|
|||||||
const LoadingDot = styled.div`
|
const LoadingDot = styled.div`
|
||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
background: #fff;
|
background: currentColor;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
margin: 0 4px;
|
margin: 0 4px;
|
||||||
animation: bounce 1.4s infinite ease-in-out both;
|
animation: bounce 1.4s infinite ease-in-out both;
|
||||||
@@ -91,6 +108,27 @@ const LoadingContainer = styled.div`
|
|||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const UpgradeNotice = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.65rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const UpgradeLink = styled(Link)`
|
||||||
|
background: ${(props) => props.theme.main};
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
type ConversationDetailCardProps = {
|
type ConversationDetailCardProps = {
|
||||||
message: string;
|
message: string;
|
||||||
user_created: boolean;
|
user_created: boolean;
|
||||||
@@ -133,6 +171,23 @@ const ConversationDetailCard = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const errorPayload = parseChatErrorPayload(message);
|
||||||
|
if (errorPayload && isRagFeatureNotAllowed(errorPayload)) {
|
||||||
|
return (
|
||||||
|
<MessageContainer $isUser={false}>
|
||||||
|
<Bubble $isUser={false}>
|
||||||
|
<UpgradeNotice>
|
||||||
|
<span>
|
||||||
|
{errorPayload.content ||
|
||||||
|
"Document search (RAG) isn't included in your current plan."}
|
||||||
|
</span>
|
||||||
|
<UpgradeLink to="/account/">Upgrade your plan</UpgradeLink>
|
||||||
|
</UpgradeNotice>
|
||||||
|
</Bubble>
|
||||||
|
</MessageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let contentToAdd = message;
|
let contentToAdd = message;
|
||||||
try {
|
try {
|
||||||
const parsedMessage = JSON.parse(message);
|
const parsedMessage = JSON.parse(message);
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ const DashboardWrapperLayout = ({ children }: DashboardWrapperLayoutProps): JSX.
|
|||||||
const [onMouseEnter, setOnMouseEnter] = useState(false);
|
const [onMouseEnter, setOnMouseEnter] = useState(false);
|
||||||
const [rtlCache, setRtlCache] = useState<EmotionCache | null>(null);
|
const [rtlCache, setRtlCache] = useState<EmotionCache | null>(null);
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
|
const brandName =
|
||||||
|
process.env.REACT_APP_DEPLOY_ENV === "beta" ? "Beta Hesychia" : "Hesychia";
|
||||||
|
|
||||||
// Cache for the rtl
|
// Cache for the rtl
|
||||||
useMemo(() => {
|
useMemo(() => {
|
||||||
@@ -115,7 +117,7 @@ const DashboardWrapperLayout = ({ children }: DashboardWrapperLayoutProps): JSX.
|
|||||||
<Sidenav
|
<Sidenav
|
||||||
color={sidenavColor}
|
color={sidenavColor}
|
||||||
brand={(transparentSidenav && !darkMode) || whiteSidenav ? brandDark : brandWhite}
|
brand={(transparentSidenav && !darkMode) || whiteSidenav ? brandDark : brandWhite}
|
||||||
brandName="Hesychia"
|
brandName={brandName}
|
||||||
routes={[]} // {routes}
|
routes={[]} // {routes}
|
||||||
onMouseEnter={handleOnMouseEnter}
|
onMouseEnter={handleOnMouseEnter}
|
||||||
onMouseLeave={handleOnMouseLeave}
|
onMouseLeave={handleOnMouseLeave}
|
||||||
@@ -136,7 +138,7 @@ const DashboardWrapperLayout = ({ children }: DashboardWrapperLayoutProps): JSX.
|
|||||||
<Sidenav
|
<Sidenav
|
||||||
color={sidenavColor}
|
color={sidenavColor}
|
||||||
brand={(transparentSidenav && !darkMode) || whiteSidenav ? brandDark : brandWhite}
|
brand={(transparentSidenav && !darkMode) || whiteSidenav ? brandDark : brandWhite}
|
||||||
brandName="Hesychia"
|
brandName={brandName}
|
||||||
routes={[]} // {routes}
|
routes={[]} // {routes}
|
||||||
onMouseEnter={handleOnMouseEnter}
|
onMouseEnter={handleOnMouseEnter}
|
||||||
onMouseLeave={handleOnMouseLeave}
|
onMouseLeave={handleOnMouseLeave}
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import { ThemeProvider } from 'styled-components';
|
||||||
|
import { AccountContext } from '../../contexts/AccountContext';
|
||||||
|
import { AuthContext } from '../../contexts/AuthContext';
|
||||||
|
import DeleteAccountSection from './DeleteAccountSection';
|
||||||
|
|
||||||
|
const mockDelete = jest.fn();
|
||||||
|
const mockPost = jest.fn();
|
||||||
|
const mockClearTokens = jest.fn();
|
||||||
|
const mockGetRefreshToken = jest.fn();
|
||||||
|
const mockApplyAccessToken = jest.fn();
|
||||||
|
const mockNavigate = jest.fn();
|
||||||
|
const mockSetAccount = jest.fn();
|
||||||
|
const mockSetAuthentication = jest.fn();
|
||||||
|
const mockTrackEvent = jest.fn();
|
||||||
|
|
||||||
|
jest.mock('../../../axiosApi', () => ({
|
||||||
|
axiosInstance: {
|
||||||
|
delete: (...args: unknown[]) => mockDelete(...args),
|
||||||
|
post: (...args: unknown[]) => mockPost(...args),
|
||||||
|
},
|
||||||
|
applyAccessToken: (...args: unknown[]) => mockApplyAccessToken(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../auth/tokenStorage', () => ({
|
||||||
|
clearTokens: (...args: unknown[]) => mockClearTokens(...args),
|
||||||
|
getRefreshToken: (...args: unknown[]) => mockGetRefreshToken(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('react-router-dom', () => ({
|
||||||
|
...jest.requireActual('react-router-dom'),
|
||||||
|
useNavigate: () => mockNavigate,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../utils/analytics', () => ({
|
||||||
|
AnalyticsEvents: {
|
||||||
|
ACCOUNT_DELETE_STARTED: 'Account Delete Started',
|
||||||
|
ACCOUNT_DELETE_SUCCESS: 'Account Delete Success',
|
||||||
|
ACCOUNT_DELETE_FAILED: 'Account Delete Failed',
|
||||||
|
},
|
||||||
|
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const theme = {
|
||||||
|
main: '#4a90e2',
|
||||||
|
darkMode: true,
|
||||||
|
colors: {
|
||||||
|
text: '#ffffff',
|
||||||
|
cardBackground: 'rgba(0,0,0,0.4)',
|
||||||
|
cardBorder: 'rgba(255,255,255,0.1)',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderSection = () =>
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ThemeProvider theme={theme}>
|
||||||
|
<AuthContext.Provider
|
||||||
|
value={{
|
||||||
|
authenticated: true,
|
||||||
|
setAuthentication: mockSetAuthentication,
|
||||||
|
needsNewPassword: false,
|
||||||
|
setNeedsNewPassword: () => {},
|
||||||
|
loading: false,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AccountContext.Provider
|
||||||
|
value={{
|
||||||
|
account: { email: 'user@example.com' } as never,
|
||||||
|
setAccount: mockSetAccount,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DeleteAccountSection />
|
||||||
|
</AccountContext.Provider>
|
||||||
|
</AuthContext.Provider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('DeleteAccountSection', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockDelete.mockReset();
|
||||||
|
mockPost.mockReset();
|
||||||
|
mockClearTokens.mockReset();
|
||||||
|
mockGetRefreshToken.mockReset();
|
||||||
|
mockApplyAccessToken.mockReset();
|
||||||
|
mockNavigate.mockReset();
|
||||||
|
mockSetAccount.mockReset();
|
||||||
|
mockSetAuthentication.mockReset();
|
||||||
|
mockTrackEvent.mockReset();
|
||||||
|
mockGetRefreshToken.mockReturnValue('refresh-token');
|
||||||
|
mockClearTokens.mockResolvedValue(undefined);
|
||||||
|
mockDelete.mockResolvedValue({ data: { deleted: true } });
|
||||||
|
mockPost.mockResolvedValue({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires email confirmation then deletes and signs out', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderSection();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /Delete my account/i }));
|
||||||
|
expect(await screen.findByTestId('delete-account-modal')).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.type(screen.getByLabelText(/Confirm email/i), 'user@example.com');
|
||||||
|
await user.click(screen.getByRole('button', { name: /^Delete account$/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockDelete).toHaveBeenCalledWith('/user/', {
|
||||||
|
data: { refresh_token: 'refresh-token' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(mockTrackEvent).toHaveBeenCalledWith('Account Delete Started');
|
||||||
|
expect(mockTrackEvent).toHaveBeenCalledWith('Account Delete Success');
|
||||||
|
expect(mockClearTokens).toHaveBeenCalled();
|
||||||
|
expect(mockApplyAccessToken).toHaveBeenCalledWith(null);
|
||||||
|
expect(mockSetAuthentication).toHaveBeenCalledWith(false);
|
||||||
|
expect(mockNavigate).toHaveBeenCalledWith('/signin/');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import React, { useContext, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import { applyAccessToken, axiosInstance } from '../../../axiosApi';
|
||||||
|
import { clearTokens, getRefreshToken } from '../../auth/tokenStorage';
|
||||||
|
import { AccountContext } from '../../contexts/AccountContext';
|
||||||
|
import { AuthContext } from '../../contexts/AuthContext';
|
||||||
|
import { AnalyticsEvents, trackEvent } from '../../utils/analytics';
|
||||||
|
|
||||||
|
const GlassCard = styled.div`
|
||||||
|
background: ${({ theme }) => theme.colors.cardBackground};
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
||||||
|
border-radius: 1rem;
|
||||||
|
padding: 2rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1000px;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const CardTitle = styled.h2`
|
||||||
|
font-size: 1.8rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: ${({ theme }) => theme.colors.text};
|
||||||
|
border-bottom: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const BodyText = styled.p`
|
||||||
|
color: ${({ theme }) => theme.colors.text};
|
||||||
|
opacity: 0.75;
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ErrorText = styled.p`
|
||||||
|
color: #ff6b6b;
|
||||||
|
margin: 0.75rem 0 0 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ButtonRow = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const DangerButton = styled.button`
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #ff6b6b;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
color: #ff6b6b;
|
||||||
|
padding: 0.8rem 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #ff6b6b22;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SecondaryButton = styled.button`
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
color: ${({ theme }) => theme.colors.text};
|
||||||
|
padding: 0.8rem 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ConfirmInput = styled.input`
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
background: ${({ theme }) =>
|
||||||
|
theme.darkMode ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.05)'};
|
||||||
|
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.8rem;
|
||||||
|
color: ${({ theme }) => theme.colors.text};
|
||||||
|
font-size: 1rem;
|
||||||
|
outline: none;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: #ff6b6b;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ModalBackdrop = styled.div`
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 1.5rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ModalCard = styled.div`
|
||||||
|
background: ${({ theme }) => theme.colors.cardBackground};
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
||||||
|
border-radius: 1rem;
|
||||||
|
padding: 1.75rem;
|
||||||
|
max-width: 520px;
|
||||||
|
width: 100%;
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ModalTitle = styled.h3`
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
color: ${({ theme }) => theme.colors.text};
|
||||||
|
font-size: 1.35rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
function apiErrorMessage(error: unknown, fallback: string): string {
|
||||||
|
const axiosError = error as {
|
||||||
|
response?: { data?: { detail?: string } };
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
return axiosError.response?.data?.detail || axiosError.message || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DeleteAccountSection = (): JSX.Element => {
|
||||||
|
const { account, setAccount } = useContext(AccountContext);
|
||||||
|
const { setAuthentication } = useContext(AuthContext);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
|
const [confirmEmail, setConfirmEmail] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const email = account?.email || '';
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
if (loading) return;
|
||||||
|
setConfirmOpen(false);
|
||||||
|
setConfirmEmail('');
|
||||||
|
setError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!email || confirmEmail.trim().toLowerCase() !== email.toLowerCase()) {
|
||||||
|
setError('Type your account email exactly to confirm.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
trackEvent(AnalyticsEvents.ACCOUNT_DELETE_STARTED);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const refreshToken = getRefreshToken();
|
||||||
|
await axiosInstance.delete('/user/', {
|
||||||
|
data: refreshToken ? { refresh_token: refreshToken } : {},
|
||||||
|
});
|
||||||
|
trackEvent(AnalyticsEvents.ACCOUNT_DELETE_SUCCESS);
|
||||||
|
try {
|
||||||
|
if (refreshToken) {
|
||||||
|
await axiosInstance.post('blacklist/', { refresh_token: refreshToken });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Account already deleted; local cleanup is enough.
|
||||||
|
}
|
||||||
|
await clearTokens();
|
||||||
|
applyAccessToken(null);
|
||||||
|
setAuthentication(false);
|
||||||
|
setAccount(undefined);
|
||||||
|
navigate('/signin/');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
trackEvent(AnalyticsEvents.ACCOUNT_DELETE_FAILED);
|
||||||
|
setError(apiErrorMessage(err, 'Could not delete account. Try again.'));
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<GlassCard data-testid="delete-account-section">
|
||||||
|
<CardTitle>Delete account</CardTitle>
|
||||||
|
<BodyText>
|
||||||
|
Permanently deactivate your account. Conversations are hidden and you will
|
||||||
|
be signed out. This cannot be undone from the app.
|
||||||
|
</BodyText>
|
||||||
|
<DangerButton type="button" onClick={() => setConfirmOpen(true)}>
|
||||||
|
Delete my account
|
||||||
|
</DangerButton>
|
||||||
|
</GlassCard>
|
||||||
|
|
||||||
|
{confirmOpen ? (
|
||||||
|
<ModalBackdrop
|
||||||
|
role="presentation"
|
||||||
|
onClick={closeModal}
|
||||||
|
data-testid="delete-account-modal"
|
||||||
|
>
|
||||||
|
<ModalCard
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="delete-account-title"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<ModalTitle id="delete-account-title">Delete your account?</ModalTitle>
|
||||||
|
<BodyText>
|
||||||
|
Type <strong>{email || 'your email'}</strong> to confirm. You will lose
|
||||||
|
access immediately after deletion.
|
||||||
|
</BodyText>
|
||||||
|
<ConfirmInput
|
||||||
|
type="email"
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder="Confirm email"
|
||||||
|
value={confirmEmail}
|
||||||
|
onChange={(event) => setConfirmEmail(event.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
aria-label="Confirm email"
|
||||||
|
/>
|
||||||
|
{error ? <ErrorText role="alert">{error}</ErrorText> : null}
|
||||||
|
<ButtonRow>
|
||||||
|
<DangerButton
|
||||||
|
type="button"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={loading || !confirmEmail}
|
||||||
|
>
|
||||||
|
{loading ? 'Deleting…' : 'Delete account'}
|
||||||
|
</DangerButton>
|
||||||
|
<SecondaryButton type="button" onClick={closeModal} disabled={loading}>
|
||||||
|
Keep account
|
||||||
|
</SecondaryButton>
|
||||||
|
</ButtonRow>
|
||||||
|
</ModalCard>
|
||||||
|
</ModalBackdrop>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DeleteAccountSection;
|
||||||
@@ -0,0 +1,534 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
import styled from "styled-components";
|
||||||
|
import {
|
||||||
|
DriveConnectIntent,
|
||||||
|
DriveConnectionKind,
|
||||||
|
DriveConnectionType,
|
||||||
|
DriveProvider,
|
||||||
|
connectDrive,
|
||||||
|
disconnectDriveConnection,
|
||||||
|
driveSyncProgressPercent,
|
||||||
|
fetchDriveConnections,
|
||||||
|
formatDriveSyncError,
|
||||||
|
parseResourceIdsInput,
|
||||||
|
saveDriveResourceSelection,
|
||||||
|
syncDriveConnection,
|
||||||
|
waitForDriveSyncSettlement,
|
||||||
|
} from "../../utils/drive";
|
||||||
|
|
||||||
|
const Section = styled.div`
|
||||||
|
background: ${({ theme }) => theme.colors.cardBackground};
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
||||||
|
border-radius: 1rem;
|
||||||
|
padding: 2rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1000px;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SectionTitle = styled.h2`
|
||||||
|
font-size: 1.8rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: ${({ theme }) => theme.colors.text};
|
||||||
|
border-bottom: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SectionDescription = styled.p`
|
||||||
|
color: ${({ theme }) => theme.colors.text};
|
||||||
|
opacity: 0.7;
|
||||||
|
margin: 0.75rem 0 1.5rem 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ConnectButtonRow = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ConnectButton = styled.button`
|
||||||
|
background: ${({ theme }) => theme.main};
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
color: #fff;
|
||||||
|
padding: 0.7rem 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px ${({ theme }) => theme.main}66;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ConnectionList = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ConnectionCard = styled.div`
|
||||||
|
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ConnectionHeader = styled.div`
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ConnectionTitle = styled.strong`
|
||||||
|
color: ${({ theme }) => theme.colors.text};
|
||||||
|
font-size: 1.05rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ConnectionMeta = styled.p`
|
||||||
|
margin: 0.35rem 0 0 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: ${({ theme }) => theme.colors.text};
|
||||||
|
opacity: 0.65;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ConnectionActions = styled.div`
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SmallButton = styled.button`
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
color: ${({ theme }) => theme.colors.text};
|
||||||
|
padding: 0.45rem 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: ${({ theme }) => theme.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'};
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const DangerButton = styled(SmallButton)`
|
||||||
|
color: #ff6b6b;
|
||||||
|
border-color: rgba(255, 107, 107, 0.4);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 71, 87, 0.1);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ResourceForm = styled.div`
|
||||||
|
margin-top: 1rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ResourceInput = styled.input`
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
background: ${({ theme }) => theme.darkMode ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.05)'};
|
||||||
|
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
color: ${({ theme }) => theme.colors.text};
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: ${({ theme }) => theme.darkMode ? 'rgba(255, 255, 255, 0.4)' : 'rgba(0, 0, 0, 0.4)'};
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ResourceTagList = styled.div`
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ResourceTag = styled.span`
|
||||||
|
background: ${({ theme }) => theme.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.08)'};
|
||||||
|
color: ${({ theme }) => theme.colors.text};
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const EmptyState = styled.p`
|
||||||
|
color: ${({ theme }) => theme.colors.text};
|
||||||
|
opacity: 0.6;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const AlertBanner = styled.div<{ $tone: 'error' | 'success' }>`
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin: 0 0 1.25rem 0;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid
|
||||||
|
${({ $tone }) => ($tone === 'error' ? 'rgba(255, 107, 107, 0.45)' : 'rgba(46, 204, 113, 0.45)')};
|
||||||
|
background: ${({ $tone }) =>
|
||||||
|
$tone === 'error' ? 'rgba(255, 71, 87, 0.12)' : 'rgba(46, 204, 113, 0.12)'};
|
||||||
|
color: ${({ theme }) => theme.colors.text};
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const AlertDismiss = styled.button`
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: inherit;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.7;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SyncErrorText = styled.p`
|
||||||
|
margin: 0.75rem 0 0 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #ff6b6b;
|
||||||
|
line-height: 1.4;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ProgressWrap = styled.div`
|
||||||
|
margin-top: 0.85rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ProgressTrack = styled.div`
|
||||||
|
width: 100%;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: ${({ theme }) =>
|
||||||
|
theme.darkMode ? 'rgba(255, 255, 255, 0.12)' : 'rgba(0, 0, 0, 0.12)'};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ProgressFill = styled.div<{ $percent: number | null }>`
|
||||||
|
height: 100%;
|
||||||
|
width: ${({ $percent }) => ($percent == null ? '40%' : `${$percent}%`)};
|
||||||
|
border-radius: 999px;
|
||||||
|
background: ${({ theme }) => theme.main};
|
||||||
|
transition: width 0.25s ease;
|
||||||
|
${({ $percent }) =>
|
||||||
|
$percent == null
|
||||||
|
? `
|
||||||
|
animation: sync-indeterminate 1.2s ease-in-out infinite;
|
||||||
|
@keyframes sync-indeterminate {
|
||||||
|
0% { transform: translateX(-100%); }
|
||||||
|
100% { transform: translateX(250%); }
|
||||||
|
}
|
||||||
|
`
|
||||||
|
: ''}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ProgressLabel = styled.p`
|
||||||
|
margin: 0.4rem 0 0 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: ${({ theme }) => theme.colors.text};
|
||||||
|
opacity: 0.7;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PROVIDER_LABELS: Record<DriveProvider, string> = {
|
||||||
|
google: "Google Drive",
|
||||||
|
microsoft: "OneDrive",
|
||||||
|
};
|
||||||
|
|
||||||
|
type PendingAction = 'sync' | 'save' | 'disconnect';
|
||||||
|
|
||||||
|
type DriveConnectionsSectionProps = {
|
||||||
|
kind: DriveConnectionKind;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
connectIntent: DriveConnectIntent;
|
||||||
|
onSynced?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DriveConnectionsSection = ({
|
||||||
|
kind,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
connectIntent,
|
||||||
|
onSynced,
|
||||||
|
}: DriveConnectionsSectionProps): JSX.Element => {
|
||||||
|
const [connections, setConnections] = useState<DriveConnectionType[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadError, setLoadError] = useState(false);
|
||||||
|
const [connectError, setConnectError] = useState<string | null>(null);
|
||||||
|
const [syncBanner, setSyncBanner] = useState<{ tone: 'error' | 'success'; message: string } | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const [resourceInputs, setResourceInputs] = useState<Record<number, string>>({});
|
||||||
|
const [pendingAction, setPendingAction] = useState<Record<number, PendingAction | undefined>>({});
|
||||||
|
|
||||||
|
const loadConnections = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setLoadError(false);
|
||||||
|
try {
|
||||||
|
const all = await fetchDriveConnections();
|
||||||
|
setConnections(all.filter((conn) => (conn.kind || 'personal') === kind));
|
||||||
|
} catch {
|
||||||
|
setLoadError(true);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [kind]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadConnections();
|
||||||
|
}, [loadConnections]);
|
||||||
|
|
||||||
|
const setAction = (id: number, action?: PendingAction) => {
|
||||||
|
setPendingAction((prev) => ({ ...prev, [id]: action }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const upsertConnection = (updated: DriveConnectionType) => {
|
||||||
|
setConnections((prev) => {
|
||||||
|
const exists = prev.some((conn) => conn.id === updated.id);
|
||||||
|
if (!exists) {
|
||||||
|
return (updated.kind || 'personal') === kind ? [...prev, updated] : prev;
|
||||||
|
}
|
||||||
|
return prev.map((conn) => (conn.id === updated.id ? { ...conn, ...updated } : conn));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDisconnect = async (id: number) => {
|
||||||
|
setAction(id, 'disconnect');
|
||||||
|
try {
|
||||||
|
await disconnectDriveConnection(id);
|
||||||
|
setConnections((prev) => prev.filter((conn) => conn.id !== id));
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
setSyncBanner({ tone: 'error', message: 'Could not disconnect this drive. Try again.' });
|
||||||
|
} finally {
|
||||||
|
setAction(id, undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSync = async (id: number) => {
|
||||||
|
setAction(id, 'sync');
|
||||||
|
setSyncBanner(null);
|
||||||
|
try {
|
||||||
|
const enqueued = await syncDriveConnection(id);
|
||||||
|
upsertConnection(enqueued.connection);
|
||||||
|
|
||||||
|
const settled =
|
||||||
|
enqueued.connection.last_sync_status === 'pending'
|
||||||
|
? await waitForDriveSyncSettlement(id, {
|
||||||
|
onProgress: (connection) => upsertConnection(connection),
|
||||||
|
})
|
||||||
|
: enqueued.connection;
|
||||||
|
|
||||||
|
upsertConnection(settled);
|
||||||
|
|
||||||
|
if (settled.last_sync_status === 'error') {
|
||||||
|
setSyncBanner({
|
||||||
|
tone: 'error',
|
||||||
|
message: formatDriveSyncError(settled.last_sync_error),
|
||||||
|
});
|
||||||
|
} else if (settled.last_sync_status === 'ok') {
|
||||||
|
setSyncBanner({ tone: 'success', message: 'Drive sync finished.' });
|
||||||
|
onSynced?.();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
const message =
|
||||||
|
err instanceof Error ? err.message : 'Drive sync failed. Try again.';
|
||||||
|
setSyncBanner({ tone: 'error', message });
|
||||||
|
await loadConnections();
|
||||||
|
} finally {
|
||||||
|
setAction(id, undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveResources = async (id: number) => {
|
||||||
|
const resourceIds = parseResourceIdsInput(resourceInputs[id] || '');
|
||||||
|
if (resourceIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAction(id, 'save');
|
||||||
|
try {
|
||||||
|
const updated = await saveDriveResourceSelection(id, {
|
||||||
|
resource_ids: resourceIds,
|
||||||
|
resource_labels: resourceIds,
|
||||||
|
});
|
||||||
|
setConnections((prev) => prev.map((conn) => (conn.id === id ? { ...conn, ...updated } : conn)));
|
||||||
|
setResourceInputs((prev) => ({ ...prev, [id]: '' }));
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
setSyncBanner({ tone: 'error', message: 'Could not save folder selection. Try again.' });
|
||||||
|
} finally {
|
||||||
|
setAction(id, undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section>
|
||||||
|
<SectionTitle>{title}</SectionTitle>
|
||||||
|
{description && <SectionDescription>{description}</SectionDescription>}
|
||||||
|
|
||||||
|
{syncBanner && (
|
||||||
|
<AlertBanner $tone={syncBanner.tone} role="alert">
|
||||||
|
<span>{syncBanner.message}</span>
|
||||||
|
<AlertDismiss
|
||||||
|
type="button"
|
||||||
|
aria-label="Dismiss"
|
||||||
|
onClick={() => setSyncBanner(null)}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</AlertDismiss>
|
||||||
|
</AlertBanner>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ConnectButtonRow>
|
||||||
|
<ConnectButton
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setConnectError(null);
|
||||||
|
void connectDrive('google', connectIntent).catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
setConnectError('Could not start Google Drive connect. Try again.');
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Connect Google Drive
|
||||||
|
</ConnectButton>
|
||||||
|
<ConnectButton
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setConnectError(null);
|
||||||
|
void connectDrive('microsoft', connectIntent).catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
setConnectError('Could not start OneDrive connect. Try again.');
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Connect OneDrive
|
||||||
|
</ConnectButton>
|
||||||
|
</ConnectButtonRow>
|
||||||
|
|
||||||
|
{connectError && <EmptyState>{connectError}</EmptyState>}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<EmptyState>Loading connections…</EmptyState>
|
||||||
|
) : loadError ? (
|
||||||
|
<EmptyState>Could not load drive connections. Try again later.</EmptyState>
|
||||||
|
) : connections.length === 0 ? (
|
||||||
|
<EmptyState>No drives connected yet.</EmptyState>
|
||||||
|
) : (
|
||||||
|
<ConnectionList>
|
||||||
|
{connections.map((conn) => {
|
||||||
|
const action = pendingAction[conn.id];
|
||||||
|
const selectedLabels = conn.selected_resource_labels?.length
|
||||||
|
? conn.selected_resource_labels
|
||||||
|
: conn.selected_resource_ids || [];
|
||||||
|
const isSyncing = action === 'sync' || conn.last_sync_status === 'pending';
|
||||||
|
const progressPercent = isSyncing ? driveSyncProgressPercent(conn) : null;
|
||||||
|
const processed = conn.sync_processed ?? 0;
|
||||||
|
const total = conn.sync_total ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConnectionCard key={conn.id}>
|
||||||
|
<ConnectionHeader>
|
||||||
|
<div>
|
||||||
|
<ConnectionTitle>{PROVIDER_LABELS[conn.provider] || conn.provider}</ConnectionTitle>
|
||||||
|
<ConnectionMeta>
|
||||||
|
{conn.external_account_email || 'Connected account'}
|
||||||
|
{conn.last_sync_status ? ` · ${conn.last_sync_status}` : ''}
|
||||||
|
{conn.last_sync_at
|
||||||
|
? ` · Last synced ${new Date(conn.last_sync_at).toLocaleString()}`
|
||||||
|
: ''}
|
||||||
|
</ConnectionMeta>
|
||||||
|
</div>
|
||||||
|
<ConnectionActions>
|
||||||
|
<SmallButton
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSync(conn.id)}
|
||||||
|
disabled={Boolean(action) || conn.last_sync_status === 'pending'}
|
||||||
|
>
|
||||||
|
{isSyncing ? 'Syncing…' : 'Sync now'}
|
||||||
|
</SmallButton>
|
||||||
|
<DangerButton
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDisconnect(conn.id)}
|
||||||
|
disabled={Boolean(action)}
|
||||||
|
>
|
||||||
|
{action === 'disconnect' ? 'Disconnecting…' : 'Disconnect'}
|
||||||
|
</DangerButton>
|
||||||
|
</ConnectionActions>
|
||||||
|
</ConnectionHeader>
|
||||||
|
|
||||||
|
{isSyncing && (
|
||||||
|
<ProgressWrap aria-label="Drive sync progress">
|
||||||
|
<ProgressTrack>
|
||||||
|
<ProgressFill $percent={progressPercent} />
|
||||||
|
</ProgressTrack>
|
||||||
|
<ProgressLabel>
|
||||||
|
{progressPercent == null
|
||||||
|
? 'Discovering files…'
|
||||||
|
: `${processed} / ${total} files (${progressPercent}%)`}
|
||||||
|
</ProgressLabel>
|
||||||
|
</ProgressWrap>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{conn.last_sync_status === 'error' && conn.last_sync_error && (
|
||||||
|
<SyncErrorText>{formatDriveSyncError(conn.last_sync_error)}</SyncErrorText>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedLabels.length > 0 && (
|
||||||
|
<ResourceTagList>
|
||||||
|
{selectedLabels.map((label, idx) => (
|
||||||
|
<ResourceTag key={`${conn.id}-${idx}`}>{label}</ResourceTag>
|
||||||
|
))}
|
||||||
|
</ResourceTagList>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ResourceForm>
|
||||||
|
<ResourceInput
|
||||||
|
type="text"
|
||||||
|
placeholder="Folder or file IDs, comma separated"
|
||||||
|
value={resourceInputs[conn.id] || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setResourceInputs((prev) => ({ ...prev, [conn.id]: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SmallButton
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSaveResources(conn.id)}
|
||||||
|
disabled={action === 'save' || !(resourceInputs[conn.id] || '').trim()}
|
||||||
|
>
|
||||||
|
{action === 'save' ? 'Saving…' : 'Save folders'}
|
||||||
|
</SmallButton>
|
||||||
|
</ResourceForm>
|
||||||
|
</ConnectionCard>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ConnectionList>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DriveConnectionsSection;
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import { ThemeProvider } from 'styled-components';
|
||||||
|
import Header2 from './Header2';
|
||||||
|
import { AuthContext } from '../../contexts/AuthContext';
|
||||||
|
import { AccountContext } from '../../contexts/AccountContext';
|
||||||
|
import { resetSubscriptionCache } from '../../hooks/useSubscription';
|
||||||
|
|
||||||
|
const mockGet = jest.fn();
|
||||||
|
const mockPost = jest.fn();
|
||||||
|
|
||||||
|
jest.mock('../../../axiosApi', () => ({
|
||||||
|
axiosInstance: {
|
||||||
|
get: (...args: unknown[]) => mockGet(...args),
|
||||||
|
post: (...args: unknown[]) => mockPost(...args),
|
||||||
|
defaults: { headers: { common: {} as Record<string, string | null> } },
|
||||||
|
},
|
||||||
|
applyAccessToken: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const theme = {
|
||||||
|
main: '#4a90e2',
|
||||||
|
focus: '#224466',
|
||||||
|
darkMode: true,
|
||||||
|
colors: {
|
||||||
|
text: '#ffffff',
|
||||||
|
cardBorder: 'rgba(255,255,255,0.1)',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderHeader = (props: { onOpenConversations?: () => void } = {}) =>
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ThemeProvider theme={theme as never}>
|
||||||
|
<AuthContext.Provider
|
||||||
|
value={{
|
||||||
|
authenticated: true,
|
||||||
|
setAuthentication: jest.fn(),
|
||||||
|
needsNewPassword: false,
|
||||||
|
setNeedsNewPassword: jest.fn(),
|
||||||
|
loading: false,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AccountContext.Provider value={{ account: undefined, setAccount: jest.fn() }}>
|
||||||
|
<Header2 {...props} />
|
||||||
|
</AccountContext.Provider>
|
||||||
|
</AuthContext.Provider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
const subscriptionWithRag = (rag: boolean) => ({
|
||||||
|
data: {
|
||||||
|
plan: {
|
||||||
|
slug: rag ? 'pro' : 'standard',
|
||||||
|
name: rag ? 'Pro' : 'Standard',
|
||||||
|
features: {
|
||||||
|
text_generation: true,
|
||||||
|
image_generation: rag,
|
||||||
|
rag,
|
||||||
|
all_future_features: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status: 'active',
|
||||||
|
source: 'stripe',
|
||||||
|
needs_checkout: false,
|
||||||
|
stripe_subscription_id: 'sub_1',
|
||||||
|
usage: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Header2 (#81 subscription-aware Documents nav link)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockGet.mockReset();
|
||||||
|
mockPost.mockReset();
|
||||||
|
resetSubscriptionCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the Documents link while the plan does not include RAG', async () => {
|
||||||
|
mockGet.mockResolvedValue(subscriptionWithRag(false));
|
||||||
|
|
||||||
|
renderHeader();
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockGet).toHaveBeenCalledWith('/finance/subscription/'));
|
||||||
|
expect(screen.queryByText('Documents')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the Documents link once the plan includes RAG', async () => {
|
||||||
|
mockGet.mockResolvedValue(subscriptionWithRag(true));
|
||||||
|
|
||||||
|
renderHeader();
|
||||||
|
|
||||||
|
// Desktop nav + mobile dropdown both render a "Documents" link.
|
||||||
|
expect(await screen.findAllByText('Documents')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Header2 (#87 conversations toggle beside logo)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockGet.mockReset();
|
||||||
|
mockPost.mockReset();
|
||||||
|
resetSubscriptionCache();
|
||||||
|
mockGet.mockResolvedValue(subscriptionWithRag(false));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render conversations toggle when callback is omitted', () => {
|
||||||
|
renderHeader();
|
||||||
|
expect(screen.queryByRole('button', { name: /open conversations/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders conversations toggle next to brand when callback is provided', () => {
|
||||||
|
const onOpenConversations = jest.fn();
|
||||||
|
renderHeader({ onOpenConversations });
|
||||||
|
|
||||||
|
// Mobile-only via CSS (display:none in jsdom desktop width); query by aria-label.
|
||||||
|
const toggle = screen.getByLabelText('Open conversations');
|
||||||
|
expect(toggle).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Hesychia')).toBeInTheDocument();
|
||||||
|
toggle.click();
|
||||||
|
expect(onOpenConversations).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@ import { AuthContext } from '../../contexts/AuthContext';
|
|||||||
import { AccountContext } from '../../contexts/AccountContext';
|
import { AccountContext } from '../../contexts/AccountContext';
|
||||||
import { applyAccessToken, axiosInstance } from '../../../axiosApi';
|
import { applyAccessToken, axiosInstance } from '../../../axiosApi';
|
||||||
import { clearTokens, getRefreshToken } from '../../auth/tokenStorage';
|
import { clearTokens, getRefreshToken } from '../../auth/tokenStorage';
|
||||||
|
import { useSubscription } from '../../hooks/useSubscription';
|
||||||
import hesychiaMark from '../../assets/brand/hesychia-mark.png';
|
import hesychiaMark from '../../assets/brand/hesychia-mark.png';
|
||||||
|
|
||||||
const HeaderContainer = styled.header`
|
const HeaderContainer = styled.header`
|
||||||
@@ -30,6 +31,50 @@ const HeaderContainer = styled.header`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const BrandCluster = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ConversationsToggle = styled.button`
|
||||||
|
display: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.35rem 0.55rem;
|
||||||
|
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
||||||
|
border-radius: 0.65rem;
|
||||||
|
background: ${({ theme }) =>
|
||||||
|
theme.darkMode ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.06)'};
|
||||||
|
color: ${({ theme }) => theme.colors.text};
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: ${({ theme }) =>
|
||||||
|
theme.darkMode ? 'rgba(255, 255, 255, 0.14)' : 'rgba(0, 0, 0, 0.1)'};
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ConversationsToggleLabel = styled.span`
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
@media (max-width: 380px) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
const Logo = styled.div`
|
const Logo = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -50,6 +95,8 @@ const LogoWordmark = styled.h4`
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
@@ -138,6 +185,14 @@ const HamburgerIcon = ({ color }: { color: string }) => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const ConversationsMenuIcon = ({ color }: { color: string }) => (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
|
<path d="M3 12H21" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<path d="M3 6H21" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<path d="M3 18H21" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
const CloseIcon = ({ color }: { color: string }) => (
|
const CloseIcon = ({ color }: { color: string }) => (
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M18 6L6 18" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
<path d="M18 6L6 18" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
@@ -149,14 +204,22 @@ type Header2Props = {
|
|||||||
absolute?: Boolean;
|
absolute?: Boolean;
|
||||||
light?: Boolean;
|
light?: Boolean;
|
||||||
isMini?: Boolean;
|
isMini?: Boolean;
|
||||||
|
/** Mobile: open conversations drawer. Rendered inline beside logo when set. */
|
||||||
|
onOpenConversations?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Header2 = ({ absolute = false, light = false, isMini = false }: Header2Props): JSX.Element => {
|
const Header2 = ({
|
||||||
|
absolute = false,
|
||||||
|
light = false,
|
||||||
|
isMini = false,
|
||||||
|
onOpenConversations,
|
||||||
|
}: Header2Props): JSX.Element => {
|
||||||
const { setAuthentication } = useContext(AuthContext);
|
const { setAuthentication } = useContext(AuthContext);
|
||||||
const { setAccount } = useContext(AccountContext);
|
const { setAccount } = useContext(AccountContext);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
const { hasRag } = useSubscription();
|
||||||
|
|
||||||
const handleSignOut = async () => {
|
const handleSignOut = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -180,16 +243,28 @@ const Header2 = ({ absolute = false, light = false, isMini = false }: Header2Pro
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<HeaderContainer>
|
<HeaderContainer>
|
||||||
|
<BrandCluster>
|
||||||
|
{onOpenConversations && (
|
||||||
|
<ConversationsToggle
|
||||||
|
type="button"
|
||||||
|
onClick={onOpenConversations}
|
||||||
|
aria-label="Open conversations"
|
||||||
|
>
|
||||||
|
<ConversationsMenuIcon color={theme.colors.text} />
|
||||||
|
<ConversationsToggleLabel>Conversations</ConversationsToggleLabel>
|
||||||
|
</ConversationsToggle>
|
||||||
|
)}
|
||||||
<Logo onClick={() => navigate('/')}>
|
<Logo onClick={() => navigate('/')}>
|
||||||
<LogoMark src={hesychiaMark} alt="" />
|
<LogoMark src={hesychiaMark} alt="" />
|
||||||
<LogoWordmark>Hesychia</LogoWordmark>
|
<LogoWordmark>Hesychia</LogoWordmark>
|
||||||
</Logo>
|
</Logo>
|
||||||
|
</BrandCluster>
|
||||||
|
|
||||||
{/* Desktop Nav */}
|
{/* Desktop Nav */}
|
||||||
<Nav>
|
<Nav>
|
||||||
<NavLink onClick={() => navigate('/')}>Dashboard</NavLink>
|
<NavLink onClick={() => navigate('/')}>Dashboard</NavLink>
|
||||||
<NavLink onClick={() => navigate('/account/')}>Account</NavLink>
|
<NavLink onClick={() => navigate('/account/')}>Account</NavLink>
|
||||||
<NavLink onClick={() => navigate('/document_storage/')}>Documents</NavLink>
|
{hasRag && <NavLink onClick={() => navigate('/document_storage/')}>Documents</NavLink>}
|
||||||
<NavLink onClick={() => navigate('/analytics/')}>Analytics</NavLink>
|
<NavLink onClick={() => navigate('/analytics/')}>Analytics</NavLink>
|
||||||
<NavLink onClick={() => navigate('/feedback/')}>Feedback</NavLink>
|
<NavLink onClick={() => navigate('/feedback/')}>Feedback</NavLink>
|
||||||
<SignOutButton onClick={handleSignOut}>Sign Out</SignOutButton>
|
<SignOutButton onClick={handleSignOut}>Sign Out</SignOutButton>
|
||||||
@@ -204,7 +279,7 @@ const Header2 = ({ absolute = false, light = false, isMini = false }: Header2Pro
|
|||||||
<MobileMenuDropdown isOpen={isMenuOpen}>
|
<MobileMenuDropdown isOpen={isMenuOpen}>
|
||||||
<NavLink onClick={() => handleNavClick('/')}>Dashboard</NavLink>
|
<NavLink onClick={() => handleNavClick('/')}>Dashboard</NavLink>
|
||||||
<NavLink onClick={() => handleNavClick('/account/')}>Account</NavLink>
|
<NavLink onClick={() => handleNavClick('/account/')}>Account</NavLink>
|
||||||
<NavLink onClick={() => handleNavClick('/document_storage/')}>Documents</NavLink>
|
{hasRag && <NavLink onClick={() => handleNavClick('/document_storage/')}>Documents</NavLink>}
|
||||||
<NavLink onClick={() => handleNavClick('/analytics/')}>Analytics</NavLink>
|
<NavLink onClick={() => handleNavClick('/analytics/')}>Analytics</NavLink>
|
||||||
<NavLink onClick={() => handleNavClick('/feedback/')}>Feedback</NavLink>
|
<NavLink onClick={() => handleNavClick('/feedback/')}>Feedback</NavLink>
|
||||||
<SignOutButton onClick={() => { handleSignOut(); setIsMenuOpen(false); }}>Sign Out</SignOutButton>
|
<SignOutButton onClick={() => { handleSignOut(); setIsMenuOpen(false); }}>Sign Out</SignOutButton>
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import styled, { useTheme } from 'styled-components';
|
||||||
|
import { AxiosResponse } from 'axios';
|
||||||
|
import { axiosInstance } from '../../../axiosApi';
|
||||||
|
|
||||||
|
export type PromptHeatmapData = {
|
||||||
|
tz: string;
|
||||||
|
total: number;
|
||||||
|
max: number;
|
||||||
|
days: string[];
|
||||||
|
hours: number[];
|
||||||
|
matrix: number[][];
|
||||||
|
most_active_day: string | null;
|
||||||
|
most_active_hour: number | null;
|
||||||
|
peak_cell: { day: string; hour: number; count: number } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const GlassCard = styled.div`
|
||||||
|
background: ${({ theme }) => theme.colors.cardBackground};
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
||||||
|
border-radius: 1rem;
|
||||||
|
padding: 2rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1000px;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const CardTitle = styled.h2`
|
||||||
|
font-size: 1.8rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: ${({ theme }) => theme.colors.text};
|
||||||
|
border-bottom: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Subtitle = styled.p`
|
||||||
|
margin: 0.75rem 0 1.25rem 0;
|
||||||
|
color: ${({ theme }) => theme.colors.text};
|
||||||
|
opacity: 0.7;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const GridScroll = styled.div`
|
||||||
|
overflow-x: auto;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const HeatmapGrid = styled.div`
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 3rem repeat(24, minmax(14px, 1fr));
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 520px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Corner = styled.div``;
|
||||||
|
|
||||||
|
const AxisLabel = styled.div`
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: ${({ theme }) => theme.colors.text};
|
||||||
|
opacity: 0.55;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const DayLabel = styled(AxisLabel)`
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding-left: 0.15rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Cell = styled.button<{ $intensity: number; $color: string }>`
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0;
|
||||||
|
cursor: default;
|
||||||
|
background: ${({ $intensity, $color, theme }) =>
|
||||||
|
$intensity <= 0
|
||||||
|
? theme.darkMode
|
||||||
|
? 'rgba(255,255,255,0.08)'
|
||||||
|
: 'rgba(0,0,0,0.08)'
|
||||||
|
: $color};
|
||||||
|
opacity: ${({ $intensity }) => ($intensity <= 0 ? 1 : 0.35 + $intensity * 0.65)};
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
outline: 1px solid ${({ theme }) => theme.colors.text}66;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Footer = styled.p`
|
||||||
|
margin: 1rem 0 0 0;
|
||||||
|
color: ${({ theme }) => theme.colors.text};
|
||||||
|
opacity: 0.7;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StatusText = styled.p`
|
||||||
|
color: ${({ theme }) => theme.colors.text};
|
||||||
|
opacity: 0.7;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const hexToRgb = (hex: string): { r: number; g: number; b: number } | null => {
|
||||||
|
const cleaned = hex.replace('#', '');
|
||||||
|
if (cleaned.length !== 6) return null;
|
||||||
|
return {
|
||||||
|
r: parseInt(cleaned.slice(0, 2), 16),
|
||||||
|
g: parseInt(cleaned.slice(2, 4), 16),
|
||||||
|
b: parseInt(cleaned.slice(4, 6), 16),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const PromptHeatmapCard = (): JSX.Element => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const [data, setData] = useState<PromptHeatmapData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
const [hover, setHover] = useState<{ day: string; hour: number; count: number } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const response: AxiosResponse<PromptHeatmapData> = await axiosInstance.get(
|
||||||
|
'/analytics/user_prompt_heatmap/',
|
||||||
|
{ params: { tz } }
|
||||||
|
);
|
||||||
|
if (!cancelled) {
|
||||||
|
setData(response.data);
|
||||||
|
setError(false);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(true);
|
||||||
|
setData(null);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const themeColor = theme?.main || '#4a90e2';
|
||||||
|
const rgb = useMemo(() => hexToRgb(themeColor), [themeColor]);
|
||||||
|
|
||||||
|
const cellColor = (count: number, max: number) => {
|
||||||
|
if (!rgb || max <= 0 || count <= 0) return themeColor;
|
||||||
|
const t = count / max;
|
||||||
|
return `rgb(${Math.round(rgb.r * (0.4 + 0.6 * t))}, ${Math.round(
|
||||||
|
rgb.g * (0.4 + 0.6 * t)
|
||||||
|
)}, ${Math.round(rgb.b * (0.4 + 0.6 * t))})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hourLabels = [0, 6, 12, 18, 23];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GlassCard>
|
||||||
|
<CardTitle>Prompt activity</CardTitle>
|
||||||
|
<Subtitle>
|
||||||
|
When you send prompts across the week (local timezone
|
||||||
|
{data?.tz ? `: ${data.tz}` : ''}).
|
||||||
|
</Subtitle>
|
||||||
|
|
||||||
|
{loading && <StatusText>Loading heatmap…</StatusText>}
|
||||||
|
{error && <StatusText>Could not load prompt activity.</StatusText>}
|
||||||
|
{!loading && !error && data && (
|
||||||
|
<>
|
||||||
|
<GridScroll>
|
||||||
|
<HeatmapGrid role="img" aria-label="Prompt activity heatmap by weekday and hour">
|
||||||
|
<Corner />
|
||||||
|
{data.hours.map((hour) => (
|
||||||
|
<AxisLabel key={`h-${hour}`}>{hourLabels.includes(hour) ? hour : ''}</AxisLabel>
|
||||||
|
))}
|
||||||
|
{data.days.map((day, dayIdx) => (
|
||||||
|
<React.Fragment key={day}>
|
||||||
|
<DayLabel>{day}</DayLabel>
|
||||||
|
{data.hours.map((hour) => {
|
||||||
|
const count = data.matrix[dayIdx]?.[hour] ?? 0;
|
||||||
|
const intensity = data.max > 0 ? count / data.max : 0;
|
||||||
|
return (
|
||||||
|
<Cell
|
||||||
|
key={`${day}-${hour}`}
|
||||||
|
type="button"
|
||||||
|
$intensity={intensity}
|
||||||
|
$color={cellColor(count, data.max)}
|
||||||
|
aria-label={`${day} ${hour}:00 — ${count} prompts`}
|
||||||
|
onMouseEnter={() => setHover({ day, hour, count })}
|
||||||
|
onMouseLeave={() => setHover(null)}
|
||||||
|
onFocus={() => setHover({ day, hour, count })}
|
||||||
|
onBlur={() => setHover(null)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</HeatmapGrid>
|
||||||
|
</GridScroll>
|
||||||
|
<Footer>
|
||||||
|
{hover
|
||||||
|
? `${hover.day} ${hover.hour}:00 — ${hover.count} prompt${hover.count === 1 ? '' : 's'}`
|
||||||
|
: data.total === 0
|
||||||
|
? 'No prompts yet. Chat a bit and this grid fills in.'
|
||||||
|
: [
|
||||||
|
`${data.total} prompt${data.total === 1 ? '' : 's'} total`,
|
||||||
|
data.most_active_day ? `Most active day: ${data.most_active_day}` : null,
|
||||||
|
data.most_active_hour != null
|
||||||
|
? `Most active hour: ${data.most_active_hour}:00`
|
||||||
|
: null,
|
||||||
|
data.peak_cell
|
||||||
|
? `Peak: ${data.peak_cell.day} ${data.peak_cell.hour}:00 (${data.peak_cell.count})`
|
||||||
|
: null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')}
|
||||||
|
</Footer>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</GlassCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PromptHeatmapCard;
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import SsoButtons from './SsoButtons';
|
||||||
|
import { startOAuth } from '../../auth/sso';
|
||||||
|
|
||||||
|
jest.mock('../../auth/sso', () => ({
|
||||||
|
startOAuth: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('SsoButtons', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.mocked(startOAuth).mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing when no providers enabled', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<SsoButtons intent="login" providers={{ google: false, microsoft: false }} />
|
||||||
|
);
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses brand-compliant labels and logos for login', () => {
|
||||||
|
render(<SsoButtons intent="login" providers={{ google: true, microsoft: true }} />);
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: 'Continue with Google' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Sign in with Microsoft' })).toBeInTheDocument();
|
||||||
|
expect(document.querySelectorAll('svg').length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses Sign up with Google for signup intent', () => {
|
||||||
|
render(<SsoButtons intent="signup" providers={{ google: true }} />);
|
||||||
|
expect(screen.getByRole('button', { name: 'Sign up with Google' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts OAuth for the selected provider', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<SsoButtons intent="login" providers={{ google: true, microsoft: true }} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Continue with Google' }));
|
||||||
|
expect(startOAuth).toHaveBeenCalledWith('google', 'login');
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Sign in with Microsoft' }));
|
||||||
|
expect(startOAuth).toHaveBeenCalledWith('microsoft', 'login');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,21 +20,32 @@ const Divider = styled.div`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const SsoButton = styled.button`
|
const ButtonStack = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: rgba(255, 255, 255, 0.06);
|
gap: 0.6rem;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
color: #fff;
|
|
||||||
padding: 0.85rem 1rem;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.2s ease, transform 0.2s ease;
|
|
||||||
margin-top: 0.6rem;
|
margin-top: 0.6rem;
|
||||||
|
`;
|
||||||
|
|
||||||
&:hover {
|
/** Shared layout for IdP buttons — equal size / visual weight. */
|
||||||
background: rgba(255, 255, 255, 0.12);
|
const SsoButtonBase = styled.button`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 2.75rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
padding: 0.65rem 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: 'Roboto', 'Google Sans', system-ui, sans-serif;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease, border-color 0.15s ease, transform 0.15s ease;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +56,93 @@ const SsoButton = styled.button`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google dark-theme button per
|
||||||
|
* https://developers.google.com/identity/branding-guidelines
|
||||||
|
* Fill #131314, stroke #8E918F, text #E3E3E3; multicolor G on white.
|
||||||
|
*/
|
||||||
|
const GoogleButton = styled(SsoButtonBase)`
|
||||||
|
background: #131314;
|
||||||
|
border: 1px solid #8e918f;
|
||||||
|
color: #e3e3e3;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #1e1f20;
|
||||||
|
border-color: #a8aba9;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Microsoft dark-theme button — logo + "Sign in with Microsoft"
|
||||||
|
* https://learn.microsoft.com/en-us/entra/identity-platform/howto-add-branding-in-apps
|
||||||
|
*/
|
||||||
|
const MicrosoftButton = styled(SsoButtonBase)`
|
||||||
|
background: #2f2f2f;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: #ffffff;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #3b3b3b;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const GoogleLogoBadge = styled.span`
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 0.125rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const LogoMark = styled.span`
|
||||||
|
display: inline-flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
/** Official multicolor Google "G" (standard color Super G). */
|
||||||
|
const GoogleGIcon = (): JSX.Element => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" aria-hidden="true" focusable="false">
|
||||||
|
<path
|
||||||
|
fill="#EA4335"
|
||||||
|
d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#4285F4"
|
||||||
|
d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#FBBC05"
|
||||||
|
d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#34A853"
|
||||||
|
d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"
|
||||||
|
/>
|
||||||
|
<path fill="none" d="M0 0h48v48H0z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Official Microsoft four-square logo — do not recolor. */
|
||||||
|
const MicrosoftLogoIcon = (): JSX.Element => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 21 21" aria-hidden="true" focusable="false">
|
||||||
|
<rect x="1" y="1" width="9" height="9" fill="#f25022" />
|
||||||
|
<rect x="11" y="1" width="9" height="9" fill="#7fba00" />
|
||||||
|
<rect x="1" y="11" width="9" height="9" fill="#00a4ef" />
|
||||||
|
<rect x="11" y="11" width="9" height="9" fill="#ffb900" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export type OAuthProviderFlags = {
|
export type OAuthProviderFlags = {
|
||||||
google?: boolean;
|
google?: boolean;
|
||||||
microsoft?: boolean;
|
microsoft?: boolean;
|
||||||
@@ -56,6 +154,9 @@ type SsoButtonsProps = {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const googleLabel = (intent: 'login' | 'signup'): string =>
|
||||||
|
intent === 'signup' ? 'Sign up with Google' : 'Continue with Google';
|
||||||
|
|
||||||
const SsoButtons = ({ intent, providers, disabled = false }: SsoButtonsProps): JSX.Element | null => {
|
const SsoButtons = ({ intent, providers, disabled = false }: SsoButtonsProps): JSX.Element | null => {
|
||||||
const google = Boolean(providers.google);
|
const google = Boolean(providers.google);
|
||||||
const microsoft = Boolean(providers.microsoft);
|
const microsoft = Boolean(providers.microsoft);
|
||||||
@@ -66,24 +167,34 @@ const SsoButtons = ({ intent, providers, disabled = false }: SsoButtonsProps): J
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Divider>or continue with</Divider>
|
<Divider>or continue with</Divider>
|
||||||
|
<ButtonStack>
|
||||||
{google && (
|
{google && (
|
||||||
<SsoButton
|
<GoogleButton
|
||||||
type="button"
|
type="button"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={() => startOAuth('google', intent)}
|
onClick={() => startOAuth('google', intent)}
|
||||||
>
|
>
|
||||||
Continue with Google
|
<GoogleLogoBadge>
|
||||||
</SsoButton>
|
<LogoMark>
|
||||||
|
<GoogleGIcon />
|
||||||
|
</LogoMark>
|
||||||
|
</GoogleLogoBadge>
|
||||||
|
{googleLabel(intent)}
|
||||||
|
</GoogleButton>
|
||||||
)}
|
)}
|
||||||
{microsoft && (
|
{microsoft && (
|
||||||
<SsoButton
|
<MicrosoftButton
|
||||||
type="button"
|
type="button"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={() => startOAuth('microsoft', intent)}
|
onClick={() => startOAuth('microsoft', intent)}
|
||||||
>
|
>
|
||||||
Continue with Microsoft
|
<LogoMark>
|
||||||
</SsoButton>
|
<MicrosoftLogoIcon />
|
||||||
|
</LogoMark>
|
||||||
|
Sign in with Microsoft
|
||||||
|
</MicrosoftButton>
|
||||||
)}
|
)}
|
||||||
|
</ButtonStack>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { axiosInstance } from '../../../axiosApi';
|
import { axiosInstance } from '../../../axiosApi';
|
||||||
|
import { AnalyticsEvents, trackEvent } from '../../utils/analytics';
|
||||||
import {
|
import {
|
||||||
|
canOpenBillingPortal,
|
||||||
formatBillingDate,
|
formatBillingDate,
|
||||||
formatTokenCount,
|
formatTokenCount,
|
||||||
|
isComplimentarySubscription,
|
||||||
SubscriptionMe,
|
SubscriptionMe,
|
||||||
} from '../../utils/finance';
|
} from '../../utils/finance';
|
||||||
|
import type { FinanceInvoice } from '../../utils/finance';
|
||||||
|
|
||||||
const GlassCard = styled.div`
|
const GlassCard = styled.div`
|
||||||
background: ${({ theme }) => theme.colors.cardBackground};
|
background: ${({ theme }) => theme.colors.cardBackground};
|
||||||
@@ -106,6 +110,27 @@ const UpgradeHint = styled.p`
|
|||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const UpgradeButton = styled.button`
|
||||||
|
margin-top: 1rem;
|
||||||
|
background: ${({ theme }) => theme.main};
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
color: #fff;
|
||||||
|
padding: 0.7rem 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
function clampPct(used: number, quota: number): number {
|
function clampPct(used: number, quota: number): number {
|
||||||
if (quota <= 0) return 0;
|
if (quota <= 0) return 0;
|
||||||
return Math.min(100, Math.round((used / quota) * 1000) / 10);
|
return Math.min(100, Math.round((used / quota) * 1000) / 10);
|
||||||
@@ -141,15 +166,26 @@ const UsageMeter = ({ label, meta, usedLabel, pct }: MeterProps): JSX.Element =>
|
|||||||
|
|
||||||
const UsageSummaryCard = (): JSX.Element => {
|
const UsageSummaryCard = (): JSX.Element => {
|
||||||
const [subscription, setSubscription] = useState<SubscriptionMe | null>(null);
|
const [subscription, setSubscription] = useState<SubscriptionMe | null>(null);
|
||||||
|
const [hasPortalAccess, setHasPortalAccess] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [upgradeLoading, setUpgradeLoading] = useState(false);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await axiosInstance.get<SubscriptionMe>('/finance/subscription/');
|
const [subscriptionResponse, invoiceResponse] = await Promise.all([
|
||||||
setSubscription(response.data || null);
|
axiosInstance.get<SubscriptionMe>('/finance/subscription/'),
|
||||||
|
axiosInstance.get<FinanceInvoice[]>('/finance/invoices/'),
|
||||||
|
]);
|
||||||
|
setSubscription(subscriptionResponse.data || null);
|
||||||
|
setHasPortalAccess(
|
||||||
|
canOpenBillingPortal(
|
||||||
|
Array.isArray(invoiceResponse.data) ? invoiceResponse.data : []
|
||||||
|
)
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
setSubscription(null);
|
setSubscription(null);
|
||||||
|
setHasPortalAccess(false);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -174,6 +210,39 @@ const UsageSummaryCard = (): JSX.Element => {
|
|||||||
? 0
|
? 0
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const complimentary = isComplimentarySubscription(subscription, hasPortalAccess);
|
||||||
|
const showUpgrade = Boolean(subscription?.plan) && hasPortalAccess && !complimentary;
|
||||||
|
|
||||||
|
const handleUpgrade = async () => {
|
||||||
|
trackEvent(AnalyticsEvents.SUBSCRIPTION_UPGRADE_STARTED, {
|
||||||
|
source: 'usage_card',
|
||||||
|
});
|
||||||
|
const billing = document.querySelector('[data-testid="billing-section"]');
|
||||||
|
if (billing) {
|
||||||
|
billing.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
const buttons = Array.from(billing.querySelectorAll('button'));
|
||||||
|
const upgrade = buttons.find((btn) =>
|
||||||
|
/upgrade/i.test(btn.textContent || '')
|
||||||
|
);
|
||||||
|
upgrade?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUpgradeLoading(true);
|
||||||
|
try {
|
||||||
|
const returnUrl = `${window.location.origin}/account/`;
|
||||||
|
const response = await axiosInstance.post<{ portal_url: string }>(
|
||||||
|
'/finance/portal/',
|
||||||
|
{ return_url: returnUrl }
|
||||||
|
);
|
||||||
|
if (response.data?.portal_url) {
|
||||||
|
trackEvent(AnalyticsEvents.BILLING_PORTAL_OPENED, { intent: 'upgrade' });
|
||||||
|
window.location.assign(response.data.portal_url);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setUpgradeLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GlassCard data-testid="usage-summary-card">
|
<GlassCard data-testid="usage-summary-card">
|
||||||
<CardTitle>Usage limit</CardTitle>
|
<CardTitle>Usage limit</CardTitle>
|
||||||
@@ -224,9 +293,27 @@ const UsageSummaryCard = (): JSX.Element => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showUpgrade ? (
|
||||||
|
<>
|
||||||
|
<UpgradeHint>
|
||||||
|
Need more capacity? Upgrade your plan in Billing, or open upgrade
|
||||||
|
below.
|
||||||
|
</UpgradeHint>
|
||||||
|
<UpgradeButton
|
||||||
|
type="button"
|
||||||
|
onClick={handleUpgrade}
|
||||||
|
disabled={upgradeLoading}
|
||||||
|
>
|
||||||
|
{upgradeLoading ? 'Opening…' : 'Upgrade'}
|
||||||
|
</UpgradeButton>
|
||||||
|
</>
|
||||||
|
) : complimentary ? (
|
||||||
|
<UpgradeHint>Complimentary access — usage limits follow your granted plan.</UpgradeHint>
|
||||||
|
) : (
|
||||||
<UpgradeHint>
|
<UpgradeHint>
|
||||||
Need more capacity? Manage or upgrade your plan in Billing below.
|
Need more capacity? Manage or upgrade your plan in Billing below.
|
||||||
</UpgradeHint>
|
</UpgradeHint>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||||
|
import { AxiosResponse } from 'axios';
|
||||||
|
import { axiosInstance } from '../../axiosApi';
|
||||||
|
import { AuthContext } from '../contexts/AuthContext';
|
||||||
|
import { planAllowsRag, SubscriptionMe } from '../utils/finance';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Module-level cache so every component that calls useSubscription() during the
|
||||||
|
* same session shares one /finance/subscription/ request instead of each
|
||||||
|
* mounting its own (e.g. Header2 + DocumentStoragePage on the same page).
|
||||||
|
*/
|
||||||
|
let cachedSubscription: SubscriptionMe | null = null;
|
||||||
|
let inFlightRequest: Promise<SubscriptionMe | null> | null = null;
|
||||||
|
|
||||||
|
async function loadSubscription(): Promise<SubscriptionMe | null> {
|
||||||
|
if (cachedSubscription) {
|
||||||
|
return cachedSubscription;
|
||||||
|
}
|
||||||
|
if (!inFlightRequest) {
|
||||||
|
inFlightRequest = axiosInstance
|
||||||
|
.get<SubscriptionMe>('/finance/subscription/')
|
||||||
|
.then((response: AxiosResponse<SubscriptionMe>) => {
|
||||||
|
cachedSubscription = response.data || null;
|
||||||
|
return cachedSubscription;
|
||||||
|
})
|
||||||
|
.catch(() => null)
|
||||||
|
.finally(() => {
|
||||||
|
inFlightRequest = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return inFlightRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drops the cached subscription so the next useSubscription() call refetches (e.g. after sign-out or a plan change). */
|
||||||
|
export function resetSubscriptionCache(): void {
|
||||||
|
cachedSubscription = null;
|
||||||
|
inFlightRequest = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UseSubscriptionResult = {
|
||||||
|
subscription: SubscriptionMe | null;
|
||||||
|
loading: boolean;
|
||||||
|
hasRag: boolean;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Fetches /finance/subscription/ once per session (shared across callers) and exposes plan-derived flags. */
|
||||||
|
export function useSubscription(): UseSubscriptionResult {
|
||||||
|
const { authenticated } = useContext(AuthContext);
|
||||||
|
const [subscription, setSubscription] = useState<SubscriptionMe | null>(cachedSubscription);
|
||||||
|
const [loading, setLoading] = useState(!cachedSubscription);
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!authenticated) {
|
||||||
|
setSubscription(null);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading((prev) => prev || !cachedSubscription);
|
||||||
|
const result = await loadSubscription();
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setSubscription(result);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [authenticated]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mountedRef.current = true;
|
||||||
|
load();
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
};
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authenticated) {
|
||||||
|
resetSubscriptionCache();
|
||||||
|
}
|
||||||
|
}, [authenticated]);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
resetSubscriptionCache();
|
||||||
|
await load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscription,
|
||||||
|
loading,
|
||||||
|
hasRag: planAllowsRag(subscription),
|
||||||
|
refresh,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import styled from "styled-components";
|
|||||||
import ThemeSettingsCard from "../../components/ThemeSettingsCard/ThemeSettingsCard";
|
import ThemeSettingsCard from "../../components/ThemeSettingsCard/ThemeSettingsCard";
|
||||||
import UsageSummaryCard from "../../components/UsageSummaryCard/UsageSummaryCard";
|
import UsageSummaryCard from "../../components/UsageSummaryCard/UsageSummaryCard";
|
||||||
import BillingSection from "../../components/BillingSection/BillingSection";
|
import BillingSection from "../../components/BillingSection/BillingSection";
|
||||||
|
import DeleteAccountSection from "../../components/DeleteAccountSection/DeleteAccountSection";
|
||||||
|
|
||||||
// Styled Components
|
// Styled Components
|
||||||
const PageContainer = styled.div`
|
const PageContainer = styled.div`
|
||||||
@@ -419,6 +420,7 @@ const AccountPage = (): JSX.Element => {
|
|||||||
<p style={{ color: 'rgba(255,255,255,0.7)' }}>Account and prompt information will be available soon</p>
|
<p style={{ color: 'rgba(255,255,255,0.7)' }}>Account and prompt information will be available soon</p>
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
)}
|
)}
|
||||||
|
<DeleteAccountSection />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import { ThemeProvider } from 'styled-components';
|
||||||
|
import AnalyticsPage from './Analytics';
|
||||||
|
import { AccountContext } from '../../contexts/AccountContext';
|
||||||
|
import { AuthContext } from '../../contexts/AuthContext';
|
||||||
|
import { Account } from '../../data';
|
||||||
|
|
||||||
|
const mockGet = jest.fn();
|
||||||
|
|
||||||
|
jest.mock('../../../axiosApi', () => ({
|
||||||
|
axiosInstance: {
|
||||||
|
get: (...args: unknown[]) => mockGet(...args),
|
||||||
|
defaults: { headers: { common: {} as Record<string, string | null> } },
|
||||||
|
},
|
||||||
|
applyAccessToken: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../components/ParticleBackground/ParticleBackground', () => () => null);
|
||||||
|
jest.mock('../../components/Header2/Header2', () => () => null);
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
class ResizeObserverMock {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
}
|
||||||
|
(global as unknown as { ResizeObserver: typeof ResizeObserverMock }).ResizeObserver =
|
||||||
|
ResizeObserverMock;
|
||||||
|
});
|
||||||
|
|
||||||
|
const theme = {
|
||||||
|
main: '#4a90e2',
|
||||||
|
focus: '#224466',
|
||||||
|
darkMode: true,
|
||||||
|
colors: {
|
||||||
|
text: '#ffffff',
|
||||||
|
cardBackground: 'rgba(0,0,0,0.3)',
|
||||||
|
cardBorder: 'rgba(255,255,255,0.1)',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyHeatmap = {
|
||||||
|
tz: 'UTC',
|
||||||
|
total: 0,
|
||||||
|
max: 0,
|
||||||
|
days: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
|
||||||
|
hours: Array.from({ length: 24 }, (_, i) => i),
|
||||||
|
matrix: Array.from({ length: 7 }, () => Array(24).fill(0)),
|
||||||
|
most_active_day: null,
|
||||||
|
most_active_hour: null,
|
||||||
|
peak_cell: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderPage = (accountInit?: ConstructorParameters<typeof Account>[0]) => {
|
||||||
|
const account = new Account(accountInit || { email: 'user@example.com' });
|
||||||
|
return render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ThemeProvider theme={theme as never}>
|
||||||
|
<AuthContext.Provider
|
||||||
|
value={{
|
||||||
|
authenticated: true,
|
||||||
|
setAuthentication: jest.fn(),
|
||||||
|
needsNewPassword: false,
|
||||||
|
setNeedsNewPassword: jest.fn(),
|
||||||
|
loading: false,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AccountContext.Provider value={{ account, setAccount: jest.fn() }}>
|
||||||
|
<AnalyticsPage />
|
||||||
|
</AccountContext.Provider>
|
||||||
|
</AuthContext.Provider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('AnalyticsPage (#94)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockGet.mockReset();
|
||||||
|
mockGet.mockImplementation((url: string) => {
|
||||||
|
if (url.includes('user_prompt_heatmap')) {
|
||||||
|
return Promise.resolve({ data: emptyHeatmap });
|
||||||
|
}
|
||||||
|
return Promise.resolve({ data: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows your activity section with heatmap for all users', async () => {
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
expect(await screen.findByText('Your activity')).toBeInTheDocument();
|
||||||
|
expect(await screen.findByText('Prompt activity')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Company')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows company section for company managers', async () => {
|
||||||
|
renderPage({
|
||||||
|
email: 'mgr@example.com',
|
||||||
|
is_company_manager: true,
|
||||||
|
company: { id: 1, name: 'Acme', state: '', zipcode: '', address: '' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await screen.findByText('Company')).toBeInTheDocument();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Team seat activity')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,9 +6,9 @@ import { axiosInstance } from "../../../axiosApi"
|
|||||||
import { AxiosResponse } from "axios"
|
import { AxiosResponse } from "axios"
|
||||||
import { AdminAnalytics, AdminAnalyticsType, CompanyUsageAnalytics, CompanyUsageAnalyticsType, UserConversationAnalytics, UserConvesationAnalyticsType, UserPromptAnalytics, UserPromptAnalyticsType } from "../../data"
|
import { AdminAnalytics, AdminAnalyticsType, CompanyUsageAnalytics, CompanyUsageAnalyticsType, UserConversationAnalytics, UserConvesationAnalyticsType, UserPromptAnalytics, UserPromptAnalyticsType } from "../../data"
|
||||||
import ParticleBackground from "../../components/ParticleBackground/ParticleBackground"
|
import ParticleBackground from "../../components/ParticleBackground/ParticleBackground"
|
||||||
|
import PromptHeatmapCard from "../../components/PromptHeatmapCard/PromptHeatmapCard"
|
||||||
import styled, { ThemeContext } from "styled-components"
|
import styled, { ThemeContext } from "styled-components"
|
||||||
|
|
||||||
// Styled Components
|
|
||||||
const PageContainer = styled.div`
|
const PageContainer = styled.div`
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
@@ -18,7 +18,6 @@ const PageContainer = styled.div`
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
color: ${({ theme }) => theme.colors.text};
|
color: ${({ theme }) => theme.colors.text};
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: 'Inter', sans-serif;
|
||||||
/* background-color: ${({ theme }) => theme.colors.background}; Removed to show particles */
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const ContentWrapper = styled.div`
|
const ContentWrapper = styled.div`
|
||||||
@@ -42,6 +41,29 @@ const ContentWrapper = styled.div`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const Section = styled.section`
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SectionHeading = styled.h1`
|
||||||
|
width: 100%;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: ${({ theme }) => theme.colors.text};
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SectionHint = styled.p`
|
||||||
|
margin: -0.5rem 0 1.25rem 0;
|
||||||
|
color: ${({ theme }) => theme.colors.text};
|
||||||
|
opacity: 0.65;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
`;
|
||||||
|
|
||||||
const GlassCard = styled.div`
|
const GlassCard = styled.div`
|
||||||
background: ${({ theme }) => theme.colors.cardBackground};
|
background: ${({ theme }) => theme.colors.cardBackground};
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
@@ -96,11 +118,11 @@ const UserPromptAnalyticsCard = (): JSX.Element => {
|
|||||||
}, [])
|
}, [])
|
||||||
return (
|
return (
|
||||||
<GlassCard>
|
<GlassCard>
|
||||||
<CardTitle>Prompt Usage</CardTitle>
|
<CardTitle>Prompt volume</CardTitle>
|
||||||
<ChartContainer>
|
<ChartContainer>
|
||||||
<ResponsiveContainer>
|
<ResponsiveContainer>
|
||||||
<BarChart data={data}>
|
<BarChart data={data}>
|
||||||
<XAxis stroke={theme?.colors.text} />
|
<XAxis dataKey="month" stroke={theme?.colors.text} />
|
||||||
<YAxis stroke={theme?.colors.text} />
|
<YAxis stroke={theme?.colors.text} />
|
||||||
<Legend wrapperStyle={{ color: theme?.colors.text }} />
|
<Legend wrapperStyle={{ color: theme?.colors.text }} />
|
||||||
<Tooltip contentStyle={{ backgroundColor: theme?.colors.cardBackground, border: `1px solid ${theme?.colors.cardBorder}`, color: theme?.colors.text }} />
|
<Tooltip contentStyle={{ backgroundColor: theme?.colors.cardBackground, border: `1px solid ${theme?.colors.cardBorder}`, color: theme?.colors.text }} />
|
||||||
@@ -132,11 +154,11 @@ const UserConversationAnalyticsCard = (): JSX.Element => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<GlassCard>
|
<GlassCard>
|
||||||
<CardTitle>Conversation Usage</CardTitle>
|
<CardTitle>Conversation volume</CardTitle>
|
||||||
<ChartContainer>
|
<ChartContainer>
|
||||||
<ResponsiveContainer>
|
<ResponsiveContainer>
|
||||||
<BarChart data={data}>
|
<BarChart data={data}>
|
||||||
<XAxis stroke={theme?.colors.text} />
|
<XAxis dataKey="month" stroke={theme?.colors.text} />
|
||||||
<YAxis stroke={theme?.colors.text} />
|
<YAxis stroke={theme?.colors.text} />
|
||||||
<Legend wrapperStyle={{ color: theme?.colors.text }} />
|
<Legend wrapperStyle={{ color: theme?.colors.text }} />
|
||||||
<Tooltip contentStyle={{ backgroundColor: theme?.colors.cardBackground, border: `1px solid ${theme?.colors.cardBorder}`, color: theme?.colors.text }} />
|
<Tooltip contentStyle={{ backgroundColor: theme?.colors.cardBackground, border: `1px solid ${theme?.colors.cardBorder}`, color: theme?.colors.text }} />
|
||||||
@@ -168,7 +190,7 @@ const CompanyUsageAnalyticsCard = (): JSX.Element => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<GlassCard>
|
<GlassCard>
|
||||||
<CardTitle>Account Usage</CardTitle>
|
<CardTitle>Team seat activity</CardTitle>
|
||||||
<ChartContainer>
|
<ChartContainer>
|
||||||
<ResponsiveContainer>
|
<ResponsiveContainer>
|
||||||
<BarChart data={data}>
|
<BarChart data={data}>
|
||||||
@@ -202,7 +224,7 @@ const AdminAnalyticsCard = (): JSX.Element => {
|
|||||||
}, [])
|
}, [])
|
||||||
return (
|
return (
|
||||||
<GlassCard>
|
<GlassCard>
|
||||||
<CardTitle>Response Times</CardTitle>
|
<CardTitle>Response times (ops)</CardTitle>
|
||||||
<ChartContainer>
|
<ChartContainer>
|
||||||
<ResponsiveContainer>
|
<ResponsiveContainer>
|
||||||
<ComposedChart data={data}>
|
<ComposedChart data={data}>
|
||||||
@@ -227,20 +249,57 @@ const AdminAnalyticsCard = (): JSX.Element => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isOpsAdmin = (email?: string, role?: string): boolean => {
|
||||||
|
if (role && ['admin', 'ops', 'staff'].includes(role.toLowerCase())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return email === "ryan+admin@aimloperations.com";
|
||||||
|
};
|
||||||
|
|
||||||
const AnalyticsInner = (): JSX.Element => {
|
const AnalyticsInner = (): JSX.Element => {
|
||||||
const { account } = useContext(AccountContext)
|
const { account } = useContext(AccountContext)
|
||||||
|
const showCompany = Boolean(account?.is_company_manager || account?.company);
|
||||||
|
const showAdmin = isOpsAdmin(account?.email, account?.role);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<Section aria-labelledby="analytics-you">
|
||||||
|
<SectionHeading id="analytics-you">Your activity</SectionHeading>
|
||||||
|
<SectionHint>
|
||||||
|
Personal prompt timing and volume. Other users' private messages are never shown here.
|
||||||
|
</SectionHint>
|
||||||
|
<PromptHeatmapCard />
|
||||||
<GridContainer>
|
<GridContainer>
|
||||||
<UserConversationAnalyticsCard />
|
<UserConversationAnalyticsCard />
|
||||||
<UserPromptAnalyticsCard />
|
<UserPromptAnalyticsCard />
|
||||||
</GridContainer>
|
</GridContainer>
|
||||||
|
</Section>
|
||||||
|
|
||||||
{account?.is_company_manager ? <CompanyUsageAnalyticsCard /> : <></>}
|
{showCompany && (
|
||||||
{account?.email === "ryan+admin@aimloperations.com" ? <AdminAnalyticsCard /> : <></>}
|
<Section aria-labelledby="analytics-company">
|
||||||
|
<SectionHeading id="analytics-company">Company</SectionHeading>
|
||||||
|
<SectionHint>
|
||||||
|
Aggregated seat and usage trends for your workspace. No message content.
|
||||||
|
</SectionHint>
|
||||||
|
{account?.is_company_manager ? (
|
||||||
|
<CompanyUsageAnalyticsCard />
|
||||||
|
) : (
|
||||||
|
<GlassCard>
|
||||||
|
<CardTitle>Company insights</CardTitle>
|
||||||
|
<p style={{ opacity: 0.7, margin: 0 }}>
|
||||||
|
Detailed team seat charts are available to company managers.
|
||||||
|
</p>
|
||||||
|
</GlassCard>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showAdmin && (
|
||||||
|
<Section aria-labelledby="analytics-ops">
|
||||||
|
<SectionHeading id="analytics-ops">Operations</SectionHeading>
|
||||||
|
<AdminAnalyticsCard />
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useContext, useEffect, useRef, useState } from "react";
|
|||||||
import styled, { ThemeContext } from "styled-components";
|
import styled, { ThemeContext } from "styled-components";
|
||||||
import { Formik, Form, Field } from "formik";
|
import { Formik, Form, Field } from "formik";
|
||||||
import * as Yup from "yup";
|
import * as Yup from "yup";
|
||||||
import { AttachFile, Delete, Send, Menu, Close } from "@mui/icons-material"; // Keeping icons for now, can replace later if needed
|
import { AttachFile, Delete, Send, Close } from "@mui/icons-material"; // Keeping icons for now, can replace later if needed
|
||||||
import { Tooltip } from "@mui/material";
|
import { Tooltip } from "@mui/material";
|
||||||
import Markdown from "markdown-to-jsx";
|
import Markdown from "markdown-to-jsx";
|
||||||
|
|
||||||
@@ -63,34 +63,6 @@ const Sidebar = styled.div<{ $isOpen: boolean }>`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const MobileSidebarToggle = styled.button`
|
|
||||||
display: none;
|
|
||||||
position: absolute;
|
|
||||||
top: calc(4.75rem + env(safe-area-inset-top, 0px)); /* Below header */
|
|
||||||
left: 1rem;
|
|
||||||
z-index: 15;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
background: ${({ theme }) => theme.main};
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 2rem;
|
|
||||||
font-weight: 600;
|
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 6px 16px rgba(0,0,0,0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Overlay = styled.div<{ $isOpen: boolean }>`
|
const Overlay = styled.div<{ $isOpen: boolean }>`
|
||||||
display: none;
|
display: none;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -454,15 +426,10 @@ const AsyncDashboardInner = (): JSX.Element => {
|
|||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<ParticleBackground />
|
<ParticleBackground />
|
||||||
<Header2 />
|
<Header2 onOpenConversations={() => setIsSidebarOpen(true)} />
|
||||||
|
|
||||||
<Overlay $isOpen={isSidebarOpen} onClick={() => setIsSidebarOpen(false)} />
|
<Overlay $isOpen={isSidebarOpen} onClick={() => setIsSidebarOpen(false)} />
|
||||||
|
|
||||||
<MobileSidebarToggle onClick={() => setIsSidebarOpen(true)}>
|
|
||||||
<Menu fontSize="small" />
|
|
||||||
<span>Conversations</span>
|
|
||||||
</MobileSidebarToggle>
|
|
||||||
|
|
||||||
<Sidebar $isOpen={isSidebarOpen}>
|
<Sidebar $isOpen={isSidebarOpen}>
|
||||||
<MobileSidebarHeader>
|
<MobileSidebarHeader>
|
||||||
<span>Conversations</span>
|
<span>Conversations</span>
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ const renderCallback = (query: string) => {
|
|||||||
<Route path="/" element={<div>Home</div>} />
|
<Route path="/" element={<div>Home</div>} />
|
||||||
<Route path="/terms_of_service/" element={<div>TOS</div>} />
|
<Route path="/terms_of_service/" element={<div>TOS</div>} />
|
||||||
<Route path="/signin/" element={<div>Sign In Page</div>} />
|
<Route path="/signin/" element={<div>Sign In Page</div>} />
|
||||||
|
<Route path="/document_storage/" element={<div>Document Storage Page</div>} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</AccountContext.Provider>
|
</AccountContext.Provider>
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
@@ -136,4 +137,13 @@ describe('AuthCallback', () => {
|
|||||||
expect(assignMock).toHaveBeenCalledWith('https://checkout.stripe.test/session');
|
expect(assignMock).toHaveBeenCalledWith('https://checkout.stripe.test/session');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('redirects drive-link callbacks straight to Documents (#83/#84)', async () => {
|
||||||
|
renderCallback('?drive_connected=1');
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Document Storage Page')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(mockGet).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -111,6 +111,13 @@ const AuthCallback = (): JSX.Element => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Drive-link flows (#83/#84) redirect here already authenticated — just
|
||||||
|
// bounce to Documents with a flag so it can show a success banner.
|
||||||
|
if (searchParams.get('drive_connected') === '1') {
|
||||||
|
navigate('/document_storage/?drive_connected=1', { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const access = searchParams.get('access');
|
const access = searchParams.get('access');
|
||||||
const refresh = searchParams.get('refresh');
|
const refresh = searchParams.get('refresh');
|
||||||
const needsCheckout = searchParams.get('needs_checkout') === '1';
|
const needsCheckout = searchParams.get('needs_checkout') === '1';
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import { ThemeProvider } from 'styled-components';
|
||||||
|
import DocumentStoragePage from './DocumentStoragePage';
|
||||||
|
import { AuthContext } from '../../contexts/AuthContext';
|
||||||
|
import { AccountContext } from '../../contexts/AccountContext';
|
||||||
|
import { Account } from '../../data';
|
||||||
|
import { resetSubscriptionCache } from '../../hooks/useSubscription';
|
||||||
|
|
||||||
|
const mockGet = jest.fn();
|
||||||
|
const mockPost = jest.fn();
|
||||||
|
const mockPatch = jest.fn();
|
||||||
|
const mockDelete = jest.fn();
|
||||||
|
|
||||||
|
jest.mock('../../../axiosApi', () => ({
|
||||||
|
axiosInstance: {
|
||||||
|
get: (...args: unknown[]) => mockGet(...args),
|
||||||
|
post: (...args: unknown[]) => mockPost(...args),
|
||||||
|
patch: (...args: unknown[]) => mockPatch(...args),
|
||||||
|
delete: (...args: unknown[]) => mockDelete(...args),
|
||||||
|
defaults: { headers: { common: {} as Record<string, string | null> } },
|
||||||
|
},
|
||||||
|
applyAccessToken: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../components/ParticleBackground/ParticleBackground', () => () => null);
|
||||||
|
jest.mock('../../components/Header2/Header2', () => () => null);
|
||||||
|
|
||||||
|
const theme = {
|
||||||
|
main: '#4a90e2',
|
||||||
|
focus: '#224466',
|
||||||
|
darkMode: true,
|
||||||
|
colors: {
|
||||||
|
text: '#ffffff',
|
||||||
|
cardBackground: 'rgba(0,0,0,0.3)',
|
||||||
|
cardBorder: 'rgba(255,255,255,0.1)',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const subscriptionResponse = (rag: boolean) => ({
|
||||||
|
data: {
|
||||||
|
plan: {
|
||||||
|
slug: rag ? 'pro' : 'standard',
|
||||||
|
name: rag ? 'Pro' : 'Standard',
|
||||||
|
features: {
|
||||||
|
text_generation: true,
|
||||||
|
image_generation: rag,
|
||||||
|
rag,
|
||||||
|
all_future_features: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status: 'active',
|
||||||
|
source: 'stripe',
|
||||||
|
needs_checkout: false,
|
||||||
|
stripe_subscription_id: 'sub_1',
|
||||||
|
usage: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockGetByUrl = (overrides: { rag: boolean }) => {
|
||||||
|
mockGet.mockImplementation((url: string) => {
|
||||||
|
if (url === '/finance/subscription/') {
|
||||||
|
return Promise.resolve(subscriptionResponse(overrides.rag));
|
||||||
|
}
|
||||||
|
if (typeof url === 'string' && url.startsWith('/documents/')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
data: { count: 0, page: 1, page_size: 20, scope: 'personal', results: [] },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url === '/drive/connections/') {
|
||||||
|
return Promise.resolve({ data: [] });
|
||||||
|
}
|
||||||
|
return Promise.resolve({ data: [] });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderPage = (options?: { isCompanyManager?: boolean; hasCompany?: boolean }) => {
|
||||||
|
const isCompanyManager = options?.isCompanyManager ?? false;
|
||||||
|
const hasCompany = options?.hasCompany ?? false;
|
||||||
|
const account = new Account({
|
||||||
|
email: 'user@example.com',
|
||||||
|
is_company_manager: isCompanyManager,
|
||||||
|
company: hasCompany ? { id: 1, name: 'Acme', state: '', zipcode: '', address: '' } : undefined,
|
||||||
|
});
|
||||||
|
return render(
|
||||||
|
<MemoryRouter initialEntries={['/document_storage/']}>
|
||||||
|
<ThemeProvider theme={theme as never}>
|
||||||
|
<AuthContext.Provider
|
||||||
|
value={{
|
||||||
|
authenticated: true,
|
||||||
|
setAuthentication: jest.fn(),
|
||||||
|
needsNewPassword: false,
|
||||||
|
setNeedsNewPassword: jest.fn(),
|
||||||
|
loading: false,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AccountContext.Provider value={{ account, setAccount: jest.fn() }}>
|
||||||
|
<DocumentStoragePage />
|
||||||
|
</AccountContext.Provider>
|
||||||
|
</AuthContext.Provider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('DocumentStoragePage (#81/#82/#83/#84/#93)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockGet.mockReset();
|
||||||
|
mockPost.mockReset();
|
||||||
|
mockPatch.mockReset();
|
||||||
|
mockDelete.mockReset();
|
||||||
|
resetSubscriptionCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an upgrade card instead of upload tables when the plan has no RAG', async () => {
|
||||||
|
mockGetByUrl({ rag: false });
|
||||||
|
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
expect(await screen.findByText('Unlock document storage')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('link', { name: /upgrade in billing/i })).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
'/account/'
|
||||||
|
);
|
||||||
|
expect(screen.queryByText('Company documents')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Personal documents')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Upload a Document')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows personal documents for users without a company', async () => {
|
||||||
|
mockGetByUrl({ rag: true });
|
||||||
|
|
||||||
|
renderPage({ hasCompany: false });
|
||||||
|
|
||||||
|
expect(await screen.findByText('Personal documents')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Company documents')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('tab', { name: 'Company' })).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByText(/shared with your whole company workspace/i)
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Upload a Document')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Cloud drives')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Company knowledge sources')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows company/personal tabs and company helper copy for company members', async () => {
|
||||||
|
mockGetByUrl({ rag: true });
|
||||||
|
|
||||||
|
renderPage({ hasCompany: true });
|
||||||
|
|
||||||
|
expect(await screen.findByText('Company documents')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('tab', { name: 'Personal' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('tab', { name: 'Company' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/shared with your whole company workspace/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('also shows the company knowledge sources section for company managers (#84)', async () => {
|
||||||
|
mockGetByUrl({ rag: true });
|
||||||
|
|
||||||
|
renderPage({ isCompanyManager: true, hasCompany: true });
|
||||||
|
|
||||||
|
expect(await screen.findByText('Company knowledge sources')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requests documents with workspace/page/search params (#93)', async () => {
|
||||||
|
mockGetByUrl({ rag: true });
|
||||||
|
|
||||||
|
renderPage({ hasCompany: true });
|
||||||
|
|
||||||
|
await screen.findByText('Company documents');
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockGet).toHaveBeenCalledWith(
|
||||||
|
'/documents/',
|
||||||
|
expect.objectContaining({
|
||||||
|
params: expect.objectContaining({
|
||||||
|
workspace: 'company',
|
||||||
|
page: 1,
|
||||||
|
page_size: 20,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useCallback, useContext, useEffect, useState } from "react";
|
||||||
|
import { Link, useSearchParams } from "react-router-dom";
|
||||||
import { Document, DocumentType } from "../../data";
|
import { Document, DocumentType } from "../../data";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { axiosInstance } from "../../../axiosApi";
|
import { axiosInstance } from "../../../axiosApi";
|
||||||
import Header2 from "../../components/Header2/Header2";
|
import Header2 from "../../components/Header2/Header2";
|
||||||
import ParticleBackground from "../../components/ParticleBackground/ParticleBackground";
|
import ParticleBackground from "../../components/ParticleBackground/ParticleBackground";
|
||||||
|
import DriveConnectionsSection from "../../components/DriveConnectionsSection/DriveConnectionsSection";
|
||||||
|
import { AccountContext } from "../../contexts/AccountContext";
|
||||||
|
import { useSubscription } from "../../hooks/useSubscription";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
|
|
||||||
// Styled Components
|
// Styled Components
|
||||||
@@ -16,7 +20,6 @@ const PageContainer = styled.div`
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
color: ${({ theme }) => theme.colors.text};
|
color: ${({ theme }) => theme.colors.text};
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: 'Inter', sans-serif;
|
||||||
/* background-color: ${({ theme }) => theme.colors.background}; Removed to show particles */
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const ContentWrapper = styled.div`
|
const ContentWrapper = styled.div`
|
||||||
@@ -60,6 +63,72 @@ const CardTitle = styled.h2`
|
|||||||
padding-bottom: 1rem;
|
padding-bottom: 1rem;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const TabRow = styled.div`
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const TabButton = styled.button<{ $active: boolean }>`
|
||||||
|
background: ${({ $active, theme }) => ($active ? theme.main : 'transparent')};
|
||||||
|
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
color: ${({ $active, theme }) => ($active ? '#fff' : theme.colors.text)};
|
||||||
|
padding: 0.55rem 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Toolbar = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SearchInput = styled.input`
|
||||||
|
flex: 1;
|
||||||
|
min-width: 180px;
|
||||||
|
background: ${({ theme }) => theme.darkMode ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.05)'};
|
||||||
|
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
color: ${({ theme }) => theme.colors.text};
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: ${({ theme }) => theme.darkMode ? 'rgba(255, 255, 255, 0.4)' : 'rgba(0, 0, 0, 0.4)'};
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PaginationRow = styled.div`
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 1rem;
|
||||||
|
color: ${({ theme }) => theme.colors.text};
|
||||||
|
opacity: 0.8;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PageButton = styled.button`
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
color: ${({ theme }) => theme.colors.text};
|
||||||
|
padding: 0.4rem 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
const StyledTable = styled.table`
|
const StyledTable = styled.table`
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
@@ -75,6 +144,15 @@ const Th = styled.th`
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const SortableTh = styled(Th)`
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
const Td = styled.td`
|
const Td = styled.td`
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border-bottom: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
border-bottom: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
||||||
@@ -106,6 +184,26 @@ const StyledButton = styled.button`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const StyledButtonLink = styled(Link)`
|
||||||
|
background: ${({ theme }) => theme.main};
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
color: #fff;
|
||||||
|
padding: 0.8rem 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px ${({ theme }) => theme.main}66;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
const FileInputLabel = styled.label`
|
const FileInputLabel = styled.label`
|
||||||
background: ${({ theme }) => theme.main};
|
background: ${({ theme }) => theme.main};
|
||||||
border: none;
|
border: none;
|
||||||
@@ -181,50 +279,177 @@ const Checkbox = styled.input`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const HelperNote = styled.p`
|
||||||
|
margin: 1rem 0 0 0;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
||||||
|
color: ${({ theme }) => theme.colors.text};
|
||||||
|
opacity: 0.65;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const UpgradeText = styled.p`
|
||||||
|
color: ${({ theme }) => theme.colors.text};
|
||||||
|
opacity: 0.8;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SuccessBanner = styled.div`
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1000px;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: rgba(76, 175, 80, 0.15);
|
||||||
|
border: 1px solid rgba(76, 175, 80, 0.4);
|
||||||
|
color: ${({ theme }) => theme.colors.text};
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const DismissButton = styled.button`
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: ${({ theme }) => theme.colors.text};
|
||||||
|
font-size: 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0.7;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const EmptyRow = styled.td`
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
color: ${({ theme }) => theme.colors.text};
|
||||||
|
opacity: 0.65;
|
||||||
|
text-align: center;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export type DocumentWorkspaceScope = 'personal' | 'company';
|
||||||
|
|
||||||
|
type DocumentsListResponse = {
|
||||||
|
count: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
scope: DocumentWorkspaceScope;
|
||||||
|
results: DocumentType[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type SortField = 'name' | 'created' | 'processed' | 'active';
|
||||||
|
|
||||||
type DocumentTableCardProps = {
|
type DocumentTableCardProps = {
|
||||||
documents: Document[],
|
scope: DocumentWorkspaceScope;
|
||||||
setDocuments: React.Dispatch<React.SetStateAction<Document[]>>
|
showTabs: boolean;
|
||||||
}
|
onScopeChange: (scope: DocumentWorkspaceScope) => void;
|
||||||
|
documents: Document[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
search: string;
|
||||||
|
ordering: string;
|
||||||
|
loading: boolean;
|
||||||
|
onSearchChange: (value: string) => void;
|
||||||
|
onSort: (field: SortField) => void;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
onToggleActive: (id: number, active: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
const CompanyDocumentStorageTableCard = ({ documents, setDocuments }: DocumentTableCardProps): JSX.Element => {
|
const sortLabel = (field: SortField, ordering: string): string => {
|
||||||
|
const labels: Record<SortField, string> = {
|
||||||
|
name: 'Name',
|
||||||
|
created: 'Date Uploaded',
|
||||||
|
processed: 'Processed',
|
||||||
|
active: 'Active',
|
||||||
|
};
|
||||||
|
if (ordering === field) return `${labels[field]} ↑`;
|
||||||
|
if (ordering === `-${field}`) return `${labels[field]} ↓`;
|
||||||
|
return labels[field];
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const DocumentStorageTableCard = ({
|
||||||
async function getUploadedDocuments() {
|
scope,
|
||||||
try {
|
showTabs,
|
||||||
const { data, }: AxiosResponse<DocumentType[]> = await axiosInstance.get(`/documents/`);
|
onScopeChange,
|
||||||
setDocuments(data.map((item) => new Document({
|
documents,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
search,
|
||||||
|
ordering,
|
||||||
|
loading,
|
||||||
|
onSearchChange,
|
||||||
|
onSort,
|
||||||
|
onPageChange,
|
||||||
|
onToggleActive,
|
||||||
|
}: DocumentTableCardProps): JSX.Element => {
|
||||||
|
const title = scope === 'company' ? 'Company documents' : 'Personal documents';
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
|
||||||
id: item.id,
|
|
||||||
name: item.file.replace(/^.*[\\/]/, ''),
|
|
||||||
date_uploaded: item.created.substring(0, 10),
|
|
||||||
active: item.active,
|
|
||||||
processed: item.processed,
|
|
||||||
|
|
||||||
})))
|
|
||||||
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.log(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
getUploadedDocuments();
|
|
||||||
}, [setDocuments])
|
|
||||||
return (
|
return (
|
||||||
<GlassCard>
|
<GlassCard>
|
||||||
<CardTitle>Your documents in the company workspace</CardTitle>
|
<CardTitle>{title}</CardTitle>
|
||||||
|
|
||||||
|
{showTabs && (
|
||||||
|
<TabRow role="tablist" aria-label="Document workspace">
|
||||||
|
<TabButton
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={scope === 'personal'}
|
||||||
|
$active={scope === 'personal'}
|
||||||
|
onClick={() => onScopeChange('personal')}
|
||||||
|
>
|
||||||
|
Personal
|
||||||
|
</TabButton>
|
||||||
|
<TabButton
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={scope === 'company'}
|
||||||
|
$active={scope === 'company'}
|
||||||
|
onClick={() => onScopeChange('company')}
|
||||||
|
>
|
||||||
|
Company
|
||||||
|
</TabButton>
|
||||||
|
</TabRow>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Toolbar>
|
||||||
|
<SearchInput
|
||||||
|
type="search"
|
||||||
|
placeholder="Search documents…"
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => onSearchChange(event.target.value)}
|
||||||
|
aria-label="Search documents"
|
||||||
|
/>
|
||||||
|
</Toolbar>
|
||||||
|
|
||||||
<div style={{ overflowX: 'auto' }}>
|
<div style={{ overflowX: 'auto' }}>
|
||||||
<StyledTable>
|
<StyledTable>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<Th>Name</Th>
|
<SortableTh onClick={() => onSort('name')}>{sortLabel('name', ordering)}</SortableTh>
|
||||||
<Th>Date Uploaded</Th>
|
<SortableTh onClick={() => onSort('created')}>{sortLabel('created', ordering)}</SortableTh>
|
||||||
<Th>Processed</Th>
|
<SortableTh onClick={() => onSort('processed')}>{sortLabel('processed', ordering)}</SortableTh>
|
||||||
<Th>Active</Th>
|
<SortableTh onClick={() => onSort('active')}>{sortLabel('active', ordering)}</SortableTh>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{documents.map((doc) => (
|
{loading ? (
|
||||||
|
<tr>
|
||||||
|
<EmptyRow colSpan={4}>Loading documents…</EmptyRow>
|
||||||
|
</tr>
|
||||||
|
) : documents.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<EmptyRow colSpan={4}>No documents found.</EmptyRow>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
documents.map((doc) => (
|
||||||
<tr key={doc.id}>
|
<tr key={doc.id}>
|
||||||
<Td>{doc.name}</Td>
|
<Td>{doc.name}</Td>
|
||||||
<Td>{doc.date_uploaded}</Td>
|
<Td>{doc.date_uploaded}</Td>
|
||||||
@@ -240,53 +465,80 @@ const CompanyDocumentStorageTableCard = ({ documents, setDocuments }: DocumentTa
|
|||||||
<Checkbox
|
<Checkbox
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={doc.active}
|
checked={doc.active}
|
||||||
disabled={true}
|
onChange={(event) => onToggleActive(doc.id, event.target.checked)}
|
||||||
/>
|
/>
|
||||||
<Slider />
|
<Slider />
|
||||||
</ToggleSwitch>
|
</ToggleSwitch>
|
||||||
</Td>
|
</Td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))
|
||||||
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</StyledTable>
|
</StyledTable>
|
||||||
</div>
|
</div>
|
||||||
</GlassCard>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const UserDocumentStorageTableCard = (): JSX.Element => {
|
<PaginationRow>
|
||||||
return (
|
<span>
|
||||||
<GlassCard>
|
{total === 0
|
||||||
<CardTitle>Your documents in your personal workspace</CardTitle>
|
? '0 documents'
|
||||||
<p style={{ color: 'rgba(255,255,255,0.7)' }}>This will become available shortly</p>
|
: `Showing ${(page - 1) * pageSize + 1}–${Math.min(page * pageSize, total)} of ${total}`}
|
||||||
</GlassCard>
|
</span>
|
||||||
)
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||||
}
|
<PageButton type="button" disabled={page <= 1} onClick={() => onPageChange(page - 1)}>
|
||||||
|
Previous
|
||||||
|
</PageButton>
|
||||||
|
<PageButton
|
||||||
|
type="button"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => onPageChange(page + 1)}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</PageButton>
|
||||||
|
</div>
|
||||||
|
</PaginationRow>
|
||||||
|
|
||||||
const DocumentUploadCard = (): JSX.Element => {
|
{scope === 'company' && (
|
||||||
|
<HelperNote>
|
||||||
|
These documents are shared with your whole company workspace. Want to search your own
|
||||||
|
files privately? Connect a personal cloud drive in the Cloud drives section below.
|
||||||
|
</HelperNote>
|
||||||
|
)}
|
||||||
|
</GlassCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type DocumentUploadCardProps = {
|
||||||
|
scope: DocumentWorkspaceScope;
|
||||||
|
onUploaded: () => void | Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DocumentUploadCard = ({ scope, onUploaded }: DocumentUploadCardProps): JSX.Element => {
|
||||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
|
const [uploading, setUploading] = useState<boolean>(false);
|
||||||
|
|
||||||
const handleDocumentUpload = async (): Promise<void> => {
|
const handleDocumentUpload = async (): Promise<void> => {
|
||||||
|
if (!selectedFile) {
|
||||||
console.log(selectedFile)
|
return;
|
||||||
if (selectedFile) {
|
}
|
||||||
|
setUploading(true);
|
||||||
try {
|
try {
|
||||||
await axiosInstance.post('/documents/', {
|
await axiosInstance.post(
|
||||||
file: selectedFile
|
'/documents/',
|
||||||
},
|
{ file: selectedFile },
|
||||||
{
|
{
|
||||||
headers: {
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
'Content-Type': 'multipart/form-data',
|
params: { workspace: scope },
|
||||||
},
|
}
|
||||||
})
|
);
|
||||||
|
|
||||||
// TODO set the documents here
|
setSelectedFile(null);
|
||||||
}
|
await onUploaded();
|
||||||
finally {
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
}
|
} finally {
|
||||||
}
|
setUploading(false);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (event.target.files && event.target.files.length > 0) {
|
if (event.target.files && event.target.files.length > 0) {
|
||||||
@@ -299,43 +551,213 @@ const DocumentUploadCard = (): JSX.Element => {
|
|||||||
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: '1rem' }}>
|
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: '1rem' }}>
|
||||||
<FileInputLabel>
|
<FileInputLabel>
|
||||||
Select File
|
Select File
|
||||||
<input type="file" hidden onChange={handleFileChange} />
|
<input type="file" hidden onChange={handleFileChange} disabled={uploading} />
|
||||||
</FileInputLabel>
|
</FileInputLabel>
|
||||||
|
|
||||||
{selectedFile && (
|
{selectedFile && (
|
||||||
<>
|
<>
|
||||||
<span style={{ color: '#fff' }}>{selectedFile.name}</span>
|
<span style={{ color: '#fff' }}>{selectedFile.name}</span>
|
||||||
<StyledButton onClick={handleDocumentUpload}>
|
<StyledButton onClick={handleDocumentUpload} disabled={uploading}>
|
||||||
Upload
|
{uploading ? 'Uploading…' : 'Upload'}
|
||||||
</StyledButton>
|
</StyledButton>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
const DocumentStoragePageInner = (): JSX.Element => {
|
const DocumentStoragePageInner = (): JSX.Element => {
|
||||||
const [documents, setDocuments] = useState<Document[]>([]);
|
const [documents, setDocuments] = useState<Document[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [searchInput, setSearchInput] = useState('');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [ordering, setOrdering] = useState('-created');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const { account } = useContext(AccountContext);
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const [showDriveConnectedBanner, setShowDriveConnectedBanner] = useState(false);
|
||||||
|
|
||||||
|
const hasCompany = Boolean(account?.company);
|
||||||
|
const [scope, setScope] = useState<DocumentWorkspaceScope>(
|
||||||
|
hasCompany ? 'company' : 'personal'
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasCompany && scope === 'company') {
|
||||||
|
setScope('personal');
|
||||||
|
}
|
||||||
|
}, [hasCompany, scope]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handle = window.setTimeout(() => {
|
||||||
|
setSearch(searchInput.trim());
|
||||||
|
setPage(1);
|
||||||
|
}, 300);
|
||||||
|
return () => window.clearTimeout(handle);
|
||||||
|
}, [searchInput]);
|
||||||
|
|
||||||
|
const fetchDocuments = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { data }: AxiosResponse<DocumentsListResponse> = await axiosInstance.get(
|
||||||
|
`/documents/`,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
workspace: scope,
|
||||||
|
page,
|
||||||
|
page_size: PAGE_SIZE,
|
||||||
|
search: search || undefined,
|
||||||
|
ordering,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const results = Array.isArray(data?.results) ? data.results : [];
|
||||||
|
setTotal(typeof data?.count === 'number' ? data.count : results.length);
|
||||||
|
setDocuments(
|
||||||
|
results.map(
|
||||||
|
(item) =>
|
||||||
|
new Document({
|
||||||
|
id: item.id,
|
||||||
|
name: item.file.replace(/^.*[\\/]/, ''),
|
||||||
|
date_uploaded: item.created.substring(0, 10),
|
||||||
|
active: item.active,
|
||||||
|
processed: item.processed,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
setDocuments([]);
|
||||||
|
setTotal(0);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [scope, page, search, ordering]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchDocuments();
|
||||||
|
}, [fetchDocuments]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchParams.get('drive_connected') === '1') {
|
||||||
|
setShowDriveConnectedBanner(true);
|
||||||
|
const next = new URLSearchParams(searchParams);
|
||||||
|
next.delete('drive_connected');
|
||||||
|
setSearchParams(next, { replace: true });
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleToggleActive = useCallback(async (id: number, active: boolean) => {
|
||||||
|
setDocuments((prev) => prev.map((doc) => (doc.id === id ? { ...doc, active } as Document : doc)));
|
||||||
|
try {
|
||||||
|
await axiosInstance.patch(`documents_details/${id}`, { active });
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
setDocuments((prev) => prev.map((doc) => (doc.id === id ? { ...doc, active: !active } as Document : doc)));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSort = (field: SortField) => {
|
||||||
|
setPage(1);
|
||||||
|
setOrdering((prev) => {
|
||||||
|
if (prev === field) return `-${field}`;
|
||||||
|
if (prev === `-${field}`) return field;
|
||||||
|
return field === 'created' ? '-created' : field;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScopeChange = (next: DocumentWorkspaceScope) => {
|
||||||
|
setScope(next);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CompanyDocumentStorageTableCard documents={documents} setDocuments={setDocuments} />
|
{showDriveConnectedBanner && (
|
||||||
<UserDocumentStorageTableCard />
|
<SuccessBanner>
|
||||||
<DocumentUploadCard />
|
<span>Drive connected. Choose folders below to include them in your knowledge base.</span>
|
||||||
|
<DismissButton
|
||||||
|
type="button"
|
||||||
|
aria-label="Dismiss"
|
||||||
|
onClick={() => setShowDriveConnectedBanner(false)}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</DismissButton>
|
||||||
|
</SuccessBanner>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DocumentStorageTableCard
|
||||||
|
scope={scope}
|
||||||
|
showTabs={hasCompany}
|
||||||
|
onScopeChange={handleScopeChange}
|
||||||
|
documents={documents}
|
||||||
|
total={total}
|
||||||
|
page={page}
|
||||||
|
pageSize={PAGE_SIZE}
|
||||||
|
search={searchInput}
|
||||||
|
ordering={ordering}
|
||||||
|
loading={loading}
|
||||||
|
onSearchChange={setSearchInput}
|
||||||
|
onSort={handleSort}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onToggleActive={handleToggleActive}
|
||||||
|
/>
|
||||||
|
<DocumentUploadCard scope={scope} onUploaded={fetchDocuments} />
|
||||||
|
|
||||||
|
<DriveConnectionsSection
|
||||||
|
kind="personal"
|
||||||
|
title="Cloud drives"
|
||||||
|
description="Connect your personal Google Drive or OneDrive so its files can be searched in chat."
|
||||||
|
connectIntent="link_drive"
|
||||||
|
onSynced={fetchDocuments}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{account?.is_company_manager && (
|
||||||
|
<DriveConnectionsSection
|
||||||
|
kind="company"
|
||||||
|
title="Company knowledge sources"
|
||||||
|
description="Connect a shared Google Shared Drive or Microsoft 365 site so your whole team can search these files in chat."
|
||||||
|
connectIntent="link_company_drive"
|
||||||
|
onSynced={fetchDocuments}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
const DocumentStoragePage = (): JSX.Element => {
|
const DocumentStoragePage = (): JSX.Element => {
|
||||||
|
const { hasRag, loading } = useSubscription();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<ParticleBackground />
|
<ParticleBackground />
|
||||||
<Header2 />
|
<Header2 />
|
||||||
<ContentWrapper>
|
<ContentWrapper>
|
||||||
|
{loading ? (
|
||||||
|
<GlassCard>
|
||||||
|
<CardTitle>Loading…</CardTitle>
|
||||||
|
</GlassCard>
|
||||||
|
) : hasRag ? (
|
||||||
<DocumentStoragePageInner />
|
<DocumentStoragePageInner />
|
||||||
|
) : (
|
||||||
|
<GlassCard>
|
||||||
|
<CardTitle>Unlock document storage</CardTitle>
|
||||||
|
<UpgradeText>
|
||||||
|
Document uploads and cloud drive connections are available on plans that include
|
||||||
|
RAG document search. Upgrade your plan to start uploading files and connecting
|
||||||
|
Google Drive or OneDrive.
|
||||||
|
</UpgradeText>
|
||||||
|
<StyledButtonLink to="/account/">Upgrade in Billing</StyledButtonLink>
|
||||||
|
</GlassCard>
|
||||||
|
)}
|
||||||
</ContentWrapper>
|
</ContentWrapper>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default DocumentStoragePage;
|
export default DocumentStoragePage;
|
||||||
@@ -85,7 +85,7 @@ describe('SignIn', () => {
|
|||||||
renderSignIn();
|
renderSignIn();
|
||||||
|
|
||||||
expect(await screen.findByRole('button', { name: 'Continue with Google' })).toBeInTheDocument();
|
expect(await screen.findByRole('button', { name: 'Continue with Google' })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: 'Continue with Microsoft' })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: 'Sign in with Microsoft' })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hides SSO buttons when oauth not configured', async () => {
|
it('hides SSO buttons when oauth not configured', async () => {
|
||||||
@@ -99,6 +99,7 @@ describe('SignIn', () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.queryByRole('button', { name: 'Continue with Google' })).not.toBeInTheDocument();
|
expect(screen.queryByRole('button', { name: 'Continue with Google' })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', { name: 'Sign in with Microsoft' })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ const queue: QueuedCall[] = [];
|
|||||||
* | Conversation Created | New chat id assigned over WS |
|
* | Conversation Created | New chat id assigned over WS |
|
||||||
* | Message Sent | User submits prompt (no content) |
|
* | Message Sent | User submits prompt (no content) |
|
||||||
* | ToS Acknowledged | POST acknowledge_tos succeeds |
|
* | ToS Acknowledged | POST acknowledge_tos succeeds |
|
||||||
* | Billing Portal Opened | When #33 portal CTA ships |
|
* | Billing Portal Opened | Account manage / portal CTAs |
|
||||||
|
* | Subscription Upgrade Started | Upgrade intent (#75) |
|
||||||
|
* | Plan Change Started | Change-plan intent (#75) |
|
||||||
|
* | Subscription Cancel Started | Cancel intent (#75) |
|
||||||
|
* | Account Delete Started / Success / Failed | Self-delete (#34 companion) |
|
||||||
*/
|
*/
|
||||||
export const AnalyticsEvents = {
|
export const AnalyticsEvents = {
|
||||||
LOGIN_SUCCESS: 'Login Success',
|
LOGIN_SUCCESS: 'Login Success',
|
||||||
@@ -36,6 +40,12 @@ export const AnalyticsEvents = {
|
|||||||
MESSAGE_SENT: 'Message Sent',
|
MESSAGE_SENT: 'Message Sent',
|
||||||
TOS_ACKNOWLEDGED: 'ToS Acknowledged',
|
TOS_ACKNOWLEDGED: 'ToS Acknowledged',
|
||||||
BILLING_PORTAL_OPENED: 'Billing Portal Opened',
|
BILLING_PORTAL_OPENED: 'Billing Portal Opened',
|
||||||
|
SUBSCRIPTION_UPGRADE_STARTED: 'Subscription Upgrade Started',
|
||||||
|
PLAN_CHANGE_STARTED: 'Plan Change Started',
|
||||||
|
SUBSCRIPTION_CANCEL_STARTED: 'Subscription Cancel Started',
|
||||||
|
ACCOUNT_DELETE_STARTED: 'Account Delete Started',
|
||||||
|
ACCOUNT_DELETE_SUCCESS: 'Account Delete Success',
|
||||||
|
ACCOUNT_DELETE_FAILED: 'Account Delete Failed',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type AnalyticsEventName = (typeof AnalyticsEvents)[keyof typeof AnalyticsEvents];
|
export type AnalyticsEventName = (typeof AnalyticsEvents)[keyof typeof AnalyticsEvents];
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { isRagFeatureNotAllowed, parseChatErrorPayload } from './chatErrors';
|
||||||
|
|
||||||
|
describe('parseChatErrorPayload', () => {
|
||||||
|
it('parses an error-type websocket payload', () => {
|
||||||
|
const raw = JSON.stringify({ type: 'error', code: 'feature_not_allowed', content: 'no rag' });
|
||||||
|
expect(parseChatErrorPayload(raw)).toEqual({
|
||||||
|
type: 'error',
|
||||||
|
code: 'feature_not_allowed',
|
||||||
|
content: 'no rag',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for non-error JSON payloads', () => {
|
||||||
|
expect(parseChatErrorPayload(JSON.stringify({ type: 'text', content: 'hi' }))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for plain streamed text chunks', () => {
|
||||||
|
expect(parseChatErrorPayload('just a plain chunk of text')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isRagFeatureNotAllowed', () => {
|
||||||
|
it('is false for unrelated error codes', () => {
|
||||||
|
expect(isRagFeatureNotAllowed({ code: 'prompt_quota_exceeded', content: 'slow down' })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is false when feature_not_allowed is about a different feature', () => {
|
||||||
|
expect(
|
||||||
|
isRagFeatureNotAllowed({
|
||||||
|
code: 'feature_not_allowed',
|
||||||
|
content: 'Your plan does not include image generation.',
|
||||||
|
details: { feature: 'image_generation' },
|
||||||
|
})
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is true when details.feature mentions rag', () => {
|
||||||
|
expect(
|
||||||
|
isRagFeatureNotAllowed({
|
||||||
|
code: 'feature_not_allowed',
|
||||||
|
content: 'Blocked',
|
||||||
|
details: { feature: 'rag' },
|
||||||
|
})
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is true when the message text mentions rag without structured details', () => {
|
||||||
|
expect(
|
||||||
|
isRagFeatureNotAllowed({
|
||||||
|
code: 'feature_not_allowed',
|
||||||
|
content: 'Your plan does not include RAG document search.',
|
||||||
|
})
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is false for a null/undefined payload', () => {
|
||||||
|
expect(isRagFeatureNotAllowed(null)).toBe(false);
|
||||||
|
expect(isRagFeatureNotAllowed(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Helpers for the websocket chat error payloads sent by the backend
|
||||||
|
* (see chat_backend/consumers.py — `{"type": "error", "code": ..., "content": ..., "details": {...}}`).
|
||||||
|
*/
|
||||||
|
export type ChatErrorPayload = {
|
||||||
|
type?: string;
|
||||||
|
code?: string;
|
||||||
|
content?: string;
|
||||||
|
message?: string;
|
||||||
|
details?: Record<string, unknown> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Parses a raw websocket text chunk into an error payload, or null when it isn't one. */
|
||||||
|
export function parseChatErrorPayload(message: string): ChatErrorPayload | null {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(message);
|
||||||
|
if (parsed && typeof parsed === 'object' && parsed.type === 'error') {
|
||||||
|
return parsed as ChatErrorPayload;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* not JSON — plain streamed text chunk */
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when a `feature_not_allowed` error payload is about the RAG (document search) feature. */
|
||||||
|
export function isRagFeatureNotAllowed(payload: ChatErrorPayload | null | undefined): boolean {
|
||||||
|
if (!payload || payload.code !== 'feature_not_allowed') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const feature = payload.details?.feature;
|
||||||
|
if (typeof feature === 'string' && feature.toLowerCase().includes('rag')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const text = `${payload.content ?? ''} ${payload.message ?? ''}`.toLowerCase();
|
||||||
|
return text.includes('rag');
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import {
|
||||||
|
driveConnectUrl,
|
||||||
|
driveSyncProgressPercent,
|
||||||
|
formatDriveSyncError,
|
||||||
|
parseResourceIdsInput,
|
||||||
|
} from './drive';
|
||||||
|
|
||||||
|
describe('driveSyncProgressPercent', () => {
|
||||||
|
it('returns null when total is unknown', () => {
|
||||||
|
expect(driveSyncProgressPercent({ id: 1, provider: 'google', sync_total: 0 })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes rounded percent from processed/total', () => {
|
||||||
|
expect(
|
||||||
|
driveSyncProgressPercent({
|
||||||
|
id: 1,
|
||||||
|
provider: 'google',
|
||||||
|
sync_total: 4,
|
||||||
|
sync_processed: 1,
|
||||||
|
})
|
||||||
|
).toBe(25);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseResourceIdsInput', () => {
|
||||||
|
it('splits comma and newline separated ids and trims whitespace', () => {
|
||||||
|
expect(parseResourceIdsInput('abc, def\nghi ,, ')).toEqual(['abc', 'def', 'ghi']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty array for blank input', () => {
|
||||||
|
expect(parseResourceIdsInput(' ')).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatDriveSyncError', () => {
|
||||||
|
it('returns a fallback when empty', () => {
|
||||||
|
expect(formatDriveSyncError('')).toMatch(/Drive sync failed/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('truncates long provider error payloads', () => {
|
||||||
|
const long = 'x'.repeat(400);
|
||||||
|
const formatted = formatDriveSyncError(long, 50);
|
||||||
|
expect(formatted.length).toBeLessThanOrEqual(51);
|
||||||
|
expect(formatted.endsWith('…')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('driveConnectUrl', () => {
|
||||||
|
const originalEnv = process.env.REACT_APP_BACKEND_REST_API_BASE_URL;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
process.env.REACT_APP_BACKEND_REST_API_BASE_URL = 'https://api.example.com/';
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
process.env.REACT_APP_BACKEND_REST_API_BASE_URL = originalEnv;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds a personal drive-link url', () => {
|
||||||
|
expect(driveConnectUrl('google', 'link_drive')).toBe(
|
||||||
|
'https://api.example.com/auth/oauth/google/start/?intent=link_drive'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds a company drive-link url', () => {
|
||||||
|
expect(driveConnectUrl('microsoft', 'link_company_drive')).toBe(
|
||||||
|
'https://api.example.com/auth/oauth/microsoft/start/?intent=link_company_drive'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { axiosInstance } from '../../axiosApi';
|
||||||
|
import { oauthStartUrl } from '../auth/sso';
|
||||||
|
|
||||||
|
export type DriveProvider = 'google' | 'microsoft';
|
||||||
|
|
||||||
|
export type DriveConnectionKind = 'personal' | 'company';
|
||||||
|
|
||||||
|
/** intent query param sent to /auth/oauth/:provider/start/ for drive-linking flows (#83/#84). */
|
||||||
|
export type DriveConnectIntent = 'link_drive' | 'link_company_drive';
|
||||||
|
|
||||||
|
export type DriveSyncStatus = 'ok' | 'error' | 'pending' | 'never';
|
||||||
|
|
||||||
|
export type DriveConnectionType = {
|
||||||
|
id: number;
|
||||||
|
provider: DriveProvider;
|
||||||
|
kind?: DriveConnectionKind;
|
||||||
|
is_active?: boolean;
|
||||||
|
external_account_email?: string | null;
|
||||||
|
selected_resource_ids?: string[];
|
||||||
|
selected_resource_labels?: string[];
|
||||||
|
last_sync_at?: string | null;
|
||||||
|
last_sync_status?: DriveSyncStatus | string | null;
|
||||||
|
last_sync_error?: string;
|
||||||
|
sync_total?: number;
|
||||||
|
sync_processed?: number;
|
||||||
|
sync_added?: number;
|
||||||
|
sync_updated?: number;
|
||||||
|
sync_failed?: number;
|
||||||
|
created?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DriveSyncEnqueueResponse = {
|
||||||
|
queued?: boolean;
|
||||||
|
connection: DriveConnectionType;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchDriveConnections(): Promise<DriveConnectionType[]> {
|
||||||
|
const { data } = await axiosInstance.get<DriveConnectionType[]>('/drive/connections/');
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function disconnectDriveConnection(connectionId: number): Promise<void> {
|
||||||
|
await axiosInstance.delete(`/drive/connections/${connectionId}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DriveResourceSelection = {
|
||||||
|
resource_ids: string[];
|
||||||
|
resource_labels: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function saveDriveResourceSelection(
|
||||||
|
connectionId: number,
|
||||||
|
selection: DriveResourceSelection
|
||||||
|
): Promise<DriveConnectionType> {
|
||||||
|
const { data } = await axiosInstance.post<DriveConnectionType>(
|
||||||
|
`/drive/connections/${connectionId}/resources/`,
|
||||||
|
selection
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enqueue a Drive sync (#57/#90). Returns quickly with pending status. */
|
||||||
|
export async function syncDriveConnection(
|
||||||
|
connectionId: number
|
||||||
|
): Promise<DriveSyncEnqueueResponse> {
|
||||||
|
const { data } = await axiosInstance.post<DriveSyncEnqueueResponse>(
|
||||||
|
`/drive/connections/${connectionId}/sync/`
|
||||||
|
);
|
||||||
|
if (!data?.connection) {
|
||||||
|
throw new Error('Drive sync did not return a connection.');
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SYNC_POLL_MS = 1500;
|
||||||
|
const DEFAULT_SYNC_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
|
/** Poll connections until the target leaves ``pending`` (or timeout). */
|
||||||
|
export async function waitForDriveSyncSettlement(
|
||||||
|
connectionId: number,
|
||||||
|
options?: {
|
||||||
|
intervalMs?: number;
|
||||||
|
timeoutMs?: number;
|
||||||
|
onProgress?: (connection: DriveConnectionType) => void;
|
||||||
|
}
|
||||||
|
): Promise<DriveConnectionType> {
|
||||||
|
const intervalMs = options?.intervalMs ?? DEFAULT_SYNC_POLL_MS;
|
||||||
|
const timeoutMs = options?.timeoutMs ?? DEFAULT_SYNC_TIMEOUT_MS;
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const connections = await fetchDriveConnections();
|
||||||
|
const connection = connections.find((item) => item.id === connectionId);
|
||||||
|
if (!connection) {
|
||||||
|
throw new Error('Drive connection disappeared while syncing.');
|
||||||
|
}
|
||||||
|
options?.onProgress?.(connection);
|
||||||
|
if (connection.last_sync_status !== 'pending') {
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||||
|
}
|
||||||
|
throw new Error('Drive sync timed out. Check status and try again.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Percent complete when total is known; otherwise null (indeterminate). */
|
||||||
|
export function driveSyncProgressPercent(connection: DriveConnectionType): number | null {
|
||||||
|
const total = connection.sync_total ?? 0;
|
||||||
|
if (total <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const processed = Math.min(connection.sync_processed ?? 0, total);
|
||||||
|
return Math.round((processed / total) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shorten long provider JSON error blobs for toasts. */
|
||||||
|
export function formatDriveSyncError(raw?: string | null, maxLen = 280): string {
|
||||||
|
const text = (raw || '').trim();
|
||||||
|
if (!text) {
|
||||||
|
return 'Drive sync failed. Try again or check provider API settings.';
|
||||||
|
}
|
||||||
|
if (text.length <= maxLen) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
return `${text.slice(0, maxLen).trim()}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Absolute backend OAuth start URL for a drive-link flow (personal or company). */
|
||||||
|
export function driveConnectUrl(provider: DriveProvider, intent: DriveConnectIntent): string {
|
||||||
|
return oauthStartUrl(provider, intent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Begin Drive link: call start with Bearer JWT, then navigate to IdP authorize URL.
|
||||||
|
* Full-page assign alone cannot send Authorization, so the backend returns JSON.
|
||||||
|
*/
|
||||||
|
export async function connectDrive(
|
||||||
|
provider: DriveProvider,
|
||||||
|
intent: DriveConnectIntent
|
||||||
|
): Promise<void> {
|
||||||
|
const { data } = await axiosInstance.get<{ authorize_url: string }>(
|
||||||
|
`/auth/oauth/${provider}/start/`,
|
||||||
|
{
|
||||||
|
params: { intent, response: 'json' },
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!data?.authorize_url) {
|
||||||
|
throw new Error('Drive connect did not return an authorize URL.');
|
||||||
|
}
|
||||||
|
window.location.assign(data.authorize_url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Splits a comma/newline separated textarea/input value into trimmed, non-empty resource ids. */
|
||||||
|
export function parseResourceIdsInput(raw: string): string[] {
|
||||||
|
return raw
|
||||||
|
.split(/[,\n]/)
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
@@ -4,8 +4,46 @@ import {
|
|||||||
formatTokenCount,
|
formatTokenCount,
|
||||||
humanizeStatus,
|
humanizeStatus,
|
||||||
pickPrimaryInvoice,
|
pickPrimaryInvoice,
|
||||||
|
planAllowsRag,
|
||||||
} from './finance';
|
} from './finance';
|
||||||
import type { FinanceInvoice } from './finance';
|
import type { FinanceInvoice, SubscriptionMe, SubscriptionPlanInfo } from './finance';
|
||||||
|
|
||||||
|
const basePlan = (overrides: Partial<SubscriptionPlanInfo> = {}): SubscriptionPlanInfo => ({
|
||||||
|
slug: 'standard',
|
||||||
|
name: 'Standard',
|
||||||
|
description: '',
|
||||||
|
price_cents: 999,
|
||||||
|
currency: 'usd',
|
||||||
|
interval: 'month',
|
||||||
|
is_public: true,
|
||||||
|
is_selectable: true,
|
||||||
|
features: {
|
||||||
|
text_generation: true,
|
||||||
|
image_generation: false,
|
||||||
|
rag: false,
|
||||||
|
all_future_features: false,
|
||||||
|
},
|
||||||
|
prompt_quota_per_window: 100,
|
||||||
|
prompt_window_hours: 6,
|
||||||
|
monthly_token_quota: null,
|
||||||
|
sort_order: 1,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseUsage: SubscriptionMe['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 baseInvoice = (overrides: Partial<FinanceInvoice> = {}): FinanceInvoice => ({
|
const baseInvoice = (overrides: Partial<FinanceInvoice> = {}): FinanceInvoice => ({
|
||||||
id: 1,
|
id: 1,
|
||||||
@@ -59,3 +97,50 @@ describe('finance helpers', () => {
|
|||||||
expect(canOpenBillingPortal([baseInvoice({ status: 'paid' })])).toBe(true);
|
expect(canOpenBillingPortal([baseInvoice({ status: 'paid' })])).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('planAllowsRag', () => {
|
||||||
|
it('is false when there is no plan/subscription', () => {
|
||||||
|
expect(planAllowsRag(null)).toBe(false);
|
||||||
|
expect(planAllowsRag(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is false when the rag feature flag is off', () => {
|
||||||
|
expect(planAllowsRag(basePlan())).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is true when the rag feature flag is on', () => {
|
||||||
|
expect(
|
||||||
|
planAllowsRag(basePlan({ features: { text_generation: true, image_generation: false, rag: true, all_future_features: false } }))
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is true when all_future_features unlocks it', () => {
|
||||||
|
expect(
|
||||||
|
planAllowsRag(basePlan({ features: { text_generation: true, image_generation: true, rag: false, all_future_features: true } }))
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a SubscriptionMe wrapper and reads its plan', () => {
|
||||||
|
const subscription: SubscriptionMe = {
|
||||||
|
plan: basePlan({ features: { text_generation: true, image_generation: false, rag: true, all_future_features: false } }),
|
||||||
|
status: 'active',
|
||||||
|
source: 'stripe',
|
||||||
|
needs_checkout: false,
|
||||||
|
stripe_subscription_id: 'sub_1',
|
||||||
|
usage: baseUsage,
|
||||||
|
};
|
||||||
|
expect(planAllowsRag(subscription)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is false when a subscription has no plan yet', () => {
|
||||||
|
const subscription: SubscriptionMe = {
|
||||||
|
plan: null,
|
||||||
|
status: 'none',
|
||||||
|
source: 'none',
|
||||||
|
needs_checkout: true,
|
||||||
|
stripe_subscription_id: '',
|
||||||
|
usage: baseUsage,
|
||||||
|
};
|
||||||
|
expect(planAllowsRag(subscription)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export type FinancePayment = {
|
|||||||
export type PlanFeatures = {
|
export type PlanFeatures = {
|
||||||
text_generation: boolean;
|
text_generation: boolean;
|
||||||
image_generation: boolean;
|
image_generation: boolean;
|
||||||
|
rag: boolean;
|
||||||
all_future_features: boolean;
|
all_future_features: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -74,9 +75,55 @@ export type SubscriptionMe = {
|
|||||||
source: string;
|
source: string;
|
||||||
needs_checkout: boolean;
|
needs_checkout: boolean;
|
||||||
stripe_subscription_id: string;
|
stripe_subscription_id: string;
|
||||||
|
cancel_at_period_end?: boolean;
|
||||||
|
current_period_end?: string | null;
|
||||||
usage: SubscriptionUsage;
|
usage: SubscriptionUsage;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SubscriptionSource = 'none' | 'stripe' | 'backer' | 'admin' | string;
|
||||||
|
|
||||||
|
/** Complimentary / admin-granted access — no Stripe cancel/change. */
|
||||||
|
export function isComplimentarySubscription(
|
||||||
|
subscription: Pick<SubscriptionMe, 'source' | 'needs_checkout'> | null | undefined,
|
||||||
|
hasPortalAccess: boolean
|
||||||
|
): boolean {
|
||||||
|
if (!subscription) return false;
|
||||||
|
if (subscription.source === 'backer' || subscription.source === 'admin') return true;
|
||||||
|
return !hasPortalAccess && subscription.needs_checkout === false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Higher-priced selectable plans relative to the current plan. */
|
||||||
|
export function higherSelectablePlans(
|
||||||
|
plans: SubscriptionPlanInfo[],
|
||||||
|
current: SubscriptionPlanInfo | null | undefined
|
||||||
|
): SubscriptionPlanInfo[] {
|
||||||
|
const selectable = plans.filter((p) => p.is_selectable);
|
||||||
|
if (!current) return selectable;
|
||||||
|
return selectable.filter(
|
||||||
|
(p) =>
|
||||||
|
p.slug !== current.slug &&
|
||||||
|
(p.price_cents > current.price_cents || p.sort_order > current.sort_order)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if the plan (or a subscription's plan) includes RAG document search, or unlocks all future features. */
|
||||||
|
export function planAllowsRag(
|
||||||
|
input: SubscriptionPlanInfo | SubscriptionMe | null | undefined
|
||||||
|
): boolean {
|
||||||
|
if (!input) return false;
|
||||||
|
const plan: SubscriptionPlanInfo | null | undefined =
|
||||||
|
'features' in input ? input : input.plan;
|
||||||
|
if (!plan) return false;
|
||||||
|
return Boolean(plan.features?.rag || plan.features?.all_future_features);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function otherSelectablePlans(
|
||||||
|
plans: SubscriptionPlanInfo[],
|
||||||
|
currentSlug: string | null | undefined
|
||||||
|
): SubscriptionPlanInfo[] {
|
||||||
|
return plans.filter((p) => p.is_selectable && p.slug !== currentSlug);
|
||||||
|
}
|
||||||
|
|
||||||
/** Display provider-reported token counts; never invent 0 for missing usage. */
|
/** Display provider-reported token counts; never invent 0 for missing usage. */
|
||||||
export function formatTokenCount(value: number | null | undefined): string {
|
export function formatTokenCount(value: number | null | undefined): string {
|
||||||
if (value === null || value === undefined) return '—';
|
if (value === null || value === undefined) return '—';
|
||||||
|
|||||||
Reference in New Issue
Block a user