feat(video): add video reviews to DyvoLis + homepage, fix importMap & ISR
Some checks are pending
CI / Type Check (push) Waiting to run
CI / Lint (push) Waiting to run
CI / Unit Tests (push) Waiting to run
Deploy / Build & Push Image (push) Waiting to run
Deploy / Deploy to VPS (push) Blocked by required conditions

- Dockerfile: run generate:importmap before next build (fixes admin "Nothing found" and SEO fields)
- lokatsii/page.tsx: revalidate 3600→60 (fixes empty page after deploy)
- DyvoLisWhyVisit: replace 5 static image posters with 7 actual clickable videos; accept reviewVideos prop from CMS
- DyvoLisPage global: add reviewVideos array (text src/poster/label fields, CMS-editable)
- Reviews collection: add videoUrl + videoPoster text fields
- Reviews component: VideoReviewCard accepts src/poster props, renders dynamically from CMS reviews with videoUrl
- types/globals.ts: add videoUrl/videoPoster to ReviewCMS interface
- public/videos/dyvolis/: 7 converted MP4s (720p crf28) + 7 poster JPGs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-05-13 18:53:23 +01:00
parent d5977e3215
commit 1c08076963
22 changed files with 170 additions and 55 deletions

View file

@ -15,6 +15,7 @@ FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN NODE_OPTIONS="--import tsx/esm" pnpm payload generate:importmap
RUN pnpm run build
# ---- Runner ----

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -55,6 +55,15 @@ export default async function DyvoLisPage() {
description: item.description,
})) ?? undefined
}
reviewVideos={
data?.reviewVideos?.map(
(v: { src: string; poster?: string | null; label?: string | null }) => ({
src: v.src,
poster: v.poster ?? null,
label: v.label ?? null,
})
) ?? undefined
}
/>
<DyvoLisTickets
workingHours={data?.workingHours ?? undefined}

View file

@ -10,7 +10,7 @@ export const metadata: Metadata = {
description: 'Атракціони та зони парку Шуміленд: ДиноПарк, Диво Ліс, Дзеркальний Лабіринт.',
}
export const revalidate = 3600
export const revalidate = 60
function getMediaUrl(img: Media | string | null | undefined): string | null {
if (!img) return null

View file

@ -3,7 +3,12 @@ import { isAdminOrEditor } from '@/access/isAdminOrEditor'
export const Reviews: CollectionConfig = {
slug: 'reviews',
access: { read: () => true, create: isAdminOrEditor, update: isAdminOrEditor, delete: isAdminOrEditor },
access: {
read: () => true,
create: isAdminOrEditor,
update: isAdminOrEditor,
delete: isAdminOrEditor,
},
admin: {
useAsTitle: 'name',
defaultColumns: ['name', 'source', 'rating', 'showOnHome', 'sort'],
@ -26,6 +31,16 @@ export const Reviews: CollectionConfig = {
],
defaultValue: 'google',
},
{
name: 'videoUrl',
type: 'text',
admin: { description: 'URL відео-відгуку, напр. /videos/dyvolis/video-01.mp4' },
},
{
name: 'videoPoster',
type: 'text',
admin: { description: 'URL постеру для відео' },
},
{ name: 'showOnHome', type: 'checkbox', defaultValue: true },
{ name: 'sort', type: 'number', defaultValue: 0 },
],

View file

@ -1,10 +1,8 @@
'use client'
/* eslint-disable @next/next/no-img-element */
import { useState, useRef, useEffect } from 'react'
const FONT_MONT = { fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }
const IMG_PLAY = '/images/figma/btn-video-play.svg'
const DEFAULT_ITEMS = [
{
@ -24,29 +22,42 @@ const DEFAULT_ITEMS = [
},
]
const FALLBACK_VIDEOS = [
{ src: '/videos/dyvolis/video-01.mp4', poster: '/videos/dyvolis/poster-01.jpg' },
{ src: '/videos/dyvolis/video-02.mp4', poster: '/videos/dyvolis/poster-02.jpg' },
{ src: '/videos/dyvolis/video-03.mp4', poster: '/videos/dyvolis/poster-03.jpg' },
{ src: '/videos/dyvolis/video-04.mp4', poster: '/videos/dyvolis/poster-04.jpg' },
{ src: '/videos/dyvolis/video-05.mp4', poster: '/videos/dyvolis/poster-05.jpg' },
{ src: '/videos/dyvolis/video-06.mp4', poster: '/videos/dyvolis/poster-06.jpg' },
{ src: '/videos/dyvolis/video-07.mp4', poster: '/videos/dyvolis/poster-07.jpg' },
]
interface ReviewVideo {
src: string
poster?: string | null
label?: string | null
}
interface DyvoLisWhyVisitProps {
title?: string
items?: Array<{ title: string; description: string }>
reviewVideos?: ReviewVideo[]
}
const VIDEO_REVIEWS = [
{ poster: '/images/dyvolis/photo-22.jpg' },
{ poster: '/images/dyvolis/photo-14.jpg' },
{ poster: '/images/dyvolis/photo-07.jpg' },
{ poster: '/images/dyvolis/photo-18.jpg' },
{ poster: '/images/dyvolis/photo-03.jpg' },
]
export function DyvoLisWhyVisit({
title = 'Чому варто відвідати ДивоЛіс',
items = DEFAULT_ITEMS,
reviewVideos,
}: DyvoLisWhyVisitProps) {
const videos = reviewVideos && reviewVideos.length > 0 ? reviewVideos : FALLBACK_VIDEOS
const [openIndex, setOpenIndex] = useState(0)
const [videoActive, setVideoActive] = useState(0)
const [playingIndex, setPlayingIndex] = useState<number | null>(null)
const videoPausedRef = useRef(false)
const accordionTimer = useRef<ReturnType<typeof setInterval> | null>(null)
const videoTimer = useRef<ReturnType<typeof setInterval> | null>(null)
const vn = VIDEO_REVIEWS.length
const videoRefs = useRef<(HTMLVideoElement | null)[]>([])
const vn = videos.length
useEffect(() => {
accordionTimer.current = setInterval(() => {
@ -76,6 +87,31 @@ export function DyvoLisWhyVisit({
}, 4000)
}
function handlePlayVideo(i: number) {
if (playingIndex === i) return
// Pause previously playing video
if (playingIndex !== null && videoRefs.current[playingIndex]) {
videoRefs.current[playingIndex]!.pause()
videoRefs.current[playingIndex]!.currentTime = 0
}
videoPausedRef.current = true
setPlayingIndex(i)
setVideoActive(i)
setTimeout(() => {
videoRefs.current[i]?.play()
}, 50)
}
function handleVideoCarouselNav(i: number) {
if (playingIndex !== null && videoRefs.current[playingIndex]) {
videoRefs.current[playingIndex]!.pause()
videoRefs.current[playingIndex]!.currentTime = 0
}
setPlayingIndex(null)
videoPausedRef.current = false
setVideoActive(i)
}
return (
<section className="py-[20px] md:py-[60px] lg:py-[40px]" style={{ background: '#f1fbeb' }}>
<div className="mx-auto max-w-[1204px] px-8">
@ -152,38 +188,62 @@ export function DyvoLisWhyVisit({
videoPausedRef.current = true
}}
onMouseLeave={() => {
videoPausedRef.current = false
if (playingIndex === null) videoPausedRef.current = false
}}
>
{VIDEO_REVIEWS.map((v, i) => (
<div
key={i}
className={`absolute inset-0 transition-opacity duration-500 ${i === videoActive ? 'opacity-100' : 'pointer-events-none opacity-0'}`}
aria-hidden={i !== videoActive}
>
<img
src={v.poster}
alt={i === videoActive ? 'Відгук про ДивоЛіс' : ''}
className="h-full w-full object-cover"
loading={i === 0 ? 'eager' : 'lazy'}
/>
</div>
))}
{/* Play button overlay */}
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<img
src={IMG_PLAY}
alt=""
aria-hidden="true"
className="h-[100px] w-[100px] drop-shadow-[0px_4px_125px_#171b24]"
/>
</div>
{videos.map((v, i) => {
const isActive = i === videoActive
const isPlaying = i === playingIndex
return (
<div
key={i}
className={`absolute inset-0 transition-opacity duration-500 ${isActive ? 'opacity-100' : 'pointer-events-none opacity-0'}`}
aria-hidden={!isActive}
>
<video
ref={(el) => {
videoRefs.current[i] = el
}}
src={v.src}
poster={v.poster ?? undefined}
className="h-full w-full object-cover"
playsInline
controls={isPlaying}
preload="none"
onEnded={() => {
setPlayingIndex(null)
videoPausedRef.current = false
}}
/>
{!isPlaying && (
<button
onClick={() => handlePlayVideo(i)}
className="absolute inset-0 flex items-center justify-center bg-black/10 transition-colors hover:bg-black/25"
aria-label={`Відтворити відео ${i + 1}`}
>
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-white/90 shadow-xl transition-transform hover:scale-110">
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
className="ml-1"
>
<path d="M5 3L19 12L5 21V3Z" fill="#396817" />
</svg>
</div>
</button>
)}
</div>
)
})}
</div>
{/* Carousel navigation */}
<div className="mt-6 flex items-center justify-center gap-6">
<button
onClick={() => setVideoActive((p) => (p - 1 + vn) % vn)}
onClick={() => handleVideoCarouselNav((videoActive - 1 + vn) % vn)}
className="flex h-10 w-10 items-center justify-center rounded-full bg-[#396817] text-white transition-all hover:scale-110 hover:bg-[#2d5414]"
aria-label="Попереднє відео"
>
@ -199,10 +259,10 @@ export function DyvoLisWhyVisit({
</button>
<div className="flex gap-2.5">
{VIDEO_REVIEWS.map((_, i) => (
{videos.map((_, i) => (
<button
key={i}
onClick={() => setVideoActive(i)}
onClick={() => handleVideoCarouselNav(i)}
className="h-2.5 w-2.5 rounded-full transition-all duration-300"
style={{ background: i === videoActive ? '#396817' : '#b8d8a0' }}
aria-label={`Відео ${i + 1}`}
@ -212,7 +272,7 @@ export function DyvoLisWhyVisit({
</div>
<button
onClick={() => setVideoActive((p) => (p + 1) % vn)}
onClick={() => handleVideoCarouselNav((videoActive + 1) % vn)}
className="flex h-10 w-10 items-center justify-center rounded-full bg-[#396817] text-white transition-all hover:scale-110 hover:bg-[#2d5414]"
aria-label="Наступне відео"
>

View file

@ -7,8 +7,6 @@ import StarRating from '@/components/ui/StarRating'
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/figma/hero-bg2.webp'
function getMediaUrl(img: Media | string | null | undefined): string | null {
if (!img) return null
@ -69,7 +67,15 @@ function GoogleIcon() {
)
}
function VideoReviewCard({ pauseScroll }: { pauseScroll: () => void }) {
function VideoReviewCard({
src,
poster,
pauseScroll,
}: {
src: string
poster?: string | null
pauseScroll: () => void
}) {
const [playing, setPlaying] = useState(false)
const videoRef = useRef<HTMLVideoElement>(null)
@ -81,12 +87,11 @@ function VideoReviewCard({ pauseScroll }: { pauseScroll: () => void }) {
return (
<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}
poster={VIDEO_REVIEW_POSTER}
src={src}
poster={poster ?? undefined}
className="h-full w-full object-cover"
playsInline
controls={playing}
@ -112,12 +117,10 @@ function VideoReviewCard({ pauseScroll }: { pauseScroll: () => void }) {
</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>
{/* Footer */}
<div className="flex items-center justify-between px-5 py-4">
<div>
<p
@ -193,10 +196,17 @@ export function Reviews({ data, title }: ReviewsProps) {
onMouseEnter={() => setAutoPaused(true)}
onMouseLeave={() => setAutoPaused(false)}
>
{/* Video card */}
<VideoReviewCard pauseScroll={pauseForVideo} />
{doubled.map((review, idx) => {
if (review.videoUrl) {
return (
<VideoReviewCard
key={`video-${review.id}-${idx}`}
src={review.videoUrl}
poster={review.videoPoster}
pauseScroll={pauseForVideo}
/>
)
}
const avatarUrl = getMediaUrl(review.avatarBg) ?? IMG_AVATAR_DEFAULT
return (
<article
@ -273,9 +283,6 @@ export function Reviews({ data, title }: ReviewsProps) {
</article>
)
})}
{/* Second video card for seamless loop */}
<VideoReviewCard pauseScroll={pauseForVideo} />
</div>
<button

View file

@ -90,6 +90,27 @@ export const DyvoLisPage: GlobalConfig = {
},
],
},
// Video reviews carousel (right column of "Why visit" section)
{
name: 'reviewVideos',
type: 'array',
label: 'Відео-відгуки (права колонка)',
admin: { description: 'Якщо порожньо — використовуються 7 стандартних відео' },
fields: [
{
name: 'src',
type: 'text',
required: true,
admin: { description: 'URL відео, напр. /videos/dyvolis/video-01.mp4' },
},
{
name: 'poster',
type: 'text',
admin: { description: 'URL постеру, напр. /videos/dyvolis/poster-01.jpg' },
},
{ name: 'label', type: 'text', admin: { description: 'Підпис (опційно)' } },
],
},
// Combo section description
{
name: 'comboDescription',

View file

@ -145,6 +145,8 @@ export interface ReviewCMS {
rating?: number | null
text: string
source?: 'google' | 'facebook' | 'instagram' | 'manual' | null
videoUrl?: string | null
videoPoster?: string | null
showOnHome?: boolean | null
sort?: number | null
}