diff --git a/src/pages/Analytics.tsx b/src/pages/Analytics.tsx index cc5e8c9..d6271ac 100644 --- a/src/pages/Analytics.tsx +++ b/src/pages/Analytics.tsx @@ -1,22 +1,27 @@ import { useState, useEffect, useCallback } from 'react' import { useParams, Link, useSearchParams, useLocation } from 'react-router-dom' import { apiClient } from '../api/axios' -import type { LinkStatsResponse, ApiResponse } from '../types' -import { - BarChart3, - ChevronLeft, - MousePointerClick, - Users, - Globe, - Smartphone, - Loader2, +import type { LinkStatsResponse, ApiResponse, UserLinkResponse, PageResponse } from '../types' +import { useAuthStore } from '../store/useAuthStore' +import { toast } from 'react-hot-toast' +import { + BarChart3, + ChevronLeft, + MousePointerClick, + Users, + Globe, + Smartphone, + Loader2, AlertCircle, ExternalLink, Calendar, Monitor, Tablet, TrendingUp, - Search + Search, + QrCode, + X, + Download } from 'lucide-react' import { XAxis, @@ -36,36 +41,109 @@ export default function Analytics() { const [searchParams] = useSearchParams() const location = useLocation() const token = searchParams.get('token') + const { user } = useAuthStore() + const [stats, setStats] = useState(null) + const [linkInfo, setLinkInfo] = useState(null) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState('') + const [dateRange, setDateRange] = useState<'30d' | 'all' | 'custom'>('30d') + + // QR States + const [showQrModal, setShowQrModal] = useState(false) + const [isGeneratingQr, setIsGeneratingQr] = useState(false) + + // Initialize with local time format YYYY-MM-DDTHH:mm + const now = new Date() + const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) + + const formatForInput = (date: Date) => { + const pad = (n: number) => n.toString().padStart(2, '0') + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}` + } + + const [startDate, setStartDate] = useState(formatForInput(thirtyDaysAgo)) + const [endDate, setEndDate] = useState(formatForInput(now)) const isPublicView = !location.pathname.startsWith('/dashboard') + const hasPrivilege = user?.role === 'ADMIN' || user?.vip const fetchAnalytics = useCallback(async () => { if (!shortCode) return setIsLoading(true) setError('') try { + const params: any = { token } + + if (dateRange === '30d') { + const to = new Date().toISOString() + const from = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString() + params.from = from + params.to = to + } else if (dateRange === 'all') { + params.from = '2024-01-01T00:00:00Z' + params.to = new Date().toISOString() + } else { + // Custom range validation + if (new Date(startDate) > new Date(endDate)) { + setError('Start date must be before end date.') + setIsLoading(false) + return + } + params.from = new Date(startDate).toISOString() + params.to = new Date(endDate).toISOString() + } + const { data } = await apiClient.get>(`/analytics/${shortCode}`, { - params: { token } + params }) if (data.success) { setStats(data.data) } else { setError(data.message || 'Failed to load analytics data.') } + + // If logged in, also try to fetch link info for QR etc. + if (user) { + try { + const { data: linkData } = await apiClient.get>>('/me/links', { + params: { keyword: shortCode, size: 1 } + }) + if (linkData.success && linkData.data.content.length > 0) { + setLinkInfo(linkData.data.content[0]) + } + } catch (e) { + // Silent fail for link info + } + } } catch (err: any) { setError(err.response?.data?.message || 'An error occurred while fetching analytics.') } finally { setIsLoading(false) } - }, [shortCode]) + }, [shortCode, token, dateRange, startDate, endDate, user]) useEffect(() => { fetchAnalytics() }, [fetchAnalytics]) + const handleGenerateQr = async () => { + if (!shortCode) return + setIsGeneratingQr(true) + try { + const { data } = await apiClient.post>(`/me/links/${shortCode}/qr-code`) + if (data.success) { + toast.success('QR Code created!') + if (linkInfo) setLinkInfo({ ...linkInfo, qrCode: data.data.qrCode }) + else fetchAnalytics() // fallback reload + } + } catch (err: any) { + toast.error(err.response?.data?.message || 'Failed to generate QR.') + } finally { + setIsGeneratingQr(false) + } + } + const getTopItem = (data: Record) => { if (!data || Object.keys(data).length === 0) return 'N/A' return Object.entries(data).reduce((a, b) => a[1] > b[1] ? a : b)[0] @@ -103,301 +181,478 @@ export default function Analytics() { } } - if (isLoading) { - return ( -
- -

Crunching your data...

-
- ) - } - - if (error || !stats) { - return ( -
-
- -

Oops! Something went wrong

-

{error || 'Could not load analytics for this link.'}

-
- - - {isPublicView ? 'Back to Home' : 'Back to Dashboard'} - - - - Try Another Link - -
-
-
- ) - } - - const topDevice = getTopItem(stats.clicksByDeviceType as any) - const topCountry = getTopItem(stats.clicksByCountry) + const topDevice = getTopItem(stats?.clicksByDeviceType || {} as any) + const topCountry = getTopItem(stats?.clicksByCountry || {}) return ( -
- {/* Header */} -
+
+ {/* Header Section */} +
-
-

Analytics

- +

Analytics

+ {shortCode}
-

- Detailed tracking for your shortened link - + Performance metrics for + + {shortCode} - Visit Link

-
+ {/* Controls Area */} +
+ {hasPrivilege && ( + + )} + + - - Try Another + + {dateRange !== 'custom' && Try Another} -
- - + + {dateRange === 'custom' && ( +
+ {/* Start Date */} +
{ + const input = document.getElementById('analytics-start-date') as HTMLInputElement; + try { input.showPicker(); } catch (e) { input.focus(); } + }} + > +
+ Start Point + + {startDate ? new Date(startDate).toLocaleString('vi-VN', { + day: '2-digit', month: '2-digit', year: 'numeric', + hour: '2-digit', minute: '2-digit', second: '2-digit', + hour12: false + }) : 'dd/mm/yyyy hh:mm:ss'} + +
+ + setStartDate(e.target.value)} + className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" + style={{ colorScheme: 'light' }} + /> +
+ +
+ + {/* End Date */} +
{ + const input = document.getElementById('analytics-end-date') as HTMLInputElement; + try { input.showPicker(); } catch (e) { input.focus(); } + }} + > +
+ End Point + + {endDate ? new Date(endDate).toLocaleString('vi-VN', { + day: '2-digit', month: '2-digit', year: 'numeric', + hour: '2-digit', minute: '2-digit', second: '2-digit', + hour12: false + }) : 'dd/mm/yyyy hh:mm:ss'} + +
+ + setEndDate(e.target.value)} + className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" + style={{ colorScheme: 'light' }} + /> +
+
+ )} + +
+ + +
- {/* Overview Cards */} -
-
-
- + {error && ( +
+
+
-

Total Clicks

-

{stats.totalClicks.toLocaleString()}

-

- +0.0% vs prev period -

+
+

Oops! We hit a snag

+

{error}

+
+
+ )} -
-
- -
-

Unique Visitors

-

{stats.uniqueVisitors.toLocaleString()}

-

- Based on unique IP addresses -

+ {isLoading && stats && ( +
+ + Updating statistics...
+ )} -
-
- + {isLoading && !stats ? ( +
+
+
+
+
-

Top Country

-

{topCountry}

-

- Highest traffic source -

+

Calculating analytics

+

Please wait a moment...

- -
-
- {topDevice === 'N/A' ? : getDeviceIcon(topDevice)} + ) : !stats ? ( +
+
+
-

Top Device

-

{topDevice.toLowerCase()}

-

- Most used platform +

No Clicks Detected

+

+ We couldn't find any data for this link within the selected date range. Try picking a broader period.

-
+ ) : ( +
+
+
+
+
+ +
+

Total Clicks

+

{stats.totalClicks.toLocaleString()}

+
- {/* Main Content placeholders */} -
- {/* Time-series Chart */} -
-
-
-

- - Click Performance -

-

Daily interaction trends

-
-
- - Live -
-
- -
- {chartData.length > 0 ? ( - - - - - - - - - - - - - - - - ) : ( -
- -

No tracking data for this period

+
+
+
+ +
+

Unique Visitors

+

{stats.uniqueVisitors.toLocaleString()}

+
+ +
+
+
+ +
+

Top Region

+

{topCountry}

+
+ +
+
+
+ {topDevice === 'N/A' ? : getDeviceIcon(topDevice)} +
+

Primary Device

+

{topDevice.toLowerCase()}

+
+
+ +
+
+
+
+

+ + Click Performance +

+

Daily engagement timeline

- )} -
-
+
+ + Live Flow +
+
- {/* Side panels */} -
- {/* Device Breakdown */} -
-

- - Devices -

-
- {deviceData.length > 0 ? ( -
- - - - {deviceData.map((_, index) => ( - - ))} - - - - - {/* Legend */} -
- {deviceData.map((entry, index) => ( -
-
-
- {entry.name.toLowerCase()} -
- {entry.value} -
- ))} -
-
+
+ {chartData.length > 0 ? ( + + + + + + + + + + + + + + + ) : ( -

No device data

+
+ +

No timeline data

+
)} -
-
- - {/* Referrer Breakdown */} -
-

- - Top Referrers -

-
- {referrerData.length > 0 ? ( - referrerData.map((ref, index) => { - const total = Object.values(stats.clicksByReferrer).reduce((a, b) => a + b, 0) - const percent = ((ref.value / total) * 100).toFixed(0) - return ( -
-
- {ref.name} - {percent}% -
-
-
-
+
+
+ +
+
+

+ + Devices +

+ {/* Fixed height container for PieChart */} +
+ {deviceData.length > 0 ? ( +
+
+ + + + {deviceData.map((_, index) => ( + + ))} + + + +
- ) - }) - ) : ( -
-

No referrer data

+
+ {deviceData.map((entry, index) => ( +
+
+
+ {entry.name.toLowerCase()} +
+ {entry.value} +
+ ))} +
+
+ ) : ( +

No device stats

+ )} +
+
+ +
+

+ + Top Referrers +

+
+ {referrerData.length > 0 ? ( + referrerData.map((ref, index) => { + const total = Object.values(stats.clicksByReferrer).reduce((a, b) => a + b, 0) + const percent = ((ref.value / total) * 100).toFixed(0) + return ( +
+
+ {ref.name} + {percent}% +
+
+
+
+
+ ) + }) + ) : ( +
+

No referrer sources

+
+ )} +
+
+
+
+
+ )} + + {/* QR Modal */} + {showQrModal && ( +
+
+
+
+

QR Code Link

+

Quick identity for {shortCode}

+
+ +
+ +
+ {linkInfo?.qrCode ? ( + <> +
+ QR Code + +
- )} -
-
+
+ + + Download QR + + +
+ + ) : ( +
+
+ +
+

No QR Code Yet

+

+ You haven't generated a QR code for this link. Create one now to share offline! +

+ +
+ )} +
+
-
+ )}
) } diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 59ec1bf..856c769 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -37,7 +37,9 @@ export default function Dashboard() { const [selectedLinkForQr, setSelectedLinkForQr] = useState(null) const [isGeneratingQr, setIsGeneratingQr] = useState(false) const [isDeletingQr, setIsDeletingQr] = useState(false) - const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + const [showQrDeleteConfirm, setShowQrDeleteConfirm] = useState(false) + const [showLinkDeleteConfirm, setShowLinkDeleteConfirm] = useState(false) + const [linkToDelete, setLinkToDelete] = useState(null) const [qrCodeToRemove, setQrCodeToRemove] = useState(null) // Debounce search keyword @@ -64,9 +66,16 @@ export default function Dashboard() { } }) if (data.success) { - setLinks(data.data.content) - setTotalPages(data.data.totalPages) - setTotalElements(data.data.totalElements) + const d = data.data as any; + const fetchedLinks = d.content || []; + setLinks(fetchedLinks) + + // Very robust fallback for pagination metadata + const totalElems = d.totalElements ?? d.total_elements ?? d.totalCount ?? d.total_count ?? d.total ?? (fetchedLinks.length || 0); + const totalPgs = d.totalPages ?? d.total_pages ?? d.page_count ?? (totalElems > 0 ? Math.ceil(totalElems / size) : 0); + + setTotalElements(totalElems) + setTotalPages(totalPgs) } } catch { // silently fail — will show empty state @@ -166,7 +175,12 @@ export default function Dashboard() { const triggerDeleteQrConfirm = (shortCode: string) => { setQrCodeToRemove(shortCode) - setShowDeleteConfirm(true) + setShowQrDeleteConfirm(true) + } + + const triggerDeleteLinkConfirm = (shortCode: string) => { + setLinkToDelete(shortCode) + setShowLinkDeleteConfirm(true) } const handleDeleteQr = async () => { @@ -185,7 +199,7 @@ export default function Dashboard() { if (recentLink?.shortCode === shortCode) { setRecentLink({ ...recentLink, qrCode: undefined } as any) } - setShowDeleteConfirm(false) + setShowQrDeleteConfirm(false) setQrCodeToRemove(null) } } catch (err: any) { @@ -270,17 +284,22 @@ export default function Dashboard() { {/* Custom Alias Field */}
- setCustomAlias(e.target.value.trim())} - placeholder="Custom alias (optional)" - className="w-full bg-transparent outline-none text-gray-900 placeholder:text-gray-400 font-medium text-sm md:text-base px-4 py-3 md:py-4 min-w-[150px] md:min-w-[180px]" - disabled={isLoading} - /> + setCustomAlias(e.target.value.substring(0, 50).trim())} + placeholder="Custom alias (optional)" + className="w-full bg-transparent outline-none text-gray-900 placeholder:text-gray-400 font-medium text-sm md:text-base px-4 py-3 md:py-4 min-w-[150px] md:min-w-[180px] pr-10" + disabled={isLoading} + /> + {customAlias.length > 0 && ( + + {customAlias.length}/50 + + )}
- {(user?.vip || user?.role === 'ADMIN') && ( + {user && (
{ @@ -407,7 +426,9 @@ export default function Dashboard() { My Links -

{totalElements} link{totalElements !== 1 ? 's' : ''} total

+

+ {totalElements || 0} link{totalElements !== 1 ? 's' : ''} total +

@@ -509,7 +530,7 @@ export default function Dashboard() {
)} + + {/* Delete Link Confirmation Modal */} + {showLinkDeleteConfirm && ( +
+
+
+ +
+ +
+

Delete this link?

+

+ This action cannot be undone. All analytics data for this link will also be lost forever. +

+
+ +
+ + +
+
+
+ )}
) }