mirror of
https://github.com/SerTimBerrners-Lee/talkis.git
synced 2026-08-11 09:45:20 +00:00
feat(cloud): show wallet limits in desktop
This commit is contained in:
parent
d0ea444d66
commit
fac28131dd
11 changed files with 252 additions and 76 deletions
|
|
@ -438,7 +438,7 @@ async fn resolve_cloud_live_dictation_connection(
|
|||
) -> Result<RealtimeConnectionRequest, String> {
|
||||
let device_token = req.api_key.as_deref().unwrap_or_default().trim();
|
||||
if device_token.is_empty() {
|
||||
return Err("Войдите в Talkis и выберите активную облачную подписку.".to_string());
|
||||
return Err("Войдите в Talkis и проверьте облачный баланс.".to_string());
|
||||
}
|
||||
let url = format!(
|
||||
"{}{}",
|
||||
|
|
@ -474,19 +474,21 @@ async fn resolve_cloud_live_dictation_connection(
|
|||
.and_then(|error| error.as_str())
|
||||
.map(str::to_string)
|
||||
});
|
||||
let message = match status.as_u16() {
|
||||
401 => "Сессия Talkis истекла. Войдите в облако повторно.".to_string(),
|
||||
403 => "Для realtime-транскрибации нужна активная подписка Talkis.".to_string(),
|
||||
429 => {
|
||||
"Слишком много запусков realtime-транскрибации. Повторите через минуту.".to_string()
|
||||
}
|
||||
_ => upstream_message.unwrap_or_else(|| {
|
||||
format!(
|
||||
"Облако Talkis не запустило realtime-транскрибацию ({})",
|
||||
status
|
||||
)
|
||||
}),
|
||||
};
|
||||
let message =
|
||||
match status.as_u16() {
|
||||
401 => "Сессия Talkis истекла. Войдите в облако повторно.".to_string(),
|
||||
402 => "Облачный баланс закончился. Пополните его в личном кабинете Talkis."
|
||||
.to_string(),
|
||||
403 => "Облачный доступ Talkis сейчас недоступен.".to_string(),
|
||||
429 => "Слишком много запусков realtime-транскрибации. Повторите через минуту."
|
||||
.to_string(),
|
||||
_ => upstream_message.unwrap_or_else(|| {
|
||||
format!(
|
||||
"Облако Talkis не запустило realtime-транскрибацию ({})",
|
||||
status
|
||||
)
|
||||
}),
|
||||
};
|
||||
logger::log_error(
|
||||
"LIVE_DICTATION",
|
||||
&format!(
|
||||
|
|
|
|||
|
|
@ -513,7 +513,7 @@ async fn resolve_translation_connection(
|
|||
}
|
||||
|
||||
if req.api_key.trim().is_empty() {
|
||||
return Err("Войдите в Talkis и выберите активную облачную подписку.".to_string());
|
||||
return Err("Войдите в Talkis и проверьте облачный баланс.".to_string());
|
||||
}
|
||||
let url = format!(
|
||||
"{}{}",
|
||||
|
|
@ -554,16 +554,18 @@ async fn resolve_translation_connection(
|
|||
.and_then(|error| error.as_str())
|
||||
.map(str::to_string)
|
||||
});
|
||||
let message = match status.as_u16() {
|
||||
401 => "Сессия Talkis истекла. Войдите в облако повторно.".to_string(),
|
||||
403 => "Для синхронного перевода нужна активная подписка Talkis.".to_string(),
|
||||
429 => {
|
||||
"Слишком много запусков синхронного перевода. Повторите через минуту.".to_string()
|
||||
}
|
||||
_ => upstream_message.unwrap_or_else(|| {
|
||||
format!("Облако Talkis не запустило синхронный перевод ({})", status)
|
||||
}),
|
||||
};
|
||||
let message =
|
||||
match status.as_u16() {
|
||||
401 => "Сессия Talkis истекла. Войдите в облако повторно.".to_string(),
|
||||
402 => "Облачный баланс закончился. Пополните его в личном кабинете Talkis."
|
||||
.to_string(),
|
||||
403 => "Облачный доступ Talkis сейчас недоступен.".to_string(),
|
||||
429 => "Слишком много запусков синхронного перевода. Повторите через минуту."
|
||||
.to_string(),
|
||||
_ => upstream_message.unwrap_or_else(|| {
|
||||
format!("Облако Talkis не запустило синхронный перевод ({})", status)
|
||||
}),
|
||||
};
|
||||
logger::log_error(
|
||||
"LIVE_TRANSLATION",
|
||||
&format!(
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
CloudProfile,
|
||||
fetchCloudProfile,
|
||||
cloudLogout,
|
||||
getAuthLoginUrl,
|
||||
getCloudTopUpUrl,
|
||||
handleAuthToken,
|
||||
generateExchangeCode,
|
||||
getAuthLoginUrlWithCode,
|
||||
|
|
@ -35,7 +35,7 @@ function extractTokenFromUrl(url: string): string | null {
|
|||
}
|
||||
|
||||
export function UserPanel() {
|
||||
const { t } = useI18n();
|
||||
const { t, lang } = useI18n();
|
||||
const [profile, setProfile] = useState<CloudProfile | null | undefined>(() => getCachedCloudProfile());
|
||||
const [loading, setLoading] = useState(() => getCachedCloudProfile() === undefined);
|
||||
const [waitingForAuth, setWaitingForAuth] = useState(false);
|
||||
|
|
@ -200,7 +200,7 @@ export function UserPanel() {
|
|||
const handleActivate = async () => {
|
||||
try {
|
||||
if (profile) {
|
||||
await openUrl(getAuthLoginUrl().replace("/auth/login?device=true", "/dashboard"));
|
||||
await openUrl(profile.topUpUrl || getCloudTopUpUrl());
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -228,20 +228,34 @@ export function UserPanel() {
|
|||
return <div style={styles.container} />;
|
||||
}
|
||||
|
||||
// ── Authenticated + active subscription ─────────────────────
|
||||
// ── Authenticated + available cloud balance ──────────────────
|
||||
if (profile && profile.subscription.active) {
|
||||
const balance = profile.wallet
|
||||
? formatCloudBalance(
|
||||
profile.wallet.balanceMilliTokens,
|
||||
lang === "ru" ? "ru-RU" : "en-US",
|
||||
)
|
||||
: null;
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<ProfileRow profile={profile} onLogout={handleLogout} />
|
||||
<div style={styles.badgeActive}>
|
||||
<div style={styles.badgeDot} />
|
||||
{t("userPanel.subscriptionActive")}
|
||||
{balance
|
||||
? t("userPanel.balance", { tokens: balance })
|
||||
: t("userPanel.subscriptionActive")}
|
||||
</div>
|
||||
{profile.wallet?.low && (
|
||||
<button onClick={handleActivate} style={styles.compactCta}>
|
||||
<IconCrown size={13} stroke={2} color="var(--accent-contrast)" />
|
||||
<span style={styles.compactCtaLabel}>{t("userPanel.upgradeToPro")}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Authenticated but no active subscription ────────────────
|
||||
// ── Authenticated but empty cloud balance ───────────────────
|
||||
if (profile && !profile.subscription.active) {
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
|
|
@ -262,6 +276,14 @@ export function UserPanel() {
|
|||
);
|
||||
}
|
||||
|
||||
function formatCloudBalance(value: string, locale: string): string {
|
||||
try {
|
||||
return (BigInt(value) / 1_000n).toLocaleString(locale);
|
||||
} catch {
|
||||
return "0";
|
||||
}
|
||||
}
|
||||
|
||||
function ProfileRow({ profile, onLogout }: { profile: CloudProfile; onLogout: () => void }) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
* Talkis Cloud authentication client.
|
||||
*
|
||||
* Handles communication with talkis.ru API for:
|
||||
* - Fetching user profile and subscription status
|
||||
* - Fetching user profile and cloud balance
|
||||
* - Deep link token handling
|
||||
* - Logout
|
||||
*/
|
||||
|
|
@ -53,9 +53,29 @@ export interface CloudSubscription {
|
|||
expiresAt: string | null;
|
||||
}
|
||||
|
||||
export interface CloudWallet {
|
||||
balanceMilliTokens: string;
|
||||
balanceTokens: string;
|
||||
reservedMilliTokens: string;
|
||||
debtMilliTokens: string;
|
||||
lifetimeGrantedMilliTokens: string;
|
||||
lifetimeSpentMilliTokens: string;
|
||||
low: boolean;
|
||||
empty: boolean;
|
||||
cloudBlocked: boolean;
|
||||
}
|
||||
|
||||
export interface CloudThresholds {
|
||||
lowBalanceMilliTokens: string;
|
||||
progressTargetMilliTokens: string;
|
||||
}
|
||||
|
||||
export interface CloudProfile {
|
||||
user: CloudUser;
|
||||
subscription: CloudSubscription;
|
||||
wallet?: CloudWallet;
|
||||
thresholds?: CloudThresholds;
|
||||
topUpUrl?: string;
|
||||
}
|
||||
|
||||
export function getCachedCloudProfile(): CloudProfile | null | undefined {
|
||||
|
|
@ -197,6 +217,10 @@ export function getAuthLoginUrl(): string {
|
|||
return `${CLOUD_API_BASE}/auth/login?device=true`;
|
||||
}
|
||||
|
||||
export function getCloudTopUpUrl(): string {
|
||||
return `${CLOUD_API_BASE}/dashboard/billing`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random exchange code for device auth.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -458,7 +458,12 @@ export function toFileTranscriptionErrorMessage(
|
|||
return tn("fileTranscription.errLocalRuntimeRejected");
|
||||
}
|
||||
|
||||
if (normalized.includes("subscription inactive") || normalized.includes("403")) {
|
||||
if (
|
||||
normalized.includes("subscription inactive") ||
|
||||
normalized.includes("insufficient_cloud_tokens") ||
|
||||
normalized.includes("402") ||
|
||||
normalized.includes("403")
|
||||
) {
|
||||
return tn("fileTranscription.errSubscriptionInactive");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -167,26 +167,27 @@ export const components = {
|
|||
|
||||
// ── UserPanel ─────────────────────────────────────────────
|
||||
"userPanel.subscriptionActive": {
|
||||
ru: "Подписка активна",
|
||||
en: "Subscription active",
|
||||
ru: "Облако доступно",
|
||||
en: "Cloud is available",
|
||||
},
|
||||
"userPanel.upgradeToPro": { ru: "Перейти на PRO", en: "Upgrade to PRO" },
|
||||
"userPanel.balance": { ru: "Баланс: {tokens} токенов", en: "Balance: {tokens} tokens" },
|
||||
"userPanel.upgradeToPro": { ru: "Пополнить баланс", en: "Top up balance" },
|
||||
"userPanel.logout": { ru: "Выйти", en: "Log out" },
|
||||
"userPanel.cta.title": { ru: "Активируйте Talkis", en: "Activate Talkis" },
|
||||
"userPanel.cta.title": { ru: "Облако Talkis", en: "Talkis Cloud" },
|
||||
"userPanel.cta.feature.unlimited": {
|
||||
ru: "Безлимитное использование",
|
||||
en: "Unlimited usage",
|
||||
ru: "Без подписки и автоплатежей",
|
||||
en: "No subscription or recurring charges",
|
||||
},
|
||||
"userPanel.cta.feature.noVpn": {
|
||||
ru: "Без VPN и Прокси",
|
||||
en: "No VPN or proxy",
|
||||
},
|
||||
"userPanel.cta.feature.deviceSync": {
|
||||
ru: "Синхронизация устройств",
|
||||
en: "Device sync",
|
||||
ru: "Токены не сгорают",
|
||||
en: "Tokens do not expire",
|
||||
},
|
||||
"userPanel.cta.feature.freeTrial": {
|
||||
ru: "7 дней бесплатно",
|
||||
en: "7 days free",
|
||||
ru: "500 токенов после регистрации",
|
||||
en: "500 tokens after registration",
|
||||
},
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -69,12 +69,12 @@ export const libMessages = {
|
|||
en: "Cloud speaker labeling is not available yet. Use local preparation in the file transcription section.",
|
||||
},
|
||||
"fileTranscription.errCloudDiarizationUnavailable": {
|
||||
ru: "Облачное разделение по говорящим сейчас недоступно. Проверьте активную подписку PRO или переключитесь на локальный режим.",
|
||||
en: "Cloud speaker separation is currently unavailable. Check your active PRO subscription or switch to local mode.",
|
||||
ru: "Облачное разделение по говорящим сейчас недоступно. Проверьте баланс или переключитесь на локальный режим.",
|
||||
en: "Cloud speaker separation is currently unavailable. Check your balance or switch to local mode.",
|
||||
},
|
||||
"fileTranscription.errSubscriptionInactive": {
|
||||
ru: "Для облачной транскрибации нужна активная подписка Talkis.",
|
||||
en: "Cloud transcription requires an active Talkis subscription.",
|
||||
ru: "Облачный баланс закончился. Пополните его или переключитесь на локальный режим.",
|
||||
en: "Your cloud balance is empty. Top it up or switch to local mode.",
|
||||
},
|
||||
"fileTranscription.errCannotPrepareDiarization": {
|
||||
ru: "Не удалось подготовить аудио для разметки говорящих. Попробуйте другой аудио- или видеофайл.",
|
||||
|
|
|
|||
|
|
@ -2,14 +2,17 @@
|
|||
export const settingsModels = {
|
||||
// ── Account / subscription cards ──
|
||||
"models.account.logout": { ru: "Выйти", en: "Log out" },
|
||||
"models.cta.upgradePro": { ru: "Перейти на PRO", en: "Upgrade to PRO" },
|
||||
"models.cta.freeTrial": { ru: "7 дней бесплатно", en: "7 days free" },
|
||||
"models.guest.title": { ru: "Подписка Talkis", en: "Talkis subscription" },
|
||||
"models.guest.benefit1": { ru: "• Безлимитное использование без ограничений", en: "• Unlimited use, no limits" },
|
||||
"models.guest.benefit2": { ru: "• Без VPN и Прокси", en: "• No VPN or proxy required" },
|
||||
"models.guest.benefit3": { ru: "• Синхронизация со всеми устройствами", en: "• Sync across all your devices" },
|
||||
"models.subscription.active": { ru: "Подписка активна", en: "Subscription active" },
|
||||
"models.subscription.unlimitedUntil": { ru: "Безлимитный доступ до {date}", en: "Unlimited access until {date}" },
|
||||
"models.cta.upgradePro": { ru: "Пополнить баланс", en: "Top up balance" },
|
||||
"models.cta.freeTrial": { ru: "500 токенов новым пользователям", en: "500 tokens for new users" },
|
||||
"models.guest.title": { ru: "Облачный баланс", en: "Cloud balance" },
|
||||
"models.guest.benefit1": { ru: "• Без подписки и автоплатежей", en: "• No subscription or recurring charges" },
|
||||
"models.guest.benefit2": { ru: "• Токены не сгорают", en: "• Tokens do not expire" },
|
||||
"models.guest.benefit3": { ru: "• Локальные модели остаются бесплатными", en: "• Local models remain free" },
|
||||
"models.subscription.active": { ru: "Облако доступно", en: "Cloud is available" },
|
||||
"models.subscription.balance": { ru: "Доступно {tokens} токенов", en: "{tokens} tokens available" },
|
||||
"models.subscription.reserved": { ru: "Зарезервировано: {tokens}", en: "Reserved: {tokens}" },
|
||||
"models.subscription.low": { ru: "Осталось меньше 20%", en: "Less than 20% remaining" },
|
||||
"models.subscription.topUp": { ru: "Пополнить", en: "Top up" },
|
||||
|
||||
// ── Prompt library ──
|
||||
"models.prompt.nameLabel": { ru: "Название", en: "Name" },
|
||||
|
|
@ -77,11 +80,11 @@ export const settingsModels = {
|
|||
en: "Recognition, text processing, and live translation run through Talkis Cloud. Data is encrypted in transit, and the primary API key remains on the server.",
|
||||
},
|
||||
"models.cloud.descGuest": {
|
||||
ru: "Для облачного режима нужна авторизация и активная подписка. После входа плашка и статус подписки обновятся автоматически.",
|
||||
en: "Cloud mode requires sign-in and an active subscription. After you log in, the banner and subscription status update automatically.",
|
||||
ru: "Для облачного режима нужна авторизация и положительный баланс. После входа данные обновятся автоматически.",
|
||||
en: "Cloud mode requires sign-in and a positive balance. The data refreshes automatically after you log in.",
|
||||
},
|
||||
"models.cloud.proReady": { ru: "PRO активен, облако готово к выбору", en: "PRO is active, the cloud is ready to select" },
|
||||
"models.cloud.needPro": { ru: "7 дней бесплатно — перейдите на PRO", en: "7 days free — upgrade to PRO" },
|
||||
"models.cloud.proReady": { ru: "Облачный режим готов к выбору", en: "Cloud mode is ready to select" },
|
||||
"models.cloud.needPro": { ru: "Пополните баланс для облачного режима", en: "Top up your balance for cloud mode" },
|
||||
|
||||
// ── API adapters section ──
|
||||
"models.apiSection.title": { ru: "Доступные API-адаптеры", en: "Available API adapters" },
|
||||
|
|
|
|||
|
|
@ -136,8 +136,8 @@ export const widget = {
|
|||
en: "The recognition service is currently unavailable in your region. Try a different endpoint or a VPN.",
|
||||
},
|
||||
"widget.error.subscriptionRequired": {
|
||||
ru: "Для облачного режима нужна активная подписка Talkis.",
|
||||
en: "Cloud mode requires an active Talkis subscription.",
|
||||
ru: "Облачный баланс закончился. Пополните его в личном кабинете Talkis.",
|
||||
en: "Your cloud balance is empty. Top it up in your Talkis account.",
|
||||
},
|
||||
"widget.error.requestRejected": {
|
||||
ru: "Сервис отклонил запрос. Проверьте API-ключ, регион доступа или настройки endpoint.",
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ import {
|
|||
cancelCloudAuthFlow,
|
||||
CloudProfile,
|
||||
fetchCloudProfile,
|
||||
getAuthLoginUrl,
|
||||
getCloudTopUpUrl,
|
||||
cloudLogout,
|
||||
handleAuthToken,
|
||||
generateExchangeCode,
|
||||
|
|
@ -880,37 +880,146 @@ function SubscriptionGuestCard({ onActivate }: { onActivate: () => void }) {
|
|||
);
|
||||
}
|
||||
|
||||
const DEFAULT_CLOUD_PROGRESS_TARGET = 3_900_000n;
|
||||
const DEFAULT_CLOUD_LOW_THRESHOLD = 780_000n;
|
||||
|
||||
function cloudMilliTokens(value: string | undefined, fallback = 0n): bigint {
|
||||
if (!value) return fallback;
|
||||
try {
|
||||
return BigInt(value);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function formatCloudTokens(milliTokens: bigint, locale: string): string {
|
||||
const whole = milliTokens / 1_000n;
|
||||
const fraction = milliTokens % 1_000n;
|
||||
if (fraction === 0n) return whole.toLocaleString(locale);
|
||||
return `${whole.toLocaleString(locale)},${fraction
|
||||
.toString()
|
||||
.padStart(3, "0")
|
||||
.replace(/0+$/, "")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Three-state subscription block, shared by the Models → IconCloud section and the
|
||||
* dedicated "Подписка Talkis" tab: active-subscription banner, signed-in account
|
||||
* card (activate / log out), or guest promo (sign in + start the free trial).
|
||||
* Three-state cloud block shared by the recognition mode surfaces: available
|
||||
* wallet, signed-in empty wallet, or guest sign-in card.
|
||||
*/
|
||||
function SubscriptionCards({
|
||||
profile,
|
||||
onActivate,
|
||||
onTopUp,
|
||||
onLogout,
|
||||
}: {
|
||||
profile: CloudProfile | null | undefined;
|
||||
onActivate: () => void;
|
||||
onTopUp: () => void;
|
||||
onLogout: () => void;
|
||||
}) {
|
||||
const { t, lang } = useI18n();
|
||||
|
||||
if (profile?.subscription.active === true) {
|
||||
const locale = lang === "ru" ? "ru-RU" : "en-US";
|
||||
const wallet = profile.wallet;
|
||||
const balance = cloudMilliTokens(wallet?.balanceMilliTokens);
|
||||
const reserved = cloudMilliTokens(wallet?.reservedMilliTokens);
|
||||
const target = cloudMilliTokens(
|
||||
profile.thresholds?.progressTargetMilliTokens,
|
||||
DEFAULT_CLOUD_PROGRESS_TARGET,
|
||||
);
|
||||
const lowThreshold = cloudMilliTokens(
|
||||
profile.thresholds?.lowBalanceMilliTokens,
|
||||
DEFAULT_CLOUD_LOW_THRESHOLD,
|
||||
);
|
||||
const boundedBalance = balance > target ? target : balance;
|
||||
const progress =
|
||||
target > 0n ? Number((boundedBalance * 10_000n) / target) / 100 : 0;
|
||||
const low = wallet?.low ?? balance < lowThreshold;
|
||||
|
||||
return (
|
||||
<div className="card" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 16 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
|
||||
<div style={{ width: 42, height: 42, borderRadius: 999, background: "var(--accent)", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
<IconCrown size={20} stroke={2.2} color="var(--accent-contrast)" />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: "var(--text-hi)" }}>{t("models.subscription.active")}</div>
|
||||
<div style={{ fontSize: 13, color: "var(--text-mid)", lineHeight: 1.6 }}>
|
||||
{t("models.subscription.unlimitedUntil", { date: profile.subscription.expiresAt ? new Date(profile.subscription.expiresAt).toLocaleDateString(lang === "ru" ? "ru-RU" : "en-US", { day: "numeric", month: "long" }) : "—" })}
|
||||
<div className="card" style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 16 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 14, minWidth: 0 }}>
|
||||
<div style={{ width: 42, height: 42, borderRadius: 999, background: "var(--accent)", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
|
||||
<IconCrown size={20} stroke={2.2} color="var(--accent-contrast)" />
|
||||
</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: "var(--text-hi)" }}>{t("models.subscription.active")}</div>
|
||||
<div style={{ fontSize: 13, color: "var(--text-mid)", lineHeight: 1.6 }}>
|
||||
{wallet
|
||||
? t("models.subscription.balance", {
|
||||
tokens: formatCloudTokens(balance, locale),
|
||||
})
|
||||
: t("models.cloud.proReady")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ width: 10, height: 10, borderRadius: 999, background: low ? "var(--text-low)" : "var(--accent)", flexShrink: 0 }} />
|
||||
</div>
|
||||
<div style={{ width: 10, height: 10, borderRadius: 999, background: "var(--accent)", flexShrink: 0 }} />
|
||||
|
||||
{wallet && (
|
||||
<>
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-label={t("models.subscription.balance", {
|
||||
tokens: formatCloudTokens(balance, locale),
|
||||
})}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.round(progress)}
|
||||
style={{
|
||||
height: 8,
|
||||
borderRadius: 999,
|
||||
overflow: "hidden",
|
||||
background: "var(--control-track)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${progress}%`,
|
||||
height: "100%",
|
||||
borderRadius: 999,
|
||||
background: "var(--accent)",
|
||||
transition: "width 0.2s ease",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, flexWrap: "wrap" }}>
|
||||
<div style={{ fontSize: 12, color: low ? "var(--text-hi)" : "var(--text-low)", lineHeight: 1.5 }}>
|
||||
{low
|
||||
? t("models.subscription.low")
|
||||
: reserved > 0n
|
||||
? t("models.subscription.reserved", {
|
||||
tokens: formatCloudTokens(reserved, locale),
|
||||
})
|
||||
: `${Math.round(progress)}%`}
|
||||
</div>
|
||||
{low && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTopUp}
|
||||
style={{
|
||||
padding: "9px 14px",
|
||||
borderRadius: 10,
|
||||
border: "none",
|
||||
background: "var(--accent)",
|
||||
color: "var(--accent-contrast)",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
fontFamily: "var(--font-main)",
|
||||
cursor: "pointer",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.06em",
|
||||
}}
|
||||
>
|
||||
{t("models.subscription.topUp")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1908,7 +2017,7 @@ export function SettingsTabs({ type }: SettingsTabsProps) {
|
|||
const authed = cloudProfile !== null && cloudProfile !== undefined;
|
||||
if (authed) {
|
||||
setWaitingForSubscriptionRefresh(true);
|
||||
await openUrl(getAuthLoginUrl().replace("/auth/login?device=true", "/dashboard"));
|
||||
await openUrl(cloudProfile.topUpUrl || getCloudTopUpUrl());
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -3018,6 +3127,7 @@ export function SettingsTabs({ type }: SettingsTabsProps) {
|
|||
<SubscriptionCards
|
||||
profile={cloudProfile}
|
||||
onActivate={handleActivateSubscription}
|
||||
onTopUp={handleActivateSubscription}
|
||||
onLogout={() => {
|
||||
void handleCloudLogout();
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -292,6 +292,13 @@ function toUserFacingErrorMessage(error: unknown, settings: AppSettings): string
|
|||
return tn("widget.error.regionUnsupported");
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.includes("402") ||
|
||||
normalized.includes("insufficient_cloud_tokens")
|
||||
) {
|
||||
return tn("widget.error.subscriptionRequired");
|
||||
}
|
||||
|
||||
if (normalized.includes("403") || normalized.includes("forbidden")) {
|
||||
if (isLocalStt) {
|
||||
return tn("widget.error.localRuntimeRejected");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue