fix: media uploads 500 error + add video review card to Reviews
- 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:
parent
9562db84e3
commit
2fa4040114
5 changed files with 91 additions and 15 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
BIN
public/videos/review-video.mp4
Normal file
BIN
public/videos/review-video.mp4
Normal file
Binary file not shown.
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue