fix(upload): give a full trial account the upgrade link where it reads the refusal

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.
This commit is contained in:
2026-08-18 11:57:17 +03:00
parent 09af61b531
commit f9f08a021d
2 changed files with 34 additions and 5 deletions
+22 -3
View File
@@ -1,6 +1,7 @@
'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { CheckCircle2, Loader2, UploadCloud, XCircle } from 'lucide-react';
import { toast } from 'sonner';
@@ -22,6 +23,7 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
import { isTrialStorageError, toastApiError } from '@/lib/client/api-error';
import {
cleanupPendingProjectUpload,
getDefaultTitleFromFile,
@@ -46,6 +48,8 @@ type QueueItem = {
status: QueueItemStatus;
progress: number;
error?: string;
/** The failure was the trial ceiling, so the row shows the way out. */
errorIsTrialLimit?: boolean;
};
interface VideoDragDropUploaderProps {
@@ -306,12 +310,17 @@ export function VideoDragDropUploader({
failCount += 1;
const message = error instanceof Error ? error.message : 'Failed to upload video';
const isTrialLimit = isTrialStorageError(error);
setQueue((prev) =>
prev.map((entry) =>
entry.id === item.id ? { ...entry, status: 'error', error: message } : entry
entry.id === item.id
? { ...entry, status: 'error', error: message, errorIsTrialLimit: isTrialLimit }
: entry
)
);
toast.error(`${item.file.name}: ${message}`);
// Keeps the error code alive to the toast: a trial account that has run
// out of room is shown the plan rather than just told the upload failed.
toastApiError(error, 'Failed to upload video', { prefix: item.file.name });
}
}
@@ -500,7 +509,17 @@ export function VideoDragDropUploader({
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{item.file.name}</p>
{item.error ? (
<p className="truncate text-xs text-destructive">{item.error}</p>
<p className="text-xs text-destructive">
<span className="align-middle">{item.error}</span>
{item.errorIsTrialLimit && (
<Link
href="/settings"
className="ml-1 align-middle font-medium underline underline-offset-2"
>
Upgrade
</Link>
)}
</p>
) : (
<p className="truncate text-xs text-muted-foreground">
{getDefaultTitleFromFile(item.file)}
+12 -2
View File
@@ -42,6 +42,14 @@ export function 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) {
@@ -82,13 +90,15 @@ export function toastApiError(
const message = messageOf(source, fallback);
const text = options.prefix ? `${options.prefix}: ${message}` : message;
if (codeOf(source) === API_ERROR_CODES.TRIAL_STORAGE_LIMIT_EXCEEDED) {
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: {
label: 'See plans',
// 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';
},