design improvements
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox, CheckboxInput } from '@/components/ui/checkbox';
|
||||
import { CheckboxInput } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
||||
@@ -18,30 +18,31 @@ import { EventIcon } from './event-icon';
|
||||
|
||||
type EventListItemProps = IServiceCreateEventPayload;
|
||||
|
||||
export function EventListItem({
|
||||
profile,
|
||||
createdAt,
|
||||
name,
|
||||
properties,
|
||||
path,
|
||||
duration,
|
||||
referrer,
|
||||
referrerName,
|
||||
referrerType,
|
||||
brand,
|
||||
model,
|
||||
browser,
|
||||
browserVersion,
|
||||
os,
|
||||
osVersion,
|
||||
city,
|
||||
region,
|
||||
country,
|
||||
continent,
|
||||
device,
|
||||
projectId,
|
||||
meta,
|
||||
}: EventListItemProps) {
|
||||
export function EventListItem(props: EventListItemProps) {
|
||||
const {
|
||||
profile,
|
||||
createdAt,
|
||||
name,
|
||||
properties,
|
||||
path,
|
||||
duration,
|
||||
referrer,
|
||||
referrerName,
|
||||
referrerType,
|
||||
brand,
|
||||
model,
|
||||
browser,
|
||||
browserVersion,
|
||||
os,
|
||||
osVersion,
|
||||
city,
|
||||
region,
|
||||
country,
|
||||
continent,
|
||||
device,
|
||||
projectId,
|
||||
meta,
|
||||
} = props;
|
||||
const params = useAppParams();
|
||||
const [, setEvents] = useEventQueryNamesFilter({ shallow: false });
|
||||
const [, setFilter] = useEventQueryFilters({ shallow: false });
|
||||
@@ -168,6 +169,9 @@ export function EventListItem({
|
||||
content={
|
||||
<>
|
||||
<KeyValueSubtle name="Time" value={createdAt.toLocaleString()} />
|
||||
{profile?.id === props.deviceId && (
|
||||
<KeyValueSubtle name="Anonymous" value={'Yes'} />
|
||||
)}
|
||||
{profile && (
|
||||
<KeyValueSubtle
|
||||
name="Profile"
|
||||
|
||||
@@ -62,9 +62,7 @@ export default async function Page({
|
||||
<OverviewTopPages projectId={projectId} />
|
||||
<OverviewTopDevices projectId={projectId} />
|
||||
<OverviewTopEvents projectId={projectId} />
|
||||
<div className="col-span-6">
|
||||
<OverviewTopGeo projectId={projectId} />
|
||||
</div>
|
||||
<OverviewTopGeo projectId={projectId} />
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
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';
|
||||
|
||||
@@ -7,7 +7,7 @@ import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-fi
|
||||
import ServerLiveCounter from '@/components/overview/live-counter';
|
||||
import { OverviewLiveHistogram } from '@/components/overview/overview-live-histogram';
|
||||
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 OverviewTopPages from '@/components/overview/overview-top-pages';
|
||||
import OverviewTopSources from '@/components/overview/overview-top-sources';
|
||||
|
||||
@@ -3,6 +3,7 @@ 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',
|
||||
|
||||
@@ -191,28 +191,31 @@ export default function OverviewTopDevices({
|
||||
<WidgetBody>
|
||||
<ChartSwitch
|
||||
hideID
|
||||
{...widget.chart}
|
||||
previous={false}
|
||||
onClick={(item) => {
|
||||
switch (widget.key) {
|
||||
case 'devices':
|
||||
setFilter('device', item.name);
|
||||
break;
|
||||
case 'browser':
|
||||
setWidget('browser_version');
|
||||
setFilter('browser', item.name);
|
||||
break;
|
||||
case 'browser_version':
|
||||
setFilter('browser_version', item.name);
|
||||
break;
|
||||
case 'os':
|
||||
setWidget('os_version');
|
||||
setFilter('os', item.name);
|
||||
break;
|
||||
case 'os_version':
|
||||
setFilter('os_version', item.name);
|
||||
break;
|
||||
}
|
||||
{...{
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
events: [
|
||||
{
|
||||
segment: 'user',
|
||||
filters,
|
||||
id: 'A',
|
||||
name: 'session_start',
|
||||
},
|
||||
],
|
||||
breakdowns: [
|
||||
{
|
||||
id: 'A',
|
||||
name: 'browser_version',
|
||||
},
|
||||
],
|
||||
chartType: 'bar',
|
||||
lineType: 'monotone',
|
||||
interval: interval,
|
||||
name: 'Top sources',
|
||||
range: range,
|
||||
previous: previous,
|
||||
metric: 'sum',
|
||||
}}
|
||||
/>
|
||||
</WidgetBody>
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -4,16 +4,18 @@ 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';
|
||||
import { Widget, WidgetBody } from '../../Widget';
|
||||
import { WidgetButtons, WidgetHead } from '../overview-widget';
|
||||
import { useOverviewOptions } from '../useOverviewOptions';
|
||||
import { useOverviewWidget } from '../useOverviewWidget';
|
||||
|
||||
interface OverviewTopEventsProps {
|
||||
export interface OverviewTopEventsProps {
|
||||
projectId: string;
|
||||
conversions: string[];
|
||||
}
|
||||
export default function OverviewTopEvents({
|
||||
projectId,
|
||||
conversions,
|
||||
}: OverviewTopEventsProps) {
|
||||
const { interval, range, previous, startDate, endDate } =
|
||||
useOverviewOptions();
|
||||
@@ -57,6 +59,44 @@ export default function OverviewTopEvents({
|
||||
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 (
|
||||
@@ -17,36 +17,6 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
||||
useOverviewOptions();
|
||||
const [filters, setFilter] = useEventQueryFilters();
|
||||
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: {
|
||||
title: 'Top countries',
|
||||
btn: 'Countries',
|
||||
@@ -179,6 +149,42 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
||||
/>
|
||||
</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: 'session_start',
|
||||
},
|
||||
],
|
||||
breakdowns: [
|
||||
{
|
||||
id: 'A',
|
||||
name: 'country',
|
||||
},
|
||||
],
|
||||
chartType: 'map',
|
||||
lineType: 'monotone',
|
||||
interval: interval,
|
||||
name: 'Top sources',
|
||||
range: range,
|
||||
previous: previous,
|
||||
metric: 'sum',
|
||||
}}
|
||||
/>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import type { IChartMetric } from '@mixan/validation';
|
||||
|
||||
import {
|
||||
getDiffIndicator,
|
||||
PreviousDiffIndicator,
|
||||
PreviousDiffIndicatorText,
|
||||
} from '../PreviousDiffIndicator';
|
||||
import { useChartContext } from './ChartProvider';
|
||||
|
||||
@@ -9,7 +9,7 @@ import { getChartColor } from '@/utils/theme';
|
||||
|
||||
import { NOT_SET_VALUE } from '@mixan/constants';
|
||||
|
||||
import { PreviousDiffIndicator } from '../PreviousDiffIndicator';
|
||||
import { PreviousDiffIndicatorText } from '../PreviousDiffIndicator';
|
||||
import { useChartContext } from './ChartProvider';
|
||||
import { SerieIcon } from './SerieIcon';
|
||||
|
||||
@@ -30,42 +30,41 @@ export function ReportBarChart({ data }: ReportBarChartProps) {
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col w-full text-xs -mx-2',
|
||||
editMode &&
|
||||
'text-base bg-white border border-border rounded-md p-4 pt-2'
|
||||
editMode && 'text-base bg-white border border-border rounded-md p-4'
|
||||
)}
|
||||
>
|
||||
{editMode && (
|
||||
<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) => {
|
||||
{series.map((serie) => {
|
||||
const isClickable = serie.name !== NOT_SET_VALUE && onClick;
|
||||
return (
|
||||
<div
|
||||
key={serie.name}
|
||||
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 ? { 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} />
|
||||
{serie.name}
|
||||
</div>
|
||||
<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">
|
||||
{number.format(serie.metrics.sum)}
|
||||
</div>
|
||||
<Progress
|
||||
color={getChartColor(index)}
|
||||
className={cn('w-1/2', editMode ? 'h-5' : 'h-2')}
|
||||
value={(serie.metrics.sum / maxCount) * 100}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -67,15 +67,12 @@ export function ReportChartTooltip({
|
||||
{getLabel(data.label)}
|
||||
</div>
|
||||
<div className="flex justify-between gap-8">
|
||||
<div>
|
||||
{number.format(data.count)}
|
||||
{unit}
|
||||
</div>
|
||||
<div>{number.formatWithUnit(data.count, unit)}</div>
|
||||
|
||||
<div className="flex gap-1">
|
||||
<PreviousDiffIndicator {...data.previous}>
|
||||
{!!data.previous &&
|
||||
`(${data.previous.value + (unit ? unit : '')})`}
|
||||
`(${number.formatWithUnit(data.previous.value, unit)})`}
|
||||
</PreviousDiffIndicator>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,12 @@ const createImageIcon = (url: string) => {
|
||||
} 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> = {
|
||||
// Events
|
||||
screen_view: MonitorPlayIcon,
|
||||
@@ -88,6 +94,121 @@ const mapper: Record<string, LucideIcon> = {
|
||||
email: MailIcon,
|
||||
unknown: HelpCircleIcon,
|
||||
[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) {
|
||||
|
||||
@@ -30,5 +30,26 @@ export function useNumber() {
|
||||
return {
|
||||
format,
|
||||
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}` : ''}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
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 { Checkbox } from '@/components/ui/checkbox';
|
||||
import { CheckboxInput } from '@/components/ui/checkbox';
|
||||
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 { useAppParams } from '@/hooks/useAppParams';
|
||||
import { clipboard } from '@/utils/clipboard';
|
||||
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 type { SubmitHandler } from 'react-hook-form';
|
||||
import { Controller, useForm, useWatch } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
@@ -19,194 +27,234 @@ import { z } from 'zod';
|
||||
import { popModal } from '.';
|
||||
import { ModalContent, ModalHeader } from './Modal/Container';
|
||||
|
||||
const validator = z.object({
|
||||
name: z.string().min(1, 'Required'),
|
||||
cors: z.string().min(1, 'Required'),
|
||||
withCors: z.boolean(),
|
||||
projectId: z.string().min(1, 'Required'),
|
||||
const validation = z.object({
|
||||
name: z.string().min(1),
|
||||
domain: z.string().optional(),
|
||||
withSecret: z.boolean().optional(),
|
||||
projectId: z.string(),
|
||||
});
|
||||
|
||||
type IForm = z.infer<typeof validator>;
|
||||
interface AddClientProps {
|
||||
organizationId: string;
|
||||
}
|
||||
type IForm = z.infer<typeof validation>;
|
||||
|
||||
export default function AddClient({ organizationId }: AddClientProps) {
|
||||
export default function AddClient() {
|
||||
const { organizationId, projectId } = useAppParams();
|
||||
const router = useRouter();
|
||||
const query = api.project.list.useQuery({
|
||||
organizationId,
|
||||
const form = useForm<IForm>({
|
||||
resolver: zodResolver(validation),
|
||||
defaultValues: {
|
||||
withSecret: false,
|
||||
name: '',
|
||||
domain: '',
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
|
||||
const mutation = api.client.create.useMutation({
|
||||
const mutation = api.client.create2.useMutation({
|
||||
onError: handleError,
|
||||
onSuccess() {
|
||||
toast('Success', {
|
||||
description: 'Client created!',
|
||||
});
|
||||
toast.success('Client created');
|
||||
router.refresh();
|
||||
},
|
||||
});
|
||||
|
||||
const { register, handleSubmit, formState, control } = useForm<IForm>({
|
||||
resolver: zodResolver(validator),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
cors: '*',
|
||||
projectId: '',
|
||||
withCors: true,
|
||||
},
|
||||
const query = api.project.list.useQuery({
|
||||
organizationId,
|
||||
});
|
||||
const onSubmit: SubmitHandler<IForm> = (values) => {
|
||||
mutation.mutate({
|
||||
name: values.name,
|
||||
domain: values.withSecret ? undefined : values.domain,
|
||||
projectId: values.projectId,
|
||||
organizationId,
|
||||
});
|
||||
};
|
||||
|
||||
const withCors = useWatch({
|
||||
control,
|
||||
name: 'withCors',
|
||||
const watch = useWatch({
|
||||
control: form.control,
|
||||
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 (
|
||||
<ModalContent>
|
||||
<ModalHeader title="Create client" />
|
||||
<form
|
||||
onSubmit={handleSubmit((values) => {
|
||||
mutation.mutate({
|
||||
...values,
|
||||
organizationId,
|
||||
});
|
||||
})}
|
||||
>
|
||||
<InputWithLabel
|
||||
label="Name"
|
||||
placeholder="Name"
|
||||
{...register('name')}
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="withCors"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<label
|
||||
htmlFor="cors"
|
||||
className="flex items-center gap-2 text-sm font-medium leading-none mb-4"
|
||||
{mutation.isSuccess ? (
|
||||
<>
|
||||
<ModalHeader
|
||||
title="Success"
|
||||
text={
|
||||
<>
|
||||
{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>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<div className="grid gap-4">
|
||||
<button
|
||||
className="mt-4 text-left"
|
||||
onClick={() => clipboard(mutation.data.clientId)}
|
||||
>
|
||||
<Checkbox
|
||||
id="cors"
|
||||
ref={field.ref}
|
||||
onBlur={field.onBlur}
|
||||
defaultChecked={field.value}
|
||||
onCheckedChange={(checked) => {
|
||||
field.onChange(checked);
|
||||
<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={() => 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>
|
||||
</>
|
||||
)}
|
||||
<Controller
|
||||
control={control}
|
||||
name="projectId"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<div>
|
||||
<Label>Project</Label>
|
||||
<Combobox
|
||||
{...field}
|
||||
onChange={(value) => {
|
||||
field.onChange(value);
|
||||
}}
|
||||
items={
|
||||
query.data?.map((item) => ({
|
||||
value: item.id,
|
||||
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>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant={'secondary'}
|
||||
onClick={() => popModal()}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
icon={SaveIcon}
|
||||
loading={mutation.isLoading}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</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> */
|
||||
}
|
||||
|
||||
@@ -19,13 +19,17 @@ export function ModalContent({ children }: ModalContentProps) {
|
||||
|
||||
interface ModalHeaderProps {
|
||||
title: string | React.ReactNode;
|
||||
text?: string | React.ReactNode;
|
||||
onClose?: (() => void) | false;
|
||||
}
|
||||
|
||||
export function ModalHeader({ title, onClose }: ModalHeaderProps) {
|
||||
export function ModalHeader({ title, text, onClose }: ModalHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="font-medium">{title}</div>
|
||||
<div className="flex justify-between mb-6">
|
||||
<div>
|
||||
<div className="font-medium mt-0.5">{title}</div>
|
||||
{!!text && <div className="text-sm text-muted-foreground">{text}</div>}
|
||||
</div>
|
||||
{onClose !== false && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -146,7 +146,7 @@ function fillEmptySpotsInTimeline(
|
||||
}
|
||||
|
||||
export function withFormula(
|
||||
{ formula }: IChartInput,
|
||||
{ formula, events }: IChartInput,
|
||||
series: GetChartDataResult
|
||||
) {
|
||||
if (!formula) {
|
||||
@@ -164,6 +164,33 @@ export function withFormula(
|
||||
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 [
|
||||
{
|
||||
...series[0],
|
||||
|
||||
Reference in New Issue
Block a user