fix: get product item full display

This commit is contained in:
John Yeo
2025-09-26 09:18:48 +01:00
parent 6b29f5ef14
commit 383ef39d6c
25 changed files with 578 additions and 263 deletions

View File

@@ -1,6 +1,6 @@
import { Feature } from "../models/featureModels/featureModels.js";
import { Organization } from "../models/orgModels/orgTable.js";
import { format } from "date-fns";
import type { Feature } from "../models/featureModels/featureModels.js";
import type { Organization } from "../models/orgModels/orgTable.js";
import { notNullish, nullish } from "./utils.js";
export const getFeatureName = ({
feature,
@@ -20,7 +20,7 @@ export const getFeatureName = ({
let featureName = feature.name || "";
if (feature.display) {
let finalPlural;
let finalPlural: boolean | undefined;
// Case 1: If units and nullish plural
if (notNullish(units) && nullish(plural)) {
finalPlural = units !== 1;
@@ -93,7 +93,7 @@ export const usageToFeatureName = ({
}) => {
const { singular, plural } = getSingularAndPlural({ feature });
if (usage == 1) {
if (usage === 1) {
return singular;
}
@@ -124,7 +124,7 @@ export const getFeatureInvoiceDescription = ({
if (isPrepaid && billingUnits && billingUnits > 1) {
result = `${usageStr} x ${billingUnits} ${plural}`; // eg. 4 x 100 credits
} else {
if (usage == 1) {
if (usage === 1) {
result = `${usageStr} ${singular}`; // eg. 1 credit
} else {
result = `${usageStr} ${plural}`; // eg. 4 credits
@@ -148,17 +148,20 @@ export const formatAmount = ({
amount,
maxFractionDigits = 2,
minFractionDigits = 0,
amountFormatOptions,
}: {
org?: Organization;
currency?: string | null;
amount: number;
maxFractionDigits?: number;
minFractionDigits?: number;
amountFormatOptions?: Intl.NumberFormatOptions;
}) => {
return new Intl.NumberFormat(undefined, {
style: "currency",
currency: currency || org?.default_currency || "USD",
minimumFractionDigits: minFractionDigits || 0,
maximumFractionDigits: maxFractionDigits || 2,
...amountFormatOptions,
}).format(amount);
};

View File

@@ -17,9 +17,9 @@ export * from "./productUtils/priceUtils.js";
export * from "./productV2Utils/compareProductUtils.ts/compareItemUtils.js";
export * from "./productV2Utils/compareProductUtils.ts/compareProductUtils.js";
export * from "./productV2Utils/mapToProductV2.js";
export * from "./productV2Utils/productItemUtils/classifyItemUtils.js";
export * from "./productV2Utils/productItemUtils/convertItemUtils.js";
export * from "./productV2Utils/productItemUtils/getItemType.js";
// Item utils
export * from "./productV2Utils/productItemUtils/mapToItem.js";
export * from "./productV2Utils/productItemUtils/productItemUtils.js";

View File

@@ -20,9 +20,11 @@ import { notNullish, nullish } from "./utils.js";
export const formatTiers = ({
item,
currency,
amountFormatOptions,
}: {
item: ProductItem;
currency?: string | null;
amountFormatOptions?: Intl.NumberFormatOptions;
}) => {
const tiers = item.tiers;
if (tiers) {
@@ -30,6 +32,7 @@ export const formatTiers = ({
return formatAmount({
currency,
amount: tiers[0].amount,
amountFormatOptions,
});
}
@@ -39,18 +42,20 @@ export const formatTiers = ({
return `${formatAmount({
currency,
amount: firstPrice,
amountFormatOptions,
})} - ${formatAmount({
currency,
amount: lastPrice,
amountFormatOptions,
})}`;
}
};
export const getIntervalString = ({
interval,
intervalCount,
intervalCount = 1,
}: {
interval: ProductItemInterval;
interval: ProductItemInterval | null | undefined;
intervalCount?: number | null;
}) => {
if (!interval) return "";
@@ -63,22 +68,16 @@ export const getIntervalString = ({
export const getFeatureItemDisplay = ({
item,
feature,
fullDisplay = false,
}: {
item: ProductItem;
feature?: Feature;
fullDisplay?: boolean;
}) => {
if (!feature) {
throw new Error(`Feature ${item.feature_id} not found`);
}
// 1. If feature
if (!feature) throw new Error(`Feature ${item.feature_id} not found`);
if (item.feature_type === ProductItemFeatureType.Static) {
return {
primary_text: getFeatureName({
feature,
plural: false,
capitalize: true,
}),
};
return { primary_text: feature.name };
}
const featureName = getFeatureName({
@@ -89,12 +88,22 @@ export const getFeatureItemDisplay = ({
const includedUsageTxt =
item.included_usage === Infinite
? "Unlimited "
: nullish(item.included_usage) || item.included_usage == 0
: nullish(item.included_usage) || item.included_usage === 0
? ""
: `${numberWithCommas(item.included_usage!)} `;
: `${numberWithCommas(item.included_usage)} `;
const intervalStr = getIntervalString({
interval: item.interval,
intervalCount: item.interval_count,
});
console.log(
`feature ${feature.id}, interval ${item.interval}, interval count ${item.interval_count}, interval string ${intervalStr}`,
);
return {
primary_text: `${includedUsageTxt}${featureName}`,
secondary_text: fullDisplay && intervalStr ? intervalStr : undefined,
};
};
@@ -111,7 +120,7 @@ export const getPriceItemDisplay = ({
});
const intervalStr = getIntervalString({
interval: item.interval!,
interval: item.interval,
intervalCount: item.interval_count,
});
@@ -128,13 +137,17 @@ export const getFeaturePriceItemDisplay = ({
item,
currency,
isMainPrice = false,
minifyIncluded = false,
// minifyIncluded = false,
amountFormatOptions,
fullDisplay = false,
}: {
feature?: Feature;
item: ProductItem;
currency?: string | null;
isMainPrice?: boolean;
minifyIncluded?: boolean;
// minifyIncluded?: boolean;
amountFormatOptions?: Intl.NumberFormatOptions;
fullDisplay?: boolean;
}) => {
if (!feature) {
throw new Error(`Feature ${item.feature_id} not found`);
@@ -148,15 +161,11 @@ export const getFeaturePriceItemDisplay = ({
const includedUsage = item.included_usage as number | null;
let includedUsageStr = "";
if (notNullish(includedUsage) && includedUsage! > 0) {
if (minifyIncluded) {
includedUsageStr = `${numberWithCommas(includedUsage!)} included`;
} else {
includedUsageStr = `${numberWithCommas(includedUsage!)} ${includedFeatureName}`;
}
if (notNullish(includedUsage) && includedUsage > 0) {
includedUsageStr = `${numberWithCommas(includedUsage)} ${includedFeatureName}`;
}
const priceStr = formatTiers({ item, currency });
const priceStr = formatTiers({ item, currency, amountFormatOptions });
const billingFeatureName = getFeatureName({
feature,
units: item.billing_units,
@@ -170,12 +179,13 @@ export const getFeaturePriceItemDisplay = ({
}
// let intervalStr = isMainPrice && item.interval ? ` per ${item.interval}` : "";
const intervalStr = isMainPrice
? getIntervalString({
interval: item.interval!,
intervalCount: item.interval_count,
})
: "";
const intervalStr =
isMainPrice || fullDisplay
? getIntervalString({
interval: item.interval,
intervalCount: item.interval_count,
})
: "";
if (includedUsageStr) {
return {
@@ -184,7 +194,7 @@ export const getFeaturePriceItemDisplay = ({
};
}
if (isMainPrice) {
if (isMainPrice || fullDisplay) {
return {
primary_text: priceStr,
secondary_text: `per ${priceStr2} ${intervalStr}`,
@@ -192,7 +202,7 @@ export const getFeaturePriceItemDisplay = ({
}
return {
primary_text: priceStr + ` per ${priceStr2} ${intervalStr}`,
primary_text: `${priceStr} per ${priceStr2} ${intervalStr}`,
secondary_text: "",
};
};
@@ -201,15 +211,20 @@ export const getProductItemDisplay = ({
item,
features,
currency = "usd",
fullDisplay = false,
amountFormatOptions,
}: {
item: ProductItem;
features: Feature[];
currency?: string | null;
fullDisplay?: boolean;
amountFormatOptions?: Intl.NumberFormatOptions;
}) => {
if (isFeatureItem(item)) {
return getFeatureItemDisplay({
item,
feature: features.find((f) => f.id === item.feature_id),
fullDisplay,
});
}
@@ -225,6 +240,8 @@ export const getProductItemDisplay = ({
item,
feature: features.find((f) => f.id === item.feature_id),
currency,
fullDisplay,
amountFormatOptions,
});
}

View File

@@ -117,6 +117,9 @@ export const productsAreSame = ({
items1 = sanitizeItems({ items: items1, features });
items2 = sanitizeItems({ items: items2, features });
// console.log("Items 1:", items1);
// console.log("Items 2:", items2);
let itemsSame = true;
let pricesChanged = false;
const newItems: ProductItem[] = [];

View File

@@ -0,0 +1,18 @@
import type { Feature } from "../../../models/featureModels/featureModels.js";
import {
type ProductItem,
ProductItemFeatureType,
} from "../../../models/productV2Models/productItemModels/productItemModels.js";
export const isContUseItem = ({
item,
features,
}: {
item: ProductItem;
features: Feature[];
}) => {
const feature = features.find((f) => f.id === item.feature_id);
if (!feature) return false;
return feature.config?.usage_type === ProductItemFeatureType.ContinuousUse;
};

View File

@@ -64,6 +64,7 @@ export interface ButtonProps
isLoading?: boolean;
transition?: boolean;
disableActive?: boolean;
hide?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
@@ -76,6 +77,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
isLoading = false,
transition = false,
disableActive = false,
hide = false,
children,
...props
},
@@ -137,6 +139,8 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
}
};
if (hide) return null;
return (
<Comp
ref={buttonRef}

View File

@@ -21,16 +21,16 @@ export function PanelButton({
data-state={isSelected ? "open" : "closed"}
className={cn(
// Fixed dimensions
"w-[125px] h-[64px] relative flex items-center justify-center overflow-hidden cursor-pointer flex-shrink-0",
"w-[144px] h-[72px] relative flex items-center justify-center overflow-hidden cursor-pointer flex-shrink-0",
// Design system classes (following Select pattern)
"input-base input-shadow select-bg",
// Thicker border for panel effect
"!rounded-xl !border-1",
"!rounded-[0.5rem] !border-[0.09375rem]",
// Custom panel shadows
"shadow-[inset_0px_-8px_22px_0px_rgba(0,0,0,0.04)]",
// Selected state shadows
isSelected &&
"shadow-[0px_8px_18px_20px_rgba(136,56,255,0.05)] shadow-[0px_2px_8px_0px_rgba(136,56,255,0.25)]",
// isSelected &&
// "shadow-[0px_8px_18px_20px_rgba(136,56,255,0.05)] shadow-[0px_2px_8px_0px_rgba(136,56,255,0.25)]",
className,
)}
>
@@ -63,11 +63,14 @@ export function PanelButton({
{/* Centered icon */}
<div
className={`size-10 rounded-lg flex items-center justify-center relative ${
isSelected ? "bg-violet-100" : "bg-zinc-100"
}`}
className={cn(
"size-9 rounded-xl flex items-center justify-center relative",
isSelected
? "bg-[var(--color-panel-icon-background)]"
: "bg-zinc-100",
)}
>
<div className={isSelected ? "text-violet-600" : "text-stone-500"}>
<div className={isSelected ? "text-primary" : "text-stone-500"}>
{icon}
</div>
</div>

View File

@@ -19,6 +19,8 @@ interface AreaCheckboxProps {
title: string;
tooltip?: string;
disabled?: boolean;
hide?: boolean;
description?: string;
children?: React.ReactNode;
}
@@ -29,37 +31,45 @@ function AreaCheckbox({
title,
tooltip,
disabled = false,
hide = false,
description,
children,
}: AreaCheckboxProps) {
const id = React.useId();
const handleToggle = () => {
if (!disabled && onCheckedChange) {
onCheckedChange(!checked);
}
};
if (hide) return null;
return (
<div className={cn("space-y-3", className)}>
<div className={cn("space-y-2", className)}>
{/* Header row with checkbox, title, and tooltip */}
<div
className="flex items-center gap-2 cursor-pointer"
onClick={handleToggle}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleToggle();
}
}}
<label
htmlFor={id}
className={cn(
"flex items-center gap-2 cursor-pointer",
disabled && "cursor-not-allowed",
)}
>
<Checkbox
id={id}
checked={checked}
onCheckedChange={onCheckedChange}
onCheckedChange={(checked) => {
if (!disabled && onCheckedChange) {
onCheckedChange(checked as boolean);
}
}}
disabled={disabled}
size="sm"
/>
<span className="text-form-label font-medium select-none">{title}</span>
<span
className={cn(
"text-checkbox-label font-medium select-none ",
!disabled && "hover:!text-t1",
!checked && "opacity-50",
)}
>
{title}
</span>
{tooltip && (
<TooltipProvider>
<Tooltip>
@@ -72,16 +82,21 @@ function AreaCheckbox({
</Tooltip>
</TooltipProvider>
)}
</div>
</label>
{/* Expanded content */}
{children && (
{(children || description) && (
<div
className={cn(
"ml-6 space-y-4",
"space-y-2",
!checked && "opacity-50 pointer-events-none",
)}
>
{description && (
<p className="text-sm text-body-secondary max-w-[100%]">
{description}
</p>
)}
{children}
</div>
)}

View File

@@ -1,7 +1,7 @@
"use client";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import type { VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";
import { Button, type ButtonProps } from "../buttons/Button";
@@ -22,6 +22,7 @@ const IconCheckbox = React.forwardRef<HTMLButtonElement, IconCheckboxProps>(
className,
variant = "secondary",
size = "sm",
hide = false,
iconOrientation = "center",
asChild = false,
checked = false,
@@ -124,12 +125,18 @@ const IconCheckbox = React.forwardRef<HTMLButtonElement, IconCheckboxProps>(
const iconToMainClass = () => {
switch (iconOrientation) {
case "center":
return "!h-6 w-6";
if (size === "sm") {
return "!h-6 w-6";
} else {
return "!h-7 w-7";
}
default:
return "";
}
};
if (hide) return null;
return (
<Comp
ref={ref}
@@ -139,7 +146,7 @@ const IconCheckbox = React.forwardRef<HTMLButtonElement, IconCheckboxProps>(
className={cn(
iconButtonVariants({ iconOrientation }),
iconToMainClass(),
"input-base input-shadow select-bg",
"input-base input-shadow-tiny select-bg",
className,
)}
onClick={handleClick}
@@ -155,4 +162,4 @@ const IconCheckbox = React.forwardRef<HTMLButtonElement, IconCheckboxProps>(
IconCheckbox.displayName = "IconCheckbox";
export { IconCheckbox };
export { IconCheckbox };

View File

@@ -14,7 +14,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
// Custom classes
// "placeholder:text-form-placeholder text-form-text rounded-lg px-2 py-1 input-border transition-none",
"placeholder:text-t6 placeholder:select-none input-base input-shadow shadow-sm",
"placeholder:text-t6 placeholder:select-none input-base input-shadow shadow-sm h-input",
className,
)}
{...props}

View File

@@ -40,7 +40,7 @@ function SelectTrigger({
"border-input [&_svg:not([class*='text-'])]:text-muted-foreground aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent text-sm whitespace-nowrap shadow-xs outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
// Custom border styles
"text-sm input-base input-shadow select-bg transition-none",
"text-sm input-base input-shadow select-bg transition-none h-input",
className,
)}
{...props}

View File

@@ -149,6 +149,7 @@
--color-hover-primary: #fcfaff;
--color-active-primary: #f6f0ff;
--color-panel-icon-background: #ede1ff;
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
@@ -269,11 +270,14 @@ input[type="number"]::-webkit-inner-spin-button {
}
body {
@apply bg-background text-foreground;
letter-spacing: -0.033px;
}
::selection {
background: var(--primary);
color: white;
}
::-moz-selection {
background: var(--primary);
color: white;

View File

@@ -27,6 +27,21 @@
}
}
.input-shadow-tiny {
box-shadow: 0 4px 4px 0 rgba(0, 0, 0, 0.02);
&:hover:not([data-disabled="true"]):not(:focus):not([data-state="open"]) {
box-shadow: 0 4px 4px 0 rgba(0, 0, 0, 0.06);
}
&:focus,
&[data-state="open"] {
box-shadow:
0 0 0 0.2px var(--primary),
0 4px 4px 0 rgba(0, 0, 0, 0.04);
}
}
.input-base {
padding: 0.25rem 0.5rem;
font-size: 13px;
@@ -63,6 +78,7 @@
background-color: var(--color-active-primary) !important;
}
&:focus,
&[data-state="open"],
&[data-state="checked"] {
background-color: var(--color-hover-primary);

View File

@@ -29,6 +29,13 @@
color: var(--color-t2);
}
.text-body-highlight {
color: var(--color-t2);
font-size: 13px;
font-weight: var(--font-weight-semibold);
letter-spacing: -0.039px;
}
.text-body-secondary {
font-size: 13px;
font-weight: var(--font-weight-normal);
@@ -41,6 +48,18 @@
color: var(--color-t4);
}
.text-tiny-id {
color: var(--color-t3);
/* Tiny ID */
font-family: "JetBrains Mono", "JetBrains Mono Fallback", monospace;
font-size: 11px;
font-style: normal;
font-weight: 500;
line-height: normal;
letter-spacing: -0.033px;
}
.text-checkbox-label {
font-size: 13px;
font-weight: var(--font-weight-semibold);
@@ -76,24 +95,3 @@
font-weight: var(--font-weight-medium);
color: var(--color-t6);
}
.text-tiny {
color: var(--color-t4);
/* leading-trim: both;
text-edge: cap;
font-family: Inter; */
font-size: 11px;
font-weight: var(--font-weight-medium);
}
.text-tiny-id {
color: var(--color-t3);
/* Tiny ID */
font-family: "JetBrains Mono", "JetBrains Mono Fallback", monospace;
font-size: 11px;
font-style: normal;
font-weight: 500;
line-height: normal;
letter-spacing: -0.033px;
}

View File

@@ -1,21 +1,6 @@
/** biome-ignore-all lint/a11y/noStaticElementInteractions: shush */
import {
FeatureUsageType,
type RolloverConfig,
RolloverDuration,
} from "@autumn/shared";
import { InfinityIcon } from "@phosphor-icons/react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { FeatureUsageType } from "@autumn/shared";
import { AreaCheckbox } from "@/components/v2/checkboxes/AreaCheckbox";
import { IconCheckbox } from "@/components/v2/checkboxes/IconCheckbox";
import { FormLabel } from "@/components/v2/form/FormLabel";
import { Input } from "@/components/v2/inputs/Input";
import {
SheetAccordion,
SheetAccordionItem,
@@ -27,6 +12,8 @@ import {
getFeatureUsageType,
} from "@/utils/product/entitlementUtils";
import { useProductItemContext } from "@/views/products/product/product-item/ProductItemContext";
import { RolloverConfig } from "./advanced-settings/RolloverConfig";
import { UsageLimit } from "./advanced-settings/UsageLimit";
export function AdvancedSettings() {
const { features } = useFeaturesQuery();
@@ -37,9 +24,6 @@ export function AdvancedSettings() {
const usageType = getFeatureUsageType({ item, features });
const hasCreditSystem = getFeatureCreditSystem({ item, features });
// Usage Limits logic
const hasUsageLimit = item.usage_limit != null;
// Rollover logic
const showRolloverConfig =
(hasCreditSystem || usageType === FeatureUsageType.Single) &&
@@ -47,43 +31,43 @@ export function AdvancedSettings() {
item.included_usage &&
Number(item.included_usage) > 0;
const defaultRollover: RolloverConfig = {
duration: RolloverDuration.Month,
length: 1 as number,
max: null,
};
// const defaultRollover: RolloverConfig = {
// duration: RolloverDuration.Month,
// length: 1 as number,
// max: null,
// };
const setRolloverConfigKey = (
key: keyof RolloverConfig,
value: null | number | RolloverDuration,
) => {
setItem({
...item,
config: {
...(item.config || {}),
rollover: {
...(item.config?.rollover || defaultRollover),
[key]: value,
},
},
});
};
// const setRolloverConfigKey = (
// key: keyof RolloverConfig,
// value: null | number | RolloverDuration,
// ) => {
// setItem({
// ...item,
// config: {
// ...(item.config || {}),
// rollover: {
// ...(item.config?.rollover || defaultRollover),
// [key]: value,
// },
// },
// });
// };
const setRolloverConfig = (rollover: RolloverConfig | null) => {
const newConfig = { ...(item.config || {}) };
if (rollover === null) {
delete newConfig.rollover;
} else {
newConfig.rollover = rollover;
}
setItem({
...item,
config: newConfig,
});
};
// const setRolloverConfig = (rollover: RolloverConfig | null) => {
// const newConfig = { ...(item.config || {}) };
// if (rollover === null) {
// delete newConfig.rollover;
// } else {
// newConfig.rollover = rollover;
// }
// setItem({
// ...item,
// config: newConfig,
// });
// };
const rollover = item.config?.rollover as RolloverConfig;
const hasRollover = item.config?.rollover != null;
// const rollover = item.config?.rollover as RolloverConfig;
// const hasRollover = item.config?.rollover != null;
return (
<SheetAccordion type="single" withSeparator={false} collapsible={true}>
@@ -92,11 +76,13 @@ export function AdvancedSettings() {
title="Advanced settings"
description="Additional configuration options for this feature"
>
<div className="space-y-6 pt-2">
<div className="space-y-6 pt-2 pb-10">
{/* Reset existing usage when product is enabled */}
<AreaCheckbox
title="Reset existing usage when product is enabled"
tooltip="A customer has used 20/100 credits on a free plan. Then they upgrade to a Pro plan with 500 credits. If this flag is enabled, they'll get 500 credits on upgrade. If false, they'll have 480."
description="When coming from another plan, this will reset the customer's feature usage to 0."
checked={!!item.reset_usage_when_enabled}
// hide={usageType === FeatureUsageType.Continuous}
disabled={
usageType === FeatureUsageType.Continuous ||
notNullish(item.config?.rollover)
@@ -110,49 +96,11 @@ export function AdvancedSettings() {
/>
{/* Usage Limits */}
<AreaCheckbox
title="Usage limits"
tooltip="Set maximum usage limits for this feature to prevent overages"
checked={hasUsageLimit}
onCheckedChange={(checked) => {
let usage_limit: number | null;
if (checked) {
usage_limit = 100; // Default value
} else {
usage_limit = null;
}
setItem({
...item,
usage_limit: usage_limit,
});
}}
>
<div
className="space-y-2"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<Input
type="number"
value={item.usage_limit || ""}
className="w-32"
onChange={(e) => {
const value = e.target.value;
const numValue =
value === "" ? null : parseInt(value) || null;
setItem({
...item,
usage_limit: numValue,
});
}}
placeholder="e.g. 100"
onClick={(e) => e.stopPropagation()}
/>
</div>
</AreaCheckbox>
<UsageLimit />
{/* Rollover */}
{showRolloverConfig && (
<RolloverConfig />
{/* {showRolloverConfig && (
<AreaCheckbox
title="Rollovers"
tooltip="Rollovers carry unused credits to the next billing cycle. Set a maximum rollover amount and specify how many cycles before resetting."
@@ -263,7 +211,7 @@ export function AdvancedSettings() {
</div>
</div>
</AreaCheckbox>
)}
)} */}
</div>
</SheetAccordionItem>
</SheetAccordion>

View File

@@ -1,3 +1,4 @@
import { isFeaturePriceItem } from "@autumn/shared";
import { CoinsIcon } from "@phosphor-icons/react";
import { PanelButton } from "@/components/v2/buttons/PanelButton";
import { IncludedUsageIcon } from "@/components/v2/icons/AutumnIcons";
@@ -9,8 +10,7 @@ export function BillingType() {
if (!item) return null;
// Derive billing type from item state
const billingType =
item.tiers && item.tiers.length > 0 ? "priced" : "included";
const isFeaturePrice = isFeaturePriceItem(item);
const setBillingType = (type: "included" | "priced") => {
if (type === "included") {
@@ -23,15 +23,15 @@ export function BillingType() {
};
return (
<div className="space-y-4 billing-type-section">
<div className="mt-3 space-y-4 billing-type-section">
<div className="flex w-full items-center gap-4">
<PanelButton
isSelected={billingType === "included"}
isSelected={!isFeaturePrice}
onClick={() => setBillingType("included")}
icon={<IncludedUsageIcon size={24} />}
icon={<IncludedUsageIcon size={18} />}
/>
<div className="flex-1">
<div className="text-sub mb-1">Included</div>
<div className="text-body-highlight mb-1">Included</div>
<div className="text-body-secondary leading-tight">
Set included usage limits with reset intervals (e.g. 100
credits/month)
@@ -41,12 +41,12 @@ export function BillingType() {
<div className="flex w-full items-center gap-4">
<PanelButton
isSelected={billingType === "priced"}
isSelected={isFeaturePrice}
onClick={() => setBillingType("priced")}
icon={<CoinsIcon size={24} />}
icon={<CoinsIcon size={20} />}
/>
<div className="flex-1">
<div className="text-sub mb-1">Priced</div>
<div className="text-body-highlight mb-1">Priced</div>
<div className="text-body-secondary leading-tight">
Set usage and overage pricing (e.g. 100 credits/month, $1 extra)
</div>

View File

@@ -2,6 +2,7 @@ import { ProductItemFeatureType } from "@autumn/shared";
import { SheetHeader, SheetSection } from "@/components/v2/sheets/InlineSheet";
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
import { getFeature } from "@/utils/product/entitlementUtils";
import { isFeaturePriceItem } from "@/utils/product/getItemType";
import { useProductItemContext } from "@/views/products/product/product-item/ProductItemContext";
import { AdvancedSettings } from "./AdvancedSettings";
import { BillingType } from "./BillingType";
@@ -15,14 +16,12 @@ export function EditPlanFeatureSheet() {
const { features } = useFeaturesQuery();
// Early return if no item
if (!item) {
return null;
}
if (!item) return null;
const feature = getFeature(item?.feature_id ?? "", features);
// Derive billing type from item state - no local state needed
const isPricedFeature = !!(item.tiers && item.tiers.length > 0);
const isFeaturePrice = isFeaturePriceItem(item);
return (
<>
@@ -33,7 +32,7 @@ export function EditPlanFeatureSheet() {
{item.feature_type !== ProductItemFeatureType.Static && (
<>
<SheetSection title="Billing type">
<SheetSection title="Billing Type">
<BillingType />
</SheetSection>
@@ -41,7 +40,7 @@ export function EditPlanFeatureSheet() {
<IncludedUsage />
</SheetSection>
{isPricedFeature && (
{isFeaturePrice && (
<SheetSection title="Price">
<PriceTiers />
<UsageReset showBillingLabel={true} />

View File

@@ -2,6 +2,7 @@ import {
BillingInterval,
EntInterval,
Infinite,
isContUseItem,
type ProductItemInterval,
} from "@autumn/shared";
import { InfinityIcon } from "@phosphor-icons/react";
@@ -22,10 +23,13 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/v2/selects/Select";
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
import { formatIntervalText } from "@/utils/formatUtils/formatTextUtils";
import { isFeaturePriceItem } from "@/utils/product/getItemType";
import { useProductItemContext } from "@/views/products/product/product-item/ProductItemContext";
export function IncludedUsage() {
const { features } = useFeaturesQuery();
const { item, setItem } = useProductItemContext();
const [open, setOpen] = useState(false);
@@ -52,9 +56,7 @@ export function IncludedUsage() {
setOpen(false);
};
// Determine billing type
const billingType =
item.tiers && item.tiers.length > 0 ? "priced" : "included";
const isFeaturePrice = isFeaturePriceItem(item);
return (
<div className="space-y-4">
@@ -81,6 +83,7 @@ export function IncludedUsage() {
disabled={includedUsage === Infinite}
/>
<IconCheckbox
hide={isFeaturePrice}
icon={<InfinityIcon />}
iconOrientation="center"
variant="muted"
@@ -100,7 +103,7 @@ export function IncludedUsage() {
</div>
{/* Only show Usage Reset dropdown for included billing type */}
{billingType === "included" && (
{!isFeaturePrice && !isContUseItem({ item, features }) && (
<div>
<div className="text-form-label block mb-2">Usage Reset</div>
<Select

View File

@@ -54,7 +54,6 @@ export function PriceTiers() {
onClick={() => addTier({ item, setItem })}
icon={<Plus size={12} />}
iconOrientation="left"
className="p-1"
>
Add Tiers
</IconButton>

View File

@@ -1,5 +1,5 @@
import { UsageModel } from "@autumn/shared";
import { LongCheckbox } from "@/components/v2/checkboxes/LongCheckbox";
import { AreaCheckbox } from "@/components/v2/checkboxes/AreaCheckbox";
import { useProductItemContext } from "@/views/products/product/product-item/ProductItemContext";
export function PricedFeatureSettings() {
@@ -11,9 +11,9 @@ export function PricedFeatureSettings() {
return (
<div className="mt-6">
<LongCheckbox
<AreaCheckbox
title="Prepaid"
subtitle="Quantity will be chosen during checkout."
description="Quantity will be chosen during checkout."
checked={prepaid}
onCheckedChange={(checked) => {
const newUsageModel = checked

View File

@@ -0,0 +1,187 @@
import {
FeatureUsageType,
type RolloverConfig as RolloverConfigType,
RolloverDuration,
} from "@autumn/shared";
import { InfinityIcon } from "@phosphor-icons/react";
import { AreaCheckbox } from "@/components/v2/checkboxes/AreaCheckbox";
import { IconCheckbox } from "@/components/v2/checkboxes/IconCheckbox";
import { FormLabel } from "@/components/v2/form/FormLabel";
import { Input } from "@/components/v2/inputs/Input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/v2/selects/Select";
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
import {
getFeatureCreditSystem,
getFeatureUsageType,
} from "@/utils/product/entitlementUtils";
import { useProductItemContext } from "@/views/products/product/product-item/ProductItemContext";
export function RolloverConfig() {
const { features } = useFeaturesQuery();
const { item, setItem } = useProductItemContext();
if (!item) return null;
const usageType = getFeatureUsageType({ item, features });
const hasCreditSystem = getFeatureCreditSystem({ item, features });
// Rollover logic
const showRolloverConfig =
(hasCreditSystem || usageType === FeatureUsageType.Single) &&
item.interval !== null &&
item.included_usage &&
Number(item.included_usage) > 0;
const defaultRollover: RolloverConfigType = {
duration: RolloverDuration.Month,
length: 1 as number,
max: null,
};
const setRolloverConfigKey = (
key: keyof RolloverConfigType,
value: null | number | RolloverDuration,
) => {
setItem({
...item,
config: {
...(item.config || {}),
rollover: {
...(item.config?.rollover || defaultRollover),
[key]: value,
},
},
});
};
const setRolloverConfig = (rollover: RolloverConfigType | null) => {
const newConfig = { ...(item.config || {}) };
if (rollover === null) {
delete newConfig.rollover;
} else {
newConfig.rollover = rollover;
}
setItem({
...item,
config: newConfig,
});
};
const rollover = item.config?.rollover as RolloverConfigType;
const hasRollover = item.config?.rollover != null;
return (
<>
{showRolloverConfig && (
<AreaCheckbox
title="Rollovers"
tooltip="Rollovers carry unused credits to the next billing cycle. Set a maximum rollover amount and specify how many cycles before resetting."
checked={hasRollover}
onCheckedChange={(checked) => {
if (checked) {
setItem({
...item,
reset_usage_when_enabled: true,
config: {
...(item.config || {}),
rollover: defaultRollover,
},
});
} else {
setRolloverConfig(null);
}
}}
>
<div
className="space-y-4"
// onClick={(e) => e.stopPropagation()}
// onKeyDown={(e) => e.stopPropagation()}
>
<div className="space-y-2">
<FormLabel>Maximum rollover amount</FormLabel>
<div className="flex items-center gap-2">
<Input
type="number"
value={
rollover?.max === null
? ""
: rollover?.max === 0
? ""
: rollover?.max
}
className="w-32"
placeholder={
rollover?.max === null ? "Unlimited" : "e.g. 100 credits"
}
disabled={rollover?.max === null}
onChange={(e) => {
const value = e.target.value;
const numValue = value === "" ? 0 : parseInt(value) || 0;
setRolloverConfigKey("max", numValue);
}}
onClick={(e) => e.stopPropagation()}
/>
<IconCheckbox
icon={<InfinityIcon />}
iconOrientation="center"
variant="muted"
size="default"
checked={rollover?.max === null}
onCheckedChange={(checked) =>
setRolloverConfigKey("max", checked ? null : 0)
}
/>
</div>
</div>
<div className="space-y-2">
<FormLabel>Rollover duration</FormLabel>
<div className="flex items-center gap-2">
{rollover?.duration === RolloverDuration.Month && (
<Input
type="number"
value={rollover?.length === 0 ? "" : rollover?.length || ""}
onChange={(e) => {
const value = e.target.value;
const numValue = value === "" ? 0 : parseInt(value) || 0;
setRolloverConfigKey("length", numValue);
}}
className="w-32"
placeholder="e.g. 1 month"
onClick={(e) => e.stopPropagation()}
/>
)}
<Select
value={rollover?.duration}
onValueChange={(value) => {
setRolloverConfigKey("duration", value as RolloverDuration);
}}
>
<SelectTrigger
className="w-32"
onClick={(e) => e.stopPropagation()}
>
<SelectValue placeholder="Select duration" />
</SelectTrigger>
<SelectContent>
{Object.values(RolloverDuration).map((duration) => (
<SelectItem key={duration} value={duration}>
{duration}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
</AreaCheckbox>
)}
</>
);
}

View File

@@ -0,0 +1,61 @@
import { notNullish } from "@autumn/shared";
import { AreaCheckbox } from "@/components/v2/checkboxes/AreaCheckbox";
import { Input } from "@/components/v2/inputs/Input";
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
import {
getFeatureCreditSystem,
getFeatureUsageType,
} from "@/utils/product/entitlementUtils";
import { useProductItemContext } from "@/views/products/product/product-item/ProductItemContext";
export function UsageLimit() {
const { features } = useFeaturesQuery();
const { item, setItem } = useProductItemContext();
if (!item) return null;
const usageType = getFeatureUsageType({ item, features });
const hasCreditSystem = getFeatureCreditSystem({ item, features });
return (
<AreaCheckbox
title="Usage limit"
// tooltip="Set maximum usage limits for this feature to prevent overages"
description="The maximum total amount of this feature a customer can use, including
their included usage."
checked={notNullish(item.usage_limit)}
onCheckedChange={(checked) => {
let usage_limit: number | null;
if (checked) {
usage_limit = 100; // Default value
} else {
usage_limit = null;
}
console.log("checked", checked, "setting usage limit to", usage_limit);
setItem({
...item,
usage_limit: usage_limit,
});
}}
>
<Input
type="number"
value={item.usage_limit || ""}
className="w-32"
onChange={(e) => {
const value = e.target.value;
const numValue = value === "" ? 0 : parseInt(value) || null;
setItem({
...item,
usage_limit: numValue,
});
}}
placeholder="e.g. 100"
onClick={(e) => e.stopPropagation()}
/>
</AreaCheckbox>
);
}

View File

@@ -3,6 +3,7 @@
import type { ProductItem } from "@autumn/shared";
import { getProductItemDisplay } from "@autumn/shared";
import { TrashIcon } from "@phosphor-icons/react";
import { useState } from "react";
import { CopyButton } from "@/components/v2/buttons/CopyButton";
import { IconButton } from "@/components/v2/buttons/IconButton";
import { useOrg } from "@/hooks/common/useOrg";
@@ -35,16 +36,15 @@ export const PlanFeatureRow = ({
const { org } = useOrg();
const { features } = useFeaturesQuery();
const { editingState } = useProductContext();
const [isPressed, setIsPressed] = useState(false);
const getDisplayText = (item: ProductItem) => {
const displayData = getProductItemDisplay({
item,
features,
currency: org?.default_currency || "USD",
});
return displayData.primary_text;
};
const display = getProductItemDisplay({
item,
features,
currency: org?.default_currency || "USD",
fullDisplay: true,
amountFormatOptions: { currencyDisplay: "narrowSymbol" },
});
const isSelected = getItemId({ item, itemIndex: index }) === editingState.id;
@@ -52,13 +52,28 @@ export const PlanFeatureRow = ({
<div
role="button"
tabIndex={0}
data-state={isSelected ? "open" : "closed"}
data-pressed={isPressed}
className={cn(
"flex w-full group !h-9 group/row input-base btn-secondary-shadow",
"flex w-full group !h-9 group/row input-base input-shadow-tiny select-bg",
// To prevent flickering when clicking inner buttons
!isSelected &&
"hover:!bg-hover-primary focus-visible:!bg-hover-primary focus-visible:!border-primary",
isSelected &&
"!bg-active-primary !border-primary !shadow-[0px_0px_0px_0.2px_var(--color-primary)]",
isSelected && "!bg-hover-primary !border-primary",
// Custom pressed state that we can control
"data-[pressed=true]:!bg-active-primary data-[pressed=true]:border-primary",
)}
onMouseDown={(e) => {
// Only set pressed if we're not clicking on a button
if (!(e.target as Element).closest("button")) {
setIsPressed(true);
}
}}
onMouseUp={() => setIsPressed(false)}
onMouseLeave={() => setIsPressed(false)}
onClick={() => onEdit?.(item)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
@@ -68,25 +83,30 @@ export const PlanFeatureRow = ({
}}
>
{/* Left side - Icons and text */}
<div className="flex flex-row items-center flex-1 gap-4 min-w-0">
<div className="flex flex-row items-center flex-1 gap-4 min-w-0 relative">
<div className="flex flex-row items-center gap-1 flex-shrink-0">
<PlanFeatureIcon item={item} position="left" />
<CustomDotIcon />
<PlanFeatureIcon item={item} position="right" />
</div>
<div className="flex items-center gap-2 flex-1 min-w-0">
<span className="text-t2 font-medium whitespace-nowrap font-inter text-[13px] leading-4 tracking-[-0.003em]">
{getDisplayText(item)}
</span>
<CopyButton
text={item.feature_id || ""}
disableActive={true}
size="sm"
variant="skeleton"
className="opacity-0 group-hover:opacity-100 transition-opacity duration-50"
/>
<div className="flex items-center gap-2 flex-1 max-w-[85%]">
<p className="whitespace-nowrap truncate">
<span className="text-body">{display.primary_text}</span>
<span className="text-body-secondary">
{" "}
{display.secondary_text}
</span>
</p>
</div>
<CopyButton
// hide={true}
text={item.feature_id || ""}
disableActive={true}
size="sm"
variant="skeleton"
className="absolute right-0 z-20 opacity-0 group-hover:opacity-100 transition-opacity duration-50 bg-hover-primary"
/>
</div>
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-50">

View File

@@ -1,4 +1,4 @@
import { LongCheckbox } from "@/components/v2/checkboxes/LongCheckbox";
import { AreaCheckbox } from "@/components/v2/checkboxes/AreaCheckbox";
import { SheetSection } from "@/components/v2/sheets/InlineSheet";
import { useProductContext } from "@/views/products/product/ProductContext";
@@ -10,18 +10,18 @@ export const AdditionalOptions = () => {
return (
<SheetSection title="Additional Options">
<div className="space-y-4">
<LongCheckbox
<AreaCheckbox
title="Default"
subtitle="This product will be enabled by default for all new users,
description="This product will be enabled by default for all new users,
typically used for your free plan"
checked={product.is_default}
onCheckedChange={(checked) =>
setProduct({ ...product, is_default: checked })
}
/>
<LongCheckbox
<AreaCheckbox
title="Add On"
subtitle="This product is an add-on that can be bought together with your
description="This product is an add-on that can be bought together with your
base products (eg, top ups)"
checked={product.is_add_on}
onCheckedChange={(checked) =>

View File

@@ -1,13 +1,19 @@
import FieldLabel from "@/components/general/modal-components/FieldLabel";
import { Input } from "@/components/ui/input";
import {
Infinite,
import {
BillingInterval,
EntInterval,
ProductItemInterval,
Infinite,
UsageModel,
} from "@autumn/shared";
import { useState } from "react";
import FieldLabel from "@/components/general/modal-components/FieldLabel";
import { InfoTooltip } from "@/components/general/modal-components/InfoTooltip";
import { ToggleDisplayButton } from "@/components/general/ToggleDisplayButton";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
@@ -15,18 +21,11 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Button } from "@/components/v2/buttons/Button";
import { useProductItemContext } from "../../ProductItemContext";
import { ToggleDisplayButton } from "@/components/general/ToggleDisplayButton";
import { itemIsUnlimited } from "@/utils/product/productItemUtils";
import { isFeaturePriceItem } from "@/utils/product/getItemType";
import { InfoTooltip } from "@/components/general/modal-components/InfoTooltip";
import { formatIntervalText } from "@/utils/formatUtils/formatTextUtils";
import { isFeaturePriceItem } from "@/utils/product/getItemType";
import { itemIsUnlimited } from "@/utils/product/productItemUtils";
import { useProductItemContext } from "../../ProductItemContext";
export const IncludedUsage = () => {
const { item, setItem } = useProductItemContext();
@@ -36,7 +35,9 @@ export const IncludedUsage = () => {
item.interval_count || 1,
);
const handleBillingIntervalSelected = (value: BillingInterval | EntInterval) => {
const handleBillingIntervalSelected = (
value: BillingInterval | EntInterval,
) => {
let usageModel = item.usage_model;
if (value === BillingInterval.OneOff) {
usageModel = UsageModel.Prepaid;
@@ -44,7 +45,10 @@ export const IncludedUsage = () => {
setItem({
...item,
interval: value === BillingInterval.OneOff || value === EntInterval.Lifetime ? null : value,
interval:
value === BillingInterval.OneOff || value === EntInterval.Lifetime
? null
: value,
usage_model: usageModel,
});
};
@@ -162,7 +166,9 @@ export const IncludedUsage = () => {
<Button
className="w-full justify-start px-2"
variant="skeleton"
disabled={item.included_usage === Infinite || item.interval == null}
disabled={
item.included_usage === Infinite || item.interval == null
}
>
<p className="text-t3">Customise Interval</p>
</Button>
@@ -191,7 +197,11 @@ export const IncludedUsage = () => {
}
}}
/>
<Button variant="secondary" className="px-4 h-7" onClick={handleSaveCustomInterval}>
<Button
variant="secondary"
className="px-4 h-7"
onClick={handleSaveCustomInterval}
>
Save
</Button>
</div>