move sdk packages to its own folder and rename api & dashboard

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-03-11 13:15:44 +01:00
parent 1ca95442b9
commit 6d4f9010d4
318 changed files with 350 additions and 351 deletions

View File

@@ -0,0 +1,252 @@
'use client';
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { CheckboxInput } from '@/components/ui/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useAppParams } from '@/hooks/useAppParams';
import { clipboard } from '@/utils/clipboard';
import { zodResolver } from '@hookform/resolvers/zod';
import { Copy, SaveIcon } from 'lucide-react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import type { SubmitHandler } from 'react-hook-form';
import { Controller, useForm, useWatch } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import { api, handleError } from '../../../_trpc/client';
const validation = z.object({
name: z.string().min(1),
domain: z.string().optional(),
withSecret: z.boolean().optional(),
});
type IForm = z.infer<typeof validation>;
export function CreateClient() {
const [open, setOpen] = useState(false);
const { organizationId, projectId } = useAppParams();
const clients = api.client.list.useQuery({
organizationId,
});
const clientsCount = clients.data?.length;
useEffect(() => {
if (clientsCount === 0) {
setOpen(true);
}
}, [clientsCount]);
const router = useRouter();
const form = useForm<IForm>({
resolver: zodResolver(validation),
defaultValues: {
withSecret: false,
name: '',
domain: '',
},
});
const mutation = api.client.create2.useMutation({
onError: handleError,
onSuccess() {
toast.success('Client created');
router.refresh();
},
});
const onSubmit: SubmitHandler<IForm> = (values) => {
mutation.mutate({
name: values.name,
domain: values.withSecret ? undefined : values.domain,
organizationId,
projectId,
});
};
const watch = useWatch({
control: form.control,
name: 'withSecret',
});
return (
<Dialog open={open}>
{mutation.isSuccess ? (
<>
<DialogContent
className="sm:max-w-[425px]"
onClose={() => setOpen(false)}
>
<DialogHeader>
<DialogTitle>Success</DialogTitle>
<DialogDescription>
{mutation.data.clientSecret
? 'Use your client id and secret with our SDK to send events to us. '
: 'Use your client id with our SDK to send events to us. '}
See our{' '}
<Link href="https//openpanel.dev/docs" className="underline">
documentation
</Link>
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<button
className="mt-4 text-left"
onClick={() => clipboard(mutation.data.clientId)}
>
<Label>Client ID</Label>
<div className="flex items-center justify-between rounded bg-gray-100 p-2 px-3">
{mutation.data.clientId}
<Copy size={16} />
</div>
</button>
{mutation.data.clientSecret ? (
<button
className="mt-4 text-left"
onClick={() => clipboard(mutation.data.clientId)}
>
<Label>Secret</Label>
<div className="flex items-center justify-between rounded bg-gray-100 p-2 px-3">
{mutation.data.clientSecret}
<Copy size={16} />
</div>
</button>
) : (
<div className="mt-4 text-left">
<Label>Cors settings</Label>
<div className="flex items-center justify-between rounded bg-gray-100 p-2 px-3">
{mutation.data.cors}
</div>
<div className="text-sm italic mt-1">
You can update cors settings{' '}
<Link className="underline" href="/qwe/qwe/">
here
</Link>
</div>
</div>
)}
</div>
<DialogFooter>
<Button
type="button"
variant={'secondary'}
onClick={() => setOpen(false)}
>
Close
</Button>
</DialogFooter>
</DialogContent>
</>
) : (
<>
<DialogContent
className="sm:max-w-[425px]"
onClose={() => setOpen(false)}
>
<DialogHeader>
<DialogTitle>Let's connect</DialogTitle>
<DialogDescription>
Create a client so you can start send events to us 🚀
</DialogDescription>
</DialogHeader>
<form
className="flex flex-col gap-4"
onSubmit={form.handleSubmit(onSubmit)}
>
<div className="grid gap-4 py-4">
<div>
<Label>Client name</Label>
<Input
placeholder="Eg. My App Client"
error={form.formState.errors.name?.message}
{...form.register('name')}
/>
</div>
<Controller
name="withSecret"
control={form.control}
render={({ field }) => (
<CheckboxInput
defaultChecked={field.value}
onCheckedChange={(checked) => {
field.onChange(!checked);
}}
>
This is a website
</CheckboxInput>
)}
/>
<div>
<Label>Your domain name</Label>
<Input
placeholder="https://...."
error={form.formState.errors.domain?.message}
{...form.register('domain')}
disabled={watch}
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant={'secondary'}
onClick={() => setOpen(false)}
>
Cancel
</Button>
<Button
type="submit"
icon={SaveIcon}
loading={mutation.isLoading}
>
Create
</Button>
</DialogFooter>
</form>
</DialogContent>
</>
)}
</Dialog>
);
}
// <div>
// <div className="text-lg">
// Select your framework and we'll generate a client for you.
// </div>
// <div className="flex flex-wrap gap-2 mt-8">
// <FeatureButton
// name="React"
// logo="https://upload.wikimedia.org/wikipedia/commons/thumb/a/a7/React-icon.svg/2300px-React-icon.svg.png"
// />
// <FeatureButton
// name="React Native"
// logo="https://upload.wikimedia.org/wikipedia/commons/thumb/a/a7/React-icon.svg/2300px-React-icon.svg.png"
// />
// <FeatureButton
// name="Next.js"
// logo="https://static-00.iconduck.com/assets.00/nextjs-icon-512x512-y563b8iq.png"
// />
// <FeatureButton
// name="Remix"
// logo="https://www.datocms-assets.com/205/1642515307-square-logo.svg"
// />
// <FeatureButton
// name="Vue"
// logo="https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQ0UhQnp6TUPCwAr3ruTEwBDiTN5HLAWaoUD3AJIgtepQ&s"
// />
// <FeatureButton
// name="HTML"
// logo="https://upload.wikimedia.org/wikipedia/commons/thumb/6/61/HTML5_logo_and_wordmark.svg/240px-HTML5_logo_and_wordmark.svg.png"
// />
// </div>
// </div>

View File

@@ -0,0 +1,140 @@
'use client';
import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { LazyChart } from '@/components/report/chart/LazyChart';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useAppParams } from '@/hooks/useAppParams';
import { cn } from '@/utils/cn';
import { ChevronRight, MoreHorizontal, PlusIcon, Trash } from 'lucide-react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import {
getDefaultIntervalByDates,
getDefaultIntervalByRange,
} from '@mixan/constants';
import type { getReportsByDashboardId } from '@mixan/db';
import { OverviewReportRange } from '../../overview-sticky-header';
interface ListReportsProps {
reports: Awaited<ReturnType<typeof getReportsByDashboardId>>;
}
export function ListReports({ reports }: ListReportsProps) {
const router = useRouter();
const params = useAppParams<{ dashboardId: string }>();
const { range, startDate, endDate } = useOverviewOptions();
return (
<>
<StickyBelowHeader className="p-4 items-center justify-between flex">
<OverviewReportRange />
<Button
icon={PlusIcon}
onClick={() => {
router.push(
`/${params.organizationId}/${
params.projectId
}/reports?${new URLSearchParams({
dashboardId: params.dashboardId,
}).toString()}`
);
}}
>
<span className="max-sm:hidden">Create report</span>
<span className="sm:hidden">Report</span>
</Button>
</StickyBelowHeader>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 p-4">
{reports.map((report) => {
const chartRange = report.range; // timeRanges[report.range];
return (
<div className="card" key={report.id}>
<Link
href={`/${params.organizationId}/${params.projectId}/reports/${report.id}`}
className="flex border-b border-border p-4 leading-none [&_svg]:hover:opacity-100 items-center justify-between"
shallow
>
<div>
<div className="font-medium">{report.name}</div>
{chartRange !== null && (
<div className="mt-2 text-sm flex gap-2">
<span
className={
range !== null || (startDate && endDate)
? 'line-through'
: ''
}
>
{chartRange}
</span>
{startDate && endDate ? (
<span>Custom dates</span>
) : (
range !== null && <span>{range}</span>
)}
</div>
)}
</div>
<div className="flex items-center gap-4">
<DropdownMenu>
<DropdownMenuTrigger className="h-8 w-8 hover:border rounded justify-center items-center flex">
<MoreHorizontal size={16} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]">
<DropdownMenuGroup>
<DropdownMenuItem
className="text-destructive"
onClick={(event) => {
// event.stopPropagation();
// deletion.mutate({
// reportId: report.id,
// });
}}
>
<Trash size={16} className="mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<ChevronRight
className="opacity-10 transition-opacity"
size={16}
/>
</div>
</Link>
<div
className={cn(
'p-4',
report.chartType === 'bar' && 'overflow-auto max-h-[300px]'
)}
>
<LazyChart
{...report}
range={range ?? report.range}
startDate={startDate}
endDate={endDate}
interval={
getDefaultIntervalByDates(startDate, endDate) ||
(range ? getDefaultIntervalByRange(range) : report.interval)
}
editMode={false}
/>
</div>
</div>
);
})}
</div>
</>
);
}

View File

@@ -0,0 +1,35 @@
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { getExists } from '@/server/pageExists';
import { notFound } from 'next/navigation';
import { getDashboardById, getReportsByDashboardId } from '@mixan/db';
import { ListReports } from './list-reports';
interface PageProps {
params: {
organizationId: string;
projectId: string;
dashboardId: string;
};
}
export default async function Page({
params: { organizationId, projectId, dashboardId },
}: PageProps) {
const [dashboard, reports] = await Promise.all([
getDashboardById(dashboardId, projectId),
getReportsByDashboardId(dashboardId),
getExists(organizationId),
]);
if (!dashboard) {
return notFound();
}
return (
<PageLayout title={dashboard.name} organizationSlug={organizationId}>
<ListReports reports={reports} />
</PageLayout>
);
}

View File

@@ -0,0 +1,26 @@
'use client';
import { Button } from '@/components/ui/button';
import { pushModal } from '@/modals';
import { PlusIcon } from 'lucide-react';
import { StickyBelowHeader } from '../layout-sticky-below-header';
export function HeaderDashboards() {
return (
<StickyBelowHeader>
<div className="p-4 flex justify-between items-center">
<div />
<Button
icon={PlusIcon}
onClick={() => {
pushModal('AddDashboard');
}}
>
<span className="max-sm:hidden">Create dashboard</span>
<span className="sm:hidden">Dashboard</span>
</Button>
</div>
</StickyBelowHeader>
);
}

View File

@@ -0,0 +1,105 @@
'use client';
import { api, handleErrorToastOptions } from '@/app/_trpc/client';
import { Card, CardActions, CardActionsItem } from '@/components/Card';
import { FullPageEmptyState } from '@/components/FullPageEmptyState';
import { Button } from '@/components/ui/button';
import { ToastAction } from '@/components/ui/toast';
import { useAppParams } from '@/hooks/useAppParams';
import { pushModal } from '@/modals';
import { LayoutPanelTopIcon, Pencil, PlusIcon, Trash } from 'lucide-react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import type { IServiceDashboards } from '@mixan/db';
interface ListDashboardsProps {
dashboards: IServiceDashboards;
}
export function ListDashboards({ dashboards }: ListDashboardsProps) {
const router = useRouter();
const params = useAppParams();
const { organizationId, projectId } = params;
const deletion = api.dashboard.delete.useMutation({
onError: (error, variables) => {
return handleErrorToastOptions({
action: {
label: 'Force delete',
onClick: () => {
deletion.mutate({
forceDelete: true,
id: variables.id,
});
},
},
})(error);
},
onSuccess() {
router.refresh();
toast('Success', {
description: 'Dashboard deleted.',
});
},
});
if (dashboards.length === 0) {
return (
<FullPageEmptyState title="No dashboards" icon={LayoutPanelTopIcon}>
<p>You have not created any dashboards for this project yet</p>
<Button
onClick={() => pushModal('AddDashboard')}
className="mt-14"
icon={PlusIcon}
>
Create dashboard
</Button>
</FullPageEmptyState>
);
}
return (
<>
<div className="grid sm:grid-cols-2 gap-4 p-4">
{dashboards.map((item) => (
<Card key={item.id} hover>
<div>
<Link
href={`/${organizationId}/${projectId}/dashboards/${item.id}`}
className="block p-4 flex flex-col"
>
<span className="font-medium">{item.name}</span>
</Link>
</div>
<CardActions>
<CardActionsItem className="w-full" asChild>
<button
onClick={() => {
pushModal('EditDashboard', item);
}}
>
<Pencil size={16} />
Edit
</button>
</CardActionsItem>
<CardActionsItem className="text-destructive w-full" asChild>
<button
onClick={() => {
deletion.mutate({
id: item.id,
});
}}
>
<Trash size={16} />
Delete
</button>
</CardActionsItem>
</CardActions>
</Card>
))}
</div>
</>
);
}

View File

@@ -0,0 +1,30 @@
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { getExists } from '@/server/pageExists';
import { getDashboardsByProjectId } from '@mixan/db';
import { HeaderDashboards } from './header-dashboards';
import { ListDashboards } from './list-dashboards';
interface PageProps {
params: {
projectId: string;
organizationId: string;
};
}
export default async function Page({
params: { projectId, organizationId },
}: PageProps) {
const [dashboards] = await Promise.all([
getDashboardsByProjectId(projectId),
await getExists(organizationId, projectId),
]);
return (
<PageLayout title="Dashboards" organizationSlug={organizationId}>
{dashboards.length > 0 && <HeaderDashboards />}
<ListDashboards dashboards={dashboards} />
</PageLayout>
);
}

View File

@@ -0,0 +1,42 @@
import { ChartSwitchShortcut } from '@/components/report/chart';
import type { IChartEvent } from '@mixan/validation';
interface Props {
projectId: string;
events?: string[];
filters?: any[];
}
export function EventChart({ projectId, filters, events }: Props) {
const fallback: IChartEvent[] = [
{
id: 'A',
name: '*',
displayName: 'All events',
segment: 'event',
filters: filters ?? [],
},
];
return (
<div className="card p-4 mb-8">
<ChartSwitchShortcut
projectId={projectId}
range="1m"
chartType="histogram"
events={
events && events.length > 0
? events.map((name) => ({
id: name,
name,
displayName: name,
segment: 'event',
filters: filters ?? [],
}))
: fallback
}
/>
</div>
);
}

View File

@@ -0,0 +1,218 @@
'use client';
import type { Dispatch, SetStateAction } from 'react';
import { ChartSwitchShortcut } from '@/components/report/chart';
import { KeyValue } from '@/components/ui/key-value';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import {
useEventQueryFilters,
useEventQueryNamesFilter,
} from '@/hooks/useEventQueryFilters';
import { round } from 'mathjs';
import type { IServiceCreateEventPayload } from '@mixan/db';
interface Props {
event: IServiceCreateEventPayload;
open: boolean;
setOpen: Dispatch<SetStateAction<boolean>>;
}
export function EventDetails({ event, open, setOpen }: Props) {
const { name } = event;
const [, setFilter] = useEventQueryFilters({ shallow: false });
const [, setEvents] = useEventQueryNamesFilter({ shallow: false });
const common = [
{
name: 'Duration',
value: event.duration ? round(event.duration / 1000, 1) : undefined,
},
{
name: 'Referrer',
value: event.referrer,
onClick() {
setFilter('referrer', event.referrer ?? '');
},
},
{
name: 'Referrer name',
value: event.referrerName,
onClick() {
setFilter('referrer_name', event.referrerName ?? '');
},
},
{
name: 'Referrer type',
value: event.referrerType,
onClick() {
setFilter('referrer_type', event.referrerType ?? '');
},
},
{
name: 'Brand',
value: event.brand,
onClick() {
setFilter('brand', event.brand ?? '');
},
},
{
name: 'Model',
value: event.model,
onClick() {
setFilter('model', event.model ?? '');
},
},
{
name: 'Browser',
value: event.browser,
onClick() {
setFilter('browser', event.browser ?? '');
},
},
{
name: 'Browser version',
value: event.browserVersion,
onClick() {
setFilter('browser_version', event.browserVersion ?? '');
},
},
{
name: 'OS',
value: event.os,
onClick() {
setFilter('os', event.os ?? '');
},
},
{
name: 'OS version',
value: event.osVersion,
onClick() {
setFilter('os_version', event.osVersion ?? '');
},
},
{
name: 'City',
value: event.city,
onClick() {
setFilter('city', event.city ?? '');
},
},
{
name: 'Region',
value: event.region,
onClick() {
setFilter('region', event.region ?? '');
},
},
{
name: 'Country',
value: event.country,
onClick() {
setFilter('country', event.country ?? '');
},
},
{
name: 'Continent',
value: event.continent,
onClick() {
setFilter('continent', event.continent ?? '');
},
},
{
name: 'Device',
value: event.device,
onClick() {
setFilter('device', event.device ?? '');
},
},
].filter((item) => typeof item.value === 'string' && item.value);
const properties = Object.entries(event.properties)
.map(([name, value]) => ({
name,
value: value as string | number | undefined,
}))
.filter((item) => typeof item.value === 'string' && item.value);
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent>
<div>
<div className="flex flex-col gap-8">
<SheetHeader>
<SheetTitle>{name.replace('_', ' ')}</SheetTitle>
</SheetHeader>
{properties.length > 0 && (
<div>
<div className="text-sm font-medium mb-2">Params</div>
<div className="flex gap-2 flex-wrap">
{properties.map((item) => (
<KeyValue
key={item.name}
name={item.name}
value={item.value}
onClick={() => {
setFilter(
`properties.${item.name}`,
item.value ? String(item.value) : '',
'is'
);
}}
/>
))}
</div>
</div>
)}
<div>
<div className="text-sm font-medium mb-2">Common</div>
<div className="flex gap-2 flex-wrap">
{common.map((item) => (
<KeyValue
key={item.name}
name={item.name}
value={item.value}
onClick={item.onClick}
/>
))}
</div>
</div>
<div>
<div className="flex justify-between text-sm font-medium mb-2">
<div>Similar events</div>
<button
className="hover:underline text-muted-foreground"
onClick={() => {
setEvents([event.name]);
setOpen(false);
}}
>
Show all
</button>
</div>
<ChartSwitchShortcut
projectId={event.projectId}
chartType="histogram"
events={[
{
id: 'A',
name: event.name,
displayName: 'Similar events',
segment: 'event',
filters: [],
},
]}
/>
</div>
</div>
</div>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,177 @@
import type { Dispatch, SetStateAction } from 'react';
import { useEffect, useState } from 'react';
import { api } from '@/app/_trpc/client';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { cn } from '@/utils/cn';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import type { IServiceCreateEventPayload } from '@mixan/db';
import {
EventIconColors,
EventIconMapper,
EventIconRecords,
} from './event-icon';
interface Props {
event: IServiceCreateEventPayload;
open: boolean;
setOpen: Dispatch<SetStateAction<boolean>>;
}
export function EventEdit({ event, open, setOpen }: Props) {
const router = useRouter();
const { name, meta, projectId } = event;
const [selectedIcon, setIcon] = useState(
meta?.icon ??
EventIconRecords[name]?.icon ??
EventIconRecords.default?.icon ??
''
);
const [selectedColor, setColor] = useState(
meta?.color ??
EventIconRecords[name]?.color ??
EventIconRecords.default?.color ??
''
);
const [conversion, setConversion] = useState(!!meta?.conversion);
useEffect(() => {
if (meta?.icon) {
setIcon(meta.icon);
}
}, [meta?.icon]);
useEffect(() => {
if (meta?.color) {
setColor(meta.color);
}
}, [meta?.color]);
useEffect(() => {
setConversion(meta?.conversion ?? false);
}, [meta?.conversion]);
const SelectedIcon = EventIconMapper[selectedIcon]!;
const mutation = api.event.updateEventMeta.useMutation({
onSuccess() {
// @ts-expect-error
document.querySelector('#close-sheet')?.click();
toast('Event updated');
router.refresh();
},
});
const getBg = (color: string) => `bg-${color}-200`;
const getText = (color: string) => `text-${color}-700`;
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent>
<SheetHeader>
<SheetTitle>Edit "{name}"</SheetTitle>
</SheetHeader>
<div className="flex flex-col gap-8 my-8">
<div>
<Label className="mb-4 block">Conversion</Label>
<label className="cursor-pointer flex items-center select-none border border-border rounded-md p-4 gap-4">
<Checkbox
checked={conversion}
onCheckedChange={(checked) => {
if (checked === 'indeterminate') return;
setConversion(checked);
}}
/>
<div>
<span>Yes, this event is important!</span>
</div>
</label>
</div>
<div>
<Label className="mb-4 block">Pick a icon</Label>
<div className="flex flex-wrap gap-4">
{Object.entries(EventIconMapper).map(([name, Icon]) => (
<button
key={name}
onClick={() => {
setIcon(name);
}}
className={cn(
'flex-shrink-0 rounded-md w-8 h-8 cursor-pointer inline-flex transition-all bg-slate-100 flex items-center justify-center',
name === selectedIcon
? 'scale-110 ring-1 ring-black'
: '[&_svg]:opacity-50'
)}
>
<Icon size={16} />
</button>
))}
</div>
</div>
<div>
<Label className="mb-4 block">Pick a color</Label>
<div className="flex flex-wrap gap-4">
{EventIconColors.map((color) => (
<button
key={color}
onClick={() => {
setColor(color);
}}
className={cn(
'flex-shrink-0 rounded-md w-8 h-8 cursor-pointer transition-all flex justify-center items-center',
color === selectedColor ? 'ring-1 ring-black' : '',
getBg(color)
)}
>
{SelectedIcon ? (
<SelectedIcon size={16} />
) : (
<svg
className={`${getText(color)} opacity-70`}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12.1" cy="12.1" r="4" />
</svg>
)}
</button>
))}
</div>
</div>
</div>
<SheetFooter>
<Button
className="w-full"
onClick={() =>
mutation.mutate({
projectId,
name,
icon: selectedIcon,
color: selectedColor,
conversion,
})
}
>
Update event
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,161 @@
import { useEffect, useState } from 'react';
import { api } from '@/app/_trpc/client';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet';
import { cn } from '@/utils/cn';
import type { VariantProps } from 'class-variance-authority';
import { cva } from 'class-variance-authority';
import type { LucideIcon } from 'lucide-react';
import * as Icons from 'lucide-react';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import type { EventMeta } from '@mixan/db';
const variants = cva('flex items-center justify-center shrink-0 rounded-full', {
variants: {
size: {
sm: 'w-6 h-6',
default: 'w-10 h-10',
},
},
defaultVariants: {
size: 'default',
},
});
type EventIconProps = VariantProps<typeof variants> & {
name: string;
meta?: EventMeta;
projectId: string;
className?: string;
};
export const EventIconRecords: Record<
string,
{
icon: string;
color: string;
}
> = {
default: {
icon: 'BotIcon',
color: 'slate',
},
screen_view: {
icon: 'MonitorPlayIcon',
color: 'blue',
},
session_start: {
icon: 'ActivityIcon',
color: 'teal',
},
link_out: {
icon: 'ExternalLinkIcon',
color: 'indigo',
},
};
export const EventIconMapper: Record<string, LucideIcon> = {
DownloadIcon: Icons.DownloadIcon,
BotIcon: Icons.BotIcon,
BoxIcon: Icons.BoxIcon,
AccessibilityIcon: Icons.AccessibilityIcon,
ActivityIcon: Icons.ActivityIcon,
AirplayIcon: Icons.AirplayIcon,
AlarmCheckIcon: Icons.AlarmCheckIcon,
AlertTriangleIcon: Icons.AlertTriangleIcon,
BellIcon: Icons.BellIcon,
BoltIcon: Icons.BoltIcon,
CandyIcon: Icons.CandyIcon,
ConeIcon: Icons.ConeIcon,
MonitorPlayIcon: Icons.MonitorPlayIcon,
PizzaIcon: Icons.PizzaIcon,
SearchIcon: Icons.SearchIcon,
HomeIcon: Icons.HomeIcon,
MailIcon: Icons.MailIcon,
AngryIcon: Icons.AngryIcon,
AnnoyedIcon: Icons.AnnoyedIcon,
ArchiveIcon: Icons.ArchiveIcon,
AwardIcon: Icons.AwardIcon,
BadgeCheckIcon: Icons.BadgeCheckIcon,
BeerIcon: Icons.BeerIcon,
BluetoothIcon: Icons.BluetoothIcon,
BookIcon: Icons.BookIcon,
BookmarkIcon: Icons.BookmarkIcon,
BookCheckIcon: Icons.BookCheckIcon,
BookMinusIcon: Icons.BookMinusIcon,
BookPlusIcon: Icons.BookPlusIcon,
CalendarIcon: Icons.CalendarIcon,
ClockIcon: Icons.ClockIcon,
CogIcon: Icons.CogIcon,
LoaderIcon: Icons.LoaderIcon,
CrownIcon: Icons.CrownIcon,
FileIcon: Icons.FileIcon,
KeyRoundIcon: Icons.KeyRoundIcon,
GemIcon: Icons.GemIcon,
GlobeIcon: Icons.GlobeIcon,
LightbulbIcon: Icons.LightbulbIcon,
LightbulbOffIcon: Icons.LightbulbOffIcon,
LockIcon: Icons.LockIcon,
MessageCircleIcon: Icons.MessageCircleIcon,
RadioIcon: Icons.RadioIcon,
RepeatIcon: Icons.RepeatIcon,
ShareIcon: Icons.ShareIcon,
ExternalLinkIcon: Icons.ExternalLinkIcon,
};
export const EventIconColors = [
'rose',
'pink',
'fuchsia',
'purple',
'violet',
'indigo',
'blue',
'sky',
'cyan',
'teal',
'emerald',
'green',
'lime',
'yellow',
'amber',
'orange',
'red',
'stone',
'neutral',
'zinc',
'grey',
'slate',
];
export function EventIcon({ className, name, size, meta }: EventIconProps) {
const Icon =
EventIconMapper[
meta?.icon ??
EventIconRecords[name]?.icon ??
EventIconRecords.default?.icon ??
''
]!;
const color =
meta?.color ??
EventIconRecords[name]?.color ??
EventIconRecords.default?.color ??
'';
return (
<div className={cn(`bg-${color}-200`, variants({ size }), className)}>
<Icon size={20} className={`text-${color}-700`} />
</div>
);
}

View File

@@ -0,0 +1,133 @@
'use client';
import { useState } from 'react';
import { ProfileAvatar } from '@/components/profiles/ProfileAvatar';
import { SerieIcon } from '@/components/report/chart/SerieIcon';
import { KeyValueSubtle } from '@/components/ui/key-value';
import { useAppParams } from '@/hooks/useAppParams';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { useNumber } from '@/hooks/useNumerFormatter';
import { cn } from '@/utils/cn';
import { getProfileName } from '@/utils/getters';
import type { IServiceCreateEventPayload } from '@mixan/db';
import { EventDetails } from './event-details';
import { EventEdit } from './event-edit';
import { EventIcon } from './event-icon';
type EventListItemProps = IServiceCreateEventPayload;
export function EventListItem(props: EventListItemProps) {
const {
profile,
createdAt,
name,
path,
duration,
brand,
browser,
city,
country,
device,
os,
projectId,
meta,
} = props;
const params = useAppParams();
const [, setFilter] = useEventQueryFilters({ shallow: false });
const [isDetailsOpen, setIsDetailsOpen] = useState(false);
const [isEditOpen, setIsEditOpen] = useState(false);
const number = useNumber();
return (
<>
<EventDetails
event={props}
open={isDetailsOpen}
setOpen={setIsDetailsOpen}
/>
<EventEdit event={props} open={isEditOpen} setOpen={setIsEditOpen} />
<div
className={cn(
'p-4 flex flex-col gap-2 hover:bg-slate-50 rounded-lg transition-colors',
meta?.conversion && `bg-${meta.color}-50 hover:bg-${meta.color}-100`
)}
>
<div className="flex justify-between items-center">
<div className="flex gap-4 items-center">
<button onClick={() => setIsEditOpen(true)}>
<EventIcon name={name} meta={meta} projectId={projectId} />
</button>
<button
onClick={() => setIsDetailsOpen(true)}
className="text-left font-semibold hover:underline"
>
{name.replace(/_/g, ' ')}
</button>
</div>
<div className="text-muted-foreground text-sm">
{createdAt.toLocaleTimeString()}
</div>
</div>
<div className="flex flex-wrap gap-2">
{path && (
<KeyValueSubtle
name={'Path'}
value={
path +
(duration
? ` (${number.shortWithUnit(duration / 1000, 'min')})`
: '')
}
/>
)}
{profile && (
<KeyValueSubtle
name={'Profile'}
value={
<>
{profile.avatar && <ProfileAvatar size="xs" {...profile} />}
{getProfileName(profile)}
</>
}
href={`/${params.organizationId}/${params.projectId}/profiles/${profile.id}`}
/>
)}
<KeyValueSubtle
name={'From'}
onClick={() => setFilter('city', city)}
value={
<>
{country && <SerieIcon name={country} />}
{city}
</>
}
/>
<KeyValueSubtle
name={'Device'}
onClick={() => setFilter('device', device)}
value={
<>
{device && <SerieIcon name={device} />}
{brand || os}
</>
}
/>
{browser !== 'WebKit' && browser !== '' && (
<KeyValueSubtle
name={'Browser'}
onClick={() => setFilter('browser', browser)}
value={
<>
{browser && <SerieIcon name={browser} />}
{browser}
</>
}
/>
)}
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,90 @@
'use client';
import { Fragment, Suspense } from 'react';
import { FullPageEmptyState } from '@/components/FullPageEmptyState';
import { Pagination } from '@/components/Pagination';
import { ChartSwitch, ChartSwitchShortcut } from '@/components/report/chart';
import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/useAppParams';
import { useCursor } from '@/hooks/useCursor';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { isSameDay } from 'date-fns';
import { GanttChartIcon } from 'lucide-react';
import type { IServiceCreateEventPayload } from '@mixan/db';
import { EventListItem } from './event-list-item';
import EventListener from './event-listener';
function showDateHeader(a: Date, b?: Date) {
if (!b) return true;
return !isSameDay(a, b);
}
interface EventListProps {
data: IServiceCreateEventPayload[];
count: number;
}
export function EventList({ data, count }: EventListProps) {
const { cursor, setCursor } = useCursor();
const [filters] = useEventQueryFilters();
return (
<>
{data.length === 0 ? (
<FullPageEmptyState title="No events here" icon={GanttChartIcon}>
{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 events with your filter</p>
) : (
<p>We have not recieved any events yet</p>
)}
</>
)}
</FullPageEmptyState>
) : (
<>
<div className="flex flex-col md:flex-row justify-between gap-2">
<EventListener />
<Pagination
cursor={cursor}
setCursor={setCursor}
count={count}
take={50}
/>
</div>
<div className="flex flex-col my-4 card p-4 gap-0.5">
{data.map((item, index, list) => (
<Fragment key={item.id}>
{showDateHeader(item.createdAt, list[index - 1]?.createdAt) && (
<div className="text-muted-foreground font-medium text-sm [&:not(:first-child)]:mt-12 text-center">
{item.createdAt.toLocaleDateString()}
</div>
)}
<EventListItem {...item} />
</Fragment>
))}
</div>
<Pagination
cursor={cursor}
setCursor={setCursor}
count={count}
take={50}
/>
</>
)}
</>
);
}

View File

@@ -0,0 +1,92 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useAppParams } from '@/hooks/useAppParams';
import { cn } from '@/utils/cn';
import { useQueryClient } from '@tanstack/react-query';
import dynamic from 'next/dynamic';
import { useRouter } from 'next/navigation';
import useWebSocket from 'react-use-websocket';
import { toast } from 'sonner';
import type { IServiceCreateEventPayload } from '@mixan/db';
const AnimatedNumbers = dynamic(() => import('react-animated-numbers'), {
ssr: false,
loading: () => <div>0</div>,
});
export default function EventListener() {
const router = useRouter();
const { projectId } = useAppParams();
const ws = String(process.env.NEXT_PUBLIC_API_URL)
.replace(/^https/, 'wss')
.replace(/^http/, 'ws');
const [counter, setCounter] = useState(0);
const [socketUrl] = useState(`${ws}/live/events/${projectId}`);
useWebSocket(socketUrl, {
shouldReconnect: () => true,
onMessage(payload) {
const event = JSON.parse(payload.data) as IServiceCreateEventPayload;
if (event?.name) {
setCounter((prev) => prev + 1);
toast(`New event ${event.name} from ${event.country}!`);
}
},
});
return (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => {
setCounter(0);
router.refresh();
}}
className="bg-white border border-border rounded h-8 px-3 leading-none flex items-center text-sm font-medium gap-2"
>
<div className="relative">
<div
className={cn(
'bg-emerald-500 h-3 w-3 rounded-full animate-ping opacity-100 transition-all'
)}
></div>
<div
className={cn(
'bg-emerald-500 h-3 w-3 rounded-full absolute top-0 left-0 transition-all'
)}
></div>
</div>
{counter === 0 ? (
'Listening to events'
) : (
<>
<AnimatedNumbers
includeComma
transitions={(index) => ({
type: 'spring',
duration: index + 0.3,
damping: 10,
stiffness: 200,
})}
animateToNumber={counter}
locale="en"
/>
new events
</>
)}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{counter === 0 ? 'Listening to new events' : 'Click to refresh'}
</TooltipContent>
</Tooltip>
);
}

View File

@@ -0,0 +1,83 @@
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,
eventQueryNamesFilter,
} from '@/hooks/useEventQueryFilters';
import { getExists } from '@/server/pageExists';
import { parseAsInteger } from 'nuqs';
import { getEventList, getEventsCount } from '@mixan/db';
import { StickyBelowHeader } from '../layout-sticky-below-header';
import { EventChart } from './event-chart';
import { EventList } from './event-list';
interface PageProps {
params: {
projectId: string;
organizationId: string;
};
searchParams: {
events?: string;
cursor?: string;
f?: string;
};
}
const nuqsOptions = {
shallow: false,
};
export default async function Page({
params: { projectId, organizationId },
searchParams,
}: PageProps) {
const filters =
eventQueryFiltersParser.parseServerSide(searchParams.f ?? '') ?? undefined;
const eventsFilter = eventQueryNamesFilter.parseServerSide(
searchParams.events ?? ''
);
const [events, count] = await Promise.all([
getEventList({
cursor:
parseAsInteger.parseServerSide(searchParams.cursor ?? '') ?? undefined,
projectId,
take: 50,
events: eventsFilter,
filters,
}),
getEventsCount({
projectId,
events: eventsFilter,
filters,
}),
getExists(organizationId, projectId),
]);
return (
<PageLayout title="Events" organizationSlug={organizationId}>
<StickyBelowHeader className="p-4 flex justify-between">
<OverviewFiltersDrawer
mode="events"
projectId={projectId}
nuqsOptions={nuqsOptions}
enableEventsFilter
/>
<OverviewFiltersButtons
className="p-0 justify-end"
nuqsOptions={nuqsOptions}
/>
</StickyBelowHeader>
<div className="p-4">
<EventChart
projectId={projectId}
events={eventsFilter}
filters={filters}
/>
<EventList data={events} count={count} />
</div>
</PageLayout>
);
}

View File

@@ -0,0 +1,161 @@
'use client';
import { useEffect } from 'react';
import { useAppParams } from '@/hooks/useAppParams';
import { cn } from '@/utils/cn';
import { useUser } from '@clerk/nextjs';
import {
BuildingIcon,
CogIcon,
DotIcon,
GanttChartIcon,
KeySquareIcon,
LayoutPanelTopIcon,
UserIcon,
UsersIcon,
WallpaperIcon,
WarehouseIcon,
} from 'lucide-react';
import type { LucideProps } from 'lucide-react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import type { IServiceDashboards } from '@mixan/db';
function LinkWithIcon({
href,
icon: Icon,
label,
active: overrideActive,
className,
}: {
href: string;
icon: React.ElementType<LucideProps>;
label: React.ReactNode;
active?: boolean;
className?: string;
}) {
const pathname = usePathname();
const active = overrideActive || href === pathname;
return (
<Link
className={cn(
'text-slate-800 text-sm font-medium flex gap-2 items-center px-3 py-2 transition-colors hover:bg-blue-100 leading-none rounded-md transition-all',
active && 'bg-blue-50',
className
)}
href={href}
>
<Icon size={20} />
<div className="flex-1">{label}</div>
</Link>
);
}
interface LayoutMenuProps {
dashboards: IServiceDashboards;
}
export default function LayoutMenu({ dashboards }: LayoutMenuProps) {
const { user } = useUser();
const pathname = usePathname();
const params = useAppParams();
const hasProjectId =
params.projectId &&
params.projectId !== 'null' &&
params.projectId !== 'undefined';
const projectId = hasProjectId
? params.projectId
: (user?.unsafeMetadata.projectId as string);
useEffect(() => {
if (hasProjectId) {
user?.update({
unsafeMetadata: {
projectId: params.projectId,
},
});
}
}, [params.projectId, hasProjectId]);
return (
<>
<LinkWithIcon
icon={WallpaperIcon}
label="Overview"
href={`/${params.organizationId}/${projectId}`}
/>
<LinkWithIcon
icon={LayoutPanelTopIcon}
label="Dashboards"
href={`/${params.organizationId}/${projectId}/dashboards`}
/>
<LinkWithIcon
icon={GanttChartIcon}
label="Events"
href={`/${params.organizationId}/${projectId}/events`}
/>
<LinkWithIcon
icon={UsersIcon}
label="Profiles"
href={`/${params.organizationId}/${projectId}/profiles`}
/>
<LinkWithIcon
icon={CogIcon}
label="Settings"
href={`/${params.organizationId}/${projectId}/settings/organization`}
/>
{pathname?.includes('/settings/') && (
<div className="pl-7 flex flex-col gap-1">
<LinkWithIcon
icon={BuildingIcon}
label="Organization"
href={`/${params.organizationId}/${projectId}/settings/organization`}
/>
<LinkWithIcon
icon={WarehouseIcon}
label="Projects"
href={`/${params.organizationId}/${projectId}/settings/projects`}
/>
<LinkWithIcon
icon={KeySquareIcon}
label="Clients"
href={`/${params.organizationId}/${projectId}/settings/clients`}
/>
<LinkWithIcon
icon={UserIcon}
label="Profile (yours)"
href={`/${params.organizationId}/${projectId}/settings/profile`}
/>
<LinkWithIcon
icon={UserIcon}
label="References"
href={`/${params.organizationId}/${projectId}/settings/references`}
/>
</div>
)}
{dashboards.length > 0 && (
<div className="mt-8">
<div className="font-medium mb-2 text-sm">Your dashboards</div>
<div className="flex flex-col gap-2">
{dashboards.map((item) => (
<LinkWithIcon
key={item.id}
icon={LayoutPanelTopIcon}
label={
<div className="flex justify-between gap-0.5 items-center">
<span>{item.name}</span>
<span className="text-xs text-muted-foreground">
{item.project.name}
</span>
</div>
}
href={`/${item.organization_slug}/${item.project_id}/dashboards/${item.id}`}
/>
))}
</div>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,47 @@
'use client';
import { Combobox } from '@/components/ui/combobox';
import { useAppParams } from '@/hooks/useAppParams';
import { Building } from 'lucide-react';
import { useRouter } from 'next/navigation';
import type { IServiceOrganization } from '@mixan/db';
interface LayoutOrganizationSelectorProps {
organizations: IServiceOrganization[];
}
export default function LayoutOrganizationSelector({
organizations,
}: LayoutOrganizationSelectorProps) {
const params = useAppParams();
const router = useRouter();
const organization = organizations.find(
(item) => item.slug === params.organizationId
);
if (!organization) {
return null;
}
return (
<Combobox
className="w-full"
placeholder="Select organization"
icon={Building}
value={organization.slug}
items={
organizations
.filter((item) => item.slug)
.map((item) => ({
label: item.name,
value: item.slug!,
})) ?? []
}
onChange={(value) => {
router.push(`/${value}`);
}}
/>
);
}

View File

@@ -0,0 +1,45 @@
'use client';
import { Combobox } from '@/components/ui/combobox';
import { useAppParams } from '@/hooks/useAppParams';
import { usePathname, useRouter } from 'next/navigation';
import type { getProjectsByOrganizationSlug } from '@mixan/db';
interface LayoutProjectSelectorProps {
projects: Awaited<ReturnType<typeof getProjectsByOrganizationSlug>>;
}
export default function LayoutProjectSelector({
projects,
}: LayoutProjectSelectorProps) {
const router = useRouter();
const { organizationId, projectId } = useAppParams();
const pathname = usePathname() || '';
return (
<div>
<Combobox
portal
align="end"
className="w-auto min-w-0 max-sm:max-w-[100px]"
placeholder={'Select project'}
onChange={(value) => {
if (organizationId && projectId) {
const split = pathname.replace(projectId, value).split('/');
// slicing here will remove everything after /{orgId}/{projectId}/dashboards [slice here] /xxx/xxx/xxx
router.push(split.slice(0, 4).join('/'));
} else {
router.push(`/${organizationId}/${value}`);
}
}}
value={projectId}
items={
projects.map((item) => ({
label: item.name,
value: item.id,
})) ?? []
}
/>
</div>
);
}

View File

@@ -0,0 +1,83 @@
'use client';
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';
import type { IServiceDashboards, IServiceOrganization } from '@mixan/db';
import LayoutMenu from './layout-menu';
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();
useEffect(() => {
setActive(false);
}, [pathname]);
return (
<>
<button
onClick={() => setActive(false)}
className={cn(
'fixed top-0 left-0 right-0 bottom-0 backdrop-blur-sm z-30 transition-opacity',
active
? 'opacity-100 pointer-events-auto'
: 'opacity-0 pointer-events-none'
)}
/>
<div
className={cn(
'fixed top-0 left-0 h-screen border-r border-border w-72 bg-white flex flex-col z-30 transition-transform',
'-translate-x-72 lg:-translate-x-0', // responsive
active && 'translate-x-0' // force active on mobile
)}
>
<div className="absolute -right-12 h-16 flex items-center lg:hidden">
<Hamburger toggled={active} onToggle={setActive} size={20} />
</div>
<div className="h-16 border-b border-border px-4 shrink-0 flex items-center">
<Link href="/">
<Logo />
</Link>
</div>
<div className="flex flex-col p-4 gap-2 flex-grow overflow-auto">
<LayoutMenu dashboards={dashboards} />
{/* Placeholder for LayoutOrganizationSelector */}
<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 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>
</div>
</>
);
}

View File

@@ -0,0 +1,22 @@
import { cn } from '@/utils/cn';
interface StickyBelowHeaderProps {
children: React.ReactNode;
className?: string;
}
export function StickyBelowHeader({
children,
className,
}: StickyBelowHeaderProps) {
return (
<div
className={cn(
'md:sticky bg-white border-b border-border z-20 [[id=dashboard]_&]:top-16 [[id=dashboard]_&]:rounded-none rounded-lg top-0',
className
)}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,34 @@
import {
getCurrentOrganizations,
getDashboardsByOrganization,
getDashboardsByProjectId,
} from '@mixan/db';
import { LayoutSidebar } from './layout-sidebar';
interface AppLayoutProps {
children: React.ReactNode;
params: {
organizationId: string;
projectId: string;
};
}
export default async function AppLayout({
children,
params: { organizationId, projectId },
}: AppLayoutProps) {
const [organizations, dashboards] = await Promise.all([
getCurrentOrganizations(),
getDashboardsByProjectId(projectId),
]);
return (
<div id="dashboard">
<LayoutSidebar
{...{ organizationId, projectId, organizations, dashboards }}
/>
<div className="lg:pl-72 transition-all">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,37 @@
'use client';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { ReportRange } from '@/components/report/ReportRange';
import { endOfDay, startOfDay } from 'date-fns';
export function OverviewReportRange() {
const { range, setRange, setEndDate, setStartDate, startDate, endDate } =
useOverviewOptions();
return (
<ReportRange
range={range}
onRangeChange={(value) => {
setRange(value);
setStartDate(null);
setEndDate(null);
}}
dates={{
startDate,
endDate,
}}
onDatesChange={(val) => {
if (!val) return;
if (val.from && val.to) {
setRange(null);
setStartDate(startOfDay(val.from).toISOString());
setEndDate(endOfDay(val.to).toISOString());
} else if (val.from) {
setStartDate(startOfDay(val.from).toISOString());
} else if (val.to) {
setEndDate(endOfDay(val.to).toISOString());
}
}}
/>
);
}

View File

@@ -0,0 +1,27 @@
import { getProjectsByOrganizationSlug } from '@mixan/db';
import LayoutProjectSelector from './layout-project-selector';
interface PageLayoutProps {
children: React.ReactNode;
title: React.ReactNode;
organizationSlug: string;
}
export default async function PageLayout({
children,
title,
organizationSlug,
}: PageLayoutProps) {
const projects = await getProjectsByOrganizationSlug(organizationSlug);
return (
<>
<div className="h-16 border-b border-border flex-shrink-0 sticky top-0 bg-white px-4 flex items-center justify-between z-20 pl-12 lg:pl-4">
<div className="text-xl font-medium">{title}</div>
{projects.length > 0 && <LayoutProjectSelector projects={projects} />}
</div>
<div>{children}</div>
</>
);
}

View File

@@ -0,0 +1,69 @@
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 ServerLiveCounter from '@/components/overview/live-counter';
import { OverviewLiveHistogram } from '@/components/overview/overview-live-histogram';
import { OverviewShare } from '@/components/overview/overview-share';
import OverviewTopDevices from '@/components/overview/overview-top-devices';
import OverviewTopEvents from '@/components/overview/overview-top-events';
import OverviewTopGeo from '@/components/overview/overview-top-geo';
import OverviewTopPages from '@/components/overview/overview-top-pages';
import OverviewTopSources from '@/components/overview/overview-top-sources';
import { getExists } from '@/server/pageExists';
import { db } from '@mixan/db';
import OverviewMetrics from '../../../../components/overview/overview-metrics';
import { CreateClient } from './create-client';
import { StickyBelowHeader } from './layout-sticky-below-header';
import { OverviewReportRange } from './overview-sticky-header';
interface PageProps {
params: {
organizationId: string;
projectId: string;
};
}
export default async function Page({
params: { organizationId, projectId },
}: PageProps) {
const [share] = await Promise.all([
db.shareOverview.findUnique({
where: {
project_id: projectId,
},
}),
getExists(organizationId, projectId),
]);
return (
<PageLayout title="Overview" organizationSlug={organizationId}>
<CreateClient />
<StickyBelowHeader>
<div className="p-4 flex gap-2 justify-between">
<div className="flex gap-2">
<OverviewReportRange />
<OverviewFiltersDrawer projectId={projectId} mode="events" />
</div>
<div className="flex gap-2">
<ServerLiveCounter projectId={projectId} />
<OverviewShare data={share} />
</div>
</div>
<OverviewFiltersButtons />
</StickyBelowHeader>
<div className="p-4 grid gap-4 grid-cols-6">
<div className="col-span-6">
<OverviewLiveHistogram projectId={projectId} />
</div>
<OverviewMetrics projectId={projectId} />
<OverviewTopSources projectId={projectId} />
<OverviewTopPages projectId={projectId} />
<OverviewTopDevices projectId={projectId} />
<OverviewTopEvents projectId={projectId} />
<OverviewTopGeo projectId={projectId} />
</div>
</PageLayout>
);
}

View File

@@ -0,0 +1,177 @@
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 { ProfileAvatar } from '@/components/profiles/ProfileAvatar';
import { ChartSwitch } from '@/components/report/chart';
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 { cn } from '@/utils/cn';
import { getProfileName } from '@/utils/getters';
import { notFound } from 'next/navigation';
import { parseAsInteger, parseAsString } from 'nuqs';
import type { GetEventListOptions } from '@mixan/db';
import {
getConversionEventNames,
getEventList,
getEventsCount,
getProfileById,
} from '@mixan/db';
import type { IChartEvent, IChartInput } from '@mixan/validation';
import { EventList } from '../../events/event-list';
import { StickyBelowHeader } from '../../layout-sticky-below-header';
interface PageProps {
params: {
projectId: string;
profileId: string;
organizationId: string;
};
searchParams: {
events?: string;
cursor?: string;
f?: string;
startDate: string;
endDate: string;
};
}
export default async function Page({
params: { projectId, profileId, organizationId },
searchParams,
}: PageProps) {
const eventListOptions: GetEventListOptions = {
projectId,
profileId,
take: 50,
cursor:
parseAsInteger.parseServerSide(searchParams.cursor ?? '') ?? undefined,
events: eventQueryNamesFilter.parseServerSide(searchParams.events ?? ''),
filters:
eventQueryFiltersParser.parseServerSide(searchParams.f ?? '') ??
undefined,
};
const startDate = parseAsString.parseServerSide(searchParams.startDate);
const endDate = parseAsString.parseServerSide(searchParams.endDate);
const [profile, events, count, conversions] = await Promise.all([
getProfileById(profileId),
getEventList(eventListOptions),
getEventsCount(eventListOptions),
getConversionEventNames(projectId),
getExists(organizationId, projectId),
]);
const chartSelectedEvents: IChartEvent[] = [
{
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,
startDate,
endDate,
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}
title={
<div className="flex items-center gap-2">
<ProfileAvatar {...profile} size="sm" className="hidden sm:block" />
{getProfileName(profile)}
</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>
<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">Events per day</span>
</WidgetHead>
<WidgetBody className="flex gap-2">
<ChartSwitch {...profileChart} />
</WidgetBody>
</Widget>
</div>
<EventList data={events} count={count} />
</div>
</PageLayout>
);
}

View File

@@ -0,0 +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 { 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) {
const [profiles, count] = await Promise.all([
getProfileList({
projectId,
take: 50,
cursor: parseAsInteger.parseServerSide(cursor ?? '') ?? undefined,
filters: eventQueryFiltersParser.parseServerSide(f ?? '') ?? undefined,
}),
getProfileListCount({
projectId,
filters: eventQueryFiltersParser.parseServerSide(f ?? '') ?? undefined,
}),
getExists(organizationId, projectId),
]);
return (
<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>
);
}

View File

@@ -0,0 +1,59 @@
'use client';
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 { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { getProfileName } from '@/utils/getters';
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 (
<>
<KeyValueSubtle name="Time" value={createdAt.toLocaleString()} />
<KeyValueSubtle
href={`/${params.organizationId}/${params.projectId}/profiles/${id}`}
name="Details"
value={'See profile'}
/>
</>
);
};
return (
<ExpandableListItem
title={getProfileName(props)}
content={renderContent()}
image={<ProfileAvatar {...props} />}
>
<>
{properties && (
<div className="bg-white 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>
)}
</>
</ExpandableListItem>
);
}

View File

@@ -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>
);
}

View File

@@ -0,0 +1,43 @@
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { getExists } from '@/server/pageExists';
import { Pencil } from 'lucide-react';
import { notFound } from 'next/navigation';
import { getOrganizationBySlug, getReportById } from '@mixan/db';
import ReportEditor from '../report-editor';
interface PageProps {
params: {
projectId: string;
reportId: string;
organizationId: string;
};
}
export default async function Page({
params: { reportId, organizationId, projectId },
}: PageProps) {
const [report] = await Promise.all([
getReportById(reportId),
getExists(organizationId, projectId),
]);
if (!report) {
return notFound();
}
return (
<PageLayout
organizationSlug={organizationId}
title={
<div className="flex gap-2 items-center cursor-pointer">
{report.name}
<Pencil size={16} />
</div>
}
>
<ReportEditor report={report} />
</PageLayout>
);
}

View File

@@ -0,0 +1,35 @@
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { getExists } from '@/server/pageExists';
import { Pencil } from 'lucide-react';
import { notFound } from 'next/navigation';
import { getOrganizationBySlug } from '@mixan/db';
import ReportEditor from './report-editor';
interface PageProps {
params: {
organizationId: string;
projectId: string;
};
}
export default async function Page({
params: { organizationId, projectId },
}: PageProps) {
await getExists(organizationId, projectId);
return (
<PageLayout
organizationSlug={organizationId}
title={
<div className="flex gap-2 items-center cursor-pointer">
Unnamed report
<Pencil size={16} />
</div>
}
>
<ReportEditor report={null} />
</PageLayout>
);
}

View File

@@ -0,0 +1,110 @@
'use client';
import { useEffect } from 'react';
import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header';
import { ChartSwitch } from '@/components/report/chart';
import { ReportChartType } from '@/components/report/ReportChartType';
import { ReportInterval } from '@/components/report/ReportInterval';
import { ReportLineType } from '@/components/report/ReportLineType';
import { ReportRange } from '@/components/report/ReportRange';
import { ReportSaveButton } from '@/components/report/ReportSaveButton';
import {
changeDateRanges,
changeDates,
changeEndDate,
changeStartDate,
ready,
reset,
setReport,
} from '@/components/report/reportSlice';
import { ReportSidebar } from '@/components/report/sidebar/ReportSidebar';
import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import { useAppParams } from '@/hooks/useAppParams';
import { useDispatch, useSelector } from '@/redux';
import { endOfDay, startOfDay } from 'date-fns';
import { GanttChartSquareIcon } from 'lucide-react';
import type { IServiceReport } from '@mixan/db';
interface ReportEditorProps {
report: IServiceReport | null;
}
export default function ReportEditor({
report: initialReport,
}: ReportEditorProps) {
const { projectId } = useAppParams();
const dispatch = useDispatch();
const report = useSelector((state) => state.report);
// Set report if reportId exists
useEffect(() => {
if (initialReport) {
dispatch(setReport(initialReport));
} else {
dispatch(ready());
}
return () => {
dispatch(reset());
};
}, [initialReport, dispatch]);
return (
<Sheet>
<StickyBelowHeader className="p-4 grid grid-cols-2 gap-2 md:grid-cols-6">
<SheetTrigger asChild>
<div>
<Button icon={GanttChartSquareIcon} variant="cta">
Pick events
</Button>
</div>
</SheetTrigger>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 col-span-4">
<ReportChartType className="min-w-0 flex-1" />
<ReportRange
className="min-w-0 flex-1"
range={report.range}
onRangeChange={(value) => {
dispatch(changeDateRanges(value));
}}
dates={{
startDate: report.startDate,
endDate: report.endDate,
}}
onDatesChange={(val) => {
if (!val) return;
if (val.from && val.to) {
dispatch(
changeDates({
startDate: startOfDay(val.from).toISOString(),
endDate: endOfDay(val.to).toISOString(),
})
);
} else if (val.from) {
dispatch(changeStartDate(startOfDay(val.from).toISOString()));
} else if (val.to) {
dispatch(changeEndDate(endOfDay(val.to).toISOString()));
}
}}
/>
<ReportInterval className="min-w-0 flex-1" />
<ReportLineType className="min-w-0 flex-1" />
</div>
<div className="col-start-2 md:col-start-6 row-start-1 text-right">
<ReportSaveButton />
</div>
</StickyBelowHeader>
<div className="flex flex-col gap-4 p-4">
{report.ready && (
<ChartSwitch {...report} projectId={projectId} editMode />
)}
</div>
<SheetContent className="!max-w-lg" side="left">
<ReportSidebar />
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,38 @@
'use client';
import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header';
import { columns } from '@/components/clients/table';
import { DataTable } from '@/components/DataTable';
import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/useAppParams';
import { pushModal } from '@/modals';
import { PlusIcon } from 'lucide-react';
import type { getClientsByOrganizationId } from '@mixan/db';
interface ListClientsProps {
clients: Awaited<ReturnType<typeof getClientsByOrganizationId>>;
}
export default function ListClients({ clients }: ListClientsProps) {
const organizationId = useAppParams().organizationId;
return (
<>
<StickyBelowHeader>
<div className="p-4 flex items-center justify-between">
<div />
<Button
icon={PlusIcon}
onClick={() => pushModal('AddClient', { organizationId })}
>
<span className="max-sm:hidden">Create client</span>
<span className="sm:hidden">Client</span>
</Button>
</div>
</StickyBelowHeader>
<div className="p-4">
<DataTable data={clients} columns={columns} />
</div>
</>
);
}

View File

@@ -0,0 +1,23 @@
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { getExists } from '@/server/pageExists';
import { getClientsByOrganizationId } from '@mixan/db';
import ListClients from './list-clients';
interface PageProps {
params: {
organizationId: string;
};
}
export default async function Page({ params: { organizationId } }: PageProps) {
await getExists(organizationId);
const clients = await getClientsByOrganizationId(organizationId);
return (
<PageLayout title="Clients" organizationSlug={organizationId}>
<ListClients clients={clients} />
</PageLayout>
);
}

View File

@@ -0,0 +1,66 @@
'use client';
import { api, handleError } from '@/app/_trpc/client';
import { InputWithLabel } from '@/components/forms/InputWithLabel';
import { Button } from '@/components/ui/button';
import { Widget, WidgetBody, WidgetHead } from '@/components/Widget';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import type { getOrganizationBySlug } from '@mixan/db';
const validator = z.object({
id: z.string().min(2),
name: z.string().min(2),
});
type IForm = z.infer<typeof validator>;
interface EditOrganizationProps {
organization: Awaited<ReturnType<typeof getOrganizationBySlug>>;
}
export default function EditOrganization({
organization,
}: EditOrganizationProps) {
const router = useRouter();
const { register, handleSubmit, formState, reset } = useForm<IForm>({
defaultValues: organization ?? undefined,
});
const mutation = api.organization.update.useMutation({
onSuccess(res) {
toast('Organization updated', {
description: 'Your organization has been updated.',
});
reset(res);
router.refresh();
},
onError: handleError,
});
return (
<form
onSubmit={handleSubmit((values) => {
mutation.mutate(values);
})}
>
<Widget>
<WidgetHead className="flex items-center justify-between">
<span className="title">Org. details</span>
<Button size="sm" type="submit" disabled={!formState.isDirty}>
Save
</Button>
</WidgetHead>
<WidgetBody>
<InputWithLabel
label="Name"
{...register('name')}
defaultValue={organization?.name}
/>
</WidgetBody>
</Widget>
</form>
);
}

View File

@@ -0,0 +1,60 @@
import { api } from '@/app/_trpc/client';
import { InputWithLabel } from '@/components/forms/InputWithLabel';
import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/useAppParams';
import { zodResolver } from '@hookform/resolvers/zod';
import { SendIcon } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import type { z } from 'zod';
import { zInviteUser } from '@mixan/validation';
type IForm = z.infer<typeof zInviteUser>;
export function InviteUser() {
const router = useRouter();
const { organizationId: organizationSlug } = useAppParams();
const { register, handleSubmit, formState, reset } = useForm<IForm>({
resolver: zodResolver(zInviteUser),
defaultValues: {
organizationSlug,
email: '',
role: 'org:member',
},
});
const mutation = api.organization.inviteUser.useMutation({
onSuccess() {
toast('User invited!', {
description: 'The user has been invited to the organization.',
});
reset();
router.refresh();
},
});
return (
<form
onSubmit={handleSubmit((values) => mutation.mutate(values))}
className="flex items-end gap-4"
>
<InputWithLabel
className="w-full max-w-sm"
label="Email"
placeholder="Who do you want to invite?"
{...register('email')}
/>
<Button
icon={SendIcon}
type="submit"
disabled={!formState.isDirty}
loading={mutation.isLoading}
>
Invite user
</Button>
</form>
);
}

View File

@@ -0,0 +1,65 @@
'use client';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Widget, WidgetBody, WidgetHead } from '@/components/Widget';
import type { IServiceInvites } from '@mixan/db';
import { InviteUser } from './invite-user';
interface InvitedUsersProps {
invites: IServiceInvites;
}
export default function InvitedUsers({ invites }: InvitedUsersProps) {
return (
<Widget>
<WidgetHead className="flex items-center justify-between">
<span className="title">Invites</span>
</WidgetHead>
<WidgetBody>
<InviteUser />
<div className="font-medium mt-8 mb-2">Invited users</div>
<Table className="mini">
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invites.map((item) => {
return (
<TableRow key={item.id}>
<TableCell className="font-medium">{item.email}</TableCell>
<TableCell>{item.role}</TableCell>
<TableCell>{item.status}</TableCell>
<TableCell>
{new Date(item.createdAt).toLocaleString()}
</TableCell>
</TableRow>
);
})}
{invites.length === 0 && (
<TableRow>
<TableCell colSpan={2} className="italic">
No invites
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</WidgetBody>
</Widget>
);
}

View File

@@ -0,0 +1,33 @@
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { clerkClient } from '@clerk/nextjs';
import { notFound } from 'next/navigation';
import { getInvites, getOrganizationBySlug } from '@mixan/db';
import EditOrganization from './edit-organization';
import InvitedUsers from './invited-users';
interface PageProps {
params: {
organizationId: string;
};
}
export default async function Page({ params: { organizationId } }: PageProps) {
const organization = await getOrganizationBySlug(organizationId);
if (!organization) {
return notFound();
}
const invites = await getInvites(organization.id);
return (
<PageLayout title={organization.name} organizationSlug={organizationId}>
<div className="p-4 grid grid-cols-1 gap-4">
<EditOrganization organization={organization} />
<InvitedUsers invites={invites} />
</div>
</PageLayout>
);
}

View File

@@ -0,0 +1 @@
export { default } from './organization/page';

View File

@@ -0,0 +1,85 @@
'use client';
import { api, handleError } from '@/app/_trpc/client';
import { InputWithLabel } from '@/components/forms/InputWithLabel';
import { Button } from '@/components/ui/button';
import { Widget, WidgetBody, WidgetHead } from '@/components/Widget';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import type { getUserById } from '@mixan/db';
const validator = z.object({
firstName: z.string().min(2),
lastName: z.string().min(2),
email: z.string().email(),
});
type IForm = z.infer<typeof validator>;
interface EditProfileProps {
profile: Awaited<ReturnType<typeof getUserById>>;
}
export default function EditProfile({ profile }: EditProfileProps) {
const router = useRouter();
const { register, handleSubmit, reset, formState } = useForm<IForm>({
resolver: zodResolver(validator),
defaultValues: {
firstName: profile.firstName ?? '',
lastName: profile.lastName ?? '',
email: profile.email ?? '',
},
});
const mutation = api.user.update.useMutation({
onSuccess(res) {
toast('Profile updated', {
description: 'Your profile has been updated.',
});
reset(res);
router.refresh();
},
onError: handleError,
});
return (
<form
onSubmit={handleSubmit((values) => {
mutation.mutate(values);
})}
>
<Widget>
<WidgetHead className="flex items-center justify-between">
<span className="title">Your profile</span>
<Button size="sm" type="submit" disabled={!formState.isDirty}>
Save
</Button>
</WidgetHead>
<WidgetBody className="flex flex-col gap-4">
<InputWithLabel
label="First name"
placeholder="Your first name"
defaultValue={profile.firstName ?? ''}
{...register('firstName')}
/>
<InputWithLabel
label="Last name"
placeholder="Your last name"
defaultValue={profile.lastName ?? ''}
{...register('lastName')}
/>
<InputWithLabel
disabled
label="Email"
placeholder="Your email"
defaultValue={profile.email ?? ''}
{...register('email')}
/>
</WidgetBody>
</Widget>
</form>
);
}

View File

@@ -0,0 +1,20 @@
'use client';
import { Widget, WidgetBody, WidgetHead } from '@/components/Widget';
import { SignOutButton } from '@clerk/nextjs';
export function Logout() {
return (
<Widget className="border-destructive">
<WidgetHead className="border-destructive">
<span className="title text-destructive">Sad part</span>
</WidgetHead>
<WidgetBody>
<p className="mb-4">
Sometime&apos;s you need to go. See you next time
</p>
<SignOutButton />
</WidgetBody>
</Widget>
);
}

View File

@@ -0,0 +1,28 @@
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { getExists } from '@/server/pageExists';
import { auth } from '@clerk/nextjs';
import { getUserById } from '@mixan/db';
import EditProfile from './edit-profile';
import { Logout } from './logout';
interface PageProps {
params: {
organizationId: string;
};
}
export default async function Page({ params: { organizationId } }: PageProps) {
const { userId } = auth();
await getExists(organizationId);
const profile = await getUserById(userId!);
return (
<PageLayout title={profile.lastName} organizationSlug={organizationId}>
<div className="p-4 flex flex-col gap-4">
<EditProfile profile={profile} />
<Logout />
</div>
</PageLayout>
);
}

View File

@@ -0,0 +1,41 @@
'use client';
import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header';
import { DataTable } from '@/components/DataTable';
import { columns } from '@/components/projects/table';
import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/useAppParams';
import { pushModal } from '@/modals';
import { PlusIcon } from 'lucide-react';
import type { getProjectsByOrganizationSlug } from '@mixan/db';
interface ListProjectsProps {
projects: Awaited<ReturnType<typeof getProjectsByOrganizationSlug>>;
}
export default function ListProjects({ projects }: ListProjectsProps) {
const organizationId = useAppParams().organizationId;
return (
<>
<StickyBelowHeader>
<div className="p-4 flex items-center justify-between">
<div />
<Button
icon={PlusIcon}
onClick={() =>
pushModal('AddProject', {
organizationId,
})
}
>
<span className="max-sm:hidden">Create project</span>
<span className="sm:hidden">Project</span>
</Button>
</div>
</StickyBelowHeader>
<div className="p-4">
<DataTable data={projects} columns={columns} />
</div>
</>
);
}

View File

@@ -0,0 +1,23 @@
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { getExists } from '@/server/pageExists';
import { getProjectsByOrganizationSlug } from '@mixan/db';
import ListProjects from './list-projects';
interface PageProps {
params: {
organizationId: string;
};
}
export default async function Page({ params: { organizationId } }: PageProps) {
await getExists(organizationId);
const projects = await getProjectsByOrganizationSlug(organizationId);
return (
<PageLayout title="Projects" organizationSlug={organizationId}>
<ListProjects projects={projects} />
</PageLayout>
);
}

View File

@@ -0,0 +1,33 @@
'use client';
import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header';
import { DataTable } from '@/components/DataTable';
import { columns } from '@/components/references/table';
import { Button } from '@/components/ui/button';
import { pushModal } from '@/modals';
import { PlusIcon } from 'lucide-react';
import type { IServiceReference } from '@mixan/db';
interface ListProjectsProps {
data: IServiceReference[];
}
export default function ListReferences({ data }: ListProjectsProps) {
return (
<>
<StickyBelowHeader>
<div className="p-4 flex items-center justify-between">
<div />
<Button icon={PlusIcon} onClick={() => pushModal('AddReference')}>
<span className="max-sm:hidden">Create reference</span>
<span className="sm:hidden">Reference</span>
</Button>
</div>
</StickyBelowHeader>
<div className="p-4">
<DataTable data={data} columns={columns} />
</div>
</>
);
}

View File

@@ -0,0 +1,32 @@
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { getExists } from '@/server/pageExists';
import { getReferences } from '@mixan/db';
import ListReferences from './list-references';
interface PageProps {
params: {
organizationId: string;
projectId: string;
};
}
export default async function Page({
params: { organizationId, projectId },
}: PageProps) {
await getExists(organizationId, projectId);
const references = await getReferences({
where: {
project_id: projectId,
},
take: 50,
skip: 0,
});
return (
<PageLayout title="References" organizationSlug={organizationId}>
<ListReferences data={references} />
</PageLayout>
);
}

View File

@@ -0,0 +1,21 @@
import { Funnel } from '@/components/report/funnel';
import PageLayout from '../page-layout';
export const metadata = {
title: 'Funnel - Openpanel.dev',
};
interface PageProps {
params: {
organizationId: string;
};
}
export default function Page({ params: { organizationId } }: PageProps) {
return (
<PageLayout title="Funnel" organizationSlug={organizationId}>
<Funnel />
</PageLayout>
);
}

View File

@@ -0,0 +1,113 @@
'use client';
import { LogoSquare } from '@/components/Logo';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useAppParams } from '@/hooks/useAppParams';
import { zodResolver } from '@hookform/resolvers/zod';
import { SaveIcon } from 'lucide-react';
import { useRouter } from 'next/navigation';
import type { SubmitHandler } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import { api, handleError } from '../../_trpc/client';
const validation = z.object({
name: z.string().min(1),
});
type IForm = z.infer<typeof validation>;
export function CreateProject() {
const params = useAppParams();
const router = useRouter();
const form = useForm<IForm>({
resolver: zodResolver(validation),
});
const mutation = api.project.create.useMutation({
onError: handleError,
onSuccess() {
toast.success('Project created');
router.refresh();
},
});
const onSubmit: SubmitHandler<IForm> = (values) => {
mutation.mutate({
name: values.name,
organizationId: params.organizationId,
});
};
return (
<>
<div>
<LogoSquare className="w-20 md:w-28 mb-8" />
<h1 className="font-medium text-3xl">Create your first project</h1>
<div className="text-lg">
A project is just a container for your events. You can create as many
as you want.
</div>
<form
className="mt-8 flex flex-col gap-4"
onSubmit={form.handleSubmit(onSubmit)}
>
<div>
<Label>Project name</Label>
<Input
placeholder="My App"
size="large"
error={form.formState.errors.name?.message}
{...form.register('name')}
/>
</div>
<div className="flex justify-end">
<Button
type="submit"
size="lg"
icon={SaveIcon}
loading={mutation.isLoading}
>
Create project
</Button>
</div>
</form>
</div>
</>
);
}
// <div>
// <div className="text-lg">
// Select your framework and we'll generate a client for you.
// </div>
// <div className="flex flex-wrap gap-2 mt-8">
// <FeatureButton
// name="React"
// logo="https://upload.wikimedia.org/wikipedia/commons/thumb/a/a7/React-icon.svg/2300px-React-icon.svg.png"
// />
// <FeatureButton
// name="React Native"
// logo="https://upload.wikimedia.org/wikipedia/commons/thumb/a/a7/React-icon.svg/2300px-React-icon.svg.png"
// />
// <FeatureButton
// name="Next.js"
// logo="https://static-00.iconduck.com/assets.00/nextjs-icon-512x512-y563b8iq.png"
// />
// <FeatureButton
// name="Remix"
// logo="https://www.datocms-assets.com/205/1642515307-square-logo.svg"
// />
// <FeatureButton
// name="Vue"
// logo="https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQ0UhQnp6TUPCwAr3ruTEwBDiTN5HLAWaoUD3AJIgtepQ&s"
// />
// <FeatureButton
// name="HTML"
// logo="https://upload.wikimedia.org/wikipedia/commons/thumb/6/61/HTML5_logo_and_wordmark.svg/240px-HTML5_logo_and_wordmark.svg.png"
// />
// </div>
// </div>

View File

@@ -0,0 +1,65 @@
import { LogoSquare } from '@/components/Logo';
import { ProjectCard } from '@/components/projects/project-card';
import { notFound, redirect } from 'next/navigation';
import {
getOrganizationBySlug,
getProjectsByOrganizationSlug,
} from '@mixan/db';
import { CreateProject } from './create-project';
interface PageProps {
params: {
organizationId: string;
};
}
export default async function Page({ params: { organizationId } }: PageProps) {
const [organization, projects] = await Promise.all([
getOrganizationBySlug(organizationId),
getProjectsByOrganizationSlug(organizationId),
]);
if (!organization) {
return notFound();
}
if (process.env.BLOCK) {
return (
<div className="flex items-center justify-center h-screen">
<div className="max-w-lg w-full">
<LogoSquare className="w-20 md:w-28 mb-8" />
<h1 className="font-medium text-3xl">Not quite there yet</h1>
<div className="text-lg">
We're still working on Openpanel, but we're not quite there yet.
We'll let you know when we're ready to go!
</div>
</div>
</div>
);
}
if (projects.length === 0) {
return (
<div className="flex items-center justify-center h-screen">
<div className="max-w-lg w-full">
<CreateProject />
</div>
</div>
);
}
if (projects.length === 1 && projects[0]) {
return redirect(`/${organizationId}/${projects[0].id}`);
}
return (
<div className="max-w-xl w-full mx-auto flex flex-col gap-4 pt-20">
<h1 className="font-medium text-xl">Select project</h1>
{projects.map((item) => (
<ProjectCard key={item.id} {...item} />
))}
</div>
);
}

View File

@@ -0,0 +1,86 @@
'use client';
import { LogoSquare } from '@/components/Logo';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { zodResolver } from '@hookform/resolvers/zod';
import { SaveIcon } from 'lucide-react';
import { useRouter } from 'next/navigation';
import type { SubmitHandler } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { api, handleError } from '../_trpc/client';
const validation = z.object({
organization: z.string().min(4),
project: z.string().optional(),
});
type IForm = z.infer<typeof validation>;
export function CreateOrganization() {
const router = useRouter();
const form = useForm<IForm>({
resolver: zodResolver(validation),
});
const mutation = api.onboarding.organziation.useMutation({
onError: handleError,
onSuccess({ organization, project }) {
let url = `/${organization.slug}`;
if (project) {
url += `/${project.id}`;
}
router.replace(url);
},
});
const onSubmit: SubmitHandler<IForm> = (values) => {
mutation.mutate(values);
};
return (
<>
<div>
<LogoSquare className="w-20 md:w-28 mb-8" />
<h1 className="font-medium text-3xl">Welcome to Openpanel</h1>
<div className="text-lg">
Create your organization below (can be personal or a company) and
optionally your first project 🤠
</div>
<form
className="mt-8 flex flex-col gap-4"
onSubmit={form.handleSubmit(onSubmit)}
>
<div>
<Label>Organization name *</Label>
<Input
placeholder="Organization name"
size="large"
error={form.formState.errors.organization?.message}
{...form.register('organization')}
/>
</div>
<div>
<Label>Project name</Label>
<Input
placeholder="Project name"
size="large"
error={form.formState.errors.project?.message}
{...form.register('project')}
/>
</div>
<div className="flex justify-end">
<Button
type="submit"
size="lg"
icon={SaveIcon}
loading={mutation.isLoading}
>
Create
</Button>
</div>
</form>
</div>
</>
);
}

View File

@@ -0,0 +1,39 @@
// import { CreateOrganization } from '@clerk/nextjs';
import { LogoSquare } from '@/components/Logo';
import { redirect } from 'next/navigation';
import { getCurrentOrganizations } from '@mixan/db';
import { CreateOrganization } from './create-organization';
export default async function Page() {
const organizations = await getCurrentOrganizations();
if (process.env.BLOCK) {
return (
<div className="flex items-center justify-center h-screen">
<div className="max-w-lg w-full">
<LogoSquare className="w-20 md:w-28 mb-8" />
<h1 className="font-medium text-3xl">Not quite there yet</h1>
<div className="text-lg">
We're still working on Openpanel, but we're not quite there yet.
We'll let you know when we're ready to go!
</div>
</div>
</div>
);
}
if (organizations.length > 0) {
return redirect(`/${organizations[0]?.slug}`);
}
return (
<div className="flex items-center justify-center h-screen">
<div className="max-w-lg w-full">
<CreateOrganization />
</div>
</div>
);
}

View File

@@ -0,0 +1,74 @@
import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header';
import { OverviewReportRange } from '@/app/(app)/[organizationId]/[projectId]/overview-sticky-header';
import { Logo } from '@/components/Logo';
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
import ServerLiveCounter from '@/components/overview/live-counter';
import { OverviewLiveHistogram } from '@/components/overview/overview-live-histogram';
import OverviewMetrics from '@/components/overview/overview-metrics';
import OverviewTopDevices from '@/components/overview/overview-top-devices';
import OverviewTopEvents from '@/components/overview/overview-top-events';
import OverviewTopGeo from '@/components/overview/overview-top-geo';
import OverviewTopPages from '@/components/overview/overview-top-pages';
import OverviewTopSources from '@/components/overview/overview-top-sources';
import { notFound } from 'next/navigation';
import { getOrganizationBySlug, getShareOverviewById } from '@mixan/db';
interface PageProps {
params: {
id: string;
};
}
export default async function Page({ params: { id } }: PageProps) {
const share = await getShareOverviewById(id);
if (!share) {
return notFound();
}
const projectId = share.project_id;
const organization = await getOrganizationBySlug(share.organization_slug);
return (
<div className="p-4 md:p-16 bg-gradient-to-tl from-blue-950 to-blue-600">
<div className="max-w-6xl mx-auto">
<div className="flex justify-between items-end mb-4">
<div className="leading-none">
<span className="text-white mb-4">{organization?.name}</span>
<h1 className="text-white text-xl font-medium">
{share.project?.name}
</h1>
</div>
<a href="https://openpanel.dev?utm_source=openpanel.dev&utm_medium=share">
<Logo className="text-white" />
</a>
</div>
<div className="bg-slate-100 rounded-lg shadow ring-8 ring-blue-600/50">
<StickyBelowHeader>
<div className="p-4 flex gap-2 justify-between">
<div className="flex gap-2">
<OverviewReportRange />
{/* <OverviewFiltersDrawer projectId={projectId} mode="events" /> */}
</div>
<div className="flex gap-2">
<ServerLiveCounter projectId={projectId} />
</div>
</div>
<OverviewFiltersButtons />
</StickyBelowHeader>
<div className="p-4 grid gap-4 grid-cols-6">
<div className="col-span-6">
<OverviewLiveHistogram projectId={projectId} />
</div>
<OverviewMetrics projectId={projectId} />
<OverviewTopSources projectId={projectId} />
<OverviewTopPages projectId={projectId} />
<OverviewTopDevices projectId={projectId} />
<OverviewTopEvents projectId={projectId} />
<OverviewTopGeo projectId={projectId} />
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,39 @@
import type { AppRouter } from '@/server/api/root';
import type { TRPCClientErrorBase } from '@trpc/react-query';
import { createTRPCReact } from '@trpc/react-query';
import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server';
import type { ExternalToast } from 'sonner';
import { toast } from 'sonner';
export const api = createTRPCReact<AppRouter>({});
/**
* Inference helper for inputs.
*
* @example type HelloInput = RouterInputs['example']['hello']
*/
export type RouterInputs = inferRouterInputs<AppRouter>;
/**
* Inference helper for outputs.
*
* @example type HelloOutput = RouterOutputs['example']['hello']
*/
export type RouterOutputs = inferRouterOutputs<AppRouter>;
export type IChartData = RouterOutputs['chart']['chart'];
export type IChartSerieDataItem = IChartData['series'][number]['data'][number];
export function handleError(error: TRPCClientErrorBase<any>) {
toast('Error', {
description: error.message,
});
}
export function handleErrorToastOptions(options: ExternalToast) {
return function (error: TRPCClientErrorBase<any>) {
toast('Error', {
description: error.message,
...options,
});
};
}

View File

@@ -0,0 +1,25 @@
import { appRouter } from '@/server/api/root';
import { getAuth } from '@clerk/nextjs/server';
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
async createContext({ req }) {
const session = getAuth(req as any);
return {
session,
};
},
onError(opts) {
const { error, type, path, input, ctx, req } = opts;
console.error('---- TRPC ERROR');
console.error('Error:', error);
console.error('Context:', ctx);
console.error();
},
});
export { handler as GET, handler as POST };

View File

@@ -0,0 +1,87 @@
'use client';
import { useState } from 'react';
import { InputWithLabel } from '@/components/forms/InputWithLabel';
import { Logo } from '@/components/Logo';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Widget, WidgetBody } from '@/components/Widget';
import { zodResolver } from '@hookform/resolvers/zod';
import { KeySquareIcon } from 'lucide-react';
import { signIn } from 'next-auth/react';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
const validator = z.object({
email: z.string().email(),
password: z.string().min(6),
});
type IForm = z.infer<typeof validator>;
export default function Auth() {
const form = useForm<IForm>({
resolver: zodResolver(validator),
});
const router = useRouter();
const pathname = usePathname();
const [state, setState] = useState<string | null>(null);
return (
<div className="flex items-center justify-center flex-col h-screen p-4">
<Widget className="max-w-md w-full mb-4">
<WidgetBody>
<div className="flex justify-center py-8">
<Logo />
</div>
<form
onSubmit={form.handleSubmit(async (values) => {
const res = await signIn('credentials', {
email: values.email,
password: values.password,
redirect: false,
}).catch(() => {
setState('Something went wrong. Please try again later');
});
if (res?.ok) {
router.refresh();
}
if (res?.status === 401) {
setState('Wrong email or password. Please try again');
}
})}
className="flex flex-col gap-4"
>
<InputWithLabel
label="Email"
placeholder="Your email"
error={form.formState.errors.email?.message}
{...form.register('email')}
/>
<InputWithLabel
label="Password"
placeholder="...and your password"
error={form.formState.errors.password?.message}
{...form.register('password')}
/>
{state !== null && (
<Alert variant="destructive">
<KeySquareIcon className="h-4 w-4" />
<AlertTitle>Failed</AlertTitle>
<AlertDescription>{state}</AlertDescription>
</Alert>
)}
<Button type="submit">Sign in</Button>
<Link href="/register" className="text-center text-sm">
No account?{' '}
<span className="font-medium text-blue-600">Sign up here!</span>
</Link>
</form>
</WidgetBody>
</Widget>
<p className="text-xs">Terms & conditions</p>
</div>
);
}

View File

@@ -0,0 +1,33 @@
import { cn } from '@/utils/cn';
import Providers from './providers';
import '@/styles/globals.css';
import '/node_modules/flag-icons/css/flag-icons.min.css';
export const metadata = {
title: 'Overview - Openpanel.dev',
};
export const viewport = {
width: 'device-width',
initialScale: 1,
maximumScale: 1,
userScalable: 1,
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className="light">
<body
className={cn('min-h-screen font-sans antialiased grainy bg-slate-100')}
>
<Providers>{children}</Providers>
</body>
</html>
);
}

View File

@@ -0,0 +1,20 @@
import type { MetadataRoute } from 'next';
export default function manifest(): MetadataRoute.Manifest {
return {
name: 'Openpanel.dev',
short_name: 'Openpanel.dev',
description: '',
start_url: '/',
display: 'standalone',
background_color: '#fff',
theme_color: '#fff',
icons: [
{
src: 'https://openpanel.dev/favicon.ico',
sizes: 'any',
type: 'image/x-icon',
},
],
};
}

View File

@@ -0,0 +1,71 @@
'use client';
import React, { useRef, useState } from 'react';
import { api } from '@/app/_trpc/client';
import { TooltipProvider } from '@/components/ui/tooltip';
import { ModalProvider } from '@/modals';
import type { AppStore } from '@/redux';
import makeStore from '@/redux';
import { ClerkProvider, useAuth } from '@clerk/nextjs';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpLink } from '@trpc/client';
import { Provider as ReduxProvider } from 'react-redux';
import { Toaster } from 'sonner';
import superjson from 'superjson';
function AllProviders({ children }: { children: React.ReactNode }) {
const { getToken } = useAuth();
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
networkMode: 'always',
refetchOnMount: true,
refetchOnWindowFocus: false,
},
},
})
);
const [trpcClient] = useState(() =>
api.createClient({
transformer: superjson,
links: [
httpLink({
url: `${process.env.NEXT_PUBLIC_DASHBOARD_URL}/api/trpc`,
async headers() {
return { Authorization: `Bearer ${await getToken()}` };
},
}),
],
})
);
const storeRef = useRef<AppStore>();
if (!storeRef.current) {
// Create the store instance the first time this renders
storeRef.current = makeStore();
}
return (
<ReduxProvider store={storeRef.current}>
<api.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<TooltipProvider delayDuration={200}>
{children}
<Toaster />
<ModalProvider />
</TooltipProvider>
</QueryClientProvider>
</api.Provider>
</ReduxProvider>
);
}
export default function Providers({ children }: { children: React.ReactNode }) {
return (
<ClerkProvider>
<AllProviders>{children}</AllProviders>
</ClerkProvider>
);
}

View File

@@ -0,0 +1,3 @@
import AutoSizer from 'react-virtualized-auto-sizer';
export { AutoSizer };

View File

@@ -0,0 +1,11 @@
import type { HtmlProps } from '@/types';
import { cn } from '@/utils/cn';
export function ButtonContainer({
className,
...props
}: HtmlProps<HTMLDivElement>) {
return (
<div className={cn('flex justify-between mt-6', className)} {...props} />
);
}

View File

@@ -0,0 +1,50 @@
'use client';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import type { HtmlProps } from '@/types';
import { cn } from '@/utils/cn';
import { MoreHorizontal } from 'lucide-react';
type CardProps = HtmlProps<HTMLDivElement> & {
hover?: boolean;
};
export function Card({ children, hover, className }: CardProps) {
return (
<div
className={cn(
'card relative',
hover && 'transition-all hover:border-black',
className
)}
>
{children}
</div>
);
}
interface CardActionsProps {
children: React.ReactNode;
}
export function CardActions({ children }: CardActionsProps) {
return (
<div className="absolute top-2 right-2 z-10">
<DropdownMenu>
<DropdownMenuTrigger className="h-8 w-8 hover:border rounded justify-center items-center flex">
<MoreHorizontal size={16} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]">
<DropdownMenuGroup>{children}</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
export const CardActionsItem = DropdownMenuItem;

View File

@@ -0,0 +1,23 @@
import type { HtmlProps } from '@/types';
import { cn } from '@/utils/cn';
import { useChartContext } from './report/chart/ChartProvider';
type ColorSquareProps = HtmlProps<HTMLDivElement>;
export function ColorSquare({ children, className }: ColorSquareProps) {
const { hideID } = useChartContext();
if (hideID) {
return null;
}
return (
<div
className={cn(
'flex h-5 w-5 flex-shrink-0 items-center justify-center rounded bg-purple-500 text-xs font-medium text-white [.mini_&]:h-4 [.mini_&]:w-4 [.mini_&]:text-[0.6rem]',
className
)}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,11 @@
import type { HtmlProps } from '@/types';
import { cn } from '@/utils/cn';
export function Container({ className, ...props }: HtmlProps<HTMLDivElement>) {
return (
<div
className={cn('mx-auto w-full max-w-4xl px-4', className)}
{...props}
/>
);
}

View File

@@ -0,0 +1,52 @@
'use client';
import { cn } from '@/utils/cn';
interface ContentHeaderProps {
title: string;
text: string;
children?: React.ReactNode;
}
export function ContentHeader({ title, text, children }: ContentHeaderProps) {
return (
<div className="flex items-center justify-between py-6 first:pt-0">
<div>
<h2 className="h2">{title}</h2>
<p className="text-sm text-muted-foreground">{text}</p>
</div>
<div>{children}</div>
</div>
);
}
interface ContentSectionProps {
title: string;
text?: string | React.ReactNode;
children: React.ReactNode;
asCol?: boolean;
}
export function ContentSection({
title,
text,
children,
asCol,
}: ContentSectionProps) {
return (
<div
className={cn(
'first:pt-0] flex py-6',
asCol ? 'col flex' : 'justify-between'
)}
>
{title && (
<div className="max-w-[50%]">
<h4 className="h4">{title}</h4>
{text && <p className="text-sm text-muted-foreground">{text}</p>}
</div>
)}
<div>{children}</div>
</div>
);
}

View File

@@ -0,0 +1,75 @@
'use client';
import {
flexRender,
getCoreRowModel,
useReactTable,
} from '@tanstack/react-table';
import type { ColumnDef } from '@tanstack/react-table';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from './ui/table';
interface DataTableProps<TData> {
columns: ColumnDef<TData, any>[];
data: TData[];
}
export function DataTable<TData>({ columns, data }: DataTableProps<TData>) {
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
return (
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
);
}

View File

@@ -0,0 +1,55 @@
import { cloneElement } from 'react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from './ui/dropdown-menu';
interface DropdownProps<Value> {
children: React.ReactNode;
label?: string;
items: {
label: string;
value: Value;
}[];
onChange?: (value: Value) => void;
}
export function Dropdown<Value extends string>({
children,
label,
items,
onChange,
}: DropdownProps<Value>) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="start">
{label && (
<>
<DropdownMenuLabel>{label}</DropdownMenuLabel>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuGroup>
{items.map((item) => (
<DropdownMenuItem
className="cursor-pointer"
key={item.value}
onClick={() => {
onChange?.(item.value);
}}
>
{item.label}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,28 @@
import { BoxSelectIcon } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
interface FullPageEmptyStateProps {
icon?: LucideIcon;
title: string;
children: React.ReactNode;
}
export function FullPageEmptyState({
icon: Icon = BoxSelectIcon,
title,
children,
}: FullPageEmptyStateProps) {
return (
<div className="p-4 flex items-center justify-center">
<div className="p-8 w-full max-w-xl flex flex-col items-center justify-center">
<div className="w-24 h-24 bg-white shadow-sm rounded-full flex justify-center items-center mb-6">
<Icon size={60} strokeWidth={1} />
</div>
<h1 className="text-xl font-medium mb-1">{title}</h1>
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,26 @@
import { cn } from '@/utils/cn';
interface LogoProps {
className?: string;
}
export function LogoSquare({ className }: LogoProps) {
return (
<img
src="/logo.svg"
className={cn('rounded-md', className)}
alt="Openpanel logo"
/>
);
}
export function Logo({ className }: LogoProps) {
return (
<div
className={cn('text-xl font-medium flex gap-2 items-center', className)}
>
<LogoSquare className="max-h-8" />
openpanel.dev
</div>
);
}

View File

@@ -0,0 +1,11 @@
import type { HtmlProps } from '@/types';
type PageTitleProps = HtmlProps<HTMLDivElement>;
export function PageTitle({ children }: PageTitleProps) {
return (
<div className="my-8 flex justify-between border-b border-border py-4">
<h1 className="h1">{children}</h1>
</div>
);
}

View File

@@ -0,0 +1,57 @@
import type { Dispatch, SetStateAction } from 'react';
import { useState } from 'react';
import { Button } from './ui/button';
export function usePagination(take: number) {
const [page, setPage] = useState(0);
return {
take,
skip: page * take,
setPage,
page,
paginate: <T,>(data: T[]): T[] =>
data.slice(page * take, (page + 1) * take),
};
}
export function Pagination({
take,
count,
cursor,
setCursor,
}: {
take?: number;
count?: number;
cursor: number;
setCursor: Dispatch<SetStateAction<number>>;
}) {
const isNextDisabled =
count !== undefined && take !== undefined && cursor * take + take >= count;
return (
<div className="flex select-none items-center justify-end gap-2">
<div className="font-medium text-xs">Page: {cursor + 1}</div>
{typeof count === 'number' && (
<div className="font-medium text-xs">Total rows: {count}</div>
)}
<Button
variant="outline"
size="sm"
onClick={() => setCursor((p) => Math.max(0, p - 1))}
disabled={cursor === 0}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setCursor((p) => p + 1)}
disabled={isNextDisabled}
>
Next
</Button>
</div>
);
}

View File

@@ -0,0 +1,19 @@
'use client';
import { Light as SyntaxHighlighter } from 'react-syntax-highlighter';
import ts from 'react-syntax-highlighter/dist/cjs/languages/hljs/typescript';
import docco from 'react-syntax-highlighter/dist/cjs/styles/hljs/docco';
SyntaxHighlighter.registerLanguage('typescript', ts);
interface SyntaxProps {
code: string;
}
export default function Syntax({ code }: SyntaxProps) {
return (
<SyntaxHighlighter wrapLongLines style={docco}>
{code}
</SyntaxHighlighter>
);
}

View File

@@ -0,0 +1,34 @@
import { cn } from '@/utils/cn';
export interface WidgetHeadProps {
children: React.ReactNode;
className?: string;
}
export function WidgetHead({ children, className }: WidgetHeadProps) {
return (
<div
className={cn(
'p-4 border-b border-border [&_.title]:font-medium [&_.title]:whitespace-nowrap',
className
)}
>
{children}
</div>
);
}
export interface WidgetBodyProps {
children: React.ReactNode;
className?: string;
}
export function WidgetBody({ children, className }: WidgetBodyProps) {
return <div className={cn('p-4', className)}>{children}</div>;
}
export interface WidgetProps {
children: React.ReactNode;
className?: string;
}
export function Widget({ children, className }: WidgetProps) {
return <div className={cn('card self-start', className)}>{children}</div>;
}

View File

@@ -0,0 +1,15 @@
import type { HtmlProps } from '@/types';
type WithSidebarProps = HtmlProps<HTMLDivElement>;
export function WithSidebar({ children }: WithSidebarProps) {
return (
<div className="grid grid-cols-[200px_minmax(0,1fr)] gap-8">{children}</div>
);
}
type SidebarProps = HtmlProps<HTMLDivElement>;
export function Sidebar({ children }: SidebarProps) {
return <div className="flex flex-col gap-1">{children}</div>;
}

View File

@@ -0,0 +1,68 @@
import * as d3 from 'd3';
export function ChartSSR({
data,
dots = false,
}: {
dots?: boolean;
data: { value: number; date: Date }[];
}) {
const xScale = d3
.scaleTime()
.domain([data[0]!.date, data[data.length - 1]!.date])
.range([0, 100]);
const yScale = d3
.scaleLinear()
.domain([0, d3.max(data.map((d) => d.value)) ?? 0])
.range([100, 0]);
const line = d3
.line<(typeof data)[number]>()
.curve(d3.curveMonotoneX)
.x((d) => xScale(d.date))
.y((d) => yScale(d.value));
const d = line(data);
if (!d) {
return null;
}
return (
<div className="@container relative h-full w-full">
{/* Chart area */}
<svg className="absolute inset-0 h-full w-full overflow-visible">
<svg
viewBox="0 0 100 100"
className="overflow-visible"
preserveAspectRatio="none"
>
{/* Line */}
<path
d={d}
fill="none"
className="text-blue-600"
stroke="currentColor"
strokeWidth="2"
vectorEffect="non-scaling-stroke"
/>
{/* Circles */}
{dots &&
data.map((d) => (
<path
key={d.date.toString()}
d={`M ${xScale(d.date)} ${yScale(d.value)} l 0.0001 0`}
vectorEffect="non-scaling-stroke"
strokeWidth="8"
strokeLinecap="round"
fill="none"
stroke="currentColor"
className="text-gray-400"
/>
))}
</svg>
</svg>
</div>
);
}

View File

@@ -0,0 +1,73 @@
'use client';
import { api } from '@/app/_trpc/client';
import { pushModal, showConfirm } from '@/modals';
import { clipboard } from '@/utils/clipboard';
import { MoreHorizontal } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import type { IServiceClientWithProject } from '@mixan/db';
import { Button } from '../ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '../ui/dropdown-menu';
export function ClientActions(client: IServiceClientWithProject) {
const { id } = client;
const router = useRouter();
const deletion = api.client.remove.useMutation({
onSuccess() {
toast('Success', {
description: 'Client revoked, incoming requests will be rejected.',
});
router.refresh();
},
});
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem onClick={() => clipboard(id)}>
Copy client ID
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
pushModal('EditClient', client);
}}
>
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={() => {
showConfirm({
title: 'Revoke client',
text: 'Are you sure you want to revoke this client? This action cannot be undone.',
onConfirm() {
deletion.mutate({
id,
});
},
});
}}
>
Revoke
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,54 @@
import { formatDate } from '@/utils/date';
import type { ColumnDef } from '@tanstack/react-table';
import type { IServiceClientWithProject } from '@mixan/db';
import { ClientActions } from './ClientActions';
export const columns: ColumnDef<IServiceClientWithProject>[] = [
{
accessorKey: 'name',
header: 'Name',
cell: ({ row }) => {
return (
<div>
<div>{row.original.name}</div>
<div className="text-sm text-muted-foreground">
{row.original.project.name}
</div>
</div>
);
},
},
{
accessorKey: 'id',
header: 'Client ID',
},
{
accessorKey: 'cors',
header: 'Cors',
},
{
accessorKey: 'secret',
header: 'Secret',
cell: (info) =>
info.getValue() ? (
<div className="italic text-muted-foreground">Hidden</div>
) : (
'None'
),
},
{
accessorKey: 'createdAt',
header: 'Created at',
cell({ row }) {
const date = row.original.createdAt;
return formatDate(date);
},
},
{
id: 'actions',
header: 'Actions',
cell: ({ row }) => <ClientActions {...row.original} />,
},
];

View File

@@ -0,0 +1,35 @@
import { toDots } from '@mixan/common';
import { Table, TableBody, TableCell, TableRow } from '../ui/table';
interface ListPropertiesProps {
data: any;
className?: string;
}
export function ListProperties({
data,
className = 'mini',
}: ListPropertiesProps) {
const dots = toDots(data);
return (
<Table className={className}>
<TableBody>
{Object.keys(dots).map((key) => {
return (
<TableRow key={key}>
<TableCell className="font-medium">{key}</TableCell>
<TableCell>
{typeof dots[key] === 'boolean'
? dots[key]
? 'true'
: 'false'
: dots[key]}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
);
}

View File

@@ -0,0 +1,11 @@
interface InputErrorProps {
message?: string;
}
export function InputError({ message }: InputErrorProps) {
if (!message) {
return null;
}
return <div className="mt-1 text-sm text-red-600">{message}</div>;
}

View File

@@ -0,0 +1,32 @@
import { forwardRef } from 'react';
import { Input } from '../ui/input';
import type { InputProps } from '../ui/input';
import { Label } from '../ui/label';
type InputWithLabelProps = InputProps & {
label: string;
error?: string | undefined;
};
export const InputWithLabel = forwardRef<HTMLInputElement, InputWithLabelProps>(
({ label, className, ...props }, ref) => {
return (
<div className={className}>
<div className="block mb-2 flex justify-between">
<Label className="mb-0" htmlFor={label}>
{label}
</Label>
{props.error && (
<span className="text-sm text-destructive leading-none">
{props.error}
</span>
)}
</div>
<Input ref={ref} id={label} {...props} />
</div>
);
}
);
InputWithLabel.displayName = 'InputWithLabel';

View File

@@ -0,0 +1,57 @@
import React, { useState } from 'react';
import { cn } from '@/utils/cn';
import { ChevronUp } from 'lucide-react';
import AnimateHeight from 'react-animate-height';
import { Button } from '../ui/button';
interface ExpandableListItemProps {
children: React.ReactNode;
content: React.ReactNode;
title: React.ReactNode;
image?: React.ReactNode;
initialOpen?: boolean;
className?: string;
}
export function ExpandableListItem({
title,
content,
image,
initialOpen = false,
children,
className,
}: ExpandableListItemProps) {
const [open, setOpen] = useState(initialOpen ?? false);
return (
<div className={cn('card overflow-hidden', className)}>
<div className="p-2 sm:p-4 flex gap-4">
<div className="flex gap-1">{image}</div>
<div className="flex flex-col flex-1 gap-1 min-w-0">
<div className="text-md font-medium leading-none mb-1">{title}</div>
{!!content && (
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-4">
{content}
</div>
)}
</div>
<Button
variant="secondary"
size="icon"
onClick={() => setOpen((p) => !p)}
className="bg-black/5 hover:bg-black/10"
>
<ChevronUp
size={20}
className={cn(
'transition-transform',
open ? 'rotate-180' : 'rotate-0'
)}
/>
</Button>
</div>
<AnimateHeight duration={200} height={open ? 'auto' : 0}>
<div className="border-t border-border">{children}</div>
</AnimateHeight>
</div>
);
}

View File

@@ -0,0 +1,57 @@
'use client';
import { Button } from '@/components/ui/button';
import {
useEventQueryFilters,
useEventQueryNamesFilter,
} from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn';
import { X } from 'lucide-react';
import type { Options as NuqsOptions } from 'nuqs';
interface OverviewFiltersButtonsProps {
className?: string;
nuqsOptions?: NuqsOptions;
}
export function OverviewFiltersButtons({
className,
nuqsOptions,
}: OverviewFiltersButtonsProps) {
const [events, setEvents] = useEventQueryNamesFilter(nuqsOptions);
const [filters, setFilter] = useEventQueryFilters(nuqsOptions);
if (filters.length === 0 && events.length === 0) return null;
return (
<div className={cn('flex flex-wrap gap-2 px-4 pb-4', className)}>
{events.map((event) => (
<Button
key={event}
size="sm"
variant="outline"
icon={X}
onClick={() => setEvents((p) => p.filter((e) => e !== event))}
>
<strong>{event}</strong>
</Button>
))}
{filters.map((filter) => {
if (!filter.value[0]) {
return null;
}
return (
<Button
key={filter.name}
size="sm"
variant="outline"
icon={X}
onClick={() => setFilter(filter.name, filter.value[0], 'is')}
>
<span className="mr-1">{filter.name} is</span>
<strong>{filter.value[0]}</strong>
</Button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,186 @@
import { Button } from '@/components/ui/button';
import { Combobox } from '@/components/ui/combobox';
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
import { SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { useEventNames } from '@/hooks/useEventNames';
import { useEventProperties } from '@/hooks/useEventProperties';
import {
useEventQueryFilters,
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';
import type {
IChartEventFilter,
IChartEventFilterOperator,
IChartEventFilterValue,
} from '@mixan/validation';
export interface OverviewFiltersDrawerContentProps {
projectId: string;
nuqsOptions?: NuqsOptions;
enableEventsFilter?: boolean;
mode: 'profiles' | 'events';
}
export function OverviewFiltersDrawerContent({
projectId,
nuqsOptions,
enableEventsFilter,
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>
<SheetHeader className="mb-8">
<SheetTitle>Overview filters</SheetTitle>
</SheetHeader>
<div className="flex flex-col gap-4">
{enableEventsFilter && (
<ComboboxAdvanced
className="w-full"
value={event}
onChange={setEvent}
// First items is * which is only used for report editing
items={eventNames.slice(1).map((item) => ({
label: item.name,
value: item.name,
}))}
placeholder="Select event"
/>
)}
<Combobox
className="w-full"
onChange={(value) => {
setFilter(value, '');
}}
value=""
placeholder="Filter by property"
label="What do you want to filter by?"
items={properties.map((item) => ({
label: item,
value: item,
}))}
searchable
/>
</div>
<div className="flex flex-col gap-4 mt-8">
{filters
.filter((filter) => filter.value[0] !== null)
.map((filter) => {
return mode === 'events' ? (
<FilterOptionEvent
key={filter.name}
projectId={projectId}
setFilter={setFilter}
{...filter}
/>
) : (
<FilterOptionProfile
key={filter.name}
projectId={projectId}
setFilter={setFilter}
{...filter}
/>
);
})}
</div>
</div>
);
}
export function FilterOptionEvent({
setFilter,
projectId,
...filter
}: IChartEventFilter & {
projectId: string;
setFilter: (
name: string,
value: IChartEventFilterValue,
operator: IChartEventFilterOperator
) => void;
}) {
const values = useEventValues(
projectId,
filter.name === 'path' ? 'screen_view' : 'session_start',
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>
);
}
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>
);
}

View File

@@ -0,0 +1,25 @@
'use client';
import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import { FilterIcon } from 'lucide-react';
import type { OverviewFiltersDrawerContentProps } from './overview-filters-drawer-content';
import { OverviewFiltersDrawerContent } from './overview-filters-drawer-content';
export function OverviewFiltersDrawer(
props: OverviewFiltersDrawerContentProps
) {
return (
<Sheet>
<SheetTrigger asChild>
<Button variant="outline" responsive icon={FilterIcon}>
Filters
</Button>
</SheetTrigger>
<SheetContent className="!max-w-lg w-full" side="right">
<OverviewFiltersDrawerContent {...props} />
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,11 @@
import { getLiveVisitors } from '@mixan/db';
import type { LiveCounterProps } from './live-counter';
import LiveCounter from './live-counter';
export default async function ServerLiveCounter(
props: Omit<LiveCounterProps, 'data'>
) {
const count = await getLiveVisitors(props.projectId);
return <LiveCounter data={count} {...props} />;
}

View File

@@ -0,0 +1,97 @@
'use client';
import { useRef, useState } from 'react';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { cn } from '@/utils/cn';
import { useQueryClient } from '@tanstack/react-query';
import dynamic from 'next/dynamic';
import useWebSocket from 'react-use-websocket';
import { toast } from 'sonner';
import { useOverviewOptions } from '../useOverviewOptions';
export interface LiveCounterProps {
data: number;
projectId: string;
}
const AnimatedNumbers = dynamic(() => import('react-animated-numbers'), {
ssr: false,
loading: () => <div>0</div>,
});
const FIFTEEN_SECONDS = 1000 * 15;
export default function LiveCounter({ data = 0, projectId }: LiveCounterProps) {
const { setLiveHistogram } = useOverviewOptions();
const ws = String(process.env.NEXT_PUBLIC_API_URL)
.replace(/^https/, 'wss')
.replace(/^http/, 'ws');
const client = useQueryClient();
const [counter, setCounter] = useState(data);
const [socketUrl] = useState(`${ws}/live/visitors/${projectId}`);
const lastRefresh = useRef(Date.now());
useWebSocket(socketUrl, {
shouldReconnect: () => true,
onMessage(event) {
const value = parseInt(event.data, 10);
if (!isNaN(value)) {
setCounter(value);
if (Date.now() - lastRefresh.current > FIFTEEN_SECONDS) {
lastRefresh.current = Date.now();
toast('Refreshed data');
client.refetchQueries({
type: 'active',
});
}
}
},
});
return (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => setLiveHistogram((p) => !p)}
className="border border-border rounded h-8 px-3 leading-none flex items-center font-medium gap-2"
>
<div className="relative">
<div
className={cn(
'bg-emerald-500 h-3 w-3 rounded-full animate-ping opacity-100 transition-all',
counter === 0 && 'bg-destructive opacity-0'
)}
></div>
<div
className={cn(
'bg-emerald-500 h-3 w-3 rounded-full absolute top-0 left-0 transition-all',
counter === 0 && 'bg-destructive'
)}
></div>
</div>
<AnimatedNumbers
includeComma
transitions={(index) => ({
type: 'spring',
duration: index + 0.3,
damping: 10,
stiffness: 200,
})}
animateToNumber={counter}
locale="en"
/>
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{counter} unique visitors last 5 minutes</p>
<p>Click to see activity for the last 30 minutes</p>
</TooltipContent>
</Tooltip>
);
}

View File

@@ -0,0 +1,16 @@
import { getConversionEventNames } from '@mixan/db';
import type { OverviewLatestEventsProps } from './overview-latest-events';
import OverviewLatestEvents from './overview-latest-events';
export default async function OverviewLatestEventsServer({
projectId,
}: Omit<OverviewLatestEventsProps, 'conversions'>) {
const eventNames = await getConversionEventNames(projectId);
return (
<OverviewLatestEvents
projectId={projectId}
conversions={eventNames.map((item) => item.name)}
/>
);
}

View File

@@ -0,0 +1,128 @@
'use client';
import { ChartSwitch } from '@/components/report/chart';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn';
import { Widget, WidgetBody } from '../../Widget';
import { WidgetButtons, WidgetHead } from '../overview-widget';
import { useOverviewOptions } from '../useOverviewOptions';
import { useOverviewWidget } from '../useOverviewWidget';
export interface OverviewLatestEventsProps {
projectId: string;
conversions: string[];
}
export default function OverviewLatestEvents({
projectId,
conversions,
}: OverviewLatestEventsProps) {
const { interval, range, previous, startDate, endDate } =
useOverviewOptions();
const [filters] = useEventQueryFilters();
const [widget, setWidget, widgets] = useOverviewWidget('ev', {
all: {
title: 'Top events',
btn: 'All',
chart: {
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters: [
...filters,
{
id: 'ex_session',
name: 'name',
operator: 'isNot',
value: ['session_start', 'session_end'],
},
],
id: 'A',
name: '*',
},
],
breakdowns: [
{
id: 'A',
name: 'name',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
conversions: {
title: 'Conversions',
btn: 'Conversions',
hide: conversions.length === 0,
chart: {
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters: [
...filters,
{
id: 'conversion',
name: 'name',
operator: 'is',
value: conversions,
},
],
id: 'A',
name: '*',
},
],
breakdowns: [
{
id: 'A',
name: 'name',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
});
return (
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>
<div className="title">{widget.title}</div>
<WidgetButtons>
{widgets
.filter((item) => item.hide !== true)
.map((w) => (
<button
key={w.key}
onClick={() => setWidget(w.key)}
className={cn(w.key === widget.key && 'active')}
>
{w.btn}
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetBody>
<ChartSwitch hideID {...widget.chart} previous={false} />
</WidgetBody>
</Widget>
</>
);
}

View File

@@ -0,0 +1,158 @@
'use client';
import { Fragment } from 'react';
import { api } from '@/app/_trpc/client';
import { cn } from '@/utils/cn';
import { ChevronsUpDownIcon } from 'lucide-react';
import AnimateHeight from 'react-animate-height';
import type { IChartInput } from '@mixan/validation';
import { redisSub } from '../../../../../packages/redis';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { useOverviewOptions } from './useOverviewOptions';
interface OverviewLiveHistogramProps {
projectId: string;
}
export function OverviewLiveHistogram({
projectId,
}: OverviewLiveHistogramProps) {
const { liveHistogram } = useOverviewOptions();
const report: IChartInput = {
projectId,
events: [
{
segment: 'user',
filters: [
{
id: '1',
name: 'name',
operator: 'is',
value: ['screen_view', 'session_start'],
},
],
id: 'A',
name: '*',
displayName: 'Active users',
},
],
chartType: 'histogram',
interval: 'minute',
range: '30min',
name: '',
metric: 'sum',
breakdowns: [],
lineType: 'monotone',
previous: false,
};
const countReport: IChartInput = {
name: '',
projectId,
events: [
{
segment: 'user',
filters: [],
id: 'A',
name: 'session_start',
},
],
breakdowns: [],
chartType: 'metric',
lineType: 'monotone',
interval: 'minute',
range: '30min',
previous: false,
metric: 'sum',
};
const res = api.chart.chart.useQuery(report);
const countRes = api.chart.chart.useQuery(countReport);
const metrics = res.data?.series[0]?.metrics;
const minutes = (res.data?.series[0]?.data || []).slice(-30);
const liveCount = countRes.data?.series[0]?.metrics?.sum ?? 0;
if (res.isInitialLoading || countRes.isInitialLoading) {
// prettier-ignore
const staticArray = [
10, 25, 30, 45, 20, 5, 55, 18, 40, 12,
50, 35, 8, 22, 38, 42, 15, 28, 52, 5,
48, 14, 32, 58, 7, 19, 33, 56, 24, 5
];
return (
<Wrapper count={0} open={liveHistogram}>
{staticArray.map((percent, i) => (
<div
key={i}
className="flex-1 rounded-md bg-slate-200 animate-pulse"
style={{ height: `${percent}%` }}
/>
))}
</Wrapper>
);
}
if (!res.isSuccess && !countRes.isSuccess) {
return null;
}
return (
<Wrapper open={liveHistogram} count={liveCount}>
{minutes.map((minute) => {
return (
<Tooltip key={minute.date}>
<TooltipTrigger asChild>
<div
className={cn(
'flex-1 rounded-md hover:scale-110 transition-all ease-in-out',
minute.count === 0 ? 'bg-slate-200' : 'bg-blue-600'
)}
style={{
height:
minute.count === 0
? '5%'
: `${(minute.count / metrics!.max) * 100}%`,
}}
/>
</TooltipTrigger>
<TooltipContent side="top">
<div>{minute.count} active users</div>
<div>@ {new Date(minute.date).toLocaleTimeString()}</div>
</TooltipContent>
</Tooltip>
);
})}
</Wrapper>
);
}
interface WrapperProps {
open: boolean;
children: React.ReactNode;
count: number;
}
function Wrapper({ open, children, count }: WrapperProps) {
return (
<AnimateHeight duration={500} height={open ? 'auto' : 0}>
<div className="flex items-end flex-col md:flex-row">
<div className="md:mr-2 flex md:flex-col max-md:justify-between items-end max-md:w-full max-md:mb-2 md:card md:p-4">
<div className="text-sm max-md:mb-1">Last 30 minutes</div>
<div className="text-2xl font-bold text-ellipsis overflow-hidden whitespace-nowrap">
{count}
</div>
</div>
<div className="max-h-[150px] aspect-[5/1] flex flex-1 gap-0.5 md:gap-2 items-end w-full relative">
<div className="absolute -top-3 right-0 text-xs text-muted-foreground">
NOW
</div>
{/* <div className="md:absolute top-0 left-0 md:card md:p-4 mr-2 md:bg-white/90 z-50"> */}
{children}
</div>
</div>
</AnimateHeight>
);
}

View File

@@ -0,0 +1,227 @@
'use client';
import { WidgetHead } from '@/components/overview/overview-widget';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { ChartSwitch } from '@/components/report/chart';
import { Widget, WidgetBody } from '@/components/Widget';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn';
import type { IChartInput } from '@mixan/validation';
interface OverviewMetricsProps {
projectId: string;
}
export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
const { previous, range, interval, metric, setMetric, startDate, endDate } =
useOverviewOptions();
const [filters] = useEventQueryFilters();
const isPageFilter = filters.find((filter) => filter.name === 'path');
const reports = [
{
id: 'Visitors',
projectId,
startDate,
endDate,
events: [
{
segment: 'user',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
displayName: 'Visitors',
},
],
breakdowns: [],
chartType: 'metric',
lineType: 'monotone',
interval,
name: 'Visitors',
range,
previous,
metric: 'sum',
},
{
id: 'Sessions',
projectId,
startDate,
endDate,
events: [
{
segment: 'session',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
displayName: 'Sessions',
},
],
breakdowns: [],
chartType: 'metric',
lineType: 'monotone',
interval,
name: 'Sessions',
range,
previous,
metric: 'sum',
},
{
id: 'Pageviews',
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters,
id: 'A',
name: 'screen_view',
displayName: 'Pageviews',
},
],
breakdowns: [],
chartType: 'metric',
lineType: 'monotone',
interval,
name: 'Pageviews',
range,
previous,
metric: 'sum',
},
{
id: 'Views per session',
projectId,
startDate,
endDate,
events: [
{
segment: 'user_average',
filters,
id: 'A',
name: 'screen_view',
displayName: 'Views per session',
},
],
breakdowns: [],
chartType: 'metric',
lineType: 'monotone',
interval,
name: 'Views per session',
range,
previous,
metric: 'average',
},
{
id: 'Bounce rate',
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters: [
{
id: '1',
name: 'properties.__bounce',
operator: 'is',
value: ['true'],
},
...filters,
],
id: 'A',
name: 'session_end',
displayName: 'Bounce rate',
},
{
segment: 'event',
filters: filters,
id: 'B',
name: 'session_end',
displayName: 'Bounce rate',
},
],
breakdowns: [],
chartType: 'metric',
lineType: 'monotone',
interval,
name: 'Bounce rate',
range,
previous,
previousIndicatorInverted: true,
formula: 'A/B*100',
metric: 'average',
unit: '%',
},
{
id: 'Visit duration',
projectId,
startDate,
endDate,
events: [
{
segment: 'property_average',
filters: [
{
name: 'duration',
operator: 'isNot',
value: ['0'],
id: 'A',
},
...filters,
],
id: 'A',
property: 'duration',
name: isPageFilter ? 'screen_view' : 'session_end',
displayName: isPageFilter ? 'Time on page' : 'Visit duration',
},
],
breakdowns: [],
chartType: 'metric',
lineType: 'monotone',
interval,
name: 'Visit duration',
range,
previous,
formula: 'A/1000',
metric: 'average',
unit: 'min',
},
] satisfies (IChartInput & { id: string })[];
const selectedMetric = reports[metric]!;
return (
<>
<div className="grid grid-cols-6 col-span-6 gap-1">
{reports.map((report, index) => (
<button
key={index}
className={cn(
'relative col-span-3 md:col-span-2 lg:col-span-1 group transition-all scale-95',
index === metric && 'shadow-md rounded-xl scale-105 z-10'
)}
onClick={() => {
setMetric(index);
}}
>
<ChartSwitch hideID {...report} />
{/* add active border */}
</button>
))}
</div>
<Widget className="col-span-6">
<WidgetHead>
<div className="title">{selectedMetric.events[0]?.displayName}</div>
</WidgetHead>
<WidgetBody>
<ChartSwitch
key={selectedMetric.id}
hideID
{...selectedMetric}
chartType="linear"
/>
</WidgetBody>
</Widget>
</>
);
}

View File

@@ -0,0 +1,76 @@
'use client';
import { api } from '@/app/_trpc/client';
import { pushModal } from '@/modals';
import { EyeIcon, Globe2Icon, LockIcon } from 'lucide-react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import type { ShareOverview } from '@mixan/db';
import { Button } from '../ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from '../ui/dropdown-menu';
interface OverviewShareProps {
data: ShareOverview | null;
}
export function OverviewShare({ data }: OverviewShareProps) {
const router = useRouter();
const mutation = api.share.shareOverview.useMutation({
onSuccess() {
router.refresh();
},
});
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button icon={data && data.public ? Globe2Icon : LockIcon} responsive>
{data && data.public ? 'Public' : 'Private'}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuGroup>
{(!data || data.public === false) && (
<DropdownMenuItem onClick={() => pushModal('ShareOverviewModal')}>
<Globe2Icon size={16} className="mr-2" />
Make public
</DropdownMenuItem>
)}
{data?.public && (
<DropdownMenuItem asChild>
<Link
href={`${process.env.NEXT_PUBLIC_DASHBOARD_URL}/share/overview/${data.id}`}
>
<EyeIcon size={16} className="mr-2" />
View
</Link>
</DropdownMenuItem>
)}
{data?.public && (
<DropdownMenuItem
onClick={() => {
mutation.mutate({
public: false,
projectId: data?.project_id,
organizationId: data?.organization_slug,
password: null,
});
}}
>
<LockIcon size={16} className="mr-2" />
Make private
</DropdownMenuItem>
)}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,221 @@
'use client';
import { ChartSwitch } from '@/components/report/chart';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn';
import { Widget, WidgetBody } from '../Widget';
import { WidgetButtons, WidgetHead } from './overview-widget';
import { useOverviewOptions } from './useOverviewOptions';
import { useOverviewWidget } from './useOverviewWidget';
interface OverviewTopDevicesProps {
projectId: string;
}
export default function OverviewTopDevices({
projectId,
}: OverviewTopDevicesProps) {
const { interval, range, previous, startDate, endDate } =
useOverviewOptions();
const [filters, setFilter] = useEventQueryFilters();
const isPageFilter = filters.find((filter) => filter.name === 'path');
const [widget, setWidget, widgets] = useOverviewWidget('tech', {
devices: {
title: 'Top devices',
btn: 'Devices',
chart: {
projectId,
startDate,
endDate,
events: [
{
segment: 'user',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'device',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
browser: {
title: 'Top browser',
btn: 'Browser',
chart: {
projectId,
startDate,
endDate,
events: [
{
segment: 'user',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'browser',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
browser_version: {
title: 'Top Browser Version',
btn: 'Browser Version',
chart: {
projectId,
startDate,
endDate,
events: [
{
segment: 'user',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'browser_version',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
os: {
title: 'Top OS',
btn: 'OS',
chart: {
projectId,
startDate,
endDate,
events: [
{
segment: 'user',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'os',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
os_version: {
title: 'Top OS version',
btn: 'OS Version',
chart: {
projectId,
startDate,
endDate,
events: [
{
segment: 'user',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'os_version',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
});
return (
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>
<div className="title">{widget.title}</div>
<WidgetButtons>
{widgets.map((w) => (
<button
key={w.key}
onClick={() => setWidget(w.key)}
className={cn(w.key === widget.key && 'active')}
>
{w.btn}
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetBody>
<ChartSwitch
hideID
{...widget.chart}
previous={false}
onClick={(item) => {
switch (widget.key) {
case 'devices':
setFilter('device', item.name);
break;
case 'browser':
setFilter('browser', item.name);
break;
case 'browser_version':
setFilter('browser_version', item.name);
break;
case 'os':
setFilter('os', item.name);
break;
case 'os_version':
setFilter('os_version', item.name);
break;
}
}}
/>
</WidgetBody>
</Widget>
</>
);
}

View File

@@ -0,0 +1,16 @@
import { getConversionEventNames } from '@mixan/db';
import type { OverviewTopEventsProps } from './overview-top-events';
import OverviewTopEvents from './overview-top-events';
export default async function OverviewTopEventsServer({
projectId,
}: Omit<OverviewTopEventsProps, 'conversions'>) {
const eventNames = await getConversionEventNames(projectId);
return (
<OverviewTopEvents
projectId={projectId}
conversions={eventNames.map((item) => item.name)}
/>
);
}

View File

@@ -0,0 +1,128 @@
'use client';
import { ChartSwitch } from '@/components/report/chart';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn';
import { Widget, WidgetBody } from '../../Widget';
import { WidgetButtons, WidgetHead } from '../overview-widget';
import { useOverviewOptions } from '../useOverviewOptions';
import { useOverviewWidget } from '../useOverviewWidget';
export interface OverviewTopEventsProps {
projectId: string;
conversions: string[];
}
export default function OverviewTopEvents({
projectId,
conversions,
}: OverviewTopEventsProps) {
const { interval, range, previous, startDate, endDate } =
useOverviewOptions();
const [filters] = useEventQueryFilters();
const [widget, setWidget, widgets] = useOverviewWidget('ev', {
all: {
title: 'Top events',
btn: 'All',
chart: {
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters: [
...filters,
{
id: 'ex_session',
name: 'name',
operator: 'isNot',
value: ['session_start', 'session_end'],
},
],
id: 'A',
name: '*',
},
],
breakdowns: [
{
id: 'A',
name: 'name',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
conversions: {
title: 'Conversions',
btn: 'Conversions',
hide: conversions.length === 0,
chart: {
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters: [
...filters,
{
id: 'conversion',
name: 'name',
operator: 'is',
value: conversions,
},
],
id: 'A',
name: '*',
},
],
breakdowns: [
{
id: 'A',
name: 'name',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
});
return (
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>
<div className="title">{widget.title}</div>
<WidgetButtons>
{widgets
.filter((item) => item.hide !== true)
.map((w) => (
<button
key={w.key}
onClick={() => setWidget(w.key)}
className={cn(w.key === widget.key && 'active')}
>
{w.btn}
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetBody>
<ChartSwitch hideID {...widget.chart} previous={false} />
</WidgetBody>
</Widget>
</>
);
}

View File

@@ -0,0 +1,191 @@
'use client';
import { ChartSwitch } from '@/components/report/chart';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn';
import { Widget, WidgetBody } from '../Widget';
import { WidgetButtons, WidgetHead } from './overview-widget';
import { useOverviewOptions } from './useOverviewOptions';
import { useOverviewWidget } from './useOverviewWidget';
interface OverviewTopGeoProps {
projectId: string;
}
export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
const { interval, range, previous, startDate, endDate } =
useOverviewOptions();
const [filters, setFilter] = useEventQueryFilters();
const isPageFilter = filters.find((filter) => filter.name === 'path');
const [widget, setWidget, widgets] = useOverviewWidget('geo', {
countries: {
title: 'Top countries',
btn: 'Countries',
chart: {
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'country',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
regions: {
title: 'Top regions',
btn: 'Regions',
chart: {
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'region',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
cities: {
title: 'Top cities',
btn: 'Cities',
chart: {
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'city',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
});
return (
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>
<div className="title">{widget.title}</div>
<WidgetButtons>
{widgets.map((w) => (
<button
key={w.key}
onClick={() => setWidget(w.key)}
className={cn(w.key === widget.key && 'active')}
>
{w.btn}
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetBody>
<ChartSwitch
hideID
{...widget.chart}
previous={false}
onClick={(item) => {
switch (widget.key) {
case 'countries':
setWidget('regions');
setFilter('country', item.name);
break;
case 'regions':
setWidget('cities');
setFilter('region', item.name);
break;
case 'cities':
setFilter('city', item.name);
break;
}
}}
/>
</WidgetBody>
</Widget>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>
<div className="title">Map</div>
</WidgetHead>
<WidgetBody>
<ChartSwitch
hideID
{...{
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'country',
},
],
chartType: 'map',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
}}
/>
</WidgetBody>
</Widget>
</>
);
}

View File

@@ -0,0 +1,142 @@
'use client';
import { ChartSwitch } from '@/components/report/chart';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn';
import { Widget, WidgetBody } from '../Widget';
import { WidgetButtons, WidgetHead } from './overview-widget';
import { useOverviewOptions } from './useOverviewOptions';
import { useOverviewWidget } from './useOverviewWidget';
interface OverviewTopPagesProps {
projectId: string;
}
export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
const { interval, range, previous, startDate, endDate } =
useOverviewOptions();
const [filters, setFilter] = useEventQueryFilters();
const [widget, setWidget, widgets] = useOverviewWidget('pages', {
top: {
title: 'Top pages',
btn: 'Top pages',
chart: {
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters,
id: 'A',
name: 'screen_view',
},
],
breakdowns: [
{
id: 'A',
name: 'path',
},
],
chartType: 'bar',
lineType: 'monotone',
interval,
name: 'Top sources',
range,
previous,
metric: 'sum',
},
},
entries: {
title: 'Entry Pages',
btn: 'Entries',
chart: {
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters,
id: 'A',
name: 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'path',
},
],
chartType: 'bar',
lineType: 'monotone',
interval,
name: 'Top sources',
range,
previous,
metric: 'sum',
},
},
exits: {
title: 'Exit Pages',
btn: 'Exits',
chart: {
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters,
id: 'A',
name: 'session_end',
},
],
breakdowns: [
{
id: 'A',
name: 'path',
},
],
chartType: 'bar',
lineType: 'monotone',
interval,
name: 'Top sources',
range,
previous,
metric: 'sum',
},
},
});
return (
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>
<div className="title">{widget.title}</div>
<WidgetButtons>
{widgets.map((w) => (
<button
key={w.key}
onClick={() => setWidget(w.key)}
className={cn(w.key === widget.key && 'active')}
>
{w.btn}
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetBody>
<ChartSwitch
hideID
{...widget.chart}
previous={false}
onClick={(item) => {
setFilter('path', item.name);
}}
/>
</WidgetBody>
</Widget>
</>
);
}

View File

@@ -0,0 +1,322 @@
'use client';
import { ChartSwitch } from '@/components/report/chart';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn';
import { Widget, WidgetBody } from '../Widget';
import { WidgetButtons, WidgetHead } from './overview-widget';
import { useOverviewOptions } from './useOverviewOptions';
import { useOverviewWidget } from './useOverviewWidget';
interface OverviewTopSourcesProps {
projectId: string;
}
export default function OverviewTopSources({
projectId,
}: OverviewTopSourcesProps) {
const { interval, range, previous, startDate, endDate } =
useOverviewOptions();
const [filters, setFilter] = useEventQueryFilters();
const isPageFilter = filters.find((filter) => filter.name === 'path');
const [widget, setWidget, widgets] = useOverviewWidget('sources', {
all: {
title: 'Top sources',
btn: 'All',
chart: {
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters: filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'referrer_name',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top groups',
range: range,
previous: previous,
metric: 'sum',
},
},
domain: {
title: 'Top urls',
btn: 'URLs',
chart: {
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters: filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'referrer',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
type: {
title: 'Top types',
btn: 'Types',
chart: {
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters: filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'referrer_type',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top types',
range: range,
previous: previous,
metric: 'sum',
},
},
utm_source: {
title: 'UTM Source',
btn: 'Source',
chart: {
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'properties.query.utm_source',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
utm_medium: {
title: 'UTM Medium',
btn: 'Medium',
chart: {
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'properties.query.utm_medium',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
utm_campaign: {
title: 'UTM Campaign',
btn: 'Campaign',
chart: {
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'properties.query.utm_campaign',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
utm_term: {
title: 'UTM Term',
btn: 'Term',
chart: {
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'properties.query.utm_term',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
utm_content: {
title: 'UTM Content',
btn: 'Content',
chart: {
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'properties.query.utm_content',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
});
return (
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>
<div className="title">{widget.title}</div>
<WidgetButtons>
{widgets.map((w) => (
<button
key={w.key}
onClick={() => setWidget(w.key)}
className={cn(w.key === widget.key && 'active')}
>
{w.btn}
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetBody>
<ChartSwitch
hideID
{...widget.chart}
previous={false}
onClick={(item) => {
switch (widget.key) {
case 'all':
setFilter('referrer_name', item.name);
setWidget('domain');
break;
case 'domain':
setFilter('referrer', item.name);
break;
case 'type':
setFilter('referrer_type', item.name);
setWidget('domain');
break;
case 'utm_source':
setFilter('properties.query.utm_source', item.name);
break;
case 'utm_medium':
setFilter('properties.query.utm_medium', item.name);
break;
case 'utm_campaign':
setFilter('properties.query.utm_campaign', item.name);
break;
case 'utm_term':
setFilter('properties.query.utm_term', item.name);
break;
case 'utm_content':
setFilter('properties.query.utm_content', item.name);
break;
}
}}
/>
</WidgetBody>
</Widget>
</>
);
}

View File

@@ -0,0 +1,122 @@
'use client';
import { Children, useEffect, useRef, useState } from 'react';
import { useThrottle } from '@/hooks/useThrottle';
import { cn } from '@/utils/cn';
import { ChevronsUpDownIcon } from 'lucide-react';
import { last } from 'ramda';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from '../ui/dropdown-menu';
import type { WidgetHeadProps } from '../Widget';
import { WidgetHead as WidgetHeadBase } from '../Widget';
export function WidgetHead({ className, ...props }: WidgetHeadProps) {
return (
<WidgetHeadBase
className={cn('flex flex-col p-0 [&_.title]:p-4', className)}
{...props}
/>
);
}
export function WidgetButtons({
className,
children,
...props
}: WidgetHeadProps) {
const container = useRef<HTMLDivElement>(null);
const sizes = useRef<number[]>([]);
const [slice, setSlice] = useState(3); // Show 3 buttons by default
const gap = 16;
const handleResize = useThrottle(() => {
if (container.current) {
if (sizes.current.length === 0) {
// Get buttons
const buttons: HTMLButtonElement[] = Array.from(
container.current.querySelectorAll(`button`)
);
// Get sizes and cache them
sizes.current = buttons.map(
(button) => Math.ceil(button.offsetWidth) + gap
);
}
const containerWidth = container.current.offsetWidth;
const buttonsWidth = sizes.current.reduce((acc, size) => acc + size, 0);
const moreWidth = (last(sizes.current) ?? 0) + gap;
if (buttonsWidth > containerWidth) {
const res = sizes.current.reduce(
(acc, size, index) => {
if (acc.size + size + moreWidth > containerWidth) {
return { index: acc.index, size: acc.size + size };
}
return { index, size: acc.size + size };
},
{ index: 0, size: 0 }
);
setSlice(res.index);
} else {
setSlice(sizes.current.length - 1);
}
}
}, 30);
useEffect(() => {
handleResize();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [handleResize, children]);
const hidden = '!opacity-0 absolute pointer-events-none';
return (
<div
ref={container}
className={cn(
'px-4 self-stretch justify-start transition-opacity flex flex-wrap [&_button]:text-xs [&_button]:opacity-50 [&_button]:whitespace-nowrap [&_button.active]:opacity-100 [&_button.active]:border-b [&_button.active]:border-black [&_button]:py-1',
className
)}
style={{ gap }}
{...props}
>
{Children.map(children, (child, index) => {
return (
<div className={cn('flex', slice < index ? hidden : 'opacity-100')}>
{child}
</div>
);
})}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className={cn(
'flex items-center gap-1 select-none',
sizes.current.length - 1 === slice ? hidden : 'opacity-50'
)}
>
More <ChevronsUpDownIcon size={12} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="[&_button]:w-full">
<DropdownMenuGroup>
{Children.map(children, (child, index) => {
if (index <= slice) {
return null;
}
return <DropdownMenuItem asChild>{child}</DropdownMenuItem>;
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

View File

@@ -0,0 +1,72 @@
import {
parseAsBoolean,
parseAsInteger,
parseAsString,
parseAsStringEnum,
useQueryState,
} from 'nuqs';
import {
getDefaultIntervalByDates,
getDefaultIntervalByRange,
timeRanges,
} from '@mixan/constants';
import { mapKeys } from '@mixan/validation';
const nuqsOptions = { history: 'push' } as const;
export function useOverviewOptions() {
const [previous, setPrevious] = useQueryState(
'compare',
parseAsBoolean.withDefault(true).withOptions(nuqsOptions)
);
const [startDate, setStartDate] = useQueryState(
'start',
parseAsString.withOptions(nuqsOptions)
);
const [endDate, setEndDate] = useQueryState(
'end',
parseAsString.withOptions(nuqsOptions)
);
const [range, setRange] = useQueryState(
'range',
parseAsStringEnum(mapKeys(timeRanges))
.withDefault('7d')
.withOptions(nuqsOptions)
);
const interval =
getDefaultIntervalByDates(startDate, endDate) ||
getDefaultIntervalByRange(range);
const [metric, setMetric] = useQueryState(
'metric',
parseAsInteger.withDefault(0).withOptions(nuqsOptions)
);
// Toggles
const [liveHistogram, setLiveHistogram] = useQueryState(
'live',
parseAsBoolean.withDefault(true).withOptions(nuqsOptions)
);
return {
previous,
setPrevious,
range,
setRange,
metric,
setMetric,
startDate,
setStartDate,
endDate,
setEndDate,
// Computed
interval,
// Toggles
liveHistogram,
setLiveHistogram,
};
}

View File

@@ -0,0 +1,31 @@
import { parseAsStringEnum, useQueryState } from 'nuqs';
import { mapKeys } from '@mixan/validation';
import type { IChartInput } from '@mixan/validation';
export function useOverviewWidget<T extends string>(
key: string,
widgets: Record<
T,
{ title: string; btn: string; chart: IChartInput; hide?: boolean }
>
) {
const keys = Object.keys(widgets) as T[];
const [widget, setWidget] = useQueryState<T>(
key,
parseAsStringEnum(keys)
.withDefault(keys[0]!)
.withOptions({ history: 'push' })
);
return [
{
...widgets[widget],
key: widget,
},
setWidget,
mapKeys(widgets).map((key) => ({
...widgets[key],
key,
})),
] as const;
}

View File

@@ -0,0 +1,54 @@
'use client';
import { cn } from '@/utils/cn';
import { AvatarImage } from '@radix-ui/react-avatar';
import type { VariantProps } from 'class-variance-authority';
import { cva } from 'class-variance-authority';
import type { IServiceProfile } from '@mixan/db';
import { Avatar, AvatarFallback } from '../ui/avatar';
interface ProfileAvatarProps
extends VariantProps<typeof variants>,
Partial<Pick<IServiceProfile, 'avatar' | 'first_name'>> {
className?: string;
}
const variants = cva('', {
variants: {
size: {
default: 'h-12 w-12 rounded-full [&>span]:rounded-full',
sm: 'h-6 w-6 rounded [&>span]:rounded',
xs: 'h-4 w-4 rounded [&>span]:rounded',
},
},
defaultVariants: {
size: 'default',
},
});
export function ProfileAvatar({
avatar,
first_name,
className,
size,
}: ProfileAvatarProps) {
return (
<Avatar className={cn(variants({ className, size }), className)}>
{avatar && <AvatarImage src={avatar} />}
<AvatarFallback
className={cn(
size === 'sm'
? 'text-xs'
: size === 'xs'
? 'text-[8px]'
: 'text-base',
'bg-slate-200 text-slate-800'
)}
>
{first_name?.at(0) ?? '🫣'}
</AvatarFallback>
</Avatar>
);
}

View File

@@ -0,0 +1,74 @@
'use client';
import { api } from '@/app/_trpc/client';
import { pushModal, showConfirm } from '@/modals';
import { clipboard } from '@/utils/clipboard';
import { MoreHorizontal } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import type { IServiceProject } from '@mixan/db';
import { Button } from '../ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '../ui/dropdown-menu';
export function ProjectActions(project: Exclude<IServiceProject, null>) {
const { id } = project;
const router = useRouter();
const deletion = api.project.remove.useMutation({
onSuccess() {
toast('Success', {
description: 'Project deleted successfully.',
});
router.refresh();
},
});
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem onClick={() => clipboard(id)}>
Copy project ID
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
pushModal('EditProject', project);
}}
>
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={() => {
showConfirm({
title: 'Delete project',
text: 'This will delete all events for this project. This action cannot be undone.',
onConfirm() {
deletion.mutate({
id,
});
},
});
}}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,68 @@
import { shortNumber } from '@/hooks/useNumerFormatter';
import Link from 'next/link';
import type { IServiceProject } from '@mixan/db';
import { chQuery } from '@mixan/db';
import { ChartSSR } from '../chart-ssr';
export async function ProjectCard({
id,
name,
organizationSlug,
}: IServiceProject) {
const [chart, [data]] = await Promise.all([
chQuery<{ value: number; date: string }>(
`SELECT countDistinct(profile_id) as value, toStartOfDay(created_at) as date FROM events WHERE project_id = '${id}' AND name = 'session_start' AND created_at >= now() - interval '1 month' GROUP BY date ORDER BY date ASC`
),
chQuery<{ total: number; month: number; day: number }>(
`
SELECT
(
SELECT count(DISTINCT profile_id) as count FROM events WHERE project_id = '${id}'
) as total,
(
SELECT count(DISTINCT profile_id) as count FROM events WHERE project_id = '${id}' AND created_at >= now() - interval '1 month'
) as month,
(
SELECT count(DISTINCT profile_id) as count FROM events WHERE project_id = '${id}' AND created_at >= now() - interval '1 day'
) as day
`
),
]);
return (
<Link
href={`/${organizationSlug}/${id}`}
className="card p-4 inline-flex flex-col gap-2 hover:-translate-y-1 transition-transform"
>
<div className="font-medium">{name}</div>
<div className="aspect-[15/1] -mx-4">
<ChartSSR data={chart.map((d) => ({ ...d, date: new Date(d.date) }))} />
</div>
<div className="flex gap-4 justify-between text-muted-foreground text-sm">
<div className="font-medium">Visitors</div>
<div className="flex gap-4">
<div className="flex flex-col md:flex-row gap-2">
<div>Total</div>
<span className="text-black font-medium">
{shortNumber('en')(data?.total)}
</span>
</div>
<div className="flex flex-col md:flex-row gap-2">
<div>Month</div>
<span className="text-black font-medium">
{shortNumber('en')(data?.month)}
</span>
</div>
<div className="flex flex-col md:flex-row gap-2">
<div>24h</div>
<span className="text-black font-medium">
{shortNumber('en')(data?.day)}
</span>
</div>
</div>
</div>
</Link>
);
}

Some files were not shown because too many files have changed in this diff Show More