feat(hero): add image collage, 3D parallax, floating stat card, and entrance animations

This commit is contained in:
MichalC
2026-09-03 09:09:33 +02:00
parent 8ce66a47d2
commit 4b3c588045
3 changed files with 210 additions and 29 deletions
+189 -29
View File
@@ -1,39 +1,145 @@
'use client'
import React from 'react'
import React, { useState } from 'react'
import { motion } from 'framer-motion'
import type { Page, Media } from '../../payload-types'
import Link from 'next/link'
import Image from 'next/image'
type Props = Extract<NonNullable<Page['layout']>[0], { blockType: 'hero' }>
export const HeroBlock: React.FC<Props> = ({ title, titleAccent, subtitle, primaryCta, secondaryCta, image }) => {
// Sprawdzamy czy obrazek jest obiektem Media czy tylko ID
const heroMedia = typeof image === 'object' && image !== null ? (image as Media) : null;
// PLACEHOLDER - do podmiany na zdjęcie klienta jeśli brak w CMS
const imageUrl = heroMedia?.url || 'https://images.unsplash.com/photo-1554224155-8d04cb21cd6c?auto=format&fit=crop&w=1200&q=80';
const imageAlt = heroMedia?.alt || 'Doradztwo podatkowe - biuro';
const customEase = [0.22, 1, 0.36, 1] as const
const containerVariants = {
hidden: {},
visible: {
transition: {
staggerChildren: 0.14,
},
},
}
const itemVariants = {
hidden: { opacity: 0, y: 22 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.5,
ease: customEase,
},
},
}
const collageVariants = {
hidden: { opacity: 0, scale: 0.96, y: 24 },
visible: {
opacity: 1,
scale: 1,
y: 0,
transition: {
duration: 0.6,
ease: customEase,
delay: 0.25,
},
},
}
function useParallax3D(maxAngle = 4.5) {
const [rotation, setRotation] = useState({ rotateX: 0, rotateY: 0 })
const [isHovered, setIsHovered] = useState(false)
const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
const centerX = rect.width / 2
const centerY = rect.height / 2
const rotateX = -((y - centerY) / centerY) * maxAngle
const rotateY = ((x - centerX) / centerX) * maxAngle
setRotation({ rotateX, rotateY })
}
const handlePointerLeave = () => {
setIsHovered(false)
setRotation({ rotateX: 0, rotateY: 0 })
}
const handlePointerEnter = () => {
setIsHovered(true)
}
return {
bind: {
onPointerMove: handlePointerMove,
onPointerEnter: handlePointerEnter,
onPointerLeave: handlePointerLeave,
},
style: {
transform: `perspective(1000px) rotateX(${rotation.rotateX.toFixed(2)}deg) rotateY(${rotation.rotateY.toFixed(2)}deg)`,
transition: isHovered ? 'transform 0.12s ease-out' : 'transform 0.55s ease-out',
},
}
}
export const HeroBlock: React.FC<Props> = ({
title,
titleAccent,
subtitle,
primaryCta,
secondaryCta,
image,
imageSecondary,
floatingStat,
}) => {
const heroMedia = typeof image === 'object' && image !== null ? (image as Media) : null
const imageUrl = heroMedia?.url || 'https://images.unsplash.com/photo-1554224155-8d04cb21cd6c?auto=format&fit=crop&w=1200&q=80'
const imageAlt = heroMedia?.alt || 'Doradztwo podatkowe - biuro'
const secondaryMedia = typeof imageSecondary === 'object' && imageSecondary !== null ? (imageSecondary as Media) : null
const secondaryUrl = secondaryMedia?.url
const secondaryAlt = secondaryMedia?.alt || 'Profesjonalne rozliczenia podatkowe'
const { bind: parallaxBind, style: parallaxStyle } = useParallax3D(4.5)
const statValue = floatingStat?.value || '20+'
const statLabel = floatingStat?.label || 'Lat doświadczenia'
return (
<section className="w-full bg-background text-foreground pt-32 pb-16 md:pt-48 md:pb-24">
<motion.section
initial="hidden"
animate="visible"
variants={containerVariants}
className="w-full bg-background text-foreground pt-32 pb-16 md:pt-44 md:pb-24 overflow-hidden"
>
<div className="container-premium">
<div className="flex flex-col-reverse md:flex-row items-center justify-between gap-12 lg:gap-24">
<div className="flex flex-col-reverse lg:flex-row items-center justify-between gap-14 lg:gap-20">
<div className="w-full md:w-1/2 max-w-xl flex flex-col">
<h1 className="font-heading text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-bold leading-[1.05] mb-8 text-balance">
{/* Kolumna tekstowa z animacjami wejścia */}
<div className="w-full lg:w-1/2 max-w-xl flex flex-col">
<motion.h1
variants={itemVariants}
className="font-heading text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-bold leading-[1.05] mb-8 text-balance"
>
<span className="block text-foreground">{title}</span>
{titleAccent && <span className="block text-accent">{titleAccent}</span>}
</h1>
</motion.h1>
<p className="text-xl sm:text-2xl text-foreground/80 mb-12 leading-relaxed font-light">
<motion.p
variants={itemVariants}
className="text-xl sm:text-2xl text-foreground/80 mb-12 leading-relaxed font-light"
>
{subtitle}
</p>
</motion.p>
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-8">
<motion.div
variants={itemVariants}
className="flex flex-col sm:flex-row items-start sm:items-center gap-8"
>
<a
href={process.env.NEXT_PUBLIC_WHATSAPP_LINK || '#'}
target="_blank"
rel="noopener noreferrer"
className="px-8 py-4 bg-green-500 hover:bg-green-600 text-white font-bold text-base sm:text-lg transition-colors flex items-center justify-center rounded-[var(--radius-button)] shadow-sm"
className="px-8 py-4 bg-green-500 hover:bg-green-600 text-white font-bold text-base sm:text-lg transition-colors flex items-center justify-center rounded-[var(--radius-button)] shadow-sm cursor-pointer"
>
{primaryCta}
</a>
@@ -45,24 +151,78 @@ export const HeroBlock: React.FC<Props> = ({ title, titleAccent, subtitle, prima
{secondaryCta.text}
</Link>
)}
</div>
</motion.div>
</div>
<div className="w-full md:w-1/2 flex justify-center md:justify-end">
<div className="relative w-full aspect-[4/5] max-w-md">
<Image
src={imageUrl}
alt={imageAlt}
fill
priority
sizes="(max-width: 768px) 100vw, 50vw"
className="object-cover object-center"
/>
{/* Prawa kolumna: Kolaż zdjęć z 3D paralaksą i pływającą kartą */}
<motion.div
variants={collageVariants}
className="w-full lg:w-1/2 flex justify-center lg:justify-end"
>
<div
{...parallaxBind}
style={parallaxStyle}
className="relative w-full max-w-md sm:max-w-lg select-none will-change-transform"
>
{/* Pływająca karta (floating card) */}
<motion.div
animate={{ y: [0, -8, 0] }}
transition={{ duration: 4, repeat: Infinity, ease: 'easeInOut' }}
className="absolute -top-6 -left-3 sm:-top-8 sm:-left-6 z-30 bg-foreground text-background px-5 py-4 shadow-xl border border-border/20 flex flex-col gap-0.5 pointer-events-none"
>
<span className="font-heading text-2xl sm:text-3xl font-extrabold tracking-tight text-accent">
{statValue}
</span>
<span className="text-xs uppercase tracking-widest text-background/80 font-medium">
{statLabel}
</span>
</motion.div>
{secondaryUrl ? (
/* Układ kolażu dwóch nachodzących na siebie zdjęć */
<div className="relative w-full aspect-[4/5] sm:aspect-[1/1] pt-6 pl-4">
{/* Zdjęcie 1 (mniejsze, przesunięte w lewo-górę) */}
<div className="absolute top-2 left-0 w-[62%] aspect-[4/5] border border-border shadow-sm overflow-hidden z-10">
<Image
src={imageUrl}
alt={imageAlt}
fill
priority
sizes="(max-width: 1024px) 55vw, 25vw"
className="object-cover object-center grayscale hover:grayscale-0 transition-all duration-700"
/>
</div>
{/* Zdjęcie 2 (większe, przesunięte w prawo-dół, nachodzące na pierwsze o ok. 18%) */}
<div className="absolute bottom-0 right-0 w-[74%] aspect-[4/5] border border-border shadow-md overflow-hidden z-20">
<Image
src={secondaryUrl}
alt={secondaryAlt}
fill
priority
sizes="(max-width: 1024px) 65vw, 32vw"
className="object-cover object-center grayscale hover:grayscale-0 transition-all duration-700"
/>
</div>
</div>
) : (
/* Pojedyncze zdjęcie jeśli imageSecondary jest puste */
<div className="relative w-full aspect-[4/5] border border-border shadow-sm overflow-hidden">
<Image
src={imageUrl}
alt={imageAlt}
fill
priority
sizes="(max-width: 768px) 100vw, 50vw"
className="object-cover object-center grayscale hover:grayscale-0 transition-all duration-700"
/>
</div>
)}
</div>
</div>
</motion.div>
</div>
</div>
</section>
</motion.section>
)
}
+9
View File
@@ -15,6 +15,15 @@ export const Hero: Block = {
{ name: 'url', type: 'text' },
],
},
{
name: 'floatingStat',
type: 'group',
fields: [
{ name: 'value', type: 'text', localized: true, defaultValue: '20+' },
{ name: 'label', type: 'text', localized: true, defaultValue: 'Lat doświadczenia' },
],
},
{ name: 'image', type: 'upload', relationTo: 'media', required: true },
{ name: 'imageSecondary', type: 'upload', relationTo: 'media' },
],
}
+12
View File
@@ -271,7 +271,12 @@ export interface Page {
text?: string | null;
url?: string | null;
};
floatingStat?: {
value?: string | null;
label?: string | null;
};
image: number | Media;
imageSecondary?: (number | null) | Media;
id?: string | null;
blockName?: string | null;
blockType: 'hero';
@@ -655,7 +660,14 @@ export interface PagesSelect<T extends boolean = true> {
text?: T;
url?: T;
};
floatingStat?:
| T
| {
value?: T;
label?: T;
};
image?: T;
imageSecondary?: T;
id?: T;
blockName?: T;
};