mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
The video uploader threw the API error away and toasted the bare message, so the one caller most likely to hit the trial storage ceiling was the one that lost the way out of it. Route it through toastApiError, which keeps the error code and attaches the action, and repeat the link in the queue row so it survives the toast timing out. The button now says Upgrade rather than See plans, matching the verb the message itself uses.
112 lines
3.7 KiB
TypeScript
112 lines
3.7 KiB
TypeScript
// What a failed API call is worth showing the person who made it.
|
|
//
|
|
// Every route answers with `{ error, code? }`, and the code is the part that
|
|
// says whether there is anything the caller can do. Losing it on the way to a
|
|
// toast is how a full trial account ends up reading "Failed to upload" and
|
|
// trying the same upload again.
|
|
|
|
import { toast } from 'sonner';
|
|
|
|
/** The machine-readable half of an error response. Mirrors ErrorCode in lib/api-response.ts. */
|
|
export const API_ERROR_CODES = {
|
|
/** Out of room because the account has not subscribed, not because the plan is full. */
|
|
TRIAL_STORAGE_LIMIT_EXCEEDED: 'TRIAL_STORAGE_LIMIT_EXCEEDED',
|
|
} as const;
|
|
|
|
export interface ApiErrorPayload {
|
|
error?: string;
|
|
code?: string;
|
|
}
|
|
|
|
/**
|
|
* A failure that came back from our own API, with the code still attached.
|
|
*
|
|
* The upload helpers throw rather than return, so without this the code is gone
|
|
* by the time anything is in a position to show it.
|
|
*/
|
|
export class ApiRequestError extends Error {
|
|
readonly code: string | null;
|
|
|
|
constructor(message: string, code?: string | null) {
|
|
super(message);
|
|
this.name = 'ApiRequestError';
|
|
this.code = code ?? null;
|
|
}
|
|
}
|
|
|
|
/** Builds the error to throw from a parsed `{ error, code }` body. */
|
|
export function apiRequestError(
|
|
payload: ApiErrorPayload | null,
|
|
fallback: string
|
|
): ApiRequestError {
|
|
return new ApiRequestError(payload?.error || fallback, payload?.code);
|
|
}
|
|
|
|
/**
|
|
* Whether this failure is the trial ceiling, for callers that draw the way out
|
|
* themselves rather than handing it to `toastApiError`.
|
|
*/
|
|
export function isTrialStorageError(source: unknown): boolean {
|
|
return codeOf(source) === API_ERROR_CODES.TRIAL_STORAGE_LIMIT_EXCEEDED;
|
|
}
|
|
|
|
function codeOf(source: unknown): string | null {
|
|
if (source instanceof ApiRequestError) return source.code;
|
|
if (source && typeof source === 'object' && 'code' in source) {
|
|
const code = (source as ApiErrorPayload).code;
|
|
return typeof code === 'string' ? code : null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function messageOf(source: unknown, fallback: string): string {
|
|
if (source instanceof Error) return source.message || fallback;
|
|
if (source && typeof source === 'object' && 'error' in source) {
|
|
const message = (source as ApiErrorPayload).error;
|
|
if (typeof message === 'string' && message) return message;
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
export interface ToastApiErrorOptions {
|
|
/** Prepended as `${prefix}: ${message}`, for per-file failures in a batch. */
|
|
prefix?: string;
|
|
}
|
|
|
|
/**
|
|
* Shows an API failure, with a way out attached when there is one.
|
|
*
|
|
* Takes either a parsed response body or a thrown error, so the same call works
|
|
* whether the caller checked `res.ok` itself or caught what an upload helper
|
|
* threw. On the trial storage ceiling it adds a link to the billing settings,
|
|
* because subscribing is the only thing that makes the upload possible and the
|
|
* uploader has no way to know that from "storage limit exceeded".
|
|
*/
|
|
export function toastApiError(
|
|
source: unknown,
|
|
fallback: string,
|
|
options: ToastApiErrorOptions = {}
|
|
): void {
|
|
const message = messageOf(source, fallback);
|
|
const text = options.prefix ? `${options.prefix}: ${message}` : message;
|
|
|
|
if (isTrialStorageError(source)) {
|
|
toast.error(text, {
|
|
// Longer than a plain error: this one is asking for a decision rather than
|
|
// just reporting, and it disappears under the cursor at the usual timing.
|
|
duration: 12000,
|
|
action: {
|
|
// The message ends in "Upgrade to get 200 GB", so the button is the verb
|
|
// that sentence just used rather than a second name for the same thing.
|
|
label: 'Upgrade',
|
|
onClick: () => {
|
|
window.location.href = '/settings';
|
|
},
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
toast.error(text);
|
|
}
|