Merge pull request #589 from useautumn/frontend-changes-and-fixes
frontend changes and fixes
This commit is contained in:
@@ -10,12 +10,14 @@ import {
|
||||
and,
|
||||
desc,
|
||||
eq,
|
||||
gt, ilike, isNotNull,
|
||||
gt,
|
||||
ilike,
|
||||
isNotNull,
|
||||
isNull,
|
||||
lt,
|
||||
notExists,
|
||||
or,
|
||||
sql
|
||||
sql,
|
||||
} from "drizzle-orm";
|
||||
import { alias } from "drizzle-orm/pg-core";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
@@ -155,6 +157,16 @@ export class CusSearchService {
|
||||
? or(
|
||||
...statuses.map((status) => {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return and(
|
||||
eq(customerProducts.status, CusProductStatus.Active),
|
||||
isNull(customerProducts.canceled_at),
|
||||
);
|
||||
case "past_due":
|
||||
return and(
|
||||
eq(customerProducts.status, CusProductStatus.PastDue),
|
||||
isNull(customerProducts.canceled_at),
|
||||
);
|
||||
case "canceled":
|
||||
return and(
|
||||
isNotNull(customerProducts.canceled_at),
|
||||
@@ -164,6 +176,7 @@ export class CusSearchService {
|
||||
return and(
|
||||
gt(customerProducts.trial_ends_at, Date.now()),
|
||||
isNotNull(customerProducts.free_trial_id),
|
||||
isNull(customerProducts.canceled_at),
|
||||
activeProdFilter,
|
||||
);
|
||||
case CusProductStatus.Expired:
|
||||
@@ -513,7 +526,8 @@ export class CusSearchService {
|
||||
});
|
||||
}
|
||||
|
||||
if (filters?.version && filters?.version.length > 0) {
|
||||
// Call searchByProduct if we have version filters OR status filters
|
||||
if ((filters?.version && filters?.version.length > 0) || (filters?.status && filters?.status.length > 0)) {
|
||||
return await CusSearchService.searchByProduct({
|
||||
db,
|
||||
orgId,
|
||||
@@ -642,7 +656,6 @@ export class CusSearchService {
|
||||
return { data: finalResults, count: totalCount };
|
||||
}
|
||||
}
|
||||
|
||||
// // Legacy support for product_id field (if still used)
|
||||
// let productIds: string[] = [];
|
||||
// if (filters.product_id) {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { FeatureType } from "../models/featureModels/featureEnums.js";
|
||||
import {
|
||||
FeatureType,
|
||||
FeatureUsageType,
|
||||
} from "../models/featureModels/featureEnums.js";
|
||||
import type { Feature } from "../models/featureModels/featureModels.js";
|
||||
import { Infinite } from "../models/productModels/productEnums.js";
|
||||
import type { ProductItem } from "../models/productV2Models/productItemModels/productItemModels.js";
|
||||
@@ -12,40 +15,84 @@ import {
|
||||
} from "./productV2Utils/productItemUtils/getItemType.js";
|
||||
import { notNullish, nullish } from "./utils.js";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
interface DisplayResult {
|
||||
primary_text: string;
|
||||
secondary_text?: string;
|
||||
}
|
||||
|
||||
interface FormatTiersParams {
|
||||
item: ProductItem;
|
||||
currency?: string | null;
|
||||
amountFormatOptions?: Intl.NumberFormatOptions;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
const getIntervalDisplay = (item: ProductItem): string | undefined => {
|
||||
if (!item.interval) return undefined;
|
||||
|
||||
return formatInterval({
|
||||
interval: item.interval,
|
||||
intervalCount: item.interval_count ?? undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const getIncludedUsageText = (item: ProductItem, feature: Feature): string => {
|
||||
const featureName = getFeatureName({
|
||||
feature,
|
||||
units: item.included_usage,
|
||||
});
|
||||
|
||||
if (item.included_usage === Infinite) {
|
||||
return `Unlimited ${featureName}`;
|
||||
}
|
||||
|
||||
if (nullish(item.included_usage) || item.included_usage === 0) {
|
||||
return `0 ${featureName}`;
|
||||
}
|
||||
|
||||
return `${numberWithCommas(item.included_usage)} ${featureName}`;
|
||||
};
|
||||
|
||||
const isSingleUseFeature = (feature: Feature): boolean => {
|
||||
return feature.config?.usage_type === FeatureUsageType.Single;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Tier Formatting
|
||||
// ============================================================================
|
||||
|
||||
export const formatTiers = ({
|
||||
item,
|
||||
currency,
|
||||
amountFormatOptions,
|
||||
}: {
|
||||
item: ProductItem;
|
||||
currency?: string | null;
|
||||
amountFormatOptions?: Intl.NumberFormatOptions;
|
||||
}) => {
|
||||
}: FormatTiersParams): string | undefined => {
|
||||
const tiers = item.tiers;
|
||||
if (tiers) {
|
||||
if (tiers.length === 1) {
|
||||
return formatAmount({
|
||||
currency,
|
||||
amount: tiers[0].amount,
|
||||
amountFormatOptions,
|
||||
});
|
||||
}
|
||||
if (!tiers) return undefined;
|
||||
|
||||
const firstPrice = tiers[0].amount;
|
||||
const lastPrice = tiers[tiers.length - 1].amount;
|
||||
const format = (amount: number) =>
|
||||
formatAmount({ currency, amount, amountFormatOptions });
|
||||
|
||||
return `${formatAmount({
|
||||
currency,
|
||||
amount: firstPrice,
|
||||
amountFormatOptions,
|
||||
})} - ${formatAmount({
|
||||
currency,
|
||||
amount: lastPrice,
|
||||
amountFormatOptions,
|
||||
})}`;
|
||||
if (tiers.length === 1) {
|
||||
return format(tiers[0].amount);
|
||||
}
|
||||
|
||||
const firstPrice = tiers[0].amount;
|
||||
const lastPrice = tiers[tiers.length - 1].amount;
|
||||
|
||||
return `${format(firstPrice)} - ${format(lastPrice)}`;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Feature Item Display (no pricing, just entitlement)
|
||||
// ============================================================================
|
||||
|
||||
export const getFeatureItemDisplay = ({
|
||||
item,
|
||||
feature,
|
||||
@@ -54,57 +101,30 @@ export const getFeatureItemDisplay = ({
|
||||
item: ProductItem;
|
||||
feature?: Feature;
|
||||
fullDisplay?: boolean;
|
||||
}) => {
|
||||
if (!feature) throw new Error(`Feature ${item.feature_id} not found`);
|
||||
}): DisplayResult => {
|
||||
if (!feature) {
|
||||
// Return fallback display when feature is not found (e.g., during feature ID rename)
|
||||
return { primary_text: item.feature_id || "Loading..." };
|
||||
}
|
||||
|
||||
// Boolean features just show the name
|
||||
if (feature.type === FeatureType.Boolean) {
|
||||
return { primary_text: feature.name };
|
||||
}
|
||||
|
||||
const featureName = getFeatureName({
|
||||
feature,
|
||||
units: item.included_usage,
|
||||
});
|
||||
const primaryText = getIncludedUsageText(item, feature);
|
||||
|
||||
const includedUsageTxt =
|
||||
item.included_usage === Infinite
|
||||
? "Unlimited "
|
||||
: nullish(item.included_usage) || item.included_usage === 0
|
||||
? "0 "
|
||||
: `${numberWithCommas(item.included_usage)} `;
|
||||
|
||||
// If interval is null for a feature item, it's a one-time/lifetime feature
|
||||
const intervalStr = item.interval
|
||||
? formatInterval({
|
||||
interval: item.interval,
|
||||
intervalCount: item.interval_count ?? undefined,
|
||||
})
|
||||
: "one-off";
|
||||
|
||||
return {
|
||||
primary_text: `${includedUsageTxt}${featureName}`,
|
||||
secondary_text: fullDisplay ? intervalStr : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export const getPriceItemDisplay = ({
|
||||
item,
|
||||
currency,
|
||||
}: {
|
||||
item: ProductItem;
|
||||
currency?: string | null;
|
||||
}) => {
|
||||
const primaryText = formatAmount({
|
||||
currency,
|
||||
amount: item.price as number,
|
||||
});
|
||||
|
||||
const intervalStr = formatInterval({
|
||||
interval: item.interval ?? undefined,
|
||||
intervalCount: item.interval_count ?? undefined,
|
||||
});
|
||||
|
||||
const secondaryText = intervalStr || undefined;
|
||||
// Determine secondary text (interval display)
|
||||
let secondaryText: string | undefined;
|
||||
if (fullDisplay) {
|
||||
const intervalDisplay = getIntervalDisplay(item);
|
||||
if (intervalDisplay) {
|
||||
secondaryText = intervalDisplay;
|
||||
} else if (isSingleUseFeature(feature)) {
|
||||
// Only show "one-off" for single-use features, not continuous use
|
||||
secondaryText = "one-off";
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
primary_text: primaryText,
|
||||
@@ -112,12 +132,39 @@ export const getPriceItemDisplay = ({
|
||||
};
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Price Item Display (flat price, no feature)
|
||||
// ============================================================================
|
||||
|
||||
export const getPriceItemDisplay = ({
|
||||
item,
|
||||
currency,
|
||||
}: {
|
||||
item: ProductItem;
|
||||
currency?: string | null;
|
||||
}): DisplayResult => {
|
||||
const primaryText = formatAmount({
|
||||
currency,
|
||||
amount: item.price as number,
|
||||
});
|
||||
|
||||
const secondaryText = getIntervalDisplay(item);
|
||||
|
||||
return {
|
||||
primary_text: primaryText,
|
||||
secondary_text: secondaryText,
|
||||
};
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Feature + Price Item Display (usage-based pricing)
|
||||
// ============================================================================
|
||||
|
||||
export const getFeaturePriceItemDisplay = ({
|
||||
feature,
|
||||
item,
|
||||
currency,
|
||||
isMainPrice = false,
|
||||
// minifyIncluded = false,
|
||||
amountFormatOptions,
|
||||
fullDisplay = false,
|
||||
}: {
|
||||
@@ -125,73 +172,77 @@ export const getFeaturePriceItemDisplay = ({
|
||||
item: ProductItem;
|
||||
currency?: string | null;
|
||||
isMainPrice?: boolean;
|
||||
// minifyIncluded?: boolean;
|
||||
amountFormatOptions?: Intl.NumberFormatOptions;
|
||||
fullDisplay?: boolean;
|
||||
}) => {
|
||||
}): DisplayResult => {
|
||||
if (!feature) {
|
||||
throw new Error(`Feature ${item.feature_id} not found`);
|
||||
}
|
||||
|
||||
// 1. Get included usage
|
||||
// Build included usage string (e.g., "100 credits")
|
||||
const includedUsage = item.included_usage as number | null;
|
||||
const hasIncludedUsage = notNullish(includedUsage) && includedUsage > 0;
|
||||
|
||||
const includedFeatureName = getFeatureName({
|
||||
feature,
|
||||
units: item.included_usage,
|
||||
});
|
||||
const includedUsageStr = hasIncludedUsage
|
||||
? `${numberWithCommas(includedUsage)} ${includedFeatureName}`
|
||||
: "";
|
||||
|
||||
const includedUsage = item.included_usage as number | null;
|
||||
let includedUsageStr = "";
|
||||
if (notNullish(includedUsage) && includedUsage > 0) {
|
||||
includedUsageStr = `${numberWithCommas(includedUsage)} ${includedFeatureName}`;
|
||||
}
|
||||
|
||||
// Build price string (e.g., "$0.01")
|
||||
const priceStr = formatTiers({ item, currency, amountFormatOptions }) ?? "";
|
||||
|
||||
// For "per X" display, use singular when billing_units is 1 or not specified
|
||||
// Build billing unit string (e.g., "credit" or "100 credits")
|
||||
const billingUnits = item.billing_units ?? 1;
|
||||
const billingFeatureName = getFeatureName({
|
||||
feature,
|
||||
units: billingUnits,
|
||||
});
|
||||
const perUnitStr =
|
||||
billingUnits > 1
|
||||
? `${numberWithCommas(billingUnits)} ${billingFeatureName}`
|
||||
: billingFeatureName;
|
||||
|
||||
let priceStr2 = "";
|
||||
if (billingUnits > 1) {
|
||||
priceStr2 = `${numberWithCommas(billingUnits)} ${billingFeatureName}`;
|
||||
} else {
|
||||
priceStr2 = `${billingFeatureName}`;
|
||||
// Build interval string
|
||||
const showInterval = isMainPrice || fullDisplay;
|
||||
let intervalStr = "";
|
||||
if (showInterval) {
|
||||
const intervalDisplay = getIntervalDisplay(item);
|
||||
if (intervalDisplay) {
|
||||
intervalStr = intervalDisplay;
|
||||
} else if (isSingleUseFeature(feature)) {
|
||||
intervalStr = "one-off";
|
||||
}
|
||||
}
|
||||
|
||||
// If interval is null for a priced feature, it's a one-time purchase
|
||||
const intervalStr =
|
||||
isMainPrice || fullDisplay
|
||||
? item.interval
|
||||
? formatInterval({
|
||||
interval: item.interval,
|
||||
intervalCount: item.interval_count ?? undefined,
|
||||
})
|
||||
: "one-off"
|
||||
: "";
|
||||
|
||||
if (includedUsageStr) {
|
||||
// Format output based on what we have
|
||||
if (hasIncludedUsage) {
|
||||
return {
|
||||
primary_text: includedUsageStr,
|
||||
secondary_text: `then ${priceStr} per ${priceStr2} ${intervalStr}`,
|
||||
secondary_text:
|
||||
`then ${priceStr} per ${perUnitStr} ${intervalStr}`.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
if (isMainPrice || fullDisplay) {
|
||||
if (showInterval) {
|
||||
return {
|
||||
primary_text: priceStr,
|
||||
secondary_text: `per ${priceStr2} ${intervalStr}`,
|
||||
secondary_text: `per ${perUnitStr} ${intervalStr}`.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
primary_text: `${priceStr} per ${priceStr2} ${intervalStr}`,
|
||||
primary_text: `${priceStr} per ${perUnitStr}`.trim(),
|
||||
secondary_text: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Main Entry Point
|
||||
// ============================================================================
|
||||
|
||||
export const getProductItemDisplay = ({
|
||||
item,
|
||||
features,
|
||||
@@ -204,26 +255,25 @@ export const getProductItemDisplay = ({
|
||||
currency?: string | null;
|
||||
fullDisplay?: boolean;
|
||||
amountFormatOptions?: Intl.NumberFormatOptions;
|
||||
}) => {
|
||||
}): DisplayResult => {
|
||||
const findFeature = () => features.find((f) => f.id === item.feature_id);
|
||||
|
||||
if (isFeatureItem(item)) {
|
||||
return getFeatureItemDisplay({
|
||||
item,
|
||||
feature: features.find((f) => f.id === item.feature_id),
|
||||
feature: findFeature(),
|
||||
fullDisplay,
|
||||
});
|
||||
}
|
||||
|
||||
if (isPriceItem(item)) {
|
||||
return getPriceItemDisplay({
|
||||
item,
|
||||
currency,
|
||||
});
|
||||
return getPriceItemDisplay({ item, currency });
|
||||
}
|
||||
|
||||
if (isFeaturePriceItem(item)) {
|
||||
return getFeaturePriceItemDisplay({
|
||||
item,
|
||||
feature: features.find((f) => f.id === item.feature_id),
|
||||
feature: findFeature(),
|
||||
currency,
|
||||
fullDisplay,
|
||||
amountFormatOptions,
|
||||
|
||||
@@ -103,6 +103,12 @@ const tiersAreSame = (
|
||||
);
|
||||
};
|
||||
|
||||
// Helper to normalize included_usage for comparison (null and 0 are equivalent)
|
||||
const normalizeIncludedUsage = (value: number | "inf" | null | undefined) => {
|
||||
if (value === null || value === undefined) return 0;
|
||||
return value;
|
||||
};
|
||||
|
||||
export const featureItemsAreSame = ({
|
||||
item1,
|
||||
item2,
|
||||
@@ -118,7 +124,10 @@ export const featureItemsAreSame = ({
|
||||
message: `Feature ID different: ${item1.feature_id} != ${item2.feature_id}`,
|
||||
},
|
||||
included_usage: {
|
||||
condition: item1.included_usage == item2.included_usage,
|
||||
// Normalize null/undefined to 0 for comparison since they're semantically equivalent
|
||||
condition:
|
||||
normalizeIncludedUsage(item1.included_usage) ==
|
||||
normalizeIncludedUsage(item2.included_usage),
|
||||
message: `Included usage different: ${item1.included_usage} != ${item2.included_usage}`,
|
||||
},
|
||||
interval: {
|
||||
@@ -232,7 +241,10 @@ export const featurePriceItemsAreSame = ({
|
||||
// console.log("Item 2 config:", item2.config);
|
||||
const entsSame = {
|
||||
included_usage: {
|
||||
condition: item1.included_usage == item2.included_usage,
|
||||
// Normalize null/undefined to 0 for comparison since they're semantically equivalent
|
||||
condition:
|
||||
normalizeIncludedUsage(item1.included_usage) ==
|
||||
normalizeIncludedUsage(item2.included_usage),
|
||||
message: `Included usage different: ${item1.included_usage} != ${item2.included_usage}`,
|
||||
},
|
||||
usage_limit: {
|
||||
|
||||
@@ -14,7 +14,7 @@ function SortIcon({ sortDirection }: { sortDirection: string | false }) {
|
||||
return (
|
||||
<ChevronUpIcon
|
||||
aria-hidden="true"
|
||||
className="shrink-0 opacity-60"
|
||||
className="shrink-0 text-primary"
|
||||
size={16}
|
||||
/>
|
||||
);
|
||||
@@ -23,7 +23,7 @@ function SortIcon({ sortDirection }: { sortDirection: string | false }) {
|
||||
return (
|
||||
<ChevronDownIcon
|
||||
aria-hidden="true"
|
||||
className={cn("shrink-0", sortDirection ? "opacity-60" : "opacity-30")}
|
||||
className={cn("shrink-0", sortDirection ? "text-primary" : "opacity-30")}
|
||||
size={16}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { AnimatePresence, motion } from "motion/react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Button } from "@/components/v2/buttons/Button";
|
||||
import { ShortcutButton } from "@/components/v2/buttons/ShortcutButton";
|
||||
import { SheetOverlay } from "@/components/v2/sheet-overlay/SheetOverlay";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CustomerPlanInfoBox } from "@/views/customers2/customer-plan/CustomerPlanInfoBox";
|
||||
import { EditPlanHeader } from "@/views/products/plan/components/EditPlanHeader";
|
||||
@@ -53,7 +54,7 @@ function InlinePlanEditorContent({
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { product } = useProduct();
|
||||
const { sheetType, closeSheet } = useSheet();
|
||||
const { sheetType } = useSheet();
|
||||
const hasPlanChanges = useHasPlanChanges();
|
||||
|
||||
return (
|
||||
@@ -63,6 +64,7 @@ function InlinePlanEditorContent({
|
||||
exit={{ opacity: 0, scale: 0.97, y: 8 }}
|
||||
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="absolute inset-0 z-100 bg-background flex flex-col"
|
||||
data-inline-editor-open
|
||||
>
|
||||
<div className="flex w-full h-full overflow-hidden relative flex-1">
|
||||
<motion.div
|
||||
@@ -99,18 +101,7 @@ function InlinePlanEditorContent({
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<AnimatePresence>
|
||||
{sheetType && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="absolute inset-0 bg-white/70 dark:bg-black/70"
|
||||
style={{ zIndex: 40 }}
|
||||
onMouseDown={() => closeSheet()}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<SheetOverlay inline />
|
||||
|
||||
<ProductSheets />
|
||||
</div>
|
||||
|
||||
@@ -104,8 +104,8 @@ function SelectContent({
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
// Change z-50 to z-[101] or higher to beat the sheet's z-100
|
||||
"bg-interactive-secondary text-popover-foreground relative z-[101] max-h-(--radix-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
// z-[200] to appear above sheets (z-[150])
|
||||
"bg-interactive-secondary text-popover-foreground relative z-[200] max-h-(--radix-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
|
||||
105
vite/src/components/v2/sheet-overlay/SheetOverlay.tsx
Normal file
105
vite/src/components/v2/sheet-overlay/SheetOverlay.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { ProductItem } from "@autumn/shared";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
useCurrentItem,
|
||||
useHasItemChanges,
|
||||
useSheet,
|
||||
} from "@/components/v2/inline-custom-plan-editor/PlanEditorContext";
|
||||
|
||||
/**
|
||||
* Determines if the sheet should close based on the mouse down event.
|
||||
* Handles edge cases like unsaved changes and input blur behavior.
|
||||
*/
|
||||
function shouldCloseSheetOnMouseDown({
|
||||
e,
|
||||
item,
|
||||
sheetType,
|
||||
hasItemChanges,
|
||||
}: {
|
||||
e: React.MouseEvent<HTMLDivElement>;
|
||||
item: ProductItem | null;
|
||||
sheetType: string | null;
|
||||
hasItemChanges: boolean;
|
||||
}): boolean {
|
||||
// Don't close if item has unsaved changes
|
||||
if (hasItemChanges) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get the active element before blur happens
|
||||
const activeElement = document.activeElement;
|
||||
|
||||
if (
|
||||
activeElement &&
|
||||
activeElement !== document.body &&
|
||||
activeElement instanceof HTMLElement
|
||||
) {
|
||||
// Only apply blur behavior to inputs, textareas, and selects
|
||||
const isInputElement =
|
||||
activeElement.tagName === "INPUT" ||
|
||||
activeElement.tagName === "TEXTAREA" ||
|
||||
activeElement.tagName === "SELECT";
|
||||
|
||||
if (!isInputElement) {
|
||||
// Not an input, proceed with normal close behavior
|
||||
return !!sheetType;
|
||||
}
|
||||
|
||||
// Check if the active element is within the sheet (not in the main content area)
|
||||
const clickTarget = e.target as HTMLElement;
|
||||
const isActiveInSheet = !clickTarget.contains(activeElement);
|
||||
|
||||
if (isActiveInSheet) {
|
||||
activeElement.blur();
|
||||
e.preventDefault(); // Prevent default to stop the click from propagating
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// If the click is outside the sheet and no input is focused, close the sheet
|
||||
return !!sheetType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared overlay component for plan editors.
|
||||
* Portals to [data-main-content] by default, or renders inline if `inline` prop is true.
|
||||
*/
|
||||
export function SheetOverlay({ inline = false }: { inline?: boolean }) {
|
||||
const { sheetType, closeSheet } = useSheet();
|
||||
const item = useCurrentItem();
|
||||
const hasItemChanges = useHasItemChanges();
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (shouldCloseSheetOnMouseDown({ e, item, sheetType, hasItemChanges })) {
|
||||
closeSheet();
|
||||
}
|
||||
};
|
||||
|
||||
const overlay = (
|
||||
<AnimatePresence>
|
||||
{sheetType && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="absolute inset-0 bg-white/70 dark:bg-black/70"
|
||||
style={{ zIndex: 40 }}
|
||||
onMouseDown={handleMouseDown}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
|
||||
if (inline) {
|
||||
return overlay;
|
||||
}
|
||||
|
||||
const mainContent = document.querySelector("[data-main-content]");
|
||||
if (!mainContent) {
|
||||
console.error("[SheetOverlay] Could not find portal target");
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(overlay, mainContent);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ interface SheetHeaderProps {
|
||||
isOnboarding?: boolean;
|
||||
breadcrumbs?: { name: string; sheet?: string }[];
|
||||
itemId?: string | null;
|
||||
action?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function SheetHeader({
|
||||
@@ -25,6 +26,7 @@ export function SheetHeader({
|
||||
className,
|
||||
isOnboarding = false,
|
||||
itemId,
|
||||
action,
|
||||
}: SheetHeaderProps) {
|
||||
return (
|
||||
<div className={cn("p-4 pb-0", className)}>
|
||||
@@ -37,16 +39,19 @@ export function SheetHeader({
|
||||
) : (
|
||||
<h2 className="text-main">{title}</h2>
|
||||
)}
|
||||
<p
|
||||
className={cn(
|
||||
"text-t3 text-sm mt-1 truncate",
|
||||
isOnboarding && "text-body-secondary",
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
<div className="flex items-end justify-between gap-2 mt-1">
|
||||
<p
|
||||
className={cn(
|
||||
"text-t3 text-sm flex-1",
|
||||
isOnboarding && "text-body-secondary",
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
{action}
|
||||
</div>
|
||||
{children}
|
||||
{!noSeparator && <Separator className="mt-4" />}
|
||||
{!noSeparator && <Separator className="mt-2" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { AppEnv } from "@autumn/shared";
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog";
|
||||
import { XIcon } from "lucide-react";
|
||||
import type * as React from "react";
|
||||
|
||||
import { usePortalContainer } from "@/contexts/PortalContainerContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEnv } from "@/utils/envUtils";
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
||||
@@ -28,11 +25,11 @@ function SheetClose({
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
const containerRef = usePortalContainer();
|
||||
const mainContent = document.querySelector("[data-main-content]");
|
||||
return (
|
||||
<SheetPrimitive.Portal
|
||||
data-slot="sheet-portal"
|
||||
container={containerRef?.current ?? undefined}
|
||||
container={mainContent ?? undefined}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -46,7 +43,7 @@ function SheetOverlay({
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-white/70 dark:bg-black/70",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 absolute inset-0 z-[150] bg-white/70 dark:bg-black/70",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -64,25 +61,17 @@ function SheetContent({
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
hideCloseButton?: boolean;
|
||||
}) {
|
||||
const env = useEnv();
|
||||
const isSandbox = env === AppEnv.Sandbox;
|
||||
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
style={
|
||||
(side === "right" || side === "left") && isSandbox
|
||||
? { top: "40px" }
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
"bg-card data-[state=open]:animate-in data-[state=closed]:animate-out absolute z-50 flex flex-col gap-0 shadow-sm transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-300",
|
||||
"bg-card data-[state=open]:animate-in data-[state=closed]:animate-out absolute z-[150] flex flex-col gap-0 shadow-sm transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-300",
|
||||
side === "right" &&
|
||||
`data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right ${isSandbox ? "" : "top-0"} bottom-0 right-0 w-full min-w-xs max-w-md border-l border-border/40`,
|
||||
`data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right top-0 bottom-0 right-0 w-full min-w-xs max-w-md border-l border-border/40`,
|
||||
side === "left" &&
|
||||
`data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left ${isSandbox ? "" : "top-0"} bottom-0 left-0 w-3/4 border-r border-border/40 sm:max-w-sm`,
|
||||
`data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left top-0 bottom-0 left-0 w-3/4 border-r border-border/40 sm:max-w-sm`,
|
||||
side === "top" &&
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top left-0 right-0 top-0 h-auto border-b border-border/40",
|
||||
side === "bottom" &&
|
||||
|
||||
@@ -122,8 +122,12 @@ export const useSheetEscapeHandler = ({
|
||||
document.querySelector('[data-state="open"][role="alertdialog"]') ||
|
||||
document.querySelector("dialog[open]");
|
||||
|
||||
const isInlineEditorOpen = document.querySelector(
|
||||
"[data-inline-editor-open]",
|
||||
);
|
||||
|
||||
// Only close sheet if no dialog is open
|
||||
if (!isDialogOpen) {
|
||||
if (!isDialogOpen && !isInlineEditorOpen) {
|
||||
// Use custom onClose if provided, otherwise default closeSheet
|
||||
if (onClose) {
|
||||
onClose();
|
||||
|
||||
@@ -40,7 +40,7 @@ function CustomersFilterButton() {
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<RenderFilterTrigger />
|
||||
<DropdownMenuContent
|
||||
className="w-56 font-regular text-zinc-800 gap-0 p-0"
|
||||
className="w-56 font-regular gap-0 p-0"
|
||||
align="start"
|
||||
>
|
||||
<DropdownMenuGroup className="p-1">
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import { Checkbox } from "@/components/v2/checkboxes/Checkbox";
|
||||
import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuItem,
|
||||
} from "@/components/v2/dropdowns/DropdownMenu";
|
||||
import { Checkbox } from "@/components/v2/checkboxes/Checkbox";
|
||||
import { keyToTitle } from "@/utils/formatUtils/formatTextUtils";
|
||||
import { useCustomersQueryStates } from "../../hooks/useCustomersQueryStates";
|
||||
|
||||
export const FilterStatusSubMenu = () => {
|
||||
const { queryStates, setQueryStates } = useCustomersQueryStates();
|
||||
const { queryStates, setFilters } = useCustomersQueryStates();
|
||||
|
||||
const statuses: string[] = ["canceled", "free_trial", "expired"];
|
||||
const statuses: string[] = [
|
||||
"active",
|
||||
"past_due",
|
||||
"canceled",
|
||||
"free_trial",
|
||||
"expired",
|
||||
];
|
||||
const selectedStatuses = queryStates.status || [];
|
||||
const hasSelections = selectedStatuses.length > 0;
|
||||
|
||||
@@ -23,7 +29,7 @@ export const FilterStatusSubMenu = () => {
|
||||
? selected.filter((s: string) => s !== status)
|
||||
: [...selected, status];
|
||||
|
||||
setQueryStates({ ...queryStates, status: updated });
|
||||
setFilters({ status: updated });
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { Checkbox } from "@/components/v2/checkboxes/Checkbox";
|
||||
import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuItem,
|
||||
} from "@/components/v2/dropdowns/DropdownMenu";
|
||||
import { Checkbox } from "@/components/v2/checkboxes/Checkbox";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useCustomersQueryStates } from "../../hooks/useCustomersQueryStates";
|
||||
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getVersionCounts } from "@/utils/productUtils";
|
||||
import { useCustomersQueryStates } from "../../hooks/useCustomersQueryStates";
|
||||
|
||||
export const ProductsSubMenu = () => {
|
||||
const { products } = useProductsQuery();
|
||||
const { queryStates, setQueryStates } = useCustomersQueryStates();
|
||||
const { queryStates, setFilters } = useCustomersQueryStates();
|
||||
const versionCounts = getVersionCounts(products);
|
||||
|
||||
const selectedVersions = queryStates.version;
|
||||
@@ -63,14 +63,9 @@ export const ProductsSubMenu = () => {
|
||||
allProductVersions.length > 0 &&
|
||||
allProductVersions.every((pv) => selectedVersions.includes(pv.key));
|
||||
if (allSelected) {
|
||||
setQueryStates({
|
||||
...queryStates,
|
||||
version: [],
|
||||
none: false,
|
||||
});
|
||||
setFilters({ version: [], none: false });
|
||||
} else {
|
||||
setQueryStates({
|
||||
...queryStates,
|
||||
setFilters({
|
||||
version: allProductVersions.map((pv) => pv.key),
|
||||
none: false,
|
||||
});
|
||||
@@ -88,7 +83,7 @@ export const ProductsSubMenu = () => {
|
||||
selectedVersions.includes(key),
|
||||
);
|
||||
|
||||
let newSelectedVersions;
|
||||
let newSelectedVersions: string[] = [];
|
||||
let newNone = queryStates.none;
|
||||
if (allProductVersionsSelected) {
|
||||
// Deselect all versions of this product
|
||||
@@ -104,18 +99,14 @@ export const ProductsSubMenu = () => {
|
||||
newNone = false;
|
||||
}
|
||||
|
||||
setQueryStates({
|
||||
...queryStates,
|
||||
version: newSelectedVersions,
|
||||
none: newNone,
|
||||
});
|
||||
setFilters({ version: newSelectedVersions, none: newNone });
|
||||
};
|
||||
|
||||
const toggleVersion = (productId: string, version: string) => {
|
||||
const versionKey = `${productId}:${version}`;
|
||||
const isSelected = selectedVersions.includes(versionKey);
|
||||
|
||||
let newSelectedVersions;
|
||||
let newSelectedVersions: string[] = [];
|
||||
let newNone = queryStates.none;
|
||||
if (isSelected) {
|
||||
newSelectedVersions = selectedVersions.filter(
|
||||
@@ -126,28 +117,20 @@ export const ProductsSubMenu = () => {
|
||||
newNone = false;
|
||||
}
|
||||
|
||||
setQueryStates({
|
||||
...queryStates,
|
||||
version: newSelectedVersions,
|
||||
none: newNone,
|
||||
});
|
||||
setFilters({ version: newSelectedVersions, none: newNone });
|
||||
};
|
||||
|
||||
const handleSelectNone = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
setQueryStates({
|
||||
...queryStates,
|
||||
version: [],
|
||||
none: !queryStates.none,
|
||||
});
|
||||
setFilters({ version: [], none: !queryStates.none });
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className="flex items-center gap-2 cursor-pointer">
|
||||
Products
|
||||
Plans
|
||||
{hasSelections && (
|
||||
<span className="text-xs text-t3 bg-muted px-1 py-0 rounded-md">
|
||||
{selectedProductsCount}
|
||||
@@ -163,17 +146,21 @@ export const ProductsSubMenu = () => {
|
||||
<>
|
||||
<div className="flex items-center justify-between px-2 h-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSelectAll}
|
||||
className={cn(
|
||||
"px-1 h-5 flex items-center gap-1 text-t2 text-xs hover:text-t1 bg-accent cursor-pointer rounded-md",
|
||||
allProductVersions.length > 0 &&
|
||||
allProductVersions.every((pv) => selectedVersions.includes(pv.key)) &&
|
||||
allProductVersions.every((pv) =>
|
||||
selectedVersions.includes(pv.key),
|
||||
) &&
|
||||
"bg-primary/10 text-primary hover:text-primary/80",
|
||||
)}
|
||||
>
|
||||
Select all
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSelectNone}
|
||||
className={cn(
|
||||
"px-1 h-5 flex items-center gap-1 text-t3 text-xs hover:text-t1 hover:bg-accent cursor-pointer rounded-md",
|
||||
@@ -181,112 +168,119 @@ export const ProductsSubMenu = () => {
|
||||
"bg-primary/10 text-primary hover:text-primary/80",
|
||||
)}
|
||||
>
|
||||
No products
|
||||
No plans
|
||||
</button>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{uniqueProducts?.map((product: any) => {
|
||||
const versionCount = versionCounts?.[product.id] || 1;
|
||||
const productVersionKeys = Array.from(
|
||||
{ length: versionCount },
|
||||
(_, i) => `${product.id}:${i + 1}`,
|
||||
);
|
||||
const allProductVersionsSelected = productVersionKeys.every((key) =>
|
||||
selectedVersions.includes(key),
|
||||
);
|
||||
const someProductVersionsSelected = productVersionKeys.some((key) =>
|
||||
selectedVersions.includes(key),
|
||||
);
|
||||
{uniqueProducts?.map((product: any) => {
|
||||
const versionCount = versionCounts?.[product.id] || 1;
|
||||
const productVersionKeys = Array.from(
|
||||
{ length: versionCount },
|
||||
(_, i) => `${product.id}:${i + 1}`,
|
||||
);
|
||||
const allProductVersionsSelected = productVersionKeys.every(
|
||||
(key) => selectedVersions.includes(key),
|
||||
);
|
||||
const someProductVersionsSelected = productVersionKeys.some(
|
||||
(key) => selectedVersions.includes(key),
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={product.id}>
|
||||
{versionCount === 1 ? (
|
||||
// Single version - show just one button for the product
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
toggleVersion(product.id, "1");
|
||||
}}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
className="flex items-center gap-2 cursor-pointer font-medium"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedVersions.includes(`${product.id}:1`)}
|
||||
className="border-border"
|
||||
/>
|
||||
{product.name}
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
// Multiple versions - show product name with hover submenu for versions
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger
|
||||
className="flex items-center gap-2 cursor-pointer font-medium"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
toggleProduct(product);
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={allProductVersionsSelected}
|
||||
className="border-border"
|
||||
ref={(ref: any) => {
|
||||
if (
|
||||
ref &&
|
||||
someProductVersionsSelected &&
|
||||
!allProductVersionsSelected
|
||||
) {
|
||||
ref.indeterminate = true;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{product.name}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
return (
|
||||
<div key={product.id}>
|
||||
{versionCount === 1 ? (
|
||||
// Single version - show just one button for the product
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
toggleProduct(product);
|
||||
toggleVersion(product.id, "1");
|
||||
}}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
className="flex items-center gap-2 cursor-pointer font-medium"
|
||||
>
|
||||
<Checkbox checked={allProductVersionsSelected} className="border-border" />
|
||||
All Versions
|
||||
<Checkbox
|
||||
checked={selectedVersions.includes(`${product.id}:1`)}
|
||||
className="border-border"
|
||||
/>
|
||||
{product.name}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{Array.from(
|
||||
{ length: versionCount },
|
||||
(_, i) => i + 1,
|
||||
).map((version) => {
|
||||
const versionKey = `${product.id}:${version}`;
|
||||
const isVersionSelected =
|
||||
selectedVersions.includes(versionKey);
|
||||
|
||||
return (
|
||||
) : (
|
||||
// Multiple versions - show product name with hover submenu for versions
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger
|
||||
className="flex items-center gap-2 cursor-pointer font-medium"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
toggleProduct(product);
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={allProductVersionsSelected}
|
||||
className="border-border"
|
||||
ref={(ref: any) => {
|
||||
if (
|
||||
ref &&
|
||||
someProductVersionsSelected &&
|
||||
!allProductVersionsSelected
|
||||
) {
|
||||
ref.indeterminate = true;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{product.name}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem
|
||||
key={versionKey}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
toggleVersion(product.id, version.toString());
|
||||
toggleProduct(product);
|
||||
}}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
className="flex items-center gap-2 cursor-pointer text-sm"
|
||||
className="flex items-center gap-2 cursor-pointer font-medium"
|
||||
>
|
||||
<Checkbox checked={isVersionSelected} className="border-border" />v{version}
|
||||
<Checkbox
|
||||
checked={allProductVersionsSelected}
|
||||
className="border-border"
|
||||
/>
|
||||
All Versions
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
{Array.from(
|
||||
{ length: versionCount },
|
||||
(_, i) => i + 1,
|
||||
).map((version) => {
|
||||
const versionKey = `${product.id}:${version}`;
|
||||
const isVersionSelected =
|
||||
selectedVersions.includes(versionKey);
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={versionKey}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
toggleVersion(product.id, version.toString());
|
||||
}}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
className="flex items-center gap-2 cursor-pointer text-sm"
|
||||
>
|
||||
<Checkbox
|
||||
checked={isVersionSelected}
|
||||
className="border-border"
|
||||
/>
|
||||
v{version}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { TrashIcon } from "@phosphor-icons/react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
@@ -9,13 +13,8 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { toast } from "sonner";
|
||||
import { getBackendErr } from "@/utils/genUtils";
|
||||
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||
import { Delete } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { getBackendErr } from "@/utils/genUtils";
|
||||
import { useCustomersQueryStates } from "../../hooks/useCustomersQueryStates";
|
||||
|
||||
interface SavedView {
|
||||
@@ -34,7 +33,7 @@ export const SavedViews = ({
|
||||
mutateViews: any;
|
||||
setDropdownOpen: (open: boolean) => void;
|
||||
}) => {
|
||||
const { setQueryStates } = useCustomersQueryStates();
|
||||
const { setFilters } = useCustomersQueryStates();
|
||||
const axiosInstance = useAxiosInstance();
|
||||
const [deletingViewId, setDeletingViewId] = useState<string | null>(null);
|
||||
|
||||
@@ -44,24 +43,17 @@ export const SavedViews = ({
|
||||
const decodedParams = atob(view.filters);
|
||||
const params = new URLSearchParams(decodedParams);
|
||||
|
||||
// Apply all parameters using setQueryStates (this will reset pagination automatically)
|
||||
// Apply all parameters using setFilters (this will reset pagination automatically)
|
||||
const statusParam = params.get("status") || "";
|
||||
const versionParam = params.get("version") || "";
|
||||
const noneParam = params.get("none");
|
||||
|
||||
const queryParams = {
|
||||
page: 1,
|
||||
setFilters({
|
||||
q: params.get("q") || "",
|
||||
status: statusParam ? statusParam.split(",").filter(Boolean) : [],
|
||||
version: versionParam ? versionParam.split(",").filter(Boolean) : [],
|
||||
none: noneParam === "true",
|
||||
lastItemId: "",
|
||||
};
|
||||
|
||||
setQueryStates(queryParams);
|
||||
|
||||
// Explicitly trigger a data refetch to ensure the view is applied immediately
|
||||
// await mutate();
|
||||
});
|
||||
|
||||
toast.success(`Applied filters from ${view.name} view`);
|
||||
} catch (error) {
|
||||
@@ -95,7 +87,7 @@ export const SavedViews = ({
|
||||
{views.map((view: SavedView) => (
|
||||
<div
|
||||
key={view.id}
|
||||
className="flex items-center justify-between cursor-pointer px-2 hover:bg-zinc-100 rounded-sm"
|
||||
className="flex items-center justify-between cursor-pointer px-2 hover:bg-accent rounded-sm"
|
||||
onClick={async () => {
|
||||
await applyView(view);
|
||||
setDropdownOpen(false);
|
||||
@@ -110,18 +102,22 @@ export const SavedViews = ({
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className="ml-2 p-1 hover:bg-zinc-200 rounded"
|
||||
className="ml-2 p-1 hover:bg-destructive/10 rounded group"
|
||||
>
|
||||
<Delete size={12} className="text-t3" />
|
||||
<TrashIcon
|
||||
size={12}
|
||||
className="text-t3 group-hover:text-red-500"
|
||||
/>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
sideOffset={2}
|
||||
align="start"
|
||||
className="border border-zinc-200 w-64 z-50"
|
||||
className="border w-64 z-200"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { BookmarkIcon, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -10,8 +12,6 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||
import { getBackendErr } from "@/utils/genUtils";
|
||||
import { BookmarkIcon, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useCustomersQueryStates } from "../../hooks/useCustomersQueryStates";
|
||||
import { useSavedViewsQuery } from "../../hooks/useSavedViewsQuery";
|
||||
|
||||
@@ -119,6 +119,7 @@ export const SavedViewsDropdown = () => {
|
||||
>
|
||||
<span className="truncate flex-1">{view.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => deleteView(view.id, view.name, e)}
|
||||
className="ml-2 p-1 hover:bg-red-100 rounded"
|
||||
>
|
||||
|
||||
@@ -68,7 +68,7 @@ export const useAnalyticsData = ({
|
||||
} = usePostSWR({
|
||||
url: `/query/events`,
|
||||
data: {
|
||||
customer_id: customerId || null,
|
||||
customer_id: customerId || undefined,
|
||||
interval: interval || "30d",
|
||||
event_names: [...(eventNames || []), ...(featureIds || [])],
|
||||
group_by: formattedGroupBy,
|
||||
@@ -132,7 +132,7 @@ export const useRawAnalyticsData = () => {
|
||||
} = usePostSWR({
|
||||
url: `/query/raw`,
|
||||
data: {
|
||||
customer_id: customerId || null,
|
||||
customer_id: customerId || undefined,
|
||||
interval: interval || "30d",
|
||||
},
|
||||
queryKey,
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
parseAsString,
|
||||
useQueryStates,
|
||||
} from "nuqs";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
export const useCustomersQueryStates = () => {
|
||||
const [queryStates, setQueryStates] = useQueryStates(
|
||||
{
|
||||
@@ -22,7 +22,13 @@ export const useCustomersQueryStates = () => {
|
||||
},
|
||||
);
|
||||
|
||||
// return { queryStates, setQueryStates };
|
||||
// Wrapper that resets pagination when filters change
|
||||
const setFilters = useCallback(
|
||||
(filters: Partial<Omit<typeof queryStates, "page" | "lastItemId">>) => {
|
||||
setQueryStates({ ...filters, page: 1, lastItemId: "" });
|
||||
},
|
||||
[setQueryStates],
|
||||
);
|
||||
|
||||
const [stableStates, setStableStates] = useState(queryStates);
|
||||
|
||||
@@ -33,5 +39,5 @@ export const useCustomersQueryStates = () => {
|
||||
debouncedSetStableStates(queryStates);
|
||||
}, [queryStates]);
|
||||
|
||||
return { queryStates: stableStates, setQueryStates };
|
||||
return { queryStates: stableStates, setQueryStates, setFilters };
|
||||
};
|
||||
|
||||
@@ -275,6 +275,7 @@ export function SubscriptionDetailSheet() {
|
||||
<CustomerProductsStatus
|
||||
status={cusProduct.status}
|
||||
canceled={cusProduct.canceled}
|
||||
canceled_at={cusProduct.canceled_at ?? undefined}
|
||||
trialing={
|
||||
isCustomerProductTrialing(cusProduct, {
|
||||
nowMs: Date.now(),
|
||||
|
||||
@@ -91,6 +91,9 @@ const getCusProductsInfo = ({
|
||||
canceled={
|
||||
(cusProduct as FullCusProduct).canceled_at ? true : undefined
|
||||
}
|
||||
canceled_at={
|
||||
(cusProduct as FullCusProduct).canceled_at ?? undefined
|
||||
}
|
||||
tooltip={true}
|
||||
trialing={
|
||||
isCustomerProductTrialing(cusProduct as FullCusProduct, {
|
||||
|
||||
@@ -19,7 +19,7 @@ import { useCustomersQueryStates } from "@/views/customers/hooks/useCustomersQue
|
||||
import { useSavedViewsQuery } from "@/views/customers/hooks/useSavedViewsQuery";
|
||||
|
||||
export function CustomerListFilterButton() {
|
||||
const { setQueryStates } = useCustomersQueryStates();
|
||||
const { setFilters } = useCustomersQueryStates();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { data, refetch: refetchSavedViews } = useSavedViewsQuery();
|
||||
@@ -27,11 +27,7 @@ export function CustomerListFilterButton() {
|
||||
const views = data?.views || [];
|
||||
|
||||
const clearFilters = () => {
|
||||
setQueryStates({
|
||||
status: [],
|
||||
version: [],
|
||||
none: false,
|
||||
});
|
||||
setFilters({ status: [], version: [], none: false });
|
||||
};
|
||||
|
||||
const closeFilterModal = () => {
|
||||
@@ -51,7 +47,7 @@ export function CustomerListFilterButton() {
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-56 font-regular text-zinc-800 gap-0 p-0"
|
||||
className="w-56 font-regular gap-0 p-0"
|
||||
align="start"
|
||||
>
|
||||
<DropdownMenuGroup className="p-1">
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useCusSearchQuery } from "@/views/customers/hooks/useCusSearchQuery";
|
||||
import { useCustomersQueryStates } from "@/views/customers/hooks/useCustomersQueryStates";
|
||||
|
||||
export function CustomerListSearchBar() {
|
||||
const { queryStates, setQueryStates } = useCustomersQueryStates();
|
||||
const { queryStates, setFilters } = useCustomersQueryStates();
|
||||
|
||||
const { totalCount } = useCusSearchQuery();
|
||||
const navigate = useNavigate();
|
||||
@@ -16,9 +16,9 @@ export function CustomerListSearchBar() {
|
||||
const debouncedSearch = useMemo(
|
||||
() =>
|
||||
debounce(async (query: string) => {
|
||||
setQueryStates({ q: query, page: 1 });
|
||||
setFilters({ q: query });
|
||||
}, 350),
|
||||
[location.search, location.pathname, navigate, setQueryStates],
|
||||
[location.search, location.pathname, navigate, setFilters],
|
||||
);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
|
||||
@@ -104,8 +104,14 @@ export function CustomerListTable({
|
||||
|
||||
const hasRows = table.getRowModel().rows.length > 0;
|
||||
const hasSearchQuery = Boolean(queryStates.q?.trim());
|
||||
const hasFilters =
|
||||
queryStates.status.length > 0 ||
|
||||
queryStates.version.length > 0 ||
|
||||
queryStates.none;
|
||||
const hasActiveFiltersOrSearch = hasSearchQuery || hasFilters;
|
||||
|
||||
if (!hasRows && !hasSearchQuery) {
|
||||
// Only show empty state if org has NO customers (no filters/search active and no results)
|
||||
if (!hasRows && !hasActiveFiltersOrSearch) {
|
||||
return (
|
||||
<EmptyState
|
||||
type="customers"
|
||||
@@ -142,7 +148,7 @@ export function CustomerListTable({
|
||||
<CustomerListCreateButton />
|
||||
</div>
|
||||
|
||||
{!hasRows && hasSearchQuery ? (
|
||||
{!hasRows && hasActiveFiltersOrSearch ? (
|
||||
<EmptyState
|
||||
type="no-customers-found"
|
||||
actionButton={<CustomerListCreateButton />}
|
||||
|
||||
@@ -50,6 +50,7 @@ export const CustomerProductsColumns = [
|
||||
status={row.original.status}
|
||||
starts_at={row.original.starts_at ?? undefined}
|
||||
canceled={row.original.canceled}
|
||||
canceled_at={row.original.canceled_at ?? undefined}
|
||||
trialing={isCustomerProductTrialing(row.original) || false}
|
||||
trial_ends_at={row.original.trial_ends_at ?? undefined}
|
||||
/>
|
||||
|
||||
@@ -14,15 +14,29 @@ const StatusItem = ({
|
||||
children,
|
||||
text,
|
||||
trial_ends_at,
|
||||
canceled_at,
|
||||
tooltip,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
text: string;
|
||||
trial_ends_at?: number;
|
||||
canceled_at?: number;
|
||||
tooltip?: boolean;
|
||||
className?: string;
|
||||
}) => {
|
||||
const getSubtext = () => {
|
||||
if (trial_ends_at) {
|
||||
return `${formatDistanceToNow(trial_ends_at)} left`;
|
||||
}
|
||||
if (canceled_at) {
|
||||
return `${formatDistanceToNow(canceled_at)} ago`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const subtext = getSubtext();
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center", className)}>
|
||||
{tooltip ? (
|
||||
@@ -31,10 +45,8 @@ const StatusItem = ({
|
||||
<TooltipTrigger>{children}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<span className="text-sm">{text} </span>
|
||||
{trial_ends_at && (
|
||||
<span className="text-sm text-t3">
|
||||
({formatDistanceToNow(trial_ends_at)} left)
|
||||
</span>
|
||||
{subtext && (
|
||||
<span className="text-sm text-t3">({subtext})</span>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -45,12 +57,10 @@ const StatusItem = ({
|
||||
{children}
|
||||
<span className="text-sm">{text}</span>
|
||||
</div>
|
||||
{trial_ends_at && (
|
||||
{subtext && (
|
||||
<>
|
||||
<DotIcon size={16} />
|
||||
<span className="text-sm text-t3 pl-1 truncate">
|
||||
{formatDistanceToNow(trial_ends_at)} left
|
||||
</span>
|
||||
<span className="text-sm text-t3 pl-1 truncate">{subtext}</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
@@ -63,6 +73,7 @@ export const CustomerProductsStatus = ({
|
||||
tooltip,
|
||||
status,
|
||||
canceled,
|
||||
canceled_at,
|
||||
trialing,
|
||||
trial_ends_at,
|
||||
starts_at,
|
||||
@@ -70,6 +81,7 @@ export const CustomerProductsStatus = ({
|
||||
status?: CusProductStatus;
|
||||
tooltip?: boolean;
|
||||
canceled?: boolean;
|
||||
canceled_at?: number;
|
||||
trialing?: boolean;
|
||||
trial_ends_at?: number;
|
||||
starts_at?: number;
|
||||
@@ -105,7 +117,7 @@ export const CustomerProductsStatus = ({
|
||||
// If product is canceled, show that status
|
||||
if (canceled) {
|
||||
return (
|
||||
<StatusItem text="Cancelling" tooltip={tooltip}>
|
||||
<StatusItem text="Cancelling" tooltip={tooltip} canceled_at={canceled_at}>
|
||||
<BanIcon
|
||||
className="text-white bg-orange-500 dark:bg-orange-600 rounded-full p-0.5"
|
||||
size={12}
|
||||
|
||||
@@ -152,7 +152,7 @@ export const MainSidebar = () => {
|
||||
icon: <CubeIcon size={16} weight="fill" />,
|
||||
},
|
||||
{
|
||||
title: "Features",
|
||||
title: "Plan Features",
|
||||
value: "features",
|
||||
icon: <LegoIcon size={16} weight="fill" />,
|
||||
},
|
||||
|
||||
@@ -355,14 +355,14 @@ export function AIChatView({ onBack }: AIChatViewProps) {
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, ease: "easeOut", delay: 0.1 }}
|
||||
className="flex flex-col items-center mt-6"
|
||||
className="flex flex-col items-center mt-3 w-full max-w-2xl"
|
||||
>
|
||||
<TemplatePrompts onSelectTemplate={handleSelectTemplate} />
|
||||
<Button
|
||||
variant="skeleton"
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="mt-3 text-sm text-t4 hover:text-t3 transition-colors"
|
||||
className=" text-xs! text-t4"
|
||||
>
|
||||
or skip to dashboard
|
||||
</Button>
|
||||
|
||||
@@ -105,9 +105,9 @@ function StepCard({
|
||||
animate={{ flex: isActive ? 4 : 1 }}
|
||||
transition={{ duration: 0.3, ease: "easeInOut" }}
|
||||
className={cn(
|
||||
"relative rounded-xl border bg-card cursor-pointer h-29 overflow-hidden",
|
||||
"relative border dark:border-none rounded-xl bg-card cursor-pointer h-29 overflow-hidden",
|
||||
isActive
|
||||
? "border-primary/30"
|
||||
? ""
|
||||
: "hover:border-primary/20 hover:bg-interactive-secondary-hover",
|
||||
isComplete && !isActive && "opacity-50",
|
||||
)}
|
||||
|
||||
@@ -7,23 +7,36 @@ interface TemplatePromptsProps {
|
||||
|
||||
export function TemplatePrompts({ onSelectTemplate }: TemplatePromptsProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-3 relative z-10">
|
||||
{PRICING_TEMPLATE_PROMPTS.map((template) => (
|
||||
<Button
|
||||
key={template.id}
|
||||
type="button"
|
||||
onClick={() => onSelectTemplate({ prompt: template.prompt })}
|
||||
variant="secondary"
|
||||
className="h-9! px-3! gap-3"
|
||||
>
|
||||
<img
|
||||
src={template.icon}
|
||||
alt={template.label}
|
||||
className="size-4 object-contain opacity-50 dark:invert"
|
||||
/>
|
||||
<span className="text-sm font-medium text-t2">{template.label}</span>
|
||||
</Button>
|
||||
))}
|
||||
<div className="flex flex-col items-center gap-3 relative z-10 bg-background/50 rounded-2xl pt-2 pb-4 px-4 border-border/50">
|
||||
{/* Header with decorative lines */}
|
||||
|
||||
<div className="flex items-center gap-3 w-full justify-center">
|
||||
<div className="h-px w-8 bg-border border-border/50" />
|
||||
<span className=" text-t4 font-normal text-xs">Copy a template</span>
|
||||
<div className="h-px w-8 bg-border border-border/50" />
|
||||
</div>
|
||||
|
||||
{/* Template cards */}
|
||||
<div className="flex items-center justify-center gap-3 flex-wrap">
|
||||
{PRICING_TEMPLATE_PROMPTS.map((template) => (
|
||||
<Button
|
||||
key={template.id}
|
||||
type="button"
|
||||
onClick={() => onSelectTemplate({ prompt: template.prompt })}
|
||||
variant="secondary"
|
||||
className="h-9! px-3! gap-3"
|
||||
>
|
||||
<img
|
||||
src={template.icon}
|
||||
alt={template.label}
|
||||
className="size-4 object-contain opacity-50 dark:invert"
|
||||
/>
|
||||
<span className="text-sm font-medium text-t2">
|
||||
{template.label}
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,12 +22,14 @@ interface UpdateFeatureSheetProps {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
selectedFeature: Feature | null;
|
||||
onSuccess?: (oldId: string, newId: string) => void;
|
||||
}
|
||||
|
||||
function UpdateFeatureSheet({
|
||||
open,
|
||||
setOpen,
|
||||
selectedFeature,
|
||||
onSuccess,
|
||||
}: UpdateFeatureSheetProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -63,6 +65,12 @@ function UpdateFeatureSheet({
|
||||
|
||||
await refetch();
|
||||
toast.success("Feature updated successfully");
|
||||
|
||||
// Call onSuccess with old and new IDs, if it's updated from the plan editor to update the plan items.
|
||||
if (onSuccess) {
|
||||
onSuccess(selectedFeature.id, feature.id);
|
||||
}
|
||||
|
||||
setOpen(false);
|
||||
} catch (error: unknown) {
|
||||
console.log(error);
|
||||
|
||||
@@ -21,12 +21,14 @@ interface UpdateCreditSystemSheetProps {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
selectedCreditSystem: Feature | null;
|
||||
onSuccess?: (oldId: string, newId: string) => void;
|
||||
}
|
||||
|
||||
function UpdateCreditSystemSheet({
|
||||
open,
|
||||
setOpen,
|
||||
selectedCreditSystem,
|
||||
onSuccess,
|
||||
}: UpdateCreditSystemSheetProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [creditSystem, setCreditSystem] = useState<CreateFeature>({
|
||||
@@ -92,6 +94,12 @@ function UpdateCreditSystemSheet({
|
||||
|
||||
await refetch();
|
||||
toast.success("Credit system updated successfully");
|
||||
|
||||
// Call onSuccess with old and new IDs
|
||||
if (onSuccess) {
|
||||
onSuccess(selectedCreditSystem.id, creditSystem.id || selectedCreditSystem.id);
|
||||
}
|
||||
|
||||
setOpen(false);
|
||||
} catch (error: unknown) {
|
||||
console.log(error);
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
useCurrentItem,
|
||||
useHasItemChanges,
|
||||
useIsCusPlanEditor,
|
||||
} from "@/hooks/stores/useProductStore";
|
||||
import { motion } from "motion/react";
|
||||
import { SheetOverlay } from "@/components/v2/sheet-overlay/SheetOverlay";
|
||||
import { useIsCusPlanEditor } from "@/hooks/stores/useProductStore";
|
||||
import { useSheetStore } from "@/hooks/stores/useSheetStore";
|
||||
import { CustomerPlanEditorBar } from "@/views/customers2/customer-plan/CustomerPlanEditorBar";
|
||||
import { CustomerPlanInfoBox } from "@/views/customers2/customer-plan/CustomerPlanInfoBox";
|
||||
@@ -14,66 +10,8 @@ import { EditPlanHeader } from "./EditPlanHeader";
|
||||
import PlanCard from "./plan-card/PlanCard";
|
||||
import { SaveChangesBar } from "./SaveChangesBar";
|
||||
|
||||
function shouldCloseSheetOnMouseDown({
|
||||
e,
|
||||
item,
|
||||
sheetType,
|
||||
hasItemChanges,
|
||||
}: {
|
||||
e: React.MouseEvent<HTMLDivElement>;
|
||||
item: ReturnType<typeof useCurrentItem>;
|
||||
sheetType: string | null;
|
||||
hasItemChanges: boolean;
|
||||
}): boolean {
|
||||
// Don't close if item has unsaved changes
|
||||
if (hasItemChanges) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Don't close if item is invalid
|
||||
// if (item && !checkItemIsValid(item, false)) {
|
||||
// return false;
|
||||
// }
|
||||
|
||||
// Get the active element before blur happens
|
||||
const activeElement = document.activeElement;
|
||||
|
||||
if (
|
||||
activeElement &&
|
||||
activeElement !== document.body &&
|
||||
activeElement instanceof HTMLElement
|
||||
) {
|
||||
// Only apply blur behavior to inputs, textareas, and selects
|
||||
const isInputElement =
|
||||
activeElement.tagName === "INPUT" ||
|
||||
activeElement.tagName === "TEXTAREA" ||
|
||||
activeElement.tagName === "SELECT";
|
||||
|
||||
if (!isInputElement) {
|
||||
// Not an input, proceed with normal close behavior
|
||||
return !!sheetType;
|
||||
}
|
||||
|
||||
// Check if the active element is within the sheet (not in the main content area)
|
||||
const clickTarget = e.target as HTMLElement;
|
||||
const isActiveInSheet = !clickTarget.contains(activeElement);
|
||||
|
||||
if (isActiveInSheet) {
|
||||
activeElement.blur();
|
||||
e.preventDefault(); // Prevent default to stop the click from propagating
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// If the click is outside the sheet and no input is focused, close the sheet
|
||||
return !!sheetType;
|
||||
}
|
||||
|
||||
export const PlanEditor = () => {
|
||||
const closeSheet = useSheetStore((s) => s.closeSheet);
|
||||
const sheetType = useSheetStore((s) => s.type);
|
||||
const item = useCurrentItem();
|
||||
const hasItemChanges = useHasItemChanges();
|
||||
|
||||
return (
|
||||
<div className="flex w-full h-full overflow-hidden relative">
|
||||
@@ -100,32 +38,7 @@ export const PlanEditor = () => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{createPortal(
|
||||
<AnimatePresence>
|
||||
{sheetType && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-white/70 dark:bg-black/70"
|
||||
style={{ zIndex: 40 }}
|
||||
onMouseDown={(e) => {
|
||||
if (
|
||||
shouldCloseSheetOnMouseDown({
|
||||
e,
|
||||
item,
|
||||
sheetType,
|
||||
hasItemChanges,
|
||||
})
|
||||
) {
|
||||
closeSheet();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body,
|
||||
)}
|
||||
<SheetOverlay />
|
||||
</motion.div>
|
||||
|
||||
<ProductSheets />
|
||||
|
||||
@@ -128,9 +128,9 @@ export const SaveChangesBar = ({
|
||||
<p className="text-body whitespace-nowrap truncate">
|
||||
You have unsaved changes
|
||||
</p>
|
||||
<Button variant="secondary" onClick={handleDiscardClicked}>
|
||||
Discard
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={handleDiscardClicked} disabled={saving}>
|
||||
Discard
|
||||
</Button>
|
||||
<ShortcutButton
|
||||
metaShortcut="s"
|
||||
onClick={handleSaveClicked}
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { FeatureType } from "@autumn/shared";
|
||||
import { PencilSimpleIcon } from "@phosphor-icons/react";
|
||||
import { useState } from "react";
|
||||
import { IconButton } from "@/components/v2/buttons/IconButton";
|
||||
import {
|
||||
useHasItemChanges,
|
||||
useProduct,
|
||||
useSheet,
|
||||
} from "@/components/v2/inline-custom-plan-editor/PlanEditorContext";
|
||||
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 UpdateFeatureSheet from "@/views/products/features/components/UpdateFeatureSheet";
|
||||
import UpdateCreditSystemSheet from "@/views/products/features/credit-systems/components/UpdateCreditSystemSheet";
|
||||
import { useProductItemContext } from "@/views/products/product/product-item/ProductItemContext";
|
||||
import { AdvancedSettings } from "./AdvancedSettings";
|
||||
import { BillingType } from "./BillingType";
|
||||
@@ -22,9 +28,28 @@ export function EditPlanFeatureSheet({
|
||||
isOnboarding?: boolean;
|
||||
}) {
|
||||
const { item } = useProductItemContext();
|
||||
const { features } = useFeaturesQuery();
|
||||
const { product } = useProduct();
|
||||
const { features, refetch } = useFeaturesQuery();
|
||||
const { product, setProduct } = useProduct();
|
||||
const { setInitialItem } = useSheet();
|
||||
const hasItemChanges = useHasItemChanges();
|
||||
const [editFeatureOpen, setEditFeatureOpen] = useState(false);
|
||||
|
||||
const handleFeatureUpdateSuccess = async (oldId: string, newId: string) => {
|
||||
if (oldId !== newId && product.items) {
|
||||
// Wait for features to be refetched to avoid race condition
|
||||
await refetch();
|
||||
// Update the feature_id in the product item
|
||||
const updatedItems = product.items.map((i) =>
|
||||
i.feature_id === oldId ? { ...i, feature_id: newId } : i,
|
||||
);
|
||||
setProduct({ ...product, items: updatedItems });
|
||||
|
||||
// Also update initialItem so it doesn't show as having changes
|
||||
if (item?.feature_id === oldId) {
|
||||
setInitialItem({ ...item, feature_id: newId });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const emptyPriceItem =
|
||||
item?.usage_model &&
|
||||
@@ -56,6 +81,16 @@ export function EditPlanFeatureSheet({
|
||||
<span className="font-medium text-t1">{feature?.name}</span>
|
||||
</p>
|
||||
}
|
||||
action={
|
||||
<IconButton
|
||||
variant="muted"
|
||||
size="sm"
|
||||
icon={<PencilSimpleIcon />}
|
||||
onClick={() => setEditFeatureOpen(true)}
|
||||
>
|
||||
Edit Feature
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -98,6 +133,23 @@ export function EditPlanFeatureSheet({
|
||||
|
||||
{/* Footer stays at bottom */}
|
||||
{showFooter && <SheetFooterActions />}
|
||||
|
||||
{/* Edit Feature Sheet */}
|
||||
{feature?.type === FeatureType.CreditSystem ? (
|
||||
<UpdateCreditSystemSheet
|
||||
open={editFeatureOpen}
|
||||
setOpen={setEditFeatureOpen}
|
||||
selectedCreditSystem={feature ?? null}
|
||||
onSuccess={handleFeatureUpdateSuccess}
|
||||
/>
|
||||
) : (
|
||||
<UpdateFeatureSheet
|
||||
open={editFeatureOpen}
|
||||
setOpen={setEditFeatureOpen}
|
||||
selectedFeature={feature ?? null}
|
||||
onSuccess={handleFeatureUpdateSuccess}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,22 +16,27 @@ export const getDefaultItem = ({
|
||||
feature,
|
||||
});
|
||||
|
||||
// For static (boolean) features, reset_usage_when_enabled is not applicable
|
||||
// since they don't track usage. Setting it causes mismatch with backend
|
||||
// which doesn't store this field for boolean features.
|
||||
const isStaticFeature = itemFeatureType === ProductItemFeatureType.Static;
|
||||
const isContinuousUse =
|
||||
itemFeatureType === ProductItemFeatureType.ContinuousUse;
|
||||
|
||||
// Create a new item with the selected feature
|
||||
const newItem = {
|
||||
feature_id: feature.id,
|
||||
feature_type: itemFeatureType,
|
||||
included_usage: null,
|
||||
interval:
|
||||
itemFeatureType === ProductItemFeatureType.ContinuousUse ||
|
||||
itemFeatureType === ProductItemFeatureType.Static
|
||||
? null
|
||||
: ProductItemInterval.Month,
|
||||
isContinuousUse || isStaticFeature ? null : ProductItemInterval.Month,
|
||||
price: null,
|
||||
tiers: null,
|
||||
billing_units: 1,
|
||||
entity_feature_id: null,
|
||||
reset_usage_when_enabled:
|
||||
itemFeatureType !== ProductItemFeatureType.ContinuousUse,
|
||||
// Only set reset_usage_when_enabled for usage-tracked features
|
||||
// Boolean/static features don't track usage, so this field is not applicable
|
||||
reset_usage_when_enabled: isStaticFeature ? undefined : !isContinuousUse,
|
||||
};
|
||||
|
||||
return newItem;
|
||||
|
||||
@@ -17,7 +17,7 @@ export const createProductListColumns = ({
|
||||
size: 300,
|
||||
header: "Name",
|
||||
accessorKey: "name",
|
||||
enableSorting: false,
|
||||
enableSorting: true,
|
||||
cell: ({ row }: { row: Row<ProductV2> }) => {
|
||||
return (
|
||||
<div className="font-medium text-t1 flex gap-1">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { isOneOffProductV2, type ProductV2 } from "@autumn/shared";
|
||||
import { CubeIcon } from "@phosphor-icons/react";
|
||||
import { useMemo } from "react";
|
||||
import type { SortingState } from "@tanstack/react-table";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Table } from "@/components/general/table";
|
||||
import { SectionTag } from "@/components/v2/badges/SectionTag";
|
||||
import { EmptyState } from "@/components/v2/empty-states/EmptyState";
|
||||
@@ -20,6 +21,9 @@ export function ProductListTable() {
|
||||
const { products, counts, isCountsLoading } = useProductsQuery();
|
||||
const { queryStates } = useProductsQueryState();
|
||||
|
||||
// Shared sorting state for all tables
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
const { recurringBasePlans, recurringAddOnPlans, oneTimePlans } =
|
||||
useMemo(() => {
|
||||
const filtered = products?.filter((product) =>
|
||||
@@ -100,6 +104,8 @@ export function ProductListTable() {
|
||||
globalFilterFn: "includesString",
|
||||
enableGlobalFilter: true,
|
||||
enableSorting: true,
|
||||
state: { sorting },
|
||||
onSortingChange: setSorting,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -110,6 +116,8 @@ export function ProductListTable() {
|
||||
globalFilterFn: "includesString",
|
||||
enableGlobalFilter: true,
|
||||
enableSorting: true,
|
||||
state: { sorting },
|
||||
onSortingChange: setSorting,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -120,6 +128,8 @@ export function ProductListTable() {
|
||||
globalFilterFn: "includesString",
|
||||
enableGlobalFilter: true,
|
||||
enableSorting: true,
|
||||
state: { sorting },
|
||||
onSortingChange: setSorting,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user