credits ui - topbar and settings modal (#804)

- new progress bar and progress circle component
- clicking the credits in the top opens the settings modal on the "Plans
& Credits" tab
This commit is contained in:
BFlat
2025-08-31 11:06:29 +07:00
committed by GitHub
parent 4aa91af67e
commit 27d6794d22
19 changed files with 543 additions and 163 deletions
@@ -35,7 +35,6 @@ import {
import { signal } from "@preact/signals-react";
import { useSignals } from "@preact/signals-react/runtime";
import { setLogoutStates } from "~/signals/authentication/utilities";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { TabId, useTabStore } from "~/pages/Stores/TabState";
import {
@@ -43,7 +42,6 @@ import {
is3DSceneLoaded,
set3DPageMounted,
} from "~/pages/PageEnigma/Editor/editor";
// import { usePricingModalStore } from "@storyteller/ui-pricing-modal"; - Uncomment for pricing modal - BFlat
import toast from "react-hot-toast";
import { gtagEvent } from "@storyteller/google-analytics";
import {
@@ -55,12 +53,22 @@ import { BaseSelectorImage } from "../../../pages/PageEdit/BaseImageSelector";
import { ProviderSetupModal } from "@storyteller/provider-setup-modal";
import { ProviderBillingModal } from "@storyteller/provider-billing-modal";
import { usePricingModalStore } from "@storyteller/ui-pricing-modal";
// import { ProgressCircle } from "@storyteller/ui-progress"; // Uncomment this for Credits indicator - BFlat
interface Props {
pageName: string;
loginSignUpPressed: () => void;
}
// Settings section type to match the SettingsModal component
type SettingsSection =
| "general"
| "accounts"
| "alerts"
| "about"
| "provider_priority"
| "billing";
const SWITCHER_THROTTLE_TIME = 500; // milliseconds
// NB: See `TabState` for the default tab.
@@ -108,6 +116,18 @@ export const TopBar = ({ pageName, loginSignUpPressed }: Props) => {
useSignals();
const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false);
const [settingsSection, setSettingsSection] =
useState<SettingsSection>("general");
// Credit state (replace with real data fetch)
const [credits, setCredits] = useState({
used: 800,
total: 1000,
});
const creditsRemaining = credits.total - credits.used;
const creditsRemainingPercentage = (creditsRemaining / credits.total) * 100;
const isLowCredit = creditsRemainingPercentage < 20; // Less than 20% credits remaining
const { isDesktop, isMaximized, minimize, toggleMaximize, close } =
useTauriWindowControls();
@@ -119,6 +139,16 @@ export const TopBar = ({ pageName, loginSignUpPressed }: Props) => {
gtagEvent("open_gallery_modal", { tab: tabStore.activeTabId });
};
// Force recreation of the modal when switching to billing
const handleOpenBillingSettings = () => {
setIsSettingsModalOpen(false);
setTimeout(() => {
setSettingsSection("billing");
setIsSettingsModalOpen(true);
gtagEvent("open_billing_settings");
}, 50);
};
const tabStore = useTabStore();
const is3DSceneReady = is3DSceneLoaded.value;
@@ -263,33 +293,60 @@ export const TopBar = ({ pageName, loginSignUpPressed }: Props) => {
</div>
<div className="flex justify-end gap-2" data-tauri-drag-region>
<div className="no-drag flex gap-2">
{/* - Uncomment for pricing modal - BFlat */}
<div className="no-drag flex items-center gap-1.5">
{/* Uncomment this for Credits indicator - BFlat */}
{/* <Tooltip
content={`${creditsRemaining}/${credits.total} credits remaining`}
position="bottom"
delay={300}
>
<Button
variant="ghost"
className="h-[30px] px-2 ps-1.5"
onClick={handleOpenBillingSettings}
>
<ProgressCircle
value={creditsRemainingPercentage}
isLow={isLowCredit}
size="small"
/>
<span className="whitespace-nowrap text-sm font-medium">
{creditsRemaining} Credits
</span>
</Button>
</Tooltip> */}
<Button
variant="primary"
icon={faGem}
onClick={toggleModal}
className="shadow-md shadow-primary-500/50 transition-all duration-300 hover:shadow-md hover:shadow-primary-500/75"
className="h-[38px] shadow-md shadow-primary-500/50 transition-all duration-300 hover:shadow-md hover:shadow-primary-500/75"
>
Upgrade Now
</Button>
<Tooltip content="Settings" position="bottom" delay={300}>
<Button
variant="secondary"
icon={faGear}
className="h-[38px] w-[38px]"
onClick={() => {
setSettingsSection("general");
setIsSettingsModalOpen(true);
gtagEvent("open_settings_modal");
}}
/>
</Tooltip>
<Button
variant="secondary"
className="h-[38px]"
icon={faImages}
onClick={handleOpenGalleryModal}
>
My Library
<span className="hidden whitespace-nowrap xl:block">
My Library
</span>
</Button>
<Activity />
@@ -333,6 +390,7 @@ export const TopBar = ({ pageName, loginSignUpPressed }: Props) => {
isOpen={isSettingsModalOpen}
onClose={() => setIsSettingsModalOpen(false)}
globalAccountLogoutCallback={() => setLogoutStates()}
initialSection={settingsSection}
/>
<GalleryModal
@@ -342,7 +400,7 @@ export const TopBar = ({ pageName, loginSignUpPressed }: Props) => {
/>
<ProviderSetupModal />
<ProviderBillingModal />
<ProviderBillingModal />
</>
);
};
@@ -14,7 +14,7 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
iconClassName?: string;
iconFlip?: boolean;
htmlFor?: string;
variant?: "primary" | "secondary" | "action" | "destructive";
variant?: "primary" | "secondary" | "action" | "destructive" | "ghost";
loading?: boolean;
as?: "button" | "link";
href?: string;
@@ -47,6 +47,9 @@ export const Button = ({
case "destructive": {
return "bg-red/70 hover:bg-red/50 text-white focus-visible:outline-red";
}
case "ghost": {
return "bg-transparent hover:bg-white/[8%] text-white focus-visible:outline-primary-600";
}
case "primary":
default: {
return "bg-primary hover:bg-primary-400 text-white focus-visible:outline-primary-600";
@@ -0,0 +1,12 @@
{
"presets": [
[
"@nx/react/babel",
{
"runtime": "automatic",
"useBuiltIns": "usage"
}
]
],
"plugins": []
}
@@ -0,0 +1,7 @@
# @storyteller/ui-progress
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test @storyteller/ui-progress` to execute the unit tests via [Vitest](https://vitest.dev/).
@@ -0,0 +1,17 @@
{
"name": "@storyteller/ui-progress",
"version": "0.0.1",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"development": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
}
}
}
@@ -0,0 +1,2 @@
export * from "./lib/progress";
export * from "./lib/progress-circle";
@@ -0,0 +1,104 @@
import { HTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
interface ProgressCircleProps extends HTMLAttributes<HTMLDivElement> {
value?: number;
max?: number;
isLow?: boolean;
size?: "small" | "large";
showPercentage?: boolean;
reverse?: boolean;
}
export const ProgressCircle = ({
value = 0,
max = 100,
isLow = false,
size = "large",
showPercentage = false,
reverse = false,
className,
...props
}: ProgressCircleProps) => {
const percentage = Math.min(Math.max(0, value), max);
const displayPercentage = percentage;
// Make small circles actually smaller
const radius = size === "small" ? 8 : 40;
const strokeWidth = size === "small" ? 4 : 6;
const circumference = 2 * Math.PI * radius;
// Calculate stroke dashoffset differently based on direction
const strokeDashoffset = reverse
? (percentage / 100) * circumference // Counter-clockwise
: circumference - (percentage / 100) * circumference; // Clockwise
const viewBoxSize = (radius + strokeWidth) * 2;
return (
<div
className={twMerge(
"relative inline-flex items-center justify-center",
size === "small" ? "h-5 w-5" : "h-24 w-24",
showPercentage && size === "small" ? "mr-6" : "", // Add margin for small with label
className
)}
{...props}
>
{/* For small size, show percentage label outside to the right */}
{showPercentage && size === "small" && (
<div className="absolute -right-6 flex items-center justify-center">
<span className="text-xs font-medium">
{Math.round(displayPercentage)}%
</span>
</div>
)}
<svg
className={`absolute h-full w-full ${
reverse ? "rotate-90" : "-rotate-90"
}`}
viewBox={`0 0 ${viewBoxSize} ${viewBoxSize}`}
>
<circle
className="stroke-white/20"
cx={viewBoxSize / 2}
cy={viewBoxSize / 2}
r={radius}
fill="none"
strokeWidth={strokeWidth}
/>
<circle
className={twMerge(
"transition-all",
isLow ? "stroke-red/80" : "stroke-primary"
)}
cx={viewBoxSize / 2}
cy={viewBoxSize / 2}
r={radius}
fill="none"
strokeWidth={strokeWidth}
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
strokeLinecap="round"
transform={
reverse
? `rotate(180, ${viewBoxSize / 2}, ${viewBoxSize / 2})`
: undefined
}
/>
</svg>
{/* For large size, show percentage in the center */}
{showPercentage && size === "large" && (
<div className="absolute flex flex-col items-center justify-center text-center">
<span className="text-xl font-medium">
{Math.round(displayPercentage)}%
</span>
</div>
)}
</div>
);
};
export default ProgressCircle;
@@ -0,0 +1,7 @@
/*
* Replace this with your own classes
*
* e.g.
* .container {
* }
*/
@@ -0,0 +1,12 @@
import { render } from '@testing-library/react';
import StorytellerUiProgress from './progress';
describe('StorytellerUiProgress', () => {
it('should render successfully', () => {
const { baseElement } = render(<StorytellerUiProgress />);
expect(baseElement).toBeTruthy();
});
});
@@ -0,0 +1,42 @@
import { HTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
interface ProgressProps extends HTMLAttributes<HTMLDivElement> {
value?: number;
max?: number;
isLow?: boolean;
}
export const Progress = ({
value = 0,
max = 100,
isLow = false,
className,
...props
}: ProgressProps) => {
const percentage = Math.min(Math.max(0, value), max);
return (
<div
className={twMerge(
"relative h-2 w-full overflow-hidden rounded-full bg-secondary",
className
)}
{...props}
>
<div
className={twMerge(
"h-full w-full flex-1 transition-all",
isLow ? "bg-red/60" : "bg-primary"
)}
style={{ transform: `translateX(-${100 - percentage}%)` }}
role="progressbar"
aria-valuemin={0}
aria-valuemax={max}
aria-valuenow={percentage}
/>
</div>
);
};
export default Progress;
@@ -0,0 +1,13 @@
{
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
],
"extends": "../../../tsconfig.base.json"
}
@@ -0,0 +1,45 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"types": [
"node",
"@nx/react/typings/cssmodule.d.ts",
"@nx/react/typings/image.d.ts",
"vite/client"
],
"rootDir": "src",
"jsx": "react-jsx",
"tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo"
},
"exclude": [
"out-tsc",
"dist",
"**/*.spec.ts",
"**/*.test.ts",
"**/*.spec.tsx",
"**/*.test.tsx",
"**/*.spec.js",
"**/*.test.js",
"**/*.spec.jsx",
"**/*.test.jsx",
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx"
],
"include": [
"src/**/*.js",
"src/**/*.jsx",
"src/**/*.ts",
"src/**/*.tsx"
]
}
@@ -0,0 +1,34 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./out-tsc/vitest",
"types": [
"vitest/globals",
"vitest/importMeta",
"vite/client",
"node",
"vitest"
],
"jsx": "react-jsx"
},
"include": [
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
],
"references": [
{
"path": "./tsconfig.lib.json"
}
]
}
@@ -0,0 +1,49 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import dts from 'vite-plugin-dts';
import * as path from 'path';
export default defineConfig(() => ({
root: __dirname,
cacheDir: '../../../node_modules/.vite/libs/components/progress',
plugins: [react(), dts({ entryRoot: 'src', tsconfigPath: path.join(__dirname, 'tsconfig.lib.json') })],
// Uncomment this if you are using workers.
// worker: {
// plugins: [ nxViteTsPaths() ],
// },
// Configuration for building your library.
// See: https://vitejs.dev/guide/build.html#library-mode
build: {
outDir: './dist',
emptyOutDir: true,
reportCompressedSize: true,
commonjsOptions: {
transformMixedEsModules: true,
},
lib: {
// Could also be a dictionary or array of multiple entry points.
entry: 'src/index.ts',
name: '@storyteller/ui-progress',
fileName: 'index',
// Change this to the formats you want to support.
// Don't forget to update your package.json as well.
formats: ['es' as const]
},
rollupOptions: {
// External packages that should not be bundled into your library.
external: ['react','react-dom','react/jsx-runtime']
},
},
test: {
watch: false,
globals: true,
environment: 'jsdom',
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
reporters: ['default'],
coverage: {
reportsDirectory: './test-output/vitest/coverage',
provider: 'v8' as const,
}
},
}));
@@ -176,7 +176,7 @@ export const PromptBoxEdit = ({
<textarea
ref={textareaRef}
rows={1}
placeholder="Describe your image..."
placeholder="Write what you want to change in your image and click generate..."
className="text-md mb-2 max-h-[5.5em] flex-1 resize-none overflow-y-auto rounded bg-transparent pb-2 pr-2 pt-1 text-white placeholder-white placeholder:text-white/60 focus:outline-none"
value={prompt}
onChange={handleChange}
@@ -0,0 +1,100 @@
import { useEffect, useState } from "react";
import { Button } from "@storyteller/ui-button";
import { Label } from "@storyteller/ui-label";
import { Progress } from "@storyteller/ui-progress";
import {
faCircleDollar,
faInfoCircle,
faStar,
} from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
interface BillingSettingsPaneProps {}
interface BillingInfo {
credits: {
remaining: number;
total: number;
};
plan: string;
nextPayment: {
amount: string;
date: string;
};
}
export const BillingSettingsPane = (args: BillingSettingsPaneProps) => {
const [billingInfo] = useState<BillingInfo>({
credits: {
remaining: 180,
total: 1000,
},
plan: "Pro Plan",
nextPayment: {
amount: "$99",
date: "Oct 18",
},
});
useEffect(() => {
const fetchBillingData = async () => {
// TODO: Replace with actual API call - BFlat
// const data = await GetBillingInfo();
// setBillingInfo(data.payload);
};
fetchBillingData();
}, []);
const creditPercentage =
(billingInfo.credits.remaining / billingInfo.credits.total) * 100;
const isLowCredit = creditPercentage < 20; // If used more than 80% of credits, consider it low
return (
<>
<div className="space-y-4">
<div className="flex flex-col">
<Label htmlFor="credits" className="flex items-center gap-2">
<FontAwesomeIcon icon={faCircleDollar} />
Monthly credits left
</Label>
<div className="flex items-center gap-2">
<Progress
value={creditPercentage}
isLow={isLowCredit}
className="h-2 w-full"
/>
<span className="text-sm text-muted-foreground font-medium">
{billingInfo.credits.remaining}/{billingInfo.credits.total}
</span>
</div>
</div>
<hr className="border-white/10" />
<div className="space-y-1">
<Label>Current Plan</Label>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faStar} className="text-purple/70" />
{billingInfo.plan}
</div>
<div className="flex gap-2">
<Button variant="secondary" className="h-[30px]">
Cancel plan
</Button>
<Button variant="primary" className="h-[30px]">
Change plan
</Button>
</div>
</div>
</div>
<div className="flex items-center gap-2 text-white/50">
<FontAwesomeIcon icon={faInfoCircle} />
Next {billingInfo.nextPayment.amount} payment due{" "}
{billingInfo.nextPayment.date}
</div>
</div>
</>
);
};
@@ -1,5 +1,5 @@
import { Modal } from "@storyteller/ui-modal";
import { useState } from "react";
import { useEffect, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faUser,
@@ -7,6 +7,7 @@ import {
faVolumeHigh,
faCircleInfo,
faRoute,
faCreditCard,
} from "@fortawesome/pro-solid-svg-icons";
import { twMerge } from "tailwind-merge";
import { MiscSettingsPane } from "./panes/MiscSettingsPane";
@@ -15,6 +16,7 @@ import { AccountSettingsPane } from "./panes/AccountSettings/AccountSettingsPane
import { AboutSettingsPane } from "./panes/AboutSettingsPane";
import { ProviderPrioritySettingsPane } from "./panes/ProviderPrioritySettingsPane";
import { gtagEvent } from "@storyteller/google-analytics";
import { BillingSettingsPane } from "./panes/BillingSettingsPane";
interface SettingsModalProps {
isOpen: boolean;
@@ -28,7 +30,8 @@ type SettingsSection =
| "accounts"
| "alerts"
| "about"
| "provider_priority";
| "provider_priority"
| "billing";
export const SettingsModal = ({
isOpen,
@@ -39,9 +42,17 @@ export const SettingsModal = ({
const [selectedSection, setSelectedSection] =
useState<SettingsSection>(initialSection);
// Sync the selected section with incoming prop when modal opens or prop changes
useEffect(() => {
if (isOpen) {
setSelectedSection(initialSection);
}
}, [isOpen, initialSection]);
const sections = [
{ id: "general" as const, label: "General", icon: faCog },
{ id: "accounts" as const, label: "Accounts", icon: faUser },
// { id: "billing" as const, label: "Plan & Credits", icon: faCreditCard }, // Uncomment this for billing - BFlat
{
id: "provider_priority" as const,
label: "Provider Priority",
@@ -69,6 +80,8 @@ export const SettingsModal = ({
return <AboutSettingsPane />;
case "provider_priority":
return <ProviderPrioritySettingsPane />;
case "billing":
return <BillingSettingsPane />;
}
};
@@ -95,7 +108,9 @@ export const SettingsModal = ({
section.id === selectedSection ? "bg-[#63636B]/40" : ""
)}
onClick={() => {
gtagEvent("switch_settings_section", { "section": section.id });
gtagEvent("switch_settings_section", {
section: section.id,
});
setSelectedSection(section.id);
}}
>
+8 -151
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -156,6 +156,9 @@
{
"path": "./libs/components/provider-setup-modal"
},
{
"path": "./libs/components/progress"
},
{
"path": "./libs/components/provider-billing-modal"
}