design improvements

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-02-28 10:19:37 +01:00
parent ee2ccbaa98
commit 3679caf547
22 changed files with 581 additions and 349 deletions

View File

@@ -52,6 +52,7 @@
"cmdk": "^0.2.1", "cmdk": "^0.2.1",
"date-fns": "^3.3.1", "date-fns": "^3.3.1",
"embla-carousel-react": "8.0.0-rc22", "embla-carousel-react": "8.0.0-rc22",
"flag-icons": "^7.1.0",
"hamburger-react": "^2.5.0", "hamburger-react": "^2.5.0",
"lodash.debounce": "^4.0.8", "lodash.debounce": "^4.0.8",
"lodash.throttle": "^4.1.1", "lodash.throttle": "^4.1.1",

View File

@@ -2,7 +2,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Checkbox, CheckboxInput } from '@/components/ui/checkbox'; import { CheckboxInput } from '@/components/ui/checkbox';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,

View File

@@ -18,30 +18,31 @@ import { EventIcon } from './event-icon';
type EventListItemProps = IServiceCreateEventPayload; type EventListItemProps = IServiceCreateEventPayload;
export function EventListItem({ export function EventListItem(props: EventListItemProps) {
profile, const {
createdAt, profile,
name, createdAt,
properties, name,
path, properties,
duration, path,
referrer, duration,
referrerName, referrer,
referrerType, referrerName,
brand, referrerType,
model, brand,
browser, model,
browserVersion, browser,
os, browserVersion,
osVersion, os,
city, osVersion,
region, city,
country, region,
continent, country,
device, continent,
projectId, device,
meta, projectId,
}: EventListItemProps) { meta,
} = props;
const params = useAppParams(); const params = useAppParams();
const [, setEvents] = useEventQueryNamesFilter({ shallow: false }); const [, setEvents] = useEventQueryNamesFilter({ shallow: false });
const [, setFilter] = useEventQueryFilters({ shallow: false }); const [, setFilter] = useEventQueryFilters({ shallow: false });
@@ -168,6 +169,9 @@ export function EventListItem({
content={ content={
<> <>
<KeyValueSubtle name="Time" value={createdAt.toLocaleString()} /> <KeyValueSubtle name="Time" value={createdAt.toLocaleString()} />
{profile?.id === props.deviceId && (
<KeyValueSubtle name="Anonymous" value={'Yes'} />
)}
{profile && ( {profile && (
<KeyValueSubtle <KeyValueSubtle
name="Profile" name="Profile"

View File

@@ -62,9 +62,7 @@ export default async function Page({
<OverviewTopPages projectId={projectId} /> <OverviewTopPages projectId={projectId} />
<OverviewTopDevices projectId={projectId} /> <OverviewTopDevices projectId={projectId} />
<OverviewTopEvents projectId={projectId} /> <OverviewTopEvents projectId={projectId} />
<div className="col-span-6"> <OverviewTopGeo projectId={projectId} />
<OverviewTopGeo projectId={projectId} />
</div>
</div> </div>
</PageLayout> </PageLayout>
); );

View File

@@ -1,61 +0,0 @@
'use client';
import { useMemo } from 'react';
import { api } from '@/app/_trpc/client';
import { Pagination, usePagination } from '@/components/Pagination';
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
import { useEventNames } from '@/hooks/useEventNames';
import { parseAsJson, useQueryState } from 'nuqs';
import { EventListItem } from '../../events/event-list-item';
interface ListProfileEvents {
projectId: string;
profileId: string;
}
export default function ListProfileEvents({
projectId,
profileId,
}: ListProfileEvents) {
const pagination = usePagination(50);
const [eventFilters, setEventFilters] = useQueryState(
'events',
parseAsJson<string[]>().withDefault([])
);
const eventNames = useEventNames(projectId);
const eventsQuery = api.event.list.useQuery(
{
projectId,
profileId,
events: eventFilters,
...pagination,
},
{
keepPreviousData: true,
}
);
const events = useMemo(() => eventsQuery.data ?? [], [eventsQuery]);
return (
<>
<div className="flex items-center justify-between mb-4">
<ComboboxAdvanced
placeholder="Filter events"
items={eventNames}
value={eventFilters}
onChange={setEventFilters}
/>
</div>
<div className="flex flex-col gap-4">
{events.map((item) => (
<EventListItem key={item.id} {...item} />
))}
</div>
<div className="mt-2">
<Pagination {...pagination} />
</div>
</>
);
}

View File

@@ -10,6 +10,7 @@ import {
eventQueryNamesFilter, eventQueryNamesFilter,
} from '@/hooks/useEventQueryFilters'; } from '@/hooks/useEventQueryFilters';
import { getExists } from '@/server/pageExists'; import { getExists } from '@/server/pageExists';
import { cn } from '@/utils/cn';
import { getProfileName } from '@/utils/getters'; import { getProfileName } from '@/utils/getters';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import { parseAsInteger, parseAsString } from 'nuqs'; import { parseAsInteger, parseAsString } from 'nuqs';

View File

@@ -7,7 +7,7 @@ import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-fi
import ServerLiveCounter from '@/components/overview/live-counter'; import ServerLiveCounter from '@/components/overview/live-counter';
import { OverviewLiveHistogram } from '@/components/overview/overview-live-histogram'; import { OverviewLiveHistogram } from '@/components/overview/overview-live-histogram';
import OverviewTopDevices from '@/components/overview/overview-top-devices'; import OverviewTopDevices from '@/components/overview/overview-top-devices';
import OverviewTopEvents from '@/components/overview/overview-top-events'; import OverviewTopEvents from '@/components/overview/overview-top-events/overview-top-events';
import OverviewTopGeo from '@/components/overview/overview-top-geo'; import OverviewTopGeo from '@/components/overview/overview-top-geo';
import OverviewTopPages from '@/components/overview/overview-top-pages'; import OverviewTopPages from '@/components/overview/overview-top-pages';
import OverviewTopSources from '@/components/overview/overview-top-sources'; import OverviewTopSources from '@/components/overview/overview-top-sources';

View File

@@ -3,6 +3,7 @@ import { cn } from '@/utils/cn';
import Providers from './providers'; import Providers from './providers';
import '@/styles/globals.css'; import '@/styles/globals.css';
import '/node_modules/flag-icons/css/flag-icons.min.css';
export const metadata = { export const metadata = {
title: 'Overview - Openpanel.dev', title: 'Overview - Openpanel.dev',

View File

@@ -191,28 +191,31 @@ export default function OverviewTopDevices({
<WidgetBody> <WidgetBody>
<ChartSwitch <ChartSwitch
hideID hideID
{...widget.chart} {...{
previous={false} projectId,
onClick={(item) => { startDate,
switch (widget.key) { endDate,
case 'devices': events: [
setFilter('device', item.name); {
break; segment: 'user',
case 'browser': filters,
setWidget('browser_version'); id: 'A',
setFilter('browser', item.name); name: 'session_start',
break; },
case 'browser_version': ],
setFilter('browser_version', item.name); breakdowns: [
break; {
case 'os': id: 'A',
setWidget('os_version'); name: 'browser_version',
setFilter('os', item.name); },
break; ],
case 'os_version': chartType: 'bar',
setFilter('os_version', item.name); lineType: 'monotone',
break; interval: interval,
} name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
}} }}
/> />
</WidgetBody> </WidgetBody>

View File

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

View File

@@ -4,16 +4,18 @@ import { ChartSwitch } from '@/components/report/chart';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters'; import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { Widget, WidgetBody } from '../Widget'; import { Widget, WidgetBody } from '../../Widget';
import { WidgetButtons, WidgetHead } from './overview-widget'; import { WidgetButtons, WidgetHead } from '../overview-widget';
import { useOverviewOptions } from './useOverviewOptions'; import { useOverviewOptions } from '../useOverviewOptions';
import { useOverviewWidget } from './useOverviewWidget'; import { useOverviewWidget } from '../useOverviewWidget';
interface OverviewTopEventsProps { export interface OverviewTopEventsProps {
projectId: string; projectId: string;
conversions: string[];
} }
export default function OverviewTopEvents({ export default function OverviewTopEvents({
projectId, projectId,
conversions,
}: OverviewTopEventsProps) { }: OverviewTopEventsProps) {
const { interval, range, previous, startDate, endDate } = const { interval, range, previous, startDate, endDate } =
useOverviewOptions(); useOverviewOptions();
@@ -57,6 +59,44 @@ export default function OverviewTopEvents({
metric: 'sum', metric: 'sum',
}, },
}, },
conversions: {
title: 'Conversions',
btn: 'Conversions',
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 ( return (

View File

@@ -17,36 +17,6 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
useOverviewOptions(); useOverviewOptions();
const [filters, setFilter] = useEventQueryFilters(); const [filters, setFilter] = useEventQueryFilters();
const [widget, setWidget, widgets] = useOverviewWidget('geo', { const [widget, setWidget, widgets] = useOverviewWidget('geo', {
map: {
title: 'Map',
btn: 'Map',
chart: {
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters,
id: 'A',
name: 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'country',
},
],
chartType: 'map',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
countries: { countries: {
title: 'Top countries', title: 'Top countries',
btn: 'Countries', btn: 'Countries',
@@ -179,6 +149,42 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
/> />
</WidgetBody> </WidgetBody>
</Widget> </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: 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'country',
},
],
chartType: 'map',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
}}
/>
</WidgetBody>
</Widget>
</> </>
); );
} }

View File

@@ -11,7 +11,6 @@ import type { IChartMetric } from '@mixan/validation';
import { import {
getDiffIndicator, getDiffIndicator,
PreviousDiffIndicator,
PreviousDiffIndicatorText, PreviousDiffIndicatorText,
} from '../PreviousDiffIndicator'; } from '../PreviousDiffIndicator';
import { useChartContext } from './ChartProvider'; import { useChartContext } from './ChartProvider';

View File

@@ -9,7 +9,7 @@ import { getChartColor } from '@/utils/theme';
import { NOT_SET_VALUE } from '@mixan/constants'; import { NOT_SET_VALUE } from '@mixan/constants';
import { PreviousDiffIndicator } from '../PreviousDiffIndicator'; import { PreviousDiffIndicatorText } from '../PreviousDiffIndicator';
import { useChartContext } from './ChartProvider'; import { useChartContext } from './ChartProvider';
import { SerieIcon } from './SerieIcon'; import { SerieIcon } from './SerieIcon';
@@ -30,42 +30,41 @@ export function ReportBarChart({ data }: ReportBarChartProps) {
<div <div
className={cn( className={cn(
'flex flex-col w-full text-xs -mx-2', 'flex flex-col w-full text-xs -mx-2',
editMode && editMode && 'text-base bg-white border border-border rounded-md p-4'
'text-base bg-white border border-border rounded-md p-4 pt-2'
)} )}
> >
{editMode && ( {series.map((serie) => {
<div className="-m-4 -mb-px flex justify-between font-medium p-4 pt-5 border-b border-border font-medium text-muted-foreground">
<div>Event</div>
<div>Count</div>
</div>
)}
{series.map((serie, index) => {
const isClickable = serie.name !== NOT_SET_VALUE && onClick; const isClickable = serie.name !== NOT_SET_VALUE && onClick;
return ( return (
<div <div
key={serie.name} key={serie.name}
className={cn( className={cn(
'py-2 px-2 flex flex-1 w-full gap-4 items-center even:bg-slate-50 [&_[role=progressbar]]:even:bg-white [&_[role=progressbar]]:shadow-sm rounded', 'relative py-3 px-2 flex flex-1 w-full gap-4 items-center even:bg-slate-50 [&_[role=progressbar]]:even:bg-white [&_[role=progressbar]]:shadow-sm rounded overflow-hidden',
isClickable && 'cursor-pointer hover:!bg-slate-100' isClickable && 'cursor-pointer hover:!bg-slate-100'
)} )}
{...(isClickable ? { onClick: () => onClick(serie) } : {})} {...(isClickable ? { onClick: () => onClick(serie) } : {})}
> >
<div className="flex-1 break-all flex items-center gap-2"> <div className="flex-1 break-all flex items-center gap-2 font-medium">
<SerieIcon name={serie.name} /> <SerieIcon name={serie.name} />
{serie.name} {serie.name}
</div> </div>
<div className="flex-shrink-0 flex w-1/4 gap-4 items-center justify-end"> <div className="flex-shrink-0 flex w-1/4 gap-4 items-center justify-end">
<PreviousDiffIndicator {...serie.metrics.previous[metric]} /> <PreviousDiffIndicatorText
{...serie.metrics.previous[metric]}
className="text-xs font-medium"
/>
{serie.metrics.previous[metric]?.value}
<div className="font-bold"> <div className="font-bold">
{number.format(serie.metrics.sum)} {number.format(serie.metrics.sum)}
</div> </div>
<Progress
color={getChartColor(index)}
className={cn('w-1/2', editMode ? 'h-5' : 'h-2')}
value={(serie.metrics.sum / maxCount) * 100}
/>
</div> </div>
<div
className="absolute left-0 bottom-0 h-0.5 rounded-full min-w-2 z-10 bg-blue-600"
style={{
width: `${(serie.metrics.sum / maxCount) * 100}%`,
}}
></div>
</div> </div>
); );
})} })}

View File

@@ -67,15 +67,12 @@ export function ReportChartTooltip({
{getLabel(data.label)} {getLabel(data.label)}
</div> </div>
<div className="flex justify-between gap-8"> <div className="flex justify-between gap-8">
<div> <div>{number.formatWithUnit(data.count, unit)}</div>
{number.format(data.count)}
{unit}
</div>
<div className="flex gap-1"> <div className="flex gap-1">
<PreviousDiffIndicator {...data.previous}> <PreviousDiffIndicator {...data.previous}>
{!!data.previous && {!!data.previous &&
`(${data.previous.value + (unit ? unit : '')})`} `(${number.formatWithUnit(data.previous.value, unit)})`}
</PreviousDiffIndicator> </PreviousDiffIndicator>
</div> </div>
</div> </div>

View File

@@ -30,6 +30,12 @@ const createImageIcon = (url: string) => {
} as LucideIcon; } as LucideIcon;
}; };
const createFlagIcon = (url: string) => {
return function (props: LucideProps) {
return <span className={`rounded fi fi-${url}`}></span>;
} as LucideIcon;
};
const mapper: Record<string, LucideIcon> = { const mapper: Record<string, LucideIcon> = {
// Events // Events
screen_view: MonitorPlayIcon, screen_view: MonitorPlayIcon,
@@ -88,6 +94,121 @@ const mapper: Record<string, LucideIcon> = {
email: MailIcon, email: MailIcon,
unknown: HelpCircleIcon, unknown: HelpCircleIcon,
[NOT_SET_VALUE]: ScanIcon, [NOT_SET_VALUE]: ScanIcon,
// Flags
se: createFlagIcon('se'),
us: createFlagIcon('us'),
gb: createFlagIcon('gb'),
ua: createFlagIcon('ua'),
ru: createFlagIcon('ru'),
de: createFlagIcon('de'),
fr: createFlagIcon('fr'),
br: createFlagIcon('br'),
in: createFlagIcon('in'),
it: createFlagIcon('it'),
es: createFlagIcon('es'),
pl: createFlagIcon('pl'),
nl: createFlagIcon('nl'),
id: createFlagIcon('id'),
tr: createFlagIcon('tr'),
ph: createFlagIcon('ph'),
ca: createFlagIcon('ca'),
ar: createFlagIcon('ar'),
mx: createFlagIcon('mx'),
za: createFlagIcon('za'),
au: createFlagIcon('au'),
co: createFlagIcon('co'),
ch: createFlagIcon('ch'),
at: createFlagIcon('at'),
be: createFlagIcon('be'),
pt: createFlagIcon('pt'),
my: createFlagIcon('my'),
th: createFlagIcon('th'),
vn: createFlagIcon('vn'),
sg: createFlagIcon('sg'),
eg: createFlagIcon('eg'),
sa: createFlagIcon('sa'),
pk: createFlagIcon('pk'),
bd: createFlagIcon('bd'),
ro: createFlagIcon('ro'),
hu: createFlagIcon('hu'),
cz: createFlagIcon('cz'),
gr: createFlagIcon('gr'),
il: createFlagIcon('il'),
no: createFlagIcon('no'),
fi: createFlagIcon('fi'),
dk: createFlagIcon('dk'),
sk: createFlagIcon('sk'),
bg: createFlagIcon('bg'),
hr: createFlagIcon('hr'),
rs: createFlagIcon('rs'),
ba: createFlagIcon('ba'),
si: createFlagIcon('si'),
lv: createFlagIcon('lv'),
lt: createFlagIcon('lt'),
ee: createFlagIcon('ee'),
by: createFlagIcon('by'),
md: createFlagIcon('md'),
kz: createFlagIcon('kz'),
uz: createFlagIcon('uz'),
kg: createFlagIcon('kg'),
tj: createFlagIcon('tj'),
tm: createFlagIcon('tm'),
az: createFlagIcon('az'),
ge: createFlagIcon('ge'),
am: createFlagIcon('am'),
af: createFlagIcon('af'),
ir: createFlagIcon('ir'),
iq: createFlagIcon('iq'),
sy: createFlagIcon('sy'),
lb: createFlagIcon('lb'),
jo: createFlagIcon('jo'),
ps: createFlagIcon('ps'),
kw: createFlagIcon('kw'),
qa: createFlagIcon('qa'),
om: createFlagIcon('om'),
ye: createFlagIcon('ye'),
ae: createFlagIcon('ae'),
bh: createFlagIcon('bh'),
cy: createFlagIcon('cy'),
mt: createFlagIcon('mt'),
sm: createFlagIcon('sm'),
li: createFlagIcon('li'),
is: createFlagIcon('is'),
al: createFlagIcon('al'),
mk: createFlagIcon('mk'),
me: createFlagIcon('me'),
ad: createFlagIcon('ad'),
lu: createFlagIcon('lu'),
mc: createFlagIcon('mc'),
fo: createFlagIcon('fo'),
gg: createFlagIcon('gg'),
je: createFlagIcon('je'),
im: createFlagIcon('im'),
gi: createFlagIcon('gi'),
va: createFlagIcon('va'),
ax: createFlagIcon('ax'),
bl: createFlagIcon('bl'),
mf: createFlagIcon('mf'),
pm: createFlagIcon('pm'),
yt: createFlagIcon('yt'),
wf: createFlagIcon('wf'),
tf: createFlagIcon('tf'),
re: createFlagIcon('re'),
sc: createFlagIcon('sc'),
mu: createFlagIcon('mu'),
zw: createFlagIcon('zw'),
mz: createFlagIcon('mz'),
na: createFlagIcon('na'),
bw: createFlagIcon('bw'),
ls: createFlagIcon('ls'),
sz: createFlagIcon('sz'),
bi: createFlagIcon('bi'),
rw: createFlagIcon('rw'),
ug: createFlagIcon('ug'),
ke: createFlagIcon('ke'),
tz: createFlagIcon('tz'),
mg: createFlagIcon('mg'),
}; };
export function SerieIcon({ name, ...props }: SerieIconProps) { export function SerieIcon({ name, ...props }: SerieIconProps) {

View File

@@ -30,5 +30,26 @@ export function useNumber() {
return { return {
format, format,
short, short,
shortWithUnit: (value: number | null | undefined, unit?: string | null) => {
if (isNil(value)) {
return 'N/A';
}
if (unit === 'min') {
return fancyMinutes(value);
}
return `${short(value)}${unit ? ` ${unit}` : ''}`;
},
formatWithUnit: (
value: number | null | undefined,
unit?: string | null
) => {
if (isNil(value)) {
return 'N/A';
}
if (unit === 'min') {
return fancyMinutes(value);
}
return `${format(value)}${unit ? ` ${unit}` : ''}`;
},
}; };
} }

View File

@@ -1,17 +1,25 @@
'use client'; 'use client';
import { useEffect } from 'react';
import { api, handleError } from '@/app/_trpc/client'; import { api, handleError } from '@/app/_trpc/client';
import { ButtonContainer } from '@/components/ButtonContainer';
import { InputWithLabel } from '@/components/forms/InputWithLabel';
import Syntax from '@/components/Syntax';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox'; import { CheckboxInput } from '@/components/ui/checkbox';
import { Combobox } from '@/components/ui/combobox'; import { Combobox } from '@/components/ui/combobox';
import {
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { useAppParams } from '@/hooks/useAppParams';
import { clipboard } from '@/utils/clipboard'; import { clipboard } from '@/utils/clipboard';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { Copy } from 'lucide-react'; import { Copy, SaveIcon } from 'lucide-react';
import Link from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import type { SubmitHandler } from 'react-hook-form';
import { Controller, useForm, useWatch } from 'react-hook-form'; import { Controller, useForm, useWatch } from 'react-hook-form';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { z } from 'zod'; import { z } from 'zod';
@@ -19,194 +27,234 @@ import { z } from 'zod';
import { popModal } from '.'; import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container'; import { ModalContent, ModalHeader } from './Modal/Container';
const validator = z.object({ const validation = z.object({
name: z.string().min(1, 'Required'), name: z.string().min(1),
cors: z.string().min(1, 'Required'), domain: z.string().optional(),
withCors: z.boolean(), withSecret: z.boolean().optional(),
projectId: z.string().min(1, 'Required'), projectId: z.string(),
}); });
type IForm = z.infer<typeof validator>; type IForm = z.infer<typeof validation>;
interface AddClientProps {
organizationId: string;
}
export default function AddClient({ organizationId }: AddClientProps) { export default function AddClient() {
const { organizationId, projectId } = useAppParams();
const router = useRouter(); const router = useRouter();
const query = api.project.list.useQuery({ const form = useForm<IForm>({
organizationId, resolver: zodResolver(validation),
defaultValues: {
withSecret: false,
name: '',
domain: '',
projectId,
},
}); });
const mutation = api.client.create2.useMutation({
const mutation = api.client.create.useMutation({
onError: handleError, onError: handleError,
onSuccess() { onSuccess() {
toast('Success', { toast.success('Client created');
description: 'Client created!',
});
router.refresh(); router.refresh();
}, },
}); });
const query = api.project.list.useQuery({
const { register, handleSubmit, formState, control } = useForm<IForm>({ organizationId,
resolver: zodResolver(validator),
defaultValues: {
name: '',
cors: '*',
projectId: '',
withCors: true,
},
}); });
const onSubmit: SubmitHandler<IForm> = (values) => {
mutation.mutate({
name: values.name,
domain: values.withSecret ? undefined : values.domain,
projectId: values.projectId,
organizationId,
});
};
const withCors = useWatch({ const watch = useWatch({
control, control: form.control,
name: 'withCors', name: 'withSecret',
}); });
if (mutation.isSuccess && mutation.data) {
const { clientId, clientSecret, cors } = mutation.data;
const snippet = clientSecret
? `const mixan = new Mixan({
clientId: "${clientId}",
// Avoid using this on web, rely on cors settings instead
// Mostly for react-native and node/backend
clientSecret: "${clientSecret}",
})`
: `const mixan = new Mixan({
clientId: "${clientId}",
})`;
return (
<ModalContent>
<ModalHeader title="Client created 🚀" />
<p>
Your client has been created! You will only see the client secret once
so keep it safe 🫣
</p>
<button className="mt-4 text-left" onClick={() => clipboard(cors)}>
<Label>Cors settings</Label>
<div className="flex items-center justify-between rounded bg-gray-100 p-2 px-3">
{cors}
<Copy size={16} />
</div>
</button>
<button className="mt-4 text-left" onClick={() => clipboard(clientId)}>
<Label>Client ID</Label>
<div className="flex items-center justify-between rounded bg-gray-100 p-2 px-3">
{clientId}
<Copy size={16} />
</div>
</button>
{clientSecret && (
<button
className="mt-4 text-left"
onClick={() => clipboard(clientSecret)}
>
<Label>Client Secret</Label>
<div className="flex items-center justify-between rounded bg-gray-100 p-2 px-3">
{clientSecret}
<Copy size={16} />
</div>
</button>
)}
<div className="mt-4">
<Label>Code snippet</Label>
<div className="[&_pre]:!rounded [&_pre]:!bg-gray-100 [&_pre]:text-sm">
<Syntax code={snippet} />
</div>
</div>
<ButtonContainer>
<div />
<Button onClick={() => popModal()}>Done</Button>
</ButtonContainer>
</ModalContent>
);
}
return ( return (
<ModalContent> <ModalContent>
<ModalHeader title="Create client" /> {mutation.isSuccess ? (
<form <>
onSubmit={handleSubmit((values) => { <ModalHeader
mutation.mutate({ title="Success"
...values, text={
organizationId, <>
}); {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. '}
<InputWithLabel See our{' '}
label="Name" <Link href="https//openpanel.dev/docs" className="underline">
placeholder="Name" documentation
{...register('name')} </Link>
className="mb-4" </>
/> }
/>
<Controller <div className="grid gap-4">
name="withCors" <button
control={control} className="mt-4 text-left"
render={({ field }) => ( onClick={() => clipboard(mutation.data.clientId)}
<label
htmlFor="cors"
className="flex items-center gap-2 text-sm font-medium leading-none mb-4"
> >
<Checkbox <Label>Client ID</Label>
id="cors" <div className="flex items-center justify-between rounded bg-gray-100 p-2 px-3">
ref={field.ref} {mutation.data.clientId}
onBlur={field.onBlur} <Copy size={16} />
defaultChecked={field.value} </div>
onCheckedChange={(checked) => { </button>
field.onChange(checked); {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={() => popModal()}
>
Close
</Button>
</DialogFooter>
</>
) : (
<>
<ModalHeader title="Create a client" />
<form
className="flex flex-col gap-4"
onSubmit={form.handleSubmit(onSubmit)}
>
<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>
<Controller
control={form.control}
name="projectId"
render={({ field }) => {
return (
<div>
<Label>Project</Label>
<Combobox
{...field}
className="w-full"
onChange={(value) => {
field.onChange(value);
}}
items={
query.data?.map((item) => ({
value: item.id,
label: item.name,
})) ?? []
}
placeholder="Select a project"
/>
</div>
);
}} }}
/> />
Auth with cors settings
</label>
)}
/>
{withCors && (
<>
<InputWithLabel
label="Cors"
placeholder="Cors"
{...register('cors')}
/>
<div className="text-xs text-muted-foreground mb-4 mt-1">
Restrict access by domain names (include https://)
</div> </div>
</> <DialogFooter>
)} <Button
<Controller type="button"
control={control} variant={'secondary'}
name="projectId" onClick={() => popModal()}
render={({ field }) => { >
return ( Cancel
<div> </Button>
<Label>Project</Label> <Button
<Combobox type="submit"
{...field} icon={SaveIcon}
onChange={(value) => { loading={mutation.isLoading}
field.onChange(value); >
}} Create
items={ </Button>
query.data?.map((item) => ({ </DialogFooter>
value: item.id, </form>
label: item.name, </>
})) ?? [] )}
}
placeholder="Select a project"
/>
</div>
);
}}
/>
<ButtonContainer>
<Button type="button" variant="outline" onClick={() => popModal()}>
Cancel
</Button>
<Button type="submit" disabled={!formState.isDirty}>
Create
</Button>
</ButtonContainer>
</form>
</ModalContent> </ModalContent>
); );
} }
{
/* <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

@@ -19,13 +19,17 @@ export function ModalContent({ children }: ModalContentProps) {
interface ModalHeaderProps { interface ModalHeaderProps {
title: string | React.ReactNode; title: string | React.ReactNode;
text?: string | React.ReactNode;
onClose?: (() => void) | false; onClose?: (() => void) | false;
} }
export function ModalHeader({ title, onClose }: ModalHeaderProps) { export function ModalHeader({ title, text, onClose }: ModalHeaderProps) {
return ( return (
<div className="flex items-center justify-between mb-6"> <div className="flex justify-between mb-6">
<div className="font-medium">{title}</div> <div>
<div className="font-medium mt-0.5">{title}</div>
{!!text && <div className="text-sm text-muted-foreground">{text}</div>}
</div>
{onClose !== false && ( {onClose !== false && (
<Button <Button
variant="ghost" variant="ghost"

View File

@@ -146,7 +146,7 @@ function fillEmptySpotsInTimeline(
} }
export function withFormula( export function withFormula(
{ formula }: IChartInput, { formula, events }: IChartInput,
series: GetChartDataResult series: GetChartDataResult
) { ) {
if (!formula) { if (!formula) {
@@ -164,6 +164,33 @@ export function withFormula(
return series; return series;
} }
if (events.length === 1) {
return series.map((serie) => {
return {
...serie,
data: serie.data.map((item) => {
serie.event.id;
const scope = {
[serie.event.id]: item?.count ?? 0,
};
const count = mathjs
.parse(formula)
.compile()
.evaluate(scope) as number;
return {
...item,
count:
Number.isNaN(count) || !Number.isFinite(count)
? null
: round(count, 2),
};
}),
};
});
}
return [ return [
{ {
...series[0], ...series[0],

View File

@@ -268,7 +268,7 @@ export async function getEventList({
sb.where.projectId = `project_id = '${projectId}'`; sb.where.projectId = `project_id = '${projectId}'`;
if (profileId) { if (profileId) {
sb.where.profileId = `profile_id = '${profileId}'`; sb.where.deviceId = `device_id IN (SELECT device_id as did FROM openpanel.events WHERE profile_id = '${profileId}' group by did)`;
} }
if (events && events.length > 0) { if (events && events.length > 0) {

7
pnpm-lock.yaml generated
View File

@@ -428,6 +428,9 @@ importers:
embla-carousel-react: embla-carousel-react:
specifier: 8.0.0-rc22 specifier: 8.0.0-rc22
version: 8.0.0-rc22(react@18.2.0) version: 8.0.0-rc22(react@18.2.0)
flag-icons:
specifier: ^7.1.0
version: 7.1.0
hamburger-react: hamburger-react:
specifier: ^2.5.0 specifier: ^2.5.0
version: 2.5.0(react@18.2.0) version: 2.5.0(react@18.2.0)
@@ -9229,6 +9232,10 @@ packages:
micromatch: 4.0.5 micromatch: 4.0.5
dev: false dev: false
/flag-icons@7.1.0:
resolution: {integrity: sha512-AH4v++19bpC5P3Wh767top4wylJYJCWkFnvNiDqGHDxqSqdMZ49jpLXp8PWBHTTXaNQ+/A+QPrOwyiIGaiIhmw==}
dev: false
/flat-cache@3.2.0: /flat-cache@3.2.0:
resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==}
engines: {node: ^10.12.0 || >=12.0.0} engines: {node: ^10.12.0 || >=12.0.0}