fix: resolve admin crash, Hero data-driven, TS errors, lint script
- (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:
parent
e644ee899a
commit
86debfcdb1
6 changed files with 138 additions and 135 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>`,
|
||||
|
|
|
|||
|
|
@ -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>`,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue