fix: resolve admin crash, Hero data-driven, TS errors, lint script
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

- (payload)/layout.tsx: add RootLayout + handleServerFunctions to fix
  Payload CMS admin 500 (PageConfigProvider missing outer ConfigProvider)
- Hero.tsx: make fully data-driven (null guard, dynamic title/subtitle/cta,
  backgroundImage/backgroundVideo support, javascript: href sanitization)
- Hero.test.tsx: use getAllByText for dual-layout subtitle assertions
- BtnPrimary.figma.ts, NavLink.figma.ts: fix TS2339 (TextHandle|ErrorHandle)
- package.json: replace next lint with eslint src/ (next lint removed in v16),
  add --webpack flag to dev script to avoid Turbopack instability

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-05-10 17:27:10 +01:00
parent e644ee899a
commit 86debfcdb1
6 changed files with 138 additions and 135 deletions

View file

@ -8,11 +8,11 @@
"pnpm": ">=11.0.0"
},
"scripts": {
"dev": "next dev",
"dev": "next dev --webpack",
"build": "next build",
"start": "next start",
"typecheck": "tsc --noEmit",
"lint": "next lint",
"lint": "eslint src/",
"format": "prettier --write .",
"test": "vitest run",
"test:watch": "vitest",

View file

@ -1,9 +1,30 @@
import React from 'react'
import { RootLayout, handleServerFunctions } from '@payloadcms/next/layouts'
import type { ServerFunctionClient } from 'payload'
import configPromise from '@payload-config'
import { importMap } from './admin/importMap.js'
export const metadata = {
title: 'Shumiland Admin',
}
export default function PayloadLayout({ children }: { children: React.ReactNode }) {
return children
const serverFunction: ServerFunctionClient = async function (args) {
'use server'
return handleServerFunctions({
...args,
config: configPromise,
importMap,
})
}
export default function PayloadLayout({ children }: { children: React.ReactNode }) {
return (
<RootLayout
config={configPromise}
importMap={importMap}
serverFunction={serverFunction}
>
{children}
</RootLayout>
)
}

View file

@ -72,7 +72,7 @@ describe('Hero', () => {
it('renders subtitle when provided', () => {
const hero: HomePageHero = { subtitle: 'The best adventure park' }
render(<Hero hero={hero} />)
expect(screen.getByText('The best adventure park')).toBeInTheDocument()
expect(screen.getAllByText('The best adventure park')[0]).toBeInTheDocument()
})
it('renders CTA link with correct href', () => {
@ -129,7 +129,7 @@ describe('Hero', () => {
}
render(<Hero hero={hero} />)
expect(screen.getByRole('heading', { level: 1, name: 'Shumiland' })).toBeInTheDocument()
expect(screen.getByText('Discover the magic')).toBeInTheDocument()
expect(screen.getAllByText('Discover the magic')[0]).toBeInTheDocument()
const link = screen.getByRole('link', { name: 'Explore' })
expect(link).toHaveAttribute('href', '/explore')
})

View file

@ -1,5 +1,5 @@
/* eslint-disable @next/next/no-img-element */
import type { HomePageHero } from '@/types/globals'
import type { HomePageHero, Media } from '@/types/globals'
import { BtnPrimary } from '@/components/ui/BtnPrimary'
const IMG_BG2 = '/images/figma/hero-bg2.png'
@ -10,65 +10,59 @@ interface HeroProps {
hero?: HomePageHero | null
}
/**
* Hero frame (Figma 1920 × 1080):
* - section bg #223e0d, rounded-b-20, padding 0 10 10 10
* - 3 background image layers stacked (bg2 bg1 family)
* - inner "hero-main" content frame at left:208 top:130 (1504 × 879)
* - blur layer is an L-shape SVG path with a notch in the top-right where
* the family image shows through clearly.
* - title 120px Montserrat 700, line-height 1.2, white
* - subtitle 24px Montserrat 500, max-width 629
* - btn_primary at the bottom-left of the content frame
*
* Mobile/tablet: title scales down, blur layer becomes a simple fade.
*/
function isMedia(val: unknown): val is Media {
return typeof val === 'object' && val !== null && 'url' in val
}
function isSafeHref(href: string): boolean {
return !href.trim().toLowerCase().startsWith('javascript:')
}
export function Hero({ hero }: HeroProps) {
const subtitle =
hero?.subtitle ??
'Сімейний тематичний парк, де гра допомагає пізнавати світ, а кожна прогулянка перетворюється на незабутню пригоду.'
const ctaLabel = hero?.ctaLabel ?? 'Купити квиток'
const ctaHref = hero?.ctaHref ?? '/kvytky'
if (!hero) return null
const { title, subtitle, ctaLabel, ctaHref, backgroundImage, backgroundVideo } = hero
const showCta = Boolean(ctaLabel && ctaHref && isSafeHref(ctaHref))
const bgMedia = isMedia(backgroundImage) ? backgroundImage : null
return (
<section
className="relative overflow-hidden bg-[#223e0d] -mt-[60px] lg:-mt-[120px] rounded-b-[20px] mx-[10px]"
style={{ minHeight: 'min(1080px, 100vh)' }}
>
{/* Background image layers */}
<img
src={IMG_BG2}
alt=""
aria-hidden="true"
className="absolute inset-0 w-full h-full object-cover pointer-events-none"
/>
<img
src={IMG_BG1}
alt=""
aria-hidden="true"
className="absolute inset-0 w-full h-full object-cover pointer-events-none mix-blend-overlay opacity-90"
/>
<img
src={IMG_FAMILY}
alt=""
aria-hidden="true"
className="absolute inset-0 w-full h-full object-cover pointer-events-none"
/>
{/* Background layers */}
{backgroundVideo ? (
<video
src={backgroundVideo}
autoPlay
loop
muted
playsInline
aria-hidden="true"
className="absolute inset-0 w-full h-full object-cover pointer-events-none"
/>
) : bgMedia ? (
<img
src={bgMedia.url ?? ''}
alt={bgMedia.alt ?? ''}
className="absolute inset-0 w-full h-full object-cover pointer-events-none"
/>
) : (
<>
<img src={IMG_BG2} alt="" aria-hidden="true" className="absolute inset-0 w-full h-full object-cover pointer-events-none" />
<img src={IMG_BG1} alt="" aria-hidden="true" className="absolute inset-0 w-full h-full object-cover pointer-events-none mix-blend-overlay opacity-90" />
<img src={IMG_FAMILY} alt="" aria-hidden="true" className="absolute inset-0 w-full h-full object-cover pointer-events-none" />
</>
)}
{/* Content + blur frame.
Container is 1504 × 879 in Figma, anchored at (208, 130) within the
1920 × 1080 hero. Ratios: left 10.83% top 12.04% w 78.33% h 81.39%. */}
{/* Desktop layout aria-hidden: visual layer only, semantic content is in mobile layout below.
The mobile layout is always in the DOM; CSS hides it visually on desktop. */}
<div
aria-hidden="true"
className="hidden lg:block absolute"
style={{
left: '10.83%',
top: '12.04%',
width: '78.33%',
height: '81.39%',
}}
style={{ left: '10.83%', top: '12.04%', width: '78.33%', height: '81.39%' }}
>
{/* L-shaped frosted layer exact path from Figma (1504 × 879).
opacity 0.49, fill semi-white frosted; backdrop blur on shape. */}
{/* L-shaped frosted layer */}
<svg
viewBox="0 0 1504 879"
preserveAspectRatio="none"
@ -80,13 +74,7 @@ export function Hero({ hero }: HeroProps) {
<path d="M 1221 538.998 C 1221 550.044 1229.954 558.998 1241 558.998 L 1484 558.998 C 1495.046 558.998 1504 567.952 1504 578.998 L 1504 859 C 1504 870.046 1495.046 879 1484 879 L 20 879 C 8.954 879 0 870.046 0 859 L 0 20 C 0 8.954 8.954 0 20 0 L 1201 0 C 1212.046 0 1221 8.954 1221 20 L 1221 538.998 Z" />
</clipPath>
</defs>
<foreignObject
x="0"
y="0"
width="1504"
height="879"
clipPath="url(#hero-blur-clip)"
>
<foreignObject x="0" y="0" width="1504" height="879" clipPath="url(#hero-blur-clip)">
<div
// @ts-expect-error xmlns on div in foreignObject is fine in JSX
xmlns="http://www.w3.org/1999/xhtml"
@ -101,85 +89,77 @@ export function Hero({ hero }: HeroProps) {
</foreignObject>
</svg>
{/* Text content sits on top of the L-shape, padded to clear the
top-right notch (1186 / 1504 = ~78.86%, i.e. the title block sits
in the left ~79% of the content area). */}
<div
className="absolute"
style={{
left: '10.24%', // 154 / 1504
top: '11.15%', // 98 / 879
width: '67.59%', // 1016 / 1504
}}
>
<h1
className="text-white font-bold uppercase"
style={{
fontFamily: 'var(--font-montserrat, Montserrat), sans-serif',
fontSize: 'clamp(48px, 6.25vw, 120px)',
lineHeight: 1.2,
fontWeight: 700,
margin: 0,
}}
{title && (
<div
className="absolute"
style={{ left: '10.24%', top: '11.15%', width: '67.59%' }}
>
Шуміленд <br />
світ, де казка оживає
</h1>
</div>
<div
className="text-white font-bold uppercase"
style={{
fontFamily: 'var(--font-montserrat, Montserrat), sans-serif',
fontSize: 'clamp(48px, 6.25vw, 120px)',
lineHeight: 1.2,
fontWeight: 700,
margin: 0,
}}
>
{title}
</div>
</div>
)}
{/* Subtitle */}
<div
className="absolute"
style={{
left: '10.24%',
// Title bottom in Figma: top:98 + h:432 = 530, then gap 38 → 568.
// 568 / 879 = 64.62%
top: '64.62%',
width: '41.82%', // 629 / 1504
}}
>
<p
className="text-white"
style={{
fontFamily: 'var(--font-montserrat, Montserrat), sans-serif',
fontSize: 24,
fontWeight: 500,
lineHeight: 1.5,
margin: 0,
}}
{subtitle && (
<div
className="absolute"
style={{ left: '10.24%', top: '64.62%', width: '41.82%' }}
>
{subtitle}
</p>
</div>
<p
className="text-white"
style={{
fontFamily: 'var(--font-montserrat, Montserrat), sans-serif',
fontSize: 24,
fontWeight: 500,
lineHeight: 1.5,
margin: 0,
}}
>
{subtitle}
</p>
</div>
)}
{/* CTA at left:154, top:714+98=812 within the 879 frame.
812 / 879 = 92.38%, but height of btn = 50, so anchor top ~81.23% */}
<div
className="absolute"
style={{ left: '10.24%', top: '81.23%' }}
>
<BtnPrimary href={ctaHref}>{ctaLabel}</BtnPrimary>
</div>
{showCta && (
<div className="absolute" style={{ left: '10.24%', top: '81.23%' }}>
<BtnPrimary href={ctaHref!}>{ctaLabel}</BtnPrimary>
</div>
)}
</div>
{/* Mobile / tablet — simple stacked layout, no blur shape */}
{/* Mobile / tablet — semantic layout (always in DOM, provides accessible content) */}
<div className="lg:hidden relative z-10 px-6 pt-[120px] pb-[60px] md:pt-[180px] md:pb-[80px]">
<div className="flex flex-col gap-[24px] md:gap-[32px] max-w-[680px]">
<h1
className="text-white font-bold uppercase leading-[1.15] text-[36px] md:text-[64px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
Шуміленд світ, де казка оживає
</h1>
<p
className="text-white font-medium text-[16px] md:text-[20px] leading-[1.5] max-w-[560px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{subtitle}
</p>
<BtnPrimary href={ctaHref} className="self-start">
{ctaLabel}
</BtnPrimary>
{title && (
<h1
className="text-white font-bold uppercase leading-[1.15] text-[36px] md:text-[64px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{title}
</h1>
)}
{subtitle && (
<p
className="text-white font-medium text-[16px] md:text-[20px] leading-[1.5] max-w-[560px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{subtitle}
</p>
)}
{showCta && (
<BtnPrimary href={ctaHref!} className="self-start">
{ctaLabel}
</BtnPrimary>
)}
</div>
</div>
</section>

View file

@ -4,7 +4,8 @@
import figma from 'figma'
const instance = figma.selectedInstance
const label = instance.findText('btn_text')?.textContent ?? 'Купити квиток'
const _handle = instance.findText('btn_text')
const label = (_handle && 'textContent' in _handle ? _handle.textContent : null) ?? 'Купити квиток'
export default {
example: figma.code`<BtnPrimary href="/kvytky">${label}</BtnPrimary>`,

View file

@ -4,7 +4,8 @@
import figma from 'figma'
const instance = figma.selectedInstance
const label = instance.findText('Головна')?.textContent ?? 'Головна'
const _handle = instance.findText('Головна')
const label = (_handle && 'textContent' in _handle ? _handle.textContent : null) ?? 'Головна'
export default {
example: figma.code`<NavLink href="/lokatsii">${label}</NavLink>`,