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..d8b403b4
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/export/route.ts
@@ -0,0 +1,75 @@
+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, getWebsiteEvents } 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 limit = 10;
+ const offset = 0;
+
+ const filters = {
+ ...(await getRequestFilters(query)),
+ startDate,
+ endDate,
+ };
+
+ const [events, pages, referrers, browsers, os, devices, countries] = await Promise.all([
+ getWebsiteEvents(websiteId, { startDate, endDate }, query),
+ getEventMetrics(websiteId, 'url', filters, limit, offset),
+ getEventMetrics(websiteId, 'referrer', filters, limit, offset),
+ getEventMetrics(websiteId, 'browser', filters, limit, offset),
+ getEventMetrics(websiteId, 'os', filters, limit, offset),
+ getEventMetrics(websiteId, 'device', filters, limit, offset),
+ getEventMetrics(websiteId, 'country', filters, limit, offset),
+ ]);
+
+ const zip = new JSZip();
+
+ const parse = (data: any) => {
+ return Papa.unparse(data, {
+ header: true,
+ skipEmptyLines: true,
+ });
+ };
+
+ zip.file('events.csv', parse(events?.data));
+ 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 && }
+