diff --git a/src/app/co-vat/page.tsx b/src/app/co-vat/page.tsx index d019e9e..1635669 100644 --- a/src/app/co-vat/page.tsx +++ b/src/app/co-vat/page.tsx @@ -25,7 +25,7 @@ export default function AntiquityPage() {
- +

Phòng trưng bày số

diff --git a/src/app/components/landing/CountUp.tsx b/src/app/components/landing/CountUp.tsx new file mode 100644 index 0000000..e3863d6 --- /dev/null +++ b/src/app/components/landing/CountUp.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +// Số liệu đếm lên từ 0 khi cuộn tới (IntersectionObserver kích hoạt 1 lần, rAF chạy +// mượt theo khung hình). Tôn trọng prefers-reduced-motion: hiện thẳng giá trị cuối. +// SSR render sẵn giá trị cuối nên không nhảy layout / tốt cho SEO khi chưa cuộn tới. +export function CountUp({ + value, + duration = 1400, + className, + suffix = "", +}: { + value: number; + duration?: number; + className?: string; + suffix?: string; +}) { + const ref = useRef(null); + const [display, setDisplay] = useState(value); + const startedRef = useRef(false); + + useEffect(() => { + const el = ref.current; + if (!el) return; + + // Giảm chuyển động: giữ nguyên giá trị cuối (đã là `value` từ useState ban đầu). + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; + + const observer = new IntersectionObserver( + (entries) => { + if (!entries[0]?.isIntersecting || startedRef.current) return; + startedRef.current = true; + observer.disconnect(); + + const start = performance.now(); + const tick = (now: number) => { + const progress = Math.min((now - start) / duration, 1); + const eased = 1 - Math.pow(1 - progress, 3); // easeOutCubic + setDisplay(Math.round(value * eased)); + if (progress < 1) requestAnimationFrame(tick); + }; + setDisplay(0); + requestAnimationFrame(tick); + }, + { threshold: 0.4 }, + ); + + observer.observe(el); + return () => observer.disconnect(); + }, [value, duration]); + + return ( + + {display} + {suffix} + + ); +} diff --git a/src/app/components/landing/Typewriter.tsx b/src/app/components/landing/Typewriter.tsx new file mode 100644 index 0000000..55a2014 --- /dev/null +++ b/src/app/components/landing/Typewriter.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +// Chữ tự gõ rồi xóa, lặp qua danh sách cụm từ — tạo điểm nhấn kể chuyện ở hero. +// Tôn trọng prefers-reduced-motion: hiển thị tĩnh cụm từ đầu (không gõ). +// Một effect tự lên lịch: mọi setState diễn ra TRONG callback của setTimeout nên +// không gây "cascading renders" (đúng khuyến nghị react-hooks). +export function Typewriter({ + words, + typingSpeed = 95, + deletingSpeed = 45, + pause = 1600, + className, +}: { + words: string[]; + typingSpeed?: number; + deletingSpeed?: number; + pause?: number; + className?: string; +}) { + const [text, setText] = useState(words[0] ?? ""); + const stateRef = useRef({ wordIndex: 0, deleting: false }); + + useEffect(() => { + if (words.length <= 1) return; + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; + + const s = stateRef.current; + const current = words[s.wordIndex % words.length]; + + let delay: number; + if (!s.deleting && text === current) { + delay = pause; // gõ xong cụm hiện tại → dừng một nhịp rồi xóa + } else { + delay = s.deleting ? deletingSpeed : typingSpeed; + } + + const timer = setTimeout(() => { + const word = words[s.wordIndex % words.length]; + + if (!s.deleting && text === word) { + s.deleting = true; + setText(word.slice(0, Math.max(word.length - 1, 0))); + } else if (s.deleting && text === "") { + s.deleting = false; + s.wordIndex = (s.wordIndex + 1) % words.length; + setText(words[s.wordIndex % words.length].slice(0, 1)); + } else { + setText((prev) => + s.deleting ? word.slice(0, prev.length - 1) : word.slice(0, prev.length + 1), + ); + } + }, delay); + + return () => clearTimeout(timer); + }, [text, words, typingSpeed, deletingSpeed, pause]); + + return ( + + {text} + + ); +} diff --git a/src/app/components/ui/hero-shader-background.tsx b/src/app/components/ui/hero-shader-background.tsx index bcae720..9c579eb 100644 --- a/src/app/components/ui/hero-shader-background.tsx +++ b/src/app/components/ui/hero-shader-background.tsx @@ -27,7 +27,7 @@ export function HeroShaderBackground({ children, imageSrc }: HeroShaderBackgroun >
diff --git a/src/app/content.ts b/src/app/content.ts index a10e327..97b6088 100644 --- a/src/app/content.ts +++ b/src/app/content.ts @@ -29,6 +29,19 @@ export const activeTour = { duration: "15-20 phút", }; +// Dải ảnh "không gian tiêu biểu" cho gallery cuộn ngang ở trang chủ — ảnh THẬT của +// Chùa Thái Lạc kèm chú thích đúng không gian, trình bày trong khung kính cao cấp. +export const featuredSpaces = [ + { file: "01.jpg", title: "Tam quan", note: "Cổng chùa chồng diêm hai tầng tám mái" }, + { file: "07.jpg", title: "Hồ Di Lặc", note: "Tượng Phật Di Lặc bên hồ cá Koi" }, + { file: "08.jpg", title: "Tiền sảnh", note: "Sân lát đá trước gác chuông" }, + { file: "10.jpg", title: "Tôn đài Đức Phật Thích Ca", note: "Đài lộ thiên giữa sân chùa" }, + { file: "11.jpg", title: "Vườn tháp cổ", note: "Những bảo tháp gạch trần cổ kính" }, + { file: "18.jpg", title: "Hành lang La Hán", note: "Dãy tượng La Hán dọc hành lang gỗ" }, + { file: "24.jpg", title: "Tiền đường", note: "Vì kèo chạm khắc gỗ thời Trần" }, + { file: "25.jpg", title: "Thượng điện", note: "Khám thờ gỗ và hệ cột cổ" }, +] as const; + export const navigation = [ { href: "/", label: "Trang chủ" }, { href: "/gioi-thieu", label: "Giới thiệu" }, @@ -99,13 +112,13 @@ export type HeritageSite = { location?: string; }; -function nationalStub(id: string, name: string, type: string, desc: string): HeritageSite { - return stub(id, name, type, "Cấp quốc gia", desc); +function nationalStub(id: string, name: string, type: string, imageFile: string, desc: string): HeritageSite { + return stub(id, name, type, "Cấp quốc gia", imageFile, desc); } -function provincialStub(id: string, name: string, type: string, desc: string): HeritageSite { - return stub(id, name, type, "Cấp tỉnh", desc); +function provincialStub(id: string, name: string, type: string, imageFile: string, desc: string): HeritageSite { + return stub(id, name, type, "Cấp tỉnh", imageFile, desc); } -function stub(id: string, name: string, type: string, level: HeritageLevel, desc: string): HeritageSite { +function stub(id: string, name: string, type: string, level: HeritageLevel, imageFile: string, desc: string): HeritageSite { return { id, name, @@ -113,7 +126,9 @@ function stub(id: string, name: string, type: string, level: HeritageLevel, desc level, status: "Đang bổ sung tư liệu", shortDescription: desc, - image: img("08.jpg"), // ảnh tạm (Chính sảnh) dùng chung cho các di tích chưa có tư liệu + // Ảnh đại diện tạm — chọn các góc khác nhau từ kho 26 ảnh để lưới di tích không bị + // lặp một ảnh. Các di tích này chưa số hóa riêng nên dùng ảnh không gian tiêu biểu. + image: img(imageFile), detailUrl: `/di-tich/${id}`, isAvailable: false, location: "Xã Như Quỳnh, tỉnh Hưng Yên", @@ -140,32 +155,32 @@ export const heritageSites: HeritageSite[] = [ isAvailable: true, location: "Xã Như Quỳnh, tỉnh Hưng Yên", }, - nationalStub("den-ghenh", "Đền Ghênh (Đền Ỷ Lan)", "Đền", + nationalStub("den-ghenh", "Đền Ghênh (Đền Ỷ Lan)", "Đền", "02.jpg", "Đền thờ Nguyên phi Ỷ Lan, gắn với truyền thống tôn vinh người phụ nữ có công với dân với nước."), - nationalStub("tu-vu-ho-truong", "Từ Vũ họ Trương", "Di tích khác", + nationalStub("tu-vu-ho-truong", "Từ Vũ họ Trương", "Di tích khác", "05.jpg", "Từ đường dòng họ Trương, di tích phản ánh truyền thống dòng tộc và tín ngưỡng thờ tổ tiên của địa phương."), - nationalStub("bai-da-den-tu-vu", "Bãi đá đền Từ Vũ", "Di tích khác", + nationalStub("bai-da-den-tu-vu", "Bãi đá đền Từ Vũ", "Di tích khác", "06.jpg", "Cụm di tích khảo cổ và kiến trúc đá gắn với đền Từ Vũ, mang giá trị lịch sử và văn hóa lâu đời."), - nationalStub("dinh-at", "Đình Ất", "Đình", + nationalStub("dinh-at", "Đình Ất", "Đình", "09.jpg", "Đình làng cổ, không gian sinh hoạt tín ngưỡng và cộng đồng tiêu biểu của làng quê Như Quỳnh."), - nationalStub("chua-hanh-lac", "Chùa Hành Lạc", "Chùa", + nationalStub("chua-hanh-lac", "Chùa Hành Lạc", "Chùa", "10.jpg", "Ngôi chùa làng cổ kính, trung tâm sinh hoạt Phật giáo và văn hóa tâm linh của cộng đồng."), - nationalStub("dinh-hanh-lac", "Đình Hành Lạc", "Đình", + nationalStub("dinh-hanh-lac", "Đình Hành Lạc", "Đình", "07.jpg", "Đình làng gắn với tín ngưỡng thờ Thành hoàng và lễ hội truyền thống của địa phương."), - nationalStub("chua-ong", "Chùa Ông", "Chùa", + nationalStub("chua-ong", "Chùa Ông", "Chùa", "24.jpg", "Di tích Phật giáo cổ, lưu giữ nhiều giá trị kiến trúc và hiện vật tín ngưỡng."), - nationalStub("chua-vinh-thai", "Chùa Vĩnh Thái", "Chùa", + nationalStub("chua-vinh-thai", "Chùa Vĩnh Thái", "Chùa", "25.jpg", "Ngôi chùa cổ mang đậm dấu ấn kiến trúc và tín ngưỡng truyền thống vùng Như Quỳnh."), - provincialStub("dinh-le-xa", "Đình Lê Xá", "Đình", + provincialStub("dinh-le-xa", "Đình Lê Xá", "Đình", "15.jpg", "Đình làng Lê Xá, không gian thờ tự và sinh hoạt cộng đồng giàu giá trị văn hóa."), - provincialStub("dinh-ngo-xuyen", "Đình Ngô Xuyên", "Đình", + provincialStub("dinh-ngo-xuyen", "Đình Ngô Xuyên", "Đình", "16.jpg", "Đình làng Ngô Xuyên gắn với truyền thống tín ngưỡng và lễ hội địa phương."), - provincialStub("chua-tu-kinh", "Chùa Từ Kính", "Chùa", + provincialStub("chua-tu-kinh", "Chùa Từ Kính", "Chùa", "26.jpg", "Ngôi chùa cổ, điểm sinh hoạt Phật giáo và văn hóa tâm linh của cộng đồng."), - provincialStub("cung-chi-nguyen", "Cung Chí Nguyên", "Di tích khác", - "Di tích gắn với nhà văn Thạch Lam, nơi khơi nguồn cảm hứng cho truyện ngắn \"Dưới bóng hoàng lan\"."), - provincialStub("van-mieu-nhu-quynh", "Văn miếu Như Quỳnh", "Văn miếu", + provincialStub("cung-chi-nguyen", "Cung Chí Nguyên", "Di tích khác", "17.jpg", + "Di tích gắn với nhà văn Thạch Lam, nơi khơi nguồn cảm hứng cho truyện ngắn “Dưới bóng hoàng lan”."), + provincialStub("van-mieu-nhu-quynh", "Văn miếu Như Quỳnh", "Văn miếu", "19.jpg", "Văn miếu tôn vinh truyền thống hiếu học và khoa bảng của vùng đất Như Quỳnh."), - provincialStub("dau-tich-chua-la", "Dấu tích Chùa Lá", "Chùa", + provincialStub("dau-tich-chua-la", "Dấu tích Chùa Lá", "Chùa", "18.jpg", "Dấu tích ngôi chùa lớn, nổi tiếng trước Cách mạng tháng Tám, mang giá trị lịch sử sâu sắc."), ]; diff --git a/src/app/di-tich/page.tsx b/src/app/di-tich/page.tsx index 4c6a8d2..93c8db3 100644 --- a/src/app/di-tich/page.tsx +++ b/src/app/di-tich/page.tsx @@ -26,7 +26,7 @@ export default function HeritageListPage() { fill priority sizes="100vw" - className="public-subpage-hero-image object-cover" + className="public-subpage-hero-image public-parallax-bg object-cover" />
diff --git a/src/app/gioi-thieu/page.tsx b/src/app/gioi-thieu/page.tsx index 322b659..3310374 100644 --- a/src/app/gioi-thieu/page.tsx +++ b/src/app/gioi-thieu/page.tsx @@ -14,6 +14,7 @@ import { Users, } from "lucide-react"; import { BackToTopButton } from "@/app/components/landing/BackToTopButton"; +import { CountUp } from "@/app/components/landing/CountUp"; import { SiteFooter } from "@/app/components/landing/SiteFooter"; import { SiteHeader } from "@/app/components/landing/SiteHeader"; import { activeTour, antiquities, heritageSites, img } from "@/app/content"; @@ -60,7 +61,7 @@ export default function AboutPage() {
- +

Giới thiệu

@@ -98,7 +99,7 @@ export default function AboutPage() { {stats.map((s) => (

- {s.value} +

{s.label}

@@ -142,21 +143,24 @@ export default function AboutPage() { Từ tư liệu đến trải nghiệm số
-
    - {steps.map((step, index) => { - const Icon = step.icon; - return ( -
  1. - - {String(index + 1).padStart(2, "0")} - - -

    {step.title}

    -

    {step.detail}

    -
  2. - ); - })} -
+
+
diff --git a/src/app/globals.css b/src/app/globals.css index 8f46d67..1511a35 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1344,3 +1344,226 @@ a { transform: translate3d(-50%, 0, 0); } } + +/* ─────────────────────────────────────────────────────────────────────────── + HIỆU ỨNG CHUYỂN ĐỘNG NÂNG CAO (bản "mạnh tay" — vẫn giữ gu di sản) + Tất cả gắn vào animation-timeline: view()/scroll() (gốc trình duyệt, không + tốn JS theo cuộn) nên cực mượt; trình duyệt không hỗ trợ sẽ nhảy thẳng tới + trạng thái cuối (hiển thị đầy đủ) — an toàn, không kẹt nội dung. + ─────────────────────────────────────────────────────────────────────────── */ + +/* 1) Nâng cấp grade ảnh — ấm & sâu hơn nhưng vẫn tự nhiên (giữ ảnh thật). */ +.public-image-stage img, +.public-card img, +.public-media img, +.public-gallery-card img { + filter: saturate(1.12) contrast(1.06) brightness(1.015); +} + +/* 2) SPLIT REVEAL — thanh màu quét ngang tiết lộ nội dung khi cuộn tới. */ +.public-split-reveal { + position: relative; + isolation: isolate; +} + +.public-split-reveal::after { + content: ""; + position: absolute; + inset: 0; + z-index: 5; + background: linear-gradient(90deg, var(--primary), var(--tour-jade) 88%); + transform: scaleX(0); + transform-origin: left center; + pointer-events: none; + border-radius: inherit; +} + +@media (prefers-reduced-motion: no-preference) { + .public-split-reveal::after { + animation: public-split-bar both; + animation-timeline: view(); + animation-range: entry 4% cover 34%; + } + + .public-split-reveal > * { + animation: public-split-content both; + animation-timeline: view(); + animation-range: entry 6% cover 34%; + } +} + +@keyframes public-split-bar { + 0% { + transform: scaleX(0); + transform-origin: left center; + } + 46% { + transform: scaleX(1); + transform-origin: left center; + } + 54% { + transform: scaleX(1); + transform-origin: right center; + } + 100% { + transform: scaleX(0); + transform-origin: right center; + } +} + +@keyframes public-split-content { + 0%, + 42% { + opacity: 0; + transform: translate3d(0, 14px, 0); + } + 64%, + 100% { + opacity: 1; + transform: translate3d(0, 0, 0); + } +} + +/* 3) TIMELINE — đường nối vẽ dần theo cuộn (hàng mốc "Hành trình số hóa"). */ +.public-timeline { + position: relative; +} + +.public-timeline-line { + position: absolute; + left: 1.5rem; + right: 1.5rem; + top: var(--timeline-top, 2.6rem); + height: 2px; + background: linear-gradient(90deg, var(--primary), var(--tour-jade) 64%, transparent); + transform: scaleX(0); + transform-origin: left center; + pointer-events: none; +} + +@media (prefers-reduced-motion: no-preference) { + .public-timeline-line { + animation: public-line-grow both; + animation-timeline: view(); + animation-range: entry 16% cover 70%; + } +} + +@keyframes public-line-grow { + from { + transform: scaleX(0); + } + to { + transform: scaleX(1); + } +} + +/* 4) PARALLAX HERO — lớp ảnh nền trôi chậm hơn nội dung khi cuộn. + Bọc trong @supports để trình duyệt thiếu scroll-timeline giữ bố cục tĩnh. */ +@supports (animation-timeline: scroll()) { + @media (prefers-reduced-motion: no-preference) { + .public-parallax-bg { + animation: public-parallax-drift linear both; + animation-timeline: scroll(root); + animation-range: 0 80vh; + will-change: transform; + } + } +} + +@keyframes public-parallax-drift { + from { + transform: translate3d(0, 0, 0) scale(1.12); + } + to { + transform: translate3d(0, 9%, 0) scale(1.12); + } +} + +/* 5) HORIZONTAL SCROLL — dải cuộn ngang có scroll-snap (thuần CSS, không jank). */ +.public-hscroll { + display: flex; + gap: 1rem; + overflow-x: auto; + overflow-y: hidden; + scroll-snap-type: x mandatory; + scroll-padding-inline: 1rem; + -webkit-overflow-scrolling: touch; + scrollbar-width: thin; + scrollbar-color: var(--primary) transparent; + padding-bottom: 0.9rem; + scroll-behavior: smooth; +} + +.public-hscroll > * { + scroll-snap-align: start; + flex: 0 0 auto; +} + +.public-hscroll::-webkit-scrollbar { + height: 8px; +} + +.public-hscroll::-webkit-scrollbar-track { + background: rgb(25 76 55 / 0.08); + border-radius: 999px; +} + +.public-hscroll::-webkit-scrollbar-thumb { + background: linear-gradient(90deg, var(--primary), var(--tour-jade)); + border-radius: 999px; +} + +.public-gallery-card { + position: relative; + overflow: hidden; + width: clamp(15rem, 72vw, 21rem); + border-radius: 14px; + border: 1px solid var(--surface-border); + background: rgb(14 52 39 / 0.1); + box-shadow: + 0 24px 66px rgb(37 75 54 / 0.16), + inset 0 1px 0 rgb(255 255 255 / 0.4); + transition: + transform 360ms cubic-bezier(0.16, 1, 0.3, 1), + box-shadow 360ms cubic-bezier(0.16, 1, 0.3, 1); +} + +.public-gallery-card:hover { + transform: translateY(-5px); + box-shadow: + 0 34px 92px rgb(37 75 54 / 0.24), + 0 0 0 1px rgb(169 111 18 / 0.24), + inset 0 1px 0 rgb(255 255 255 / 0.5); +} + +.public-gallery-card img { + transition: transform 700ms cubic-bezier(0.16, 1, 0.3, 1); +} + +.public-gallery-card:hover img { + transform: scale(1.06); +} + +/* 6) TYPEWRITER — con trỏ nhấp nháy cho dòng chữ tự gõ ở hero. */ +.public-typewriter-caret { + display: inline-block; + width: 0.5ch; + height: 1.02em; + margin-left: 0.1em; + vertical-align: -0.14em; + background: linear-gradient(180deg, var(--primary), var(--tour-jade)); + border-radius: 1px; + animation: public-caret-blink 1.05s steps(1) infinite; +} + +@keyframes public-caret-blink { + 0%, + 48% { + opacity: 1; + } + 49%, + 100% { + opacity: 0; + } +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 7181917..befbf2c 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -6,8 +6,9 @@ import { BackToTopButton } from "@/app/components/landing/BackToTopButton"; import { HeritageMarquee } from "@/app/components/landing/HeritageMarquee"; import { SiteFooter } from "@/app/components/landing/SiteFooter"; import { SiteHeader } from "@/app/components/landing/SiteHeader"; +import { Typewriter } from "@/app/components/landing/Typewriter"; import { HeroShaderBackground } from "@/app/components/ui/hero-shader-background"; -import { activeTour, heritageSites, img } from "@/app/content"; +import { activeTour, featuredSpaces, heritageSites, img } from "@/app/content"; export const metadata: Metadata = { title: "Trang chủ | VR360 Như Quỳnh", @@ -56,6 +57,19 @@ export default function Home() {

Hành trình số hóa hệ thống di tích, không gian văn hóa và giá trị truyền thống của Xã Như Quỳnh, tỉnh Hưng Yên bằng công nghệ tham quan thực tế ảo 360 độ.

+

+ Khám phá + +

@@ -82,6 +96,43 @@ export default function Home() { + {/* Không gian tiêu biểu — gallery cuộn ngang, khoe ảnh thật của di tích */} +
+
+

Không gian tiêu biểu

+

+ Dạo qua từng không gian của Chùa Thái Lạc +

+

+ Kéo ngang để lướt qua cổng tam quan, sân chùa, hành lang tượng và thượng điện — tất cả là hình ảnh thật được số hóa. +

+
+ +
+ {featuredSpaces.map((space) => ( + +
+ {space.title} +
+
+

{space.title}

+

{space.note}

+
+
+ + ))} +
+
+

Giới thiệu nhanh

@@ -190,7 +241,7 @@ export default function Home() {
-
+

Điểm nổi bật

Giá trị của nền tảng VR360.