Merge pull request #474 from useautumn/dev

dev
This commit is contained in:
John Yeo
2025-12-17 22:36:30 +00:00
committed by GitHub
11 changed files with 629 additions and 57 deletions

View File

@@ -1,7 +1,3 @@
/** biome-ignore-all lint/complexity/noStaticOnlyClass: wrap it up buddy */
/** biome-ignore-all lint/complexity/noStaticOnlyClass: wrap it up buddy */
import {
ErrCode,
type FullCustomer,

View File

@@ -4,6 +4,7 @@ import {
FeatureType,
type FullCusProduct,
type FullCustomer,
type RangeEnum,
} from "@autumn/shared";
import { Router } from "express";
import { StatusCodes } from "http-status-codes";
@@ -12,6 +13,7 @@ import RecaseError from "@/utils/errorUtils.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
import { routeHandler } from "@/utils/routerUtils.js";
import { CusService } from "../customers/CusService.js";
import { EventsAggregationService } from "../events/EventsAggregationService.js";
import { AnalyticsService } from "./AnalyticsService.js";
export const analyticsRouter = Router();
@@ -111,7 +113,7 @@ analyticsRouter.post("/events", async (req: any, res: any) =>
handler: async () => {
AnalyticsService.handleEarlyExit();
const { db, org, env, features } = req;
let { interval, event_names, customer_id } = req.body;
let { interval, event_names, customer_id, group_by } = req.body;
let topEvents: { featureIds: string[]; eventNames: string[] } | undefined;
@@ -159,15 +161,29 @@ analyticsRouter.post("/events", async (req: any, res: any) =>
event_names = event_names.filter((name: string) => name !== "");
}
const events = await AnalyticsService.getTimeseriesEvents({
req,
// const events = await AnalyticsService.getTimeseriesEvents({
// req,
// params: {
// customer_id,
// interval,
// event_names,
// },
// customer,
// aggregateAll,
// });
const binSize = interval === "24h" ? "hour" : "day";
const events = await EventsAggregationService.getTimeseriesEvents({
ctx: req,
params: {
customer_id,
interval,
interval: interval as RangeEnum,
event_names,
bin_size: binSize,
aggregateAll,
group_by: group_by,
},
customer,
aggregateAll,
});
res.status(200).json({

View File

@@ -236,6 +236,47 @@ export class EventsAggregationService {
const groupBy = getGroupByClause();
const groupByFieldName = groupBy.fieldName;
// Validate distinct group count if group_by is provided
if (params.group_by && groupBy.groupBy) {
const propertyPath = params.group_by.replace("properties.", "");
const pathSegments = propertyPath.split(".");
const escapedPathArgs = pathSegments
.map((seg) => `'${seg.replace(/'/g, "''")}'`)
.join(", ");
const groupField = `JSONExtractString(e.properties, ${escapedPathArgs})`;
const distinctCountQuery = `
SELECT COUNT(DISTINCT ${groupField}) as distinct_count
FROM org_events_view(org_id={org_id:String}, org_slug='', env={env:String}) e
${params.aggregateAll ? "" : "WHERE e.customer_id = {customer_id:String}"}
`;
const distinctResult = await (clickhouseClient as ClickHouseClient).query(
{
query: distinctCountQuery,
query_params: {
org_id: org?.id,
env: env,
customer_id: params.customer_id,
},
format: "JSON",
},
);
const distinctJson = (await distinctResult.json()) as ClickHouseResult<{
distinct_count: string;
}>;
const distinctCount = Number(distinctJson.data[0]?.distinct_count ?? 0);
if (distinctCount > 30) {
throw new RecaseError({
message: `Too many distinct group values (${distinctCount}). Maximum allowed is 30. Please choose a property with fewer unique values.`,
code: ErrCode.InvalidInputs,
statusCode: StatusCodes.BAD_REQUEST,
});
}
}
const query = `
with customer_events as (
select *

View File

@@ -75,6 +75,7 @@ export function EventsBarChart({
chartConfig: any;
}) {
const { selectedInterval } = useAnalyticsContext();
const [options, setOptions] = useState<AgChartOptions>({
data: data.data,
series: chartConfig,
@@ -138,11 +139,9 @@ export function EventsBarChart({
},
legend: {
enabled: false,
item: {
label: {
color: "#52525b",
},
},
},
tooltip: {
enabled: true,
},
});

View File

@@ -1,7 +1,7 @@
import { ErrCode, type Feature, FeatureType } from "@autumn/shared";
import { ErrCode } from "@autumn/shared";
import { ChartBarIcon, DatabaseIcon } from "@phosphor-icons/react";
import type { AgGridReact } from "ag-grid-react";
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router";
import { Card, CardContent } from "@/components/ui/card";
import { EmptyState } from "@/components/v2/empty-states/EmptyState";
@@ -14,6 +14,11 @@ import {
useAnalyticsData,
useRawAnalyticsData,
} from "./hooks/useAnalyticsData";
import { extractPropertyKeys } from "./utils/extractPropertyKeys";
import {
generateChartConfig,
transformGroupedData,
} from "./utils/transformGroupedChartData";
export const AnalyticsView = () => {
const [searchParams] = useSearchParams();
@@ -25,6 +30,7 @@ export const AnalyticsView = () => {
const [currentPage, setCurrentPage] = useState(0);
const [totalPages, setTotalPages] = useState(0);
const [totalRows, setTotalRows] = useState(0);
const [groupFilter, setGroupFilter] = useState<string | null>(null);
const gridRef = useRef<AgGridReact>(null);
const navigate = useNavigate();
@@ -39,43 +45,78 @@ export const AnalyticsView = () => {
bcExclusionFlag,
topEventsLoading,
topEvents,
groupBy,
} = useAnalyticsData({ hasCleared });
// Clear the filter when groupBy changes
useEffect(() => {
setGroupFilter(null);
}, [groupBy]);
// Extract unique group values from events data for filtering
const availableGroupValues = useMemo(() => {
if (!groupBy || !events?.data) {
return [];
}
const groupByColumn = `properties.${groupBy}`;
const uniqueValues = new Set<string>();
for (const row of events.data) {
const value = row[groupByColumn];
if (value !== undefined && value !== null && value !== "") {
uniqueValues.add(String(value));
}
}
return Array.from(uniqueValues).sort();
}, [groupBy, events?.data]);
const { rawEvents, queryLoading: rawQueryLoading } = useRawAnalyticsData();
const chartConfig = events?.meta
.filter((x: { name: string }) => x.name !== "period")
.map((x: { name: string }, index: number) => {
if (x.name !== "period") {
const colorIndex = index % colors.length;
// Extract property keys from raw events for the group by dropdown
const propertyKeys = useMemo(() => {
return extractPropertyKeys({ rawEvents: rawEvents?.data });
}, [rawEvents?.data]);
return {
xKey: "period",
yKey: x.name,
type: "bar",
stacked: true,
yName:
features.find((feature: Feature) => {
const eventName = x.name.replace("_count", "");
// Transform and configure chart data
const { chartData, chartConfig } = useMemo(() => {
if (!events) {
return { chartData: null, chartConfig: null };
}
// console.log("Feature: ", feature, eventName);
// Apply frontend filter if a group filter is selected
let filteredEvents = events;
if (groupBy && groupFilter) {
const groupByColumn = `properties.${groupBy}`;
const filteredData = events.data.filter(
(row: Record<string, string | number>) =>
String(row[groupByColumn]) === groupFilter,
);
filteredEvents = {
...events,
data: filteredData,
rows: filteredData.length,
};
}
if (feature.type === FeatureType.Boolean) return false;
if (feature.id === eventName) {
return true;
}
if (feature.event_names && feature.event_names.length > 0) {
return feature.event_names.includes(eventName);
}
return false;
})?.name || x.name.replace("_count", ""),
fill: colors[colorIndex],
};
} else return null;
// Transform data for grouped display (pivots rows into columns per group)
const transformed = transformGroupedData({
events: filteredEvents,
groupBy,
});
// Generate chart config with different colors per group
const config = generateChartConfig({
events: transformed,
features,
groupBy,
originalColors: colors,
});
return { chartData: transformed, chartConfig: config };
}, [events, features, groupBy, groupFilter]);
useEffect(() => {
if (error?.response?.data?.code === ErrCode.ClickHouseDisabled) {
setClickHouseDisabled(true);
@@ -131,6 +172,10 @@ export const AnalyticsView = () => {
totalRows,
setTotalRows,
topEvents,
propertyKeys,
groupFilter,
setGroupFilter,
availableGroupValues,
}}
>
<div className="flex flex-col gap-4 h-full relative w-full text-sm pb-8 max-w-5xl mx-auto px-10 pt-8">
@@ -151,13 +196,18 @@ export const AnalyticsView = () => {
)}
<div className="h-full overflow-hidden">
{events && events.data.length > 0 && (
{chartData && chartData.data.length > 0 && (
<div className="h-full overflow-hidden bg-interactive-secondary border max-h-[350px]">
<EventsBarChart data={events} chartConfig={chartConfig} />
<EventsBarChart
data={
chartData as Parameters<typeof EventsBarChart>[0]["data"]
}
chartConfig={chartConfig}
/>
</div>
)}
{!events && !queryLoading && (
{!chartData && !queryLoading && (
<div className="flex-1 px-10 pt-6">
<p className="text-t3 text-sm">
No events found. Please widen your filters.{" "}

View File

@@ -12,6 +12,7 @@ import { IconButton } from "@/components/v2/buttons/IconButton";
import { useAnalyticsContext } from "../AnalyticsContext";
import { CustomerComboBox } from "./CustomerComboBox";
import { SelectFeatureDropdown } from "./SelectFeatureDropdown";
import { SelectGroupByDropdown } from "./SelectGroupByDropdown";
export const INTERVALS: Record<string, string> = {
"24h": "Last 24 hours",
@@ -23,8 +24,13 @@ export const INTERVALS: Record<string, string> = {
};
export const QueryTopbar = () => {
const { customer, selectedInterval, setSelectedInterval, bcExclusionFlag } =
useAnalyticsContext();
const {
customer,
selectedInterval,
setSelectedInterval,
bcExclusionFlag,
propertyKeys,
} = useAnalyticsContext();
const navigate = useNavigate();
const location = useLocation();
@@ -80,9 +86,17 @@ export const QueryTopbar = () => {
</DropdownMenu>
<SelectFeatureDropdown
classNames={{
trigger: "h-full border-y-0 border-l-0 border-r-1",
trigger: "h-full border-y-0 border-l-0 border-r-0",
}}
/>
{propertyKeys && propertyKeys.length > 0 && (
<SelectGroupByDropdown
propertyKeys={propertyKeys}
classNames={{
trigger: "h-full border-y-0 border-l-0 border-r-1",
}}
/>
)}
</div>
);
};

View File

@@ -0,0 +1,154 @@
import { CaretDownIcon, MagnifyingGlassIcon } from "@phosphor-icons/react";
import { Check } from "lucide-react";
import { useState } from "react";
import { useLocation, useNavigate, useSearchParams } from "react-router";
import { IconButton } from "@/components/v2/buttons/IconButton";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/v2/dropdowns/DropdownMenu";
import { cn } from "@/lib/utils";
import { useAnalyticsContext } from "../AnalyticsContext";
export const SelectGroupByDropdown = ({
propertyKeys,
classNames,
}: {
propertyKeys: string[];
classNames?: {
trigger?: string;
};
}) => {
const [open, setOpen] = useState(false);
const [searchValue, setSearchValue] = useState("");
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const location = useLocation();
const { groupFilter, setGroupFilter, availableGroupValues } =
useAnalyticsContext();
const currentGroupBy = searchParams.get("group_by") || "";
const updateQueryParams = ({ groupBy }: { groupBy: string | null }) => {
const params = new URLSearchParams(location.search);
if (groupBy) {
params.set("group_by", groupBy);
} else {
params.delete("group_by");
}
navigate(`${location.pathname}?${params.toString()}`);
};
const filteredOptions = propertyKeys.filter((key) =>
key.toLowerCase().includes(searchValue.toLowerCase()),
);
const handleSelect = ({ property }: { property: string | null }) => {
updateQueryParams({ groupBy: property });
setOpen(false);
};
const displayValue = currentGroupBy || "No grouping";
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<IconButton
variant="secondary"
size="default"
icon={<CaretDownIcon size={12} weight="bold" />}
iconOrientation="right"
className={cn(classNames?.trigger, open && "btn-secondary-active")}
>
{currentGroupBy ? `Group: ${currentGroupBy}` : "Group By"}
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]">
{propertyKeys.length > 5 && (
<div className="flex items-center gap-2 px-2 py-1.5 border-b border-border">
<MagnifyingGlassIcon className="size-4 text-t4" />
<input
type="text"
placeholder="Search properties..."
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
onKeyDown={(e) => e.stopPropagation()}
className="flex-1 bg-transparent text-sm outline-none placeholder:text-t4"
/>
</div>
)}
<div className="max-h-[300px] overflow-y-auto">
<DropdownMenuItem
onClick={() => handleSelect({ property: null })}
className="flex items-center justify-between"
>
<span className="text-xs">No grouping</span>
{!currentGroupBy && <Check className="ml-2 h-3 w-3 text-t3" />}
</DropdownMenuItem>
{propertyKeys.length > 0 && <DropdownMenuSeparator />}
{filteredOptions.length === 0 && propertyKeys.length > 0 && (
<div className="py-4 text-center text-sm text-t4">
No properties found
</div>
)}
{filteredOptions.map((property) => (
<DropdownMenuItem
key={property}
onClick={() => handleSelect({ property })}
className="flex items-center justify-between"
>
<span className="text-xs font-mono">{property}</span>
{currentGroupBy === property && (
<Check className="ml-2 h-3 w-3 text-t3" />
)}
</DropdownMenuItem>
))}
{/* Filter section - only shown when a groupBy is selected */}
{currentGroupBy && availableGroupValues.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-t4 font-normal">
Filter by value
</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => setGroupFilter(null)}
className="flex items-center justify-between"
>
<span className="text-xs">All values</span>
{!groupFilter && <Check className="ml-2 h-3 w-3 text-t3" />}
</DropdownMenuItem>
{availableGroupValues.map((value) => (
<DropdownMenuItem
key={value}
onClick={() => setGroupFilter(value)}
className="flex items-center justify-between"
>
<span className="text-xs font-mono truncate max-w-[150px]">
{value}
</span>
{groupFilter === value && (
<Check className="ml-2 h-3 w-3 text-t3 shrink-0" />
)}
</DropdownMenuItem>
))}
</>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
);
};

View File

@@ -19,6 +19,7 @@ export const useAnalyticsData = ({
const featureIds = searchParams.get("feature_ids")?.split(",");
const eventNames = searchParams.get("event_names")?.split(",");
const interval = searchParams.get("interval");
const groupBy = searchParams.get("group_by");
const { topEvents, isLoading: topEventsLoading } = useTopEventNames();
@@ -31,6 +32,9 @@ export const useAnalyticsData = ({
const { features: featuresData, isLoading: featuresLoading } =
useFeaturesQuery();
// Format group_by for API (must be prefixed with "properties.")
const formattedGroupBy = groupBy ? `properties.${groupBy}` : undefined;
// Create a simple queryKey with the actual values that change
const queryKey = [
customerId,
@@ -38,6 +42,7 @@ export const useAnalyticsData = ({
...(eventNames || []).sort(),
...(featureIds || []).sort(),
org?.slug,
groupBy,
];
const {
@@ -50,6 +55,7 @@ export const useAnalyticsData = ({
customer_id: customerId || null,
interval: interval || "30d",
event_names: [...(eventNames || []), ...(featureIds || [])],
group_by: formattedGroupBy,
},
queryKey: ["query-events", ...queryKey],
options: {
@@ -72,6 +78,7 @@ export const useAnalyticsData = ({
error: error?.code === ErrCode.ClickHouseDisabled ? null : error,
bcExclusionFlag: data?.bcExclusionFlag ?? false,
topEventsLoading,
groupBy,
};
};

View File

@@ -0,0 +1,69 @@
/**
* Raw event structure from the analytics API
*/
interface RawEvent {
timestamp: string;
event_name: string;
value: number;
properties: Record<string, unknown> | string;
}
/**
* Internal properties to exclude from the group by dropdown
*/
const EXCLUDED_PROPERTIES = new Set(["value"]);
/**
* Maximum number of events to scan for property keys
*/
const MAX_EVENTS_TO_SCAN = 1000;
/**
* Extracts unique property keys from raw events for use in group by dropdown.
* Scans up to 1000 events and filters out internal properties.
*/
export function extractPropertyKeys({
rawEvents,
}: {
rawEvents: RawEvent[] | undefined;
}): string[] {
if (!rawEvents || rawEvents.length === 0) {
return [];
}
const propertyKeys = new Set<string>();
const eventsToScan = rawEvents.slice(0, MAX_EVENTS_TO_SCAN);
for (const event of eventsToScan) {
const properties = parseProperties(event.properties);
if (!properties) continue;
for (const key of Object.keys(properties)) {
if (!EXCLUDED_PROPERTIES.has(key)) {
propertyKeys.add(key);
}
}
}
return Array.from(propertyKeys).sort();
}
/**
* Parses properties which may be a string (JSON) or already an object
*/
function parseProperties(
properties: Record<string, unknown> | string | undefined,
): Record<string, unknown> | null {
if (!properties) return null;
if (typeof properties === "string") {
try {
return JSON.parse(properties);
} catch {
return null;
}
}
return properties;
}

View File

@@ -0,0 +1,226 @@
import type { Feature } from "@autumn/shared";
import { FeatureType } from "@autumn/shared";
/**
* Row data from the events API
*/
type EventRow = Record<string, string | number>;
/**
* Events data structure from the API
*/
interface EventsData {
meta: Array<{ name: string }>;
rows: number;
data: EventRow[];
}
/**
* Chart series configuration
*/
interface ChartSeriesConfig {
xKey: string;
yKey: string;
type: "bar";
stacked: boolean;
yName: string;
fill: string;
}
/**
* Chart colors palette - more distinct colors for groups
*/
const CHART_COLORS = [
"#9c5aff", // purple
"#27a7ff", // blue
"#10b981", // green
"#f59e0b", // orange
"#ef4444", // red
"#ec4899", // pink
"#06b6d4", // cyan
"#8b5cf6", // violet
"#14b8a6", // teal
"#f97316", // orange-dark
];
/**
* Gets feature name for a given event/feature key
*/
function getFeatureName({
key,
features,
}: {
key: string;
features: Feature[];
}): string {
const eventName = key.replace("_count", "");
const feature = features.find((f) => {
if (f.type === FeatureType.Boolean) return false;
if (f.id === eventName) return true;
if (f.event_names && f.event_names.length > 0) {
return f.event_names.includes(eventName);
}
return false;
});
return feature?.name || eventName;
}
/**
* Transforms grouped data from backend format to chart-ready format.
*
* Backend returns (when group_by is used):
* [
* { period: "2024-01-01", "properties.platform": "ios", messages_count: 5 },
* { period: "2024-01-01", "properties.platform": "android", messages_count: 3 },
* ]
*
* Chart needs:
* [
* { period: "2024-01-01", "messages_count__ios": 5, "messages_count__android": 3 },
* ]
*/
export function transformGroupedData({
events,
groupBy,
}: {
events: EventsData;
groupBy: string | null;
}): EventsData {
if (!groupBy) {
return events;
}
const groupByColumn = `properties.${groupBy}`;
// Check if data has the group_by column
const hasGroupColumn = events.meta.some((m) => m.name === groupByColumn);
if (!hasGroupColumn) {
return events;
}
// Get feature columns (exclude period and group_by column)
const featureColumns = events.meta
.filter((m) => m.name !== "period" && m.name !== groupByColumn)
.map((m) => m.name);
// Collect all unique group values
const groupValues = new Set<string>();
for (const row of events.data) {
const groupValue = row[groupByColumn];
if (groupValue !== undefined && groupValue !== null && groupValue !== "") {
groupValues.add(String(groupValue));
}
}
// Pivot data: group by period and create columns for each group value
const pivotedMap = new Map<
string | number,
Record<string, string | number>
>();
for (const row of events.data) {
const period = row.period;
const groupValue = String(row[groupByColumn] || "unknown");
if (!pivotedMap.has(period)) {
pivotedMap.set(period, { period });
}
const pivotedRow = pivotedMap.get(period)!;
// Add each feature value with the group suffix
for (const featureCol of featureColumns) {
const newKey = `${featureCol}__${groupValue}`;
pivotedRow[newKey] = row[featureCol] ?? 0;
}
}
// Ensure all group combinations exist (fill with 0)
for (const pivotedRow of pivotedMap.values()) {
for (const featureCol of featureColumns) {
for (const groupValue of groupValues) {
const key = `${featureCol}__${groupValue}`;
if (pivotedRow[key] === undefined) {
pivotedRow[key] = 0;
}
}
}
}
// Build new meta
const newMeta: Array<{ name: string }> = [{ name: "period" }];
for (const featureCol of featureColumns) {
for (const groupValue of groupValues) {
newMeta.push({ name: `${featureCol}__${groupValue}` });
}
}
return {
meta: newMeta,
rows: pivotedMap.size,
data: Array.from(pivotedMap.values()),
};
}
/**
* Generates chart configuration with different colors per group.
*/
export function generateChartConfig({
events,
features,
groupBy,
originalColors,
}: {
events: EventsData;
features: Feature[];
groupBy: string | null;
originalColors: string[];
}): ChartSeriesConfig[] {
const colorsToUse = groupBy ? CHART_COLORS : originalColors;
if (!groupBy) {
// Non-grouped: original behavior
return events.meta
.filter((m) => m.name !== "period")
.map((m, index) => ({
xKey: "period",
yKey: m.name,
type: "bar" as const,
stacked: true,
yName: getFeatureName({ key: m.name, features }),
fill: colorsToUse[index % colorsToUse.length],
}));
}
// Grouped: create series for each feature__group combination
const config: ChartSeriesConfig[] = [];
let colorIndex = 0;
for (const meta of events.meta) {
if (meta.name === "period") continue;
// Parse feature__groupValue format
const parts = meta.name.split("__");
if (parts.length < 2) continue;
const featureKey = parts.slice(0, -1).join("__"); // Handle feature names with underscores
const groupValue = parts[parts.length - 1];
const featureName = getFeatureName({ key: featureKey, features });
config.push({
xKey: "period",
yKey: meta.name,
type: "bar",
stacked: true,
yName: `${featureName} (${groupValue})`,
fill: colorsToUse[colorIndex % colorsToUse.length],
});
colorIndex++;
}
return config;
}

View File

@@ -1,11 +1,11 @@
import { FingerprintIcon, Ticket } from "@phosphor-icons/react";
import { FingerprintIcon, TicketIcon } from "@phosphor-icons/react";
import { CopyButton } from "@/components/v2/buttons/CopyButton";
import { useCusReferralQuery } from "@/views/customers/customer/hooks/useCusReferralQuery";
import { CustomerActions } from "./CustomerActions";
import { useCustomerContext } from "./CustomerContext";
const mutedDivClassName =
"py-0.5 px-1.5 rounded-lg text-t3 text-tiny flex items-center gap-1 h-6 max-w-48 truncate ";
"py-0.5 px-1.5 rounded-lg text-t3 text-tiny flex items-center gap-2 h-6 max-w-48 truncate bg-muted text-tiny-id";
const placeholderText = "NULL";
@@ -13,7 +13,7 @@ export const CustomerPageDetails = () => {
const { customer } = useCustomerContext();
const { stripeCus } = useCusReferralQuery();
const appliedCoupon = stripeCus?.discount?.coupon;
const appliedCoupon = stripeCus?.discount?.source;
return (
<div className="flex h-4 items-center">
@@ -48,9 +48,9 @@ export const CustomerPageDetails = () => {
</div>
)}
{appliedCoupon && (
<div className="py-0.5 px-1.5 bg-secondary rounded-lg text-t3 text-sm flex items-center gap-1 h-6 max-w-48 truncate">
<Ticket size={12} className="shrink-0" />
<span className="truncate">{appliedCoupon.name}</span>
<div className={mutedDivClassName}>
<TicketIcon size={13} className="shrink-0" />
<span className="truncate">{appliedCoupon.coupon}</span>
</div>
)}
<CustomerActions />