From fb816fca1137f068ebcf49b392fffcb1858ef5e6 Mon Sep 17 00:00:00 2001 From: taDuc Date: Thu, 4 Jun 2026 02:48:14 +0700 Subject: [PATCH] feat: implement state-managed interactive map entry and optimize image loading performance --- src/app/layout.tsx | 1 - src/app/page.tsx | 6 ++ src/components/ecommerce/StatisticsChart.tsx | 1 + src/store/features/uiSlice.ts | 22 +++++++ src/store/store.ts | 2 + src/uhm/components/preview/MapPlaceholder.tsx | 12 ++++ .../preview/PublicPreviewClientPage.tsx | 15 +++-- .../preview/PublicPreviewWrapper.tsx | 63 ++++++++++++++++--- 8 files changed, 106 insertions(+), 16 deletions(-) create mode 100644 src/store/features/uiSlice.ts diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 81fda39..a0bf69d 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,7 +1,6 @@ import type { Metadata } from 'next'; import { Inter } from 'next/font/google'; import './globals.css'; -import "flatpickr/dist/flatpickr.css"; import { SidebarProvider } from '@/context/SidebarContext'; import { ThemeProvider } from '@/context/ThemeContext'; import { Toaster } from 'sonner'; diff --git a/src/app/page.tsx b/src/app/page.tsx index c3a3c15..93fa387 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -30,6 +30,10 @@ export default function Page() { src="/images/map_placeholder.webp" alt="Map Background" fetchPriority="high" + loading="eager" + decoding="sync" + width={1920} + height={1080} style={{ position: "absolute", inset: 0, @@ -37,6 +41,8 @@ export default function Page() { height: "100%", objectFit: "cover", zIndex: 0, + backgroundImage: "url('data:image/webp;base64,UklGRmgAAABXRUJQVlA4IFwAAAAQAgCdASoQAAkAAgA0JbACdAD0j9ruBiEAAP71e6Hb3PzxBzEI1XGXkdQ3Wq1ek8XLa1nPPm65FhrFIjmR0%2BxZwNUJBvg15I7CuzvhuunZ%2FUF83IaP8Evo6gAAAA%3D%3D')", + backgroundSize: "cover", }} /> diff --git a/src/components/ecommerce/StatisticsChart.tsx b/src/components/ecommerce/StatisticsChart.tsx index a88e71b..ee91bf1 100644 --- a/src/components/ecommerce/StatisticsChart.tsx +++ b/src/components/ecommerce/StatisticsChart.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef } from "react"; import dynamic from "next/dynamic"; import { ApexOptions } from "apexcharts"; import flatpickr from "flatpickr"; +import "flatpickr/dist/flatpickr.css"; import ChartTab from "../common/ChartTab"; import { CalenderIcon } from "../../icons"; diff --git a/src/store/features/uiSlice.ts b/src/store/features/uiSlice.ts new file mode 100644 index 0000000..a6d34ab --- /dev/null +++ b/src/store/features/uiSlice.ts @@ -0,0 +1,22 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +interface UiState { + mapEntered: boolean; +} + +const initialState: UiState = { + mapEntered: false, +}; + +const uiSlice = createSlice({ + name: 'ui', + initialState, + reducers: { + setMapEntered: (state, action: PayloadAction) => { + state.mapEntered = action.payload; + }, + }, +}); + +export const { setMapEntered } = uiSlice.actions; +export default uiSlice.reducer; diff --git a/src/store/store.ts b/src/store/store.ts index c3c8be0..a043f02 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -1,9 +1,11 @@ import { configureStore } from '@reduxjs/toolkit'; import userReducer from './features/userSlice'; +import uiReducer from './features/uiSlice'; export const store = configureStore({ reducer: { user: userReducer, + ui: uiReducer, }, }); diff --git a/src/uhm/components/preview/MapPlaceholder.tsx b/src/uhm/components/preview/MapPlaceholder.tsx index 1ab18b0..a83e903 100644 --- a/src/uhm/components/preview/MapPlaceholder.tsx +++ b/src/uhm/components/preview/MapPlaceholder.tsx @@ -29,6 +29,10 @@ export default function MapPlaceholder({ isLoaderOnly = true, onEnter }: MapPlac src="/images/map_placeholder.webp" alt="Map Loading Placeholder" fetchPriority="high" + loading="eager" + decoding="sync" + width={1920} + height={1080} style={{ position: "absolute", inset: 0, @@ -37,6 +41,8 @@ export default function MapPlaceholder({ isLoaderOnly = true, onEnter }: MapPlac objectFit: "cover", zIndex: 1, filter: "brightness(0.3) contrast(1.1)", + backgroundImage: "url('data:image/webp;base64,UklGRmgAAABXRUJQVlA4IFwAAAAQAgCdASoQAAkAAgA0JbACdAD0j9ruBiEAAP71e6Hb3PzxBzEI1XGXkdQ3Wq1ek8XLa1nPPm65FhrFIjmR0%2BxZwNUJBvg15I7CuzvhuunZ%2FUF83IaP8Evo6gAAAA%3D%3D')", + backgroundSize: "cover", }} /> @@ -112,6 +118,10 @@ export default function MapPlaceholder({ isLoaderOnly = true, onEnter }: MapPlac src="/images/map_placeholder.webp" alt="Map Background" fetchPriority="high" + loading="eager" + decoding="sync" + width={1920} + height={1080} style={{ position: "absolute", inset: 0, @@ -122,6 +132,8 @@ export default function MapPlaceholder({ isLoaderOnly = true, onEnter }: MapPlac transform: "scale(1.02)", filter: "brightness(0.25) contrast(1.15) sepia(0.2)", transition: "transform 10s ease-out", + backgroundImage: "url('data:image/webp;base64,UklGRmgAAABXRUJQVlA4IFwAAAAQAgCdASoQAAkAAgA0JbACdAD0j9ruBiEAAP71e6Hb3PzxBzEI1XGXkdQ3Wq1ek8XLa1nPPm65FhrFIjmR0%2BxZwNUJBvg15I7CuzvhuunZ%2FUF83IaP8Evo6gAAAA%3D%3D')", + backgroundSize: "cover", }} /> diff --git a/src/uhm/components/preview/PublicPreviewClientPage.tsx b/src/uhm/components/preview/PublicPreviewClientPage.tsx index 3063ca2..04dbf4f 100644 --- a/src/uhm/components/preview/PublicPreviewClientPage.tsx +++ b/src/uhm/components/preview/PublicPreviewClientPage.tsx @@ -33,7 +33,12 @@ import { clampYearToFixedRange, TIMELINE_DEBOUNCE_MS } from "@/uhm/lib/utils/tim const CURRENT_YEAR = new Date().getUTCFullYear(); -export default function PublicPreviewClientPage() { +interface PublicPreviewClientPageProps { + userHasEntered: boolean; + onEnter: () => void; +} + +export default function PublicPreviewClientPage({ userHasEntered, onEnter }: PublicPreviewClientPageProps) { const [selectedFeatureIds, setSelectedFeatureIds] = useState<(string | number)[]>([]); const [timelineYear, setTimelineYear] = useState(1000); const [timelineDraftYear, setTimelineDraftYear] = useState(1000); @@ -454,13 +459,13 @@ export default function PublicPreviewClientPage() { position: "fixed", inset: 0, zIndex: 9999, - pointerEvents: isMapLoaded && isBackgroundVisibilityReady && loadInteractiveMap ? "none" : "auto", - opacity: isMapLoaded && isBackgroundVisibilityReady && loadInteractiveMap ? 0 : 1, - visibility: isMapLoaded && isBackgroundVisibilityReady && loadInteractiveMap ? "hidden" : "visible", + pointerEvents: userHasEntered && isMapLoaded && isBackgroundVisibilityReady && loadInteractiveMap ? "none" : "auto", + opacity: userHasEntered && isMapLoaded && isBackgroundVisibilityReady && loadInteractiveMap ? 0 : 1, + visibility: userHasEntered && isMapLoaded && isBackgroundVisibilityReady && loadInteractiveMap ? "hidden" : "visible", transition: "opacity 0.8s cubic-bezier(0.4, 0, 0.2, 1), visibility 0.8s", }} > - + {linkEntityPopup ? ( diff --git a/src/uhm/components/preview/PublicPreviewWrapper.tsx b/src/uhm/components/preview/PublicPreviewWrapper.tsx index 38ee203..eeaf8d3 100644 --- a/src/uhm/components/preview/PublicPreviewWrapper.tsx +++ b/src/uhm/components/preview/PublicPreviewWrapper.tsx @@ -2,45 +2,88 @@ import { useState, useEffect } from "react"; import dynamic from "next/dynamic"; +import { useDispatch, useSelector } from "react-redux"; +import { RootState } from "@/store/store"; +import { setMapEntered } from "@/store/features/uiSlice"; import MapPlaceholder from "./MapPlaceholder"; +// Loader component to handle dynamic chunk loading with Redux support +const LoaderComponent = () => { + const mapEntered = useSelector((state: RootState) => state.ui.mapEntered); + const dispatch = useDispatch(); + + const handleEnter = () => { + dispatch(setMapEntered(true)); + }; + + return ( + + ); +}; + const PublicPreviewClientPage = dynamic( () => import("./PublicPreviewClientPage"), { ssr: false, - loading: () => , + loading: () => , } ); export default function PublicPreviewWrapper() { - const [loadInteractive, setLoadInteractive] = useState(false); + const mapEntered = useSelector((state: RootState) => state.ui.mapEntered); + const dispatch = useDispatch(); + + const [loadInteractive, setLoadInteractive] = useState(() => mapEntered); + + const handleEnter = () => { + dispatch(setMapEntered(true)); + setLoadInteractive(true); + }; useEffect(() => { + if (mapEntered) return; + const handleInteraction = () => { - setLoadInteractive(true); + handleEnter(); }; window.addEventListener("click", handleInteraction, { passive: true, once: true }); window.addEventListener("touchstart", handleInteraction, { passive: true, once: true }); window.addEventListener("keydown", handleInteraction, { passive: true, once: true }); - // Tải trước (prefetch) file JS của bản đồ sau 1.5s để lưu vào cache trình duyệt - // Giúp người dùng click vào là có bản đồ ngay mà không làm giảm điểm Lighthouse lúc đầu + // Tải ngầm bản đồ sau 2.5 giây khi trình duyệt rảnh rỗi (idle) + let idleId: any = null; const prefetchTimer = setTimeout(() => { - import("./PublicPreviewClientPage").catch(() => {}); - }, 1500); + const runPrefetch = () => { + setLoadInteractive(true); + }; + + if (typeof window !== "undefined") { + if ("requestIdleCallback" in window) { + idleId = (window as any).requestIdleCallback(runPrefetch, { timeout: 3000 }); + } else { + setTimeout(runPrefetch, 200); + } + } + }, 2000); return () => { window.removeEventListener("click", handleInteraction); window.removeEventListener("touchstart", handleInteraction); window.removeEventListener("keydown", handleInteraction); clearTimeout(prefetchTimer); + if (idleId && typeof window !== "undefined" && "cancelIdleCallback" in window) { + (window as any).cancelIdleCallback(idleId); + } }; - }, []); + }, [mapEntered]); if (!loadInteractive) { - return setLoadInteractive(true)} />; + return ; } - return ; + return ; }