fix: media uploads 500 error + add video review card to Reviews
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: create /app/media dir with nextjs ownership before USER switch
- docker-compose.prod.yml: mount named volume shumiland-media-uploads → /app/media
- Reviews: add VideoReviewCard component with 9:16 portrait inline player
- Add review-video.mp4 (93s, 720×1280, 15MB) + poster from IMG_8336.MOV

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-05-11 14:17:05 +01:00
parent 9562db84e3
commit 2fa4040114
5 changed files with 91 additions and 15 deletions

View file

@ -26,6 +26,7 @@ RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
RUN mkdir -p /app/media && chown nextjs:nodejs /app/media
USER nextjs
EXPOSE 3000
ENV PORT=3000

View file

@ -24,6 +24,8 @@ services:
depends_on:
postgres:
condition: service_healthy
volumes:
- media_uploads:/app/media
restart: unless-stopped
networks:
- internal
@ -65,3 +67,5 @@ networks:
volumes:
postgres_data:
name: shumiland-postgres-data
media_uploads:
name: shumiland-media-uploads

Binary file not shown.

View file

@ -478,7 +478,9 @@ export async function POST(req: NextRequest) {
indent: 0,
version: 1,
direction: 'ltr',
children: [{ type: 'text', detail: 0, format: 0, mode: 'normal', style: '', text, version: 1 }],
children: [
{ type: 'text', detail: 0, format: 0, mode: 'normal', style: '', text, version: 1 },
],
})),
},
}
@ -492,9 +494,11 @@ export async function POST(req: NextRequest) {
if (postCount === 0) {
const postDefs = [
{
title: 'Сезон пригод відкрито: Shumiland на ВДНГ запрошує у світ, де оживають динозаври та казки!',
title:
'Сезон пригод відкрито: Shumiland на ВДНГ запрошує у світ, де оживають динозаври та казки!',
slug: 'sezon-pryhod-vidkryto',
excerpt: 'Сезон стартував із події національного масштабу — фіксації рекорду України: найбільші динозаври країни. 19 квітня у Shumiland представники Книги рекордів України офіційно зафіксували рекорд.',
excerpt:
'Сезон стартував із події національного масштабу — фіксації рекорду України: найбільші динозаври країни. 19 квітня у Shumiland представники Книги рекордів України офіційно зафіксували рекорд.',
status: 'published',
publishedAt: '2026-04-19T10:00:00.000Z',
imgFile: 'sezon-pryhod.webp',
@ -504,17 +508,18 @@ export async function POST(req: NextRequest) {
'Більше ніж парк: простір, де фантазія стає реальністю',
'Після яскравого старту в стилі «Аліси в Дивокраї», парк працює у повноцінному режимі, пропонуючи гостям унікальний формат відпочинку просто неба. На території ВДНГ розгорнувся масштабний всесвіт пригод, що поєднує освіту, розваги та естетику.',
'Динопарк — рекорд, який вражає: Локація в парку, де серед густої зелені «ожили» реалістичні динозаври натуральної величини, серед яких — найбільші динозаври України, офіційно зафіксовані як рекорд. Завдяки сучасним технологіям гіганти рухаються та гарчать, створюючи ефект повної присутності.',
'Диво Ліс та Будиночок Лісовика: Казкова локація з унікальними топіарними фігурами та арт-об\'єктами. Тут розташована резиденція Лісовика, де діти можуть зануритися у світ легенд та інтерактивних історій.',
"Диво Ліс та Будиночок Лісовика: Казкова локація з унікальними топіарними фігурами та арт-об'єктами. Тут розташована резиденція Лісовика, де діти можуть зануритися у світ легенд та інтерактивних історій.",
'Дзеркальний лабіринт: Простір світла та ілюзій, що відкриває нові ракурси реальності та створює десятки ефектних фотолокацій.',
'Атракціони: У сезоні 2026 парк значно розширив зону розваг — на гостей чекає багато нових атракціонів для дітей різного віку.',
'Shumiland — обов\'язкова точка для візиту на мапі Києва. Велика територія для активного відпочинку, продумана комфортна інфраструктура та оновлені фудкорти для всієї родини.',
"Shumiland — обов'язкова точка для візиту на мапі Києва. Велика територія для активного відпочинку, продумана комфортна інфраструктура та оновлені фудкорти для всієї родини.",
'«Ми створили місце, де кожен візит перетворюється на родинне свято. Shumiland — це простір, де діти стають дослідниками, а дорослі дозволяють собі мріяти», — зазначає команда парку.',
]),
},
{
title: 'У Шуміленді заклали капсулу часу',
slug: 'kapsula-chasu',
excerpt: 'У Шуміленді відбулася особлива подія — ми заклали капсулу часу як символ початку великого шляху, сповненого дитячого сміху, щирих емоцій, сімейних моментів і незабутніх вражень.',
excerpt:
'У Шуміленді відбулася особлива подія — ми заклали капсулу часу як символ початку великого шляху, сповненого дитячого сміху, щирих емоцій, сімейних моментів і незабутніх вражень.',
status: 'published',
publishedAt: '2026-04-20T10:00:00.000Z',
imgFile: 'kapsula-chasu.webp',
@ -529,7 +534,8 @@ export async function POST(req: NextRequest) {
{
title: 'Травень у Шуміленді — місяць пригод для всієї родини',
slug: 'traven-u-shumilendt',
excerpt: 'Травень у Шуміленді обіцяє бути яскравим, веселим і сповненим незабутніх емоцій. Щовихідних на гостей чекають тематичні програми, квести, улюблені герої та атмосфера справжнього сімейного відпочинку.',
excerpt:
'Травень у Шуміленді обіцяє бути яскравим, веселим і сповненим незабутніх емоцій. Щовихідних на гостей чекають тематичні програми, квести, улюблені герої та атмосфера справжнього сімейного відпочинку.',
status: 'published',
publishedAt: '2026-05-01T10:00:00.000Z',
imgFile: 'traven-shymiland.webp',

View file

@ -7,6 +7,8 @@ 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/review-video-poster.jpg'
function getMediaUrl(img: Media | string | null | undefined): string | null {
if (!img) return null
@ -44,6 +46,64 @@ const STATIC_REVIEWS: ReviewCMS[] = [
},
]
function VideoReviewCard({ pauseScroll }: { pauseScroll: () => void }) {
const [playing, setPlaying] = useState(false)
const videoRef = useRef<HTMLVideoElement>(null)
function handlePlay() {
pauseScroll()
setPlaying(true)
videoRef.current?.play()
}
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">
<video
ref={videoRef}
src={VIDEO_REVIEW_SRC}
poster={VIDEO_REVIEW_POSTER}
className="h-full w-full object-cover"
playsInline
controls={playing}
preload="none"
/>
{!playing && (
<button
onClick={handlePlay}
className="absolute inset-0 flex items-center justify-center bg-black/20 transition-colors hover:bg-black/30"
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"
>
<path d="M5 3L19 12L5 21V3Z" fill="#396817" />
</svg>
</div>
</button>
)}
</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>
<StarRating value={5} size={14} className="flex gap-[2px]" />
</div>
</div>
)
}
interface ReviewsProps {
data?: ReviewCMS[]
title?: string
@ -65,11 +125,15 @@ export function Reviews({ data, title }: ReviewsProps) {
pauseTimer.current = setTimeout(() => setAutoPaused(false), 3000)
}
function pauseForVideo() {
setAutoPaused(true)
}
return (
<section className="py-[20px] md:py-[60px] lg:py-[40px]">
<div className="mx-auto max-w-[1204px] px-8">
<h2
className="mb-[40px] text-[24px] font-bold text-[#272727] uppercase md:mb-[60px] md:text-[32px]"
className="mb-[40px] text-[24px] font-bold uppercase text-[#272727] md:mb-[60px] md:text-[32px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{title ?? 'Відгуки'}
@ -94,9 +158,12 @@ export function Reviews({ data, title }: ReviewsProps) {
<div
ref={trackRef}
className="flex flex-col gap-5 overflow-x-auto scroll-smooth pb-2 md:flex-row"
className="flex items-start gap-5 overflow-x-auto scroll-smooth pb-2"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{/* Video review card — first in each half */}
<VideoReviewCard pauseScroll={pauseForVideo} />
{doubled.map((review, idx) => {
const avatarUrl = getMediaUrl(review.avatarBg) ?? IMG_AVATAR_DEFAULT
return (
@ -106,7 +173,6 @@ export function Reviews({ data, title }: ReviewsProps) {
>
<div className="flex flex-col gap-6">
<div className="flex items-center gap-[27px]">
{/* Avatar: photo background + initial letter overlay */}
<div className="relative h-[94px] w-[94px] flex-none overflow-hidden rounded-full">
<img
src={avatarUrl}
@ -137,11 +203,7 @@ export function Reviews({ data, title }: ReviewsProps) {
>
{review.ago}
</span>
<StarRating
value={review.rating ?? 5}
size={16}
className="flex gap-[2px]"
/>
<StarRating value={review.rating ?? 5} size={16} className="flex gap-[2px]" />
</div>
</div>
</div>
@ -156,6 +218,9 @@ export function Reviews({ data, title }: ReviewsProps) {
</article>
)
})}
{/* Second video card at end for seamless loop */}
<VideoReviewCard pauseScroll={pauseForVideo} />
</div>
<button