+7
-1
frontend/src/components/LinkList.tsx
+7
-1
frontend/src/components/LinkList.tsx
···
85
85
const baseUrl = window.location.origin
86
86
navigator.clipboard.writeText(`${baseUrl}/${shortCode}`)
87
87
toast({
88
-
description: "Link copied to clipboard",
88
+
description: (
89
+
<>
90
+
Link copied to clipboard
91
+
<br />
92
+
You can add ?source=TextHere to the end of the link to track the source of clicks
93
+
</>
94
+
),
89
95
})
90
96
}
91
97
+148
-104
frontend/src/components/StatisticsModal.tsx
+148
-104
frontend/src/components/StatisticsModal.tsx
···
1
1
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
2
2
import {
3
-
LineChart,
4
-
Line,
5
-
XAxis,
6
-
YAxis,
7
-
CartesianGrid,
8
-
Tooltip,
9
-
ResponsiveContainer,
3
+
LineChart,
4
+
Line,
5
+
XAxis,
6
+
YAxis,
7
+
CartesianGrid,
8
+
Tooltip,
9
+
ResponsiveContainer,
10
10
} from "recharts";
11
11
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
12
-
import { toast } from "@/hooks/use-toast"
12
+
import { toast } from "@/hooks/use-toast";
13
13
import { useState, useEffect } from "react";
14
14
15
-
import { getLinkClickStats, getLinkSourceStats } from '../api/client';
16
-
import { ClickStats, SourceStats } from '../types/api';
15
+
import { getLinkClickStats, getLinkSourceStats } from "../api/client";
16
+
import { ClickStats, SourceStats } from "../types/api";
17
17
18
18
interface StatisticsModalProps {
19
-
isOpen: boolean;
20
-
onClose: () => void;
21
-
linkId: number;
19
+
isOpen: boolean;
20
+
onClose: () => void;
21
+
linkId: number;
22
22
}
23
+
24
+
interface EnhancedClickStats extends ClickStats {
25
+
sources?: { source: string; count: number }[];
26
+
}
27
+
28
+
const CustomTooltip = ({
29
+
active,
30
+
payload,
31
+
label,
32
+
}: {
33
+
active?: boolean;
34
+
payload?: any[];
35
+
label?: string;
36
+
}) => {
37
+
if (active && payload && payload.length > 0) {
38
+
const data = payload[0].payload;
39
+
return (
40
+
<div className="bg-background text-foreground p-4 rounded-lg shadow-lg border">
41
+
<p className="font-medium">{label}</p>
42
+
<p className="text-sm">Clicks: {data.clicks}</p>
43
+
{data.sources && data.sources.length > 0 && (
44
+
<div className="mt-2">
45
+
<p className="font-medium text-sm">Sources:</p>
46
+
<ul className="text-sm">
47
+
{data.sources.map((source: { source: string; count: number }) => (
48
+
<li key={source.source}>
49
+
{source.source}: {source.count}
50
+
</li>
51
+
))}
52
+
</ul>
53
+
</div>
54
+
)}
55
+
</div>
56
+
);
57
+
}
58
+
return null;
59
+
};
23
60
24
61
export function StatisticsModal({ isOpen, onClose, linkId }: StatisticsModalProps) {
25
-
const [clicksOverTime, setClicksOverTime] = useState<ClickStats[]>([]);
26
-
const [sourcesData, setSourcesData] = useState<SourceStats[]>([]);
27
-
const [loading, setLoading] = useState(true);
62
+
const [clicksOverTime, setClicksOverTime] = useState<EnhancedClickStats[]>([]);
63
+
const [sourcesData, setSourcesData] = useState<SourceStats[]>([]);
64
+
const [loading, setLoading] = useState(true);
65
+
66
+
useEffect(() => {
67
+
if (isOpen && linkId) {
68
+
const fetchData = async () => {
69
+
try {
70
+
setLoading(true);
71
+
const [clicksData, sourcesData] = await Promise.all([
72
+
getLinkClickStats(linkId),
73
+
getLinkSourceStats(linkId),
74
+
]);
28
75
29
-
useEffect(() => {
30
-
if (isOpen && linkId) {
31
-
const fetchData = async () => {
32
-
try {
33
-
setLoading(true);
34
-
const [clicksData, sourcesData] = await Promise.all([
35
-
getLinkClickStats(linkId),
36
-
getLinkSourceStats(linkId),
37
-
]);
38
-
setClicksOverTime(clicksData);
39
-
setSourcesData(sourcesData);
40
-
} catch (error: any) {
41
-
console.error("Failed to fetch statistics:", error);
42
-
toast({
43
-
variant: "destructive",
44
-
title: "Error",
45
-
description: error.response?.data || "Failed to load statistics",
46
-
});
47
-
} finally {
48
-
setLoading(false);
49
-
}
50
-
};
51
-
52
-
fetchData();
76
+
// Enhance clicks data with source information
77
+
const enhancedClicksData = clicksData.map((clickData) => ({
78
+
...clickData,
79
+
sources: sourcesData.filter((source) => source.date === clickData.date),
80
+
}));
81
+
82
+
setClicksOverTime(enhancedClicksData);
83
+
setSourcesData(sourcesData);
84
+
} catch (error: any) {
85
+
console.error("Failed to fetch statistics:", error);
86
+
toast({
87
+
variant: "destructive",
88
+
title: "Error",
89
+
description: error.response?.data || "Failed to load statistics",
90
+
});
91
+
} finally {
92
+
setLoading(false);
53
93
}
54
-
}, [isOpen, linkId]);
94
+
};
55
95
56
-
return (
57
-
<Dialog open={isOpen} onOpenChange={onClose}>
58
-
<DialogContent className="max-w-3xl">
59
-
<DialogHeader>
60
-
<DialogTitle>Link Statistics</DialogTitle>
61
-
</DialogHeader>
96
+
fetchData();
97
+
}
98
+
}, [isOpen, linkId]);
62
99
63
-
{loading ? (
64
-
<div className="flex items-center justify-center h-64">Loading...</div>
65
-
) : (
66
-
<div className="grid gap-4">
67
-
<Card>
68
-
<CardHeader>
69
-
<CardTitle>Clicks Over Time</CardTitle>
70
-
</CardHeader>
71
-
<CardContent>
72
-
<div className="h-[300px]">
73
-
<ResponsiveContainer width="100%" height="100%">
74
-
<LineChart data={clicksOverTime}>
75
-
<CartesianGrid strokeDasharray="3 3" />
76
-
<XAxis dataKey="date" />
77
-
<YAxis />
78
-
<Tooltip />
79
-
<Line
80
-
type="monotone"
81
-
dataKey="clicks"
82
-
stroke="#8884d8"
83
-
strokeWidth={2}
84
-
/>
85
-
</LineChart>
86
-
</ResponsiveContainer>
87
-
</div>
88
-
</CardContent>
89
-
</Card>
100
+
return (
101
+
<Dialog open={isOpen} onOpenChange={onClose}>
102
+
<DialogContent className="max-w-3xl">
103
+
<DialogHeader>
104
+
<DialogTitle>Link Statistics</DialogTitle>
105
+
</DialogHeader>
90
106
91
-
<Card>
92
-
<CardHeader>
93
-
<CardTitle>Top Sources</CardTitle>
94
-
</CardHeader>
95
-
<CardContent>
96
-
<ul className="space-y-2">
97
-
{sourcesData.map((source, index) => (
98
-
<li
99
-
key={source.source}
100
-
className="flex items-center justify-between py-2 border-b last:border-0"
101
-
>
102
-
<span className="text-sm">
103
-
<span className="font-medium text-muted-foreground mr-2">
104
-
{index + 1}.
105
-
</span>
106
-
{source.source}
107
-
</span>
108
-
<span className="text-sm font-medium">
109
-
{source.count} clicks
110
-
</span>
111
-
</li>
112
-
))}
113
-
</ul>
114
-
</CardContent>
115
-
</Card>
116
-
</div>
117
-
)}
118
-
</DialogContent>
119
-
</Dialog>
120
-
);
107
+
{loading ? (
108
+
<div className="flex items-center justify-center h-64">Loading...</div>
109
+
) : (
110
+
<div className="grid gap-4">
111
+
<Card>
112
+
<CardHeader>
113
+
<CardTitle>Clicks Over Time</CardTitle>
114
+
</CardHeader>
115
+
<CardContent>
116
+
<div className="h-[300px]">
117
+
<ResponsiveContainer width="100%" height="100%">
118
+
<LineChart data={clicksOverTime}>
119
+
<CartesianGrid strokeDasharray="3 3" />
120
+
<XAxis dataKey="date" />
121
+
<YAxis />
122
+
<Tooltip content={<CustomTooltip />} />
123
+
<Line
124
+
type="monotone"
125
+
dataKey="clicks"
126
+
stroke="#8884d8"
127
+
strokeWidth={2}
128
+
/>
129
+
</LineChart>
130
+
</ResponsiveContainer>
131
+
</div>
132
+
</CardContent>
133
+
</Card>
134
+
135
+
<Card>
136
+
<CardHeader>
137
+
<CardTitle>Top Sources</CardTitle>
138
+
</CardHeader>
139
+
<CardContent>
140
+
<ul className="space-y-2">
141
+
{sourcesData.map((source, index) => (
142
+
<li
143
+
key={source.source}
144
+
className="flex items-center justify-between py-2 border-b last:border-0"
145
+
>
146
+
<span className="text-sm">
147
+
<span className="font-medium text-muted-foreground mr-2">
148
+
{index + 1}.
149
+
</span>
150
+
{source.source}
151
+
</span>
152
+
<span className="text-sm font-medium">
153
+
{source.count} clicks
154
+
</span>
155
+
</li>
156
+
))}
157
+
</ul>
158
+
</CardContent>
159
+
</Card>
160
+
</div>
161
+
)}
162
+
</DialogContent>
163
+
</Dialog>
164
+
);
121
165
}
+1
frontend/src/types/api.ts
+1
frontend/src/types/api.ts
+8
-6
src/handlers.rs
+8
-6
src/handlers.rs
···
643
643
sqlx::query_as::<_, SourceStats>(
644
644
r#"
645
645
SELECT
646
+
DATE(created_at)::text as date,
646
647
query_source as source,
647
648
COUNT(*)::bigint as count
648
649
FROM clicks
649
650
WHERE link_id = $1
650
651
AND query_source IS NOT NULL
651
652
AND query_source != ''
652
-
GROUP BY query_source
653
-
ORDER BY COUNT(*) DESC
654
-
LIMIT 10
653
+
GROUP BY DATE(created_at), query_source
654
+
ORDER BY DATE(created_at) ASC, COUNT(*) DESC
655
+
LIMIT 300
655
656
"#,
656
657
)
657
658
.bind(link_id)
···
662
663
sqlx::query_as::<_, SourceStats>(
663
664
r#"
664
665
SELECT
666
+
DATE(created_at) as date,
665
667
query_source as source,
666
668
COUNT(*) as count
667
669
FROM clicks
668
670
WHERE link_id = ?
669
671
AND query_source IS NOT NULL
670
672
AND query_source != ''
671
-
GROUP BY query_source
672
-
ORDER BY COUNT(*) DESC
673
-
LIMIT 10
673
+
GROUP BY DATE(created_at), query_source
674
+
ORDER BY DATE(created_at) ASC, COUNT(*) DESC
675
+
LIMIT 300
674
676
"#,
675
677
)
676
678
.bind(link_id)