mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-20 17:02:53 -04:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57c109c0bc | ||
|
|
181db30992 | ||
|
|
66ffe52cc2 | ||
|
|
f99c86b173 | ||
|
|
b003ccef27 | ||
|
|
2f9b979de1 |
@@ -6,6 +6,8 @@ import {
|
||||
faFilm,
|
||||
faPaintbrush,
|
||||
faImage,
|
||||
faDiamond,
|
||||
faGem,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { AuthButtons } from "./AuthButtons";
|
||||
@@ -34,6 +36,7 @@ import {
|
||||
is3DSceneLoaded,
|
||||
set3DPageMounted,
|
||||
} from "~/pages/PageEnigma/Editor/editor";
|
||||
import { usePricingModalStore } from "@storyteller/ui-pricing-modal";
|
||||
interface Props {
|
||||
pageName: string;
|
||||
}
|
||||
@@ -92,6 +95,8 @@ export const TopBar = ({ pageName }: Props) => {
|
||||
);
|
||||
};
|
||||
|
||||
const { toggleModal } = usePricingModalStore();
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="fixed left-0 top-0 z-[60] w-full border-b border-white/5 bg-ui-background">
|
||||
@@ -141,6 +146,14 @@ export const TopBar = ({ pageName }: Props) => {
|
||||
|
||||
<div className="flex justify-end gap-3.5">
|
||||
<div className="flex gap-2">
|
||||
{/* <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"
|
||||
>
|
||||
Upgrade Now
|
||||
</Button> */}
|
||||
<Tooltip content="Settings" position="bottom" delay={300}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
|
||||
@@ -27,6 +27,7 @@ import { useGenerationCompleteEvent } from "./TauriEvents/generations/useGenerat
|
||||
import { PageEditor } from "~/pages/PageEnigma/PageEditor";
|
||||
import { GalleryDragComponent } from "@storyteller/ui-gallery-modal";
|
||||
import { LoadingDots } from "@storyteller/ui-loading";
|
||||
import { PricingModal } from "@storyteller/ui-pricing-modal";
|
||||
|
||||
import {
|
||||
isActionReminderOpen,
|
||||
@@ -132,6 +133,8 @@ export const PageEnigma = ({ sceneToken }: { sceneToken?: string }) => {
|
||||
openAiLogo={currentReminderModalProps.openAiLogo}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PricingModal />
|
||||
</EngineProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -29,6 +29,12 @@ export default {
|
||||
white: colors.white,
|
||||
gray: colors.gray,
|
||||
black: colors.black,
|
||||
blue: colors.blue,
|
||||
green: colors.green,
|
||||
yellow: colors.yellow,
|
||||
purple: colors.purple,
|
||||
pink: colors.pink,
|
||||
orange: colors.orange,
|
||||
primary: {
|
||||
DEFAULT: "#2d81ff",
|
||||
"50": "#eef6ff",
|
||||
|
||||
@@ -65,10 +65,10 @@
|
||||
"path": "../../libs/components/button-dropdown"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/tauri-api"
|
||||
"path": "../../libs/components/action-reminder-modal"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/action-reminder-modal"
|
||||
"path": "../../libs/components/pricing-modal"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/toaster"
|
||||
@@ -82,6 +82,9 @@
|
||||
{
|
||||
"path": "../../libs/components/promptbox"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/tauri-api"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/popover"
|
||||
},
|
||||
@@ -106,6 +109,9 @@
|
||||
{
|
||||
"path": "../../libs/components/gravatar"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/login-modal"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/select"
|
||||
},
|
||||
|
||||
@@ -2,6 +2,15 @@
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../tauri-api"
|
||||
},
|
||||
{
|
||||
"path": "../../api"
|
||||
},
|
||||
{
|
||||
"path": "../image-input"
|
||||
},
|
||||
{
|
||||
"path": "../gallery-modal"
|
||||
},
|
||||
|
||||
@@ -43,6 +43,15 @@
|
||||
"src/**/*.tsx"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../tauri-api/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../api/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../image-input/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../gallery-modal/tsconfig.lib.json"
|
||||
},
|
||||
|
||||
@@ -376,7 +376,7 @@ export const GalleryModal = React.memo(
|
||||
)}
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="border-b border-white/10 p-4 py-3">
|
||||
<div className="grid grid-cols-2 items-center">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<h2 className="text-xl font-semibold">
|
||||
{mode === "select" ? "Select Images" : "My Library"}
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "../button"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
|
||||
@@ -41,5 +41,10 @@
|
||||
"src/**/*.jsx",
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../button/tsconfig.lib.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "../loading-spinner"
|
||||
},
|
||||
{
|
||||
"path": "../../tauri-api"
|
||||
},
|
||||
@@ -9,7 +12,7 @@
|
||||
"path": "../button"
|
||||
},
|
||||
{
|
||||
"path": "../close-button"
|
||||
"path": "../modal"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
|
||||
@@ -43,6 +43,9 @@
|
||||
"src/**/*.tsx"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../loading-spinner/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../tauri-api/tsconfig.lib.json"
|
||||
},
|
||||
@@ -50,7 +53,7 @@
|
||||
"path": "../button/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../close-button/tsconfig.lib.json"
|
||||
"path": "../modal/tsconfig.lib.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"presets": [
|
||||
[
|
||||
"@nx/react/babel",
|
||||
{
|
||||
"runtime": "automatic",
|
||||
"useBuiltIns": "usage"
|
||||
}
|
||||
]
|
||||
],
|
||||
"plugins": []
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# @storyteller/ui-pricing-modal
|
||||
|
||||
This library was generated with [Nx](https://nx.dev).
|
||||
|
||||
## Running unit tests
|
||||
|
||||
Run `nx test @storyteller/ui-pricing-modal` to execute the unit tests via [Vitest](https://vitest.dev/).
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@storyteller/ui-pricing-modal",
|
||||
"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,3 @@
|
||||
export * from "./lib/pricing-modal";
|
||||
export * from "./lib/pricing-config";
|
||||
export * from "./lib/pricing-modal-store";
|
||||
@@ -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[],
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
interface SubscriptionState {
|
||||
currentPlanId: string;
|
||||
hasActiveSubscription: boolean;
|
||||
customerId?: string;
|
||||
subscriptionId?: string;
|
||||
billingCycle?: "monthly" | "yearly";
|
||||
}
|
||||
|
||||
interface PricingModalState {
|
||||
isOpen: boolean;
|
||||
subscription: SubscriptionState;
|
||||
openModal: () => void;
|
||||
closeModal: () => void;
|
||||
toggleModal: () => void;
|
||||
setSubscription: (subscription: Partial<SubscriptionState>) => void;
|
||||
updateCurrentPlan: (planId: string, hasActiveSubscription: boolean) => void;
|
||||
}
|
||||
|
||||
export const usePricingModalStore = create<PricingModalState>((set) => ({
|
||||
isOpen: false,
|
||||
subscription: {
|
||||
currentPlanId: "free",
|
||||
hasActiveSubscription: false,
|
||||
},
|
||||
openModal: () => set({ isOpen: true }),
|
||||
closeModal: () => set({ isOpen: false }),
|
||||
toggleModal: () => set((state) => ({ isOpen: !state.isOpen })),
|
||||
setSubscription: (subscription) =>
|
||||
set((state) => ({
|
||||
subscription: { ...state.subscription, ...subscription },
|
||||
})),
|
||||
updateCurrentPlan: (planId, hasActiveSubscription) =>
|
||||
set((state) => ({
|
||||
subscription: {
|
||||
...state.subscription,
|
||||
currentPlanId: planId,
|
||||
hasActiveSubscription,
|
||||
},
|
||||
})),
|
||||
}));
|
||||
@@ -0,0 +1,7 @@
|
||||
/*
|
||||
* Replace this with your own classes
|
||||
*
|
||||
* e.g.
|
||||
* .container {
|
||||
* }
|
||||
*/
|
||||
@@ -0,0 +1,12 @@
|
||||
import { render } from '@testing-library/react';
|
||||
|
||||
import PricingModal from './pricing-modal';
|
||||
|
||||
describe('PricingModal', () => {
|
||||
|
||||
it('should render successfully', () => {
|
||||
const { baseElement } = render(<PricingModal />);
|
||||
expect(baseElement).toBeTruthy();
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,316 @@
|
||||
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";
|
||||
|
||||
const billingTabs = [
|
||||
{ id: "yearly", label: "Yearly" },
|
||||
{ id: "monthly", label: "Monthly" },
|
||||
];
|
||||
|
||||
interface PricingModalProps {
|
||||
currentPlanId?: string;
|
||||
hasActiveSubscription?: boolean;
|
||||
}
|
||||
|
||||
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 handleUpgrade = async (tierId: string) => {
|
||||
// TODO: Implement Stripe checkout
|
||||
const tier = pricingConfig.tiers.find((t) => t.id === tierId);
|
||||
const priceId = isYearly ? tier?.yearlyPriceId : tier?.monthlyPriceId;
|
||||
|
||||
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 handleManageSubscription = () => {
|
||||
// TODO: Redirect to Stripe customer portal
|
||||
console.log("Managing subscription");
|
||||
};
|
||||
|
||||
const tierHierarchy = { free: 0, basic: 1, pro: 2, max: 3 };
|
||||
|
||||
const isCurrentPlan = (tierId: string) => {
|
||||
return tierId === activePlanId;
|
||||
};
|
||||
|
||||
const getButtonText = (tier: PricingTier) => {
|
||||
if (isCurrentPlan(tier.id)) {
|
||||
return "Current Plan";
|
||||
}
|
||||
|
||||
if (activePlanId && activePlanId !== "free") {
|
||||
const currentTierLevel =
|
||||
tierHierarchy[activePlanId as keyof typeof tierHierarchy];
|
||||
const thisTierLevel =
|
||||
tierHierarchy[tier.id as keyof typeof tierHierarchy];
|
||||
|
||||
if (thisTierLevel < currentTierLevel) {
|
||||
return "Switch Plan";
|
||||
}
|
||||
}
|
||||
|
||||
return "Upgrade Plan";
|
||||
};
|
||||
|
||||
const getColorSchemeClasses = (
|
||||
colorScheme: PricingTier["colorScheme"],
|
||||
isCurrent: boolean
|
||||
) => {
|
||||
const baseClasses =
|
||||
"relative rounded-xl border p-8 h-full flex flex-col transition-all duration-300 ring-[6px] ring-transparent";
|
||||
|
||||
switch (colorScheme) {
|
||||
case "dark":
|
||||
return twMerge(
|
||||
baseClasses,
|
||||
"bg-[#2C2C2C] border-gray-600",
|
||||
isCurrent && "ring-white/70"
|
||||
);
|
||||
case "green":
|
||||
// Basic
|
||||
return twMerge(
|
||||
baseClasses,
|
||||
"bg-gradient-to-b from-[#002D23] via-[#006B54] to-[#00D28B] border-[#00a873]",
|
||||
"hover:shadow-[0_0_30px_rgba(0,210,139,0.4)]",
|
||||
isCurrent && "ring-[#00D28B] shadow-[0_0_30px_rgba(0,210,139,0.4)]"
|
||||
);
|
||||
case "purple":
|
||||
// Pro
|
||||
return twMerge(
|
||||
baseClasses,
|
||||
"bg-gradient-to-b from-[#2D004D] via-[#6400A8] to-[#C03FFF] border-[#9D4CFF]",
|
||||
"hover:shadow-[0_0_30px_rgba(192,63,255,0.4)]",
|
||||
isCurrent && "ring-[#C03FFF] shadow-[0_0_30px_rgba(192,63,255,0.4)]"
|
||||
);
|
||||
case "orange":
|
||||
// Max
|
||||
return twMerge(
|
||||
baseClasses,
|
||||
"bg-gradient-to-b from-[#332100] via-[#B35C00] to-[#FFB347] border-[#FF8C00]",
|
||||
"hover:shadow-[0_0_30px_rgba(255,179,71,0.4)]",
|
||||
isCurrent && "ring-[#FFB347] shadow-[0_0_30px_rgba(255,179,71,0.4)]"
|
||||
);
|
||||
default:
|
||||
return twMerge(
|
||||
baseClasses,
|
||||
"bg-[#2C2C2C] border-gray-600",
|
||||
"hover:shadow-[0_0_30px_rgba(255,255,255,0.3)]",
|
||||
isCurrent && "ring-white/70"
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const formatPrice = (tier: PricingTier) => {
|
||||
if (tier.monthlyPrice === 0) {
|
||||
return {
|
||||
current: "$0",
|
||||
original: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (isYearly) {
|
||||
const discountedMonthlyPrice = Math.round(tier.yearlyPrice / 12);
|
||||
const originalMonthlyPrice = tier.originalYearlyPrice
|
||||
? Math.round(tier.originalYearlyPrice / 12)
|
||||
: null;
|
||||
|
||||
return {
|
||||
current: `$${discountedMonthlyPrice}`,
|
||||
original: originalMonthlyPrice ? `$${originalMonthlyPrice}` : null,
|
||||
};
|
||||
} else {
|
||||
const monthlyPrice = tier.originalMonthlyPrice || tier.monthlyPrice;
|
||||
|
||||
return {
|
||||
current: `$${monthlyPrice}`,
|
||||
original: null,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={closeModal}
|
||||
className="rounded-xl bg-[#1A1A1A] max-h-[90vh] max-w-screen-2xl overflow-y-auto flex flex-col"
|
||||
allowBackgroundInteraction={true}
|
||||
showClose={true}
|
||||
closeOnOutsideClick={true}
|
||||
resizable={false}
|
||||
backdropClassName="bg-black/80"
|
||||
>
|
||||
<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-white mb-4">
|
||||
{pricingConfig.header.title}
|
||||
</h1>
|
||||
<p className="text-gray-400 text-lg mb-6">
|
||||
{pricingConfig.header.subtitle}
|
||||
</p>
|
||||
|
||||
{/* Billing Toggle */}
|
||||
<div className="flex items-center justify-center gap-4 mb-8 relative w-fit mx-auto">
|
||||
<TabSelector
|
||||
tabs={billingTabs}
|
||||
activeTab={billingType}
|
||||
onTabChange={setBillingType}
|
||||
className="w-fit"
|
||||
tabClassName="w-24 text-md"
|
||||
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}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pricing Tiers */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-10">
|
||||
{pricingConfig.tiers.map((tier) => {
|
||||
const pricing = formatPrice(tier);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tier.id}
|
||||
className={getColorSchemeClasses(
|
||||
tier.colorScheme,
|
||||
isCurrentPlan(tier.id)
|
||||
)}
|
||||
>
|
||||
{/* Current Plan Badge */}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* Tier Header */}
|
||||
<div className="text-center mb-8">
|
||||
<h3 className="text-4xl font-bold text-white mb-4">
|
||||
{tier.name}
|
||||
</h3>
|
||||
<div className="flex items-baseline justify-center gap-2">
|
||||
{pricing.original && (
|
||||
<span className="text-white/60 line-through text-2xl">
|
||||
{pricing.original}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-4xl font-bold text-white">
|
||||
{pricing.current}
|
||||
</span>
|
||||
<span className="text-white/60 text-sm">/month</span>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{tier.features.map((feature, index) => (
|
||||
<div key={index} className="flex items-start gap-3">
|
||||
<div
|
||||
className={twMerge(
|
||||
"w-5 h-5 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5",
|
||||
feature.included
|
||||
? "bg-white text-black"
|
||||
: "bg-transparent border border-gray-400"
|
||||
)}
|
||||
>
|
||||
{feature.included && (
|
||||
<svg
|
||||
className="w-3 h-3"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={twMerge(
|
||||
"text-sm mt-0.5",
|
||||
feature.included ? "text-white" : "text-gray-400"
|
||||
)}
|
||||
>
|
||||
{feature.text}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* CTA Button */}
|
||||
<Button
|
||||
onClick={() => handleUpgrade(tier.id)}
|
||||
disabled={isCurrentPlan(tier.id)}
|
||||
className="w-full bg-white text-black hover:bg-white/90 h-12 rounded-xl"
|
||||
>
|
||||
{getButtonText(tier)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Manage Subscription Button - only show if user has a paid plan */}
|
||||
{hasActiveSub && activePlanId !== "free" && (
|
||||
<div className="flex justify-center mt-8">
|
||||
<Button
|
||||
onClick={handleManageSubscription}
|
||||
className="bg-transparent border border-white/25 text-white hover:bg-white/10 px-8 py-3 rounded-xl"
|
||||
>
|
||||
Manage your subscription
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// Additional interfaces for Stripe integration
|
||||
export interface SubscriptionData {
|
||||
currentPlanId: string;
|
||||
hasActiveSubscription: boolean;
|
||||
customerId?: string;
|
||||
subscriptionId?: string;
|
||||
billingCycle?: "monthly" | "yearly";
|
||||
nextBillingDate?: Date;
|
||||
}
|
||||
|
||||
export default PricingModal;
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "../button"
|
||||
},
|
||||
{
|
||||
"path": "../modal"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
],
|
||||
"extends": "../../../tsconfig.base.json"
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"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"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../button/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../modal/tsconfig.lib.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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/pricing-modal',
|
||||
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-pricing-modal',
|
||||
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,
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -15,6 +15,9 @@ interface TabSelectorProps {
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
disabledMessage?: string;
|
||||
tabClassName?: string;
|
||||
indicatorClassName?: string;
|
||||
selectedTabClassName?: string;
|
||||
}
|
||||
|
||||
export const TabSelector: React.FC<TabSelectorProps> = ({
|
||||
@@ -24,6 +27,9 @@ export const TabSelector: React.FC<TabSelectorProps> = ({
|
||||
className,
|
||||
disabled,
|
||||
disabledMessage,
|
||||
tabClassName,
|
||||
indicatorClassName,
|
||||
selectedTabClassName,
|
||||
}) => {
|
||||
// Find the index of the active tab
|
||||
const selectedIndex = tabs.findIndex((tab) => tab.id === activeTab);
|
||||
@@ -63,7 +69,10 @@ export const TabSelector: React.FC<TabSelectorProps> = ({
|
||||
<TabList className="glass glass-no-hover relative inline-flex min-w-fit overflow-x-auto rounded-lg p-0.5 py-1 !shadow-none">
|
||||
{/* Animated indicator */}
|
||||
<div
|
||||
className="absolute top-1 z-10 h-[calc(100%-8px)] rounded-md bg-primary/30 transition-all duration-200 ease-in-out"
|
||||
className={twMerge(
|
||||
"absolute top-1 z-10 h-[calc(100%-8px)] rounded-md bg-primary/30 transition-all duration-200 ease-in-out",
|
||||
indicatorClassName
|
||||
)}
|
||||
style={{
|
||||
left: indicatorStyle.left,
|
||||
width: indicatorStyle.width,
|
||||
@@ -83,8 +92,11 @@ export const TabSelector: React.FC<TabSelectorProps> = ({
|
||||
twMerge(
|
||||
"relative z-20 mx-0.5 min-w-max rounded-md border-2 border-transparent px-4 py-0.5 text-center text-sm font-semibold transition-all duration-200 ease-in-out",
|
||||
"focus-visible:outline-none focus-visible:ring-0",
|
||||
selected ? "text-white" : "text-gray-300 hover:text-white",
|
||||
disabled ? "cursor-not-allowed opacity-60" : ""
|
||||
selected
|
||||
? twMerge("text-white", selectedTabClassName)
|
||||
: "text-gray-300 hover:text-white",
|
||||
disabled ? "cursor-not-allowed opacity-60" : "",
|
||||
tabClassName
|
||||
)
|
||||
}
|
||||
>
|
||||
|
||||
Generated
+8
@@ -1466,6 +1466,10 @@
|
||||
"name": "@storyteller/ui-popover",
|
||||
"version": "0.0.1"
|
||||
},
|
||||
"libs/components/pricing-modal": {
|
||||
"name": "@storyteller/ui-pricing-modal",
|
||||
"version": "0.0.1"
|
||||
},
|
||||
"libs/components/promptbox": {
|
||||
"name": "@storyteller/ui-promptbox",
|
||||
"version": "0.0.1"
|
||||
@@ -9765,6 +9769,10 @@
|
||||
"resolved": "libs/components/popover",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@storyteller/ui-pricing-modal": {
|
||||
"resolved": "libs/components/pricing-modal",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@storyteller/ui-promptbox": {
|
||||
"resolved": "libs/components/promptbox",
|
||||
"link": true
|
||||
|
||||
@@ -140,6 +140,9 @@
|
||||
},
|
||||
{
|
||||
"path": "./libs/components/image-input"
|
||||
},
|
||||
{
|
||||
"path": "./libs/components/pricing-modal"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user