Skip to content

Commit 0f23726

Browse files
committed
feat: new settings page
1 parent c51e718 commit 0f23726

File tree

8 files changed

+688
-3
lines changed

8 files changed

+688
-3
lines changed

apps/dashboard/app/(main)/websites/[id]/layout.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { useParams, usePathname } from 'next/navigation';
66
import { toast } from 'sonner';
77
import NotFound from '@/app/not-found';
88
import { useTrackingSetup } from '@/hooks/use-tracking-setup';
9+
import { cn } from '@/lib/utils';
910
import { isAnalyticsRefreshingAtom } from '@/stores/jotai/filterAtoms';
1011
import { AnalyticsToolbar } from './_components/analytics-toolbar';
1112
import { FiltersSection } from './_components/filters-section';
@@ -31,7 +32,10 @@ export default function WebsiteLayout({ children }: WebsiteLayoutProps) {
3132
pathname.includes('/assistant') ||
3233
pathname.includes('/map') ||
3334
pathname.includes('/flags') ||
34-
pathname.includes('/databunny');
35+
pathname.includes('/databunny') ||
36+
pathname.includes('/settings');
37+
38+
const noPadding = pathname.includes('/settings');
3539

3640
const handleRefresh = async () => {
3741
setIsRefreshing(true);
@@ -55,7 +59,12 @@ export default function WebsiteLayout({ children }: WebsiteLayoutProps) {
5559
};
5660

5761
return (
58-
<div className="mx-auto flex h-full max-w-[1600px] flex-col p-3 sm:p-4 lg:p-6">
62+
<div
63+
className={cn(
64+
'mx-auto flex h-full max-w-[1600px] flex-col',
65+
noPadding ? 'p-0' : 'p-3 sm:p-4 lg:p-6'
66+
)}
67+
>
5968
{isTrackingSetup && !isAssistantPage && (
6069
<div className="flex-shrink-0 space-y-4">
6170
<AnalyticsToolbar
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
'use client';
2+
3+
import type { Website } from '@databuddy/shared';
4+
import { WarningCircleIcon } from '@phosphor-icons/react';
5+
import {
6+
AlertDialog,
7+
AlertDialogAction,
8+
AlertDialogCancel,
9+
AlertDialogContent,
10+
AlertDialogDescription,
11+
AlertDialogFooter,
12+
AlertDialogHeader,
13+
AlertDialogTitle,
14+
} from '@/components/ui/alert-dialog';
15+
16+
interface DeleteWebsiteDialogProps {
17+
open: boolean;
18+
onOpenChange: (open: boolean) => void;
19+
websiteData: Website;
20+
isDeleting: boolean;
21+
onConfirmDelete: () => void;
22+
}
23+
24+
export function DeleteWebsiteDialog({
25+
open,
26+
onOpenChange,
27+
websiteData,
28+
isDeleting,
29+
onConfirmDelete,
30+
}: DeleteWebsiteDialogProps) {
31+
return (
32+
<AlertDialog onOpenChange={onOpenChange} open={open}>
33+
<AlertDialogContent>
34+
<AlertDialogHeader>
35+
<AlertDialogTitle>Delete Website</AlertDialogTitle>
36+
<AlertDialogDescription asChild>
37+
<div className="space-y-4">
38+
<p className="text-muted-foreground text-sm">
39+
Are you sure you want to delete{' '}
40+
<span className="font-medium">
41+
{websiteData.name || websiteData.domain}
42+
</span>
43+
? This action cannot be undone.
44+
</p>
45+
46+
<div className="rounded-md bg-muted p-3 text-muted-foreground text-sm">
47+
<div className="flex items-start gap-2">
48+
<WarningCircleIcon className="h-5 w-5 shrink-0 text-muted-foreground" />
49+
<div className="space-y-1">
50+
<p className="font-medium">Warning:</p>
51+
<ul className="list-disc space-y-1 pl-4 text-xs">
52+
<li>All analytics data will be permanently deleted</li>
53+
<li>Tracking will stop immediately</li>
54+
<li>All website settings will be lost</li>
55+
</ul>
56+
</div>
57+
</div>
58+
</div>
59+
</div>
60+
</AlertDialogDescription>
61+
</AlertDialogHeader>
62+
<AlertDialogFooter>
63+
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
64+
<AlertDialogAction
65+
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
66+
disabled={isDeleting}
67+
onClick={onConfirmDelete}
68+
>
69+
{isDeleting ? 'Deleting...' : 'Delete Website'}
70+
</AlertDialogAction>
71+
</AlertDialogFooter>
72+
</AlertDialogContent>
73+
</AlertDialog>
74+
);
75+
}
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
'use client';
2+
3+
import { CheckIcon, DownloadIcon } from '@phosphor-icons/react';
4+
import dayjs from 'dayjs';
5+
import { useParams } from 'next/navigation';
6+
import { useCallback, useMemo, useState } from 'react';
7+
import type { DateRange as DayPickerRange } from 'react-day-picker';
8+
import { DateRangePicker } from '@/components/date-range-picker';
9+
import { Badge } from '@/components/ui/badge';
10+
import { Button } from '@/components/ui/button';
11+
import { Label } from '@/components/ui/label';
12+
import { Switch } from '@/components/ui/switch';
13+
import { useDataExport } from '@/hooks/use-data-export';
14+
import { useWebsite } from '@/hooks/use-websites';
15+
16+
export default function ExportPage() {
17+
const params = useParams();
18+
const websiteId = params.id as string;
19+
const { data: websiteData } = useWebsite(websiteId);
20+
21+
type ExportFormat = 'json' | 'csv' | 'txt';
22+
const [selectedFormat, setSelectedFormat] = useState<ExportFormat>('csv');
23+
const [dateRange, setDateRange] = useState<DayPickerRange | undefined>(
24+
undefined
25+
);
26+
const [useCustomRange, setUseCustomRange] = useState(false);
27+
28+
const { mutate: exportData, isPending: isExporting } = useDataExport({
29+
websiteId,
30+
websiteName: websiteData?.name || undefined,
31+
});
32+
33+
const formatOptions = useMemo(
34+
() => [
35+
{
36+
value: 'json' as const,
37+
label: 'JSON',
38+
description: 'Structured data for developers',
39+
icon: '📄',
40+
},
41+
{
42+
value: 'csv' as const,
43+
label: 'CSV',
44+
description: 'Works with spreadsheets',
45+
icon: '📊',
46+
},
47+
{
48+
value: 'txt' as const,
49+
label: 'TXT',
50+
description: 'Plain text export',
51+
icon: '📝',
52+
},
53+
],
54+
[]
55+
);
56+
57+
const isExportDisabled =
58+
isExporting || (useCustomRange && !(dateRange?.from && dateRange?.to));
59+
60+
const handleExport = useCallback(() => {
61+
if (!websiteData) {
62+
return;
63+
}
64+
if (useCustomRange && !(dateRange?.from && dateRange?.to)) {
65+
return;
66+
}
67+
68+
if (useCustomRange && dateRange?.from && dateRange?.to) {
69+
const startDate = dayjs(dateRange.from).format('YYYY-MM-DD');
70+
const endDate = dayjs(dateRange.to).format('YYYY-MM-DD');
71+
exportData({ format: selectedFormat, startDate, endDate });
72+
} else {
73+
exportData({ format: selectedFormat });
74+
}
75+
}, [websiteData, useCustomRange, dateRange, selectedFormat, exportData]);
76+
77+
if (!websiteData) {
78+
return (
79+
<div className="flex h-64 items-center justify-center">
80+
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary/20 border-t-primary" />
81+
</div>
82+
);
83+
}
84+
85+
return (
86+
<div className="flex h-full flex-col">
87+
{/* Header - align with websites header */}
88+
<div className="h-[89px] border-b">
89+
<div className="flex h-full flex-col justify-center gap-2 px-4 sm:flex-row sm:items-center sm:justify-between sm:gap-0">
90+
<div className="min-w-0 flex-1">
91+
<div className="flex items-center gap-3">
92+
<div className="rounded-lg border border-primary/20 bg-primary/10 p-2">
93+
<DownloadIcon className="h-5 w-5 text-primary" />
94+
</div>
95+
<div className="min-w-0 flex-1">
96+
<div className="flex items-center gap-2">
97+
<h1 className="truncate font-bold text-foreground text-xl tracking-tight sm:text-2xl">
98+
Data Export
99+
</h1>
100+
<Badge className="h-5 px-2" variant="secondary">
101+
Tools
102+
</Badge>
103+
</div>
104+
<p className="mt-0.5 text-muted-foreground text-xs sm:text-sm">
105+
Download your analytics data for backup and analysis
106+
</p>
107+
</div>
108+
</div>
109+
</div>
110+
{/* Right-side actions (optional) */}
111+
</div>
112+
113+
{/* Content */}
114+
<div className="flex min-h-0 flex-1 flex-col">
115+
{/* Format selection */}
116+
<section className="border-b px-4 py-5 sm:px-6">
117+
<div className="mb-3">
118+
<Label className="font-medium text-sm">Export format</Label>
119+
</div>
120+
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
121+
{formatOptions.map((format) => (
122+
<button
123+
className={`flex items-start gap-3 rounded-md border p-4 text-left transition-colors hover:border-primary/50 ${
124+
selectedFormat === format.value
125+
? 'border-primary bg-primary/5'
126+
: 'border-border'
127+
}`}
128+
key={format.value}
129+
onClick={() => setSelectedFormat(format.value)}
130+
type="button"
131+
>
132+
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-muted text-lg">
133+
{format.icon}
134+
</div>
135+
<div className="min-w-0 flex-1">
136+
<div className="mb-1 flex items-center gap-2">
137+
<span className="font-medium text-sm">
138+
{format.label}
139+
</span>
140+
{selectedFormat === format.value && (
141+
<CheckIcon className="h-4 w-4 text-primary" />
142+
)}
143+
</div>
144+
<p className="text-muted-foreground text-xs">
145+
{format.description}
146+
</p>
147+
</div>
148+
</button>
149+
))}
150+
</div>
151+
</section>
152+
153+
{/* Date range */}
154+
<section className="border-b px-4 py-5 sm:px-6">
155+
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
156+
<div>
157+
<h2 className="font-medium text-sm">Date range</h2>
158+
<p className="text-muted-foreground text-xs">
159+
{useCustomRange
160+
? 'Export a specific range'
161+
: 'Export all available data'}
162+
</p>
163+
</div>
164+
<Switch
165+
aria-label="Use custom date range"
166+
checked={useCustomRange}
167+
id="custom-range"
168+
onCheckedChange={setUseCustomRange}
169+
/>
170+
</div>
171+
{useCustomRange && (
172+
<div className="mt-3 border-t pt-3">
173+
<div className="flex items-center gap-3">
174+
<Label className="shrink-0 font-medium text-sm">
175+
From - To:
176+
</Label>
177+
<DateRangePicker
178+
className="flex-1"
179+
maxDate={new Date()}
180+
minDate={new Date(2020, 0, 1)}
181+
onChange={setDateRange}
182+
value={dateRange}
183+
/>
184+
</div>
185+
</div>
186+
)}
187+
</section>
188+
189+
{/* Export action */}
190+
<section className="px-4 py-5 sm:px-6">
191+
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
192+
<div>
193+
<h3 className="font-medium text-sm">
194+
Ready to export {websiteData.name || 'your website'} data
195+
</h3>
196+
<p className="text-muted-foreground text-xs">
197+
Format:{' '}
198+
<Badge className="font-mono" variant="secondary">
199+
{selectedFormat.toUpperCase()}
200+
</Badge>
201+
{useCustomRange && dateRange?.from && dateRange?.to && (
202+
<span className="ml-2">
203+
{dayjs(dateRange.from).format('MMM D, YYYY')} -{' '}
204+
{dayjs(dateRange.to).format('MMM D, YYYY')}
205+
</span>
206+
)}
207+
</p>
208+
</div>
209+
<Button
210+
aria-label="Start data export"
211+
className="min-w-[140px]"
212+
disabled={isExportDisabled}
213+
onClick={handleExport}
214+
size="lg"
215+
>
216+
{isExporting ? (
217+
<>
218+
<div className="mr-2 h-4 w-4 animate-spin rounded-full border border-current border-t-transparent" />
219+
Exporting...
220+
</>
221+
) : (
222+
<>
223+
<DownloadIcon className="mr-2 h-4 w-4" />
224+
Export Data
225+
</>
226+
)}
227+
</Button>
228+
</div>
229+
</section>
230+
</div>
231+
</div>
232+
</div>
233+
);
234+
}

0 commit comments

Comments
 (0)