127 lines
7.4 KiB
TypeScript
127 lines
7.4 KiB
TypeScript
import React, { useEffect, useRef, useState } from "react";
|
|
import { Alert, Box, Button, LinearProgress, Skeleton, Typography } from "@mui/material";
|
|
import { PageSection } from "../layout/PageLayout";
|
|
import { api } from "../api";
|
|
import { useI18n } from "../i18n/I18nProvider";
|
|
import { checkSessionCookie, getSessionContext, isSessionInvalidated } from "../sessionBoundary";
|
|
|
|
type Usage = {
|
|
currentMonth: { calls: number; estimatedTokens: number };
|
|
plan: string;
|
|
monthlyCallLimit: number;
|
|
monthlyTokenLimit: number;
|
|
storageUsedBytes: number;
|
|
storageLimitBytes: number;
|
|
};
|
|
|
|
type BillingStatus = { enabled: boolean; canCheckout: boolean; canManage: boolean };
|
|
|
|
function formatBytes(bytes: number, locale: string) {
|
|
if (bytes < 1_000_000) return `${Math.round(bytes / 1_000).toLocaleString(locale)} KB`;
|
|
const options = { minimumFractionDigits: 1, maximumFractionDigits: 1 };
|
|
if (bytes < 1_000_000_000) return `${(bytes / 1_000_000).toLocaleString(locale, options)} MB`;
|
|
return `${(bytes / 1_000_000_000).toLocaleString(locale, options)} GB`;
|
|
}
|
|
|
|
export default function AiUsageCard() {
|
|
const { t, language } = useI18n();
|
|
const locale = language === "nb" ? "nb-NO" : "en";
|
|
const [usage, setUsage] = useState<Usage | null>(null);
|
|
const [billing, setBilling] = useState<BillingStatus | null>(null);
|
|
const [billingBusy, setBillingBusy] = useState(false);
|
|
const [billingFailed, setBillingFailed] = useState(false);
|
|
const [failed, setFailed] = useState(false);
|
|
const [billingReadFailed, setBillingReadFailed] = useState(false);
|
|
const [usageAttempt, setUsageAttempt] = useState(0);
|
|
const [billingAttempt, setBillingAttempt] = useState(0);
|
|
const owner = useRef({ mounted: false, generation: 0, billingBusy: false });
|
|
const body = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
const lifetime = owner.current;
|
|
lifetime.mounted = true;
|
|
lifetime.generation += 1;
|
|
return () => { lifetime.mounted = false; lifetime.generation += 1; };
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
let active = true;
|
|
const context = getSessionContext();
|
|
const current = () => active && !isSessionInvalidated() && getSessionContext() === context;
|
|
api.get<Usage>("/ai/usage")
|
|
.then((response) => { if (current()) setUsage(response.data); })
|
|
.catch(() => { if (current()) setFailed(true); });
|
|
return () => { active = false; };
|
|
}, [usageAttempt]);
|
|
|
|
useEffect(() => {
|
|
let active = true;
|
|
const context = getSessionContext();
|
|
const current = () => active && !isSessionInvalidated() && getSessionContext() === context;
|
|
api.get<BillingStatus>("/billing/status")
|
|
.then((response) => { if (current()) setBilling(response.data); })
|
|
.catch(() => { if (current()) setBillingReadFailed(true); });
|
|
return () => { active = false; };
|
|
}, [billingAttempt]);
|
|
|
|
const openBilling = async (path: "checkout" | "portal") => {
|
|
checkSessionCookie();
|
|
const lifetime = owner.current;
|
|
if (!lifetime.mounted || lifetime.billingBusy || isSessionInvalidated()) return;
|
|
const generation = lifetime.generation;
|
|
const context = getSessionContext();
|
|
const active = () => lifetime.mounted && lifetime.generation === generation && !isSessionInvalidated() && getSessionContext() === context;
|
|
lifetime.billingBusy = true;
|
|
setBillingBusy(true);
|
|
setBillingFailed(false);
|
|
try {
|
|
const response = await api.post<{ url: string }>(`/billing/${path}`);
|
|
checkSessionCookie();
|
|
if (!active()) return;
|
|
window.location.assign(response.data.url);
|
|
} catch {
|
|
checkSessionCookie();
|
|
if (!active()) return;
|
|
setBillingFailed(true);
|
|
setBillingBusy(false);
|
|
lifetime.billingBusy = false;
|
|
}
|
|
};
|
|
|
|
const callsPercent = usage && usage.monthlyCallLimit > 0 ? Math.min(100, usage.currentMonth.calls / usage.monthlyCallLimit * 100) : 0;
|
|
const tokensPercent = usage && usage.monthlyTokenLimit > 0 ? Math.min(100, usage.currentMonth.estimatedTokens / usage.monthlyTokenLimit * 100) : 0;
|
|
const storagePercent = usage && usage.storageLimitBytes > 0 ? Math.min(100, usage.storageUsedBytes / usage.storageLimitBytes * 100) : 0;
|
|
|
|
return (
|
|
<PageSection surface title={t("settingsUsageTitle")} actions={usage ? <Typography variant="caption" sx={{ textTransform: "capitalize", fontWeight: 700 }}>{t("settingsUsagePlan", { plan: usage.plan })}</Typography> : undefined}>
|
|
<Box ref={body} role="group" aria-label={t("settingsUsageTitle")} tabIndex={-1}>
|
|
{failed ? <Alert severity="warning" action={<Button color="inherit" onClick={() => { if (body.current?.contains(document.activeElement)) body.current.focus(); setFailed(false); setUsageAttempt(value => value + 1); }}>{t("retry")}</Button>}>{t("settingsUsageUnavailable")}</Alert> : !usage ? <Box role="status" aria-label={t("loading")}><Skeleton variant="rounded" height={150} /></Box> : null}
|
|
{usage ? <>
|
|
{usage.monthlyCallLimit === 0 ? <Alert severity="info" sx={{ mb: 2 }}>{t("settingsUsageNoAi")}</Alert> : <><Box sx={{ mb: 2 }}>
|
|
<Typography variant="body2">{t("settingsUsageGenerations", { used: usage.currentMonth.calls.toLocaleString(locale), limit: usage.monthlyCallLimit.toLocaleString(locale) })}</Typography>
|
|
<LinearProgress variant="determinate" value={callsPercent} aria-label={t("settingsUsageGenerationsProgress")} sx={{ mt: 0.75, height: 7, borderRadius: 99 }} />
|
|
</Box>
|
|
<Box>
|
|
<Typography variant="body2">{t("settingsUsageTokens", { used: usage.currentMonth.estimatedTokens.toLocaleString(locale), limit: usage.monthlyTokenLimit.toLocaleString(locale) })}</Typography>
|
|
<LinearProgress variant="determinate" value={tokensPercent} aria-label={t("settingsUsageTokensProgress")} sx={{ mt: 0.75, height: 7, borderRadius: 99 }} />
|
|
</Box></>}
|
|
<Box sx={{ mt: 2 }}>
|
|
<Typography variant="body2">{t("settingsUsageStorage", { used: formatBytes(usage.storageUsedBytes, locale), limit: formatBytes(usage.storageLimitBytes, locale) })}</Typography>
|
|
<LinearProgress variant="determinate" value={storagePercent} aria-label={t("settingsUsageStorageProgress")} sx={{ mt: 0.75, height: 7, borderRadius: 99 }} />
|
|
</Box>
|
|
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 1.5 }}>{t("settingsUsageReset")}</Typography>
|
|
</> : null}
|
|
{billingReadFailed ? <Alert severity="warning" sx={{ mt: 2 }} action={<Button color="inherit" onClick={() => { if (body.current?.contains(document.activeElement)) body.current.focus(); setBillingReadFailed(false); setBillingAttempt(value => value + 1); }}>{t("retry")}</Button>}>{t("settingsBillingUnavailable")}</Alert> : !billing ? <Box role="status" aria-label={t("loading")}><Skeleton variant="rounded" height={36} /></Box> : null}
|
|
{billing?.canCheckout ? <Button sx={{ mt: 2 }} variant="contained" disabled={billingBusy} onClick={() => void openBilling("checkout")}>{t("settingsBillingUpgrade")}</Button> : null}
|
|
{billing?.canManage ? <Button sx={{ mt: 2 }} variant="outlined" disabled={billingBusy} onClick={() => void openBilling("portal")}>{t("settingsBillingManage")}</Button> : null}
|
|
{billingFailed ? <Alert severity="error" sx={{ mt: 2 }}>{t("settingsBillingUnavailable")}</Alert> : null}
|
|
{usage?.plan === "free" && billing?.enabled === false ? (
|
|
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 1.5 }}>
|
|
{t("settingsBillingNotConfigured")}
|
|
</Typography>
|
|
) : null}
|
|
</Box>
|
|
</PageSection>
|
|
);
|
|
}
|