From 8bc36371fa3d676a8d017f7ee58b61d800466c0b Mon Sep 17 00:00:00 2001 From: alpha Date: Tue, 13 Oct 2026 10:19:01 -0500 Subject: [PATCH 01/17] feat(state): subscription status from the ping, plans from subscription-plans AppState gains subscribed, subscriptionExpiresAt and subscriptionPlan. credits stays the free balance: balanceState reads free_credits when the backend sends it, since its own credits is, for a subscriber, a compatibility figure for clients that predate subscriptions. An older backend's credits is used as before. Plans now come from /api/payment/subscription-plans, and history rows carry days. Refs #143 Co-Authored-By: Claude Opus 5.5 --- src/main/api/payment.ts | 22 +++++++--- src/main/ipc/payment.ts | 6 +-- src/main/services/app-state.service.ts | 3 ++ src/main/services/health-check.service.ts | 5 ++- src/main/services/payment.service.ts | 21 ++++++---- src/main/types/app-state.ts | 11 +++++ src/main/types/health-check.ts | 8 ++++ src/main/types/payment.ts | 29 ++++++++++---- src/main/utils/balance.ts | 29 ++++++++++++++ ...y-credits-tab.tsx => subscription-tab.tsx} | 0 src/renderer/hooks/use-app-state.tsx | 3 ++ src/renderer/hooks/use-payment.ts | 4 +- src/renderer/types/app-state.ts | 11 +++++ src/renderer/types/electron-api.d.ts | 8 ++-- src/renderer/types/payment.ts | 29 ++++++++++---- test/balance.test.mjs | 40 +++++++++++++++++++ test/run.mjs | 1 + 17 files changed, 188 insertions(+), 42 deletions(-) create mode 100644 src/main/utils/balance.ts rename src/renderer/components/custom/payment/{buy-credits-tab.tsx => subscription-tab.tsx} (100%) create mode 100644 test/balance.test.mjs diff --git a/src/main/api/payment.ts b/src/main/api/payment.ts index ede55d1f..946d7516 100644 --- a/src/main/api/payment.ts +++ b/src/main/api/payment.ts @@ -7,20 +7,30 @@ import { AvailableCurrency, CreatePaymentRequest, CreatePaymentResponse, - CreditPlan, PaymentHistory, + PaymentPlan, PaymentStatusResponse, } from '../types/payment.js'; +import type { BackendBalance } from '../utils/balance.js'; import { ApiClient, ApiResponse } from './client.js'; export class PaymentApi extends ApiClient { /** - * Get available credit plans + * Get the subscription passes on sale. `/api/payment/plans` is the legacy credit packs, kept by + * the backend only for clients that predate subscriptions. */ async getPlans(): Promise< - ApiResponse<{ plan: CreditPlan; credits: number; price_usd: number; popular: boolean }[]> + ApiResponse< + { + plan: PaymentPlan; + price_usd: number; + days: number; + price_per_month_usd: number; + popular: boolean; + }[] + > > { - return this.get('/api/payment/plans'); + return this.get('/api/payment/subscription-plans'); } /** @@ -52,9 +62,9 @@ export class PaymentApi extends ApiClient { } /** - * Get current user credits + * Get the current balance and subscription */ - async getCredits(): Promise> { + async getCredits(): Promise> { return this.get('/api/payment/credits'); } } diff --git a/src/main/ipc/payment.ts b/src/main/ipc/payment.ts index 951526a9..890d4a72 100644 --- a/src/main/ipc/payment.ts +++ b/src/main/ipc/payment.ts @@ -10,18 +10,18 @@ import { AvailableCurrency, CreatePaymentRequest, CreatePaymentResponse, - CreditPlanInfo, PaymentHistory, PaymentStatusResponse, + SubscriptionPlanInfo, } from '../types/payment.js'; export function registerPaymentHandlers(): void { /** - * Get available credit plans + * Get the subscription passes on sale */ ipcMain.handle( 'payment:get-plans', - async (): Promise<{ success: boolean; data?: CreditPlanInfo[]; error?: string }> => { + async (): Promise<{ success: boolean; data?: SubscriptionPlanInfo[]; error?: string }> => { return paymentService.getPlans(); } ); diff --git a/src/main/services/app-state.service.ts b/src/main/services/app-state.service.ts index 6254a890..a94f1b0b 100644 --- a/src/main/services/app-state.service.ts +++ b/src/main/services/app-state.service.ts @@ -26,6 +26,9 @@ const DEFAULT_STATE: AppState = { liveSuggestions: [], actionSuggestions: [], credits: undefined, + subscribed: undefined, + subscriptionExpiresAt: undefined, + subscriptionPlan: undefined, userRole: undefined, providedLLMModel: undefined, creditsPerMinute: undefined, diff --git a/src/main/services/health-check.service.ts b/src/main/services/health-check.service.ts index af583d6b..749aa7f0 100644 --- a/src/main/services/health-check.service.ts +++ b/src/main/services/health-check.service.ts @@ -5,6 +5,7 @@ import { HealthCheckApi } from '../api/health-check.js'; import { MockInterviewApi } from '../api/mock-interview.js'; +import { balanceState } from '../utils/balance.js'; import { safeSleep } from '../utils/sleep.js'; import { uiStrings } from '../utils/ui-strings.js'; import { accountService } from './account.service.js'; @@ -60,7 +61,7 @@ export class HealthCheckService { loggedIn = res.status === 200; appStateService.updateState({ isLoggedIn: loggedIn, - credits: res.data?.credits, + ...(res.data ? balanceState(res.data) : { credits: undefined }), userRole: res.data?.user_role, providedLLMModel: res.data?.provided_llm_model, // Undefined here means "not answered yet", not free and not the compiled-in default - @@ -192,7 +193,7 @@ export class HealthCheckService { } else if (res.data?.credits !== undefined) { console.log('client ping response:', res.data); appStateService.updateState({ - credits: res.data?.credits, + ...balanceState(res.data), providedLLMModel: res.data?.provided_llm_model, userRole: res.data?.user_role, creditsPerMinute: usableRate(res.data?.credits_per_minute), diff --git a/src/main/services/payment.service.ts b/src/main/services/payment.service.ts index c42cecb4..c490a48c 100644 --- a/src/main/services/payment.service.ts +++ b/src/main/services/payment.service.ts @@ -8,10 +8,11 @@ import { AvailableCurrency, CreatePaymentRequest, CreatePaymentResponse, - CreditPlanInfo, PaymentHistory, PaymentStatusResponse, + SubscriptionPlanInfo, } from '../types/payment.js'; +import { balanceState } from '../utils/balance.js'; import { uiStrings } from '../utils/ui-strings.js'; import { appStateService } from './app-state.service.js'; @@ -23,12 +24,12 @@ export class PaymentService { } /** - * Get available credit plans. + * Get the subscription passes on sale. * * Pricing is never synthesized locally: a stale hardcoded plan would quote the user one price * and charge another. Failures surface as errors so the UI can say so. */ - async getPlans(): Promise<{ success: boolean; data?: CreditPlanInfo[]; error?: string }> { + async getPlans(): Promise<{ success: boolean; data?: SubscriptionPlanInfo[]; error?: string }> { try { const response = await this.api.getPlans(); @@ -41,11 +42,12 @@ export class PaymentService { } // Map backend plans to frontend format - const plans: CreditPlanInfo[] = + const plans: SubscriptionPlanInfo[] = response.data?.map((plan) => ({ plan: plan.plan, - credits: plan.credits, priceUsd: plan.price_usd, + days: plan.days, + pricePerMonthUsd: plan.price_per_month_usd, popular: plan.popular, })) || []; @@ -165,12 +167,15 @@ export class PaymentService { }; } - // Update app state with latest credits + // Update app state with the latest balance and subscription if (response.data?.credits !== undefined) { - appStateService.updateState({ credits: response.data.credits }); + appStateService.updateState(balanceState(response.data)); } - return { success: true, credits: response.data?.credits || 0 }; + return { + success: true, + credits: response.data?.free_credits ?? response.data?.credits ?? 0, + }; } catch (error) { console.error('[PaymentService] Failed to get credits:', error); return { success: false, error: uiStrings().paymentErrors.getCredits }; diff --git a/src/main/types/app-state.ts b/src/main/types/app-state.ts index 02f5b59b..d11797bc 100644 --- a/src/main/types/app-state.ts +++ b/src/main/types/app-state.ts @@ -96,7 +96,18 @@ export interface AppState { transcripts: Transcript[]; liveSuggestions: LiveSuggestion[]; actionSuggestions: ActionSuggestion[]; + /** The free-tier balance: the free hour, in credits. Only spent while not subscribed. */ credits?: number; + /** + * Whether the account has an active subscription: unlimited interviews on the paid model. False + * (never undefined once a ping has answered) against a backend that predates subscriptions. + * While true, `credits` (the free balance) is not spent and does not gate a start. + */ + subscribed?: boolean; + /** When the subscription ends, in unix ms; null for an account that never had one. */ + subscriptionExpiresAt?: number | null; + /** The last pass applied (`monthly` / `yearly`), for display; null when unknown. */ + subscriptionPlan?: string | null; userRole?: UserRole; providedLLMModel?: string; interviewConfig: InterviewConfig; diff --git a/src/main/types/health-check.ts b/src/main/types/health-check.ts index 32f6d894..a58303c9 100644 --- a/src/main/types/health-check.ts +++ b/src/main/types/health-check.ts @@ -19,4 +19,12 @@ export interface ClientPingResponse { * fallback that case reads as. */ credits_per_minute?: number; + /** + * The subscription fields. Absent from a backend that predates subscriptions; see `balanceState` + * for how `credits` and `free_credits` are told apart. + */ + free_credits?: number; + subscribed?: boolean; + subscription_expires_at?: number | null; + subscription_plan?: string | null; } diff --git a/src/main/types/payment.ts b/src/main/types/payment.ts index 388a95eb..7246beb1 100644 --- a/src/main/types/payment.ts +++ b/src/main/types/payment.ts @@ -14,22 +14,31 @@ export enum PaymentStatus { Expired = 'expired', } -export enum CreditPlan { +/** + * What a payment buys. Monthly and yearly are the subscription passes on sale; the credit packs + * are only here because orders placed before subscriptions carry them. + */ +export enum PaymentPlan { + Monthly = 'monthly', + Yearly = 'yearly', Starter = 'starter', Pro = 'pro', Enterprise = 'enterprise', } -export interface CreditPlanInfo { - plan: CreditPlan; - credits: number; +/** A subscription pass, from `GET /api/payment/subscription-plans`. */ +export interface SubscriptionPlanInfo { + plan: PaymentPlan; priceUsd: number; - popular?: boolean; - description?: string; + /** How many days of subscription the pass adds. */ + days: number; + /** The price per month, for display: $144 for a year is $12 a month. */ + pricePerMonthUsd: number; + popular: boolean; } export interface CreatePaymentRequest { - plan: CreditPlan; + plan: PaymentPlan; pay_currency?: string; } @@ -73,8 +82,12 @@ export interface AvailableCurrency { export interface PaymentHistory { payment_id: string | null; order_id: string; - plan: CreditPlan; + plan: PaymentPlan; credits_amount: number; + /** Absent from a backend that predates subscriptions. */ + days_amount?: number; + /** What the order actually granted once applied, overpay bonus included. */ + days_granted?: number | null; price_amount: number; pay_address: string | null; pay_amount: number | null; diff --git a/src/main/utils/balance.ts b/src/main/utils/balance.ts new file mode 100644 index 00000000..ba774e86 --- /dev/null +++ b/src/main/utils/balance.ts @@ -0,0 +1,29 @@ +import type { AppState } from '../types/app-state.js'; + +/** The balance fields the ping and `GET /api/payment/credits` both answer with. */ +export interface BackendBalance { + credits?: number; + free_credits?: number; + subscribed?: boolean; + subscription_expires_at?: number | null; + subscription_plan?: string | null; +} + +/** + * The AppState slice a balance answer sets. + * + * `credits` in AppState is always the free-tier balance. A backend with subscriptions sends it as + * `free_credits`, and keeps `credits` for clients that predate subscriptions - where, for a + * subscriber, it is the time left on the subscription expressed in credits so those clients do not + * lock a paying user out. Reading that legacy value here would show a subscriber a balance they + * cannot spend, so `free_credits` wins whenever it is present. An older backend sends only + * `credits`, which is then the real (and only) balance, and nothing about a subscription. + */ +export function balanceState(data: BackendBalance): Partial { + return { + credits: data.free_credits ?? data.credits, + subscribed: data.subscribed ?? false, + subscriptionExpiresAt: data.subscription_expires_at ?? null, + subscriptionPlan: data.subscription_plan ?? null, + }; +} diff --git a/src/renderer/components/custom/payment/buy-credits-tab.tsx b/src/renderer/components/custom/payment/subscription-tab.tsx similarity index 100% rename from src/renderer/components/custom/payment/buy-credits-tab.tsx rename to src/renderer/components/custom/payment/subscription-tab.tsx diff --git a/src/renderer/hooks/use-app-state.tsx b/src/renderer/hooks/use-app-state.tsx index d4bd5ff3..836dc360 100644 --- a/src/renderer/hooks/use-app-state.tsx +++ b/src/renderer/hooks/use-app-state.tsx @@ -39,6 +39,9 @@ class AppStateManager { liveSuggestions: raw.liveSuggestions ?? [], actionSuggestions: raw.actionSuggestions ?? [], credits: raw.credits, + subscribed: raw.subscribed, + subscriptionExpiresAt: raw.subscriptionExpiresAt, + subscriptionPlan: raw.subscriptionPlan, userRole: raw.userRole, providedLLMModel: raw.providedLLMModel, creditsPerMinute: raw.creditsPerMinute, diff --git a/src/renderer/hooks/use-payment.ts b/src/renderer/hooks/use-payment.ts index 04deed89..fbe4abcc 100644 --- a/src/renderer/hooks/use-payment.ts +++ b/src/renderer/hooks/use-payment.ts @@ -10,9 +10,9 @@ import type { AvailableCurrency, CreatePaymentRequest, CreatePaymentResponse, - CreditPlanInfo, PaymentHistory, PaymentStatusResponse, + SubscriptionPlanInfo, } from '@/types/payment'; export function usePayment() { @@ -20,7 +20,7 @@ export function usePayment() { // dependency: they are memoised and close over it, so without that a language change would // leave the old wording in place for the rest of the session. const t = useT(); - const [plans, setPlans] = useState([]); + const [plans, setPlans] = useState([]); const [currencies, setCurrencies] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); diff --git a/src/renderer/types/app-state.ts b/src/renderer/types/app-state.ts index abfe114c..09b30511 100644 --- a/src/renderer/types/app-state.ts +++ b/src/renderer/types/app-state.ts @@ -38,7 +38,18 @@ export interface AppState { transcripts: Transcript[]; liveSuggestions: LiveSuggestion[]; actionSuggestions: ActionSuggestion[]; + /** The free-tier balance: the free hour, in credits. Only spent while not subscribed. */ credits?: number; + /** + * Whether the account has an active subscription: unlimited interviews on the paid model. False + * (never undefined once a ping has answered) against a backend that predates subscriptions. + * While true, `credits` (the free balance) is not spent and does not gate a start. + */ + subscribed?: boolean; + /** When the subscription ends, in unix ms; null for an account that never had one. */ + subscriptionExpiresAt?: number | null; + /** The last pass applied (`monthly` / `yearly`), for display; null when unknown. */ + subscriptionPlan?: string | null; userRole?: UserRole; providedLLMModel?: string; interviewConfig: InterviewConfigSummary; diff --git a/src/renderer/types/electron-api.d.ts b/src/renderer/types/electron-api.d.ts index c74dfbaf..bcb1e17c 100644 --- a/src/renderer/types/electron-api.d.ts +++ b/src/renderer/types/electron-api.d.ts @@ -6,9 +6,9 @@ import type { AvailableCurrency, CreatePaymentRequest, CreatePaymentResponse, - CreditPlanInfo, PaymentHistory, PaymentStatusResponse, + SubscriptionPlanInfo, } from './payment'; import type { PushNotification } from './push-notification'; @@ -81,14 +81,12 @@ declare global { data: { fullName: string; profileData: string; context: string }; error?: string; }>; - setOnboardingCompleted: ( - completed: boolean - ) => Promise<{ success: boolean; error?: string }>; + setOnboardingCompleted: (completed: boolean) => Promise<{ success: boolean; error?: string }>; }; // Payment management payment: { - getPlans: () => Promise<{ success: boolean; data?: CreditPlanInfo[]; error?: string }>; + getPlans: () => Promise<{ success: boolean; data?: SubscriptionPlanInfo[]; error?: string }>; getCurrencies: () => Promise<{ success: boolean; data?: AvailableCurrency[]; diff --git a/src/renderer/types/payment.ts b/src/renderer/types/payment.ts index c330ae47..3250d497 100644 --- a/src/renderer/types/payment.ts +++ b/src/renderer/types/payment.ts @@ -14,22 +14,31 @@ export enum PaymentStatus { Expired = 'expired', } -export enum CreditPlan { +/** + * What a payment buys. Monthly and yearly are the subscription passes on sale; the credit packs + * are only here because orders placed before subscriptions carry them. + */ +export enum PaymentPlan { + Monthly = 'monthly', + Yearly = 'yearly', Starter = 'starter', Pro = 'pro', Enterprise = 'enterprise', } -export interface CreditPlanInfo { - plan: CreditPlan; - credits: number; +/** A subscription pass, from `GET /api/payment/subscription-plans`. */ +export interface SubscriptionPlanInfo { + plan: PaymentPlan; priceUsd: number; - popular?: boolean; - description?: string; + /** How many days of subscription the pass adds. */ + days: number; + /** The price per month, for display: $144 for a year is $12 a month. */ + pricePerMonthUsd: number; + popular: boolean; } export interface CreatePaymentRequest { - plan: CreditPlan; + plan: PaymentPlan; pay_currency?: string; } @@ -73,8 +82,12 @@ export interface AvailableCurrency { export interface PaymentHistory { payment_id: string | null; order_id: string; - plan: CreditPlan; + plan: PaymentPlan; credits_amount: number; + /** Absent from a backend that predates subscriptions. */ + days_amount?: number; + /** What the order actually granted once applied, overpay bonus included. */ + days_granted?: number | null; price_amount: number; pay_address: string | null; pay_amount: number | null; diff --git a/test/balance.test.mjs b/test/balance.test.mjs new file mode 100644 index 00000000..e33119bc --- /dev/null +++ b/test/balance.test.mjs @@ -0,0 +1,40 @@ +/** + * How a balance answer from the backend becomes AppState. + * + * A backend with subscriptions sends `credits` for clients that predate them: for a subscriber it + * is the time left on the subscription expressed in credits, so an old client does not lock a + * paying user out. This client reads `free_credits` instead whenever it is there. Getting that the + * wrong way round fails quietly in both directions: a subscriber shown hundreds of thousands of + * credits, or a free user's free hour hidden behind a number they cannot spend. + */ +import { createChecker, loadMain } from './helpers.mjs'; + +export async function run() { + const { check, failures } = createChecker('balance'); + const { balanceState } = await loadMain('utils/balance.js'); + + const subscriber = balanceState({ + credits: 432000, + free_credits: 0, + subscribed: true, + subscription_expires_at: 1_800_000_000_000, + subscription_plan: 'yearly', + }); + check('a subscriber shows the free balance, not the legacy figure', subscriber.credits === 0); + check('and is subscribed', subscriber.subscribed === true); + check('with the end date', subscriber.subscriptionExpiresAt === 1_800_000_000_000); + check('and the plan', subscriber.subscriptionPlan === 'yearly'); + + const free = balanceState({ credits: 540, free_credits: 540, subscribed: false }); + check('a free user shows the free hour left', free.credits === 540); + check('and is not subscribed', free.subscribed === false); + check('with no end date', free.subscriptionExpiresAt === null); + + // A backend deployed before subscriptions sends only `credits`, which is then the real balance. + const legacy = balanceState({ credits: 1200 }); + check('an older backend keeps its credits', legacy.credits === 1200); + check('and reads as not subscribed rather than unknown', legacy.subscribed === false); + check('with no plan', legacy.subscriptionPlan === null); + + return failures; +} diff --git a/test/run.mjs b/test/run.mjs index 31ab77fd..7fc10ab4 100644 --- a/test/run.mjs +++ b/test/run.mjs @@ -28,6 +28,7 @@ for (const module of [ // their parameterised strings rather than type-checking them. './locale-runtime.test.mjs', './app-state.test.mjs', + './balance.test.mjs', './account.test.mjs', // After account.test.mjs: both swap the accountService singleton's client, and that one's // sequence depends on the pull it sets up for itself. From 12555fc6303d286a042b457e05e1354b89c90719 Mon Sep 17 00:00:00 2001 From: alpha Date: Tue, 13 Oct 2026 10:19:03 -0500 Subject: [PATCH 02/17] feat(ui): subscription page, plan status, and the start gate The payment page becomes Subscription: the current plan (pass and end date, or free time left) and the monthly and yearly passes through the existing crypto checkout. The title bar, stealth status panel and home strip show the plan and end date or the free time left, warning in the last three days. Subscribers may always start and get no low-balance toasts; the mock setup says unlimited. Copy for the gate, 4402 and the trial notice points to the subscription, in English and Russian. Refs #143 Co-Authored-By: Claude Opus 5.5 --- .../components/custom/command-palette.tsx | 2 +- .../components/custom/control-panel/index.tsx | 17 ++- .../components/custom/credits-display.tsx | 100 ++++++++++----- .../custom/mock-interview-setup-fields.tsx | 19 +-- .../custom/payment/payment-history-tab.tsx | 7 +- .../custom/payment/subscription-tab.tsx | 117 +++++++++++------- .../components/custom/status-panel.tsx | 9 +- src/renderer/components/custom/titlebar.tsx | 3 +- src/renderer/hooks/use-low-balance-warning.ts | 7 +- .../hooks/use-mock-interview-setup-form.ts | 14 ++- src/renderer/hooks/use-mock-interview.ts | 2 +- src/renderer/hooks/use-now.ts | 17 +++ src/renderer/i18n/locales/en.ts | 114 +++++++++-------- src/renderer/i18n/locales/ru.ts | 114 +++++++++-------- src/renderer/lib/credit-gate.ts | 10 +- src/renderer/lib/subscription.ts | 40 ++++++ src/renderer/pages/home/index.tsx | 49 ++++++-- src/renderer/pages/main/index.tsx | 9 +- src/renderer/pages/payment/index.tsx | 12 +- test/credit-gate.test.mjs | 4 + 20 files changed, 425 insertions(+), 241 deletions(-) create mode 100644 src/renderer/hooks/use-now.ts create mode 100644 src/renderer/lib/subscription.ts diff --git a/src/renderer/components/custom/command-palette.tsx b/src/renderer/components/custom/command-palette.tsx index dbdb3991..c333402a 100644 --- a/src/renderer/components/custom/command-palette.tsx +++ b/src/renderer/components/custom/command-palette.tsx @@ -167,7 +167,7 @@ export function CommandPalette() { run(() => navigate('/payment'))}> - {t.commandPalette.buyCredits} + {t.commandPalette.subscribe} diff --git a/src/renderer/components/custom/control-panel/index.tsx b/src/renderer/components/custom/control-panel/index.tsx index 8ad8ee79..89a5b00e 100644 --- a/src/renderer/components/custom/control-panel/index.tsx +++ b/src/renderer/components/custom/control-panel/index.tsx @@ -12,7 +12,7 @@ import { useLowBalanceWarning } from '@/hooks/use-low-balance-warning'; import { useSaveHistoryGuard } from '@/hooks/use-save-history-guard'; import { useT } from '@/i18n'; import { isMac } from '@/lib/consts'; -import { canStartSession, minimumStartCredits } from '@/lib/credit-gate'; +import { canStartSession } from '@/lib/credit-gate'; import { getElectron } from '@/lib/utils'; import { liveTranscriptionService } from '@/services/live-transcription.service'; import { RunningState } from '@/types/app-state'; @@ -73,7 +73,7 @@ export default function ControlPanel() { const endForCredits = useCallback(() => { toast.error(t.creditGate.outOfCredits, { description: t.creditGate.outOfCreditsHint, - action: { label: t.creditGate.buyCredits, onClick: () => navigate('/payment') }, + action: { label: t.creditGate.subscribe, onClick: () => navigate('/payment') }, }); if (isStealthRef.current) { void stopAssistantRef.current().catch((error) => { @@ -179,15 +179,12 @@ export default function ControlPanel() { } } - // Last, like the mock form's, and the same rule: at least a minute of credit. The backend - // closes the socket at zero, so a session started on less would be cut off almost at once. - if (!canStartSession(appState?.credits, appState?.creditsPerMinute)) { + // Last, like the mock form's, and the same rule: subscribed, or a minute of free time. The + // backend closes a free socket at zero, so a session started on less would be cut off at once. + if (!canStartSession(appState?.credits, appState?.creditsPerMinute, appState?.subscribed)) { toast.error(t.creditGate.tooLow, { - description: t.creditGate.tooLowHint( - minimumStartCredits(appState?.creditsPerMinute), - appState?.credits ?? 0 - ), - action: { label: t.creditGate.buyCredits, onClick: () => navigate('/payment') }, + description: t.creditGate.tooLowHint, + action: { label: t.creditGate.subscribe, onClick: () => navigate('/payment') }, }); return false; } diff --git a/src/renderer/components/custom/credits-display.tsx b/src/renderer/components/custom/credits-display.tsx index 05f00023..20832e81 100644 --- a/src/renderer/components/custom/credits-display.tsx +++ b/src/renderer/components/custom/credits-display.tsx @@ -1,14 +1,21 @@ import React from 'react'; +import { useNow } from '@/hooks/use-now'; import { useT } from '@/i18n'; import { CREDITS_PER_MINUTE } from '@/lib/consts'; +import { + daysLeft, + EXPIRY_WARNING_DAYS, + planKey, + type SubscriptionSummary, +} from '@/lib/subscription'; import { cn } from '@/lib/utils'; -import { UserRole } from '@/types/app-state'; interface CreditsDisplayProps { + /** The free-tier balance, in credits. */ credits: number; + subscription: SubscriptionSummary; llmModel?: string; - userRole?: UserRole; /** From the backend ping; falls back to the compiled-in mirror while that has not arrived. */ creditsPerMinute?: number; className?: string; @@ -17,41 +24,80 @@ interface CreditsDisplayProps { export default function CreditsDisplay({ credits, + subscription, llmModel, - userRole, creditsPerMinute, className, style, }: CreditsDisplayProps) { + return ( +
+ {subscription.subscribed && subscription.expiresAt !== null ? ( + + ) : ( + + )} + {llmModel && ( + <> +
+ {llmModel} + + )} +
+ ); +} + +function SubscribedSummary({ + subscription, + expiresAt, +}: { + subscription: SubscriptionSummary; + expiresAt: number; +}) { const t = useT(); + const planLabel = t.creditsDisplay.planNames[planKey(subscription.plan)]; + const days = daysLeft(expiresAt, useNow()); + const ending = days <= EXPIRY_WARNING_DAYS; + + return ( + <> + {planLabel} +
+ + {ending + ? t.creditsDisplay.endsIn(days) + : t.creditsDisplay.unlimitedUntil(t.creditsDisplay.date(expiresAt))} + + + ); +} - // Not a real plan name - "Pro" is an actual purchasable SKU (see CreditPlan), so labeling - // every non-trial user that way shows a starter or enterprise buyer a plan they never bought. - const planLabel = - userRole === UserRole.TrialUser ? t.creditsDisplay.trialPlan : t.creditsDisplay.paidPlan; +function FreeSummary({ + credits, + creditsPerMinute, +}: { + credits: number; + creditsPerMinute?: number; +}) { + const t = useT(); const availableMinutes = Math.floor(credits / (creditsPerMinute ?? CREDITS_PER_MINUTE)); // Split into hours and minutes here and assembled in the locale, because which plural form // each number takes is a property of the language: `2 hours 15 mins` against // `2 часа 15 минут`, where 2 and 22 agree and 5 and 25 do not. - const formatDuration = (mins: number) => - t.creditsDisplay.duration(Math.floor(mins / 60), mins % 60); - const availableTime = - availableMinutes <= 0 - ? credits > 0 - ? t.creditsDisplay.lessThanAMinute - : t.creditsDisplay.noCreditsLeft - : formatDuration(availableMinutes); + availableMinutes > 0 + ? t.creditsDisplay.freeLeft( + t.creditsDisplay.duration(Math.floor(availableMinutes / 60), availableMinutes % 60) + ) + : credits > 0 + ? t.creditsDisplay.freeLeft(t.creditsDisplay.lessThanAMinute) + : t.creditsDisplay.noFreeTimeLeft; return ( -
- {userRole !== undefined && ( - <> - {planLabel} -
- - )} + <> + {t.creditsDisplay.freePlan} +
= 5 @@ -61,14 +107,8 @@ export default function CreditsDisplay({ : 'text-destructive animate-pulse' )} > - {t.creditsDisplay.summary(credits, availableTime)} + {availableTime} - {llmModel && ( - <> -
- {llmModel} - - )} -
+ ); } diff --git a/src/renderer/components/custom/mock-interview-setup-fields.tsx b/src/renderer/components/custom/mock-interview-setup-fields.tsx index e2c6ccea..7de314e3 100644 --- a/src/renderer/components/custom/mock-interview-setup-fields.tsx +++ b/src/renderer/components/custom/mock-interview-setup-fields.tsx @@ -10,7 +10,7 @@ import { } from '@/components/ui/select'; import type { MockInterviewSetupForm } from '@/hooks/use-mock-interview-setup-form'; import { useT } from '@/i18n'; -import { effectiveRate, minutesCovered, mockSessionMinutes } from '@/lib/credit-gate'; +import { minutesCovered, mockSessionMinutes } from '@/lib/credit-gate'; import { MockDifficulty, MockSeniority } from '@/types/mock-interview'; /** @@ -50,10 +50,13 @@ export function MockInterviewSetupFields({ form }: { form: MockInterviewSetupFor setQuestionCount, credits, creditsPerMinute, + subscribed, } = form; const estimatedMinutes = mockSessionMinutes(questionCount); - const coveredMinutes = credits === undefined ? null : minutesCovered(credits, creditsPerMinute); + // A subscriber's session is not metered, so how far a balance goes says nothing to them. + const coveredMinutes = + subscribed || credits === undefined ? null : minutesCovered(credits, creditsPerMinute); return (
@@ -93,14 +96,12 @@ export function MockInterviewSetupFields({ form }: { form: MockInterviewSetupFor
- {/* An estimate, not a quote: a mock is billed by the minute for the time it takes, at the - same rate as a live interview, and ends with its report when the credits run out. So no - length is refused here - the candidate is told how far the balance goes instead. */} + {/* An estimate, not a quote: a free mock spends free time by the minute for as long as it + takes, and ends with its report when that runs out. So no length is refused here - the + candidate is told how far their free time goes instead. */}

- {t.mock.setup.estimate( - estimatedMinutes, - estimatedMinutes * effectiveRate(creditsPerMinute) - )} + {t.mock.setup.estimate(estimatedMinutes)} + {subscribed && <> {t.mock.setup.unlimited}} {coveredMinutes !== null && <> {t.mock.setup.covers(coveredMinutes)}} {coveredMinutes !== null && coveredMinutes < estimatedMinutes && ( <> {t.mock.setup.endsEarly} diff --git a/src/renderer/components/custom/payment/payment-history-tab.tsx b/src/renderer/components/custom/payment/payment-history-tab.tsx index 26d5cdbb..716ba641 100644 --- a/src/renderer/components/custom/payment/payment-history-tab.tsx +++ b/src/renderer/components/custom/payment/payment-history-tab.tsx @@ -102,7 +102,7 @@ export default function PaymentHistoryTab({ @@ -139,7 +139,10 @@ export default function PaymentHistoryTab({ {payment.payment_id} - {payment.credits_amount.toLocaleString()} + {t.payment.history.grant( + payment.days_granted ?? payment.days_amount ?? 0, + payment.credits_amount + )} {payment.pay_amount && payment.pay_currency diff --git a/src/renderer/components/custom/payment/subscription-tab.tsx b/src/renderer/components/custom/payment/subscription-tab.tsx index 6c51fb67..4d830237 100644 --- a/src/renderer/components/custom/payment/subscription-tab.tsx +++ b/src/renderer/components/custom/payment/subscription-tab.tsx @@ -12,39 +12,39 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { useNow } from '@/hooks/use-now'; import { usePayment } from '@/hooks/use-payment'; import { useT } from '@/i18n'; -import { CREDITS_PER_MINUTE } from '@/lib/consts'; +import { minutesCovered } from '@/lib/credit-gate'; +import { daysLeft, planKey, type SubscriptionSummary } from '@/lib/subscription'; import { cn } from '@/lib/utils'; -import type { AvailableCurrency, CreditPlanInfo } from '@/types/payment'; -import { CreditPlan } from '@/types/payment'; +import type { AvailableCurrency, SubscriptionPlanInfo } from '@/types/payment'; -/** - * Which locale entry names each plan. `Pro` is a real purchasable SKU, so the names stay as the - * brand's own words in both languages; only the descriptions are prose. - */ -const planKeys: Record = { - [CreditPlan.Starter]: 'starter', - [CreditPlan.Pro]: 'pro', - [CreditPlan.Enterprise]: 'enterprise', -}; - -interface BuyCreditsTabProps { +interface SubscriptionTabProps { + /** The free-tier balance, in credits. */ credits: number; + subscription: SubscriptionSummary; /** From the backend ping; falls back to the compiled-in mirror while that has not arrived. */ creditsPerMinute?: number; onPaymentCreated: (paymentId: string) => void; } -export default function BuyCreditsTab({ +/** How much cheaper a pass is per month than the dearest one on offer, in whole percent. */ +function savingPercent(plan: SubscriptionPlanInfo, plans: SubscriptionPlanInfo[]): number { + const dearest = Math.max(...plans.map((p) => p.pricePerMonthUsd)); + if (dearest <= 0) return 0; + return Math.round(((dearest - plan.pricePerMonthUsd) / dearest) * 100); +} + +export default function SubscriptionTab({ credits, + subscription, creditsPerMinute, onPaymentCreated, -}: BuyCreditsTabProps) { +}: SubscriptionTabProps) { const t = useT(); - const effectiveCreditsPerMinute = creditsPerMinute ?? CREDITS_PER_MINUTE; const { plans, currencies, loading, error, createPayment } = usePayment(); - const [selectedPlan, setSelectedPlan] = useState(null); + const [selectedPlan, setSelectedPlan] = useState(null); const [selectedCurrency, setSelectedCurrency] = useState(''); const [creating, setCreating] = useState(false); const [currencySearch, setCurrencySearch] = useState(''); @@ -78,11 +78,11 @@ export default function BuyCreditsTab({ if (!open) setCurrencySearch(''); }, []); - const availableMinutes = Math.floor(credits / effectiveCreditsPerMinute); - const availableHours = Math.floor(availableMinutes / 60); - const availableRemMinutes = availableMinutes % 60; + const now = useNow(); + const freeMinutes = minutesCovered(credits, creditsPerMinute); + const active = subscription.subscribed && subscription.expiresAt !== null; - const handleSelectPlan = useCallback((plan: CreditPlanInfo) => { + const handleSelectPlan = useCallback((plan: SubscriptionPlanInfo) => { setSelectedPlan(plan); setTimeout(() => { paymentDetailsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); @@ -112,16 +112,34 @@ export default function BuyCreditsTab({ return (

-
{t.payment.buy.currentBalance}
-
{t.payment.buy.credits(credits)}
- {/* Assembled in the locale rather than out of JSX fragments: the hours and minutes each - take their own plural form, and which one is a property of the number. */} -
- {t.payment.buy.availableFor( - t.payment.buy.duration(availableHours, availableRemMinutes), - effectiveCreditsPerMinute - )} -
+
{t.payment.buy.currentPlan}
+ {active && subscription.expiresAt !== null ? ( + <> +
+ {t.payment.buy.subscribedUntil( + t.creditsDisplay.planNames[planKey(subscription.plan)], + t.creditsDisplay.date(subscription.expiresAt) + )} +
+
+ {t.payment.buy.daysLeft(daysLeft(subscription.expiresAt, now))}.{' '} + {t.payment.buy.extends} +
+ + ) : ( + <> +
{t.payment.buy.freePlan}
+ {/* Assembled in the locale rather than out of JSX fragments: the hours and minutes + each take their own plural form, and which one is a property of the number. */} +
+ {freeMinutes > 0 + ? t.payment.buy.freeLeft( + t.payment.buy.duration(Math.floor(freeMinutes / 60), freeMinutes % 60) + ) + : t.payment.buy.freeUsedUp} +
+ + )}
{error && ( @@ -136,16 +154,14 @@ export default function BuyCreditsTab({
) : ( <> -
+
{plans.map((plan) => { - const isPro = plan.plan === CreditPlan.Pro; + const isPro = plan.popular; const isSelected = selectedPlan?.plan === plan.plan; - const minutes = Math.floor(plan.credits / effectiveCreditsPerMinute); - const planKey = planKeys[plan.plan]; - const planName = planKey ? t.payment.buy.planNames[planKey] : plan.plan; - const planDescription = planKey - ? t.payment.buy.planDescriptions[planKey] - : plan.description || ''; + const key = planKey(plan.plan); + const planName = key === 'other' ? plan.plan : t.payment.buy.planNames[key]; + const planDescription = key === 'other' ? '' : t.payment.buy.planDescriptions[key]; + const saving = savingPercent(plan, plans); return ( {planName} {planDescription}
- ${plan.priceUsd} + ${plan.pricePerMonthUsd} - {t.payment.buy.perCredits(plan.credits)} + {t.payment.buy.perMonth} + {saving > 0 && ( + + {t.payment.buy.save(saving)} + + )}

- {t.payment.buy.timeOfAssistance( - t.payment.buy.duration(Math.floor(minutes / 60), minutes % 60) - )} + {t.payment.buy.billedAs(plan.priceUsd, plan.days)}

@@ -222,13 +241,19 @@ export default function BuyCreditsTab({ })}
+

{t.payment.buy.prepaid}

+ {selectedPlan && ( {t.payment.buy.detailsTitle} {t.payment.buy.detailsLead} - {t.payment.buy.credits(selectedPlan.credits)} + + {planKey(selectedPlan.plan) === 'other' + ? selectedPlan.plan + : t.payment.buy.planNames[planKey(selectedPlan.plan) as 'monthly' | 'yearly']} + {t.payment.buy.detailsFor} ${selectedPlan.priceUsd} USD diff --git a/src/renderer/components/custom/status-panel.tsx b/src/renderer/components/custom/status-panel.tsx index 46b9c555..4073175f 100644 --- a/src/renderer/components/custom/status-panel.tsx +++ b/src/renderer/components/custom/status-panel.tsx @@ -7,8 +7,9 @@ import { useSuggestionMode } from '@/hooks/use-suggestion-mode'; import { useTranscriptPanel } from '@/hooks/use-transcript-panel'; import { useT } from '@/i18n'; import { Hotkey, HOTKEYS } from '@/lib/hotkeys'; +import type { SubscriptionSummary } from '@/lib/subscription'; import { cn } from '@/lib/utils'; -import { RunningState, UserRole } from '@/types/app-state'; +import { RunningState } from '@/types/app-state'; import { HotkeyCheatsheetDialog } from './hotkey-cheatsheet'; import { RunningIndicator } from './running-indicator'; @@ -35,8 +36,8 @@ function useHotkeyCheatsheetShortcut(onOpen: () => void) { interface StatusPanelProps { runningState: RunningState; credits: number; + subscription: SubscriptionSummary; llmModel: string; - userRole?: UserRole; creditsPerMinute?: number; } @@ -52,7 +53,7 @@ export default function StatusPanel({ runningState, llmModel, credits, - userRole, + subscription, creditsPerMinute, }: StatusPanelProps) { const t = useT(); @@ -67,8 +68,8 @@ export default function StatusPanel({ diff --git a/src/renderer/components/custom/titlebar.tsx b/src/renderer/components/custom/titlebar.tsx index 9a6f13e3..948c38c5 100644 --- a/src/renderer/components/custom/titlebar.tsx +++ b/src/renderer/components/custom/titlebar.tsx @@ -13,6 +13,7 @@ import { useInterviewLock } from '@/hooks/use-interview-lock'; import useIsStealthMode from '@/hooks/use-is-stealth-mode'; import { useT } from '@/i18n'; import { APP_NAME, isMac } from '@/lib/consts'; +import { subscriptionOf } from '@/lib/subscription'; import { getElectron } from '@/lib/utils'; // WebkitAppRegion is an Electron-specific CSS property not included in React.CSSProperties @@ -84,8 +85,8 @@ export default function Titlebar() { {appState?.isLoggedIn && appState?.credits !== undefined && ( diff --git a/src/renderer/hooks/use-low-balance-warning.ts b/src/renderer/hooks/use-low-balance-warning.ts index 87cfce61..a4ebda5e 100644 --- a/src/renderer/hooks/use-low-balance-warning.ts +++ b/src/renderer/hooks/use-low-balance-warning.ts @@ -22,12 +22,15 @@ export const LOW_BALANCE_WARNING_MINUTES = [5, 1] as const; * under: starting with half a minute left says "one minute", not "five" and then "one". * * `active` is whether a session is running now; the warnings re-arm each time it turns true. + * + * Only the free hour runs down: a subscriber's session is not metered, so it never warns them. */ export function useLowBalanceWarning(active: boolean) { const t = useT(); const { appState } = useAppState(); const credits = appState?.credits; const creditsPerMinute = appState?.creditsPerMinute; + const subscribed = appState?.subscribed === true; // The thresholds already warned about in this session. const warned = useRef(new Set()); @@ -37,7 +40,7 @@ export function useLowBalanceWarning(active: boolean) { warned.current.clear(); return; } - if (credits === undefined) return; + if (credits === undefined || subscribed) return; const minutes = minutesCovered(credits, creditsPerMinute); const crossed = LOW_BALANCE_WARNING_MINUTES.filter((threshold) => minutes < threshold + 1); @@ -52,5 +55,5 @@ export function useLowBalanceWarning(active: boolean) { toast.warning(t.creditGate.lowBalance(Math.max(minutes, 1)), { description: t.creditGate.lowBalanceHint, }); - }, [active, credits, creditsPerMinute, t]); + }, [active, credits, creditsPerMinute, subscribed, t]); } diff --git a/src/renderer/hooks/use-mock-interview-setup-form.ts b/src/renderer/hooks/use-mock-interview-setup-form.ts index 8b2ca9df..8f2ad7bd 100644 --- a/src/renderer/hooks/use-mock-interview-setup-form.ts +++ b/src/renderer/hooks/use-mock-interview-setup-form.ts @@ -6,7 +6,7 @@ import { useAppState } from '@/hooks/use-app-state'; import { useAudioInputDevices } from '@/hooks/use-audio-devices'; import { useConfigStore } from '@/hooks/use-config-store'; import { useT } from '@/i18n'; -import { canStartSession, minimumStartCredits } from '@/lib/credit-gate'; +import { canStartSession } from '@/lib/credit-gate'; import { getElectron } from '@/lib/utils'; import type { MockInterviewSetup } from '@/types/mock-interview'; import { MockDifficulty, MockSeniority } from '@/types/mock-interview'; @@ -39,6 +39,7 @@ export function useMockInterviewSetupForm(onStart: (setup: MockInterviewSetup) = const credits = appState?.credits; const creditsPerMinute = appState?.creditsPerMinute; + const subscribed = appState?.subscribed === true; const selectedAudioInputDeviceName = config?.audioInputDeviceName ?? ''; const noAudioInputDevices = audioDevicesReady && audioInputDevices.length === 0; @@ -73,12 +74,12 @@ export function useMockInterviewSetupForm(onStart: (setup: MockInterviewSetup) = return false; } // Last of the checks, and the only one with somewhere to send the user. Any length may be - // chosen: the session is billed by the minute and ends at zero with its report, so the - // setup screen says how far the balance goes rather than refusing a length. - if (!canStartSession(credits, creditsPerMinute)) { + // chosen: a free session spends free time by the minute and ends at zero with its report, so + // the setup screen says how far that goes rather than refusing a length. + if (!canStartSession(credits, creditsPerMinute, subscribed)) { toast.error(t.creditGate.tooLow, { - description: t.creditGate.tooLowHint(minimumStartCredits(creditsPerMinute), credits ?? 0), - action: { label: t.creditGate.buyCredits, onClick: () => navigate('/payment') }, + description: t.creditGate.tooLowHint, + action: { label: t.creditGate.subscribe, onClick: () => navigate('/payment') }, }); return false; } @@ -120,6 +121,7 @@ export function useMockInterviewSetupForm(onStart: (setup: MockInterviewSetup) = setQuestionCount, credits, creditsPerMinute, + subscribed, starting, headphoneNoticeOpen, setHeadphoneNoticeOpen, diff --git a/src/renderer/hooks/use-mock-interview.ts b/src/renderer/hooks/use-mock-interview.ts index 8b8eb17f..6fb66eb2 100644 --- a/src/renderer/hooks/use-mock-interview.ts +++ b/src/renderer/hooks/use-mock-interview.ts @@ -101,7 +101,7 @@ export function useMockInterview() { const t = currentTranslation(); toast.error(t.creditGate.outOfCredits, { description: t.creditGate.outOfCreditsHint, - action: { label: t.creditGate.buyCredits, onClick: () => navigate('/payment') }, + action: { label: t.creditGate.subscribe, onClick: () => navigate('/payment') }, }); void window.electronAPI?.mockInterview.endSession(); }, [navigate]); diff --git a/src/renderer/hooks/use-now.ts b/src/renderer/hooks/use-now.ts new file mode 100644 index 00000000..e61da369 --- /dev/null +++ b/src/renderer/hooks/use-now.ts @@ -0,0 +1,17 @@ +import { useEffect, useState } from 'react'; + +/** + * The current time, re-read every `intervalMs`. Held in state rather than read during render, which + * the React Compiler refuses: a render must give the same output for the same inputs, and the clock + * is not an input until it is state. + */ +export function useNow(intervalMs = 60_000): number { + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), intervalMs); + return () => clearInterval(id); + }, [intervalMs]); + + return now; +} diff --git a/src/renderer/i18n/locales/en.ts b/src/renderer/i18n/locales/en.ts index b3987fb5..ade4d449 100644 --- a/src/renderer/i18n/locales/en.ts +++ b/src/renderer/i18n/locales/en.ts @@ -238,8 +238,7 @@ export const en = { ready: 'The AI asks, you answer out loud, and you get a scored report at the end.', unsupported: 'Not available on this server yet. Update the app, or try again later.', liveRunning: 'Stop the live assistant first - the two cannot share your microphone.', - unaffordable: (minimum: number) => - `Not enough credits - you need at least ${minimum} (1 minute) to start. Buy more to practise.`, + unaffordable: 'Your free hour is used up. Subscribe to keep practising.', }, live: { @@ -248,15 +247,14 @@ export const en = { ready: 'Transcribes your real interview and suggests answers as it happens.', running: 'Your live assistant is already running.', mockRunning: 'Finish the mock interview first - the two cannot share your microphone.', - unaffordable: (minimum: number) => - `Not enough credits - you need at least ${minimum} (1 minute) to start.`, + unaffordable: 'Your free hour is used up. Subscribe to keep interviewing.', }, accountLabel: 'Account', notSignedIn: 'Not signed in', - creditsLabel: 'Credits', - creditsUnavailable: 'Unavailable', - buyCredits: 'Buy Credits', + planLabel: 'Plan', + planUnavailable: 'Unavailable', + subscribe: 'Subscription', nav: { account: 'Account', @@ -496,7 +494,7 @@ export const en = { home: 'Home', account: 'Account', configuration: 'Configuration', - buyCredits: 'Buy Credits', + subscribe: 'Subscription', startMock: 'Start mock interview', startLive: 'Start live assistant', switchToFullSentence: 'Switch to full-sentence mode', @@ -621,10 +619,14 @@ export const en = { }, creditsDisplay: { - trialPlan: 'Trial Plan', - paidPlan: 'Paid Plan', - summary: (credits: number, availableTime: string) => - `${credits.toLocaleString()} credits - ${availableTime}`, + freePlan: 'Free', + /** The plan an active subscription was bought as; `other` when the backend did not say. */ + planNames: { monthly: 'Monthly', yearly: 'Yearly', other: 'Subscribed' }, + date: (ms: number) => + new Date(ms).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }), + unlimitedUntil: (date: string) => `Unlimited until ${date}`, + endsIn: (days: number) => `Ends in ${days} ${days === 1 ? 'day' : 'days'}`, + freeLeft: (availableTime: string) => `${availableTime} free`, duration: (hours: number, minutes: number) => { const parts: string[] = []; if (hours) parts.push(`${hours} hour${hours > 1 ? 's' : ''}`); @@ -632,7 +634,7 @@ export const en = { return parts.join(' ') || '0 mins'; }, lessThanAMinute: 'Less than 1 min', - noCreditsLeft: 'No credits left', + noFreeTimeLeft: 'Free hour used up', }, panels: { @@ -856,12 +858,12 @@ export const en = { }, languageDescription: 'What the interviewer asks in, what is transcribed, and what your feedback comes back in.', - /** An estimate, not a price: the session is billed by the minute for the time it takes. */ - estimate: (minutes: number, credits: number) => - `Usually about ${minutes} minutes, about ${credits.toLocaleString()} credits.`, + /** An estimate, not a limit: a free session spends free time by the minute for as long as it takes. */ + estimate: (minutes: number) => `Usually about ${minutes} minutes.`, + unlimited: 'Unlimited with your subscription.', covers: (minutes: number) => - `Your balance covers about ${minutes} ${minutes === 1 ? 'minute' : 'minutes'}.`, - endsEarly: 'The session ends when your credits run out, and you still get your report.', + `Your free time covers about ${minutes} ${minutes === 1 ? 'minute' : 'minutes'}.`, + endsEarly: 'The session ends when your free time runs out, and you still get your report.', }, session: { @@ -984,44 +986,46 @@ export const en = { }, payment: { - title: 'Buy Credits', + title: 'Subscription', tabs: { - buy: 'Buy Credits', + buy: 'Plans', history: 'History', status: 'Status', }, buy: { - currentBalance: 'Current Balance', - credits: (credits: number) => `${credits.toLocaleString()} credits`, - /** "Available for ~2 hours 15 minutes (10 credits per minute)". */ - availableFor: (duration: string, perMinute: number) => - `Available for ~${duration} (${perMinute} credits per minute)`, + currentPlan: 'Your plan', + subscribedUntil: (plan: string, date: string) => `${plan}, until ${date}`, + daysLeft: (days: number) => `${days} ${days === 1 ? 'day' : 'days'} left`, + freePlan: 'Free plan', + freeLeft: (duration: string) => `${duration} of interviews left on the free model.`, + freeUsedUp: 'Your free hour is used up. Subscribe to keep interviewing.', duration: (hours: number, minutes: number) => { const parts: string[] = []; if (hours) parts.push(`${hours} hour${hours === 1 ? '' : 's'}`); if (minutes) parts.push(`${minutes} minute${minutes === 1 ? '' : 's'}`); return parts.join(' ') || '0 minutes'; }, - loadingPlans: 'Loading payment plans…', - mostPopular: 'Most Popular', + loadingPlans: 'Loading plans…', + mostPopular: 'Best value', planNames: { - starter: 'Starter', - pro: 'Pro', - enterprise: 'Enterprise', + monthly: 'Monthly', + yearly: 'Yearly', }, planDescriptions: { - starter: 'Perfect for trying out the platform', - pro: 'Best value for serious job seekers', - enterprise: 'For heavy users and teams', + monthly: 'Unlimited interviews on the SOTA model for 30 days', + yearly: 'Unlimited interviews on the SOTA model for a year', }, - perCredits: (credits: number) => ` / ${credits.toLocaleString()} credits`, - timeOfAssistance: (duration: string) => `~${duration} of AI assistance`, + perMonth: ' / month', + billedAs: (price: number, days: number) => `$${price} paid once, for ${days} days`, + save: (percent: number) => `Save ${percent}%`, + prepaid: 'Paid once in crypto. A pass never renews on its own.', + extends: 'Your subscription is active: a new pass adds its days after the current end date.', selected: 'Selected', - buy: 'Buy', + buy: 'Choose', detailsTitle: 'Payment Details', - detailsLead: 'Complete your purchase of ', - detailsFor: ' for ', + detailsLead: 'Complete your purchase of the ', + detailsFor: ' plan for ', currencyLabel: 'Payment Currency', currencyPlaceholder: 'Select a currency', currencySearchPlaceholder: 'Search currency…', @@ -1034,13 +1038,18 @@ export const en = { loading: 'Loading payment history…', loadFailed: 'Failed to load payment history', emptyTitle: 'No Payment History', - emptyBody: 'You have not made any payments yet. Purchase credits to get started.', - buyCredits: 'Buy Credits', + emptyBody: 'You have not made any payments yet. Subscribe to get started.', + subscribe: 'See plans', refreshing: 'Refreshing…', + /** What an order bought: days of subscription, or credits on an order from before subscriptions. */ + grant: (days: number, credits: number) => + days > 0 + ? `${days.toLocaleString()} ${days === 1 ? 'day' : 'days'}` + : `${credits.toLocaleString()} credits`, columns: { created: 'Created', paymentId: 'Payment ID', - credits: 'Credits', + credits: 'Purchase', amount: 'Amount', status: 'Status', action: 'Action', @@ -1073,7 +1082,7 @@ export const en = { amountToSend: 'Amount to Send', sendExactly: 'Send exactly this amount to the address above.', successTitle: 'Payment Successful!', - successBody: 'Your credits have been added to your account.', + successBody: 'Your subscription has been applied to your account.', expiredTitle: 'Payment Expired', failedTitle: 'Payment Failed', expiredBody: 'This payment has expired. Please create a new payment.', @@ -1137,17 +1146,16 @@ export const en = { `Audio input device "${deviceName}" is not found. Choose a different one from the main screen's audio settings.`, }, - /** The one start rule live and mock share: at least one minute of credit. */ + /** The one start rule live and mock share: subscribed, or at least a minute of free time. */ creditGate: { - tooLow: 'Not enough credits to start', - outOfCredits: 'Out of credits - the session has ended', - outOfCreditsHint: 'Buy credits to start another one.', + tooLow: 'Your free hour is used up', + outOfCredits: 'Your free hour is used up - the session has ended', + outOfCreditsHint: 'Subscribe for unlimited interviews.', lowBalance: (minutes: number) => - `About ${minutes} ${minutes === 1 ? 'minute' : 'minutes'} of credit left`, - lowBalanceHint: 'The session ends when your credits run out.', - tooLowHint: (minimum: number, credits: number) => - `A session needs at least ${minimum} credits (1 minute). You have ${credits}.`, - buyCredits: 'Buy credits', + `About ${minutes} ${minutes === 1 ? 'minute' : 'minutes'} of free time left`, + lowBalanceHint: 'The session ends when your free time runs out.', + tooLowHint: 'Subscribe for unlimited interviews on the SOTA model.', + subscribe: 'See plans', }, trialNotice: { @@ -1157,10 +1165,10 @@ export const en = { * English puts "free tier" before the noun it qualifies and Russian puts its equivalent * after it, so the tail is what absorbs the difference. */ - freeTierLead: 'Trial plan: 1 hour of free usage with the ', + freeTierLead: 'Free plan: 1 hour of interviews with the ', freeTier: 'free tier', freeTierTail: ' model.', - sotaLead: 'Buy credits to unlock ', + sotaLead: 'Subscribe for unlimited interviews on the ', sota: 'SOTA', sotaTail: ' model.', }, diff --git a/src/renderer/i18n/locales/ru.ts b/src/renderer/i18n/locales/ru.ts index 34de2a58..2b8dfac3 100644 --- a/src/renderer/i18n/locales/ru.ts +++ b/src/renderer/i18n/locales/ru.ts @@ -240,8 +240,7 @@ export const ru: Translation = { unsupported: 'Пока недоступно на этом сервере. Обновите приложение или попробуйте позже.', liveRunning: 'Сначала остановите живого ассистента - они не могут использовать микрофон одновременно.', - unaffordable: (minimum: number) => - `Недостаточно кредитов - для начала нужно хотя бы ${minimum} ${plural(minimum, 'кредит', 'кредита', 'кредитов')} (1 минута). Пополните баланс, чтобы тренироваться.`, + unaffordable: 'Бесплатный час закончился. Оформите подписку, чтобы продолжить тренировки.', }, live: { @@ -251,15 +250,14 @@ export const ru: Translation = { running: 'Ассистент уже работает.', mockRunning: 'Сначала завершите пробное собеседование - они не могут использовать микрофон одновременно.', - unaffordable: (minimum: number) => - `Недостаточно кредитов - для начала нужно хотя бы ${minimum} ${plural(minimum, 'кредит', 'кредита', 'кредитов')} (1 минута).`, + unaffordable: 'Бесплатный час закончился. Оформите подписку, чтобы продолжить.', }, accountLabel: 'Учётная запись', notSignedIn: 'Вход не выполнен', - creditsLabel: 'Кредиты', - creditsUnavailable: 'Нет данных', - buyCredits: 'Купить кредиты', + planLabel: 'Тариф', + planUnavailable: 'Нет данных', + subscribe: 'Подписка', nav: { account: 'Учётная запись', @@ -512,7 +510,7 @@ export const ru: Translation = { home: 'Главная', account: 'Учётная запись', configuration: 'Настройки', - buyCredits: 'Купить кредиты', + subscribe: 'Подписка', startMock: 'Начать пробное собеседование', startLive: 'Запустить ассистента', switchToFullSentence: 'Переключиться на полные фразы', @@ -637,10 +635,14 @@ export const ru: Translation = { }, creditsDisplay: { - trialPlan: 'Пробный тариф', - paidPlan: 'Платный тариф', - summary: (credits: number, availableTime: string) => - `${credits.toLocaleString('ru-RU')} ${plural(credits, 'кредит', 'кредита', 'кредитов')} - ${availableTime}`, + freePlan: 'Бесплатно', + planNames: { monthly: 'Месячная', yearly: 'Годовая', other: 'Подписка' }, + // The numeric form ("30.10.2026"), which needs no format options: those are English words to + // the check that every string here is translated. + date: (ms: number) => new Date(ms).toLocaleDateString('ru-RU'), + unlimitedUntil: (date: string) => `Без ограничений до ${date}`, + endsIn: (days: number) => `Закончится через ${days} ${plural(days, 'день', 'дня', 'дней')}`, + freeLeft: (availableTime: string) => `${availableTime} бесплатно`, duration: (hours: number, minutes: number) => { const parts: string[] = []; if (hours) parts.push(`${hours} ${plural(hours, 'час', 'часа', 'часов')}`); @@ -648,7 +650,7 @@ export const ru: Translation = { return parts.join(' ') || '0 минут'; }, lessThanAMinute: 'Меньше минуты', - noCreditsLeft: 'Кредиты закончились', + noFreeTimeLeft: 'Бесплатный час закончился', }, panels: { @@ -868,12 +870,13 @@ export const ru: Translation = { }, languageDescription: 'На каком языке спрашивает интервьюер, что распознаётся и на каком языке приходит разбор.', - estimate: (minutes: number, credits: number) => - `Обычно около ${minutes} ${plural(minutes, 'минуты', 'минут', 'минут')}, примерно ${credits.toLocaleString('ru-RU')} ${plural(credits, 'кредит', 'кредита', 'кредитов')}.`, + estimate: (minutes: number) => + `Обычно около ${minutes} ${plural(minutes, 'минуты', 'минут', 'минут')}.`, + unlimited: 'С подпиской - без ограничений.', covers: (minutes: number) => - `Вашего баланса хватит примерно на ${minutes} ${plural(minutes, 'минуту', 'минуты', 'минут')}.`, + `Бесплатного времени хватит примерно на ${minutes} ${plural(minutes, 'минуту', 'минуты', 'минут')}.`, endsEarly: - 'Когда кредиты закончатся, собеседование завершится, а отчёт вы всё равно получите.', + 'Когда бесплатное время закончится, собеседование завершится, а отчёт вы всё равно получите.', }, session: { @@ -988,19 +991,21 @@ export const ru: Translation = { }, payment: { - title: 'Покупка кредитов', + title: 'Подписка', tabs: { - buy: 'Покупка', + buy: 'Тарифы', history: 'История', status: 'Статус', }, buy: { - currentBalance: 'Текущий баланс', - credits: (credits: number) => - `${credits.toLocaleString('ru-RU')} ${plural(credits, 'кредит', 'кредита', 'кредитов')}`, - availableFor: (duration: string, perMinute: number) => - `Хватит примерно на ${duration} (${perMinute} ${plural(perMinute, 'кредит', 'кредита', 'кредитов')} в минуту)`, + currentPlan: 'Ваш тариф', + subscribedUntil: (plan: string, date: string) => `${plan}, до ${date}`, + daysLeft: (days: number) => `Осталось ${days} ${plural(days, 'день', 'дня', 'дней')}`, + freePlan: 'Бесплатный тариф', + // "на" takes the accusative, which is the case `duration` is written in + freeLeft: (duration: string) => `Осталось собеседований на ${duration} на базовой модели.`, + freeUsedUp: 'Бесплатный час закончился. Оформите подписку, чтобы продолжить.', duration: (hours: number, minutes: number) => { const parts: string[] = []; if (hours) parts.push(`${hours} ${plural(hours, 'час', 'часа', 'часов')}`); @@ -1008,25 +1013,25 @@ export const ru: Translation = { return parts.join(' ') || '0 минут'; }, loadingPlans: 'Загружаем тарифы…', - mostPopular: 'Чаще всего берут', + mostPopular: 'Выгоднее всего', planNames: { - starter: 'Starter', - pro: 'Pro', - enterprise: 'Enterprise', + monthly: 'Месячная', + yearly: 'Годовая', }, planDescriptions: { - starter: 'Чтобы попробовать платформу', - pro: 'Лучший выбор, если вы серьёзно ищете работу', - enterprise: 'Для интенсивного использования и команд', + monthly: 'Собеседования без ограничений на передовой модели на 30 дней', + yearly: 'Собеседования без ограничений на передовой модели на год', }, - perCredits: (credits: number) => - ` / ${credits.toLocaleString('ru-RU')} ${plural(credits, 'кредит', 'кредита', 'кредитов')}`, - // "на" takes the accusative, which is the case `duration` is written in - timeOfAssistance: (duration: string) => `примерно на ${duration} работы ассистента`, + perMonth: ' / месяц', + billedAs: (price: number, days: number) => + `$${price} единоразово, на ${days} ${plural(days, 'день', 'дня', 'дней')}`, + save: (percent: number) => `Скидка ${percent}%`, + prepaid: 'Оплата один раз в криптовалюте. Подписка не продлевается сама.', + extends: 'Подписка активна: новый тариф добавит свои дни после текущей даты окончания.', selected: 'Выбрано', - buy: 'Купить', + buy: 'Выбрать', detailsTitle: 'Детали оплаты', - detailsLead: 'Завершите покупку: ', + detailsLead: 'Завершите покупку тарифа ', detailsFor: ' за ', currencyLabel: 'Валюта платежа', currencyPlaceholder: 'Выберите валюту', @@ -1040,13 +1045,17 @@ export const ru: Translation = { loading: 'Загружаем историю платежей…', loadFailed: 'Не удалось загрузить историю платежей', emptyTitle: 'История платежей пуста', - emptyBody: 'Вы ещё не совершали платежей. Купите кредиты, чтобы начать.', - buyCredits: 'Купить кредиты', + emptyBody: 'Вы ещё не совершали платежей. Оформите подписку, чтобы начать.', + subscribe: 'Тарифы', refreshing: 'Обновляем…', + grant: (days: number, credits: number) => + days > 0 + ? `${days.toLocaleString('ru-RU')} ${plural(days, 'день', 'дня', 'дней')}` + : `${credits.toLocaleString('ru-RU')} ${plural(credits, 'кредит', 'кредита', 'кредитов')}`, columns: { created: 'Создан', paymentId: 'ID платежа', - credits: 'Кредиты', + credits: 'Покупка', amount: 'Сумма', status: 'Статус', action: 'Действие', @@ -1079,7 +1088,7 @@ export const ru: Translation = { amountToSend: 'Сумма к отправке', sendExactly: 'Отправьте ровно эту сумму на адрес выше.', successTitle: 'Платёж прошёл', - successBody: 'Кредиты зачислены на вашу учётную запись.', + successBody: 'Подписка применена к вашей учётной записи.', expiredTitle: 'Срок платежа истёк', failedTitle: 'Платёж не прошёл', expiredBody: 'Срок этого платежа истёк. Создайте новый платёж.', @@ -1146,23 +1155,22 @@ export const ru: Translation = { }, creditGate: { - tooLow: 'Недостаточно кредитов для начала', - outOfCredits: 'Кредиты закончились - сессия завершена', - outOfCreditsHint: 'Пополните баланс, чтобы начать новую.', + tooLow: 'Бесплатный час закончился', + outOfCredits: 'Бесплатный час закончился - сессия завершена', + outOfCreditsHint: 'Оформите подписку для собеседований без ограничений.', lowBalance: (minutes: number) => - `Кредитов осталось примерно на ${minutes} ${plural(minutes, 'минуту', 'минуты', 'минут')}`, - lowBalanceHint: 'Когда кредиты закончатся, сессия завершится.', - tooLowHint: (minimum: number, credits: number) => - `Нужно хотя бы ${minimum} ${plural(minimum, 'кредит', 'кредита', 'кредитов')} (1 минута), а у вас ${credits}.`, - buyCredits: 'Купить кредиты', + `Бесплатного времени осталось примерно на ${minutes} ${plural(minutes, 'минуту', 'минуты', 'минут')}`, + lowBalanceHint: 'Когда бесплатное время закончится, сессия завершится.', + tooLowHint: 'Оформите подписку для собеседований без ограничений на передовой модели.', + subscribe: 'Тарифы', }, trialNotice: { - freeTierLead: 'Пробный тариф: 1 час бесплатно на модели ', + freeTierLead: 'Бесплатный тариф: 1 час собеседований на модели ', freeTier: 'базового уровня', freeTierTail: '.', - sotaLead: 'Купите кредиты, чтобы открыть ', - sota: 'передовую', - sotaTail: ' модель.', + sotaLead: 'Оформите подписку, чтобы без ограничений работать с ', + sota: 'передовой', + sotaTail: ' моделью.', }, }; diff --git a/src/renderer/lib/credit-gate.ts b/src/renderer/lib/credit-gate.ts index 5d8e43eb..d8c8b8e6 100644 --- a/src/renderer/lib/credit-gate.ts +++ b/src/renderer/lib/credit-gate.ts @@ -4,6 +4,9 @@ import { CREDITS_PER_MINUTE } from './consts'; * One rule for whether a session may start, shared by the live and mock start paths and the home * screen cards that front them. * + * A subscriber may always start: their sessions are not metered. Everyone else spends the free hour, + * and the rest of this applies to that balance. + * * Both kinds of interview are metered by the minute on their audio socket at the same rate, and * the backend closes that socket when the balance reaches zero. A session started on a few seconds * of credit would be cut off almost at once, so starting asks for at least one minute's worth. @@ -32,9 +35,12 @@ export function minimumStartCredits(creditsPerMinute: number | undefined): numbe */ export function canStartSession( credits: number | undefined, - creditsPerMinute: number | undefined + creditsPerMinute: number | undefined, + subscribed?: boolean ): boolean { - return credits === undefined || credits >= minimumStartCredits(creditsPerMinute); + return ( + subscribed === true || credits === undefined || credits >= minimumStartCredits(creditsPerMinute) + ); } /** Whole minutes this balance pays for. */ diff --git a/src/renderer/lib/subscription.ts b/src/renderer/lib/subscription.ts new file mode 100644 index 00000000..6ee7c8f0 --- /dev/null +++ b/src/renderer/lib/subscription.ts @@ -0,0 +1,40 @@ +/** What the UI needs to know about an account's subscription, read off the ping. */ +export interface SubscriptionSummary { + subscribed: boolean; + /** Unix ms; null for an account that never had one. */ + expiresAt: number | null; + /** `monthly` / `yearly`, or null when unknown. */ + plan: string | null; +} + +export function subscriptionOf( + state: + | { + subscribed?: boolean; + subscriptionExpiresAt?: number | null; + subscriptionPlan?: string | null; + } + | null + | undefined +): SubscriptionSummary { + return { + subscribed: state?.subscribed === true, + expiresAt: state?.subscriptionExpiresAt ?? null, + plan: state?.subscriptionPlan ?? null, + }; +} + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** Whole days left, rounded up, so the last day reads "1 day" rather than "0 days". */ +export function daysLeft(expiresAt: number, now: number): number { + return Math.max(0, Math.ceil((expiresAt - now) / DAY_MS)); +} + +/** Days left at or below which the end date is shown as a warning. */ +export const EXPIRY_WARNING_DAYS = 3; + +/** Which locale name an active plan is shown as; `other` when the backend did not say. */ +export function planKey(plan: string | null): 'monthly' | 'yearly' | 'other' { + return plan === 'monthly' || plan === 'yearly' ? plan : 'other'; +} diff --git a/src/renderer/pages/home/index.tsx b/src/renderer/pages/home/index.tsx index 1eaf38e4..1f0da207 100644 --- a/src/renderer/pages/home/index.tsx +++ b/src/renderer/pages/home/index.tsx @@ -11,7 +11,8 @@ import useAuth from '@/hooks/use-auth'; import { useConfigStore } from '@/hooks/use-config-store'; import { useSaveHistoryGuard } from '@/hooks/use-save-history-guard'; import { useT } from '@/i18n'; -import { canStartSession, minimumStartCredits } from '@/lib/credit-gate'; +import { canStartSession, minutesCovered } from '@/lib/credit-gate'; +import { planKey, subscriptionOf, type SubscriptionSummary } from '@/lib/subscription'; import { cn } from '@/lib/utils'; import { RunningState } from '@/types/app-state'; import type { MockInterviewSetup } from '@/types/mock-interview'; @@ -120,11 +121,14 @@ export default function HomePage() { // unavailable would grey the card out for the first seconds of every launch. const mockUnsupported = appState?.mockInterviewSupported === false; - // Both kinds of interview are billed by the minute at the same rate, so one rule decides - // whether either card may start a session: at least a minute of credit. A card that cannot - // start says so and goes to the payment page instead of into a start that would be refused. - const creditsTooLow = !canStartSession(appState?.credits, appState?.creditsPerMinute); - const minimumCredits = minimumStartCredits(appState?.creditsPerMinute); + // One rule decides whether either card may start a session: subscribed, or at least a minute of + // the free hour left. A card that cannot start says so and goes to the subscription page instead + // of into a start that would be refused. + const creditsTooLow = !canStartSession( + appState?.credits, + appState?.creditsPerMinute, + appState?.subscribed + ); const [mockSetupOpen, setMockSetupOpen] = useState(false); const [signingOut, setSigningOut] = useState(false); @@ -161,7 +165,8 @@ export default function HomePage() { // The account's own email, not the remembered credential: `config.email` is blank whenever the // user declined "remember me", which would report a signed-in user as not signed in. const email = appState?.accountEmail || config?.email; - const credits = appState?.credits; + const subscription = subscriptionOf(appState); + const planSummary = describePlan(t, subscription, appState?.credits, appState?.creditsPerMinute); // appState starts null before the first IPC round-trip resolves, and the config arrives over a // second one - both need to settle before "no data" is trustworthy. const accountReady = !configLoading && config !== undefined && appState !== null; @@ -244,7 +249,7 @@ export default function HomePage() { : liveSessionActive ? t.home.mock.liveRunning : creditsTooLow - ? t.home.mock.unaffordable(minimumCredits) + ? t.home.mock.unaffordable : t.home.mock.ready } onClick={() => (creditsTooLow ? navigate('/payment') : setMockSetupOpen(true))} @@ -259,7 +264,7 @@ export default function HomePage() { : mockSessionActive ? t.home.live.mockRunning : creditsTooLow - ? t.home.live.unaffordable(minimumCredits) + ? t.home.live.unaffordable : t.home.live.ready } onClick={ @@ -279,9 +284,9 @@ export default function HomePage() {

-

{t.home.creditsLabel}

+

{t.home.planLabel}

- {accountReady ? (credits ?? t.home.creditsUnavailable) : t.common.loading} + {accountReady ? (planSummary ?? t.home.planUnavailable) : t.common.loading}

@@ -353,3 +358,23 @@ export default function HomePage() { ); } + +/** The account strip's plan line: the active plan and its end date, or the free time left. */ +function describePlan( + t: ReturnType, + subscription: SubscriptionSummary, + credits: number | undefined, + creditsPerMinute: number | undefined +): string | undefined { + if (subscription.subscribed && subscription.expiresAt !== null) { + return t.payment.buy.subscribedUntil( + t.creditsDisplay.planNames[planKey(subscription.plan)], + t.creditsDisplay.date(subscription.expiresAt) + ); + } + if (credits === undefined) return undefined; + const minutes = minutesCovered(credits, creditsPerMinute); + return minutes > 0 + ? t.creditsDisplay.freeLeft(t.creditsDisplay.duration(Math.floor(minutes / 60), minutes % 60)) + : t.creditsDisplay.noFreeTimeLeft; +} diff --git a/src/renderer/pages/main/index.tsx b/src/renderer/pages/main/index.tsx index 62d85142..e5b8f7b2 100644 --- a/src/renderer/pages/main/index.tsx +++ b/src/renderer/pages/main/index.tsx @@ -29,6 +29,7 @@ import { TRANSCRIPT_DOCK_MIN_HEIGHT, TRANSCRIPT_DOCK_RATIO, } from '@/lib/consts'; +import { subscriptionOf } from '@/lib/subscription'; import { getElectron } from '@/lib/utils'; import { RunningState, UserRole } from '@/types/app-state'; import { type ActionSuggestion, type LiveSuggestion } from '@/types/suggestion'; @@ -355,9 +356,9 @@ export default function MainPage() { )} {/* Show trial user notice */} - {appState?.userRole === UserRole.TrialUser && !trialNoticeClosed && ( - setTrialNoticeClosed(true)} /> - )} + {appState?.userRole === UserRole.TrialUser && + !appState?.subscribed && + !trialNoticeClosed && setTrialNoticeClosed(true)} />} {!hasSuggestions && !showTranscriptDock && (
@@ -395,8 +396,8 @@ export default function MainPage() { )} diff --git a/src/renderer/pages/payment/index.tsx b/src/renderer/pages/payment/index.tsx index fcd1ff12..a9c7d2ce 100644 --- a/src/renderer/pages/payment/index.tsx +++ b/src/renderer/pages/payment/index.tsx @@ -7,13 +7,14 @@ import { CreditCard, History, Receipt } from 'lucide-react'; import { useEffect, useState } from 'react'; import PageHeader from '@/components/custom/page-header'; -import BuyCreditsTab from '@/components/custom/payment/buy-credits-tab'; import PaymentHistoryTab from '@/components/custom/payment/payment-history-tab'; import PaymentStatusTab from '@/components/custom/payment/payment-status-tab'; +import SubscriptionTab from '@/components/custom/payment/subscription-tab'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { useAppState } from '@/hooks/use-app-state'; import { usePayment } from '@/hooks/use-payment'; import { useT } from '@/i18n'; +import { subscriptionOf } from '@/lib/subscription'; type PaymentTab = 'buy' | 'history' | 'status'; @@ -42,7 +43,7 @@ export default function PaymentPage() { setActiveTab('buy'); }; - const remainingCredits = appState?.credits ?? 0; + const freeCredits = appState?.credits ?? 0; return ( {/* Header. Falls back to `/main` rather than home: this page is most often opened from the - interview screen, on a credits warning the user wants to get back from. */} + interview screen, on a free-time warning the user wants to get back from. */} @@ -72,8 +73,9 @@ export default function PaymentPage() { {/* Content */}
- diff --git a/test/credit-gate.test.mjs b/test/credit-gate.test.mjs index 9f5864ef..4dfc5c56 100644 --- a/test/credit-gate.test.mjs +++ b/test/credit-gate.test.mjs @@ -49,6 +49,10 @@ export async function run(userDataDir) { gate.canStartSession(undefined, 10) ); + check('a subscriber may start with no free time left', gate.canStartSession(0, 10, true)); + check('and before the ping has said how much', gate.canStartSession(undefined, 10, true)); + check('not subscribed is the free-hour rule', !gate.canStartSession(0, 10, false)); + check('minutes covered rounds down', gate.minutesCovered(25, 10) === 2); check('and is never negative', gate.minutesCovered(-5, 10) === 0); check('the minimum is one minute of the served rate', gate.minimumStartCredits(12) === 12); From 27ebefaff4a8a1b0910632614041d54506ac59e4 Mon Sep 17 00:00:00 2001 From: alpha Date: Tue, 13 Oct 2026 10:19:03 -0500 Subject: [PATCH 03/17] docs: subscription pricing in CLAUDE.md and SPEC.md Refs #143 Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 27 +++++++++++++++++++++------ SPEC.md | 14 +++++++------- docs/ux-conventions.md | 2 +- 3 files changed, 29 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cc2356eb..b4cc874b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -667,8 +667,20 @@ body text in every question. ### What an interview costs -**Every interview, live or mock, is billed by the minute on its ASR socket, at the same rate, and -nothing else charges.** A mock used to be priced per question, follow-up and report, with +**Pricing is a subscription.** A free hour once on the free-tier model, then a prepaid monthly +($20) or yearly ($144) pass: unlimited interviews on the SOTA model, no auto-renewal. A +subscriber's sockets are not metered; everything below is about the free hour. + +**`AppState.credits` is the free balance, and `AppState.subscribed` sits beside it.** The backend +sends the free balance as `free_credits` and keeps `credits` for clients that predate +subscriptions - for a subscriber that is the time left expressed in credits, so an old client does +not lock a paying user out. `balanceState` (`src/main/utils/balance.ts`) reads `free_credits` when +present and falls back to `credits` against an older backend; `test/balance.test.mjs` pins it. +Never show the legacy figure. Anything that reads the clock in render goes through `useNow`: the +React Compiler refuses `Date.now()` there. + +**Every free interview, live or mock, is billed by the minute on its ASR socket, at the same rate, +and nothing else charges.** A mock used to be priced per question, follow-up and report, with `billing=per_turn` on its requests and `metered=0` on its socket; both are gone. The mock socket is open from Start until the session reaches Idle or Finished (`use-mock-interview.ts`), so metering it already covers question generation, speaking, scoring and the report. @@ -679,8 +691,9 @@ the live session's two, so without it a mock pays half. `test/mock-billing-contr pins it, and that nothing still declares per-turn billing - a backend that predates the switch would honour such a declaration *and* run the clock. -**One start rule for both** (`lib/credit-gate.ts`): at least one minute of credit. The home cards, -the live control panel and the mock setup form all use it and send the user to Buy credits. The +**One start rule for both** (`lib/credit-gate.ts`): subscribed, or at least one minute of the free +hour. The home cards, the live control panel and the mock setup form all use it and send the user +to the subscription page. The backend refuses only at zero, because it cannot tell a new session from a reconnect inside one; this side can, so the minimum lives here. An unknown balance (before the first ping) is let through rather than greying both cards out at launch. The mock setup screen gives an estimate in minutes and @@ -704,7 +717,9 @@ to start, so a refusal that lands while the session still reads Idle is held and main's start resolves. `test/out-of-credits.test.mjs` pins all of it. -`useLowBalanceWarning` toasts once at five minutes and once at one, off the ping balance. Sockets +`useLowBalanceWarning` toasts once at five minutes and once at one, off the ping balance, and never +for a subscriber. A subscription within three days of its end shows "Ends in N days" in the title +bar instead of its end date. Sockets also carry `kind=mock` and a per-session `client_session_id`, which the backend uses only to label and group its ledger rows. @@ -716,7 +731,7 @@ The main window reference is passed to `windowControlService` and `zoomService` 36px titlebar. The home screen did not: six full-width rows down a column half the window wide came to roughly 580px against 564px of room, so the app's front door opened on a scrollbar with the sign-out row below the fold. Nothing was dropped to fix it - the two launch cards share a row, -the account strip carries Buy Credits on its own line, and the three navigations are one row of +the account strip carries Subscription on its own line, and the three navigations are one row of three, which is spending the width the window already has rather than the height it does not. The outer container keeps `overflow-auto` as a safety valve for large zoom factors. diff --git a/SPEC.md b/SPEC.md index 45a14f1a..d4921fb3 100644 --- a/SPEC.md +++ b/SPEC.md @@ -69,11 +69,9 @@ Earlier attempts worked on this from the other end - having the reveal wait for ### What a Mock Interview Costs -A live interview is metered per minute; a mock one is priced per question (20 credits), follow-up (10) and report (40), and its transcription socket is not metered at all. The two spend their wall clock differently: a mock session spends much of it generating the question, speaking it, scoring the turn and writing the report - none of which the candidate can act during - and most of the rest on think-time, which is the behaviour the feature exists to train. +Nothing extra. Pricing is a subscription: a free hour once on the free-tier model, then a $20 monthly or $144 yearly pass with unlimited interviews on the SOTA model. A subscriber's mock and live sessions are not metered at all. A free user's sessions, live or mock, spend the free hour by the minute on their transcription socket and stop when it runs out; a mock still ends with its report. -The prices come from the backend on the 5-second ping, never from local constants, so a quote can never name a number the user is not charged. The setup dialog shows two: what the session is guaranteed to cost, and the ceiling if the interviewer follows up on every answer. The smaller one is the promise - every question quoted and the report are guaranteed once the session starts, and a follow-up is declined rather than allowed to eat into them. - -A length the balance cannot cover is disabled in the question-count picker with the reason, so the answer to "not enough credits" is a shorter interview chosen on the same screen; the home screen's mock card says the same thing one level earlier, against the shortest session there is. A backend that predates per-turn pricing sends no prices, and the client then quotes nothing and gates nothing. +The setup dialog gives an estimate in minutes. For a free user it adds how far the free time goes and that the session ends early if it runs out; for a subscriber it says the session is unlimited. No length is ever refused. ### Hint-Only Mode @@ -99,7 +97,7 @@ Nothing in it is a trap: Skip is on every step, every setting has a working defa ### Navigation -`/` is a launch hub naming the five things a user comes to the app to do: start a mock interview, start the live assistant, open Account (`/account` - sign-in identity, profile, context, password), open Configuration (`/configuration` - app language, microphone, interview language, suggestion style, interface size, transcript panel), or buy credits. +`/` is a launch hub naming the five things a user comes to the app to do: start a mock interview, start the live assistant, open Account (`/account` - sign-in identity, profile, context, password), open Configuration (`/configuration` - app language, microphone, interview language, suggestion style, interface size, transcript panel), or manage the subscription. **It is the only place a session begins.** Both launch buttons start one; neither implements starting one. Live hands off to `/main` through router state, because `/main`'s control panel owns the whole start sequence; mock hands off to `/mock-interview` with the setup its dialog collected. `/main` itself carries only Stop - it is the live assistant, not a place to choose one - and shows a way back to `/` on the rare idle visit (a start cancelled at the headphone notice, or the route opened directly). Stopping asks whether to save the interview, clears it, and returns to `/`. See [docs/ux-conventions.md](docs/ux-conventions.md) for where a new capability belongs. @@ -111,9 +109,11 @@ While stealth mode is on - or while the assistant is running with the opt-in "Hi Full name, profile/CV, and context are stored on the user's backend account and pulled on login or a remembered session, so the setup follows the user across devices. Service: [src/main/services/account.service.ts](src/main/services/account.service.ts). The full values are kept in the main process and fetched on demand over `account:get`; the app-state broadcast carries only a `{ fullName, hasProfileData }` summary, since the profile and context can each run to 128,000 characters. -### Credits and Payments +### Subscription and Payments + +Route: `/payment`. The Plans tab shows the account's plan (the active pass and its end date, or the free time left) and the passes on sale from `/api/payment/subscription-plans`, bought through the same crypto checkout as before. Passes are prepaid and never renew; buying one while subscribed adds its days after the current end date. Plans, the balance and the subscription are always served by the backend (`/api/payment/credits`, plus `free_credits`, `subscribed`, `subscription_expires_at`, `subscription_plan` and `credits_per_minute` on every 5-second `/api/health-check/ping-client`); a failed plan fetch surfaces as an error rather than falling back to stale figures. -Purchase and usage tracking via the payment API. Route: `/payment`. Plans, the credit balance and the per-minute burn rate are always served by the backend (`/api/payment/plans`, `/api/payment/credits`, plus the balance and `credits_per_minute` carried on every 5-second `/api/health-check/ping-client`); a failed plan fetch surfaces as an error rather than falling back to stale figures. `CREDITS_PER_MINUTE` in `renderer/lib/consts.ts` is a fallback mirror only, used while `AppState.creditsPerMinute` is `undefined` - before the first ping answers, or against a backend old enough not to send it - since the real rate is an env-overridable deployment setting rather than a constant. +`AppState.credits` is always the free balance: `free_credits` when the backend sends it, else `credits` (`balanceState` in `src/main/utils/balance.ts`). The backend's own `credits` is, for a subscriber, a compatibility figure for clients that predate subscriptions, and must not be shown. `CREDITS_PER_MINUTE` in `renderer/lib/consts.ts` is a fallback mirror only, used while `AppState.creditsPerMinute` is `undefined` - before the first ping answers, or against a backend old enough not to send it - since the real rate is an env-overridable deployment setting rather than a constant. ### Auto-Updates diff --git a/docs/ux-conventions.md b/docs/ux-conventions.md index 766fd3e9..ad06a493 100644 --- a/docs/ux-conventions.md +++ b/docs/ux-conventions.md @@ -63,7 +63,7 @@ feature can still fail a non-developer if it doesn't read as polished and easy t new or changed UI surface must: - **Use plain, user-facing language**, not internal identifiers — labels, menu items, and error - messages are written for the person using the app, not for the codebase (e.g. "Buy Credits", + messages are written for the person using the app, not for the codebase (e.g. "Subscription", not a raw enum or hotkey constant name). - **Define an explicit state for empty, loading, and error** — no surface ships with only a "happy path" and a blank area or unhandled exception for everything else. From 1da8436277995daa69a5f749fe26619a80dbef8e Mon Sep 17 00:00:00 2001 From: alpha Date: Tue, 13 Oct 2026 11:52:46 -0400 Subject: [PATCH 04/17] fix(copy): out-of-free-time wording that is true for every account "Your free hour is used up" was shown to anyone unsubscribed at a zero balance, including a reseller's customer who never had a free hour and a subscriber whose pass lapsed. Say "No free time left" instead (en, ru). Co-Authored-By: Claude Opus 5.5 --- src/renderer/i18n/locales/en.ts | 12 ++++++------ src/renderer/i18n/locales/ru.ts | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/renderer/i18n/locales/en.ts b/src/renderer/i18n/locales/en.ts index ade4d449..1daeba06 100644 --- a/src/renderer/i18n/locales/en.ts +++ b/src/renderer/i18n/locales/en.ts @@ -238,7 +238,7 @@ export const en = { ready: 'The AI asks, you answer out loud, and you get a scored report at the end.', unsupported: 'Not available on this server yet. Update the app, or try again later.', liveRunning: 'Stop the live assistant first - the two cannot share your microphone.', - unaffordable: 'Your free hour is used up. Subscribe to keep practising.', + unaffordable: 'No free time left. Subscribe to keep practising.', }, live: { @@ -247,7 +247,7 @@ export const en = { ready: 'Transcribes your real interview and suggests answers as it happens.', running: 'Your live assistant is already running.', mockRunning: 'Finish the mock interview first - the two cannot share your microphone.', - unaffordable: 'Your free hour is used up. Subscribe to keep interviewing.', + unaffordable: 'No free time left. Subscribe to keep interviewing.', }, accountLabel: 'Account', @@ -634,7 +634,7 @@ export const en = { return parts.join(' ') || '0 mins'; }, lessThanAMinute: 'Less than 1 min', - noFreeTimeLeft: 'Free hour used up', + noFreeTimeLeft: 'No free time left', }, panels: { @@ -999,7 +999,7 @@ export const en = { daysLeft: (days: number) => `${days} ${days === 1 ? 'day' : 'days'} left`, freePlan: 'Free plan', freeLeft: (duration: string) => `${duration} of interviews left on the free model.`, - freeUsedUp: 'Your free hour is used up. Subscribe to keep interviewing.', + freeUsedUp: 'No free time left. Subscribe to keep interviewing.', duration: (hours: number, minutes: number) => { const parts: string[] = []; if (hours) parts.push(`${hours} hour${hours === 1 ? '' : 's'}`); @@ -1148,8 +1148,8 @@ export const en = { /** The one start rule live and mock share: subscribed, or at least a minute of free time. */ creditGate: { - tooLow: 'Your free hour is used up', - outOfCredits: 'Your free hour is used up - the session has ended', + tooLow: 'No free time left', + outOfCredits: 'Out of free time - the session has ended', outOfCreditsHint: 'Subscribe for unlimited interviews.', lowBalance: (minutes: number) => `About ${minutes} ${minutes === 1 ? 'minute' : 'minutes'} of free time left`, diff --git a/src/renderer/i18n/locales/ru.ts b/src/renderer/i18n/locales/ru.ts index 2b8dfac3..644c6824 100644 --- a/src/renderer/i18n/locales/ru.ts +++ b/src/renderer/i18n/locales/ru.ts @@ -240,7 +240,7 @@ export const ru: Translation = { unsupported: 'Пока недоступно на этом сервере. Обновите приложение или попробуйте позже.', liveRunning: 'Сначала остановите живого ассистента - они не могут использовать микрофон одновременно.', - unaffordable: 'Бесплатный час закончился. Оформите подписку, чтобы продолжить тренировки.', + unaffordable: 'Бесплатное время закончилось. Оформите подписку, чтобы продолжить тренировки.', }, live: { @@ -250,7 +250,7 @@ export const ru: Translation = { running: 'Ассистент уже работает.', mockRunning: 'Сначала завершите пробное собеседование - они не могут использовать микрофон одновременно.', - unaffordable: 'Бесплатный час закончился. Оформите подписку, чтобы продолжить.', + unaffordable: 'Бесплатное время закончилось. Оформите подписку, чтобы продолжить.', }, accountLabel: 'Учётная запись', @@ -650,7 +650,7 @@ export const ru: Translation = { return parts.join(' ') || '0 минут'; }, lessThanAMinute: 'Меньше минуты', - noFreeTimeLeft: 'Бесплатный час закончился', + noFreeTimeLeft: 'Бесплатное время закончилось', }, panels: { @@ -1005,7 +1005,7 @@ export const ru: Translation = { freePlan: 'Бесплатный тариф', // "на" takes the accusative, which is the case `duration` is written in freeLeft: (duration: string) => `Осталось собеседований на ${duration} на базовой модели.`, - freeUsedUp: 'Бесплатный час закончился. Оформите подписку, чтобы продолжить.', + freeUsedUp: 'Бесплатное время закончилось. Оформите подписку, чтобы продолжить.', duration: (hours: number, minutes: number) => { const parts: string[] = []; if (hours) parts.push(`${hours} ${plural(hours, 'час', 'часа', 'часов')}`); @@ -1155,8 +1155,8 @@ export const ru: Translation = { }, creditGate: { - tooLow: 'Бесплатный час закончился', - outOfCredits: 'Бесплатный час закончился - сессия завершена', + tooLow: 'Бесплатное время закончилось', + outOfCredits: 'Бесплатное время закончилось - сессия завершена', outOfCreditsHint: 'Оформите подписку для собеседований без ограничений.', lowBalance: (minutes: number) => `Бесплатного времени осталось примерно на ${minutes} ${plural(minutes, 'минуту', 'минуты', 'минут')}`, From 2ee227993533e5596e4b18b8981abf4fed5bfc77 Mon Sep 17 00:00:00 2001 From: alpha Date: Tue, 13 Oct 2026 12:16:38 -0400 Subject: [PATCH 05/17] refactor(payment): names that match the subscription UI - isPro becomes isPopular: the highlighted card is whichever plan the backend marks popular, and no plan is called Pro any more. - One planNameOf helper replaces a cast repeated at the purchase summary. - The history column key is purchase, matching its label. - The balance fetch error says the plan failed to load instead of "credits". Co-Authored-By: Claude Opus 5.5 --- src/main/utils/ui-strings.ts | 4 ++-- .../custom/payment/payment-history-tab.tsx | 2 +- .../custom/payment/subscription-tab.tsx | 22 +++++++++++-------- src/renderer/i18n/locales/en.ts | 4 ++-- src/renderer/i18n/locales/ru.ts | 4 ++-- 5 files changed, 20 insertions(+), 16 deletions(-) diff --git a/src/main/utils/ui-strings.ts b/src/main/utils/ui-strings.ts index c9d10974..bd5d9c54 100644 --- a/src/main/utils/ui-strings.ts +++ b/src/main/utils/ui-strings.ts @@ -190,7 +190,7 @@ const STRINGS: Record = { createPayment: 'Failed to create payment', getStatus: 'Failed to get payment status', getHistory: 'Failed to get payment history', - getCredits: 'Failed to get credits', + getCredits: 'Failed to load your plan', pollingTimeout: 'Payment polling timeout', }, @@ -284,7 +284,7 @@ const STRINGS: Record = { createPayment: 'Не удалось создать платёж', getStatus: 'Не удалось получить статус платежа', getHistory: 'Не удалось получить историю платежей', - getCredits: 'Не удалось получить баланс кредитов', + getCredits: 'Не удалось загрузить данные о тарифе', pollingTimeout: 'Платёж не подтвердился вовремя', }, diff --git a/src/renderer/components/custom/payment/payment-history-tab.tsx b/src/renderer/components/custom/payment/payment-history-tab.tsx index 716ba641..5494ca01 100644 --- a/src/renderer/components/custom/payment/payment-history-tab.tsx +++ b/src/renderer/components/custom/payment/payment-history-tab.tsx @@ -124,7 +124,7 @@ export default function PaymentHistoryTab({ {t.payment.history.columns.created} {t.payment.history.columns.paymentId} - {t.payment.history.columns.credits} + {t.payment.history.columns.purchase} {t.payment.history.columns.amount} {t.payment.history.columns.status} {t.payment.history.columns.action} diff --git a/src/renderer/components/custom/payment/subscription-tab.tsx b/src/renderer/components/custom/payment/subscription-tab.tsx index 4d830237..18d50c0b 100644 --- a/src/renderer/components/custom/payment/subscription-tab.tsx +++ b/src/renderer/components/custom/payment/subscription-tab.tsx @@ -82,6 +82,12 @@ export default function SubscriptionTab({ const freeMinutes = minutesCovered(credits, creditsPerMinute); const active = subscription.subscribed && subscription.expiresAt !== null; + /** The plan's display name; a plan this build has no name for shows its id. */ + const planNameOf = (plan: SubscriptionPlanInfo) => { + const key = planKey(plan.plan); + return key === 'other' ? plan.plan : t.payment.buy.planNames[key]; + }; + const handleSelectPlan = useCallback((plan: SubscriptionPlanInfo) => { setSelectedPlan(plan); setTimeout(() => { @@ -156,10 +162,10 @@ export default function SubscriptionTab({ <>
{plans.map((plan) => { - const isPro = plan.popular; + const isPopular = plan.popular; const isSelected = selectedPlan?.plan === plan.plan; const key = planKey(plan.plan); - const planName = key === 'other' ? plan.plan : t.payment.buy.planNames[key]; + const planName = planNameOf(plan); const planDescription = key === 'other' ? '' : t.payment.buy.planDescriptions[key]; const saving = savingPercent(plan, plans); @@ -168,7 +174,7 @@ export default function SubscriptionTab({ key={plan.plan} className={cn( 'relative flex flex-col gap-3 py-4 transition-all duration-150', - isPro + isPopular ? 'border-primary shadow-lg hover:shadow-xl' : 'shadow-sm hover:shadow-lg', isSelected @@ -182,7 +188,7 @@ export default function SubscriptionTab({
)} - {isPro && ( + {isPopular && (
{t.payment.buy.mostPopular} @@ -216,11 +222,11 @@ export default function SubscriptionTab({ 'w-full cursor-pointer', isSelected ? '' - : isPro + : isPopular ? '' : 'border border-input bg-background hover:bg-accent hover:text-accent-foreground' )} - variant={isSelected || isPro ? 'default' : 'outline'} + variant={isSelected || isPopular ? 'default' : 'outline'} onClick={(e) => { e.stopPropagation(); handleSelectPlan(plan); @@ -250,9 +256,7 @@ export default function SubscriptionTab({ {t.payment.buy.detailsLead} - {planKey(selectedPlan.plan) === 'other' - ? selectedPlan.plan - : t.payment.buy.planNames[planKey(selectedPlan.plan) as 'monthly' | 'yearly']} + {planNameOf(selectedPlan)} {t.payment.buy.detailsFor} ${selectedPlan.priceUsd} USD diff --git a/src/renderer/i18n/locales/en.ts b/src/renderer/i18n/locales/en.ts index 1daeba06..f407d3fa 100644 --- a/src/renderer/i18n/locales/en.ts +++ b/src/renderer/i18n/locales/en.ts @@ -1049,7 +1049,7 @@ export const en = { columns: { created: 'Created', paymentId: 'Payment ID', - credits: 'Purchase', + purchase: 'Purchase', amount: 'Amount', status: 'Status', action: 'Action', @@ -1113,7 +1113,7 @@ export const en = { createPayment: 'Failed to create payment', getStatus: 'Failed to get payment status', getHistory: 'Failed to get payment history', - getCredits: 'Failed to get credits', + getCredits: 'Failed to load your plan', }, }, diff --git a/src/renderer/i18n/locales/ru.ts b/src/renderer/i18n/locales/ru.ts index 644c6824..e48f4d21 100644 --- a/src/renderer/i18n/locales/ru.ts +++ b/src/renderer/i18n/locales/ru.ts @@ -1055,7 +1055,7 @@ export const ru: Translation = { columns: { created: 'Создан', paymentId: 'ID платежа', - credits: 'Покупка', + purchase: 'Покупка', amount: 'Сумма', status: 'Статус', action: 'Действие', @@ -1118,7 +1118,7 @@ export const ru: Translation = { createPayment: 'Не удалось создать платёж', getStatus: 'Не удалось получить статус платежа', getHistory: 'Не удалось получить историю платежей', - getCredits: 'Не удалось получить баланс кредитов', + getCredits: 'Не удалось загрузить данные о тарифе', }, }, From 142b9d1dcefcfac40705eb322509f49d194f8091 Mon Sep 17 00:00:00 2001 From: alpha Date: Tue, 13 Oct 2026 12:17:35 -0400 Subject: [PATCH 06/17] style(payment): format the purchase summary Co-Authored-By: Claude Opus 5.5 --- src/renderer/components/custom/payment/subscription-tab.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/renderer/components/custom/payment/subscription-tab.tsx b/src/renderer/components/custom/payment/subscription-tab.tsx index 18d50c0b..f858e42a 100644 --- a/src/renderer/components/custom/payment/subscription-tab.tsx +++ b/src/renderer/components/custom/payment/subscription-tab.tsx @@ -255,9 +255,7 @@ export default function SubscriptionTab({ {t.payment.buy.detailsTitle} {t.payment.buy.detailsLead} - - {planNameOf(selectedPlan)} - + {planNameOf(selectedPlan)} {t.payment.buy.detailsFor} ${selectedPlan.priceUsd} USD From bfc16c0bf9eba2535ac046dcf896abcb94b6cc6b Mon Sep 17 00:00:00 2001 From: alpha Date: Tue, 13 Oct 2026 12:41:29 -0400 Subject: [PATCH 07/17] docs(payment): drop the note about the removed legacy plans endpoint Co-Authored-By: Claude Opus 5.5 --- src/main/api/payment.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/api/payment.ts b/src/main/api/payment.ts index 946d7516..dfe3be4e 100644 --- a/src/main/api/payment.ts +++ b/src/main/api/payment.ts @@ -16,8 +16,7 @@ import { ApiClient, ApiResponse } from './client.js'; export class PaymentApi extends ApiClient { /** - * Get the subscription passes on sale. `/api/payment/plans` is the legacy credit packs, kept by - * the backend only for clients that predate subscriptions. + * Get the subscription passes on sale. */ async getPlans(): Promise< ApiResponse< From 4422b925001dc8769bc7a5134737c8a15119a87b Mon Sep 17 00:00:00 2001 From: alpha Date: Tue, 13 Oct 2026 13:09:21 -0400 Subject: [PATCH 08/17] docs(payment): yearly pass is $96 ($8 a month) Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- SPEC.md | 2 +- src/main/types/payment.ts | 2 +- src/renderer/types/payment.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b4cc874b..7e7b3731 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -668,7 +668,7 @@ body text in every question. ### What an interview costs **Pricing is a subscription.** A free hour once on the free-tier model, then a prepaid monthly -($20) or yearly ($144) pass: unlimited interviews on the SOTA model, no auto-renewal. A +($20) or yearly ($96) pass: unlimited interviews on the SOTA model, no auto-renewal. A subscriber's sockets are not metered; everything below is about the free hour. **`AppState.credits` is the free balance, and `AppState.subscribed` sits beside it.** The backend diff --git a/SPEC.md b/SPEC.md index d4921fb3..9786746f 100644 --- a/SPEC.md +++ b/SPEC.md @@ -69,7 +69,7 @@ Earlier attempts worked on this from the other end - having the reveal wait for ### What a Mock Interview Costs -Nothing extra. Pricing is a subscription: a free hour once on the free-tier model, then a $20 monthly or $144 yearly pass with unlimited interviews on the SOTA model. A subscriber's mock and live sessions are not metered at all. A free user's sessions, live or mock, spend the free hour by the minute on their transcription socket and stop when it runs out; a mock still ends with its report. +Nothing extra. Pricing is a subscription: a free hour once on the free-tier model, then a $20 monthly or $96 yearly pass with unlimited interviews on the SOTA model. A subscriber's mock and live sessions are not metered at all. A free user's sessions, live or mock, spend the free hour by the minute on their transcription socket and stop when it runs out; a mock still ends with its report. The setup dialog gives an estimate in minutes. For a free user it adds how far the free time goes and that the session ends early if it runs out; for a subscriber it says the session is unlimited. No length is ever refused. diff --git a/src/main/types/payment.ts b/src/main/types/payment.ts index 7246beb1..e37bc268 100644 --- a/src/main/types/payment.ts +++ b/src/main/types/payment.ts @@ -32,7 +32,7 @@ export interface SubscriptionPlanInfo { priceUsd: number; /** How many days of subscription the pass adds. */ days: number; - /** The price per month, for display: $144 for a year is $12 a month. */ + /** The price per month, for display: $96 for a year is $8 a month. */ pricePerMonthUsd: number; popular: boolean; } diff --git a/src/renderer/types/payment.ts b/src/renderer/types/payment.ts index 3250d497..1213f874 100644 --- a/src/renderer/types/payment.ts +++ b/src/renderer/types/payment.ts @@ -32,7 +32,7 @@ export interface SubscriptionPlanInfo { priceUsd: number; /** How many days of subscription the pass adds. */ days: number; - /** The price per month, for display: $144 for a year is $12 a month. */ + /** The price per month, for display: $96 for a year is $8 a month. */ pricePerMonthUsd: number; popular: boolean; } From 3a86c61877dab6ed70486b8e6e2b01638c87eff0 Mon Sep 17 00:00:00 2001 From: alpha Date: Tue, 13 Oct 2026 15:00:40 -0500 Subject: [PATCH 09/17] refactor(credit-gate): effectiveRate is internal now Co-Authored-By: Claude Opus 5.5 --- src/renderer/lib/credit-gate.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/lib/credit-gate.ts b/src/renderer/lib/credit-gate.ts index d8c8b8e6..eee2b487 100644 --- a/src/renderer/lib/credit-gate.ts +++ b/src/renderer/lib/credit-gate.ts @@ -17,7 +17,7 @@ import { CREDITS_PER_MINUTE } from './consts'; */ /** The rate to divide by: the backend's when it has said, the compiled-in mirror until then. */ -export function effectiveRate(creditsPerMinute: number | undefined): number { +function effectiveRate(creditsPerMinute: number | undefined): number { return creditsPerMinute ?? CREDITS_PER_MINUTE; } From b33eb16bd72f5b021d1179e2f0877f60570860f5 Mon Sep 17 00:00:00 2001 From: alpha Date: Tue, 13 Oct 2026 15:49:41 -0500 Subject: [PATCH 10/17] refactor(i18n): one set of plan names for the plan line and the plans tab Co-Authored-By: Claude Opus 5.5 --- src/renderer/components/custom/payment/subscription-tab.tsx | 2 +- src/renderer/i18n/locales/en.ts | 4 ---- src/renderer/i18n/locales/ru.ts | 4 ---- 3 files changed, 1 insertion(+), 9 deletions(-) diff --git a/src/renderer/components/custom/payment/subscription-tab.tsx b/src/renderer/components/custom/payment/subscription-tab.tsx index f858e42a..c4375ad3 100644 --- a/src/renderer/components/custom/payment/subscription-tab.tsx +++ b/src/renderer/components/custom/payment/subscription-tab.tsx @@ -85,7 +85,7 @@ export default function SubscriptionTab({ /** The plan's display name; a plan this build has no name for shows its id. */ const planNameOf = (plan: SubscriptionPlanInfo) => { const key = planKey(plan.plan); - return key === 'other' ? plan.plan : t.payment.buy.planNames[key]; + return key === 'other' ? plan.plan : t.creditsDisplay.planNames[key]; }; const handleSelectPlan = useCallback((plan: SubscriptionPlanInfo) => { diff --git a/src/renderer/i18n/locales/en.ts b/src/renderer/i18n/locales/en.ts index f407d3fa..2148e258 100644 --- a/src/renderer/i18n/locales/en.ts +++ b/src/renderer/i18n/locales/en.ts @@ -1008,10 +1008,6 @@ export const en = { }, loadingPlans: 'Loading plans…', mostPopular: 'Best value', - planNames: { - monthly: 'Monthly', - yearly: 'Yearly', - }, planDescriptions: { monthly: 'Unlimited interviews on the SOTA model for 30 days', yearly: 'Unlimited interviews on the SOTA model for a year', diff --git a/src/renderer/i18n/locales/ru.ts b/src/renderer/i18n/locales/ru.ts index e48f4d21..7642e2d8 100644 --- a/src/renderer/i18n/locales/ru.ts +++ b/src/renderer/i18n/locales/ru.ts @@ -1014,10 +1014,6 @@ export const ru: Translation = { }, loadingPlans: 'Загружаем тарифы…', mostPopular: 'Выгоднее всего', - planNames: { - monthly: 'Месячная', - yearly: 'Годовая', - }, planDescriptions: { monthly: 'Собеседования без ограничений на передовой модели на 30 дней', yearly: 'Собеседования без ограничений на передовой модели на год', From 74d6b0314396cc3b637409006ad34efc0f7c1d7c Mon Sep 17 00:00:00 2001 From: alpha Date: Tue, 13 Oct 2026 15:50:37 -0500 Subject: [PATCH 11/17] fix(copy): mock refusal message speaks of free time, not credits Co-Authored-By: Claude Opus 5.5 --- src/main/utils/ui-strings.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/utils/ui-strings.ts b/src/main/utils/ui-strings.ts index bd5d9c54..7827f14d 100644 --- a/src/main/utils/ui-strings.ts +++ b/src/main/utils/ui-strings.ts @@ -211,7 +211,7 @@ const STRINGS: Record = { nothingRecorded: 'The interview ended with nothing recorded. Check that the right microphone is selected and that it is not muted, then try again.', endedBeforeScoring: 'The interview was ended before scoring finished.', - notEnoughCredits: 'Not enough credits', + notEnoughCredits: 'Out of free time', }, actionNames: { @@ -305,7 +305,7 @@ const STRINGS: Record = { nothingRecorded: 'Собеседование закончилось, и ничего не записано. Проверьте, что выбран нужный микрофон и он не выключен, затем попробуйте снова.', endedBeforeScoring: 'Собеседование завершили до того, как закончилась оценка.', - notEnoughCredits: 'Недостаточно кредитов', + notEnoughCredits: 'Бесплатное время закончилось', }, actionNames: { From 5e5acf3609a417c0587fe2b2a8a3d18acfd03627 Mon Sep 17 00:00:00 2001 From: alpha Date: Tue, 13 Oct 2026 23:58:21 -0500 Subject: [PATCH 12/17] fix(state): subscription fields are server-owned and cleared between accounts The renderer could write subscribed / subscriptionExpiresAt / subscriptionPlan through app:update-state, and subscribed overrides the free balance in the start gate. Sign-in, sign-out and an unanswered first ping now reset the whole balance slice, so a subscriber's plan no longer shows for the next account on the machine. Subscribed without an end date reads as not subscribed, so the gate and the display cannot disagree. Co-Authored-By: Claude Opus 5.5 --- src/main/ipc/app-state.ts | 10 +++++++++- src/main/services/auth.service.ts | 5 +++-- src/main/services/health-check.service.ts | 4 ++-- src/main/utils/balance.ts | 19 +++++++++++++++++-- test/balance.test.mjs | 10 ++++++++++ 5 files changed, 41 insertions(+), 7 deletions(-) diff --git a/src/main/ipc/app-state.ts b/src/main/ipc/app-state.ts index 202aa1fe..499b3539 100644 --- a/src/main/ipc/app-state.ts +++ b/src/main/ipc/app-state.ts @@ -14,7 +14,15 @@ import { type AppState } from '../types/app-state.js'; * `FAILURE_INTERVAL`/`SUCCESS_INTERVAL` later) silently overwrote it - an unguarded path onto a * financial field that happened to have no caller today. */ -const SERVER_OWNED_KEYS = ['credits', 'creditsPerMinute', 'userRole'] as const; +const SERVER_OWNED_KEYS = [ + 'credits', + 'creditsPerMinute', + 'userRole', + // `subscribed` overrides `credits` in the start gate, so it needs the same guard. + 'subscribed', + 'subscriptionExpiresAt', + 'subscriptionPlan', +] as const; function stripServerOwnedFields(updates: Partial): Partial { const sanitized = { ...updates }; diff --git a/src/main/services/auth.service.ts b/src/main/services/auth.service.ts index e5f806a6..1cd56a0a 100644 --- a/src/main/services/auth.service.ts +++ b/src/main/services/auth.service.ts @@ -1,5 +1,6 @@ import { AuthApi } from '../api/auth.js'; import { configStore } from '../store/config.store.js'; +import { CLEARED_BALANCE } from '../utils/balance.js'; import { uiStrings } from '../utils/ui-strings.js'; import { accountService } from './account.service.js'; import { appStateService } from './app-state.service.js'; @@ -121,7 +122,7 @@ export class AuthService { configStore.updateConfig({ sessionToken: response.data?.session_token }); // update app state to logged in - appStateService.updateState({ isLoggedIn: true }); + appStateService.updateState({ isLoggedIn: true, ...CLEARED_BALANCE }); // pull this account's synced config (full name, profile, context) from the backend. // Cleared first so a failed pull leaves the config unloaded rather than whatever @@ -163,7 +164,7 @@ export class AuthService { disableStealth(); // clear session token and update app state configStore.updateConfig({ sessionToken: '' }); - appStateService.updateState({ isLoggedIn: false }); + appStateService.updateState({ isLoggedIn: false, ...CLEARED_BALANCE }); accountService.clearState(); // The interview goes with the session it belonged to. The transcript, the suggestions and diff --git a/src/main/services/health-check.service.ts b/src/main/services/health-check.service.ts index 749aa7f0..7349582c 100644 --- a/src/main/services/health-check.service.ts +++ b/src/main/services/health-check.service.ts @@ -5,7 +5,7 @@ import { HealthCheckApi } from '../api/health-check.js'; import { MockInterviewApi } from '../api/mock-interview.js'; -import { balanceState } from '../utils/balance.js'; +import { balanceState, CLEARED_BALANCE } from '../utils/balance.js'; import { safeSleep } from '../utils/sleep.js'; import { uiStrings } from '../utils/ui-strings.js'; import { accountService } from './account.service.js'; @@ -61,7 +61,7 @@ export class HealthCheckService { loggedIn = res.status === 200; appStateService.updateState({ isLoggedIn: loggedIn, - ...(res.data ? balanceState(res.data) : { credits: undefined }), + ...(res.data ? balanceState(res.data) : CLEARED_BALANCE), userRole: res.data?.user_role, providedLLMModel: res.data?.provided_llm_model, // Undefined here means "not answered yet", not free and not the compiled-in default - diff --git a/src/main/utils/balance.ts b/src/main/utils/balance.ts index ba774e86..4c146a2d 100644 --- a/src/main/utils/balance.ts +++ b/src/main/utils/balance.ts @@ -20,10 +20,25 @@ export interface BackendBalance { * `credits`, which is then the real (and only) balance, and nothing about a subscription. */ export function balanceState(data: BackendBalance): Partial { + const expiresAt = data.subscription_expires_at ?? null; return { credits: data.free_credits ?? data.credits, - subscribed: data.subscribed ?? false, - subscriptionExpiresAt: data.subscription_expires_at ?? null, + // Subscribed only with an end date to show: the display and the start gate both need one, and + // they must not disagree about whether this account is unlimited. + subscribed: data.subscribed === true && expiresAt !== null, + subscriptionExpiresAt: expiresAt, subscriptionPlan: data.subscription_plan ?? null, }; } + +/** + * The AppState slice with no account's balance in it: "not answered yet", as at launch. Written on + * sign-in and sign-out so one account's free time and subscription never show for the next one on + * a shared machine while the first ping is still on its way. + */ +export const CLEARED_BALANCE: Partial = { + credits: undefined, + subscribed: undefined, + subscriptionExpiresAt: undefined, + subscriptionPlan: undefined, +}; diff --git a/test/balance.test.mjs b/test/balance.test.mjs index e33119bc..1296b381 100644 --- a/test/balance.test.mjs +++ b/test/balance.test.mjs @@ -36,5 +36,15 @@ export async function run() { check('and reads as not subscribed rather than unknown', legacy.subscribed === false); check('with no plan', legacy.subscriptionPlan === null); + // Subscribed with no end date is not a state the display can show, so it is not subscribed: the + // start gate and the title bar must agree about whether the account is unlimited. + const noEnd = balanceState({ + credits: 0, + free_credits: 0, + subscribed: true, + subscription_expires_at: null, + }); + check('subscribed without an end date reads as not subscribed', noEnd.subscribed === false); + return failures; } From 6d54c7affdaf2bae37c9d895d9365415650834ac Mon Sep 17 00:00:00 2001 From: alpha Date: Tue, 13 Oct 2026 23:58:22 -0500 Subject: [PATCH 13/17] fix(copy): under a minute of free time is not 'no free time left' A refused start said 'No free time left' while the title bar showed time left. One freeTimeLabel now serves the title bar and the home plan line, and the Plans tab and refusal copy say less than a minute. Co-Authored-By: Claude Opus 5.5 --- .../components/custom/credits-display.tsx | 18 +++---------- .../custom/payment/subscription-tab.tsx | 4 ++- src/renderer/i18n/locales/en.ts | 9 ++++--- src/renderer/i18n/locales/ru.ts | 11 +++++--- src/renderer/lib/subscription.ts | 25 +++++++++++++++++++ src/renderer/pages/home/index.tsx | 14 ++++++----- 6 files changed, 52 insertions(+), 29 deletions(-) diff --git a/src/renderer/components/custom/credits-display.tsx b/src/renderer/components/custom/credits-display.tsx index 20832e81..b96db1f6 100644 --- a/src/renderer/components/custom/credits-display.tsx +++ b/src/renderer/components/custom/credits-display.tsx @@ -2,10 +2,11 @@ import React from 'react'; import { useNow } from '@/hooks/use-now'; import { useT } from '@/i18n'; -import { CREDITS_PER_MINUTE } from '@/lib/consts'; +import { minutesCovered } from '@/lib/credit-gate'; import { daysLeft, EXPIRY_WARNING_DAYS, + freeTimeLabel, planKey, type SubscriptionSummary, } from '@/lib/subscription'; @@ -80,19 +81,8 @@ function FreeSummary({ creditsPerMinute?: number; }) { const t = useT(); - const availableMinutes = Math.floor(credits / (creditsPerMinute ?? CREDITS_PER_MINUTE)); - - // Split into hours and minutes here and assembled in the locale, because which plural form - // each number takes is a property of the language: `2 hours 15 mins` against - // `2 часа 15 минут`, where 2 and 22 agree and 5 and 25 do not. - const availableTime = - availableMinutes > 0 - ? t.creditsDisplay.freeLeft( - t.creditsDisplay.duration(Math.floor(availableMinutes / 60), availableMinutes % 60) - ) - : credits > 0 - ? t.creditsDisplay.freeLeft(t.creditsDisplay.lessThanAMinute) - : t.creditsDisplay.noFreeTimeLeft; + const availableMinutes = minutesCovered(credits, creditsPerMinute); + const availableTime = freeTimeLabel(t, credits, creditsPerMinute); return ( <> diff --git a/src/renderer/components/custom/payment/subscription-tab.tsx b/src/renderer/components/custom/payment/subscription-tab.tsx index c4375ad3..6c809e1a 100644 --- a/src/renderer/components/custom/payment/subscription-tab.tsx +++ b/src/renderer/components/custom/payment/subscription-tab.tsx @@ -142,7 +142,9 @@ export default function SubscriptionTab({ ? t.payment.buy.freeLeft( t.payment.buy.duration(Math.floor(freeMinutes / 60), freeMinutes % 60) ) - : t.payment.buy.freeUsedUp} + : credits > 0 + ? t.payment.buy.freeLeft(t.creditsDisplay.lessThanAMinute) + : t.payment.buy.freeUsedUp}
)} diff --git a/src/renderer/i18n/locales/en.ts b/src/renderer/i18n/locales/en.ts index 2148e258..49af0c15 100644 --- a/src/renderer/i18n/locales/en.ts +++ b/src/renderer/i18n/locales/en.ts @@ -238,7 +238,7 @@ export const en = { ready: 'The AI asks, you answer out loud, and you get a scored report at the end.', unsupported: 'Not available on this server yet. Update the app, or try again later.', liveRunning: 'Stop the live assistant first - the two cannot share your microphone.', - unaffordable: 'No free time left. Subscribe to keep practising.', + unaffordable: 'Less than a minute of free time left. Subscribe to keep practising.', }, live: { @@ -247,7 +247,7 @@ export const en = { ready: 'Transcribes your real interview and suggests answers as it happens.', running: 'Your live assistant is already running.', mockRunning: 'Finish the mock interview first - the two cannot share your microphone.', - unaffordable: 'No free time left. Subscribe to keep interviewing.', + unaffordable: 'Less than a minute of free time left. Subscribe to keep interviewing.', }, accountLabel: 'Account', @@ -1144,13 +1144,14 @@ export const en = { /** The one start rule live and mock share: subscribed, or at least a minute of free time. */ creditGate: { - tooLow: 'No free time left', + tooLow: 'Not enough free time to start', outOfCredits: 'Out of free time - the session has ended', outOfCreditsHint: 'Subscribe for unlimited interviews.', lowBalance: (minutes: number) => `About ${minutes} ${minutes === 1 ? 'minute' : 'minutes'} of free time left`, lowBalanceHint: 'The session ends when your free time runs out.', - tooLowHint: 'Subscribe for unlimited interviews on the SOTA model.', + tooLowHint: + 'A session needs at least a minute. Subscribe for unlimited interviews on the SOTA model.', subscribe: 'See plans', }, diff --git a/src/renderer/i18n/locales/ru.ts b/src/renderer/i18n/locales/ru.ts index 7642e2d8..4a267af8 100644 --- a/src/renderer/i18n/locales/ru.ts +++ b/src/renderer/i18n/locales/ru.ts @@ -240,7 +240,8 @@ export const ru: Translation = { unsupported: 'Пока недоступно на этом сервере. Обновите приложение или попробуйте позже.', liveRunning: 'Сначала остановите живого ассистента - они не могут использовать микрофон одновременно.', - unaffordable: 'Бесплатное время закончилось. Оформите подписку, чтобы продолжить тренировки.', + unaffordable: + 'Осталось меньше минуты бесплатного времени. Оформите подписку, чтобы продолжить тренировки.', }, live: { @@ -250,7 +251,8 @@ export const ru: Translation = { running: 'Ассистент уже работает.', mockRunning: 'Сначала завершите пробное собеседование - они не могут использовать микрофон одновременно.', - unaffordable: 'Бесплатное время закончилось. Оформите подписку, чтобы продолжить.', + unaffordable: + 'Осталось меньше минуты бесплатного времени. Оформите подписку, чтобы продолжить.', }, accountLabel: 'Учётная запись', @@ -1151,13 +1153,14 @@ export const ru: Translation = { }, creditGate: { - tooLow: 'Бесплатное время закончилось', + tooLow: 'Недостаточно бесплатного времени для начала', outOfCredits: 'Бесплатное время закончилось - сессия завершена', outOfCreditsHint: 'Оформите подписку для собеседований без ограничений.', lowBalance: (minutes: number) => `Бесплатного времени осталось примерно на ${minutes} ${plural(minutes, 'минуту', 'минуты', 'минут')}`, lowBalanceHint: 'Когда бесплатное время закончится, сессия завершится.', - tooLowHint: 'Оформите подписку для собеседований без ограничений на передовой модели.', + tooLowHint: + 'Для сессии нужна хотя бы минута. Оформите подписку для собеседований без ограничений на передовой модели.', subscribe: 'Тарифы', }, diff --git a/src/renderer/lib/subscription.ts b/src/renderer/lib/subscription.ts index 6ee7c8f0..d6958e25 100644 --- a/src/renderer/lib/subscription.ts +++ b/src/renderer/lib/subscription.ts @@ -1,3 +1,6 @@ +import type { Translation } from '@/i18n'; +import { minutesCovered } from '@/lib/credit-gate'; + /** What the UI needs to know about an account's subscription, read off the ping. */ export interface SubscriptionSummary { subscribed: boolean; @@ -38,3 +41,25 @@ export const EXPIRY_WARNING_DAYS = 3; export function planKey(plan: string | null): 'monthly' | 'yearly' | 'other' { return plan === 'monthly' || plan === 'yearly' ? plan : 'other'; } + +/** + * The free hour left, as the title bar and the home plan line both say it. Split into hours and + * minutes and assembled in the locale, because which plural form each number takes is a property + * of the language. Under a minute but above zero is its own case: "no free time left" would be + * untrue, and is what a refused start used to tell a user the title bar said had time left. + */ +export function freeTimeLabel( + t: Translation, + credits: number, + creditsPerMinute: number | undefined +): string { + const minutes = minutesCovered(credits, creditsPerMinute); + if (minutes > 0) { + return t.creditsDisplay.freeLeft( + t.creditsDisplay.duration(Math.floor(minutes / 60), minutes % 60) + ); + } + return credits > 0 + ? t.creditsDisplay.freeLeft(t.creditsDisplay.lessThanAMinute) + : t.creditsDisplay.noFreeTimeLeft; +} diff --git a/src/renderer/pages/home/index.tsx b/src/renderer/pages/home/index.tsx index 1f0da207..ebdc5bb2 100644 --- a/src/renderer/pages/home/index.tsx +++ b/src/renderer/pages/home/index.tsx @@ -11,8 +11,13 @@ import useAuth from '@/hooks/use-auth'; import { useConfigStore } from '@/hooks/use-config-store'; import { useSaveHistoryGuard } from '@/hooks/use-save-history-guard'; import { useT } from '@/i18n'; -import { canStartSession, minutesCovered } from '@/lib/credit-gate'; -import { planKey, subscriptionOf, type SubscriptionSummary } from '@/lib/subscription'; +import { canStartSession } from '@/lib/credit-gate'; +import { + freeTimeLabel, + planKey, + subscriptionOf, + type SubscriptionSummary, +} from '@/lib/subscription'; import { cn } from '@/lib/utils'; import { RunningState } from '@/types/app-state'; import type { MockInterviewSetup } from '@/types/mock-interview'; @@ -373,8 +378,5 @@ function describePlan( ); } if (credits === undefined) return undefined; - const minutes = minutesCovered(credits, creditsPerMinute); - return minutes > 0 - ? t.creditsDisplay.freeLeft(t.creditsDisplay.duration(Math.floor(minutes / 60), minutes % 60)) - : t.creditsDisplay.noFreeTimeLeft; + return freeTimeLabel(t, credits, creditsPerMinute); } From 84891053ee4f70be75c956afee070c808d696393 Mon Sep 17 00:00:00 2001 From: alpha Date: Wed, 14 Oct 2026 09:45:33 -0500 Subject: [PATCH 14/17] refactor(payment): remove the unused balance fetch and payment poller Nothing called them: the renderer's usePayment().getCredits had no consumer, so the payment:get-credits IPC, its preload bridge and PaymentService.getCredits were reachable only from each other, and PaymentService.pollPaymentStatus had no caller at all. The status tab polls through getStatus, and the ping already refreshes the balance and subscription every few seconds. The PR had ported the dead path to the subscription balance; removing it instead, with the two error strings only it used. Co-Authored-By: Claude Opus 5.5 --- src/main/api/payment.ts | 8 --- src/main/ipc/payment.ts | 10 ---- src/main/preload.cts | 1 - src/main/services/payment.service.ts | 78 ---------------------------- src/main/utils/ui-strings.ts | 12 +---- src/renderer/hooks/use-payment.ts | 20 ------- src/renderer/i18n/locales/en.ts | 1 - src/renderer/i18n/locales/ru.ts | 1 - src/renderer/types/electron-api.d.ts | 1 - 9 files changed, 1 insertion(+), 131 deletions(-) diff --git a/src/main/api/payment.ts b/src/main/api/payment.ts index dfe3be4e..8e349204 100644 --- a/src/main/api/payment.ts +++ b/src/main/api/payment.ts @@ -11,7 +11,6 @@ import { PaymentPlan, PaymentStatusResponse, } from '../types/payment.js'; -import type { BackendBalance } from '../utils/balance.js'; import { ApiClient, ApiResponse } from './client.js'; export class PaymentApi extends ApiClient { @@ -59,11 +58,4 @@ export class PaymentApi extends ApiClient { async getPaymentHistory(): Promise> { return this.get('/api/payment/history'); } - - /** - * Get the current balance and subscription - */ - async getCredits(): Promise> { - return this.get('/api/payment/credits'); - } } diff --git a/src/main/ipc/payment.ts b/src/main/ipc/payment.ts index 890d4a72..1db1aded 100644 --- a/src/main/ipc/payment.ts +++ b/src/main/ipc/payment.ts @@ -71,14 +71,4 @@ export function registerPaymentHandlers(): void { return paymentService.getPaymentHistory(); } ); - - /** - * Get current user credits - */ - ipcMain.handle( - 'payment:get-credits', - async (): Promise<{ success: boolean; credits?: number; error?: string }> => { - return paymentService.getCredits(); - } - ); } diff --git a/src/main/preload.cts b/src/main/preload.cts index e5011579..c13fe03c 100644 --- a/src/main/preload.cts +++ b/src/main/preload.cts @@ -88,7 +88,6 @@ const electronApi = { create: (data: Record) => ipcRenderer.invoke('payment:create', data), getStatus: (paymentId: string) => ipcRenderer.invoke('payment:get-status', paymentId), getHistory: () => ipcRenderer.invoke('payment:get-history'), - getCredits: () => ipcRenderer.invoke('payment:get-credits'), }, appState: { diff --git a/src/main/services/payment.service.ts b/src/main/services/payment.service.ts index c490a48c..b504db4f 100644 --- a/src/main/services/payment.service.ts +++ b/src/main/services/payment.service.ts @@ -12,9 +12,7 @@ import { PaymentStatusResponse, SubscriptionPlanInfo, } from '../types/payment.js'; -import { balanceState } from '../utils/balance.js'; import { uiStrings } from '../utils/ui-strings.js'; -import { appStateService } from './app-state.service.js'; export class PaymentService { private api: PaymentApi; @@ -152,82 +150,6 @@ export class PaymentService { return { success: false, error: uiStrings().paymentErrors.getHistory }; } } - - /** - * Get current user credits - */ - async getCredits(): Promise<{ success: boolean; credits?: number; error?: string }> { - try { - const response = await this.api.getCredits(); - - if (response.error) { - return { - success: false, - error: response.error.message || uiStrings().paymentErrors.getCredits, - }; - } - - // Update app state with the latest balance and subscription - if (response.data?.credits !== undefined) { - appStateService.updateState(balanceState(response.data)); - } - - return { - success: true, - credits: response.data?.free_credits ?? response.data?.credits ?? 0, - }; - } catch (error) { - console.error('[PaymentService] Failed to get credits:', error); - return { success: false, error: uiStrings().paymentErrors.getCredits }; - } - } - - /** - * Poll payment status until it's completed or failed - */ - async pollPaymentStatus( - paymentId: string, - onUpdate?: (status: PaymentStatusResponse) => void, - maxAttempts: number = 60, - intervalMs: number = 5000 - ): Promise<{ success: boolean; data?: PaymentStatusResponse; error?: string }> { - let attempts = 0; - - const poll = async (): Promise<{ - success: boolean; - data?: PaymentStatusResponse; - error?: string; - }> => { - if (attempts >= maxAttempts) { - return { success: false, error: uiStrings().paymentErrors.pollingTimeout }; - } - - attempts++; - const result = await this.getPaymentStatus(paymentId); - - if (!result.success || !result.data) { - return result; - } - - onUpdate?.(result.data); - - // Check if payment is in a final state - const finalStates = ['finished', 'failed', 'refunded', 'expired']; - if (finalStates.includes(result.data.payment_status)) { - // Refresh credits if payment is finished - if (result.data.payment_status === 'finished') { - await this.getCredits(); - } - return result; - } - - // Continue polling - await new Promise((resolve) => setTimeout(resolve, intervalMs)); - return poll(); - }; - - return poll(); - } } export const paymentService = new PaymentService(); diff --git a/src/main/utils/ui-strings.ts b/src/main/utils/ui-strings.ts index 7827f14d..38df5bc5 100644 --- a/src/main/utils/ui-strings.ts +++ b/src/main/utils/ui-strings.ts @@ -77,13 +77,7 @@ export interface UiStrings { /** Same shape again for the payment service, whose every path sets its own message. */ paymentErrors: Record< - | 'getPlans' - | 'getCurrencies' - | 'createPayment' - | 'getStatus' - | 'getHistory' - | 'getCredits' - | 'pollingTimeout', + 'getPlans' | 'getCurrencies' | 'createPayment' | 'getStatus' | 'getHistory', string >; @@ -190,8 +184,6 @@ const STRINGS: Record = { createPayment: 'Failed to create payment', getStatus: 'Failed to get payment status', getHistory: 'Failed to get payment history', - getCredits: 'Failed to load your plan', - pollingTimeout: 'Payment polling timeout', }, suggestionErrors: { @@ -284,8 +276,6 @@ const STRINGS: Record = { createPayment: 'Не удалось создать платёж', getStatus: 'Не удалось получить статус платежа', getHistory: 'Не удалось получить историю платежей', - getCredits: 'Не удалось загрузить данные о тарифе', - pollingTimeout: 'Платёж не подтвердился вовремя', }, suggestionErrors: { diff --git a/src/renderer/hooks/use-payment.ts b/src/renderer/hooks/use-payment.ts index fbe4abcc..dbbf1cb7 100644 --- a/src/renderer/hooks/use-payment.ts +++ b/src/renderer/hooks/use-payment.ts @@ -124,25 +124,6 @@ export function usePayment() { } }, [t]); - // Get current credits - const getCredits = useCallback(async (): Promise => { - try { - setLoading(true); - setError(null); - const result = await window.electronAPI?.payment.getCredits(); - if (result?.success && result.credits !== undefined) { - return result.credits; - } else { - throw new Error(result?.error || t.payment.errors.getCredits); - } - } catch (err) { - setError(err instanceof Error ? err.message : t.payment.errors.getCredits); - return null; - } finally { - setLoading(false); - } - }, [t]); - // Load plans on mount useEffect(() => { getPlans(); @@ -159,6 +140,5 @@ export function usePayment() { createPayment, getPaymentStatus, getPaymentHistory, - getCredits, }; } diff --git a/src/renderer/i18n/locales/en.ts b/src/renderer/i18n/locales/en.ts index 49af0c15..5010586e 100644 --- a/src/renderer/i18n/locales/en.ts +++ b/src/renderer/i18n/locales/en.ts @@ -1109,7 +1109,6 @@ export const en = { createPayment: 'Failed to create payment', getStatus: 'Failed to get payment status', getHistory: 'Failed to get payment history', - getCredits: 'Failed to load your plan', }, }, diff --git a/src/renderer/i18n/locales/ru.ts b/src/renderer/i18n/locales/ru.ts index 4a267af8..2dd91a68 100644 --- a/src/renderer/i18n/locales/ru.ts +++ b/src/renderer/i18n/locales/ru.ts @@ -1116,7 +1116,6 @@ export const ru: Translation = { createPayment: 'Не удалось создать платёж', getStatus: 'Не удалось получить статус платежа', getHistory: 'Не удалось получить историю платежей', - getCredits: 'Не удалось загрузить данные о тарифе', }, }, diff --git a/src/renderer/types/electron-api.d.ts b/src/renderer/types/electron-api.d.ts index bcb1e17c..eca8888b 100644 --- a/src/renderer/types/electron-api.d.ts +++ b/src/renderer/types/electron-api.d.ts @@ -99,7 +99,6 @@ declare global { paymentId: string ) => Promise<{ success: boolean; data?: PaymentStatusResponse; error?: string }>; getHistory: () => Promise<{ success: boolean; data?: PaymentHistory[]; error?: string }>; - getCredits: () => Promise<{ success: boolean; credits?: number; error?: string }>; }; // App state management From b7fa44b549c974fc009c0aded06a13727d23d8b6 Mon Sep 17 00:00:00 2001 From: alpha Date: Wed, 14 Oct 2026 10:06:42 -0500 Subject: [PATCH 15/17] feat(subscription): toast before a pass ends, and when it lapses Passes never renew, and the only warning was the title bar turning "Ends in N days" yellow, which nobody reads until an interview stops at zero free time. useSubscriptionExpiryWarning, mounted in MainFrame so it runs on every route, toasts once at each of 3, 2 and 1 days left (keyed by the expiry, so buying another pass re-arms it) and once when a subscription this session saw active lapses. A lapse before launch and the undefined in between two accounts on sign-out never read as one. Both offer "See plans". SPEC also stops naming /api/payment/credits, which the client no longer calls. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 4 +- SPEC.md | 2 +- src/renderer/components/custom/main-frame.tsx | 4 ++ .../hooks/use-subscription-expiry-warning.ts | 65 +++++++++++++++++++ src/renderer/i18n/locales/en.ts | 10 +++ src/renderer/i18n/locales/ru.ts | 9 +++ 6 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 src/renderer/hooks/use-subscription-expiry-warning.ts diff --git a/CLAUDE.md b/CLAUDE.md index 7e7b3731..0cad7700 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -719,7 +719,9 @@ of it. `useLowBalanceWarning` toasts once at five minutes and once at one, off the ping balance, and never for a subscriber. A subscription within three days of its end shows "Ends in N days" in the title -bar instead of its end date. Sockets +bar instead of its end date, and `useSubscriptionExpiryWarning` (mounted in `MainFrame`) toasts +once at each of three, two and one days left, and once when a subscription this session saw active +lapses - never for one that lapsed before launch, and never across a sign-out. Sockets also carry `kind=mock` and a per-session `client_session_id`, which the backend uses only to label and group its ledger rows. diff --git a/SPEC.md b/SPEC.md index 9786746f..2aa26b67 100644 --- a/SPEC.md +++ b/SPEC.md @@ -111,7 +111,7 @@ Full name, profile/CV, and context are stored on the user's backend account and ### Subscription and Payments -Route: `/payment`. The Plans tab shows the account's plan (the active pass and its end date, or the free time left) and the passes on sale from `/api/payment/subscription-plans`, bought through the same crypto checkout as before. Passes are prepaid and never renew; buying one while subscribed adds its days after the current end date. Plans, the balance and the subscription are always served by the backend (`/api/payment/credits`, plus `free_credits`, `subscribed`, `subscription_expires_at`, `subscription_plan` and `credits_per_minute` on every 5-second `/api/health-check/ping-client`); a failed plan fetch surfaces as an error rather than falling back to stale figures. +Route: `/payment`. The Plans tab shows the account's plan (the active pass and its end date, or the free time left) and the passes on sale from `/api/payment/subscription-plans`, bought through the same crypto checkout as before. Passes are prepaid and never renew; buying one while subscribed adds its days after the current end date. Plans, the balance and the subscription are always served by the backend (`credits`, `free_credits`, `subscribed`, `subscription_expires_at`, `subscription_plan` and `credits_per_minute` on every 5-second `/api/health-check/ping-client`); a failed plan fetch surfaces as an error rather than falling back to stale figures. A toast warns at three, two and one days before a pass ends, and once when it lapses while the app is open. `AppState.credits` is always the free balance: `free_credits` when the backend sends it, else `credits` (`balanceState` in `src/main/utils/balance.ts`). The backend's own `credits` is, for a subscriber, a compatibility figure for clients that predate subscriptions, and must not be shown. `CREDITS_PER_MINUTE` in `renderer/lib/consts.ts` is a fallback mirror only, used while `AppState.creditsPerMinute` is `undefined` - before the first ping answers, or against a backend old enough not to send it - since the real rate is an env-overridable deployment setting rather than a constant. diff --git a/src/renderer/components/custom/main-frame.tsx b/src/renderer/components/custom/main-frame.tsx index 5dacfeec..f9aca2eb 100644 --- a/src/renderer/components/custom/main-frame.tsx +++ b/src/renderer/components/custom/main-frame.tsx @@ -7,6 +7,7 @@ import { useConfigStore } from '@/hooks/use-config-store'; import { MainContainerContext } from '@/hooks/use-main-container'; import { useOnboardingDismissed } from '@/hooks/use-onboarding-dismissed'; import usePointerLockGuard from '@/hooks/use-pointer-lock-guard'; +import { useSubscriptionExpiryWarning } from '@/hooks/use-subscription-expiry-warning'; import { useUiLanguageSync } from '@/i18n'; import type { PushNotification } from '@/types/push-notification'; @@ -24,6 +25,9 @@ export default function MainFrame({ children }: { children: React.ReactNode }) { // is the one component mounted for every route. useUiLanguageSync(); + // Here for the same reason: an expiring subscription is worth saying on whatever screen is up. + useSubscriptionExpiryWarning(); + // Loaded here rather than per page. Routes reached directly - a reload on `/configuration`, the // onboarding gate on `/` - all read the config, and every one of them holding its own fetch is // how one of them ends up not having it. Pages that need a *fresh* read still ask for one. diff --git a/src/renderer/hooks/use-subscription-expiry-warning.ts b/src/renderer/hooks/use-subscription-expiry-warning.ts new file mode 100644 index 00000000..c2148d84 --- /dev/null +++ b/src/renderer/hooks/use-subscription-expiry-warning.ts @@ -0,0 +1,65 @@ +import { useEffect, useRef } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { toast } from 'sonner'; + +import { useAppState } from '@/hooks/use-app-state'; +import { useNow } from '@/hooks/use-now'; +import { useT } from '@/i18n'; +import { daysLeft, EXPIRY_WARNING_DAYS } from '@/lib/subscription'; + +/** + * Toast when a subscription is about to end, and when it has. + * + * Passes are prepaid and never renew, so nothing else will tell the user their time is running + * out: the title bar turns "Ends in N days" yellow, but nobody reads the title bar until something + * stops working. That something is an interview cut off at zero free time. + * + * - **Ending soon**: once per day count, at 3, 2 and 1 days left, keyed by the expiry so buying + * another pass (which moves it) re-arms rather than repeats. Shown again on the next launch, + * since a reminder that only ever fires once is easy to dismiss and forget. + * - **Ended**: once, when this session saw the subscription go from active to lapsed. Never on + * launch for an expiry long past, and never across a sign-out: `CLEARED_BALANCE` sets + * `subscribed` to undefined between accounts, which resets what was seen rather than reading + * as a lapse. + * + * Mounted in `MainFrame`, the one component every route renders, so neither depends on the screen + * the user happens to be on. `useNow` is what crosses a day boundary without a ping changing + * anything. + */ +export function useSubscriptionExpiryWarning() { + const t = useT(); + const navigate = useNavigate(); + const { appState } = useAppState(); + const subscribed = appState?.subscribed; + const expiresAt = appState?.subscriptionExpiresAt ?? null; + const now = useNow(); + + const warned = useRef(new Set()); + const wasSubscribed = useRef(undefined); + + useEffect(() => { + if (subscribed !== true || expiresAt === null) return; + const days = daysLeft(expiresAt, now); + if (days <= 0 || days > EXPIRY_WARNING_DAYS) return; + + const key = `${expiresAt}:${days}`; + if (warned.current.has(key)) return; + warned.current.add(key); + + toast.warning(t.subscriptionExpiry.endingSoon(days), { + description: t.subscriptionExpiry.endingSoonHint, + action: { label: t.creditGate.subscribe, onClick: () => navigate('/payment') }, + }); + }, [subscribed, expiresAt, now, t, navigate]); + + useEffect(() => { + const previous = wasSubscribed.current; + wasSubscribed.current = subscribed; + if (previous !== true || subscribed !== false) return; + + toast.warning(t.subscriptionExpiry.ended, { + description: t.subscriptionExpiry.endedHint, + action: { label: t.creditGate.subscribe, onClick: () => navigate('/payment') }, + }); + }, [subscribed, t, navigate]); +} diff --git a/src/renderer/i18n/locales/en.ts b/src/renderer/i18n/locales/en.ts index 5010586e..94e81579 100644 --- a/src/renderer/i18n/locales/en.ts +++ b/src/renderer/i18n/locales/en.ts @@ -1154,6 +1154,16 @@ export const en = { subscribe: 'See plans', }, + /** Toasts for a subscription about to end, or just ended; see `useSubscriptionExpiryWarning`. */ + subscriptionExpiry: { + endingSoon: (days: number) => + `Your subscription ends in ${days} ${days === 1 ? 'day' : 'days'}`, + endingSoonHint: + 'Buy another pass to keep interviews unlimited. Its days are added after the current end date.', + ended: 'Your subscription has ended', + endedHint: 'Interviews now use your free time on the free model.', + }, + trialNotice: { /** * Split into lead / emphasis / tail rather than one sentence with a `` in it, diff --git a/src/renderer/i18n/locales/ru.ts b/src/renderer/i18n/locales/ru.ts index 2dd91a68..7f80eed6 100644 --- a/src/renderer/i18n/locales/ru.ts +++ b/src/renderer/i18n/locales/ru.ts @@ -1163,6 +1163,15 @@ export const ru: Translation = { subscribe: 'Тарифы', }, + subscriptionExpiry: { + endingSoon: (days: number) => + `Подписка закончится через ${days} ${plural(days, 'день', 'дня', 'дней')}`, + endingSoonHint: + 'Купите новый срок, чтобы собеседования оставались без ограничений. Его дни добавятся после текущей даты окончания.', + ended: 'Подписка закончилась', + endedHint: 'Собеседования снова расходуют бесплатное время на базовой модели.', + }, + trialNotice: { freeTierLead: 'Бесплатный тариф: 1 час собеседований на модели ', freeTier: 'базового уровня', From d3a98fff1db69995f0fc89b790a92415eec3671e Mon Sep 17 00:00:00 2001 From: alpha Date: Wed, 14 Oct 2026 10:24:19 -0500 Subject: [PATCH 16/17] docs: the mock setup estimate is minutes only, plus free time left on the free hour Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0cad7700..10882990 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -696,8 +696,8 @@ hour. The home cards, the live control panel and the mock setup form all use it to the subscription page. The backend refuses only at zero, because it cannot tell a new session from a reconnect inside one; this side can, so the minimum lives here. An unknown balance (before the first ping) is let through -rather than greying both cards out at launch. The mock setup screen gives an estimate in minutes and -credits and says how far the balance goes, rather than disabling lengths. +rather than greying both cards out at launch. The mock setup screen gives an estimate in minutes and, +on the free hour, says how far the free time goes, rather than disabling lengths. **The backend ends a socket at zero with close code 4402** (`WS_CLOSE_INSUFFICIENT_CREDITS`), and `AudioWsStream` must never reconnect on it - a reconnect is refused the same way, and the backoff From 15149fad0a918a6c749859af2e079238a913bd82 Mon Sep 17 00:00:00 2001 From: alpha Date: Wed, 14 Oct 2026 11:03:21 -0500 Subject: [PATCH 17/17] test(subscription): pin the conditions that keep the expiry toasts honest Source-level, like interview-lock.test.mjs, because each condition reads like something to simplify and none breaks a type or a lint: "ended" fires only on an active-to-lapsed transition (not on launch, not across sign-out), "ending soon" is keyed by expiry and day count, and the hook stays mounted in MainFrame so a lapse on any route is announced. Co-Authored-By: Claude Opus 5.5 --- test/run.mjs | 1 + test/subscription-expiry-warning.test.mjs | 46 +++++++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 test/subscription-expiry-warning.test.mjs diff --git a/test/run.mjs b/test/run.mjs index 7fc10ab4..fd9c44d2 100644 --- a/test/run.mjs +++ b/test/run.mjs @@ -94,6 +94,7 @@ for (const module of [ // Source-level and independent of everything above: it reads the renderer's navigation lock // and the mock route off disk, so it sits with the other source-level checks. './interview-lock.test.mjs', + './subscription-expiry-warning.test.mjs', // Source-level too, and next to the lock for the same reason: it reads which component owns // the signed-out redirect off disk. './signed-out-redirect.test.mjs', diff --git a/test/subscription-expiry-warning.test.mjs b/test/subscription-expiry-warning.test.mjs new file mode 100644 index 00000000..f8bab7ea --- /dev/null +++ b/test/subscription-expiry-warning.test.mjs @@ -0,0 +1,46 @@ +/** + * The subscription expiry toasts are renderer code, so these are source-level checks in the same + * shape as `interview-lock.test.mjs`. What they pin are the conditions that keep a toast honest, + * each of which a tidy-up could loosen with no symptom a type checker or a linter can see: + * + * - "Ended" fires only on a transition this session saw, active to lapsed. Reading `!subscribed` + * alone would announce a lapse to every free user on launch, and to everyone on sign-out, since + * `CLEARED_BALANCE` writes `undefined` between accounts. + * - "Ending soon" is keyed by the expiry *and* the day count. Keyed by the day count alone, a + * second pass bought inside the window would never warn about its own end; keyed by the expiry + * alone, the 2-day and 1-day reminders would never fire. + * - The hook is mounted in `MainFrame`, the one component every route renders. Mounted on a page, + * a subscription lapsing while the user is elsewhere goes unannounced. + */ +import { codeOnly, createChecker, readSource } from './helpers.mjs'; + +export async function run() { + const { check, failures } = createChecker('subscription-expiry-warning'); + + const hook = codeOnly( + readSource(new URL('../src/renderer/hooks/use-subscription-expiry-warning.ts', import.meta.url)) + ); + const frame = codeOnly( + readSource(new URL('../src/renderer/components/custom/main-frame.tsx', import.meta.url)) + ); + + check('MainFrame mounts the expiry warning', frame.includes('useSubscriptionExpiryWarning()')); + + check( + '"ended" needs the previous value to have been true and the new one false', + /previous\s*!==\s*true\s*\|\|\s*subscribed\s*!==\s*false/.test(hook) + ); + check( + 'the previous value is recorded before that test', + hook.includes('wasSubscribed.current = subscribed') + ); + + check('"ending soon" only warns an active subscription', hook.includes('subscribed !== true')); + check('and is keyed by the expiry and the day count', /`\$\{expiresAt\}:\$\{days\}`/.test(hook)); + check( + 'within the warning window and not after the end', + hook.includes('days <= 0') && hook.includes('days > EXPIRY_WARNING_DAYS') + ); + + return failures; +}