mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-04 09:28:11 +00:00
checkout old pricing modal -- this makes it work!
This commit is contained in:
1 parent
7d14c3c01c
commit
ccbba3f4d6
13 files changed
+273
-356
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import {
|
||||
faGear,
|
||||
faImages,
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "@fortawesome/pro-regular-svg-icons";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { PopoverMenu } from "@storyteller/ui-popover";
|
||||
import { AuthButtons } from "./AuthButtons";
|
||||
import { SceneTitleInput } from "./SceneTitleInput";
|
||||
import { Activity } from "~/pages/PageEnigma/comps/GenerateModals/Activity";
|
||||
import {
|
||||
@@ -57,10 +58,6 @@ import {
|
||||
usePricingModalStore,
|
||||
useCreditsModalStore,
|
||||
} from "@storyteller/ui-pricing-modal";
|
||||
import { useCreditsBalanceChangedEvent } from "@storyteller/tauri-events";
|
||||
import { useSubscriptionPlanChangedEvent } from "@storyteller/tauri-events";
|
||||
import { useCreditsState } from "@storyteller/credits";
|
||||
import { useSubscriptionState } from "@storyteller/subscription";
|
||||
|
||||
interface Props {
|
||||
pageName: string;
|
||||
@@ -79,7 +76,7 @@ type SettingsSection =
|
||||
const SWITCHER_THROTTLE_TIME = 500; // milliseconds
|
||||
|
||||
// NB: See `TabState` for the default tab.
|
||||
const appMenuTabs: MenuIconItem[] = [
|
||||
const appMenuTabs: MenuIconItem<TabId>[] = [
|
||||
{
|
||||
id: "IMAGE",
|
||||
label: "Text to Image",
|
||||
@@ -153,25 +150,6 @@ export const TopBar = ({ pageName, loginSignUpPressed }: Props) => {
|
||||
const [disableSwitcher, setDisableSwitcher] = useState(false);
|
||||
const switcherThrottle = useRef(false);
|
||||
|
||||
const creditsStore = useCreditsState();
|
||||
const sumTotalCredits = creditsStore.totalCredits;
|
||||
|
||||
const subscriptionStore = useSubscriptionState();
|
||||
const hasPaidPlan = subscriptionStore.hasPaidPlan();
|
||||
|
||||
useEffect(() => {
|
||||
creditsStore.fetchFromServer();
|
||||
subscriptionStore.fetchFromServer();
|
||||
}, []);
|
||||
|
||||
useCreditsBalanceChangedEvent(async () => {
|
||||
creditsStore.fetchFromServer();
|
||||
});
|
||||
|
||||
useSubscriptionPlanChangedEvent(async () => {
|
||||
subscriptionStore.fetchFromServer();
|
||||
});
|
||||
|
||||
const disableTabSwitcher = () => {
|
||||
return (
|
||||
disableSwitcher ||
|
||||
@@ -240,43 +218,13 @@ export const TopBar = ({ pageName, loginSignUpPressed }: Props) => {
|
||||
|
||||
const pageTitle = getPageTitle();
|
||||
|
||||
const { toggleModal: toggleSubscriptionModal } = usePricingModalStore();
|
||||
const { toggleModal } = usePricingModalStore();
|
||||
const { toggleModal: toggleCreditsModal } = useCreditsModalStore();
|
||||
|
||||
// Pick logo based on current theme (light uses black logo; others use white)
|
||||
const [logoSrc, setLogoSrc] = useState<string>(
|
||||
"/resources/logo/artcraft-logo-color-white.svg",
|
||||
);
|
||||
useEffect(() => {
|
||||
const computeLogo = () => {
|
||||
const root = document.documentElement;
|
||||
const isLight = root.classList.contains("theme-light");
|
||||
setLogoSrc(
|
||||
isLight
|
||||
? "/resources/logo/artcraft-logo-color-black.svg"
|
||||
: "/resources/logo/artcraft-logo-color-white.svg",
|
||||
);
|
||||
};
|
||||
computeLogo();
|
||||
const mo = new MutationObserver((muts) => {
|
||||
for (const m of muts) {
|
||||
if (m.type === "attributes" && m.attributeName === "class") {
|
||||
computeLogo();
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
mo.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
return () => mo.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header
|
||||
className="fixed left-0 top-0 z-[60] w-full border-b border-ui-panel-border bg-ui-background"
|
||||
className="fixed left-0 top-0 z-[60] w-full border-b border-white/5 bg-ui-background"
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<nav
|
||||
@@ -291,8 +239,8 @@ export const TopBar = ({ pageName, loginSignUpPressed }: Props) => {
|
||||
</span>
|
||||
<img
|
||||
className="h-[24px] w-auto"
|
||||
src={logoSrc}
|
||||
alt="ArtCraft Logo"
|
||||
src="/resources/images/artcraft-logo-3.png"
|
||||
alt="Logo ArtCraft"
|
||||
data-tauri-drag-region
|
||||
/>
|
||||
</div>
|
||||
@@ -316,7 +264,7 @@ export const TopBar = ({ pageName, loginSignUpPressed }: Props) => {
|
||||
} else {
|
||||
set3DPageMounted(false);
|
||||
}
|
||||
useTabStore.getState().setActiveTab(tabId as TabId);
|
||||
useTabStore.getState().setActiveTab(tabId);
|
||||
setTimeout(() => {
|
||||
// Clear the throttle
|
||||
switcherThrottle.current = false;
|
||||
@@ -335,9 +283,7 @@ export const TopBar = ({ pageName, loginSignUpPressed }: Props) => {
|
||||
{tabStore.activeTabId === "3D" ? (
|
||||
<SceneTitleInput pageName={pageName} />
|
||||
) : (
|
||||
<h1 className="text-base-fg" data-tauri-drag-region>
|
||||
{pageTitle}
|
||||
</h1>
|
||||
<h1 data-tauri-drag-region>{pageTitle}</h1>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -354,20 +300,20 @@ export const TopBar = ({ pageName, loginSignUpPressed }: Props) => {
|
||||
}
|
||||
triggerLabel={
|
||||
<span className="whitespace-nowrap text-sm font-medium">
|
||||
{sumTotalCredits} Credits
|
||||
180 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"
|
||||
buttonClassName="h-[30px] px-2 ps-1.5 bg-transparent hover:bg-white/10"
|
||||
panelClassName="mt-3"
|
||||
>
|
||||
{(close) => (
|
||||
<div className="text-base-fg w-72 p-2.5">
|
||||
<div className="w-72 p-2.5 text-white">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-base-fg/80 flex items-center gap-1.5 text-sm font-medium">
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium text-white/80">
|
||||
Your credit balance
|
||||
</span>
|
||||
<button
|
||||
className="text-sm font-medium text-primary-400 transition-all hover:text-primary-300"
|
||||
className="text-sm font-medium text-primary-300 transition-all hover:text-primary-200"
|
||||
onClick={() => {
|
||||
close();
|
||||
toggleCreditsModal();
|
||||
@@ -376,17 +322,17 @@ export const TopBar = ({ pageName, loginSignUpPressed }: Props) => {
|
||||
Buy credits
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-base-fg flex items-center gap-2 text-4xl font-bold">
|
||||
<div className="flex items-center gap-2 text-4xl font-bold">
|
||||
<FontAwesomeIcon
|
||||
icon={faCoinFront}
|
||||
className="text-2xl text-primary"
|
||||
/>
|
||||
{sumTotalCredits}
|
||||
180
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button
|
||||
variant="action"
|
||||
className="h-9 grow"
|
||||
className="h-9 grow bg-white/15 hover:bg-white/20"
|
||||
onClick={() => {
|
||||
close();
|
||||
handleOpenBillingSettings();
|
||||
@@ -399,7 +345,7 @@ export const TopBar = ({ pageName, loginSignUpPressed }: Props) => {
|
||||
className="h-9 grow"
|
||||
onClick={() => {
|
||||
close();
|
||||
toggleSubscriptionModal();
|
||||
toggleModal();
|
||||
}}
|
||||
icon={faGem}
|
||||
>
|
||||
@@ -410,16 +356,14 @@ export const TopBar = ({ pageName, loginSignUpPressed }: Props) => {
|
||||
)}
|
||||
</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"
|
||||
>
|
||||
Upgrade
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={faGem}
|
||||
onClick={toggleModal}
|
||||
className="h-[38px] shadow-md shadow-primary-500/50 transition-all duration-300 hover:shadow-md hover:shadow-primary-500/75"
|
||||
>
|
||||
Upgrade
|
||||
</Button>
|
||||
|
||||
<Tooltip content="Settings" position="bottom" delay={300}>
|
||||
<Button
|
||||
@@ -440,30 +384,28 @@ export const TopBar = ({ pageName, loginSignUpPressed }: Props) => {
|
||||
icon={faImages}
|
||||
onClick={handleOpenGalleryModal}
|
||||
>
|
||||
<span className="text-base-fg hidden whitespace-nowrap xl:block">
|
||||
<span className="hidden whitespace-nowrap xl:block">
|
||||
My Library
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<Activity />
|
||||
</div>
|
||||
|
||||
<div className="no-drag">
|
||||
{/* TODO(bt,2025-09-12): This was the old auth buttons that didn't work. We need to remove this and clean up the DOM. */}
|
||||
<AuthButtons loginSignUpPressed={loginSignUpPressed} />
|
||||
</div>
|
||||
|
||||
{isDesktop && platform !== "macos" && (
|
||||
<div className="no-drag flex items-center">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="text-base-fg h-[32px] w-[44px] rounded-none border-0 bg-transparent opacity-70 shadow-none hover:bg-ui-controls/20 hover:opacity-100"
|
||||
className="h-[32px] w-[44px] rounded-none bg-transparent text-white/70 hover:bg-white/10 hover:text-white"
|
||||
onClick={minimize}
|
||||
>
|
||||
<FontAwesomeIcon icon={faDash} className="text-xs" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="text-base-fg h-[32px] w-[44px] rounded-none border-0 bg-transparent opacity-70 shadow-none hover:bg-ui-controls/20 hover:opacity-100"
|
||||
className="h-[32px] w-[44px] rounded-none bg-transparent text-white/70 hover:bg-white/10 hover:text-white"
|
||||
onClick={toggleMaximize}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
@@ -473,7 +415,7 @@ export const TopBar = ({ pageName, loginSignUpPressed }: Props) => {
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="text-base-fg h-[32px] w-[44px] rounded-none border-0 bg-transparent opacity-70 shadow-none hover:bg-red/10 hover:text-red"
|
||||
className="h-[32px] w-[44px] rounded-none bg-transparent text-white/70 hover:bg-red/40 hover:text-white"
|
||||
onClick={close}
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} className="text-lg" />
|
||||
|
||||
@@ -52,9 +52,6 @@
|
||||
{
|
||||
"path": "../../libs/components/button-dropdown"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/pricing-modal"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/toaster"
|
||||
},
|
||||
@@ -64,6 +61,9 @@
|
||||
{
|
||||
"path": "../../libs/soundboard"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/tauri-events"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/slider-v2"
|
||||
},
|
||||
@@ -85,17 +85,17 @@
|
||||
{
|
||||
"path": "../../libs/model-list"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/popover"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/common"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/state/credits"
|
||||
"path": "../../libs/components/pricing-modal"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/state/subscription"
|
||||
"path": "../../libs/components/provider-billing-modal"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/provider-setup-modal"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/analytics/google-analytics"
|
||||
@@ -109,6 +109,9 @@
|
||||
{
|
||||
"path": "../../libs/components/settings-modal"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/popover"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/gravatar"
|
||||
},
|
||||
@@ -121,12 +124,6 @@
|
||||
{
|
||||
"path": "../../libs/components/upload-modal"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/provider-setup-modal"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/provider-billing-modal"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/modal"
|
||||
},
|
||||
@@ -148,9 +145,6 @@
|
||||
{
|
||||
"path": "../../libs/tauri-utils"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/tauri-events"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/api"
|
||||
}
|
||||
|
||||
@@ -13,8 +13,5 @@
|
||||
"import": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.4.0"
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./lib/pricing-modal";
|
||||
export * from "./lib/pricing-config";
|
||||
export * from "./lib/pricing-modal-store";
|
||||
export * from "./lib/credits-modal";
|
||||
export * from "./lib/credits-modal-store";
|
||||
@@ -99,23 +99,23 @@ export function CreditsModal({
|
||||
};
|
||||
|
||||
const cardBase =
|
||||
"relative rounded-xl border p-6 h-full flex flex-col justify-between bg-ui-panel border-ui-panel-border hover:bg-ui-controls/10 transition-all";
|
||||
"relative rounded-xl border p-6 h-full flex flex-col justify-between bg-[#1F1F1F] border-white/10 hover:border-white/20 transition-all";
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose ?? (() => {})}
|
||||
className="rounded-xl bg-ui-panel border border-ui-panel-border max-h-[90vh] max-w-screen-2xl overflow-y-auto flex flex-col"
|
||||
className="rounded-xl bg-[#1A1A1A] max-h-[90vh] max-w-screen-2xl overflow-y-auto flex flex-col"
|
||||
allowBackgroundInteraction={false}
|
||||
showClose={true}
|
||||
closeOnOutsideClick={true}
|
||||
resizable={false}
|
||||
backdropClassName=""
|
||||
backdropClassName="bg-black/80"
|
||||
>
|
||||
<div className="p-16 py-24 flex-1 overflow-y-auto min-h-0 text-base-fg">
|
||||
<div className="p-16 py-24 flex-1 overflow-y-auto min-h-0">
|
||||
<div className="text-center mb-10">
|
||||
<h1 className="text-5xl font-bold text-base-fg mb-4">Buy credits</h1>
|
||||
<p className="text-base-fg/60 text-lg">
|
||||
<h1 className="text-5xl font-bold text-white mb-4">Buy credits</h1>
|
||||
<p className="text-gray-400 text-lg">
|
||||
Choose a one-time credits package. No subscription required.
|
||||
</p>
|
||||
</div>
|
||||
@@ -124,7 +124,7 @@ export function CreditsModal({
|
||||
{creditPacks.map((pack) => (
|
||||
<div key={pack.id} className={cardBase}>
|
||||
{pack.badge && (
|
||||
<div className="absolute -top-3 right-4 bg-ui-controls text-base-fg border border-ui-controls-border px-3 py-1 rounded-full text-xs font-semibold shadow-xl">
|
||||
<div className="absolute -top-3 right-4 bg-white text-black px-3 py-1 rounded-full text-xs font-semibold shadow-xl">
|
||||
{pack.badge}
|
||||
</div>
|
||||
)}
|
||||
@@ -132,7 +132,7 @@ export function CreditsModal({
|
||||
<CoinIcon />
|
||||
|
||||
<div>
|
||||
<div className="text-base-fg text-5xl font-bold tracking-tight flex items-center gap-2.5">
|
||||
<div className="text-white text-5xl font-bold tracking-tight flex items-center gap-2.5">
|
||||
<FontAwesomeIcon
|
||||
icon={faCoinFront}
|
||||
className="text-primary text-3xl"
|
||||
@@ -140,9 +140,9 @@ export function CreditsModal({
|
||||
{pack.total.toLocaleString()}
|
||||
</div>
|
||||
{pack.bonus > 0 && (
|
||||
<div className="text-base-fg/60 text-sm mt-2">
|
||||
<div className="text-gray-400 text-sm mt-2">
|
||||
Total: {pack.base.toLocaleString()} {" + "}
|
||||
<span className="text-primary-300">
|
||||
<span className="text-[#faca5a]">
|
||||
{pack.bonus.toLocaleString()} Bonus
|
||||
</span>
|
||||
</div>
|
||||
@@ -150,12 +150,12 @@ export function CreditsModal({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-6">
|
||||
<div className="text-base-fg text-xl font-semibold">
|
||||
<div className="text-white text-xl font-semibold">
|
||||
${pack.priceUsd}
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => handlePurchase(pack)}
|
||||
className="px-5 h-10 rounded-xl bg-ui-controls text-base-fg border border-ui-controls-border hover:bg-ui-controls/80"
|
||||
className="bg-white text-black hover:bg-white/90 px-5 h-10 rounded-xl"
|
||||
>
|
||||
Purchase
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
export interface PricingFeature {
|
||||
text: string;
|
||||
included: boolean;
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
export interface PricingTier {
|
||||
id: string;
|
||||
name: string;
|
||||
monthlyPrice: number;
|
||||
yearlyPrice: number;
|
||||
originalMonthlyPrice?: number;
|
||||
originalYearlyPrice?: number;
|
||||
monthlyPriceId?: string;
|
||||
yearlyPriceId?: string;
|
||||
features: PricingFeature[];
|
||||
isPopular?: boolean;
|
||||
colorScheme: "dark" | "green" | "purple" | "orange";
|
||||
}
|
||||
|
||||
export const pricingConfig = {
|
||||
header: {
|
||||
title: "Purchase a subscription",
|
||||
subtitle:
|
||||
"Upgrade to gain access to Pro features and generate more, faster.",
|
||||
},
|
||||
yearlyDiscount: 20,
|
||||
tiers: [
|
||||
{
|
||||
id: "free",
|
||||
name: "Free",
|
||||
monthlyPrice: 0,
|
||||
yearlyPrice: 0,
|
||||
features: [
|
||||
{ text: "Free daily generations", included: true },
|
||||
{ text: "Limited access to ArtCraft tools", included: true },
|
||||
],
|
||||
colorScheme: "dark" as const,
|
||||
},
|
||||
{
|
||||
id: "plus",
|
||||
name: "Plus",
|
||||
monthlyPrice: 8,
|
||||
yearlyPrice: 96,
|
||||
originalMonthlyPrice: 10,
|
||||
originalYearlyPrice: 120,
|
||||
monthlyPriceId: "price_1234567890abcdef", //dummy
|
||||
yearlyPriceId: "price_abcdef1234567890", //dummy
|
||||
features: [
|
||||
{ text: "~1,010 Flux images", included: true },
|
||||
{ text: "~36,000 real-time images", included: true },
|
||||
{ text: "~180 enhanced images", included: true },
|
||||
{ text: "~6 training jobs", included: true },
|
||||
{ text: "Commercial license", included: true },
|
||||
],
|
||||
colorScheme: "green" as const,
|
||||
},
|
||||
{
|
||||
id: "pro",
|
||||
name: "Pro",
|
||||
monthlyPrice: 28,
|
||||
yearlyPrice: 336,
|
||||
originalMonthlyPrice: 35,
|
||||
originalYearlyPrice: 420,
|
||||
monthlyPriceId: "price_fedcba0987654321", //dummy
|
||||
yearlyPriceId: "price_123456789fedcba0", //dummy
|
||||
features: [
|
||||
{ text: "~5,048 Flux images", included: true },
|
||||
{ text: "~180,000 real-time images", included: true },
|
||||
{ text: "~900 enhanced images", included: true },
|
||||
{ text: "~30 training jobs", included: true },
|
||||
{ text: "Commercial license", included: true },
|
||||
],
|
||||
colorScheme: "purple" as const,
|
||||
},
|
||||
{
|
||||
id: "max",
|
||||
name: "Max",
|
||||
monthlyPrice: 48,
|
||||
yearlyPrice: 576,
|
||||
originalMonthlyPrice: 60,
|
||||
originalYearlyPrice: 720,
|
||||
monthlyPriceId: "price_9876543210fedcba", //dummy
|
||||
yearlyPriceId: "price_abcdef9876543210", //dummy
|
||||
features: [
|
||||
{ text: "~15,142 Flux images", included: true },
|
||||
{ text: "~540,000 real-time images", included: true },
|
||||
{ text: "~2,700 enhanced images", included: true },
|
||||
{ text: "~90 training jobs", included: true },
|
||||
{ text: "Commercial license", included: true },
|
||||
],
|
||||
colorScheme: "orange" as const,
|
||||
},
|
||||
] as PricingTier[],
|
||||
};
|
||||
@@ -2,101 +2,67 @@ import { Modal } from "@storyteller/ui-modal";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { useState } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { pricingConfig, PricingTier } from "./pricing-config";
|
||||
import { usePricingModalStore } from "./pricing-modal-store";
|
||||
import { TabSelector } from "@storyteller/ui-tab-selector";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import {
|
||||
SUBSCRIPTION_PLANS,
|
||||
SubscriptionPlanDetails,
|
||||
} from "@storyteller/subscription";
|
||||
import { useSubscriptionState } from "@storyteller/subscription";
|
||||
|
||||
const billingTabs = [
|
||||
{ id: "yearly", label: "Yearly" },
|
||||
{ id: "monthly", label: "Monthly" },
|
||||
];
|
||||
|
||||
const pricingConfig = {
|
||||
header: {
|
||||
title: "Purchase a subscription",
|
||||
subtitle:
|
||||
"Upgrade to gain access to Pro features and generate more, faster.",
|
||||
},
|
||||
yearlyDiscount: 20,
|
||||
};
|
||||
interface PricingModalProps {
|
||||
currentPlanId?: string;
|
||||
hasActiveSubscription?: boolean;
|
||||
}
|
||||
|
||||
interface PricingModalProps {}
|
||||
|
||||
export function PricingModal({}: PricingModalProps = {}) {
|
||||
const { isOpen, closeModal } = usePricingModalStore();
|
||||
|
||||
const subscriptionStore = useSubscriptionState();
|
||||
|
||||
const hasActiveSub = subscriptionStore.hasPaidPlan();
|
||||
|
||||
const activePlanId = subscriptionStore.subscriptionInfo?.productSlug;
|
||||
export function PricingModal({
|
||||
currentPlanId,
|
||||
hasActiveSubscription,
|
||||
}: PricingModalProps = {}) {
|
||||
const { isOpen, closeModal, subscription } = usePricingModalStore();
|
||||
|
||||
// Use props if provided or fall back to store
|
||||
const activePlanId = currentPlanId ?? subscription.currentPlanId;
|
||||
const hasActiveSub =
|
||||
hasActiveSubscription ?? subscription.hasActiveSubscription;
|
||||
const [billingType, setBillingType] = useState("yearly");
|
||||
const isYearly = billingType === "yearly";
|
||||
|
||||
const handleUnsubscribe = async () => {
|
||||
await invoke("storyteller_open_customer_portal_cancel_plan_command");
|
||||
};
|
||||
const handleUpgrade = async (tierId: string) => {
|
||||
// TODO: Implement Stripe checkout
|
||||
const tier = pricingConfig.tiers.find((t) => t.id === tierId);
|
||||
const priceId = isYearly ? tier?.yearlyPriceId : tier?.monthlyPriceId;
|
||||
|
||||
const handleManageSubscription = async () => {
|
||||
await invoke("storyteller_open_customer_portal_manage_plan_command");
|
||||
};
|
||||
|
||||
const handleUpdatePaymentMethod = async () => {
|
||||
await invoke(
|
||||
"storyteller_open_customer_portal_update_payment_method_command"
|
||||
console.log(
|
||||
`Upgrading to ${tierId} plan with ${
|
||||
isYearly ? "yearly" : "monthly"
|
||||
} billing`,
|
||||
{ priceId, tierId }
|
||||
);
|
||||
|
||||
// Example Stripe checkout implementation:
|
||||
// await stripe.redirectToCheckout({
|
||||
// lineItems: [{ price: priceId, quantity: 1 }],
|
||||
// mode: 'subscription',
|
||||
// successUrl: `${window.location.origin}/success`,
|
||||
// cancelUrl: `${window.location.origin}/cancel`,
|
||||
// });
|
||||
};
|
||||
|
||||
const handleSetPlan = async (tierSlug: string) => {
|
||||
const tier = SUBSCRIPTION_PLANS.find((t) => t.slug === tierSlug);
|
||||
const planSlug = tier?.slug;
|
||||
const cadence = isYearly ? "yearly" : "monthly";
|
||||
|
||||
if (planSlug === "free") {
|
||||
if (hasActiveSub) {
|
||||
await handleUnsubscribe();
|
||||
return;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasActiveSub) {
|
||||
await invoke("storyteller_open_customer_portal_switch_plan_command", {
|
||||
request: {
|
||||
plan: planSlug,
|
||||
cadence: cadence,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await invoke("storyteller_open_subscription_purchase_command", {
|
||||
request: {
|
||||
plan: planSlug,
|
||||
cadence: cadence,
|
||||
},
|
||||
});
|
||||
}
|
||||
const handleManageSubscription = () => {
|
||||
// TODO: Redirect to Stripe customer portal
|
||||
console.log("Managing subscription");
|
||||
};
|
||||
|
||||
const tierHierarchy = {
|
||||
free: 0,
|
||||
artcraft_basic: 1,
|
||||
artcraft_pro: 2,
|
||||
artcraft_max: 3,
|
||||
};
|
||||
const tierHierarchy = { free: 0, basic: 1, pro: 2, max: 3 };
|
||||
|
||||
const isCurrentPlan = (tierId: string) => {
|
||||
return tierId === activePlanId;
|
||||
};
|
||||
|
||||
const getButtonText = (tier: SubscriptionPlanDetails) => {
|
||||
if (isCurrentPlan(tier.slug)) {
|
||||
const getButtonText = (tier: PricingTier) => {
|
||||
if (isCurrentPlan(tier.id)) {
|
||||
return "Current Plan";
|
||||
}
|
||||
|
||||
@@ -104,12 +70,9 @@ export function PricingModal({}: PricingModalProps = {}) {
|
||||
const currentTierLevel =
|
||||
tierHierarchy[activePlanId as keyof typeof tierHierarchy];
|
||||
const thisTierLevel =
|
||||
tierHierarchy[tier.slug as keyof typeof tierHierarchy];
|
||||
tierHierarchy[tier.id as keyof typeof tierHierarchy];
|
||||
|
||||
if (thisTierLevel < currentTierLevel) {
|
||||
if (tier.slug === "free") {
|
||||
return "Cancel Plan";
|
||||
}
|
||||
return "Switch Plan";
|
||||
}
|
||||
}
|
||||
@@ -118,7 +81,7 @@ export function PricingModal({}: PricingModalProps = {}) {
|
||||
};
|
||||
|
||||
const getColorSchemeClasses = (
|
||||
colorScheme: SubscriptionPlanDetails["colorScheme"],
|
||||
colorScheme: PricingTier["colorScheme"],
|
||||
isCurrent: boolean
|
||||
) => {
|
||||
const baseClasses =
|
||||
@@ -165,8 +128,8 @@ export function PricingModal({}: PricingModalProps = {}) {
|
||||
}
|
||||
};
|
||||
|
||||
const formatPrice = (plan: SubscriptionPlanDetails) => {
|
||||
if (plan.monthlyPrice === 0) {
|
||||
const formatPrice = (tier: PricingTier) => {
|
||||
if (tier.monthlyPrice === 0) {
|
||||
return {
|
||||
current: "$0",
|
||||
original: null,
|
||||
@@ -174,9 +137,9 @@ export function PricingModal({}: PricingModalProps = {}) {
|
||||
}
|
||||
|
||||
if (isYearly) {
|
||||
const discountedMonthlyPrice = Math.round(plan.yearlyPrice / 12);
|
||||
const originalMonthlyPrice = plan.originalYearlyPrice
|
||||
? Math.round(plan.originalYearlyPrice / 12)
|
||||
const discountedMonthlyPrice = Math.round(tier.yearlyPrice / 12);
|
||||
const originalMonthlyPrice = tier.originalYearlyPrice
|
||||
? Math.round(tier.originalYearlyPrice / 12)
|
||||
: null;
|
||||
|
||||
return {
|
||||
@@ -184,7 +147,7 @@ export function PricingModal({}: PricingModalProps = {}) {
|
||||
original: originalMonthlyPrice ? `$${originalMonthlyPrice}` : null,
|
||||
};
|
||||
} else {
|
||||
const monthlyPrice = plan.originalMonthlyPrice || plan.monthlyPrice;
|
||||
const monthlyPrice = tier.originalMonthlyPrice || tier.monthlyPrice;
|
||||
|
||||
return {
|
||||
current: `$${monthlyPrice}`,
|
||||
@@ -197,20 +160,20 @@ export function PricingModal({}: PricingModalProps = {}) {
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={closeModal}
|
||||
className="rounded-xl bg-ui-panel max-h-[90vh] max-w-screen-2xl overflow-y-auto flex flex-col border border-ui-panel-border"
|
||||
className="rounded-xl bg-[#1A1A1A] max-h-[90vh] max-w-screen-2xl overflow-y-auto flex flex-col"
|
||||
allowBackgroundInteraction={false}
|
||||
showClose={true}
|
||||
closeOnOutsideClick={true}
|
||||
resizable={false}
|
||||
backdropClassName=""
|
||||
backdropClassName="bg-black/80"
|
||||
>
|
||||
<div className="p-16 py-24 flex-1 overflow-y-auto min-h-0 text-base-fg">
|
||||
<div className="p-16 py-24 flex-1 overflow-y-auto min-h-0">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-10">
|
||||
<h1 className="text-5xl font-bold text-base-fg mb-4">
|
||||
<h1 className="text-5xl font-bold text-white mb-4">
|
||||
{pricingConfig.header.title}
|
||||
</h1>
|
||||
<p className="text-base-fg/60 text-lg mb-6">
|
||||
<p className="text-gray-400 text-lg mb-6">
|
||||
{pricingConfig.header.subtitle}
|
||||
</p>
|
||||
|
||||
@@ -220,10 +183,10 @@ export function PricingModal({}: PricingModalProps = {}) {
|
||||
tabs={billingTabs}
|
||||
activeTab={billingType}
|
||||
onTabChange={setBillingType}
|
||||
className="w-fit border border-base-fg/20 rounded-lg"
|
||||
className="w-fit"
|
||||
tabClassName="w-24 text-md"
|
||||
indicatorClassName="bg-primary/30 border border-primary"
|
||||
selectedTabClassName="text-base-fg"
|
||||
indicatorClassName="bg-white"
|
||||
selectedTabClassName="text-black"
|
||||
/>
|
||||
<span className="bg-primary text-white px-3 py-0.5 rounded-full text-sm font-medium -top-3 -left-6 absolute pointer-events-none">
|
||||
-{pricingConfig.yearlyDiscount}%
|
||||
@@ -233,19 +196,19 @@ export function PricingModal({}: PricingModalProps = {}) {
|
||||
|
||||
{/* Pricing Tiers */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-10">
|
||||
{SUBSCRIPTION_PLANS.map((plan) => {
|
||||
const pricing = formatPrice(plan);
|
||||
{pricingConfig.tiers.map((tier) => {
|
||||
const pricing = formatPrice(tier);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={plan.slug}
|
||||
key={tier.id}
|
||||
className={getColorSchemeClasses(
|
||||
plan.colorScheme,
|
||||
isCurrentPlan(plan.slug)
|
||||
tier.colorScheme,
|
||||
isCurrentPlan(tier.id)
|
||||
)}
|
||||
>
|
||||
{/* Current Plan Badge */}
|
||||
{isCurrentPlan(plan.slug) && (
|
||||
{isCurrentPlan(tier.id) && (
|
||||
<div className="absolute -top-4 right-5 bg-white text-black px-3 py-1 rounded-full text-md font-semibold shadow-xl">
|
||||
Active
|
||||
</div>
|
||||
@@ -254,7 +217,7 @@ export function PricingModal({}: PricingModalProps = {}) {
|
||||
{/* Tier Header */}
|
||||
<div className="text-center mb-8">
|
||||
<h3 className="text-4xl font-bold text-white mb-4">
|
||||
{plan.name}
|
||||
{tier.name}
|
||||
</h3>
|
||||
<div className="flex items-baseline justify-center gap-2">
|
||||
{pricing.original && (
|
||||
@@ -268,14 +231,14 @@ export function PricingModal({}: PricingModalProps = {}) {
|
||||
<span className="text-white/60 text-sm">/month</span>
|
||||
</div>
|
||||
|
||||
<p className="text-white/60 text-xs mt-1">
|
||||
<p className="text-gray-300 text-xs mt-1">
|
||||
{isYearly ? "billed yearly" : "billed monthly"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<div className="flex-1 space-y-3 mb-6">
|
||||
{plan.features.map((feature, index) => (
|
||||
{tier.features.map((feature, index) => (
|
||||
<div key={index} className="flex items-start gap-3">
|
||||
<div
|
||||
className={twMerge(
|
||||
@@ -302,7 +265,7 @@ export function PricingModal({}: PricingModalProps = {}) {
|
||||
<span
|
||||
className={twMerge(
|
||||
"text-sm mt-0.5",
|
||||
feature.included ? "text-white" : "text-white/60"
|
||||
feature.included ? "text-white" : "text-gray-400"
|
||||
)}
|
||||
>
|
||||
{feature.text}
|
||||
@@ -313,11 +276,11 @@ export function PricingModal({}: PricingModalProps = {}) {
|
||||
|
||||
{/* CTA Button */}
|
||||
<Button
|
||||
onClick={() => handleSetPlan(plan.slug)}
|
||||
disabled={isCurrentPlan(plan.slug)}
|
||||
className="w-full h-12 rounded-xl bg-white text-black border hover:bg-white/90"
|
||||
onClick={() => handleUpgrade(tier.id)}
|
||||
disabled={isCurrentPlan(tier.id)}
|
||||
className="w-full bg-white text-black hover:bg-white/90 h-12 rounded-xl"
|
||||
>
|
||||
{getButtonText(plan)}
|
||||
{getButtonText(tier)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -327,15 +290,9 @@ export function PricingModal({}: PricingModalProps = {}) {
|
||||
{/* Manage Subscription Button - only show if user has a paid plan */}
|
||||
{hasActiveSub && activePlanId !== "free" && (
|
||||
<div className="flex justify-center mt-8">
|
||||
<Button
|
||||
onClick={handleUpdatePaymentMethod}
|
||||
className="bg-transparent border border-white/25 text-white hover:bg-white/10 px-8 py-3 mx-3 rounded-xl"
|
||||
>
|
||||
Update your payment method
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleManageSubscription}
|
||||
className="bg-transparent border border-white/25 text-white hover:bg-white/10 px-8 py-3 mx-3 rounded-xl"
|
||||
className="bg-transparent border border-white/25 text-white hover:bg-white/10 px-8 py-3 rounded-xl"
|
||||
>
|
||||
Manage your subscription
|
||||
</Button>
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../state/subscription"
|
||||
},
|
||||
{
|
||||
"path": "../tab-selector"
|
||||
},
|
||||
|
||||
@@ -43,9 +43,6 @@
|
||||
"src/**/*.tsx"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../state/subscription/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../tab-selector/tsconfig.lib.json"
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { Label } from "@storyteller/ui-label";
|
||||
import {
|
||||
@@ -9,50 +9,49 @@ import {
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { usePricingModalStore } from "@storyteller/ui-pricing-modal";
|
||||
import { useCreditsModalStore } from "@storyteller/ui-pricing-modal";
|
||||
import { useCreditsState, CreditsState } from "@storyteller/credits";
|
||||
import {
|
||||
FREE_PLAN,
|
||||
SubscriptionPlanDetails,
|
||||
useSubscriptionState,
|
||||
} from "@storyteller/subscription";
|
||||
import { SUBSCRIPTION_PLANS_BY_SLUG } from "@storyteller/subscription";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
interface BillingSettingsPaneProps {}
|
||||
|
||||
interface BillingInfo {
|
||||
credits: {
|
||||
remaining: number;
|
||||
total: number;
|
||||
};
|
||||
plan: string;
|
||||
nextPayment: {
|
||||
amount: string;
|
||||
date: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const BillingSettingsPane = (args: BillingSettingsPaneProps) => {
|
||||
const { toggleModal: toggleSubscriptionModal } = usePricingModalStore();
|
||||
|
||||
const creditsStore = useCreditsState();
|
||||
const sumTotalCredits = creditsStore.totalCredits;
|
||||
|
||||
const subscriptionStore = useSubscriptionState();
|
||||
|
||||
const maybePlanSlug = subscriptionStore.subscriptionInfo?.productSlug;
|
||||
|
||||
const currentPlanDetails: SubscriptionPlanDetails = maybePlanSlug
|
||||
? SUBSCRIPTION_PLANS_BY_SLUG.get(maybePlanSlug) || FREE_PLAN
|
||||
: FREE_PLAN;
|
||||
|
||||
const canCancelPlan = subscriptionStore.canCancelPlan();
|
||||
|
||||
const nextBillAt =
|
||||
subscriptionStore.subscriptionInfo?.nextBillAt?.toLocaleDateString();
|
||||
const subscriptionEndAt =
|
||||
subscriptionStore.subscriptionInfo?.subscriptionEndAt?.toLocaleDateString();
|
||||
|
||||
const changeOrUpgradePlanButtonLabel = canCancelPlan
|
||||
? "Change plan"
|
||||
: "Upgrade plan";
|
||||
const [billingInfo] = useState<BillingInfo>({
|
||||
credits: {
|
||||
remaining: 180,
|
||||
total: 1000,
|
||||
},
|
||||
plan: "Pro Plan",
|
||||
nextPayment: {
|
||||
amount: "$99",
|
||||
date: "Oct 18",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
creditsStore.fetchFromServer();
|
||||
subscriptionStore.fetchFromServer();
|
||||
const fetchBillingData = async () => {
|
||||
// TODO: Replace with actual API call - BFlat
|
||||
// const data = await GetBillingInfo();
|
||||
// setBillingInfo(data.payload);
|
||||
};
|
||||
fetchBillingData();
|
||||
}, []);
|
||||
|
||||
const { toggleModal } = usePricingModalStore();
|
||||
const { toggleModal: toggleCreditsModal } = useCreditsModalStore();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4 text-base-fg">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Current Plan</Label>
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -61,45 +60,32 @@ export const BillingSettingsPane = (args: BillingSettingsPaneProps) => {
|
||||
icon={faStar}
|
||||
className="text-[#C03FFF] text-lg"
|
||||
/>
|
||||
{currentPlanDetails.name}
|
||||
{billingInfo.plan}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{canCancelPlan && <CancelPlanButton />}
|
||||
|
||||
<Button variant="secondary" className="h-[30px]">
|
||||
Cancel plan
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="h-[30px]"
|
||||
onClick={() => toggleSubscriptionModal()}
|
||||
onClick={() => {
|
||||
toggleModal();
|
||||
}}
|
||||
>
|
||||
{changeOrUpgradePlanButtonLabel}
|
||||
Upgrade plan
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TODO(bt): expose this information via API
|
||||
<div className="flex items-center gap-2 text-white/50">
|
||||
<FontAwesomeIcon icon={faInfoCircle} />
|
||||
Next {billingInfo.nextPayment.amount} payment due{" "}
|
||||
{billingInfo.nextPayment.date}
|
||||
</div>
|
||||
*/}
|
||||
|
||||
{subscriptionEndAt && (
|
||||
<div className="flex items-center gap-2 text-white/50">
|
||||
<FontAwesomeIcon icon={faInfoCircle} />
|
||||
Subscription ends on {subscriptionEndAt}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{nextBillAt && (
|
||||
<div className="flex items-center gap-2 text-white/50">
|
||||
<FontAwesomeIcon icon={faInfoCircle} />
|
||||
Next payment on {nextBillAt}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<hr className="border-ui-panel-border" />
|
||||
<hr className="border-white/10" />
|
||||
|
||||
<div className="flex flex-col">
|
||||
<Label htmlFor="credits" className="flex items-center gap-2">
|
||||
@@ -111,56 +97,22 @@ export const BillingSettingsPane = (args: BillingSettingsPaneProps) => {
|
||||
icon={faCoinFront}
|
||||
className="text-primary text-lg"
|
||||
/>
|
||||
<span className="text-2xl font-bold">{sumTotalCredits}</span>
|
||||
<span className="text-2xl font-bold">
|
||||
{billingInfo.credits.remaining}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<BuyCreditsButton />
|
||||
<Button
|
||||
variant="primary"
|
||||
className="h-[30px]"
|
||||
onClick={() => toggleCreditsModal()}
|
||||
>
|
||||
Buy credits
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CreditsTally creditsStore={creditsStore} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const CancelPlanButton = () => {
|
||||
const handleClick = async () => {
|
||||
await invoke("storyteller_open_customer_portal_cancel_plan_command");
|
||||
};
|
||||
|
||||
return (
|
||||
<Button variant="secondary" className="h-[30px]" onClick={handleClick}>
|
||||
Cancel plan
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const BuyCreditsButton = () => {
|
||||
const { toggleModal: toggleCreditsModal } = useCreditsModalStore();
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="primary"
|
||||
className="h-[30px]"
|
||||
onClick={() => toggleCreditsModal()}
|
||||
>
|
||||
Buy credits
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const CreditsTally = ({ creditsStore }: { creditsStore: CreditsState }) => {
|
||||
return (
|
||||
<div className="flex pl-5 pt-3">
|
||||
<ul className="list-disc">
|
||||
<li>
|
||||
{" "}
|
||||
{creditsStore.monthlyCredits} monthly credits (refilled monthly){" "}
|
||||
</li>
|
||||
<li> {creditsStore.bankedCredits} purchased credits </li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -11,12 +11,6 @@
|
||||
{
|
||||
"path": "../../model-list"
|
||||
},
|
||||
{
|
||||
"path": "../../state/subscription"
|
||||
},
|
||||
{
|
||||
"path": "../../state/credits"
|
||||
},
|
||||
{
|
||||
"path": "../pricing-modal"
|
||||
},
|
||||
|
||||
@@ -52,12 +52,6 @@
|
||||
{
|
||||
"path": "../../model-list/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../state/subscription/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../state/credits/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../pricing-modal/tsconfig.lib.json"
|
||||
},
|
||||
|
||||
Generated
+1
-4
@@ -1291,10 +1291,7 @@
|
||||
},
|
||||
"libs/components/pricing-modal": {
|
||||
"name": "@storyteller/ui-pricing-modal",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.4.0"
|
||||
}
|
||||
"version": "0.0.1"
|
||||
},
|
||||
"libs/components/progress": {
|
||||
"name": "@storyteller/ui-progress",
|
||||
|
||||
Reference in new issue
Block a user