feat: group analytics
* wip * wip * wip * wip * wip * add buffer * wip * wip * fixes * fix * wip * group validation * fix group issues * docs: add groups
This commit is contained in:
committed by
GitHub
parent
88a2d876ce
commit
11e9ecac1a
@@ -63,6 +63,7 @@ async function main() {
|
||||
imported_at: null,
|
||||
sdk_name: 'test-script',
|
||||
sdk_version: '1.0.0',
|
||||
groups: [],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
|
||||
import {
|
||||
getProfileById,
|
||||
getSalts,
|
||||
groupBuffer,
|
||||
replayBuffer,
|
||||
upsertProfile,
|
||||
} from '@openpanel/db';
|
||||
@@ -13,7 +14,9 @@ import {
|
||||
} from '@openpanel/queue';
|
||||
import { getRedisCache } from '@openpanel/redis';
|
||||
import {
|
||||
type IAssignGroupPayload,
|
||||
type IDecrementPayload,
|
||||
type IGroupPayload,
|
||||
type IIdentifyPayload,
|
||||
type IIncrementPayload,
|
||||
type IReplayPayload,
|
||||
@@ -218,6 +221,7 @@ async function handleTrack(
|
||||
headers,
|
||||
event: {
|
||||
...payload,
|
||||
groups: payload.groups ?? [],
|
||||
timestamp: timestamp.value,
|
||||
isTimestampFromThePast: timestamp.isFromPast,
|
||||
},
|
||||
@@ -333,6 +337,36 @@ async function handleReplay(
|
||||
await replayBuffer.add(row);
|
||||
}
|
||||
|
||||
async function handleGroup(
|
||||
payload: IGroupPayload,
|
||||
context: TrackContext
|
||||
): Promise<void> {
|
||||
const { id, type, name, properties = {} } = payload;
|
||||
await groupBuffer.add({
|
||||
id,
|
||||
projectId: context.projectId,
|
||||
type,
|
||||
name,
|
||||
properties,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleAssignGroup(
|
||||
payload: IAssignGroupPayload,
|
||||
context: TrackContext
|
||||
): Promise<void> {
|
||||
const profileId = payload.profileId ?? context.deviceId;
|
||||
if (!profileId) {
|
||||
return;
|
||||
}
|
||||
await upsertProfile({
|
||||
id: String(profileId),
|
||||
projectId: context.projectId,
|
||||
isExternal: !!payload.profileId,
|
||||
groups: payload.groupIds,
|
||||
});
|
||||
}
|
||||
|
||||
export async function handler(
|
||||
request: FastifyRequest<{
|
||||
Body: ITrackHandlerPayload;
|
||||
@@ -381,6 +415,12 @@ export async function handler(
|
||||
case 'replay':
|
||||
await handleReplay(validatedBody.payload, context);
|
||||
break;
|
||||
case 'group':
|
||||
await handleGroup(validatedBody.payload, context);
|
||||
break;
|
||||
case 'assign_group':
|
||||
await handleAssignGroup(validatedBody.payload, context);
|
||||
break;
|
||||
default:
|
||||
return reply.status(400).send({
|
||||
status: 400,
|
||||
|
||||
@@ -68,6 +68,34 @@ app.listen(3000, () => {
|
||||
- `trackRequest` - A function that returns `true` if the request should be tracked.
|
||||
- `getProfileId` - A function that returns the profile ID of the user making the request.
|
||||
|
||||
## Working with Groups
|
||||
|
||||
Groups let you track analytics at the account or company level. Since Express is a backend SDK, you can upsert groups and assign users from your route handlers.
|
||||
|
||||
See the [Groups guide](/docs/get-started/groups) for the full walkthrough.
|
||||
|
||||
```ts
|
||||
app.post('/login', async (req, res) => {
|
||||
const user = await loginUser(req.body);
|
||||
|
||||
// Identify the user
|
||||
req.op.identify({ profileId: user.id, email: user.email });
|
||||
|
||||
// Create/update the group entity
|
||||
req.op.upsertGroup({
|
||||
id: user.organizationId,
|
||||
type: 'company',
|
||||
name: user.organizationName,
|
||||
properties: { plan: user.plan },
|
||||
});
|
||||
|
||||
// Assign the user to the group
|
||||
req.op.setGroup(user.organizationId);
|
||||
|
||||
res.json({ ok: true });
|
||||
});
|
||||
```
|
||||
|
||||
## Typescript
|
||||
|
||||
If `req.op` is not typed you can extend the `Request` interface.
|
||||
|
||||
@@ -116,9 +116,38 @@ op.decrement({
|
||||
});
|
||||
```
|
||||
|
||||
### Working with Groups
|
||||
|
||||
Groups let you track analytics at the account or company level. See the [Groups guide](/docs/get-started/groups) for the full walkthrough.
|
||||
|
||||
**Create or update a group:**
|
||||
|
||||
```js title="index.js"
|
||||
import { op } from './op.ts'
|
||||
|
||||
op.upsertGroup({
|
||||
id: 'org_acme',
|
||||
type: 'company',
|
||||
name: 'Acme Inc',
|
||||
properties: { plan: 'enterprise' },
|
||||
});
|
||||
```
|
||||
|
||||
**Assign the current user to a group** (call after `identify`):
|
||||
|
||||
```js title="index.js"
|
||||
import { op } from './op.ts'
|
||||
|
||||
op.setGroup('org_acme');
|
||||
// or multiple groups:
|
||||
op.setGroups(['org_acme', 'team_eng']);
|
||||
```
|
||||
|
||||
Once set, all subsequent `track()` calls will automatically include the group IDs.
|
||||
|
||||
### Clearing User Data
|
||||
|
||||
To clear the current user's data:
|
||||
To clear the current user's data (including groups):
|
||||
|
||||
```js title="index.js"
|
||||
import { op } from './op.ts'
|
||||
|
||||
@@ -227,9 +227,32 @@ useOpenPanel().decrement({
|
||||
});
|
||||
```
|
||||
|
||||
### Working with Groups
|
||||
|
||||
Groups let you track analytics at the account or company level. See the [Groups guide](/docs/get-started/groups) for the full walkthrough.
|
||||
|
||||
**Create or update a group:**
|
||||
|
||||
```tsx title="app/login/page.tsx"
|
||||
useOpenPanel().upsertGroup({
|
||||
id: 'org_acme',
|
||||
type: 'company',
|
||||
name: 'Acme Inc',
|
||||
properties: { plan: 'enterprise' },
|
||||
});
|
||||
```
|
||||
|
||||
**Assign the current user to a group** (call after `identify`):
|
||||
|
||||
```tsx title="app/login/page.tsx"
|
||||
useOpenPanel().setGroup('org_acme');
|
||||
```
|
||||
|
||||
Once set, all subsequent `track()` calls will automatically include the group IDs.
|
||||
|
||||
### Clearing User Data
|
||||
|
||||
To clear the current user's data:
|
||||
To clear the current user's data (including groups):
|
||||
|
||||
```js title="index.js"
|
||||
useOpenPanel().clear()
|
||||
|
||||
@@ -174,9 +174,37 @@ function MyComponent() {
|
||||
}
|
||||
```
|
||||
|
||||
### Working with Groups
|
||||
|
||||
Groups let you track analytics at the account or company level. See the [Groups guide](/docs/get-started/groups) for the full walkthrough.
|
||||
|
||||
```tsx
|
||||
import { op } from '@/openpanel';
|
||||
|
||||
function LoginComponent() {
|
||||
const handleLogin = async (user: User) => {
|
||||
// 1. Identify the user
|
||||
op.identify({ profileId: user.id, email: user.email });
|
||||
|
||||
// 2. Create/update the group entity (only when data changes)
|
||||
op.upsertGroup({
|
||||
id: user.organizationId,
|
||||
type: 'company',
|
||||
name: user.organizationName,
|
||||
properties: { plan: user.plan },
|
||||
});
|
||||
|
||||
// 3. Link the user to their group — tags all future events
|
||||
op.setGroup(user.organizationId);
|
||||
};
|
||||
|
||||
return <button onClick={() => handleLogin(user)}>Login</button>;
|
||||
}
|
||||
```
|
||||
|
||||
### Clearing User Data
|
||||
|
||||
To clear the current user's data:
|
||||
To clear the current user's data (including groups):
|
||||
|
||||
```tsx
|
||||
import { op } from '@/openpanel';
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
208
apps/public/content/docs/get-started/groups.mdx
Normal file
208
apps/public/content/docs/get-started/groups.mdx
Normal file
@@ -0,0 +1,208 @@
|
||||
---
|
||||
title: Groups
|
||||
description: Track analytics at the account, company, or team level — not just individual users.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
Groups let you associate users with a shared entity — like a company, workspace, or team — and analyze behavior at that level. Instead of asking "what did Jane do?", you can ask "what is Acme Inc doing?"
|
||||
|
||||
This is especially useful for B2B SaaS products where a single paying account has many users.
|
||||
|
||||
## How Groups work
|
||||
|
||||
There are two separate concepts:
|
||||
|
||||
1. **The group entity** — created/updated with `upsertGroup()`. Stores metadata about the group (name, plan, etc.).
|
||||
2. **Group membership** — set with `setGroup()` / `setGroups()`. Links a user profile to one or more groups, and automatically attaches those group IDs to every subsequent `track()` call.
|
||||
|
||||
## Creating or updating a group
|
||||
|
||||
Call `upsertGroup()` to create a group or update its properties. The group is identified by its `id` and `type`.
|
||||
|
||||
```typescript
|
||||
op.upsertGroup({
|
||||
id: 'org_acme', // Your group's unique ID
|
||||
type: 'company', // Group type (company, workspace, team, etc.)
|
||||
name: 'Acme Inc', // Display name
|
||||
properties: {
|
||||
plan: 'enterprise',
|
||||
seats: 25,
|
||||
industry: 'logistics',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Group payload
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `id` | `string` | Yes | Unique identifier for the group |
|
||||
| `type` | `string` | Yes | Category of group (e.g. `"company"`, `"workspace"`) |
|
||||
| `name` | `string` | Yes | Human-readable display name |
|
||||
| `properties` | `object` | No | Custom metadata about the group |
|
||||
|
||||
## Managing groups in the dashboard
|
||||
|
||||
The easiest way to create, edit, and delete groups is directly in the OpenPanel dashboard. Navigate to your project and open the **Groups** section — from there you can manage group names, types, and properties without touching any code.
|
||||
|
||||
`upsertGroup()` is the right tool when your group properties are **dynamic and driven by your own data** — for example, syncing a customer's current plan, seat count, or MRR from your backend at login time.
|
||||
|
||||
<Callout>
|
||||
A good rule of thumb: call `upsertGroup()` on login or when group properties change — not on every request or page view. If you find yourself calling it frequently with the same data, the dashboard is probably the better place to manage that group.
|
||||
</Callout>
|
||||
|
||||
## Assigning a user to a group
|
||||
|
||||
After identifying a user, call `setGroup()` to link them to a group. This also attaches the group ID to all future `track()` calls for the current session.
|
||||
|
||||
```typescript
|
||||
// After login
|
||||
op.identify({ profileId: 'user_123' });
|
||||
|
||||
// Link the user to their organization
|
||||
op.setGroup('org_acme');
|
||||
```
|
||||
|
||||
For users that belong to multiple groups:
|
||||
|
||||
```typescript
|
||||
op.setGroups(['org_acme', 'team_engineering']);
|
||||
```
|
||||
|
||||
<Callout>
|
||||
`setGroup()` and `setGroups()` persist group IDs on the SDK instance. All subsequent `track()` calls will automatically include these group IDs until `clear()` is called.
|
||||
</Callout>
|
||||
|
||||
## Full login flow example
|
||||
|
||||
`setGroup()` doesn't require the group to exist first. You can call it with just an ID — events will be tagged with that group ID, and you can create the group later in the dashboard or via `upsertGroup()`.
|
||||
|
||||
```typescript
|
||||
// 1. Identify the user
|
||||
op.identify({
|
||||
profileId: 'user_123',
|
||||
firstName: 'Jane',
|
||||
email: 'jane@acme.com',
|
||||
});
|
||||
|
||||
// 2. Assign the user to the group — the group doesn't need to exist yet
|
||||
op.setGroup('org_acme');
|
||||
|
||||
// 3. All subsequent events are now tagged with the group
|
||||
op.track('dashboard_viewed'); // → includes groups: ['org_acme']
|
||||
op.track('report_exported'); // → includes groups: ['org_acme']
|
||||
```
|
||||
|
||||
If you want to sync dynamic group properties from your own data (plan, seats, MRR), add `upsertGroup()` to the flow:
|
||||
|
||||
```typescript
|
||||
op.identify({ profileId: 'user_123', email: 'jane@acme.com' });
|
||||
|
||||
// Sync group metadata from your backend
|
||||
op.upsertGroup({
|
||||
id: 'org_acme',
|
||||
type: 'company',
|
||||
name: 'Acme Inc',
|
||||
properties: { plan: 'pro' },
|
||||
});
|
||||
|
||||
op.setGroup('org_acme');
|
||||
```
|
||||
|
||||
## Per-event group override
|
||||
|
||||
You can attach group IDs to a specific event without affecting the SDK's persistent group state:
|
||||
|
||||
```typescript
|
||||
op.track('file_shared', {
|
||||
filename: 'q4-report.pdf',
|
||||
groups: ['org_acme', 'org_partner'], // Only applies to this event
|
||||
});
|
||||
```
|
||||
|
||||
Groups passed in `track()` are **merged** with any groups already set on the SDK instance.
|
||||
|
||||
## Clearing groups on logout
|
||||
|
||||
`clear()` resets the profile, device, session, and all groups. Always call it on logout.
|
||||
|
||||
```typescript
|
||||
function handleLogout() {
|
||||
op.clear();
|
||||
// redirect to login...
|
||||
}
|
||||
```
|
||||
|
||||
## Common patterns
|
||||
|
||||
### B2B SaaS — company accounts
|
||||
|
||||
```typescript
|
||||
// On login
|
||||
op.identify({ profileId: user.id, email: user.email });
|
||||
op.upsertGroup({
|
||||
id: user.organizationId,
|
||||
type: 'company',
|
||||
name: user.organizationName,
|
||||
properties: { plan: user.plan, mrr: user.mrr },
|
||||
});
|
||||
op.setGroup(user.organizationId);
|
||||
```
|
||||
|
||||
### Multi-tenant — workspaces
|
||||
|
||||
```typescript
|
||||
// When user switches workspace
|
||||
op.upsertGroup({
|
||||
id: workspace.id,
|
||||
type: 'workspace',
|
||||
name: workspace.name,
|
||||
});
|
||||
op.setGroup(workspace.id);
|
||||
```
|
||||
|
||||
### Teams within a company
|
||||
|
||||
```typescript
|
||||
// User belongs to a company and a specific team
|
||||
op.setGroups([user.organizationId, user.teamId]);
|
||||
```
|
||||
|
||||
## API reference
|
||||
|
||||
### `upsertGroup(payload)`
|
||||
|
||||
Creates the group if it doesn't exist, or merges properties into the existing group.
|
||||
|
||||
```typescript
|
||||
op.upsertGroup({
|
||||
id: string; // Required
|
||||
type: string; // Required
|
||||
name: string; // Required
|
||||
properties?: Record<string, unknown>;
|
||||
});
|
||||
```
|
||||
|
||||
### `setGroup(groupId)`
|
||||
|
||||
Adds a single group ID to the SDK's internal group list and sends an `assign_group` event to link the current profile to that group.
|
||||
|
||||
```typescript
|
||||
op.setGroup('org_acme');
|
||||
```
|
||||
|
||||
### `setGroups(groupIds)`
|
||||
|
||||
Same as `setGroup()` but for multiple group IDs at once.
|
||||
|
||||
```typescript
|
||||
op.setGroups(['org_acme', 'team_engineering']);
|
||||
```
|
||||
|
||||
## What to avoid
|
||||
|
||||
- **Calling `upsertGroup()` on every event or page view** — call it on login or when group properties actually change. For static group management, use the dashboard instead.
|
||||
- **Not calling `setGroup()` after `identify()`** — without it, events won't be tagged with the group and you won't see group-level data in the dashboard.
|
||||
- **Forgetting `clear()` on logout** — groups persist on the SDK instance, so a new user logging in on the same session could inherit the previous user's groups.
|
||||
- **Using `upsertGroup()` to link a user to a group** — `upsertGroup()` manages the group entity only. Use `setGroup()` to link a user profile to it.
|
||||
@@ -3,6 +3,7 @@
|
||||
"install-openpanel",
|
||||
"track-events",
|
||||
"identify-users",
|
||||
"groups",
|
||||
"revenue-tracking"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
130
apps/start/src/components/groups/group-member-growth.tsx
Normal file
130
apps/start/src/components/groups/group-member-growth.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { TrendingUpIcon } from 'lucide-react';
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
Tooltip as RechartTooltip,
|
||||
ResponsiveContainer,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { WidgetHead, WidgetTitle } from '../overview/overview-widget';
|
||||
import {
|
||||
useXAxisProps,
|
||||
useYAxisProps,
|
||||
} from '@/components/report-chart/common/axis';
|
||||
import { Widget, WidgetBody } from '@/components/widget';
|
||||
import { useFormatDateInterval } from '@/hooks/use-format-date-interval';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
|
||||
type Props = {
|
||||
data: { date: string; count: number }[];
|
||||
};
|
||||
|
||||
function Tooltip(props: any) {
|
||||
const number = useNumber();
|
||||
const formatDate = useFormatDateInterval({ interval: 'day', short: false });
|
||||
const payload = props.payload?.[0]?.payload;
|
||||
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-w-[160px] flex-col gap-2 rounded-xl border bg-card p-3 shadow-xl">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{formatDate(new Date(payload.timestamp))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="h-10 w-1 rounded-full"
|
||||
style={{ background: getChartColor(0) }}
|
||||
/>
|
||||
<div className="col gap-1">
|
||||
<div className="text-muted-foreground text-sm">Total members</div>
|
||||
<div
|
||||
className="font-semibold text-lg"
|
||||
style={{ color: getChartColor(0) }}
|
||||
>
|
||||
{number.format(payload.cumulative)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{payload.count > 0 && (
|
||||
<div className="text-muted-foreground text-xs">
|
||||
+{number.format(payload.count)} new
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GroupMemberGrowth({ data }: Props) {
|
||||
const xAxisProps = useXAxisProps({ interval: 'day' });
|
||||
const yAxisProps = useYAxisProps({});
|
||||
const color = getChartColor(0);
|
||||
|
||||
let cumulative = 0;
|
||||
const chartData = data.map((item) => {
|
||||
cumulative += item.count;
|
||||
return {
|
||||
date: item.date,
|
||||
timestamp: new Date(item.date).getTime(),
|
||||
count: item.count,
|
||||
cumulative,
|
||||
};
|
||||
});
|
||||
|
||||
const gradientId = 'memberGrowthGradient';
|
||||
|
||||
return (
|
||||
<Widget className="w-full">
|
||||
<WidgetHead>
|
||||
<WidgetTitle icon={TrendingUpIcon}>Member growth</WidgetTitle>
|
||||
</WidgetHead>
|
||||
<WidgetBody>
|
||||
{data.length === 0 ? (
|
||||
<p className="py-4 text-center text-muted-foreground text-sm">
|
||||
No data yet
|
||||
</p>
|
||||
) : (
|
||||
<div className="h-[200px] w-full">
|
||||
<ResponsiveContainer>
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor={color} stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<RechartTooltip
|
||||
content={<Tooltip />}
|
||||
cursor={{ stroke: color, strokeOpacity: 0.3 }}
|
||||
/>
|
||||
<Area
|
||||
dataKey="cumulative"
|
||||
dot={false}
|
||||
fill={`url(#${gradientId})`}
|
||||
isAnimationActive={false}
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
/>
|
||||
<XAxis {...xAxisProps} />
|
||||
<YAxis {...yAxisProps} />
|
||||
<CartesianGrid
|
||||
className="stroke-border"
|
||||
horizontal={true}
|
||||
strokeDasharray="3 3"
|
||||
strokeOpacity={0.5}
|
||||
vertical={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
);
|
||||
}
|
||||
76
apps/start/src/components/groups/table/columns.tsx
Normal file
76
apps/start/src/components/groups/table/columns.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
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 type IServiceGroupWithStats = IServiceGroup & {
|
||||
memberCount: number;
|
||||
lastActiveAt: Date | null;
|
||||
};
|
||||
|
||||
export function useGroupColumns(): ColumnDef<IServiceGroupWithStats>[] {
|
||||
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: 'memberCount',
|
||||
header: 'Members',
|
||||
cell: ({ row }) => (
|
||||
<span className="tabular-nums">{row.original.memberCount}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'lastActiveAt',
|
||||
header: 'Last active',
|
||||
size: ColumnCreatedAt.size,
|
||||
cell: ({ row }) =>
|
||||
row.original.lastActiveAt ? (
|
||||
<ColumnCreatedAt>{row.original.lastActiveAt}</ColumnCreatedAt>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'createdAt',
|
||||
header: 'Created',
|
||||
size: ColumnCreatedAt.size,
|
||||
cell: ({ row }) => (
|
||||
<ColumnCreatedAt>{row.original.createdAt}</ColumnCreatedAt>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
114
apps/start/src/components/groups/table/index.tsx
Normal file
114
apps/start/src/components/groups/table/index.tsx
Normal 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 { type IServiceGroupWithStats, 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 IServiceGroupWithStats[];
|
||||
|
||||
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.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<IServiceGroupWithStats>;
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -242,6 +242,7 @@ export default function BillingUsage({ organization }: Props) {
|
||||
<XAxis {...xAxisProps} dataKey="date" />
|
||||
<YAxis {...yAxisProps} domain={[0, 'dataMax']} />
|
||||
<CartesianGrid
|
||||
className="stroke-border"
|
||||
horizontal={true}
|
||||
strokeDasharray="3 3"
|
||||
strokeOpacity={0.5}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Widget } from '@/components/widget';
|
||||
import { ZapIcon } from 'lucide-react';
|
||||
import { Widget, WidgetEmptyState } from '@/components/widget';
|
||||
import { WidgetHead, WidgetTitle } from '../overview/overview-widget';
|
||||
|
||||
type Props = {
|
||||
@@ -6,28 +7,32 @@ type Props = {
|
||||
};
|
||||
|
||||
export const MostEvents = ({ data }: Props) => {
|
||||
const max = Math.max(...data.map((item) => item.count));
|
||||
const max = data.length > 0 ? Math.max(...data.map((item) => item.count)) : 0;
|
||||
return (
|
||||
<Widget className="w-full">
|
||||
<WidgetHead>
|
||||
<WidgetTitle>Popular events</WidgetTitle>
|
||||
</WidgetHead>
|
||||
<div className="flex flex-col gap-1 p-1">
|
||||
{data.slice(0, 5).map((item) => (
|
||||
<div key={item.name} className="relative px-3 py-2">
|
||||
<div
|
||||
className="absolute bottom-0 left-0 top-0 rounded bg-def-200"
|
||||
style={{
|
||||
width: `${(item.count / max) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
<div className="relative flex justify-between ">
|
||||
<div>{item.name}</div>
|
||||
<div>{item.count}</div>
|
||||
{data.length === 0 ? (
|
||||
<WidgetEmptyState icon={ZapIcon} text="No events yet" />
|
||||
) : (
|
||||
<div className="flex flex-col gap-1 p-1">
|
||||
{data.slice(0, 5).map((item) => (
|
||||
<div key={item.name} className="relative px-3 py-2">
|
||||
<div
|
||||
className="absolute bottom-0 left-0 top-0 rounded bg-def-200"
|
||||
style={{
|
||||
width: `${(item.count / max) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
<div className="relative flex justify-between ">
|
||||
<div>{item.name}</div>
|
||||
<div>{item.count}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Widget>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Widget } from '@/components/widget';
|
||||
import { RouteIcon } from 'lucide-react';
|
||||
import { Widget, WidgetEmptyState } from '@/components/widget';
|
||||
import { WidgetHead, WidgetTitle } from '../overview/overview-widget';
|
||||
|
||||
type Props = {
|
||||
@@ -6,28 +7,32 @@ type Props = {
|
||||
};
|
||||
|
||||
export const PopularRoutes = ({ data }: Props) => {
|
||||
const max = Math.max(...data.map((item) => item.count));
|
||||
const max = data.length > 0 ? Math.max(...data.map((item) => item.count)) : 0;
|
||||
return (
|
||||
<Widget className="w-full">
|
||||
<WidgetHead>
|
||||
<WidgetTitle>Most visted pages</WidgetTitle>
|
||||
</WidgetHead>
|
||||
<div className="flex flex-col gap-1 p-1">
|
||||
{data.slice(0, 5).map((item) => (
|
||||
<div key={item.path} className="relative px-3 py-2">
|
||||
<div
|
||||
className="absolute bottom-0 left-0 top-0 rounded bg-def-200"
|
||||
style={{
|
||||
width: `${(item.count / max) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
<div className="relative flex justify-between ">
|
||||
<div>{item.path}</div>
|
||||
<div>{item.count}</div>
|
||||
{data.length === 0 ? (
|
||||
<WidgetEmptyState icon={RouteIcon} text="No pages visited yet" />
|
||||
) : (
|
||||
<div className="flex flex-col gap-1 p-1">
|
||||
{data.slice(0, 5).map((item) => (
|
||||
<div key={item.path} className="relative px-3 py-2">
|
||||
<div
|
||||
className="absolute bottom-0 left-0 top-0 rounded bg-def-200"
|
||||
style={{
|
||||
width: `${(item.count / max) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
<div className="relative flex justify-between ">
|
||||
<div>{item.path}</div>
|
||||
<div>{item.count}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Widget>
|
||||
);
|
||||
};
|
||||
|
||||
43
apps/start/src/components/profiles/profile-groups.tsx
Normal file
43
apps/start/src/components/profiles/profile-groups.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { ProjectLink } from '@/components/links';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { UsersIcon } from 'lucide-react';
|
||||
|
||||
interface 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 === 0 || !query.data?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="flex shrink-0 items-center gap-1.5 text-muted-foreground text-xs">
|
||||
<UsersIcon className="size-3.5" />
|
||||
Groups
|
||||
</span>
|
||||
{query.data.map((group) => (
|
||||
<ProjectLink
|
||||
key={group.id}
|
||||
href={`/groups/${encodeURIComponent(group.id)}`}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border bg-muted/50 px-2.5 py-1 text-xs transition-colors hover:bg-muted"
|
||||
>
|
||||
<span className="font-medium">{group.name}</span>
|
||||
<span className="text-muted-foreground">{group.type}</span>
|
||||
</ProjectLink>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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') {
|
||||
|
||||
@@ -166,7 +166,8 @@ export function Tables({
|
||||
metric: 'sum',
|
||||
options: funnelOptions,
|
||||
},
|
||||
stepIndex, // Pass the step index for funnel queries
|
||||
stepIndex,
|
||||
breakdownValues: breakdowns,
|
||||
});
|
||||
};
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { chartSegments } from '@openpanel/constants';
|
||||
import { type IChartEventSegment, mapKeys } from '@openpanel/validation';
|
||||
import {
|
||||
ActivityIcon,
|
||||
Building2Icon,
|
||||
ClockIcon,
|
||||
EqualApproximatelyIcon,
|
||||
type LucideIcon,
|
||||
@@ -10,10 +13,7 @@ import {
|
||||
UserCheckIcon,
|
||||
UsersIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { chartSegments } from '@openpanel/constants';
|
||||
import { type IChartEventSegment, mapKeys } from '@openpanel/validation';
|
||||
|
||||
import { Button } from '../ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
interface ReportChartTypeProps {
|
||||
className?: string;
|
||||
@@ -46,6 +45,7 @@ export function ReportSegment({
|
||||
event: ActivityIcon,
|
||||
user: UsersIcon,
|
||||
session: ClockIcon,
|
||||
group: Building2Icon,
|
||||
user_average: UserCheck2Icon,
|
||||
one_event_per_user: UserCheckIcon,
|
||||
property_sum: SigmaIcon,
|
||||
@@ -58,9 +58,9 @@ export function ReportSegment({
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
icon={Icons[value]}
|
||||
className={cn('justify-start text-sm', className)}
|
||||
icon={Icons[value]}
|
||||
variant="outline"
|
||||
>
|
||||
{items.find((item) => item.value === value)?.label}
|
||||
</Button>
|
||||
@@ -74,13 +74,13 @@ export function ReportSegment({
|
||||
const Icon = Icons[item.value];
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
className="group"
|
||||
key={item.value}
|
||||
onClick={() => onChange(item.value)}
|
||||
className="group"
|
||||
>
|
||||
{item.label}
|
||||
<DropdownMenuShortcut>
|
||||
<Icon className="size-4 group-hover:text-blue-500 group-hover:scale-125 transition-all group-hover:rotate-12" />
|
||||
<Icon className="size-4 transition-all group-hover:rotate-12 group-hover:scale-125 group-hover:text-blue-500" />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
import type { IChartEvent } from '@openpanel/validation';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
Building2Icon,
|
||||
DatabaseIcon,
|
||||
UserIcon,
|
||||
} from 'lucide-react';
|
||||
import VirtualList from 'rc-virtual-list';
|
||||
import { type Dispatch, type SetStateAction, useEffect, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -10,11 +21,7 @@ import {
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
import { useEventProperties } from '@/hooks/use-event-properties';
|
||||
import type { IChartEvent } from '@openpanel/validation';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { ArrowLeftIcon, DatabaseIcon, UserIcon } from 'lucide-react';
|
||||
import VirtualList from 'rc-virtual-list';
|
||||
import { type Dispatch, type SetStateAction, useEffect, useState } from 'react';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
|
||||
interface PropertiesComboboxProps {
|
||||
event?: IChartEvent;
|
||||
@@ -40,15 +47,15 @@ function SearchHeader({
|
||||
return (
|
||||
<div className="row items-center gap-1">
|
||||
{!!onBack && (
|
||||
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||
<Button onClick={onBack} size="icon" variant="ghost">
|
||||
<ArrowLeftIcon className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Input
|
||||
autoFocus
|
||||
onChange={(e) => onSearch(e.target.value)}
|
||||
placeholder="Search"
|
||||
value={value}
|
||||
onChange={(e) => onSearch(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -62,18 +69,24 @@ export function PropertiesCombobox({
|
||||
exclude = [],
|
||||
}: PropertiesComboboxProps) {
|
||||
const { projectId } = useAppParams();
|
||||
const trpc = useTRPC();
|
||||
const [open, setOpen] = useState(false);
|
||||
const properties = useEventProperties({
|
||||
event: event?.name,
|
||||
projectId,
|
||||
});
|
||||
const [state, setState] = useState<'index' | 'event' | 'profile'>('index');
|
||||
const groupPropertiesQuery = useQuery(
|
||||
trpc.group.properties.queryOptions({ projectId })
|
||||
);
|
||||
const [state, setState] = useState<'index' | 'event' | 'profile' | 'group'>(
|
||||
'index'
|
||||
);
|
||||
const [search, setSearch] = useState('');
|
||||
const [direction, setDirection] = useState<'forward' | 'backward'>('forward');
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setState(!mode ? 'index' : mode === 'events' ? 'event' : 'profile');
|
||||
setState(mode ? (mode === 'events' ? 'event' : 'profile') : 'index');
|
||||
}
|
||||
}, [open, mode]);
|
||||
|
||||
@@ -86,11 +99,21 @@ export function PropertiesCombobox({
|
||||
});
|
||||
};
|
||||
|
||||
// Mock data for the lists
|
||||
// Fixed group properties: name, type, plus dynamic property keys
|
||||
const groupActions = [
|
||||
{ value: 'group.name', label: 'name', description: 'group' },
|
||||
{ value: 'group.type', label: 'type', description: 'group' },
|
||||
...(groupPropertiesQuery.data ?? []).map((key) => ({
|
||||
value: `group.properties.${key}`,
|
||||
label: key,
|
||||
description: 'group.properties',
|
||||
})),
|
||||
].filter((a) => shouldShowProperty(a.value));
|
||||
|
||||
const profileActions = properties
|
||||
.filter(
|
||||
(property) =>
|
||||
property.startsWith('profile') && shouldShowProperty(property),
|
||||
property.startsWith('profile') && shouldShowProperty(property)
|
||||
)
|
||||
.map((property) => ({
|
||||
value: property,
|
||||
@@ -100,7 +123,7 @@ export function PropertiesCombobox({
|
||||
const eventActions = properties
|
||||
.filter(
|
||||
(property) =>
|
||||
!property.startsWith('profile') && shouldShowProperty(property),
|
||||
!property.startsWith('profile') && shouldShowProperty(property)
|
||||
)
|
||||
.map((property) => ({
|
||||
value: property,
|
||||
@@ -108,7 +131,9 @@ export function PropertiesCombobox({
|
||||
description: property.split('.').slice(0, -1).join('.'),
|
||||
}));
|
||||
|
||||
const handleStateChange = (newState: 'index' | 'event' | 'profile') => {
|
||||
const handleStateChange = (
|
||||
newState: 'index' | 'event' | 'profile' | 'group'
|
||||
) => {
|
||||
setDirection(newState === 'index' ? 'backward' : 'forward');
|
||||
setState(newState);
|
||||
};
|
||||
@@ -135,7 +160,7 @@ export function PropertiesCombobox({
|
||||
}}
|
||||
>
|
||||
Event properties
|
||||
<DatabaseIcon className="size-4 group-hover:text-blue-500 group-hover:scale-125 transition-all group-hover:rotate-12" />
|
||||
<DatabaseIcon className="size-4 transition-all group-hover:rotate-12 group-hover:scale-125 group-hover:text-blue-500" />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="group justify-between gap-2"
|
||||
@@ -145,7 +170,17 @@ export function PropertiesCombobox({
|
||||
}}
|
||||
>
|
||||
Profile properties
|
||||
<UserIcon className="size-4 group-hover:text-blue-500 group-hover:scale-125 transition-all group-hover:rotate-12" />
|
||||
<UserIcon className="size-4 transition-all group-hover:rotate-12 group-hover:scale-125 group-hover:text-blue-500" />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="group justify-between gap-2"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleStateChange('group');
|
||||
}}
|
||||
>
|
||||
Group properties
|
||||
<Building2Icon className="size-4 transition-all group-hover:rotate-12 group-hover:scale-125 group-hover:text-blue-500" />
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
);
|
||||
@@ -155,7 +190,7 @@ export function PropertiesCombobox({
|
||||
const filteredActions = eventActions.filter(
|
||||
(action) =>
|
||||
action.label.toLowerCase().includes(search.toLowerCase()) ||
|
||||
action.description.toLowerCase().includes(search.toLowerCase()),
|
||||
action.description.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -169,20 +204,20 @@ export function PropertiesCombobox({
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
<VirtualList
|
||||
height={300}
|
||||
data={filteredActions}
|
||||
height={300}
|
||||
itemHeight={40}
|
||||
itemKey="id"
|
||||
>
|
||||
{(action) => (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="p-2 hover:bg-accent cursor-pointer rounded-md col gap-px"
|
||||
className="col cursor-pointer gap-px rounded-md p-2 hover:bg-accent"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
onClick={() => handleSelect(action)}
|
||||
>
|
||||
<div className="font-medium">{action.label}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{action.description}
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -196,7 +231,7 @@ export function PropertiesCombobox({
|
||||
const filteredActions = profileActions.filter(
|
||||
(action) =>
|
||||
action.label.toLowerCase().includes(search.toLowerCase()) ||
|
||||
action.description.toLowerCase().includes(search.toLowerCase()),
|
||||
action.description.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -208,20 +243,59 @@ export function PropertiesCombobox({
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
<VirtualList
|
||||
height={300}
|
||||
data={filteredActions}
|
||||
height={300}
|
||||
itemHeight={40}
|
||||
itemKey="id"
|
||||
>
|
||||
{(action) => (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="p-2 hover:bg-accent cursor-pointer rounded-md col gap-px"
|
||||
className="col cursor-pointer gap-px rounded-md p-2 hover:bg-accent"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
onClick={() => handleSelect(action)}
|
||||
>
|
||||
<div className="font-medium">{action.label}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{action.description}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</VirtualList>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderGroup = () => {
|
||||
const filteredActions = groupActions.filter(
|
||||
(action) =>
|
||||
action.label.toLowerCase().includes(search.toLowerCase()) ||
|
||||
action.description.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<SearchHeader
|
||||
onBack={() => handleStateChange('index')}
|
||||
onSearch={setSearch}
|
||||
value={search}
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
<VirtualList
|
||||
data={filteredActions}
|
||||
height={Math.min(300, filteredActions.length * 40 + 8)}
|
||||
itemHeight={40}
|
||||
itemKey="value"
|
||||
>
|
||||
{(action) => (
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="col cursor-pointer gap-px rounded-md p-2 hover:bg-accent"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
onClick={() => handleSelect(action)}
|
||||
>
|
||||
<div className="font-medium">{action.label}</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{action.description}
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -233,20 +307,20 @@ export function PropertiesCombobox({
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
open={open}
|
||||
onOpenChange={(open) => {
|
||||
setOpen(open);
|
||||
}}
|
||||
open={open}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>{children(setOpen)}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="max-w-80" align="start">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<DropdownMenuContent align="start" className="max-w-80">
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{state === 'index' && (
|
||||
<motion.div
|
||||
key="index"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
initial={{ opacity: 0 }}
|
||||
key="index"
|
||||
transition={{ duration: 0.05 }}
|
||||
>
|
||||
{renderIndex()}
|
||||
@@ -254,10 +328,10 @@ export function PropertiesCombobox({
|
||||
)}
|
||||
{state === 'event' && (
|
||||
<motion.div
|
||||
key="event"
|
||||
initial={{ opacity: 0, x: direction === 'forward' ? 20 : -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: direction === 'forward' ? -20 : 20 }}
|
||||
initial={{ opacity: 0, x: direction === 'forward' ? 20 : -20 }}
|
||||
key="event"
|
||||
transition={{ duration: 0.05 }}
|
||||
>
|
||||
{renderEvent()}
|
||||
@@ -265,15 +339,26 @@ export function PropertiesCombobox({
|
||||
)}
|
||||
{state === 'profile' && (
|
||||
<motion.div
|
||||
key="profile"
|
||||
initial={{ opacity: 0, x: direction === 'forward' ? 20 : -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: direction === 'forward' ? -20 : 20 }}
|
||||
initial={{ opacity: 0, x: direction === 'forward' ? 20 : -20 }}
|
||||
key="profile"
|
||||
transition={{ duration: 0.05 }}
|
||||
>
|
||||
{renderProfile()}
|
||||
</motion.div>
|
||||
)}
|
||||
{state === 'group' && (
|
||||
<motion.div
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: direction === 'forward' ? -20 : 20 }}
|
||||
initial={{ opacity: 0, x: direction === 'forward' ? 20 : -20 }}
|
||||
key="group"
|
||||
transition={{ duration: 0.05 }}
|
||||
>
|
||||
{renderGroup()}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AnimatePresence, motion } from 'framer-motion';
|
||||
import {
|
||||
BellIcon,
|
||||
BookOpenIcon,
|
||||
Building2Icon,
|
||||
ChartLineIcon,
|
||||
ChevronDownIcon,
|
||||
CogIcon,
|
||||
@@ -62,6 +63,7 @@ export default function SidebarProjectMenu({
|
||||
<SidebarLink href={'/events'} icon={GanttChartIcon} label="Events" />
|
||||
<SidebarLink href={'/sessions'} icon={UsersIcon} label="Sessions" />
|
||||
<SidebarLink href={'/profiles'} icon={UserCircleIcon} label="Profiles" />
|
||||
<SidebarLink href={'/groups'} icon={Building2Icon} label="Groups" />
|
||||
<div className="mt-4 mb-2 font-medium text-muted-foreground text-sm">
|
||||
Manage
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -54,6 +54,19 @@ export function WidgetBody({ children, className }: WidgetBodyProps) {
|
||||
return <div className={cn('p-4', className)}>{children}</div>;
|
||||
}
|
||||
|
||||
export interface WidgetEmptyStateProps {
|
||||
icon: LucideIcon;
|
||||
text: string;
|
||||
}
|
||||
export function WidgetEmptyState({ icon: Icon, text }: WidgetEmptyStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-8 text-muted-foreground">
|
||||
<Icon size={28} strokeWidth={1.5} />
|
||||
<p className="text-sm">{text}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface WidgetProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
|
||||
139
apps/start/src/modals/add-group.tsx
Normal file
139
apps/start/src/modals/add-group.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { zCreateGroup } from '@openpanel/validation';
|
||||
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 { z } from 'zod';
|
||||
import { popModal } from '.';
|
||||
import { ModalContent, ModalHeader } from './Modal/Container';
|
||||
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';
|
||||
|
||||
const zForm = zCreateGroup.omit({ projectId: true, properties: true }).extend({
|
||||
properties: z.array(z.object({ key: z.string(), value: z.string() })),
|
||||
});
|
||||
type IForm = z.infer<typeof zForm>;
|
||||
|
||||
export default function AddGroup() {
|
||||
const { projectId } = useAppParams();
|
||||
const queryClient = useQueryClient();
|
||||
const trpc = useTRPC();
|
||||
|
||||
const { register, handleSubmit, control, formState } = useForm<IForm>({
|
||||
resolver: zodResolver(zForm),
|
||||
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')}
|
||||
autoFocus
|
||||
error={formState.errors.id?.message}
|
||||
/>
|
||||
<InputWithLabel
|
||||
label="Name"
|
||||
placeholder="Acme Corp"
|
||||
{...register('name')}
|
||||
error={formState.errors.name?.message}
|
||||
/>
|
||||
<InputWithLabel
|
||||
label="Type"
|
||||
placeholder="company"
|
||||
{...register('type')}
|
||||
error={formState.errors.type?.message}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-sm">Properties</span>
|
||||
<Button
|
||||
onClick={() => append({ key: '', value: '' })}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<PlusIcon className="mr-1 size-3" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
{fields.map((field, index) => (
|
||||
<div className="flex gap-2" key={field.id}>
|
||||
<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
|
||||
className="shrink-0"
|
||||
onClick={() => remove(index)}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2Icon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ButtonContainer>
|
||||
<Button onClick={() => popModal()} type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!formState.isDirty || mutation.isPending}
|
||||
type="submit"
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</ButtonContainer>
|
||||
</form>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
147
apps/start/src/modals/edit-group.tsx
Normal file
147
apps/start/src/modals/edit-group.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import type { IServiceGroup } from '@openpanel/db';
|
||||
import { zUpdateGroup } from '@openpanel/validation';
|
||||
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 { z } from 'zod';
|
||||
import { popModal } from '.';
|
||||
import { ModalContent, ModalHeader } from './Modal/Container';
|
||||
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';
|
||||
|
||||
const zForm = zUpdateGroup
|
||||
.omit({ id: true, projectId: true, properties: true })
|
||||
.extend({
|
||||
properties: z.array(z.object({ key: z.string(), value: z.string() })),
|
||||
});
|
||||
type IForm = z.infer<typeof zForm>;
|
||||
|
||||
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>({
|
||||
resolver: zodResolver(zForm),
|
||||
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')}
|
||||
error={formState.errors.name?.message}
|
||||
/>
|
||||
<InputWithLabel
|
||||
label="Type"
|
||||
{...register('type')}
|
||||
error={formState.errors.type?.message}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-sm">Properties</span>
|
||||
<Button
|
||||
onClick={() => append({ key: '', value: '' })}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<PlusIcon className="mr-1 size-3" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
{fields.map((field, index) => (
|
||||
<div className="flex gap-2" key={field.id}>
|
||||
<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
|
||||
className="shrink-0"
|
||||
onClick={() => remove(index)}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2Icon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ButtonContainer>
|
||||
<Button onClick={() => popModal()} type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!formState.isDirty || mutation.isPending}
|
||||
type="submit"
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</ButtonContainer>
|
||||
</form>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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} />
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ import { Route as AppOrganizationIdProjectIdReferencesRouteImport } from './rout
|
||||
import { Route as AppOrganizationIdProjectIdRealtimeRouteImport } from './routes/_app.$organizationId.$projectId.realtime'
|
||||
import { Route as AppOrganizationIdProjectIdPagesRouteImport } from './routes/_app.$organizationId.$projectId.pages'
|
||||
import { Route as AppOrganizationIdProjectIdInsightsRouteImport } from './routes/_app.$organizationId.$projectId.insights'
|
||||
import { Route as AppOrganizationIdProjectIdGroupsRouteImport } from './routes/_app.$organizationId.$projectId.groups'
|
||||
import { Route as AppOrganizationIdProjectIdDashboardsRouteImport } from './routes/_app.$organizationId.$projectId.dashboards'
|
||||
import { Route as AppOrganizationIdProjectIdChatRouteImport } from './routes/_app.$organizationId.$projectId.chat'
|
||||
import { Route as AppOrganizationIdProfileTabsIndexRouteImport } from './routes/_app.$organizationId.profile._tabs.index'
|
||||
@@ -82,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',
|
||||
@@ -113,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',
|
||||
@@ -350,6 +358,12 @@ const AppOrganizationIdProjectIdInsightsRoute =
|
||||
path: '/insights',
|
||||
getParentRoute: () => AppOrganizationIdProjectIdRoute,
|
||||
} as any)
|
||||
const AppOrganizationIdProjectIdGroupsRoute =
|
||||
AppOrganizationIdProjectIdGroupsRouteImport.update({
|
||||
id: '/groups',
|
||||
path: '/groups',
|
||||
getParentRoute: () => AppOrganizationIdProjectIdRoute,
|
||||
} as any)
|
||||
const AppOrganizationIdProjectIdDashboardsRoute =
|
||||
AppOrganizationIdProjectIdDashboardsRouteImport.update({
|
||||
id: '/dashboards',
|
||||
@@ -368,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: '/',
|
||||
@@ -555,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',
|
||||
@@ -579,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',
|
||||
@@ -591,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
|
||||
@@ -615,6 +658,7 @@ export interface FileRoutesByFullPath {
|
||||
'/$organizationId/': typeof AppOrganizationIdIndexRoute
|
||||
'/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute
|
||||
'/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute
|
||||
'/$organizationId/$projectId/groups': typeof AppOrganizationIdProjectIdGroupsRoute
|
||||
'/$organizationId/$projectId/insights': typeof AppOrganizationIdProjectIdInsightsRoute
|
||||
'/$organizationId/$projectId/pages': typeof AppOrganizationIdProjectIdPagesRoute
|
||||
'/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
|
||||
@@ -646,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
|
||||
@@ -663,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 {
|
||||
@@ -688,6 +736,7 @@ export interface FileRoutesByTo {
|
||||
'/$organizationId': typeof AppOrganizationIdIndexRoute
|
||||
'/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute
|
||||
'/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute
|
||||
'/$organizationId/$projectId/groups': typeof AppOrganizationIdProjectIdGroupsRoute
|
||||
'/$organizationId/$projectId/insights': typeof AppOrganizationIdProjectIdInsightsRoute
|
||||
'/$organizationId/$projectId/pages': typeof AppOrganizationIdProjectIdPagesRoute
|
||||
'/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
|
||||
@@ -716,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
|
||||
@@ -729,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
|
||||
}
|
||||
@@ -760,6 +812,7 @@ export interface FileRoutesById {
|
||||
'/_app/$organizationId/': typeof AppOrganizationIdIndexRoute
|
||||
'/_app/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute
|
||||
'/_app/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute
|
||||
'/_app/$organizationId/$projectId/groups': typeof AppOrganizationIdProjectIdGroupsRoute
|
||||
'/_app/$organizationId/$projectId/insights': typeof AppOrganizationIdProjectIdInsightsRoute
|
||||
'/_app/$organizationId/$projectId/pages': typeof AppOrganizationIdProjectIdPagesRoute
|
||||
'/_app/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
|
||||
@@ -798,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
|
||||
@@ -816,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 {
|
||||
@@ -845,6 +903,7 @@ export interface FileRouteTypes {
|
||||
| '/$organizationId/'
|
||||
| '/$organizationId/$projectId/chat'
|
||||
| '/$organizationId/$projectId/dashboards'
|
||||
| '/$organizationId/$projectId/groups'
|
||||
| '/$organizationId/$projectId/insights'
|
||||
| '/$organizationId/$projectId/pages'
|
||||
| '/$organizationId/$projectId/realtime'
|
||||
@@ -876,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'
|
||||
@@ -893,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:
|
||||
@@ -918,6 +981,7 @@ export interface FileRouteTypes {
|
||||
| '/$organizationId'
|
||||
| '/$organizationId/$projectId/chat'
|
||||
| '/$organizationId/$projectId/dashboards'
|
||||
| '/$organizationId/$projectId/groups'
|
||||
| '/$organizationId/$projectId/insights'
|
||||
| '/$organizationId/$projectId/pages'
|
||||
| '/$organizationId/$projectId/realtime'
|
||||
@@ -946,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'
|
||||
@@ -959,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:
|
||||
@@ -989,6 +1056,7 @@ export interface FileRouteTypes {
|
||||
| '/_app/$organizationId/'
|
||||
| '/_app/$organizationId/$projectId/chat'
|
||||
| '/_app/$organizationId/$projectId/dashboards'
|
||||
| '/_app/$organizationId/$projectId/groups'
|
||||
| '/_app/$organizationId/$projectId/insights'
|
||||
| '/_app/$organizationId/$projectId/pages'
|
||||
| '/_app/$organizationId/$projectId/realtime'
|
||||
@@ -1027,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'
|
||||
@@ -1045,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
|
||||
}
|
||||
@@ -1378,6 +1451,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AppOrganizationIdProjectIdInsightsRouteImport
|
||||
parentRoute: typeof AppOrganizationIdProjectIdRoute
|
||||
}
|
||||
'/_app/$organizationId/$projectId/groups': {
|
||||
id: '/_app/$organizationId/$projectId/groups'
|
||||
path: '/groups'
|
||||
fullPath: '/$organizationId/$projectId/groups'
|
||||
preLoaderRoute: typeof AppOrganizationIdProjectIdGroupsRouteImport
|
||||
parentRoute: typeof AppOrganizationIdProjectIdRoute
|
||||
}
|
||||
'/_app/$organizationId/$projectId/dashboards': {
|
||||
id: '/_app/$organizationId/$projectId/dashboards'
|
||||
path: '/dashboards'
|
||||
@@ -1399,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: '/'
|
||||
@@ -1623,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'
|
||||
@@ -1651,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'
|
||||
@@ -1665,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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1872,9 +1987,46 @@ 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
|
||||
AppOrganizationIdProjectIdGroupsRoute: typeof AppOrganizationIdProjectIdGroupsRoute
|
||||
AppOrganizationIdProjectIdInsightsRoute: typeof AppOrganizationIdProjectIdInsightsRoute
|
||||
AppOrganizationIdProjectIdPagesRoute: typeof AppOrganizationIdProjectIdPagesRoute
|
||||
AppOrganizationIdProjectIdRealtimeRoute: typeof AppOrganizationIdProjectIdRealtimeRoute
|
||||
@@ -1890,6 +2042,7 @@ interface AppOrganizationIdProjectIdRouteChildren {
|
||||
AppOrganizationIdProjectIdReportsReportIdRoute: typeof AppOrganizationIdProjectIdReportsReportIdRoute
|
||||
AppOrganizationIdProjectIdSessionsSessionIdRoute: typeof AppOrganizationIdProjectIdSessionsSessionIdRoute
|
||||
AppOrganizationIdProjectIdSettingsRoute: typeof AppOrganizationIdProjectIdSettingsRouteWithChildren
|
||||
AppOrganizationIdProjectIdGroupsGroupIdRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdRouteWithChildren
|
||||
}
|
||||
|
||||
const AppOrganizationIdProjectIdRouteChildren: AppOrganizationIdProjectIdRouteChildren =
|
||||
@@ -1897,6 +2050,8 @@ const AppOrganizationIdProjectIdRouteChildren: AppOrganizationIdProjectIdRouteCh
|
||||
AppOrganizationIdProjectIdChatRoute: AppOrganizationIdProjectIdChatRoute,
|
||||
AppOrganizationIdProjectIdDashboardsRoute:
|
||||
AppOrganizationIdProjectIdDashboardsRoute,
|
||||
AppOrganizationIdProjectIdGroupsRoute:
|
||||
AppOrganizationIdProjectIdGroupsRoute,
|
||||
AppOrganizationIdProjectIdInsightsRoute:
|
||||
AppOrganizationIdProjectIdInsightsRoute,
|
||||
AppOrganizationIdProjectIdPagesRoute: AppOrganizationIdProjectIdPagesRoute,
|
||||
@@ -1924,6 +2079,8 @@ const AppOrganizationIdProjectIdRouteChildren: AppOrganizationIdProjectIdRouteCh
|
||||
AppOrganizationIdProjectIdSessionsSessionIdRoute,
|
||||
AppOrganizationIdProjectIdSettingsRoute:
|
||||
AppOrganizationIdProjectIdSettingsRouteWithChildren,
|
||||
AppOrganizationIdProjectIdGroupsGroupIdRoute:
|
||||
AppOrganizationIdProjectIdGroupsGroupIdRouteWithChildren,
|
||||
}
|
||||
|
||||
const AppOrganizationIdProjectIdRouteWithChildren =
|
||||
|
||||
100
apps/start/src/routes/_app.$organizationId.$projectId.groups.tsx
Normal file
100
apps/start/src/routes/_app.$organizationId.$projectId.groups.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
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 { Button } from '@/components/ui/button';
|
||||
import { useDataTablePagination } from '@/components/ui/data-table/data-table-hooks';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useSearchQueryState } from '@/hooks/use-search-query-state';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { pushModal } from '@/modals';
|
||||
import { createProjectTitle } from '@/utils/title';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
export const Route = createFileRoute('/_app/$organizationId/$projectId/groups')(
|
||||
{
|
||||
component: Component,
|
||||
head: () => ({
|
||||
meta: [{ title: createProjectTitle('Groups') }],
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
function Component() {
|
||||
const { projectId } = Route.useParams();
|
||||
const trpc = useTRPC();
|
||||
const { debouncedSearch } = useSearchQueryState();
|
||||
const [typeFilter, setTypeFilter] = useQueryState(
|
||||
'type',
|
||||
parseAsString.withDefault('')
|
||||
);
|
||||
const { page } = useDataTablePagination(PAGE_SIZE);
|
||||
|
||||
const typesQuery = useQuery(trpc.group.types.queryOptions({ projectId }));
|
||||
|
||||
const groupsQuery = useQuery(
|
||||
trpc.group.list.queryOptions(
|
||||
{
|
||||
projectId,
|
||||
search: debouncedSearch || undefined,
|
||||
type: typeFilter || undefined,
|
||||
take: PAGE_SIZE,
|
||||
cursor: (page - 1) * PAGE_SIZE,
|
||||
},
|
||||
{ placeholderData: keepPreviousData }
|
||||
)
|
||||
);
|
||||
|
||||
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"
|
||||
/>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
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 { GroupMemberGrowth } from '@/components/groups/group-member-growth';
|
||||
import { OverviewMetricCard } from '@/components/overview/overview-metric-card';
|
||||
import { WidgetHead, WidgetTitle } from '@/components/overview/overview-widget';
|
||||
import { MostEvents } from '@/components/profiles/most-events';
|
||||
import { PopularRoutes } from '@/components/profiles/popular-routes';
|
||||
import { ProfileActivity } from '@/components/profiles/profile-activity';
|
||||
import { KeyValueGrid } from '@/components/ui/key-value-grid';
|
||||
import { Widget, WidgetBody, WidgetEmptyState } 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 mostEvents = useSuspenseQuery(
|
||||
trpc.group.mostEvents.queryOptions({ id: groupId, projectId })
|
||||
);
|
||||
const popularRoutes = useSuspenseQuery(
|
||||
trpc.group.popularRoutes.queryOptions({ id: groupId, projectId })
|
||||
);
|
||||
const memberGrowth = useSuspenseQuery(
|
||||
trpc.group.memberGrowth.queryOptions({ id: groupId, projectId })
|
||||
);
|
||||
|
||||
const g = group.data;
|
||||
const m = metrics.data;
|
||||
|
||||
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>
|
||||
|
||||
{/* Member growth */}
|
||||
<div className="col-span-1">
|
||||
<GroupMemberGrowth data={memberGrowth.data} />
|
||||
</div>
|
||||
|
||||
{/* Top events */}
|
||||
<div className="col-span-1">
|
||||
<MostEvents data={mostEvents.data} />
|
||||
</div>
|
||||
|
||||
{/* Popular routes */}
|
||||
<div className="col-span-1">
|
||||
<PopularRoutes data={popularRoutes.data} />
|
||||
</div>
|
||||
|
||||
{/* Members preview */}
|
||||
<div className="col-span-1 md:col-span-2">
|
||||
<Widget className="w-full">
|
||||
<WidgetHead>
|
||||
<WidgetTitle icon={UsersIcon}>Members</WidgetTitle>
|
||||
</WidgetHead>
|
||||
<WidgetBody className="p-0">
|
||||
{members.data.length === 0 ? (
|
||||
<WidgetEmptyState icon={UsersIcon} text="No members yet" />
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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) * 50,
|
||||
take: 50,
|
||||
search: debouncedSearch || undefined,
|
||||
}),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
return (
|
||||
<ProfilesTable
|
||||
pageSize={50}
|
||||
query={query as Parameters<typeof ProfilesTable>[0]['query']}
|
||||
type="profiles"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -103,8 +104,15 @@ function Component() {
|
||||
<ProfileMetrics data={metrics.data} />
|
||||
</div>
|
||||
{/* Profile properties - full width */}
|
||||
<div className="col-span-1 md:col-span-2">
|
||||
<div className="col-span-1 flex flex-col gap-3 md:col-span-2">
|
||||
<ProfileProperties profile={profile.data!} />
|
||||
{profile.data?.groups?.length ? (
|
||||
<ProfileGroups
|
||||
profileId={profileId}
|
||||
projectId={projectId}
|
||||
groups={profile.data.groups}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Heatmap / Activity */}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { IServiceEvent, IServiceSession } from '@openpanel/db';
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { useSuspenseQuery, useQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute, Link } from '@tanstack/react-router';
|
||||
import { EventIcon } from '@/components/events/event-icon';
|
||||
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||
@@ -165,6 +165,14 @@ function Component() {
|
||||
})
|
||||
);
|
||||
|
||||
const { data: sessionGroups } = useQuery({
|
||||
...trpc.group.listByIds.queryOptions({
|
||||
projectId,
|
||||
ids: session.groups ?? [],
|
||||
}),
|
||||
enabled: (session.groups?.length ?? 0) > 0,
|
||||
});
|
||||
|
||||
const fakeEvent = sessionToFakeEvent(session);
|
||||
|
||||
return (
|
||||
@@ -324,6 +332,35 @@ function Component() {
|
||||
</Widget>
|
||||
)}
|
||||
|
||||
{/* Group cards */}
|
||||
{sessionGroups && sessionGroups.length > 0 && (
|
||||
<Widget className="w-full">
|
||||
<WidgetHead>
|
||||
<WidgetTitle>Groups</WidgetTitle>
|
||||
</WidgetHead>
|
||||
<WidgetBody className="p-0">
|
||||
{sessionGroups.map((group) => (
|
||||
<Link
|
||||
key={group.id}
|
||||
className="row items-center gap-3 p-4 transition-colors hover:bg-accent"
|
||||
params={{ organizationId, projectId, groupId: group.id }}
|
||||
to="/$organizationId/$projectId/groups/$groupId"
|
||||
>
|
||||
<div className="col min-w-0 flex-1 gap-0.5">
|
||||
<span className="truncate font-medium">{group.name}</span>
|
||||
<span className="truncate text-muted-foreground text-sm font-mono">
|
||||
{group.id}
|
||||
</span>
|
||||
</div>
|
||||
<span className="shrink-0 rounded border px-1.5 py-0.5 text-muted-foreground text-xs">
|
||||
{group.type}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
)}
|
||||
|
||||
{/* Visited pages */}
|
||||
<VisitedRoutes
|
||||
paths={events
|
||||
|
||||
@@ -10,7 +10,7 @@ const BASE_TITLE = 'OpenPanel.dev';
|
||||
export function createTitle(
|
||||
pageTitle: string,
|
||||
section?: string,
|
||||
baseTitle = BASE_TITLE,
|
||||
baseTitle = BASE_TITLE
|
||||
): string {
|
||||
const parts = [pageTitle];
|
||||
if (section) {
|
||||
@@ -25,7 +25,7 @@ export function createTitle(
|
||||
*/
|
||||
export function createOrganizationTitle(
|
||||
pageTitle: string,
|
||||
organizationName?: string,
|
||||
organizationName?: string
|
||||
): string {
|
||||
if (organizationName) {
|
||||
return createTitle(pageTitle, organizationName);
|
||||
@@ -39,7 +39,7 @@ export function createOrganizationTitle(
|
||||
export function createProjectTitle(
|
||||
pageTitle: string,
|
||||
projectName?: string,
|
||||
organizationName?: string,
|
||||
organizationName?: string
|
||||
): string {
|
||||
const parts = [pageTitle];
|
||||
if (projectName) {
|
||||
@@ -59,7 +59,7 @@ export function createEntityTitle(
|
||||
entityName: string,
|
||||
entityType: string,
|
||||
projectName?: string,
|
||||
organizationName?: string,
|
||||
organizationName?: string
|
||||
): string {
|
||||
const parts = [entityName, entityType];
|
||||
if (projectName) {
|
||||
@@ -95,6 +95,9 @@ export const PAGE_TITLES = {
|
||||
PROFILES: 'Profiles',
|
||||
PROFILE_EVENTS: 'Profile events',
|
||||
PROFILE_DETAILS: 'Profile details',
|
||||
// Groups
|
||||
GROUPS: 'Groups',
|
||||
GROUP_DETAILS: 'Group details',
|
||||
|
||||
// Sub-sections
|
||||
CONVERSIONS: 'Conversions',
|
||||
|
||||
4
apps/testbed/.gitignore
vendored
Normal file
4
apps/testbed/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
public/op1.js
|
||||
.env
|
||||
12
apps/testbed/index.html
Normal file
12
apps/testbed/index.html
Normal 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
24
apps/testbed/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
16
apps/testbed/scripts/copy-op1.mjs
Normal file
16
apps/testbed/scripts/copy-op1.mjs
Normal 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);
|
||||
}
|
||||
217
apps/testbed/src/App.tsx
Normal file
217
apps/testbed/src/App.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
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);
|
||||
if (meta) {
|
||||
op.upsertGroup({ 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>
|
||||
);
|
||||
}
|
||||
10
apps/testbed/src/analytics.ts
Normal file
10
apps/testbed/src/analytics.ts
Normal 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
10
apps/testbed/src/main.tsx
Normal 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>
|
||||
);
|
||||
63
apps/testbed/src/pages/Cart.tsx
Normal file
63
apps/testbed/src/pages/Cart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
apps/testbed/src/pages/Checkout.tsx
Normal file
43
apps/testbed/src/pages/Checkout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
186
apps/testbed/src/pages/Login.tsx
Normal file
186
apps/testbed/src/pages/Login.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
apps/testbed/src/pages/Product.tsx
Normal file
61
apps/testbed/src/pages/Product.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
apps/testbed/src/pages/Shop.tsx
Normal file
36
apps/testbed/src/pages/Shop.tsx
Normal 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
358
apps/testbed/src/styles.css
Normal 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
23
apps/testbed/src/types.ts
Normal 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>;
|
||||
};
|
||||
17
apps/testbed/tsconfig.json
Normal file
17
apps/testbed/tsconfig.json
Normal 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"]
|
||||
}
|
||||
9
apps/testbed/vite.config.ts
Normal file
9
apps/testbed/vite.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
define: {
|
||||
'process.env': {},
|
||||
},
|
||||
});
|
||||
@@ -68,6 +68,11 @@ export async function bootCron() {
|
||||
type: 'flushReplay',
|
||||
pattern: 1000 * 10,
|
||||
},
|
||||
{
|
||||
name: 'flush',
|
||||
type: 'flushGroups',
|
||||
pattern: 1000 * 10,
|
||||
},
|
||||
{
|
||||
name: 'insightsDaily',
|
||||
type: 'insightsDaily',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Job } from 'bullmq';
|
||||
|
||||
import { eventBuffer, profileBackfillBuffer, profileBuffer, replayBuffer, sessionBuffer } from '@openpanel/db';
|
||||
import { eventBuffer, groupBuffer, profileBackfillBuffer, profileBuffer, replayBuffer, sessionBuffer } from '@openpanel/db';
|
||||
import type { CronQueuePayload } from '@openpanel/queue';
|
||||
|
||||
import { jobdeleteProjects } from './cron.delete-projects';
|
||||
@@ -30,6 +30,9 @@ export async function cronJob(job: Job<CronQueuePayload>) {
|
||||
case 'flushReplay': {
|
||||
return await replayBuffer.tryFlush();
|
||||
}
|
||||
case 'flushGroups': {
|
||||
return await groupBuffer.tryFlush();
|
||||
}
|
||||
case 'ping': {
|
||||
return await ping();
|
||||
}
|
||||
|
||||
@@ -134,6 +134,7 @@ export async function incomingEvent(
|
||||
__hash: hash,
|
||||
__query: query,
|
||||
}),
|
||||
groups: body.groups ?? [],
|
||||
createdAt,
|
||||
duration: 0,
|
||||
sdkName,
|
||||
|
||||
@@ -129,6 +129,7 @@ describe('incomingEvent', () => {
|
||||
referrerType: '',
|
||||
sdkName: jobData.headers['openpanel-sdk-name'],
|
||||
sdkVersion: jobData.headers['openpanel-sdk-version'],
|
||||
groups: [],
|
||||
};
|
||||
|
||||
(createEvent as Mock).mockReturnValue(event);
|
||||
@@ -242,6 +243,7 @@ describe('incomingEvent', () => {
|
||||
referrerType: '',
|
||||
sdkName: jobData.headers['openpanel-sdk-name'],
|
||||
sdkVersion: jobData.headers['openpanel-sdk-version'],
|
||||
groups: [],
|
||||
};
|
||||
|
||||
expect(spySessionsQueueAdd).toHaveBeenCalledTimes(0);
|
||||
@@ -312,6 +314,7 @@ describe('incomingEvent', () => {
|
||||
screen_views: [],
|
||||
sign: 1,
|
||||
version: 1,
|
||||
groups: [],
|
||||
} satisfies IClickhouseSession);
|
||||
|
||||
await incomingEvent(jobData);
|
||||
@@ -349,6 +352,7 @@ describe('incomingEvent', () => {
|
||||
sdkName: 'server',
|
||||
sdkVersion: '1.0.0',
|
||||
revenue: undefined,
|
||||
groups: [],
|
||||
});
|
||||
|
||||
expect(sessionsQueue.add).not.toHaveBeenCalled();
|
||||
@@ -412,6 +416,7 @@ describe('incomingEvent', () => {
|
||||
referrerType: undefined,
|
||||
sdkName: 'server',
|
||||
sdkVersion: '1.0.0',
|
||||
groups: [],
|
||||
});
|
||||
|
||||
expect(sessionsQueue.add).not.toHaveBeenCalled();
|
||||
|
||||
Reference in New Issue
Block a user