redesign pricing and entitlements
This commit is contained in:
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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
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
BIN
vite/public/metal2.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 436 KiB |
BIN
vite/public/metal3.jpg
Normal file
BIN
vite/public/metal3.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 778 KiB |
@@ -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'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'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">
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
property: "",
|
||||
operator: "",
|
||||
value: [],
|
||||
},
|
||||
]},
|
||||
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("");
|
||||
@@ -47,18 +61,18 @@ export const CreateFeature = () => {
|
||||
|
||||
const setFeatureToDefault = () => {
|
||||
setFeature({
|
||||
type: FeatureType.Metered,
|
||||
config: {
|
||||
filters: [
|
||||
{
|
||||
property: "",
|
||||
operator: "",
|
||||
value: [],
|
||||
}
|
||||
]
|
||||
},
|
||||
name: "",
|
||||
id: "",
|
||||
type: FeatureType.Metered,
|
||||
config: {
|
||||
filters: [
|
||||
{
|
||||
property: "",
|
||||
operator: "",
|
||||
value: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
name: "",
|
||||
id: "",
|
||||
});
|
||||
setEventNameInput("");
|
||||
setEventNameChanged(false);
|
||||
@@ -90,20 +104,58 @@ export const CreateFeature = () => {
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await FeatureService.createFeature(axiosInstance, {
|
||||
name: feature.name,
|
||||
id: feature.id,
|
||||
type: feature.type,
|
||||
config: updateConfig(),
|
||||
});
|
||||
mutate();
|
||||
setOpen(false);
|
||||
let { data: createdFeature } = await FeatureService.createFeature(
|
||||
axiosInstance,
|
||||
{
|
||||
name: feature.name,
|
||||
id: feature.id,
|
||||
type: feature.type,
|
||||
config: updateConfig(),
|
||||
}
|
||||
);
|
||||
await mutate();
|
||||
|
||||
if (isFromEntitlement) {
|
||||
if (createdFeature) {
|
||||
setSelectedFeature(createdFeature);
|
||||
}
|
||||
setShowFeatureCreate(false);
|
||||
} else {
|
||||
setOpen(false);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(getBackendErr(error, "Failed to create feature"));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<FeatureConfig
|
||||
feature={feature}
|
||||
setFeature={setFeature}
|
||||
eventNameInput={eventNameInput}
|
||||
setEventNameInput={setEventNameInput}
|
||||
eventNameChanged={eventNameChanged}
|
||||
setEventNameChanged={setEventNameChanged}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={handleCreateFeature}
|
||||
isLoading={loading}
|
||||
className="w-fit"
|
||||
variant="gradientPrimary"
|
||||
>
|
||||
Create Feature
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CreateFeatureDialog = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
@@ -111,9 +163,6 @@ export const CreateFeature = () => {
|
||||
startIcon={<PlusIcon size={15} />}
|
||||
variant="dashed"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
setFeatureToDefault();
|
||||
}}
|
||||
>
|
||||
Create Feature
|
||||
</Button>
|
||||
@@ -122,24 +171,13 @@ export const CreateFeature = () => {
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Feature</DialogTitle>
|
||||
</DialogHeader>
|
||||
<FeatureConfig
|
||||
feature={feature}
|
||||
setFeature={setFeature}
|
||||
eventNameInput={eventNameInput}
|
||||
setEventNameInput={setEventNameInput}
|
||||
eventNameChanged={eventNameChanged}
|
||||
setEventNameChanged={setEventNameChanged}
|
||||
<CreateFeature
|
||||
isFromEntitlement={false}
|
||||
setShowFeatureCreate={() => {}}
|
||||
setSelectedFeature={() => {}}
|
||||
setOpen={setOpen}
|
||||
open={open}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={handleCreateFeature}
|
||||
isLoading={loading}
|
||||
variant="gradientPrimary"
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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]"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
114
vite/src/views/products/product/ProductSidebar.tsx
Normal file
114
vite/src/views/products/product/ProductSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,42 +119,55 @@ function ProductView({ env }: { env: AppEnv }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<ProductContext.Provider
|
||||
<FeaturesContext.Provider
|
||||
value={{
|
||||
...data,
|
||||
mutate,
|
||||
env,
|
||||
product,
|
||||
setProduct,
|
||||
selectedEntitlementAllowance,
|
||||
setSelectedEntitlementAllowance,
|
||||
mutate,
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<Breadcrumb className="text-t3">
|
||||
<BreadcrumbList className="text-t3 text-xs">
|
||||
<BreadcrumbItem
|
||||
onClick={() => navigateTo("/products", navigate, env)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
Products
|
||||
</BreadcrumbItem>
|
||||
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem className="cursor-pointer">
|
||||
{product.name ? product.name : product.id}
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<ManageProduct product={product} />
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<AddProductButton
|
||||
handleCreateProduct={handleCreateProduct}
|
||||
actionState={actionState}
|
||||
/>
|
||||
</div>
|
||||
</ProductContext.Provider>
|
||||
<ProductContext.Provider
|
||||
value={{
|
||||
...data,
|
||||
mutate,
|
||||
env,
|
||||
product,
|
||||
setProduct,
|
||||
selectedEntitlementAllowance,
|
||||
setSelectedEntitlementAllowance,
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<Breadcrumb className="text-t3">
|
||||
<BreadcrumbList className="text-t3 text-xs">
|
||||
<BreadcrumbItem
|
||||
onClick={() => navigateTo("/products", navigate, env)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
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}
|
||||
actionState={actionState}
|
||||
/>
|
||||
</div>
|
||||
</ProductContext.Provider>
|
||||
</FeaturesContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
<EntitlementConfig
|
||||
entitlement={entitlement}
|
||||
setEntitlement={setEntitlement}
|
||||
/>
|
||||
{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}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={handleCreateEntitlement}
|
||||
variant="gradientPrimary"
|
||||
>
|
||||
Add to Product
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={handleCreateEntitlement} variant="gradientPrimary">
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -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,185 +189,191 @@ 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 className="flex flex-col">
|
||||
<FieldLabel className="flex items-center gap-2">
|
||||
Allowance
|
||||
<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
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</FieldLabel>
|
||||
</div> */}
|
||||
<TabsContent value="fixed" className="flex flex-col gap-4">
|
||||
<div className="flex flex-col ">
|
||||
<FieldLabel className="flex items-center gap-2">
|
||||
{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
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</FieldLabel>
|
||||
{showPrice ? (
|
||||
<TieredPrice
|
||||
selectedFeature={selectedFeature}
|
||||
config={priceConfig}
|
||||
setShowPrice={setShowPrice}
|
||||
setConfig={setPriceConfig}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
placeholder="eg. 100"
|
||||
className="w-30"
|
||||
value={fields.allowance}
|
||||
className=""
|
||||
value={
|
||||
priceConfig.usage_tiers[0].to > 0
|
||||
? priceConfig.usage_tiers[0].to
|
||||
: ""
|
||||
}
|
||||
type="number"
|
||||
onChange={(e) =>
|
||||
setFields({
|
||||
...fields,
|
||||
allowance: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{showPerEntity && (
|
||||
<div className="flex flex-col w-full overflow-hidden">
|
||||
<FieldLabel className="flex items-center gap-2">
|
||||
Entity
|
||||
<Tooltip delayDuration={400}>
|
||||
<TooltipTrigger asChild>
|
||||
<InfoIcon className="w-3 h-3 text-t3/50" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={5} side="top">
|
||||
An entity (eg, a user) within the customer to assign
|
||||
this entitlement to
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</FieldLabel>
|
||||
<Select
|
||||
value={fields.entity_feature_id}
|
||||
onValueChange={(value) =>
|
||||
setFields({ ...fields, entity_feature_id: value })
|
||||
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,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select feature" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{features
|
||||
.filter((feature: Feature) => {
|
||||
if (feature.type === FeatureType.Boolean) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
selectedFeature?.internal_id ===
|
||||
feature.internal_id
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map((feature: Feature) => (
|
||||
<SelectItem
|
||||
key={feature.internal_id}
|
||||
value={feature.id}
|
||||
>
|
||||
<div className="flex gap-2 items-center">
|
||||
per {feature.name}
|
||||
<span className="font-mono text-t3">
|
||||
{feature.id}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col w-full">
|
||||
<FieldLabel className="flex items-center gap-2">
|
||||
Reset
|
||||
<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
|
||||
</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>
|
||||
</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">
|
||||
Entity
|
||||
<Tooltip delayDuration={400}>
|
||||
<TooltipTrigger asChild>
|
||||
<InfoIcon className="w-3 h-3 text-t3/50" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={5} side="top">
|
||||
An entity (eg, a user) within the customer to assign
|
||||
this entitlement to
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</FieldLabel>
|
||||
<Select
|
||||
value={fields.entity_feature_id}
|
||||
onValueChange={(value) =>
|
||||
setFields({ ...fields, entity_feature_id: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select feature" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{features
|
||||
.filter((feature: Feature) => {
|
||||
if (feature.type === FeatureType.Boolean) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
selectedFeature?.internal_id ===
|
||||
feature.internal_id
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map((feature: Feature) => (
|
||||
<SelectItem
|
||||
key={feature.internal_id}
|
||||
value={feature.id}
|
||||
>
|
||||
<div className="flex gap-2 items-center">
|
||||
per {feature.name}
|
||||
<span className="font-mono text-t3">
|
||||
{feature.id}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
{showCycle && (
|
||||
<div className="flex flex-col w-full">
|
||||
<FieldLabel className="flex items-center gap-2">
|
||||
{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 feature is reset
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</FieldLabel>
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
84
vite/src/views/products/product/entitlements/SelectCycle.tsx
Normal file
84
vite/src/views/products/product/entitlements/SelectCycle.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
177
vite/src/views/products/product/entitlements/TieredPrice.tsx
Normal file
177
vite/src/views/products/product/entitlements/TieredPrice.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user