add onboarding and reports improvements

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-02-26 10:19:38 +01:00
parent 15388882be
commit 94a0ac7bd0
33 changed files with 1204 additions and 73 deletions

View File

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

View File

@@ -9,10 +9,12 @@ 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 { Dialog } from '@/components/ui/dialog';
import { getExists } from '@/server/pageExists';
import { db } from '@mixan/db';
import { CreateClient } from './create-client';
import { StickyBelowHeader } from './layout-sticky-below-header';
import OverviewMetrics from './overview-metrics';
import { OverviewReportRange } from './overview-sticky-header';
@@ -38,6 +40,7 @@ export default async function Page({
return (
<PageLayout title="Overview" organizationSlug={organizationId}>
<CreateClient />
<StickyBelowHeader>
<div className="p-4 flex gap-2 justify-between">
<div className="flex gap-2">

View File

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

View File

@@ -1,8 +1,9 @@
import { getOrganizationBySlug } from '@mixan/db';
import { getProjectWithMostEvents } from '@mixan/db';
import { LogoSquare } from '@/components/Logo';
import { notFound, redirect } from 'next/navigation';
import PageLayout from './[projectId]/page-layout';
import { getOrganizationBySlug, getProjectWithMostEvents } from '@mixan/db';
import { CreateProject } from './create-project';
interface PageProps {
params: {
@@ -20,15 +21,30 @@ export default async function Page({ params: { organizationId } }: PageProps) {
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 (project) {
return redirect(`/${organizationId}/${project.id}`);
}
return (
<PageLayout title="Projects" organizationSlug={organizationId}>
<div className="p-4">
<h1>Create your first project</h1>
<div className="flex items-center justify-center h-screen">
<div className="max-w-lg w-full">
<CreateProject />
</div>
</PageLayout>
</div>
);
}

View File

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

View File

@@ -1,14 +1,39 @@
import { CreateOrganization } from '@clerk/nextjs';
// 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 (organizations.length === 0) {
return <CreateOrganization />;
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>
);
}
return redirect(`/${organizations[0]?.slug}`);
if (organizations.length > 0) {
return redirect(`/${organizations[0]?.slug}`);
}
return (
<div className="flex items-center justify-center h-screen">
<div className="max-w-lg w-full">
<CreateOrganization />
</div>
</div>
);
}

View File

@@ -4,16 +4,22 @@ 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)}
>
<img
src="/logo.svg"
className="max-h-8 rounded-md"
alt="Openpanel logo"
/>
<LogoSquare className="max-h-8" />
openpanel.dev
</div>
);

View File

@@ -1,21 +1,115 @@
import { CalendarIcon } from 'lucide-react';
import * as React from 'react';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { useBreakpoint } from '@/hooks/useBreakpoint';
import { pushModal } from '@/modals';
import { useDispatch, useSelector } from '@/redux';
import { cn } from '@/utils/cn';
import { addDays, endOfDay, format, startOfDay, subDays } from 'date-fns';
import { CalendarIcon, ChevronsUpDownIcon } from 'lucide-react';
import type { DateRange, SelectRangeEventHandler } from 'react-day-picker';
import { timeRanges } from '@mixan/constants';
import type { IChartRange } from '@mixan/validation';
import type { ExtendedComboboxProps } from '../ui/combobox';
import { Combobox } from '../ui/combobox';
import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group';
import { changeDates, changeEndDate, changeStartDate } from './reportSlice';
export function ReportRange({
onChange,
value,
className,
...props
}: ExtendedComboboxProps<IChartRange>) {
const dispatch = useDispatch();
const startDate = useSelector((state) => state.report.startDate);
const endDate = useSelector((state) => state.report.endDate);
const setDate: SelectRangeEventHandler = (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()));
}
};
const { isBelowSm } = useBreakpoint('sm');
export function ReportRange(props: ExtendedComboboxProps<IChartRange>) {
return (
<Combobox
icon={CalendarIcon}
placeholder={'Range'}
items={Object.values(timeRanges).map((key) => ({
label: key,
value: key,
}))}
{...props}
/>
<>
<Popover>
<PopoverTrigger asChild>
<Button
id="date"
variant={'outline'}
className={cn('justify-start text-left font-normal', className)}
icon={CalendarIcon}
{...props}
>
<span className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap">
{startDate ? (
endDate ? (
<>
{format(startDate, 'LLL dd')} - {format(endDate, 'LLL dd')}
</>
) : (
format(startDate, 'LLL dd, y')
)
) : (
<span>{value}</span>
)}
</span>
<ChevronsUpDownIcon className="ml-auto h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<div className="p-4 border-b border-border">
<ToggleGroup
value={value}
onValueChange={(value) => {
if (value) onChange(value);
}}
type="single"
variant="outline"
className="flex-wrap max-sm:max-w-xs"
>
{Object.values(timeRanges).map((key) => (
<ToggleGroupItem value={key} aria-label={key} key={key}>
{key}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
<Calendar
initialFocus
mode="range"
defaultMonth={startDate ? new Date(startDate) : new Date()}
selected={{
from: startDate ? new Date(startDate) : undefined,
to: endDate ? new Date(endDate) : undefined,
}}
onSelect={setDate}
numberOfMonths={isBelowSm ? 1 : 2}
className="[&_table]:mx-auto [&_table]:w-auto"
/>
</PopoverContent>
</Popover>
</>
);
}

View File

@@ -28,6 +28,8 @@ export function Chart({
unit,
metric,
projectId,
startDate,
endDate,
}: ReportChartProps) {
const [data] = api.chart.chart.useSuspenseQuery(
{
@@ -39,8 +41,8 @@ export function Chart({
breakdowns,
name,
range,
startDate: null,
endDate: null,
startDate,
endDate,
projectId,
previous,
formula,

View File

@@ -12,6 +12,8 @@ import { cn } from '@/utils/cn';
import { round } from '@/utils/math';
import { ArrowRight, ArrowRightIcon } from 'lucide-react';
import { useChartContext } from '../chart/ChartProvider';
function FunnelChart({ from, to }: { from: number; to: number }) {
const fromY = 100 - from;
const toY = 100 - to;
@@ -82,15 +84,19 @@ export function FunnelSteps({
steps,
totalSessions,
}: RouterOutputs['chart']['funnel']) {
const { editMode } = useChartContext();
return (
<Carousel className="w-full py-4" opts={{ loop: false, dragFree: true }}>
<Carousel className="w-full" opts={{ loop: false, dragFree: true }}>
<CarouselContent>
<CarouselItem className={'flex-[0_0_0px] pl-3'} />
<CarouselItem className={'flex-[0_0_0] pl-3'} />
{steps.map((step, index, list) => {
const finalStep = index === list.length - 1;
return (
<CarouselItem
className={'flex-[0_0_320px] max-w-full p-0 px-1'}
className={cn(
'flex-[0_0_250px] max-w-full p-0 px-1',
editMode && 'flex-[0_0_320px]'
)}
key={step.event.id}
>
<div className="border border-border divide-y divide-border bg-white">

View File

@@ -1,5 +1,7 @@
import { start } from 'repl';
import { createSlice } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit';
import { isSameDay, isSameMonth } from 'date-fns';
import {
alphabetIds,
@@ -37,8 +39,8 @@ const initialState: InitialState = {
breakdowns: [],
events: [],
range: '1m',
startDate: null,
endDate: null,
startDate: new Date('2024-02-24 00:00:00').toISOString(),
endDate: new Date('2024-02-24 23:59:59').toISOString(),
previous: false,
formula: undefined,
unit: undefined,
@@ -66,6 +68,7 @@ export const reportSlice = createSlice({
},
setReport(state, action: PayloadAction<IChartInput>) {
return {
...state,
...action.payload,
startDate: null,
endDate: null,
@@ -176,21 +179,65 @@ export const reportSlice = createSlice({
state.lineType = action.payload;
},
// Custom start and end date
changeDates: (
state,
action: PayloadAction<{
startDate: string;
endDate: string;
}>
) => {
state.dirty = true;
state.startDate = action.payload.startDate;
state.endDate = action.payload.endDate;
if (isSameDay(state.startDate, state.endDate)) {
state.interval = 'hour';
} else if (isSameMonth(state.startDate, state.endDate)) {
state.interval = 'day';
} else {
state.interval = 'month';
}
},
// Date range
changeStartDate: (state, action: PayloadAction<string>) => {
state.dirty = true;
state.startDate = action.payload;
if (state.startDate && state.endDate) {
if (isSameDay(state.startDate, state.endDate)) {
state.interval = 'hour';
} else if (isSameMonth(state.startDate, state.endDate)) {
state.interval = 'day';
} else {
state.interval = 'month';
}
}
},
// Date range
changeEndDate: (state, action: PayloadAction<string>) => {
state.dirty = true;
state.endDate = action.payload;
if (state.startDate && state.endDate) {
if (isSameDay(state.startDate, state.endDate)) {
state.interval = 'hour';
} else if (isSameMonth(state.startDate, state.endDate)) {
state.interval = 'day';
} else {
state.interval = 'month';
}
}
},
changeDateRanges: (state, action: PayloadAction<IChartRange>) => {
state.dirty = true;
state.range = action.payload;
state.startDate = null;
state.endDate = null;
state.interval = getDefaultIntervalByRange(action.payload);
},
@@ -215,6 +262,9 @@ export const {
removeBreakdown,
changeBreakdown,
changeInterval,
changeDates,
changeStartDate,
changeEndDate,
changeDateRanges,
changeChartType,
changeLineType,

View File

@@ -43,7 +43,7 @@ export function ReportBreakdowns() {
<div className="flex flex-col gap-4">
{selectedBreakdowns.map((item, index) => {
return (
<div key={item.name} className="rounded-lg border">
<div key={item.name} className="rounded-lg border bg-slate-50">
<div className="flex items-center gap-2 p-2 px-4">
<ColorSquare>{index}</ColorSquare>
<Combobox

View File

@@ -54,7 +54,7 @@ export function ReportEvents() {
<div className="flex flex-col gap-4">
{selectedEvents.map((event) => {
return (
<div key={event.name} className="rounded-lg border">
<div key={event.name} className="rounded-lg border bg-slate-50">
<div className="flex items-center gap-2 p-2">
<ColorSquare>{event.id}</ColorSquare>
<Combobox
@@ -131,7 +131,7 @@ export function ReportEvents() {
]}
label="Segment"
>
<button className="flex items-center gap-1 rounded-md border border-border p-1 px-2 font-medium leading-none text-xs">
<button className="flex items-center gap-1 rounded-md border border-border p-1 px-2 font-medium leading-none text-xs bg-white">
{event.segment === 'user' ? (
<>
<Users size={12} /> Unique users

View File

@@ -119,6 +119,7 @@ export function FilterItem({ filter, event }: FilterProps) {
<ComboboxAdvanced
items={valuesCombobox}
value={filter.value}
className="flex-1"
onChange={(setFn) => {
changeFilterValue(
typeof setFn === 'function' ? setFn(filter.value) : setFn

View File

@@ -54,7 +54,7 @@ export function FiltersCombobox({ event }: FiltersComboboxProps) {
);
}}
>
<button className="flex items-center gap-1 rounded-md border border-border p-1 px-2 font-medium leading-none text-xs">
<button className="flex items-center gap-1 rounded-md border border-border p-1 px-2 font-medium leading-none text-xs bg-white">
<FilterIcon size={12} /> Add filter
</button>
</Combobox>

View File

@@ -0,0 +1,56 @@
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDown } from "lucide-react"
import { cn } from "@/utils/cn"
const Accordion = AccordionPrimitive.Root
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
{...props}
/>
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@@ -75,7 +75,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
{Icon && (
<Icon
className={cn(
'h-4 w-4 mr-2',
'h-4 w-4 mr-2 flex-shrink-0',
responsive && 'mr-0 sm:mr-2',
loading && 'animate-spin'
)}

View File

@@ -0,0 +1,64 @@
import * as React from "react"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { DayPicker } from "react-day-picker"
import { cn } from "@/utils/cn"
import { buttonVariants } from "@/components/ui/button"
export type CalendarProps = React.ComponentProps<typeof DayPicker>
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell:
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
day: cn(
buttonVariants({ variant: "ghost" }),
"h-9 w-9 p-0 font-normal aria-selected:opacity-100"
),
day_range_end: "day-range-end",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ ...props }) => <ChevronLeft className="h-4 w-4" />,
IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" />,
}}
{...props}
/>
)
}
Calendar.displayName = "Calendar"
export { Calendar }

View File

@@ -26,4 +26,15 @@ const Checkbox = React.forwardRef<
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };
const CheckboxInput = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>((props, ref) => (
<label className="cursor-pointer flex items-center select-none border border-border rounded-md px-3 gap-4 min-h-10">
<Checkbox ref={ref} {...props} />
<div className="text-sm font-medium">{props.children}</div>
</label>
));
CheckboxInput.displayName = 'CheckboxInput';
export { Checkbox, CheckboxInput };

View File

@@ -1,20 +1,17 @@
'use client';
import * as React from 'react';
import { Badge } from '@/components/ui/badge';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { ChevronsUpDownIcon } from 'lucide-react';
import { useOnClickOutside } from 'usehooks-ts';
import { Button } from './button';
import { Checkbox } from './checkbox';
import { Input } from './input';
import { Popover, PopoverContent, PopoverTrigger } from './popover';
type IValue = any;
@@ -90,34 +87,33 @@ export function ComboboxAdvanced({
>
<div className="flex gap-1 flex-wrap w-full">
{value.length === 0 && placeholder}
{value.slice(0, 2).map((value) => {
{value.map((value) => {
const item = items.find((item) => item.value === value) ?? {
value,
label: value,
};
return <Badge key={String(item.value)}>{item.label}</Badge>;
})}
{value.length > 2 && <Badge>+{value.length - 2} more</Badge>}
</div>
<ChevronsUpDownIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full max-w-md p-0" align="start">
<Command>
<Command shouldFilter={false}>
<CommandInput
placeholder="Search"
value={inputValue}
onValueChange={setInputValue}
/>
<CommandGroup>
{inputValue === ''
? value.map(renderUnknownItem)
: renderItem({
value: inputValue,
label: `Pick "${inputValue}"`,
})}
<CommandList>
{inputValue !== '' &&
renderItem({
value: inputValue,
label: `Pick '${inputValue}'`,
})}
{value.map(renderUnknownItem)}
{selectables.map(renderItem)}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>

View File

@@ -77,8 +77,8 @@ export function Combobox<T extends string>({
aria-expanded={open}
className={cn('justify-between', className)}
>
<div className="flex min-w-0 gap-2 items-center">
{Icon ? <Icon className="mr-2" size={16} /> : null}
<div className="flex min-w-0 items-center">
{Icon ? <Icon size={16} className="mr-2 shrink-0" /> : null}
<span className="overflow-hidden text-ellipsis whitespace-nowrap">
{value ? find(value)?.label ?? 'No match' : placeholder}
</span>

View File

@@ -30,8 +30,10 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
onClose?: () => void;
}
>(({ className, children, onClose, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
@@ -43,7 +45,10 @@ const DialogContent = React.forwardRef<
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<DialogPrimitive.Close
onClick={onClose}
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
@@ -72,7 +77,7 @@ const DialogFooter = ({
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
'flex flex-col-reverse sm:flex-row sm:justify-end gap-2',
className
)}
{...props}

View File

@@ -2,19 +2,36 @@
import * as React from 'react';
import { cn } from '@/utils/cn';
import type { VariantProps } from 'class-variance-authority';
import { cva } from 'class-variance-authority';
export type InputProps = React.InputHTMLAttributes<HTMLInputElement> & {
error?: string | undefined;
};
const inputVariant = cva(
'flex w-full rounded-md border border-input bg-background ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50',
{
variants: {
size: {
default: 'h-8 px-3 py-2 text-sm',
large: 'h-12 px-4 py-3 text-lg',
},
},
defaultVariants: {
size: 'default',
},
}
);
export type InputProps = VariantProps<typeof inputVariant> &
Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'> & {
error?: string | undefined;
};
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, error, type, ...props }, ref) => {
({ className, error, type, size, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50',
className,
inputVariant({ size, className }),
!!error && 'border-destructive'
)}
ref={ref}

View File

@@ -1,5 +1,3 @@
'use client';
import * as React from 'react';
import { cn } from '@/utils/cn';
import * as PopoverPrimitive from '@radix-ui/react-popover';
@@ -12,7 +10,7 @@ const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
<>
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
@@ -21,9 +19,12 @@ const PopoverContent = React.forwardRef<
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
style={{
boxShadow: '0 0 0 9000px rgba(0, 0, 0, 0.05)',
}}
{...props}
/>
</>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;

View File

@@ -0,0 +1,59 @@
import * as React from "react"
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
import { VariantProps } from "class-variance-authority"
import { cn } from "@/utils/cn"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: "default",
variant: "default",
})
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root
ref={ref}
className={cn("flex items-center justify-center gap-1", className)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
))
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>
>(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
})
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
export { ToggleGroup, ToggleGroupItem }

View File

@@ -0,0 +1,43 @@
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/utils/cn"
const toggleVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-10 px-3",
sm: "h-9 px-2.5",
lg: "h-11 px-5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const Toggle = React.forwardRef<
React.ElementRef<typeof TogglePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root
ref={ref}
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
))
Toggle.displayName = TogglePrimitive.Root.displayName
export { Toggle, toggleVariants }

View File

@@ -4,6 +4,7 @@ import { chartRouter } from './routers/chart';
import { clientRouter } from './routers/client';
import { dashboardRouter } from './routers/dashboard';
import { eventRouter } from './routers/event';
import { onboardingRouter } from './routers/onboarding';
import { organizationRouter } from './routers/organization';
import { profileRouter } from './routers/profile';
import { projectRouter } from './routers/project';
@@ -29,6 +30,7 @@ export const appRouter = createTRPCRouter({
profile: profileRouter,
ui: uiRouter,
share: shareRouter,
onboarding: onboardingRouter,
});
// export type definition of API

View File

@@ -5,7 +5,17 @@ import {
} from '@/server/api/trpc';
import { getDaysOldDate } from '@/utils/date';
import { average, max, min, round, sum } from '@/utils/math';
import { flatten, map, pipe, prop, repeat, reverse, sort, uniq } from 'ramda';
import {
flatten,
map,
pick,
pipe,
prop,
repeat,
reverse,
sort,
uniq,
} from 'ramda';
import { z } from 'zod';
import { chQuery, createSqlBuilder } from '@mixan/db';
@@ -260,7 +270,10 @@ export const chartRouter = createTRPCRouter({
// TODO: Make this private
chart: publicProcedure.input(zChartInput).query(async ({ input }) => {
const current = getDatesFromRange(input.range);
const current =
input.startDate && input.endDate
? { startDate: input.startDate, endDate: input.endDate }
: getDatesFromRange(input.range);
let diff = 0;
switch (input.range) {

View File

@@ -80,6 +80,33 @@ export const clientRouter = createTRPCRouter({
cors: client.cors,
};
}),
create2: protectedProcedure
.input(
z.object({
name: z.string(),
projectId: z.string(),
organizationId: z.string(),
domain: z.string().nullish(),
})
)
.mutation(async ({ input }) => {
const secret = randomUUID();
const client = await db.client.create({
data: {
organization_slug: input.organizationId,
project_id: input.projectId,
name: input.name,
secret: input.domain ? undefined : await hashPassword(secret),
cors: input.domain || undefined,
},
});
return {
clientSecret: input.domain ? null : secret,
clientId: client.id,
cors: client.cors,
};
}),
remove: protectedProcedure
.input(
z.object({

View File

@@ -0,0 +1,40 @@
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import { clerkClient } from '@clerk/nextjs';
import { z } from 'zod';
import { db } from '@mixan/db';
export const onboardingRouter = createTRPCRouter({
organziation: protectedProcedure
.input(
z.object({
organization: z.string(),
project: z.string().optional(),
})
)
.mutation(async ({ input, ctx }) => {
const org = await clerkClient.organizations.createOrganization({
name: input.organization,
createdBy: ctx.session.userId,
});
if (org.slug && input.project) {
const project = await db.project.create({
data: {
name: input.project,
organization_slug: org.slug,
},
});
return {
project,
organization: org,
};
}
return {
project: null,
organization: org,
};
}),
});