diff --git a/src/app/(main)/websites/[websiteId]/WebsiteTableView.tsx b/src/app/(main)/websites/[websiteId]/WebsiteTableView.tsx
index 02422075..9b505140 100644
--- a/src/app/(main)/websites/[websiteId]/WebsiteTableView.tsx
+++ b/src/app/(main)/websites/[websiteId]/WebsiteTableView.tsx
@@ -14,6 +14,7 @@ export default function WebsiteTableView({ websiteId }: { websiteId: string }) {
const pathname = usePathname();
const tableProps = {
websiteId,
+ allowDownload: false,
limit: 10,
};
const isSharePage = pathname.includes('/share/');
diff --git a/src/app/api/websites/[websiteId]/export/route.ts b/src/app/api/websites/[websiteId]/export/route.ts
new file mode 100644
index 00000000..534fd229
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/export/route.ts
@@ -0,0 +1,73 @@
+import { z } from 'zod';
+import JSZip from 'jszip';
+import Papa from 'papaparse';
+import { getRequestFilters, parseRequest } from '@/lib/request';
+import { unauthorized, json } from '@/lib/response';
+import { canViewWebsite } from '@/lib/auth';
+import { pagingParams } from '@/lib/schema';
+import { getEventMetrics, getPageviewMetrics, getSessionMetrics } from '@/queries';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const schema = z.object({
+ startAt: z.coerce.number().int(),
+ endAt: z.coerce.number().int(),
+ ...pagingParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+ const { startAt, endAt } = query;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const startDate = new Date(+startAt);
+ const endDate = new Date(+endAt);
+
+ const filters = {
+ ...(await getRequestFilters(query)),
+ startDate,
+ endDate,
+ };
+
+ const [events, pages, referrers, browsers, os, devices, countries] = await Promise.all([
+ getEventMetrics(websiteId, 'event', filters),
+ getPageviewMetrics(websiteId, 'url', filters),
+ getPageviewMetrics(websiteId, 'referrer', filters),
+ getSessionMetrics(websiteId, 'browser', filters),
+ getSessionMetrics(websiteId, 'os', filters),
+ getSessionMetrics(websiteId, 'device', filters),
+ getSessionMetrics(websiteId, 'country', filters),
+ ]);
+
+ const zip = new JSZip();
+
+ const parse = (data: any) => {
+ return Papa.unparse(data, {
+ header: true,
+ skipEmptyLines: true,
+ });
+ };
+
+ zip.file('events.csv', parse(events));
+ zip.file('pages.csv', parse(pages));
+ zip.file('referrers.csv', parse(referrers));
+ zip.file('browsers.csv', parse(browsers));
+ zip.file('os.csv', parse(os));
+ zip.file('devices.csv', parse(devices));
+ zip.file('countries.csv', parse(countries));
+
+ const content = await zip.generateAsync({ type: 'nodebuffer' });
+ const base64 = content.toString('base64');
+
+ return json({ zip: base64 });
+}
diff --git a/src/assets/download.svg b/src/assets/download.svg
new file mode 100644
index 00000000..b2482c9b
--- /dev/null
+++ b/src/assets/download.svg
@@ -0,0 +1 @@
+
diff --git a/src/assets/export.svg b/src/assets/export.svg
new file mode 100644
index 00000000..d7585b15
--- /dev/null
+++ b/src/assets/export.svg
@@ -0,0 +1 @@
+
diff --git a/src/components/hooks/queries/useReport.ts b/src/components/hooks/queries/useReport.ts
index 45aea19c..7e9d81b0 100644
--- a/src/components/hooks/queries/useReport.ts
+++ b/src/components/hooks/queries/useReport.ts
@@ -29,7 +29,7 @@ export function useReport(
data.parameters = {
...defaultParameters?.parameters,
...data.parameters,
- dateRange: parseDateRange(dateRange.value),
+ dateRange: dateRange ? parseDateRange(dateRange?.value) : {},
};
setReport(data);
diff --git a/src/components/icons.ts b/src/components/icons.ts
index e952e500..2667e70e 100644
--- a/src/components/icons.ts
+++ b/src/components/icons.ts
@@ -8,6 +8,8 @@ import Change from '@/assets/change.svg';
import Clock from '@/assets/clock.svg';
import Compare from '@/assets/compare.svg';
import Dashboard from '@/assets/dashboard.svg';
+import Download from '@/assets/download.svg';
+import Export from '@/assets/export.svg';
import Eye from '@/assets/eye.svg';
import Gear from '@/assets/gear.svg';
import Globe from '@/assets/globe.svg';
@@ -37,6 +39,8 @@ const icons = {
Clock,
Compare,
Dashboard,
+ Download,
+ Export,
Eye,
Gear,
Globe,
diff --git a/src/components/input/DownloadButton.tsx b/src/components/input/DownloadButton.tsx
new file mode 100644
index 00000000..9a0e7994
--- /dev/null
+++ b/src/components/input/DownloadButton.tsx
@@ -0,0 +1,41 @@
+import Papa from 'papaparse';
+import { Button, Icon, TooltipPopup } from 'react-basics';
+import Icons from '@/components/icons';
+import { useMessages } from '@/components/hooks';
+
+export function DownloadButton({
+ filename = 'data',
+ data,
+}: {
+ filename?: string;
+ data?: any;
+ onClick?: () => void;
+}) {
+ const { formatMessage, labels } = useMessages();
+
+ const handleClick = async () => {
+ downloadCsv(`${filename}.csv`, Papa.unparse(data));
+ };
+
+ return (
+
+
+
+ );
+}
+
+function downloadCsv(filename: string, data: any) {
+ const blob = new Blob([data], { type: 'text/csv' });
+ const url = URL.createObjectURL(blob);
+
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ a.click();
+
+ URL.revokeObjectURL(url);
+}
diff --git a/src/components/input/ExportButton.tsx b/src/components/input/ExportButton.tsx
new file mode 100644
index 00000000..2b17a629
--- /dev/null
+++ b/src/components/input/ExportButton.tsx
@@ -0,0 +1,47 @@
+import { useState } from 'react';
+import { Icon, TooltipPopup, LoadingButton } from 'react-basics';
+import Icons from '@/components/icons';
+import { useMessages, useApi } from '@/components/hooks';
+import { useFilterParams } from '@/components/hooks/useFilterParams';
+import { useSearchParams } from 'next/navigation';
+
+export function ExportButton({ websiteId }: { websiteId: string }) {
+ const { formatMessage, labels } = useMessages();
+ const [isLoading, setIsLoading] = useState(false);
+ const params = useFilterParams(websiteId);
+ const searchParams = useSearchParams();
+ const { get } = useApi();
+
+ const handleClick = async () => {
+ setIsLoading(true);
+
+ const { zip } = await get(`/websites/${websiteId}/export`, { ...params, ...searchParams });
+
+ const binary = atob(zip);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) {
+ bytes[i] = binary.charCodeAt(i);
+ }
+
+ const blob = new Blob([bytes], { type: 'application/zip' });
+ const url = URL.createObjectURL(blob);
+
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = 'download.zip';
+ a.click();
+ URL.revokeObjectURL(url);
+
+ setIsLoading(false);
+ };
+
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/messages.ts b/src/components/messages.ts
index 1a7a1858..27086b49 100644
--- a/src/components/messages.ts
+++ b/src/components/messages.ts
@@ -316,6 +316,7 @@ export const labels = defineMessages({
other: { id: 'label.other', defaultMessage: 'Other' },
chart: { id: 'label.chart', defaultMessage: 'Chart' },
table: { id: 'label.table', defaultMessage: 'Table' },
+ download: { id: 'label.download', defaultMessage: 'Download' },
});
export const messages = defineMessages({
diff --git a/src/components/metrics/EventsTable.tsx b/src/components/metrics/EventsTable.tsx
index 45b81094..2be6f013 100644
--- a/src/components/metrics/EventsTable.tsx
+++ b/src/components/metrics/EventsTable.tsx
@@ -32,6 +32,7 @@ export function EventsTable({ onLabelClick, ...props }: EventsTableProps) {
metric={formatMessage(labels.actions)}
onDataLoad={handleDataLoad}
renderLabel={renderLabel}
+ allowDownload={false}
/>
);
}
diff --git a/src/components/metrics/MetricsTable.module.css b/src/components/metrics/MetricsTable.module.css
index f04d9ae4..112cf382 100644
--- a/src/components/metrics/MetricsTable.module.css
+++ b/src/components/metrics/MetricsTable.module.css
@@ -14,6 +14,13 @@
margin-bottom: 10px;
}
+.buttons {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 10px;
+}
+
.footer {
display: flex;
justify-content: center;
diff --git a/src/components/metrics/MetricsTable.tsx b/src/components/metrics/MetricsTable.tsx
index 616262cb..39ba712d 100644
--- a/src/components/metrics/MetricsTable.tsx
+++ b/src/components/metrics/MetricsTable.tsx
@@ -15,6 +15,7 @@ import {
import Icons from '@/components/icons';
import ListTable, { ListTableProps } from './ListTable';
import styles from './MetricsTable.module.css';
+import { DownloadButton } from '@/components/input/DownloadButton';
export interface MetricsTableProps extends ListTableProps {
websiteId: string;
@@ -29,6 +30,7 @@ export interface MetricsTableProps extends ListTableProps {
searchFormattedValues?: boolean;
showMore?: boolean;
params?: { [key: string]: any };
+ allowDownload?: boolean;
children?: ReactNode;
}
@@ -44,6 +46,7 @@ export function MetricsTable({
searchFormattedValues = false,
showMore = true,
params,
+ allowDownload = true,
children,
...props
}: MetricsTableProps) {
@@ -104,7 +107,10 @@ export function MetricsTable({
autoFocus={true}
/>
)}
- {children}
+
+ {children}
+ {allowDownload && }
+
diff --git a/src/queries/sql/reports/getUTM.ts b/src/queries/sql/reports/getUTM.ts
index 5463815b..f96c62d3 100644
--- a/src/queries/sql/reports/getUTM.ts
+++ b/src/queries/sql/reports/getUTM.ts
@@ -44,7 +44,7 @@ async function relationalQuery(
startDate,
endDate,
},
- ).then(result => parseParameters(result as any[]));
+ );
}
async function clickhouseQuery(
@@ -73,30 +73,5 @@ async function clickhouseQuery(
startDate,
endDate,
},
- ).then(result => parseParameters(result as any[]));
-}
-
-function parseParameters(data: any[]) {
- return data.reduce((obj, { url_query, num }) => {
- try {
- const searchParams = new URLSearchParams(url_query);
-
- for (const [key, value] of searchParams) {
- if (key.match(/^utm_(\w+)$/)) {
- const name = value;
- if (!obj[key]) {
- obj[key] = { [name]: Number(num) };
- } else if (!obj[key][name]) {
- obj[key][name] = Number(num);
- } else {
- obj[key][name] += Number(num);
- }
- }
- }
- } catch {
- // Ignore
- }
-
- return obj;
- }, {});
+ );
}