redesign pricing and entitlements

This commit is contained in:
Ayush Rodrigues
2025-04-04 22:02:50 +01:00
parent dc3d491f29
commit 17f1164f4c
24 changed files with 944 additions and 318 deletions

View File

@@ -162,12 +162,12 @@ featureApiRouter.post("", async (req: any, res) => {
...parsedFeature,
};
await FeatureService.insert({
let insertedFeature = await FeatureService.insert({
sb: req.sb,
data: feature,
});
res.status(200).json({ message: "Feature created" });
res.status(200).json(insertedFeature);
} catch (error) {
handleRequestError({ req, error, res, action: "Create feature" });
}

View File

@@ -138,7 +138,8 @@ export class FeatureService {
let { data: insertedData, error } = await sb
.from("features")
.insert(data)
.select();
.select()
.single();
if (error) {
if (error.code === "23505") {

BIN
vite/public/metal.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

BIN
vite/public/metal2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

BIN
vite/public/metal3.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 778 KiB

View File

@@ -47,7 +47,7 @@ export function MainLayout() {
<MainSidebar />
<div className="w-full h-screen flex flex-col overflow-hidden">
{env === AppEnv.Sandbox && (
<div className="w-full h-10 bg-primary/80 text-white text-sm flex items-center justify-center">
<div className="w-full h-10 bg-primary text-white text-sm flex items-center justify-center">
<p className="font-medium">You&apos;re in sandbox mode.</p>
</div>
)}
@@ -76,7 +76,17 @@ export function MainLayout() {
// 3. If user, but no org, redirect to onboarding
return (
<main className="w-screen h-screen flex">
<main
className="w-screen h-screen flex"
style={{
backgroundImage: "url('/metal.jpg')",
backgroundSize: "cover",
backgroundPosition: "top left",
backgroundRepeat: "no-repeat",
backgroundColor: "rgba(255, 255, 255, 0)",
backgroundBlendMode: "overlay",
}}
>
<Toaster
position="top-center"
className="flex justify-center"
@@ -103,13 +113,13 @@ const MainContent = () => {
return (
<div className="w-full h-screen flex flex-col overflow-hidden">
{env === AppEnv.Sandbox && (
<div className="w-full h-10 bg-primary/80 text-white text-sm flex items-center justify-center">
<div className="w-full h-10 bg-primary text-white text-sm flex items-center justify-center">
<p className="font-medium">You&apos;re in sandbox mode.</p>
</div>
)}
<div
className={cn(
"w-full h-full overflow-auto bg-stone-50 p-6 flex justify-center"
"w-full h-full overflow-auto p-6 flex justify-center bg-stone-50"
)}
>
<div className="hidden md:flex w-full h-full max-w-[1048px] flex-col gap-4">

View File

@@ -1,13 +1,13 @@
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDownIcon } from "lucide-react"
import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDownIcon } from "lucide-react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
}
function AccordionItem({
@@ -17,10 +17,10 @@ function AccordionItem({
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
className={cn("", className)}
{...props}
/>
)
);
}
function AccordionTrigger({
@@ -33,7 +33,7 @@ function AccordionTrigger({
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
@@ -42,7 +42,7 @@ function AccordionTrigger({
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
);
}
function AccordionContent({
@@ -58,7 +58,7 @@ function AccordionContent({
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
)
);
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View File

@@ -57,7 +57,7 @@ function DialogContent({
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
`bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg
`bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-xl
bg-stone-50

View File

@@ -3,6 +3,7 @@ import {
BillingInterval,
BillWhen,
EntitlementWithFeature,
PriceType,
} from "@autumn/shared";
import { FixedPriceConfig, Price, UsagePriceConfig } from "@autumn/shared";
@@ -55,3 +56,30 @@ export const getBillingUnits = (
return `${entitlement.allowance} `;
};
export const getDefaultPriceConfig = (type: PriceType) => {
if (type === PriceType.Fixed) {
return {
type: PriceType.Fixed,
amount: "",
interval: BillingInterval.Month,
};
}
return {
type: PriceType.Usage,
internal_feature_id: "",
feature_id: "",
bill_when: BillWhen.EndOfPeriod,
interval: BillingInterval.Month,
billing_units: 1,
usage_tiers: [
{
from: 0,
to: -1,
amount: 0.0,
},
],
should_prorate: false,
};
};

View File

@@ -9,31 +9,45 @@ import {
import { PlusIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { FeatureType } from "@autumn/shared";
import { Entitlement, Feature, FeatureType } from "@autumn/shared";
import { useFeaturesContext } from "./FeaturesContext";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { FeatureService } from "@/services/FeatureService";
import { FeatureConfig } from "./metered-features/FeatureConfig";
import { getBackendErr } from "@/utils/genUtils";
import { validateFeature } from "./featureUtils";
import { getFeature } from "@/utils/product/entitlementUtils";
const defaultFeature = {
type: FeatureType.Metered,
config: {filters: [
config: {
filters: [
{
property: "",
operator: "",
value: [],
},
]},
],
},
name: "",
id: "",
};
export const CreateFeature = () => {
const { env, mutate } = useFeaturesContext();
export const CreateFeature = ({
isFromEntitlement,
setShowFeatureCreate,
setSelectedFeature,
setOpen,
open,
}: {
isFromEntitlement: boolean;
setShowFeatureCreate: (show: boolean) => void;
setSelectedFeature: (feature: Feature) => void;
setOpen: (open: boolean) => void;
open: boolean;
}) => {
const { env, mutate, features } = useFeaturesContext();
const axiosInstance = useAxiosInstance({ env });
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [feature, setFeature] = useState(defaultFeature);
const [eventNameInput, setEventNameInput] = useState("");
@@ -54,8 +68,8 @@ export const CreateFeature = () => {
property: "",
operator: "",
value: [],
}
]
},
],
},
name: "",
id: "",
@@ -90,14 +104,25 @@ export const CreateFeature = () => {
setLoading(true);
try {
await FeatureService.createFeature(axiosInstance, {
let { data: createdFeature } = await FeatureService.createFeature(
axiosInstance,
{
name: feature.name,
id: feature.id,
type: feature.type,
config: updateConfig(),
});
mutate();
}
);
await mutate();
if (isFromEntitlement) {
if (createdFeature) {
setSelectedFeature(createdFeature);
}
setShowFeatureCreate(false);
} else {
setOpen(false);
}
} catch (error) {
toast.error(getBackendErr(error, "Failed to create feature"));
}
@@ -105,23 +130,7 @@ export const CreateFeature = () => {
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
startIcon={<PlusIcon size={15} />}
variant="dashed"
className="w-full"
onClick={() => {
setFeatureToDefault();
}}
>
Create Feature
</Button>
</DialogTrigger>
<DialogContent className="w-[500px]">
<DialogHeader>
<DialogTitle>Create Feature</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<FeatureConfig
feature={feature}
setFeature={setFeature}
@@ -130,16 +139,45 @@ export const CreateFeature = () => {
eventNameChanged={eventNameChanged}
setEventNameChanged={setEventNameChanged}
/>
<DialogFooter>
<Button
onClick={handleCreateFeature}
isLoading={loading}
className="w-fit"
variant="gradientPrimary"
>
Create
Create Feature
</Button>
</DialogFooter>
</div>
);
};
export const CreateFeatureDialog = () => {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
startIcon={<PlusIcon size={15} />}
variant="dashed"
className="w-full"
>
Create Feature
</Button>
</DialogTrigger>
<DialogContent className="w-[500px]">
<DialogHeader>
<DialogTitle>Create Feature</DialogTitle>
</DialogHeader>
<CreateFeature
isFromEntitlement={false}
setShowFeatureCreate={() => {}}
setSelectedFeature={() => {}}
setOpen={setOpen}
open={open}
/>
</DialogContent>
</Dialog>
);

View File

@@ -4,7 +4,7 @@ import React, { useEffect, useState } from "react";
import { FeaturesContext } from "./FeaturesContext";
import { useAxiosSWR } from "@/services/useAxiosSwr";
import { Feature, FeatureType } from "@autumn/shared";
import { CreateFeature } from "./CreateFeature";
import { CreateFeature, CreateFeatureDialog } from "./CreateFeature";
import { AppEnv } from "@autumn/shared";
import LoadingScreen from "../general/LoadingScreen";
import { FeaturesTable } from "./FeaturesTable";
@@ -76,7 +76,7 @@ function FeaturesView({ env }: { env: AppEnv }) {
</div>
<FeaturesTable />
<CreateFeature />
<CreateFeatureDialog />
{showCredits && (
<div className="flex flex-col gap-4 h-fit mt-6">

View File

@@ -37,7 +37,7 @@ export const EnvDropdown = ({ env }: { env: AppEnv }) => {
{expanded ? (
<DropdownMenuTrigger
className={cn(
"ring-0 focus:ring-0 border bg-white text-t2 rounded-sm w-full flex items-center p-2 h-8",
"ring-0 focus:ring-0 bg-white text-t2 rounded-sm w-full flex items-center bg-transparent pl-2 h-8",
state != "expanded" && "!w-6 !h-6 p-0 items-center justify-center"
)}
>

View File

@@ -21,7 +21,7 @@ export const MainSidebar = () => {
<SidebarContext.Provider value={{ state, setState }}>
<div
className={cn(
`h-full bg-zinc-100 py-4 flex flex-col justify-between transition-all duration-150`,
`h-full bg-zinc-100/80 py-4 flex flex-col justify-between transition-all duration-150 border-r border-zinc-300`,
state == "expanded"
? "min-w-[200px] max-w-[200px]"
: "min-w-[50px] max-w-[50px]"

View File

@@ -35,22 +35,22 @@ export const NavButton = ({
onMouseLeave={() => setIsHovered(false)}
className={cn(
`cursor-pointer text-t2 font-medium transition-all duration-100
text-black text-sm flex h-9 items-center text-t2 hover:text-primary`,
isActive && "font-bold text-primary hover:text-primary/80",
text-black text-sm flex h-9 items-center text-t1 hover:text-primary`,
isActive && "font-semibold text-primary hover:text-primary/80",
className
)}
target={href ? "_blank" : undefined}
>
<div
className={cn(
"flex justify-center w-4 h-4 items-center bg-zinc-100 rounded-sm transition-all duration-100",
"flex justify-center w-4 h-4 items-center rounded-sm transition-all duration-100",
state == "expanded" && "mr-2",
isHovered && "translate-x-[-1px]"
)}
>
{icon}
</div>
{state == "expanded" && <span className=" font-semibold">{title}</span>}
{state == "expanded" && <span className="">{title}</span>}
</Link>
);
};

View File

@@ -48,7 +48,6 @@ export const ManageProduct = ({
)}
</div>
</div>
<div className="flex items-center gap-2">
{customerData && (
<Badge className="flex items-center gap-1 w-fit text-xs text-lime-600 bg-lime-50 border border-lime-200">
@@ -73,9 +72,8 @@ export const ManageProduct = ({
)}
</div>
</div>
<div className="flex flex-col gap-4">
<p className="text-md text-t2 font-medium">Entitlements</p>
<p className="text-md text-t2 font-medium">Features</p>
{product.entitlements.length > 0 && (
<ProductEntitlementTable entitlements={product.entitlements} />
)}

View File

@@ -0,0 +1,114 @@
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Separator } from "@/components/ui/separator";
import { useProductContext } from "./ProductContext";
export default function ProductSidebar() {
const { product, org } = useProductContext();
const getPriceDisplay = (price: any) => {
const currency = org?.default_currency || "USD";
if (price.config.type === "fixed") {
const formattedAmount = new Intl.NumberFormat("en-US", {
style: "currency",
currency: currency,
}).format(price.config.amount);
return `${formattedAmount}/${price.config.interval}`;
} else if (price.config.type === "usage") {
const formatUsageAmount = (amount: number) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: currency,
minimumFractionDigits: 0,
maximumFractionDigits: 10,
}).format(amount);
};
const amounts = price.config.usage_tiers.map((tier: any) => tier.amount);
if (amounts.length > 1) {
const minAmount = formatUsageAmount(Math.min(...amounts));
const maxAmount = formatUsageAmount(Math.max(...amounts));
return `${minAmount} - ${maxAmount} per unit`;
}
return `${formatUsageAmount(amounts[0])} per unit`;
}
return "N/A";
};
return (
<div className="flex flex-col gap-4 border-l border-stone-300 h-full">
<Accordion
type="multiple"
defaultValue={["pricing"]}
className="w-full flex flex-col gap-4"
>
<div className="pl-4">
<AccordionItem
value="entitlements"
className="data-[state=open]:bg-white data-[state=open]:border-zinc-200 border border-transparent transition-all duration-100 ease-out origin-top"
>
<div className="w-fit">
<AccordionTrigger className="data-[state=closed]:hover:bg-white data-[state=closed]:hover:border-zinc-200 border border-transparent text-t2 p-2">
<span>Features Included</span>
</AccordionTrigger>
</div>
<AccordionContent>
<div className="flex flex-col gap-2 p-2 px-4">
<div className="flex items-center justify-between">
<span>Status</span>
<span className="text-muted-foreground">Active</span>
</div>
<div className="flex items-center justify-between">
<span>Type</span>
<span className="text-muted-foreground">Standard</span>
</div>
{/* Add more entitlement fields as needed */}
</div>
</AccordionContent>
</AccordionItem>
</div>
<div className="pl-4">
<AccordionItem
value="pricing"
className="data-[state=open]:bg-white data-[state=open]:border-zinc-200 border border-transparent transition-all duration-100 ease-out origin-top"
>
<div className="w-fit">
<AccordionTrigger className="data-[state=closed]:hover:bg-white data-[state=closed]:hover:border-zinc-200 border border-transparent text-t2 p-2">
<span>Pricing</span>
</AccordionTrigger>
</div>
<AccordionContent>
<div className="flex flex-col gap-2 p-2">
{product.prices.map((price: any, index: number) => (
<div key={index} className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<span>{price.name}</span>
<span className="text-muted-foreground">
{getPriceDisplay(price)}
</span>
</div>
<span className="text-xs text-muted-foreground">
{price.config.type.charAt(0).toUpperCase() +
price.config.type.slice(1)}
</span>
</div>
))}
{product.prices.length === 0 && (
<span className="text-muted-foreground">
No pricing configured
</span>
)}
</div>
</AccordionContent>
</AccordionItem>
</div>
</Accordion>
</div>
);
}

View File

@@ -21,6 +21,8 @@ import {
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import ErrorScreen from "@/views/general/ErrorScreen";
import ProductSidebar from "./ProductSidebar";
import { FeaturesContext } from "@/views/features/FeaturesContext";
function ProductView({ env }: { env: AppEnv }) {
const { product_id } = useParams();
@@ -36,8 +38,6 @@ function ProductView({ env }: { env: AppEnv }) {
env,
});
console.log(data);
//this is to make sure pricing for unlimited entitlements can't be applied
const [selectedEntitlementAllowance, setSelectedEntitlementAllowance] =
useState<"unlimited" | number>(0);
@@ -119,6 +119,12 @@ function ProductView({ env }: { env: AppEnv }) {
};
return (
<FeaturesContext.Provider
value={{
env,
mutate,
}}
>
<ProductContext.Provider
value={{
...data,
@@ -139,15 +145,21 @@ function ProductView({ env }: { env: AppEnv }) {
>
Products
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem className="cursor-pointer">
{product.name ? product.name : product.id}
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="flex gap-8">
<div className="flex-1 w-full min-w-sm">
<ManageProduct product={product} />
</div>
<div className="max-w-[300px] w-1/3 shrink-1">
<ProductSidebar />
</div>
</div>
</div>
<div className="flex justify-end gap-2">
<AddProductButton
handleCreateProduct={handleCreateProduct}
@@ -155,6 +167,7 @@ function ProductView({ env }: { env: AppEnv }) {
/>
</div>
</ProductContext.Provider>
</FeaturesContext.Provider>
);
}

View File

@@ -10,12 +10,24 @@ import { PlusIcon } from "lucide-react";
import { useState } from "react";
import { useProductContext } from "../ProductContext";
import { EntitlementConfig } from "./EntitlementConfig";
import { CreateEntitlementSchema, Entitlement } from "@autumn/shared";
import { CreateEntitlementSchema, Entitlement, Feature } from "@autumn/shared";
import { CreateFeature } from "@/views/features/CreateFeature";
import { FeatureConfig } from "@/views/features/metered-features/FeatureConfig";
import { FeaturesContext } from "@/views/features/FeaturesContext";
import { getFeature } from "@/utils/product/entitlementUtils";
export const CreateEntitlement = () => {
const [open, setOpen] = useState(false);
const [entitlement, setEntitlement] = useState<Entitlement | null>(null);
const { product, setProduct } = useProductContext();
const { features, product, setProduct } = useProductContext();
// console.log("Entitlement", entitlement);
const [selectedFeature, setSelectedFeature] = useState<Feature | null>(
getFeature(entitlement?.internal_feature_id, features) || null
);
const [showFeatureCreate, setShowFeatureCreate] = useState(false);
const handleCreateEntitlement = async () => {
const newEntitlement = CreateEntitlementSchema.parse(entitlement);
@@ -37,26 +49,60 @@ export const CreateEntitlement = () => {
startIcon={<PlusIcon size={15} />}
variant="dashed"
className="w-full"
onClick={() => {
setSelectedFeature(null);
setEntitlement(null);
}}
>
Create Entitlement
Add Feature
</Button>
</DialogTrigger>
<DialogContent className="">
<DialogContent className="translate-y-[0%] top-[20%]">
<DialogHeader>
<DialogTitle>Create Entitlement</DialogTitle>
<div className="flex flex-col">
{showFeatureCreate && (
<Button
variant="ghost"
className="text-xs py-0 px-2 w-fit -ml-5 -mt-7 hover:bg-transparent"
onClick={() => setShowFeatureCreate(false)}
>
Product
</Button>
)}
<DialogTitle>Add Feature</DialogTitle>
</div>
</DialogHeader>
<div className="flex w-full overflow-hidden">
{showFeatureCreate ? (
<div className="w-full">
<CreateFeature
isFromEntitlement={true}
setShowFeatureCreate={setShowFeatureCreate}
setSelectedFeature={setSelectedFeature}
setOpen={setOpen}
open={open}
/>
</div>
) : (
<div className="w-full flex flex-col gap-4">
<EntitlementConfig
entitlement={entitlement}
setEntitlement={setEntitlement}
setShowFeatureCreate={setShowFeatureCreate}
selectedFeature={selectedFeature}
setSelectedFeature={setSelectedFeature}
/>
</div>
<DialogFooter>
<Button onClick={handleCreateEntitlement} variant="gradientPrimary">
Create
<Button
onClick={handleCreateEntitlement}
variant="gradientPrimary"
>
Add to Product
</Button>
</DialogFooter>
</div>
)}
</div>
</DialogContent>
</Dialog>
);

View File

@@ -11,10 +11,12 @@ import {
import {
AppEnv,
BillingInterval,
CreateEntitlementSchema,
Entitlement,
Feature,
FeatureType,
PriceType,
} from "@autumn/shared";
import { useEffect, useState } from "react";
import { Input } from "@/components/ui/input";
@@ -28,7 +30,7 @@ import {
} from "@autumn/shared";
import { getFeature } from "@/utils/product/entitlementUtils";
import { Button } from "@/components/ui/button";
import { EllipsisVertical, InfoIcon, PlusIcon } from "lucide-react";
import { EllipsisVertical, InfoIcon, MinusIcon, PlusIcon } from "lucide-react";
import { useNavigate } from "react-router";
import { Checkbox } from "@/components/ui/checkbox";
@@ -43,15 +45,28 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { CreateFeature } from "@/views/features/CreateFeature";
import { ToggleDisplayButton } from "@/components/general/ToggleDisplayButton";
import CreateUsagePrice from "../prices/CreateUsagePrice";
import TieredPrice from "./TieredPrice";
import { getDefaultPriceConfig } from "@/utils/product/priceUtils";
import MoreMenuButton from "./MoreMenuButton";
import { SelectCycle } from "./SelectCycle";
export const EntitlementConfig = ({
isUpdate = false,
entitlement,
setEntitlement,
setShowFeatureCreate,
selectedFeature,
setSelectedFeature,
}: {
isUpdate?: boolean;
entitlement: EntitlementWithFeature | Entitlement | null;
setEntitlement: (entitlement: EntitlementWithFeature | null) => void;
setShowFeatureCreate: (show: boolean) => void;
selectedFeature: Feature | null;
setSelectedFeature: (feature: Feature | null) => void;
}) => {
const { features, product, env } = useProductContext();
const navigate = useNavigate();
@@ -62,11 +77,16 @@ export const EntitlementConfig = ({
const [showPerEntity, setShowPerEntity] = useState(
entitlement?.entity_feature_id ? true : false
);
const [showPopover, setShowPopover] = useState(false);
const [selectedFeature, setSelectedFeature] = useState<Feature | null>(
getFeature(entitlement?.internal_feature_id, features) || null
const [showPrice, setShowPrice] = useState(false); // for the add price button
const [showCycle, setShowCycle] = useState(false); // for the add cycle button
const [priceConfig, setPriceConfig] = useState<any>(
getDefaultPriceConfig(PriceType.Usage) // default price config
);
// const [selectedFeature, setSelectedFeature] = useState<Feature | null>(
// getFeature(entitlement?.internal_feature_id, features) || null
// );
const [fields, setFields] = useState({
carry_from_previous: entitlement?.carry_from_previous || false,
allowance_type: entitlement?.allowance_type || AllowanceType.Fixed,
@@ -96,10 +116,12 @@ export const EntitlementConfig = ({
feature: selectedFeature,
});
} else {
console.log("setting entitlement to null");
setEntitlement(null);
}
}, [
selectedFeature,
priceConfig,
fields,
originalEntitlement,
setEntitlement,
@@ -108,7 +130,6 @@ export const EntitlementConfig = ({
return (
<div className="w-full overflow-hidden">
<FieldLabel>Entitlement </FieldLabel>
<Select
value={selectedFeature?.internal_id}
onValueChange={(value) =>
@@ -123,13 +144,13 @@ export const EntitlementConfig = ({
{features
.filter((feature: Feature) => {
if (selectedFeature?.internal_id == feature.internal_id) {
return true;
return true; // show the selected feature in the dropdown
}
const existingEnt = product.entitlements.find(
(ent: Entitlement) =>
ent.internal_feature_id === feature.internal_id
);
return !existingEnt;
return !existingEnt; // show features that are not already in the product
})
.map((feature: Feature) => (
<SelectItem
@@ -144,9 +165,9 @@ export const EntitlementConfig = ({
))}
<Button
className="flex w-full text-xs font-medium bg-white shadow-none text-primary hover:bg-stone-200"
onClick={() => {
window.location.href =
env === AppEnv.Sandbox ? "/sandbox/features" : "/features";
onClick={(e) => {
e.preventDefault();
setShowFeatureCreate(true);
}}
>
<PlusIcon className="w-3 h-3 mr-2" />
@@ -168,93 +189,78 @@ export const EntitlementConfig = ({
})
}
>
<div className="flex justify-between items-center">
{/* <div className="flex justify-between items-center">
<TabsList>
<TabsTrigger value="fixed">Fixed</TabsTrigger>
<TabsTrigger value="unlimited">Unlimited</TabsTrigger>
</TabsList>
<div className="flex gap-1">
<Popover open={showPopover} onOpenChange={setShowPopover}>
<PopoverTrigger asChild>
<Button
className="h-7 p-2 border rounded-none text-t3 text-xs"
variant="outline"
>
<EllipsisVertical size={12} />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-48 p-2 flex flex-col text-xs"
align="end"
>
<div className="flex items-center space-x-2">
<Button
variant="secondary"
className="text-xs text-t3 shadow-none border-none"
onClick={() => {
setFields({
...fields,
carry_from_previous: !fields.carry_from_previous,
});
}}
>
<Checkbox
className="border-t3 mr-1"
checked={fields.carry_from_previous}
onCheckedChange={(checked) =>
setFields({
...fields,
carry_from_previous: Boolean(checked),
})
}
/>
Keep usage on upgrade
</Button>
</div>
<Button
className="h-7 shadow-none text-t3 text-xs justify-start border-none"
variant="outline"
startIcon={<PlusIcon size={14} className="ml-0.5 mr-1" />}
onClick={() => {
setShowPerEntity(!showPerEntity);
// hide the popover
setShowPopover(false);
}}
>
{showPerEntity ? "Remove Entity" : "Per Entity"}
</Button>
</PopoverContent>
</Popover>
</div>
</div>
<TabsContent value="fixed">
<div className="flex gap-2 items-center">
</div> */}
<TabsContent value="fixed" className="flex flex-col gap-4">
<div className="flex flex-col ">
<FieldLabel className="flex items-center gap-2">
Allowance
{showPrice ? "Pricing" : "Included Usage"}
<Tooltip delayDuration={400}>
<TooltipTrigger asChild>
<InfoIcon className="w-3 h-3 text-t3/50" />
</TooltipTrigger>
<TooltipContent sideOffset={5} side="top">
How much usage of this feature is included as part of
this plan
How much usage of this feature is included as part of this
plan
</TooltipContent>
</Tooltip>
</FieldLabel>
{showPrice ? (
<TieredPrice
selectedFeature={selectedFeature}
config={priceConfig}
setShowPrice={setShowPrice}
setConfig={setPriceConfig}
/>
) : (
<Input
placeholder="eg. 100"
className="w-30"
value={fields.allowance}
type="number"
onChange={(e) =>
setFields({
...fields,
allowance: e.target.value,
})
className=""
value={
priceConfig.usage_tiers[0].to > 0
? priceConfig.usage_tiers[0].to
: ""
}
type="number"
onChange={(e) => {
if (Number(e.target.value) > 0) {
setPriceConfig({
...priceConfig,
usage_tiers: [
{
from: 0,
to: e.target.value,
amount: 0.0,
},
{
from: e.target.value,
to: -1,
amount: 0.0,
},
],
});
} else {
setPriceConfig({
...priceConfig,
usage_tiers: [
{
from: 0,
to: e.target.value,
amount: 0.0,
},
],
});
}
}}
/>
)}
</div>
{(showPerEntity || showCycle) && (
<div className="flex gap-2 transition-all duration-200 ease-in-out animate-in fade-in fade-out">
{showPerEntity && (
<div className="flex flex-col w-full overflow-hidden">
<FieldLabel className="flex items-center gap-2">
@@ -309,44 +315,65 @@ export const EntitlementConfig = ({
</Select>
</div>
)}
{showCycle && (
<div className="flex flex-col w-full">
<FieldLabel className="flex items-center gap-2">
Reset
{showPrice ? "Billing Cycle" : "Reset Cycle"}
<Tooltip delayDuration={400}>
<TooltipTrigger asChild>
<InfoIcon className="w-3 h-3 text-t3/50" />
</TooltipTrigger>
<TooltipContent sideOffset={5} side="top">
Frequency at which this entitlement should be reset back
to the allowance value
Frequency at which this feature is reset
</TooltipContent>
</Tooltip>
</FieldLabel>
<Select
value={fields.interval}
onValueChange={(value) =>
setFields({
...fields,
interval: value as EntInterval,
})
}
>
<SelectTrigger>
<SelectValue placeholder="Select reset" />
</SelectTrigger>
<SelectContent>
{Object.values(EntInterval).map((interval) => (
<SelectItem key={interval} value={interval}>
{interval === "semi_annual"
? "per half year"
: interval === "lifetime"
? "never"
: `per ${interval}`}
</SelectItem>
))}
</SelectContent>
</Select>
<SelectCycle
showPrice={showPrice}
priceConfig={priceConfig}
setPriceConfig={setPriceConfig}
fields={fields}
setFields={setFields}
setShowCycle={setShowCycle}
/>
</div>
)}
</div>
)}
<div className="flex gap-2 w-full justify-end">
<ToggleDisplayButton
label="Add Cycle"
show={showCycle}
onClick={() => setShowCycle(!showCycle)}
>
{showCycle ? (
<MinusIcon size={14} className="mr-1" />
) : (
<PlusIcon size={14} className="mr-1" />
)}
{showCycle ? "Remove Cycle" : "Add Cycle"}
</ToggleDisplayButton>
<ToggleDisplayButton
label="Add Price"
show={showPrice}
onClick={() => {
setShowPrice(!showPrice);
!showCycle && !showPrice && setShowCycle(true);
}}
>
{showPrice ? (
<MinusIcon size={14} className="mr-1" />
) : (
<PlusIcon size={14} className="mr-1" />
)}
{showPrice ? "Remove Price" : "Add Price"}
</ToggleDisplayButton>
<MoreMenuButton
fields={fields}
setFields={setFields}
showPerEntity={showPerEntity}
setShowPerEntity={setShowPerEntity}
/>
</div>
</TabsContent>
</Tabs>

View File

@@ -0,0 +1,83 @@
import { ToggleDisplayButton } from "@/components/general/ToggleDisplayButton";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { EllipsisVertical, MinusIcon, PlusIcon } from "lucide-react";
import { useState } from "react";
export default function MoreMenuButton({
fields,
setFields,
showPerEntity,
setShowPerEntity,
}: {
fields: any;
setFields: (fields: any) => void;
showPerEntity: boolean;
setShowPerEntity: (showPerEntity: boolean) => void;
}) {
const [showPopover, setShowPopover] = useState(false);
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="text-t3 text-xs"
onClick={() => setShowPopover(!showPopover)}
>
<EllipsisVertical size={12} />
</Button>
</PopoverTrigger>
<PopoverContent className="w-48 p-2 flex flex-col text-xs" align="end">
<div className="flex items-center space-x-2">
<Button
variant="secondary"
className="text-xs text-t3 shadow-none border-none"
onClick={() => {
setFields({
...fields,
carry_from_previous: !fields.carry_from_previous,
});
}}
>
<Checkbox
className="border-t3 mr-1"
checked={fields.carry_from_previous}
onCheckedChange={(checked) =>
setFields({
...fields,
carry_from_previous: Boolean(checked),
})
}
/>
Keep usage on upgrade
</Button>
</div>
<Button
className="h-7 shadow-none text-t3 text-xs justify-start border-none"
variant="outline"
startIcon={
showPerEntity ? (
<MinusIcon size={14} className="ml-0.5 mr-1" />
) : (
<PlusIcon size={14} className="ml-0.5 mr-1" />
)
}
onClick={() => {
setShowPerEntity(!showPerEntity);
// hide the popover
setShowPopover(false);
}}
>
{showPerEntity ? "Remove Per Entity" : "Add Per Entity"}
</Button>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,84 @@
import { Select } from "@/components/ui/select";
import {
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { BillingInterval, EntInterval } from "@autumn/shared";
export const SelectCycle = ({
fields,
setFields,
showPrice,
priceConfig,
setPriceConfig,
setShowCycle,
}: {
fields: any;
setFields: (fields: any) => void;
showPrice: boolean;
priceConfig: any;
setPriceConfig: (priceConfig: any) => void;
setShowCycle: (showCycle: boolean) => void;
}) => {
return (
<>
{showPrice ? (
<Select
value={priceConfig.interval}
defaultValue={BillingInterval.Month}
onValueChange={(value) => {
setPriceConfig({
...priceConfig,
interval: value as BillingInterval,
});
value == BillingInterval.OneOff && setShowCycle(false);
}}
>
<SelectTrigger>
<SelectValue placeholder="Select reset" />
</SelectTrigger>
<SelectContent>
{Object.values(BillingInterval).map((interval) => (
<SelectItem key={interval} value={interval}>
{interval === "semi_annual"
? "per half year"
: interval === "one_off"
? "one off"
: `per ${interval}`}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Select
value={fields.interval}
onValueChange={(value) => {
setFields({
...fields,
interval: value as EntInterval,
});
value == EntInterval.Lifetime && setShowCycle(false);
}}
>
<SelectTrigger>
<SelectValue placeholder="Select reset" />
</SelectTrigger>
<SelectContent>
{Object.values(EntInterval).map((interval) => (
<SelectItem key={interval} value={interval}>
{interval === "semi_annual"
? "per half year"
: interval === "lifetime"
? "never"
: `per ${interval}`}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</>
);
};

View File

@@ -0,0 +1,177 @@
import { Button } from "@/components/ui/button";
import { UsageTierInput } from "../prices/CreateUsagePrice";
import { cn } from "@/lib/utils";
import { Minus, Pencil, Plus, X } from "lucide-react";
import { Input } from "@/components/ui/input";
import { useState } from "react";
export default function TieredPrice({
config,
setConfig,
setShowPrice,
selectedFeature,
}: // product,
{
config: any;
setConfig: (config: any) => void;
setShowPrice: (showPrice: boolean) => void;
selectedFeature: any;
}) {
const [editBillingUnits, setEditBillingUnits] = useState(false);
const setUsageTier = (index: number, key: string, value: string) => {
const newUsageTiers = [...config.usage_tiers];
newUsageTiers[index] = { ...newUsageTiers[index], [key]: value };
if (key === "to" && newUsageTiers[index + 1]) {
newUsageTiers[index + 1].from = value;
}
setConfig({ ...config, usage_tiers: newUsageTiers });
};
const handleAddTier = () => {
const newUsageTiers = [...config.usage_tiers];
// First, change the last tier to be 0
const lastTier = newUsageTiers[newUsageTiers.length - 1];
if (lastTier.to == -1) {
newUsageTiers[newUsageTiers.length - 1].to = 0;
}
newUsageTiers.push({
from: Number(lastTier.to),
to: -1,
amount: 0.0,
});
setConfig({ ...config, usage_tiers: newUsageTiers });
};
const handleRemoveTier = (index: number) => {
const newUsageTiers = [...config.usage_tiers];
if (newUsageTiers.length == 1) {
setShowPrice(false); // If there is only one tier, then set back to allowance input
return;
}
newUsageTiers.splice(index, 1);
newUsageTiers[newUsageTiers.length - 1].to = -1;
setConfig({ ...config, usage_tiers: newUsageTiers });
};
return (
<div className="flex flex-col gap-1">
{config.usage_tiers.map((tier: any, index: number) => (
<div key={index} className="flex gap-1 w-full items-center">
<div className="w-full gap-2 flex items-center">
{config.usage_tiers.length > 1 && ( // First tier is just a price and billing units. No from or to tiers.
<div className="flex w-full items-center">
<div className="flex w-full text-sm">
<UsageTierInput
value={tier.from || 0}
onChange={(e) =>
setUsageTier(index, "from", e.target.value)
}
type="from"
config={config}
// entitlements={product.entitlements}
/>
</div>
<span className="px-2 text-t3 text-xs">to</span>
<div
className={cn(
"flex w-full text-sm",
tier.to == -1 && "bg-transparent"
)}
>
<UsageTierInput
value={tier.to}
onChange={(e) => {
setUsageTier(index, "to", e.target.value);
}}
type="to"
config={config}
// entitlements={product.entitlements}
/>
</div>
</div>
)}
<div className="flex w-full gap-2 text-sm items-center">
<div className="w-full">
<UsageTierInput
value={tier.amount}
onChange={(e) =>
setUsageTier(index, "amount", e.target.value)
}
type="amount"
config={config}
/>
</div>
{editBillingUnits && index == 0 ? (
<>
<span className="pr-2 text-t3 text-xs">per</span>
<div
className="w-full flex items-center relative"
onBlur={() => setEditBillingUnits(false)}
>
<Input
autoFocus
value={config.billing_units}
onChange={(e) =>
setConfig({ ...config, billing_units: e.target.value })
}
/>
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-t3 text-[10px]">
{selectedFeature.name}
</span>
</div>
</>
) : (
<div className="flex w-fit">
<Button
size="sm"
variant="ghost"
disabled={index > 0}
className="w-fit text-t3"
onClick={() => setEditBillingUnits(true)}
>
{config.billing_units == 1
? `per ${selectedFeature.name}`
: `per ${config.billing_units} ${selectedFeature.name}`}
<Pencil
size={12}
className={cn("text-t3", index > 0 && "text-transparent")}
/>
</Button>
</div>
)}
</div>
</div>
<Button
isIcon
size="sm"
variant="ghost"
className="w-fit text-t3"
onClick={() => handleAddTier()}
dim={6}
>
<Plus size={12} className="text-t3" />
</Button>
<Button
isIcon
size="sm"
variant="ghost"
className="w-fit text-t3"
onClick={() => handleRemoveTier(index)}
dim={6}
>
<Minus size={12} className="text-t3" />
</Button>
</div>
))}
{/* <Button
size="sm"
variant="outline"
className="w-fit mt-2"
onClick={handleAddTier}
>
Add Tier
</Button> */}
</div>
);
}

View File

@@ -8,6 +8,7 @@ import {
} from "@/components/ui/dialog";
import { useProductContext } from "../ProductContext";
import { toast } from "sonner";
import { getFeature } from "@/utils/product/entitlementUtils";
export default function UpdateEntitlement({
open,
@@ -20,7 +21,7 @@ export default function UpdateEntitlement({
selectedEntitlement: any;
setSelectedEntitlement: (entitlement: any) => void;
}) {
const { setProduct, product } = useProductContext();
const { setProduct, product, features } = useProductContext();
const handleDeleteEntitlement = () => {
const relatedPrice = product.prices.find((price: any) => {
@@ -76,11 +77,17 @@ export default function UpdateEntitlement({
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="">
<DialogTitle>Update Entitlement</DialogTitle>
<DialogTitle>Update Feature</DialogTitle>
<EntitlementConfig
entitlement={selectedEntitlement}
setEntitlement={setSelectedEntitlement}
isUpdate={true}
setShowFeatureCreate={() => {}}
selectedFeature={getFeature(
selectedEntitlement?.internal_feature_id,
features
)}
setSelectedFeature={() => {}}
/>
<DialogFooter>
@@ -88,7 +95,7 @@ export default function UpdateEntitlement({
Delete
</Button>
<Button onClick={handleUpdateEntitlement} variant="gradientPrimary">
Update Entitlement
Update Feature
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -360,9 +360,10 @@ export const UsageTierInput = ({
entitlements?: any[];
}) => {
if ((type === "to" && value === -1) || type === "from") {
//disable inputs for certain tier inputs
return (
<Input
className="outline-none bg-transparent shadow-none flex-grow mr-1 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
className="outline-none bg-transparent shadow-none flex-grow [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
value={value === -1 ? "♾️" : value}
disabled
type="text"
@@ -387,11 +388,11 @@ export const UsageTierInput = ({
// };
return (
<div className="relative flex-grow mr-1">
<div className="relative w-full flex">
<Input
className={cn(
"outline-none w-full [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none",
type === "amount" && "pr-12"
"outline-none flex w-full [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none",
type === "amount" && "pr-8"
)}
value={value}
onChange={onChange}
@@ -400,8 +401,7 @@ export const UsageTierInput = ({
/>
{type === "amount" && (
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-t3 text-[10px]">
/ {getBillingUnits(config, entitlements!)}
units
USD
</span>
)}
</div>