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 />
<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-rise public-hero-rule max-w-4xl">
<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
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}")` }}
/>
<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",
};
// 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."),
];
+1 -1
View File
@@ -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"
/>
<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">
+7 -3
View File
@@ -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() {
<SiteHeader />
<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-rise public-hero-rule max-w-4xl">
<p className="public-kicker">Giới thiệu</p>
@@ -98,7 +99,7 @@ export default function AboutPage() {
{stats.map((s) => (
<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]">
{s.value}
<CountUp value={Number(s.value)} />
</p>
<p className="mt-3 text-sm leading-6 text-[var(--muted-foreground)]">{s.label}</p>
</div>
@@ -142,7 +143,9 @@ export default function AboutPage() {
Từ liệu đến trải nghiệm số
</h2>
</div>
<ol className="mt-10 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="public-timeline relative mt-10">
<span className="public-timeline-line hidden xl:block" aria-hidden="true" />
<ol className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{steps.map((step, index) => {
const Icon = step.icon;
return (
@@ -158,6 +161,7 @@ export default function AboutPage() {
})}
</ol>
</div>
</div>
</section>
{/* Featured tour CTA */}
+223
View File
@@ -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;
}
}
+53 -2
View File
@@ -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() {
<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 đ.
</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">
<Link href="/di-tich" className="public-cta">
<PlayCircle className="h-4 w-4" strokeWidth={1.8} />
@@ -82,6 +96,43 @@ export default function Home() {
<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">
<div className="min-w-0">
<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)]">
<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>
<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.