feat: blog posts, redesigned sections, video full-width, reviews hover pause
- Blog listing: force-dynamic SSR, new photo cards with date badge + hover animation - Blog article: custom typography (h1-h3 styling, lists, blockquotes) without plugin - News.tsx: fix hero image mapping (direct upload, not .image sub-field) - Reviews.tsx: hover pauses auto-scroll, resumes on mouse leave; fix poster 404 - VideoSection: full viewport width, no container constraint - next.config.ts: add shumi.ai-impress.com to remotePatterns (fixes 400 on blog images) - Seed: add _status published for Payload drafts; force-posts flag; real blog photos - Locations.tsx: per-slug fallback images (fixes all cards showing dinopark photo) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
55d25cacac
commit
f8421ed42d
11 changed files with 221 additions and 174 deletions
|
|
@ -33,6 +33,8 @@ const nextConfig: NextConfig = {
|
|||
remotePatterns: [
|
||||
{ protocol: 'http', hostname: 'localhost', port: '3000', pathname: '/media/**' },
|
||||
{ protocol: 'https', hostname: 'shumiland.com.ua', pathname: '/media/**' },
|
||||
{ protocol: 'https', hostname: 'shumi.ai-impress.com', pathname: '/api/media/**' },
|
||||
{ protocol: 'http', hostname: 'localhost', port: '3000', pathname: '/api/media/**' },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 181 KiB After Width: | Height: | Size: 210 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 136 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 144 KiB |
|
|
@ -1,9 +1,11 @@
|
|||
/* eslint-disable @next/next/no-img-element */
|
||||
import type { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { getPayload } from 'payload'
|
||||
import configPromise from '@payload-config'
|
||||
import { RichText } from '@payloadcms/richtext-lexical/react'
|
||||
import { PageHero } from '@/components/ui/PageHero'
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ slug: string }>
|
||||
|
|
@ -52,59 +54,74 @@ export default async function BlogPostPage({ params }: Props) {
|
|||
const post = await getPost(slug)
|
||||
if (!post) notFound()
|
||||
|
||||
const publishedDate = post.publishedAt
|
||||
? new Date(post.publishedAt).toLocaleDateString('uk-UA', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#f1fbeb]">
|
||||
{/* Header band */}
|
||||
<div className="bg-[#396817] px-8 py-16">
|
||||
<div className="mx-auto max-w-[800px]">
|
||||
<h1
|
||||
className="text-[28px] leading-tight font-bold text-white md:text-[40px] lg:text-[48px]"
|
||||
<PageHero
|
||||
title={post.title}
|
||||
bgSrc={post.hero?.url ?? undefined}
|
||||
/>
|
||||
|
||||
<div className="mx-auto max-w-[800px] px-8 py-12">
|
||||
{/* Back + date row */}
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<Link
|
||||
href="/blog"
|
||||
className="flex items-center gap-2 text-[14px] font-semibold text-[#396817] transition-colors hover:text-[#f28b4a]"
|
||||
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
|
||||
>
|
||||
{post.title}
|
||||
</h1>
|
||||
{post.publishedAt && (
|
||||
<p className="mt-3 text-[14px] text-white/60">
|
||||
{new Date(post.publishedAt).toLocaleDateString('uk-UA', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M13 8H3M7 4L3 8l4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
Усі статті
|
||||
</Link>
|
||||
{publishedDate && (
|
||||
<span className="rounded-full bg-[#f28b4a] px-4 py-1.5 text-[13px] font-semibold text-white">
|
||||
{publishedDate}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cover image */}
|
||||
{post.hero?.url && (
|
||||
<div className="mx-auto -mt-8 max-w-[800px] px-8">
|
||||
<div className="overflow-hidden rounded-[20px] shadow-[0_4px_60px_0_rgba(242,139,74,0.25)]">
|
||||
<img
|
||||
src={post.hero.url}
|
||||
alt={post.hero.alt ?? post.title}
|
||||
className="max-h-[450px] w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Excerpt */}
|
||||
{post.excerpt && (
|
||||
<p
|
||||
className="mb-8 text-[18px] leading-relaxed font-medium text-[#272727]/70 border-l-4 border-[#f28b4a] pl-5"
|
||||
style={{ fontFamily: 'var(--font-poppins, Poppins), sans-serif' }}
|
||||
>
|
||||
{post.excerpt}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Body */}
|
||||
<div className="mx-auto max-w-[800px] px-8 py-16">
|
||||
{/* Body */}
|
||||
{post.body ? (
|
||||
<div
|
||||
className="prose prose-lg max-w-none text-[#272727]"
|
||||
className="[&_p]:mb-5 [&_p]:text-[17px] [&_p]:leading-[1.85] [&_p]:text-[#272727] [&_h1]:mb-6 [&_h1]:mt-10 [&_h1]:text-[32px] [&_h1]:font-bold [&_h1]:leading-tight [&_h1]:text-[#223e0d] [&_h2]:mb-4 [&_h2]:mt-10 [&_h2]:border-b [&_h2]:border-[#396817]/20 [&_h2]:pb-2 [&_h2]:text-[26px] [&_h2]:font-bold [&_h2]:leading-tight [&_h2]:text-[#223e0d] [&_h3]:mb-3 [&_h3]:mt-8 [&_h3]:text-[20px] [&_h3]:font-semibold [&_h3]:text-[#272727] [&_ul]:mb-5 [&_ul]:list-disc [&_ul]:pl-7 [&_ol]:mb-5 [&_ol]:list-decimal [&_ol]:pl-7 [&_li]:mb-2 [&_li]:text-[17px] [&_li]:leading-[1.75] [&_strong]:font-semibold [&_strong]:text-[#223e0d] [&_em]:italic [&_a]:text-[#396817] [&_a]:underline [&_a]:underline-offset-2 [&_blockquote]:my-6 [&_blockquote]:border-l-4 [&_blockquote]:border-[#f28b4a] [&_blockquote]:pl-5 [&_blockquote]:italic [&_blockquote]:text-[#272727]/70"
|
||||
style={{ fontFamily: 'var(--font-poppins, Poppins), sans-serif' }}
|
||||
>
|
||||
<RichText data={post.body} />
|
||||
</div>
|
||||
) : (
|
||||
<p
|
||||
className="text-[18px] text-[#272727]/60"
|
||||
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
|
||||
>
|
||||
<p className="text-[18px] text-[#272727]/60" style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}>
|
||||
Вміст статті незабаром з'явиться тут.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Back button bottom */}
|
||||
<div className="mt-12 border-t border-[#396817]/10 pt-8">
|
||||
<Link
|
||||
href="/blog"
|
||||
className="inline-flex items-center gap-2 rounded-[64px] bg-[#396817] px-8 py-3 text-[15px] font-semibold text-white transition-all hover:bg-[#f28b4a]"
|
||||
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M13 8H3M7 4L3 8l4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
Інші статті
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { getPayload } from 'payload'
|
||||
import configPromise from '@payload-config'
|
||||
import { PageHero } from '@/components/ui/PageHero'
|
||||
|
|
@ -9,14 +10,15 @@ export const metadata: Metadata = {
|
|||
description: 'Новини, статті та корисна інформація від парку Шуміленд.',
|
||||
}
|
||||
|
||||
export const revalidate = 300
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
interface Post {
|
||||
id: string
|
||||
title: string
|
||||
slug: string
|
||||
excerpt?: string
|
||||
publishedAt?: string
|
||||
excerpt?: string | null
|
||||
publishedAt?: string | null
|
||||
hero?: { url?: string | null; alt?: string | null } | null
|
||||
}
|
||||
|
||||
async function getPosts(): Promise<Post[]> {
|
||||
|
|
@ -27,7 +29,7 @@ async function getPosts(): Promise<Post[]> {
|
|||
where: { status: { equals: 'published' } },
|
||||
sort: '-publishedAt',
|
||||
limit: 12,
|
||||
depth: 0,
|
||||
depth: 1,
|
||||
})
|
||||
return result.docs as unknown as Post[]
|
||||
} catch {
|
||||
|
|
@ -35,6 +37,10 @@ async function getPosts(): Promise<Post[]> {
|
|||
}
|
||||
}
|
||||
|
||||
function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString('uk-UA', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
}
|
||||
|
||||
export default async function BlogPage() {
|
||||
const posts = await getPosts()
|
||||
|
||||
|
|
@ -45,10 +51,7 @@ export default async function BlogPage() {
|
|||
<div className="mx-auto max-w-[1204px] px-8 py-16">
|
||||
{posts.length === 0 ? (
|
||||
<div className="py-20 text-center">
|
||||
<p
|
||||
className="text-[20px] text-[#272727]/60"
|
||||
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
|
||||
>
|
||||
<p className="text-[20px] text-[#272727]/60" style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}>
|
||||
Статті незабаром з'являться тут.
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -58,31 +61,55 @@ export default async function BlogPage() {
|
|||
<Link
|
||||
key={post.id}
|
||||
href={`/blog/${post.slug}`}
|
||||
className="flex flex-col gap-3 rounded-[20px] bg-white p-6 shadow-sm transition-shadow hover:shadow-md"
|
||||
className="group flex flex-col overflow-hidden rounded-[24px] bg-white shadow-[0_4px_30px_0_rgba(57,104,23,0.1)] transition-all duration-300 hover:-translate-y-1 hover:shadow-[0_12px_40px_0_rgba(57,104,23,0.2)]"
|
||||
>
|
||||
<h2
|
||||
className="text-[18px] leading-snug font-bold text-[#272727]"
|
||||
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
|
||||
>
|
||||
{post.title}
|
||||
</h2>
|
||||
{post.excerpt && (
|
||||
<p
|
||||
className="line-clamp-3 text-[14px] leading-relaxed text-[#272727]/60"
|
||||
{/* Hero image */}
|
||||
<div className="relative h-[220px] overflow-hidden bg-[#396817]">
|
||||
{post.hero?.url ? (
|
||||
<Image
|
||||
src={post.hero.url}
|
||||
alt={post.hero.alt ?? post.title}
|
||||
fill
|
||||
sizes="(min-width: 1024px) 380px, (min-width: 640px) 50vw, 100vw"
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full bg-gradient-to-br from-[#396817] to-[#74d22c]">
|
||||
<div className="flex h-full items-center justify-center opacity-30">
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="white"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-5 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{post.publishedAt && (
|
||||
<div className="absolute top-4 left-4 rounded-full bg-[#f28b4a] px-3 py-1 text-[12px] font-semibold text-white shadow-md">
|
||||
{formatDate(post.publishedAt)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex flex-1 flex-col gap-3 p-6">
|
||||
<h2
|
||||
className="line-clamp-2 text-[18px] leading-snug font-bold text-[#272727] transition-colors duration-200 group-hover:text-[#396817]"
|
||||
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
|
||||
>
|
||||
{post.excerpt}
|
||||
</p>
|
||||
)}
|
||||
{post.publishedAt && (
|
||||
<p className="mt-auto text-[13px] font-bold text-[#f28b4a]">
|
||||
{new Date(post.publishedAt).toLocaleDateString('uk-UA', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{post.title}
|
||||
</h2>
|
||||
{post.excerpt && (
|
||||
<p
|
||||
className="line-clamp-3 text-[14px] leading-relaxed text-[#272727]/60"
|
||||
style={{ fontFamily: 'var(--font-poppins, Poppins), sans-serif' }}
|
||||
>
|
||||
{post.excerpt}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-auto flex items-center gap-2 pt-2 text-[14px] font-semibold text-[#396817]">
|
||||
Читати далі
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="transition-transform duration-200 group-hover:translate-x-1">
|
||||
<path d="M3 8h10M9 4l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ async function findOrUploadMedia(
|
|||
|
||||
export async function POST(req: NextRequest) {
|
||||
const forceLocations = req.nextUrl.searchParams.get('force') === 'locations'
|
||||
const forcePosts = req.nextUrl.searchParams.get("force") === "posts"
|
||||
const payload = await getPayload({ config })
|
||||
const results: string[] = []
|
||||
|
||||
|
|
@ -491,7 +492,11 @@ export async function POST(req: NextRequest) {
|
|||
limit: 1,
|
||||
overrideAccess: true,
|
||||
})
|
||||
if (postCount === 0) {
|
||||
if (postCount === 0 || forcePosts) {
|
||||
if (forcePosts && postCount > 0) {
|
||||
const { docs: existingPosts } = await payload.find({ collection: 'blog-posts', limit: 100, overrideAccess: true })
|
||||
for (const p of existingPosts) await payload.delete({ collection: 'blog-posts', id: p.id, overrideAccess: true })
|
||||
}
|
||||
const postDefs = [
|
||||
{
|
||||
title:
|
||||
|
|
@ -566,6 +571,7 @@ export async function POST(req: NextRequest) {
|
|||
slug: post.slug,
|
||||
excerpt: post.excerpt,
|
||||
status: post.status,
|
||||
_status: 'published',
|
||||
publishedAt: post.publishedAt,
|
||||
hero: heroId ?? undefined,
|
||||
body: post.body,
|
||||
|
|
|
|||
|
|
@ -77,7 +77,10 @@ export function Locations({ data, title }: LocationsProps) {
|
|||
slug: loc.slug,
|
||||
tagline: loc.tagline ?? '',
|
||||
description: loc.shortDesc ?? '',
|
||||
image: getMediaUrl(loc.image) ?? FALLBACK_IMAGES[loc.slug] ?? '/images/figma/loc-dinopark.webp',
|
||||
image:
|
||||
getMediaUrl(loc.image) ??
|
||||
FALLBACK_IMAGES[loc.slug] ??
|
||||
'/images/figma/loc-dinopark.webp',
|
||||
href: loc.href ?? `/lokatsii#${loc.slug}`,
|
||||
}))
|
||||
: STATIC_LOCATIONS
|
||||
|
|
|
|||
|
|
@ -46,14 +46,14 @@ async function getLatestPosts(limit = 3): Promise<Article[]> {
|
|||
title: string
|
||||
slug: string
|
||||
excerpt?: string
|
||||
hero?: { image?: { url?: string } }
|
||||
hero?: { url?: string } | null
|
||||
}
|
||||
return {
|
||||
id: String(doc.id),
|
||||
title: d.title,
|
||||
slug: d.slug,
|
||||
excerpt: d.excerpt,
|
||||
heroImage: d.hero?.image ?? null,
|
||||
heroImage: d.hero ?? null,
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import type { ReviewCMS, Media } from '@/types/globals'
|
|||
|
||||
const IMG_AVATAR_DEFAULT = '/images/figma/review-avatar-bg.webp'
|
||||
const VIDEO_REVIEW_SRC = '/videos/review-video.mp4'
|
||||
const VIDEO_REVIEW_POSTER = '/images/review-video-poster.jpg'
|
||||
const VIDEO_REVIEW_POSTER = '/images/figma/hero-bg2.webp'
|
||||
|
||||
function getMediaUrl(img: Media | string | null | undefined): string | null {
|
||||
if (!img) return null
|
||||
|
|
@ -23,7 +23,7 @@ const STATIC_REVIEWS: ReviewCMS[] = [
|
|||
initial: 'Ж',
|
||||
ago: '2 місяці тому',
|
||||
rating: 5,
|
||||
text: 'Beautiful, interesting for children and adults. Large area and different locations. Few visitors on weekdays.',
|
||||
text: 'Гарне місце, цікаво для дітей і дорослих. Велика площа і різні локації. Мало відвідувачів у будні.',
|
||||
source: 'google',
|
||||
},
|
||||
{
|
||||
|
|
@ -32,7 +32,7 @@ const STATIC_REVIEWS: ReviewCMS[] = [
|
|||
initial: 'А',
|
||||
ago: '6 місяців тому',
|
||||
rating: 5,
|
||||
text: 'A wonderful dinosaur park, a park of figures made of grass. You can climb on the figures, the kids are delighted! The dinosaurs move, roar, everyone works.',
|
||||
text: "Чудовий динозавровий парк, фігури з трав'яного покриття. Можна залізти на фігури, дітям в захваті! Динозаври рухаються і гарчать.",
|
||||
source: 'google',
|
||||
},
|
||||
{
|
||||
|
|
@ -41,11 +41,22 @@ const STATIC_REVIEWS: ReviewCMS[] = [
|
|||
initial: 'V',
|
||||
ago: '10 місяців тому',
|
||||
rating: 5,
|
||||
text: 'My family and I visited the open-air park at VDNH, we really liked it! It was much better than I expected. The dinosaurs were very memorable — incredible!',
|
||||
text: "Ми з сім'єю відвідали відкритий парк, нам дуже сподобалось! Значно краще, ніж очікував. Динозаври дуже запам'яталися — неймовірно!",
|
||||
source: 'google',
|
||||
},
|
||||
]
|
||||
|
||||
function GoogleIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
|
||||
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
|
||||
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
|
||||
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function VideoReviewCard({ pauseScroll }: { pauseScroll: () => void }) {
|
||||
const [playing, setPlaying] = useState(false)
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
|
|
@ -57,9 +68,9 @@ function VideoReviewCard({ pauseScroll }: { pauseScroll: () => void }) {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex w-full flex-none flex-col overflow-hidden rounded-[20px] bg-[#396817] shadow-[0_4px_60px_0_rgba(242,139,74,0.25)] md:w-[280px]">
|
||||
{/* 9:16 video area */}
|
||||
<div className="relative aspect-[9/16] w-full overflow-hidden">
|
||||
<div className="relative flex w-full flex-none flex-col overflow-hidden rounded-[24px] bg-[#2d5613] shadow-[0_8px_40px_0_rgba(57,104,23,0.25)] md:w-[320px]">
|
||||
{/* Video thumbnail area — fixed height */}
|
||||
<div className="relative h-[240px] w-full overflow-hidden">
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={VIDEO_REVIEW_SRC}
|
||||
|
|
@ -76,28 +87,25 @@ function VideoReviewCard({ pauseScroll }: { pauseScroll: () => void }) {
|
|||
aria-label="Відтворити відеовідгук"
|
||||
>
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-white/90 shadow-lg transition-transform hover:scale-110">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
className="ml-1"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true" className="ml-1">
|
||||
<path d="M5 3L19 12L5 21V3Z" fill="#396817" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
{/* Badge */}
|
||||
<div className="absolute top-3 left-3 rounded-full bg-[#f28b4a] px-3 py-1 text-[12px] font-semibold text-white">
|
||||
Відео
|
||||
</div>
|
||||
</div>
|
||||
{/* Label */}
|
||||
<div className="flex items-center justify-between px-5 py-3">
|
||||
<p
|
||||
className="text-[14px] font-medium text-white/80"
|
||||
style={{ fontFamily: 'var(--font-inter, Inter), sans-serif' }}
|
||||
>
|
||||
Відеовідгук
|
||||
</p>
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-5 py-4">
|
||||
<div>
|
||||
<p className="text-[15px] font-semibold text-white" style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}>
|
||||
Відеовідгук
|
||||
</p>
|
||||
<p className="mt-0.5 text-[12px] text-white/50">Шуміленд 2026</p>
|
||||
</div>
|
||||
<StarRating value={5} size={14} className="flex gap-[2px]" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -119,7 +127,7 @@ export function Reviews({ data, title }: ReviewsProps) {
|
|||
useAutoScroll(trackRef, { speed: 1, intervalMs: 20, disabled: autoPaused })
|
||||
|
||||
function scrollByOne(dir: 1 | -1) {
|
||||
trackRef.current?.scrollBy({ left: dir * 611, behavior: 'smooth' })
|
||||
trackRef.current?.scrollBy({ left: dir * 420, behavior: 'smooth' })
|
||||
setAutoPaused(true)
|
||||
if (pauseTimer.current) clearTimeout(pauseTimer.current)
|
||||
pauseTimer.current = setTimeout(() => setAutoPaused(false), 3000)
|
||||
|
|
@ -146,22 +154,18 @@ export function Reviews({ data, title }: ReviewsProps) {
|
|||
aria-label="Попередній відгук"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M13 4L7 10L13 16"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path d="M13 4L7 10L13 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div
|
||||
ref={trackRef}
|
||||
className="flex items-start gap-5 overflow-x-auto scroll-smooth pb-2"
|
||||
className="flex items-stretch gap-5 overflow-x-auto scroll-smooth pb-2"
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
onMouseEnter={() => setAutoPaused(true)}
|
||||
onMouseLeave={() => setAutoPaused(false)}
|
||||
>
|
||||
{/* Video review card — first in each half */}
|
||||
{/* Video card */}
|
||||
<VideoReviewCard pauseScroll={pauseForVideo} />
|
||||
|
||||
{doubled.map((review, idx) => {
|
||||
|
|
@ -169,61 +173,58 @@ export function Reviews({ data, title }: ReviewsProps) {
|
|||
return (
|
||||
<article
|
||||
key={`${review.id}-${idx}`}
|
||||
className="flex w-full flex-none flex-col gap-2.5 rounded-[20px] bg-[#396817] px-[39px] py-[41px] shadow-[0_4px_60px_0_rgba(242,139,74,0.25)] md:w-[591px]"
|
||||
className="flex w-full flex-none flex-col rounded-[24px] bg-[#2d5613] shadow-[0_8px_40px_0_rgba(57,104,23,0.25)] md:w-[380px]"
|
||||
>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-[27px]">
|
||||
<div className="relative h-[94px] w-[94px] flex-none overflow-hidden rounded-full">
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-[#f28b4a]/70">
|
||||
<span
|
||||
className="text-[48px] leading-none font-medium text-white"
|
||||
style={{ fontFamily: 'var(--font-inter, Inter), sans-serif' }}
|
||||
>
|
||||
{review.initial ?? review.name[0]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<p
|
||||
className="text-[24px] font-medium text-white"
|
||||
style={{ fontFamily: 'var(--font-inter, Inter), sans-serif' }}
|
||||
>
|
||||
{review.name}
|
||||
</p>
|
||||
<div className="flex items-center gap-[22px]">
|
||||
<span
|
||||
className="text-[16px] font-medium text-white"
|
||||
style={{ fontFamily: 'var(--font-inter, Inter), sans-serif' }}
|
||||
>
|
||||
{review.ago}
|
||||
</span>
|
||||
<StarRating
|
||||
value={review.rating ?? 5}
|
||||
size={16}
|
||||
className="flex gap-[2px]"
|
||||
/>
|
||||
</div>
|
||||
{/* Quote mark decoration */}
|
||||
<div className="px-7 pt-6 pb-0">
|
||||
<svg width="32" height="24" viewBox="0 0 32 24" fill="none" aria-hidden="true">
|
||||
<path d="M0 24V14.4C0 10.4 0.933333 7.06667 2.8 4.4C4.66667 1.6 7.46667 0 11.2 0L12.8 2.4C10.1333 3.06667 8.13333 4.33333 6.8 6.2C5.6 8.06667 5 10.2667 5 12.8H9.6V24H0ZM19.2 24V14.4C19.2 10.4 20.1333 7.06667 22 4.4C23.8667 1.6 26.6667 0 30.4 0L32 2.4C29.3333 3.06667 27.3333 4.33333 26 6.2C24.8 8.06667 24.2 10.2667 24.2 12.8H28.8V24H19.2Z" fill="white" fillOpacity="0.15"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Review text */}
|
||||
<p
|
||||
className="flex-1 px-7 pt-3 pb-5 text-[15px] leading-[1.6] text-white/85"
|
||||
style={{ fontFamily: 'var(--font-poppins, Poppins), sans-serif' }}
|
||||
>
|
||||
{review.text}
|
||||
</p>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="mx-7 h-px bg-white/10" />
|
||||
|
||||
{/* Author row */}
|
||||
<div className="flex items-center gap-4 px-7 py-5">
|
||||
<div className="relative h-12 w-12 flex-none overflow-hidden rounded-full">
|
||||
<img src={avatarUrl} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover" />
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-[#f28b4a]/75">
|
||||
<span className="text-[20px] font-semibold leading-none text-white" style={{ fontFamily: 'var(--font-inter, Inter), sans-serif' }}>
|
||||
{review.initial ?? review.name[0]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
className="line-clamp-2 text-[16px] leading-[1.5] font-medium text-white"
|
||||
style={{ fontFamily: 'var(--font-inter, Inter), sans-serif' }}
|
||||
>
|
||||
{review.text}
|
||||
</p>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="truncate text-[15px] font-semibold text-white" style={{ fontFamily: 'var(--font-inter, Inter), sans-serif' }}>
|
||||
{review.name}
|
||||
</p>
|
||||
<div className="mt-1 flex items-center gap-3">
|
||||
<StarRating value={review.rating ?? 5} size={13} className="flex gap-[2px]" />
|
||||
<span className="text-[12px] text-white/50" style={{ fontFamily: 'var(--font-inter, Inter), sans-serif' }}>
|
||||
{review.ago}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{review.source === 'google' && (
|
||||
<div className="flex-none opacity-60">
|
||||
<GoogleIcon />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Second video card at end for seamless loop */}
|
||||
{/* Second video card for seamless loop */}
|
||||
<VideoReviewCard pauseScroll={pauseForVideo} />
|
||||
</div>
|
||||
|
||||
|
|
@ -233,13 +234,7 @@ export function Reviews({ data, title }: ReviewsProps) {
|
|||
aria-label="Наступний відгук"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M7 4L13 10L7 16"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path d="M7 4L13 10L7 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -65,25 +65,22 @@ export function VideoSection({ poster, src }: VideoSectionProps) {
|
|||
)
|
||||
}
|
||||
|
||||
// Default: autoplay local video (square 1:1)
|
||||
// Default: autoplay local video — full section width
|
||||
return (
|
||||
<section className="flex w-full items-center justify-center bg-[#396817] py-10 lg:py-16">
|
||||
<div className="mx-auto w-full max-w-[1200px] px-4">
|
||||
<div className="flex justify-center">
|
||||
<video
|
||||
className="aspect-square w-full max-w-[720px] rounded-[20px] object-cover"
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
preload="metadata"
|
||||
poster={posterUrl}
|
||||
>
|
||||
<source src={DEFAULT_WEBM} type="video/webm" />
|
||||
<source src={DEFAULT_MP4} type="video/mp4" />
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
<section className="w-full overflow-hidden bg-[#396817]">
|
||||
<video
|
||||
className="w-full object-cover"
|
||||
style={{ display: 'block', maxHeight: '90vh' }}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
preload="metadata"
|
||||
poster={posterUrl}
|
||||
>
|
||||
<source src={DEFAULT_WEBM} type="video/webm" />
|
||||
<source src={DEFAULT_MP4} type="video/mp4" />
|
||||
</video>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue