fix: resolve contact form placeholders, restore process block layout, and fix testimonials carousel scroll logic
This commit is contained in:
@@ -9,7 +9,7 @@ import configPromise from '@payload-config'
|
||||
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 { ConsentProvider, CookieBanner, CookieButton } from '../../../components/Consent'
|
||||
import { FloatingWhatsApp } from '../../../components/FloatingWhatsApp'
|
||||
import { getConsentTexts } from '@intecion/ipal-kit'
|
||||
import { i18n } from '../../../i18n.config'
|
||||
|
||||
@@ -25,7 +25,7 @@ export const ContactInfoBlock: React.FC<Props> = ({
|
||||
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"
|
||||
className="w-full max-w-4xl mx-auto 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)'
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
'use client'
|
||||
import React from 'react'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { MapPin, Phone, Mail, Clock } from 'lucide-react'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
export const ContactSectionClient: React.FC<any> = ({
|
||||
address,
|
||||
phone,
|
||||
email,
|
||||
hours,
|
||||
formNode,
|
||||
}) => {
|
||||
const pathname = usePathname()
|
||||
const isDe = pathname?.split('/').filter(Boolean)[0] === 'de'
|
||||
|
||||
return (
|
||||
<section className="py-16 md:py-24 bg-background">
|
||||
<div className="container-premium">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 lg:gap-16">
|
||||
|
||||
{/* Lewa kolumna: Informacje kontaktowe */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -16 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="flex flex-col"
|
||||
>
|
||||
<h2 className="font-heading text-3xl md:text-4xl font-extrabold text-foreground mb-8 text-balance">
|
||||
{isDe ? 'Kontaktinformationen' : 'Dane kontaktowe'}
|
||||
</h2>
|
||||
|
||||
<div className="bg-card border border-border p-8 relative overflow-hidden flex-grow"
|
||||
style={{ boxShadow: '0 10px 40px -10px rgba(0,0,0,0.08), 0 1px 3px rgba(0,0,0,0.03)' }}
|
||||
>
|
||||
<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" />
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-1 gap-10">
|
||||
{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">
|
||||
{address}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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"
|
||||
>
|
||||
{phone}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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"
|
||||
>
|
||||
{email}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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>
|
||||
<p className="text-foreground/80 leading-relaxed font-medium whitespace-pre-line text-base">
|
||||
{hours}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Prawa kolumna: Formularz kontaktowy */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 16 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1], delay: 0.1 }}
|
||||
className="flex flex-col"
|
||||
>
|
||||
<h2 className="font-heading text-3xl md:text-4xl font-extrabold text-foreground mb-8 text-balance">
|
||||
{isDe ? 'Schreiben Sie uns' : 'Napisz do nas'}
|
||||
</h2>
|
||||
<div className="bg-card border border-border p-8 relative overflow-hidden flex-grow"
|
||||
style={{ boxShadow: '0 10px 40px -10px rgba(0,0,0,0.08), 0 1px 3px rgba(0,0,0,0.03)' }}
|
||||
>
|
||||
{formNode}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from 'react'
|
||||
import type { Page } from '../../payload-types'
|
||||
import { ContactSectionClient } from './Client'
|
||||
import { FormRenderer } from '../../components/FormRenderer'
|
||||
|
||||
type Props = Extract<NonNullable<Page['layout']>[0], { blockType: 'contactSection' }>
|
||||
|
||||
export const ContactSectionBlock: React.FC<Props> = (props) => {
|
||||
const { form } = props
|
||||
|
||||
return (
|
||||
<ContactSectionClient
|
||||
{...props}
|
||||
formNode={
|
||||
typeof form === 'object' && form !== null ? (
|
||||
<FormRenderer form={form} />
|
||||
) : (
|
||||
<p className="text-foreground/60">Formularz nie został podpięty.</p>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Block } from 'payload'
|
||||
|
||||
export const ContactSection: Block = {
|
||||
slug: 'contactSection',
|
||||
fields: [
|
||||
{
|
||||
name: 'address',
|
||||
type: 'textarea',
|
||||
localized: true,
|
||||
admin: {
|
||||
description: 'Użyj Enter by przejść do nowej linii',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'phone',
|
||||
type: 'text',
|
||||
localized: true,
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
type: 'text',
|
||||
localized: true,
|
||||
},
|
||||
{
|
||||
name: 'hours',
|
||||
type: 'textarea',
|
||||
localized: true,
|
||||
admin: {
|
||||
description: 'Użyj Enter by przejść do nowej linii',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'form',
|
||||
type: 'relationship',
|
||||
relationTo: 'forms',
|
||||
required: true,
|
||||
localized: true,
|
||||
admin: {
|
||||
description: 'Wybierz formularz kontaktowy. Dzięki opcji "localized" możesz wybrać inny formularz dla języka polskiego i inny dla niemieckiego.',
|
||||
}
|
||||
}
|
||||
],
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { FinalCTABlock } from './FinalCTA/Component'
|
||||
|
||||
import { PageHeaderBlock } from './PageHeader/Component'
|
||||
import { ContactInfoBlock } from './ContactInfo/Component'
|
||||
import { ContactSectionBlock } from './ContactSection/Component'
|
||||
import { BioBlock } from './Bio/Component'
|
||||
import { TeamBlock } from './Team/Component'
|
||||
import { ServicesDetailedBlock } from './ServicesDetailed/Component'
|
||||
@@ -25,6 +26,7 @@ import { StatsBlock } from './Stats/Component'
|
||||
const blockComponents = {
|
||||
pageHeader: PageHeaderBlock,
|
||||
contactInfo: ContactInfoBlock,
|
||||
contactSection: ContactSectionBlock,
|
||||
bio: BioBlock,
|
||||
team: TeamBlock,
|
||||
servicesDetailed: ServicesDetailedBlock,
|
||||
|
||||
@@ -64,6 +64,7 @@ export const ServicesDetailedBlock: React.FC<Props> = ({
|
||||
return (
|
||||
<motion.div
|
||||
key={service.id || index}
|
||||
id={service.anchor}
|
||||
variants={{
|
||||
hidden: { opacity: 0, y: isFirst ? 24 : 16 },
|
||||
visible: {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Block } from 'payload'
|
||||
import { buildSlugField } from '@intecion/ipal-kit'
|
||||
|
||||
export const ServicesDetailed: Block = {
|
||||
slug: 'servicesDetailed',
|
||||
@@ -12,6 +13,7 @@ export const ServicesDetailed: Block = {
|
||||
required: true,
|
||||
fields: [
|
||||
{ name: 'title', type: 'text', required: true },
|
||||
buildSlugField({ name: 'anchor', from: 'title', required: true, localized: false }),
|
||||
{ name: 'description', type: 'textarea', required: true },
|
||||
{
|
||||
name: 'icon',
|
||||
|
||||
@@ -7,13 +7,15 @@ export const Team: Block = {
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
localized: true,
|
||||
defaultValue: 'Nasz zespół',
|
||||
defaultValue: ({ locale }: { locale: string }) => locale === 'de' ? 'Unser Team' : 'Nasz zespół',
|
||||
},
|
||||
{
|
||||
name: 'subtitle',
|
||||
type: 'textarea',
|
||||
localized: true,
|
||||
defaultValue: 'Poznaj ekspertów, którzy z zaangażowaniem dbają o Twoje finanse. Każdy z nas wnosi wieloletnie doświadczenie, by zagwarantować Ci spokój i bezpieczeństwo.',
|
||||
defaultValue: ({ locale }: { locale: string }) => locale === 'de'
|
||||
? 'Lernen Sie die Experten kennen, die sich mit Engagement um Ihre Finanzen kümmern. Jeder von uns bringt langjährige Erfahrung mit, um Ihnen Sicherheit und Ruhe zu garantieren.'
|
||||
: 'Poznaj ekspertów, którzy z zaangażowaniem dbają o Twoje finanse. Każdy z nas wnosi wieloletnie doświadczenie, by zagwarantować Ci spokój i bezpieczeństwo.',
|
||||
},
|
||||
{
|
||||
name: 'members',
|
||||
|
||||
@@ -32,21 +32,38 @@ export const TestimonialsCarousel: React.FC<CarouselProps> = ({ title, items })
|
||||
if (!containerRef.current) return
|
||||
const { scrollLeft, scrollWidth, clientWidth } = containerRef.current
|
||||
|
||||
// Obliczamy który element jest najbardziej widoczny
|
||||
// Szerokość jednego elementu (przy założeniu że wszystkie są równe i wypełniają szerokość kontenera na mobile,
|
||||
// lub połowę na desktop)
|
||||
const cardWidth = containerRef.current.firstElementChild?.clientWidth || clientWidth
|
||||
const newIndex = Math.round(scrollLeft / cardWidth)
|
||||
|
||||
if (newIndex !== activeIndex) {
|
||||
setActiveIndex(newIndex)
|
||||
// Jeśli przewinęliśmy do samego końca (uwzględniamy drobną tolerancję na różnice pikselowe w przeglądarkach)
|
||||
if (Math.abs(scrollWidth - clientWidth - scrollLeft) < 5) {
|
||||
if (activeIndex !== items.length - 1) {
|
||||
setActiveIndex(items.length - 1)
|
||||
}
|
||||
return
|
||||
}
|
||||
}, [activeIndex])
|
||||
|
||||
// Znajdź kartę, która jest najbliżej lewej krawędzi (scrollLeft)
|
||||
const children = Array.from(containerRef.current.children) as HTMLElement[]
|
||||
let closestIndex = 0
|
||||
let minDiff = Infinity
|
||||
|
||||
children.forEach((child, index) => {
|
||||
const diff = Math.abs(child.offsetLeft - scrollLeft)
|
||||
if (diff < minDiff) {
|
||||
minDiff = diff
|
||||
closestIndex = index
|
||||
}
|
||||
})
|
||||
|
||||
if (closestIndex !== activeIndex) {
|
||||
setActiveIndex(closestIndex)
|
||||
}
|
||||
}, [activeIndex, items.length])
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current
|
||||
if (el) {
|
||||
el.addEventListener('scroll', handleScroll, { passive: true })
|
||||
// Wywołaj raz na start, żeby upewnić się że stan jest zsynchronizowany
|
||||
handleScroll()
|
||||
return () => el.removeEventListener('scroll', handleScroll)
|
||||
}
|
||||
}, [handleScroll])
|
||||
@@ -54,14 +71,16 @@ export const TestimonialsCarousel: React.FC<CarouselProps> = ({ title, items })
|
||||
const scrollTo = (index: number) => {
|
||||
if (!containerRef.current) return
|
||||
|
||||
const cardWidth = containerRef.current.firstElementChild?.clientWidth || containerRef.current.clientWidth
|
||||
|
||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
|
||||
containerRef.current.scrollTo({
|
||||
left: index * cardWidth,
|
||||
behavior: prefersReducedMotion ? 'auto' : 'smooth'
|
||||
})
|
||||
const children = containerRef.current.children
|
||||
if (index >= 0 && index < children.length) {
|
||||
const child = children[index] as HTMLElement
|
||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
|
||||
containerRef.current.scrollTo({
|
||||
left: child.offsetLeft,
|
||||
behavior: prefersReducedMotion ? 'auto' : 'smooth'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const scrollPrev = () => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { FinalCTA } from '../blocks/FinalCTA/config'
|
||||
|
||||
import { PageHeader } from '../blocks/PageHeader/config'
|
||||
import { ContactInfo } from '../blocks/ContactInfo/config'
|
||||
import { ContactSection } from '../blocks/ContactSection/config'
|
||||
import { TextSection } from '../blocks/TextSection/config'
|
||||
import { ServicesDetailed } from '../blocks/ServicesDetailed/config'
|
||||
import { Eligibility } from '../blocks/Eligibility/config'
|
||||
@@ -45,6 +46,7 @@ export const Pages: CollectionConfig = {
|
||||
blocks: [
|
||||
PageHeader,
|
||||
ContactInfo,
|
||||
ContactSection,
|
||||
Bio,
|
||||
Team,
|
||||
ServicesDetailed,
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
'use client'
|
||||
export { ConsentProvider, CookieBanner, CookieButton, useConsentContext } from '@intecion/ipal-kit/client'
|
||||
@@ -2,7 +2,8 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { MessageCircle } from 'lucide-react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { useConsentContext } from '@intecion/ipal-kit/client'
|
||||
|
||||
import { useConsentContext } from '../Consent'
|
||||
|
||||
export const FloatingWhatsApp = ({ whatsappLink, ariaLabel }: { whatsappLink: string, ariaLabel: string }) => {
|
||||
const [isVisible, setIsVisible] = useState(true)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
'use server'
|
||||
|
||||
import { getPayload } from 'payload'
|
||||
import configPromise from '../../payload.config'
|
||||
import { submitForm } from '@intecion/ipal-kit/server'
|
||||
import { headers } from 'next/headers'
|
||||
|
||||
export async function submitContactFormAction(formData: FormData) {
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
|
||||
const formId = formData.get('formId') as string
|
||||
const turnstileToken = formData.get('cf-turnstile-response') as string | undefined
|
||||
|
||||
const data: Record<string, unknown> = {}
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (key !== 'formId' && key !== 'cf-turnstile-response' && !key.startsWith('$ACTION')) {
|
||||
data[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
// The consent checkbox usually sends "on" when checked.
|
||||
// We need to convert it to true for the form builder to accept it as checked boolean if the field is a checkbox.
|
||||
// We can do this automatically for all fields that are not empty but 'on' or 'true' or just leave it.
|
||||
// Wait, payload-plugin-form-builder expects boolean for checkboxes.
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (value === 'on') data[key] = true
|
||||
}
|
||||
|
||||
const heads = await headers()
|
||||
const ip = heads.get('x-forwarded-for') || heads.get('x-real-ip') || undefined
|
||||
|
||||
const result = await submitForm({
|
||||
payload,
|
||||
formId,
|
||||
data,
|
||||
turnstileToken,
|
||||
ip,
|
||||
consentFieldName: 'consent', // we can set default 'consent' or leave undefined (it defaults to 'consent')
|
||||
})
|
||||
|
||||
// Next.js Server Actions must return plain objects
|
||||
return JSON.parse(JSON.stringify(result))
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { Turnstile, resolveFormMessage } from '@intecion/ipal-kit/client'
|
||||
import { submitContactFormAction } from './actions'
|
||||
|
||||
export function FormClient({
|
||||
form,
|
||||
notifications,
|
||||
turnstileSiteKey
|
||||
}: {
|
||||
form: any;
|
||||
notifications: any;
|
||||
turnstileSiteKey: string
|
||||
}) {
|
||||
const [status, setStatus] = useState<'idle' | 'submitting' | 'success'>('idle')
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null)
|
||||
const [turnstileToken, setTurnstileToken] = useState<string | null>(null)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
setStatus('submitting')
|
||||
setErrorMsg(null)
|
||||
|
||||
const formData = new FormData(e.currentTarget)
|
||||
formData.set('formId', form.id.toString())
|
||||
if (turnstileToken) {
|
||||
formData.set('cf-turnstile-response', turnstileToken)
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await submitContactFormAction(formData)
|
||||
if (result.success) {
|
||||
setStatus('success')
|
||||
} else {
|
||||
setStatus('idle')
|
||||
setErrorMsg(resolveFormMessage(result, notifications))
|
||||
}
|
||||
} catch (err) {
|
||||
setStatus('idle')
|
||||
setErrorMsg('Wystąpił błąd podczas wysyłania formularza. Spróbuj ponownie później.')
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 'success') {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-8 text-center bg-accent/5 border border-accent/20 rounded-lg">
|
||||
<div className="w-16 h-16 bg-accent text-accent-foreground rounded-full flex items-center justify-center mb-6">
|
||||
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-foreground mb-4 font-heading">{form.confirmationType === 'message' && form.confirmationMessage ? 'Wiadomość wysłana' : 'Sukces'}</h3>
|
||||
<p className="text-foreground/80 leading-relaxed max-w-md">
|
||||
Dziękujemy za kontakt. Odpowiemy najszybciej jak to możliwe.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-6">
|
||||
{errorMsg && (
|
||||
<div className="p-4 bg-destructive/10 border border-destructive/20 text-destructive rounded-lg text-sm font-medium">
|
||||
{errorMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.fields?.map((field: any, idx: number) => {
|
||||
if (field.blockType === 'text') {
|
||||
return (
|
||||
<div key={idx} className="flex flex-col gap-2">
|
||||
{field.label && <label htmlFor={field.name} className="text-sm font-medium text-foreground/80">{field.label} {field.required && <span className="text-destructive">*</span>}</label>}
|
||||
<input
|
||||
type="text"
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
required={field.required}
|
||||
placeholder={field.defaultValue || ''}
|
||||
className="flex h-12 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (field.blockType === 'email') {
|
||||
return (
|
||||
<div key={idx} className="flex flex-col gap-2">
|
||||
{field.label && <label htmlFor={field.name} className="text-sm font-medium text-foreground/80">{field.label} {field.required && <span className="text-destructive">*</span>}</label>}
|
||||
<input
|
||||
type="email"
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
required={field.required}
|
||||
placeholder={field.defaultValue || (field.label === 'Adres e-mail' ? '[email protected]' : '[email protected]')}
|
||||
className="flex h-12 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (field.blockType === 'textarea') {
|
||||
return (
|
||||
<div key={idx} className="flex flex-col gap-2">
|
||||
{field.label && <label htmlFor={field.name} className="text-sm font-medium text-foreground/80">{field.label} {field.required && <span className="text-destructive">*</span>}</label>}
|
||||
<textarea
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
required={field.required}
|
||||
rows={5}
|
||||
placeholder={field.defaultValue || ''}
|
||||
className="flex min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (field.blockType === 'select') {
|
||||
return (
|
||||
<div key={idx} className="flex flex-col gap-2">
|
||||
{field.label && <label htmlFor={field.name} className="text-sm font-medium text-foreground/80">{field.label} {field.required && <span className="text-destructive">*</span>}</label>}
|
||||
<select
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
required={field.required}
|
||||
className="flex h-12 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<option value="">--</option>
|
||||
{field.options?.map((opt: any, oIdx: number) => (
|
||||
<option key={oIdx} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (field.blockType === 'checkbox') {
|
||||
return (
|
||||
<div key={idx} className="flex flex-row items-start gap-3 mt-2">
|
||||
<div className="flex items-center h-6">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
required={field.required}
|
||||
defaultChecked={field.defaultValue}
|
||||
className="w-5 h-5 rounded border-border text-accent focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
{field.label && (
|
||||
<label htmlFor={field.name} className="text-sm text-foreground/80 leading-tight">
|
||||
<span dangerouslySetInnerHTML={{ __html: field.label }} />
|
||||
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
|
||||
{turnstileSiteKey && (
|
||||
<div className="my-2">
|
||||
<Turnstile siteKey={turnstileSiteKey} onToken={setTurnstileToken} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === 'submitting'}
|
||||
className="mt-4 h-14 bg-accent hover:bg-accent/90 text-white font-bold font-heading tracking-wide uppercase px-8 flex items-center justify-center transition-colors disabled:opacity-70 disabled:cursor-not-allowed"
|
||||
>
|
||||
{status === 'submitting' ? 'Wysyłanie...' : (form.submitButtonLabel || 'Wyślij')}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from 'react'
|
||||
import { getPayload } from 'payload'
|
||||
import configPromise from '../../payload.config'
|
||||
import { FormClient } from './client'
|
||||
import { getNotificationTexts, getSiteIntegrations } from '@intecion/ipal-kit'
|
||||
import { headers } from 'next/headers'
|
||||
|
||||
export async function FormRenderer({ form }: { form: any }) {
|
||||
if (typeof form !== 'object' || !form) return null
|
||||
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
|
||||
// Get current locale from headers or fallback
|
||||
const heads = await headers()
|
||||
const pathname = heads.get('x-pathname') || ''
|
||||
const locale = pathname.split('/').filter(Boolean)[0] === 'de' ? 'de' : 'pl'
|
||||
|
||||
const notifications = await getNotificationTexts({ payload, locale })
|
||||
const siteIntegrations = await getSiteIntegrations<any>(payload, { locale })
|
||||
|
||||
const turnstileSiteKey = siteIntegrations?.turnstile?.siteKey || ''
|
||||
|
||||
return (
|
||||
<FormClient
|
||||
form={form}
|
||||
notifications={notifications.form}
|
||||
turnstileSiteKey={turnstileSiteKey}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -29,16 +29,16 @@ export function Header({ navItems = [], whatsappNumber }: { navItems?: any[], wh
|
||||
return (
|
||||
<>
|
||||
<header
|
||||
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
|
||||
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 bg-background/90 backdrop-blur-md shadow-sm border-b border-border ${
|
||||
isScrolled
|
||||
? "bg-background/90 backdrop-blur-md shadow-sm border-b border-border py-4"
|
||||
: "bg-transparent py-6"
|
||||
? "py-4"
|
||||
: "py-6"
|
||||
}`}
|
||||
style={{ paddingRight: 'var(--scrollbar-width)' }}
|
||||
>
|
||||
<div className="container-premium flex items-center justify-between">
|
||||
{/* Logo */}
|
||||
<Link href={`/${currentLocale.code}`} className="group flex items-center gap-3 z-50" aria-label="Finanz- & Lohnbuchhaltung Dipl.-Ing. Janusz Kędzierski">
|
||||
<Link href={`/${currentLocale.code}`} className="group flex items-center gap-3 z-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-background rounded-sm" aria-label="Finanz- & Lohnbuchhaltung Dipl.-Ing. Janusz Kędzierski">
|
||||
<div className="w-10 h-10 rounded-none bg-accent text-white flex items-center justify-center font-heading font-bold text-xl group-hover:bg-accent-hover transition-colors shadow-sm">
|
||||
JK
|
||||
</div>
|
||||
|
||||
@@ -71,6 +71,8 @@ export interface Config {
|
||||
users: User;
|
||||
media: Media;
|
||||
pages: Page;
|
||||
forms: Form;
|
||||
'form-submissions': FormSubmission;
|
||||
'payload-mcp-api-keys': PayloadMcpApiKey;
|
||||
'payload-kv': PayloadKv;
|
||||
'payload-locked-documents': PayloadLockedDocument;
|
||||
@@ -82,6 +84,8 @@ export interface Config {
|
||||
users: UsersSelect<false> | UsersSelect<true>;
|
||||
media: MediaSelect<false> | MediaSelect<true>;
|
||||
pages: PagesSelect<false> | PagesSelect<true>;
|
||||
forms: FormsSelect<false> | FormsSelect<true>;
|
||||
'form-submissions': FormSubmissionsSelect<false> | FormSubmissionsSelect<true>;
|
||||
'payload-mcp-api-keys': PayloadMcpApiKeysSelect<false> | PayloadMcpApiKeysSelect<true>;
|
||||
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
|
||||
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
||||
@@ -230,6 +234,25 @@ export interface Page {
|
||||
blockName?: string | null;
|
||||
blockType: 'contactInfo';
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* Użyj Enter by przejść do nowej linii
|
||||
*/
|
||||
address?: string | null;
|
||||
phone?: string | null;
|
||||
email?: string | null;
|
||||
/**
|
||||
* Użyj Enter by przejść do nowej linii
|
||||
*/
|
||||
hours?: string | null;
|
||||
/**
|
||||
* Wybierz formularz kontaktowy. Dzięki opcji "localized" możesz wybrać inny formularz dla języka polskiego i inny dla niemieckiego.
|
||||
*/
|
||||
form: number | Form;
|
||||
id?: string | null;
|
||||
blockName?: string | null;
|
||||
blockType: 'contactSection';
|
||||
}
|
||||
| {
|
||||
title: string;
|
||||
paragraphs: {
|
||||
@@ -269,6 +292,10 @@ export interface Page {
|
||||
subtitle?: string | null;
|
||||
services: {
|
||||
title: string;
|
||||
/**
|
||||
* Auto-generated from the title when left empty. You can override it.
|
||||
*/
|
||||
anchor: string;
|
||||
description: string;
|
||||
icon?: ('fileText' | 'calculator' | 'shieldCheck' | 'scale' | 'helpCircle' | 'users' | 'coins') | null;
|
||||
id?: string | null;
|
||||
@@ -508,6 +535,193 @@ export interface Page {
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "forms".
|
||||
*/
|
||||
export interface Form {
|
||||
id: number;
|
||||
title: string;
|
||||
fields?:
|
||||
| (
|
||||
| {
|
||||
name: string;
|
||||
label?: string | null;
|
||||
width?: number | null;
|
||||
required?: boolean | null;
|
||||
defaultValue?: boolean | null;
|
||||
id?: string | null;
|
||||
blockName?: string | null;
|
||||
blockType: 'checkbox';
|
||||
}
|
||||
| {
|
||||
name: string;
|
||||
label?: string | null;
|
||||
width?: number | null;
|
||||
required?: boolean | null;
|
||||
id?: string | null;
|
||||
blockName?: string | null;
|
||||
blockType: 'country';
|
||||
}
|
||||
| {
|
||||
name: string;
|
||||
label?: string | null;
|
||||
width?: number | null;
|
||||
required?: boolean | null;
|
||||
id?: string | null;
|
||||
blockName?: string | null;
|
||||
blockType: 'email';
|
||||
}
|
||||
| {
|
||||
message?: {
|
||||
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: 'message';
|
||||
}
|
||||
| {
|
||||
name: string;
|
||||
label?: string | null;
|
||||
width?: number | null;
|
||||
defaultValue?: number | null;
|
||||
required?: boolean | null;
|
||||
id?: string | null;
|
||||
blockName?: string | null;
|
||||
blockType: 'number';
|
||||
}
|
||||
| {
|
||||
name: string;
|
||||
label?: string | null;
|
||||
width?: number | null;
|
||||
defaultValue?: string | null;
|
||||
placeholder?: string | null;
|
||||
options?:
|
||||
| {
|
||||
label: string;
|
||||
value: string;
|
||||
id?: string | null;
|
||||
}[]
|
||||
| null;
|
||||
required?: boolean | null;
|
||||
id?: string | null;
|
||||
blockName?: string | null;
|
||||
blockType: 'select';
|
||||
}
|
||||
| {
|
||||
name: string;
|
||||
label?: string | null;
|
||||
width?: number | null;
|
||||
required?: boolean | null;
|
||||
id?: string | null;
|
||||
blockName?: string | null;
|
||||
blockType: 'state';
|
||||
}
|
||||
| {
|
||||
name: string;
|
||||
label?: string | null;
|
||||
width?: number | null;
|
||||
defaultValue?: string | null;
|
||||
required?: boolean | null;
|
||||
id?: string | null;
|
||||
blockName?: string | null;
|
||||
blockType: 'text';
|
||||
}
|
||||
| {
|
||||
name: string;
|
||||
label?: string | null;
|
||||
width?: number | null;
|
||||
defaultValue?: string | null;
|
||||
required?: boolean | null;
|
||||
id?: string | null;
|
||||
blockName?: string | null;
|
||||
blockType: 'textarea';
|
||||
}
|
||||
)[]
|
||||
| null;
|
||||
submitButtonLabel?: string | null;
|
||||
confirmationType?: ('message' | 'redirect') | null;
|
||||
confirmationMessage?: {
|
||||
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;
|
||||
redirect?: {
|
||||
type?: ('reference' | 'custom') | null;
|
||||
reference?: {
|
||||
relationTo: 'pages';
|
||||
value: number | Page;
|
||||
} | null;
|
||||
url?: string | null;
|
||||
};
|
||||
emails?:
|
||||
| {
|
||||
emailTo?: string | null;
|
||||
cc?: string | null;
|
||||
bcc?: string | null;
|
||||
replyTo?: string | null;
|
||||
emailFrom?: string | null;
|
||||
subject: string;
|
||||
message?: {
|
||||
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;
|
||||
}[]
|
||||
| null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "form-submissions".
|
||||
*/
|
||||
export interface FormSubmission {
|
||||
id: number;
|
||||
form: number | Form;
|
||||
submissionData?:
|
||||
| {
|
||||
field: string;
|
||||
value: string;
|
||||
id?: string | null;
|
||||
}[]
|
||||
| null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* API keys control which collections, resources, tools, and prompts MCP clients can access
|
||||
*
|
||||
@@ -571,6 +785,14 @@ export interface PayloadLockedDocument {
|
||||
relationTo: 'pages';
|
||||
value: number | Page;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'forms';
|
||||
value: number | Form;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'form-submissions';
|
||||
value: number | FormSubmission;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'payload-mcp-api-keys';
|
||||
value: number | PayloadMcpApiKey;
|
||||
@@ -696,6 +918,17 @@ export interface PagesSelect<T extends boolean = true> {
|
||||
id?: T;
|
||||
blockName?: T;
|
||||
};
|
||||
contactSection?:
|
||||
| T
|
||||
| {
|
||||
address?: T;
|
||||
phone?: T;
|
||||
email?: T;
|
||||
hours?: T;
|
||||
form?: T;
|
||||
id?: T;
|
||||
blockName?: T;
|
||||
};
|
||||
bio?:
|
||||
| T
|
||||
| {
|
||||
@@ -743,6 +976,7 @@ export interface PagesSelect<T extends boolean = true> {
|
||||
| T
|
||||
| {
|
||||
title?: T;
|
||||
anchor?: T;
|
||||
description?: T;
|
||||
icon?: T;
|
||||
id?: T;
|
||||
@@ -986,6 +1220,157 @@ export interface PagesSelect<T extends boolean = true> {
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "forms_select".
|
||||
*/
|
||||
export interface FormsSelect<T extends boolean = true> {
|
||||
title?: T;
|
||||
fields?:
|
||||
| T
|
||||
| {
|
||||
checkbox?:
|
||||
| T
|
||||
| {
|
||||
name?: T;
|
||||
label?: T;
|
||||
width?: T;
|
||||
required?: T;
|
||||
defaultValue?: T;
|
||||
id?: T;
|
||||
blockName?: T;
|
||||
};
|
||||
country?:
|
||||
| T
|
||||
| {
|
||||
name?: T;
|
||||
label?: T;
|
||||
width?: T;
|
||||
required?: T;
|
||||
id?: T;
|
||||
blockName?: T;
|
||||
};
|
||||
email?:
|
||||
| T
|
||||
| {
|
||||
name?: T;
|
||||
label?: T;
|
||||
width?: T;
|
||||
required?: T;
|
||||
id?: T;
|
||||
blockName?: T;
|
||||
};
|
||||
message?:
|
||||
| T
|
||||
| {
|
||||
message?: T;
|
||||
id?: T;
|
||||
blockName?: T;
|
||||
};
|
||||
number?:
|
||||
| T
|
||||
| {
|
||||
name?: T;
|
||||
label?: T;
|
||||
width?: T;
|
||||
defaultValue?: T;
|
||||
required?: T;
|
||||
id?: T;
|
||||
blockName?: T;
|
||||
};
|
||||
select?:
|
||||
| T
|
||||
| {
|
||||
name?: T;
|
||||
label?: T;
|
||||
width?: T;
|
||||
defaultValue?: T;
|
||||
placeholder?: T;
|
||||
options?:
|
||||
| T
|
||||
| {
|
||||
label?: T;
|
||||
value?: T;
|
||||
id?: T;
|
||||
};
|
||||
required?: T;
|
||||
id?: T;
|
||||
blockName?: T;
|
||||
};
|
||||
state?:
|
||||
| T
|
||||
| {
|
||||
name?: T;
|
||||
label?: T;
|
||||
width?: T;
|
||||
required?: T;
|
||||
id?: T;
|
||||
blockName?: T;
|
||||
};
|
||||
text?:
|
||||
| T
|
||||
| {
|
||||
name?: T;
|
||||
label?: T;
|
||||
width?: T;
|
||||
defaultValue?: T;
|
||||
required?: T;
|
||||
id?: T;
|
||||
blockName?: T;
|
||||
};
|
||||
textarea?:
|
||||
| T
|
||||
| {
|
||||
name?: T;
|
||||
label?: T;
|
||||
width?: T;
|
||||
defaultValue?: T;
|
||||
required?: T;
|
||||
id?: T;
|
||||
blockName?: T;
|
||||
};
|
||||
};
|
||||
submitButtonLabel?: T;
|
||||
confirmationType?: T;
|
||||
confirmationMessage?: T;
|
||||
redirect?:
|
||||
| T
|
||||
| {
|
||||
type?: T;
|
||||
reference?: T;
|
||||
url?: T;
|
||||
};
|
||||
emails?:
|
||||
| T
|
||||
| {
|
||||
emailTo?: T;
|
||||
cc?: T;
|
||||
bcc?: T;
|
||||
replyTo?: T;
|
||||
emailFrom?: T;
|
||||
subject?: T;
|
||||
message?: T;
|
||||
id?: T;
|
||||
};
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "form-submissions_select".
|
||||
*/
|
||||
export interface FormSubmissionsSelect<T extends boolean = true> {
|
||||
form?: T;
|
||||
submissionData?:
|
||||
| T
|
||||
| {
|
||||
field?: T;
|
||||
value?: T;
|
||||
id?: T;
|
||||
};
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-mcp-api-keys_select".
|
||||
|
||||
+12
-2
@@ -1,6 +1,6 @@
|
||||
import { sqliteAdapter } from '@payloadcms/db-sqlite'
|
||||
import { mcpPlugin } from '@payloadcms/plugin-mcp'
|
||||
import { ipalKit } from '@intecion/ipal-kit'
|
||||
import { ipalKit, panelSmtpAdapter } from '@intecion/ipal-kit'
|
||||
import { lexicalEditor } from '@payloadcms/richtext-lexical'
|
||||
import path from 'path'
|
||||
import { buildConfig } from 'payload'
|
||||
@@ -43,5 +43,15 @@ export default buildConfig({
|
||||
fallback: true,
|
||||
defaultLocale: i18n.defaultLocale,
|
||||
},
|
||||
plugins: [mcpPlugin({}), ipalKit({ i18n, pages: { slug: 'pages' } })],
|
||||
email: panelSmtpAdapter(),
|
||||
plugins: [
|
||||
mcpPlugin({}),
|
||||
ipalKit({
|
||||
i18n,
|
||||
pages: { slug: 'pages' },
|
||||
forms: {
|
||||
redirectRelationships: ['pages']
|
||||
}
|
||||
})
|
||||
],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user