feat(video): add video reviews to DyvoLis + homepage, fix importMap & ISR
- 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>
|
|
@ -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 ----
|
||||
|
|
|
|||
BIN
public/videos/dyvolis/poster-01.jpg
Normal file
|
After Width: | Height: | Size: 173 KiB |
BIN
public/videos/dyvolis/poster-02.jpg
Normal file
|
After Width: | Height: | Size: 138 KiB |
BIN
public/videos/dyvolis/poster-03.jpg
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
public/videos/dyvolis/poster-04.jpg
Normal file
|
After Width: | Height: | Size: 115 KiB |
BIN
public/videos/dyvolis/poster-05.jpg
Normal file
|
After Width: | Height: | Size: 167 KiB |
BIN
public/videos/dyvolis/poster-06.jpg
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
public/videos/dyvolis/poster-07.jpg
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
public/videos/dyvolis/video-01.mp4
Normal file
BIN
public/videos/dyvolis/video-02.mp4
Normal file
BIN
public/videos/dyvolis/video-03.mp4
Normal file
BIN
public/videos/dyvolis/video-04.mp4
Normal file
BIN
public/videos/dyvolis/video-05.mp4
Normal file
BIN
public/videos/dyvolis/video-06.mp4
Normal file
BIN
public/videos/dyvolis/video-07.mp4
Normal 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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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="Наступне відео"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||