fix(mobile): responsive CoverflowSlider, LocationsSlider overlay, DinoPageContent hero+wheel overflow, gallery lightbox
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

This commit is contained in:
Vadym Samoilenko 2026-06-02 21:12:50 +01:00
parent e159fe8449
commit 3d994b1bf7
5 changed files with 109 additions and 32 deletions

23
_generateGuide.mjs Normal file
View file

@ -0,0 +1,23 @@
import { chromium } from './node_modules/.pnpm/playwright-core@1.60.0/node_modules/playwright-core/index.js'
import { readFileSync, writeFileSync, mkdirSync } from 'fs'
const guideHtml = readFileSync('/tmp/shumiland_guide.html', 'utf8')
mkdirSync('docs', { recursive: true })
let browser
try {
browser = await chromium.launch({
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,
})
const page = await browser.newPage()
await page.setContent(guideHtml, { waitUntil: 'networkidle' })
await page.pdf({
path: 'docs/shumiland-admin-guide.pdf',
format: 'A4',
margin: { top: '20mm', right: '15mm', bottom: '20mm', left: '15mm' },
printBackground: true,
})
console.log('PDF generated: docs/shumiland-admin-guide.pdf')
} finally {
await browser?.close()
}

Binary file not shown.

View file

@ -1,8 +1,9 @@
'use client'
/* eslint-disable @next/next/no-img-element */
import { useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCart } from '@/context/CartContext'
import { ImageLightbox } from '@/components/ui/ImageLightbox'
// ─── Types ────────────────────────────────────────────────────────────────────
@ -515,7 +516,7 @@ const DYNO_CSS = `
.dino-page .hero-text h1{font-size:52px;}
}
@media(max-width:820px){
.dino-page .hero-dino{width:min(620px,90vw);right:-40px;top:0;opacity:.85;}
.dino-page .hero-dino{width:min(520px,80vw);height:auto;max-height:75vh;right:-20px;top:0;opacity:.8;}
.dino-page .hero-text{padding-top:40px;}
.dino-page .hero-text h1{font-size:40px;}
.dino-page .hero-text p{font-size:18px;}
@ -526,15 +527,21 @@ const DYNO_CSS = `
}
@media(max-width:560px){
.dino-page{--gutter:18px;}
/* Hero dino: smaller, clipped by hero overflow:hidden */
.dino-page .hero-dino{width:min(320px,70vw);height:auto;max-height:60vh;right:-10px;top:20px;opacity:.7;}
.dino-page .hero-text h1{font-size:32px;}
.dino-page .hero-text p{font-size:16px;}
.dino-page .tip{font-size:18px;padding:18px 24px;}
.dino-page .tip-badge{width:120px;height:120px;font-size:60px;border-width:10px;}
.dino-page .tip-main .tip{padding-left:60px;}
.dino-page .section-head h2,.dino-page .price-title,.dino-page .time-bar h2{font-size:24px;}
.dino-page .gallery{grid-template-columns:1fr 1fr;gap:10px;}
.dino-page .gallery .exhibit{cursor:pointer;}
.dino-page .ticket .t-price{font-size:48px;}
.dino-page .dino-wheel{height:400px;}
.dino-page .dino-wheel{height:400px;overflow:hidden;}
.dino-page .wheel-info h3{font-size:24px;}
/* Prevent SVG wheel from creating horizontal scroll */
.dino-page .slider-section{overflow:hidden;}
}
`
@ -567,6 +574,22 @@ export function DinoPageContent({
() => (galleryImages && galleryImages.length >= 4 ? galleryImages : FB_GALLERY),
[galleryImages]
)
// Lightbox state for gallery
const [lightboxIdx, setLightboxIdx] = useState<number | null>(null)
const lbImages = galleryList.slice(0, 4).map((src, i) => ({
src,
alt: FB_GALLERY_ALTS[i] ?? `Динопарк — фото ${i + 1}`,
}))
const openLightbox = useCallback((i: number) => setLightboxIdx(i), [])
const closeLightbox = useCallback(() => setLightboxIdx(null), [])
const prevLb = useCallback(
() => setLightboxIdx((p) => (p != null ? (p - 1 + lbImages.length) % lbImages.length : 0)),
[lbImages.length]
)
const nextLb = useCallback(
() => setLightboxIdx((p) => (p != null ? (p + 1) % lbImages.length : 0)),
[lbImages.length]
)
const activityList = useMemo(
() => (activities && activities.length > 0 ? activities : FB_ACTIVITIES),
[activities]
@ -951,17 +974,31 @@ export function DinoPageContent({
<div className="wrap">
<div className="gallery">
{galleryList.slice(0, 4).map((src, i) => (
<div key={i} className="exhibit">
<img
src={src}
alt={FB_GALLERY_ALTS[i] ?? `Динопарк — фото ${i + 1}`}
loading="lazy"
/>
{lbImages.map((photo, i) => (
<div
key={i}
className="exhibit"
role="button"
tabIndex={0}
aria-label={`Відкрити фото ${i + 1}`}
style={{ cursor: 'pointer' }}
onClick={() => openLightbox(i)}
onKeyDown={(e) => e.key === 'Enter' && openLightbox(i)}
>
<img src={photo.src} alt={photo.alt} loading="lazy" />
</div>
))}
</div>
</div>
{lightboxIdx !== null && (
<ImageLightbox
images={lbImages}
index={lightboxIdx}
onClose={closeLightbox}
onPrev={prevLb}
onNext={nextLb}
/>
)}
</section>
{/* ── ATTRACTIONS ── */}

View file

@ -18,22 +18,22 @@ export function LocationsSlider({ locations }: { locations: LocationData[] }) {
src: loc.image,
alt: loc.name,
overlay: (
<div className="absolute top-0 left-[57px] flex h-full w-[327px] max-w-[calc(100%-57px)] flex-col justify-center gap-[28px] bg-[rgba(57,104,23,0.8)] px-[30px]">
<div className="flex flex-col gap-3 text-white">
<div className="absolute inset-y-0 right-0 flex w-[54%] min-w-[180px] flex-col justify-center gap-[18px] bg-[rgba(57,104,23,0.85)] px-[20px] md:right-auto md:left-[57px] md:w-[327px] md:px-[30px]">
<div className="flex flex-col gap-2 text-white">
<h3
className="text-[22px] leading-[1.1] font-bold"
className="text-[16px] leading-[1.2] font-bold md:text-[22px] md:leading-[1.1]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{loc.name}
</h3>
<p
className="text-[17px] leading-[1.5] font-normal"
className="text-[13px] leading-[1.4] font-normal md:text-[17px] md:leading-[1.5]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{loc.tagline}
</p>
<p
className="line-clamp-5 text-[14px] leading-[1.5] font-normal"
className="line-clamp-3 text-[12px] leading-[1.4] font-normal md:line-clamp-5 md:text-[14px] md:leading-[1.5]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{loc.description}

View file

@ -12,9 +12,9 @@ export interface CoverflowSlide {
interface CoverflowSliderProps {
slides: CoverflowSlide[]
/** px width of each card (default 420) */
/** px width of each card at full desktop size (default 420) */
cardWidth?: number
/** px height of each card (default 300) */
/** px height of each card at full desktop size (default 300) */
cardHeight?: number
/** Border radius of each card (default 20) */
radius?: number
@ -23,7 +23,6 @@ interface CoverflowSliderProps {
autoplay?: number
}
const SLOT = 230 // px between card centers
const ANGLE = 42 // deg rotation per slot
const SCALE_STEP = 0.14
const OPACITY_STEP = 0.22
@ -39,12 +38,36 @@ export function CoverflowSlider({
}: CoverflowSliderProps) {
const [active, setActive] = useState(0)
const autoTimer = useRef<ReturnType<typeof setInterval> | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
// Responsive: scale card + slot to fit the container
const [effectiveCard, setEffectiveCard] = useState({ w: cardWidth, h: cardHeight })
useEffect(() => {
function measure() {
const vw = containerRef.current?.offsetWidth ?? window.innerWidth
if (vw < cardWidth * 0.9) {
// On small screens cap card width at ~85% of viewport
const ratio = (vw * 0.85) / cardWidth
setEffectiveCard({ w: Math.round(cardWidth * ratio), h: Math.round(cardHeight * ratio) })
} else {
setEffectiveCard({ w: cardWidth, h: cardHeight })
}
}
measure()
window.addEventListener('resize', measure)
return () => window.removeEventListener('resize', measure)
}, [cardWidth, cardHeight])
const cw = effectiveCard.w
const ch = effectiveCard.h
// Slot scales with card size so side cards don't overflow
const SLOT = Math.round(cw * 0.72)
const n = slides.length
const prev = useCallback(() => setActive((a) => (a - 1 + n) % n), [n])
const next = useCallback(() => setActive((a) => (a + 1) % n), [n])
// Keyboard navigation
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'ArrowLeft') prev()
@ -54,7 +77,6 @@ export function CoverflowSlider({
return () => window.removeEventListener('keydown', onKey)
}, [prev, next])
// Autoplay
useEffect(() => {
if (!autoplay) return
autoTimer.current = setInterval(next, autoplay)
@ -63,7 +85,6 @@ export function CoverflowSlider({
}
}, [autoplay, next])
// Touch/drag support
const dragStart = useRef<number | null>(null)
const onTouchStart = (e: React.TouchEvent) => {
dragStart.current = e.touches[0]?.clientX ?? null
@ -75,20 +96,19 @@ export function CoverflowSlider({
dragStart.current = null
}
const containerHeight = Math.round(cardHeight * 1.25)
const containerHeight = Math.round(ch * 1.25)
return (
<div className={`flex flex-col items-center ${className}`}>
{/* 3D stage */}
<div ref={containerRef} className={`flex flex-col items-center overflow-hidden ${className}`}>
{/* 3D stage — overflow-hidden prevents side cards from creating scrollbar */}
<div
className="relative w-full select-none"
className="relative w-full overflow-hidden select-none"
style={{ height: containerHeight, perspective: '1100px' }}
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
>
{slides.map((slide, i) => {
const offset = i - active
// Wrap-around distance (shortest path)
const wrapped = offset > n / 2 ? offset - n : offset < -n / 2 ? offset + n : offset
const abs = Math.abs(wrapped)
@ -110,8 +130,8 @@ export function CoverflowSlider({
onKeyDown={(e) => e.key === 'Enter' && setActive(i)}
className="absolute cursor-pointer overflow-hidden"
style={{
width: cardWidth,
height: cardHeight,
width: cw,
height: ch,
borderRadius: radius,
left: '50%',
top: '50%',
@ -136,9 +156,8 @@ export function CoverflowSlider({
})}
</div>
{/* Controls row */}
{/* Controls */}
<div className="mt-6 flex items-center gap-4">
{/* Prev button */}
<button
onClick={prev}
aria-label="Попередній слайд"
@ -155,7 +174,6 @@ export function CoverflowSlider({
</svg>
</button>
{/* Dots */}
<div className="flex flex-wrap items-center justify-center gap-[6px]">
{slides.map((_, i) => (
<button
@ -172,7 +190,6 @@ export function CoverflowSlider({
))}
</div>
{/* Next button */}
<button
onClick={next}
aria-label="Наступний слайд"