aboutsummaryrefslogtreecommitdiff
path: root/src/app/(main)/pixels
diff options
context:
space:
mode:
Diffstat (limited to 'src/app/(main)/pixels')
-rw-r--r--src/app/(main)/pixels/PixelAddButton.tsx19
-rw-r--r--src/app/(main)/pixels/PixelDeleteButton.tsx57
-rw-r--r--src/app/(main)/pixels/PixelEditButton.tsx21
-rw-r--r--src/app/(main)/pixels/PixelEditForm.tsx129
-rw-r--r--src/app/(main)/pixels/PixelProvider.tsx21
-rw-r--r--src/app/(main)/pixels/PixelsDataTable.tsx14
-rw-r--r--src/app/(main)/pixels/PixelsPage.tsx26
-rw-r--r--src/app/(main)/pixels/PixelsTable.tsx48
-rw-r--r--src/app/(main)/pixels/[pixelId]/PixelControls.tsx32
-rw-r--r--src/app/(main)/pixels/[pixelId]/PixelHeader.tsx19
-rw-r--r--src/app/(main)/pixels/[pixelId]/PixelMetricsBar.tsx70
-rw-r--r--src/app/(main)/pixels/[pixelId]/PixelPage.tsx34
-rw-r--r--src/app/(main)/pixels/[pixelId]/PixelPanels.tsx83
-rw-r--r--src/app/(main)/pixels/[pixelId]/page.tsx12
-rw-r--r--src/app/(main)/pixels/page.tsx10
15 files changed, 595 insertions, 0 deletions
diff --git a/src/app/(main)/pixels/PixelAddButton.tsx b/src/app/(main)/pixels/PixelAddButton.tsx
new file mode 100644
index 0000000..1573b9e
--- /dev/null
+++ b/src/app/(main)/pixels/PixelAddButton.tsx
@@ -0,0 +1,19 @@
+import { useMessages } from '@/components/hooks';
+import { Plus } from '@/components/icons';
+import { DialogButton } from '@/components/input/DialogButton';
+import { PixelEditForm } from './PixelEditForm';
+
+export function PixelAddButton({ teamId }: { teamId?: string }) {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ <DialogButton
+ icon={<Plus />}
+ label={formatMessage(labels.addPixel)}
+ variant="primary"
+ width="600px"
+ >
+ {({ close }) => <PixelEditForm teamId={teamId} onClose={close} />}
+ </DialogButton>
+ );
+}
diff --git a/src/app/(main)/pixels/PixelDeleteButton.tsx b/src/app/(main)/pixels/PixelDeleteButton.tsx
new file mode 100644
index 0000000..436dba5
--- /dev/null
+++ b/src/app/(main)/pixels/PixelDeleteButton.tsx
@@ -0,0 +1,57 @@
+import { ConfirmationForm } from '@/components/common/ConfirmationForm';
+import { useDeleteQuery, useMessages, useModified } from '@/components/hooks';
+import { Trash } from '@/components/icons';
+import { DialogButton } from '@/components/input/DialogButton';
+import { messages } from '@/components/messages';
+
+export function PixelDeleteButton({
+ pixelId,
+ name,
+ onSave,
+}: {
+ pixelId: string;
+ name: string;
+ onSave?: () => void;
+}) {
+ const { formatMessage, labels, getErrorMessage, FormattedMessage } = useMessages();
+ const { mutateAsync, isPending, error } = useDeleteQuery(`/pixels/${pixelId}`);
+ const { touch } = useModified();
+
+ const handleConfirm = async (close: () => void) => {
+ await mutateAsync(null, {
+ onSuccess: () => {
+ touch('pixels');
+ onSave?.();
+ close();
+ },
+ });
+ };
+
+ return (
+ <DialogButton
+ icon={<Trash />}
+ variant="quiet"
+ title={formatMessage(labels.confirm)}
+ width="400px"
+ >
+ {({ close }) => (
+ <ConfirmationForm
+ message={
+ <FormattedMessage
+ {...messages.confirmRemove}
+ values={{
+ target: <b>{name}</b>,
+ }}
+ />
+ }
+ isLoading={isPending}
+ error={getErrorMessage(error)}
+ onConfirm={handleConfirm.bind(null, close)}
+ onClose={close}
+ buttonLabel={formatMessage(labels.delete)}
+ buttonVariant="danger"
+ />
+ )}
+ </DialogButton>
+ );
+}
diff --git a/src/app/(main)/pixels/PixelEditButton.tsx b/src/app/(main)/pixels/PixelEditButton.tsx
new file mode 100644
index 0000000..3c5924d
--- /dev/null
+++ b/src/app/(main)/pixels/PixelEditButton.tsx
@@ -0,0 +1,21 @@
+import { useMessages } from '@/components/hooks';
+import { Edit } from '@/components/icons';
+import { DialogButton } from '@/components/input/DialogButton';
+import { PixelEditForm } from './PixelEditForm';
+
+export function PixelEditButton({ pixelId }: { pixelId: string }) {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ <DialogButton
+ icon={<Edit />}
+ title={formatMessage(labels.addPixel)}
+ variant="quiet"
+ width="600px"
+ >
+ {({ close }) => {
+ return <PixelEditForm pixelId={pixelId} onClose={close} />;
+ }}
+ </DialogButton>
+ );
+}
diff --git a/src/app/(main)/pixels/PixelEditForm.tsx b/src/app/(main)/pixels/PixelEditForm.tsx
new file mode 100644
index 0000000..aedd3a3
--- /dev/null
+++ b/src/app/(main)/pixels/PixelEditForm.tsx
@@ -0,0 +1,129 @@
+import {
+ Button,
+ Column,
+ Form,
+ FormField,
+ FormSubmitButton,
+ Icon,
+ Label,
+ Loading,
+ Row,
+ TextField,
+} from '@umami/react-zen';
+import { useEffect, useState } from 'react';
+import { useConfig, useMessages, usePixelQuery } from '@/components/hooks';
+import { useUpdateQuery } from '@/components/hooks/queries/useUpdateQuery';
+import { RefreshCw } from '@/components/icons';
+import { PIXELS_URL } from '@/lib/constants';
+import { getRandomChars } from '@/lib/generate';
+
+const generateId = () => getRandomChars(9);
+
+export function PixelEditForm({
+ pixelId,
+ teamId,
+ onSave,
+ onClose,
+}: {
+ pixelId?: string;
+ teamId?: string;
+ onSave?: () => void;
+ onClose?: () => void;
+}) {
+ const { formatMessage, labels, messages, getErrorMessage } = useMessages();
+ const { mutateAsync, error, isPending, touch, toast } = useUpdateQuery(
+ pixelId ? `/pixels/${pixelId}` : '/pixels',
+ {
+ id: pixelId,
+ teamId,
+ },
+ );
+ const { pixelsUrl } = useConfig();
+ const hostUrl = pixelsUrl || PIXELS_URL;
+ const { data, isLoading } = usePixelQuery(pixelId);
+ const [slug, setSlug] = useState(generateId());
+
+ const handleSubmit = async (data: any) => {
+ await mutateAsync(data, {
+ onSuccess: async () => {
+ toast(formatMessage(messages.saved));
+ touch('pixels');
+ onSave?.();
+ onClose?.();
+ },
+ });
+ };
+
+ const handleSlug = () => {
+ const slug = generateId();
+
+ setSlug(slug);
+
+ return slug;
+ };
+
+ useEffect(() => {
+ if (data) {
+ setSlug(data.slug);
+ }
+ }, [data]);
+
+ if (pixelId && isLoading) {
+ return <Loading placement="absolute" />;
+ }
+
+ return (
+ <Form onSubmit={handleSubmit} error={getErrorMessage(error)} defaultValues={{ slug, ...data }}>
+ {({ setValue }) => {
+ return (
+ <>
+ <FormField
+ label={formatMessage(labels.name)}
+ name="name"
+ rules={{ required: formatMessage(labels.required) }}
+ >
+ <TextField autoComplete="off" />
+ </FormField>
+
+ <FormField
+ name="slug"
+ rules={{
+ required: formatMessage(labels.required),
+ }}
+ style={{ display: 'none' }}
+ >
+ <input type="hidden" />
+ </FormField>
+
+ <Column>
+ <Label>{formatMessage(labels.link)}</Label>
+ <Row alignItems="center" gap>
+ <TextField
+ value={`${hostUrl}/${slug}`}
+ autoComplete="off"
+ isReadOnly
+ allowCopy
+ style={{ width: '100%' }}
+ />
+ <Button onPress={() => setValue('slug', handleSlug(), { shouldDirty: true })}>
+ <Icon>
+ <RefreshCw />
+ </Icon>
+ </Button>
+ </Row>
+ </Column>
+
+ <Row justifyContent="flex-end" paddingTop="3" gap="3">
+ {onClose && (
+ <Button isDisabled={isPending} onPress={onClose}>
+ {formatMessage(labels.cancel)}
+ </Button>
+ )}
+ <FormSubmitButton isDisabled={false}>{formatMessage(labels.save)}</FormSubmitButton>
+ </Row>
+ </>
+ );
+ }}
+ </Form>
+ );
+}
diff --git a/src/app/(main)/pixels/PixelProvider.tsx b/src/app/(main)/pixels/PixelProvider.tsx
new file mode 100644
index 0000000..9e929d8
--- /dev/null
+++ b/src/app/(main)/pixels/PixelProvider.tsx
@@ -0,0 +1,21 @@
+'use client';
+import { Loading } from '@umami/react-zen';
+import { createContext, type ReactNode } from 'react';
+import { usePixelQuery } from '@/components/hooks/queries/usePixelQuery';
+import type { Pixel } from '@/generated/prisma/client';
+
+export const PixelContext = createContext<Pixel>(null);
+
+export function PixelProvider({ pixelId, children }: { pixelId?: string; children: ReactNode }) {
+ const { data: pixel, isLoading, isFetching } = usePixelQuery(pixelId);
+
+ if (isFetching && isLoading) {
+ return <Loading placement="absolute" />;
+ }
+
+ if (!pixel) {
+ return null;
+ }
+
+ return <PixelContext.Provider value={pixel}>{children}</PixelContext.Provider>;
+}
diff --git a/src/app/(main)/pixels/PixelsDataTable.tsx b/src/app/(main)/pixels/PixelsDataTable.tsx
new file mode 100644
index 0000000..51b8c5a
--- /dev/null
+++ b/src/app/(main)/pixels/PixelsDataTable.tsx
@@ -0,0 +1,14 @@
+import { DataGrid } from '@/components/common/DataGrid';
+import { useNavigation, usePixelsQuery } from '@/components/hooks';
+import { PixelsTable } from './PixelsTable';
+
+export function PixelsDataTable() {
+ const { teamId } = useNavigation();
+ const query = usePixelsQuery({ teamId });
+
+ return (
+ <DataGrid query={query} allowSearch={true} autoFocus={false} allowPaging={true}>
+ {({ data }) => <PixelsTable data={data} />}
+ </DataGrid>
+ );
+}
diff --git a/src/app/(main)/pixels/PixelsPage.tsx b/src/app/(main)/pixels/PixelsPage.tsx
new file mode 100644
index 0000000..4f6acef
--- /dev/null
+++ b/src/app/(main)/pixels/PixelsPage.tsx
@@ -0,0 +1,26 @@
+'use client';
+import { Column } from '@umami/react-zen';
+import { PageBody } from '@/components/common/PageBody';
+import { PageHeader } from '@/components/common/PageHeader';
+import { Panel } from '@/components/common/Panel';
+import { useMessages, useNavigation } from '@/components/hooks';
+import { PixelAddButton } from './PixelAddButton';
+import { PixelsDataTable } from './PixelsDataTable';
+
+export function PixelsPage() {
+ const { formatMessage, labels } = useMessages();
+ const { teamId } = useNavigation();
+
+ return (
+ <PageBody>
+ <Column gap="6" margin="2">
+ <PageHeader title={formatMessage(labels.pixels)}>
+ <PixelAddButton teamId={teamId} />
+ </PageHeader>
+ <Panel>
+ <PixelsDataTable />
+ </Panel>
+ </Column>
+ </PageBody>
+ );
+}
diff --git a/src/app/(main)/pixels/PixelsTable.tsx b/src/app/(main)/pixels/PixelsTable.tsx
new file mode 100644
index 0000000..48a8458
--- /dev/null
+++ b/src/app/(main)/pixels/PixelsTable.tsx
@@ -0,0 +1,48 @@
+import { DataColumn, DataTable, type DataTableProps, Row } from '@umami/react-zen';
+import Link from 'next/link';
+import { DateDistance } from '@/components/common/DateDistance';
+import { ExternalLink } from '@/components/common/ExternalLink';
+import { useMessages, useNavigation, useSlug } from '@/components/hooks';
+import { PixelDeleteButton } from './PixelDeleteButton';
+import { PixelEditButton } from './PixelEditButton';
+
+export function PixelsTable(props: DataTableProps) {
+ const { formatMessage, labels } = useMessages();
+ const { renderUrl } = useNavigation();
+ const { getSlugUrl } = useSlug('pixel');
+
+ return (
+ <DataTable {...props}>
+ <DataColumn id="name" label={formatMessage(labels.name)}>
+ {({ id, name }: any) => {
+ return <Link href={renderUrl(`/pixels/${id}`)}>{name}</Link>;
+ }}
+ </DataColumn>
+ <DataColumn id="url" label="URL">
+ {({ slug }: any) => {
+ const url = getSlugUrl(slug);
+ return (
+ <ExternalLink href={url} prefetch={false}>
+ {url}
+ </ExternalLink>
+ );
+ }}
+ </DataColumn>
+ <DataColumn id="created" label={formatMessage(labels.created)}>
+ {(row: any) => <DateDistance date={new Date(row.createdAt)} />}
+ </DataColumn>
+ <DataColumn id="action" align="end" width="100px">
+ {(row: any) => {
+ const { id, name } = row;
+
+ return (
+ <Row>
+ <PixelEditButton pixelId={id} />
+ <PixelDeleteButton pixelId={id} name={name} />
+ </Row>
+ );
+ }}
+ </DataColumn>
+ </DataTable>
+ );
+}
diff --git a/src/app/(main)/pixels/[pixelId]/PixelControls.tsx b/src/app/(main)/pixels/[pixelId]/PixelControls.tsx
new file mode 100644
index 0000000..55dcd57
--- /dev/null
+++ b/src/app/(main)/pixels/[pixelId]/PixelControls.tsx
@@ -0,0 +1,32 @@
+import { Column, Row } from '@umami/react-zen';
+import { ExportButton } from '@/components/input/ExportButton';
+import { FilterBar } from '@/components/input/FilterBar';
+import { MonthFilter } from '@/components/input/MonthFilter';
+import { WebsiteDateFilter } from '@/components/input/WebsiteDateFilter';
+import { WebsiteFilterButton } from '@/components/input/WebsiteFilterButton';
+
+export function PixelControls({
+ pixelId: websiteId,
+ allowFilter = true,
+ allowDateFilter = true,
+ allowMonthFilter,
+ allowDownload = false,
+}: {
+ pixelId: string;
+ allowFilter?: boolean;
+ allowDateFilter?: boolean;
+ allowMonthFilter?: boolean;
+ allowDownload?: boolean;
+}) {
+ return (
+ <Column gap>
+ <Row alignItems="center" justifyContent="space-between" gap="3">
+ {allowFilter ? <WebsiteFilterButton websiteId={websiteId} /> : <div />}
+ {allowDateFilter && <WebsiteDateFilter websiteId={websiteId} showAllTime={false} />}
+ {allowDownload && <ExportButton websiteId={websiteId} />}
+ {allowMonthFilter && <MonthFilter />}
+ </Row>
+ {allowFilter && <FilterBar websiteId={websiteId} />}
+ </Column>
+ );
+}
diff --git a/src/app/(main)/pixels/[pixelId]/PixelHeader.tsx b/src/app/(main)/pixels/[pixelId]/PixelHeader.tsx
new file mode 100644
index 0000000..c771687
--- /dev/null
+++ b/src/app/(main)/pixels/[pixelId]/PixelHeader.tsx
@@ -0,0 +1,19 @@
+import { IconLabel } from '@umami/react-zen';
+import { LinkButton } from '@/components/common/LinkButton';
+import { PageHeader } from '@/components/common/PageHeader';
+import { useMessages, usePixel, useSlug } from '@/components/hooks';
+import { ExternalLink, Grid2x2 } from '@/components/icons';
+
+export function PixelHeader() {
+ const { formatMessage, labels } = useMessages();
+ const { getSlugUrl } = useSlug('pixel');
+ const pixel = usePixel();
+
+ return (
+ <PageHeader title={pixel.name} icon={<Grid2x2 />}>
+ <LinkButton href={getSlugUrl(pixel.slug)} target="_blank" prefetch={false} asAnchor>
+ <IconLabel icon={<ExternalLink />} label={formatMessage(labels.view)} />
+ </LinkButton>
+ </PageHeader>
+ );
+}
diff --git a/src/app/(main)/pixels/[pixelId]/PixelMetricsBar.tsx b/src/app/(main)/pixels/[pixelId]/PixelMetricsBar.tsx
new file mode 100644
index 0000000..c9dcd35
--- /dev/null
+++ b/src/app/(main)/pixels/[pixelId]/PixelMetricsBar.tsx
@@ -0,0 +1,70 @@
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useDateRange, useMessages } from '@/components/hooks';
+import { useWebsiteStatsQuery } from '@/components/hooks/queries/useWebsiteStatsQuery';
+import { MetricCard } from '@/components/metrics/MetricCard';
+import { MetricsBar } from '@/components/metrics/MetricsBar';
+import { formatLongNumber } from '@/lib/format';
+
+export function PixelMetricsBar({
+ pixelId,
+}: {
+ pixelId: string;
+ showChange?: boolean;
+ compareMode?: boolean;
+}) {
+ const { isAllTime } = useDateRange();
+ const { formatMessage, labels } = useMessages();
+ const { data, isLoading, isFetching, error } = useWebsiteStatsQuery(pixelId);
+
+ const { pageviews, visitors, visits, comparison } = data || {};
+
+ const metrics = data
+ ? [
+ {
+ value: visitors,
+ label: formatMessage(labels.visitors),
+ change: visitors - comparison.visitors,
+ formatValue: formatLongNumber,
+ },
+ {
+ value: visits,
+ label: formatMessage(labels.visits),
+ change: visits - comparison.visits,
+ formatValue: formatLongNumber,
+ },
+ {
+ value: pageviews,
+ label: formatMessage(labels.views),
+ change: pageviews - comparison.pageviews,
+ formatValue: formatLongNumber,
+ },
+ ]
+ : null;
+
+ return (
+ <LoadingPanel
+ data={metrics}
+ isLoading={isLoading}
+ isFetching={isFetching}
+ error={error}
+ minHeight="136px"
+ >
+ <MetricsBar>
+ {metrics?.map(({ label, value, prev, change, formatValue, reverseColors }: any) => {
+ return (
+ <MetricCard
+ key={label}
+ value={value}
+ previousValue={prev}
+ label={label}
+ change={change}
+ formatValue={formatValue}
+ reverseColors={reverseColors}
+ showChange={!isAllTime}
+ />
+ );
+ })}
+ </MetricsBar>
+ </LoadingPanel>
+ );
+}
diff --git a/src/app/(main)/pixels/[pixelId]/PixelPage.tsx b/src/app/(main)/pixels/[pixelId]/PixelPage.tsx
new file mode 100644
index 0000000..7a4ae9d
--- /dev/null
+++ b/src/app/(main)/pixels/[pixelId]/PixelPage.tsx
@@ -0,0 +1,34 @@
+'use client';
+import { Column, Grid } from '@umami/react-zen';
+import { PixelControls } from '@/app/(main)/pixels/[pixelId]/PixelControls';
+import { PixelHeader } from '@/app/(main)/pixels/[pixelId]/PixelHeader';
+import { PixelMetricsBar } from '@/app/(main)/pixels/[pixelId]/PixelMetricsBar';
+import { PixelPanels } from '@/app/(main)/pixels/[pixelId]/PixelPanels';
+import { PixelProvider } from '@/app/(main)/pixels/PixelProvider';
+import { ExpandedViewModal } from '@/app/(main)/websites/[websiteId]/ExpandedViewModal';
+import { WebsiteChart } from '@/app/(main)/websites/[websiteId]/WebsiteChart';
+import { PageBody } from '@/components/common/PageBody';
+import { Panel } from '@/components/common/Panel';
+
+const excludedIds = ['path', 'entry', 'exit', 'title', 'language', 'screen', 'event'];
+
+export function PixelPage({ pixelId }: { pixelId: string }) {
+ return (
+ <PixelProvider pixelId={pixelId}>
+ <Grid width="100%" height="100%">
+ <Column margin="2">
+ <PageBody gap>
+ <PixelHeader />
+ <PixelControls pixelId={pixelId} />
+ <PixelMetricsBar pixelId={pixelId} showChange={true} />
+ <Panel>
+ <WebsiteChart websiteId={pixelId} />
+ </Panel>
+ <PixelPanels pixelId={pixelId} />
+ </PageBody>
+ <ExpandedViewModal websiteId={pixelId} excludedIds={excludedIds} />
+ </Column>
+ </Grid>
+ </PixelProvider>
+ );
+}
diff --git a/src/app/(main)/pixels/[pixelId]/PixelPanels.tsx b/src/app/(main)/pixels/[pixelId]/PixelPanels.tsx
new file mode 100644
index 0000000..9cc24c9
--- /dev/null
+++ b/src/app/(main)/pixels/[pixelId]/PixelPanels.tsx
@@ -0,0 +1,83 @@
+import { Grid, Heading, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen';
+import { GridRow } from '@/components/common/GridRow';
+import { Panel } from '@/components/common/Panel';
+import { useMessages } from '@/components/hooks';
+import { MetricsTable } from '@/components/metrics/MetricsTable';
+import { WorldMap } from '@/components/metrics/WorldMap';
+
+export function PixelPanels({ pixelId }: { pixelId: string }) {
+ const { formatMessage, labels } = useMessages();
+ const tableProps = {
+ websiteId: pixelId,
+ limit: 10,
+ allowDownload: false,
+ showMore: true,
+ metric: formatMessage(labels.visitors),
+ };
+ const rowProps = { minHeight: 570 };
+
+ return (
+ <Grid gap="3">
+ <GridRow layout="two" {...rowProps}>
+ <Panel>
+ <Heading size="2">{formatMessage(labels.sources)}</Heading>
+ <Tabs>
+ <TabList>
+ <Tab id="referrer">{formatMessage(labels.referrers)}</Tab>
+ <Tab id="channel">{formatMessage(labels.channels)}</Tab>
+ </TabList>
+ <TabPanel id="referrer">
+ <MetricsTable type="referrer" title={formatMessage(labels.domain)} {...tableProps} />
+ </TabPanel>
+ <TabPanel id="channel">
+ <MetricsTable type="channel" title={formatMessage(labels.type)} {...tableProps} />
+ </TabPanel>
+ </Tabs>
+ </Panel>
+ <Panel>
+ <Heading size="2">{formatMessage(labels.environment)}</Heading>
+ <Tabs>
+ <TabList>
+ <Tab id="browser">{formatMessage(labels.browsers)}</Tab>
+ <Tab id="os">{formatMessage(labels.os)}</Tab>
+ <Tab id="device">{formatMessage(labels.devices)}</Tab>
+ </TabList>
+ <TabPanel id="browser">
+ <MetricsTable type="browser" title={formatMessage(labels.browser)} {...tableProps} />
+ </TabPanel>
+ <TabPanel id="os">
+ <MetricsTable type="os" title={formatMessage(labels.os)} {...tableProps} />
+ </TabPanel>
+ <TabPanel id="device">
+ <MetricsTable type="device" title={formatMessage(labels.device)} {...tableProps} />
+ </TabPanel>
+ </Tabs>
+ </Panel>
+ </GridRow>
+ <GridRow layout="two" {...rowProps}>
+ <Panel padding="0">
+ <WorldMap websiteId={pixelId} />
+ </Panel>
+ <Panel>
+ <Heading size="2">{formatMessage(labels.location)}</Heading>
+ <Tabs>
+ <TabList>
+ <Tab id="country">{formatMessage(labels.countries)}</Tab>
+ <Tab id="region">{formatMessage(labels.regions)}</Tab>
+ <Tab id="city">{formatMessage(labels.cities)}</Tab>
+ </TabList>
+ <TabPanel id="country">
+ <MetricsTable type="country" title={formatMessage(labels.country)} {...tableProps} />
+ </TabPanel>
+ <TabPanel id="region">
+ <MetricsTable type="region" title={formatMessage(labels.region)} {...tableProps} />
+ </TabPanel>
+ <TabPanel id="city">
+ <MetricsTable type="city" title={formatMessage(labels.city)} {...tableProps} />
+ </TabPanel>
+ </Tabs>
+ </Panel>
+ </GridRow>
+ </Grid>
+ );
+}
diff --git a/src/app/(main)/pixels/[pixelId]/page.tsx b/src/app/(main)/pixels/[pixelId]/page.tsx
new file mode 100644
index 0000000..d1db92f
--- /dev/null
+++ b/src/app/(main)/pixels/[pixelId]/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from 'next';
+import { PixelPage } from './PixelPage';
+
+export default async function ({ params }: { params: { pixelId: string } }) {
+ const { pixelId } = await params;
+
+ return <PixelPage pixelId={pixelId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'Pixel',
+};
diff --git a/src/app/(main)/pixels/page.tsx b/src/app/(main)/pixels/page.tsx
new file mode 100644
index 0000000..cc240cd
--- /dev/null
+++ b/src/app/(main)/pixels/page.tsx
@@ -0,0 +1,10 @@
+import type { Metadata } from 'next';
+import { PixelsPage } from './PixelsPage';
+
+export default function () {
+ return <PixelsPage />;
+}
+
+export const metadata: Metadata = {
+ title: 'Pixels',
+};