Files
homaroma/apps/website/src/components/custom/StoryFeatureMosaic.tsx
T
2026-07-13 20:04:23 -04:00

194 lines
6.4 KiB
TypeScript

import Image from 'next/image'
import { ArrowRight, CakeSlice, CheckCircle, Heart, Leaf, Sparkles, type LucideIcon } from 'lucide-react'
import Link from '../LocalizedLink'
import type { CustomBlockComponentProps } from '../../config/customRegistry'
import { resolveLocalized } from '../../lib/localization'
type LocalizedValue = string | Record<string, string>
type MosaicImage = {
imageUrl: string
alt?: LocalizedValue
}
type FeatureItem = {
icon?: string
title: LocalizedValue
subtitle?: LocalizedValue
}
type ResolvedImage = {
imageUrl: string
alt: string
}
type ResolvedFeature = {
icon: string
title: string
subtitle: string
}
const iconMap: Record<string, LucideIcon> = {
leaf: Leaf,
cake: CakeSlice,
fresh: CakeSlice,
heart: Heart,
care: Heart,
sparkles: Sparkles,
premium: Sparkles,
check: CheckCircle,
}
const defaultImages: MosaicImage[] = [
{ imageUrl: '', alt: 'Dessert preparation' },
{ imageUrl: '', alt: 'Fresh bakery' },
{ imageUrl: '', alt: 'Our team' },
]
const defaultFeatures: FeatureItem[] = [
{ icon: 'leaf', title: 'Quality ingredients' },
{ icon: 'cake', title: 'Fresh daily' },
{ icon: 'heart', title: 'Made with care' },
]
function readString(value: unknown, fallback: string) {
return typeof value === 'string' && value.trim() ? value : fallback
}
function readLocalized(value: unknown, lang: string, fallback: string) {
const text = resolveLocalized(value as LocalizedValue | null | undefined, lang)
return text.trim() ? text : fallback
}
function readImages(value: unknown, lang: string): ResolvedImage[] {
const sourceImages = Array.isArray(value) ? value : defaultImages
return sourceImages.reduce<ResolvedImage[]>((acc, item, index) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) return acc
const record = item as Record<string, unknown>
const imageUrl = readString(record.imageUrl, '')
const alt = readLocalized(record.alt, lang, `Story image ${index + 1}`)
acc.push({ imageUrl, alt })
return acc
}, []).slice(0, 3)
}
function readFeatures(value: unknown, lang: string): ResolvedFeature[] {
const sourceFeatures = Array.isArray(value) ? value : defaultFeatures
return sourceFeatures.reduce<ResolvedFeature[]>((acc, item) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) return acc
const record = item as Record<string, unknown>
const title = readLocalized(record.title, lang, '')
if (!title) return acc
acc.push({
icon: readString(record.icon, 'check'),
title,
subtitle: readLocalized(record.subtitle, lang, ''),
})
return acc
}, []).slice(0, 4)
}
function StoryImage({ image, index }: { image: ResolvedImage, index: number }) {
const className = index === 0
? 'lg:col-span-3 lg:aspect-[4/3]'
: 'lg:col-span-2 lg:aspect-[4/3]'
return (
<div className={`relative aspect-[4/3] overflow-hidden rounded-2xl bg-surface-muted shadow-card ${className}`}>
{image.imageUrl ? (
<Image
src={image.imageUrl}
alt={image.alt}
fill
unoptimized
className="object-cover transition duration-700 hover:scale-105"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-sm font-semibold text-muted-text">
{image.alt}
</div>
)}
</div>
)
}
export default function StoryFeatureMosaic({ props, lang }: CustomBlockComponentProps) {
const title = readLocalized(props?.title, lang, 'Our Story')
const subtitle = readLocalized(props?.subtitle, lang, 'From craft to happiness in every bite')
const description = readLocalized(props?.description, lang, '')
const buttonText = readLocalized(props?.buttonText, lang, '')
const buttonHref = readString(props?.buttonHref, '')
const images = readImages(props?.images, lang)
const features = readFeatures(props?.features, lang)
return (
<section className="ui-section-band bg-surface-page">
<div className="ui-section-shell">
<div className="grid gap-8 lg:grid-cols-12 lg:items-center">
<div className="space-y-4 lg:col-span-3">
<div className="space-y-2">
{subtitle && (
<p className="text-sm font-semibold text-brand-primary md:text-base">
{subtitle}
</p>
)}
<h2 className="text-3xl font-semibold leading-tight text-text-main md:text-4xl">
{title}
</h2>
</div>
{description && (
<p className="whitespace-pre-line text-sm leading-7 text-muted-text md:text-base">
{description}
</p>
)}
{buttonText && buttonHref && (
<Link
href={buttonHref}
className="inline-flex items-center gap-3 rounded-full border border-brand-primary px-6 py-3 text-sm font-semibold text-text-main transition hover:bg-brand-primary hover:text-surface-card"
>
{buttonText}
<ArrowRight className="h-4 w-4" />
</Link>
)}
</div>
<div className="grid gap-4 sm:grid-cols-3 lg:col-span-7 lg:grid-cols-7">
{images.map((image, index) => (
<StoryImage key={`${image.imageUrl}-${index}`} image={image} index={index} />
))}
</div>
<div className="space-y-5 lg:col-span-2">
{features.map((feature, index) => {
const Icon = iconMap[feature.icon] ?? CheckCircle
return (
<div key={`${feature.title}-${index}`} className="flex items-center gap-4">
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-full border border-brand-primary/30 bg-surface-card text-brand-primary shadow-card">
<Icon className="h-7 w-7" strokeWidth={1.7} />
</div>
<div className="min-w-0">
<h3 className="text-sm font-semibold leading-snug text-text-main">
{feature.title}
</h3>
{feature.subtitle && (
<p className="mt-1 text-xs leading-snug text-muted-text">
{feature.subtitle}
</p>
)}
</div>
</div>
)
})}
</div>
</div>
</div>
</section>
)
}