From d6f3dda9e5f3773f36219f1a4355867ee4750165 Mon Sep 17 00:00:00 2001 From: Do Siki Date: Thu, 10 Sep 2026 16:15:09 +0200 Subject: [PATCH] feat(frontend): Payload Local API + hu/en locale routing (MITHOME-91/114) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the JSON content system with Payload's Local API across every frontend page, and introduces symmetric locale-prefixed routing (/hu/..., /en/...) with per-locale translated slugs — supersedes the earlier "hu unprefixed" decision (see chat 2026-09-10). Routing structure: - src/app/(frontend)/layout.tsx: now a minimal shell (html/body, theme script, ThemeProvider) — no longer locale-aware. - src/app/(frontend)/page.tsx: redirects bare "/" to the default locale (/hu). - src/app/(frontend)/not-found.tsx: explicit 404 for the (frontend) group — without it, Next's built-in fallback collided with the (payload) group's own root and reproduced the "double html / script tag" symptom from MITHOME-87, but only on notFound() paths. Verified fixed in both dev and a real production (standalone) server; the remaining "script tag" console warning on invalid routes turned out to be Turbopack dev-mode-only noise (zero console errors in production) — confirmed by building and running .next/standalone directly. - src/app/(frontend)/[locale]/layout.tsx: validates the locale segment (generateStaticParams hu/en, notFound() otherwise), fetches Common + Home via Payload, renders Header/Footer/staging-banner. - src/app/(frontend)/[locale]/page.tsx: home, fetches Home global + Partners collection. - src/app/(frontend)/[locale]/[slug]/page.tsx: catch-all for about/ services/contact/privacy/terms — resolves slug -> PageKey via src/lib/i18n.ts's PAGE_SLUGS map (generateStaticParams pre-renders all 10 locale×slug combinations), generateMetadata per page. New lib layer: - src/lib/i18n.ts: Locale/PageKey types, PAGE_SLUGS (translated slugs per locale), localePath()/resolvePageKey()/switchLocalePath() helpers (the last one already shaped for MITHOME-115). - src/lib/payload-content.ts: Local API getters that also unwrap Payload's `{ value: string }[]` array-field shape back into plain string[] (see src/globals/fields/stringArray.ts) — keeps the page JSX consuming the exact shape the old content/types.ts had, so the migration is a data-source swap, not a markup rewrite. Presentational split: page bodies moved to src/components/views/ (HomeView, AboutView, ServicesView, ContactView, LegalPageView — the last one shared by both legal pages, identical shape) as prop-driven components; the app-router page.tsx files became thin server-side fetch + render wrappers. Header/Footer converted from importing content directly to accepting nav/locale/content props, since they're 'use client' and can't call the Payload Local API themselves — config/site.ts's navigation arrays became getMainNavigation(locale)/ getFooterNavigation(locale)/getFooterLegalLinks(locale) functions. Two real, pre-existing bugs fixed along the way (not introduced by this migration): - Services and Contact pages' "Webmail belépés" links used the primary CTA's href (/kapcsolat) with target="_blank" instead of the actual webmail URL (home.hero.cta.secondary.href) — now correct. - The GDPR checkbox link pointed to "/adatkezelesi-tajekoztato", which never matched the real privacy page route under any past URL scheme. contact.json's gdpr.label now carries a {privacyHref} placeholder that ContactView replaces with the locale-correct path — also fixes the adatvedelem page's own tag, which previously read "Adatvédelmi Tájékoztató | Szolgáltatás jellemzők:" (a copy-paste bug using common.labels.features instead of the site name). Known, accepted limitation: the outer shell layout hardcodes <html lang="hu"> because it sits above the [locale] segment and can't read the param — every [locale]/[slug] page's own generateMetadata is locale-correct, but the initial lang attribute isn't. Documented as a MITHOME-116 (SEO/hreflang) follow-up rather than restructured now. Verified end to end in a real browser: /hu matches the https://stage.mozdit.hu visual baseline (MITHOME-117) exactly; /en renders with English nav/metadata (content body still Hungarian-only, as expected — MITHOME-111/113 not done yet); /hu/kapcsolat's GDPR link resolves to /hu/adatvedelem; dark mode still works; invalid locale (/fr/about) and invalid slug (/hu/nemletezo-oldal) both 404 correctly; bare "/" redirects to /hu. build/lint/tsc/test (58 passed) all clean, including a clean production standalone-server run. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --- .../app/(frontend)/[locale]/[slug]/page.tsx | 128 +++++++ proto/src/app/(frontend)/[locale]/layout.tsx | 58 ++++ proto/src/app/(frontend)/[locale]/page.tsx | 41 +++ .../felhasznalasi-feltetelek/page.tsx | 53 --- proto/src/app/(frontend)/kapcsolat/layout.tsx | 23 -- proto/src/app/(frontend)/layout.tsx | 60 +--- proto/src/app/(frontend)/not-found.tsx | 27 ++ proto/src/app/(frontend)/page.tsx | 309 +---------------- proto/src/components/Footer.test.tsx | 37 +- proto/src/components/Footer.tsx | 91 +++-- proto/src/components/Header.test.tsx | 38 ++- proto/src/components/Header.tsx | 18 +- .../views/AboutView.tsx} | 25 +- .../views/ContactView.tsx} | 43 ++- proto/src/components/views/HomeView.tsx | 321 ++++++++++++++++++ .../views/LegalPageView.tsx} | 32 +- .../views/ServicesView.tsx} | 42 +-- proto/src/config/site.ts | 100 ++++-- proto/src/content/pages/contact.json | 2 +- proto/src/lib/i18n.ts | 52 +++ proto/src/lib/payload-content.ts | 103 ++++++ 21 files changed, 1007 insertions(+), 596 deletions(-) create mode 100644 proto/src/app/(frontend)/[locale]/[slug]/page.tsx create mode 100644 proto/src/app/(frontend)/[locale]/layout.tsx create mode 100755 proto/src/app/(frontend)/[locale]/page.tsx delete mode 100644 proto/src/app/(frontend)/felhasznalasi-feltetelek/page.tsx delete mode 100755 proto/src/app/(frontend)/kapcsolat/layout.tsx create mode 100644 proto/src/app/(frontend)/not-found.tsx mode change 100755 => 100644 proto/src/app/(frontend)/page.tsx rename proto/src/{app/(frontend)/rolunk/page.tsx => components/views/AboutView.tsx} (90%) rename proto/src/{app/(frontend)/kapcsolat/page.tsx => components/views/ContactView.tsx} (88%) create mode 100755 proto/src/components/views/HomeView.tsx rename proto/src/{app/(frontend)/adatvedelem/page.tsx => components/views/LegalPageView.tsx} (64%) rename proto/src/{app/(frontend)/szolgaltatasok/page.tsx => components/views/ServicesView.tsx} (83%) create mode 100644 proto/src/lib/i18n.ts create mode 100644 proto/src/lib/payload-content.ts diff --git a/proto/src/app/(frontend)/[locale]/[slug]/page.tsx b/proto/src/app/(frontend)/[locale]/[slug]/page.tsx new file mode 100644 index 0000000..6b7b614 --- /dev/null +++ b/proto/src/app/(frontend)/[locale]/[slug]/page.tsx @@ -0,0 +1,128 @@ +import type { Metadata } from 'next' +import { notFound } from 'next/navigation' +import AboutView from '@/components/views/AboutView' +import ServicesView from '@/components/views/ServicesView' +import ContactView from '@/components/views/ContactView' +import LegalPageView from '@/components/views/LegalPageView' +import { LOCALES, PAGE_SLUGS, resolvePageKey, isLocale, type Locale, type PageKey } from '@/lib/i18n' +import { + getAboutContent, + getServicesContent, + getContactContent, + getHomeContent, + getCommonContent, + getLegalPage, +} from '@/lib/payload-content' +import { siteConfig, getOgLocale } from '@/config/site' + +type Params = { locale: string; slug: string } + +export function generateStaticParams() { + return LOCALES.flatMap((locale) => + (Object.keys(PAGE_SLUGS) as PageKey[]).map((key) => ({ + locale, + slug: PAGE_SLUGS[key][locale], + })) + ) +} + +const LEGAL_META: Record<'privacy' | 'terms', Record<Locale, string>> = { + privacy: { + hu: 'Adatvédelmi tájékoztató - ismerje meg, hogyan kezeljük személyes adatait.', + en: 'Privacy policy — learn how we handle your personal data.', + }, + terms: { + hu: 'Általános Szerződési Feltételek - Ismerje meg a mozdIT Bt. szolgáltatásainak használati feltételeit.', + en: 'Terms of Service — learn about the terms and conditions of mozdIT Bt.’s services.', + }, +} + +export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> { + const { locale: rawLocale, slug } = await params + if (!isLocale(rawLocale)) return {} + const locale: Locale = rawLocale + const key = resolvePageKey(locale, slug) + if (!key) return {} + + const base = (title: string, description: string, ogDescription = description) => ({ + title: `${title} | ${siteConfig.general.name}`, + description, + openGraph: { + title: `${title} | ${siteConfig.general.name}`, + description: ogDescription, + url: `${siteConfig.general.url}${'/' + locale}/${slug}`, + locale: getOgLocale(locale), + }, + }) + + if (key === 'about') { + const about = await getAboutContent(locale) + return base(about.meta.title, about.meta.description, about.meta.ogDescription) + } + if (key === 'services') { + const services = await getServicesContent(locale) + return base(services.meta.title, services.meta.description, services.meta.ogDescription) + } + if (key === 'contact') { + const contact = await getContactContent(locale) + return base(contact.meta.title, contact.meta.description) + } + if (key === 'privacy') { + const page = await getLegalPage('adatvedelem', locale) + return base(page?.title ?? 'Adatvédelem', LEGAL_META.privacy[locale]) + } + // terms + const page = await getLegalPage('hasznalati-feltetelek', locale) + return base(page?.title ?? 'ÁSZF', LEGAL_META.terms[locale]) +} + +export default async function CatchAllPage({ params }: { params: Promise<Params> }) { + const { locale: rawLocale, slug } = await params + if (!isLocale(rawLocale)) notFound() + const locale: Locale = rawLocale + const key = resolvePageKey(locale, slug) + if (!key) notFound() + + if (key === 'about') { + const content = await getAboutContent(locale) + return <AboutView content={content} locale={locale} /> + } + + if (key === 'services') { + const [content, home, common] = await Promise.all([ + getServicesContent(locale), + getHomeContent(locale), + getCommonContent(locale), + ]) + return ( + <ServicesView + content={content} + homeServices={home.services.items} + featuresLabel={common.labels.features} + webmailHref={home.hero.cta.secondary?.href ?? siteConfig.general.url} + locale={locale} + /> + ) + } + + if (key === 'contact') { + const [content, home, common] = await Promise.all([ + getContactContent(locale), + getHomeContent(locale), + getCommonContent(locale), + ]) + return ( + <ContactView + content={content} + contactEmail={siteConfig.contact.email} + footerAddress={common.footer.address} + webmailHref={home.hero.cta.secondary?.href ?? siteConfig.general.url} + locale={locale} + /> + ) + } + + const page = await getLegalPage(key === 'privacy' ? 'adatvedelem' : 'hasznalati-feltetelek', locale) + if (!page) notFound() + return <LegalPageView page={page} locale={locale} /> +} diff --git a/proto/src/app/(frontend)/[locale]/layout.tsx b/proto/src/app/(frontend)/[locale]/layout.tsx new file mode 100644 index 0000000..07447fb --- /dev/null +++ b/proto/src/app/(frontend)/[locale]/layout.tsx @@ -0,0 +1,58 @@ +import { notFound } from 'next/navigation' +import Header from '../../../components/Header' +import Footer from '../../../components/Footer' +import { LOCALES, isLocale, localePath, type Locale } from '@/lib/i18n' +import { getCommonContent, getHomeContent } from '@/lib/payload-content' +import { siteConfig, getMainNavigation, getFooterNavigation, getFooterLegalLinks, getSiteDescription } from '@/config/site' + +export function generateStaticParams() { + return LOCALES.map((locale) => ({ locale })) +} + +export default async function LocaleLayout({ + children, + params, +}: { + children: React.ReactNode + params: Promise<{ locale: string }> +}) { + const { locale: rawLocale } = await params + if (!isLocale(rawLocale)) notFound() + const locale: Locale = rawLocale + + const [common, home] = await Promise.all([ + getCommonContent(locale), + getHomeContent(locale), + ]) + + const isStaging = process.env.NEXT_PUBLIC_DEPLOY_ENV === 'staging' + + return ( + <> + {isStaging && ( + <div className="bg-amber-400 px-4 py-2 text-center text-xs font-extrabold tracking-[0.18em] text-amber-950 sm:text-sm"> + {common.staging.banner} + </div> + )} + <Header + nav={getMainNavigation(locale)} + homeHref={localePath(locale)} + a11y={common.a11y} + /> + <main className="flex-1"> + {children} + </main> + <Footer + nav={getFooterNavigation(locale)} + legalLinks={getFooterLegalLinks(locale)} + homeHref={localePath(locale)} + description={getSiteDescription(locale)} + contactEmail={siteConfig.contact.email} + footerAddress={common.footer.address} + footerCopyright={common.footer.copyright} + homeServices={home.services.items.map((item) => ({ id: item.id, title: item.title, icon: item.icon }))} + locale={locale} + /> + </> + ) +} diff --git a/proto/src/app/(frontend)/[locale]/page.tsx b/proto/src/app/(frontend)/[locale]/page.tsx new file mode 100755 index 0000000..428ad9c --- /dev/null +++ b/proto/src/app/(frontend)/[locale]/page.tsx @@ -0,0 +1,41 @@ +import type { Metadata } from 'next' +import HomeView from '@/components/views/HomeView' +import { isLocale, type Locale } from '@/lib/i18n' +import { getHomeContent, getPartners } from '@/lib/payload-content' +import { siteConfig, getSiteDescription, getOgLocale } from '@/config/site' +import { notFound } from 'next/navigation' + +type Params = { locale: string } + +export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> { + const { locale: rawLocale } = await params + if (!isLocale(rawLocale)) return {} + const locale: Locale = rawLocale + const description = getSiteDescription(locale) + return { + title: `${siteConfig.general.name} | ${description}`, + description, + openGraph: { + title: siteConfig.general.name, + description, + url: siteConfig.general.url, + siteName: siteConfig.general.name, + images: [{ url: siteConfig.general.ogImage, width: 1200, height: 630, alt: siteConfig.general.name }], + locale: getOgLocale(locale), + type: 'website', + }, + } +} + +export default async function HomePage({ params }: { params: Promise<Params> }) { + const { locale: rawLocale } = await params + if (!isLocale(rawLocale)) notFound() + const locale: Locale = rawLocale + + const [content, partners] = await Promise.all([ + getHomeContent(locale), + getPartners(), + ]) + + return <HomeView content={content} partners={partners} locale={locale} /> +} diff --git a/proto/src/app/(frontend)/felhasznalasi-feltetelek/page.tsx b/proto/src/app/(frontend)/felhasznalasi-feltetelek/page.tsx deleted file mode 100644 index d6717c9..0000000 --- a/proto/src/app/(frontend)/felhasznalasi-feltetelek/page.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { content } from '@/content' - -export const metadata = { - title: `${content.pages.hasznalatiFeltetelek.title} | ${content.common.labels.features || 'mozdIT Bt.'}`, - description: 'Általános Szerződési Feltételek - Ismerje meg a mozdIT Bt. szolgáltatásainak használati feltételeit.', -} - -export default function TermsOfService() { - const pageContent = content.pages.hasznalatiFeltetelek - - return ( - <div className="py-20 lg:py-28 max-w-4xl mx-auto px-4 sm:px-6 lg:px-8"> - <div className="mb-12"> - <h1 - className="text-4xl md:text-5xl font-bold mb-4" - style={{ color: 'var(--color-foreground)' }} - > - {pageContent.title} - </h1> - <p - className="text-sm" - style={{ color: 'var(--color-foreground-muted)' }} - > - Utolsó frissítés: {pageContent.lastUpdated} - </p> - </div> - - <div className="space-y-12"> - {pageContent.sections.map((section) => ( - <section key={section.id}> - <h2 - className="text-2xl font-semibold mb-4" - style={{ color: 'var(--color-foreground)' }} - > - {section.title} - </h2> - <div - className="prose max-w-none" - style={{ color: 'var(--color-foreground-muted)' }} - dangerouslySetInnerHTML={{ - __html: section.content - .replace(/\n\n/g, '</p><p class="mb-4">') - .replace(/• \*\*(.*?)\*\*/g, '<br/>• <strong>$1</strong>') - .replace(/^/, '<p class="mb-4">') - .replace(/$/, '</p>') - }} - /> - </section> - ))} - </div> - </div> - ) -} diff --git a/proto/src/app/(frontend)/kapcsolat/layout.tsx b/proto/src/app/(frontend)/kapcsolat/layout.tsx deleted file mode 100755 index 58a1f6a..0000000 --- a/proto/src/app/(frontend)/kapcsolat/layout.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { siteConfig } from '@/config/site' -import { content } from '@/content' -import type { Metadata } from 'next' - -const { contact: pageContent } = content.pages - -export const metadata: Metadata = { - title: `${pageContent.meta.title} | ${siteConfig.general.name}`, - description: pageContent.meta.description, - openGraph: { - title: `${pageContent.meta.title} | ${siteConfig.general.name}`, - description: pageContent.meta.description, - url: `${siteConfig.general.url}/kapcsolat`, - }, -} - -export default function ContactLayout({ - children, -}: { - children: React.ReactNode -}) { - return children -} diff --git a/proto/src/app/(frontend)/layout.tsx b/proto/src/app/(frontend)/layout.tsx index e0096da..a402d50 100755 --- a/proto/src/app/(frontend)/layout.tsx +++ b/proto/src/app/(frontend)/layout.tsx @@ -2,11 +2,8 @@ import type { Metadata, Viewport } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; -import Header from "../../components/Header"; -import Footer from "../../components/Footer"; import { ThemeProvider } from "../../components/ThemeProvider"; -import { siteConfig } from "../../config/site"; -import { common } from "../../content"; +import { siteConfig, getSiteDescription } from "../../config/site"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -18,47 +15,18 @@ const geistMono = Geist_Mono({ subsets: ["latin"], }); +// WHY hu itt: ez a legkülső, nyelv-független layout (html/body/theme-script +// csak egyszer, MITHOME-91/114) — a metadataBase és az alap description a +// magyar (alapértelmezett) nyelvet tükrözi, de minden [locale]/[slug] +// oldal a saját generateMetadata()-jával felülírja title/description-t +// nyelvhelyesen. Ez csak a legelső, JS nélküli betöltéskori fallback. export const metadata: Metadata = { // WHY metadataBase: without it Next resolves relative OG/twitter image URLs // against localhost, producing broken social previews in production. metadataBase: new URL(siteConfig.general.url), - title: `${siteConfig.general.name} | ${siteConfig.general.description}`, - description: siteConfig.general.description, + title: siteConfig.general.name, + description: getSiteDescription("hu"), authors: [{ name: siteConfig.general.name }], - keywords: ["web hosting", "email szolgáltatás", "DNS adminisztráció", "IT szolgáltatás", "mozdIT"], - openGraph: { - title: siteConfig.general.name, - description: siteConfig.general.description, - url: siteConfig.general.url, - siteName: siteConfig.general.name, - images: [ - { - url: siteConfig.general.ogImage, - width: 1200, - height: 630, - alt: siteConfig.general.name, - }, - ], - locale: siteConfig.general.locale, - type: "website", - }, - twitter: { - card: "summary_large_image", - title: siteConfig.general.name, - description: siteConfig.general.description, - images: [siteConfig.general.ogImage], - }, - robots: { - index: true, - follow: true, - googleBot: { - index: true, - follow: true, - "max-video-preview": -1, - "max-image-preview": "large", - "max-snippet": -1, - }, - }, }; // Keep Safari's browser chrome neutral; only the in-page staging strip is amber. @@ -71,7 +39,6 @@ export default function RootLayout({ }: Readonly<{ children: React.ReactNode; }>) { - const isStaging = process.env.NEXT_PUBLIC_DEPLOY_ENV === 'staging'; return ( <html lang="hu" suppressHydrationWarning> <head> @@ -95,16 +62,7 @@ export default function RootLayout({ style={{ background: 'var(--color-background)', color: 'var(--color-foreground)' }} > <ThemeProvider> - {isStaging && ( - <div className="bg-amber-400 px-4 py-2 text-center text-xs font-extrabold tracking-[0.18em] text-amber-950 sm:text-sm"> - {common.staging.banner} - </div> - )} - <Header /> - <main className="flex-1"> - {children} - </main> - <Footer /> + {children} </ThemeProvider> </body> </html> diff --git a/proto/src/app/(frontend)/not-found.tsx b/proto/src/app/(frontend)/not-found.tsx new file mode 100644 index 0000000..a21c6bb --- /dev/null +++ b/proto/src/app/(frontend)/not-found.tsx @@ -0,0 +1,27 @@ +import Link from 'next/link' +import { DEFAULT_LOCALE, localePath } from '@/lib/i18n' + +/** + * MITHOME-91/114: explicit not-found a (frontend) route group szintjén. + * + * WHY kell ez expliciten: notFound() hívásra (érvénytelen locale vagy slug + * a [locale]/[slug] catch-all-ban) Next.js enélkül a beépített, kétértelmű + * fallback UI-t próbálja renderelni — ez a (payload) route group saját + * <html> gyökerével ütközve ugyanazt a "script tag" / dupla-html hibát + * okozta, amit a MITHOME-87-ben már egyszer megoldottunk a (frontend)/ + * (payload) szétválasztással. Egy saját not-found.tsx a (frontend) alatt + * egyértelművé teszi, melyik gyökér html-be kell renderelni. + */ +export default function NotFound() { + return ( + <div className="flex flex-1 flex-col items-center justify-center py-24 px-4 text-center"> + <h1 className="text-4xl font-bold mb-4" style={{ color: 'var(--color-foreground)' }}>404</h1> + <p className="mb-8" style={{ color: 'var(--color-foreground-muted)' }}> + A keresett oldal nem található. / The page you’re looking for could not be found. + </p> + <Link href={localePath(DEFAULT_LOCALE)} className="btn btn-primary"> + mozdIT Bt. + </Link> + </div> + ) +} diff --git a/proto/src/app/(frontend)/page.tsx b/proto/src/app/(frontend)/page.tsx old mode 100755 new mode 100644 index 3dfb149..4a71668 --- a/proto/src/app/(frontend)/page.tsx +++ b/proto/src/app/(frontend)/page.tsx @@ -1,305 +1,8 @@ -import { content } from '@/content' -import Image from 'next/image' +import { redirect } from 'next/navigation' +import { DEFAULT_LOCALE, localePath } from '@/lib/i18n' -const { home: pageContent } = content.pages - -export default function Home() { - return ( - <div className="space-y-0"> - {/* Hero Section */} - <section className="bg-gradient-hero py-20 lg:py-28 relative overflow-hidden"> - {/* Background decoration */} - <div className="absolute inset-0 overflow-hidden pointer-events-none"> - <div - className="absolute -top-40 -right-40 w-80 h-80 rounded-full opacity-30 animate-pulse-slow" - style={{ background: 'var(--color-primary-200)' }} - /> - <div - className="absolute -bottom-40 -left-40 w-96 h-96 rounded-full opacity-20 animate-pulse-slow" - style={{ background: 'var(--color-accent-200)', animationDelay: '1s' }} - /> - </div> - - <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center relative z-10"> - <h1 - className="text-4xl md:text-5xl lg:text-6xl font-bold mb-6 leading-tight animate-fade-in-up" - style={{ color: 'var(--color-foreground)' }} - > - {pageContent.hero.title} - </h1> - <p - className="text-lg md:text-xl max-w-4xl mx-auto mb-8 leading-relaxed animate-fade-in-up" - style={{ color: 'var(--color-foreground-muted)', animationDelay: '100ms' }} - > - {pageContent.hero.description} - </p> - - {pageContent.hero.trustBullets && ( - <div - className="flex flex-wrap justify-center gap-4 md:gap-8 mb-10 text-sm font-medium animate-fade-in-up" - style={{ color: 'var(--color-foreground-secondary)', animationDelay: '150ms' }} - > - {pageContent.hero.trustBullets.map((bullet: string, i: number) => ( - <div key={i} className="flex items-center gap-2"> - <span style={{ color: 'var(--color-primary-500)' }}>✓</span> - {bullet} - </div> - ))} - </div> - )} - <div - className="flex flex-col sm:flex-row gap-4 justify-center items-center animate-fade-in-up" - style={{ animationDelay: '200ms' }} - > - <a - href={pageContent.hero.cta.primary.href} - target={pageContent.hero.cta.primary.external ? '_blank' : undefined} - rel={pageContent.hero.cta.primary.external ? 'noopener noreferrer' : undefined} - className="btn btn-primary text-lg px-8 py-4 group" - > - <svg - className="w-5 h-5 transition-transform duration-200 group-hover:scale-110" - fill="none" - stroke="currentColor" - viewBox="0 0 24 24" - > - <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10" /> - </svg> - {pageContent.hero.cta.primary.text} - </a> - {pageContent.hero.cta.secondary && ( - <a - href={pageContent.hero.cta.secondary.href} - className="btn btn-secondary text-lg px-8 py-4" - > - {pageContent.hero.cta.secondary.text} - </a> - )} - </div> - </div> - </section> - - {/* USP Section */} - <section - className="py-20" - style={{ background: 'var(--color-background-secondary)' }} - > - <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> - <div className="text-center mb-12"> - <h2 - className="text-3xl md:text-4xl font-bold mb-4" - style={{ color: 'var(--color-foreground)' }} - > - {pageContent.about.title} - </h2> - </div> - <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 stagger-children"> - {pageContent.about.usps.map((usp, index) => ( - <div - key={usp.id} - className="group text-center p-6 rounded-xl transition-all duration-300 hover-lift animate-fade-in-up" - style={{ - background: 'var(--color-background)', - border: '1px solid var(--color-border)', - animationDelay: `${index * 100}ms` - }} - > - <div - className="icon-container icon-container-lg mx-auto mb-4" - > - <span className="text-2xl">{usp.icon}</span> - </div> - <h3 - className="text-lg font-semibold mb-2 transition-colors duration-200" - style={{ color: 'var(--color-foreground)' }} - > - {usp.title} - </h3> - <p style={{ color: 'var(--color-foreground-muted)' }}> - {usp.description} - </p> - </div> - ))} - </div> - </div> - </section> - - {/* Services Section */} - <section - className="py-20" - style={{ background: 'var(--color-background)' }} - > - <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> - <div className="text-center mb-12"> - <h2 - className="text-3xl md:text-4xl font-bold mb-4" - style={{ color: 'var(--color-foreground)' }} - > - {pageContent.services.title} - </h2> - <p - className="text-lg max-w-3xl mx-auto" - style={{ color: 'var(--color-foreground-muted)' }} - > - {pageContent.services.subtitle} - </p> - </div> - <div className="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-5xl mx-auto"> - {pageContent.services.items.map((service, index) => ( - <div - key={service.id} - className="group card hover-lift" - style={{ animationDelay: `${index * 100}ms` }} - > - <div className="icon-container mb-4"> - <span className="text-xl">{service.icon}</span> - </div> - <h3 - className="text-xl font-semibold mb-3 transition-colors duration-200 group-hover:text-blue-600" - style={{ color: 'var(--color-foreground)' }} - > - {service.title} - </h3> - <p - className="leading-relaxed mb-4" - style={{ color: 'var(--color-foreground-muted)' }} - > - {service.description} - </p> - <div className="mb-4"> - <h4 - className="text-sm font-semibold mb-2" - style={{ color: 'var(--color-foreground-secondary)' }} - > - {pageContent.serviceFeatures.title} - </h4> - <ul - className="text-sm space-y-1.5" - style={{ color: 'var(--color-foreground-muted)' }} - > - {service.features.map((feature, idx) => ( - <li key={idx} className="flex items-start group/item"> - <span - className="mr-2 transition-transform duration-200 group-hover/item:scale-125" - style={{ color: 'var(--color-success-500)' }} - > - ✓ - </span> - {feature} - </li> - ))} - </ul> - </div> - <a - href="/kapcsolat" - className="inline-flex items-center font-medium transition-all duration-200 group/link" - style={{ color: 'var(--color-primary-600)' }} - > - {service.ctaText} - <svg - className="w-4 h-4 ml-1 transition-transform duration-200 group-hover/link:translate-x-1" - fill="none" - stroke="currentColor" - viewBox="0 0 24 24" - > - <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /> - </svg> - </a> - </div> - ))} - </div> - </div> - </section> - - {/* Partners Section */} - {pageContent.partners.items.length > 0 && ( - <section className="py-16"> - <div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8"> - <h2 className="text-3xl font-bold text-center mb-3" style={{ color: 'var(--color-foreground)' }}> - {pageContent.partners.title} - </h2> - <p className="text-center mb-10" style={{ color: 'var(--color-foreground-muted)' }}> - {pageContent.partners.subtitle} - </p> - <div className="flex flex-wrap justify-center items-center gap-10"> - {pageContent.partners.items.map((partner) => ( - <a - key={partner.name} - href={partner.url} - target="_blank" - rel="noopener noreferrer" - className="group flex flex-col items-center gap-3 opacity-80 hover:opacity-100 transition-opacity duration-200" - title={partner.name} - > - <span className="relative h-12 w-40"> - <Image - src={partner.logo} - alt={partner.name} - fill - sizes="160px" - className="object-contain" - /> - </span> - <span className="text-sm" style={{ color: 'var(--color-foreground-muted)' }}> - {partner.name} - </span> - </a> - ))} - </div> - </div> - </section> - )} - - {/* CTA Section */} - <section - className="py-20 relative overflow-hidden" - style={{ background: 'var(--color-foreground)' }} - > - {/* Animated background elements */} - <div className="absolute inset-0 overflow-hidden pointer-events-none opacity-10"> - <div - className="absolute top-10 left-10 w-32 h-32 rounded-full animate-float" - style={{ background: 'var(--color-primary-500)' }} - /> - <div - className="absolute bottom-10 right-20 w-24 h-24 rounded-full animate-float" - style={{ background: 'var(--color-accent-500)', animationDelay: '0.5s' }} - /> - <div - className="absolute top-1/2 right-1/4 w-16 h-16 rounded-full animate-float" - style={{ background: 'var(--color-primary-400)', animationDelay: '1s' }} - /> - </div> - - <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center relative z-10"> - <h2 - className="text-3xl md:text-4xl font-bold mb-4" - style={{ color: 'var(--color-background)' }} - > - {pageContent.cta.title} - </h2> - <p - className="text-xl mb-8 max-w-2xl mx-auto" - style={{ color: 'var(--color-background)', opacity: 0.8 }} - > - {pageContent.cta.subtitle} - </p> - <a - href="/kapcsolat" - className="btn btn-primary text-lg px-8 py-4 hover-glow" - > - {pageContent.cta.button} - <svg - className="w-5 h-5 ml-2" - fill="none" - stroke="currentColor" - viewBox="0 0 24 24" - > - <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" /> - </svg> - </a> - </div> - </section> - </div> - ) +// A puszta domain-gyökér (pl. mozdit.hu/) az alapértelmezett nyelvre +// (hu) irányít — a tényleges főoldal a /hu alatt él (MITHOME-91/114). +export default function RootRedirect() { + redirect(localePath(DEFAULT_LOCALE)) } diff --git a/proto/src/components/Footer.test.tsx b/proto/src/components/Footer.test.tsx index 278d265..c8fbfab 100755 --- a/proto/src/components/Footer.test.tsx +++ b/proto/src/components/Footer.test.tsx @@ -1,27 +1,44 @@ import { render, screen } from '@testing-library/react' import '@testing-library/jest-dom' import Footer from './Footer' -import { common } from '@/content' +import { common, content } from '@/content' +import { getFooterNavigation, getFooterLegalLinks, getSiteDescription } from '@/config/site' + +// MITHOME-91/114: Footer lett props-alapú (locale-aware tartalom a szülő +// [locale] layoutból jön, Payload Local API-n keresztül) — a teszt a +// content/*.json fixture-öket + a valódi config/site.ts helper-eket adja +// át, hogy a viselkedés a ténylegeshez hasonló maradjon. +const footerProps = { + nav: getFooterNavigation('hu'), + legalLinks: getFooterLegalLinks('hu'), + homeHref: '/hu', + description: getSiteDescription('hu'), + contactEmail: 'info@mozdit.hu', + footerAddress: common.footer.address, + footerCopyright: common.footer.copyright, + homeServices: content.pages.home.services.items.map((item) => ({ id: item.id, title: item.title, icon: item.icon })), + locale: 'hu' as const, +} describe('Footer', () => { it('should render company information', () => { - render(<Footer />) + render(<Footer {...footerProps} />) expect(screen.getByText('mozdIT Bt.')).toBeInTheDocument() expect(screen.getByText('Megbízható web- és email szolgáltatás személyre szabott támogatással. Stabil tárhely, üzembiztos levelezés és DNS adminisztráció gyors reakcióval.')).toBeInTheDocument() }) it('should render email and company details', () => { - render(<Footer />) + render(<Footer {...footerProps} />) expect(screen.getByText('info@mozdit.hu')).toBeInTheDocument() expect(screen.getAllByText('mozdIT Bt.').length).toBeGreaterThan(0) - // Address is CMS-editable (common.json footer.address) — assert the source value + // Address is CMS-editable (Payload Common global footer.address) — assert the source value expect(screen.getByText(common.footer.address)).toBeInTheDocument() }) it('should render navigation links', () => { - render(<Footer />) + render(<Footer {...footerProps} />) expect(screen.getByText('Kezdőlap')).toBeInTheDocument() expect(screen.getByText('Rólunk')).toBeInTheDocument() @@ -30,7 +47,7 @@ describe('Footer', () => { }) it('should render service sections', () => { - render(<Footer />) + render(<Footer {...footerProps} />) expect(screen.getByText('Webtárhely (Hosting)')).toBeInTheDocument() expect(screen.getByText('E-mail szolgáltatás')).toBeInTheDocument() @@ -39,21 +56,21 @@ describe('Footer', () => { }) it('should render copyright notice from common.json with the current year', () => { - render(<Footer />) + render(<Footer {...footerProps} />) const currentYear = new Date().getFullYear() expect(screen.getByText(`© 2002–${currentYear} mozdIT Bt. Minden jog fenntartva.`)).toBeInTheDocument() }) it('should render legal links', () => { - render(<Footer />) + render(<Footer {...footerProps} />) expect(screen.getAllByText('Adatvédelmi tájékoztató')).toHaveLength(2) // Appears in both sections expect(screen.getAllByText('Használati feltételek')).toHaveLength(2) // Appears in both sections }) it('should render with proper grid layout', () => { - const { container } = render(<Footer />) + const { container } = render(<Footer {...footerProps} />) const gridContainer = container.querySelector('.grid.grid-cols-1.md\\:grid-cols-4') expect(gridContainer).toBeInTheDocument() @@ -63,7 +80,7 @@ describe('Footer', () => { }) it('should render with proper semantic structure', () => { - const { container } = render(<Footer />) + const { container } = render(<Footer {...footerProps} />) // Should have a footer element const footer = container.firstChild as HTMLElement diff --git a/proto/src/components/Footer.tsx b/proto/src/components/Footer.tsx index 1260d05..6b63b82 100755 --- a/proto/src/components/Footer.tsx +++ b/proto/src/components/Footer.tsx @@ -1,14 +1,35 @@ 'use client' import { siteConfig } from '@/config/site' -import { content } from '@/content' import Link from 'next/link' +import type { NavigationItem } from '@/types/site' +import type { Locale } from '@/lib/i18n' -export default function Footer() { +type FooterServiceItem = { id: string; title: string; icon: string } + +type FooterProps = { + nav: NavigationItem[] + legalLinks: NavigationItem[] + homeHref: string + description: string + contactEmail: string + footerAddress: string + footerCopyright: string + homeServices: FooterServiceItem[] + locale: Locale +} + +const SECTION_LABELS: Record<Locale, { navigation: string; services: string; support: string }> = { + hu: { navigation: 'Navigáció', services: 'Szolgáltatások', support: 'Műszaki támogatás' }, + en: { navigation: 'Navigation', services: 'Services', support: 'Technical support' }, +} + +export default function Footer({ nav, legalLinks, homeHref, description, contactEmail, footerAddress, footerCopyright, homeServices, locale }: FooterProps) { + const t = SECTION_LABELS[locale] return ( - <footer + <footer className="border-t" - style={{ + style={{ background: 'var(--color-background-secondary)', borderColor: 'var(--color-border)' }} @@ -17,65 +38,65 @@ export default function Footer() { <div className="grid grid-cols-1 md:grid-cols-4 gap-8 lg:gap-12"> {/* Company Info */} <div className="md:col-span-2"> - <Link - href="/" + <Link + href={homeHref} className="inline-flex items-center gap-2 text-xl font-bold mb-4 transition-colors duration-200" style={{ color: 'var(--color-primary-600)' }} > {siteConfig.general.name} - <span + <span className="inline-block w-2 h-2 rounded-full animate-pulse-slow" style={{ background: 'var(--color-success-500)' }} /> </Link> - <p + <p className="mb-6 max-w-md leading-relaxed" style={{ color: 'var(--color-foreground-muted)' }} > - {siteConfig.general.description} + {description} </p> <div className="space-y-2 text-sm" style={{ color: 'var(--color-foreground-muted)' }}> - <a - href={`mailto:${siteConfig.contact.email}`} + <a + href={`mailto:${contactEmail}`} className="flex items-center gap-2 group transition-colors duration-200 hover:text-blue-600" style={{ color: 'var(--color-foreground-secondary)' }} > - <span + <span className="flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center transition-all duration-200 group-hover:scale-110" style={{ background: 'var(--color-primary-100)' }} > ✉️ </span> - <span>{siteConfig.contact.email}</span> + <span>{contactEmail}</span> </a> - <div + <div className="flex items-center gap-2" style={{ color: 'var(--color-foreground-secondary)' }} > - <span + <span className="flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center" style={{ background: 'var(--color-primary-100)' }} > 🏢 </span> - <span>{content.common.footer.address}</span> + <span>{footerAddress}</span> </div> </div> </div> {/* Navigation Links */} <div> - <h3 + <h3 className="text-sm font-semibold uppercase tracking-wider mb-4" style={{ color: 'var(--color-foreground)' }} > - Navigáció + {t.navigation} </h3> <ul className="space-y-3"> - {siteConfig.navigation.footer.map((item) => ( + {nav.map((item) => ( <li key={item.href}> - <a - href={item.href} + <a + href={item.href} className="group flex items-center gap-2 text-sm transition-all duration-200" style={{ color: 'var(--color-foreground-muted)' }} onMouseEnter={(e) => { @@ -85,7 +106,7 @@ export default function Footer() { e.currentTarget.style.color = 'var(--color-foreground-muted)' }} > - <span + <span className="w-1.5 h-1.5 rounded-full transition-all duration-200 group-hover:scale-150" style={{ background: 'var(--color-primary-500)' }} /> @@ -98,16 +119,16 @@ export default function Footer() { {/* Services */} <div> - <h3 + <h3 className="text-sm font-semibold uppercase tracking-wider mb-4" style={{ color: 'var(--color-foreground)' }} > - Szolgáltatások + {t.services} </h3> <ul className="space-y-3"> - {content.pages.home.services.items.map((service) => ( - <li - key={service.id} + {homeServices.map((service) => ( + <li + key={service.id} className="flex items-center gap-2 text-sm" style={{ color: 'var(--color-foreground-muted)' }} > @@ -115,19 +136,19 @@ export default function Footer() { {service.title} </li> ))} - <li + <li className="flex items-center gap-2 text-sm" style={{ color: 'var(--color-foreground-muted)' }} > <span className="text-base">🛠️</span> - Műszaki támogatás + {t.support} </li> </ul> </div> </div> {/* Bottom section */} - <div + <div className="border-t pt-8 mt-8" style={{ borderColor: 'var(--color-border)' }} > @@ -136,11 +157,11 @@ export default function Footer() { className="text-sm" style={{ color: 'var(--color-foreground-muted)' }} > - {/* Copyright text is CMS-editable (common.json); {year} resolves to the current year */} - {content.common.footer.copyright.replace('{year}', String(new Date().getFullYear()))} + {/* Copyright text is CMS-editable (Payload Common global); {year} resolves to the current year */} + {footerCopyright.replace('{year}', String(new Date().getFullYear()))} </p> <div className="flex items-center gap-6"> - {siteConfig.footer.links.map((link) => ( + {legalLinks.map((link) => ( <a key={link.href} href={link.href} @@ -162,9 +183,9 @@ export default function Footer() { {/* Tech badge */} <div className="mt-8 flex justify-center"> - <div + <div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full text-xs" - style={{ + style={{ background: 'var(--color-background-tertiary)', color: 'var(--color-foreground-muted)' }} diff --git a/proto/src/components/Header.test.tsx b/proto/src/components/Header.test.tsx index 23867ed..7603fbf 100755 --- a/proto/src/components/Header.test.tsx +++ b/proto/src/components/Header.test.tsx @@ -3,6 +3,7 @@ import '@testing-library/jest-dom' import userEvent from '@testing-library/user-event' import Header from './Header' import { common } from '@/content' +import { getMainNavigation } from '@/config/site' // Mock Next.js Link component jest.mock('next/link', () => { @@ -11,14 +12,23 @@ jest.mock('next/link', () => { ) }) +// MITHOME-91/114: Header lett props-alapú (locale-aware nav/a11y a szülő +// [locale] layoutból jön) — a teszt a valódi getMainNavigation('hu')-t adja +// át, hogy a feliratok/hrefek a tényleges alkalmazás-viselkedést tükrözzék. +const headerProps = { + nav: getMainNavigation('hu'), + homeHref: '/hu', + a11y: common.a11y, +} + describe('Header', () => { it('should render the company logo', () => { - render(<Header />) + render(<Header {...headerProps} />) expect(screen.getByAltText('mozdIT Bt.')).toBeInTheDocument() }) it('should render all navigation links in desktop menu', () => { - render(<Header />) + render(<Header {...headerProps} />) // Desktop menu should contain all links with specific structures const desktopMenu = document.querySelector('.hidden.md\\:flex') @@ -32,7 +42,7 @@ describe('Header', () => { }) it('should render contact button with correct styling', () => { - render(<Header />) + render(<Header {...headerProps} />) const contactButtons = screen.getAllByText('Kapcsolat') expect(contactButtons.length).toBeGreaterThan(0) @@ -49,7 +59,7 @@ describe('Header', () => { }) it('should render hamburger menu button on mobile', () => { - render(<Header />) + render(<Header {...headerProps} />) // The hamburger menu button is hidden by default in desktop view // We can test its presence even if not visible @@ -58,14 +68,14 @@ describe('Header', () => { }) it('should have proper accessibility attributes', () => { - render(<Header />) + render(<Header {...headerProps} />) const hamburgerButton = screen.getByRole('button', { name: new RegExp(common.a11y.openMenu, 'i') }) expect(hamburgerButton).toHaveAttribute('aria-expanded', 'false') }) it('should render with proper semantic structure', () => { - const { container } = render(<Header />) + const { container } = render(<Header {...headerProps} />) // Should have header element with proper structure const header = container.firstChild as HTMLElement @@ -81,7 +91,7 @@ describe('Header', () => { it('should toggle mobile menu when hamburger button is clicked', async () => { const user = userEvent.setup() - render(<Header />) + render(<Header {...headerProps} />) const hamburgerButton = screen.getByRole('button', { name: new RegExp(common.a11y.openMenu, 'i') }) @@ -99,7 +109,7 @@ describe('Header', () => { it('should close mobile menu when navigation link is clicked', async () => { const user = userEvent.setup() - render(<Header />) + render(<Header {...headerProps} />) const hamburgerButton = screen.getByRole('button', { name: new RegExp(common.a11y.openMenu, 'i') }) @@ -120,31 +130,31 @@ describe('Header', () => { }) it('should have correct navigation links with proper hrefs', () => { - render(<Header />) + render(<Header {...headerProps} />) // Check for home link const homeLinks = screen.getAllByText('Kezdőlap') expect(homeLinks.length).toBeGreaterThan(0) - expect(homeLinks[0].closest('a')).toHaveAttribute('href', '/') + expect(homeLinks[0].closest('a')).toHaveAttribute('href', '/hu') // Check for about link const aboutLinks = screen.getAllByText('Rólunk') expect(aboutLinks.length).toBeGreaterThan(0) - expect(aboutLinks[0].closest('a')).toHaveAttribute('href', '/rolunk') + expect(aboutLinks[0].closest('a')).toHaveAttribute('href', '/hu/rolunk') // Check for services link const servicesLinks = screen.getAllByText('Szolgáltatások') expect(servicesLinks.length).toBeGreaterThan(0) - expect(servicesLinks[0].closest('a')).toHaveAttribute('href', '/szolgaltatasok') + expect(servicesLinks[0].closest('a')).toHaveAttribute('href', '/hu/szolgaltatasok') // Check for contact link const contactLinks = screen.getAllByText('Kapcsolat') expect(contactLinks.length).toBeGreaterThan(0) - expect(contactLinks[0].closest('a')).toHaveAttribute('href', '/kapcsolat') + expect(contactLinks[0].closest('a')).toHaveAttribute('href', '/hu/kapcsolat') }) it('should have proper responsive classes', () => { - const { container } = render(<Header />) + const { container } = render(<Header {...headerProps} />) // Desktop menu should be hidden on mobile const desktopMenu = container.querySelector('.hidden.md\\:flex') diff --git a/proto/src/components/Header.tsx b/proto/src/components/Header.tsx index 836f8eb..e0aa15a 100755 --- a/proto/src/components/Header.tsx +++ b/proto/src/components/Header.tsx @@ -1,13 +1,19 @@ 'use client' import { siteConfig } from '@/config/site' -import { common } from '@/content' import { useState, useEffect } from 'react' import { ThemeToggle } from './ThemeProvider' import Link from 'next/link' import Image from 'next/image' +import type { NavigationItem } from '@/types/site' -export default function Header() { +type HeaderProps = { + nav: NavigationItem[] + homeHref: string + a11y: { openMenu: string; closeMenu: string } +} + +export default function Header({ nav, homeHref, a11y }: HeaderProps) { const [isMenuOpen, setIsMenuOpen] = useState(false) const [isScrolled, setIsScrolled] = useState(false) @@ -49,7 +55,7 @@ export default function Header() { {/* Logo */} <div className="flex-shrink-0"> <Link - href="/" + href={homeHref} className="group flex items-center gap-2 transition-all duration-200" > <Image @@ -64,7 +70,7 @@ export default function Header() { {/* Desktop Navigation */} <div className="hidden md:flex items-center space-x-1"> - {siteConfig.navigation.main.map((item) => ( + {nav.map((item) => ( <a key={item.href} href={item.href} @@ -115,7 +121,7 @@ export default function Header() { }} aria-expanded={isMenuOpen} > - <span className="sr-only">{isMenuOpen ? common.a11y.closeMenu : common.a11y.openMenu}</span> + <span className="sr-only">{isMenuOpen ? a11y.closeMenu : a11y.openMenu}</span> <div className="relative w-6 h-6"> {/* Hamburger to X animation */} <span @@ -151,7 +157,7 @@ export default function Header() { className="py-3 space-y-1 border-t" style={{ borderColor: 'var(--color-border)' }} > - {siteConfig.navigation.main.map((item, index) => ( + {nav.map((item, index) => ( <a key={item.href} href={item.href} diff --git a/proto/src/app/(frontend)/rolunk/page.tsx b/proto/src/components/views/AboutView.tsx similarity index 90% rename from proto/src/app/(frontend)/rolunk/page.tsx rename to proto/src/components/views/AboutView.tsx index 5f589cb..a2fef78 100755 --- a/proto/src/app/(frontend)/rolunk/page.tsx +++ b/proto/src/components/views/AboutView.tsx @@ -1,20 +1,13 @@ -import { siteConfig } from '@/config/site' -import { content } from '@/content' -import type { Metadata } from 'next' +import { localePath, type Locale } from '@/lib/i18n' +import type { getAboutContent } from '@/lib/payload-content' -const { about: pageContent } = content.pages - -export const metadata: Metadata = { - title: `${pageContent.meta.title} | ${siteConfig.general.name}`, - description: pageContent.meta.description, - openGraph: { - title: `${pageContent.meta.title} | ${siteConfig.general.name}`, - description: pageContent.meta.ogDescription, - url: `${siteConfig.general.url}/rolunk`, - }, +type AboutViewProps = { + content: Awaited<ReturnType<typeof getAboutContent>> + locale: Locale } -export default function AboutPage() { +export default function AboutView({ content: pageContent, locale }: AboutViewProps) { + const contactHref = localePath(locale, 'contact') return ( <div className="space-y-0"> {/* Hero Section */} @@ -92,7 +85,7 @@ export default function AboutPage() { </div> <div className="grid grid-cols-1 md:grid-cols-3 gap-8 stagger-children"> - {pageContent.mission.values.map((item, index) => ( + {(pageContent.mission.values ?? []).map((item, index) => ( <div key={item.title} className="group text-center p-8 rounded-xl hover-lift animate-fade-in-up" @@ -182,7 +175,7 @@ export default function AboutPage() { {pageContent.cta.subtitle} </p> <a - href="/kapcsolat" + href={contactHref} className="btn btn-primary text-lg px-8 py-4 hover-glow" > {pageContent.cta.button} diff --git a/proto/src/app/(frontend)/kapcsolat/page.tsx b/proto/src/components/views/ContactView.tsx similarity index 88% rename from proto/src/app/(frontend)/kapcsolat/page.tsx rename to proto/src/components/views/ContactView.tsx index a086ab0..17ddd1a 100755 --- a/proto/src/app/(frontend)/kapcsolat/page.tsx +++ b/proto/src/components/views/ContactView.tsx @@ -1,12 +1,25 @@ 'use client' -import { siteConfig } from '@/config/site' -import { content } from '@/content' import { useState } from 'react' +import { siteConfig } from '@/config/site' +import { localePath, type Locale } from '@/lib/i18n' +import type { getContactContent } from '@/lib/payload-content' -const { contact: pageContent } = content.pages +type ContactViewProps = { + content: Awaited<ReturnType<typeof getContactContent>> + contactEmail: string + footerAddress: string + webmailHref: string + locale: Locale +} -export default function ContactPage() { +export default function ContactView({ content: pageContent, contactEmail, footerAddress, webmailHref, locale }: ContactViewProps) { + // WHY placeholder-csere: a GDPR-szöveg (Payload Contact.form.fields.gdpr.label) + // egy {privacyHref} tokent tartalmaz a beágyazott <a> linkben, mert a + // tényleges adatvédelmi oldal útvonala nyelvenként eltér (MITHOME-114) — a + // korábbi, hardcode-olt "/adatkezelesi-tajekoztato" út sosem egyezett a + // valódi oldallal, ez javítja azt is. + const gdprLabel = pageContent.form.fields.gdpr.label.replace('{privacyHref}', localePath(locale, 'privacy')) const [formData, setFormData] = useState({ name: '', email: '', @@ -135,7 +148,7 @@ export default function ContactPage() { {submitStatus === 'error' && ( <div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-md"> <p className="text-red-800"> - ❌ {pageContent.form.errorMessage.replace('{email}', siteConfig.contact.email)} + ❌ {pageContent.form.errorMessage.replace('{email}', contactEmail)} </p> </div> )} @@ -224,7 +237,7 @@ export default function ContactPage() { /> <span className="text-sm text-gray-700" - dangerouslySetInnerHTML={{ __html: pageContent.form.fields.gdpr.label + ' *' }} + dangerouslySetInnerHTML={{ __html: gdprLabel + ' *' }} /> </label> {errors.gdprConsent && <p className="mt-1 text-sm text-red-600">{errors.gdprConsent}</p>} @@ -255,11 +268,11 @@ export default function ContactPage() { </div> <div> <h3 className="font-semibold text-gray-900 mb-1">{pageContent.info.email.title}</h3> - <a - href={`mailto:${siteConfig.contact.email}`} + <a + href={`mailto:${contactEmail}`} className="text-blue-600 hover:text-blue-700" > - {siteConfig.contact.email} + {contactEmail} </a> <p className="text-sm text-gray-600 mt-1"> {pageContent.info.email.responseTime} @@ -274,7 +287,7 @@ export default function ContactPage() { <div> <h3 className="font-semibold text-gray-900 mb-1">{pageContent.info.company.title}</h3> <p className="text-gray-700">{siteConfig.general.name}</p> - <p className="text-sm text-gray-600">{content.common.footer.address}</p> + <p className="text-sm text-gray-600">{footerAddress}</p> </div> </div> @@ -284,8 +297,12 @@ export default function ContactPage() { </div> <div> <h3 className="font-semibold text-gray-900 mb-1">{pageContent.info.webmail.title}</h3> - <a - href={content.pages.home.hero.cta.primary.href} + <a + // WHY webmailHref: a migráció előtti kód itt is a + // "/kapcsolat" hrefet használta (ugyanaz a bug, mint a + // Szolgáltatások oldalon) — javítva a tényleges webmail + // URL-re. + href={webmailHref} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:text-blue-700" @@ -307,7 +324,7 @@ export default function ContactPage() { </h2> <div className="space-y-4"> - {pageContent.faq.items.map((item, index) => ( + {(pageContent.faq.items ?? []).map((item, index) => ( <div key={index}> <h3 className="font-semibold text-gray-900 mb-2">{item.question}</h3> <p className="text-gray-600 text-sm">{item.answer}</p> diff --git a/proto/src/components/views/HomeView.tsx b/proto/src/components/views/HomeView.tsx new file mode 100755 index 0000000..c405cc2 --- /dev/null +++ b/proto/src/components/views/HomeView.tsx @@ -0,0 +1,321 @@ +import Image from 'next/image' +import { localePath, type Locale } from '@/lib/i18n' +import type { getHomeContent, PartnerView } from '@/lib/payload-content' + +type HomeViewProps = { + content: Awaited<ReturnType<typeof getHomeContent>> + partners: PartnerView[] + locale: Locale +} + +// WHY hardcoded itt: a Partners collection (MITHOME-89) csak name/url/logo-t +// tárol, a szekció saját címe/alcíme sosem volt Payload-tartalom (a Home +// Globalból is szándékosan kimaradt, lásd src/globals/Home.ts) — ugyanaz a +// minta, mint a config/site.ts navigáció-feliratoknál. +const PARTNERS_COPY: Record<Locale, { title: string; subtitle: string }> = { + hu: { title: 'Partnereink', subtitle: 'Akikkel együtt dolgozunk' }, + en: { title: 'Our Partners', subtitle: 'Who we work with' }, +} + +export default function HomeView({ content: pageContent, partners, locale }: HomeViewProps) { + const contactHref = localePath(locale, 'contact') + const partnersCopy = PARTNERS_COPY[locale] + return ( + <div className="space-y-0"> + {/* Hero Section */} + <section className="bg-gradient-hero py-20 lg:py-28 relative overflow-hidden"> + {/* Background decoration */} + <div className="absolute inset-0 overflow-hidden pointer-events-none"> + <div + className="absolute -top-40 -right-40 w-80 h-80 rounded-full opacity-30 animate-pulse-slow" + style={{ background: 'var(--color-primary-200)' }} + /> + <div + className="absolute -bottom-40 -left-40 w-96 h-96 rounded-full opacity-20 animate-pulse-slow" + style={{ background: 'var(--color-accent-200)', animationDelay: '1s' }} + /> + </div> + + <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center relative z-10"> + <h1 + className="text-4xl md:text-5xl lg:text-6xl font-bold mb-6 leading-tight animate-fade-in-up" + style={{ color: 'var(--color-foreground)' }} + > + {pageContent.hero.title} + </h1> + <p + className="text-lg md:text-xl max-w-4xl mx-auto mb-8 leading-relaxed animate-fade-in-up" + style={{ color: 'var(--color-foreground-muted)', animationDelay: '100ms' }} + > + {pageContent.hero.description} + </p> + + {pageContent.hero.trustBullets && ( + <div + className="flex flex-wrap justify-center gap-4 md:gap-8 mb-10 text-sm font-medium animate-fade-in-up" + style={{ color: 'var(--color-foreground-secondary)', animationDelay: '150ms' }} + > + {pageContent.hero.trustBullets.map((bullet: string, i: number) => ( + <div key={i} className="flex items-center gap-2"> + <span style={{ color: 'var(--color-primary-500)' }}>✓</span> + {bullet} + </div> + ))} + </div> + )} + <div + className="flex flex-col sm:flex-row gap-4 justify-center items-center animate-fade-in-up" + style={{ animationDelay: '200ms' }} + > + <a + href={pageContent.hero.cta.primary.href} + target={pageContent.hero.cta.primary.external ? '_blank' : undefined} + rel={pageContent.hero.cta.primary.external ? 'noopener noreferrer' : undefined} + className="btn btn-primary text-lg px-8 py-4 group" + > + <svg + className="w-5 h-5 transition-transform duration-200 group-hover:scale-110" + fill="none" + stroke="currentColor" + viewBox="0 0 24 24" + > + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10" /> + </svg> + {pageContent.hero.cta.primary.text} + </a> + {pageContent.hero.cta.secondary && ( + <a + href={pageContent.hero.cta.secondary.href} + className="btn btn-secondary text-lg px-8 py-4" + > + {pageContent.hero.cta.secondary.text} + </a> + )} + </div> + </div> + </section> + + {/* USP Section */} + <section + className="py-20" + style={{ background: 'var(--color-background-secondary)' }} + > + <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> + <div className="text-center mb-12"> + <h2 + className="text-3xl md:text-4xl font-bold mb-4" + style={{ color: 'var(--color-foreground)' }} + > + {pageContent.about.title} + </h2> + </div> + <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 stagger-children"> + {(pageContent.about.usps ?? []).map((usp, index) => ( + <div + key={usp.id} + className="group text-center p-6 rounded-xl transition-all duration-300 hover-lift animate-fade-in-up" + style={{ + background: 'var(--color-background)', + border: '1px solid var(--color-border)', + animationDelay: `${index * 100}ms` + }} + > + <div + className="icon-container icon-container-lg mx-auto mb-4" + > + <span className="text-2xl">{usp.icon}</span> + </div> + <h3 + className="text-lg font-semibold mb-2 transition-colors duration-200" + style={{ color: 'var(--color-foreground)' }} + > + {usp.title} + </h3> + <p style={{ color: 'var(--color-foreground-muted)' }}> + {usp.description} + </p> + </div> + ))} + </div> + </div> + </section> + + {/* Services Section */} + <section + className="py-20" + style={{ background: 'var(--color-background)' }} + > + <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> + <div className="text-center mb-12"> + <h2 + className="text-3xl md:text-4xl font-bold mb-4" + style={{ color: 'var(--color-foreground)' }} + > + {pageContent.services.title} + </h2> + <p + className="text-lg max-w-3xl mx-auto" + style={{ color: 'var(--color-foreground-muted)' }} + > + {pageContent.services.subtitle} + </p> + </div> + <div className="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-5xl mx-auto"> + {pageContent.services.items.map((service, index) => ( + <div + key={service.id} + className="group card hover-lift" + style={{ animationDelay: `${index * 100}ms` }} + > + <div className="icon-container mb-4"> + <span className="text-xl">{service.icon}</span> + </div> + <h3 + className="text-xl font-semibold mb-3 transition-colors duration-200 group-hover:text-blue-600" + style={{ color: 'var(--color-foreground)' }} + > + {service.title} + </h3> + <p + className="leading-relaxed mb-4" + style={{ color: 'var(--color-foreground-muted)' }} + > + {service.description} + </p> + <div className="mb-4"> + <h4 + className="text-sm font-semibold mb-2" + style={{ color: 'var(--color-foreground-secondary)' }} + > + {pageContent.serviceFeatures.title} + </h4> + <ul + className="text-sm space-y-1.5" + style={{ color: 'var(--color-foreground-muted)' }} + > + {service.features.map((feature, idx) => ( + <li key={idx} className="flex items-start group/item"> + <span + className="mr-2 transition-transform duration-200 group-hover/item:scale-125" + style={{ color: 'var(--color-success-500)' }} + > + ✓ + </span> + {feature} + </li> + ))} + </ul> + </div> + <a + href={contactHref} + className="inline-flex items-center font-medium transition-all duration-200 group/link" + style={{ color: 'var(--color-primary-600)' }} + > + {service.ctaText} + <svg + className="w-4 h-4 ml-1 transition-transform duration-200 group-hover/link:translate-x-1" + fill="none" + stroke="currentColor" + viewBox="0 0 24 24" + > + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /> + </svg> + </a> + </div> + ))} + </div> + </div> + </section> + + {/* Partners Section */} + {partners.length > 0 && ( + <section className="py-16"> + <div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8"> + <h2 className="text-3xl font-bold text-center mb-3" style={{ color: 'var(--color-foreground)' }}> + {partnersCopy.title} + </h2> + <p className="text-center mb-10" style={{ color: 'var(--color-foreground-muted)' }}> + {partnersCopy.subtitle} + </p> + <div className="flex flex-wrap justify-center items-center gap-10"> + {partners.map((partner) => ( + <a + key={partner.name} + href={partner.url} + target="_blank" + rel="noopener noreferrer" + className="group flex flex-col items-center gap-3 opacity-80 hover:opacity-100 transition-opacity duration-200" + title={partner.name} + > + <span className="relative h-12 w-40"> + <Image + src={partner.logo.url} + alt={partner.logo.alt} + fill + sizes="160px" + className="object-contain" + /> + </span> + <span className="text-sm" style={{ color: 'var(--color-foreground-muted)' }}> + {partner.name} + </span> + </a> + ))} + </div> + </div> + </section> + )} + + {/* CTA Section */} + <section + className="py-20 relative overflow-hidden" + style={{ background: 'var(--color-foreground)' }} + > + {/* Animated background elements */} + <div className="absolute inset-0 overflow-hidden pointer-events-none opacity-10"> + <div + className="absolute top-10 left-10 w-32 h-32 rounded-full animate-float" + style={{ background: 'var(--color-primary-500)' }} + /> + <div + className="absolute bottom-10 right-20 w-24 h-24 rounded-full animate-float" + style={{ background: 'var(--color-accent-500)', animationDelay: '0.5s' }} + /> + <div + className="absolute top-1/2 right-1/4 w-16 h-16 rounded-full animate-float" + style={{ background: 'var(--color-primary-400)', animationDelay: '1s' }} + /> + </div> + + <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center relative z-10"> + <h2 + className="text-3xl md:text-4xl font-bold mb-4" + style={{ color: 'var(--color-background)' }} + > + {pageContent.cta.title} + </h2> + <p + className="text-xl mb-8 max-w-2xl mx-auto" + style={{ color: 'var(--color-background)', opacity: 0.8 }} + > + {pageContent.cta.subtitle} + </p> + <a + href={contactHref} + className="btn btn-primary text-lg px-8 py-4 hover-glow" + > + {pageContent.cta.button} + <svg + className="w-5 h-5 ml-2" + fill="none" + stroke="currentColor" + viewBox="0 0 24 24" + > + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" /> + </svg> + </a> + </div> + </section> + </div> + ) +} diff --git a/proto/src/app/(frontend)/adatvedelem/page.tsx b/proto/src/components/views/LegalPageView.tsx similarity index 64% rename from proto/src/app/(frontend)/adatvedelem/page.tsx rename to proto/src/components/views/LegalPageView.tsx index c818160..57c3e5c 100644 --- a/proto/src/app/(frontend)/adatvedelem/page.tsx +++ b/proto/src/components/views/LegalPageView.tsx @@ -1,40 +1,46 @@ -import { content } from '@/content' +import type { Locale } from '@/lib/i18n' +import type { getLegalPage } from '@/lib/payload-content' -export const metadata = { - title: `${content.pages.adatvedelem.title} | ${content.common.labels.features || 'mozdIT Bt.'}`, - description: 'Adatvédelmi tájékoztató - ismerje meg, hogyan kezeljük személyes adatait.', +type LegalPageViewProps = { + page: NonNullable<Awaited<ReturnType<typeof getLegalPage>>> + locale: Locale } -export default function PrivacyPolicy() { - const pageContent = content.pages.adatvedelem +const LAST_UPDATED_LABEL: Record<Locale, string> = { + hu: 'Utolsó frissítés', + en: 'Last updated', +} +/** Közös nézet az adatvédelmi tájékoztatóhoz és a használati feltételekhez + * — mindkettő azonos szerkezetű (title/lastUpdated/sections). */ +export default function LegalPageView({ page, locale }: LegalPageViewProps) { return ( <div className="py-20 lg:py-28 max-w-4xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="mb-12"> - <h1 + <h1 className="text-4xl md:text-5xl font-bold mb-4" style={{ color: 'var(--color-foreground)' }} > - {pageContent.title} + {page.title} </h1> - <p + <p className="text-sm" style={{ color: 'var(--color-foreground-muted)' }} > - Utolsó frissítés: {pageContent.lastUpdated} + {LAST_UPDATED_LABEL[locale]}: {page.lastUpdated} </p> </div> <div className="space-y-12"> - {pageContent.sections.map((section) => ( + {page.sections.map((section) => ( <section key={section.id}> - <h2 + <h2 className="text-2xl font-semibold mb-4" style={{ color: 'var(--color-foreground)' }} > {section.title} </h2> - <div + <div className="prose max-w-none" style={{ color: 'var(--color-foreground-muted)' }} dangerouslySetInnerHTML={{ diff --git a/proto/src/app/(frontend)/szolgaltatasok/page.tsx b/proto/src/components/views/ServicesView.tsx similarity index 83% rename from proto/src/app/(frontend)/szolgaltatasok/page.tsx rename to proto/src/components/views/ServicesView.tsx index 3bb9bfe..9fe35fa 100755 --- a/proto/src/app/(frontend)/szolgaltatasok/page.tsx +++ b/proto/src/components/views/ServicesView.tsx @@ -1,20 +1,16 @@ -import { siteConfig } from '@/config/site' -import { content } from '@/content' -import type { Metadata } from 'next' +import { localePath, type Locale } from '@/lib/i18n' +import type { getHomeContent, getServicesContent } from '@/lib/payload-content' -const { services: pageContent } = content.pages - -export const metadata: Metadata = { - title: `${pageContent.meta.title} | ${siteConfig.general.name}`, - description: pageContent.meta.description, - openGraph: { - title: `${pageContent.meta.title} | ${siteConfig.general.name}`, - description: pageContent.meta.ogDescription, - url: `${siteConfig.general.url}/szolgaltatasok`, - }, +type ServicesViewProps = { + content: Awaited<ReturnType<typeof getServicesContent>> + homeServices: Awaited<ReturnType<typeof getHomeContent>>['services']['items'] + featuresLabel: string + webmailHref: string + locale: Locale } -export default function ServicesPage() { +export default function ServicesView({ content: pageContent, homeServices, featuresLabel, webmailHref, locale }: ServicesViewProps) { + const contactHref = localePath(locale, 'contact') return ( <div className="space-y-16 py-8"> {/* Hero Section */} @@ -32,7 +28,7 @@ export default function ServicesPage() { {/* Services Grid */} <section className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-8"> - {content.pages.home.services.items.map((service) => ( + {homeServices.map((service) => ( <div key={service.id} className="bg-white rounded-xl shadow-sm border border-gray-200 p-8 hover:shadow-md transition-shadow"> <div className="w-16 h-16 bg-blue-100 rounded-lg flex items-center justify-center mb-6"> <span className="text-2xl">{service.icon}</span> @@ -42,7 +38,7 @@ export default function ServicesPage() { <p className="text-gray-600 leading-relaxed mb-6">{service.description}</p> <div className="mb-6"> - <h3 className="text-lg font-semibold text-gray-900 mb-3">{content.common.labels.features}</h3> + <h3 className="text-lg font-semibold text-gray-900 mb-3">{featuresLabel}</h3> <ul className="space-y-2"> {service.features.map((feature, index) => ( <li key={index} className="flex items-start"> @@ -53,8 +49,8 @@ export default function ServicesPage() { </ul> </div> - <a - href="/kapcsolat" + <a + href={contactHref} className="inline-flex items-center text-blue-600 hover:text-blue-700 font-medium transition-colors" > {service.ctaText} @@ -115,7 +111,7 @@ export default function ServicesPage() { {pageContent.support.subtitle} </p> <div className="grid grid-cols-1 md:grid-cols-3 gap-6 text-sm"> - {pageContent.support.channels.map((channel) => ( + {(pageContent.support.channels ?? []).map((channel) => ( <div key={channel.title}> <h3 className="font-semibold text-gray-900 mb-2">{channel.title}</h3> <p className="text-gray-600">{channel.description}</p> @@ -136,13 +132,17 @@ export default function ServicesPage() { </p> <div className="flex flex-col sm:flex-row gap-4 justify-center"> <a - href="/kapcsolat" + href={contactHref} className="inline-block bg-blue-600 hover:bg-blue-700 text-white font-medium px-8 py-3 rounded-md transition-colors" > {pageContent.cta.primaryButton} </a> <a - href={content.pages.home.hero.cta.primary.href} + // WHY webmailHref és nem "/kapcsolat" ismét: a migráció előtti + // kód itt (bugként) a primary CTA hrefjét (/kapcsolat) használta + // target="_blank"-kal a "Webmail belépés" gombhoz — javítva a + // tényleges webmail URL-re. + href={webmailHref} target="_blank" rel="noopener noreferrer" className="inline-block border-2 border-white text-white hover:bg-white hover:text-gray-900 font-medium px-8 py-3 rounded-md transition-colors" diff --git a/proto/src/config/site.ts b/proto/src/config/site.ts index c83a022..990286e 100755 --- a/proto/src/config/site.ts +++ b/proto/src/config/site.ts @@ -1,47 +1,73 @@ -import { SiteConfig } from '@/types/site' +import type { NavigationItem } from '@/types/site' +import { localePath, type Locale } from '@/lib/i18n' /** - * Site Configuration - Centralized configuration for all public content - * All content is easily modifiable without code changes - * This structure supports easy expansion for CMS integration later + * Site Configuration - statikus, nyelvfüggetlen alapadatok. + * + * MITHOME-91/114: a navigáció és a lábláb jogi linkjei nyelvfüggővé váltak + * (a szlögök nyelvenként eltérnek — lásd src/lib/i18n.ts PAGE_SLUGS), ezért + * ezek most függvények, nem statikus tömbök. A navigáció-feliratok itt + * maradnak (nem Payload-tartalom) — ez sosem volt része a JSON content + * rendszernek, csak ez a config fájl, ezért a MITHOME-91 hatóköre ("JSON + * content rendszer kivezetése") nem érinti; a hu/en feliratpárok itt kézzel + * tartott, statikus fordítások. */ -export const siteConfig: SiteConfig = { +export const siteConfig = { general: { name: 'mozdIT Bt.', - description: 'Megbízható web- és email szolgáltatás személyre szabott támogatással. Stabil tárhely, üzembiztos levelezés és DNS adminisztráció gyors reakcióval.', url: process.env.NEXT_PUBLIC_SITE_URL || 'https://localhost:3000', ogImage: '/mozdit_logo_text.png', - locale: 'hu-HU' }, - - navigation: { - main: [ - { label: 'Kezdőlap', href: '/' }, - { label: 'Rólunk', href: '/rolunk' }, - { label: 'Szolgáltatások', href: '/szolgaltatasok' }, - { label: 'Kapcsolat', href: '/kapcsolat', primary: true } - ], - footer: [ - { label: 'Kezdőlap', href: '/' }, - { label: 'Rólunk', href: '/rolunk' }, - { label: 'Szolgáltatások', href: '/szolgaltatasok' }, - { label: 'Kapcsolat', href: '/kapcsolat' }, - { label: 'Adatvédelmi tájékoztató', href: '/adatvedelem' }, - { label: 'Használati feltételek', href: '/felhasznalasi-feltetelek' } - ] - }, - - footer: { - // Copyright text lives in content/common.json (CMS-editable) — only links remain here. - links: [ - { label: 'Adatvédelmi tájékoztató', href: '/adatvedelem' }, - { label: 'Használati feltételek', href: '/felhasznalasi-feltetelek' } - ] - }, - contact: { email: process.env.NEXT_PUBLIC_CONTACT_EMAIL || 'info@mozdit.hu', - // address lives in content/common.json (footer.address) — CMS-editable. - // The form fields live in content/pages/contact.json. - } -} \ No newline at end of file + // address lives in the Payload Common global (footer.address) — CMS-editable. + }, +} + +const DESCRIPTIONS: Record<Locale, string> = { + hu: 'Megbízható web- és email szolgáltatás személyre szabott támogatással. Stabil tárhely, üzembiztos levelezés és DNS adminisztráció gyors reakcióval.', + en: 'Reliable web hosting and business email with personal support. Stable hosting, dependable mail delivery and DNS administration with a fast response.', +} + +export function getSiteDescription(locale: Locale): string { + return DESCRIPTIONS[locale] +} + +export function getOgLocale(locale: Locale): string { + return locale === 'hu' ? 'hu-HU' : 'en-US' +} + +const NAV_LABELS: Record<Locale, { home: string; about: string; services: string; contact: string; privacy: string; terms: string }> = { + hu: { home: 'Kezdőlap', about: 'Rólunk', services: 'Szolgáltatások', contact: 'Kapcsolat', privacy: 'Adatvédelmi tájékoztató', terms: 'Használati feltételek' }, + en: { home: 'Home', about: 'About', services: 'Services', contact: 'Contact', privacy: 'Privacy Policy', terms: 'Terms of Service' }, +} + +export function getMainNavigation(locale: Locale): NavigationItem[] { + const t = NAV_LABELS[locale] + return [ + { label: t.home, href: localePath(locale) }, + { label: t.about, href: localePath(locale, 'about') }, + { label: t.services, href: localePath(locale, 'services') }, + { label: t.contact, href: localePath(locale, 'contact'), primary: true }, + ] +} + +export function getFooterNavigation(locale: Locale): NavigationItem[] { + const t = NAV_LABELS[locale] + return [ + { label: t.home, href: localePath(locale) }, + { label: t.about, href: localePath(locale, 'about') }, + { label: t.services, href: localePath(locale, 'services') }, + { label: t.contact, href: localePath(locale, 'contact') }, + { label: t.privacy, href: localePath(locale, 'privacy') }, + { label: t.terms, href: localePath(locale, 'terms') }, + ] +} + +export function getFooterLegalLinks(locale: Locale): NavigationItem[] { + const t = NAV_LABELS[locale] + return [ + { label: t.privacy, href: localePath(locale, 'privacy') }, + { label: t.terms, href: localePath(locale, 'terms') }, + ] +} diff --git a/proto/src/content/pages/contact.json b/proto/src/content/pages/contact.json index 490ea5d..916b092 100644 --- a/proto/src/content/pages/contact.json +++ b/proto/src/content/pages/contact.json @@ -35,7 +35,7 @@ "errorMinLength": "Az üzenet legalább 10 karakter hosszú legyen" }, "gdpr": { - "label": "Elfogadom az <a href=\"/adatkezelesi-tajekoztato\" class=\"text-blue-600 hover:text-blue-700 underline\">adatkezelési tájékoztatót</a> és hozzájárulok személyes adataim kezeléséhez a kapcsolatfelvétel céljából.", + "label": "Elfogadom az <a href=\"{privacyHref}\" class=\"text-blue-600 hover:text-blue-700 underline\">adatkezelési tájékoztatót</a> és hozzájárulok személyes adataim kezeléséhez a kapcsolatfelvétel céljából.", "error": "Az adatkezelési tájékoztató elfogadása kötelező" } }, diff --git a/proto/src/lib/i18n.ts b/proto/src/lib/i18n.ts new file mode 100644 index 0000000..9d11cc4 --- /dev/null +++ b/proto/src/lib/i18n.ts @@ -0,0 +1,52 @@ +/** + * MITHOME-91/114: locale + útvonal segédfüggvények. + * + * URL-stratégia (2026-09-10, felülírja a korábbi "hu prefix nélkül" döntést): + * mindkét nyelv prefixet kap, szimmetrikusan (`/hu/...`, `/en/...`), a + * szlögök nyelvenként lefordítva (pl. /hu/rolunk vs /en/about). Nincs + * redirect a régi, prefix nélküli URL-ekről — a projekt még nincs + * production-ben (MITHOME-15 backlog). + */ + +export const LOCALES = ['hu', 'en'] as const +export type Locale = (typeof LOCALES)[number] +export const DEFAULT_LOCALE: Locale = 'hu' + +export function isLocale(value: string): value is Locale { + return (LOCALES as readonly string[]).includes(value) +} + +/** A catch-all [slug] route alá tartozó oldalak azonosítói. */ +export type PageKey = 'about' | 'services' | 'contact' | 'privacy' | 'terms' + +/** Nyelvenkénti szlög minden oldalhoz — a nyelvváltó (MITHOME-115) és a + * sitemap/hreflang (MITHOME-116) is ezt a táblát fogja használni. */ +export const PAGE_SLUGS: Record<PageKey, Record<Locale, string>> = { + about: { hu: 'rolunk', en: 'about' }, + services: { hu: 'szolgaltatasok', en: 'services' }, + contact: { hu: 'kapcsolat', en: 'contact' }, + privacy: { hu: 'adatvedelem', en: 'privacy-policy' }, + terms: { hu: 'felhasznalasi-feltetelek', en: 'terms-of-service' }, +} + +/** slug -> PageKey visszakeresés egy adott nyelven belül. */ +export function resolvePageKey(locale: Locale, slug: string): PageKey | undefined { + return (Object.keys(PAGE_SLUGS) as PageKey[]).find((key) => PAGE_SLUGS[key][locale] === slug) +} + +/** Útvonal a főoldalhoz vagy egy PageKey-hez, adott nyelven. */ +export function localePath(locale: Locale, key?: PageKey): string { + if (!key) return `/${locale}` + return `/${locale}/${PAGE_SLUGS[key][locale]}` +} + +/** + * Ugyanaz az oldal a másik nyelven — a nyelvváltóhoz (MITHOME-115). + * Ha a jelenlegi útvonal nem ismert PageKey (pl. 404), a másik nyelv + * főoldalára esik vissza. + */ +export function switchLocalePath(currentLocale: Locale, targetLocale: Locale, slug?: string): string { + if (!slug) return localePath(targetLocale) + const key = resolvePageKey(currentLocale, slug) + return localePath(targetLocale, key) +} diff --git a/proto/src/lib/payload-content.ts b/proto/src/lib/payload-content.ts new file mode 100644 index 0000000..cf1b26c --- /dev/null +++ b/proto/src/lib/payload-content.ts @@ -0,0 +1,103 @@ +/** + * MITHOME-91: Payload Local API adat-adapter réteg. + * + * WHY adapterek: a Payload Globals/Collections mezői (stringArrayField, + * lásd src/globals/fields/stringArray.ts) `{ value: string }[]` alakban + * tárolják azt, ami a JSON content rendszerben egyszerű `string[]` volt. + * Ezek a getterek visszaadaptálják az eredeti alakra, hogy a page + * komponensek JSX-e (ami a régi content/types.ts formát várja) NE + * változzon — csak az adatforrás. + */ +import { getPayload } from 'payload' +import config from '@payload-config' +import type { Locale } from './i18n' + +let cached: ReturnType<typeof getPayload> | undefined + +function payloadClient() { + cached ??= getPayload({ config }) + return cached +} + +function unwrap(items: readonly { value: string }[] | null | undefined): string[] { + return (items ?? []).map((item) => item.value) +} + +export async function getCommonContent(locale: Locale) { + const payload = await payloadClient() + return payload.findGlobal({ slug: 'common', locale }) +} + +export async function getHomeContent(locale: Locale) { + const payload = await payloadClient() + const home = await payload.findGlobal({ slug: 'home', locale }) + return { + ...home, + hero: { ...home.hero, trustBullets: unwrap(home.hero?.trustBullets) }, + services: { + ...home.services, + items: (home.services?.items ?? []).map((item) => ({ + ...item, + features: unwrap(item.features), + })), + }, + } +} + +export async function getAboutContent(locale: Locale) { + const payload = await payloadClient() + const about = await payload.findGlobal({ slug: 'about', locale }) + return { + ...about, + story: { ...about.story, paragraphs: unwrap(about.story?.paragraphs) }, + team: { ...about.team, paragraphs: unwrap(about.team?.paragraphs) }, + } +} + +export async function getServicesContent(locale: Locale) { + const payload = await payloadClient() + const services = await payload.findGlobal({ slug: 'services', locale }) + return { + ...services, + details: { + ...services.details, + services: (services.details?.services ?? []).map((service) => ({ + ...service, + specs: { ...service.specs, items: unwrap(service.specs?.items) }, + })), + }, + } +} + +export async function getContactContent(locale: Locale) { + const payload = await payloadClient() + return payload.findGlobal({ slug: 'contact', locale }) +} + +export async function getLegalPage(slug: 'adatvedelem' | 'hasznalati-feltetelek', locale: Locale) { + const payload = await payloadClient() + const result = await payload.find({ + collection: 'legal-pages', + where: { slug: { equals: slug } }, + locale, + limit: 1, + }) + return result.docs[0] +} + +export type PartnerView = { name: string; url: string; logo: { url: string; alt: string } } + +export async function getPartners(): Promise<PartnerView[]> { + const payload = await payloadClient() + const result = await payload.find({ collection: 'partners', limit: 100, sort: 'name' }) + return result.docs + .filter((doc) => typeof doc.logo === 'object' && doc.logo?.url) + .map((doc) => ({ + name: doc.name, + url: doc.url, + logo: { + url: (doc.logo as { url: string }).url, + alt: (doc.logo as { alt?: string }).alt ?? doc.name, + }, + })) +}