move sdk packages to its own folder and rename api & dashboard
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}`);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './organization/page';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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's you need to go. See you next time
|
||||
</p>
|
||||
<SignOutButton />
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
113
apps/dashboard/src/app/(app)/[organizationId]/create-project.tsx
Normal file
113
apps/dashboard/src/app/(app)/[organizationId]/create-project.tsx
Normal 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>
|
||||
65
apps/dashboard/src/app/(app)/[organizationId]/page.tsx
Normal file
65
apps/dashboard/src/app/(app)/[organizationId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
apps/dashboard/src/app/(app)/create-organization.tsx
Normal file
86
apps/dashboard/src/app/(app)/create-organization.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
39
apps/dashboard/src/app/(app)/page.tsx
Normal file
39
apps/dashboard/src/app/(app)/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
apps/dashboard/src/app/(public)/share/overview/[id]/page.tsx
Normal file
74
apps/dashboard/src/app/(public)/share/overview/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
apps/dashboard/src/app/_trpc/client.tsx
Normal file
39
apps/dashboard/src/app/_trpc/client.tsx
Normal 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,
|
||||
});
|
||||
};
|
||||
}
|
||||
25
apps/dashboard/src/app/api/trpc/[trpc]/route.ts
Normal file
25
apps/dashboard/src/app/api/trpc/[trpc]/route.ts
Normal 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 };
|
||||
87
apps/dashboard/src/app/auth.tsx
Normal file
87
apps/dashboard/src/app/auth.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
apps/dashboard/src/app/layout.tsx
Normal file
33
apps/dashboard/src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
apps/dashboard/src/app/manifest.ts
Normal file
20
apps/dashboard/src/app/manifest.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
71
apps/dashboard/src/app/providers.tsx
Normal file
71
apps/dashboard/src/app/providers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
3
apps/dashboard/src/components/AutoSizer.tsx
Normal file
3
apps/dashboard/src/components/AutoSizer.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
|
||||
export { AutoSizer };
|
||||
11
apps/dashboard/src/components/ButtonContainer.tsx
Normal file
11
apps/dashboard/src/components/ButtonContainer.tsx
Normal 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} />
|
||||
);
|
||||
}
|
||||
50
apps/dashboard/src/components/Card.tsx
Normal file
50
apps/dashboard/src/components/Card.tsx
Normal 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;
|
||||
23
apps/dashboard/src/components/ColorSquare.tsx
Normal file
23
apps/dashboard/src/components/ColorSquare.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
apps/dashboard/src/components/Container.tsx
Normal file
11
apps/dashboard/src/components/Container.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
52
apps/dashboard/src/components/Content.tsx
Normal file
52
apps/dashboard/src/components/Content.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
apps/dashboard/src/components/DataTable.tsx
Normal file
75
apps/dashboard/src/components/DataTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
apps/dashboard/src/components/Dropdown.tsx
Normal file
55
apps/dashboard/src/components/Dropdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
apps/dashboard/src/components/FullPageEmptyState.tsx
Normal file
28
apps/dashboard/src/components/FullPageEmptyState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
apps/dashboard/src/components/Logo.tsx
Normal file
26
apps/dashboard/src/components/Logo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
apps/dashboard/src/components/PageTitle.tsx
Normal file
11
apps/dashboard/src/components/PageTitle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
apps/dashboard/src/components/Pagination.tsx
Normal file
57
apps/dashboard/src/components/Pagination.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
apps/dashboard/src/components/Syntax.tsx
Normal file
19
apps/dashboard/src/components/Syntax.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
apps/dashboard/src/components/Widget.tsx
Normal file
34
apps/dashboard/src/components/Widget.tsx
Normal 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>;
|
||||
}
|
||||
15
apps/dashboard/src/components/WithSidebar.tsx
Normal file
15
apps/dashboard/src/components/WithSidebar.tsx
Normal 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>;
|
||||
}
|
||||
68
apps/dashboard/src/components/chart-ssr.tsx
Normal file
68
apps/dashboard/src/components/chart-ssr.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
apps/dashboard/src/components/clients/ClientActions.tsx
Normal file
73
apps/dashboard/src/components/clients/ClientActions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
apps/dashboard/src/components/clients/table.tsx
Normal file
54
apps/dashboard/src/components/clients/table.tsx
Normal 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} />,
|
||||
},
|
||||
];
|
||||
35
apps/dashboard/src/components/events/ListProperties.tsx
Normal file
35
apps/dashboard/src/components/events/ListProperties.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
apps/dashboard/src/components/forms/InputError.tsx
Normal file
11
apps/dashboard/src/components/forms/InputError.tsx
Normal 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>;
|
||||
}
|
||||
32
apps/dashboard/src/components/forms/InputWithLabel.tsx
Normal file
32
apps/dashboard/src/components/forms/InputWithLabel.tsx
Normal 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';
|
||||
57
apps/dashboard/src/components/general/ExpandableListItem.tsx
Normal file
57
apps/dashboard/src/components/general/ExpandableListItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
227
apps/dashboard/src/components/overview/overview-metrics.tsx
Normal file
227
apps/dashboard/src/components/overview/overview-metrics.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
76
apps/dashboard/src/components/overview/overview-share.tsx
Normal file
76
apps/dashboard/src/components/overview/overview-share.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
221
apps/dashboard/src/components/overview/overview-top-devices.tsx
Normal file
221
apps/dashboard/src/components/overview/overview-top-devices.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
191
apps/dashboard/src/components/overview/overview-top-geo.tsx
Normal file
191
apps/dashboard/src/components/overview/overview-top-geo.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
142
apps/dashboard/src/components/overview/overview-top-pages.tsx
Normal file
142
apps/dashboard/src/components/overview/overview-top-pages.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
322
apps/dashboard/src/components/overview/overview-top-sources.tsx
Normal file
322
apps/dashboard/src/components/overview/overview-top-sources.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
122
apps/dashboard/src/components/overview/overview-widget.tsx
Normal file
122
apps/dashboard/src/components/overview/overview-widget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
apps/dashboard/src/components/overview/useOverviewOptions.ts
Normal file
72
apps/dashboard/src/components/overview/useOverviewOptions.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
31
apps/dashboard/src/components/overview/useOverviewWidget.tsx
Normal file
31
apps/dashboard/src/components/overview/useOverviewWidget.tsx
Normal 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;
|
||||
}
|
||||
54
apps/dashboard/src/components/profiles/ProfileAvatar.tsx
Normal file
54
apps/dashboard/src/components/profiles/ProfileAvatar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
apps/dashboard/src/components/projects/ProjectActions.tsx
Normal file
74
apps/dashboard/src/components/projects/ProjectActions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
68
apps/dashboard/src/components/projects/project-card.tsx
Normal file
68
apps/dashboard/src/components/projects/project-card.tsx
Normal 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
Reference in New Issue
Block a user