diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f7fa87e..03b6d9a 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,34 +1,76 @@ -import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; -import "./globals.css"; +import type { Metadata } from 'next' +import { Playfair_Display, Inter } from 'next/font/google' +import { Header, Footer } from '@/components/layout' +import { UmamiScript } from '@/components/analytics' +import { getSiteSettings, getNavigation } from '@/lib/api' +import './globals.css' -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); +const playfair = Playfair_Display({ + variable: '--font-playfair', + subsets: ['latin'], + display: 'swap', +}) -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); +const inter = Inter({ + variable: '--font-inter', + subsets: ['latin'], + display: 'swap', +}) -export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", -}; +export async function generateMetadata(): Promise { + const settings = await getSiteSettings() -export default function RootLayout({ + return { + title: { + default: settings?.siteName || 'BlogWoman', + template: `%s | ${settings?.siteName || 'BlogWoman'}`, + }, + description: settings?.siteDescription || 'Lifestyle-Blog für moderne Frauen', + metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL || 'https://blogwoman.de'), + openGraph: { + type: 'website', + locale: 'de_DE', + siteName: settings?.siteName || 'BlogWoman', + }, + twitter: { + card: 'summary_large_image', + }, + robots: { + index: true, + follow: true, + }, + } +} + +export default async function RootLayout({ children, }: Readonly<{ - children: React.ReactNode; + children: React.ReactNode }>) { + // Fetch navigation and settings in parallel + const [headerNav, footerNav, settings] = await Promise.all([ + getNavigation('header'), + getNavigation('footer'), + getSiteSettings(), + ]) + return ( - - - {children} + + + {/* Skip to main content link for accessibility */} + + Zum Hauptinhalt springen + +
+
+
{children}
+
+
+ - ); + ) } diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx new file mode 100644 index 0000000..0d24445 --- /dev/null +++ b/src/app/not-found.tsx @@ -0,0 +1,69 @@ +import Link from 'next/link' +import { Button } from '@/components/ui' + +export default function NotFound() { + return ( +
+
+
+ {/* 404 Visual */} +
+

+ 404 +

+
+ + {/* Content */} +

+ Seite nicht gefunden +

+

+ Die gesuchte Seite existiert leider nicht oder wurde verschoben. + Keine Sorge, hier sind einige hilfreiche Links: +

+ + {/* Primary Action */} +
+ +
+ + {/* Helpful Links */} +
+

Beliebte Seiten

+ +
+
+
+
+ ) +} diff --git a/src/components/blocks/DividerBlock.tsx b/src/components/blocks/DividerBlock.tsx new file mode 100644 index 0000000..2842c8b --- /dev/null +++ b/src/components/blocks/DividerBlock.tsx @@ -0,0 +1,40 @@ +import type { DividerBlock as DividerBlockType } from '@/lib/types' + +type DividerBlockProps = Omit + +export function DividerBlock({ style = 'line', text }: DividerBlockProps) { + if (style === 'space') { + return
+ } + + if (style === 'dots') { + return ( +
+ + + +
+ ) + } + + // Line with optional text + if (text) { + return ( +
+
+
+ + {text} + +
+
+
+ ) + } + + return ( +
+
+
+ ) +} diff --git a/src/components/blocks/FAQBlock.tsx b/src/components/blocks/FAQBlock.tsx new file mode 100644 index 0000000..3058018 --- /dev/null +++ b/src/components/blocks/FAQBlock.tsx @@ -0,0 +1,198 @@ +'use client' + +import { useState } from 'react' +import Script from 'next/script' +import { cn } from '@/lib/utils' +import { RichTextRenderer } from './RichTextRenderer' +import type { FAQBlock as FAQBlockType, FAQ } from '@/lib/types' + +type FAQBlockProps = Omit & { + faqs?: FAQ[] +} + +export function FAQBlock({ + title, + subtitle, + displayMode, + selectedFaqs, + filterCategory, + layout = 'accordion', + expandFirst = false, + showSchema = true, + faqs: externalFaqs, +}: FAQBlockProps) { + // Use selectedFaqs if displayMode is 'selected', otherwise use externalFaqs + const items = displayMode === 'selected' ? selectedFaqs : externalFaqs + + if (!items || items.length === 0) return null + + // Generate JSON-LD schema data + const schemaData = showSchema + ? { + '@context': 'https://schema.org', + '@type': 'FAQPage', + mainEntity: items.map((faq) => ({ + '@type': 'Question', + name: faq.question, + acceptedAnswer: { + '@type': 'Answer', + text: extractTextFromRichText(faq.answer), + }, + })), + } + : null + + return ( +
+
+ {/* Section Header */} + {(title || subtitle) && ( +
+ {title &&

{title}

} + {subtitle && ( +

{subtitle}

+ )} +
+ )} + + {/* FAQ Items */} +
+ {layout === 'accordion' ? ( + + ) : layout === 'grid' ? ( + + ) : ( + + )} +
+ + {/* JSON-LD Schema using Next.js Script component for safety */} + {schemaData && ( + + )} +
+
+ ) +} + +function AccordionFAQ({ + items, + expandFirst, +}: { + items: FAQ[] + expandFirst: boolean +}) { + const [openIndex, setOpenIndex] = useState( + expandFirst ? 0 : null + ) + + return ( +
+ {items.map((faq, index) => ( +
+ + +
+
+
+ +
+
+
+
+ ))} +
+ ) +} + +function ListFAQ({ items }: { items: FAQ[] }) { + return ( +
+ {items.map((faq) => ( +
+

{faq.question}

+ +
+ ))} +
+ ) +} + +function GridFAQ({ items }: { items: FAQ[] }) { + return ( +
+ {items.map((faq) => ( +
+

{faq.question}

+ +
+ ))} +
+ ) +} + +// Helper to extract plain text from RichText for schema +function extractTextFromRichText(richText: FAQ['answer']): string { + if (!richText?.root?.children) return '' + + function extractFromNode(node: Record): string { + if (node.text) return node.text as string + + const children = node.children as Record[] | undefined + if (children) { + return children.map(extractFromNode).join('') + } + + return '' + } + + return richText.root.children + .map((node) => extractFromNode(node as Record)) + .join(' ') + .trim() +} diff --git a/src/components/blocks/FavoritesBlock.tsx b/src/components/blocks/FavoritesBlock.tsx new file mode 100644 index 0000000..98b7655 --- /dev/null +++ b/src/components/blocks/FavoritesBlock.tsx @@ -0,0 +1,198 @@ +import Image from 'next/image' +import { cn, getImageUrl } from '@/lib/utils' +import { Badge } from '@/components/ui' +import { getFavorites } from '@/lib/api' +import type { FavoritesBlock as FavoritesBlockType, Favorite } from '@/lib/types' + +type FavoritesBlockProps = Omit + +export async function FavoritesBlock({ + title, + subtitle, + displayMode, + selectedFavorites, + filterCategory, + layout = 'grid', + columns = 3, + limit = 12, + showPrice = true, + showBadge = true, +}: FavoritesBlockProps) { + // Fetch favorites if not using selected mode + let items: Favorite[] = [] + + if (displayMode === 'selected' && selectedFavorites) { + items = selectedFavorites + } else { + const favoritesData = await getFavorites({ + category: filterCategory, + limit, + }) + items = favoritesData.docs + } + + if (!items || items.length === 0) return null + + const columnClasses = { + 2: 'md:grid-cols-2', + 3: 'md:grid-cols-2 lg:grid-cols-3', + 4: 'md:grid-cols-2 lg:grid-cols-4', + } + + const badgeLabels: Record = { + 'investment-piece': 'Investment-Piece', + 'daily-driver': 'Täglicher Begleiter', + 'grfi-approved': 'GRFI-Favorit', + new: 'Neu', + bestseller: 'Bestseller', + } + + const badgeVariants: Record = { + 'investment-piece': 'investment', + 'daily-driver': 'daily', + 'grfi-approved': 'grfi', + new: 'new', + bestseller: 'popular', + } + + return ( +
+
+ {/* Section Header */} + {(title || subtitle) && ( +
+ {title &&

{title}

} + {subtitle && ( +

{subtitle}

+ )} +
+ )} + + {/* Favorites Grid */} +
+ {items.map((favorite) => ( + + ))} +
+ + {/* Affiliate Disclosure */} +
+ + + +

+ Einige Links auf dieser Seite sind Affiliate-Links. Das bedeutet, dass ich eine kleine Provision erhalte, wenn du über diese Links einkaufst - ohne Mehrkosten für dich. +

+
+
+
+ ) +} + +interface FavoriteCardProps { + favorite: Favorite + showPrice?: boolean + showBadge?: boolean + badgeLabels: Record + badgeVariants: Record +} + +function FavoriteCard({ + favorite, + showPrice, + showBadge, + badgeLabels, + badgeVariants, +}: FavoriteCardProps) { + const imageUrl = getImageUrl(favorite.image) + + return ( + +
+ {/* Image */} +
+ {imageUrl && ( + {favorite.image?.alt + )} + + {/* Badge */} + {showBadge && favorite.badge && ( +
+ + {badgeLabels[favorite.badge]} + +
+ )} +
+ + {/* Content */} +
+ {favorite.category && ( +

+ {favorite.category} +

+ )} + +

+ {favorite.title} +

+ + {/* Footer */} +
+ {showPrice && favorite.price && ( + + {favorite.price} + + )} + + + Ansehen + + + + +
+
+
+
+ ) +} diff --git a/src/components/blocks/HeroBlock.tsx b/src/components/blocks/HeroBlock.tsx new file mode 100644 index 0000000..054ba53 --- /dev/null +++ b/src/components/blocks/HeroBlock.tsx @@ -0,0 +1,83 @@ +import Image from 'next/image' +import { Button } from '@/components/ui' +import { cn } from '@/lib/utils' +import type { HeroBlock as HeroBlockType } from '@/lib/types' + +type HeroBlockProps = Omit + +export function HeroBlock({ + heading, + subheading, + backgroundImage, + ctaText, + ctaLink, + alignment = 'center', + overlay = true, + overlayOpacity = 50, +}: HeroBlockProps) { + const alignmentClasses = { + left: 'text-left items-start', + center: 'text-center items-center', + right: 'text-right items-end', + } + + return ( +
+ {/* Background Image */} + {backgroundImage?.url && ( +
+ {backgroundImage.alt + {overlay && ( +
+ )} +
+ )} + + {/* Content */} +
+
+

+ {heading} +

+ + {subheading && ( +

+ {subheading} +

+ )} + + {ctaText && ctaLink && ( + + )} +
+
+
+ ) +} diff --git a/src/components/blocks/NewsletterBlock.tsx b/src/components/blocks/NewsletterBlock.tsx new file mode 100644 index 0000000..cb27066 --- /dev/null +++ b/src/components/blocks/NewsletterBlock.tsx @@ -0,0 +1,176 @@ +'use client' + +import { useState } from 'react' +import Image from 'next/image' +import Link from 'next/link' +import { Button, Input } from '@/components/ui' +import { cn } from '@/lib/utils' +import { subscribeNewsletter } from '@/lib/api' +import type { NewsletterBlock as NewsletterBlockType } from '@/lib/types' + +type NewsletterBlockProps = Omit + +export function NewsletterBlock({ + title = 'Newsletter', + subtitle, + buttonText = 'Anmelden', + layout = 'card', + backgroundImage, + showPrivacyNote = true, + source = 'newsletter-block', + showFirstName = false, +}: NewsletterBlockProps) { + const [email, setEmail] = useState('') + const [firstName, setFirstName] = useState('') + const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle') + const [errorMessage, setErrorMessage] = useState('') + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setStatus('loading') + setErrorMessage('') + + try { + const result = await subscribeNewsletter( + email, + showFirstName && firstName ? firstName : undefined, + source + ) + + if (result.success) { + setStatus('success') + setEmail('') + } else { + setStatus('error') + setErrorMessage(result.message || 'Ein Fehler ist aufgetreten.') + } + } catch { + setStatus('error') + setErrorMessage('Ein Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.') + } + } + + const formContent = ( + <> + {status === 'success' ? ( +
+

Vielen Dank für Ihre Anmeldung!

+

Bitte bestätigen Sie Ihre E-Mail-Adresse.

+
+ ) : ( +
+ {showFirstName && ( + setFirstName(e.target.value)} + placeholder="Dein Vorname (optional)" + disabled={status === 'loading'} + /> + )} +
+ setEmail(e.target.value)} + placeholder="Deine E-Mail-Adresse" + required + disabled={status === 'loading'} + error={status === 'error' ? errorMessage : undefined} + className={cn( + (layout === 'inline' || layout === 'minimal') && 'sm:flex-1' + )} + /> + +
+
+ )} + + {showPrivacyNote && status !== 'success' && ( +

+ Mit der Anmeldung akzeptieren Sie unsere{' '} + + Datenschutzerklärung + + . +

+ )} + + ) + + // Minimal layout + if (layout === 'minimal') { + return ( +
+
+ {formContent} +
+
+ ) + } + + // Card layout (default) + return ( +
+
+
+ {/* Background Image */} + {backgroundImage?.url && ( +
+ +
+
+ )} + +
+ {title && ( +

+ {title} +

+ )} + + {subtitle && ( +

+ {subtitle} +

+ )} + + {formContent} +
+
+
+
+ ) +} diff --git a/src/components/blocks/PostsListBlock.tsx b/src/components/blocks/PostsListBlock.tsx new file mode 100644 index 0000000..4787951 --- /dev/null +++ b/src/components/blocks/PostsListBlock.tsx @@ -0,0 +1,373 @@ +import Image from 'next/image' +import Link from 'next/link' +import { cn, formatDate, getImageUrl } from '@/lib/utils' +import { getPosts } from '@/lib/api' +import { EmptyState } from '@/components/ui' +import type { PostsListBlock as PostsListBlockType, Post, Author } from '@/lib/types' + +// Helper to get author name safely +function getAuthorName(author: Author | string | undefined): string | null { + if (!author) return null + if (typeof author === 'string') return null + return author.name +} + +type PostsListBlockProps = Omit + +export async function PostsListBlock({ + title, + subtitle, + postType, + layout = 'grid', + columns = 3, + limit = 6, + showFeaturedOnly, + filterByCategory, + showExcerpt = true, + showDate = true, + showAuthor = false, + showCategory = true, + showPagination = false, + backgroundColor = 'ivory', +}: PostsListBlockProps) { + const postsData = await getPosts({ + type: postType, + category: filterByCategory, + limit, + featured: showFeaturedOnly, + }) + + const posts = postsData.docs + + if (!posts || posts.length === 0) { + return ( +
+
+ {(title || subtitle) && ( +
+ {title &&

{title}

} + {subtitle && ( +

{subtitle}

+ )} +
+ )} + +
+
+ ) + } + + const bgClasses = { + white: 'bg-soft-white', + ivory: 'bg-ivory', + sand: 'bg-sand/20', + } + + const columnClasses = { + 2: 'md:grid-cols-2', + 3: 'md:grid-cols-2 lg:grid-cols-3', + 4: 'md:grid-cols-2 lg:grid-cols-4', + } + + return ( +
+
+ {/* Section Header */} + {(title || subtitle) && ( +
+ {title &&

{title}

} + {subtitle && ( +

{subtitle}

+ )} +
+ )} + + {/* Posts */} + {layout === 'grid' ? ( +
+ {posts.map((post) => ( + + ))} +
+ ) : layout === 'featured' ? ( + + ) : layout === 'list' ? ( + + ) : ( + + )} + + {/* Pagination would go here */} + {showPagination && postsData.totalPages > 1 && ( +
+ {/* Implement pagination component */} +
+ )} +
+
+ ) +} + +interface PostCardProps { + post: Post + showExcerpt?: boolean + showDate?: boolean + showAuthor?: boolean + showCategory?: boolean +} + +function PostCard({ + post, + showExcerpt, + showDate, + showAuthor, + showCategory, +}: PostCardProps) { + const imageUrl = getImageUrl(post.featuredImage) + const postUrl = getPostUrl(post) + + return ( + +
+ {imageUrl && ( +
+ {post.featuredImage?.alt +
+ )} + +
+ {/* Meta */} +
+ {showCategory && post.categories?.[0] && ( + + {post.categories[0].title} + + )} + {showDate && post.publishedAt && ( + {formatDate(post.publishedAt)} + )} +
+ + {/* Title */} +

+ {post.title} +

+ + {/* Excerpt */} + {showExcerpt && post.excerpt && ( +

{post.excerpt}

+ )} + + {/* Author */} + {showAuthor && getAuthorName(post.author) && ( +

+ von {getAuthorName(post.author)} +

+ )} +
+
+ + ) +} + +function FeaturedLayout({ + posts, + showExcerpt, + showDate, + showCategory, +}: { + posts: Post[] + showExcerpt?: boolean + showDate?: boolean + showCategory?: boolean +}) { + const [featured, ...rest] = posts + + return ( +
+ {/* Featured Post */} + +
+ {featured.featuredImage && ( +
+ {featured.featuredImage.alt +
+ )} +
+
+ {showCategory && featured.categories?.[0] && ( + + {featured.categories[0].title} + + )} + {showDate && featured.publishedAt && ( + {formatDate(featured.publishedAt)} + )} +
+

+ {featured.title} +

+ {showExcerpt && featured.excerpt && ( +

{featured.excerpt}

+ )} +
+
+ + + {/* Secondary Posts */} +
+ {rest.slice(0, 2).map((post) => ( + +
+ {post.featuredImage && ( +
+ {post.featuredImage.alt +
+ )} +
+ {showDate && post.publishedAt && ( + + {formatDate(post.publishedAt)} + + )} +

+ {post.title} +

+
+
+ + ))} +
+
+ ) +} + +function ListLayout({ + posts, + showExcerpt, + showDate, + showCategory, +}: { + posts: Post[] + showExcerpt?: boolean + showDate?: boolean + showCategory?: boolean +}) { + return ( +
+ {posts.map((post) => ( + +
+ {post.featuredImage && ( +
+ {post.featuredImage.alt +
+ )} +
+
+ {showCategory && post.categories?.[0] && ( + + {post.categories[0].title} + + )} + {showDate && post.publishedAt && ( + {formatDate(post.publishedAt)} + )} +
+

+ {post.title} +

+ {showExcerpt && post.excerpt && ( +

{post.excerpt}

+ )} +
+
+ + ))} +
+ ) +} + +function CompactLayout({ + posts, + showDate, +}: { + posts: Post[] + showDate?: boolean +}) { + return ( +
+ {posts.map((post) => ( + +
+

+ {post.title} +

+ {showDate && post.publishedAt && ( + + {formatDate(post.publishedAt)} + + )} +
+ + ))} +
+ ) +} + +function getPostUrl(post: Post): string { + const prefixes: Record = { + blog: '/blog', + news: '/news', + press: '/presse', + announcement: '/aktuelles', + } + const prefix = prefixes[post.type] || '/blog' + return `${prefix}/${post.slug}` +} diff --git a/src/components/blocks/RichTextRenderer.tsx b/src/components/blocks/RichTextRenderer.tsx new file mode 100644 index 0000000..3a3b0df --- /dev/null +++ b/src/components/blocks/RichTextRenderer.tsx @@ -0,0 +1,160 @@ +import Link from 'next/link' +import Image from 'next/image' +import type { RichText } from '@/lib/types' + +interface RichTextRendererProps { + content?: RichText | null + className?: string +} + +export function RichTextRenderer({ content, className }: RichTextRendererProps) { + if (!content?.root?.children) return null + + return ( +
+ {content.root.children.map((node, index) => ( + } /> + ))} +
+ ) +} + +interface RenderNodeProps { + node: Record +} + +function RenderNode({ node }: RenderNodeProps) { + const type = node.type as string + const children = node.children as Record[] | undefined + + switch (type) { + case 'paragraph': + return ( +

+ {children?.map((child, i) => )} +

+ ) + + case 'heading': + const tag = node.tag as 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' + const HeadingTag = tag || 'h2' + const headingClasses: Record = { + h1: 'text-4xl md:text-5xl mb-6', + h2: 'text-3xl md:text-4xl mb-5', + h3: 'text-2xl md:text-3xl mb-4', + h4: 'text-xl md:text-2xl mb-3', + h5: 'text-lg md:text-xl mb-3', + h6: 'text-base md:text-lg mb-2', + } + return ( + + {children?.map((child, i) => )} + + ) + + case 'list': + const listTag = node.listType === 'number' ? 'ol' : 'ul' + const ListTag = listTag as 'ol' | 'ul' + return ( + + {children?.map((child, i) => )} + + ) + + case 'listitem': + return ( +
  • + {children?.map((child, i) => )} +
  • + ) + + case 'link': + const url = node.url as string + const linkType = node.linkType as string + const newTab = node.newTab as boolean + + if (linkType === 'internal') { + return ( + + {children?.map((child, i) => )} + + ) + } + + return ( + + {children?.map((child, i) => )} + + ) + + case 'upload': + const value = node.value as { url?: string; alt?: string; width?: number; height?: number } | undefined + if (!value?.url) return null + return ( +
    + {value.alt +
    + ) + + case 'quote': + return ( +
    + {children?.map((child, i) => )} +
    + ) + + case 'text': + const text = node.text as string + if (!text) return null + + const format = node.format as number | undefined + let element: React.ReactNode = text + + // Apply formatting based on bitmask + if (format) { + if (format & 1) element = {element} // bold + if (format & 2) element = {element} // italic + if (format & 8) element = {element} // underline + if (format & 4) element = {element} // strikethrough + if (format & 16) element = {element} + } + + return <>{element} + + case 'linebreak': + return
    + + default: + // For unknown types, try to render children + if (children) { + return ( + <> + {children.map((child, i) => ( + + ))} + + ) + } + return null + } +} diff --git a/src/components/blocks/TestimonialsBlock.tsx b/src/components/blocks/TestimonialsBlock.tsx new file mode 100644 index 0000000..b36fc4a --- /dev/null +++ b/src/components/blocks/TestimonialsBlock.tsx @@ -0,0 +1,229 @@ +'use client' + +import { useState } from 'react' +import Image from 'next/image' +import { cn } from '@/lib/utils' +import type { TestimonialsBlock as TestimonialsBlockType, Testimonial } from '@/lib/types' + +type TestimonialsBlockProps = Omit & { + testimonials?: Testimonial[] +} + +export function TestimonialsBlock({ + title, + subtitle, + displayMode, + selectedTestimonials, + layout = 'carousel', + testimonials: externalTestimonials, +}: TestimonialsBlockProps) { + const items = displayMode === 'selected' ? selectedTestimonials : externalTestimonials + + if (!items || items.length === 0) return null + + return ( +
    +
    + {/* Section Header */} + {(title || subtitle) && ( +
    + {title &&

    {title}

    } + {subtitle && ( +

    {subtitle}

    + )} +
    + )} + + {/* Testimonials */} + {layout === 'carousel' ? ( + + ) : layout === 'grid' ? ( + + ) : ( + + )} +
    +
    + ) +} + +function CarouselLayout({ items }: { items: Testimonial[] }) { + const [current, setCurrent] = useState(0) + + const prev = () => setCurrent((i) => (i === 0 ? items.length - 1 : i - 1)) + const next = () => setCurrent((i) => (i === items.length - 1 ? 0 : i + 1)) + + return ( +
    +
    + {items.map((testimonial, index) => ( +
    + +
    + ))} +
    + + {/* Navigation */} + {items.length > 1 && ( +
    + + +
    + {items.map((_, index) => ( +
    + + +
    + )} +
    + ) +} + +function GridLayout({ items }: { items: Testimonial[] }) { + return ( +
    + {items.map((testimonial) => ( + + ))} +
    + ) +} + +function ListLayout({ items }: { items: Testimonial[] }) { + return ( +
    + {items.map((testimonial) => ( + + ))} +
    + ) +} + +interface TestimonialCardProps { + testimonial: Testimonial + variant?: 'default' | 'featured' | 'wide' +} + +function TestimonialCard({ testimonial, variant = 'default' }: TestimonialCardProps) { + return ( +
    + {/* Quote */} +
    + “{testimonial.quote}” +
    + + {/* Author */} +
    + {testimonial.authorImage?.url && ( + {testimonial.authorName} + )} +
    +

    {testimonial.authorName}

    + {(testimonial.authorTitle || testimonial.authorCompany) && ( +

    + {[testimonial.authorTitle, testimonial.authorCompany] + .filter(Boolean) + .join(', ')} +

    + )} +
    +
    + + {/* Rating */} + {testimonial.rating && ( +
    + {Array.from({ length: 5 }).map((_, i) => ( + + + + ))} +
    + )} +
    + ) +} diff --git a/src/components/blocks/VideoBlock.tsx b/src/components/blocks/VideoBlock.tsx new file mode 100644 index 0000000..b2b6f19 --- /dev/null +++ b/src/components/blocks/VideoBlock.tsx @@ -0,0 +1,97 @@ +'use client' + +import { useState } from 'react' +import Image from 'next/image' +import { cn, extractYouTubeId, getPrivacyYouTubeUrl, getYouTubeThumbnail } from '@/lib/utils' +import type { VideoBlock as VideoBlockType } from '@/lib/types' + +type VideoBlockProps = Omit + +export function VideoBlock({ + title, + videoUrl, + thumbnailImage, + aspectRatio = '16:9', +}: VideoBlockProps) { + const [isPlaying, setIsPlaying] = useState(false) + + const videoId = extractYouTubeId(videoUrl) + const thumbnailUrl = thumbnailImage?.url || (videoId ? getYouTubeThumbnail(videoId) : null) + const embedUrl = videoId ? getPrivacyYouTubeUrl(videoId) : null + + const aspectClasses = { + '16:9': 'aspect-video', + '4:3': 'aspect-[4/3]', + '1:1': 'aspect-square', + } + + if (!embedUrl) { + return null + } + + return ( +
    +
    + {title && ( +

    {title}

    + )} + +
    +
    + {isPlaying ? ( +