This commit is contained in:
Carl-Gerhard Lindesvärd
2026-03-06 09:00:10 +01:00
parent 765e4aa107
commit 90881e5ffb
68 changed files with 4092 additions and 1694 deletions

View File

@@ -63,6 +63,7 @@ async function main() {
imported_at: null,
sdk_name: 'test-script',
sdk_version: '1.0.0',
groups: [],
});
}

View File

@@ -341,13 +341,23 @@ async function handleGroup(
context: TrackContext
): Promise<void> {
const { id, type, name, properties = {} } = payload;
await upsertGroup({
id,
projectId: context.projectId,
type,
name,
properties,
});
const profileId = payload.profileId ?? context.deviceId;
await Promise.all([
upsertGroup({
id,
projectId: context.projectId,
type,
name,
properties,
}),
upsertProfile({
id: profileId,
projectId: context.projectId,
isExternal: !!(payload.profileId ?? context.identity?.profileId),
groups: [id],
}),
]);
}
export async function handler(

View File

@@ -59,7 +59,16 @@ The trailing edge of the line (the current, incomplete interval) is shown as a d
## Insights
If you have configured insights for your project, a scrollable row of insight cards appears below the chart. Each card shows a pre-configured metric with its current value and trend. Clicking a card applies that insight's filter to the entire overview page. Insights are optional—this section is hidden when none have been configured.
A scrollable row of insight cards appears below the chart once your project has at least 30 days of data. OpenPanel automatically detects significant trends across pageviews, entry pages, referrers, and countries—no configuration needed.
Each card shows:
- **Share**: The percentage of total traffic that property represents (e.g., "United States: 42% of all sessions")
- **Absolute change**: The raw increase or decrease in sessions compared to the previous period
- **Percentage change**: How much that property grew or declined relative to its own previous value
For example, if the US had 1,000 sessions last week and 1,200 this week, the card shows "+200 sessions (+20%)".
Clicking any insight card filters the entire overview page to show only data matching that property—letting you drill into what's driving the trend.
---

View File

@@ -76,7 +76,6 @@ export function useColumns() {
<span className="flex min-w-0 flex-1 gap-2">
<button
className="min-w-0 max-w-full truncate text-left font-medium hover:underline"
title={fullTitle}
onClick={() => {
pushModal('EventDetails', {
id: row.original.id,
@@ -84,6 +83,7 @@ export function useColumns() {
projectId: row.original.projectId,
});
}}
title={fullTitle}
type="button"
>
<span className="block truncate">{renderName()}</span>
@@ -204,6 +204,32 @@ export function useColumns() {
);
},
},
{
accessorKey: 'groups',
header: 'Groups',
size: 200,
meta: {
hidden: true,
},
cell({ row }) {
const { groups } = row.original;
if (!groups?.length) {
return null;
}
return (
<div className="flex flex-wrap gap-1">
{groups.map((g) => (
<span
className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs"
key={g}
>
{g}
</span>
))}
</div>
);
},
},
{
accessorKey: 'properties',
header: 'Properties',

View File

@@ -0,0 +1,53 @@
import { useAppParams } from '@/hooks/use-app-params';
import { ColumnCreatedAt } from '@/components/column-created-at';
import { Badge } from '@/components/ui/badge';
import { Link } from '@tanstack/react-router';
import type { ColumnDef } from '@tanstack/react-table';
import type { IServiceGroup } from '@openpanel/db';
export function useGroupColumns(): ColumnDef<IServiceGroup>[] {
const { organizationId, projectId } = useAppParams();
return [
{
accessorKey: 'name',
header: 'Name',
cell: ({ row }) => {
const group = row.original;
return (
<Link
className="font-medium hover:underline"
params={{ organizationId, projectId, groupId: group.id }}
to="/$organizationId/$projectId/groups/$groupId"
>
{group.name}
</Link>
);
},
},
{
accessorKey: 'id',
header: 'ID',
cell: ({ row }) => (
<span className="font-mono text-muted-foreground text-xs">
{row.original.id}
</span>
),
},
{
accessorKey: 'type',
header: 'Type',
cell: ({ row }) => (
<Badge variant="outline">{row.original.type}</Badge>
),
},
{
accessorKey: 'createdAt',
header: 'Created',
size: ColumnCreatedAt.size,
cell: ({ row }) => (
<ColumnCreatedAt>{row.original.createdAt}</ColumnCreatedAt>
),
},
];
}

View File

@@ -0,0 +1,114 @@
import type { IServiceGroup } from '@openpanel/db';
import type { UseQueryResult } from '@tanstack/react-query';
import type { PaginationState, Table, Updater } from '@tanstack/react-table';
import { getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { memo } from 'react';
import { useGroupColumns } from './columns';
import { DataTable } from '@/components/ui/data-table/data-table';
import {
useDataTableColumnVisibility,
useDataTablePagination,
} from '@/components/ui/data-table/data-table-hooks';
import {
AnimatedSearchInput,
DataTableToolbarContainer,
} from '@/components/ui/data-table/data-table-toolbar';
import { DataTableViewOptions } from '@/components/ui/data-table/data-table-view-options';
import { useSearchQueryState } from '@/hooks/use-search-query-state';
import type { RouterOutputs } from '@/trpc/client';
import { arePropsEqual } from '@/utils/are-props-equal';
const PAGE_SIZE = 50;
interface Props {
query: UseQueryResult<RouterOutputs['group']['list'], unknown>;
pageSize?: number;
toolbarLeft?: React.ReactNode;
}
const LOADING_DATA = [{}, {}, {}, {}, {}, {}, {}, {}, {}] as IServiceGroup[];
export const GroupsTable = memo(
({ query, pageSize = PAGE_SIZE, toolbarLeft }: Props) => {
const { data, isLoading } = query;
const columns = useGroupColumns();
const { setPage, state: pagination } = useDataTablePagination(pageSize);
const {
columnVisibility,
setColumnVisibility,
columnOrder,
setColumnOrder,
} = useDataTableColumnVisibility(columns, 'groups');
const table = useReactTable({
data: isLoading ? LOADING_DATA : (data?.data ?? []),
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
manualFiltering: true,
manualSorting: true,
columns,
rowCount: data?.meta.count,
pageCount: Math.ceil(
(data?.meta.count || 0) / (pagination.pageSize || 1)
),
filterFns: {
isWithinRange: () => true,
},
state: {
pagination,
columnVisibility,
columnOrder,
},
onColumnVisibilityChange: setColumnVisibility,
onColumnOrderChange: setColumnOrder,
onPaginationChange: (updaterOrValue: Updater<PaginationState>) => {
const nextPagination =
typeof updaterOrValue === 'function'
? updaterOrValue(pagination)
: updaterOrValue;
setPage(nextPagination.pageIndex + 1);
},
getRowId: (row, index) => (row as IServiceGroup).id ?? `loading-${index}`,
});
return (
<>
<GroupsTableToolbar table={table} toolbarLeft={toolbarLeft} />
<DataTable
empty={{
title: 'No groups found',
description:
'Groups represent companies, teams, or other entities that events belong to.',
}}
loading={isLoading}
table={table}
/>
</>
);
},
arePropsEqual(['query.isLoading', 'query.data', 'pageSize', 'toolbarLeft'])
);
function GroupsTableToolbar({
table,
toolbarLeft,
}: {
table: Table<IServiceGroup>;
toolbarLeft?: React.ReactNode;
}) {
const { search, setSearch } = useSearchQueryState();
return (
<DataTableToolbarContainer>
<div className="flex flex-wrap items-center gap-2">
{toolbarLeft}
<AnimatedSearchInput
onChange={setSearch}
placeholder="Search groups..."
value={search}
/>
</div>
<DataTableViewOptions table={table} />
</DataTableToolbarContainer>
);
}

View File

@@ -0,0 +1,52 @@
import { ProjectLink } from '@/components/links';
import { Widget } from '@/components/widget';
import { useTRPC } from '@/integrations/trpc/react';
import { WidgetHead } from '../overview/overview-widget';
import { useQuery } from '@tanstack/react-query';
import { FullPageEmptyState } from '../full-page-empty-state';
type Props = {
profileId: string;
projectId: string;
groups: string[];
};
export const ProfileGroups = ({ projectId, groups }: Props) => {
const trpc = useTRPC();
const query = useQuery(
trpc.group.listByIds.queryOptions({
projectId,
ids: groups,
}),
);
if (!groups.length) return null;
return (
<Widget className="w-full">
<WidgetHead>
<div className="title">Groups</div>
</WidgetHead>
{query.data?.length ? (
<div className="flex flex-wrap gap-2 p-4">
{query.data.map((group) => (
<ProjectLink
key={group.id}
href={`/groups/${encodeURIComponent(group.id)}`}
className="flex items-center gap-2 rounded-md border bg-muted/50 px-3 py-2 hover:bg-muted transition-colors"
>
<div>
<div className="text-sm font-medium">{group.name}</div>
<div className="text-xs text-muted-foreground">
{group.type} · {group.id}
</div>
</div>
</ProjectLink>
))}
</div>
) : query.isLoading ? null : (
<FullPageEmptyState title="No groups found" className="p-4" />
)}
</Widget>
);
};

View File

@@ -1,15 +1,10 @@
import type { IServiceProfile } from '@openpanel/db';
import type { ColumnDef } from '@tanstack/react-table';
import { ProfileAvatar } from '../profile-avatar';
import { ColumnCreatedAt } from '@/components/column-created-at';
import { ProjectLink } from '@/components/links';
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
import { Tooltiper } from '@/components/ui/tooltip';
import { formatDateTime, formatTime } from '@/utils/date';
import { getProfileName } from '@/utils/getters';
import type { ColumnDef } from '@tanstack/react-table';
import { isToday } from 'date-fns';
import type { IServiceProfile } from '@openpanel/db';
import { ColumnCreatedAt } from '@/components/column-created-at';
import { ProfileAvatar } from '../profile-avatar';
export function useColumns(type: 'profiles' | 'power-users') {
const columns: ColumnDef<IServiceProfile>[] = [
@@ -20,8 +15,8 @@ export function useColumns(type: 'profiles' | 'power-users') {
const profile = row.original;
return (
<ProjectLink
href={`/profiles/${encodeURIComponent(profile.id)}`}
className="flex items-center gap-2 font-medium"
href={`/profiles/${encodeURIComponent(profile.id)}`}
title={getProfileName(profile, false)}
>
<ProfileAvatar size="sm" {...profile} />
@@ -100,13 +95,40 @@ export function useColumns(type: 'profiles' | 'power-users') {
},
{
accessorKey: 'createdAt',
header: 'Last seen',
header: 'First seen',
size: ColumnCreatedAt.size,
cell: ({ row }) => {
const item = row.original;
return <ColumnCreatedAt>{item.createdAt}</ColumnCreatedAt>;
},
},
{
accessorKey: 'groups',
header: 'Groups',
size: 200,
meta: {
hidden: true,
},
cell({ row }) {
const { groups } = row.original;
if (!groups?.length) {
return null;
}
return (
<div className="flex flex-wrap gap-1">
{groups.map((g) => (
<ProjectLink
className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs hover:underline"
href={`/groups/${encodeURIComponent(g)}`}
key={g}
>
{g}
</ProjectLink>
))}
</div>
);
},
},
];
if (type === 'power-users') {

View File

@@ -166,7 +166,8 @@ export function Tables({
metric: 'sum',
options: funnelOptions,
},
stepIndex, // Pass the step index for funnel queries
stepIndex,
breakdownValues: breakdowns,
});
};
return (

View File

@@ -270,6 +270,27 @@ export function useColumns() {
header: 'Device ID',
size: 120,
},
{
accessorKey: 'groups',
header: 'Groups',
size: 200,
cell: ({ row }) => {
const { groups } = row.original;
if (!groups?.length) return null;
return (
<div className="flex flex-wrap gap-1">
{groups.map((g) => (
<span
key={g}
className="rounded bg-muted px-1.5 py-0.5 text-xs font-mono"
>
{g}
</span>
))}
</div>
);
},
},
];
return columns;

View File

@@ -1,7 +1,6 @@
import type { Column, Table } from '@tanstack/react-table';
import { SearchIcon, X, XIcon } from 'lucide-react';
import * as React from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { Button } from '@/components/ui/button';
import { DataTableDateFilter } from '@/components/ui/data-table/data-table-date-filter';
import { DataTableFacetedFilter } from '@/components/ui/data-table/data-table-faceted-filter';
@@ -23,12 +22,12 @@ export function DataTableToolbarContainer({
}: React.ComponentProps<'div'>) {
return (
<div
role="toolbar"
aria-orientation="horizontal"
className={cn(
'flex flex-1 items-start justify-between gap-2 mb-2',
className,
'mb-2 flex flex-1 items-start justify-between gap-2',
className
)}
role="toolbar"
{...props}
/>
);
@@ -47,12 +46,12 @@ export function DataTableToolbar<TData>({
});
const isFiltered = table.getState().columnFilters.length > 0;
const columns = React.useMemo(
const columns = useMemo(
() => table.getAllColumns().filter((column) => column.getCanFilter()),
[table],
[table]
);
const onReset = React.useCallback(() => {
const onReset = useCallback(() => {
table.resetColumnFilters();
}, [table]);
@@ -61,23 +60,23 @@ export function DataTableToolbar<TData>({
<div className="flex flex-1 flex-wrap items-center gap-2">
{globalSearchKey && (
<AnimatedSearchInput
onChange={setSearch}
placeholder={globalSearchPlaceholder ?? 'Search'}
value={search}
onChange={setSearch}
/>
)}
{columns.map((column) => (
<DataTableToolbarFilter key={column.id} column={column} />
<DataTableToolbarFilter column={column} key={column.id} />
))}
{isFiltered && (
<Button
aria-label="Reset filters"
variant="outline"
size="sm"
className="border-dashed"
onClick={onReset}
size="sm"
variant="outline"
>
<XIcon className="size-4 mr-2" />
<XIcon className="mr-2 size-4" />
Reset
</Button>
)}
@@ -99,20 +98,22 @@ function DataTableToolbarFilter<TData>({
{
const columnMeta = column.columnDef.meta;
const getTitle = React.useCallback(() => {
const getTitle = useCallback(() => {
return columnMeta?.label ?? columnMeta?.placeholder ?? column.id;
}, [columnMeta, column]);
const onFilterRender = React.useCallback(() => {
if (!columnMeta?.variant) return null;
const onFilterRender = useCallback(() => {
if (!columnMeta?.variant) {
return null;
}
switch (columnMeta.variant) {
case 'text':
return (
<AnimatedSearchInput
onChange={(value) => column.setFilterValue(value)}
placeholder={columnMeta.placeholder ?? columnMeta.label}
value={(column.getFilterValue() as string) ?? ''}
onChange={(value) => column.setFilterValue(value)}
/>
);
@@ -120,12 +121,12 @@ function DataTableToolbarFilter<TData>({
return (
<div className="relative">
<Input
type="number"
inputMode="numeric"
placeholder={getTitle()}
value={(column.getFilterValue() as string) ?? ''}
onChange={(event) => column.setFilterValue(event.target.value)}
className={cn('h-8 w-[120px]', columnMeta.unit && 'pr-8')}
inputMode="numeric"
onChange={(event) => column.setFilterValue(event.target.value)}
placeholder={getTitle()}
type="number"
value={(column.getFilterValue() as string) ?? ''}
/>
{columnMeta.unit && (
<span className="absolute top-0 right-0 bottom-0 flex items-center rounded-r-md bg-accent px-2 text-muted-foreground text-sm">
@@ -143,8 +144,8 @@ function DataTableToolbarFilter<TData>({
return (
<DataTableDateFilter
column={column}
title={getTitle()}
multiple={columnMeta.variant === 'dateRange'}
title={getTitle()}
/>
);
@@ -153,9 +154,9 @@ function DataTableToolbarFilter<TData>({
return (
<DataTableFacetedFilter
column={column}
title={getTitle()}
options={columnMeta.options ?? []}
multiple={columnMeta.variant === 'multiSelect'}
options={columnMeta.options ?? []}
title={getTitle()}
/>
);
@@ -179,11 +180,11 @@ export function AnimatedSearchInput({
value,
onChange,
}: AnimatedSearchInputProps) {
const [isFocused, setIsFocused] = React.useState(false);
const inputRef = React.useRef<HTMLInputElement | null>(null);
const [isFocused, setIsFocused] = useState(false);
const inputRef = useRef<HTMLInputElement | null>(null);
const isExpanded = isFocused || (value?.length ?? 0) > 0;
const handleClear = React.useCallback(() => {
const handleClear = useCallback(() => {
onChange('');
// Re-focus after clearing
requestAnimationFrame(() => inputRef.current?.focus());
@@ -191,34 +192,35 @@ export function AnimatedSearchInput({
return (
<div
aria-label={placeholder ?? 'Search'}
className={cn(
'relative flex h-8 items-center rounded-md border border-input bg-background text-sm transition-[width] duration-300 ease-out',
'relative flex items-center rounded-md border border-input bg-background text-sm transition-[width] duration-300 ease-out',
'focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background',
isExpanded ? 'w-56 lg:w-72' : 'w-32',
'h-8 min-h-8',
isExpanded ? 'w-56 lg:w-72' : 'w-32'
)}
role="search"
aria-label={placeholder ?? 'Search'}
>
<SearchIcon className="size-4 ml-2 shrink-0" />
<SearchIcon className="ml-2 size-4 shrink-0" />
<Input
ref={inputRef}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className={cn(
'absolute inset-0 -top-px h-8 w-full rounded-md border-0 bg-transparent pl-7 pr-7 shadow-none',
'absolute inset-0 h-full w-full rounded-md border-0 bg-transparent py-2 pr-7 pl-7 shadow-none',
'focus-visible:ring-0 focus-visible:ring-offset-0',
'transition-opacity duration-200',
'font-medium text-[14px] truncate align-baseline',
'truncate align-baseline font-medium text-[14px]'
)}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
onChange={(e) => onChange(e.target.value)}
onFocus={() => setIsFocused(true)}
placeholder={placeholder}
ref={inputRef}
size="sm"
value={value}
/>
{isExpanded && value && (
<button
type="button"
aria-label="Clear search"
className="absolute right-1 flex size-6 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground"
onClick={(e) => {
@@ -226,6 +228,7 @@ export function AnimatedSearchInput({
e.stopPropagation();
handleClear();
}}
type="button"
>
<X className="size-4" />
</button>

View File

@@ -1,7 +1,6 @@
import * as SelectPrimitive from '@radix-ui/react-select';
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
import type * as React from 'react';
import { cn } from '@/lib/utils';
function Select({
@@ -32,12 +31,12 @@ function SelectTrigger({
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
"flex w-fit items-center justify-between gap-2 whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[size=default]:h-8 data-[size=sm]:h-8 data-[placeholder]:text-muted-foreground *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:hover:bg-input/50 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
data-size={size}
data-slot="select-trigger"
{...props}
>
{children}
@@ -57,13 +56,13 @@ function SelectContent({
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in',
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',
className,
'data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=bottom]:translate-y-1 data-[side=top]:-translate-y-1',
className
)}
data-slot="select-content"
position={position}
{...props}
>
@@ -72,7 +71,7 @@ function SelectContent({
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1',
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1'
)}
>
{children}
@@ -89,8 +88,8 @@ function SelectLabel({
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
className={cn('px-2 py-1.5 text-muted-foreground text-xs', className)}
data-slot="select-label"
className={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
{...props}
/>
);
@@ -103,11 +102,11 @@ function SelectItem({
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
"relative flex w-full cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
data-slot="select-item"
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
@@ -126,8 +125,8 @@ function SelectSeparator({
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
className={cn('pointer-events-none -mx-1 my-1 h-px bg-border', className)}
data-slot="select-separator"
className={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}
{...props}
/>
);
@@ -139,11 +138,11 @@ function SelectScrollUpButton({
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
'flex cursor-default items-center justify-center py-1',
className,
className
)}
data-slot="select-scroll-up-button"
{...props}
>
<ChevronUpIcon className="size-4" />
@@ -157,11 +156,11 @@ function SelectScrollDownButton({
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
'flex cursor-default items-center justify-center py-1',
className,
className
)}
data-slot="select-scroll-down-button"
{...props}
>
<ChevronDownIcon className="size-4" />

View File

@@ -0,0 +1,118 @@
import { ButtonContainer } from '@/components/button-container';
import { InputWithLabel } from '@/components/forms/input-with-label';
import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/use-app-params';
import { handleError, useTRPC } from '@/integrations/trpc/react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { PlusIcon, Trash2Icon } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container';
interface IForm {
id: string;
type: string;
name: string;
properties: { key: string; value: string }[];
}
export default function AddGroup() {
const { projectId } = useAppParams();
const queryClient = useQueryClient();
const trpc = useTRPC();
const { register, handleSubmit, control, formState } = useForm<IForm>({
defaultValues: {
id: '',
type: '',
name: '',
properties: [],
},
});
const { fields, append, remove } = useFieldArray({
control,
name: 'properties',
});
const mutation = useMutation(
trpc.group.create.mutationOptions({
onSuccess() {
queryClient.invalidateQueries(trpc.group.list.pathFilter());
queryClient.invalidateQueries(trpc.group.types.pathFilter());
toast('Success', { description: 'Group created.' });
popModal();
},
onError: handleError,
}),
);
return (
<ModalContent>
<ModalHeader title="Add group" />
<form
className="flex flex-col gap-4"
onSubmit={handleSubmit(({ properties, ...values }) => {
const props = Object.fromEntries(
properties
.filter((p) => p.key.trim() !== '')
.map((p) => [p.key.trim(), String(p.value)]),
);
mutation.mutate({ projectId, ...values, properties: props });
})}
>
<InputWithLabel label="ID" placeholder="acme-corp" {...register('id', { required: true })} autoFocus />
<InputWithLabel label="Name" placeholder="Acme Corp" {...register('name', { required: true })} />
<InputWithLabel label="Type" placeholder="company" {...register('type', { required: true })} />
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Properties</span>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => append({ key: '', value: '' })}
>
<PlusIcon className="mr-1 size-3" />
Add
</Button>
</div>
{fields.map((field, index) => (
<div key={field.id} className="flex gap-2">
<input
className="h-9 flex-1 rounded-md border bg-background px-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
placeholder="key"
{...register(`properties.${index}.key`)}
/>
<input
className="h-9 flex-1 rounded-md border bg-background px-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
placeholder="value"
{...register(`properties.${index}.value`)}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0"
onClick={() => remove(index)}
>
<Trash2Icon className="size-4" />
</Button>
</div>
))}
</div>
<ButtonContainer>
<Button type="button" variant="outline" onClick={() => popModal()}>
Cancel
</Button>
<Button type="submit" disabled={!formState.isDirty || mutation.isPending}>
Create
</Button>
</ButtonContainer>
</form>
</ModalContent>
);
}

View File

@@ -0,0 +1,120 @@
import { ButtonContainer } from '@/components/button-container';
import { InputWithLabel } from '@/components/forms/input-with-label';
import { Button } from '@/components/ui/button';
import { handleError, useTRPC } from '@/integrations/trpc/react';
import type { IServiceGroup } from '@openpanel/db';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { PlusIcon, Trash2Icon } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container';
interface IForm {
type: string;
name: string;
properties: { key: string; value: string }[];
}
type EditGroupProps = Pick<IServiceGroup, 'id' | 'projectId' | 'name' | 'type' | 'properties'>;
export default function EditGroup({ id, projectId, name, type, properties }: EditGroupProps) {
const queryClient = useQueryClient();
const trpc = useTRPC();
const { register, handleSubmit, control, formState } = useForm<IForm>({
defaultValues: {
type,
name,
properties: Object.entries(properties as Record<string, string>).map(([key, value]) => ({
key,
value: String(value),
})),
},
});
const { fields, append, remove } = useFieldArray({
control,
name: 'properties',
});
const mutation = useMutation(
trpc.group.update.mutationOptions({
onSuccess() {
queryClient.invalidateQueries(trpc.group.list.pathFilter());
queryClient.invalidateQueries(trpc.group.byId.pathFilter());
queryClient.invalidateQueries(trpc.group.types.pathFilter());
toast('Success', { description: 'Group updated.' });
popModal();
},
onError: handleError,
}),
);
return (
<ModalContent>
<ModalHeader title="Edit group" />
<form
className="flex flex-col gap-4"
onSubmit={handleSubmit(({ properties: formProps, ...values }) => {
const props = Object.fromEntries(
formProps
.filter((p) => p.key.trim() !== '')
.map((p) => [p.key.trim(), String(p.value)]),
);
mutation.mutate({ id, projectId, ...values, properties: props });
})}
>
<InputWithLabel label="Name" {...register('name', { required: true })} />
<InputWithLabel label="Type" {...register('type', { required: true })} />
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Properties</span>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => append({ key: '', value: '' })}
>
<PlusIcon className="mr-1 size-3" />
Add
</Button>
</div>
{fields.map((field, index) => (
<div key={field.id} className="flex gap-2">
<input
className="h-9 flex-1 rounded-md border bg-background px-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
placeholder="key"
{...register(`properties.${index}.key`)}
/>
<input
className="h-9 flex-1 rounded-md border bg-background px-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
placeholder="value"
{...register(`properties.${index}.value`)}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0"
onClick={() => remove(index)}
>
<Trash2Icon className="size-4" />
</Button>
</div>
))}
</div>
<ButtonContainer>
<Button type="button" variant="outline" onClick={() => popModal()}>
Cancel
</Button>
<Button type="submit" disabled={!formState.isDirty || mutation.isPending}>
Update
</Button>
</ButtonContainer>
</form>
</ModalContent>
);
}

View File

@@ -1,7 +1,7 @@
import PageDetails from './page-details';
import { createPushModal } from 'pushmodal';
import AddClient from './add-client';
import AddDashboard from './add-dashboard';
import AddGroup from './add-group';
import AddImport from './add-import';
import AddIntegration from './add-integration';
import AddNotificationRule from './add-notification-rule';
@@ -16,6 +16,7 @@ import DateTimePicker from './date-time-picker';
import EditClient from './edit-client';
import EditDashboard from './edit-dashboard';
import EditEvent from './edit-event';
import EditGroup from './edit-group';
import EditMember from './edit-member';
import EditReference from './edit-reference';
import EditReport from './edit-report';
@@ -23,6 +24,7 @@ import EventDetails from './event-details';
import Instructions from './Instructions';
import OverviewChartDetails from './overview-chart-details';
import OverviewFilters from './overview-filters';
import PageDetails from './page-details';
import RequestPasswordReset from './request-reset-password';
import SaveReport from './save-report';
import SelectBillingPlan from './select-billing-plan';
@@ -36,6 +38,8 @@ import { op } from '@/utils/op';
const modals = {
PageDetails,
AddGroup,
EditGroup,
OverviewTopPagesModal,
OverviewTopGenericModal,
RequestPasswordReset,

View File

@@ -281,9 +281,10 @@ function ChartUsersView({ chartData, report, date }: ChartUsersViewProps) {
interface FunnelUsersViewProps {
report: IReportInput;
stepIndex: number;
breakdownValues?: string[];
}
function FunnelUsersView({ report, stepIndex }: FunnelUsersViewProps) {
function FunnelUsersView({ report, stepIndex, breakdownValues }: FunnelUsersViewProps) {
const trpc = useTRPC();
const [showDropoffs, setShowDropoffs] = useState(false);
@@ -306,6 +307,7 @@ function FunnelUsersView({ report, stepIndex }: FunnelUsersViewProps) {
? report.options.funnelGroup
: undefined,
breakdowns: report.breakdowns,
breakdownValues: breakdownValues,
},
{
enabled: stepIndex !== undefined,
@@ -384,13 +386,14 @@ type ViewChartUsersProps =
type: 'funnel';
report: IReportInput;
stepIndex: number;
breakdownValues?: string[];
};
// Main component that routes to the appropriate view
export default function ViewChartUsers(props: ViewChartUsersProps) {
if (props.type === 'funnel') {
return (
<FunnelUsersView report={props.report} stepIndex={props.stepIndex} />
<FunnelUsersView report={props.report} stepIndex={props.stepIndex} breakdownValues={props.breakdownValues} />
);
}

View File

@@ -64,7 +64,6 @@ import { Route as AppOrganizationIdProjectIdSessionsSessionIdRouteImport } from
import { Route as AppOrganizationIdProjectIdReportsReportIdRouteImport } from './routes/_app.$organizationId.$projectId.reports_.$reportId'
import { Route as AppOrganizationIdProjectIdProfilesTabsRouteImport } from './routes/_app.$organizationId.$projectId.profiles._tabs'
import { Route as AppOrganizationIdProjectIdNotificationsTabsRouteImport } from './routes/_app.$organizationId.$projectId.notifications._tabs'
import { Route as AppOrganizationIdProjectIdGroupsGroupIdRouteImport } from './routes/_app.$organizationId.$projectId.groups_.$groupId'
import { Route as AppOrganizationIdProjectIdEventsTabsRouteImport } from './routes/_app.$organizationId.$projectId.events._tabs'
import { Route as AppOrganizationIdProjectIdDashboardsDashboardIdRouteImport } from './routes/_app.$organizationId.$projectId.dashboards_.$dashboardId'
import { Route as AppOrganizationIdProjectIdSettingsTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.index'
@@ -84,12 +83,16 @@ import { Route as AppOrganizationIdProjectIdProfilesTabsAnonymousRouteImport } f
import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs'
import { Route as AppOrganizationIdProjectIdNotificationsTabsRulesRouteImport } from './routes/_app.$organizationId.$projectId.notifications._tabs.rules'
import { Route as AppOrganizationIdProjectIdNotificationsTabsNotificationsRouteImport } from './routes/_app.$organizationId.$projectId.notifications._tabs.notifications'
import { Route as AppOrganizationIdProjectIdGroupsGroupIdTabsRouteImport } from './routes/_app.$organizationId.$projectId.groups_.$groupId._tabs'
import { Route as AppOrganizationIdProjectIdEventsTabsStatsRouteImport } from './routes/_app.$organizationId.$projectId.events._tabs.stats'
import { Route as AppOrganizationIdProjectIdEventsTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.events._tabs.events'
import { Route as AppOrganizationIdProjectIdEventsTabsConversionsRouteImport } from './routes/_app.$organizationId.$projectId.events._tabs.conversions'
import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.index'
import { Route as AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.groups_.$groupId._tabs.index'
import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.sessions'
import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.events'
import { Route as AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRouteImport } from './routes/_app.$organizationId.$projectId.groups_.$groupId._tabs.members'
import { Route as AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.groups_.$groupId._tabs.events'
const AppOrganizationIdProfileRouteImport = createFileRoute(
'/_app/$organizationId/profile',
@@ -115,6 +118,9 @@ const AppOrganizationIdProjectIdEventsRouteImport = createFileRoute(
const AppOrganizationIdProjectIdProfilesProfileIdRouteImport = createFileRoute(
'/_app/$organizationId/$projectId/profiles/$profileId',
)()
const AppOrganizationIdProjectIdGroupsGroupIdRouteImport = createFileRoute(
'/_app/$organizationId/$projectId/groups_/$groupId',
)()
const UnsubscribeRoute = UnsubscribeRouteImport.update({
id: '/unsubscribe',
@@ -376,6 +382,12 @@ const AppOrganizationIdProjectIdProfilesProfileIdRoute =
path: '/$profileId',
getParentRoute: () => AppOrganizationIdProjectIdProfilesRoute,
} as any)
const AppOrganizationIdProjectIdGroupsGroupIdRoute =
AppOrganizationIdProjectIdGroupsGroupIdRouteImport.update({
id: '/groups_/$groupId',
path: '/groups/$groupId',
getParentRoute: () => AppOrganizationIdProjectIdRoute,
} as any)
const AppOrganizationIdProfileTabsIndexRoute =
AppOrganizationIdProfileTabsIndexRouteImport.update({
id: '/',
@@ -451,12 +463,6 @@ const AppOrganizationIdProjectIdNotificationsTabsRoute =
id: '/_tabs',
getParentRoute: () => AppOrganizationIdProjectIdNotificationsRoute,
} as any)
const AppOrganizationIdProjectIdGroupsGroupIdRoute =
AppOrganizationIdProjectIdGroupsGroupIdRouteImport.update({
id: '/groups_/$groupId',
path: '/groups/$groupId',
getParentRoute: () => AppOrganizationIdProjectIdRoute,
} as any)
const AppOrganizationIdProjectIdEventsTabsRoute =
AppOrganizationIdProjectIdEventsTabsRouteImport.update({
id: '/_tabs',
@@ -569,6 +575,11 @@ const AppOrganizationIdProjectIdNotificationsTabsNotificationsRoute =
path: '/notifications',
getParentRoute: () => AppOrganizationIdProjectIdNotificationsTabsRoute,
} as any)
const AppOrganizationIdProjectIdGroupsGroupIdTabsRoute =
AppOrganizationIdProjectIdGroupsGroupIdTabsRouteImport.update({
id: '/_tabs',
getParentRoute: () => AppOrganizationIdProjectIdGroupsGroupIdRoute,
} as any)
const AppOrganizationIdProjectIdEventsTabsStatsRoute =
AppOrganizationIdProjectIdEventsTabsStatsRouteImport.update({
id: '/stats',
@@ -593,6 +604,12 @@ const AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute =
path: '/',
getParentRoute: () => AppOrganizationIdProjectIdProfilesProfileIdTabsRoute,
} as any)
const AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRoute =
AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => AppOrganizationIdProjectIdGroupsGroupIdTabsRoute,
} as any)
const AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute =
AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRouteImport.update({
id: '/sessions',
@@ -605,6 +622,18 @@ const AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute =
path: '/events',
getParentRoute: () => AppOrganizationIdProjectIdProfilesProfileIdTabsRoute,
} as any)
const AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRoute =
AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRouteImport.update({
id: '/members',
path: '/members',
getParentRoute: () => AppOrganizationIdProjectIdGroupsGroupIdTabsRoute,
} as any)
const AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRoute =
AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRouteImport.update({
id: '/events',
path: '/events',
getParentRoute: () => AppOrganizationIdProjectIdGroupsGroupIdTabsRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
@@ -645,7 +674,6 @@ export interface FileRoutesByFullPath {
'/$organizationId/$projectId/': typeof AppOrganizationIdProjectIdIndexRoute
'/$organizationId/$projectId/dashboards/$dashboardId': typeof AppOrganizationIdProjectIdDashboardsDashboardIdRoute
'/$organizationId/$projectId/events': typeof AppOrganizationIdProjectIdEventsTabsRouteWithChildren
'/$organizationId/$projectId/groups/$groupId': typeof AppOrganizationIdProjectIdGroupsGroupIdRoute
'/$organizationId/$projectId/notifications': typeof AppOrganizationIdProjectIdNotificationsTabsRouteWithChildren
'/$organizationId/$projectId/profiles': typeof AppOrganizationIdProjectIdProfilesTabsRouteWithChildren
'/$organizationId/$projectId/reports/$reportId': typeof AppOrganizationIdProjectIdReportsReportIdRoute
@@ -662,6 +690,7 @@ export interface FileRoutesByFullPath {
'/$organizationId/$projectId/events/conversions': typeof AppOrganizationIdProjectIdEventsTabsConversionsRoute
'/$organizationId/$projectId/events/events': typeof AppOrganizationIdProjectIdEventsTabsEventsRoute
'/$organizationId/$projectId/events/stats': typeof AppOrganizationIdProjectIdEventsTabsStatsRoute
'/$organizationId/$projectId/groups/$groupId': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsRouteWithChildren
'/$organizationId/$projectId/notifications/notifications': typeof AppOrganizationIdProjectIdNotificationsTabsNotificationsRoute
'/$organizationId/$projectId/notifications/rules': typeof AppOrganizationIdProjectIdNotificationsTabsRulesRoute
'/$organizationId/$projectId/profiles/$profileId': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsRouteWithChildren
@@ -679,8 +708,11 @@ export interface FileRoutesByFullPath {
'/$organizationId/$projectId/notifications/': typeof AppOrganizationIdProjectIdNotificationsTabsIndexRoute
'/$organizationId/$projectId/profiles/': typeof AppOrganizationIdProjectIdProfilesTabsIndexRoute
'/$organizationId/$projectId/settings/': typeof AppOrganizationIdProjectIdSettingsTabsIndexRoute
'/$organizationId/$projectId/groups/$groupId/events': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRoute
'/$organizationId/$projectId/groups/$groupId/members': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRoute
'/$organizationId/$projectId/profiles/$profileId/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute
'/$organizationId/$projectId/profiles/$profileId/sessions': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute
'/$organizationId/$projectId/groups/$groupId/': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRoute
'/$organizationId/$projectId/profiles/$profileId/': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute
}
export interface FileRoutesByTo {
@@ -720,7 +752,6 @@ export interface FileRoutesByTo {
'/$organizationId/$projectId': typeof AppOrganizationIdProjectIdIndexRoute
'/$organizationId/$projectId/dashboards/$dashboardId': typeof AppOrganizationIdProjectIdDashboardsDashboardIdRoute
'/$organizationId/$projectId/events': typeof AppOrganizationIdProjectIdEventsTabsIndexRoute
'/$organizationId/$projectId/groups/$groupId': typeof AppOrganizationIdProjectIdGroupsGroupIdRoute
'/$organizationId/$projectId/notifications': typeof AppOrganizationIdProjectIdNotificationsTabsIndexRoute
'/$organizationId/$projectId/profiles': typeof AppOrganizationIdProjectIdProfilesTabsIndexRoute
'/$organizationId/$projectId/reports/$reportId': typeof AppOrganizationIdProjectIdReportsReportIdRoute
@@ -734,6 +765,7 @@ export interface FileRoutesByTo {
'/$organizationId/$projectId/events/conversions': typeof AppOrganizationIdProjectIdEventsTabsConversionsRoute
'/$organizationId/$projectId/events/events': typeof AppOrganizationIdProjectIdEventsTabsEventsRoute
'/$organizationId/$projectId/events/stats': typeof AppOrganizationIdProjectIdEventsTabsStatsRoute
'/$organizationId/$projectId/groups/$groupId': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRoute
'/$organizationId/$projectId/notifications/notifications': typeof AppOrganizationIdProjectIdNotificationsTabsNotificationsRoute
'/$organizationId/$projectId/notifications/rules': typeof AppOrganizationIdProjectIdNotificationsTabsRulesRoute
'/$organizationId/$projectId/profiles/$profileId': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute
@@ -747,6 +779,8 @@ export interface FileRoutesByTo {
'/$organizationId/$projectId/settings/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
'/$organizationId/$projectId/settings/tracking': typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute
'/$organizationId/$projectId/settings/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
'/$organizationId/$projectId/groups/$groupId/events': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRoute
'/$organizationId/$projectId/groups/$groupId/members': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRoute
'/$organizationId/$projectId/profiles/$profileId/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute
'/$organizationId/$projectId/profiles/$profileId/sessions': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute
}
@@ -798,7 +832,6 @@ export interface FileRoutesById {
'/_app/$organizationId/$projectId/dashboards_/$dashboardId': typeof AppOrganizationIdProjectIdDashboardsDashboardIdRoute
'/_app/$organizationId/$projectId/events': typeof AppOrganizationIdProjectIdEventsRouteWithChildren
'/_app/$organizationId/$projectId/events/_tabs': typeof AppOrganizationIdProjectIdEventsTabsRouteWithChildren
'/_app/$organizationId/$projectId/groups_/$groupId': typeof AppOrganizationIdProjectIdGroupsGroupIdRoute
'/_app/$organizationId/$projectId/notifications': typeof AppOrganizationIdProjectIdNotificationsRouteWithChildren
'/_app/$organizationId/$projectId/notifications/_tabs': typeof AppOrganizationIdProjectIdNotificationsTabsRouteWithChildren
'/_app/$organizationId/$projectId/profiles': typeof AppOrganizationIdProjectIdProfilesRouteWithChildren
@@ -818,6 +851,8 @@ export interface FileRoutesById {
'/_app/$organizationId/$projectId/events/_tabs/conversions': typeof AppOrganizationIdProjectIdEventsTabsConversionsRoute
'/_app/$organizationId/$projectId/events/_tabs/events': typeof AppOrganizationIdProjectIdEventsTabsEventsRoute
'/_app/$organizationId/$projectId/events/_tabs/stats': typeof AppOrganizationIdProjectIdEventsTabsStatsRoute
'/_app/$organizationId/$projectId/groups_/$groupId': typeof AppOrganizationIdProjectIdGroupsGroupIdRouteWithChildren
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsRouteWithChildren
'/_app/$organizationId/$projectId/notifications/_tabs/notifications': typeof AppOrganizationIdProjectIdNotificationsTabsNotificationsRoute
'/_app/$organizationId/$projectId/notifications/_tabs/rules': typeof AppOrganizationIdProjectIdNotificationsTabsRulesRoute
'/_app/$organizationId/$projectId/profiles/$profileId': typeof AppOrganizationIdProjectIdProfilesProfileIdRouteWithChildren
@@ -836,8 +871,11 @@ export interface FileRoutesById {
'/_app/$organizationId/$projectId/notifications/_tabs/': typeof AppOrganizationIdProjectIdNotificationsTabsIndexRoute
'/_app/$organizationId/$projectId/profiles/_tabs/': typeof AppOrganizationIdProjectIdProfilesTabsIndexRoute
'/_app/$organizationId/$projectId/settings/_tabs/': typeof AppOrganizationIdProjectIdSettingsTabsIndexRoute
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs/events': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRoute
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs/members': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRoute
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/sessions': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs/': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRoute
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute
}
export interface FileRouteTypes {
@@ -881,7 +919,6 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId/'
| '/$organizationId/$projectId/dashboards/$dashboardId'
| '/$organizationId/$projectId/events'
| '/$organizationId/$projectId/groups/$groupId'
| '/$organizationId/$projectId/notifications'
| '/$organizationId/$projectId/profiles'
| '/$organizationId/$projectId/reports/$reportId'
@@ -898,6 +935,7 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId/events/conversions'
| '/$organizationId/$projectId/events/events'
| '/$organizationId/$projectId/events/stats'
| '/$organizationId/$projectId/groups/$groupId'
| '/$organizationId/$projectId/notifications/notifications'
| '/$organizationId/$projectId/notifications/rules'
| '/$organizationId/$projectId/profiles/$profileId'
@@ -915,8 +953,11 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId/notifications/'
| '/$organizationId/$projectId/profiles/'
| '/$organizationId/$projectId/settings/'
| '/$organizationId/$projectId/groups/$groupId/events'
| '/$organizationId/$projectId/groups/$groupId/members'
| '/$organizationId/$projectId/profiles/$profileId/events'
| '/$organizationId/$projectId/profiles/$profileId/sessions'
| '/$organizationId/$projectId/groups/$groupId/'
| '/$organizationId/$projectId/profiles/$profileId/'
fileRoutesByTo: FileRoutesByTo
to:
@@ -956,7 +997,6 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId'
| '/$organizationId/$projectId/dashboards/$dashboardId'
| '/$organizationId/$projectId/events'
| '/$organizationId/$projectId/groups/$groupId'
| '/$organizationId/$projectId/notifications'
| '/$organizationId/$projectId/profiles'
| '/$organizationId/$projectId/reports/$reportId'
@@ -970,6 +1010,7 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId/events/conversions'
| '/$organizationId/$projectId/events/events'
| '/$organizationId/$projectId/events/stats'
| '/$organizationId/$projectId/groups/$groupId'
| '/$organizationId/$projectId/notifications/notifications'
| '/$organizationId/$projectId/notifications/rules'
| '/$organizationId/$projectId/profiles/$profileId'
@@ -983,6 +1024,8 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId/settings/imports'
| '/$organizationId/$projectId/settings/tracking'
| '/$organizationId/$projectId/settings/widgets'
| '/$organizationId/$projectId/groups/$groupId/events'
| '/$organizationId/$projectId/groups/$groupId/members'
| '/$organizationId/$projectId/profiles/$profileId/events'
| '/$organizationId/$projectId/profiles/$profileId/sessions'
id:
@@ -1033,7 +1076,6 @@ export interface FileRouteTypes {
| '/_app/$organizationId/$projectId/dashboards_/$dashboardId'
| '/_app/$organizationId/$projectId/events'
| '/_app/$organizationId/$projectId/events/_tabs'
| '/_app/$organizationId/$projectId/groups_/$groupId'
| '/_app/$organizationId/$projectId/notifications'
| '/_app/$organizationId/$projectId/notifications/_tabs'
| '/_app/$organizationId/$projectId/profiles'
@@ -1053,6 +1095,8 @@ export interface FileRouteTypes {
| '/_app/$organizationId/$projectId/events/_tabs/conversions'
| '/_app/$organizationId/$projectId/events/_tabs/events'
| '/_app/$organizationId/$projectId/events/_tabs/stats'
| '/_app/$organizationId/$projectId/groups_/$groupId'
| '/_app/$organizationId/$projectId/groups_/$groupId/_tabs'
| '/_app/$organizationId/$projectId/notifications/_tabs/notifications'
| '/_app/$organizationId/$projectId/notifications/_tabs/rules'
| '/_app/$organizationId/$projectId/profiles/$profileId'
@@ -1071,8 +1115,11 @@ export interface FileRouteTypes {
| '/_app/$organizationId/$projectId/notifications/_tabs/'
| '/_app/$organizationId/$projectId/profiles/_tabs/'
| '/_app/$organizationId/$projectId/settings/_tabs/'
| '/_app/$organizationId/$projectId/groups_/$groupId/_tabs/events'
| '/_app/$organizationId/$projectId/groups_/$groupId/_tabs/members'
| '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/events'
| '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/sessions'
| '/_app/$organizationId/$projectId/groups_/$groupId/_tabs/'
| '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/'
fileRoutesById: FileRoutesById
}
@@ -1432,6 +1479,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdRouteImport
parentRoute: typeof AppOrganizationIdProjectIdProfilesRoute
}
'/_app/$organizationId/$projectId/groups_/$groupId': {
id: '/_app/$organizationId/$projectId/groups_/$groupId'
path: '/groups/$groupId'
fullPath: '/$organizationId/$projectId/groups/$groupId'
preLoaderRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdRouteImport
parentRoute: typeof AppOrganizationIdProjectIdRoute
}
'/_app/$organizationId/profile/_tabs/': {
id: '/_app/$organizationId/profile/_tabs/'
path: '/'
@@ -1523,13 +1577,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppOrganizationIdProjectIdNotificationsTabsRouteImport
parentRoute: typeof AppOrganizationIdProjectIdNotificationsRoute
}
'/_app/$organizationId/$projectId/groups_/$groupId': {
id: '/_app/$organizationId/$projectId/groups_/$groupId'
path: '/groups/$groupId'
fullPath: '/$organizationId/$projectId/groups/$groupId'
preLoaderRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdRouteImport
parentRoute: typeof AppOrganizationIdProjectIdRoute
}
'/_app/$organizationId/$projectId/events/_tabs': {
id: '/_app/$organizationId/$projectId/events/_tabs'
path: '/events'
@@ -1663,6 +1710,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppOrganizationIdProjectIdNotificationsTabsNotificationsRouteImport
parentRoute: typeof AppOrganizationIdProjectIdNotificationsTabsRoute
}
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs': {
id: '/_app/$organizationId/$projectId/groups_/$groupId/_tabs'
path: '/groups/$groupId'
fullPath: '/$organizationId/$projectId/groups/$groupId'
preLoaderRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsRouteImport
parentRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdRoute
}
'/_app/$organizationId/$projectId/events/_tabs/stats': {
id: '/_app/$organizationId/$projectId/events/_tabs/stats'
path: '/stats'
@@ -1691,6 +1745,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRouteImport
parentRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsRoute
}
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs/': {
id: '/_app/$organizationId/$projectId/groups_/$groupId/_tabs/'
path: '/'
fullPath: '/$organizationId/$projectId/groups/$groupId/'
preLoaderRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRouteImport
parentRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsRoute
}
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/sessions': {
id: '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/sessions'
path: '/sessions'
@@ -1705,6 +1766,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRouteImport
parentRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsRoute
}
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs/members': {
id: '/_app/$organizationId/$projectId/groups_/$groupId/_tabs/members'
path: '/members'
fullPath: '/$organizationId/$projectId/groups/$groupId/members'
preLoaderRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRouteImport
parentRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsRoute
}
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs/events': {
id: '/_app/$organizationId/$projectId/groups_/$groupId/_tabs/events'
path: '/events'
fullPath: '/$organizationId/$projectId/groups/$groupId/events'
preLoaderRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRouteImport
parentRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsRoute
}
}
}
@@ -1912,6 +1987,42 @@ const AppOrganizationIdProjectIdSettingsRouteWithChildren =
AppOrganizationIdProjectIdSettingsRouteChildren,
)
interface AppOrganizationIdProjectIdGroupsGroupIdTabsRouteChildren {
AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRoute
AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRoute
AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRoute
}
const AppOrganizationIdProjectIdGroupsGroupIdTabsRouteChildren: AppOrganizationIdProjectIdGroupsGroupIdTabsRouteChildren =
{
AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRoute:
AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRoute,
AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRoute:
AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRoute,
AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRoute:
AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRoute,
}
const AppOrganizationIdProjectIdGroupsGroupIdTabsRouteWithChildren =
AppOrganizationIdProjectIdGroupsGroupIdTabsRoute._addFileChildren(
AppOrganizationIdProjectIdGroupsGroupIdTabsRouteChildren,
)
interface AppOrganizationIdProjectIdGroupsGroupIdRouteChildren {
AppOrganizationIdProjectIdGroupsGroupIdTabsRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsRouteWithChildren
}
const AppOrganizationIdProjectIdGroupsGroupIdRouteChildren: AppOrganizationIdProjectIdGroupsGroupIdRouteChildren =
{
AppOrganizationIdProjectIdGroupsGroupIdTabsRoute:
AppOrganizationIdProjectIdGroupsGroupIdTabsRouteWithChildren,
}
const AppOrganizationIdProjectIdGroupsGroupIdRouteWithChildren =
AppOrganizationIdProjectIdGroupsGroupIdRoute._addFileChildren(
AppOrganizationIdProjectIdGroupsGroupIdRouteChildren,
)
interface AppOrganizationIdProjectIdRouteChildren {
AppOrganizationIdProjectIdChatRoute: typeof AppOrganizationIdProjectIdChatRoute
AppOrganizationIdProjectIdDashboardsRoute: typeof AppOrganizationIdProjectIdDashboardsRoute
@@ -1926,12 +2037,12 @@ interface AppOrganizationIdProjectIdRouteChildren {
AppOrganizationIdProjectIdIndexRoute: typeof AppOrganizationIdProjectIdIndexRoute
AppOrganizationIdProjectIdDashboardsDashboardIdRoute: typeof AppOrganizationIdProjectIdDashboardsDashboardIdRoute
AppOrganizationIdProjectIdEventsRoute: typeof AppOrganizationIdProjectIdEventsRouteWithChildren
AppOrganizationIdProjectIdGroupsGroupIdRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdRoute
AppOrganizationIdProjectIdNotificationsRoute: typeof AppOrganizationIdProjectIdNotificationsRouteWithChildren
AppOrganizationIdProjectIdProfilesRoute: typeof AppOrganizationIdProjectIdProfilesRouteWithChildren
AppOrganizationIdProjectIdReportsReportIdRoute: typeof AppOrganizationIdProjectIdReportsReportIdRoute
AppOrganizationIdProjectIdSessionsSessionIdRoute: typeof AppOrganizationIdProjectIdSessionsSessionIdRoute
AppOrganizationIdProjectIdSettingsRoute: typeof AppOrganizationIdProjectIdSettingsRouteWithChildren
AppOrganizationIdProjectIdGroupsGroupIdRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdRouteWithChildren
}
const AppOrganizationIdProjectIdRouteChildren: AppOrganizationIdProjectIdRouteChildren =
@@ -1958,8 +2069,6 @@ const AppOrganizationIdProjectIdRouteChildren: AppOrganizationIdProjectIdRouteCh
AppOrganizationIdProjectIdDashboardsDashboardIdRoute,
AppOrganizationIdProjectIdEventsRoute:
AppOrganizationIdProjectIdEventsRouteWithChildren,
AppOrganizationIdProjectIdGroupsGroupIdRoute:
AppOrganizationIdProjectIdGroupsGroupIdRoute,
AppOrganizationIdProjectIdNotificationsRoute:
AppOrganizationIdProjectIdNotificationsRouteWithChildren,
AppOrganizationIdProjectIdProfilesRoute:
@@ -1970,6 +2079,8 @@ const AppOrganizationIdProjectIdRouteChildren: AppOrganizationIdProjectIdRouteCh
AppOrganizationIdProjectIdSessionsSessionIdRoute,
AppOrganizationIdProjectIdSettingsRoute:
AppOrganizationIdProjectIdSettingsRouteWithChildren,
AppOrganizationIdProjectIdGroupsGroupIdRoute:
AppOrganizationIdProjectIdGroupsGroupIdRouteWithChildren,
}
const AppOrganizationIdProjectIdRouteWithChildren =

View File

@@ -1,11 +1,12 @@
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { createFileRoute, Link } from '@tanstack/react-router';
import { Building2Icon } from 'lucide-react';
import { createFileRoute } from '@tanstack/react-router';
import { PlusIcon } from 'lucide-react';
import { parseAsString, useQueryState } from 'nuqs';
import { GroupsTable } from '@/components/groups/table';
import { PageContainer } from '@/components/page-container';
import { PageHeader } from '@/components/page-header';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { useDataTablePagination } from '@/components/ui/data-table/data-table-hooks';
import {
Select,
SelectContent,
@@ -15,9 +16,11 @@ import {
} from '@/components/ui/select';
import { useSearchQueryState } from '@/hooks/use-search-query-state';
import { useTRPC } from '@/integrations/trpc/react';
import { formatDateTime } from '@/utils/date';
import { pushModal } from '@/modals';
import { createProjectTitle } from '@/utils/title';
const PAGE_SIZE = 50;
export const Route = createFileRoute('/_app/$organizationId/$projectId/groups')(
{
component: Component,
@@ -28,13 +31,14 @@ export const Route = createFileRoute('/_app/$organizationId/$projectId/groups')(
);
function Component() {
const { projectId, organizationId } = Route.useParams();
const { projectId } = Route.useParams();
const trpc = useTRPC();
const { search, setSearch, debouncedSearch } = useSearchQueryState();
const { debouncedSearch } = useSearchQueryState();
const [typeFilter, setTypeFilter] = useQueryState(
'type',
parseAsString.withDefault('')
);
const { page } = useDataTablePagination(PAGE_SIZE);
const typesQuery = useQuery(trpc.group.types.queryOptions({ projectId }));
@@ -44,103 +48,53 @@ function Component() {
projectId,
search: debouncedSearch || undefined,
type: typeFilter || undefined,
take: 100,
take: PAGE_SIZE,
cursor: (page - 1) * PAGE_SIZE,
},
{ placeholderData: keepPreviousData }
)
);
const groups = groupsQuery.data?.data ?? [];
const types = typesQuery.data ?? [];
return (
<PageContainer>
<PageHeader
actions={
<Button onClick={() => pushModal('AddGroup')}>
<PlusIcon className="mr-2 size-4" />
Add group
</Button>
}
className="mb-8"
description="Groups represent companies, teams, or other entities that events belong to."
title="Groups"
/>
<div className="mb-6 flex flex-wrap gap-3">
<Input
className="w-64"
onChange={(e) => setSearch(e.target.value)}
placeholder="Search groups..."
value={search}
/>
{types.length > 0 && (
<Select
onValueChange={(v) => setTypeFilter(v === 'all' ? '' : v)}
value={typeFilter || 'all'}
>
<SelectTrigger className="w-40">
<SelectValue placeholder="All types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All types</SelectItem>
{types.map((t) => (
<SelectItem key={t} value={t}>
{t}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{groups.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 py-24 text-muted-foreground">
<Building2Icon className="size-10 opacity-30" />
<p className="text-sm">No groups found</p>
</div>
) : (
<div className="card overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-def-100">
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
Name
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
ID
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
Type
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
Created
</th>
</tr>
</thead>
<tbody>
{groups.map((group) => (
<tr
className="border-b transition-colors last:border-0 hover:bg-def-100"
key={`${group.projectId}-${group.id}`}
>
<td className="px-4 py-3">
<Link
className="font-medium hover:underline"
params={{ organizationId, projectId, groupId: group.id }}
to="/$organizationId/$projectId/groups/$groupId"
>
{group.name}
</Link>
</td>
<td className="px-4 py-3 font-mono text-muted-foreground text-xs">
{group.id}
</td>
<td className="px-4 py-3">
<Badge variant="outline">{group.type}</Badge>
</td>
<td className="px-4 py-3 text-muted-foreground">
{formatDateTime(new Date(group.createdAt))}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<GroupsTable
pageSize={PAGE_SIZE}
query={groupsQuery}
toolbarLeft={
types.length > 0 ? (
<Select
onValueChange={(v) => setTypeFilter(v === 'all' ? '' : v)}
value={typeFilter || 'all'}
>
<SelectTrigger className="w-40">
<SelectValue placeholder="All types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All types</SelectItem>
{types.map((t) => (
<SelectItem key={t} value={t}>
{t}
</SelectItem>
))}
</SelectContent>
</Select>
) : null
}
/>
</PageContainer>
);
}

View File

@@ -0,0 +1,46 @@
import { EventsTable } from '@/components/events/table';
import { useReadColumnVisibility } from '@/components/ui/data-table/data-table-hooks';
import { useEventQueryNamesFilter } from '@/hooks/use-event-query-filters';
import { useTRPC } from '@/integrations/trpc/react';
import { createProjectTitle } from '@/utils/title';
import { useInfiniteQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { parseAsIsoDateTime, useQueryState } from 'nuqs';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs/events'
)({
component: Component,
head: () => ({
meta: [{ title: createProjectTitle('Group events') }],
}),
});
function Component() {
const { projectId, groupId } = Route.useParams();
const trpc = useTRPC();
const [startDate] = useQueryState('startDate', parseAsIsoDateTime);
const [endDate] = useQueryState('endDate', parseAsIsoDateTime);
const [eventNames] = useEventQueryNamesFilter();
const columnVisibility = useReadColumnVisibility('events');
const query = useInfiniteQuery(
trpc.event.events.infiniteQueryOptions(
{
projectId,
groupId,
filters: [], // Always scope to group only; date + event names from toolbar still apply
startDate: startDate || undefined,
endDate: endDate || undefined,
events: eventNames,
columnVisibility: columnVisibility ?? {},
},
{
enabled: columnVisibility !== null,
getNextPageParam: (lastPage) => lastPage.meta.next,
}
)
);
return <EventsTable query={query} />;
}

View File

@@ -0,0 +1,208 @@
import { useSuspenseQuery } from '@tanstack/react-query';
import { createFileRoute, Link } from '@tanstack/react-router';
import { UsersIcon } from 'lucide-react';
import FullPageLoadingState from '@/components/full-page-loading-state';
import { OverviewMetricCard } from '@/components/overview/overview-metric-card';
import { WidgetHead, WidgetTitle } from '@/components/overview/overview-widget';
import { ProfileActivity } from '@/components/profiles/profile-activity';
import { KeyValueGrid } from '@/components/ui/key-value-grid';
import { Widget, WidgetBody } from '@/components/widget';
import { WidgetTable } from '@/components/widget-table';
import { useTRPC } from '@/integrations/trpc/react';
import { formatDateTime, formatTimeAgoOrDateTime } from '@/utils/date';
import { createProjectTitle } from '@/utils/title';
const MEMBERS_PREVIEW_LIMIT = 13;
export const Route = createFileRoute(
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs/'
)({
component: Component,
loader: async ({ context, params }) => {
await Promise.all([
context.queryClient.prefetchQuery(
context.trpc.group.activity.queryOptions({
id: params.groupId,
projectId: params.projectId,
})
),
]);
},
pendingComponent: FullPageLoadingState,
head: () => ({
meta: [{ title: createProjectTitle('Group') }],
}),
});
function Component() {
const { projectId, organizationId, groupId } = Route.useParams();
const trpc = useTRPC();
const group = useSuspenseQuery(
trpc.group.byId.queryOptions({ id: groupId, projectId })
);
const metrics = useSuspenseQuery(
trpc.group.metrics.queryOptions({ id: groupId, projectId })
);
const activity = useSuspenseQuery(
trpc.group.activity.queryOptions({ id: groupId, projectId })
);
const members = useSuspenseQuery(
trpc.group.members.queryOptions({ id: groupId, projectId })
);
const g = group.data;
const m = metrics.data?.[0];
if (!g) {
return null;
}
const properties = g.properties as Record<string, unknown>;
return (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{/* Metrics */}
{m && (
<div className="col-span-1 md:col-span-2">
<div className="card grid grid-cols-2 overflow-hidden rounded-md md:grid-cols-4">
<OverviewMetricCard
data={[]}
id="totalEvents"
isLoading={false}
label="Total Events"
metric={{ current: m.totalEvents, previous: null }}
unit=""
/>
<OverviewMetricCard
data={[]}
id="uniqueMembers"
isLoading={false}
label="Unique Members"
metric={{ current: m.uniqueProfiles, previous: null }}
unit=""
/>
<OverviewMetricCard
data={[]}
id="firstSeen"
isLoading={false}
label="First Seen"
metric={{
current: m.firstSeen ? new Date(m.firstSeen).getTime() : 0,
previous: null,
}}
unit="timeAgo"
/>
<OverviewMetricCard
data={[]}
id="lastSeen"
isLoading={false}
label="Last Seen"
metric={{
current: m.lastSeen ? new Date(m.lastSeen).getTime() : 0,
previous: null,
}}
unit="timeAgo"
/>
</div>
</div>
)}
{/* Properties */}
<div className="col-span-1 md:col-span-2">
<Widget className="w-full">
<WidgetHead>
<div className="title">Group Information</div>
</WidgetHead>
<KeyValueGrid
className="border-0"
columns={3}
copyable
data={[
{ name: 'id', value: g.id },
{ name: 'name', value: g.name },
{ name: 'type', value: g.type },
{
name: 'createdAt',
value: formatDateTime(new Date(g.createdAt)),
},
...Object.entries(properties)
.filter(([, v]) => v !== undefined && v !== '')
.map(([k, v]) => ({
name: k,
value: String(v),
})),
]}
/>
</Widget>
</div>
{/* Activity heatmap */}
<div className="col-span-1">
<ProfileActivity data={activity.data} />
</div>
{/* Members preview */}
<div className="col-span-1">
<Widget className="w-full">
<WidgetHead>
<WidgetTitle icon={UsersIcon}>Members</WidgetTitle>
</WidgetHead>
<WidgetBody className="p-0">
{members.data.length === 0 ? (
<p className="py-4 text-center text-muted-foreground text-sm">
No members found
</p>
) : (
<WidgetTable
columnClassName="px-2"
columns={[
{
key: 'profile',
name: 'Profile',
width: 'w-full',
render: (member) => (
<Link
className="font-mono text-xs hover:underline"
params={{
organizationId,
projectId,
profileId: member.profileId,
}}
to="/$organizationId/$projectId/profiles/$profileId"
>
{member.profileId}
</Link>
),
},
{
key: 'events',
name: 'Events',
width: '60px',
className: 'text-muted-foreground',
render: (member) => member.eventCount,
},
{
key: 'lastSeen',
name: 'Last Seen',
width: '150px',
className: 'text-muted-foreground',
render: (member) =>
formatTimeAgoOrDateTime(new Date(member.lastSeen)),
},
]}
data={members.data.slice(0, MEMBERS_PREVIEW_LIMIT)}
keyExtractor={(member) => member.profileId}
/>
)}
{members.data.length > MEMBERS_PREVIEW_LIMIT && (
<p className="border-t py-2 text-center text-muted-foreground text-xs">
{`${members.data.length} members found. View all in Members tab`}
</p>
)}
</WidgetBody>
</Widget>
</div>
</div>
);
}

View File

@@ -0,0 +1,42 @@
import { ProfilesTable } from '@/components/profiles/table';
import { useDataTablePagination } from '@/components/ui/data-table/data-table-hooks';
import { useSearchQueryState } from '@/hooks/use-search-query-state';
import { useTRPC } from '@/integrations/trpc/react';
import { createProjectTitle } from '@/utils/title';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs/members'
)({
component: Component,
head: () => ({
meta: [{ title: createProjectTitle('Group members') }],
}),
});
function Component() {
const { projectId, groupId } = Route.useParams();
const trpc = useTRPC();
const { debouncedSearch } = useSearchQueryState();
const { page } = useDataTablePagination(50);
const query = useQuery({
...trpc.group.listProfiles.queryOptions({
projectId,
groupId,
cursor: page - 1,
take: 50,
search: debouncedSearch || undefined,
}),
placeholderData: keepPreviousData,
});
return (
<ProfilesTable
pageSize={50}
query={query as Parameters<typeof ProfilesTable>[0]['query']}
type="profiles"
/>
);
}

View File

@@ -0,0 +1,161 @@
import {
useMutation,
useQueryClient,
useSuspenseQuery,
} from '@tanstack/react-query';
import {
createFileRoute,
Outlet,
useNavigate,
useRouter,
} from '@tanstack/react-router';
import { Building2Icon, PencilIcon, Trash2Icon } from 'lucide-react';
import FullPageLoadingState from '@/components/full-page-loading-state';
import { PageContainer } from '@/components/page-container';
import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { usePageTabs } from '@/hooks/use-page-tabs';
import { handleError, useTRPC } from '@/integrations/trpc/react';
import { pushModal, showConfirm } from '@/modals';
import { createProjectTitle } from '@/utils/title';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs'
)({
component: Component,
loader: async ({ context, params }) => {
await Promise.all([
context.queryClient.prefetchQuery(
context.trpc.group.byId.queryOptions({
id: params.groupId,
projectId: params.projectId,
})
),
context.queryClient.prefetchQuery(
context.trpc.group.metrics.queryOptions({
id: params.groupId,
projectId: params.projectId,
})
),
]);
},
pendingComponent: FullPageLoadingState,
head: () => ({
meta: [{ title: createProjectTitle('Group') }],
}),
});
function Component() {
const router = useRouter();
const { projectId, organizationId, groupId } = Route.useParams();
const trpc = useTRPC();
const queryClient = useQueryClient();
const navigate = useNavigate();
const group = useSuspenseQuery(
trpc.group.byId.queryOptions({ id: groupId, projectId })
);
const deleteMutation = useMutation(
trpc.group.delete.mutationOptions({
onSuccess() {
queryClient.invalidateQueries(trpc.group.list.pathFilter());
navigate({
to: '/$organizationId/$projectId/groups',
params: { organizationId, projectId },
});
},
onError: handleError,
})
);
const { activeTab, tabs } = usePageTabs([
{ id: '/$organizationId/$projectId/groups/$groupId', label: 'Overview' },
{ id: 'members', label: 'Members' },
{ id: 'events', label: 'Events' },
]);
const handleTabChange = (tabId: string) => {
router.navigate({
from: Route.fullPath,
to: tabId,
});
};
const g = group.data;
if (!g) {
return (
<PageContainer>
<div className="flex flex-col items-center justify-center gap-3 py-24 text-muted-foreground">
<Building2Icon className="size-10 opacity-30" />
<p className="text-sm">Group not found</p>
</div>
</PageContainer>
);
}
return (
<PageContainer className="col">
<PageHeader
actions={
<div className="row gap-2">
<Button
onClick={() =>
pushModal('EditGroup', {
id: g.id,
projectId: g.projectId,
name: g.name,
type: g.type,
properties: g.properties,
})
}
size="sm"
variant="outline"
>
<PencilIcon className="mr-2 size-4" />
Edit
</Button>
<Button
onClick={() =>
showConfirm({
title: 'Delete group',
text: `Are you sure you want to delete "${g.name}"? This action cannot be undone.`,
onConfirm: () =>
deleteMutation.mutate({ id: g.id, projectId }),
})
}
size="sm"
variant="outline"
>
<Trash2Icon className="mr-2 size-4" />
Delete
</Button>
</div>
}
title={
<div className="row min-w-0 items-center gap-3">
<Building2Icon className="size-6 shrink-0" />
<span className="truncate">{g.name}</span>
</div>
}
/>
<Tabs
className="mt-2 mb-8"
onValueChange={handleTabChange}
value={activeTab}
>
<TabsList>
{tabs.map((tab) => (
<TabsTrigger key={tab.id} value={tab.id}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<Outlet />
</PageContainer>
);
}

View File

@@ -1,244 +0,0 @@
import { useSuspenseQuery } from '@tanstack/react-query';
import { createFileRoute, Link } from '@tanstack/react-router';
import { Building2Icon, UsersIcon } from 'lucide-react';
import FullPageLoadingState from '@/components/full-page-loading-state';
import { OverviewMetricCard } from '@/components/overview/overview-metric-card';
import { WidgetHead, WidgetTitle } from '@/components/overview/overview-widget';
import { PageContainer } from '@/components/page-container';
import { PageHeader } from '@/components/page-header';
import { ProfileActivity } from '@/components/profiles/profile-activity';
import { Badge } from '@/components/ui/badge';
import { KeyValueGrid } from '@/components/ui/key-value-grid';
import { Widget, WidgetBody } from '@/components/widget';
import { useTRPC } from '@/integrations/trpc/react';
import { formatDateTime } from '@/utils/date';
import { createProjectTitle } from '@/utils/title';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId/groups_/$groupId'
)({
component: Component,
loader: async ({ context, params }) => {
await Promise.all([
context.queryClient.prefetchQuery(
context.trpc.group.byId.queryOptions({
id: params.groupId,
projectId: params.projectId,
})
),
context.queryClient.prefetchQuery(
context.trpc.group.metrics.queryOptions({
id: params.groupId,
projectId: params.projectId,
})
),
context.queryClient.prefetchQuery(
context.trpc.group.activity.queryOptions({
id: params.groupId,
projectId: params.projectId,
})
),
context.queryClient.prefetchQuery(
context.trpc.group.members.queryOptions({
id: params.groupId,
projectId: params.projectId,
})
),
]);
},
pendingComponent: FullPageLoadingState,
head: () => ({
meta: [{ title: createProjectTitle('Group') }],
}),
});
function Component() {
const { projectId, organizationId, groupId } = Route.useParams();
const trpc = useTRPC();
const group = useSuspenseQuery(
trpc.group.byId.queryOptions({ id: groupId, projectId })
);
const metrics = useSuspenseQuery(
trpc.group.metrics.queryOptions({ id: groupId, projectId })
);
const activity = useSuspenseQuery(
trpc.group.activity.queryOptions({ id: groupId, projectId })
);
const members = useSuspenseQuery(
trpc.group.members.queryOptions({ id: groupId, projectId })
);
const g = group.data;
const m = metrics.data?.[0];
if (!g) {
return (
<PageContainer>
<div className="flex flex-col items-center justify-center gap-3 py-24 text-muted-foreground">
<Building2Icon className="size-10 opacity-30" />
<p className="text-sm">Group not found</p>
</div>
</PageContainer>
);
}
const properties = g.properties as Record<string, unknown>;
return (
<PageContainer>
<PageHeader
title={
<div className="row min-w-0 items-center gap-3">
<Building2Icon className="size-6 shrink-0" />
<span className="truncate">{g.name}</span>
<Badge className="shrink-0" variant="outline">
{g.type}
</Badge>
</div>
}
>
<p className="font-mono text-muted-foreground text-sm">{g.id}</p>
</PageHeader>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{/* Metrics */}
{m && (
<div className="col-span-1 md:col-span-2">
<div className="card grid grid-cols-2 overflow-hidden rounded-md md:grid-cols-4">
<OverviewMetricCard
data={[]}
id="totalEvents"
isLoading={false}
label="Total Events"
metric={{ current: m.totalEvents, previous: null }}
unit=""
/>
<OverviewMetricCard
data={[]}
id="uniqueMembers"
isLoading={false}
label="Unique Members"
metric={{ current: m.uniqueProfiles, previous: null }}
unit=""
/>
<OverviewMetricCard
data={[]}
id="firstSeen"
isLoading={false}
label="First Seen"
metric={{
current: m.firstSeen ? new Date(m.firstSeen).getTime() : 0,
previous: null,
}}
unit="timeAgo"
/>
<OverviewMetricCard
data={[]}
id="lastSeen"
isLoading={false}
label="Last Seen"
metric={{
current: m.lastSeen ? new Date(m.lastSeen).getTime() : 0,
previous: null,
}}
unit="timeAgo"
/>
</div>
</div>
)}
{/* Properties */}
<div className="col-span-1 md:col-span-2">
<Widget className="w-full">
<WidgetHead>
<div className="title">Group Information</div>
</WidgetHead>
<KeyValueGrid
className="border-0"
columns={3}
copyable
data={[
{ name: 'id', value: g.id },
{ name: 'name', value: g.name },
{ name: 'type', value: g.type },
{
name: 'createdAt',
value: formatDateTime(new Date(g.createdAt)),
},
...Object.entries(properties)
.filter(([, v]) => v !== undefined && v !== '')
.map(([k, v]) => ({
name: k,
value: String(v),
})),
]}
/>
</Widget>
</div>
{/* Activity heatmap */}
<div className="col-span-1">
<ProfileActivity data={activity.data} />
</div>
{/* Members */}
<div className="col-span-1">
<Widget className="w-full">
<WidgetHead>
<WidgetTitle icon={UsersIcon}>Members</WidgetTitle>
</WidgetHead>
<WidgetBody>
{members.data.length === 0 ? (
<p className="py-4 text-center text-muted-foreground text-sm">
No members found
</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="py-2 text-left font-medium text-muted-foreground">
Profile
</th>
<th className="py-2 text-right font-medium text-muted-foreground">
Events
</th>
</tr>
</thead>
<tbody>
{members.data.map((member) => (
<tr
className="border-b last:border-0"
key={member.profileId}
>
<td className="py-2">
<Link
className="font-mono text-xs hover:underline"
params={{
organizationId,
projectId,
profileId: member.profileId,
}}
to="/$organizationId/$projectId/profiles/$profileId"
>
{member.profileId}
</Link>
</td>
<td className="py-2 text-right text-muted-foreground">
{member.eventCount}
</td>
</tr>
))}
</tbody>
</table>
)}
</WidgetBody>
</Widget>
</div>
</div>
</PageContainer>
);
}

View File

@@ -4,6 +4,7 @@ import { MostEvents } from '@/components/profiles/most-events';
import { PopularRoutes } from '@/components/profiles/popular-routes';
import { ProfileActivity } from '@/components/profiles/profile-activity';
import { ProfileCharts } from '@/components/profiles/profile-charts';
import { ProfileGroups } from '@/components/profiles/profile-groups';
import { ProfileMetrics } from '@/components/profiles/profile-metrics';
import { ProfileProperties } from '@/components/profiles/profile-properties';
import { useTRPC } from '@/integrations/trpc/react';
@@ -107,6 +108,17 @@ function Component() {
<ProfileProperties profile={profile.data!} />
</div>
{/* Groups - full width, only if profile belongs to groups */}
{profile.data?.groups?.length ? (
<div className="col-span-1 md:col-span-2">
<ProfileGroups
profileId={profileId}
projectId={projectId}
groups={profile.data.groups}
/>
</div>
) : null}
{/* Heatmap / Activity */}
<div className="col-span-1">
<ProfileActivity data={activity.data} />

4
apps/testbed/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules
dist
public/op1.js
.env

12
apps/testbed/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Testbed | OpenPanel SDK</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

24
apps/testbed/package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "@openpanel/testbed",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --port 3100",
"build": "tsc && vite build",
"postinstall": "node scripts/copy-op1.mjs"
},
"dependencies": {
"@openpanel/web": "workspace:*",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.13.1"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "catalog:",
"vite": "^6.0.0"
}
}

View File

@@ -0,0 +1,16 @@
import { copyFileSync, mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const src = join(__dirname, '../../public/public/op1.js');
const dest = join(__dirname, '../public/op1.js');
mkdirSync(join(__dirname, '../public'), { recursive: true });
try {
copyFileSync(src, dest);
console.log('✓ Copied op1.js to public/');
} catch (e) {
console.warn('⚠ Could not copy op1.js:', e.message);
}

218
apps/testbed/src/App.tsx Normal file
View File

@@ -0,0 +1,218 @@
import { useEffect, useState } from 'react';
import { Link, Route, Routes, useNavigate } from 'react-router-dom';
import { op } from './analytics';
import { CartPage } from './pages/Cart';
import { CheckoutPage } from './pages/Checkout';
import { LoginPage, PRESET_GROUPS } from './pages/Login';
import { ProductPage } from './pages/Product';
import { ShopPage } from './pages/Shop';
import type { CartItem, Product, User } from './types';
const PRODUCTS: Product[] = [
{ id: 'p1', name: 'Classic T-Shirt', price: 25, category: 'clothing' },
{ id: 'p2', name: 'Coffee Mug', price: 15, category: 'accessories' },
{ id: 'p3', name: 'Hoodie', price: 60, category: 'clothing' },
{ id: 'p4', name: 'Sticker Pack', price: 10, category: 'accessories' },
{ id: 'p5', name: 'Cap', price: 35, category: 'clothing' },
];
export default function App() {
const navigate = useNavigate();
const [cart, setCart] = useState<CartItem[]>([]);
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
const stored = localStorage.getItem('op_testbed_user');
if (stored) {
const u = JSON.parse(stored) as User;
setUser(u);
op.identify({
profileId: u.id,
firstName: u.firstName,
lastName: u.lastName,
email: u.email,
});
applyGroups(u);
}
op.ready();
}, []);
function applyGroups(u: User) {
op.setGroups(u.groupIds);
for (const id of u.groupIds) {
const meta = PRESET_GROUPS.find((g) => g.id === id);
console.log('meta', meta);
if (meta) {
op.setGroup(id, meta);
}
}
}
function login(u: User) {
localStorage.setItem('op_testbed_user', JSON.stringify(u));
setUser(u);
op.identify({
profileId: u.id,
firstName: u.firstName,
lastName: u.lastName,
email: u.email,
});
applyGroups(u);
op.track('user_login', { method: 'form', group_count: u.groupIds.length });
navigate('/');
}
function logout() {
localStorage.removeItem('op_testbed_user');
op.clear();
setUser(null);
}
function addToCart(product: Product) {
setCart((prev) => {
const existing = prev.find((i) => i.id === product.id);
if (existing) {
return prev.map((i) =>
i.id === product.id ? { ...i, qty: i.qty + 1 } : i
);
}
return [...prev, { ...product, qty: 1 }];
});
op.track('add_to_cart', {
product_id: product.id,
product_name: product.name,
price: product.price,
category: product.category,
});
}
function removeFromCart(id: string) {
const item = cart.find((i) => i.id === id);
if (item) {
op.track('remove_from_cart', {
product_id: item.id,
product_name: item.name,
});
}
setCart((prev) => prev.filter((i) => i.id !== id));
}
function startCheckout() {
const total = cart.reduce((sum, i) => sum + i.price * i.qty, 0);
op.track('checkout_started', {
total,
item_count: cart.reduce((sum, i) => sum + i.qty, 0),
items: cart.map((i) => i.id),
});
navigate('/checkout');
}
function pay(succeed: boolean) {
const total = cart.reduce((sum, i) => sum + i.price * i.qty, 0);
op.track('payment_attempted', { total, success: succeed });
if (succeed) {
op.revenue(total, {
items: cart.map((i) => i.id),
item_count: cart.reduce((sum, i) => sum + i.qty, 0),
});
op.track('purchase_completed', { total });
setCart([]);
navigate('/success');
} else {
op.track('purchase_failed', { total, reason: 'declined' });
navigate('/error');
}
}
const cartCount = cart.reduce((sum, i) => sum + i.qty, 0);
return (
<div className="app">
<nav className="nav">
<Link className="nav-brand" to="/">
TESTSTORE
</Link>
<div className="nav-links">
<Link to="/">Shop</Link>
<Link to="/cart">Cart ({cartCount})</Link>
{user ? (
<>
<span className="nav-user">{user.firstName}</span>
<button onClick={logout} type="button">
Logout
</button>
</>
) : (
<Link to="/login">Login</Link>
)}
</div>
</nav>
<main className="main">
<Routes>
<Route
element={<ShopPage onAddToCart={addToCart} products={PRODUCTS} />}
path="/"
/>
<Route
element={
<ProductPage onAddToCart={addToCart} products={PRODUCTS} />
}
path="/product/:id"
/>
<Route element={<LoginPage onLogin={login} />} path="/login" />
<Route
element={
<CartPage
cart={cart}
onCheckout={startCheckout}
onRemove={removeFromCart}
/>
}
path="/cart"
/>
<Route
element={<CheckoutPage cart={cart} onPay={pay} />}
path="/checkout"
/>
<Route
element={
<div className="result-page">
<div className="result-icon">[OK]</div>
<div className="result-title">Payment successful</div>
<p>Your order has been placed. Thanks for testing!</p>
<div className="result-actions">
<Link to="/">
<button className="primary" type="button">
Continue shopping
</button>
</Link>
</div>
</div>
}
path="/success"
/>
<Route
element={
<div className="result-page">
<div className="result-icon">[ERR]</div>
<div className="result-title">Payment failed</div>
<p>Card declined. Try again or go back to cart.</p>
<div className="result-actions">
<Link to="/checkout">
<button type="button">Retry</button>
</Link>
<Link to="/cart">
<button type="button">Back to cart</button>
</Link>
</div>
</div>
}
path="/error"
/>
</Routes>
</main>
</div>
);
}

View File

@@ -0,0 +1,10 @@
import { OpenPanel } from '@openpanel/web';
export const op = new OpenPanel({
clientId: import.meta.env.VITE_OPENPANEL_CLIENT_ID ?? 'testbed-client',
apiUrl: import.meta.env.VITE_OPENPANEL_API_URL ?? 'http://localhost:3333',
trackScreenViews: true,
trackOutgoingLinks: true,
trackAttributes: true,
disabled: true,
});

10
apps/testbed/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import './styles.css';
createRoot(document.getElementById('root')!).render(
<BrowserRouter>
<App />
</BrowserRouter>
);

View File

@@ -0,0 +1,63 @@
import { Link } from 'react-router-dom';
import type { CartItem } from '../types';
type Props = {
cart: CartItem[];
onRemove: (id: string) => void;
onCheckout: () => void;
};
export function CartPage({ cart, onRemove, onCheckout }: Props) {
const total = cart.reduce((sum, i) => sum + i.price * i.qty, 0);
if (cart.length === 0) {
return (
<div>
<div className="page-title">Cart</div>
<div className="cart-empty">Your cart is empty.</div>
<Link to="/"><button type="button"> Back to shop</button></Link>
</div>
);
}
return (
<div>
<div className="page-title">Cart</div>
<table className="cart-table">
<thead>
<tr>
<th>Product</th>
<th>Price</th>
<th>Qty</th>
<th>Subtotal</th>
<th></th>
</tr>
</thead>
<tbody>
{cart.map((item) => (
<tr key={item.id}>
<td>{item.name}</td>
<td>${item.price}</td>
<td>{item.qty}</td>
<td>${item.price * item.qty}</td>
<td>
<button type="button" className="danger" onClick={() => onRemove(item.id)}>
Remove
</button>
</td>
</tr>
))}
</tbody>
</table>
<div className="cart-summary">
<div className="cart-total">Total: ${total}</div>
<div className="cart-actions">
<Link to="/"><button type="button"> Shop</button></Link>
<button type="button" className="primary" onClick={onCheckout}>
Checkout
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,43 @@
import { Link } from 'react-router-dom';
import type { CartItem } from '../types';
type Props = {
cart: CartItem[];
onPay: (succeed: boolean) => void;
};
export function CheckoutPage({ cart, onPay }: Props) {
const total = cart.reduce((sum, i) => sum + i.price * i.qty, 0);
return (
<div>
<div className="page-title">Checkout</div>
<div className="checkout-form">
<div className="form-group">
<label className="form-label" htmlFor="card">Card number</label>
<input id="card" defaultValue="4242 4242 4242 4242" readOnly />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
<div className="form-group">
<label className="form-label" htmlFor="expiry">Expiry</label>
<input id="expiry" defaultValue="12/28" readOnly />
</div>
<div className="form-group">
<label className="form-label" htmlFor="cvc">CVC</label>
<input id="cvc" defaultValue="123" readOnly />
</div>
</div>
<div className="checkout-total">Total: ${total}</div>
<div className="checkout-pay-buttons">
<Link to="/cart"><button type="button"> Back</button></Link>
<button type="button" className="primary" onClick={() => onPay(true)}>
Pay ${total} (success)
</button>
<button type="button" className="danger" onClick={() => onPay(false)}>
Pay ${total} (fail)
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,186 @@
import { useState } from 'react';
import type { Group, User } from '../types';
export const PRESET_GROUPS: Group[] = [
{
type: 'company',
id: 'grp_acme',
name: 'Acme Corp',
properties: { plan: 'enterprise' },
},
{
type: 'company',
id: 'grp_globex',
name: 'Globex',
properties: { plan: 'pro' },
},
{
type: 'company',
id: 'grp_initech',
name: 'Initech',
properties: { plan: 'pro' },
},
{
type: 'company',
id: 'grp_umbrella',
name: 'Umbrella Ltd',
properties: { plan: 'enterprise' },
},
{
type: 'company',
id: 'grp_stark',
name: 'Stark Industries',
properties: { plan: 'enterprise' },
},
{
type: 'company',
id: 'grp_wayne',
name: 'Wayne Enterprises',
properties: { plan: 'pro' },
},
{
type: 'company',
id: 'grp_dunder',
name: 'Dunder Mifflin',
properties: { plan: 'free' },
},
{
type: 'company',
id: 'grp_pied',
name: 'Pied Piper',
properties: { plan: 'free' },
},
{
type: 'company',
id: 'grp_hooli',
name: 'Hooli',
properties: { plan: 'pro' },
},
{
type: 'company',
id: 'grp_vandelay',
name: 'Vandelay Industries',
properties: { plan: 'free' },
},
];
const FIRST_NAMES = ['Alice', 'Bob', 'Carol', 'Dave', 'Eve', 'Frank', 'Grace', 'Hank', 'Iris', 'Jack'];
const LAST_NAMES = ['Smith', 'Jones', 'Brown', 'Taylor', 'Wilson', 'Davis', 'Clark', 'Hall', 'Lewis', 'Young'];
function randomMock(): User {
const first = FIRST_NAMES[Math.floor(Math.random() * FIRST_NAMES.length)];
const last = LAST_NAMES[Math.floor(Math.random() * LAST_NAMES.length)];
const id = Math.random().toString(36).slice(2, 8);
return {
id: `usr_${id}`,
firstName: first,
lastName: last,
email: `${first.toLowerCase()}.${last.toLowerCase()}@example.com`,
groupIds: [],
};
}
type Props = {
onLogin: (user: User) => void;
};
export function LoginPage({ onLogin }: Props) {
const [form, setForm] = useState<User>(randomMock);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
onLogin(form);
}
function set(field: keyof User, value: string) {
setForm((prev) => ({ ...prev, [field]: value }));
}
function toggleGroup(id: string) {
setForm((prev) => ({
...prev,
groupIds: prev.groupIds.includes(id)
? prev.groupIds.filter((g) => g !== id)
: [...prev.groupIds, id],
}));
}
return (
<div>
<div className="page-title">Login</div>
<form className="login-form" onSubmit={handleSubmit}>
<div className="form-group">
<label className="form-label" htmlFor="id">
User ID
</label>
<input
id="id"
onChange={(e) => set('id', e.target.value)}
required
value={form.id}
/>
</div>
<div className="form-group">
<label className="form-label" htmlFor="firstName">
First name
</label>
<input
id="firstName"
onChange={(e) => set('firstName', e.target.value)}
required
value={form.firstName}
/>
</div>
<div className="form-group">
<label className="form-label" htmlFor="lastName">
Last name
</label>
<input
id="lastName"
onChange={(e) => set('lastName', e.target.value)}
required
value={form.lastName}
/>
</div>
<div className="form-group">
<label className="form-label" htmlFor="email">
Email
</label>
<input
id="email"
onChange={(e) => set('email', e.target.value)}
required
type="email"
value={form.email}
/>
</div>
<div className="form-group">
<div className="form-label" style={{ marginBottom: 8 }}>
Groups (optional)
</div>
<div className="group-picker">
{PRESET_GROUPS.map((group) => {
const selected = form.groupIds.includes(group.id);
return (
<button
className={selected ? 'primary' : ''}
key={group.id}
onClick={() => toggleGroup(group.id)}
type="button"
>
{group.name}
<span className="group-plan">{group.plan}</span>
</button>
);
})}
</div>
</div>
<button className="primary" style={{ width: '100%' }} type="submit">
Login
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,61 @@
import { useEffect } from 'react';
import { Link, useParams } from 'react-router-dom';
import { op } from '../analytics';
import type { Product } from '../types';
type Props = {
products: Product[];
onAddToCart: (product: Product) => void;
};
export function ProductPage({ products, onAddToCart }: Props) {
const { id } = useParams<{ id: string }>();
const product = products.find((p) => p.id === id);
useEffect(() => {
if (product) {
op.track('product_viewed', {
product_id: product.id,
product_name: product.name,
price: product.price,
category: product.category,
});
}
}, [product]);
if (!product) {
return (
<div>
<div className="page-title">Product not found</div>
<Link to="/"><button type="button"> Back to shop</button></Link>
</div>
);
}
return (
<div>
<div style={{ marginBottom: 16 }}>
<Link to="/"> Back to shop</Link>
</div>
<div className="product-detail">
<div className="product-detail-img">[img]</div>
<div className="product-detail-info">
<div className="product-card-category">{product.category}</div>
<div className="product-detail-name">{product.name}</div>
<div className="product-detail-price">${product.price}</div>
<p className="product-detail-desc">
A high quality {product.name.toLowerCase()} for testing purposes.
Lorem ipsum dolor sit amet consectetur adipiscing elit.
</p>
<button
type="button"
className="primary"
onClick={() => onAddToCart(product)}
>
Add to cart
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,36 @@
import { Link } from 'react-router-dom';
import type { Product } from '../types';
type Props = {
products: Product[];
onAddToCart: (product: Product) => void;
};
export function ShopPage({ products, onAddToCart }: Props) {
return (
<div>
<div className="page-title">Products</div>
<div className="product-grid">
{products.map((product) => (
<div key={product.id} className="product-card">
<div className="product-card-category">{product.category}</div>
<Link to={`/product/${product.id}`} className="product-card-name">
{product.name}
</Link>
<div className="product-card-price">${product.price}</div>
<div className="product-card-actions">
<button
type="button"
className="primary"
style={{ width: '100%' }}
onClick={() => onAddToCart(product)}
>
Add to cart
</button>
</div>
</div>
))}
</div>
</div>
);
}

358
apps/testbed/src/styles.css Normal file
View File

@@ -0,0 +1,358 @@
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--border: 1px solid #999;
--bg: #f5f5f5;
--surface: #fff;
--text: #111;
--muted: #666;
--accent: #1a1a1a;
--gap: 16px;
}
body {
font-family: monospace;
font-size: 14px;
background: var(--bg);
color: var(--text);
line-height: 1.5;
}
button, input, select {
font-family: monospace;
font-size: 14px;
}
button {
cursor: pointer;
border: var(--border);
background: var(--surface);
padding: 6px 14px;
}
button:hover {
background: var(--accent);
color: #fff;
}
button.primary {
background: var(--accent);
color: #fff;
}
button.primary:hover {
opacity: 0.85;
}
button.danger {
border-color: #c00;
color: #c00;
}
button.danger:hover {
background: #c00;
color: #fff;
}
input {
border: var(--border);
background: var(--surface);
padding: 6px 10px;
width: 100%;
}
input:focus {
outline: 2px solid var(--accent);
}
/* Layout */
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.nav {
border-bottom: var(--border);
padding: 12px var(--gap);
background: var(--surface);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--gap);
}
.nav-brand {
font-weight: bold;
font-size: 16px;
cursor: pointer;
letter-spacing: 1px;
text-decoration: none;
color: inherit;
}
.nav-links a {
color: inherit;
text-decoration: underline;
}
.nav-links a:hover {
color: var(--muted);
}
.nav-links {
display: flex;
align-items: center;
gap: 16px;
}
.nav-links span {
cursor: default;
}
.nav-user {
text-decoration: none !important;
cursor: default !important;
color: var(--muted);
}
.main {
flex: 1;
padding: var(--gap);
max-width: 900px;
margin: 0 auto;
width: 100%;
}
/* Page common */
.page-title {
font-size: 20px;
font-weight: bold;
margin-bottom: 20px;
padding-bottom: 8px;
border-bottom: var(--border);
}
/* Shop */
.product-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: var(--gap);
}
.product-card {
border: var(--border);
background: var(--surface);
padding: var(--gap);
display: flex;
flex-direction: column;
gap: 8px;
}
.product-card-name {
font-weight: bold;
}
.product-card-category {
color: var(--muted);
font-size: 12px;
}
.product-card-price {
font-size: 16px;
}
.product-card-actions {
margin-top: auto;
}
/* Cart */
.cart-empty {
color: var(--muted);
padding: 40px 0;
text-align: center;
}
.cart-table {
width: 100%;
border-collapse: collapse;
margin-bottom: var(--gap);
}
.cart-table th,
.cart-table td {
border: var(--border);
padding: 8px 12px;
text-align: left;
}
.cart-table th {
background: var(--bg);
}
.cart-summary {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-top: var(--border);
margin-top: 8px;
}
.cart-total {
font-size: 16px;
font-weight: bold;
}
.cart-actions {
display: flex;
gap: 8px;
}
/* Checkout */
.checkout-form {
border: var(--border);
background: var(--surface);
padding: var(--gap);
max-width: 400px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 12px;
}
.form-label {
font-size: 12px;
color: var(--muted);
text-transform: uppercase;
}
.checkout-total {
margin: 16px 0;
padding: 12px;
border: var(--border);
background: var(--bg);
font-weight: bold;
}
.checkout-pay-buttons {
display: flex;
gap: 8px;
margin-top: 16px;
}
/* Login */
.login-form {
border: var(--border);
background: var(--surface);
padding: var(--gap);
max-width: 360px;
}
/* Product detail */
.product-detail {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 32px;
max-width: 700px;
}
.product-detail-img {
border: var(--border);
background: var(--surface);
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 48px;
color: var(--muted);
}
.product-detail-info {
display: flex;
flex-direction: column;
gap: 12px;
}
.product-detail-name {
font-size: 22px;
font-weight: bold;
}
.product-detail-price {
font-size: 20px;
}
.product-detail-desc {
color: var(--muted);
line-height: 1.6;
}
.product-card-name {
font-weight: bold;
color: inherit;
}
/* Group picker */
.group-picker {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.group-picker button {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
font-size: 13px;
}
.group-plan {
font-size: 11px;
opacity: 0.6;
border-left: 1px solid currentColor;
padding-left: 6px;
}
/* Result pages */
.result-page {
text-align: center;
padding: 60px 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.result-icon {
font-size: 48px;
line-height: 1;
}
.result-title {
font-size: 22px;
font-weight: bold;
}
.result-actions {
display: flex;
gap: 8px;
margin-top: 8px;
}

23
apps/testbed/src/types.ts Normal file
View File

@@ -0,0 +1,23 @@
export type Product = {
id: string;
name: string;
price: number;
category: string;
};
export type CartItem = Product & { qty: number };
export type User = {
id: string;
firstName: string;
lastName: string;
email: string;
groupIds: string[];
};
export type Group = {
id: string;
name: string;
type: string;
properties: Record<string, string>;
};

View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,9 @@
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [react()],
define: {
'process.env': {},
},
});

View File

@@ -312,6 +312,7 @@ describe('incomingEvent', () => {
screen_views: [],
sign: 1,
version: 1,
groups: [],
} satisfies IClickhouseSession);
await incomingEvent(jobData);