feat: implement parallax background effects and add CountUp and Typewriter components

This commit is contained in:
tuyenio
2026-06-26 11:18:34 +07:00
parent 5cf7726372
commit ea402e4422
9 changed files with 460 additions and 43 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ export default function AntiquityPage() {
<SiteHeader /> <SiteHeader />
<section className="public-subpage-hero public-subpage-hero--compact relative isolate overflow-hidden border-b border-[var(--surface-border)]"> <section className="public-subpage-hero public-subpage-hero--compact relative isolate overflow-hidden border-b border-[var(--surface-border)]">
<Image src={activeTour.shrineImage} alt="" fill priority sizes="100vw" className="public-subpage-hero-image object-cover" /> <Image src={activeTour.shrineImage} alt="" fill priority sizes="100vw" className="public-subpage-hero-image public-parallax-bg object-cover" />
<div className="public-subpage-hero-content mx-auto max-w-7xl px-4 py-16 sm:px-6 lg:py-22"> <div className="public-subpage-hero-content mx-auto max-w-7xl px-4 py-16 sm:px-6 lg:py-22">
<div className="public-rise public-hero-rule max-w-4xl"> <div className="public-rise public-hero-rule max-w-4xl">
<p className="public-kicker">Phòng trưng bày số</p> <p className="public-kicker">Phòng trưng bày số</p>
+59
View File
@@ -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<HTMLSpanElement>(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 (
<span ref={ref} className={className}>
{display}
{suffix}
</span>
);
}
+65
View File
@@ -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 (
<span className={className}>
{text}
<span className="public-typewriter-caret" aria-hidden="true" />
</span>
);
}
@@ -27,7 +27,7 @@ export function HeroShaderBackground({ children, imageSrc }: HeroShaderBackgroun
> >
<div <div
aria-hidden aria-hidden
className="absolute inset-0 bg-cover bg-center opacity-[0.58] saturate-[1.12] contrast-[1.04]" className="public-parallax-bg absolute inset-0 bg-cover bg-center opacity-[0.58] saturate-[1.12] contrast-[1.04]"
style={{ backgroundImage: `url("${imageSrc}")` }} style={{ backgroundImage: `url("${imageSrc}")` }}
/> />
<div aria-hidden className="absolute inset-0 bg-[rgb(243_247_240_/_0.12)]" /> <div aria-hidden className="absolute inset-0 bg-[rgb(243_247_240_/_0.12)]" />
+36 -21
View File
@@ -29,6 +29,19 @@ export const activeTour = {
duration: "15-20 phút", 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 = [ export const navigation = [
{ href: "/", label: "Trang chủ" }, { href: "/", label: "Trang chủ" },
{ href: "/gioi-thieu", label: "Giới thiệu" }, { href: "/gioi-thieu", label: "Giới thiệu" },
@@ -99,13 +112,13 @@ export type HeritageSite = {
location?: string; location?: string;
}; };
function nationalStub(id: string, name: string, type: string, desc: string): HeritageSite { function nationalStub(id: string, name: string, type: string, imageFile: string, desc: string): HeritageSite {
return stub(id, name, type, "Cấp quốc gia", desc); return stub(id, name, type, "Cấp quốc gia", imageFile, desc);
} }
function provincialStub(id: string, name: string, type: string, desc: string): HeritageSite { function provincialStub(id: string, name: string, type: string, imageFile: string, desc: string): HeritageSite {
return stub(id, name, type, "Cấp tỉnh", desc); 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 { return {
id, id,
name, name,
@@ -113,7 +126,9 @@ function stub(id: string, name: string, type: string, level: HeritageLevel, desc
level, level,
status: "Đang bổ sung tư liệu", status: "Đang bổ sung tư liệu",
shortDescription: desc, 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}`, detailUrl: `/di-tich/${id}`,
isAvailable: false, isAvailable: false,
location: "Xã Như Quỳnh, tỉnh Hưng Yên", location: "Xã Như Quỳnh, tỉnh Hưng Yên",
@@ -140,32 +155,32 @@ export const heritageSites: HeritageSite[] = [
isAvailable: true, isAvailable: true,
location: "Xã Như Quỳnh, tỉnh Hưng Yên", 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."), "Đề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."), "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."), "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."), "Đì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."), "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."), "Đì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."), "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."), "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."), "Đì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."), "Đì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."), "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", 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\"."), "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("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."), "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."), "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."),
]; ];
+1 -1
View File
@@ -26,7 +26,7 @@ export default function HeritageListPage() {
fill fill
priority priority
sizes="100vw" sizes="100vw"
className="public-subpage-hero-image object-cover" className="public-subpage-hero-image public-parallax-bg object-cover"
/> />
<div className="public-subpage-hero-content mx-auto grid max-w-7xl gap-10 px-4 py-16 sm:px-6 lg:min-h-[580px] lg:grid-cols-[0.95fr_0.7fr] lg:items-center lg:py-22"> <div className="public-subpage-hero-content mx-auto grid max-w-7xl gap-10 px-4 py-16 sm:px-6 lg:min-h-[580px] lg:grid-cols-[0.95fr_0.7fr] lg:items-center lg:py-22">
+21 -17
View File
@@ -14,6 +14,7 @@ import {
Users, Users,
} from "lucide-react"; } from "lucide-react";
import { BackToTopButton } from "@/app/components/landing/BackToTopButton"; import { BackToTopButton } from "@/app/components/landing/BackToTopButton";
import { CountUp } from "@/app/components/landing/CountUp";
import { SiteFooter } from "@/app/components/landing/SiteFooter"; import { SiteFooter } from "@/app/components/landing/SiteFooter";
import { SiteHeader } from "@/app/components/landing/SiteHeader"; import { SiteHeader } from "@/app/components/landing/SiteHeader";
import { activeTour, antiquities, heritageSites, img } from "@/app/content"; import { activeTour, antiquities, heritageSites, img } from "@/app/content";
@@ -60,7 +61,7 @@ export default function AboutPage() {
<SiteHeader /> <SiteHeader />
<section className="public-subpage-hero public-subpage-hero--compact relative isolate overflow-hidden border-b border-[var(--surface-border)]"> <section className="public-subpage-hero public-subpage-hero--compact relative isolate overflow-hidden border-b border-[var(--surface-border)]">
<Image src={activeTour.gateImage} alt="" fill priority sizes="100vw" className="public-subpage-hero-image object-cover" /> <Image src={activeTour.gateImage} alt="" fill priority sizes="100vw" className="public-subpage-hero-image public-parallax-bg object-cover" />
<div className="public-subpage-hero-content mx-auto max-w-7xl px-4 py-16 sm:px-6 lg:py-22"> <div className="public-subpage-hero-content mx-auto max-w-7xl px-4 py-16 sm:px-6 lg:py-22">
<div className="public-rise public-hero-rule max-w-4xl"> <div className="public-rise public-hero-rule max-w-4xl">
<p className="public-kicker">Giới thiệu</p> <p className="public-kicker">Giới thiệu</p>
@@ -98,7 +99,7 @@ export default function AboutPage() {
{stats.map((s) => ( {stats.map((s) => (
<div key={s.label} className="public-panel public-stagger-item rounded-[14px] p-6 text-center"> <div key={s.label} className="public-panel public-stagger-item rounded-[14px] p-6 text-center">
<p className="public-gradient-text font-display-vn text-[2.6rem] font-extrabold leading-none [font-variant-numeric:tabular-nums]"> <p className="public-gradient-text font-display-vn text-[2.6rem] font-extrabold leading-none [font-variant-numeric:tabular-nums]">
{s.value} <CountUp value={Number(s.value)} />
</p> </p>
<p className="mt-3 text-sm leading-6 text-[var(--muted-foreground)]">{s.label}</p> <p className="mt-3 text-sm leading-6 text-[var(--muted-foreground)]">{s.label}</p>
</div> </div>
@@ -142,21 +143,24 @@ export default function AboutPage() {
Từ liệu đến trải nghiệm số Từ liệu đến trải nghiệm số
</h2> </h2>
</div> </div>
<ol className="mt-10 grid gap-4 md:grid-cols-2 xl:grid-cols-4"> <div className="public-timeline relative mt-10">
{steps.map((step, index) => { <span className="public-timeline-line hidden xl:block" aria-hidden="true" />
const Icon = step.icon; <ol className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
return ( {steps.map((step, index) => {
<li key={step.title} className="public-panel public-stagger-item relative rounded-[16px] p-6"> const Icon = step.icon;
<span className="font-display-vn text-sm font-extrabold tracking-[0.2em] text-[var(--primary)]"> return (
{String(index + 1).padStart(2, "0")} <li key={step.title} className="public-panel public-stagger-item relative rounded-[16px] p-6">
</span> <span className="font-display-vn text-sm font-extrabold tracking-[0.2em] text-[var(--primary)]">
<Icon className="mt-4 h-7 w-7 text-[var(--tour-jade)]" strokeWidth={1.7} /> {String(index + 1).padStart(2, "0")}
<h3 className="mt-4 text-lg font-bold text-[var(--tour-ink)]">{step.title}</h3> </span>
<p className="mt-2 text-sm leading-6 text-[var(--muted-foreground)]">{step.detail}</p> <Icon className="mt-4 h-7 w-7 text-[var(--tour-jade)]" strokeWidth={1.7} />
</li> <h3 className="mt-4 text-lg font-bold text-[var(--tour-ink)]">{step.title}</h3>
); <p className="mt-2 text-sm leading-6 text-[var(--muted-foreground)]">{step.detail}</p>
})} </li>
</ol> );
})}
</ol>
</div>
</div> </div>
</section> </section>
+223
View File
@@ -1344,3 +1344,226 @@ a {
transform: translate3d(-50%, 0, 0); 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;
}
}
+53 -2
View File
@@ -6,8 +6,9 @@ import { BackToTopButton } from "@/app/components/landing/BackToTopButton";
import { HeritageMarquee } from "@/app/components/landing/HeritageMarquee"; import { HeritageMarquee } from "@/app/components/landing/HeritageMarquee";
import { SiteFooter } from "@/app/components/landing/SiteFooter"; import { SiteFooter } from "@/app/components/landing/SiteFooter";
import { SiteHeader } from "@/app/components/landing/SiteHeader"; import { SiteHeader } from "@/app/components/landing/SiteHeader";
import { Typewriter } from "@/app/components/landing/Typewriter";
import { HeroShaderBackground } from "@/app/components/ui/hero-shader-background"; 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 = { export const metadata: Metadata = {
title: "Trang chủ | VR360 Như Quỳnh", title: "Trang chủ | VR360 Như Quỳnh",
@@ -56,6 +57,19 @@ export default function Home() {
<p className="mt-6 max-w-[58ch] text-base leading-8 text-[var(--foreground)]/82 sm:text-lg"> <p className="mt-6 max-w-[58ch] text-base leading-8 text-[var(--foreground)]/82 sm:text-lg">
Hành trình số hóa hệ thống di tích, không gian văn hóa giá trị truyền thống của Như Quỳnh, tỉnh Hưng Yên bằng công nghệ tham quan thực tế o 360 đ. Hành trình số hóa hệ thống di tích, không gian văn hóa giá trị truyền thống của Như Quỳnh, tỉnh Hưng Yên bằng công nghệ tham quan thực tế o 360 đ.
</p> </p>
<p className="mt-5 flex flex-wrap items-baseline gap-x-2 gap-y-1 text-base font-semibold sm:text-lg">
<span className="text-[var(--muted-foreground)]">Khám phá</span>
<Typewriter
words={[
"đình làng cổ kính",
"ngôi chùa thời Trần",
"đền thờ Nguyên phi Ỷ Lan",
"văn miếu khoa bảng",
"cổ vật số hóa 3D",
]}
className="font-bold text-[var(--primary)]"
/>
</p>
<div className="mt-8 flex flex-wrap items-center gap-3"> <div className="mt-8 flex flex-wrap items-center gap-3">
<Link href="/di-tich" className="public-cta"> <Link href="/di-tich" className="public-cta">
<PlayCircle className="h-4 w-4" strokeWidth={1.8} /> <PlayCircle className="h-4 w-4" strokeWidth={1.8} />
@@ -82,6 +96,43 @@ export default function Home() {
<HeritageMarquee /> <HeritageMarquee />
{/* Không gian tiêu biểu — gallery cuộn ngang, khoe ảnh thật của di tích */}
<section className="mx-auto max-w-7xl px-4 py-14 sm:px-6 lg:py-20">
<div className="public-split-reveal max-w-3xl rounded-[6px]">
<p className="public-kicker">Không gian tiêu biểu</p>
<h2 className="public-display public-heading-safe mt-3 text-[2rem] font-bold text-[var(--tour-ink)] sm:text-4xl">
Dạo qua từng không gian của Chùa Thái Lạc
</h2>
<p className="mt-3 max-w-[64ch] text-sm leading-6 text-[var(--muted-foreground)] sm:text-base">
Kéo ngang đ lướt qua cổng tam quan, sân chùa, hành lang tượng thượng điện tất cả hình nh thật đưc số hóa.
</p>
</div>
<div className="public-hscroll mt-9">
{featuredSpaces.map((space) => (
<Link key={space.file} href={activeTour.href} className="public-gallery-card group">
<div className="relative aspect-[3/4] w-full overflow-hidden rounded-[14px]">
<Image
src={img(space.file)}
alt={space.title}
fill
sizes="(max-width: 640px) 72vw, 21rem"
className="object-cover"
/>
<div
aria-hidden
className="absolute inset-0 bg-[linear-gradient(180deg,transparent_36%,rgb(8_31_23_/_0.8))]"
/>
<div className="absolute inset-x-0 bottom-0 p-5">
<p className="text-lg font-extrabold leading-tight text-white drop-shadow-sm">{space.title}</p>
<p className="mt-1 text-sm leading-5 text-white/85">{space.note}</p>
</div>
</div>
</Link>
))}
</div>
</section>
<section className="mx-auto grid max-w-7xl gap-10 px-4 py-14 sm:px-6 lg:grid-cols-[0.8fr_1.2fr] lg:items-center lg:py-20"> <section className="mx-auto grid max-w-7xl gap-10 px-4 py-14 sm:px-6 lg:grid-cols-[0.8fr_1.2fr] lg:items-center lg:py-20">
<div className="min-w-0"> <div className="min-w-0">
<p className="public-kicker">Giới thiệu nhanh</p> <p className="public-kicker">Giới thiệu nhanh</p>
@@ -190,7 +241,7 @@ export default function Home() {
<section className="border-y border-[var(--surface-border)] bg-[var(--surface-band)]"> <section className="border-y border-[var(--surface-border)] bg-[var(--surface-band)]">
<div className="mx-auto max-w-7xl px-4 py-14 sm:px-6 lg:py-20"> <div className="mx-auto max-w-7xl px-4 py-14 sm:px-6 lg:py-20">
<div className="max-w-3xl"> <div className="public-split-reveal max-w-3xl rounded-[6px]">
<p className="public-kicker">Điểm nổi bật</p> <p className="public-kicker">Điểm nổi bật</p>
<h2 className="public-heading-safe mt-3 text-[2rem] font-bold text-[var(--tour-ink)] sm:text-4xl"> <h2 className="public-heading-safe mt-3 text-[2rem] font-bold text-[var(--tour-ink)] sm:text-4xl">
Giá trị của nền tảng VR360. Giá trị của nền tảng VR360.