Fix FAQFull component: Migrate correct page ID (6), fix JSON-LD SSR and TS errors, remove justified-text

This commit is contained in:
MichalC
2026-09-16 12:21:42 +02:00
parent 9806a4929e
commit 437e1e4823
5 changed files with 369 additions and 1 deletions
@@ -7,6 +7,17 @@ import { getLocalizedSlugs } from '@intecion/ipal-kit'
import { i18n } from '../../../../i18n.config'
import { SetLanguageSlugs } from '../../../../components/LanguageSlugsProvider'
import { RenderBlocks } from '../../../../blocks/RenderBlocks'
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[] }>
@@ -47,8 +58,35 @@ export default async function Page(props: {
const layout = docForRender.layout || []
// Generowanie schematu FAQPage dla bloku faqFull (na poziomie Server Component gwarantuje to SSR)
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));
if (validQuestions.length > 0) {
faqSchema = {
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": validQuestions.map((q: any) => ({
"@type": "Question",
"name": q.question,
"acceptedAnswer": {
"@type": "Answer",
"text": q.answer
}
}))
};
}
}
return (
<div className="pt-8 pb-24">
{faqSchema && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqSchema) }}
/>
)}
{/* Rejestracja zlokalizowanych slugów w globalnym kontekście dla LanguageSwitcher */}
<SetLanguageSlugs slugs={slugs} />
@@ -59,7 +97,7 @@ export default async function Page(props: {
)}
<main>
<RenderBlocks blocks={layout} />
<RenderBlocks blocks={layout} locale={locale} />
</main>
</div>
)
+154
View File
@@ -0,0 +1,154 @@
'use client'
import React, { useState } from 'react'
import { motion, AnimatePresence, useScroll, useSpring } from 'framer-motion'
import { ChevronDown, MessageCircle } from 'lucide-react'
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 }) => {
const { scrollYProgress } = useScroll();
const scaleX = useSpring(scrollYProgress, {
stiffness: 100,
damping: 30,
restDelta: 0.001
});
// State to track open question per category
const [openStates, setOpenStates] = useState<Record<string, number | null>>({});
const toggleQuestion = (category: string, index: number) => {
setOpenStates(prev => ({
...prev,
[category]: prev[category] === index ? null : index
}))
}
// Filter out placeholder questions
const validQuestions = (questions || []).filter(q => !isPlaceholder(q.answer));
// Group by category
const groupedQuestions = validQuestions.reduce((acc, q) => {
const cat = q.category || 'Inne';
if (!acc[cat]) {
acc[cat] = [];
}
acc[cat].push(q);
return acc;
}, {} as Record<string, typeof validQuestions>);
return (
<>
{/* Sticky Progress Bar */}
<motion.div
className="fixed top-0 left-0 right-0 h-1 bg-accent z-50 origin-left"
style={{ scaleX }}
/>
{/* Page Header Area */}
<section className="border-b border-border py-16 md:py-20 bg-background">
<div className="container-premium">
<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-6 text-lg md:text-xl text-foreground/70 leading-relaxed max-w-[65ch]">
{subtitle}
</p>
)}
{ctaText && ctaUrl && (
<div className="mt-8">
<a
href={ctaUrl}
className="inline-flex items-center justify-center gap-2 px-6 py-3 text-base font-bold text-accent bg-accent/10 rounded-md hover:bg-accent/20 transition-colors"
>
<MessageCircle size={20} />
{ctaText}
</a>
</div>
)}
</motion.div>
</div>
</section>
{/* FAQ Categories */}
<section className="py-16 md:py-24 bg-background">
<div className="container-premium max-w-4xl">
<div className="space-y-16">
{Object.entries(groupedQuestions).map(([category, catQuestions], catIndex) => (
<motion.div
key={category}
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-100px" }}
transition={{ duration: 0.5, delay: catIndex * 0.1 }}
className="space-y-6"
>
<h2 className="font-heading text-2xl sm:text-3xl font-extrabold text-foreground">
{category}
</h2>
<div className="space-y-4">
{catQuestions.map((q, index) => {
const isOpen = openStates[category] === index;
return (
<div
key={q.id || index}
className="bg-card border border-border overflow-hidden shadow-sm hover:shadow-md transition-shadow"
>
<button
onClick={() => toggleQuestion(category, index)}
className="w-full flex items-center justify-between p-6 text-left hover:bg-background transition-colors focus:outline-none"
aria-expanded={isOpen}
>
<h3 className="font-heading text-lg sm:text-xl font-bold text-foreground pr-8">
{q.question}
</h3>
<div className="w-8 h-8 rounded-full bg-accent/10 flex items-center justify-center shrink-0">
<ChevronDown
className={`w-5 h-5 text-accent transition-transform duration-300 ${isOpen ? 'rotate-180' : ''}`}
/>
</div>
</button>
<AnimatePresence initial={false}>
{isOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
>
<div className="p-6 pt-0 text-foreground/80 leading-relaxed border-t border-border/10 max-w-[70ch]">
{q.answer}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
})}
</div>
</motion.div>
))}
</div>
</div>
</section>
</>
)
}