Fix FAQFull: Use isDraft boolean for draft filtering, fix Kontakt ID override
This commit is contained in:
@@ -11,14 +11,6 @@ import type { Page } from '../../../../payload-types'
|
||||
|
||||
type FAQFullBlock = Extract<NonNullable<Page['layout']>[0], { blockType: 'faqFull' }>
|
||||
|
||||
const PLACEHOLDER_PL = "Odpowiedź w przygotowaniu"
|
||||
const PLACEHOLDER_DE = "Antwort in Vorbereitung"
|
||||
|
||||
function isPlaceholder(answer: string | null | undefined): boolean {
|
||||
if (!answer) return true;
|
||||
return answer.includes(PLACEHOLDER_PL) || answer.includes(PLACEHOLDER_DE) || answer.includes('DO POTWIERDZENIA');
|
||||
}
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{ locale: string; slug?: string[] }>
|
||||
}) {
|
||||
@@ -62,7 +54,7 @@ export default async function Page(props: {
|
||||
let faqSchema: Record<string, any> | null = null;
|
||||
const faqBlock = layout.find((b: any) => b.blockType === 'faqFull') as FAQFullBlock | undefined;
|
||||
if (faqBlock && faqBlock.questions) {
|
||||
const validQuestions = faqBlock.questions.filter((q: any) => !isPlaceholder(q.answer));
|
||||
const validQuestions = faqBlock.questions.filter((q: any) => !q.isDraft);
|
||||
if (validQuestions.length > 0) {
|
||||
faqSchema = {
|
||||
"@context": "https://schema.org",
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import React from 'react'
|
||||
import { Public_Sans, Fraunces } from 'next/font/google'
|
||||
import { Providers } from '../../components/Providers'
|
||||
import { LanguageSlugsProvider } from '../../components/LanguageSlugsProvider'
|
||||
import { Header } from '../../components/Header'
|
||||
import { Footer } from '../../components/Footer'
|
||||
import { Providers } from '../../../components/Providers'
|
||||
import { LanguageSlugsProvider } from '../../../components/LanguageSlugsProvider'
|
||||
import { Header } from '../../../components/Header'
|
||||
import { Footer } from '../../../components/Footer'
|
||||
import { getPayload } from 'payload'
|
||||
import configPromise from '@payload-config'
|
||||
import '../globals.css'
|
||||
import type { Config, Page } from '../../../payload-types'
|
||||
import { getGlobal } from '../../../lib/content'
|
||||
import '../../globals.css'
|
||||
import { ConsentProvider, CookieBanner, CookieButton } from '@intecion/ipal-kit/client'
|
||||
import { FloatingWhatsApp } from '../../../components/FloatingWhatsApp'
|
||||
import { getConsentTexts } from '@intecion/ipal-kit'
|
||||
import { i18n } from '../../../i18n.config'
|
||||
|
||||
const publicSans = Public_Sans({ subsets: ['latin'], variable: '--font-sans' })
|
||||
const fraunces = Fraunces({
|
||||
@@ -19,66 +25,74 @@ export const metadata = {
|
||||
description: 'Profesjonalne doradztwo podatkowe.',
|
||||
}
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode
|
||||
}>) {
|
||||
export default async function RootLayout(props: {
|
||||
children: React.ReactNode,
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { children } = props;
|
||||
const { locale } = await props.params;
|
||||
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const typedLocale = locale as Config['locale']
|
||||
|
||||
// Pobieramy Globals z locale 'all', by od razu otrzymać zlokalizowane dane we wszystkich obsługiwanych językach
|
||||
// Używamy overrideAccess: false, żeby Payload nie zwracał stron, które nie są opublikowane
|
||||
const [headerData, footerData, companyData] = await Promise.all([
|
||||
payload.findGlobal({
|
||||
slug: 'header',
|
||||
locale: 'all',
|
||||
depth: 1,
|
||||
overrideAccess: false,
|
||||
}),
|
||||
payload.findGlobal({
|
||||
slug: 'footer',
|
||||
locale: 'all',
|
||||
depth: 1,
|
||||
overrideAccess: false,
|
||||
}),
|
||||
payload.findGlobal({
|
||||
slug: 'company',
|
||||
locale: 'all',
|
||||
overrideAccess: false,
|
||||
}),
|
||||
const [headerData, footerData, companyData, siteData] = await Promise.all([
|
||||
getGlobal('header', 1, typedLocale),
|
||||
getGlobal('footer', 1, typedLocale),
|
||||
getGlobal('company', 0, typedLocale),
|
||||
getGlobal('site-settings', 1, typedLocale),
|
||||
]);
|
||||
|
||||
// Filtrujemy tylko poprawnie spopulowane strony (chroni przed brakiem dostępu lub usuniętymi relacjami) lub linki anchor
|
||||
const headerNavItems = (headerData.navigation || []).filter((item: any) => (item.page && typeof item.page === 'object' && item.page !== null) || item.anchor);
|
||||
const footerNavItems = (footerData.navigation || []).map((item: any) => item.page).filter((page: any) => typeof page === 'object' && page !== null);
|
||||
const headerNavItems = (headerData?.navigation || []).filter((item) => (item.page && typeof item.page === 'object' && item.page !== null) || item.anchor);
|
||||
const footerNavItems = (footerData?.navigation || []).map((item) => item.page).filter((page): page is Page => typeof page === 'object' && page !== null);
|
||||
|
||||
const consentTexts = await getConsentTexts({
|
||||
payload,
|
||||
config: i18n,
|
||||
locale: locale,
|
||||
privacyPolicy: { label: locale === 'de' ? 'Datenschutzerklärung / Cookies' : 'Polityka prywatności / Cookies', page: siteData?.cookiePolicy as unknown as { slug?: Record<string, unknown> } | null },
|
||||
});
|
||||
|
||||
const cookieButtonAriaLabel = locale === 'de' ? 'Cookie-Einstellungen' : 'Ustawienia cookies';
|
||||
const whatsappAriaLabel = locale === 'de' ? 'Auf WhatsApp schreiben' : 'Napisz na WhatsApp';
|
||||
|
||||
// CMS: fallback to generic link if no number is configured.
|
||||
const whatsappLink = companyData?.whatsappNumber ? `https://wa.me/${companyData.whatsappNumber}` : '#';
|
||||
|
||||
return (
|
||||
<html lang="pl" suppressHydrationWarning className={`${publicSans.variable} ${fraunces.variable}`}>
|
||||
<body>
|
||||
<Providers>
|
||||
<LanguageSlugsProvider>
|
||||
<Header navItems={headerNavItems} />
|
||||
<main className="min-h-screen pt-24">{children}</main>
|
||||
<Footer
|
||||
navItems={footerNavItems}
|
||||
companyInfo={companyData}
|
||||
association={footerData.association}
|
||||
copyright={footerData.copyright}
|
||||
creator={footerData.creator}
|
||||
/>
|
||||
<html lang={locale} suppressHydrationWarning className={`${publicSans.variable} ${fraunces.variable}`}>
|
||||
<body className="font-sans antialiased bg-background text-foreground min-h-screen flex flex-col">
|
||||
<Providers>
|
||||
<LanguageSlugsProvider>
|
||||
<ConsentProvider texts={consentTexts}>
|
||||
<Header
|
||||
navItems={headerNavItems}
|
||||
whatsappNumber={companyData?.whatsappNumber}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
{children}
|
||||
</div>
|
||||
<Footer
|
||||
navItems={footerNavItems}
|
||||
companyInfo={companyData}
|
||||
association={footerData?.association}
|
||||
copyright={footerData?.copyright}
|
||||
creator={footerData?.creator}
|
||||
/>
|
||||
<div className="ipal-banner-wrapper">
|
||||
<CookieBanner />
|
||||
</div>
|
||||
<div className="fixed bottom-6 left-6 z-40">
|
||||
<CookieButton aria-label={cookieButtonAriaLabel} className="w-12 h-12 rounded-full bg-background border border-border shadow-md flex items-center justify-center text-foreground hover:bg-card transition-colors [&>svg]:w-6 [&>svg]:h-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2" />
|
||||
</div>
|
||||
</ConsentProvider>
|
||||
|
||||
{/* Floating WhatsApp Button */}
|
||||
<a
|
||||
href={process.env.NEXT_PUBLIC_WHATSAPP_LINK || '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="fixed bottom-6 right-6 z-50 flex items-center justify-center w-14 h-14 bg-green-500 hover:bg-green-600 text-white rounded-full shadow-lg shadow-green-500/30 transition-transform hover:scale-110 animate-in fade-in slide-in-from-bottom-10 duration-500"
|
||||
aria-label="Napisz na WhatsApp"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>
|
||||
</svg>
|
||||
</a>
|
||||
{companyData?.whatsappNumber && (
|
||||
<FloatingWhatsApp
|
||||
whatsappLink={whatsappLink}
|
||||
ariaLabel={locale === 'de' ? 'Auf WhatsApp schreiben' : 'Napisz do nas na WhatsApp'}
|
||||
/>
|
||||
)}
|
||||
</LanguageSlugsProvider>
|
||||
</Providers>
|
||||
</body>
|
||||
|
||||
@@ -1,9 +1,55 @@
|
||||
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { MaskedField as MaskedField_630f031911beff97691b963f10a20a86 } from '@intecion/ipal-kit/client'
|
||||
import { TestEmailButton as TestEmailButton_630f031911beff97691b963f10a20a86 } from '@intecion/ipal-kit/client'
|
||||
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
|
||||
|
||||
/** @type import('payload').ImportMap */
|
||||
export const importMap = {
|
||||
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UploadFeatureClient": UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#BlockquoteFeatureClient": BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#RelationshipFeatureClient": RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ChecklistFeatureClient": ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#OrderedListFeatureClient": OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UnorderedListFeatureClient": UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#IndentFeatureClient": IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#AlignFeatureClient": AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#InlineCodeFeatureClient": InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#SuperscriptFeatureClient": SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#SubscriptFeatureClient": SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#StrikethroughFeatureClient": StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@intecion/ipal-kit/client#MaskedField": MaskedField_630f031911beff97691b963f10a20a86,
|
||||
"@intecion/ipal-kit/client#TestEmailButton": TestEmailButton_630f031911beff97691b963f10a20a86,
|
||||
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
|
||||
|
||||
+13
-1
@@ -61,7 +61,8 @@ h1, h2, h3, h4, h5, h6 {
|
||||
|
||||
/* Stylizacja elementów interaktywnych dla "premium feel" */
|
||||
.btn-primary {
|
||||
@apply inline-flex items-center justify-center rounded-none px-6 py-3 font-medium text-white transition-all duration-300;
|
||||
@apply inline-flex items-center justify-center px-6 py-3 font-medium text-white transition-all duration-300;
|
||||
border-radius: var(--radius-button);
|
||||
background-color: var(--accent-color);
|
||||
}
|
||||
.btn-primary:hover {
|
||||
@@ -73,3 +74,14 @@ h1, h2, h3, h4, h5, h6 {
|
||||
.container-premium {
|
||||
@apply max-w-7xl mx-auto px-6 md:px-12 lg:px-24;
|
||||
}
|
||||
|
||||
/* Tekstura papieru dla sekcji (bardzo subtelny szum) */
|
||||
.bg-paper-noise {
|
||||
background-color: var(--bg-color);
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)' opacity='0.04'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* Fix ipal-kit justified text anti-pattern */
|
||||
.ipal-banner-wrapper p.text-justify {
|
||||
text-align: left !important;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
'use client'
|
||||
import React from 'react'
|
||||
|
||||
import { Landmark, Scale, ShieldCheck } from 'lucide-react'
|
||||
import { Landmark, Scale, ShieldCheck, CheckCircle } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import type { Page } from '../../payload-types'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
type Props = Extract<NonNullable<Page['layout']>[0], { blockType: 'affiliation' }>
|
||||
|
||||
@@ -14,52 +15,83 @@ const iconMap = {
|
||||
}
|
||||
|
||||
export const AffiliationBlock: React.FC<Props> = ({ eyebrow, title, description, logo, linkText, linkUrl, trustPoints }) => {
|
||||
const logoUrl = (logo && typeof logo === 'object' && logo.url) ? logo.url : null
|
||||
|
||||
return (
|
||||
<section className="py-20 md:py-32 bg-background border-t border-border overflow-hidden">
|
||||
<section className="py-20 md:py-32 bg-accent/5 border-y border-accent/10 overflow-hidden">
|
||||
<div className="container-premium">
|
||||
<div className="flex flex-col gap-16 lg:gap-24">
|
||||
<div className="max-w-3xl">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-16 lg:gap-24 items-center">
|
||||
|
||||
{/* Lewa kolumna: Logo (Stamp) */}
|
||||
<div className="lg:col-span-5 flex justify-center lg:justify-end order-1">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 1.5, rotate: -15 }}
|
||||
whileInView={{ opacity: 1, scale: 1, rotate: 0 }}
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
transition={{ type: "spring", stiffness: 100, damping: 15 }}
|
||||
className="w-48 h-48 md:w-64 md:h-64 relative bg-card rounded-full p-8 shadow-xl border-4 border-accent/20 flex items-center justify-center shrink-0"
|
||||
>
|
||||
{logoUrl ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt={(logo && typeof logo === 'object' && logo.alt) || "Logo b.b.h. Lohnsteuerhilfeverein e.V."}
|
||||
className="max-w-full max-h-full object-contain mix-blend-multiply"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center text-border bg-card rounded-full">
|
||||
<Landmark size={48} strokeWidth={1.5} />
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Prawa kolumna: Tekst i lista */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 30 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1], delay: 0.2 }}
|
||||
className="lg:col-span-7 flex flex-col items-start order-2"
|
||||
>
|
||||
<h2 className="font-heading text-3xl sm:text-4xl md:text-5xl font-extrabold tracking-tight text-foreground mb-6 text-balance">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-foreground/75 text-lg md:text-xl font-light leading-relaxed mb-12 max-w-xl">
|
||||
<p className="text-foreground/80 text-lg md:text-xl font-light leading-relaxed mb-10 max-w-2xl">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-8">
|
||||
{logo && typeof logo === 'object' && logo.url && (
|
||||
<div className="w-40 shrink-0">
|
||||
<img src={logo.url} alt={logo.alt || "Logo stowarzyszenia"} className="max-w-full h-auto grayscale hover:grayscale-0 transition-all duration-300" />
|
||||
</div>
|
||||
)}
|
||||
{linkText && linkUrl && (
|
||||
linkUrl.startsWith('#') ? (
|
||||
<a href={linkUrl} className="text-accent font-semibold text-lg hover:text-accent-hover underline underline-offset-4">
|
||||
{trustPoints && trustPoints.length > 0 && (
|
||||
<div className="flex flex-col gap-5 mb-10 w-full">
|
||||
{trustPoints.map((point: NonNullable<Props['trustPoints']>[0], index: number) => {
|
||||
return (
|
||||
<div key={point.id || index} className="flex items-start gap-4">
|
||||
<CheckCircle size={24} className="text-accent shrink-0 mt-0.5" strokeWidth={2} />
|
||||
<div>
|
||||
<h3 className="font-heading text-xl font-bold text-foreground mb-1">{point.title}</h3>
|
||||
<p className="text-base text-foreground/75 leading-relaxed">{point.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{linkText && linkUrl && (
|
||||
<div className="mt-2 pt-8 border-t border-accent/20 w-full">
|
||||
{linkUrl.startsWith('#') ? (
|
||||
<a href={linkUrl} className="inline-flex items-center gap-2 text-accent font-bold text-lg hover:text-accent-hover transition-colors">
|
||||
{linkText}
|
||||
</a>
|
||||
) : (
|
||||
<Link href={linkUrl} className="text-accent font-semibold text-lg hover:text-accent-hover underline underline-offset-4">
|
||||
<Link href={linkUrl} className="inline-flex items-center gap-2 text-accent font-bold text-lg hover:text-accent-hover transition-colors">
|
||||
{linkText}
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{trustPoints && trustPoints.length > 0 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6">
|
||||
{trustPoints.map((point: any, index: number) => {
|
||||
const Icon = (point.icon && iconMap[point.icon as keyof typeof iconMap]) || ShieldCheck
|
||||
return (
|
||||
<div key={point.id || index} className="bg-card border border-border p-6 md:p-8 flex flex-col gap-4">
|
||||
<Icon size={28} className="text-accent" />
|
||||
<h3 className="font-heading text-xl font-bold text-foreground">{point.title}</h3>
|
||||
<p className="text-sm text-foreground/70 leading-relaxed">{point.description}</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client'
|
||||
import React from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import React, { useRef } from 'react'
|
||||
import { motion, useScroll, useTransform } from 'framer-motion'
|
||||
import type { Page, Media } from '../../payload-types'
|
||||
import Image from 'next/image'
|
||||
import { Check, User } from 'lucide-react'
|
||||
|
||||
type Props = Extract<NonNullable<Page['layout']>[0], { blockType: 'bio' }>
|
||||
|
||||
@@ -22,41 +23,61 @@ const sectionVariants = {
|
||||
|
||||
export const BioBlock: React.FC<Props> = ({ title, paragraphs, credentials, image }) => {
|
||||
const bioMedia = typeof image === 'object' && image !== null ? (image as Media) : null
|
||||
// PLACEHOLDER - do podmiany na realne zdjęcie Janusza Kędzierskiego (bez twarzy na etapie placeholderu)
|
||||
const imageUrl = bioMedia?.url || 'https://images.unsplash.com/photo-1554224155-8d04cb21cd6c?auto=format&fit=crop&w=1200&q=80'
|
||||
const imageAlt = bioMedia?.alt || 'Janusz Kędzierski - Doradztwo podatkowe'
|
||||
const imageUrl = bioMedia?.url
|
||||
const imageAlt = bioMedia?.alt || ''
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: containerRef,
|
||||
offset: ["start end", "end start"]
|
||||
})
|
||||
|
||||
// Parallax tilt effect
|
||||
const y = useTransform(scrollYProgress, [0, 1], [40, -40])
|
||||
const rotateX = useTransform(scrollYProgress, [0, 1], [4, -4])
|
||||
|
||||
return (
|
||||
<motion.section
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, margin: "-100px" }}
|
||||
variants={sectionVariants}
|
||||
className="py-20 md:py-32 bg-background border-y border-border overflow-hidden"
|
||||
>
|
||||
<div className="container-premium">
|
||||
<div className="container-premium" ref={containerRef}>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-14 lg:gap-20 items-center">
|
||||
|
||||
{/* Kolumna ze zdjęciem */}
|
||||
<div className="lg:col-span-5 order-2 lg:order-1">
|
||||
<div className="relative aspect-[4/5] overflow-hidden border border-border rounded-none shadow-sm">
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={imageAlt}
|
||||
fill
|
||||
sizes="(max-width: 1024px) 100vw, 42vw"
|
||||
className="object-cover grayscale hover:grayscale-0 transition-all duration-700"
|
||||
/>
|
||||
</div>
|
||||
<div className="lg:col-span-5 order-2 lg:order-1" style={{ perspective: '1200px' }}>
|
||||
<motion.div
|
||||
style={{ y, rotateX, transformStyle: "preserve-3d" }}
|
||||
className="relative aspect-[4/5] overflow-hidden"
|
||||
>
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={imageAlt}
|
||||
fill
|
||||
sizes="(max-width: 1024px) 100vw, 42vw"
|
||||
className="object-cover transition-all duration-700"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center bg-transparent text-border gap-4 p-8 text-center">
|
||||
<User size={64} strokeWidth={1} />
|
||||
<span className="text-xs tracking-widest uppercase font-semibold text-foreground/40">ZDJĘCIE PORTRETOWE</span>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Kolumna tekstowa */}
|
||||
<div className="lg:col-span-7 order-1 lg:order-2 flex flex-col items-start max-w-3xl">
|
||||
<div className="lg:col-span-7 order-1 lg:order-2 flex flex-col items-start">
|
||||
<h2 className="font-heading text-3xl sm:text-4xl md:text-5xl font-extrabold text-foreground tracking-tight mb-8 text-balance">
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
{paragraphs && paragraphs.length > 0 && (
|
||||
<div className="space-y-5 text-foreground/80 text-lg md:text-xl font-light leading-relaxed mb-10">
|
||||
<div className="space-y-6 text-foreground/80 text-lg md:text-xl leading-relaxed mb-10 max-w-[70ch]">
|
||||
{paragraphs.map((p: any, index: number) => (
|
||||
<p key={p.id || index}>
|
||||
{p.text}
|
||||
@@ -66,11 +87,11 @@ export const BioBlock: React.FC<Props> = ({ title, paragraphs, credentials, imag
|
||||
)}
|
||||
|
||||
{credentials && credentials.length > 0 && (
|
||||
<div className="flex flex-col gap-3.5 w-full pt-8 border-t border-border">
|
||||
<div className="flex flex-col gap-4 w-full pt-8 border-t border-border/50">
|
||||
{credentials.map((cred: any, index: number) => (
|
||||
<div key={cred.id || index} className="flex items-start gap-3 text-foreground/85 text-base">
|
||||
<span className="w-2 h-2 rounded-full bg-accent mt-2 shrink-0" />
|
||||
<span className="font-medium">{cred.text}</span>
|
||||
<div key={cred.id || index} className="flex items-start gap-3 text-foreground/90 text-base max-w-[70ch]">
|
||||
<Check size={20} className="text-accent shrink-0 mt-0.5" strokeWidth={2.5} />
|
||||
<span className="font-medium leading-relaxed">{cred.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
'use client'
|
||||
import React, { useState } from 'react'
|
||||
import React from 'react'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { MapPin, Phone, Mail, Clock, MessageCircle, Send } from 'lucide-react'
|
||||
import { MapPin, Phone, Mail, Clock } from 'lucide-react'
|
||||
import type { Page } from '../../payload-types'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
type Props = Extract<NonNullable<Page['layout']>[0], { blockType: 'contactInfo' }>
|
||||
type BaseProps = Extract<NonNullable<Page['layout']>[0], { blockType: 'contactInfo' }>
|
||||
type Props = BaseProps & { whatsappNumber?: string | null }
|
||||
|
||||
export const ContactInfoBlock: React.FC<Props> = ({
|
||||
address = 'DO POTWIERDZENIA PRZEZ KLIENTA',
|
||||
@@ -13,240 +15,92 @@ export const ContactInfoBlock: React.FC<Props> = ({
|
||||
hours = 'Pon-Pt: 9:00 - 17:00\nPo wcześniejszym umówieniu',
|
||||
}) => {
|
||||
const pathname = usePathname()
|
||||
const isDe = pathname?.startsWith('/de')
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
service: '',
|
||||
name: '',
|
||||
email: '',
|
||||
message: '',
|
||||
})
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
// TODO: Podłączyć logikę wysyłki formularza przez backend API (np. Resend / SMTP / Payload custom endpoint).
|
||||
// Pamiętać o przekazaniu w payloadzie: formData.service (wybrana usługa), formData.name, formData.email, formData.message.
|
||||
setSubmitted(true)
|
||||
}
|
||||
const isDe = pathname?.split('/').filter(Boolean)[0] === 'de'
|
||||
|
||||
return (
|
||||
<section className="py-12 md:py-16 bg-background">
|
||||
<div className="container-premium">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 lg:gap-16 items-start">
|
||||
|
||||
{/* Kolumna 1: Dane kontaktowe + WhatsApp CTA (Główna opcja) */}
|
||||
<div className="lg:col-span-5 flex flex-col gap-8">
|
||||
<div className="bg-card border border-border p-8 rounded-none shadow-sm flex flex-col gap-6">
|
||||
<h2 className="font-heading text-2xl font-bold text-foreground">
|
||||
{isDe ? 'Direkter Kontakt' : 'Bezpośredni kontakt'}
|
||||
</h2>
|
||||
|
||||
<ul className="flex flex-col gap-5 text-foreground/80 text-base">
|
||||
{address && (
|
||||
<li className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-none bg-accent/10 flex items-center justify-center text-accent shrink-0 mt-0.5">
|
||||
<MapPin size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs text-foreground/50 uppercase tracking-wider font-semibold block mb-0.5">
|
||||
{isDe ? 'Adresse' : 'Adres'}
|
||||
</span>
|
||||
<span className="font-medium whitespace-pre-line text-foreground">{address}</span>
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
<section className="py-16 md:py-24 bg-background">
|
||||
<div className="container-premium flex justify-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="w-full max-w-4xl bg-card border border-border p-8 md:p-16 relative overflow-hidden"
|
||||
style={{
|
||||
boxShadow: '0 10px 40px -10px rgba(0,0,0,0.08), 0 1px 3px rgba(0,0,0,0.03)'
|
||||
}}
|
||||
>
|
||||
{/* Subtelny narożnik (zagięcie kartki) */}
|
||||
<div className="absolute top-0 right-0 w-12 h-12 bg-background border-l border-b border-border shadow-sm transform translate-x-6 -translate-y-6 rotate-45 pointer-events-none" />
|
||||
|
||||
{phone && (
|
||||
<li className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-none bg-accent/10 flex items-center justify-center text-accent shrink-0 mt-0.5">
|
||||
<Phone size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs text-foreground/50 uppercase tracking-wider font-semibold block mb-0.5">
|
||||
{isDe ? 'Telefon' : 'Telefon'}
|
||||
</span>
|
||||
<a href={`tel:${phone.replace(/\s+/g, '')}`} className="font-medium hover:text-accent transition-colors text-foreground">
|
||||
{phone}
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
<h2 className="font-heading text-2xl md:text-3xl font-bold text-foreground mb-10 text-center md:text-left">
|
||||
{isDe ? 'Kontaktinformationen' : 'Dane kontaktowe'}
|
||||
</h2>
|
||||
|
||||
{email && (
|
||||
<li className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-none bg-accent/10 flex items-center justify-center text-accent shrink-0 mt-0.5">
|
||||
<Mail size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs text-foreground/50 uppercase tracking-wider font-semibold block mb-0.5">
|
||||
{isDe ? 'E-Mail' : 'E-mail'}
|
||||
</span>
|
||||
<a href={`mailto:${email}`} className="font-medium hover:text-accent transition-colors text-foreground">
|
||||
{email}
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
|
||||
{hours && (
|
||||
<li className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-none bg-accent/10 flex items-center justify-center text-accent shrink-0 mt-0.5">
|
||||
<Clock size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs text-foreground/50 uppercase tracking-wider font-semibold block mb-0.5">
|
||||
{isDe ? 'Öffnungszeiten' : 'Godziny pracy'}
|
||||
</span>
|
||||
<span className="font-medium whitespace-pre-line text-foreground">{hours}</span>
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Główne CTA: WhatsApp */}
|
||||
<div className="p-8 bg-card border-2 border-green-500/20 rounded-none flex flex-col gap-4">
|
||||
<span className="inline-block text-xs font-bold uppercase tracking-widest text-green-600 dark:text-green-400">
|
||||
{isDe ? 'Empfohlen & Am schnellsten' : 'Zalecany i najszybszy kontakt'}
|
||||
</span>
|
||||
<h3 className="font-heading text-xl font-bold text-foreground">
|
||||
{isDe ? 'Schreiben Sie auf WhatsApp' : 'Napisz na WhatsApp'}
|
||||
</h3>
|
||||
<p className="text-sm text-foreground/70 leading-relaxed">
|
||||
{isDe
|
||||
? 'Erhalten Sie schnelle Antworten auf Ihre Fragen direkt von Dipl.-Ing. Janusz Kędzierski.'
|
||||
: 'Najszybsza forma kontaktu. Prześlij zapytanie lub dokumenty i uzyskaj szybką odpowiedź.'}
|
||||
</p>
|
||||
<a
|
||||
href={process.env.NEXT_PUBLIC_WHATSAPP_LINK || '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-2 inline-flex items-center justify-center gap-3 px-6 py-4 bg-green-500 hover:bg-green-600 text-white font-bold text-base rounded-none transition-colors shadow-sm"
|
||||
>
|
||||
<MessageCircle size={22} />
|
||||
<span>{isDe ? 'WhatsApp Chat starten' : 'Napisz na WhatsApp'}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Kolumna 2: Prosty formularz kontaktowy (Drugorzędna opcja) */}
|
||||
<div className="lg:col-span-7 bg-card border border-border p-8 md:p-10 rounded-none shadow-sm">
|
||||
<div className="mb-8">
|
||||
<h2 className="font-heading text-2xl md:text-3xl font-bold text-foreground">
|
||||
{isDe ? 'Kontaktformular' : 'Formularz kontaktowy'}
|
||||
</h2>
|
||||
<p className="text-sm md:text-base text-foreground/70 mt-2 leading-relaxed">
|
||||
{isDe
|
||||
? 'Bevorzugen Sie eine schriftliche Nachricht? Füllen Sie das folgende Formular aus — wir antworten in der Regel innerhalb von 24 Stunden.'
|
||||
: 'Wolisz kontakt tradycyjny? Wypełnij formularz, a odpowiemy najszybciej jak to możliwe.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{submitted ? (
|
||||
<div className="p-6 bg-accent/10 border border-accent/20 rounded-none text-center">
|
||||
<h3 className="font-heading font-bold text-lg text-foreground mb-2">
|
||||
{isDe ? 'Vielen Dank für Ihre Nachricht!' : 'Dziękujemy za wiadomość!'}
|
||||
</h3>
|
||||
<p className="text-sm text-foreground/70">
|
||||
{isDe
|
||||
? 'Ihre Anfrage wurde entgegengenommen. Wir melden uns in Kürze.'
|
||||
: 'Twoje zapytanie zostało zarejestrowane. Skontaktujemy się wkrótce.'}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-10 md:gap-12">
|
||||
{address && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3 text-accent border-b border-border/50 pb-3">
|
||||
<MapPin size={24} strokeWidth={2.5} />
|
||||
<h3 className="font-heading font-bold text-lg text-foreground tracking-wide">
|
||||
{isDe ? 'Adresse' : 'Adres'}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-foreground/80 leading-relaxed font-medium whitespace-pre-line text-base max-w-[70ch]">
|
||||
{address}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-6">
|
||||
<div>
|
||||
<label htmlFor="service" className="block text-sm font-semibold text-foreground mb-2">
|
||||
{isDe ? 'Worum geht es?' : 'Czego dotyczy zapytanie?'} <span className="text-accent">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="service"
|
||||
required
|
||||
value={formData.service}
|
||||
onChange={(e) => setFormData({ ...formData, service: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-background border border-border rounded-none text-foreground focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent transition-all"
|
||||
>
|
||||
<option value="" disabled>
|
||||
{isDe ? '— Bitte auswählen —' : '— Wybierz temat zapytania —'}
|
||||
</option>
|
||||
{isDe ? (
|
||||
<>
|
||||
<option value="Steuererklärung">Steuererklärung</option>
|
||||
<option value="Steuerbescheidprüfung">Steuerbescheidprüfung</option>
|
||||
<option value="Kindergeld / Riester-Rente">Kindergeld / Riester-Rente</option>
|
||||
<option value="Sonstiges">Sonstiges</option>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<option value="Rozliczenie roczne (Steuererklärung)">Rozliczenie roczne (Steuererklärung)</option>
|
||||
<option value="Sprawdzenie decyzji podatkowej (Steuerbescheid)">Sprawdzenie decyzji podatkowej (Steuerbescheid)</option>
|
||||
<option value="Zasiłki i dopłaty (Kindergeld, Riester-Rente)">Zasiłki i dopłaty (Kindergeld, Riester-Rente)</option>
|
||||
<option value="Inna sprawa">Inna sprawa</option>
|
||||
</>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-semibold text-foreground mb-2">
|
||||
{isDe ? 'Vor- und Nachname' : 'Imię i nazwisko'} <span className="text-accent">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder={isDe ? 'z. B. Jan Kowalski' : 'np. Jan Kowalski'}
|
||||
className="w-full px-4 py-3 bg-background border border-border rounded-none text-foreground focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent transition-all"
|
||||
/>
|
||||
{phone && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3 text-accent border-b border-border/50 pb-3">
|
||||
<Phone size={24} strokeWidth={2.5} />
|
||||
<h3 className="font-heading font-bold text-lg text-foreground tracking-wide">
|
||||
{isDe ? 'Telefon' : 'Telefon'}
|
||||
</h3>
|
||||
</div>
|
||||
<a
|
||||
href={`tel:${phone.replace(/\s+/g, '')}`}
|
||||
className="text-foreground/80 leading-relaxed font-medium text-base hover:text-accent transition-colors max-w-[70ch]"
|
||||
>
|
||||
{phone}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-semibold text-foreground mb-2">
|
||||
{isDe ? 'E-Mail-Adresse' : 'Adres e-mail'} <span className="text-accent">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
placeholder="[email protected]"
|
||||
className="w-full px-4 py-3 bg-background border border-border rounded-none text-foreground focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent transition-all"
|
||||
/>
|
||||
{email && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3 text-accent border-b border-border/50 pb-3">
|
||||
<Mail size={24} strokeWidth={2.5} />
|
||||
<h3 className="font-heading font-bold text-lg text-foreground tracking-wide">
|
||||
{isDe ? 'E-Mail' : 'E-mail'}
|
||||
</h3>
|
||||
</div>
|
||||
<a
|
||||
href={`mailto:${email}`}
|
||||
className="text-foreground/80 leading-relaxed font-medium text-base hover:text-accent transition-colors max-w-[70ch]"
|
||||
>
|
||||
{email}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="message" className="block text-sm font-semibold text-foreground mb-2">
|
||||
{isDe ? 'Ihre Nachricht' : 'Treść wiadomości'} <span className="text-accent">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="message"
|
||||
required
|
||||
rows={5}
|
||||
value={formData.message}
|
||||
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
|
||||
placeholder={isDe ? 'Wie können wir Ihnen helfen?' : 'W czym możemy Ci pomóc? Opisz krótko swoją sprawę...'}
|
||||
className="w-full px-4 py-3 bg-background border border-border rounded-none text-foreground focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent transition-all resize-y"
|
||||
/>
|
||||
{hours && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3 text-accent border-b border-border/50 pb-3">
|
||||
<Clock size={24} strokeWidth={2.5} />
|
||||
<h3 className="font-heading font-bold text-lg text-foreground tracking-wide">
|
||||
{isDe ? 'Öffnungszeiten' : 'Godziny pracy'}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full sm:w-auto px-8 py-4 bg-accent hover:bg-accent-hover text-accent-foreground font-bold text-base rounded-none transition-colors shadow-sm flex items-center justify-center gap-2 cursor-pointer"
|
||||
>
|
||||
<Send size={18} />
|
||||
<span>{isDe ? 'Nachricht senden' : 'Wyślij wiadomość'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<p className="text-foreground/80 leading-relaxed font-medium whitespace-pre-line text-base max-w-[70ch]">
|
||||
{hours}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client'
|
||||
import React from 'react'
|
||||
import { CheckCircle2, XCircle, AlertTriangle, ShieldAlert } from 'lucide-react'
|
||||
import { CheckCircle2, XCircle, FileText, Scale } from 'lucide-react'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
export interface EligibilityProps {
|
||||
eyebrow?: string | null
|
||||
@@ -25,94 +27,99 @@ export const EligibilityBlock: React.FC<EligibilityProps> = ({ eyebrow, title,
|
||||
legalNotice,
|
||||
}) => {
|
||||
return (
|
||||
<section className="py-12 md:py-20 bg-card/60 border-y border-border">
|
||||
<section className="py-16 md:py-24 bg-card border-y border-border">
|
||||
<div className="container-premium">
|
||||
<div className="max-w-3xl mb-12">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<div className="max-w-3xl mb-12">
|
||||
<h2 className="font-heading text-3xl sm:text-4xl md:text-5xl font-extrabold text-foreground tracking-tight">
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle && (
|
||||
<p className="mt-3 text-base sm:text-lg text-foreground/70 leading-relaxed max-w-[70ch]">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h2 className="font-heading text-3xl sm:text-4xl md:text-5xl font-extrabold text-foreground tracking-tight">
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle && (
|
||||
<p className="mt-3 text-base sm:text-lg text-foreground/70 leading-relaxed">
|
||||
{subtitle}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-12 mb-10">
|
||||
{/* 1: Kto się kwalifikuje */}
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-3 mb-6 pb-4 border-b border-border/50">
|
||||
<div className="text-emerald-600/80 dark:text-emerald-500/80 shrink-0">
|
||||
<CheckCircle2 size={24} />
|
||||
</div>
|
||||
<h3 className="font-heading text-xl font-bold text-foreground">
|
||||
{eligibleTitle}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-4">
|
||||
{(eligibleItems || []).map((item, idx) => (
|
||||
<li key={item.id || idx} className="flex items-start gap-3">
|
||||
<CheckCircle2
|
||||
size={20}
|
||||
className="text-emerald-600/60 dark:text-emerald-500/60 shrink-0 mt-0.5"
|
||||
/>
|
||||
<span className="text-foreground/80 text-sm sm:text-base leading-relaxed max-w-[70ch]">
|
||||
{item.text}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* 3: Wykluczenia - kolor stonowany, jak dokument */}
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-3 mb-6 pb-4 border-b border-border/50">
|
||||
<div className="text-foreground/60 shrink-0">
|
||||
<XCircle size={24} />
|
||||
</div>
|
||||
<h3 className="font-heading text-xl font-bold text-foreground">
|
||||
{excludedTitle}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-4">
|
||||
{(excludedItems || []).map((item, idx) => (
|
||||
<li key={item.id || idx} className="flex items-start gap-3">
|
||||
<XCircle
|
||||
size={20}
|
||||
className="text-foreground/40 shrink-0 mt-0.5"
|
||||
/>
|
||||
<span className="text-foreground/80 text-sm sm:text-base leading-relaxed max-w-[70ch]">
|
||||
{item.text}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2: Limit przychodów - techniczny wtręt */}
|
||||
<div className="border-y border-border/50 py-6 mb-8 flex flex-row items-start gap-4">
|
||||
<div className="mt-1 text-foreground/40 shrink-0">
|
||||
<FileText size={22} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-bold text-foreground mb-1 text-base sm:text-lg">{limitationsTitle}</h4>
|
||||
<p className="text-foreground/75 leading-relaxed text-sm sm:text-base max-w-[70ch]">
|
||||
{limitationsText}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{legalNotice && (
|
||||
<div className="pt-6 border-t border-border flex items-start gap-3 text-foreground/50 text-xs sm:text-sm">
|
||||
<Scale size={16} className="shrink-0 mt-0.5" />
|
||||
<p className="leading-relaxed max-w-[70ch]">{legalNotice}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* 1: Kto się kwalifikuje */}
|
||||
<div className="bg-card border-2 border-border rounded-none p-6 sm:p-8 shadow-sm">
|
||||
<div className="flex items-center gap-3 mb-6 pb-4 border-b border-border">
|
||||
<div className="w-10 h-10 rounded-full bg-accent/10 text-accent flex items-center justify-center shrink-0">
|
||||
<CheckCircle2 size={24} />
|
||||
</div>
|
||||
<h3 className="font-heading text-xl sm:text-2xl font-bold text-foreground">
|
||||
{eligibleTitle}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-4">
|
||||
{(eligibleItems || []).map((item, idx) => (
|
||||
<li key={item.id || idx} className="flex items-start gap-3">
|
||||
<CheckCircle2
|
||||
size={20}
|
||||
className="text-accent shrink-0 mt-0.5"
|
||||
/>
|
||||
<span className="text-foreground/90 font-medium leading-relaxed">
|
||||
{item.text}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* 2: Limit przychodów */}
|
||||
<div className="bg-card border border-border rounded-none p-6 sm:p-8 shadow-sm">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-full bg-amber-500/10 text-amber-500 flex items-center justify-center shrink-0">
|
||||
<AlertTriangle size={22} />
|
||||
</div>
|
||||
<h3 className="font-heading text-xl sm:text-2xl font-bold text-foreground">
|
||||
{limitationsTitle}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-foreground/80 leading-relaxed text-sm sm:text-base">
|
||||
{limitationsText}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 3: Wykluczenia - czerwony kolor ostrzegawczy dla bezpieczeństwa prawnego klienta */}
|
||||
<div className="bg-red-500/5 border-2 border-red-500/30 rounded-none p-6 sm:p-8 shadow-sm">
|
||||
<div className="flex items-center gap-3 mb-4 pb-3 border-b border-red-500/20">
|
||||
<div className="w-10 h-10 rounded-full bg-red-500/15 text-red-600 flex items-center justify-center shrink-0">
|
||||
<XCircle size={22} />
|
||||
</div>
|
||||
<h3 className="font-heading text-xl sm:text-2xl font-bold text-red-950 dark:text-red-200">
|
||||
{excludedTitle}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-3">
|
||||
{(excludedItems || []).map((item, idx) => (
|
||||
<li key={item.id || idx} className="flex items-start gap-3">
|
||||
<XCircle
|
||||
size={18}
|
||||
className="text-red-500 shrink-0 mt-0.5"
|
||||
/>
|
||||
<span className="text-foreground/90 font-medium text-sm sm:text-base leading-relaxed">
|
||||
{item.text}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{legalNotice && (
|
||||
<div className="mt-10 p-5 rounded-none bg-background border border-border text-xs sm:text-sm text-foreground/60 leading-relaxed">
|
||||
<p>{legalNotice}</p>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -6,15 +6,13 @@ import type { Page } from '../../payload-types'
|
||||
|
||||
type Props = Extract<NonNullable<Page['layout']>[0], { blockType: 'faqFull' }>
|
||||
|
||||
const PLACEHOLDER_PL = "Odpowiedź w przygotowaniu"
|
||||
const PLACEHOLDER_DE = "Antwort in Vorbereitung"
|
||||
|
||||
function isPlaceholder(answer: string | null | undefined): boolean {
|
||||
if (!answer) return true;
|
||||
return answer.includes(PLACEHOLDER_PL) || answer.includes(PLACEHOLDER_DE) || answer.includes('DO POTWIERDZENIA');
|
||||
}
|
||||
|
||||
export const FAQFullBlockComponent: React.FC<Props> = ({ title, subtitle, questions, ctaText, ctaUrl }) => {
|
||||
export const FAQFullBlockComponent: React.FC<Props> = ({
|
||||
title,
|
||||
subtitle,
|
||||
questions,
|
||||
ctaText,
|
||||
ctaUrl,
|
||||
}) => {
|
||||
const { scrollYProgress } = useScroll();
|
||||
const scaleX = useSpring(scrollYProgress, {
|
||||
stiffness: 100,
|
||||
@@ -33,7 +31,7 @@ export const FAQFullBlockComponent: React.FC<Props> = ({ title, subtitle, questi
|
||||
}
|
||||
|
||||
// Filter out placeholder questions
|
||||
const validQuestions = (questions || []).filter(q => !isPlaceholder(q.answer));
|
||||
const validQuestions = (questions || []).filter((q: any) => !q.isDraft);
|
||||
|
||||
// Group by category
|
||||
const groupedQuestions = validQuestions.reduce((acc, q) => {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Block } from 'payload'
|
||||
|
||||
export const FAQFull: Block = {
|
||||
slug: 'faqFull',
|
||||
fields: [
|
||||
{ name: 'title', type: 'text', required: true, localized: true },
|
||||
{
|
||||
name: 'subtitle',
|
||||
type: 'text',
|
||||
required: false,
|
||||
localized: true,
|
||||
},
|
||||
{
|
||||
name: 'questions',
|
||||
type: 'array',
|
||||
minRows: 1,
|
||||
localized: true,
|
||||
fields: [
|
||||
{ name: 'category', type: 'text', required: true },
|
||||
{
|
||||
name: 'isDraft',
|
||||
type: 'checkbox',
|
||||
defaultValue: false,
|
||||
admin: {
|
||||
description: 'Zaznacz, jeśli treść nie jest jeszcze gotowa do publikacji (pytanie nie pojawi się na stronie ani w SEO)',
|
||||
},
|
||||
},
|
||||
{ name: 'question', type: 'text', required: true },
|
||||
{ name: 'answer', type: 'textarea', required: true },
|
||||
],
|
||||
},
|
||||
{ name: 'ctaText', type: 'text', required: false, localized: true },
|
||||
{ name: 'ctaUrl', type: 'text', required: false },
|
||||
],
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import type { Page } from '../../payload-types'
|
||||
|
||||
type Props = Extract<NonNullable<Page['layout']>[0], { blockType: 'faqTeaser' }>
|
||||
|
||||
export const FAQTeaserBlock: React.FC<Props> = ({ title, questions }) => {
|
||||
export const FAQTeaserBlock: React.FC<Props> = ({ title, questions, ctaText, ctaUrl }) => {
|
||||
const [openIndex, setOpenIndex] = useState<number | null>(0)
|
||||
|
||||
const toggleQuestion = (index: number) => {
|
||||
@@ -14,7 +14,7 @@ export const FAQTeaserBlock: React.FC<Props> = ({ title, questions }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="py-20 md:py-32 bg-card/40 border-t border-border" id="faq">
|
||||
<section className="py-20 md:py-32" id="faq">
|
||||
<div className="container-premium max-w-4xl">
|
||||
<div className="mb-12 md:mb-16 text-center">
|
||||
<h2 className="font-heading text-3xl sm:text-4xl md:text-5xl font-extrabold text-foreground tracking-tight text-balance">
|
||||
@@ -63,6 +63,17 @@ export const FAQTeaserBlock: React.FC<Props> = ({ title, questions }) => {
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{ctaText && ctaUrl && (
|
||||
<div className="mt-12 text-center">
|
||||
<a
|
||||
href={ctaUrl}
|
||||
className="inline-flex items-center justify-center h-14 px-8 text-base sm:text-lg font-bold text-background bg-accent rounded-[6px] hover:bg-accent-hover transition-colors"
|
||||
>
|
||||
{ctaText}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -10,9 +10,12 @@ export const FAQTeaser: Block = {
|
||||
minRows: 1,
|
||||
localized: true,
|
||||
fields: [
|
||||
{ name: 'category', type: 'text', required: false },
|
||||
{ name: 'question', type: 'text', required: true },
|
||||
{ name: 'answer', type: 'textarea', required: true },
|
||||
],
|
||||
},
|
||||
{ name: 'ctaText', type: 'text', required: false, localized: true },
|
||||
{ name: 'ctaUrl', type: 'text', required: false },
|
||||
],
|
||||
}
|
||||
|
||||
@@ -1,40 +1,42 @@
|
||||
'use client'
|
||||
import React from 'react'
|
||||
import type { Page } from '../../payload-types'
|
||||
import { MessageCircle } from 'lucide-react'
|
||||
import { MotionStamp } from '../../components/MotionStamp'
|
||||
|
||||
type Props = Extract<NonNullable<Page['layout']>[0], { blockType: 'finalCta' }>
|
||||
type BaseProps = Extract<NonNullable<Page['layout']>[0], { blockType: 'finalCta' }>
|
||||
type Props = BaseProps & { whatsappNumber?: string | null }
|
||||
|
||||
export const FinalCTABlock: React.FC<Props> = ({ eyebrow, title, subtitle, buttonText }) => {
|
||||
export const FinalCTABlock: React.FC<Props> = ({ eyebrow, title, subtitle, buttonText, whatsappNumber }) => {
|
||||
const whatsappLink = whatsappNumber ? `https://wa.me/${whatsappNumber}` : '#'
|
||||
return (
|
||||
<section className="py-20 md:py-32 bg-card border-y border-border text-left">
|
||||
<MotionStamp id="final-cta" className="py-20 md:py-32 text-center" style={{ backgroundColor: 'var(--accent-color)', color: 'var(--bg-color)' }}>
|
||||
<div className="container-premium">
|
||||
<div className="max-w-3xl">
|
||||
{/* Removed eyebrow */}
|
||||
|
||||
<h2 className="font-heading text-3xl sm:text-4xl md:text-5xl font-extrabold text-foreground tracking-tight mb-6 text-balance">
|
||||
<div className="max-w-3xl mx-auto flex flex-col items-center">
|
||||
<h2 className="font-heading text-3xl sm:text-4xl md:text-5xl font-extrabold tracking-tight mb-6 text-balance text-[var(--bg-color)]">
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
{subtitle && (
|
||||
<p className="text-lg md:text-xl font-light text-foreground/75 mb-12 leading-relaxed">
|
||||
<p className="text-lg md:text-xl font-light mb-12 leading-relaxed text-[var(--bg-color)]/90">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<a
|
||||
href={process.env.NEXT_PUBLIC_WHATSAPP_LINK || '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center justify-center gap-2.5 px-8 py-4 bg-green-500 hover:bg-green-600 text-white text-base sm:text-lg font-bold rounded-none transition-colors shadow-sm"
|
||||
>
|
||||
<MessageCircle size={22} />
|
||||
<span>{buttonText}</span>
|
||||
</a>
|
||||
</div>
|
||||
{whatsappNumber && (
|
||||
<div>
|
||||
<a
|
||||
href={whatsappLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center justify-center gap-2.5 px-8 py-4 bg-background hover:bg-border text-accent text-base sm:text-lg font-bold rounded-md transition-colors shadow-sm"
|
||||
>
|
||||
<MessageCircle size={22} />
|
||||
<span>{buttonText}</span>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</MotionStamp>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ import { motion } from 'framer-motion'
|
||||
import type { Page, Media } from '../../payload-types'
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { ImageIcon } from 'lucide-react'
|
||||
|
||||
type Props = Extract<NonNullable<Page['layout']>[0], { blockType: 'hero' }>
|
||||
type BaseProps = Extract<NonNullable<Page['layout']>[0], { blockType: 'hero' }>
|
||||
type Props = BaseProps & { whatsappNumber?: string | null }
|
||||
|
||||
const customEase = [0.22, 1, 0.36, 1] as const
|
||||
|
||||
@@ -90,10 +92,14 @@ export const HeroBlock: React.FC<Props> = ({
|
||||
image,
|
||||
imageSecondary,
|
||||
floatingStat,
|
||||
whatsappNumber,
|
||||
}) => {
|
||||
const [imagesLoaded, setImagesLoaded] = useState(false)
|
||||
const whatsappLink = whatsappNumber ? `https://wa.me/${whatsappNumber}` : '#'
|
||||
|
||||
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 imageUrl = heroMedia?.url
|
||||
const imageAlt = heroMedia?.alt || ''
|
||||
|
||||
const secondaryMedia = typeof imageSecondary === 'object' && imageSecondary !== null ? (imageSecondary as Media) : null
|
||||
const secondaryUrl = secondaryMedia?.url
|
||||
@@ -109,7 +115,7 @@ export const HeroBlock: React.FC<Props> = ({
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={containerVariants}
|
||||
className="w-full bg-background text-foreground min-h-[calc(100svh-6rem)] flex items-center py-12 lg:py-0 overflow-hidden"
|
||||
className="w-full bg-paper-noise text-foreground min-h-[calc(100svh-6rem)] flex items-center py-12 lg:py-0 overflow-hidden"
|
||||
>
|
||||
<div className="container-premium w-full">
|
||||
<div className="flex flex-col-reverse lg:flex-row items-center justify-between gap-14 lg:gap-20">
|
||||
@@ -118,15 +124,21 @@ export const HeroBlock: React.FC<Props> = ({
|
||||
<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"
|
||||
className="font-heading text-4xl sm:text-5xl md:text-6xl font-bold leading-tight mb-8 text-balance"
|
||||
>
|
||||
<span className="block text-foreground">{title}</span>
|
||||
{titleAccent && <span className="block text-accent">{titleAccent}</span>}
|
||||
{titleAccent && (
|
||||
<span className="block text-accent">
|
||||
{titleAccent.split(/(po polsku)/i).map((part, i) =>
|
||||
part.toLowerCase() === 'po polsku' ? <i key={i} className="italic font-medium">{part}</i> : part
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</motion.h1>
|
||||
|
||||
<motion.p
|
||||
variants={itemVariants}
|
||||
className="text-xl sm:text-2xl text-foreground/80 mb-12 leading-relaxed font-light"
|
||||
className="text-xl sm:text-2xl text-foreground/80 mb-12 leading-relaxed font-light max-w-[65ch]"
|
||||
>
|
||||
{subtitle}
|
||||
</motion.p>
|
||||
@@ -135,14 +147,16 @@ export const HeroBlock: React.FC<Props> = ({
|
||||
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-none shadow-sm cursor-pointer"
|
||||
>
|
||||
{primaryCta}
|
||||
</a>
|
||||
{whatsappNumber && (
|
||||
<a
|
||||
href={whatsappLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-8 py-4 bg-[#25D366] hover:bg-[#20bd5a] text-[#1C1B1A] font-bold text-base sm:text-lg transition-colors flex items-center justify-center rounded-none shadow-sm cursor-pointer"
|
||||
>
|
||||
{primaryCta}
|
||||
</a>
|
||||
)}
|
||||
{secondaryCta?.text && secondaryCta?.url && (
|
||||
secondaryCta.url.startsWith('#') ? (
|
||||
<a
|
||||
@@ -191,40 +205,55 @@ export const HeroBlock: React.FC<Props> = ({
|
||||
/* 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 className="absolute top-2 left-0 w-[62%] aspect-[4/5] border border-border shadow-sm overflow-hidden z-10 bg-card">
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={imageAlt}
|
||||
fill
|
||||
priority
|
||||
sizes="(max-width: 1024px) 55vw, 25vw"
|
||||
onLoad={() => setImagesLoaded(true)}
|
||||
className={`object-cover object-center grayscale hover:grayscale-0 transition-all duration-700 ease-out ${imagesLoaded ? 'opacity-100 scale-100' : 'opacity-0 scale-105'}`}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-border border border-border">
|
||||
<ImageIcon size={32} strokeWidth={1} />
|
||||
</div>
|
||||
)}
|
||||
</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">
|
||||
<div className="absolute bottom-0 right-0 w-[74%] aspect-[4/5] border border-border shadow-md overflow-hidden z-20 bg-border/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"
|
||||
className={`object-cover object-center grayscale hover:grayscale-0 transition-all duration-700 ease-out ${imagesLoaded ? 'opacity-100 scale-100' : 'opacity-0 scale-105'}`}
|
||||
/>
|
||||
</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 className="relative w-full aspect-[4/5] border border-border shadow-sm overflow-hidden bg-card">
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={imageAlt}
|
||||
fill
|
||||
priority
|
||||
sizes="(max-width: 768px) 100vw, 50vw"
|
||||
onLoad={() => setImagesLoaded(true)}
|
||||
className={`object-cover object-center grayscale hover:grayscale-0 transition-all duration-700 ease-out ${imagesLoaded ? 'opacity-100 scale-100' : 'opacity-0 scale-105'}`}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center text-border gap-4 p-8 text-center border border-border">
|
||||
<ImageIcon size={64} strokeWidth={1} />
|
||||
<span className="text-xs tracking-widest uppercase font-semibold text-foreground/40">ZDJĘCIE GŁÓWNE</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
'use client'
|
||||
import React from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
export interface PageHeaderProps {
|
||||
title: string
|
||||
@@ -7,18 +9,23 @@ export interface PageHeaderProps {
|
||||
|
||||
export const PageHeaderBlock: React.FC<PageHeaderProps> = ({ title, subtitle }) => {
|
||||
return (
|
||||
<section className="bg-card border-b border-border py-16 md:py-20 mb-12">
|
||||
<section className="border-b border-border py-16 md:py-20 mb-12 bg-background">
|
||||
<div className="container-premium">
|
||||
<div className="max-w-4xl">
|
||||
<h1 className="font-heading text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-extrabold text-foreground tracking-tight leading-tight text-balance">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="max-w-4xl"
|
||||
>
|
||||
<h1 className="font-heading text-4xl sm:text-5xl lg:text-5xl font-extrabold text-foreground tracking-tight leading-tight text-balance max-w-4xl">
|
||||
{title}
|
||||
</h1>
|
||||
{subtitle && (
|
||||
<p className="mt-4 text-lg md:text-xl text-foreground/70 leading-relaxed max-w-3xl">
|
||||
<p className="mt-6 text-lg md:text-xl text-foreground/70 leading-relaxed max-w-[65ch]">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -2,50 +2,67 @@
|
||||
import React from 'react'
|
||||
import type { Page, Media } from '../../payload-types'
|
||||
import Image from 'next/image'
|
||||
import { ImageIcon } from 'lucide-react'
|
||||
import { ArrowLink } from '../../components/ArrowLink'
|
||||
import { motion, useScroll } from 'framer-motion'
|
||||
import { useRef } from 'react'
|
||||
|
||||
type Props = Extract<NonNullable<Page['layout']>[0], { blockType: 'process' }>
|
||||
|
||||
export const ProcessBlock: React.FC<Props> = ({ subtitle, title, steps, ctaText, ctaUrl, image }) => {
|
||||
const processMedia = typeof image === 'object' && image !== null ? (image as Media) : null;
|
||||
const imageUrl = processMedia?.url || 'https://images.unsplash.com/photo-1611162617474-5b21e879e113?auto=format&fit=crop&w=1200&q=80';
|
||||
const imageAlt = processMedia?.alt || 'Proces współpracy';
|
||||
const imageUrl = processMedia?.url
|
||||
const imageAlt = processMedia?.alt || ''
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: containerRef,
|
||||
offset: ["start center", "end center"]
|
||||
})
|
||||
|
||||
return (
|
||||
<section id="jak-to-dziala" className="py-20 md:py-32 bg-background text-foreground overflow-hidden">
|
||||
<div className="container-premium">
|
||||
<div className="container-premium" ref={containerRef}>
|
||||
<div className="max-w-3xl mb-16 md:mb-24">
|
||||
<h2 className="font-heading text-3xl sm:text-4xl md:text-5xl font-extrabold tracking-tight text-foreground mb-6 text-balance">
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle && (
|
||||
<p className="text-foreground/75 text-lg md:text-xl font-light leading-relaxed">
|
||||
<p className="text-foreground/75 text-lg md:text-xl font-light leading-relaxed max-w-[70ch]">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-16 lg:gap-24 items-center">
|
||||
<div className="flex flex-col gap-16">
|
||||
{steps?.map((step: NonNullable<Props['steps']>[0], index: number) => {
|
||||
const stepNumber = String(index + 1).padStart(2, '0')
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-16 lg:gap-24 items-start">
|
||||
<div className="flex flex-col relative pl-6 md:pl-10 pb-8">
|
||||
{/* Ścieżka pionowa z scroll-linked line-draw */}
|
||||
<div className="absolute top-2 bottom-0 left-0 w-px bg-border/40">
|
||||
<motion.div
|
||||
className="absolute top-0 w-full bg-accent origin-top"
|
||||
style={{ height: '100%', scaleY: scrollYProgress }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{steps?.map((step: NonNullable<Props['steps']>[0], index: number) => {
|
||||
const stepNumber = index + 1
|
||||
return (
|
||||
<div
|
||||
key={step.id || index}
|
||||
className="relative flex flex-col sm:flex-row gap-6 group"
|
||||
className="relative flex flex-col gap-2 mb-16 last:mb-0 group"
|
||||
>
|
||||
<div className="font-heading text-6xl md:text-7xl font-black text-foreground/5 tracking-tighter leading-none sm:-mt-2 group-hover:text-accent/10 transition-colors shrink-0">
|
||||
{stepNumber}
|
||||
</div>
|
||||
<div className="relative z-10 pt-2 border-t-2 border-transparent group-hover:border-accent transition-colors flex-1">
|
||||
<h3 className="font-heading text-xl sm:text-2xl font-bold text-foreground mb-4">
|
||||
{step.title}
|
||||
</h3>
|
||||
<p className="text-foreground/75 text-lg font-light leading-relaxed">
|
||||
{step.description}
|
||||
</p>
|
||||
{/* Znacznik kroku (Kropka na ścieżce) */}
|
||||
<div className="absolute -left-[28px] md:-left-[44px] top-1.5 w-2 h-2 rounded-full bg-border group-hover:bg-accent transition-colors z-10 ring-4 ring-background" />
|
||||
|
||||
<div className="font-heading text-sm md:text-base font-bold text-accent uppercase tracking-widest mb-1">
|
||||
Krok {stepNumber}
|
||||
</div>
|
||||
<h3 className="font-heading text-2xl sm:text-3xl font-bold text-foreground mb-4">
|
||||
{step.title}
|
||||
</h3>
|
||||
<p className="text-foreground/75 text-lg md:text-xl font-light leading-relaxed max-w-[70ch]">
|
||||
{step.description}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -59,15 +76,22 @@ export const ProcessBlock: React.FC<Props> = ({ subtitle, title, steps, ctaText,
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative aspect-[4/5] w-full bg-card rounded-none overflow-hidden">
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={imageAlt}
|
||||
fill
|
||||
sizes="(max-width: 1024px) 100vw, 50vw"
|
||||
className="object-cover w-full h-full grayscale hover:grayscale-0 transition-all duration-700"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-background/80 to-transparent mix-blend-multiply pointer-events-none" />
|
||||
<div className="relative aspect-[4/5] w-full bg-border/20 overflow-hidden lg:sticky lg:top-32">
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={imageAlt}
|
||||
fill
|
||||
sizes="(max-width: 1024px) 100vw, 50vw"
|
||||
className="object-cover w-full h-full grayscale hover:grayscale-0 transition-all duration-700"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center bg-card text-border gap-4 p-8 text-center border border-border">
|
||||
<ImageIcon size={64} strokeWidth={1} />
|
||||
<span className="text-xs tracking-widest uppercase font-semibold text-foreground/40">ZDJĘCIE ILUSTRACYJNE</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-background/40 to-transparent pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { AffiliationBlock } from './Affiliation/Component'
|
||||
import { TestimonialsBlock } from './Testimonials/Component'
|
||||
import { TestimonialsGridBlock } from './TestimonialsGrid/Component'
|
||||
import { FAQTeaserBlock } from './FAQTeaser/Component'
|
||||
import { FAQFullBlockComponent } from './FAQFull/Component'
|
||||
import { FinalCTABlock } from './FinalCTA/Component'
|
||||
|
||||
import { PageHeaderBlock } from './PageHeader/Component'
|
||||
@@ -17,6 +18,7 @@ import { ContactInfoBlock } from './ContactInfo/Component'
|
||||
import { BioBlock } from './Bio/Component'
|
||||
import { TeamBlock } from './Team/Component'
|
||||
import { ServicesDetailedBlock } from './ServicesDetailed/Component'
|
||||
import { TextSectionBlock } from './TextSection/Component'
|
||||
import { EligibilityBlock } from './Eligibility/Component'
|
||||
import { StatsBlock } from './Stats/Component'
|
||||
|
||||
@@ -37,14 +39,22 @@ const blockComponents = {
|
||||
testimonials: TestimonialsBlock,
|
||||
testimonialsGrid: TestimonialsGridBlock,
|
||||
faqTeaser: FAQTeaserBlock,
|
||||
faqFull: FAQFullBlockComponent,
|
||||
finalCta: FinalCTABlock,
|
||||
TextSection: ({ text }: { text: string }) => <section className="py-20"><div className="container mx-auto px-4"><p>{text}</p></div></section>,
|
||||
TextSection: TextSectionBlock,
|
||||
}
|
||||
|
||||
export const RenderBlocks: React.FC<{
|
||||
blocks: Page['layout']
|
||||
}> = (props) => {
|
||||
const { blocks } = props
|
||||
import { getGlobal } from '../lib/content'
|
||||
import type { Config } from '../payload-types'
|
||||
|
||||
export const RenderBlocks = async (props: {
|
||||
blocks: Page['layout'],
|
||||
locale: string
|
||||
}) => {
|
||||
const { blocks, locale } = props
|
||||
|
||||
const companyData = await getGlobal('company', 0, locale as Config['locale'])
|
||||
const whatsappNumber = companyData?.whatsappNumber
|
||||
|
||||
const hasBlocks = blocks && Array.isArray(blocks) && blocks.length > 0
|
||||
|
||||
@@ -58,7 +68,7 @@ export const RenderBlocks: React.FC<{
|
||||
const Block = blockComponents[blockType as keyof typeof blockComponents]
|
||||
if (Block) {
|
||||
return (
|
||||
<Block key={index} id={`block-${index}`} {...(block as any)} />
|
||||
<Block key={index} id={`block-${index}`} {...(block as any)} whatsappNumber={whatsappNumber} />
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,61 +47,64 @@ export const ServicesDetailedBlock: React.FC<Props> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-12 gap-6 md:gap-8">
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, margin: "-100px" }}
|
||||
variants={{
|
||||
hidden: {},
|
||||
visible: { transition: { staggerChildren: 0.08 } }
|
||||
}}
|
||||
className="grid grid-cols-1 md:grid-cols-2 gap-6 md:gap-8"
|
||||
>
|
||||
{serviceList.map((service, index) => {
|
||||
const Icon = (service.icon && iconMap[service.icon]) || FileText
|
||||
const isFirst = hasBento && index === 0
|
||||
const isSecondOrThird = hasBento && (index === 1 || index === 2)
|
||||
|
||||
// Asymmetric Bento grid layout:
|
||||
// 1st: col-span-6 on desktop, dark card
|
||||
// 2nd & 3rd: col-span-3 on desktop, light card
|
||||
// Remaining (4+): col-span-4 on desktop, light card
|
||||
// Fallback (< 3 items): col-span-4 on desktop, light card
|
||||
let colSpanClasses = 'col-span-1 sm:col-span-1 lg:col-span-4'
|
||||
if (isFirst) {
|
||||
colSpanClasses = 'col-span-1 sm:col-span-2 lg:col-span-6'
|
||||
} else if (isSecondOrThird) {
|
||||
colSpanClasses = 'col-span-1 sm:col-span-1 lg:col-span-3'
|
||||
}
|
||||
const isFirst = index === 0
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={service.id || index}
|
||||
whileHover={{ y: -4 }}
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 25 }}
|
||||
className={`${colSpanClasses} ${
|
||||
isFirst
|
||||
? 'bg-foreground text-background border-0 shadow-md p-8 md:p-10'
|
||||
: 'bg-card text-foreground border border-border shadow-xs p-6 md:p-8'
|
||||
} rounded-none min-h-[240px] flex flex-col justify-between overflow-hidden`}
|
||||
variants={{
|
||||
hidden: { opacity: 0, y: isFirst ? 24 : 16 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.6, ease: [0.16, 1, 0.3, 1] }
|
||||
}
|
||||
}}
|
||||
className={`${isFirst ? 'md:col-span-2' : 'md:col-span-1'}
|
||||
bg-card border border-border p-6 md:p-8 flex flex-col relative overflow-hidden group hover:border-border/80 transition-colors
|
||||
`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-accent mb-6">
|
||||
<Icon size={isFirst ? 32 : 28} />
|
||||
{/* Górny cienki akcent (line-draw przy wejściu) */}
|
||||
<motion.div
|
||||
variants={{
|
||||
hidden: { scaleX: 0 },
|
||||
visible: {
|
||||
scaleX: 1,
|
||||
transition: { delay: 0.2 + (index * 0.08), duration: 0.7, ease: "easeOut" }
|
||||
}
|
||||
}}
|
||||
style={{ originX: 0 }}
|
||||
className="absolute top-0 left-0 w-full h-[2px] bg-accent"
|
||||
/>
|
||||
|
||||
<div className={`flex ${isFirst ? 'flex-row items-center gap-4 mb-4' : 'flex-row items-center gap-3 mb-3'}`}>
|
||||
<div className="text-accent shrink-0">
|
||||
<Icon size={isFirst ? 28 : 20} strokeWidth={2.5} />
|
||||
</div>
|
||||
<h3
|
||||
className={`font-heading font-bold mb-3 leading-snug break-words hyphens-auto ${
|
||||
isFirst
|
||||
? 'text-2xl sm:text-3xl text-background'
|
||||
: 'text-xl sm:text-2xl text-foreground'
|
||||
}`}
|
||||
>
|
||||
<h3 className={`font-heading font-bold text-foreground leading-tight ${isFirst ? 'text-2xl sm:text-3xl' : 'text-xl sm:text-2xl'}`}>
|
||||
{service.title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p
|
||||
className={`mt-4 text-sm sm:text-base leading-relaxed ${
|
||||
isFirst ? 'text-background/80' : 'text-foreground/75'
|
||||
}`}
|
||||
>
|
||||
<p className={`text-foreground/75 leading-relaxed ${isFirst ? 'text-base sm:text-lg max-w-[70ch]' : 'text-sm sm:text-base mt-2 max-w-[70ch]'}`}>
|
||||
{service.description}
|
||||
</p>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -1,12 +1,32 @@
|
||||
'use client'
|
||||
import React from 'react'
|
||||
import type { Page } from '../../payload-types'
|
||||
import { motion } from 'framer-motion'
|
||||
import { ArrowRight, CheckCircle } from 'lucide-react'
|
||||
|
||||
const containerVariants = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: {
|
||||
staggerChildren: 0.1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cardVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.5, ease: [0.22, 1, 0.36, 1] as [number, number, number, number] }
|
||||
}
|
||||
}
|
||||
|
||||
type Props = Extract<NonNullable<Page['layout']>[0], { blockType: 'servicesList' }>
|
||||
|
||||
export const ServicesListBlock: React.FC<Props> = ({ title, services }) => {
|
||||
return (
|
||||
<section className="py-20 md:py-32 bg-background text-foreground">
|
||||
<section className="py-24 md:py-32 relative overflow-hidden" id="uslugi">
|
||||
<div className="container-premium">
|
||||
<div className="max-w-3xl mb-16 md:mb-24">
|
||||
<h2 className="font-heading text-3xl sm:text-4xl md:text-5xl font-extrabold text-foreground tracking-tight max-w-2xl text-balance">
|
||||
@@ -14,25 +34,39 @@ export const ServicesListBlock: React.FC<Props> = ({ title, services }) => {
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border">
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
variants={containerVariants}
|
||||
className="grid grid-cols-1 md:grid-cols-3 gap-8"
|
||||
>
|
||||
{services?.map((service: NonNullable<Props['services']>[0], index: number) => (
|
||||
<div
|
||||
<motion.div
|
||||
key={service.id || index}
|
||||
className="group border-b border-border py-8 md:py-12 flex flex-col md:flex-row gap-6 md:gap-12 lg:gap-24 items-start md:items-center hover:bg-foreground/[0.02] transition-colors"
|
||||
variants={cardVariants}
|
||||
className="group flex flex-col bg-card border border-border p-8 hover:shadow-sm hover:border-border/80 transition-all duration-300"
|
||||
>
|
||||
<div className="md:w-1/3 shrink-0">
|
||||
<h3 className="font-heading text-xl sm:text-2xl font-bold text-foreground group-hover:text-accent transition-colors">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<CheckCircle className="text-accent w-5 h-5 shrink-0" />
|
||||
<h3 className="font-heading text-xl sm:text-2xl font-bold text-foreground">
|
||||
{service.title}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="md:w-7/12 flex-grow">
|
||||
<p className="text-foreground/75 text-lg md:text-xl font-light leading-relaxed">
|
||||
{service.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-foreground/75 text-base md:text-lg font-light leading-relaxed flex-grow">
|
||||
{service.description}
|
||||
</p>
|
||||
|
||||
<a
|
||||
href={service.linkUrl || '#'}
|
||||
className="mt-8 inline-flex items-center gap-2 text-accent font-bold hover:text-accent-hover transition-colors"
|
||||
>
|
||||
{service.linkText || 'Zobacz więcej'}
|
||||
<ArrowRight size={18} />
|
||||
</a>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -13,6 +13,8 @@ export const ServicesList: Block = {
|
||||
fields: [
|
||||
{ name: 'title', type: 'text', required: true },
|
||||
{ name: 'description', type: 'textarea', required: true },
|
||||
{ name: 'linkText', type: 'text' },
|
||||
{ name: 'linkUrl', type: 'text' },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,6 +1,35 @@
|
||||
'use client'
|
||||
import React from 'react'
|
||||
import type { Page } from '../../payload-types'
|
||||
import { motion, animate, useInView } from 'framer-motion'
|
||||
import { useRef, useEffect } from 'react'
|
||||
|
||||
const CountUp = ({ text }: { text: string }) => {
|
||||
const ref = useRef<HTMLSpanElement>(null)
|
||||
const inView = useInView(ref, { once: true })
|
||||
|
||||
useEffect(() => {
|
||||
if (inView && ref.current) {
|
||||
const match = text.match(/^(\d+)([%+]?)$/)
|
||||
if (match) {
|
||||
const [_, numStr, suffix] = match
|
||||
const num = parseInt(numStr, 10)
|
||||
animate(0, num, {
|
||||
duration: 1.5,
|
||||
ease: 'easeOut',
|
||||
onUpdate: (val) => {
|
||||
if (ref.current) ref.current.textContent = `${Math.floor(val)}${suffix}`
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [inView, text])
|
||||
|
||||
const isNumeric = /^\d+[%+]?$/.test(text)
|
||||
if (!isNumeric) return <span>{text}</span>
|
||||
|
||||
return <span ref={ref}>{text.replace(/\d+/, '0')}</span>
|
||||
}
|
||||
|
||||
type Props = Extract<NonNullable<Page['layout']>[0], { blockType: 'stats' }>
|
||||
|
||||
@@ -8,7 +37,7 @@ export const StatsBlock: React.FC<Props> = ({ title, subtitle, items }) => {
|
||||
if (!items || items.length === 0) return null
|
||||
|
||||
return (
|
||||
<section className="py-20 md:py-32 bg-background border-y border-border">
|
||||
<section className="py-20 md:py-32 relative">
|
||||
<div className="container-premium">
|
||||
{(title || subtitle) && (
|
||||
<div className="max-w-3xl mb-16 md:mb-24">
|
||||
@@ -25,14 +54,14 @@ export const StatsBlock: React.FC<Props> = ({ title, subtitle, items }) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 border-t border-l border-border mt-12">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 divide-y md:divide-y-0 md:divide-x divide-border mt-12 border-y border-border/30">
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={item.id || index}
|
||||
className="flex flex-col p-8 sm:p-12 border-b border-r border-border hover:bg-foreground/[0.02] transition-colors"
|
||||
className="flex flex-col p-8 sm:p-12 hover:bg-foreground/[0.02] transition-colors"
|
||||
>
|
||||
<div className="font-heading text-5xl lg:text-7xl font-extrabold text-accent mb-4 tracking-tight">
|
||||
{item.stat}
|
||||
<CountUp text={item.stat} />
|
||||
</div>
|
||||
<div className="font-heading text-xl font-bold text-foreground mb-2">
|
||||
{item.label}
|
||||
@@ -46,6 +75,15 @@ export const StatsBlock: React.FC<Props> = ({ title, subtitle, items }) => {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Line Draw Animation na samym dole sekcji */}
|
||||
<motion.div
|
||||
initial={{ scaleX: 0 }}
|
||||
whileInView={{ scaleX: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 1.5, ease: "easeInOut" }}
|
||||
className="absolute bottom-0 left-0 w-full h-[1px] bg-border origin-left"
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { motion } from 'framer-motion'
|
||||
type Props = Extract<NonNullable<Page['layout']>[0], { blockType: 'team' }>
|
||||
|
||||
export const TeamBlock: React.FC<Props> = ({ members }) => {
|
||||
if (!members || members.length === 0) return null
|
||||
if (!members || members.length <= 1) return null
|
||||
|
||||
return (
|
||||
<section className="py-20 md:py-32 bg-background border-y border-border overflow-hidden">
|
||||
|
||||
@@ -1,32 +1,33 @@
|
||||
'use client'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import React from 'react'
|
||||
import type { Page } from '../../payload-types'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
type Props = Extract<NonNullable<Page['layout']>[0], { blockType: 'testimonials' }>
|
||||
|
||||
export const TestimonialsBlock: React.FC<Props> = ({ title, testimonials }) => {
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [lastActionTime, setLastActionTime] = useState(Date.now())
|
||||
|
||||
useEffect(() => {
|
||||
if (!testimonials || testimonials.length <= 1) return
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setCurrentIndex((prev) => (prev + 1) % testimonials.length)
|
||||
}, 7500)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [testimonials, lastActionTime])
|
||||
|
||||
const handleDotClick = (index: number) => {
|
||||
setCurrentIndex(index)
|
||||
setLastActionTime(Date.now())
|
||||
const containerVariants = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: {
|
||||
staggerChildren: 0.1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cardVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.5, ease: [0.22, 1, 0.36, 1] as [number, number, number, number] }
|
||||
}
|
||||
}
|
||||
|
||||
export const TestimonialsBlock: React.FC<Props> = ({ title, testimonials }) => {
|
||||
if (!testimonials || testimonials.length === 0) return null
|
||||
|
||||
return (
|
||||
<section className="relative py-24 md:py-40 bg-background border-y border-border overflow-hidden">
|
||||
<section className="relative py-24 md:py-40 bg-background overflow-hidden">
|
||||
<div className="container-premium relative z-10">
|
||||
<div className="max-w-3xl mb-16 md:mb-24">
|
||||
{title && (
|
||||
@@ -36,50 +37,43 @@ export const TestimonialsBlock: React.FC<Props> = ({ title, testimonials }) => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative min-h-[300px] sm:min-h-[240px]">
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
variants={containerVariants}
|
||||
className="grid grid-cols-1 md:grid-cols-2 gap-8"
|
||||
>
|
||||
{testimonials.map((t: NonNullable<Props['testimonials']>[0], index: number) => {
|
||||
const initial = t.author ? t.author.charAt(0).toUpperCase() : ''
|
||||
return (
|
||||
<div
|
||||
<motion.div
|
||||
key={t.id || index}
|
||||
className={`absolute inset-0 flex flex-col items-start justify-start transition-opacity duration-1000 ease-in-out ${
|
||||
index === currentIndex ? 'opacity-100 z-10' : 'opacity-0 z-0 pointer-events-none'
|
||||
}`}
|
||||
variants={cardVariants}
|
||||
className="bg-card border border-border p-8 md:p-12 flex flex-col items-start shadow-sm"
|
||||
>
|
||||
<p className="relative z-10 font-heading text-3xl sm:text-4xl md:text-5xl font-extrabold text-foreground leading-[1.2] mb-10 text-balance tracking-tight max-w-4xl">
|
||||
{t.quote}
|
||||
<div className="text-accent mb-6">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M14.017 21v-7.391c0-5.704 3.731-9.57 8.983-10.609l.995 2.151c-2.432.917-3.995 3.638-3.995 5.849h4v10h-9.983zm-14.017 0v-7.391c0-5.704 3.748-9.57 9-10.609l.996 2.151c-2.433.917-3.996 3.638-3.996 5.849h3.983v10h-9.983z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<p className="font-sans italic text-xl sm:text-2xl text-foreground/90 leading-relaxed mb-10 flex-grow">
|
||||
"{t.quote}"
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="hidden sm:block w-12 h-px bg-accent/60" />
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-full bg-accent text-background font-heading flex items-center justify-center text-xl sm:text-2xl font-bold shrink-0">
|
||||
{initial}
|
||||
</div>
|
||||
<p className="font-heading font-bold text-lg sm:text-xl text-foreground/80 uppercase tracking-wide">
|
||||
{t.author}
|
||||
</p>
|
||||
<div className="flex items-center gap-4 mt-auto">
|
||||
<div className="w-12 h-12 rounded-full bg-accent text-background font-heading flex items-center justify-center text-xl font-bold shrink-0">
|
||||
{initial}
|
||||
</div>
|
||||
<p className="font-heading font-bold text-lg text-foreground uppercase tracking-wide">
|
||||
{t.author}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{testimonials.length > 1 && (
|
||||
<div className="flex items-center gap-3 mt-16 md:mt-24">
|
||||
{testimonials.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => handleDotClick(index)}
|
||||
className={`w-2 h-2 transition-all ${
|
||||
index === currentIndex ? 'bg-accent w-8' : 'bg-border hover:bg-foreground/30'
|
||||
}`}
|
||||
aria-label={`Pokaż opinię ${index + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
'use client'
|
||||
import React from 'react'
|
||||
import type { Page } from '../../payload-types'
|
||||
import { RichText } from '@payloadcms/richtext-lexical/react'
|
||||
|
||||
type Props = Extract<NonNullable<Page['layout']>[0], { blockType: 'TextSection' }>
|
||||
|
||||
export const TextSectionBlock: React.FC<Props> = ({ text }) => {
|
||||
if (!text) return null;
|
||||
|
||||
return (
|
||||
<section className="py-20 md:py-32 bg-background text-foreground">
|
||||
<div className="container-premium max-w-4xl">
|
||||
<div className="prose prose-lg dark:prose-invert prose-headings:font-heading prose-a:text-accent hover:prose-a:text-accent-hover">
|
||||
{typeof text === 'string' ? <p>{text}</p> : <RichText data={text} />}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { Block } from 'payload'
|
||||
|
||||
export const TextSection: Block = {
|
||||
slug: 'TextSection',
|
||||
fields: [
|
||||
{
|
||||
name: 'text',
|
||||
type: 'richText',
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
'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'
|
||||
|
||||
@@ -11,28 +13,53 @@ const iconMap = {
|
||||
star: Star,
|
||||
}
|
||||
|
||||
const containerVariants = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: {
|
||||
staggerChildren: 0.08,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 15 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.4, ease: [0.22, 1, 0.36, 1] as [number, number, number, number] }
|
||||
}
|
||||
}
|
||||
|
||||
export const TrustBarBlock: React.FC<Props> = ({ items }) => {
|
||||
if (!items || items.length === 0) return null
|
||||
|
||||
return (
|
||||
<section className="bg-background py-8 border-b border-border">
|
||||
<section className="bg-card py-10 border-b border-border">
|
||||
<div className="container-premium">
|
||||
<div className="flex flex-wrap gap-x-8 gap-y-6 md:gap-x-12 lg:gap-x-16 items-start justify-start">
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
variants={containerVariants}
|
||||
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 divide-y sm:divide-y-0 sm:divide-x divide-border"
|
||||
>
|
||||
{items.map((item: NonNullable<Props['items']>[0], index: number) => {
|
||||
const Icon = item.icon ? iconMap[item.icon as keyof typeof iconMap] : CheckCircle
|
||||
return (
|
||||
<div
|
||||
<motion.div
|
||||
key={item.id || index}
|
||||
className="flex items-center gap-3"
|
||||
variants={itemVariants}
|
||||
className={`flex items-center gap-4 ${index !== 0 ? 'sm:pl-6' : ''} ${index > 1 ? 'lg:pl-6' : ''} pt-6 sm:pt-0`}
|
||||
>
|
||||
<div className="text-foreground flex-shrink-0">
|
||||
<Icon size={24} />
|
||||
<div className="text-accent flex-shrink-0">
|
||||
<Icon size={24} strokeWidth={1.5} />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-foreground">{item.text}</span>
|
||||
</div>
|
||||
<span className="text-sm md:text-base font-medium text-foreground">{item.text}</span>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -2,52 +2,72 @@
|
||||
import React from 'react'
|
||||
import type { Page } from '../../payload-types'
|
||||
import Image from 'next/image'
|
||||
import { CheckCircle } from 'lucide-react'
|
||||
import { CheckCircle, ImageIcon } from 'lucide-react'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
type Props = Extract<NonNullable<Page['layout']>[0], { blockType: 'uniqueValue' }>
|
||||
|
||||
export const UniqueValueBlock: React.FC<Props> = ({ eyebrow, features, title, description, image }) => {
|
||||
const imageUrl = image && typeof image === 'object' && image.url ? image.url : null
|
||||
const imageAlt = (image && typeof image === 'object' && image.alt) || "Rozliczenia bez bariery językowej - komunikacja"
|
||||
|
||||
return (
|
||||
<section className="py-20 md:py-32 bg-card/60 border-y border-border">
|
||||
<div className="container-premium">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-16 lg:gap-24 items-center">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-16 lg:gap-24 items-center overflow-hidden">
|
||||
{/* Kolumna ze zdjęciem */}
|
||||
<div className="lg:col-span-5 order-2 lg:order-1">
|
||||
<div className="relative aspect-[4/5] overflow-hidden border border-border">
|
||||
{/* PLACEHOLDER - do podmiany na realne zdjęcia klienta */}
|
||||
<Image
|
||||
src={image && typeof image === 'object' && image.url ? image.url : "https://images.unsplash.com/photo-1454165804606-c3d57bc86b40?auto=format&fit=crop&w=1000&q=80"}
|
||||
alt={(image && typeof image === 'object' && image.alt) || "Dokumenty i profesjonalne doradztwo podatkowe"}
|
||||
fill
|
||||
sizes="(max-width: 1024px) 100vw, 40vw"
|
||||
className="object-cover grayscale hover:grayscale-0 transition-all duration-700"
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -30 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="lg:col-span-5 order-2 lg:order-1"
|
||||
>
|
||||
<div className="relative aspect-[4/5] overflow-hidden border border-border bg-card">
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={imageAlt}
|
||||
fill
|
||||
sizes="(max-width: 1024px) 100vw, 40vw"
|
||||
className="object-cover grayscale hover:grayscale-0 transition-all duration-700"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center text-border gap-4 p-8 text-center border border-border">
|
||||
<ImageIcon size={64} strokeWidth={1} />
|
||||
<span className="text-xs tracking-widest uppercase font-semibold text-foreground/40">ZDJĘCIE ILUSTRACYJNE</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Kolumna tekstowa */}
|
||||
<div className="lg:col-span-7 order-1 lg:order-2 flex flex-col items-start max-w-3xl">
|
||||
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1], delay: 0.1 }}
|
||||
className="lg:col-span-7 order-1 lg:order-2 flex flex-col items-start max-w-3xl"
|
||||
>
|
||||
<h2 className="font-heading text-3xl sm:text-4xl md:text-5xl font-extrabold text-foreground tracking-tight mb-6 text-balance">
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
<p className="text-foreground/75 text-lg md:text-xl font-light leading-relaxed mb-12">
|
||||
<p className="text-foreground/75 text-lg md:text-xl font-light leading-relaxed mb-12 max-w-[65ch]">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
{features && features.length > 0 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6 w-full pt-6 border-t border-border/60 text-base">
|
||||
{features.map((feature: any, index: number) => (
|
||||
<div key={feature.id || index} className="flex items-center gap-2.5 text-foreground/80 font-medium">
|
||||
<span className="w-2 h-2 rounded-full bg-accent" />
|
||||
{features.map((feature: NonNullable<Props['features']>[0], index: number) => (
|
||||
<div key={feature.id || index} className="flex items-center gap-3 text-foreground/90 font-medium">
|
||||
<CheckCircle className="text-accent flex-shrink-0" size={20} strokeWidth={2} />
|
||||
<span>{feature.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -8,10 +8,12 @@ import { Process } from '../blocks/Process/config'
|
||||
import { Affiliation } from '../blocks/Affiliation/config'
|
||||
import { Testimonials } from '../blocks/Testimonials/config'
|
||||
import { FAQTeaser } from '../blocks/FAQTeaser/config'
|
||||
import { FAQFull } from '../blocks/FAQFull/config'
|
||||
import { FinalCTA } from '../blocks/FinalCTA/config'
|
||||
|
||||
import { PageHeader } from '../blocks/PageHeader/config'
|
||||
import { ContactInfo } from '../blocks/ContactInfo/config'
|
||||
import { TextSection } from '../blocks/TextSection/config'
|
||||
import { ServicesDetailed } from '../blocks/ServicesDetailed/config'
|
||||
import { Eligibility } from '../blocks/Eligibility/config'
|
||||
import { Stats } from '../blocks/Stats/config'
|
||||
@@ -57,16 +59,9 @@ export const Pages: CollectionConfig = {
|
||||
Testimonials,
|
||||
TestimonialsGrid,
|
||||
FAQTeaser,
|
||||
FAQFull,
|
||||
FinalCTA,
|
||||
{
|
||||
slug: 'TextSection',
|
||||
fields: [
|
||||
{
|
||||
name: 'text',
|
||||
type: 'text',
|
||||
}
|
||||
]
|
||||
}
|
||||
TextSection,
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
'use client'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { MessageCircle } from 'lucide-react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
|
||||
export const FloatingWhatsApp = ({ whatsappLink, ariaLabel }: { whatsappLink: string, ariaLabel: string }) => {
|
||||
const [isVisible, setIsVisible] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
setIsVisible(!entry.isIntersecting)
|
||||
})
|
||||
}, { rootMargin: '0px 0px -100px 0px' })
|
||||
|
||||
const finalCta = document.getElementById('final-cta')
|
||||
if (finalCta) {
|
||||
observer.observe(finalCta)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (finalCta) observer.unobserve(finalCta)
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isVisible && (
|
||||
<motion.div
|
||||
className="fixed bottom-6 right-6 z-40"
|
||||
initial={{ scale: 1.5, rotate: 10, opacity: 0 }}
|
||||
animate={{ scale: 1, rotate: 0, opacity: 1 }}
|
||||
exit={{ scale: 0.8, opacity: 0 }}
|
||||
transition={{ duration: 0.4, ease: [0.175, 0.885, 0.32, 1.275] }}
|
||||
>
|
||||
<a
|
||||
href={whatsappLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex h-14 w-14 items-center justify-center rounded-full bg-[#25D366] text-[#1C1B1A] shadow-lg transition-transform hover:scale-105 hover:bg-[#20bd5a] focus:outline-none focus:ring-4 focus:ring-[#25D366]/50"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<MessageCircle size={28} />
|
||||
</a>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { Menu, X, MessageCircle } from "lucide-react";
|
||||
import { i18n } from '../../i18n.config';
|
||||
import { LocalizedLink } from '../LocalizedLink';
|
||||
|
||||
export function Header({ navItems = [] }: { navItems?: any[] }) {
|
||||
export function Header({ navItems = [], whatsappNumber }: { navItems?: any[], whatsappNumber?: string | null }) {
|
||||
const pathname = usePathname();
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
@@ -24,6 +24,7 @@ export function Header({ navItems = [] }: { navItems?: any[] }) {
|
||||
|
||||
const currentLocaleCode = (pathname?.split('/').filter(Boolean)[0]) || i18n.defaultLocale;
|
||||
const currentLocale = i18n.locales.find(l => l.code === currentLocaleCode) || i18n.locales[0];
|
||||
const whatsappLink = whatsappNumber ? `https://wa.me/${whatsappNumber}` : '#';
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -139,15 +140,17 @@ export function Header({ navItems = [] }: { navItems?: any[] }) {
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={process.env.NEXT_PUBLIC_WHATSAPP_NUMBER ? `https://wa.me/${process.env.NEXT_PUBLIC_WHATSAPP_NUMBER}` : '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-2 bg-green-500 hover:bg-green-600 text-white px-8 py-4 rounded-none transition-colors w-full max-w-xs shadow-lg"
|
||||
>
|
||||
<MessageCircle size={24} />
|
||||
<span className="font-bold text-lg">WhatsApp</span>
|
||||
</Link>
|
||||
{whatsappNumber && (
|
||||
<Link
|
||||
href={whatsappLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-2 bg-[#25D366] hover:bg-[#20bd5a] text-[#1C1B1A] px-8 py-4 rounded-none transition-colors w-full max-w-xs shadow-lg"
|
||||
>
|
||||
<MessageCircle size={24} />
|
||||
<span className="font-bold text-lg">WhatsApp</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
'use client'
|
||||
import React from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
export const MotionStamp: React.FC<{ children: React.ReactNode, className?: string, id?: string, style?: React.CSSProperties }> = ({ children, className, id, style }) => {
|
||||
return (
|
||||
<motion.section
|
||||
id={id}
|
||||
style={style}
|
||||
initial={{ scale: 1.1, rotate: 2, opacity: 0 }}
|
||||
whileInView={{ scale: 1, rotate: 0, opacity: 1 }}
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
transition={{ type: "spring", stiffness: 100, damping: 15 }}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</motion.section>
|
||||
)
|
||||
}
|
||||
@@ -30,7 +30,9 @@ export const Company: GlobalConfig = {
|
||||
name: 'whatsappNumber',
|
||||
type: 'text',
|
||||
localized: true,
|
||||
defaultValue: () => process.env.NEXT_PUBLIC_WHATSAPP_NUMBER || '',
|
||||
admin: {
|
||||
description: 'Numer WhatsApp do kontaktu. Wpisz z numerem kierunkowym bez + (np. 48123456789).',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { getPayload } from 'payload';
|
||||
import configPromise from '@payload-config';
|
||||
import { cache } from 'react';
|
||||
import type { Config } from '../payload-types';
|
||||
|
||||
export const getGlobal = cache(async <T extends keyof Config['globals']>(
|
||||
slug: T,
|
||||
depth = 0,
|
||||
locale: Config['locale'] | 'all' = 'all'
|
||||
) => {
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
const global = await payload.findGlobal({
|
||||
slug,
|
||||
depth,
|
||||
locale,
|
||||
overrideAccess: false,
|
||||
}).catch((err) => {
|
||||
payload.logger.error(`[getGlobal] Failed to fetch '${slug}': ${err instanceof Error ? err.message : String(err)}`);
|
||||
return null;
|
||||
});
|
||||
|
||||
return global;
|
||||
});
|
||||
+68
-1
@@ -360,6 +360,8 @@ export interface Page {
|
||||
| {
|
||||
title: string;
|
||||
description: string;
|
||||
linkText?: string | null;
|
||||
linkUrl?: string | null;
|
||||
id?: string | null;
|
||||
}[]
|
||||
| null;
|
||||
@@ -432,15 +434,39 @@ export interface Page {
|
||||
title: string;
|
||||
questions?:
|
||||
| {
|
||||
category?: string | null;
|
||||
question: string;
|
||||
answer: string;
|
||||
id?: string | null;
|
||||
}[]
|
||||
| null;
|
||||
ctaText?: string | null;
|
||||
ctaUrl?: string | null;
|
||||
id?: string | null;
|
||||
blockName?: string | null;
|
||||
blockType: 'faqTeaser';
|
||||
}
|
||||
| {
|
||||
title: string;
|
||||
subtitle?: string | null;
|
||||
questions?:
|
||||
| {
|
||||
category: string;
|
||||
/**
|
||||
* Zaznacz, jeśli treść nie jest jeszcze gotowa do publikacji (pytanie nie pojawi się na stronie ani w SEO)
|
||||
*/
|
||||
isDraft?: boolean | null;
|
||||
question: string;
|
||||
answer: string;
|
||||
id?: string | null;
|
||||
}[]
|
||||
| null;
|
||||
ctaText?: string | null;
|
||||
ctaUrl?: string | null;
|
||||
id?: string | null;
|
||||
blockName?: string | null;
|
||||
blockType: 'faqFull';
|
||||
}
|
||||
| {
|
||||
eyebrow?: string | null;
|
||||
title: string;
|
||||
@@ -451,7 +477,21 @@ export interface Page {
|
||||
blockType: 'finalCta';
|
||||
}
|
||||
| {
|
||||
text?: string | null;
|
||||
text?: {
|
||||
root: {
|
||||
type: string;
|
||||
children: {
|
||||
type: any;
|
||||
version: number;
|
||||
[k: string]: unknown;
|
||||
}[];
|
||||
direction: ('ltr' | 'rtl') | null;
|
||||
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
|
||||
indent: number;
|
||||
version: number;
|
||||
};
|
||||
[k: string]: unknown;
|
||||
} | null;
|
||||
id?: string | null;
|
||||
blockName?: string | null;
|
||||
blockType: 'TextSection';
|
||||
@@ -804,6 +844,8 @@ export interface PagesSelect<T extends boolean = true> {
|
||||
| {
|
||||
title?: T;
|
||||
description?: T;
|
||||
linkText?: T;
|
||||
linkUrl?: T;
|
||||
id?: T;
|
||||
};
|
||||
id?: T;
|
||||
@@ -883,10 +925,32 @@ export interface PagesSelect<T extends boolean = true> {
|
||||
questions?:
|
||||
| T
|
||||
| {
|
||||
category?: T;
|
||||
question?: T;
|
||||
answer?: T;
|
||||
id?: T;
|
||||
};
|
||||
ctaText?: T;
|
||||
ctaUrl?: T;
|
||||
id?: T;
|
||||
blockName?: T;
|
||||
};
|
||||
faqFull?:
|
||||
| T
|
||||
| {
|
||||
title?: T;
|
||||
subtitle?: T;
|
||||
questions?:
|
||||
| T
|
||||
| {
|
||||
category?: T;
|
||||
isDraft?: T;
|
||||
question?: T;
|
||||
answer?: T;
|
||||
id?: T;
|
||||
};
|
||||
ctaText?: T;
|
||||
ctaUrl?: T;
|
||||
id?: T;
|
||||
blockName?: T;
|
||||
};
|
||||
@@ -975,6 +1039,9 @@ export interface Company {
|
||||
phone?: string | null;
|
||||
email?: string | null;
|
||||
hours?: string | null;
|
||||
/**
|
||||
* Numer WhatsApp do kontaktu. Wpisz z numerem kierunkowym bez + (np. 48123456789).
|
||||
*/
|
||||
whatsappNumber?: string | null;
|
||||
updatedAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
|
||||
Reference in New Issue
Block a user