diff --git a/apps/web/package.json b/apps/web/package.json index 8142b4ec..b58777d9 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -52,6 +52,7 @@ "cmdk": "^0.2.1", "date-fns": "^3.3.1", "embla-carousel-react": "8.0.0-rc22", + "flag-icons": "^7.1.0", "hamburger-react": "^2.5.0", "lodash.debounce": "^4.0.8", "lodash.throttle": "^4.1.1", diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/create-client.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/create-client.tsx index 84b1b53d..71757a87 100644 --- a/apps/web/src/app/(app)/[organizationId]/[projectId]/create-client.tsx +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/create-client.tsx @@ -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, diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/events/event-list-item.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/events/event-list-item.tsx index cea0a06b..6cf3d545 100644 --- a/apps/web/src/app/(app)/[organizationId]/[projectId]/events/event-list-item.tsx +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/events/event-list-item.tsx @@ -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={ <> + {profile?.id === props.deviceId && ( + + )} {profile && ( -
- -
+ ); diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/profiles/[profileId]/list-profile-events.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/profiles/[profileId]/list-profile-events.tsx deleted file mode 100644 index 6af713dc..00000000 --- a/apps/web/src/app/(app)/[organizationId]/[projectId]/profiles/[profileId]/list-profile-events.tsx +++ /dev/null @@ -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().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 ( - <> -
- -
-
- {events.map((item) => ( - - ))} -
-
- -
- - ); -} diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/profiles/[profileId]/page.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/profiles/[profileId]/page.tsx index 60f9f2c3..4b788c46 100644 --- a/apps/web/src/app/(app)/[organizationId]/[projectId]/profiles/[profileId]/page.tsx +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/profiles/[profileId]/page.tsx @@ -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'; diff --git a/apps/web/src/app/(public)/share/overview/[id]/page.tsx b/apps/web/src/app/(public)/share/overview/[id]/page.tsx index 0e6ae352..a26dc6e8 100644 --- a/apps/web/src/app/(public)/share/overview/[id]/page.tsx +++ b/apps/web/src/app/(public)/share/overview/[id]/page.tsx @@ -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'; diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index b157a96e..188ec3b4 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -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', diff --git a/apps/web/src/components/overview/overview-top-devices.tsx b/apps/web/src/components/overview/overview-top-devices.tsx index 95d28009..4d346f25 100644 --- a/apps/web/src/components/overview/overview-top-devices.tsx +++ b/apps/web/src/components/overview/overview-top-devices.tsx @@ -191,28 +191,31 @@ export default function OverviewTopDevices({ { - 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', }} /> diff --git a/apps/web/src/components/overview/overview-top-events/index.tsx b/apps/web/src/components/overview/overview-top-events/index.tsx new file mode 100644 index 00000000..f54fa5eb --- /dev/null +++ b/apps/web/src/components/overview/overview-top-events/index.tsx @@ -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) { + const eventNames = await getConversionEventNames(projectId); + return ( + item.name)} + /> + ); +} diff --git a/apps/web/src/components/overview/overview-top-events.tsx b/apps/web/src/components/overview/overview-top-events/overview-top-events.tsx similarity index 63% rename from apps/web/src/components/overview/overview-top-events.tsx rename to apps/web/src/components/overview/overview-top-events/overview-top-events.tsx index 1a11d358..0519ba21 100644 --- a/apps/web/src/components/overview/overview-top-events.tsx +++ b/apps/web/src/components/overview/overview-top-events/overview-top-events.tsx @@ -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 ( diff --git a/apps/web/src/components/overview/overview-top-geo.tsx b/apps/web/src/components/overview/overview-top-geo.tsx index 1fa97b93..7e0e3b09 100644 --- a/apps/web/src/components/overview/overview-top-geo.tsx +++ b/apps/web/src/components/overview/overview-top-geo.tsx @@ -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) { /> + + +
Map
+
+ + + +
); } diff --git a/apps/web/src/components/report/chart/MetricCard.tsx b/apps/web/src/components/report/chart/MetricCard.tsx index 122b9c54..cbb12418 100644 --- a/apps/web/src/components/report/chart/MetricCard.tsx +++ b/apps/web/src/components/report/chart/MetricCard.tsx @@ -11,7 +11,6 @@ import type { IChartMetric } from '@mixan/validation'; import { getDiffIndicator, - PreviousDiffIndicator, PreviousDiffIndicatorText, } from '../PreviousDiffIndicator'; import { useChartContext } from './ChartProvider'; diff --git a/apps/web/src/components/report/chart/ReportBarChart.tsx b/apps/web/src/components/report/chart/ReportBarChart.tsx index 24e1040b..1a16025c 100644 --- a/apps/web/src/components/report/chart/ReportBarChart.tsx +++ b/apps/web/src/components/report/chart/ReportBarChart.tsx @@ -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) {
- {editMode && ( -
-
Event
-
Count
-
- )} - {series.map((serie, index) => { + {series.map((serie) => { const isClickable = serie.name !== NOT_SET_VALUE && onClick; return (
onClick(serie) } : {})} > -
+
{serie.name}
- + + {serie.metrics.previous[metric]?.value}
{number.format(serie.metrics.sum)}
-
+ +
); })} diff --git a/apps/web/src/components/report/chart/ReportChartTooltip.tsx b/apps/web/src/components/report/chart/ReportChartTooltip.tsx index 28c889f5..a05dac98 100644 --- a/apps/web/src/components/report/chart/ReportChartTooltip.tsx +++ b/apps/web/src/components/report/chart/ReportChartTooltip.tsx @@ -67,15 +67,12 @@ export function ReportChartTooltip({ {getLabel(data.label)}
-
- {number.format(data.count)} - {unit} -
+
{number.formatWithUnit(data.count, unit)}
{!!data.previous && - `(${data.previous.value + (unit ? unit : '')})`} + `(${number.formatWithUnit(data.previous.value, unit)})`}
diff --git a/apps/web/src/components/report/chart/SerieIcon.tsx b/apps/web/src/components/report/chart/SerieIcon.tsx index dacd14a0..f751b915 100644 --- a/apps/web/src/components/report/chart/SerieIcon.tsx +++ b/apps/web/src/components/report/chart/SerieIcon.tsx @@ -30,6 +30,12 @@ const createImageIcon = (url: string) => { } as LucideIcon; }; +const createFlagIcon = (url: string) => { + return function (props: LucideProps) { + return ; + } as LucideIcon; +}; + const mapper: Record = { // Events screen_view: MonitorPlayIcon, @@ -88,6 +94,121 @@ const mapper: Record = { 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) { diff --git a/apps/web/src/hooks/useNumerFormatter.ts b/apps/web/src/hooks/useNumerFormatter.ts index 2533d427..bd9c392b 100644 --- a/apps/web/src/hooks/useNumerFormatter.ts +++ b/apps/web/src/hooks/useNumerFormatter.ts @@ -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}` : ''}`; + }, }; } diff --git a/apps/web/src/modals/AddClient.tsx b/apps/web/src/modals/AddClient.tsx index 15d03ba9..d700145e 100644 --- a/apps/web/src/modals/AddClient.tsx +++ b/apps/web/src/modals/AddClient.tsx @@ -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; -interface AddClientProps { - organizationId: string; -} +type IForm = z.infer; -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({ + 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({ - resolver: zodResolver(validator), - defaultValues: { - name: '', - cors: '*', - projectId: '', - withCors: true, - }, + const query = api.project.list.useQuery({ + organizationId, }); + const onSubmit: SubmitHandler = (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 ( - - -

- Your client has been created! You will only see the client secret once - so keep it safe 🫣 -

- - - - {clientSecret && ( - - )} -
- -
- -
-
- - -
- - - - ); - } - return ( - -
{ - mutation.mutate({ - ...values, - organizationId, - }); - })} - > - - - ( -