fix(mobile): responsive CoverflowSlider, LocationsSlider overlay, DinoPageContent hero+wheel overflow, gallery lightbox
This commit is contained in:
parent
e159fe8449
commit
3d994b1bf7
5 changed files with 109 additions and 32 deletions
23
_generateGuide.mjs
Normal file
23
_generateGuide.mjs
Normal 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.
|
|
@ -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 ── */}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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="Наступний слайд"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue