import { useEffect, useMemo, useRef, useState } from 'react' import type { FC } from 'react' import PageTitle from '@/components/shared/PageTitle' import { AboutDto } from '@/proxy/about/models' import { getAbout, saveAboutPage } from '@/services/about' import { useLocalization } from '@/utils/hooks/useLocalization' import { PUBLIC_PAGE_DESIGN } from '@/constants/permission.constant' import { DesignerLayer, DesignerSelection, SelectableBlock, TranslateFn, iconField, imageField, isLikelyLocalizationKey, numberField, booleanField, resolveLocalizedValue, styleField, textAreaField, textField, translateLabel, usePageDesigner, } from './designer' import { designerPageClass, getIconColor } from './pageStyles' import { PageIcon, PublicPageLoader } from './shared' interface AboutStatContent { icon: string value: string label: string labelKey: string styleClassKey: string styleClass: string valueStyleClassKey: string valueStyleClass: string labelStyleClassKey: string labelStyleClass: string useCounter: boolean counterEnd: string counterSuffix: string counterDuration: number } interface AboutDescriptionContent { key: string text: string styleClassKey: string styleClass: string } interface AboutSectionContent { title: string description: string titleKey: string descriptionKey: string cardStyleClassKey: string cardStyleClass: string titleStyleClassKey: string titleStyleClass: string descriptionStyleClassKey: string descriptionStyleClass: string } interface AboutContent { heroTitle: string heroTitleKey: string heroTitleStyleClassKey: string heroTitleStyleClass: string heroSubtitle: string heroSubtitleKey: string heroSubtitleStyleClassKey: string heroSubtitleStyleClass: string heroImage: string heroImageKey: string heroSectionStyleClassKey: string heroSectionStyleClass: string descriptionsContainerStyleClassKey: string descriptionsContainerStyleClass: string stats: AboutStatContent[] descriptions: AboutDescriptionContent[] sections: AboutSectionContent[] } const ABOUT_HERO_IMAGE = 'https://images.pexels.com/photos/3183183/pexels-photo-3183183.jpeg?auto=compress&cs=tinysrgb&w=1920' const ABOUT_HERO_TITLE_KEY = 'App.About' const ABOUT_HERO_SUBTITLE_KEY = 'App.PublicAbout.Subtitle' const ABOUT_HERO_IMAGE_KEY = 'Public.about.heroImage' const ABOUT_HERO_SECTION_STYLE_KEY = 'Public.about.hero.sectionStyleClass' const ABOUT_HERO_TITLE_STYLE_KEY = 'Public.about.hero.titleStyleClass' const ABOUT_HERO_SUBTITLE_STYLE_KEY = 'Public.about.hero.subtitleStyleClass' const ABOUT_DESCRIPTIONS_CONTAINER_STYLE_KEY = 'Public.about.descriptions.containerStyleClass' const HERO_SECTION_STYLE_DEFAULT = 'relative bg-blue-900 text-white py-12' const HERO_TITLE_STYLE_DEFAULT = 'text-5xl font-bold ml-4 mt-3 mb-2 text-white' const HERO_SUBTITLE_STYLE_DEFAULT = 'text-xl max-w-3xl ml-4' const DESCRIPTIONS_CONTAINER_STYLE_DEFAULT = 'p-5 mx-auto text-gray-800 dark:text-gray-200 text-lg leading-relaxed shadow-md bg-white dark:bg-gray-900 border-l-4 border-blue-600' const STAT_STYLE_DEFAULT = 'text-center rounded-xl px-4 py-6' const STAT_VALUE_STYLE_DEFAULT = 'text-4xl font-bold text-gray-900 dark:text-gray-100 mb-2' const STAT_LABEL_STYLE_DEFAULT = 'text-gray-600 dark:text-gray-300' const SECTION_CARD_STYLE_DEFAULT = 'bg-white dark:bg-gray-900 p-8 rounded-xl shadow-lg dark:shadow-gray-950/40' const SECTION_TITLE_STYLE_DEFAULT = 'text-2xl font-bold text-gray-900 dark:text-gray-100 mb-4' const SECTION_DESCRIPTION_STYLE_DEFAULT = 'text-gray-700 dark:text-gray-300' function getCounterParts(value: string, suffix: string) { const numericMatch = (value || '0').replace(',', '.').match(/\d+(?:\.\d+)?/) if (!numericMatch) { return null } const endValue = Number(numericMatch[0]) if (!Number.isFinite(endValue)) { return null } const matchIndex = numericMatch.index ?? 0 return { endValue, prefix: value.slice(0, matchIndex), suffix: suffix || value.slice(matchIndex + numericMatch[0].length), hasDecimals: numericMatch[0].includes('.'), } } /** * Sayac animasyonu yalnizca deger ekrana girdiginde bir kez calisir; * boylece sayfanin altindaki istatistikler kullanici oraya gelmeden bitmis olmaz. */ const AnimatedStatValue: FC<{ stat: AboutStatContent }> = ({ stat }) => { const counterParts = useMemo( () => getCounterParts(stat.counterEnd || stat.value, stat.counterSuffix), [stat.counterEnd, stat.counterSuffix, stat.value], ) const [currentValue, setCurrentValue] = useState(0) const [isVisible, setIsVisible] = useState(false) const containerRef = useRef(null) useEffect(() => { const element = containerRef.current if (!stat.useCounter || !element || typeof IntersectionObserver === 'undefined') { setIsVisible(true) return } const observer = new IntersectionObserver( (entries) => { if (entries.some((entry) => entry.isIntersecting)) { setIsVisible(true) observer.disconnect() } }, { threshold: 0.3 }, ) observer.observe(element) return () => observer.disconnect() }, [stat.useCounter]) useEffect(() => { if (!stat.useCounter || !counterParts || !isVisible) { return } const duration = Math.max(stat.counterDuration || 2000, 0) let animationFrameId = 0 let startTime: number | null = null const animate = (timestamp: number) => { startTime ??= timestamp const progress = duration === 0 ? 1 : Math.min((timestamp - startTime) / duration, 1) setCurrentValue(counterParts.endValue * (1 - Math.pow(1 - progress, 3))) if (progress < 1) { animationFrameId = requestAnimationFrame(animate) } } setCurrentValue(0) animationFrameId = requestAnimationFrame(animate) return () => cancelAnimationFrame(animationFrameId) }, [counterParts, isVisible, stat.counterDuration, stat.useCounter]) if (!stat.useCounter || !counterParts) { return <>{stat.value} } return ( {counterParts.prefix} {counterParts.hasDecimals ? currentValue.toFixed(1) : Math.floor(currentValue)} {counterParts.suffix} ) } function buildAboutContent(about: AboutDto | undefined, translate: TranslateFn): AboutContent { return { heroTitle: resolveLocalizedValue(translate, ABOUT_HERO_TITLE_KEY, 'About'), heroTitleKey: ABOUT_HERO_TITLE_KEY, heroTitleStyleClassKey: ABOUT_HERO_TITLE_STYLE_KEY, heroTitleStyleClass: resolveLocalizedValue( translate, ABOUT_HERO_TITLE_STYLE_KEY, HERO_TITLE_STYLE_DEFAULT, ), heroSubtitle: resolveLocalizedValue(translate, ABOUT_HERO_SUBTITLE_KEY), heroSubtitleKey: ABOUT_HERO_SUBTITLE_KEY, heroSubtitleStyleClassKey: ABOUT_HERO_SUBTITLE_STYLE_KEY, heroSubtitleStyleClass: resolveLocalizedValue( translate, ABOUT_HERO_SUBTITLE_STYLE_KEY, HERO_SUBTITLE_STYLE_DEFAULT, ), heroImage: resolveLocalizedValue(translate, ABOUT_HERO_IMAGE_KEY, ABOUT_HERO_IMAGE), heroImageKey: ABOUT_HERO_IMAGE_KEY, heroSectionStyleClassKey: ABOUT_HERO_SECTION_STYLE_KEY, heroSectionStyleClass: resolveLocalizedValue( translate, ABOUT_HERO_SECTION_STYLE_KEY, HERO_SECTION_STYLE_DEFAULT, ), descriptionsContainerStyleClassKey: ABOUT_DESCRIPTIONS_CONTAINER_STYLE_KEY, descriptionsContainerStyleClass: resolveLocalizedValue( translate, ABOUT_DESCRIPTIONS_CONTAINER_STYLE_KEY, DESCRIPTIONS_CONTAINER_STYLE_DEFAULT, ), stats: (about?.statsDto ?? []).map((stat, index) => { const prefix = `Public.about.dynamic.stat.${index + 1}` return { icon: stat.icon || '', value: stat.value, label: resolveLocalizedValue(translate, stat.labelKey, stat.labelKey), labelKey: (isLikelyLocalizationKey(stat.labelKey) ? stat.labelKey : '') || `${prefix}.label`, styleClassKey: `${prefix}.styleClass`, styleClass: resolveLocalizedValue(translate, `${prefix}.styleClass`, STAT_STYLE_DEFAULT), valueStyleClassKey: `${prefix}.valueStyleClass`, valueStyleClass: resolveLocalizedValue( translate, `${prefix}.valueStyleClass`, STAT_VALUE_STYLE_DEFAULT, ), labelStyleClassKey: `${prefix}.labelStyleClass`, labelStyleClass: resolveLocalizedValue( translate, `${prefix}.labelStyleClass`, STAT_LABEL_STYLE_DEFAULT, ), useCounter: stat.useCounter ?? false, counterEnd: stat.counterEnd ?? '', counterSuffix: stat.counterSuffix ?? '', counterDuration: stat.counterDuration ?? 2000, } }), descriptions: (about?.descriptionsDto ?? []).map((item, index) => { const prefix = `Public.about.dynamic.description.${index + 1}` return { key: (isLikelyLocalizationKey(item) ? item : '') || prefix, text: resolveLocalizedValue(translate, item, item), styleClassKey: `${prefix}.styleClass`, styleClass: resolveLocalizedValue( translate, `${prefix}.styleClass`, index % 2 === 0 ? '' : 'text-center p-5 text-blue-800 dark:text-white', ), } }), sections: (about?.sectionsDto ?? []).map((section) => { const prefix = `Public.about.dynamic.section.${section.key}` return { title: resolveLocalizedValue(translate, section.key, section.key), description: resolveLocalizedValue(translate, section.descKey, section.descKey), titleKey: (isLikelyLocalizationKey(section.key) ? section.key : '') || `${prefix}.title`, descriptionKey: (isLikelyLocalizationKey(section.descKey) ? section.descKey : '') || `${prefix}.description`, cardStyleClassKey: `${prefix}.cardStyleClass`, cardStyleClass: resolveLocalizedValue( translate, `${prefix}.cardStyleClass`, SECTION_CARD_STYLE_DEFAULT, ), titleStyleClassKey: `${prefix}.titleStyleClass`, titleStyleClass: resolveLocalizedValue( translate, `${prefix}.titleStyleClass`, SECTION_TITLE_STYLE_DEFAULT, ), descriptionStyleClassKey: `${prefix}.descriptionStyleClass`, descriptionStyleClass: resolveLocalizedValue( translate, `${prefix}.descriptionStyleClass`, SECTION_DESCRIPTION_STYLE_DEFAULT, ), } }), } } function toSaveInput(content: AboutContent, cultureName: string) { return { cultureName, heroTitleKey: content.heroTitleKey, heroTitleValue: content.heroTitle, heroSubtitleKey: content.heroSubtitleKey, heroSubtitleValue: content.heroSubtitle, heroImageKey: content.heroImageKey, heroImageValue: content.heroImage, stats: content.stats.map((stat, index) => ({ icon: stat.icon, value: stat.value, labelKey: stat.labelKey || `Public.about.dynamic.stat.${index + 1}.label`, labelValue: stat.label, useCounter: stat.useCounter, counterEnd: stat.counterEnd, counterSuffix: stat.counterSuffix, counterDuration: stat.counterDuration, })), descriptions: content.descriptions.map((item, index) => ({ key: item.key || `Public.about.dynamic.description.${index + 1}`, value: item.text, })), sections: content.sections.map((section, index) => ({ titleKey: section.titleKey || `Public.about.dynamic.section.${index + 1}.title`, titleValue: section.title, descriptionKey: section.descriptionKey || `Public.about.dynamic.section.${index + 1}.description`, descriptionValue: section.description, })), styleTexts: [ { key: content.heroSectionStyleClassKey, value: content.heroSectionStyleClass }, { key: content.heroTitleStyleClassKey, value: content.heroTitleStyleClass }, { key: content.heroSubtitleStyleClassKey, value: content.heroSubtitleStyleClass }, { key: content.descriptionsContainerStyleClassKey, value: content.descriptionsContainerStyleClass, }, ...content.stats.flatMap((stat) => [ { key: stat.styleClassKey, value: stat.styleClass }, { key: stat.valueStyleClassKey, value: stat.valueStyleClass }, { key: stat.labelStyleClassKey, value: stat.labelStyleClass }, ]), ...content.descriptions.map((item) => ({ key: item.styleClassKey, value: item.styleClass, })), ...content.sections.flatMap((section) => [ { key: section.cardStyleClassKey, value: section.cardStyleClass }, { key: section.titleStyleClassKey, value: section.titleStyleClass }, { key: section.descriptionStyleClassKey, value: section.descriptionStyleClass }, ]), ], } } const About: FC = () => { const { translate } = useLocalization() const [loading, setLoading] = useState(true) const [about, setAbout] = useState() useEffect(() => { const fetchAbout = async () => { setLoading(true) try { const result = await getAbout() setAbout(result.data) } catch (error) { console.error('About alinirken hata olustu:', error) } finally { setLoading(false) } } fetchAbout() }, []) // Referansi kararli tutmak icin memoize edilir; aksi halde tasarim state'i her render'da yeniden tohumlanir. const initialContent = useMemo( () => (loading ? null : buildAboutContent(about, translate)), [about, loading, translate], ) const designer = usePageDesigner({ pageKey: 'About', permission: PUBLIC_PAGE_DESIGN.ABOUT, initialContent, onSave: (content, cultureName) => saveAboutPage(toSaveInput(content, cultureName)), }) const { content, isDesignMode, isPanelVisible, selectedBlockId, selectBlock } = designer const selection: DesignerSelection | null = useMemo(() => { if (!content || !selectedBlockId) { return null } if (selectedBlockId === 'hero') { return { id: 'hero', title: content.heroTitleKey, description: translateLabel(translate, 'App.PublicDesigner.DesignerDesc4'), fields: [ textField('heroTitle', content.heroTitleKey, content.heroTitle), textAreaField('heroSubtitle', content.heroSubtitleKey, content.heroSubtitle), imageField('heroImage', content.heroImageKey, content.heroImage, { group: 'media' }), styleField( 'heroTitleStyleClass', content.heroTitleStyleClassKey, content.heroTitleStyleClass, ), styleField( 'heroSubtitleStyleClass', content.heroSubtitleStyleClassKey, content.heroSubtitleStyleClass, ), styleField( 'heroSectionStyleClass', content.heroSectionStyleClassKey, content.heroSectionStyleClass, ), ], } } if (selectedBlockId === 'descriptions') { return { id: 'descriptions', title: 'Public.about.description.*', description: translateLabel(translate, 'Public.designer.descriptionsHint'), fields: [ ...content.descriptions.map((item, index) => textAreaField(`descriptions.${index}.text`, item.key, item.text, { rows: index % 2 === 0 ? 4 : 3, }), ), styleField( 'descriptionsContainerStyleClass', content.descriptionsContainerStyleClassKey, content.descriptionsContainerStyleClass, ), ...content.descriptions.map((item, index) => styleField(`descriptions.${index}.styleClass`, item.styleClassKey, item.styleClass), ), ], } } if (selectedBlockId.startsWith('stat-')) { const index = Number(selectedBlockId.replace('stat-', '')) const stat = content.stats[index] if (!stat) { return null } return { id: selectedBlockId, title: stat.labelKey, description: translateLabel(translate, 'App.PublicDesigner.DesignerDesc1'), fields: [ iconField( `stats.${index}.icon`, translateLabel(translate, 'App.PublicDesigner.IkonAnahtari'), stat.icon, { placeholder: translate('::App.PublicDesigner.IconPlaceholder', { icon: 'FaUsers' }) }, ), textField( `stats.${index}.value`, translate('::App.Listform.ListformField.Value'), stat.value, ), textField(`stats.${index}.label`, stat.labelKey, stat.label), booleanField(`stats.${index}.useCounter`, 'useCounter', stat.useCounter, { group: 'advanced', placeholder: translateLabel(translate, 'Public.designer.useCounter'), }), textField(`stats.${index}.counterEnd`, 'counterEnd', stat.counterEnd, { group: 'advanced', helpText: translateLabel(translate, 'Public.designer.counterEndHint'), }), textField(`stats.${index}.counterSuffix`, 'counterSuffix', stat.counterSuffix, { group: 'advanced', }), numberField( `stats.${index}.counterDuration`, 'counterDuration (ms)', stat.counterDuration, { group: 'advanced', }, ), styleField(`stats.${index}.styleClass`, stat.styleClassKey, stat.styleClass), styleField( `stats.${index}.valueStyleClass`, stat.valueStyleClassKey, stat.valueStyleClass, ), styleField( `stats.${index}.labelStyleClass`, stat.labelStyleClassKey, stat.labelStyleClass, ), ], } } if (selectedBlockId.startsWith('section-')) { const index = Number(selectedBlockId.replace('section-', '')) const section = content.sections[index] if (!section) { return null } return { id: selectedBlockId, title: section.titleKey, description: translateLabel(translate, 'Public.designer.sectionHint'), fields: [ textField(`sections.${index}.title`, section.titleKey, section.title), textAreaField( `sections.${index}.description`, section.descriptionKey, section.description, ), styleField( `sections.${index}.cardStyleClass`, section.cardStyleClassKey, section.cardStyleClass, ), styleField( `sections.${index}.titleStyleClass`, section.titleStyleClassKey, section.titleStyleClass, ), styleField( `sections.${index}.descriptionStyleClass`, section.descriptionStyleClassKey, section.descriptionStyleClass, ), ], } } return null }, [content, selectedBlockId, translate]) if (loading) { return } return ( <>
{/* Hero */}

{content?.heroTitle}

{content?.heroSubtitle}

{/* Stats */}
{content?.stats.map((stat, index) => (
{stat.label}
))}
{/* Descriptions + sections */}
{content?.descriptions.map((item, index) => (

{item.text}

))}
{content?.sections.map((section, index) => (

{section.title}

{section.description}

))}
) } export default About