feat: implement homepage blocks, animations and sticky whatsapp CTA

This commit is contained in:
MichalC
2026-08-31 23:04:48 +02:00
parent d728108b3e
commit 0a1e74dbe4
26 changed files with 1056 additions and 43 deletions
+37
View File
@@ -0,0 +1,37 @@
'use client'
import React from 'react'
import { motion } from 'framer-motion'
import Link from 'next/link'
import type { Page } from '../../payload-types'
type Props = Extract<NonNullable<Page['layout']>[0], { blockType: 'affiliation' }>
export const AffiliationBlock: React.FC<Props> = ({ title, description, logo, linkText }) => {
return (
<section className="py-24 bg-slate-50 border-t border-slate-200">
<div className="container mx-auto px-4 max-w-4xl">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="bg-white p-8 md:p-12 rounded-2xl shadow-sm border border-slate-100 flex flex-col md:flex-row items-center gap-10"
>
<div className="flex-1 text-center md:text-left">
<h2 className="text-2xl md:text-3xl font-bold text-slate-900 mb-4">{title}</h2>
<p className="text-slate-600 mb-6 leading-relaxed text-sm md:text-base">{description}</p>
{linkText && (
<Link href="/stowarzyszenie" className="text-blue-600 font-semibold hover:text-blue-700 underline underline-offset-4">
{linkText}
</Link>
)}
</div>
{logo && typeof logo === 'object' && logo.url && (
<div className="w-48 shrink-0 flex items-center justify-center">
<img src={logo.url} alt={logo.alt || "Logo stowarzyszenia"} className="max-w-full h-auto grayscale hover:grayscale-0 transition-all duration-300" />
</div>
)}
</motion.div>
</div>
</section>
)
}
+11
View File
@@ -0,0 +1,11 @@
import type { Block } from 'payload'
export const Affiliation: Block = {
slug: 'affiliation',
fields: [
{ name: 'title', type: 'text', required: true, localized: true },
{ name: 'description', type: 'textarea', required: true, localized: true },
{ name: 'logo', type: 'upload', relationTo: 'media' },
{ name: 'linkText', type: 'text', localized: true },
],
}
+48
View File
@@ -0,0 +1,48 @@
'use client'
import React from 'react'
import { motion } from 'framer-motion'
import Link from 'next/link'
import type { Page } from '../../payload-types'
type Props = Extract<NonNullable<Page['layout']>[0], { blockType: 'faqTeaser' }>
export const FAQTeaserBlock: React.FC<Props> = ({ title, questions, linkText }) => {
return (
<section className="py-24 bg-slate-50">
<div className="container mx-auto px-4 max-w-4xl">
<motion.h2
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="text-3xl md:text-5xl font-bold text-slate-900 mb-12 text-center"
>
{title}
</motion.h2>
<div className="space-y-6 mb-12">
{questions?.map((q: any, index: number) => (
<motion.div
key={q.id || index}
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.4, delay: index * 0.1 }}
className="bg-white p-6 md:p-8 rounded-2xl shadow-sm border border-slate-100"
>
<h3 className="text-xl font-bold text-slate-900 mb-3">{q.question}</h3>
<p className="text-slate-600">{q.answer}</p>
</motion.div>
))}
</div>
{linkText && (
<div className="text-center">
<Link href="/faq" className="inline-block text-blue-600 font-semibold hover:text-blue-700 underline underline-offset-4">
{linkText}
</Link>
</div>
)}
</div>
</section>
)
}
+19
View File
@@ -0,0 +1,19 @@
import type { Block } from 'payload'
export const FAQTeaser: Block = {
slug: 'faqTeaser',
fields: [
{ name: 'title', type: 'text', required: true, localized: true },
{
name: 'questions',
type: 'array',
minRows: 1,
localized: true,
fields: [
{ name: 'question', type: 'text', required: true },
{ name: 'answer', type: 'textarea', required: true },
],
},
{ name: 'linkText', type: 'text', localized: true },
],
}
+50
View File
@@ -0,0 +1,50 @@
'use client'
import React from 'react'
import { motion } from 'framer-motion'
import type { Page } from '../../payload-types'
type Props = Extract<NonNullable<Page['layout']>[0], { blockType: 'finalCta' }>
export const FinalCTABlock: React.FC<Props> = ({ title, subtitle, buttonText }) => {
return (
<section className="py-32 bg-blue-600 text-white text-center">
<div className="container mx-auto px-4 max-w-3xl">
<motion.h2
initial={{ opacity: 0, scale: 0.95 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="text-4xl md:text-5xl font-bold mb-6"
>
{title}
</motion.h2>
{subtitle && (
<motion.p
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.1 }}
className="text-xl md:text-2xl text-blue-100 mb-12"
>
{subtitle}
</motion.p>
)}
<motion.div
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.2 }}
>
<a
href={process.env.NEXT_PUBLIC_WHATSAPP_LINK || '#'}
target="_blank"
rel="noopener noreferrer"
className="inline-block px-10 py-5 bg-green-500 hover:bg-green-400 text-white text-lg font-bold rounded-xl transition-transform hover:scale-105 shadow-lg shadow-green-500/30"
>
{buttonText}
</a>
</motion.div>
</div>
</section>
)
}
+10
View File
@@ -0,0 +1,10 @@
import type { Block } from 'payload'
export const FinalCTA: Block = {
slug: 'finalCta',
fields: [
{ name: 'title', type: 'text', required: true, localized: true },
{ name: 'subtitle', type: 'text', localized: true },
{ name: 'buttonText', type: 'text', required: true, localized: true },
],
}
+69
View File
@@ -0,0 +1,69 @@
'use client'
import React from 'react'
import { motion } from 'framer-motion'
import type { Page } from '../../payload-types'
import Link from 'next/link'
type Props = Extract<NonNullable<Page['layout']>[0], { blockType: 'hero' }>
export const HeroBlock: React.FC<Props> = ({ title, subtitle, primaryCta, secondaryCta, yearsOfExperience }) => {
return (
<section className="relative overflow-hidden bg-slate-900 text-white py-24 lg:py-32">
<div className="absolute inset-0 bg-gradient-to-br from-blue-900/40 to-slate-900 z-0" />
<div className="container mx-auto px-4 relative z-10 flex flex-col items-center text-center">
<motion.h1
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
className="text-4xl md:text-6xl font-bold mb-6 max-w-4xl"
>
{title}
</motion.h1>
<motion.p
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.1 }}
className="text-xl md:text-2xl text-slate-300 mb-10 max-w-2xl"
>
{subtitle}
</motion.p>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.2 }}
className="flex flex-col sm:flex-row gap-4 mb-12"
>
<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-semibold rounded-lg transition-colors flex items-center justify-center gap-2"
>
{primaryCta}
</a>
{secondaryCta?.text && secondaryCta?.url && (
<Link
href={secondaryCta.url}
className="px-8 py-4 bg-transparent border-2 border-slate-600 hover:border-slate-400 text-white font-semibold rounded-lg transition-colors"
>
{secondaryCta.text}
</Link>
)}
</motion.div>
{yearsOfExperience && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.6, delay: 0.4 }}
className="text-sm text-slate-400 uppercase tracking-widest"
>
{yearsOfExperience}
</motion.div>
)}
</div>
</section>
)
}
+19
View File
@@ -0,0 +1,19 @@
import type { Block } from 'payload'
export const Hero: Block = {
slug: 'hero',
fields: [
{ name: 'title', type: 'text', required: true, localized: true },
{ name: 'subtitle', type: 'text', required: true, localized: true },
{ name: 'primaryCta', type: 'text', required: true, localized: true },
{
name: 'secondaryCta',
type: 'group',
fields: [
{ name: 'text', type: 'text', localized: true },
{ name: 'url', type: 'text' },
],
},
{ name: 'yearsOfExperience', type: 'text', localized: true },
],
}
+62
View File
@@ -0,0 +1,62 @@
'use client'
import React from 'react'
import { motion } from 'framer-motion'
import Link from 'next/link'
import type { Page } from '../../payload-types'
type Props = Extract<NonNullable<Page['layout']>[0], { blockType: 'process' }>
export const ProcessBlock: React.FC<Props> = ({ title, steps, ctaText }) => {
return (
<section className="py-24 bg-slate-900 text-white">
<div className="container mx-auto px-4">
<motion.h2
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="text-3xl md:text-5xl font-bold mb-16 text-center"
>
{title}
</motion.h2>
<div className="max-w-4xl mx-auto">
<div className="flex flex-col md:flex-row gap-8 relative">
{/* Linia łącząca na desktopie */}
<div className="hidden md:block absolute top-6 left-[10%] right-[10%] h-0.5 bg-slate-800 z-0" />
{steps?.map((step: any, index: number) => (
<motion.div
key={step.id || index}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.15 }}
className="flex-1 relative z-10 text-center md:text-left flex flex-col items-center md:items-start"
>
<div className="w-12 h-12 rounded-full bg-blue-600 border-4 border-slate-900 flex items-center justify-center text-xl font-bold mb-6">
{index + 1}
</div>
<h3 className="text-xl font-bold mb-3">{step.title}</h3>
<p className="text-slate-400 text-center md:text-left">{step.description}</p>
</motion.div>
))}
</div>
{ctaText && (
<motion.div
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.6 }}
className="mt-16 text-center"
>
<Link href="/jak-to-dziala" className="inline-block px-8 py-4 border-2 border-slate-700 hover:border-slate-500 rounded-lg text-white font-semibold transition-colors">
{ctaText}
</Link>
</motion.div>
)}
</div>
</div>
</section>
)
}
+20
View File
@@ -0,0 +1,20 @@
import type { Block } from 'payload'
export const Process: Block = {
slug: 'process',
fields: [
{ name: 'title', type: 'text', required: true, localized: true },
{
name: 'steps',
type: 'array',
minRows: 1,
maxRows: 3,
localized: true,
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'description', type: 'textarea', required: true },
],
},
{ name: 'ctaText', type: 'text', localized: true },
],
}
+55
View File
@@ -0,0 +1,55 @@
import React, { Fragment } from 'react'
import type { Page } from '../payload-types'
import { HeroBlock } from './Hero/Component'
import { TrustBarBlock } from './TrustBar/Component'
import { UniqueValueBlock } from './UniqueValue/Component'
import { ServicesListBlock } from './ServicesList/Component'
import { ProcessBlock } from './Process/Component'
import { AffiliationBlock } from './Affiliation/Component'
import { TestimonialsBlock } from './Testimonials/Component'
import { FAQTeaserBlock } from './FAQTeaser/Component'
import { FinalCTABlock } from './FinalCTA/Component'
const blockComponents = {
hero: HeroBlock,
trustBar: TrustBarBlock,
uniqueValue: UniqueValueBlock,
servicesList: ServicesListBlock,
process: ProcessBlock,
affiliation: AffiliationBlock,
testimonials: TestimonialsBlock,
faqTeaser: FAQTeaserBlock,
finalCta: FinalCTABlock,
TextSection: ({ text }: { text: string }) => <section className="py-20"><div className="container mx-auto px-4"><p>{text}</p></div></section>,
}
export const RenderBlocks: React.FC<{
blocks: Page['layout']
}> = (props) => {
const { blocks } = props
const hasBlocks = blocks && Array.isArray(blocks) && blocks.length > 0
if (hasBlocks) {
return (
<Fragment>
{blocks.map((block, index) => {
const { blockType } = block
if (blockType && blockType in blockComponents) {
const Block = blockComponents[blockType as keyof typeof blockComponents]
if (Block) {
return (
<Block key={index} id={`block-${index}`} {...(block as any)} />
)
}
}
return null
})}
</Fragment>
)
}
return null
}
+47
View File
@@ -0,0 +1,47 @@
'use client'
import React from 'react'
import { motion } from 'framer-motion'
import { ArrowRight } from 'lucide-react'
import Link from 'next/link'
import type { Page } from '../../payload-types'
type Props = Extract<NonNullable<Page['layout']>[0], { blockType: 'servicesList' }>
export const ServicesListBlock: React.FC<Props> = ({ title, services }) => {
return (
<section className="py-24 bg-white">
<div className="container mx-auto px-4">
<motion.h2
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="text-3xl md:text-5xl font-bold text-slate-900 mb-16 text-center"
>
{title}
</motion.h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{services?.map((service: any, index: number) => (
<motion.div
key={service.id || index}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }}
className="group"
>
<Link href="/uslugi" className="block h-full p-8 rounded-2xl bg-slate-50 border border-slate-100 transition-all duration-300 hover:-translate-y-2 hover:shadow-xl hover:bg-white">
<h3 className="text-xl font-bold text-slate-900 mb-4">{service.title}</h3>
<p className="text-slate-600 mb-8">{service.description}</p>
<div className="flex items-center text-blue-600 font-semibold group-hover:text-blue-700 mt-auto">
{service.linkText}
<ArrowRight size={20} className="ml-2 transition-transform group-hover:translate-x-1" />
</div>
</Link>
</motion.div>
))}
</div>
</div>
</section>
)
}
+19
View File
@@ -0,0 +1,19 @@
import type { Block } from 'payload'
export const ServicesList: Block = {
slug: 'servicesList',
fields: [
{ name: 'title', type: 'text', required: true, localized: true },
{
name: 'services',
type: 'array',
minRows: 1,
localized: true,
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'description', type: 'textarea', required: true },
{ name: 'linkText', type: 'text', required: true },
],
},
],
}
+41
View File
@@ -0,0 +1,41 @@
'use client'
import React from 'react'
import { motion } from 'framer-motion'
import { Quote } from 'lucide-react'
import type { Page } from '../../payload-types'
type Props = Extract<NonNullable<Page['layout']>[0], { blockType: 'testimonials' }>
export const TestimonialsBlock: React.FC<Props> = ({ title, testimonials }) => {
return (
<section className="py-24 bg-white">
<div className="container mx-auto px-4 max-w-6xl">
<motion.h2
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="text-3xl md:text-5xl font-bold text-slate-900 mb-16 text-center"
>
{title}
</motion.h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{testimonials?.map((t: any, index: number) => (
<motion.div
key={t.id || index}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }}
className="bg-slate-50 p-8 rounded-2xl relative"
>
<Quote className="absolute top-6 right-6 text-slate-200" size={40} />
<p className="text-slate-700 italic mb-6 relative z-10">"{t.quote}"</p>
<div className="font-semibold text-slate-900">— {t.author}</div>
</motion.div>
))}
</div>
</div>
</section>
)
}
+17
View File
@@ -0,0 +1,17 @@
import type { Block } from 'payload'
export const Testimonials: Block = {
slug: 'testimonials',
fields: [
{ name: 'title', type: 'text', required: true, localized: true },
{
name: 'testimonials',
type: 'array',
localized: true,
fields: [
{ name: 'quote', type: 'textarea', required: true },
{ name: 'author', type: 'text', required: true },
],
},
],
}
+45
View File
@@ -0,0 +1,45 @@
'use client'
import React from 'react'
import { motion } from 'framer-motion'
import { Shield, CheckCircle, MessageCircle, Star } from 'lucide-react'
import type { Page } from '../../payload-types'
type Props = Extract<NonNullable<Page['layout']>[0], { blockType: 'trustBar' }>
const iconMap = {
shield: Shield,
check: CheckCircle,
message: MessageCircle,
star: Star,
}
export const TrustBarBlock: React.FC<Props> = ({ items }) => {
if (!items || items.length === 0) return null
return (
<section className="bg-slate-100 py-8 border-b border-slate-200">
<div className="container mx-auto px-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 text-center">
{items.map((item: any, index: number) => {
const Icon = item.icon ? iconMap[item.icon as keyof typeof iconMap] : CheckCircle
return (
<motion.div
key={item.id || index}
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }}
className="flex flex-col items-center justify-center gap-3"
>
<div className="text-blue-600">
<Icon size={28} />
</div>
<span className="text-sm font-medium text-slate-700">{item.text}</span>
</motion.div>
)
})}
</div>
</div>
</section>
)
}
+18
View File
@@ -0,0 +1,18 @@
import type { Block } from 'payload'
export const TrustBar: Block = {
slug: 'trustBar',
fields: [
{
name: 'items',
type: 'array',
minRows: 1,
maxRows: 4,
localized: true,
fields: [
{ name: 'text', type: 'text', required: true },
{ name: 'icon', type: 'select', options: ['shield', 'check', 'message', 'star'] },
],
},
],
}
+34
View File
@@ -0,0 +1,34 @@
'use client'
import React from 'react'
import { motion } from 'framer-motion'
import type { Page } from '../../payload-types'
type Props = Extract<NonNullable<Page['layout']>[0], { blockType: 'uniqueValue' }>
export const UniqueValueBlock: React.FC<Props> = ({ title, description }) => {
return (
<section className="py-24 bg-blue-50 relative overflow-hidden">
<div className="absolute top-0 right-0 -mr-20 -mt-20 w-64 h-64 rounded-full bg-blue-100 opacity-50 blur-3xl" />
<div className="container mx-auto px-4 relative z-10 text-center max-w-3xl">
<motion.h2
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
className="text-3xl md:text-5xl font-bold text-slate-900 mb-8"
>
{title}
</motion.h2>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.2 }}
className="text-lg md:text-xl text-slate-700 leading-relaxed"
>
{description}
</motion.div>
</div>
</section>
)
}
+9
View File
@@ -0,0 +1,9 @@
import type { Block } from 'payload'
export const UniqueValue: Block = {
slug: 'uniqueValue',
fields: [
{ name: 'title', type: 'text', required: true, localized: true },
{ name: 'description', type: 'textarea', required: true, localized: true },
],
}