a looooot
This commit is contained in:
@@ -59,7 +59,12 @@ export function EventList({ data, count }: EventListProps) {
|
||||
<EventListItem key={item.id} {...item} />
|
||||
))}
|
||||
</div>
|
||||
<Pagination cursor={cursor} setCursor={setCursor} />
|
||||
<Pagination
|
||||
cursor={cursor}
|
||||
setCursor={setCursor}
|
||||
count={count}
|
||||
take={50}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
eventQueryNamesFilter,
|
||||
} from '@/hooks/useEventQueryFilters';
|
||||
import { getExists } from '@/server/pageExists';
|
||||
import { parseAsInteger } from 'nuqs';
|
||||
|
||||
import { getEventList, getEventsCount } from '@mixan/db';
|
||||
|
||||
@@ -28,20 +29,13 @@ const nuqsOptions = {
|
||||
shallow: false,
|
||||
};
|
||||
|
||||
function parseQueryAsNumber(value: string | undefined) {
|
||||
if (typeof value === 'string') {
|
||||
return parseInt(value, 10);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export default async function Page({
|
||||
params: { projectId, organizationId },
|
||||
searchParams,
|
||||
}: PageProps) {
|
||||
const [events, count] = await Promise.all([
|
||||
getEventList({
|
||||
cursor: parseQueryAsNumber(searchParams.cursor),
|
||||
cursor: parseAsInteger.parse(searchParams.cursor ?? '') ?? undefined,
|
||||
projectId,
|
||||
take: 50,
|
||||
events: eventQueryNamesFilter.parse(searchParams.events ?? ''),
|
||||
@@ -59,6 +53,7 @@ export default async function Page({
|
||||
<PageLayout title="Events" organizationSlug={organizationId}>
|
||||
<StickyBelowHeader className="p-4 flex justify-between">
|
||||
<OverviewFiltersDrawer
|
||||
mode="events"
|
||||
projectId={projectId}
|
||||
nuqsOptions={nuqsOptions}
|
||||
enableEventsFilter
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Logo } from '@/components/Logo';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { Rotate as Hamburger } from 'hamburger-react';
|
||||
import { PlusIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
@@ -15,10 +17,14 @@ import LayoutOrganizationSelector from './layout-organization-selector';
|
||||
interface LayoutSidebarProps {
|
||||
organizations: IServiceOrganization[];
|
||||
dashboards: IServiceDashboards;
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
}
|
||||
export function LayoutSidebar({
|
||||
organizations,
|
||||
dashboards,
|
||||
organizationId,
|
||||
projectId,
|
||||
}: LayoutSidebarProps) {
|
||||
const [active, setActive] = useState(false);
|
||||
const pathname = usePathname();
|
||||
@@ -56,11 +62,18 @@ export function LayoutSidebar({
|
||||
<div className="flex flex-col p-4 gap-2 flex-grow overflow-auto">
|
||||
<LayoutMenu dashboards={dashboards} />
|
||||
{/* Placeholder for LayoutOrganizationSelector */}
|
||||
<div className="h-16 block shrink-0"></div>
|
||||
<div className="h-32 block shrink-0"></div>
|
||||
</div>
|
||||
<div className="fixed bottom-0 left-0 right-0">
|
||||
<div className="bg-gradient-to-t from-white to-white/0 h-8 w-full"></div>
|
||||
<div className="bg-white p-4 pt-0">
|
||||
<div className="bg-white p-4 pt-0 flex flex-col gap-2">
|
||||
<Link
|
||||
className={cn('flex gap-2', buttonVariants())}
|
||||
href={`/${organizationId}/${projectId}/reports`}
|
||||
>
|
||||
<PlusIcon size={16} />
|
||||
Create a report
|
||||
</Link>
|
||||
<LayoutOrganizationSelector organizations={organizations} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,12 +9,13 @@ interface AppLayoutProps {
|
||||
children: React.ReactNode;
|
||||
params: {
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default async function AppLayout({
|
||||
children,
|
||||
params: { organizationId },
|
||||
params: { organizationId, projectId },
|
||||
}: AppLayoutProps) {
|
||||
const [organizations, dashboards] = await Promise.all([
|
||||
getCurrentOrganizations(),
|
||||
@@ -23,7 +24,9 @@ export default async function AppLayout({
|
||||
|
||||
return (
|
||||
<div id="dashboard">
|
||||
<LayoutSidebar {...{ organizations, dashboards }} />
|
||||
<LayoutSidebar
|
||||
{...{ organizationId, projectId, organizations, dashboards }}
|
||||
/>
|
||||
<div className="lg:pl-72 transition-all">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
|
||||
|
||||
const reports = [
|
||||
{
|
||||
id: 'Unique visitors',
|
||||
id: 'Visitors',
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
@@ -27,20 +27,20 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
|
||||
filters,
|
||||
id: 'A',
|
||||
name: 'session_start',
|
||||
displayName: 'Unique visitors',
|
||||
displayName: 'Visitors',
|
||||
},
|
||||
],
|
||||
breakdowns: [],
|
||||
chartType: 'metric',
|
||||
lineType: 'monotone',
|
||||
interval,
|
||||
name: 'Unique visitors',
|
||||
name: 'Visitors',
|
||||
range,
|
||||
previous,
|
||||
metric: 'sum',
|
||||
},
|
||||
{
|
||||
id: 'Total sessions',
|
||||
id: 'Sessions',
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
@@ -48,20 +48,20 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
|
||||
filters,
|
||||
id: 'A',
|
||||
name: 'session_start',
|
||||
displayName: 'Total sessions',
|
||||
displayName: 'Sessions',
|
||||
},
|
||||
],
|
||||
breakdowns: [],
|
||||
chartType: 'metric',
|
||||
lineType: 'monotone',
|
||||
interval,
|
||||
name: 'Total sessions',
|
||||
name: 'Sessions',
|
||||
range,
|
||||
previous,
|
||||
metric: 'sum',
|
||||
},
|
||||
{
|
||||
id: 'Total pageviews',
|
||||
id: 'Pageviews',
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
@@ -69,14 +69,14 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
|
||||
filters,
|
||||
id: 'A',
|
||||
name: 'screen_view',
|
||||
displayName: 'Total pageviews',
|
||||
displayName: 'Pageviews',
|
||||
},
|
||||
],
|
||||
breakdowns: [],
|
||||
chartType: 'metric',
|
||||
lineType: 'monotone',
|
||||
interval,
|
||||
name: 'Total pageviews',
|
||||
name: 'Pageviews',
|
||||
range,
|
||||
previous,
|
||||
metric: 'sum',
|
||||
|
||||
@@ -42,7 +42,7 @@ export default async function Page({
|
||||
<div className="p-4 flex gap-2 justify-between">
|
||||
<div className="flex gap-2">
|
||||
<OverviewReportRange />
|
||||
<OverviewFiltersDrawer projectId={projectId} />
|
||||
<OverviewFiltersDrawer projectId={projectId} mode="events" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<ServerLiveCounter projectId={projectId} />
|
||||
|
||||
@@ -1,13 +1,35 @@
|
||||
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
|
||||
import { ListProperties } from '@/components/events/ListProperties';
|
||||
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
|
||||
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
|
||||
import { ProfileAvatar } from '@/components/profiles/ProfileAvatar';
|
||||
import { Chart } from '@/components/report/chart';
|
||||
import { GradientBackground } from '@/components/ui/gradient-background';
|
||||
import { KeyValue } from '@/components/ui/key-value';
|
||||
import { Widget, WidgetBody, WidgetHead } from '@/components/Widget';
|
||||
import {
|
||||
eventQueryFiltersParser,
|
||||
eventQueryNamesFilter,
|
||||
} from '@/hooks/useEventQueryFilters';
|
||||
import { getExists } from '@/server/pageExists';
|
||||
import { formatDateTime } from '@/utils/date';
|
||||
import { getProfileName } from '@/utils/getters';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { parseAsInteger } from 'nuqs';
|
||||
|
||||
import { getProfileById, getProfilesByExternalId } from '@mixan/db';
|
||||
import type { GetEventListOptions } from '@mixan/db';
|
||||
import {
|
||||
getConversionEventNames,
|
||||
getEventList,
|
||||
getEventMeta,
|
||||
getEventsCount,
|
||||
getProfileById,
|
||||
getProfilesByExternalId,
|
||||
} from '@mixan/db';
|
||||
import type { IChartInput } from '@mixan/validation';
|
||||
|
||||
import { EventList } from '../../events/event-list';
|
||||
import { StickyBelowHeader } from '../../layout-sticky-below-header';
|
||||
import ListProfileEvents from './list-profile-events';
|
||||
|
||||
interface PageProps {
|
||||
@@ -16,18 +38,90 @@ interface PageProps {
|
||||
profileId: string;
|
||||
organizationId: string;
|
||||
};
|
||||
searchParams: {
|
||||
events?: string;
|
||||
cursor?: string;
|
||||
f?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Page({
|
||||
params: { projectId, profileId, organizationId },
|
||||
searchParams,
|
||||
}: PageProps) {
|
||||
const [profile] = await Promise.all([
|
||||
const eventListOptions: GetEventListOptions = {
|
||||
projectId,
|
||||
profileId,
|
||||
take: 50,
|
||||
cursor: parseAsInteger.parse(searchParams.cursor ?? '') ?? undefined,
|
||||
events: eventQueryNamesFilter.parse(searchParams.events ?? ''),
|
||||
filters: eventQueryFiltersParser.parse(searchParams.f ?? '') ?? undefined,
|
||||
};
|
||||
const [profile, events, count, conversions] = await Promise.all([
|
||||
getProfileById(profileId),
|
||||
getEventList(eventListOptions),
|
||||
getEventsCount(eventListOptions),
|
||||
getConversionEventNames(projectId),
|
||||
getExists(organizationId, projectId),
|
||||
]);
|
||||
const profiles = (
|
||||
await getProfilesByExternalId(profile.external_id, profile.project_id)
|
||||
).filter((item) => item.id !== profile.id);
|
||||
|
||||
const chartSelectedEvents = [
|
||||
{
|
||||
segment: 'event',
|
||||
filters: [
|
||||
{
|
||||
id: 'profile_id',
|
||||
name: 'profile_id',
|
||||
operator: 'is',
|
||||
value: [profileId],
|
||||
},
|
||||
],
|
||||
id: 'A',
|
||||
name: '*',
|
||||
displayName: 'Events',
|
||||
},
|
||||
];
|
||||
|
||||
if (conversions.length) {
|
||||
chartSelectedEvents.push({
|
||||
segment: 'event',
|
||||
filters: [
|
||||
{
|
||||
id: 'profile_id',
|
||||
name: 'profile_id',
|
||||
operator: 'is',
|
||||
value: [profileId],
|
||||
},
|
||||
{
|
||||
id: 'name',
|
||||
name: 'name',
|
||||
operator: 'is',
|
||||
value: conversions.map((c) => c.name),
|
||||
},
|
||||
],
|
||||
id: 'B',
|
||||
name: '*',
|
||||
displayName: 'Conversions',
|
||||
});
|
||||
}
|
||||
|
||||
const profileChart: IChartInput = {
|
||||
projectId,
|
||||
chartType: 'histogram',
|
||||
events: chartSelectedEvents,
|
||||
breakdowns: [],
|
||||
lineType: 'monotone',
|
||||
interval: 'day',
|
||||
name: 'Events',
|
||||
range: '7d',
|
||||
previous: false,
|
||||
metric: 'sum',
|
||||
};
|
||||
|
||||
if (!profile) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
organizationSlug={organizationId}
|
||||
@@ -38,50 +132,41 @@ export default async function Page({
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<StickyBelowHeader className="p-4 flex justify-between">
|
||||
<OverviewFiltersDrawer
|
||||
projectId={projectId}
|
||||
mode="events"
|
||||
nuqsOptions={{ shallow: false }}
|
||||
/>
|
||||
<OverviewFiltersButtons
|
||||
nuqsOptions={{ shallow: false }}
|
||||
className="p-0 justify-end"
|
||||
/>
|
||||
</StickyBelowHeader>
|
||||
<div className="p-4">
|
||||
<div className="grid gap-4 grid-cols-1 md:grid-cols-2 mb-8">
|
||||
<Widget>
|
||||
<WidgetHead>
|
||||
<span className="title">Properties</span>
|
||||
</WidgetHead>
|
||||
<ListProperties
|
||||
data={profile.properties}
|
||||
className="rounded-none border-none"
|
||||
/>
|
||||
<WidgetBody className="flex gap-2 flex-wrap">
|
||||
{Object.entries(profile.properties)
|
||||
.filter(([, value]) => !!value)
|
||||
.map(([key, value]) => (
|
||||
<KeyValue key={key} name={key} value={value} />
|
||||
))}
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
<Widget>
|
||||
<WidgetHead>
|
||||
<span className="title">Linked profile</span>
|
||||
<span className="title">Events per day</span>
|
||||
</WidgetHead>
|
||||
{profiles.length > 0 ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
{profiles.map((profile) => (
|
||||
<div key={profile.id} className="border-b border-border">
|
||||
<WidgetBody className="flex gap-4">
|
||||
<ProfileAvatar {...profile} />
|
||||
<div>
|
||||
<div className="font-medium mt-1">
|
||||
{getProfileName(profile)}
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2 text-muted-foreground text-xs">
|
||||
<span>{profile.id}</span>
|
||||
<span>{formatDateTime(profile.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</WidgetBody>
|
||||
<ListProperties
|
||||
data={profile.properties}
|
||||
className="rounded-none border-none"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4">No linked profiles</div>
|
||||
)}
|
||||
<WidgetBody className="flex gap-2">
|
||||
<Chart {...profileChart} />
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
</div>
|
||||
<ListProfileEvents projectId={projectId} profileId={profileId} />
|
||||
<EventList data={events} count={count} />
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { api } from '@/app/_trpc/client';
|
||||
import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header';
|
||||
import { FullPageEmptyState } from '@/components/FullPageEmptyState';
|
||||
import { Pagination, usePagination } from '@/components/Pagination';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { UsersIcon } from 'lucide-react';
|
||||
import { useQueryState } from 'nuqs';
|
||||
|
||||
import { ProfileListItem } from './profile-list-item';
|
||||
|
||||
interface ListProfilesProps {
|
||||
projectId: string;
|
||||
}
|
||||
export function ListProfiles({ projectId }: ListProfilesProps) {
|
||||
const [query, setQuery] = useQueryState('q');
|
||||
const pagination = usePagination();
|
||||
const profilesQuery = api.profile.list.useQuery(
|
||||
{
|
||||
projectId,
|
||||
query,
|
||||
...pagination,
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
}
|
||||
);
|
||||
const profiles = useMemo(() => profilesQuery.data ?? [], [profilesQuery]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<StickyBelowHeader className="p-4 flex justify-between">
|
||||
<Input
|
||||
placeholder="Search by name"
|
||||
value={query ?? ''}
|
||||
onChange={(event) => setQuery(event.target.value || null)}
|
||||
/>
|
||||
</StickyBelowHeader>
|
||||
<div className="p-4">
|
||||
{profiles.length === 0 ? (
|
||||
<FullPageEmptyState title="No profiles" icon={UsersIcon}>
|
||||
{query ? (
|
||||
<p>
|
||||
No match for <strong>"{query}"</strong>
|
||||
</p>
|
||||
) : (
|
||||
<p>We could not find any profiles on this project</p>
|
||||
)}
|
||||
</FullPageEmptyState>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
{profiles.map((item) => (
|
||||
<ProfileListItem key={item.id} {...item} />
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<Pagination {...pagination} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,62 @@
|
||||
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
|
||||
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
|
||||
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
|
||||
import { eventQueryFiltersParser } from '@/hooks/useEventQueryFilters';
|
||||
import { getExists } from '@/server/pageExists';
|
||||
import { parseAsInteger } from 'nuqs';
|
||||
|
||||
import { ListProfiles } from './list-profiles';
|
||||
import { getProfileList, getProfileListCount } from '@mixan/db';
|
||||
|
||||
import { StickyBelowHeader } from '../layout-sticky-below-header';
|
||||
import { ProfileList } from './profile-list';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
};
|
||||
searchParams: {
|
||||
f?: string;
|
||||
cursor?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const nuqsOptions = {
|
||||
shallow: false,
|
||||
};
|
||||
|
||||
export default async function Page({
|
||||
params: { organizationId, projectId },
|
||||
searchParams: { cursor, f },
|
||||
}: PageProps) {
|
||||
await getExists(organizationId, projectId);
|
||||
const [profiles, count] = await Promise.all([
|
||||
getProfileList({
|
||||
projectId,
|
||||
take: 50,
|
||||
cursor: parseAsInteger.parse(cursor ?? '') ?? undefined,
|
||||
filters: eventQueryFiltersParser.parse(f ?? '') ?? undefined,
|
||||
}),
|
||||
getProfileListCount({
|
||||
projectId,
|
||||
filters: eventQueryFiltersParser.parse(f ?? '') ?? undefined,
|
||||
}),
|
||||
getExists(organizationId, projectId),
|
||||
]);
|
||||
|
||||
return (
|
||||
<PageLayout title="Events" organizationSlug={organizationId}>
|
||||
<ListProfiles projectId={projectId} />
|
||||
<PageLayout title="Profiles" organizationSlug={organizationId}>
|
||||
<StickyBelowHeader className="p-4 flex justify-between">
|
||||
<OverviewFiltersDrawer
|
||||
projectId={projectId}
|
||||
nuqsOptions={nuqsOptions}
|
||||
mode="events"
|
||||
/>
|
||||
<OverviewFiltersButtons
|
||||
className="p-0 justify-end"
|
||||
nuqsOptions={nuqsOptions}
|
||||
/>
|
||||
</StickyBelowHeader>
|
||||
<ProfileList data={profiles} count={count} />
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import type { RouterOutputs } from '@/app/_trpc/client';
|
||||
import { ListProperties } from '@/components/events/ListProperties';
|
||||
import { ExpandableListItem } from '@/components/general/ExpandableListItem';
|
||||
import { ProfileAvatar } from '@/components/profiles/ProfileAvatar';
|
||||
import { KeyValue, KeyValueSubtle } from '@/components/ui/key-value';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import { formatDateTime } from '@/utils/date';
|
||||
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
|
||||
import { getProfileName } from '@/utils/getters';
|
||||
import Link from 'next/link';
|
||||
|
||||
type ProfileListItemProps = RouterOutputs['profile']['list'][number];
|
||||
import type { IServiceProfile } from '@mixan/db';
|
||||
|
||||
type ProfileListItemProps = IServiceProfile;
|
||||
|
||||
export function ProfileListItem(props: ProfileListItemProps) {
|
||||
const { id, properties, createdAt } = props;
|
||||
const params = useAppParams();
|
||||
const [, setFilter] = useEventQueryFilters({ shallow: false });
|
||||
|
||||
const renderContent = () => {
|
||||
return (
|
||||
<>
|
||||
<span>{formatDateTime(createdAt)}</span>
|
||||
<Link
|
||||
<KeyValueSubtle name="Time" value={createdAt.toLocaleString()} />
|
||||
<KeyValueSubtle
|
||||
href={`/${params.organizationId}/${params.projectId}/profiles/${id}`}
|
||||
className="text-black font-medium hover:underline"
|
||||
>
|
||||
See profile
|
||||
</Link>
|
||||
name="Details"
|
||||
value={'See profile'}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -35,7 +35,29 @@ export function ProfileListItem(props: ProfileListItemProps) {
|
||||
content={renderContent()}
|
||||
image={<ProfileAvatar {...props} />}
|
||||
>
|
||||
<ListProperties data={properties} className="rounded-none border-none" />
|
||||
<>
|
||||
{properties && (
|
||||
<div className="p-2">
|
||||
<div className="bg-gradient-to-tr from-slate-100 to-white rounded-md">
|
||||
<div className="p-4 flex flex-col gap-4">
|
||||
<div className="font-medium">Properties</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-2">
|
||||
{Object.entries(properties)
|
||||
.filter(([, value]) => !!value)
|
||||
.map(([key, value]) => (
|
||||
<KeyValue
|
||||
onClick={() => setFilter(`properties.${key}`, value)}
|
||||
key={key}
|
||||
name={key}
|
||||
value={value}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</ExpandableListItem>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import { FullPageEmptyState } from '@/components/FullPageEmptyState';
|
||||
import { Pagination } from '@/components/Pagination';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useCursor } from '@/hooks/useCursor';
|
||||
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
|
||||
import { UsersIcon } from 'lucide-react';
|
||||
|
||||
import type { IServiceProfile } from '@mixan/db';
|
||||
|
||||
import { ProfileListItem } from './profile-list-item';
|
||||
|
||||
interface ProfileListProps {
|
||||
data: IServiceProfile[];
|
||||
count: number;
|
||||
}
|
||||
export function ProfileList({ data, count }: ProfileListProps) {
|
||||
const { cursor, setCursor } = useCursor();
|
||||
const [filters] = useEventQueryFilters();
|
||||
|
||||
return (
|
||||
<Suspense>
|
||||
<div className="p-4">
|
||||
{data.length === 0 ? (
|
||||
<FullPageEmptyState title="No profiles here" icon={UsersIcon}>
|
||||
{cursor !== 0 ? (
|
||||
<>
|
||||
<p>Looks like you have reached the end of the list</p>
|
||||
<Button
|
||||
className="mt-4"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCursor((p) => Math.max(0, p - 1))}
|
||||
>
|
||||
Go back
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{filters.length ? (
|
||||
<p>Could not find any profiles with your filter</p>
|
||||
) : (
|
||||
<p>No profiles have been created yet</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</FullPageEmptyState>
|
||||
) : (
|
||||
<>
|
||||
<Pagination
|
||||
cursor={cursor}
|
||||
setCursor={setCursor}
|
||||
count={count}
|
||||
take={50}
|
||||
/>
|
||||
<div className="flex flex-col gap-4 my-4">
|
||||
{data.map((item) => (
|
||||
<ProfileListItem key={item.id} {...item} />
|
||||
))}
|
||||
</div>
|
||||
<Pagination
|
||||
cursor={cursor}
|
||||
setCursor={setCursor}
|
||||
count={count}
|
||||
take={50}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -48,7 +48,7 @@ export default async function Page({ params: { id } }: PageProps) {
|
||||
<div className="p-4 flex gap-2 justify-between">
|
||||
<div className="flex gap-2">
|
||||
<OverviewReportRange />
|
||||
<OverviewFiltersDrawer projectId={projectId} />
|
||||
<OverviewFiltersDrawer projectId={projectId} mode="events" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<ServerLiveCounter projectId={projectId} />
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
useEventQueryNamesFilter,
|
||||
} from '@/hooks/useEventQueryFilters';
|
||||
import { useEventValues } from '@/hooks/useEventValues';
|
||||
import { useProfileProperties } from '@/hooks/useProfileProperties';
|
||||
import { useProfileValues } from '@/hooks/useProfileValues';
|
||||
import { XIcon } from 'lucide-react';
|
||||
import type { Options as NuqsOptions } from 'nuqs';
|
||||
|
||||
@@ -18,21 +20,25 @@ import type {
|
||||
IChartEventFilterValue,
|
||||
} from '@mixan/validation';
|
||||
|
||||
interface OverviewFiltersProps {
|
||||
export interface OverviewFiltersDrawerContentProps {
|
||||
projectId: string;
|
||||
nuqsOptions?: NuqsOptions;
|
||||
enableEventsFilter?: boolean;
|
||||
mode: 'profiles' | 'events';
|
||||
}
|
||||
|
||||
export function OverviewFiltersDrawerContent({
|
||||
projectId,
|
||||
nuqsOptions,
|
||||
enableEventsFilter,
|
||||
}: OverviewFiltersProps) {
|
||||
mode,
|
||||
}: OverviewFiltersDrawerContentProps) {
|
||||
const [filters, setFilter] = useEventQueryFilters(nuqsOptions);
|
||||
const [event, setEvent] = useEventQueryNamesFilter(nuqsOptions);
|
||||
const eventNames = useEventNames(projectId);
|
||||
const eventProperties = useEventProperties(projectId);
|
||||
const profileProperties = useProfileProperties(projectId);
|
||||
const properties = mode === 'events' ? eventProperties : profileProperties;
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -62,7 +68,7 @@ export function OverviewFiltersDrawerContent({
|
||||
value=""
|
||||
placeholder="Filter by property"
|
||||
label="What do you want to filter by?"
|
||||
items={eventProperties.map((item) => ({
|
||||
items={properties.map((item) => ({
|
||||
label: item,
|
||||
value: item,
|
||||
}))}
|
||||
@@ -74,8 +80,15 @@ export function OverviewFiltersDrawerContent({
|
||||
{filters
|
||||
.filter((filter) => filter.value[0] !== null)
|
||||
.map((filter) => {
|
||||
return (
|
||||
<FilterOption
|
||||
return mode === 'events' ? (
|
||||
<FilterOptionEvent
|
||||
key={filter.name}
|
||||
projectId={projectId}
|
||||
setFilter={setFilter}
|
||||
{...filter}
|
||||
/>
|
||||
) : (
|
||||
<FilterOptionProfile
|
||||
key={filter.name}
|
||||
projectId={projectId}
|
||||
setFilter={setFilter}
|
||||
@@ -88,7 +101,7 @@ export function OverviewFiltersDrawerContent({
|
||||
);
|
||||
}
|
||||
|
||||
export function FilterOption({
|
||||
export function FilterOptionEvent({
|
||||
setFilter,
|
||||
projectId,
|
||||
...filter
|
||||
@@ -131,3 +144,43 @@ export function FilterOption({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FilterOptionProfile({
|
||||
setFilter,
|
||||
projectId,
|
||||
...filter
|
||||
}: IChartEventFilter & {
|
||||
projectId: string;
|
||||
setFilter: (
|
||||
name: string,
|
||||
value: IChartEventFilterValue,
|
||||
operator: IChartEventFilterOperator
|
||||
) => void;
|
||||
}) {
|
||||
const values = useProfileValues(projectId, filter.name);
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 items-center">
|
||||
<div>{filter.name}</div>
|
||||
<Combobox
|
||||
className="flex-1"
|
||||
onChange={(value) => setFilter(filter.name, value, filter.operator)}
|
||||
placeholder={'Select a value'}
|
||||
items={values.map((value) => ({
|
||||
value,
|
||||
label: value,
|
||||
}))}
|
||||
value={String(filter.value[0] ?? '')}
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
setFilter(filter.name, filter.value[0] ?? '', filter.operator)
|
||||
}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,21 +3,13 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
||||
import { FilterIcon } from 'lucide-react';
|
||||
import type { Options as NuqsOptions } from 'nuqs';
|
||||
|
||||
import type { OverviewFiltersDrawerContentProps } from './overview-filters-drawer-content';
|
||||
import { OverviewFiltersDrawerContent } from './overview-filters-drawer-content';
|
||||
|
||||
interface OverviewFiltersDrawerProps {
|
||||
projectId: string;
|
||||
nuqsOptions?: NuqsOptions;
|
||||
enableEventsFilter?: boolean;
|
||||
}
|
||||
|
||||
export function OverviewFiltersDrawer({
|
||||
projectId,
|
||||
nuqsOptions,
|
||||
enableEventsFilter,
|
||||
}: OverviewFiltersDrawerProps) {
|
||||
export function OverviewFiltersDrawer(
|
||||
props: OverviewFiltersDrawerContentProps
|
||||
) {
|
||||
return (
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
@@ -26,11 +18,7 @@ export function OverviewFiltersDrawer({
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="!max-w-lg w-full" side="right">
|
||||
<OverviewFiltersDrawerContent
|
||||
projectId={projectId}
|
||||
nuqsOptions={nuqsOptions}
|
||||
enableEventsFilter={enableEventsFilter}
|
||||
/>
|
||||
<OverviewFiltersDrawerContent {...props} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
|
||||
24
apps/web/src/components/ui/gradient-background.tsx
Normal file
24
apps/web/src/components/ui/gradient-background.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
interface GradientBackgroundProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function GradientBackground({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: GradientBackgroundProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-gradient-to-tr from-slate-100 to-white rounded-md',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="p-4 flex flex-col gap-4">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
apps/web/src/hooks/useProfileProperties.ts
Normal file
10
apps/web/src/hooks/useProfileProperties.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { api } from '@/app/_trpc/client';
|
||||
|
||||
export function useProfileProperties(projectId: string, event?: string) {
|
||||
const query = api.profile.properties.useQuery({
|
||||
projectId: projectId,
|
||||
event,
|
||||
});
|
||||
|
||||
return query.data ?? [];
|
||||
}
|
||||
10
apps/web/src/hooks/useProfileValues.ts
Normal file
10
apps/web/src/hooks/useProfileValues.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { api } from '@/app/_trpc/client';
|
||||
|
||||
export function useProfileValues(projectId: string, property: string) {
|
||||
const query = api.profile.values.useQuery({
|
||||
projectId: projectId,
|
||||
property,
|
||||
});
|
||||
|
||||
return query.data?.values ?? [];
|
||||
}
|
||||
@@ -1,7 +1,14 @@
|
||||
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
|
||||
import {
|
||||
createTRPCRouter,
|
||||
protectedProcedure,
|
||||
publicProcedure,
|
||||
} from '@/server/api/trpc';
|
||||
import { db } from '@/server/db';
|
||||
import { flatten, map, pipe, prop, sort, uniq } from 'ramda';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { chQuery, createSqlBuilder } from '@mixan/db';
|
||||
|
||||
export const profileRouter = createTRPCRouter({
|
||||
list: protectedProcedure
|
||||
.input(
|
||||
@@ -61,4 +68,56 @@ export const profileRouter = createTRPCRouter({
|
||||
},
|
||||
});
|
||||
}),
|
||||
properties: protectedProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.query(async ({ input: { projectId } }) => {
|
||||
const events = await chQuery<{ keys: string[] }>(
|
||||
`SELECT distinct mapKeys(properties) as keys from profiles where project_id = '${projectId}';`
|
||||
);
|
||||
|
||||
const properties = events
|
||||
.flatMap((event) => event.keys)
|
||||
.map((item) => item.replace(/\.([0-9]+)\./g, '.*.'))
|
||||
.map((item) => item.replace(/\.([0-9]+)/g, '[*]'))
|
||||
.map((item) => `properties.${item}`);
|
||||
|
||||
properties.push('external_id', 'first_name', 'last_name', 'email');
|
||||
|
||||
return pipe(
|
||||
sort<string>((a, b) => a.length - b.length),
|
||||
uniq
|
||||
)(properties);
|
||||
}),
|
||||
values: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
property: z.string(),
|
||||
projectId: z.string(),
|
||||
})
|
||||
)
|
||||
.query(async ({ input: { property, projectId } }) => {
|
||||
const { sb, getSql } = createSqlBuilder();
|
||||
sb.from = 'profiles';
|
||||
sb.where.project_id = `project_id = '${projectId}'`;
|
||||
if (property.startsWith('properties.')) {
|
||||
sb.select.values = `distinct mapValues(mapExtractKeyLike(properties, '${property
|
||||
.replace(/^properties\./, '')
|
||||
.replace('.*.', '.%.')}')) as values`;
|
||||
} else {
|
||||
sb.select.values = `${property} as values`;
|
||||
}
|
||||
|
||||
const profiles = await chQuery<{ values: string[] }>(getSql());
|
||||
|
||||
const values = pipe(
|
||||
(data: typeof profiles) => map(prop('values'), data),
|
||||
flatten,
|
||||
uniq,
|
||||
sort((a, b) => a.length - b.length)
|
||||
)(profiles);
|
||||
|
||||
return {
|
||||
values,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user