From 539f6729a5f98052f9f8bb166508c55b4099e09d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=C6=B0=C6=A1ng=20L=C3=AA=20Anh=20V=C5=A9?= Date: Tue, 17 Mar 2026 02:52:41 +0700 Subject: [PATCH 01/11] feat: add date range selection for analytics data fetching --- src/pages/Analytics.tsx | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/src/pages/Analytics.tsx b/src/pages/Analytics.tsx index cc5e8c9..06dd75e 100644 --- a/src/pages/Analytics.tsx +++ b/src/pages/Analytics.tsx @@ -39,6 +39,7 @@ export default function Analytics() { const [stats, setStats] = useState(null) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState('') + const [dateRange, setDateRange] = useState<'30d' | 'all'>('30d') const isPublicView = !location.pathname.startsWith('/dashboard') @@ -47,8 +48,22 @@ export default function Analytics() { 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 { + // "All Time": backend defaults to 30 days if from is null, + // so we send a very old date to get all data. + params.from = '2024-01-01T00:00:00Z' + params.to = new Date().toISOString() + } + const { data } = await apiClient.get>(`/analytics/${shortCode}`, { - params: { token } + params }) if (data.success) { setStats(data.data) @@ -60,7 +75,7 @@ export default function Analytics() { } finally { setIsLoading(false) } - }, [shortCode]) + }, [shortCode, token, dateRange]) useEffect(() => { fetchAnalytics() @@ -186,10 +201,16 @@ export default function Analytics() { Try Another
- -
From c8c44c6dc2f128c20ff9d496585074905af3bf62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=C6=B0=C6=A1ng=20L=C3=AA=20Anh=20V=C5=A9?= Date: Tue, 17 Mar 2026 02:58:54 +0700 Subject: [PATCH 02/11] fix: update user condition for VIP and ADMIN role check in Dashboard --- src/pages/Dashboard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 59ec1bf..def5495 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -280,7 +280,7 @@ export default function Dashboard() { /> - {(user?.vip || user?.role === 'ADMIN') && ( + {user && (
{ From 9604b09b7805b3f278e05796cbe430496fc76335 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=C6=B0=C6=A1ng=20L=C3=AA=20Anh=20V=C5=A9?= Date: Tue, 17 Mar 2026 11:14:08 +0700 Subject: [PATCH 03/11] feat: add custom date range selection for analytics and improve validation --- src/pages/Analytics.tsx | 148 ++++++++++++++++++++++++++++------------ 1 file changed, 104 insertions(+), 44 deletions(-) diff --git a/src/pages/Analytics.tsx b/src/pages/Analytics.tsx index 06dd75e..f93bc1e 100644 --- a/src/pages/Analytics.tsx +++ b/src/pages/Analytics.tsx @@ -39,7 +39,21 @@ export default function Analytics() { const [stats, setStats] = useState(null) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState('') - const [dateRange, setDateRange] = useState<'30d' | 'all'>('30d') + const [dateRange, setDateRange] = useState<'30d' | 'all' | 'custom'>('30d') + + // 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())}` + } + + const [startDate, setStartDate] = useState(formatForInput(thirtyDaysAgo)) + const [endDate, setEndDate] = useState(formatForInput(now)) + + const isPublicView = !location.pathname.startsWith('/dashboard') @@ -55,13 +69,22 @@ export default function Analytics() { const from = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString() params.from = from params.to = to - } else { - // "All Time": backend defaults to 30 days if from is null, - // so we send a very old date to get all data. + } 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 }) @@ -75,7 +98,8 @@ export default function Analytics() { } finally { setIsLoading(false) } - }, [shortCode, token, dateRange]) + }, [shortCode, token, dateRange, startDate, endDate]) + useEffect(() => { fetchAnalytics() @@ -200,21 +224,54 @@ export default function Analytics() { Try Another -
- - +
+ {dateRange === 'custom' && ( +
+
+ From + setStartDate(e.target.value)} + className="bg-transparent border-none p-0 text-xs font-bold focus:ring-0 cursor-pointer text-gray-700 h-4" + /> +
+
+
+ To + setEndDate(e.target.value)} + className="bg-transparent border-none p-0 text-xs font-bold focus:ring-0 cursor-pointer text-gray-700 h-4" + /> +
+
+ )} +
+ + + + +
+
{/* Overview Cards */} @@ -343,41 +400,44 @@ export default function Analytics() { Devices -
+
{deviceData.length > 0 ? ( -
- - - - {deviceData.map((_, index) => ( - - ))} - - - - +
+
+ + + + {deviceData.map((_, index) => ( + + ))} + + + + +
{/* Legend */} -
+
{deviceData.map((entry, index) => (
-
-
- {entry.name.toLowerCase()} +
+
+ {entry.name.toLowerCase()}
- {entry.value} + {entry.value}
))}
+ ) : (

No device data

)} From 6b53267b48be2c534fa75f0b93c65a2552e73f13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=C6=B0=C6=A1ng=20L=C3=AA=20Anh=20V=C5=A9?= Date: Tue, 17 Mar 2026 11:24:51 +0700 Subject: [PATCH 04/11] fix: enhance date input handling in analytics with seconds precision and improve layout consistency --- src/pages/Analytics.tsx | 386 +++++++++++++++++++++------------------- 1 file changed, 202 insertions(+), 184 deletions(-) diff --git a/src/pages/Analytics.tsx b/src/pages/Analytics.tsx index f93bc1e..da4fa6c 100644 --- a/src/pages/Analytics.tsx +++ b/src/pages/Analytics.tsx @@ -2,14 +2,14 @@ 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 { + BarChart3, + ChevronLeft, + MousePointerClick, + Users, + Globe, + Smartphone, + Loader2, AlertCircle, ExternalLink, Calendar, @@ -40,21 +40,20 @@ export default function Analytics() { const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState('') const [dateRange, setDateRange] = useState<'30d' | 'all' | 'custom'>('30d') - + // 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())}` + 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 fetchAnalytics = useCallback(async () => { @@ -63,14 +62,14 @@ export default function Analytics() { 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.from = '2024-01-01T00:00:00Z' params.to = new Date().toISOString() } else { // Custom range validation @@ -159,15 +158,15 @@ export default function Analytics() {

Oops! Something went wrong

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

- {isPublicView ? 'Back to Home' : 'Back to Dashboard'} - @@ -187,8 +186,8 @@ export default function Analytics() { {/* Header */}
- @@ -203,9 +202,9 @@ export default function Analytics() {

Detailed tracking for your shortened link - @@ -226,43 +225,62 @@ export default function Analytics() {

{dateRange === 'custom' && ( -
-
- From - +
{ + const input = document.getElementById('analytics-start-date') as HTMLInputElement; + try { input.showPicker(); } catch (e) { input.focus(); } + }} + > + Start Point + setStartDate(e.target.value)} - className="bg-transparent border-none p-0 text-xs font-bold focus:ring-0 cursor-pointer text-gray-700 h-4" + className="bg-transparent border-none p-0 text-xs font-bold focus:ring-0 cursor-pointer text-gray-700 h-5" + style={{ colorScheme: 'light' }} />
-
-
- To - +
{ + const input = document.getElementById('analytics-end-date') as HTMLInputElement; + try { input.showPicker(); } catch (e) { input.focus(); } + }} + > + End Point + setEndDate(e.target.value)} - className="bg-transparent border-none p-0 text-xs font-bold focus:ring-0 cursor-pointer text-gray-700 h-4" + className="bg-transparent border-none p-0 text-xs font-bold focus:ring-0 cursor-pointer text-gray-700 h-5" + style={{ colorScheme: 'light' }} />
)} -
+
+ - - - - -
+
+
{ + const input = document.getElementById('analytics-end-date') as HTMLInputElement; + try { input.showPicker(); } catch (e) { input.focus(); } + }} > - Custom - + End Point + setEndDate(e.target.value)} + className="bg-transparent border-none p-0 text-xs font-bold focus:ring-0 cursor-pointer text-gray-700 h-5" + style={{ colorScheme: 'light' }} + /> +
+ )} + +
+ + +
-
- {/* Overview Cards */} -
-
-
- + {/* Errors / Status Area */} + {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... +
+ )} + + {/* Main Stats Area */} + {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.

-
+ ) : ( +
+ {/* Overview Grid */} +
+
+
+
+ +
+

Total Clicks

+

{stats.totalClicks.toLocaleString()}

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

- - Click Performance -

-

Daily interaction trends

+
+
+
+ +
+

Unique Visitors

+

{stats.uniqueVisitors.toLocaleString()}

-
- - Live + +
+
+
+ +
+

Top Region

+

{topCountry}

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

No tracking data for this period

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

Primary Device

+

{topDevice.toLowerCase()}

+
-
- {/* Side panels */} -
- {/* Device Breakdown */} -
-

- - Devices -

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

+ + Click Performance +

+

Daily engagement timeline

+
+
+ + Live Flow
+
- ) : ( -

No device data

- )} +
+ {chartData.length > 0 ? ( + + + + + + + + + + + + + + + + ) : ( +
+ +

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 +

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

No referrer data

+ ) : ( +

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

+
+ )} +
+
-
+ )}
) } From efe799029d0815021e3acf06e185fb0356abbf82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=C6=B0=C6=A1ng=20L=C3=AA=20Anh=20V=C5=A9?= Date: Tue, 17 Mar 2026 11:38:28 +0700 Subject: [PATCH 06/11] feat: add QR code generation feature and link info fetching in Analytics component --- src/pages/Analytics.tsx | 148 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 137 insertions(+), 11 deletions(-) diff --git a/src/pages/Analytics.tsx b/src/pages/Analytics.tsx index 5442a58..8f3d0ef 100644 --- a/src/pages/Analytics.tsx +++ b/src/pages/Analytics.tsx @@ -1,7 +1,9 @@ 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 type { LinkStatsResponse, ApiResponse, UserLinkResponse, PageResponse } from '../types' +import { useAuthStore } from '../store/useAuthStore' +import { toast } from 'react-hot-toast' import { BarChart3, ChevronLeft, @@ -16,7 +18,10 @@ import { Monitor, Tablet, TrendingUp, - Search + Search, + QrCode, + X, + Download } from 'lucide-react' import { XAxis, @@ -36,11 +41,18 @@ 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) @@ -54,6 +66,7 @@ export default function Analytics() { 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 @@ -89,17 +102,48 @@ export default function Analytics() { } 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, token, dateRange, startDate, endDate]) + }, [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] @@ -176,6 +220,16 @@ export default function Analytics() { {/* Controls Area */}
+ {hasPrivilege && ( + + )} +
- {/* Errors / Status Area */} {error && (
@@ -275,7 +328,6 @@ export default function Analytics() {
)} - {/* Main Stats Area */} {isLoading && !stats ? (
@@ -298,7 +350,6 @@ export default function Analytics() {
) : (
- {/* Overview Grid */}
@@ -337,7 +388,6 @@ export default function Analytics() {
- {/* Charts area */}
@@ -412,15 +462,16 @@ export default function Analytics() {
-
+

Devices

-
+ {/* Fixed height container for PieChart */} +
{deviceData.length > 0 ? ( -
-
+
+
)} + + {/* 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! +

+ +
+ )} +
+
+
+ )}
) } From 7ebf295ef55fee81ed2c65c28d9413a7c7a0faf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=C6=B0=C6=A1ng=20L=C3=AA=20Anh=20V=C5=A9?= Date: Tue, 17 Mar 2026 11:47:30 +0700 Subject: [PATCH 07/11] fix: adjust button padding and visibility based on date range selection in Analytics component --- src/pages/Analytics.tsx | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/pages/Analytics.tsx b/src/pages/Analytics.tsx index 8f3d0ef..2b06f99 100644 --- a/src/pages/Analytics.tsx +++ b/src/pages/Analytics.tsx @@ -223,19 +223,22 @@ export default function Analytics() { {hasPrivilege && ( )} + - Try Another + {dateRange !== 'custom' && Try Another} {dateRange === 'custom' && ( @@ -570,15 +573,17 @@ export default function Analytics() { <>
QR Code +
From 1739bc30eac89a2d67e0c9441151b4c47ac26cd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=C6=B0=C6=A1ng=20L=C3=AA=20Anh=20V=C5=A9?= Date: Tue, 17 Mar 2026 11:50:43 +0700 Subject: [PATCH 08/11] fix: enhance custom date range selection with improved layout and datetime formatting in Analytics component --- src/pages/Analytics.tsx | 40 ++++++++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/src/pages/Analytics.tsx b/src/pages/Analytics.tsx index 2b06f99..8c81bc6 100644 --- a/src/pages/Analytics.tsx +++ b/src/pages/Analytics.tsx @@ -242,41 +242,65 @@ export default function Analytics() { {dateRange === 'custom' && ( -
+
+ {/* Start Date */}
{ const input = document.getElementById('analytics-start-date') as HTMLInputElement; try { input.showPicker(); } catch (e) { input.focus(); } }} > - Start Point +
+ 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="bg-transparent border-none p-0 text-xs font-bold focus:ring-0 cursor-pointer text-gray-700 h-5" + 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 +
+ 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="bg-transparent border-none p-0 text-xs font-bold focus:ring-0 cursor-pointer text-gray-700 h-5" + className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" style={{ colorScheme: 'light' }} />
From 60fb9d0aeb1771adf50a9bec26c1f9da7855c74e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=C6=B0=C6=A1ng=20L=C3=AA=20Anh=20V=C5=A9?= Date: Tue, 17 Mar 2026 11:53:46 +0700 Subject: [PATCH 09/11] fix: adjust chart axis domain and disable decimal values in Analytics component --- src/pages/Analytics.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/pages/Analytics.tsx b/src/pages/Analytics.tsx index 8c81bc6..d6271ac 100644 --- a/src/pages/Analytics.tsx +++ b/src/pages/Analytics.tsx @@ -454,6 +454,8 @@ export default function Analytics() { tickLine={false} tick={{ fontSize: 11, fill: '#64748b', fontWeight: 700 }} dx={-5} + domain={[0, 'dataMax + 1']} + allowDecimals={false} /> Date: Wed, 18 Mar 2026 09:07:36 +0700 Subject: [PATCH 10/11] fix: enhance delete confirmation modals for QR codes and links in Dashboard component --- src/pages/Dashboard.tsx | 90 +++++++++++++++++++++++++++++++++-------- 1 file changed, 73 insertions(+), 17 deletions(-) diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index def5495..1068f30 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 @@ -65,8 +67,10 @@ export default function Dashboard() { }) if (data.success) { setLinks(data.data.content) - setTotalPages(data.data.totalPages) - setTotalElements(data.data.totalElements) + // Fallback for snake_case naming if backend uses it + const d = data.data as any; + setTotalPages(d.totalPages ?? d.total_pages ?? 0) + setTotalElements(d.totalElements ?? d.total_elements ?? 0) } } catch { // silently fail — will show empty state @@ -166,7 +170,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 +194,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,14 +279,19 @@ 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 && ( @@ -407,7 +421,9 @@ export default function Dashboard() { My Links -

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

+

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

@@ -509,7 +525,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. +

+
+ +
+ + +
+
+
+ )}
) } From 983f448c5896cb4eeccaf23e06256c1ef83f3b19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=C6=B0=C6=A1ng=20L=C3=AA=20Anh=20V=C5=A9?= Date: Wed, 18 Mar 2026 09:22:36 +0700 Subject: [PATCH 11/11] fix: improve link fetching and pagination handling in Dashboard component --- src/pages/Dashboard.tsx | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 1068f30..856c769 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -66,11 +66,16 @@ export default function Dashboard() { } }) if (data.success) { - setLinks(data.data.content) - // Fallback for snake_case naming if backend uses it const d = data.data as any; - setTotalPages(d.totalPages ?? d.total_pages ?? 0) - setTotalElements(d.totalElements ?? d.total_elements ?? 0) + 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 @@ -741,7 +746,7 @@ export default function Dashboard() {

Delete this link?

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

@@ -754,13 +759,15 @@ export default function Dashboard() { setLinkToDelete(null); } }} - className="w-full py-3 bg-red-600 hover:bg-red-700 text-white rounded-xl font-semibold transition-all flex items-center justify-center gap-2" + disabled={!!deletingCode} + className="w-full py-3 bg-red-600 hover:bg-red-700 disabled:bg-red-300 text-white rounded-xl font-semibold transition-all flex items-center justify-center gap-2" > - - Yes, Delete Link + {deletingCode ? : } + Confirm Delete