Compare commits

...
Author SHA1 Message Date
Tanish Manku b8db60365b artcraft: surface credits-fetch state on top bar coin + 60s poll
Customers occasionally saw "0 Credits" with no signal that the fetch
had failed silently. Add a status badge on the coin (slow/failed/
recovered, hidden in the happy path), a 60s background refresh, and
gate the initial fetch on AUTH_STATUS.LOGGED_IN to remove the 1s
setTimeout band-aid that was racing intermediate auth states.
2026-04-30 16:22:50 -07:00
2 changed files with 303 additions and 144 deletions
@@ -11,14 +11,26 @@ import {
faGrid2,
faImages,
faCalculator,
faExclamation,
faCheck,
} from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { signal } from "@preact/signals-react";
import { useSignals } from "@preact/signals-react/runtime";
import { FilterMediaClasses } from "@storyteller/api";
import { getCreatorIcon, ModelCreator, IMAGE_MODELS_BY_ID, VIDEO_MODELS_BY_ID, CommonAspectRatio, CommonResolution } from "@storyteller/model-list";
import { useClassyModelSelectorStore, ModelPage } from "@storyteller/ui-model-selector";
import { useCreditsState } from "@storyteller/credits";
import {
getCreatorIcon,
ModelCreator,
IMAGE_MODELS_BY_ID,
VIDEO_MODELS_BY_ID,
CommonAspectRatio,
CommonResolution,
} from "@storyteller/model-list";
import {
useClassyModelSelectorStore,
ModelPage,
} from "@storyteller/ui-model-selector";
import { useCreditsState, type CreditsIconStatus } from "@storyteller/credits";
import { gtagEvent } from "@storyteller/google-analytics";
import { ProviderBillingModal } from "@storyteller/provider-billing-modal";
import { ProviderSetupModal } from "@storyteller/provider-setup-modal";
@@ -51,9 +63,14 @@ import {
useCostBreakdownModalStore,
CreditsModal,
} from "@storyteller/ui-pricing-modal";
import { RefImage, RefVideo, RefAudio, usePromptImageStore, usePromptVideoStore } from "@storyteller/ui-promptbox";
import {
RefImage,
RefVideo,
RefAudio,
usePromptImageStore,
usePromptVideoStore,
} from "@storyteller/ui-promptbox";
import type { Prompts } from "@storyteller/api";
import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
import { SettingsModal } from "@storyteller/ui-settings-modal";
import { Tooltip } from "@storyteller/ui-tooltip";
import { useEffect, useRef, useState } from "react";
@@ -70,6 +87,7 @@ import { useImageTo3DStore } from "~/pages/PageImageTo3DObject/ImageTo3DStore";
import { useImageTo3DWorldStore } from "~/pages/PageImageTo3DWorld/ImageTo3DWorldStore";
import { useRemoveBackgroundStore } from "~/pages/PageRemoveBackground/RemoveBackgroundStore";
import { TabId, useTabStore } from "~/pages/Stores/TabState";
import { AUTH_STATUS } from "~/enums";
import { authentication } from "~/signals";
import { setLogoutStates } from "~/signals/authentication/utilities";
import type { BaseSelectorImage } from "@storyteller/ui-pagedraw";
@@ -97,6 +115,7 @@ type SettingsSection =
| "billing";
const SWITCHER_THROTTLE_TIME = 500; // milliseconds
const CREDITS_POLL_INTERVAL = 60_000; // milliseconds
// NB: See `TabState` for the default tab.
const appMenuTabs: MenuIconItem[] = [
@@ -123,6 +142,74 @@ const appMenuTabs: MenuIconItem[] = [
export const topNavMediaId = signal<string>("");
export const topNavMediaUrl = signal<string>("");
const CreditsCoinWithStatus = ({
iconStatus,
}: {
iconStatus: CreditsIconStatus;
}) => {
const showBadge = iconStatus !== "hidden";
const badgeColorClass =
iconStatus === "failed"
? "bg-red text-white"
: iconStatus === "recovered"
? "bg-emerald-500 text-white"
: "bg-amber-400 text-black"; // 'slow'
const badgeIconDef = iconStatus === "recovered" ? faCheck : faExclamation;
const tooltipMessage =
iconStatus === "failed"
? "Couldn't refresh your balance."
: iconStatus === "recovered"
? "Balance up to date."
: "Refreshing your balance — current amount may not be up to date.";
const showRetry = iconStatus === "slow" || iconStatus === "failed";
const handleRetry = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
console.log("TopBar: Retrying credits fetch");
void useCreditsState.getState().fetchFromServer();
};
return (
<Tooltip
position="bottom"
interactive
disabled={!showBadge}
content={
<div className="flex max-w-[220px] flex-col gap-2 text-xs text-base-fg">
<span>{tooltipMessage}</span>
{showRetry && (
<Button
variant="secondary"
className="h-7 self-start px-2 text-xs"
onClick={handleRetry}
>
Retry
</Button>
)}
</div>
}
>
<span className="relative inline-flex">
<FontAwesomeIcon icon={faCoins} className="text-primary" />
{showBadge && (
<span
className={`absolute -right-1.5 -top-1.5 flex h-3 w-3 items-center justify-center rounded-full ring-1 ring-ui-background ${badgeColorClass}`}
>
<FontAwesomeIcon icon={badgeIconDef} className="text-[7px]" />
</span>
)}
</span>
</Tooltip>
);
};
export const TopBar = ({ pageName }: Props) => {
useSignals();
@@ -157,39 +244,33 @@ export const TopBar = ({ pageName }: Props) => {
const [disableSwitcher, setDisableSwitcher] = useState(false);
const switcherThrottle = useRef(false);
const creditsStore = useCreditsState();
const sumTotalCredits = creditsStore.totalCredits;
const sumTotalCredits = useCreditsState((s) => s.totalCredits);
const creditsIconStatus = useCreditsState((s) => s.iconStatus);
// Just calling this function kills the app:
const subscriptionStore = useSubscriptionState();
const hasPaidPlan = subscriptionStore.hasPaidPlan();
const [isCreditsLoading, setIsCreditsLoading] = useState(true);
// Fetch credits + subscription on entering LOGGED_IN, then poll credits every
// 60s. Reading via getState() inside the effect keeps the dep array honest
// (the only real dep is the auth status). Earlier versions had a 1s setTimeout
// band-aid to outrun intermediate auth states; gating on LOGGED_IN replaces it.
const authStatus = authentication.status.value;
useEffect(() => {
const t = setTimeout(() => {
Promise.all([
creditsStore.fetchFromServer(),
subscriptionStore.fetchFromServer(),
])
}, 1000);
return () => clearTimeout(t);
}, []);
if (authStatus !== AUTH_STATUS.LOGGED_IN) return;
// Re-fetch credits & subscription whenever the user logs in and out
useEffect(() => {
setIsCreditsLoading(true);
const t = setTimeout(() => {
Promise.all([
creditsStore.fetchFromServer(),
subscriptionStore.fetchFromServer(),
]).finally(() => setIsCreditsLoading(false));
}, 1000); // 1 second delay before fetching to make sure credits and subscription info loads
return () => clearTimeout(t);
}, [authentication.status.value]);
void useCreditsState.getState().fetchFromServer();
void useSubscriptionState.getState().fetchFromServer();
const interval = setInterval(() => {
void useCreditsState.getState().fetchFromServer();
console.log("TopBar: Polled credits");
}, CREDITS_POLL_INTERVAL);
return () => clearInterval(interval);
}, [authStatus]);
useCreditsBalanceChangedEvent(async () => {
creditsStore.fetchFromServer();
useCreditsState.getState().fetchFromServer();
});
useSubscriptionPlanChangedEvent(async () => {
@@ -296,10 +377,18 @@ export const TopBar = ({ pageName }: Props) => {
endFrameImage = { ...base, file: new File([], "recreate-ref") };
break;
case "vid_ref":
vidRefs.push({ ...base, file: new File([], "recreate-ref"), duration: 0 });
vidRefs.push({
...base,
file: new File([], "recreate-ref"),
duration: 0,
});
break;
case "audioref":
audioRefs.push({ ...base, file: new File([], "recreate-ref"), duration: 0 });
audioRefs.push({
...base,
file: new File([], "recreate-ref"),
duration: 0,
});
break;
default:
// imgref, imgref_character, imgref_style, imgref_bg, imgsrc, vid_start_frame, imgmask
@@ -310,7 +399,8 @@ export const TopBar = ({ pageName }: Props) => {
// Determine input mode from generation_mode or context image semantics
const hasKeyframeSemantics = contextImages.some(
(ci) => ci.semantic === "vid_start_frame" || ci.semantic === "vid_end_frame",
(ci) =>
ci.semantic === "vid_start_frame" || ci.semantic === "vid_end_frame",
);
const inputMode: "keyframe" | "reference" =
promptData.maybe_generation_mode === "keyframe" || hasKeyframeSemantics
@@ -383,10 +473,14 @@ export const TopBar = ({ pageName }: Props) => {
}
if (imgRefs.length > 0) imageStore.setReferenceImages(imgRefs);
if (promptData.maybe_aspect_ratio) {
imageStore.setCommonAspectRatio(promptData.maybe_aspect_ratio as CommonAspectRatio);
imageStore.setCommonAspectRatio(
promptData.maybe_aspect_ratio as CommonAspectRatio,
);
}
if (promptData.maybe_resolution) {
imageStore.setCommonResolution(promptData.maybe_resolution as CommonResolution);
imageStore.setCommonResolution(
promptData.maybe_resolution as CommonResolution,
);
}
if (promptData.maybe_model_type) {
@@ -604,97 +698,91 @@ export const TopBar = ({ pageName }: Props) => {
<div className="flex justify-end gap-2" data-tauri-drag-region>
<div className="no-drag flex items-center gap-1.5">
{isCreditsLoading ? (
<LoadingSpinner className="mx-2 text-base-fg/50" />
) : (
<>
<PopoverMenu
position="bottom"
align="center"
triggerIcon={
<FontAwesomeIcon icon={faCoins} className="text-primary" />
}
triggerLabel={
<span className="whitespace-nowrap text-sm font-medium">
{sumTotalCredits} Credits
<PopoverMenu
position="bottom"
align="center"
triggerIcon={
<CreditsCoinWithStatus iconStatus={creditsIconStatus} />
}
triggerLabel={
<span className="whitespace-nowrap text-sm font-medium">
{sumTotalCredits} Credits
</span>
}
buttonClassName="h-[30px] px-2 ps-1.5 bg-transparent hover:bg-ui-controls/30 border-0 shadow-none"
panelClassName="mt-3 bg-ui-panel border border-ui-panel-border text-base-fg"
>
{(close) => (
<div className="w-72 p-2.5 text-base-fg">
<div className="mb-2 flex items-center justify-between">
<span className="flex items-center gap-1.5 text-sm font-medium text-base-fg/80">
Your credit balance
</span>
}
buttonClassName="h-[30px] px-2 ps-1.5 bg-transparent hover:bg-ui-controls/30 border-0 shadow-none"
panelClassName="mt-3 bg-ui-panel border border-ui-panel-border text-base-fg"
>
{(close) => (
<div className="w-72 p-2.5 text-base-fg">
<div className="mb-2 flex items-center justify-between">
<span className="flex items-center gap-1.5 text-sm font-medium text-base-fg/80">
Your credit balance
</span>
<button
className="text-sm font-medium text-primary-400 transition-all hover:text-primary-300"
onClick={() => {
close();
toggleCreditsModal();
}}
>
Buy credits
</button>
</div>
<div className="flex items-center gap-2 text-4xl font-bold text-base-fg">
<FontAwesomeIcon
icon={faCoins}
className="text-2xl text-primary"
/>
{sumTotalCredits}
</div>
<button
className="text-sm font-medium text-primary-400 transition-all hover:text-primary-300"
onClick={() => {
close();
toggleCreditsModal();
}}
>
Buy credits
</button>
</div>
<div className="flex items-center gap-2 text-4xl font-bold text-base-fg">
<FontAwesomeIcon
icon={faCoins}
className="text-2xl text-primary"
/>
{sumTotalCredits}
</div>
<button
className="mt-2 flex items-center gap-1.5 text-xs text-base-fg/50 transition-colors hover:text-primary"
onClick={() => {
close();
useCostBreakdownModalStore.getState().openModal();
}}
>
<FontAwesomeIcon icon={faCalculator} />
Cost calculator
</button>
<div className="mt-3 flex gap-2">
<Button
variant="action"
className="h-9 grow"
onClick={() => {
close();
handleOpenBillingSettings();
}}
>
See details
</Button>
<Button
variant="primary"
className="h-9 grow"
onClick={() => {
close();
toggleSubscriptionModal();
}}
icon={faGem}
>
Support
</Button>
</div>
</div>
)}
</PopoverMenu>
{!hasPaidPlan && (
<Button
variant="primary"
icon={faGem}
onClick={toggleSubscriptionModal}
className="h-[38px] shadow-md shadow-primary-500/50 transition-all duration-300 hover:shadow-md hover:shadow-primary-500/75"
<button
className="mt-2 flex items-center gap-1.5 text-xs text-base-fg/50 transition-colors hover:text-primary"
onClick={() => {
close();
useCostBreakdownModalStore.getState().openModal();
}}
>
Support
</Button>
)}
</>
<FontAwesomeIcon icon={faCalculator} />
Cost calculator
</button>
<div className="mt-3 flex gap-2">
<Button
variant="action"
className="h-9 grow"
onClick={() => {
close();
handleOpenBillingSettings();
}}
>
See details
</Button>
<Button
variant="primary"
className="h-9 grow"
onClick={() => {
close();
toggleSubscriptionModal();
}}
icon={faGem}
>
Support
</Button>
</div>
</div>
)}
</PopoverMenu>
{!hasPaidPlan && (
<Button
variant="primary"
icon={faGem}
onClick={toggleSubscriptionModal}
className="h-[38px] shadow-md shadow-primary-500/50 transition-all duration-300 hover:shadow-md hover:shadow-primary-500/75"
>
Support
</Button>
)}
<UploadImagesButton className="h-[38px] w-[38px]" />
@@ -1,6 +1,11 @@
import { create } from 'zustand'
import { ArtcraftGetCredits } from "@storyteller/tauri-api";
const SLOW_FETCH_MS = 3000;
const RECOVERED_HOLD_MS = 1000;
export type CreditsIconStatus = 'hidden' | 'slow' | 'failed' | 'recovered';
export interface CreditsState {
// Daily free credits (if/when we offer them)
freeCredits: number,
@@ -13,6 +18,9 @@ export interface CreditsState {
// Total credits available
totalCredits: number,
// Drives the status badge on the credits coin in TopBar.
iconStatus: CreditsIconStatus,
}
export type CreditsActions = {
@@ -22,31 +30,94 @@ export type CreditsActions = {
reset: () => void
}
export const useCreditsState = create<CreditsState & CreditsActions>((set) => ({
freeCredits: 0,
monthlyCredits: 0,
bankedCredits: 0,
totalCredits: 0,
export const useCreditsState = create<CreditsState & CreditsActions>((set, get) => {
let token = 0;
let slowTimer: ReturnType<typeof setTimeout> | null = null;
let recoveredTimer: ReturnType<typeof setTimeout> | null = null;
reset: () => set({ freeCredits: 0, monthlyCredits: 0, bankedCredits: 0, totalCredits: 0 }),
const clearSlowTimer = () => {
if (slowTimer) { clearTimeout(slowTimer); slowTimer = null; }
};
const clearRecoveredTimer = () => {
if (recoveredTimer) { clearTimeout(recoveredTimer); recoveredTimer = null; }
};
// Call to fetch credits from the server
fetchFromServer: async () => {
let data;
try {
data = await ArtcraftGetCredits();
} catch (error) {
console.error("Error fetching credits", error);
return;
}
console.log("Fetched credits from server: ", data);
if (!!data.payload) {
set((state) => ({
return {
freeCredits: 0,
monthlyCredits: 0,
bankedCredits: 0,
totalCredits: 0,
iconStatus: 'hidden',
reset: () => {
token += 1;
clearSlowTimer();
clearRecoveredTimer();
set({
freeCredits: 0,
monthlyCredits: 0,
bankedCredits: 0,
totalCredits: 0,
iconStatus: 'hidden',
});
},
fetchFromServer: async () => {
const myToken = ++token;
clearSlowTimer();
clearRecoveredTimer();
// The recovered window has served its purpose; start the new cycle clean.
// Preserve visible 'slow' / 'failed' so the icon doesn't flicker while the
// new fetch is in flight.
if (get().iconStatus === 'recovered') {
set({ iconStatus: 'hidden' });
}
slowTimer = setTimeout(() => {
if (myToken === token) {
set({ iconStatus: 'slow' });
}
}, SLOW_FETCH_MS);
let data;
try {
data = await ArtcraftGetCredits();
} catch (error) {
console.error("Error fetching credits", error);
if (myToken !== token) return;
clearSlowTimer();
set({ iconStatus: 'failed' });
return;
}
if (myToken !== token) return;
clearSlowTimer();
console.log("Fetched credits from server: ", data);
if (!data?.payload) {
set({ iconStatus: 'failed' });
return;
}
const prior = get().iconStatus;
const wasProblematic = prior === 'slow' || prior === 'failed';
set({
freeCredits: data.payload.free_credits,
monthlyCredits: data.payload.monthly_credits,
bankedCredits: data.payload.banked_credits,
totalCredits: data.payload.sum_total_credits,
}));
}
}
}))
iconStatus: wasProblematic ? 'recovered' : 'hidden',
});
if (wasProblematic) {
recoveredTimer = setTimeout(() => {
if (get().iconStatus === 'recovered') {
set({ iconStatus: 'hidden' });
}
recoveredTimer = null;
}, RECOVERED_HOLD_MS);
}
},
};
});