diff --git a/package.json b/package.json index 26ac6683..4e775a59 100644 --- a/package.json +++ b/package.json @@ -107,6 +107,7 @@ "next": "15.3.3", "node-fetch": "^3.2.8", "npm-run-all": "^4.1.5", + "papaparse": "^5.5.3", "prisma": "6.7.0", "pure-rand": "^6.1.0", "react": "^19.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 10fdbff0..6f5e4bef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -131,6 +131,9 @@ importers: npm-run-all: specifier: ^4.1.5 version: 4.1.5 + papaparse: + specifier: ^5.5.3 + version: 5.5.3 prisma: specifier: 6.7.0 version: 6.7.0(typescript@5.8.3) @@ -5107,6 +5110,9 @@ packages: pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + papaparse@5.5.3: + resolution: {integrity: sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -12275,6 +12281,8 @@ snapshots: pako@1.0.11: {} + papaparse@5.5.3: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 diff --git a/src/app/(main)/reports/[reportId]/ReportBody.tsx b/src/app/(main)/reports/[reportId]/ReportBody.tsx index 9a740c5e..fe2f8c91 100644 --- a/src/app/(main)/reports/[reportId]/ReportBody.tsx +++ b/src/app/(main)/reports/[reportId]/ReportBody.tsx @@ -1,6 +1,7 @@ import { useContext } from 'react'; import { ReportContext } from './Report'; import styles from './ReportBody.module.css'; +import { DownloadButton } from '@/components/input/DownloadButton'; export function ReportBody({ children }) { const { report } = useContext(ReportContext); @@ -9,7 +10,14 @@ export function ReportBody({ children }) { return null; } - return
{children}
; + return ( +
+ {report.type !== 'revenue' && report.type !== 'attribution' && ( + + )} + {children} +
+ ); } export default ReportBody; diff --git a/src/app/(main)/reports/[reportId]/ReportPage.tsx b/src/app/(main)/reports/[reportId]/ReportPage.tsx index 5e215cd2..aea5c0de 100644 --- a/src/app/(main)/reports/[reportId]/ReportPage.tsx +++ b/src/app/(main)/reports/[reportId]/ReportPage.tsx @@ -31,5 +31,9 @@ export default function ReportPage({ reportId }: { reportId: string }) { const ReportComponent = reports[report.type]; + if (!ReportComponent) { + return null; + } + return ; } diff --git a/src/app/(main)/reports/utm/UTMView.tsx b/src/app/(main)/reports/utm/UTMView.tsx index ba025824..a8200c2c 100644 --- a/src/app/(main)/reports/utm/UTMView.tsx +++ b/src/app/(main)/reports/utm/UTMView.tsx @@ -15,6 +15,31 @@ function toArray(data: { [key: string]: number } = {}) { .sort(firstBy('value', -1)); } +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; + }, {}); +} + export default function UTMView() { const { formatMessage, labels } = useMessages(); const { report } = useContext(ReportContext); @@ -27,7 +52,7 @@ export default function UTMView() { return (
{UTM_PARAMS.map(param => { - const items = toArray(data[param]); + const items = toArray(parseParameters(data)[param]); const chartData = { labels: items.map(({ name }) => name), datasets: [ diff --git a/src/app/(main)/websites/[websiteId]/WebsiteMetricsBar.tsx b/src/app/(main)/websites/[websiteId]/WebsiteMetricsBar.tsx index f206d3c9..37e6861e 100644 --- a/src/app/(main)/websites/[websiteId]/WebsiteMetricsBar.tsx +++ b/src/app/(main)/websites/[websiteId]/WebsiteMetricsBar.tsx @@ -1,6 +1,6 @@ import { Dropdown, Item } from 'react-basics'; import classNames from 'classnames'; -import { useDateRange, useMessages, useSticky } from '@/components/hooks'; +import { useDateRange, useMessages, useNavigation, useSticky } from '@/components/hooks'; import WebsiteDateFilter from '@/components/input/WebsiteDateFilter'; import MetricCard from '@/components/metrics/MetricCard'; import MetricsBar from '@/components/metrics/MetricsBar'; @@ -8,6 +8,7 @@ import { formatShortTime, formatLongNumber } from '@/lib/format'; import useWebsiteStats from '@/components/hooks/queries/useWebsiteStats'; import useStore, { setWebsiteDateCompare } from '@/store/websites'; import WebsiteFilterButton from './WebsiteFilterButton'; +import { ExportButton } from '@/components/input/ExportButton'; import styles from './WebsiteMetricsBar.module.css'; export function WebsiteMetricsBar({ @@ -31,6 +32,9 @@ export function WebsiteMetricsBar({ websiteId, compareMode && dateCompare, ); + const { + query: { view }, + } = useNavigation(); const isAllTime = dateRange.value === 'all'; const { pageviews, visitors, visits, bounces, totaltime } = data || {}; @@ -109,7 +113,10 @@ export function WebsiteMetricsBar({
- {showFilter && } +
+ {showFilter && } + {!view && } +
{compareMode && (
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 && } +
{data && !error && ( 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; - }, {}); + ); }